feat(ui): show chats that no person started apart from the person's chats

Chats that a trigger, a connector, a job or a page started no longer fill
the sidebar, Jump back in or the default All chats list. All chats gets a
second tab, From add-ons and automations, that groups them by origin kind
and by origin, using the name of the binding or the trigger (a kind this
release does not know falls under a generic group). The All chats entry
in the sidebar shows how many of them wait for the person. Ctrl+K still
finds every chat and marks these with a badge, and a transcript of such a
chat says what started it, with a link to its page. The browser remembers
the tab.

The lists ask the server for origin=person or origin=automated, so a
burst of bot chats cannot push a person's chats off a page.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 11:41:13 +00:00
1 parent d676d629a0
commit dae3d34a21
39 files changed
+1270 -56

No files matched your search

+54 -2
View File
@@ -102,7 +102,17 @@
"untitled": "Neuer Chat",
"origin": {
"trigger": "Von einem Auslöser gestartet",
"connector": "Über einen Connector"
"connector": "Über einen Connector",
"job": "Von einem Job gestartet",
"page": "Von einer Seite gestartet",
"other": "Von etwas anderem gestartet"
},
"originBadge": {
"trigger": "Trigger",
"connector": "Connector",
"job": "Job",
"page": "Seite",
"other": "Automatisch"
}
},
"message": {
@@ -386,7 +396,38 @@
"loadFailed": "Deine Chats konnten nicht geladen werden.",
"emptyTitle": "Noch keine Chats",
"emptyBody": "Ein Chat beginnt auf der Startseite: Nachricht eingeben und senden. Danach stehen deine Chats hier.",
"start": "Chat beginnen"
"start": "Chat beginnen",
"tabsLabel": "Chat-Listen",
"tab": {
"mine": "Meine Chats",
"automated": "Von Add-ons und Automatisierungen"
},
"automated": {
"intro": "Unterhaltungen, die nicht du gestartet hast, sondern etwa ein Connector, ein Trigger oder ein Job. Sie erscheinen nicht in deinen Chat-Listen.",
"empty": "Bisher hat nichts von selbst einen Chat gestartet.",
"kind": {
"trigger": "Trigger",
"connector": "Connectors",
"job": "Jobs",
"page": "Seiten",
"other": "Sonstige"
},
"badge": {
"trigger": "Trigger",
"connector": "Connector",
"job": "Job",
"page": "Seite",
"other": "Automatisch"
},
"chats": "Chats: {{count}}",
"lastActive": "Zuletzt aktiv {{when}}",
"running": "Läuft",
"waiting": "Wartet auf dich",
"waitingCount": "Chats, die auf dich warten: {{count}}",
"open": "{{title}} öffnen",
"showAll": "Alle {{count}} anzeigen",
"showLess": "Weniger anzeigen"
}
},
"foot": {
"state": {
@@ -600,5 +641,16 @@
"missing": "Der Ordner dieses Agenten existiert nicht mehr. Wähle im Agenten einen anderen Ordner.",
"startError": "Der Ordner dieses Agenten fehlt, daher kann der Chat nicht starten. Wähle einen anderen Ordner für den Agenten.",
"editAgent": "Agent bearbeiten"
},
"origin": {
"banner": "Diese Unterhaltung wurde von {{title}} gestartet.",
"open": "{{title}} öffnen",
"kindFallback": {
"trigger": "einem Trigger",
"connector": "einem Connector",
"job": "einem Job",
"page": "einer Seite",
"other": "etwas anderem"
}
}
}
@@ -117,5 +117,7 @@
"recentChats": "Letzte Chats",
"newChat": "Neuer Chat",
"untitledChat": "Chat ohne Titel",
"chatRunning": "Läuft"
"chatRunning": "Läuft",
"automatedWaiting": "Automatische Chats, die auf dich warten: {{count}}",
"automatedRunning": "Automatische Chats, die laufen: {{count}}"
}
+55 -3
View File
@@ -174,10 +174,20 @@
"untitled": "New chat",
"origin": {
"trigger": "Started by a trigger",
"connector": "From a connector"
"connector": "From a connector",
"job": "Started by a job",
"page": "Started by a page",
"other": "Started by something else"
},
"exportJson": "Export as JSON",
"importChat": "Import a chat"
"importChat": "Import a chat",
"originBadge": {
"trigger": "Trigger",
"connector": "Connector",
"job": "Job",
"page": "Page",
"other": "Automated"
}
},
"message": {
"you": "You",
@@ -541,7 +551,38 @@
"loadFailed": "Could not load your chats.",
"emptyTitle": "No chats yet",
"emptyBody": "A chat starts on Home: type a message and send it. Your chats are listed here after that.",
"start": "Start a chat"
"start": "Start a chat",
"tabsLabel": "Chat lists",
"tab": {
"mine": "My chats",
"automated": "From add-ons and automations"
},
"automated": {
"intro": "Conversations that something other than you started, such as a connector, a trigger or a job. They stay out of your chat lists.",
"empty": "Nothing has started a chat by itself yet.",
"kind": {
"trigger": "Triggers",
"connector": "Connectors",
"job": "Jobs",
"page": "Pages",
"other": "Other"
},
"badge": {
"trigger": "Trigger",
"connector": "Connector",
"job": "Job",
"page": "Page",
"other": "Automated"
},
"chats": "Chats: {{count}}",
"lastActive": "Last active {{when}}",
"running": "Running",
"waiting": "Waiting for you",
"waitingCount": "Chats waiting for you: {{count}}",
"open": "Open {{title}}",
"showAll": "Show all {{count}}",
"showLess": "Show fewer"
}
},
"foot": {
"state": {
@@ -747,5 +788,16 @@
"missing": "The folder of this agent is gone. Choose another folder in the agent.",
"startError": "This agent's folder is missing, so the chat cannot start. Choose another folder for the agent.",
"editAgent": "Edit agent"
},
"origin": {
"banner": "This conversation was started by {{title}}.",
"open": "Open {{title}}",
"kindFallback": {
"trigger": "a trigger",
"connector": "a connector",
"job": "a job",
"page": "a page",
"other": "something else"
}
}
}
@@ -116,5 +116,7 @@
"recentChats": "Recent chats",
"newChat": "New chat",
"untitledChat": "Untitled chat",
"chatRunning": "Working"
"chatRunning": "Working",
"automatedWaiting": "Automated chats waiting for you: {{count}}",
"automatedRunning": "Automated chats running: {{count}}"
}
+54 -2
View File
@@ -102,7 +102,17 @@
"untitled": "Nuevo chat",
"origin": {
"trigger": "Iniciado por un disparador",
"connector": "Desde un conector"
"connector": "Desde un conector",
"job": "Iniciado por un trabajo",
"page": "Iniciado por una página",
"other": "Iniciado por otra cosa"
},
"originBadge": {
"trigger": "Disparador",
"connector": "Conector",
"job": "Trabajo",
"page": "Página",
"other": "Automático"
}
},
"message": {
@@ -386,7 +396,38 @@
"loadFailed": "No se pudieron cargar tus chats.",
"emptyTitle": "Aún no hay chats",
"emptyBody": "Un chat empieza en Inicio: escribe un mensaje y envíalo. Después, tus chats aparecen aquí.",
"start": "Empezar un chat"
"start": "Empezar un chat",
"tabsLabel": "Listas de chats",
"tab": {
"mine": "Mis chats",
"automated": "De complementos y automatizaciones"
},
"automated": {
"intro": "Conversaciones que inició algo distinto de ti, como un conector, un disparador o un trabajo. No aparecen en tus listas de chats.",
"empty": "Nada ha iniciado un chat por sí solo todavía.",
"kind": {
"trigger": "Disparadores",
"connector": "Conectores",
"job": "Trabajos",
"page": "Páginas",
"other": "Otros"
},
"badge": {
"trigger": "Disparador",
"connector": "Conector",
"job": "Trabajo",
"page": "Página",
"other": "Automático"
},
"chats": "Chats: {{count}}",
"lastActive": "Última actividad {{when}}",
"running": "En curso",
"waiting": "Esperando por ti",
"waitingCount": "Chats que te esperan: {{count}}",
"open": "Abrir {{title}}",
"showAll": "Mostrar los {{count}}",
"showLess": "Mostrar menos"
}
},
"foot": {
"state": {
@@ -600,5 +641,16 @@
"missing": "La carpeta de este agente ya no existe. Elige otra carpeta en el agente.",
"startError": "Falta la carpeta de este agente, así que el chat no puede empezar. Elige otra carpeta para el agente.",
"editAgent": "Editar agente"
},
"origin": {
"banner": "Esta conversación la inició {{title}}.",
"open": "Abrir {{title}}",
"kindFallback": {
"trigger": "un disparador",
"connector": "un conector",
"job": "un trabajo",
"page": "una página",
"other": "otra cosa"
}
}
}
@@ -117,5 +117,7 @@
"recentChats": "Chats recientes",
"newChat": "Nuevo chat",
"untitledChat": "Chat sin título",
"chatRunning": "En curso"
"chatRunning": "En curso",
"automatedWaiting": "Chats automáticos que te esperan: {{count}}",
"automatedRunning": "Chats automáticos en curso: {{count}}"
}
+54 -2
View File
@@ -100,7 +100,17 @@
"untitled": "Nouveau chat",
"origin": {
"trigger": "Lancé par un déclencheur",
"connector": "Depuis un connecteur"
"connector": "Depuis un connecteur",
"job": "Démarré par une tâche",
"page": "Démarré par une page",
"other": "Démarré par autre chose"
},
"originBadge": {
"trigger": "Déclencheur",
"connector": "Connecteur",
"job": "Tâche",
"page": "Page",
"other": "Automatique"
}
},
"message": {
@@ -384,7 +394,38 @@
"loadFailed": "Impossible de charger vos conversations.",
"emptyTitle": "Aucune conversation",
"emptyBody": "Une conversation commence sur l'accueil : écrivez un message et envoyez-le. Vos conversations s'affichent ensuite ici.",
"start": "Commencer une conversation"
"start": "Commencer une conversation",
"tabsLabel": "Listes de chats",
"tab": {
"mine": "Mes chats",
"automated": "Des modules et automatisations"
},
"automated": {
"intro": "Des conversations démarrées par autre chose que vous, comme un connecteur, un déclencheur ou une tâche. Elles restent hors de vos listes de chats.",
"empty": "Rien n’a encore démarré de chat tout seul.",
"kind": {
"trigger": "Déclencheurs",
"connector": "Connecteurs",
"job": "Tâches",
"page": "Pages",
"other": "Autres"
},
"badge": {
"trigger": "Déclencheur",
"connector": "Connecteur",
"job": "Tâche",
"page": "Page",
"other": "Automatique"
},
"chats": "Chats : {{count}}",
"lastActive": "Dernière activité {{when}}",
"running": "En cours",
"waiting": "Vous attend",
"waitingCount": "Chats qui vous attendent : {{count}}",
"open": "Ouvrir {{title}}",
"showAll": "Tout afficher ({{count}})",
"showLess": "Afficher moins"
}
},
"foot": {
"state": {
@@ -598,5 +639,16 @@
"missing": "Le dossier de cet agent n'existe plus. Choisissez un autre dossier dans l'agent.",
"startError": "Le dossier de cet agent est manquant, le chat ne peut donc pas démarrer. Choisissez un autre dossier pour l'agent.",
"editAgent": "Modifier l'agent"
},
"origin": {
"banner": "Cette conversation a été démarrée par {{title}}.",
"open": "Ouvrir {{title}}",
"kindFallback": {
"trigger": "un déclencheur",
"connector": "un connecteur",
"job": "une tâche",
"page": "une page",
"other": "autre chose"
}
}
}
@@ -120,5 +120,7 @@
"recentChats": "Conversations récentes",
"newChat": "Nouvelle conversation",
"untitledChat": "Conversation sans titre",
"chatRunning": "En cours"
"chatRunning": "En cours",
"automatedWaiting": "Chats automatiques qui vous attendent : {{count}}",
"automatedRunning": "Chats automatiques en cours : {{count}}"
}
+54 -2
View File
@@ -102,7 +102,17 @@
"untitled": "Obrolan baru",
"origin": {
"trigger": "Dimulai oleh pemicu",
"connector": "Dari konektor"
"connector": "Dari konektor",
"job": "Dimulai oleh job",
"page": "Dimulai oleh halaman",
"other": "Dimulai oleh hal lain"
},
"originBadge": {
"trigger": "Pemicu",
"connector": "Konektor",
"job": "Job",
"page": "Halaman",
"other": "Otomatis"
}
},
"message": {
@@ -386,7 +396,38 @@
"loadFailed": "Tidak dapat memuat obrolan Anda.",
"emptyTitle": "Belum ada obrolan",
"emptyBody": "Obrolan dimulai di Beranda: ketik pesan lalu kirim. Setelah itu obrolan Anda tercantum di sini.",
"start": "Mulai obrolan"
"start": "Mulai obrolan",
"tabsLabel": "Daftar obrolan",
"tab": {
"mine": "Obrolan saya",
"automated": "Dari add-on dan otomatisasi"
},
"automated": {
"intro": "Percakapan yang dimulai oleh sesuatu selain Anda, seperti konektor, pemicu, atau job. Percakapan ini tidak muncul di daftar obrolan Anda.",
"empty": "Belum ada yang memulai obrolan sendiri.",
"kind": {
"trigger": "Pemicu",
"connector": "Konektor",
"job": "Job",
"page": "Halaman",
"other": "Lainnya"
},
"badge": {
"trigger": "Pemicu",
"connector": "Konektor",
"job": "Job",
"page": "Halaman",
"other": "Otomatis"
},
"chats": "Obrolan: {{count}}",
"lastActive": "Terakhir aktif {{when}}",
"running": "Berjalan",
"waiting": "Menunggu Anda",
"waitingCount": "Obrolan yang menunggu Anda: {{count}}",
"open": "Buka {{title}}",
"showAll": "Tampilkan semua ({{count}})",
"showLess": "Tampilkan lebih sedikit"
}
},
"foot": {
"state": {
@@ -600,5 +641,16 @@
"missing": "Folder agen ini sudah tidak ada. Pilih folder lain di agen.",
"startError": "Folder agen ini hilang, jadi chat tidak bisa dimulai. Pilih folder lain untuk agen.",
"editAgent": "Edit agen"
},
"origin": {
"banner": "Percakapan ini dimulai oleh {{title}}.",
"open": "Buka {{title}}",
"kindFallback": {
"trigger": "sebuah pemicu",
"connector": "sebuah konektor",
"job": "sebuah job",
"page": "sebuah halaman",
"other": "hal lain"
}
}
}
@@ -117,5 +117,7 @@
"recentChats": "Obrolan terbaru",
"newChat": "Obrolan baru",
"untitledChat": "Obrolan tanpa judul",
"chatRunning": "Berjalan"
"chatRunning": "Berjalan",
"automatedWaiting": "Obrolan otomatis yang menunggu Anda: {{count}}",
"automatedRunning": "Obrolan otomatis yang berjalan: {{count}}"
}
+54 -2
View File
@@ -102,7 +102,17 @@
"untitled": "Nuova chat",
"origin": {
"trigger": "Avviata da un trigger",
"connector": "Da un connettore"
"connector": "Da un connettore",
"job": "Avviato da un job",
"page": "Avviato da una pagina",
"other": "Avviato da qualcos’altro"
},
"originBadge": {
"trigger": "Trigger",
"connector": "Connettore",
"job": "Job",
"page": "Pagina",
"other": "Automatico"
}
},
"message": {
@@ -386,7 +396,38 @@
"loadFailed": "Impossibile caricare le tue chat.",
"emptyTitle": "Ancora nessuna chat",
"emptyBody": "Una chat inizia dalla Home: scrivi un messaggio e invialo. Poi le tue chat sono elencate qui.",
"start": "Inizia una chat"
"start": "Inizia una chat",
"tabsLabel": "Elenchi delle chat",
"tab": {
"mine": "Le mie chat",
"automated": "Da componenti aggiuntivi e automazioni"
},
"automated": {
"intro": "Conversazioni avviate da qualcosa che non sei tu, come un connettore, un trigger o un job. Restano fuori dai tuoi elenchi di chat.",
"empty": "Nulla ha ancora avviato una chat da solo.",
"kind": {
"trigger": "Trigger",
"connector": "Connettori",
"job": "Job",
"page": "Pagine",
"other": "Altro"
},
"badge": {
"trigger": "Trigger",
"connector": "Connettore",
"job": "Job",
"page": "Pagina",
"other": "Automatico"
},
"chats": "Chat: {{count}}",
"lastActive": "Ultima attività {{when}}",
"running": "In corso",
"waiting": "Ti aspetta",
"waitingCount": "Chat che ti aspettano: {{count}}",
"open": "Apri {{title}}",
"showAll": "Mostra tutte ({{count}})",
"showLess": "Mostra meno"
}
},
"foot": {
"state": {
@@ -600,5 +641,16 @@
"missing": "La cartella di questo agente non esiste più. Scegli un'altra cartella nell'agente.",
"startError": "La cartella di questo agente manca, quindi la chat non può partire. Scegli un'altra cartella per l'agente.",
"editAgent": "Modifica agente"
},
"origin": {
"banner": "Questa conversazione è stata avviata da {{title}}.",
"open": "Apri {{title}}",
"kindFallback": {
"trigger": "un trigger",
"connector": "un connettore",
"job": "un job",
"page": "una pagina",
"other": "qualcos’altro"
}
}
}
@@ -117,5 +117,7 @@
"recentChats": "Chat recenti",
"newChat": "Nuova chat",
"untitledChat": "Chat senza titolo",
"chatRunning": "In corso"
"chatRunning": "In corso",
"automatedWaiting": "Chat automatiche che ti aspettano: {{count}}",
"automatedRunning": "Chat automatiche in corso: {{count}}"
}
+54 -2
View File
@@ -102,7 +102,17 @@
"untitled": "새 채팅",
"origin": {
"trigger": "트리거로 시작됨",
"connector": "커넥터에서"
"connector": "커넥터에서",
"job": "작업이 시작함",
"page": "페이지가 시작함",
"other": "다른 것이 시작함"
},
"originBadge": {
"trigger": "트리거",
"connector": "커넥터",
"job": "작업",
"page": "페이지",
"other": "자동"
}
},
"message": {
@@ -386,7 +396,38 @@
"loadFailed": "채팅을 불러오지 못했습니다.",
"emptyTitle": "아직 채팅이 없습니다",
"emptyBody": "채팅은 홈에서 시작합니다. 메시지를 입력하고 보내세요. 그 후 채팅이 여기에 표시됩니다.",
"start": "채팅 시작"
"start": "채팅 시작",
"tabsLabel": "채팅 목록",
"tab": {
"mine": "내 채팅",
"automated": "애드온 및 자동화"
},
"automated": {
"intro": "커넥터, 트리거, 작업처럼 사용자가 아닌 것이 시작한 대화입니다. 내 채팅 목록에는 표시되지 않습니다.",
"empty": "아직 스스로 채팅을 시작한 것이 없습니다.",
"kind": {
"trigger": "트리거",
"connector": "커넥터",
"job": "작업",
"page": "페이지",
"other": "기타"
},
"badge": {
"trigger": "트리거",
"connector": "커넥터",
"job": "작업",
"page": "페이지",
"other": "자동"
},
"chats": "채팅: {{count}}",
"lastActive": "마지막 활동 {{when}}",
"running": "실행 중",
"waiting": "확인 대기 중",
"waitingCount": "확인을 기다리는 채팅: {{count}}",
"open": "{{title}} 열기",
"showAll": "{{count}}개 모두 보기",
"showLess": "간단히 보기"
}
},
"foot": {
"state": {
@@ -600,5 +641,16 @@
"missing": "이 에이전트의 폴더가 더 이상 없습니다. 에이전트에서 다른 폴더를 선택하세요.",
"startError": "이 에이전트의 폴더가 없어 채팅을 시작할 수 없습니다. 에이전트에 다른 폴더를 선택하세요.",
"editAgent": "에이전트 편집"
},
"origin": {
"banner": "이 대화는 {{title}}에서 시작되었습니다.",
"open": "{{title}} 열기",
"kindFallback": {
"trigger": "트리거",
"connector": "커넥터",
"job": "작업",
"page": "페이지",
"other": "다른 요소"
}
}
}
@@ -117,5 +117,7 @@
"recentChats": "최근 채팅",
"newChat": "새 채팅",
"untitledChat": "제목 없는 채팅",
"chatRunning": "진행 중"
"chatRunning": "진행 중",
"automatedWaiting": "확인을 기다리는 자동 채팅: {{count}}",
"automatedRunning": "실행 중인 자동 채팅: {{count}}"
}
@@ -105,7 +105,17 @@
"untitled": "Novo chat",
"origin": {
"trigger": "Iniciado por um gatilho",
"connector": "De um conector"
"connector": "De um conector",
"job": "Iniciado por um job",
"page": "Iniciado por uma página",
"other": "Iniciado por outra coisa"
},
"originBadge": {
"trigger": "Gatilho",
"connector": "Conector",
"job": "Job",
"page": "Página",
"other": "Automático"
}
},
"message": {
@@ -389,7 +399,38 @@
"loadFailed": "Não foi possível carregar seus chats.",
"emptyTitle": "Nenhum chat ainda",
"emptyBody": "Um chat começa no Início: digite uma mensagem e envie. Depois disso, seus chats aparecem aqui.",
"start": "Começar um chat"
"start": "Começar um chat",
"tabsLabel": "Listas de chats",
"tab": {
"mine": "Meus chats",
"automated": "De complementos e automações"
},
"automated": {
"intro": "Conversas iniciadas por algo que não é você, como um conector, um gatilho ou um job. Elas ficam fora das suas listas de chats.",
"empty": "Nada iniciou um chat sozinho até agora.",
"kind": {
"trigger": "Gatilhos",
"connector": "Conectores",
"job": "Jobs",
"page": "Páginas",
"other": "Outros"
},
"badge": {
"trigger": "Gatilho",
"connector": "Conector",
"job": "Job",
"page": "Página",
"other": "Automático"
},
"chats": "Chats: {{count}}",
"lastActive": "Última atividade {{when}}",
"running": "Em execução",
"waiting": "Aguardando você",
"waitingCount": "Chats aguardando você: {{count}}",
"open": "Abrir {{title}}",
"showAll": "Mostrar todos ({{count}})",
"showLess": "Mostrar menos"
}
},
"foot": {
"state": {
@@ -603,5 +644,16 @@
"missing": "A pasta deste agente não existe mais. Escolha outra pasta no agente.",
"startError": "A pasta deste agente está ausente, então o chat não pode começar. Escolha outra pasta para o agente.",
"editAgent": "Editar agente"
},
"origin": {
"banner": "Esta conversa foi iniciada por {{title}}.",
"open": "Abrir {{title}}",
"kindFallback": {
"trigger": "um gatilho",
"connector": "um conector",
"job": "um job",
"page": "uma página",
"other": "outra coisa"
}
}
}
@@ -117,5 +117,7 @@
"recentChats": "Chats recentes",
"newChat": "Novo chat",
"untitledChat": "Chat sem título",
"chatRunning": "Em andamento"
"chatRunning": "Em andamento",
"automatedWaiting": "Chats automáticos aguardando você: {{count}}",
"automatedRunning": "Chats automáticos em execução: {{count}}"
}
@@ -102,7 +102,17 @@
"untitled": "新对话",
"origin": {
"trigger": "由触发器启动",
"connector": "来自连接器"
"connector": "来自连接器",
"job": "由作业发起",
"page": "由页面发起",
"other": "由其他来源发起"
},
"originBadge": {
"trigger": "触发器",
"connector": "连接器",
"job": "作业",
"page": "页面",
"other": "自动"
}
},
"message": {
@@ -386,7 +396,38 @@
"loadFailed": "无法加载你的对话。",
"emptyTitle": "还没有对话",
"emptyBody": "对话从首页开始:输入消息并发送。之后你的对话会列在这里。",
"start": "开始对话"
"start": "开始对话",
"tabsLabel": "聊天列表",
"tab": {
"mine": "我的聊天",
"automated": "来自附加组件和自动化"
},
"automated": {
"intro": "不是你发起的对话,例如由连接器、触发器或作业发起。它们不会出现在你的聊天列表中。",
"empty": "还没有任何内容自行发起聊天。",
"kind": {
"trigger": "触发器",
"connector": "连接器",
"job": "作业",
"page": "页面",
"other": "其他"
},
"badge": {
"trigger": "触发器",
"connector": "连接器",
"job": "作业",
"page": "页面",
"other": "自动"
},
"chats": "聊天:{{count}}",
"lastActive": "最近活动 {{when}}",
"running": "运行中",
"waiting": "等待你处理",
"waitingCount": "等待你处理的聊天:{{count}}",
"open": "打开{{title}}",
"showAll": "显示全部 {{count}} 个",
"showLess": "收起"
}
},
"foot": {
"state": {
@@ -600,5 +641,16 @@
"missing": "此智能体的文件夹已不存在。请在智能体中选择其他文件夹。",
"startError": "此智能体的文件夹缺失,因此无法开始对话。请为智能体选择其他文件夹。",
"editAgent": "编辑智能体"
},
"origin": {
"banner": "此对话由{{title}}发起。",
"open": "打开{{title}}",
"kindFallback": {
"trigger": "触发器",
"connector": "连接器",
"job": "作业",
"page": "页面",
"other": "其他来源"
}
}
}
@@ -117,5 +117,7 @@
"recentChats": "最近对话",
"newChat": "新对话",
"untitledChat": "未命名对话",
"chatRunning": "进行中"
"chatRunning": "进行中",
"automatedWaiting": "等待你处理的自动聊天:{{count}}",
"automatedRunning": "运行中的自动聊天:{{count}}"
}
@@ -0,0 +1,95 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Icon from './Icon'
import { StatusChip } from './ui'
import { chatStatus, groupByOrigin, knownKind } from '../utils/originChats'
// How many chats a group lists before "Show all".
const SHOWN = 5
// The chats that something other than a person started, for the second tab of
// All chats. One block per origin kind (triggers, connectors, jobs, pages and
// whatever a later release adds), and inside it one group per origin: the
// binding of a connector, a trigger, a job, a page. A group says how many
// chats it has, when it was last active, whether a turn runs and whether a
// chat waits for the person, and links to the page of its origin.
export default function AutomatedChats({ chats, ctx, needsYou, onOpen, onDelete }) {
const { t, i18n } = useTranslation('chat')
const [open, setOpen] = useState({})
const kinds = useMemo(() => groupByOrigin(chats, ctx, needsYou), [chats, ctx, needsYou])
const fmt = useMemo(() => new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }), [i18n.language])
if (kinds.length === 0) {
return <div className="ac-empty" data-testid="automated-empty">{t('allChats.automated.empty')}</div>
}
return (
<div className="ac" data-testid="automated-chats">
<p className="ac-intro">{t('allChats.automated.intro')}</p>
{kinds.map((k) => {
const kind = knownKind(k.kind)
return (
<section key={k.kind} className="ac-kind" data-testid={`automated-kind-${k.kind}`} aria-labelledby={`ac-kind-${k.kind}`}>
<h2 id={`ac-kind-${k.kind}`} className="ac-kind__title">
{t(`allChats.automated.kind.${kind}`)}
<span className="ac-kind__count">{k.count}</span>
</h2>
{k.groups.map((g) => {
const expanded = Boolean(open[g.key])
const shown = expanded ? g.chats : g.chats.slice(0, SHOWN)
const title = g.title || t(`allChats.automated.badge.${kind}`)
return (
<div key={g.key} className="ac-group" data-testid="automated-group">
<div className="ac-group__head">
<div className="ac-group__main">
<span className="ac-badge" data-testid="automated-origin-badge" title={t(`menu.origin.${kind}`)}>{t(`allChats.automated.badge.${kind}`)}</span>
<h3 className="ac-group__title" data-testid="automated-group-title">{title}</h3>
</div>
<div className="ac-group__meta">
<span data-testid="automated-count">{t('allChats.automated.chats', { count: g.count })}</span>
<span>{t('allChats.automated.lastActive', { when: fmt.format(g.lastActive) })}</span>
{g.running > 0 && <StatusChip status="starting" label={t('allChats.automated.running')} testId="automated-running" />}
{g.waiting > 0 && <StatusChip tone="warn" icon="warning" label={t('allChats.automated.waiting')} testId="automated-waiting" />}
{g.href && <Link className="ac-link" to={g.href} data-testid="automated-origin-link">{t('allChats.automated.open', { title })}</Link>}
</div>
</div>
<ul className="ac-list">
{shown.map((c) => {
const status = chatStatus(c, needsYou)
return (
<li key={c.id} className="ac-row" data-testid="automated-chat" data-status={status}>
<button type="button" className="ac-row__open" onClick={() => onOpen(c.id)}>
<span className="ac-row__name">{c.name || t('menu.untitled')}</span>
<span className="ac-row__time">{fmt.format(c.updatedAt || c.createdAt || 0)}</span>
</button>
{status === 'running' && <Icon name="spinner" spin className="ac-row__busy" title={t('allChats.automated.running')} />}
{status === 'waiting' && <Icon name="warning" className="ac-row__wait" title={t('allChats.automated.waiting')} />}
<button
type="button"
className="cx-icobtn cx-icobtn--danger"
onClick={() => onDelete(c)}
title={t('menu.deleteChat')}
aria-label={t('menu.deleteChat')}
>
<Icon name="trash" />
</button>
</li>
)
})}
</ul>
{g.chats.length > SHOWN && (
<button type="button" className="cx-link ac-more" onClick={() => setOpen((o) => ({ ...o, [g.key]: !expanded }))}>
{expanded ? t('allChats.automated.showLess') : t('allChats.automated.showAll', { count: g.chats.length })}
</button>
)}
</div>
)
})}
</section>
)
})}
</div>
)
}
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { groupConversations } from '../utils/homeConversations'
import Icon from './Icon'
import { knownKind } from '../utils/originChats'
// The chats come from the server list (see menuChats in utils/chatThread.js):
// a name, the model and the times. The names are matched here at once; with
@@ -14,7 +15,9 @@ function matches(chat, query) {
const SEARCH_DELAY_MS = 250
// The chat history: a search box, one block per day, the model that answered
// and the time, with rename, copy, export and delete on each row. It reads
// and the time, with rename, copy, export and delete on each row. A chat that
// no person started (a trigger, a connector, a job or a page) carries a badge
// with the name of what started it (originTitle) or the name of its kind. It reads
// like the Home "Jump back in" list. Two frames show it: the Ctrl or Cmd K
// dialog of a transcript (ChatsMenu) and the All chats page.
//
@@ -32,6 +35,7 @@ export default function ChatsList({
onExport,
onCopyChat,
onSearch,
originTitle,
onEscape,
autoFocus = false,
idPrefix = 'chats',
@@ -188,7 +192,8 @@ export default function ChatsList({
const idx = n++
const selected = idx === activeIdx
const editing = editingId === chat.id
const preview = snippets[chat.id] || (chat.origin ? t(`menu.origin.${chat.origin}`, chat.origin) : '')
const preview = snippets[chat.id] || ''
const originKind = chat.origin ? (knownKind(chat.origin)) : ''
return (
<div
key={chat.id}
@@ -227,6 +232,11 @@ export default function ChatsList({
{chat.name || t('menu.untitled')}
</span>
)}
{originKind && (
<span className="cx-row-item__origin" data-testid="chats-origin-badge" title={t(`menu.origin.${originKind}`)}>
{originTitle?.(chat) || t(`menu.originBadge.${originKind}`)}
</span>
)}
</div>
{preview && <div className="cx-row-item__preview">{preview}</div>}
<span className="cx-row-item__model">{chat.model || t('menu.noModel')}</span>
@@ -22,6 +22,7 @@ const ChatsMenu = forwardRef(function ChatsMenu({
onExport,
onCopyChat,
onSearch,
originTitle,
}, ref) {
const { t } = useTranslation('chat')
const [open, setOpen] = useState(false)
@@ -109,6 +110,7 @@ const ChatsMenu = forwardRef(function ChatsMenu({
onExport={onExport}
onCopyChat={onCopyChat}
onSearch={onSearch}
originTitle={originTitle}
onEscape={close}
autoFocus
/>
@@ -2,7 +2,8 @@
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Icon from './Icon'
import { useRecentChats } from '../hooks/useRecentChats'
import { useAutomatedChats, useRecentChats } from '../hooks/useRecentChats'
import { attention } from '../utils/originChats'
import { allChatsDestination } from '../utils/navigationModel'
import { preloadRoute } from '../router'
@@ -21,7 +22,14 @@ export default function SidebarChats({ onClose, collapsed }) {
const navigate = useNavigate()
const { pathname } = useLocation()
const chats = useRecentChats(true, RECENT_CHATS, pathname)
// The chats that no person started are not listed here (All chats has them
// on a tab of their own); the entry only says when one needs the person.
const automated = useAutomatedChats(true, pathname)
const { waiting, running } = attention(automated.chats, automated.needsYou)
const allLabel = t(allChatsDestination.labelKey)
const badge = waiting > 0
? t('automatedWaiting', { count: waiting })
: running > 0 ? t('automatedRunning', { count: running }) : ''
const preload = () => preloadRoute('/app/chats')
return (
@@ -64,6 +72,8 @@ export default function SidebarChats({ onClose, collapsed }) {
>
<Icon name={allChatsDestination.icon} className="nav-icon" aria-hidden="true" />
<span className="nav-label">{allLabel}</span>
{waiting > 0 && <span className="nav-chat-all__badge" data-testid="sidebar-automated-badge" title={badge} aria-label={badge}>{waiting > 9 ? '9+' : waiting}</span>}
{waiting === 0 && running > 0 && <span className="nav-chat-all__dot" data-testid="sidebar-automated-running" title={badge} role="img" aria-label={badge} />}
</NavLink>
</div>
)
@@ -99,6 +99,36 @@
.sidebar-chats__new:hover { background: var(--dk-hover); color: var(--dk-text); }
.sidebar-chats__new:focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 1px; }
.nav-chat .nav-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-chat-all__badge {
flex: none;
min-width: 18px;
height: 18px;
margin-left: auto;
padding: 0 5px;
border-radius: var(--radius-full);
background: var(--dk-accent);
color: var(--dk-on-accent);
font-size: var(--fs-meta);
font-weight: 600;
line-height: 18px;
text-align: center;
}
.nav-chat-all__dot {
flex: none;
width: 8px;
height: 8px;
margin-left: auto;
border-radius: var(--radius-full);
background: var(--dk-accent);
opacity: 0.7;
}
.sidebar.collapsed .nav-chat-all { position: relative; }
.sidebar.collapsed .nav-chat-all__badge,
.sidebar.collapsed .nav-chat-all__dot { position: absolute; top: 2px; right: 2px; margin: 0; }
@media (max-width: 639px) {
.sidebar.collapsed .nav-chat-all__badge,
.sidebar.collapsed .nav-chat-all__dot { position: static; margin-left: auto; }
}
.nav-chat__busy { flex: none; margin-left: auto; color: var(--color-text-muted); }
.sidebar.collapsed .sidebar-chats__head { display: none; }
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react'
import { chatConnectorsApi, chatTriggersApi } from '../utils/api'
const EMPTY = { connectors: [], connectorKinds: [], triggers: [] }
// The lists that name what started a chat: the connector bindings (and the
// kinds the add-ons serve) and the triggers. Each read is best effort: a
// server with connectors or triggers off answers 409, and the chats of that
// origin are then named by their kind. Nothing is read until `enabled`.
export function useOriginContext(enabled) {
const [ctx, setCtx] = useState(EMPTY)
useEffect(() => {
if (!enabled) return undefined
let live = true
Promise.all([
chatConnectorsApi.list().catch(() => null),
chatConnectorsApi.kinds().catch(() => null),
chatTriggersApi.list().catch(() => null),
]).then(([connectors, kinds, triggers]) => {
if (!live) return
setCtx({
connectors: connectors?.connectors || [],
connectorKinds: kinds?.kinds || [],
triggers: triggers?.triggers || [],
})
})
return () => { live = false }
}, [enabled])
return ctx
}
+42 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { chatsApi } from '../utils/api'
import { chatNotificationsApi, chatsApi } from '../utils/api'
import { menuChats } from '../utils/chatThread'
import { needsYouIds } from '../utils/originChats'
import { pendingChatDeletes } from './useChatDelete'
const EVENT = 'localai:chats-list'
@@ -17,8 +18,10 @@ export function newestChats(chats, limit) {
return [...(chats || [])].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)).slice(0, limit)
}
// The newest chats of the signed-in user, for the sidebar. It reads
// GET /api/chats?limit=N&kind=chat when it starts and whenever `key` changes
// The newest chats of the signed-in user, for the sidebar. Only the chats a
// person started: the ones a trigger, a connector or a page started have their
// own area (All chats, second tab). It reads
// GET /api/chats?limit=N&kind=chat&origin=person when it starts and whenever `key` changes
// (the sidebar passes the route), and takes the announcements in between.
// A failed read keeps what is shown: the list is a shortcut, not a record.
export function useRecentChats(enabled, limit, key) {
@@ -27,18 +30,51 @@ export function useRecentChats(enabled, limit, key) {
useEffect(() => {
if (!enabled) { setChats([]); return undefined }
let live = true
chatsApi.list(limit, 'chat')
.then((r) => { if (live) setChats(newestChats(menuChats((r?.conversations || []).filter(c => c.kind !== 'subagent' && !pendingChatDeletes.has(c.id))), limit)) })
chatsApi.list(limit, 'chat', 'person')
.then((r) => { if (live) setChats(newestChats(menuChats((r?.conversations || []).filter(c => c.kind !== 'subagent' && !pendingChatDeletes.has(c.id))).filter(c => !c.origin), limit)) })
.catch(() => {})
return () => { live = false }
}, [enabled, limit, key])
useEffect(() => {
if (!enabled) return undefined
const onList = (e) => setChats(newestChats(e.detail, limit))
const onList = (e) => setChats(newestChats((e.detail || []).filter(c => !c.origin), limit))
window.addEventListener(EVENT, onList)
return () => window.removeEventListener(EVENT, onList)
}, [enabled, limit])
return chats
}
// The chats that a trigger, a connector, a job or a page started, and what
// needs the person among them, for the badge on the sidebar's All chats entry.
// It reads GET /api/chats?kind=chat&origin=automated (and the unread inbox
// items, which say which chats ask for the person) when it starts and whenever
// `key` changes (the sidebar passes the route) and when the tab gets focus
// back. A failed read keeps what is shown.
export function useAutomatedChats(enabled, key) {
const [state, setState] = useState({ chats: [], needsYou: new Set() })
useEffect(() => {
if (!enabled) return undefined
let live = true
const load = () => {
Promise.all([
chatsApi.list(200, 'chat', 'automated'),
chatNotificationsApi.list(true, 200).catch(() => null),
]).then(([r, inbox]) => {
if (!live) return
setState({
chats: menuChats((r?.conversations || []).filter(c => c.kind !== 'subagent' && c.origin)),
needsYou: needsYouIds(inbox?.notifications),
})
}).catch(() => {})
}
load()
const onVisible = () => { if (document.visibilityState === 'visible') load() }
document.addEventListener('visibilitychange', onVisible)
return () => { live = false; document.removeEventListener('visibilitychange', onVisible) }
}, [enabled, key])
return state
}
+13 -2
View File
@@ -111,8 +111,19 @@ export function useServerChat({ seed = null } = {}) {
// Sub-agent runs belong to their parent; the list shows chats only. The
// server leaves the runs out before it applies the limit (200 is its
// maximum); the filter stays for a server that does not know kind.
const data = await chatsApi.list(200, 'chat')
setChats((data.conversations || []).filter(c => c.kind !== 'subagent'))
//
// The chats a person started and the others are read apart, each with its
// own limit, so a burst of chats from a connector or a trigger cannot push
// the person's chats out of the page. A failed read of the others leaves
// them out: the list is a shortcut, the person's chats are the record.
const [data, others] = await Promise.all([
chatsApi.list(200, 'chat', 'person'),
chatsApi.list(200, 'chat', 'automated').catch(() => null),
])
const seen = new Set()
const all = [...(data.conversations || []), ...(others?.conversations || [])]
.filter(c => c.kind !== 'subagent' && !seen.has(c.id) && seen.add(c.id))
setChats(all)
setPersistent(data.persistent !== false)
setFiles({ on: data.files !== false, reason: data.files_reason || '' })
setImageModel(data.image_model || '')
+77 -9
View File
@@ -2,13 +2,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { chatsApi } from '../utils/api'
import { chatNotificationsApi, chatsApi } from '../utils/api'
import { chatFromExport, chatMarkdown, downloadFrom, menuChats } from '../utils/chatThread'
import { copyToClipboard } from '../utils/clipboard'
import { CAP_CHAT } from '../utils/capabilities'
import { useModels } from '../hooks/useModels'
import { CHAT_UNDO_MS, useChatDelete } from '../hooks/useChatDelete'
import { announceChats } from '../hooks/useRecentChats'
import { useOriginContext } from '../hooks/useOriginContext'
import { attention, needsYouIds, readTab, writeTab } from '../utils/originChats'
import AutomatedChats from '../components/AutomatedChats'
import ChatsList from '../components/ChatsList'
import ConfirmDialog from '../components/ConfirmDialog'
import HomeUndoToast from '../components/home/HomeUndoToast'
@@ -20,24 +23,41 @@ import './chat.css'
// transcript (ChatsList): search over titles and message text, rename, copy,
// export, delete with Undo and Clear all, plus Import. A row opens its
// transcript; New chat opens Home, where a chat starts.
//
// Two tabs. "My chats" lists the chats a person started. "From add-ons and
// automations" lists the others (a connector, a trigger, a job, a page
// started them), grouped by what started them, so a bot's conversations do not
// sit among the person's own. The browser remembers the tab.
export default function AllChats() {
const navigate = useNavigate()
const { addToast } = useOutletContext()
const { t } = useTranslation('chat')
const { t: tc } = useTranslation('chats')
const { models: chatModels } = useModels(CAP_CHAT)
const [state, setState] = useState({ status: 'loading', chats: [], error: null })
const [state, setState] = useState({ status: 'loading', chats: [], needsYou: new Set(), error: null })
const [tab, setTabState] = useState(() => readTab())
const setTab = (id) => { setTabState(id); writeTab(id) }
const [confirmDialog, setConfirmDialog] = useState(null)
const importInputRef = useRef(null)
const load = useCallback(async (keep = false) => {
if (!keep) setState(s => ({ ...s, status: 'loading', error: null }))
try {
const data = await chatsApi.list(200, 'chat')
setState({ status: 'ready', chats: (data.conversations || []).filter(c => c.kind !== 'subagent'), error: null })
// Each side has its own limit, so a burst of chats from a connector cannot
// push a person's chats off the page. The inbox says which of the others
// wait for the person; a failed read of it or of the others is not an error.
const [mine, others, inbox] = await Promise.all([
chatsApi.list(200, 'chat', 'person'),
chatsApi.list(200, 'chat', 'automated').catch(() => null),
chatNotificationsApi.list(true, 200).catch(() => null),
])
const seen = new Set()
const chats = [...(mine.conversations || []), ...(others?.conversations || [])]
.filter(c => c.kind !== 'subagent' && !seen.has(c.id) && seen.add(c.id))
setState({ status: 'ready', chats, needsYou: needsYouIds(inbox?.notifications), error: null })
} catch (error) {
// A failed refresh keeps the list on screen; a failed first load says so.
setState(s => (keep && s.status === 'ready' ? s : { status: 'error', chats: [], error }))
setState(s => (keep && s.status === 'ready' ? s : { status: 'error', chats: [], needsYou: new Set(), error }))
}
}, [])
useEffect(() => { load() }, [load])
@@ -48,10 +68,16 @@ export default function AllChats() {
onFailed: (e) => addToast(e.message || tc('errors.delete'), 'error'),
})
const visibleChats = useMemo(
const allVisible = useMemo(
() => menuChats(state.chats).filter(c => c.id !== deletes.pending?.id),
[state.chats, deletes.pending],
)
const visibleChats = useMemo(() => allVisible.filter(c => !c.origin), [allVisible])
const automatedChats = useMemo(() => allVisible.filter(c => c.origin), [allVisible])
const originCtx = useOriginContext(automatedChats.length > 0)
const { waiting } = attention(automatedChats, state.needsYou)
// The tabs appear once there is a second list to show.
const showTabs = automatedChats.length > 0 || tab === 'automated'
// The sidebar's recent chats follow this list.
useEffect(() => { if (state.status === 'ready') announceChats(visibleChats) }, [visibleChats, state.status])
@@ -118,11 +144,53 @@ export default function AllChats() {
</Button>
)}
/>
{state.status === 'ready' && showTabs && (
<div className="dk-tabs ac-tabs" role="tablist" aria-label={t('allChats.tabsLabel')} data-testid="all-chats-tabs">
{['mine', 'automated'].map(id => (
<button
key={id}
type="button"
role="tab"
id={`all-chats-tab-${id}`}
aria-controls={`all-chats-panel-${id}`}
aria-selected={tab === id}
tabIndex={tab === id ? 0 : -1}
className="dk-tab"
data-testid={`all-chats-tab-${id}`}
onClick={() => setTab(id)}
onKeyDown={(e) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
e.preventDefault()
const next = id === 'mine' ? 'automated' : 'mine'
setTab(next)
document.getElementById(`all-chats-tab-${next}`)?.focus()
}}
>
{t(`allChats.tab.${id}`)}
<span className="ac-tab__count" data-testid={`all-chats-count-${id}`}>{id === 'mine' ? visibleChats.length : automatedChats.length}</span>
{id === 'automated' && waiting > 0 && (
<span className="ac-tab__wait" data-testid="all-chats-waiting" title={t('allChats.automated.waitingCount', { count: waiting })} aria-label={t('allChats.automated.waitingCount', { count: waiting })}>{waiting}</span>
)}
</button>
))}
</div>
)}
{state.status === 'loading' && <SkeletonRows count={6} />}
{state.status === 'error' && (
<LoadError title={t('allChats.loadFailed')} error={state.error} onRetry={() => load()} nothingChanged={false} />
)}
{state.status === 'ready' && visibleChats.length === 0 && !deletes.pending && (
{state.status === 'ready' && tab === 'automated' && (
<div role={showTabs ? 'tabpanel' : undefined} id="all-chats-panel-automated" aria-labelledby="all-chats-tab-automated">
<AutomatedChats
chats={automatedChats}
ctx={originCtx}
needsYou={state.needsYou}
onOpen={(id) => navigate(`/app/chats/${encodeURIComponent(id)}`)}
onDelete={(c) => deletes.request({ id: c.id, name: c.name || tc('list.untitled') })}
/>
</div>
)}
{state.status === 'ready' && tab === 'mine' && visibleChats.length === 0 && !deletes.pending && (
<EmptyState
testId="all-chats-empty"
title={t('allChats.emptyTitle')}
@@ -130,8 +198,8 @@ export default function AllChats() {
actions={<Button variant="primary" icon="chat" onClick={() => navigate('/app')}>{t('allChats.start')}</Button>}
/>
)}
{state.status === 'ready' && (visibleChats.length > 0 || deletes.pending) && (
<div className="cx-menu cx-menu--inline" data-testid="all-chats-list">
{state.status === 'ready' && tab === 'mine' && (visibleChats.length > 0 || deletes.pending) && (
<div className="cx-menu cx-menu--inline" role={showTabs ? 'tabpanel' : undefined} id="all-chats-panel-mine" aria-labelledby={showTabs ? 'all-chats-tab-mine' : undefined} data-testid="all-chats-list">
<ChatsList
chats={visibleChats}
idPrefix="all-chats"
+26 -4
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react'
import { useParams, useOutletContext, useNavigate, useLocation, useSearchParams } from 'react-router-dom'
import { useParams, useOutletContext, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { fromState } from '../utils/editorNav'
import { useServerChat } from '../hooks/useServerChat'
@@ -8,6 +8,8 @@ import { useComposerArrival } from '../hooks/useComposerTransition'
import { useChatEgress } from '../hooks/useChatEgress'
import { cardsByTool } from '../utils/chatEgress'
import { useInbox } from '../hooks/useInbox'
import { useOriginContext } from '../hooks/useOriginContext'
import { isAutomated, knownKind, resolveOrigin } from '../utils/originChats'
import { highlightAll, enhanceCodeBlocks } from '../utils/markdown'
import {
threadRows, streamingParts, lastUserText, chatMarkdown, menuChats, downloadFrom, chatFromExport,
@@ -699,8 +701,14 @@ function ChatPage() {
() => menuChats(chat.chats).filter(c => c.id !== pendingDelete?.id),
[chat.chats, pendingDelete],
)
// The sidebar's recent chats follow this list.
useEffect(() => { announceChats(visibleChats) }, [visibleChats])
// The sidebar's recent chats follow this list, without the chats that no
// person started: they have their own area (All chats, second tab).
useEffect(() => { announceChats(visibleChats.filter(c => !c.origin)) }, [visibleChats])
// What started a chat is named by the connector and trigger lists. They are
// read only when this page shows a chat that something else started.
const hasAutomated = visibleChats.some(c => c.origin) || Boolean(activeChat?.origin)
const originCtx = useOriginContext(hasAutomated)
const originTitle = (c) => resolveOrigin({ origin: c.origin, originId: c.originId }, originCtx).title
const promptDeleteAll = () => setConfirmDialog({
title: t('deleteAllDialog.title'),
@@ -709,7 +717,8 @@ function ChatPage() {
danger: true,
onConfirm: async () => {
setConfirmDialog(null)
for (const c of visibleChats) {
// Clear all removes the person's own chats. The ones that something else started stay.
for (const c of visibleChats.filter(x => !x.origin)) {
try { await chat.remove(c.id); drafts.clear(c.id); pane.forget(c.id) } catch (e) { addToast(e.message || tc('errors.delete'), 'error'); break }
}
navigate('/app')
@@ -1028,6 +1037,7 @@ function ChatPage() {
onExport={(c) => exportChat(c.id)}
onCopyChat={(c) => copyChat(c.id)}
onSearch={(text) => chatsApi.search(text, 50)}
originTitle={originTitle}
/>
)}
manageMode={manage}
@@ -1056,6 +1066,18 @@ function ChatPage() {
/>
)}
{activeId && isAutomated(activeChat) && (() => {
const o = resolveOrigin({ origin: activeChat.origin, originId: activeChat.origin_id }, originCtx)
const kind = knownKind(o.kind)
const name = o.title || t(`origin.kindFallback.${kind}`)
return (
<p className="cx-notice cx-origin-banner" role="note" data-testid="chat-origin-banner">
<span>{t('origin.banner', { title: name })}</span>
{o.href && <Link className="cx-link" to={o.href} data-testid="chat-origin-link">{t('origin.open', { title: name })}</Link>}
</p>
)
})()}
<div className="cx-stage">
<div className="cx-body" ref={messagesRef}>
{isEmpty && <EmptyHead name={activeId ? title : (pickedAgent?.name || '')} manage={manage} />}
+1 -1
View File
@@ -328,7 +328,7 @@ export default function Home() {
// chats leaves the strip empty.
useEffect(() => {
let live = true
chatsApi.list(1, 'chat').then(r => {
chatsApi.list(1, 'chat', 'person').then(r => {
if (!live) return
setConversations(resumeItems(r.conversations))
setChatServer({ files: r.files !== false, filesReason: r.files_reason || '', persistent: r.persistent !== false, imageModel: r.image_model || '' })
+102
View File
@@ -1843,3 +1843,105 @@ html[data-handoff='fade']::view-transition-new(root) {
animation: cxFade var(--dur-slow) var(--ease-out) both;
}
}
/* Chats that a person did not start: the badge in a list row, the banner over
a transcript, the tabs and the groups of All chats. */
.cx-row-item__origin {
flex: none;
max-width: 14em;
overflow: hidden;
padding: 1px 7px;
border-radius: var(--radius-full);
background: var(--dk-accent-wash);
color: var(--dk-accent-text);
font-size: var(--fs-meta);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 639px) { .cx-row-item__origin { max-width: 6.5em; } }
.cx-origin-banner { margin: 8px 16px 0; justify-content: flex-start; flex-wrap: wrap; }
.cx-origin-banner .cx-link { margin-left: auto; }
.ac-tabs { margin-bottom: 16px; }
.ac-tabs .dk-tab { display: inline-flex; align-items: center; gap: 8px; }
.ac-tab__count {
color: var(--dk-muted);
font-size: var(--fs-meta);
font-variant-numeric: tabular-nums;
}
.ac-tab__wait {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: var(--radius-full);
background: var(--dk-warn-wash);
color: var(--dk-warn-text);
font-size: var(--fs-meta);
font-weight: 700;
line-height: 18px;
text-align: center;
}
.ac-intro, .ac-empty { margin: 0 0 16px; color: var(--dk-muted); font-size: var(--fs-small); }
.ac-empty { padding: 28px 16px; text-align: center; }
.ac-kind { margin-bottom: 28px; }
.ac-kind__title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 10px;
color: var(--dk-muted);
font-family: var(--font-sans);
font-size: var(--fs-meta);
font-weight: 600;
letter-spacing: .09em;
text-transform: uppercase;
}
.ac-kind__count { font-variant-numeric: tabular-nums; }
.ac-group {
margin-bottom: 12px;
padding: 12px 14px;
border-radius: var(--dk-radius-2);
box-shadow: inset 0 0 0 1px var(--dk-edge);
}
.ac-group__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 16px; }
.ac-group__main { display: flex; align-items: center; gap: 8px; min-width: 0; }
.ac-group__title { margin: 0; min-width: 0; overflow: hidden; font-size: var(--fs-body); font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.ac-group__meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; color: var(--dk-muted); font-size: var(--fs-small); }
.ac-badge {
flex: none;
padding: 1px 7px;
border-radius: var(--radius-full);
background: var(--dk-accent-wash);
color: var(--dk-accent-text);
font-size: var(--fs-meta);
font-weight: 600;
}
.ac-link { color: var(--dk-accent-text); font-weight: 600; }
.ac-list { margin: 8px 0 0; padding: 0; list-style: none; }
.ac-row { display: flex; align-items: center; gap: 8px; border-radius: var(--dk-radius-2); }
.ac-row:hover { background: var(--dk-hover); }
.ac-row__open {
display: flex;
flex: 1;
align-items: baseline;
justify-content: space-between;
gap: 12px;
min-width: 0;
padding: 8px 6px;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.ac-row__name { min-width: 0; overflow: hidden; font-size: var(--fs-small); text-overflow: ellipsis; white-space: nowrap; }
.ac-row__time { flex: none; color: var(--dk-muted); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; }
.ac-row__busy { color: var(--dk-muted); }
.ac-row__wait { color: var(--dk-warn-text); }
.ac-more { margin-top: 6px; }
@media (max-width: 639px) {
.ac-group { padding: 10px; }
.ac-row__open { flex-direction: column; align-items: flex-start; gap: 2px; }
}
+1 -1
View File
@@ -134,7 +134,7 @@ export const backendsApi = {
// Chats API: conversations the server keeps. A message sent while a turn runs
// waits and reaches the model, with any others, at its next request.
export const chatsApi = {
list: (limit, kind) => fetchJSON(chatsListPath(API_CONFIG.endpoints.chats, limit, kind)),
list: (limit, kind, origin) => fetchJSON(chatsListPath(API_CONFIG.endpoints.chats, limit, kind, origin)),
// With an agent, its model (when it has one) replaces model. With assistant,
// the chat gets the LocalAI management tools (admin only, setting on).
// switches are the drafted switches of a new chat (useChatSwitches): the
+2
View File
@@ -163,6 +163,8 @@ export function menuChats(conversations) {
model: c.model || '',
running: Boolean(c.running),
origin: c.origin || '',
originId: c.origin_id || '',
lastTurn: c.last_turn || '',
agentId: c.agent_id || '',
assistant: Boolean(c.assistant),
createdAt: Date.parse(c.created) || 0,
+6 -1
View File
@@ -1,10 +1,15 @@
// The address of the chat list. With kind "chat" the server leaves sub-agent
// runs out and counts only chats against the limit, so a burst of newer runs
// cannot push a person's chats off the page.
export function chatsListPath(base, limit, kind) {
//
// With an origin the server returns only the chats of that origin and counts
// the limit against them: person (a person started it), automated (anything
// else) or one origin kind such as trigger or connector.
export function chatsListPath(base, limit, kind, origin) {
const sp = new URLSearchParams()
if (limit) sp.set('limit', String(limit))
if (kind) sp.set('kind', kind)
if (origin) sp.set('origin', origin)
const s = sp.toString()
return s ? `${base}?${s}` : base
}
+1 -1
View File
@@ -7,7 +7,7 @@ const DAY_MS = 24 * 60 * 60 * 1000
// newest first. A sub-agent run belongs to its parent and is left out.
export function resumeItems(conversations) {
return (conversations || [])
.filter(c => c && c.id && c.kind !== 'subagent')
.filter(c => c && c.id && c.kind !== 'subagent' && !c.origin)
.map(c => ({
id: c.id,
title: (c.title || '').trim() || 'New chat',
+159
View File
@@ -0,0 +1,159 @@
import { addonPageAddress } from '../addons/addresses.js'
// Chats that a person did not start: a trigger, a connector binding, an agent
// job or a page sent the first message. They stay out of the lists of the
// person's own chats and have an area of their own. No React, so a node test
// drives these helpers.
//
// Nothing here knows a platform. A chat has an origin kind (trigger,
// connector, job, page, or a kind a later release adds) and the id of the
// thing that started it. The title of that thing comes from the list the
// kind belongs to; a kind that has no list falls back to the id, and a kind
// this file never heard of falls under a generic group.
// The order of the groups. A kind that is not here comes after, by name.
const KIND_ORDER = ['trigger', 'connector', 'job', 'page']
// The origin kinds that have words of their own; any other falls under "other".
const KNOWN = ['trigger', 'connector', 'job', 'page']
export const knownKind = (kind) => (KNOWN.includes(kind) ? kind : 'other')
export const isAutomated = (chat) => Boolean(chat?.origin)
// The chat summary, split: the chats a person started, then the others.
export function splitByOrigin(chats) {
const person = []
const automated = []
for (const c of chats || []) (isAutomated(c) ? automated : person).push(c)
return { person, automated }
}
// Where the thing that started a chat has its page, or '' when it has none.
function originHref(kind, id) {
if (kind === 'trigger') return id ? `/app/triggers/${encodeURIComponent(id)}` : '/app/triggers'
if (kind === 'connector') return '/app/chats/connectors'
if (kind === 'job') return '/app/triggers'
if (kind === 'page') return id ? `/app/pages/${encodeURIComponent(id)}` : '/app/pages'
return ''
}
// The name a list gives the origin, or '' when the list does not know it.
// ctx holds the lists the page read: connectors (bindings), connectorKinds
// and triggers. A connector without a name of its own takes the title of its
// kind.
function originName(kind, id, ctx) {
if (kind === 'connector') {
const b = (ctx?.connectors || []).find((x) => x.id === id)
if (!b) return ''
if (b.name) return b.name
return (ctx?.connectorKinds || []).find((k) => k.kind === b.kind)?.title || b.kind || ''
}
if (kind === 'trigger') return (ctx?.triggers || []).find((x) => x.id === id)?.name || ''
// A job or a page has no list of its own here: its id is its name.
if (kind === 'job' || kind === 'page') return id || ''
return ''
}
// What started a chat: { kind, id, key, title, href }. title is '' when
// nothing names it; the view then shows the label of the kind.
export function resolveOrigin(chat, ctx) {
const kind = chat?.origin || ''
const id = chat?.originId || ''
let href = originHref(kind, id)
if (kind === 'connector') {
// The add-on that serves the binding's kind has a page of its own for it.
const b = (ctx?.connectors || []).find((x) => x.id === id)
const k = b && (ctx?.connectorKinds || []).find((x) => x.kind === b.kind && (!b.addon || x.addon === b.addon))
if (k?.page) href = addonPageAddress({ addon: k.addon, page: k.page })
}
return { kind, id, key: `${kind}/${id}`, title: originName(kind, id, ctx), href }
}
// The status of a chat of this kind: running while a turn runs, waiting when
// it needs the person (its turn paused at the step limit, or an unread
// notification asks for them; needsYou is the Set of chat ids with one), idle
// otherwise.
export function chatStatus(chat, needsYou) {
if (chat?.lastTurn === 'paused' || needsYou?.has(chat?.id)) return 'waiting'
if (chat?.running) return 'running'
return 'idle'
}
// The ids of the chats that unread inbox items ask the person to look at: a
// run that failed, or something that needs them. An item links to its chat.
const CHAT_LINK = /^\/app\/chats\/([^/?#]+)/
export function needsYouIds(notifications) {
const ids = new Set()
for (const n of notifications || []) {
if (n.read || (n.kind !== 'needs_attention' && n.kind !== 'run_failed')) continue
const m = CHAT_LINK.exec(n.link || '')
if (m) ids.add(decodeURIComponent(m[1]))
}
return ids
}
// How many of these chats need the person, and whether any turn runs.
export function attention(chats, needsYou) {
let waiting = 0
let running = 0
for (const c of chats || []) {
if (!isAutomated(c)) continue
const s = chatStatus(c, needsYou)
if (s === 'waiting') waiting++
else if (s === 'running') running++
}
return { waiting, running }
}
// The chats that were not started by a person, in groups by origin kind and,
// inside a kind, by origin (one trigger, one connector binding...). Kinds
// keep a fixed order, origins and chats go newest first. Every group has the
// count, the time of its latest chat and how many chats run or wait.
export function groupByOrigin(chats, ctx, needsYou) {
const kinds = new Map()
for (const c of chats || []) {
if (!isAutomated(c)) continue
const o = resolveOrigin(c, ctx)
if (!kinds.has(o.kind)) kinds.set(o.kind, new Map())
const origins = kinds.get(o.kind)
if (!origins.has(o.key)) origins.set(o.key, { ...o, chats: [], count: 0, lastActive: 0, running: 0, waiting: 0 })
const g = origins.get(o.key)
g.chats.push(c)
g.count++
g.lastActive = Math.max(g.lastActive, c.updatedAt || 0)
const s = chatStatus(c, needsYou)
if (s === 'running') g.running++
if (s === 'waiting') g.waiting++
}
const rank = (k) => { const i = KIND_ORDER.indexOf(k); return i < 0 ? KIND_ORDER.length : i }
return [...kinds.entries()]
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
.map(([kind, origins]) => {
const groups = [...origins.values()].sort((a, b) => b.lastActive - a.lastActive)
for (const g of groups) g.chats.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
return {
kind,
count: groups.reduce((n, g) => n + g.count, 0),
waiting: groups.reduce((n, g) => n + g.waiting, 0),
running: groups.reduce((n, g) => n + g.running, 0),
groups,
}
})
}
// The tab of All chats the browser remembers.
export const TAB_KEY = 'localai_all_chats_tab'
export const TABS = ['mine', 'automated']
export function readTab(storage) {
try {
const v = (storage || window.localStorage).getItem(TAB_KEY)
return TABS.includes(v) ? v : 'mine'
} catch {
return 'mine'
}
}
export function writeTab(tab, storage) {
try { (storage || window.localStorage).setItem(TAB_KEY, tab) } catch { /* private mode: the tab is not remembered */ }
}
@@ -8,6 +8,16 @@ test('chats list: a request for chats asks the server to leave sub-agent runs ou
assert.equal(chatsListPath('/api/chats', 200, 'chat'), '/api/chats?limit=200&kind=chat')
assert.equal(chatsListPath('/api/chats'), '/api/chats')
assert.equal(chatsListPath('/api/chats', 5), '/api/chats?limit=5')
assert.equal(chatsListPath('/api/chats', 8, 'chat', 'person'), '/api/chats?limit=8&kind=chat&origin=person')
assert.equal(chatsListPath('/api/chats', 200, 'chat', 'automated'), '/api/chats?limit=200&kind=chat&origin=automated')
})
test('chats list: resume rows leave out chats that a trigger or a connector started', () => {
const rows = resumeItems([
{ id: 'bot', origin: 'connector', origin_id: 'b1', updated: '2026-10-27T10:00:00Z' },
{ id: 'me', title: 'Mine', origin: '', updated: '2026-10-27T09:00:00Z' },
])
assert.deepEqual(rows.map(r => r.id), ['me'])
})
test('chats list: resume rows skip a sub-agent run that is the newest record', () => {
@@ -80,4 +80,7 @@ test('chat thread: server chats in the menu shape', () => {
assert.equal(c.name, 'T')
assert.equal(c.running, true)
assert.equal(c.updatedAt, Date.parse('2026-01-02T00:00:00Z'))
assert.deepEqual([c.origin, c.originId, c.lastTurn], ['', '', ''])
const [b] = menuChats([{ id: 'b', origin: 'connector', origin_id: 'b1', last_turn: 'paused' }])
assert.deepEqual([b.origin, b.originId, b.lastTurn], ['connector', 'b1', 'paused'])
})
@@ -0,0 +1,99 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
attention, chatStatus, groupByOrigin, needsYouIds, readTab, resolveOrigin, splitByOrigin, writeTab,
} from '../../src/utils/originChats.js'
const chat = (id, origin, originId, updatedAt, extra = {}) => ({ id, origin, originId, updatedAt, running: false, lastTurn: 'done', ...extra })
const ctx = {
connectors: [{ id: 'b1', name: 'Team bot', kind: 'chatapp' }, { id: 'b2', name: '', kind: 'chatapp' }],
connectorKinds: [{ kind: 'chatapp', title: 'Chat App' }],
triggers: [{ id: 't1', name: 'Morning digest' }],
}
test('origin chats: the person and the others are split by origin', () => {
const { person, automated } = splitByOrigin([chat('a', '', '', 1), chat('b', 'connector', 'b1', 2), chat('c', 'someday', 'x', 3)])
assert.deepEqual(person.map(c => c.id), ['a'])
assert.deepEqual(automated.map(c => c.id), ['b', 'c'])
})
test('origin chats: a connector takes the name of its binding, then the title of its kind', () => {
assert.equal(resolveOrigin(chat('a', 'connector', 'b1', 1), ctx).title, 'Team bot')
assert.equal(resolveOrigin(chat('a', 'connector', 'b2', 1), ctx).title, 'Chat App')
assert.equal(resolveOrigin(chat('a', 'connector', 'gone', 1), ctx).title, '')
assert.equal(resolveOrigin(chat('a', 'connector', 'b1', 1), ctx).href, '/app/chats/connectors')
})
test('origin chats: a connector links to the page of the add-on that serves its kind', () => {
const withPage = { ...ctx, connectors: [{ id: 'b1', name: 'Team bot', kind: 'chatapp', addon: 'chatapp' }], connectorKinds: [{ addon: 'chatapp', kind: 'chatapp', title: 'Chat App', page: 'bot' }] }
assert.equal(resolveOrigin(chat('a', 'connector', 'b1', 1), withPage).href, '/app/addons/chatapp/bot')
})
test('origin chats: a trigger, a job and a page are named by their list or their id', () => {
assert.equal(resolveOrigin(chat('a', 'trigger', 't1', 1), ctx).title, 'Morning digest')
assert.equal(resolveOrigin(chat('a', 'trigger', 't1', 1), ctx).href, '/app/triggers/t1')
assert.equal(resolveOrigin(chat('a', 'job', 'nightly', 1), ctx).title, 'nightly')
assert.equal(resolveOrigin(chat('a', 'page', 'notes', 1), ctx).href, '/app/pages/notes')
})
test('origin chats: an origin this release does not know gets no title and no link', () => {
const o = resolveOrigin(chat('a', 'future', 'f1', 1), ctx)
assert.deepEqual([o.kind, o.title, o.href], ['future', '', ''])
})
test('origin chats: groups go by kind, then by origin, newest first, with counts', () => {
const chats = [
chat('a', 'connector', 'b1', 10), chat('b', 'connector', 'b1', 30), chat('c', 'connector', 'b2', 20),
chat('d', 'trigger', 't1', 5), chat('e', 'future', 'f1', 99), chat('p', '', '', 100),
]
const kinds = groupByOrigin(chats, ctx, new Set())
assert.deepEqual(kinds.map(k => k.kind), ['trigger', 'connector', 'future'])
const conn = kinds[1]
assert.equal(conn.count, 3)
assert.deepEqual(conn.groups.map(g => g.id), ['b1', 'b2'])
assert.equal(conn.groups[0].count, 2)
assert.equal(conn.groups[0].lastActive, 30)
assert.deepEqual(conn.groups[0].chats.map(c => c.id), ['b', 'a'])
assert.ok(!JSON.stringify(kinds).includes('"p"'), 'the chat of a person is not grouped')
})
test('origin chats: a running turn and a chat that needs the person are counted apart', () => {
const chats = [
chat('r', 'connector', 'b1', 3, { running: true }),
chat('w', 'connector', 'b1', 2, { lastTurn: 'paused' }),
chat('n', 'trigger', 't1', 1),
chat('i', 'trigger', 't1', 0),
]
const needs = new Set(['n'])
assert.equal(chatStatus(chats[0], needs), 'running')
assert.equal(chatStatus(chats[1], needs), 'waiting')
assert.equal(chatStatus(chats[2], needs), 'waiting')
assert.equal(chatStatus(chats[3], needs), 'idle')
assert.deepEqual(attention(chats, needs), { waiting: 2, running: 1 })
const conn = groupByOrigin(chats, ctx, needs).find(k => k.kind === 'connector')
assert.deepEqual([conn.running, conn.waiting], [1, 1])
})
test('origin chats: only unread items that ask for the person count, and they link to a chat', () => {
const ids = needsYouIds([
{ kind: 'needs_attention', read: false, link: '/app/chats/c1' },
{ kind: 'run_failed', read: false, link: '/app/chats/c%202?x=1' },
{ kind: 'run_ended', read: false, link: '/app/chats/c3' },
{ kind: 'needs_attention', read: true, link: '/app/chats/c4' },
{ kind: 'needs_attention', read: false, link: '/app/triggers' },
])
assert.deepEqual([...ids].sort(), ['c 2', 'c1'])
})
test('origin chats: the tab is remembered, and a storage that throws does not break it', () => {
const mem = new Map()
const storage = { getItem: (k) => mem.get(k) ?? null, setItem: (k, v) => mem.set(k, v) }
assert.equal(readTab(storage), 'mine')
writeTab('automated', storage)
assert.equal(readTab(storage), 'automated')
mem.set('localai_all_chats_tab', 'nonsense')
assert.equal(readTab(storage), 'mine')
const broken = { getItem() { throw new Error('denied') }, setItem() { throw new Error('denied') } }
assert.equal(readTab(broken), 'mine')
assert.doesNotThrow(() => writeTab('automated', broken))
})
+32
View File
@@ -174,6 +174,22 @@ The answers have the shapes of `GET /api/chats/{id}/tools` and `GET
/api/chats/{id}/addons`. A model you may not use is `403`, a model the server
does not have is `400`, and another user's agent is `404`.
### List the chats of one origin
`GET /api/chats` returns every chat unless you send `origin`. With
`origin=person` it returns only the chats a person started, with
`origin=automated` only the chats that something else started, and with an
origin kind (`trigger`, `connector`, `job`, `page`) only the chats of that kind.
Each chat carries `origin` (empty for a person) and `origin_id` (the trigger,
binding, job or page that started it). With `origin` set, `limit` counts the
chats kept and sub-agent runs are left out, so a burst of chats from a bot
cannot push your own chats out of the first page.
```bash
curl -s 'localhost:8080/api/chats?kind=chat&origin=person&limit=8'
curl -s 'localhost:8080/api/chats?kind=chat&origin=automated&limit=200'
```
## Keep typing while the model works
Send another message while a turn runs and it waits. The response says so:
@@ -1804,6 +1820,22 @@ and opens again from any browser.
Markdown or delete one, clear them all, or import a JSON export. A deleted
chat can be brought back for a few seconds with **Undo**; then the server
deletes it.
The page lists the chats you started. Chats that something else started (a
connector, a trigger, an agent job or a page) have a second tab, **From
add-ons and automations**, which appears once there is one. It groups them by
what started them: first the kind (triggers, connectors, jobs, pages, any
other), then the origin itself, such as one connector binding or one
trigger, with the origin's name, how many chats it has, when it was last
active, a **Running** mark while a turn runs and **Waiting for you** when a
chat needs you (its turn paused at the step limit, or an unread inbox item
asks for you). A link opens the origin's page. These chats do not appear in
the sidebar, in **Jump back in** or in the **My chats** tab; the **All
chats** entry in the sidebar shows a number when some of them wait for you.
`Ctrl+K` in a transcript still lists and finds every chat, with a badge on
the ones you did not start. Opening one shows "This conversation was started
by ..." over the transcript, with a link to the origin. The browser
remembers which tab you used last.
- **Old addresses.** `/app/chats` with no chat id opens Home. A `?model=` in
the address selects that model on Home, so `/app/chats?model=<id>` and the
older `/app/chat/<model>` still start a chat on that model.