mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(chat): surface agent Thoughts inline + relocate parent narration into processing (#4019)
This commit is contained in:
@@ -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': 'المصادر',
|
||||
|
||||
@@ -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': 'উৎসসমূহ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'स्रोत',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '소스',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Источники',
|
||||
|
||||
@@ -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': '来源',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user