From f05b971fb24b16e541b7fe6db07760958f8d1229 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 24 Jun 2026 04:12:59 +0530 Subject: [PATCH] feat(chat): surface agent Thoughts inline + relocate parent narration into processing (#4019) --- app/src/lib/i18n/ar.ts | 2 + app/src/lib/i18n/bn.ts | 2 + app/src/lib/i18n/de.ts | 2 + app/src/lib/i18n/en.ts | 2 + app/src/lib/i18n/es.ts | 2 + app/src/lib/i18n/fr.ts | 2 + app/src/lib/i18n/hi.ts | 2 + app/src/lib/i18n/id.ts | 2 + app/src/lib/i18n/it.ts | 2 + app/src/lib/i18n/ko.ts | 2 + app/src/lib/i18n/pl.ts | 2 + app/src/lib/i18n/pt.ts | 2 + app/src/lib/i18n/ru.ts | 2 + app/src/lib/i18n/zh-CN.ts | 2 + app/src/pages/Conversations.tsx | 30 ++- .../components/SubagentDrawer.tsx | 5 +- .../components/ToolTimelineBlock.tsx | 245 +++++++++++++----- .../__tests__/ToolTimelineBlock.test.tsx | 124 ++++++++- .../__tests__/toolTimelineFormatting.test.ts | 28 +- app/src/utils/toolTimelineFormatting.ts | 14 + 20 files changed, 378 insertions(+), 96 deletions(-) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 9827f441f..941997c19 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -2989,6 +2989,7 @@ const messages: TranslationMap = { 'conversations.subagent.viewProcessing': 'عرض المعالجة الكاملة', 'conversations.subagent.parent': 'الأصل', 'conversations.subagent.thinking': 'التفكير', + 'conversations.subagent.thoughts': 'الأفكار', 'conversations.subagent.response': 'الرد', 'conversations.subagent.toolCalls': 'استدعاءات الأدوات', 'conversations.subagent.working': 'يعمل…', @@ -3006,6 +3007,7 @@ const messages: TranslationMap = { 'conversations.subagent.statusAwaitingUser': 'في انتظار المستخدم', 'conversations.subagent.statusCancelled': 'أُلغيت', 'conversations.agentTaskInsights.title': 'رؤى مهام الوكيل', + 'conversations.agentTaskInsights.response': 'الرد', 'conversations.agentTaskInsights.processSourceTitle': 'مصدر عملية الوكيل', 'conversations.agentTaskInsights.stepsHeading': 'الخطوات', 'conversations.agentTaskInsights.sourcesHeading': 'المصادر', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 821301f69..e09d23741 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3053,6 +3053,7 @@ const messages: TranslationMap = { 'conversations.subagent.viewProcessing': 'সম্পূর্ণ প্রক্রিয়া দেখুন', 'conversations.subagent.parent': 'মূল', 'conversations.subagent.thinking': 'ভাবছে', + 'conversations.subagent.thoughts': 'চিন্তাভাবনা', 'conversations.subagent.response': 'প্রতিক্রিয়া', 'conversations.subagent.toolCalls': 'টুল কল', 'conversations.subagent.working': 'কাজ করছে…', @@ -3070,6 +3071,7 @@ const messages: TranslationMap = { 'conversations.subagent.statusAwaitingUser': 'ব্যবহারকারীর অপেক্ষায়', 'conversations.subagent.statusCancelled': 'বাতিল হয়েছে', 'conversations.agentTaskInsights.title': 'এজেন্ট টাস্ক অন্তর্দৃষ্টি', + 'conversations.agentTaskInsights.response': 'প্রতিক্রিয়া', 'conversations.agentTaskInsights.processSourceTitle': 'এজেন্ট প্রক্রিয়া উৎস', 'conversations.agentTaskInsights.stepsHeading': 'ধাপসমূহ', 'conversations.agentTaskInsights.sourcesHeading': 'উৎসসমূহ', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 25fb5ffd8..e01f0fdd8 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3125,6 +3125,7 @@ const messages: TranslationMap = { 'conversations.subagent.viewProcessing': 'Vollständige Verarbeitung anzeigen', 'conversations.subagent.parent': 'Übergeordnet', 'conversations.subagent.thinking': 'Denkt nach', + 'conversations.subagent.thoughts': 'Gedanken', 'conversations.subagent.response': 'Antwort', 'conversations.subagent.toolCalls': 'Tool-Aufrufe', 'conversations.subagent.working': 'Arbeitet…', @@ -3143,6 +3144,7 @@ const messages: TranslationMap = { 'conversations.subagent.statusAwaitingUser': 'warte auf Benutzer', 'conversations.subagent.statusCancelled': 'abgebrochen', 'conversations.agentTaskInsights.title': 'Agenten-Aufgabeneinblicke', + 'conversations.agentTaskInsights.response': 'Antwort', 'conversations.agentTaskInsights.processSourceTitle': 'Agentenprozess-Quelle', 'conversations.agentTaskInsights.stepsHeading': 'Schritte', 'conversations.agentTaskInsights.sourcesHeading': 'Quellen', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 607b0ad30..99eb1c494 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3583,6 +3583,7 @@ const en: TranslationMap = { 'conversations.subagent.viewProcessing': 'View full processing', 'conversations.subagent.parent': 'Parent', 'conversations.subagent.thinking': 'Thinking', + 'conversations.subagent.thoughts': 'Thoughts', 'conversations.subagent.response': 'Response', 'conversations.subagent.toolCalls': 'Tool calls', 'conversations.subagent.working': 'Working…', @@ -3617,6 +3618,7 @@ const en: TranslationMap = { 'conversations.subagent.statusAwaitingUser': 'awaiting user', 'conversations.subagent.statusCancelled': 'cancelled', 'conversations.agentTaskInsights.title': 'Agentic task insights', + 'conversations.agentTaskInsights.response': 'Response', 'conversations.agentTaskInsights.processSourceTitle': 'Agent Process Source', 'conversations.agentTaskInsights.stepsHeading': 'Steps', 'conversations.agentTaskInsights.sourcesHeading': 'Sources', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 7fe5a8b09..2777835c2 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3104,6 +3104,7 @@ const messages: TranslationMap = { 'conversations.subagent.viewProcessing': 'Ver procesamiento completo', 'conversations.subagent.parent': 'Principal', 'conversations.subagent.thinking': 'Pensando', + 'conversations.subagent.thoughts': 'Pensamientos', 'conversations.subagent.response': 'Respuesta', 'conversations.subagent.toolCalls': 'Llamadas a herramientas', 'conversations.subagent.working': 'Trabajando…', @@ -3121,6 +3122,7 @@ const messages: TranslationMap = { 'conversations.subagent.statusAwaitingUser': 'esperando al usuario', 'conversations.subagent.statusCancelled': 'cancelado', 'conversations.agentTaskInsights.title': 'Información de tareas del agente', + 'conversations.agentTaskInsights.response': 'Respuesta', 'conversations.agentTaskInsights.processSourceTitle': 'Fuente del proceso del agente', 'conversations.agentTaskInsights.stepsHeading': 'Pasos', 'conversations.agentTaskInsights.sourcesHeading': 'Fuentes', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index bcad3d08a..fa81de79f 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3119,6 +3119,7 @@ const messages: TranslationMap = { 'conversations.subagent.viewProcessing': 'Voir le traitement complet', 'conversations.subagent.parent': 'Parent', 'conversations.subagent.thinking': 'Réflexion', + 'conversations.subagent.thoughts': 'Réflexions', 'conversations.subagent.response': 'Réponse', 'conversations.subagent.toolCalls': 'Appels d’outils', 'conversations.subagent.working': 'En cours…', @@ -3136,6 +3137,7 @@ const messages: TranslationMap = { 'conversations.subagent.statusAwaitingUser': "en attente de l'utilisateur", 'conversations.subagent.statusCancelled': 'annulé', 'conversations.agentTaskInsights.title': "Aperçu des tâches de l'agent", + 'conversations.agentTaskInsights.response': 'Réponse', 'conversations.agentTaskInsights.processSourceTitle': "Source du processus de l'agent", 'conversations.agentTaskInsights.stepsHeading': 'Étapes', 'conversations.agentTaskInsights.sourcesHeading': 'Sources', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 714211c4d..b4ef5d90b 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3054,6 +3054,7 @@ const messages: TranslationMap = { 'conversations.subagent.viewProcessing': 'पूरी प्रोसेसिंग देखें', 'conversations.subagent.parent': 'मूल', 'conversations.subagent.thinking': 'सोच रहा है', + 'conversations.subagent.thoughts': 'विचार', 'conversations.subagent.response': 'प्रतिक्रिया', 'conversations.subagent.toolCalls': 'टूल कॉल', 'conversations.subagent.working': 'काम कर रहा है…', @@ -3071,6 +3072,7 @@ const messages: TranslationMap = { 'conversations.subagent.statusAwaitingUser': 'उपयोगकर्ता की प्रतीक्षा', 'conversations.subagent.statusCancelled': 'रद्द किया गया', 'conversations.agentTaskInsights.title': 'एजेंट कार्य अंतर्दृष्टि', + 'conversations.agentTaskInsights.response': 'प्रतिक्रिया', 'conversations.agentTaskInsights.processSourceTitle': 'एजेंट प्रक्रिया स्रोत', 'conversations.agentTaskInsights.stepsHeading': 'चरण', 'conversations.agentTaskInsights.sourcesHeading': 'स्रोत', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 30a1b9df0..a3949c49a 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3057,6 +3057,7 @@ const messages: TranslationMap = { 'conversations.subagent.viewProcessing': 'Lihat proses lengkap', 'conversations.subagent.parent': 'Induk', 'conversations.subagent.thinking': 'Berpikir', + 'conversations.subagent.thoughts': 'Pemikiran', 'conversations.subagent.response': 'Respons', 'conversations.subagent.toolCalls': 'Panggilan alat', 'conversations.subagent.working': 'Bekerja…', @@ -3074,6 +3075,7 @@ const messages: TranslationMap = { 'conversations.subagent.statusAwaitingUser': 'menunggu pengguna', 'conversations.subagent.statusCancelled': 'dibatalkan', 'conversations.agentTaskInsights.title': 'Wawasan tugas agen', + 'conversations.agentTaskInsights.response': 'Respons', 'conversations.agentTaskInsights.processSourceTitle': 'Sumber proses agen', 'conversations.agentTaskInsights.stepsHeading': 'Langkah', 'conversations.agentTaskInsights.sourcesHeading': 'Sumber', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index c115ed21c..c38659517 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3098,6 +3098,7 @@ const messages: TranslationMap = { 'conversations.subagent.viewProcessing': 'Visualizza elaborazione completa', 'conversations.subagent.parent': 'Principale', 'conversations.subagent.thinking': 'Ragionamento', + 'conversations.subagent.thoughts': 'Pensieri', 'conversations.subagent.response': 'Risposta', 'conversations.subagent.toolCalls': 'Chiamate agli strumenti', 'conversations.subagent.working': 'In corso…', @@ -3115,6 +3116,7 @@ const messages: TranslationMap = { 'conversations.subagent.statusAwaitingUser': "in attesa dell'utente", 'conversations.subagent.statusCancelled': 'annullato', 'conversations.agentTaskInsights.title': 'Approfondimenti attività agente', + 'conversations.agentTaskInsights.response': 'Risposta', 'conversations.agentTaskInsights.processSourceTitle': "Origine del processo dell'agente", 'conversations.agentTaskInsights.stepsHeading': 'Passaggi', 'conversations.agentTaskInsights.sourcesHeading': 'Fonti', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index bc9652fbb..03c1e46dd 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3029,6 +3029,7 @@ const messages: TranslationMap = { 'conversations.subagent.viewProcessing': '전체 처리 과정 보기', 'conversations.subagent.parent': '상위', 'conversations.subagent.thinking': '생각 중', + 'conversations.subagent.thoughts': '생각', 'conversations.subagent.response': '응답', 'conversations.subagent.toolCalls': '도구 호출', 'conversations.subagent.working': '작업 중…', @@ -3046,6 +3047,7 @@ const messages: TranslationMap = { 'conversations.subagent.statusAwaitingUser': '사용자 대기 중', 'conversations.subagent.statusCancelled': '취소됨', 'conversations.agentTaskInsights.title': '에이전트 작업 인사이트', + 'conversations.agentTaskInsights.response': '응답', 'conversations.agentTaskInsights.processSourceTitle': '에이전트 프로세스 소스', 'conversations.agentTaskInsights.stepsHeading': '단계', 'conversations.agentTaskInsights.sourcesHeading': '소스', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 3e715e767..fead28d39 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3085,6 +3085,7 @@ const messages: TranslationMap = { 'conversations.subagent.viewProcessing': 'Zobacz pełne przetwarzanie', 'conversations.subagent.parent': 'Nadrzędny', 'conversations.subagent.thinking': 'Myślenie', + 'conversations.subagent.thoughts': 'Myśli', 'conversations.subagent.response': 'Odpowiedź', 'conversations.subagent.toolCalls': 'Wywołania narzędzi', 'conversations.subagent.working': 'Pracuje…', @@ -3102,6 +3103,7 @@ const messages: TranslationMap = { 'conversations.subagent.statusAwaitingUser': 'oczekiwanie na użytkownika', 'conversations.subagent.statusCancelled': 'anulowano', 'conversations.agentTaskInsights.title': 'Wgląd w zadania agenta', + 'conversations.agentTaskInsights.response': 'Odpowiedź', 'conversations.agentTaskInsights.processSourceTitle': 'Źródło procesu agenta', 'conversations.agentTaskInsights.stepsHeading': 'Kroki', 'conversations.agentTaskInsights.sourcesHeading': 'Źródła', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index af3ad1825..727ef6111 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3103,6 +3103,7 @@ const messages: TranslationMap = { 'conversations.subagent.viewProcessing': 'Ver processamento completo', 'conversations.subagent.parent': 'Principal', 'conversations.subagent.thinking': 'Pensando', + 'conversations.subagent.thoughts': 'Pensamentos', 'conversations.subagent.response': 'Resposta', 'conversations.subagent.toolCalls': 'Chamadas de ferramentas', 'conversations.subagent.working': 'Trabalhando…', @@ -3120,6 +3121,7 @@ const messages: TranslationMap = { 'conversations.subagent.statusAwaitingUser': 'aguardando usuário', 'conversations.subagent.statusCancelled': 'cancelado', 'conversations.agentTaskInsights.title': 'Insights de tarefas do agente', + 'conversations.agentTaskInsights.response': 'Resposta', 'conversations.agentTaskInsights.processSourceTitle': 'Fonte do processo do agente', 'conversations.agentTaskInsights.stepsHeading': 'Etapas', 'conversations.agentTaskInsights.sourcesHeading': 'Fontes', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 1fd79939a..7b994431e 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3076,6 +3076,7 @@ const messages: TranslationMap = { 'conversations.subagent.viewProcessing': 'Посмотреть весь процесс', 'conversations.subagent.parent': 'Родительский', 'conversations.subagent.thinking': 'Думает', + 'conversations.subagent.thoughts': 'Мысли', 'conversations.subagent.response': 'Ответ', 'conversations.subagent.toolCalls': 'Вызовы инструментов', 'conversations.subagent.working': 'Выполняется…', @@ -3093,6 +3094,7 @@ const messages: TranslationMap = { 'conversations.subagent.statusAwaitingUser': 'ожидание пользователя', 'conversations.subagent.statusCancelled': 'отменено', 'conversations.agentTaskInsights.title': 'Сведения о задачах агента', + 'conversations.agentTaskInsights.response': 'Ответ', 'conversations.agentTaskInsights.processSourceTitle': 'Источник процесса агента', 'conversations.agentTaskInsights.stepsHeading': 'Шаги', 'conversations.agentTaskInsights.sourcesHeading': 'Источники', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index b0560ae66..5be6b5093 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2906,6 +2906,7 @@ const messages: TranslationMap = { 'conversations.subagent.viewProcessing': '查看完整处理过程', 'conversations.subagent.parent': '父级', 'conversations.subagent.thinking': '思考中', + 'conversations.subagent.thoughts': '想法', 'conversations.subagent.response': '回复', 'conversations.subagent.toolCalls': '工具调用', 'conversations.subagent.working': '处理中…', @@ -2923,6 +2924,7 @@ const messages: TranslationMap = { 'conversations.subagent.statusAwaitingUser': '等待用户', 'conversations.subagent.statusCancelled': '已取消', 'conversations.agentTaskInsights.title': '智能体任务洞察', + 'conversations.agentTaskInsights.response': '回复', 'conversations.agentTaskInsights.processSourceTitle': '智能体处理来源', 'conversations.agentTaskInsights.stepsHeading': '步骤', 'conversations.agentTaskInsights.sourcesHeading': '来源', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index ae0b3ace4..9b6851feb 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -2133,8 +2133,9 @@ const Conversations = ({ avoid jitter from partially-parsed fences. The final bubble replaces this via addInferenceResponse on chat_done. */} {selectedStreamingAssistant && - (selectedStreamingAssistant.content.length > 0 || - selectedStreamingAssistant.thinking.length > 0) && ( + (selectedStreamingAssistant.thinking.length > 0 || + (selectedStreamingAssistant.content.length > 0 && + (selectedThreadToolTimeline.length === 0 || hideAgentInsights))) && (
{selectedStreamingAssistant.thinking.length > 0 && ( @@ -2148,17 +2149,19 @@ const Conversations = ({ )} - {selectedStreamingAssistant.content.length > 0 && ( -
-

- {selectedStreamingAssistant.content.length > STREAMING_PREVIEW_CHARS && ( - - )} - {selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)} - -

-
- )} + {selectedStreamingAssistant.content.length > 0 && + (selectedThreadToolTimeline.length === 0 || hideAgentInsights) && ( +
+

+ {selectedStreamingAssistant.content.length > + STREAMING_PREVIEW_CHARS && ( + + )} + {selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)} + +

+
+ )}
)} @@ -2277,6 +2280,7 @@ const Conversations = ({ setOpenSubagentTaskId(sub.taskId)} + liveResponse={selectedStreamingAssistant?.content} /> ))} {/* "View full agent process" — only in the settled/inline state diff --git a/app/src/pages/conversations/components/SubagentDrawer.tsx b/app/src/pages/conversations/components/SubagentDrawer.tsx index bc7615eca..e8aea304e 100644 --- a/app/src/pages/conversations/components/SubagentDrawer.tsx +++ b/app/src/pages/conversations/components/SubagentDrawer.tsx @@ -8,6 +8,7 @@ import type { ToolTimelineEntryStatus, } from '../../../store/chatRuntimeSlice'; import type { ThreadMessage } from '../../../types/thread'; +import { stripToolCallEnvelopes } from '../../../utils/toolTimelineFormatting'; import { BubbleMarkdown } from './AgentMessageBubble'; /** @@ -349,7 +350,7 @@ export function SubagentDrawer({ {t('conversations.subagent.thinking')}
-                          {item.text}
+                          {stripToolCallEnvelopes(item.text).trim()}
                         
@@ -360,7 +361,7 @@ export function SubagentDrawer({ return (
- + {isRunning && idx === lastTextIdx ? ( ) : null} diff --git a/app/src/pages/conversations/components/ToolTimelineBlock.tsx b/app/src/pages/conversations/components/ToolTimelineBlock.tsx index d1b774737..eb0113857 100644 --- a/app/src/pages/conversations/components/ToolTimelineBlock.tsx +++ b/app/src/pages/conversations/components/ToolTimelineBlock.tsx @@ -1,8 +1,16 @@ import WorktreeActions from '../../../components/worktree/WorktreeActions'; import { useT } from '../../../lib/i18n/I18nContext'; -import type { SubagentActivity, ToolTimelineEntry } from '../../../store/chatRuntimeSlice'; +import type { + SubagentActivity, + ToolTimelineEntry, + ToolTimelineEntryStatus, +} from '../../../store/chatRuntimeSlice'; import { basename } from '../../../utils/pathUtils'; -import { formatTimelineEntry, formatToolName } from '../../../utils/toolTimelineFormatting'; +import { + formatTimelineEntry, + formatToolName, + stripToolCallEnvelopes, +} from '../../../utils/toolTimelineFormatting'; import { parseWorkerThreadRef } from '../utils/workerThreadRef'; import { agentNameTone, AgentTimelineRail } from './AgentTimelineRail'; import { WorkerThreadRefCard, type WorkerThreadStatus } from './WorkerThreadRefCard'; @@ -26,21 +34,143 @@ function workerStatusFromEntry( return undefined; } -/** - * Render the live activity of one running (or completed) sub-agent - * inside its parent timeline row — the mode/dedicated-thread badge, - * the child iteration counter, the final-run statistics, and the - * flat list of child tool calls the sub-agent has executed. - * - * Kept as a sibling of the existing worker-thread / detail block so - * the surrounding `
` chevron + status pill behaviour is - * unaffected — this component only renders when `subagent` is - * present on the entry, which is true for any row produced by the - * `subagent_*` socket events from a current core. - */ -/** Chars of streamed subagent text/thinking shown in the inline card tail. */ -const SUBAGENT_PREVIEW_CHARS = 140; +/** Tone classes for a child tool-call row keyed by its lifecycle status. */ +function toolCallTone(status: ToolTimelineEntryStatus): string { + if (status === 'running') return 'text-amber-700 dark:text-amber-300'; + if (status === 'success') return 'text-sage-700 dark:text-sage-300'; + return 'text-coral-700 dark:text-coral-300'; +} +/** + * One child tool-call row in a sub-agent's inline activity. Shared by the + * ordered transcript (interleaved with {@link ThoughtBlock}) and the flat + * `toolCalls` fallback, so the row markup lives in exactly one place. + */ +function ToolCallRow({ + call, +}: { + call: { + callId: string; + toolName: string; + status: ToolTimelineEntryStatus; + elapsedMs?: number; + iteration?: number; + }; +}) { + const tone = toolCallTone(call.status); + return ( +
+ + + {formatToolName(call.toolName)} + + {call.iteration != null ? ( + ·t{call.iteration} + ) : null} + {call.status} + {call.elapsedMs != null && call.status !== 'running' ? ( + + {call.elapsedMs >= 1000 + ? `${(call.elapsedMs / 1000).toFixed(1)}s` + : `${call.elapsedMs}ms`} + + ) : null} +
+ ); +} + +/** + * The agent's reasoning or visible narration, surfaced inline in the timeline + * as a quoted/italic "Thoughts" block at the position it streamed — so a + * thought shows up wherever it occurred between tool calls. Both `thinking` + * and `text` transcript items render through here. Renders nothing for an + * all-whitespace delta so a half-streamed item never flashes an empty quote. + */ +function ThoughtBlock({ text }: { text: string }) { + const { t } = useT(); + // Drop any inline `` envelope the model emitted as + // text — the call already shows as its own row — then flatten to one line. + const clean = stripToolCallEnvelopes(text).replace(/\s+/g, ' ').trim(); + if (!clean) return null; + return ( +
+ + + 💭 + + + {t('conversations.subagent.thoughts')} + + + ▶ + + +
+ “{clean}” +
+
+ ); +} + +/** Tail of the parent's in-flight response shown in the processing panel. */ +const RESPONSE_PREVIEW_CHARS = 320; + +/** + * The parent agent's live response, surfaced inside the processing panel while + * the turn is in flight — its lead-in narration ("Let me check your Notion…") + * belongs with the work it's narrating, not in a standalone chat bubble. The + * final answer still lands in the message bubble once the turn settles. + * Collapsible + accented apart from the stone-toned sub-agent Thoughts so the + * parent's own voice reads as the primary thread. + */ +function LiveResponseBlock({ text }: { text: string }) { + const { t } = useT(); + const clean = stripToolCallEnvelopes(text) + .replace(/[ \t]+\n/g, '\n') + .trimEnd(); + const shown = clean.slice(-RESPONSE_PREVIEW_CHARS); + if (!shown.trim()) return null; + return ( +
+ + + 💬 + + + {t('conversations.agentTaskInsights.response')} + + + ▶ + + +

+ {clean.length > RESPONSE_PREVIEW_CHARS ? ( + + ) : null} + {shown} + +

+
+ ); +} + +/** + * Render the live activity of one running (or completed) sub-agent inside its + * parent timeline row — the mode/dedicated-thread badge, the child iteration + * counter, the final-run statistics, and the ordered transcript of child tool + * calls interleaved with the agent's "Thoughts" (reasoning + narration). + * + * Kept as a sibling of the existing worker-thread / detail block so the + * surrounding `
` chevron + status pill behaviour is unaffected — this + * component only renders when `subagent` is present on the entry, which is true + * for any row produced by the `subagent_*` socket events from a current core. + */ export function SubagentActivityBlock({ subagent, onView, @@ -77,20 +207,12 @@ export function SubagentActivityBlock({ ); } - // Live one-line preview of the subagent's streamed processing, derived - // from the ordered transcript: prefer the latest visible-output tail, then - // fall back to the latest reasoning tail while the child is still thinking - // and hasn't emitted visible text yet. Drives the at-a-glance "what is it - // doing right now" affordance on the card. + // The ordered transcript drives the inline activity: child tool-call rows + // and the agent's "Thoughts" (reasoning + visible narration) render in the + // exact order they streamed, so each thought appears wherever it occurred + // between tool calls. Falls back to the flat tool-call list when the prose + // transcript is absent (e.g. a rehydrated/interrupted snapshot). const transcript = subagent.transcript ?? []; - const lastTextItem = [...transcript].reverse().find(i => i.kind === 'text'); - const lastThinkingItem = [...transcript].reverse().find(i => i.kind === 'thinking'); - const previewItem = lastTextItem ?? lastThinkingItem; - const previewIcon = previewItem?.kind === 'text' ? '📝' : '💭'; - const preview = - previewItem && 'text' in previewItem - ? previewItem.text.replace(/\s+/g, ' ').trim().slice(-SUBAGENT_PREVIEW_CHARS) - : ''; return (
) : null} - {subagent.toolCalls.length > 0 ? ( -
    - {subagent.toolCalls.map(call => { - const tone = - call.status === 'running' - ? 'text-amber-700 dark:text-amber-300' - : call.status === 'success' - ? 'text-sage-700 dark:text-sage-300' - : 'text-coral-700 dark:text-coral-300'; - return ( -
  • - - - {formatToolName(call.toolName)} - - {call.iteration != null ? ( - - ·t{call.iteration} - - ) : null} - {call.status} - {call.elapsedMs != null && call.status !== 'running' ? ( - - {call.elapsedMs >= 1000 - ? `${(call.elapsedMs / 1000).toFixed(1)}s` - : `${call.elapsedMs}ms`} - - ) : null} -
  • - ); - })} -
- ) : null} - {preview ? ( -
- {previewIcon} - {preview} + {transcript.length > 0 ? ( +
+ {transcript.map((item, i) => + item.kind === 'tool' ? ( + + ) : ( + + ) + )} +
+ ) : subagent.toolCalls.length > 0 ? ( +
+ {subagent.toolCalls.map(call => ( + + ))}
) : null} {subagent.worktreePath ? ( @@ -230,6 +325,7 @@ export function ToolTimelineBlock({ entries, onViewSubagent, expandAllRows = false, + liveResponse, }: { entries: ToolTimelineEntry[]; /** Opens the full-transcript drawer for a subagent row. When omitted, @@ -240,6 +336,12 @@ export function ToolTimelineBlock({ * Source" panel, where the whole run should be visible at a glance). * In the inline chat only the latest running row auto-expands. */ expandAllRows?: boolean; + /** The parent agent's in-flight response text. While the turn streams, its + * narration renders inside this panel (as a "Response" block) instead of a + * standalone chat bubble, so the lead-in sits with the work it narrates. + * Omitted/empty once the turn settles — the final answer is the message + * bubble. */ + liveResponse?: string; }) { const { t } = useT(); const latestRunningEntryId = [...entries].reverse().find(entry => entry.status === 'running')?.id; @@ -325,6 +427,7 @@ export function ToolTimelineBlock({ ); })}
+ {liveResponse ? : null}
); } diff --git a/app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx b/app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx index 2269aecd2..858f0984d 100644 --- a/app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx +++ b/app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx @@ -90,7 +90,7 @@ describe('SubagentActivityBlock', () => { expect(calls[2].textContent).toContain('error'); }); - it('shows a live preview of streamed visible text (preferred over thinking)', () => { + it('renders every thought inline as a labeled "Thoughts" block (reasoning + narration)', () => { renderInStore( { }} /> ); - const preview = screen.getByTestId('subagent-preview'); - expect(preview.textContent).toContain('Here is what I found so far'); - // Visible text takes precedence, so the thinking tail is not shown. - expect(preview.textContent).not.toContain('pondering'); + const thoughts = screen.getAllByTestId('subagent-thought'); + // Both reasoning and visible narration surface as their own Thoughts block. + expect(thoughts).toHaveLength(2); + expect(thoughts[0].textContent).toContain('Thoughts'); + expect(thoughts[0].textContent).toContain('pondering the request'); + expect(thoughts[1].textContent).toContain('Here is what I found so far'); }); - it('falls back to the thinking tail while only reasoning has streamed', () => { + it('renders thoughts and tool calls interleaved in transcript order', () => { renderInStore( ); - expect(screen.getByTestId('subagent-preview').textContent).toContain( - 'I should search the web first' + const rows = screen.getByTestId('subagent-transcript').children; + // Order is preserved: thought → tool → thought. + expect(rows[0]).toHaveAttribute('data-testid', 'subagent-thought'); + expect(rows[0].textContent).toContain('I should search the web first'); + expect(rows[1]).toHaveAttribute('data-testid', 'subagent-tool-call'); + expect(rows[1].textContent).toContain('Searching the web'); + expect(rows[2]).toHaveAttribute('data-testid', 'subagent-thought'); + expect(rows[2].textContent).toContain('Found three relevant results'); + }); + + it('renders each thought as a collapsible details (open by default) with a thought icon', () => { + renderInStore( + ); + const thought = screen.getByTestId('subagent-thought'); + expect(thought.tagName).toBe('DETAILS'); + expect(thought).toHaveAttribute('open'); + // The thought-balloon icon sits in the clickable summary alongside the label. + expect(thought.querySelector('summary')?.textContent).toContain('💭'); + expect(thought.querySelector('summary')?.textContent).toContain('Thoughts'); + }); + + it('strips a leaked envelope from the thought text', () => { + renderInStore( + {"name": "NOTION_SEARCH", "arguments": {"query": "audit"}} ', + }, + ], + }} + /> + ); + const thought = screen.getByTestId('subagent-thought'); + expect(thought.textContent).toContain("I'll search your Notion for that."); + // The raw tool-call envelope must not leak into the displayed prose. + expect(thought.textContent).not.toContain('tool_call'); + expect(thought.textContent).not.toContain('NOTION_SEARCH'); + }); + + it('skips an all-whitespace thought delta', () => { + renderInStore( + + ); + expect(screen.queryByTestId('subagent-thought')).toBeNull(); }); it('renders the view-processing button only when onView is provided', async () => { @@ -207,6 +275,44 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { const { container } = renderInStore(); expect(container.querySelector('[data-testid="agent-task-insights"]')).toBeNull(); }); + + it('renders the parent live response inside the panel under a Response heading', () => { + const entries: ToolTimelineEntry[] = [ + { id: 'r', name: 'web_search', round: 1, status: 'running', argsBuffer: '{"query":"f1"}' }, + ]; + renderInStore( + + ); + const resp = screen.getByTestId('agent-live-response'); + expect(resp.textContent).toContain('Response'); + expect(resp.textContent).toContain('Let me check your Notion for that audit file.'); + }); + + it('omits the Response block when there is no live response', () => { + const entries: ToolTimelineEntry[] = [ + { id: 'r', name: 'web_search', round: 1, status: 'running' }, + ]; + renderInStore(); + expect(screen.queryByTestId('agent-live-response')).toBeNull(); + }); + + it('strips a leaked envelope from the live response', () => { + const entries: ToolTimelineEntry[] = [ + { id: 'r', name: 'web_search', round: 1, status: 'running' }, + ]; + renderInStore( + {"name":"X"} '} + /> + ); + const resp = screen.getByTestId('agent-live-response'); + expect(resp.textContent).toContain('Searching now.'); + expect(resp.textContent).not.toContain('tool_call'); + }); }); describe('ToolTimelineBlock — subagent rendering', () => { diff --git a/app/src/utils/__tests__/toolTimelineFormatting.test.ts b/app/src/utils/__tests__/toolTimelineFormatting.test.ts index 253775402..045b18d8b 100644 --- a/app/src/utils/__tests__/toolTimelineFormatting.test.ts +++ b/app/src/utils/__tests__/toolTimelineFormatting.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest'; import type { ToolTimelineEntry } from '../../store/chatRuntimeSlice'; -import { formatTimelineEntry, formatToolName } from '../toolTimelineFormatting'; +import { + formatTimelineEntry, + formatToolName, + stripToolCallEnvelopes, +} from '../toolTimelineFormatting'; function entry(overrides: Partial): ToolTimelineEntry { return { id: 'x', name: 'delegate_notion', round: 1, status: 'running', ...overrides }; @@ -245,3 +249,25 @@ describe('formatToolName', () => { expect(formatToolName('custom_fancy_tool')).toBe('Custom Fancy Tool'); }); }); + +describe('stripToolCallEnvelopes', () => { + it('removes a complete envelope, keeping the prose', () => { + const out = stripToolCallEnvelopes( + 'Searching now. {"name": "NOTION_SEARCH", "arguments": {"q": "x"}} done.' + ); + expect(out).toContain('Searching now.'); + expect(out).toContain('done.'); + expect(out).not.toContain('tool_call'); + expect(out).not.toContain('NOTION_SEARCH'); + }); + + it('removes a trailing, still-streaming unclosed …', () => { + const out = stripToolCallEnvelopes('Let me check. {"name": "X", "argum'); + expect(out.trim()).toBe('Let me check.'); + expect(out).not.toContain('tool_call'); + }); + + it('leaves text without an envelope untouched', () => { + expect(stripToolCallEnvelopes('just a normal sentence')).toBe('just a normal sentence'); + }); +}); diff --git a/app/src/utils/toolTimelineFormatting.ts b/app/src/utils/toolTimelineFormatting.ts index 16061a8f0..93dd45de2 100644 --- a/app/src/utils/toolTimelineFormatting.ts +++ b/app/src/utils/toolTimelineFormatting.ts @@ -81,6 +81,20 @@ export function formatToolName(toolName: string): string { return TOOL_DISPLAY_NAMES[toolName] ?? humanizeIdentifier(toolName); } +/** + * Strip `` envelopes that some models emit inline in + * their visible / reasoning text. The structured call is already surfaced as + * its own timeline row, so the raw envelope is pure noise in displayed prose. + * Also removes a trailing, still-streaming unclosed `…` so a + * half-arrived delta never flashes raw markup. Whitespace is left intact — + * callers that render single-line previews collapse it themselves. + */ +export function stripToolCallEnvelopes(text: string): string { + return text + .replace(/]*>[\s\S]*?<\/tool_call>/gi, '') + .replace(/]*>[\s\S]*$/i, ''); +} + export function formatTimelineEntry(entry: ToolTimelineEntry): { title: string; detail?: string } { const parsedArgs = parseToolArgs(entry.argsBuffer);