Add execution graph to chat history (#776)

This commit is contained in:
Lingxuan Zuo
2026-04-07 01:37:06 +08:00
committed by GitHub
Unverified
parent 91c735c9f4
commit c866205eac
13 changed files with 1261 additions and 48 deletions

View File

@@ -17,6 +17,7 @@ import { extractText, extractThinking, extractImages, extractToolUse, formatTime
interface ChatMessageProps {
message: RawMessage;
showThinking: boolean;
suppressToolCards?: boolean;
isStreaming?: boolean;
streamingTools?: Array<{
id?: string;
@@ -40,6 +41,7 @@ function imageSrc(img: ExtractedImage): string | null {
export const ChatMessage = memo(function ChatMessage({
message,
showThinking,
suppressToolCards = false,
isStreaming = false,
streamingTools = [],
}: ChatMessageProps) {
@@ -52,7 +54,7 @@ export const ChatMessage = memo(function ChatMessage({
const images = extractImages(message);
const tools = extractToolUse(message);
const visibleThinking = showThinking ? thinking : null;
const visibleTools = tools;
const visibleTools = suppressToolCards ? [] : tools;
const attachedFiles = message._attachedFiles || [];
const [lightboxImg, setLightboxImg] = useState<{ src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string } | null>(null);

View File

@@ -0,0 +1,187 @@
import { useState } from 'react';
import { ArrowDown, ArrowUp, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleDashed, GitBranch, Sparkles, Wrench, XCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import type { TaskStep } from './task-visualization';
interface ExecutionGraphCardProps {
agentLabel: string;
sessionLabel: string;
steps: TaskStep[];
active: boolean;
onJumpToTrigger?: () => void;
onJumpToReply?: () => void;
}
function GraphStatusIcon({ status }: { status: TaskStep['status'] }) {
if (status === 'completed') return <CheckCircle2 className="h-4 w-4" />;
if (status === 'error') return <XCircle className="h-4 w-4" />;
return <CircleDashed className="h-4 w-4" />;
}
function StepDetailCard({ step }: { step: TaskStep }) {
const { t } = useTranslation('chat');
const [expanded, setExpanded] = useState(false);
const hasDetail = !!step.detail;
return (
<div className="min-w-0 flex-1 rounded-xl border border-black/10 bg-white/40 px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
<button
type="button"
className={cn('flex w-full items-start gap-2 text-left', hasDetail ? 'cursor-pointer' : 'cursor-default')}
onClick={() => {
if (!hasDetail) return;
setExpanded((value) => !value);
}}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground">{step.label}</p>
<span className="rounded-full bg-black/5 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground dark:bg-white/10">
{t(`taskPanel.stepStatus.${step.status}`)}
</span>
{step.depth > 1 && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
{t('executionGraph.branchLabel')}
</span>
)}
</div>
{step.detail && !expanded && (
<p className="mt-1 text-[12px] leading-5 text-muted-foreground line-clamp-2">{step.detail}</p>
)}
</div>
{hasDetail && (
<span className="mt-0.5 shrink-0 text-muted-foreground">
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</span>
)}
</button>
{step.detail && expanded && (
<div className="mt-3 rounded-lg border border-black/10 bg-black/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
<pre className="whitespace-pre-wrap break-all text-[12px] leading-5 text-muted-foreground">
{step.detail}
</pre>
</div>
)}
</div>
);
}
export function ExecutionGraphCard({
agentLabel,
sessionLabel,
steps,
active,
onJumpToTrigger,
onJumpToReply,
}: ExecutionGraphCardProps) {
const { t } = useTranslation('chat');
return (
<div
data-testid="chat-execution-graph"
className="w-full rounded-2xl border border-black/10 bg-[#f5f1e8]/70 px-4 py-4 shadow-sm dark:border-white/10 dark:bg-white/[0.04]"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground/70">
{t('executionGraph.eyebrow')}
</p>
<h3 className="mt-1 text-base font-semibold text-foreground">{t('executionGraph.title')}</h3>
<p className="mt-1 text-[12px] text-muted-foreground">
{agentLabel} · {sessionLabel}
</p>
</div>
<span
className={cn(
'rounded-full px-2.5 py-1 text-[11px] font-medium',
active ? 'bg-primary/10 text-primary' : 'bg-black/5 text-foreground/70 dark:bg-white/10 dark:text-foreground/70',
)}
>
{active ? t('executionGraph.status.active') : t('executionGraph.status.previous')}
</span>
</div>
<div className="mt-4 space-y-3">
<button
type="button"
data-testid="chat-execution-jump-trigger"
onClick={onJumpToTrigger}
className="flex items-center gap-2 text-[12px] text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowUp className="h-3.5 w-3.5" />
<span>{t('executionGraph.userTriggerHint')}</span>
</button>
<div className="pl-4">
<div className="ml-4 h-4 w-px bg-border" />
</div>
<div className="flex gap-3">
<div className="flex w-8 shrink-0 justify-center">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
<Bot className="h-4 w-4" />
</div>
</div>
<div className="min-w-0 flex-1 rounded-xl border border-primary/15 bg-primary/5 px-3 py-2">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<GitBranch className="h-4 w-4 text-primary" />
<span>{t('executionGraph.agentRun', { agent: agentLabel })}</span>
</div>
</div>
</div>
{steps.map((step, index) => (
<div key={step.id}>
<div
className="pl-4"
style={{ marginLeft: `${Math.max(step.depth - 1, 0) * 24}px` }}
>
<div className="ml-4 h-4 w-px bg-border" />
</div>
<div
className="flex gap-3"
data-testid="chat-execution-step"
style={{ marginLeft: `${Math.max(step.depth - 1, 0) * 24}px` }}
>
<div className="flex w-8 shrink-0 justify-center">
<div className="relative flex items-center justify-center">
{step.depth > 1 && (
<div className="absolute -left-4 top-1/2 h-px w-4 -translate-y-1/2 bg-border" />
)}
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full',
step.status === 'running' && 'bg-primary/10 text-primary',
step.status === 'completed' && 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
step.status === 'error' && 'bg-destructive/10 text-destructive',
)}
>
{step.kind === 'thinking' ? <Sparkles className="h-4 w-4" /> : step.kind === 'tool' ? <Wrench className="h-4 w-4" /> : <GraphStatusIcon status={step.status} />}
</div>
</div>
</div>
<StepDetailCard step={step} />
</div>
{index === steps.length - 1 && (
<>
<div className="pl-4">
<div className="ml-4 h-4 w-px bg-border" />
</div>
<button
type="button"
data-testid="chat-execution-jump-reply"
onClick={onJumpToReply}
className="flex items-center gap-2 pl-11 text-[12px] text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowDown className="h-3.5 w-3.5" />
<span>{t('executionGraph.agentReplyHint')}</span>
</button>
</>
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -9,11 +9,14 @@ import { AlertCircle, Loader2, Sparkles } from 'lucide-react';
import { useChatStore, type RawMessage } from '@/stores/chat';
import { useGatewayStore } from '@/stores/gateway';
import { useAgentsStore } from '@/stores/agents';
import { hostApiFetch } from '@/lib/host-api';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput';
import { ExecutionGraphCard } from './ExecutionGraphCard';
import { ChatToolbar } from './ChatToolbar';
import { extractImages, extractText, extractThinking, extractToolUse } from './message-utils';
import { deriveTaskSteps, parseSubagentCompletionInfo } from './task-visualization';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { useStickToBottomInstant } from '@/hooks/use-stick-to-bottom-instant';
@@ -26,6 +29,8 @@ export function Chat() {
const messages = useChatStore((s) => s.messages);
const currentSessionKey = useChatStore((s) => s.currentSessionKey);
const currentAgentId = useChatStore((s) => s.currentAgentId);
const sessionLabels = useChatStore((s) => s.sessionLabels);
const loading = useChatStore((s) => s.loading);
const sending = useChatStore((s) => s.sending);
const error = useChatStore((s) => s.error);
@@ -37,8 +42,10 @@ export function Chat() {
const abortRun = useChatStore((s) => s.abortRun);
const clearError = useChatStore((s) => s.clearError);
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
const agents = useAgentsStore((s) => s.agents);
const cleanupEmptySession = useChatStore((s) => s.cleanupEmptySession);
const [childTranscripts, setChildTranscripts] = useState<Record<string, RawMessage[]>>({});
const [streamingTimestamp, setStreamingTimestamp] = useState<number>(0);
const minLoading = useMinLoading(loading && messages.length > 0);
@@ -61,6 +68,55 @@ export function Chat() {
void fetchAgents();
}, [fetchAgents]);
useEffect(() => {
const completions = messages
.map((message) => parseSubagentCompletionInfo(message))
.filter((value): value is NonNullable<typeof value> => value != null);
const missing = completions.filter((completion) => !childTranscripts[completion.sessionId]);
if (missing.length === 0) return;
let cancelled = false;
void Promise.all(
missing.map(async (completion) => {
try {
const result = await hostApiFetch<{ success: boolean; messages?: RawMessage[] }>(
`/api/sessions/transcript?agentId=${encodeURIComponent(completion.agentId)}&sessionId=${encodeURIComponent(completion.sessionId)}`,
);
if (!result.success) {
console.warn('Failed to load child transcript:', {
agentId: completion.agentId,
sessionId: completion.sessionId,
result,
});
return null;
}
return { sessionId: completion.sessionId, messages: result.messages || [] };
} catch (error) {
console.warn('Failed to load child transcript:', {
agentId: completion.agentId,
sessionId: completion.sessionId,
error,
});
return null;
}
}),
).then((results) => {
if (cancelled) return;
setChildTranscripts((current) => {
const next = { ...current };
for (const result of results) {
if (!result) continue;
next[result.sessionId] = result.messages;
}
return next;
});
});
return () => {
cancelled = true;
};
}, [messages, childTranscripts]);
// Update timestamp when sending starts
useEffect(() => {
if (sending && streamingTimestamp === 0) {
@@ -89,61 +145,182 @@ export function Chat() {
const hasAnyStreamContent = hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus;
const isEmpty = messages.length === 0 && !sending;
const subagentCompletionInfos = messages.map((message) => parseSubagentCompletionInfo(message));
const nextUserMessageIndexes = new Array<number>(messages.length).fill(-1);
let nextUserMessageIndex = -1;
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
nextUserMessageIndexes[idx] = nextUserMessageIndex;
if (messages[idx].role === 'user' && !subagentCompletionInfos[idx]) {
nextUserMessageIndex = idx;
}
}
const userRunCards = messages.flatMap((message, idx) => {
if (message.role !== 'user' || subagentCompletionInfos[idx]) return [];
const nextUserIndex = nextUserMessageIndexes[idx];
const segmentEnd = nextUserIndex === -1 ? messages.length : nextUserIndex;
const segmentMessages = messages.slice(idx + 1, segmentEnd);
const replyIndexOffset = segmentMessages.findIndex((candidate) => candidate.role === 'assistant');
const replyIndex = replyIndexOffset === -1 ? null : idx + 1 + replyIndexOffset;
const completionInfos = subagentCompletionInfos
.slice(idx + 1, segmentEnd)
.filter((value): value is NonNullable<typeof value> => value != null);
const isLatestOpenRun = nextUserIndex === -1 && (sending || pendingFinal || hasAnyStreamContent);
let steps = deriveTaskSteps({
messages: segmentMessages,
streamingMessage: isLatestOpenRun ? streamingMessage : null,
streamingTools: isLatestOpenRun ? streamingTools : [],
sending: isLatestOpenRun ? sending : false,
pendingFinal: isLatestOpenRun ? pendingFinal : false,
showThinking,
});
for (const completion of completionInfos) {
const childMessages = childTranscripts[completion.sessionId];
if (!childMessages || childMessages.length === 0) continue;
const branchRootId = `subagent:${completion.sessionId}`;
const childSteps = deriveTaskSteps({
messages: childMessages,
streamingMessage: null,
streamingTools: [],
sending: false,
pendingFinal: false,
showThinking,
}).map((step) => ({
...step,
id: `${completion.sessionId}:${step.id}`,
depth: step.depth + 1,
parentId: branchRootId,
}));
steps = [
...steps,
{
id: branchRootId,
label: `${completion.agentId} subagent`,
status: 'completed',
kind: 'system' as const,
detail: completion.sessionKey,
depth: 1,
parentId: 'agent-run',
},
...childSteps,
];
}
if (steps.length === 0) return [];
const segmentAgentId = currentAgentId;
const segmentAgentLabel = agents.find((agent) => agent.id === segmentAgentId)?.name || segmentAgentId;
const segmentSessionLabel = sessionLabels[currentSessionKey] || currentSessionKey;
return [{
triggerIndex: idx,
replyIndex,
active: isLatestOpenRun,
agentLabel: segmentAgentLabel,
sessionLabel: segmentSessionLabel,
segmentEnd: replyIndex ?? (nextUserIndex === -1 ? messages.length - 1 : nextUserIndex - 1),
steps,
}];
});
return (
<div className={cn("relative flex flex-col -m-6 transition-colors duration-500 dark:bg-background")} style={{ height: 'calc(100vh - 2.5rem)' }}>
<div className={cn("relative flex min-h-0 flex-col -m-6 transition-colors duration-500 dark:bg-background")} style={{ height: 'calc(100vh - 2.5rem)' }}>
{/* Toolbar */}
<div className="flex shrink-0 items-center justify-end px-4 py-2">
<ChatToolbar />
</div>
{/* Messages Area */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
<div ref={contentRef} className="max-w-4xl mx-auto space-y-4">
{isEmpty ? (
<WelcomeScreen />
) : (
<>
{messages.map((msg, idx) => (
<ChatMessage
key={msg.id || `msg-${idx}`}
message={msg}
showThinking={showThinking}
/>
))}
<div className="min-h-0 flex-1 overflow-hidden px-4 py-4">
<div className="mx-auto flex h-full min-h-0 max-w-6xl flex-col gap-4 lg:flex-row lg:items-stretch">
<div ref={scrollRef} className="min-h-0 min-w-0 flex-1 overflow-y-auto">
<div ref={contentRef} className="max-w-4xl space-y-4">
{isEmpty ? (
<WelcomeScreen />
) : (
<>
{messages.map((msg, idx) => {
const suppressToolCards = userRunCards.some((card) =>
idx > card.triggerIndex && idx <= card.segmentEnd,
);
return (
<div
key={msg.id || `msg-${idx}`}
className="space-y-3"
id={`chat-message-${idx}`}
data-testid={`chat-message-${idx}`}
>
<ChatMessage
message={msg}
showThinking={showThinking}
suppressToolCards={suppressToolCards}
/>
{userRunCards
.filter((card) => card.triggerIndex === idx)
.map((card) => (
<ExecutionGraphCard
key={`graph-${idx}`}
agentLabel={card.agentLabel}
sessionLabel={card.sessionLabel}
steps={card.steps}
active={card.active}
onJumpToTrigger={() => {
document.getElementById(`chat-message-${card.triggerIndex}`)?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}}
onJumpToReply={() => {
if (card.replyIndex == null) return;
document.getElementById(`chat-message-${card.replyIndex}`)?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}}
/>
))}
</div>
);
})}
{/* Streaming message */}
{shouldRenderStreaming && (
<ChatMessage
message={(streamMsg
? {
...(streamMsg as Record<string, unknown>),
role: (typeof streamMsg.role === 'string' ? streamMsg.role : 'assistant') as RawMessage['role'],
content: streamMsg.content ?? streamText,
timestamp: streamMsg.timestamp ?? streamingTimestamp,
}
: {
role: 'assistant',
content: streamText,
timestamp: streamingTimestamp,
}) as RawMessage}
showThinking={showThinking}
isStreaming
streamingTools={streamingTools}
/>
)}
{/* Streaming message */}
{shouldRenderStreaming && (
<ChatMessage
message={(streamMsg
? {
...(streamMsg as Record<string, unknown>),
role: (typeof streamMsg.role === 'string' ? streamMsg.role : 'assistant') as RawMessage['role'],
content: streamMsg.content ?? streamText,
timestamp: streamMsg.timestamp ?? streamingTimestamp,
}
: {
role: 'assistant',
content: streamText,
timestamp: streamingTimestamp,
}) as RawMessage}
showThinking={showThinking}
isStreaming
streamingTools={streamingTools}
/>
)}
{/* Activity indicator: waiting for next AI turn after tool execution */}
{sending && pendingFinal && !shouldRenderStreaming && (
<ActivityIndicator phase="tool_processing" />
)}
{/* Activity indicator: waiting for next AI turn after tool execution */}
{sending && pendingFinal && !shouldRenderStreaming && (
<ActivityIndicator phase="tool_processing" />
)}
{/* Typing indicator when sending but no stream content yet */}
{sending && !pendingFinal && !hasAnyStreamContent && (
<TypingIndicator />
{/* Typing indicator when sending but no stream content yet */}
{sending && !pendingFinal && !hasAnyStreamContent && (
<TypingIndicator />
)}
</>
)}
</>
)}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,278 @@
import { extractThinking, extractToolUse } from './message-utils';
import type { RawMessage, ToolStatus } from '@/stores/chat';
export type TaskStepStatus = 'running' | 'completed' | 'error';
export interface TaskStep {
id: string;
label: string;
status: TaskStepStatus;
kind: 'thinking' | 'tool' | 'system';
detail?: string;
depth: number;
parentId?: string;
}
const MAX_TASK_STEPS = 8;
interface DeriveTaskStepsInput {
messages: RawMessage[];
streamingMessage: unknown | null;
streamingTools: ToolStatus[];
sending: boolean;
pendingFinal: boolean;
showThinking: boolean;
}
export interface SubagentCompletionInfo {
sessionKey: string;
sessionId: string;
agentId: string;
}
function normalizeText(text: string | null | undefined): string | undefined {
if (!text) return undefined;
const normalized = text.replace(/\s+/g, ' ').trim();
if (!normalized) return undefined;
return normalized;
}
function makeToolId(prefix: string, name: string, index: number): string {
return `${prefix}:${name}:${index}`;
}
export function parseAgentIdFromSessionKey(sessionKey: string): string | null {
const parts = sessionKey.split(':');
if (parts.length < 2 || parts[0] !== 'agent') return null;
return parts[1] || null;
}
export function parseSubagentCompletionInfo(message: RawMessage): SubagentCompletionInfo | null {
const text = typeof message.content === 'string'
? message.content
: Array.isArray(message.content)
? message.content.map((block) => ('text' in block && typeof block.text === 'string' ? block.text : '')).join('\n')
: '';
if (!text.includes('[Internal task completion event]')) return null;
const sessionKeyMatch = text.match(/session_key:\s*(.+)/);
const sessionIdMatch = text.match(/session_id:\s*(.+)/);
const sessionKey = sessionKeyMatch?.[1]?.trim();
const sessionId = sessionIdMatch?.[1]?.trim();
if (!sessionKey || !sessionId) return null;
const agentId = parseAgentIdFromSessionKey(sessionKey);
if (!agentId) return null;
return { sessionKey, sessionId, agentId };
}
function isSpawnLikeStep(label: string): boolean {
return /(spawn|subagent|delegate|parallel)/i.test(label);
}
function tryParseJsonObject(detail: string | undefined): Record<string, unknown> | null {
if (!detail) return null;
try {
const parsed = JSON.parse(detail) as unknown;
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null;
} catch {
return null;
}
}
function extractBranchAgent(step: TaskStep): string | null {
const parsed = tryParseJsonObject(step.detail);
const agentId = parsed?.agentId;
if (typeof agentId === 'string' && agentId.trim()) return agentId.trim();
const message = typeof parsed?.message === 'string' ? parsed.message : step.detail;
if (!message) return null;
const match = message.match(/\b(coder|reviewer|project-manager|manager|planner|researcher|worker|subagent)\b/i);
return match ? match[1] : null;
}
function attachTopology(steps: TaskStep[]): TaskStep[] {
const withTopology: TaskStep[] = [];
let activeBranchNodeId: string | null = null;
for (const step of steps) {
if (step.kind === 'system') {
activeBranchNodeId = null;
withTopology.push({ ...step, depth: 1, parentId: 'agent-run' });
continue;
}
if (/sessions_spawn/i.test(step.label)) {
const branchAgent = extractBranchAgent(step) || 'subagent';
const branchNodeId = `${step.id}:branch`;
withTopology.push({ ...step, depth: 1, parentId: 'agent-run' });
withTopology.push({
id: branchNodeId,
label: `${branchAgent} run`,
status: step.status,
kind: 'system',
detail: `Spawned branch for ${branchAgent}`,
depth: 2,
parentId: step.id,
});
activeBranchNodeId = branchNodeId;
continue;
}
if (/sessions_yield/i.test(step.label)) {
withTopology.push({
...step,
depth: activeBranchNodeId ? 3 : 1,
parentId: activeBranchNodeId ?? 'agent-run',
});
activeBranchNodeId = null;
continue;
}
if (step.kind === 'thinking') {
withTopology.push({
...step,
depth: activeBranchNodeId ? 3 : 1,
parentId: activeBranchNodeId ?? 'agent-run',
});
continue;
}
if (isSpawnLikeStep(step.label)) {
activeBranchNodeId = step.id;
withTopology.push({
...step,
depth: 1,
parentId: 'agent-run',
});
continue;
}
withTopology.push({
...step,
depth: activeBranchNodeId ? 3 : 1,
parentId: activeBranchNodeId ?? 'agent-run',
});
}
return withTopology;
}
export function deriveTaskSteps({
messages,
streamingMessage,
streamingTools,
sending,
pendingFinal,
showThinking,
}: DeriveTaskStepsInput): TaskStep[] {
const steps: TaskStep[] = [];
const seenIds = new Set<string>();
const activeToolNames = new Set<string>();
const pushStep = (step: TaskStep): void => {
if (seenIds.has(step.id)) return;
seenIds.add(step.id);
steps.push(step);
};
const streamMessage = streamingMessage && typeof streamingMessage === 'object'
? streamingMessage as RawMessage
: null;
if (streamMessage && showThinking) {
const thinking = extractThinking(streamMessage);
if (thinking) {
pushStep({
id: 'stream-thinking',
label: 'Thinking',
status: 'running',
kind: 'thinking',
detail: normalizeText(thinking),
depth: 1,
});
}
}
streamingTools.forEach((tool, index) => {
activeToolNames.add(tool.name);
pushStep({
id: tool.toolCallId || tool.id || makeToolId('stream-status', tool.name, index),
label: tool.name,
status: tool.status,
kind: 'tool',
detail: normalizeText(tool.summary),
depth: 1,
});
});
if (streamMessage) {
extractToolUse(streamMessage).forEach((tool, index) => {
if (activeToolNames.has(tool.name)) return;
pushStep({
id: tool.id || makeToolId('stream-tool', tool.name, index),
label: tool.name,
status: 'running',
kind: 'tool',
detail: normalizeText(JSON.stringify(tool.input, null, 2)),
depth: 1,
});
});
}
if (sending && pendingFinal) {
pushStep({
id: 'system-finalizing',
label: 'Finalizing answer',
status: 'running',
kind: 'system',
detail: 'Waiting for the assistant to finish this run.',
depth: 1,
});
} else if (sending && steps.length === 0) {
pushStep({
id: 'system-preparing',
label: 'Preparing run',
status: 'running',
kind: 'system',
detail: 'Waiting for the first streaming update.',
depth: 1,
});
}
if (steps.length === 0) {
const relevantAssistantMessages = messages.filter((message) => {
if (!message || message.role !== 'assistant') return false;
if (extractToolUse(message).length > 0) return true;
return showThinking && !!extractThinking(message);
});
for (const [messageIndex, assistantMessage] of relevantAssistantMessages.entries()) {
if (showThinking) {
const thinking = extractThinking(assistantMessage);
if (thinking) {
pushStep({
id: `history-thinking-${assistantMessage.id || messageIndex}`,
label: 'Thinking',
status: 'completed',
kind: 'thinking',
detail: normalizeText(thinking),
depth: 1,
});
}
}
extractToolUse(assistantMessage).forEach((tool, index) => {
pushStep({
id: tool.id || makeToolId(`history-tool-${assistantMessage.id || messageIndex}`, tool.name, index),
label: tool.name,
status: 'completed',
kind: 'tool',
detail: normalizeText(JSON.stringify(tool.input, null, 2)),
depth: 1,
});
});
}
}
return attachTopology(steps).slice(0, MAX_TASK_STEPS);
}