feat(chat): surface agent Thoughts inline + relocate parent narration into processing (#4019)

This commit is contained in:
Cyrus Gray
2026-06-23 15:42:59 -07:00
committed by GitHub
parent 8801a929db
commit f05b971fb2
20 changed files with 378 additions and 96 deletions
+2
View File
@@ -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': 'المصادر',
+2
View File
@@ -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': 'উৎসসমূহ',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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 doutils',
'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',
+2
View File
@@ -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': 'स्रोत',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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': '소스',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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': 'Источники',
+2
View File
@@ -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': '来源',
+17 -13
View File
@@ -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))) && (
<div className="flex justify-start">
<div className="relative w-fit max-w-[75%]">
{selectedStreamingAssistant.thinking.length > 0 && (
@@ -2148,17 +2149,19 @@ const Conversations = ({
</pre>
</details>
)}
{selectedStreamingAssistant.content.length > 0 && (
<div className="rounded-2xl rounded-bl-md px-3 py-1.5 bg-stone-200/80 dark:bg-neutral-800 text-stone-900 dark:text-neutral-100">
<p className="text-xs text-stone-700 dark:text-neutral-200 font-mono whitespace-pre-wrap break-words leading-snug">
{selectedStreamingAssistant.content.length > STREAMING_PREVIEW_CHARS && (
<span className="text-stone-400 dark:text-neutral-500"></span>
)}
{selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)}
<span className="inline-block w-1 h-3 ml-0.5 align-middle bg-primary-400 animate-pulse" />
</p>
</div>
)}
{selectedStreamingAssistant.content.length > 0 &&
(selectedThreadToolTimeline.length === 0 || hideAgentInsights) && (
<div className="rounded-2xl rounded-bl-md px-3 py-1.5 bg-stone-200/80 dark:bg-neutral-800 text-stone-900 dark:text-neutral-100">
<p className="text-xs text-stone-700 dark:text-neutral-200 font-mono whitespace-pre-wrap break-words leading-snug">
{selectedStreamingAssistant.content.length >
STREAMING_PREVIEW_CHARS && (
<span className="text-stone-400 dark:text-neutral-500"></span>
)}
{selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)}
<span className="inline-block w-1 h-3 ml-0.5 align-middle bg-primary-400 animate-pulse" />
</p>
</div>
)}
</div>
</div>
)}
@@ -2277,6 +2280,7 @@ const Conversations = ({
<ToolTimelineBlock
entries={selectedThreadToolTimeline}
onViewSubagent={sub => setOpenSubagentTaskId(sub.taskId)}
liveResponse={selectedStreamingAssistant?.content}
/>
))}
{/* "View full agent process" — only in the settled/inline state
@@ -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')}
</div>
<pre className="whitespace-pre-wrap break-words font-sans text-[12px] leading-relaxed text-stone-600 dark:text-neutral-300">
{item.text}
{stripToolCallEnvelopes(item.text).trim()}
</pre>
</div>
</ItemWrapper>
@@ -360,7 +361,7 @@ export function SubagentDrawer({
return (
<ItemWrapper key={`tx-${idx}`} divider={turnDivider}>
<div data-testid="subagent-transcript-text">
<BubbleMarkdown content={item.text} />
<BubbleMarkdown content={stripToolCallEnvelopes(item.text)} />
{isRunning && idx === lastTextIdx ? (
<span className="ml-0.5 inline-block h-3 w-1 animate-pulse bg-primary-400 align-middle" />
) : null}
@@ -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 `<details>` 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 (
<div className="flex items-center gap-1.5" data-testid="subagent-tool-call">
<span className={`text-[9px] ${tone}`}></span>
<span className="text-[10px] text-stone-700 dark:text-neutral-200">
{formatToolName(call.toolName)}
</span>
{call.iteration != null ? (
<span className="text-[9px] text-stone-400 dark:text-neutral-500">·t{call.iteration}</span>
) : null}
<span className={`text-[9px] ${tone}`}>{call.status}</span>
{call.elapsedMs != null && call.status !== 'running' ? (
<span className="text-[9px] text-stone-400 dark:text-neutral-500">
{call.elapsedMs >= 1000
? `${(call.elapsedMs / 1000).toFixed(1)}s`
: `${call.elapsedMs}ms`}
</span>
) : null}
</div>
);
}
/**
* 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 `<tool_call>…</tool_call>` 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 (
<details
open
data-testid="subagent-thought"
className="group/thought my-0.5 border-l-2 border-stone-200 pl-2 dark:border-neutral-700">
<summary className="flex cursor-pointer list-none items-center gap-1 select-none marker:hidden">
<span aria-hidden className="text-[9px] leading-none">
💭
</span>
<span className="text-[9px] font-semibold tracking-wide text-stone-400 uppercase dark:text-neutral-500">
{t('conversations.subagent.thoughts')}
</span>
<span className="text-[8px] text-stone-300 transition-transform group-open/thought:rotate-90 dark:text-neutral-600">
</span>
</summary>
<div className="mt-0.5 text-[10px] break-words text-stone-500 italic dark:text-neutral-400">
{clean}
</div>
</details>
);
}
/** 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 (
<details
open
data-testid="agent-live-response"
className="group/resp mt-1.5 border-l-2 border-primary-300 pl-2 dark:border-primary-500/50">
<summary className="flex cursor-pointer list-none items-center gap-1 select-none marker:hidden">
<span aria-hidden className="text-[9px] leading-none">
💬
</span>
<span className="text-[9px] font-semibold tracking-wide text-primary-500 uppercase dark:text-primary-300">
{t('conversations.agentTaskInsights.response')}
</span>
<span className="text-[8px] text-stone-300 transition-transform group-open/resp:rotate-90 dark:text-neutral-600">
</span>
</summary>
<p className="mt-0.5 text-[10px] leading-snug break-words whitespace-pre-wrap text-stone-600 dark:text-neutral-300">
{clean.length > RESPONSE_PREVIEW_CHARS ? (
<span className="text-stone-400 dark:text-neutral-500"></span>
) : null}
{shown}
<span className="ml-0.5 inline-block h-3 w-1 animate-pulse bg-primary-400 align-middle" />
</p>
</details>
);
}
/**
* 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 `<details>` 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 (
<div
@@ -107,48 +229,21 @@ export function SubagentActivityBlock({
))}
</div>
) : null}
{subagent.toolCalls.length > 0 ? (
<ul className="ml-1 space-y-0.5">
{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 (
<li
key={call.callId}
className="flex items-center gap-1.5"
data-testid="subagent-tool-call">
<span className={`text-[9px] ${tone}`}></span>
<span className="text-[10px] text-stone-700 dark:text-neutral-200">
{formatToolName(call.toolName)}
</span>
{call.iteration != null ? (
<span className="text-[9px] text-stone-400 dark:text-neutral-500">
·t{call.iteration}
</span>
) : null}
<span className={`text-[9px] ${tone}`}>{call.status}</span>
{call.elapsedMs != null && call.status !== 'running' ? (
<span className="text-[9px] text-stone-400 dark:text-neutral-500">
{call.elapsedMs >= 1000
? `${(call.elapsedMs / 1000).toFixed(1)}s`
: `${call.elapsedMs}ms`}
</span>
) : null}
</li>
);
})}
</ul>
) : null}
{preview ? (
<div
className="flex items-start gap-1 text-[10px] text-stone-500 dark:text-neutral-400"
data-testid="subagent-preview">
<span aria-hidden>{previewIcon}</span>
<span className="line-clamp-2 break-words italic">{preview}</span>
{transcript.length > 0 ? (
<div className="ml-1 space-y-0.5" data-testid="subagent-transcript">
{transcript.map((item, i) =>
item.kind === 'tool' ? (
<ToolCallRow key={item.callId} call={item} />
) : (
<ThoughtBlock key={`thought-${i}`} text={item.text} />
)
)}
</div>
) : subagent.toolCalls.length > 0 ? (
<div className="ml-1 space-y-0.5">
{subagent.toolCalls.map(call => (
<ToolCallRow key={call.callId} call={call} />
))}
</div>
) : 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({
);
})}
</div>
{liveResponse ? <LiveResponseBlock text={liveResponse} /> : null}
</details>
);
}
@@ -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(
<SubagentActivityBlock
subagent={{
@@ -104,26 +104,94 @@ describe('SubagentActivityBlock', () => {
}}
/>
);
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(
<SubagentActivityBlock
subagent={{
taskId: 't',
agentId: 'researcher',
toolCalls: [],
transcript: [{ kind: 'thinking', iteration: 1, text: 'I should search the web first' }],
transcript: [
{ kind: 'thinking', iteration: 1, text: 'I should search the web first' },
{ kind: 'tool', iteration: 1, callId: 'c1', toolName: 'web_search', status: 'success' },
{ kind: 'text', iteration: 1, text: 'Found three relevant results' },
],
}}
/>
);
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(
<SubagentActivityBlock
subagent={{
taskId: 't',
agentId: 'researcher',
toolCalls: [],
transcript: [{ kind: 'thinking', iteration: 1, text: 'weighing the options' }],
}}
/>
);
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 <tool_call> envelope from the thought text', () => {
renderInStore(
<SubagentActivityBlock
subagent={{
taskId: 't',
agentId: 'researcher',
toolCalls: [],
transcript: [
{
kind: 'text',
iteration: 1,
text: 'I\'ll search your Notion for that. <tool_call> {"name": "NOTION_SEARCH", "arguments": {"query": "audit"}} </tool_call>',
},
],
}}
/>
);
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(
<SubagentActivityBlock
subagent={{
taskId: 't',
agentId: 'researcher',
toolCalls: [],
transcript: [{ kind: 'thinking', iteration: 1, text: ' \n ' }],
}}
/>
);
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(<ToolTimelineBlock entries={[]} />);
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(
<ToolTimelineBlock
entries={entries}
liveResponse="Let me check your Notion for that audit file."
/>
);
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(<ToolTimelineBlock entries={entries} />);
expect(screen.queryByTestId('agent-live-response')).toBeNull();
});
it('strips a leaked <tool_call> envelope from the live response', () => {
const entries: ToolTimelineEntry[] = [
{ id: 'r', name: 'web_search', round: 1, status: 'running' },
];
renderInStore(
<ToolTimelineBlock
entries={entries}
liveResponse={'Searching now. <tool_call> {"name":"X"} </tool_call>'}
/>
);
const resp = screen.getByTestId('agent-live-response');
expect(resp.textContent).toContain('Searching now.');
expect(resp.textContent).not.toContain('tool_call');
});
});
describe('ToolTimelineBlock — subagent rendering', () => {
@@ -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>): 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 <tool_call>…</tool_call> envelope, keeping the prose', () => {
const out = stripToolCallEnvelopes(
'Searching now. <tool_call> {"name": "NOTION_SEARCH", "arguments": {"q": "x"}} </tool_call> 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 <tool_call>…', () => {
const out = stripToolCallEnvelopes('Let me check. <tool_call> {"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');
});
});
+14
View File
@@ -81,6 +81,20 @@ export function formatToolName(toolName: string): string {
return TOOL_DISPLAY_NAMES[toolName] ?? humanizeIdentifier(toolName);
}
/**
* Strip `<tool_call>…</tool_call>` 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 `<tool_call>…` 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(/<tool_call\b[^>]*>[\s\S]*?<\/tool_call>/gi, '')
.replace(/<tool_call\b[^>]*>[\s\S]*$/i, '');
}
export function formatTimelineEntry(entry: ToolTimelineEntry): { title: string; detail?: string } {
const parsedArgs = parseToolArgs(entry.argsBuffer);