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);