import { useCallback, useEffect, useRef, useState } from "react"; import { useClient } from "@/providers/ClientProvider"; import { toMediaAttachment } from "@/lib/media"; import { mergeToolProgressEvents, mergeUniqueToolTraceLines, normalizeToolProgressEvents, toolTraceLinesFromEvents, } from "@/lib/tool-traces"; import type { StreamError } from "@/lib/nanobot-client"; import type { InboundEvent, OutboundCliAppMention, OutboundImageGeneration, OutboundMcpPresetMention, OutboundMedia, GoalStateWsPayload, ToolProgressEvent, UIImage, UIFileEdit, UIMessage, UITurnPhase, WorkspaceScopePayload, } from "@/lib/types"; interface StreamBuffer { /** ID of the assistant message currently receiving deltas (cleared on ``stream_end``). */ messageId: string; } interface ActiveAssistantCursor { id: string; index: number; } type PendingStreamEvent = | { kind: "delta"; text: string; turn: UIMessageTurnFields } | { kind: "reasoning"; text: string; turn: UIMessageTurnFields }; type UIMessageTurnFields = Pick; const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]); function turnFieldsFromEvent( ev: { turn_id?: string; turn_phase?: UITurnPhase; turn_seq?: number }, fallbackPhase?: UITurnPhase, ): UIMessageTurnFields { const fields: UIMessageTurnFields = {}; if (typeof ev.turn_id === "string" && ev.turn_id.length > 0) { fields.turnId = ev.turn_id; } const phase = ev.turn_phase ?? fallbackPhase; if (phase) fields.turnPhase = phase; if (typeof ev.turn_seq === "number" && Number.isFinite(ev.turn_seq)) { fields.turnSeq = ev.turn_seq; } return fields; } function matchesTurn(message: UIMessage, turn: UIMessageTurnFields): boolean { return !turn.turnId || !message.turnId || message.turnId === turn.turnId; } /** Find a still-open streamed assistant turn. Closed stream segments stay visible * as streaming until ``turn_end`` for visual continuity, but they must not * receive later delta segments. */ function findStreamingAssistantIndex( prev: UIMessage[], closedStreamIds: ReadonlySet, turn: UIMessageTurnFields = {}, ): number | null { for (let i = prev.length - 1; i >= 0; i -= 1) { const m = prev[i]; if (m.kind === "trace") continue; if ( m.role === "assistant" && m.isStreaming && !closedStreamIds.has(m.id) && matchesTurn(m, turn) ) return i; if (m.role === "user") break; } return null; } /** * Append a reasoning chunk to the last open reasoning stream in ``prev``. * * Lookup rule: reasoning can only extend the current reasoning placeholder. * Once ordinary answer text has appeared, the next reasoning chunk starts a * fresh Thought block so streamed output stays in arrival order: * Thought -> answer -> Thought -> answer. */ function attachReasoningChunk( prev: UIMessage[], chunk: string, segments?: { ensure: () => string; }, turn: UIMessageTurnFields = {}, ): UIMessage[] { for (let i = prev.length - 1; i >= 0; i -= 1) { const candidate = prev[i]; // A user turn is a hard boundary: reasoning after it belongs to the new // assistant turn, never to an earlier assistant reply. if (candidate.role === "user") break; // A trace row (e.g. Used tools) is also a phase boundary. Reasoning after // tools belongs to the next assistant iteration, not the assistant turn // that produced those tool calls. if (candidate.kind === "trace") break; if (candidate.role !== "assistant") continue; if (!matchesTurn(candidate, turn)) break; const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure(); const hasAnswer = candidate.content.length > 0; if (hasAnswer) break; if ( candidate.reasoningStreaming || candidate.reasoning !== undefined || candidate.isStreaming ) { const merged: UIMessage = { ...candidate, reasoning: (candidate.reasoning ?? "") + chunk, reasoningStreaming: true, ...(activitySegmentId ? { activitySegmentId } : {}), ...turn, }; return [...prev.slice(0, i), merged, ...prev.slice(i + 1)]; } break; } const activitySegmentId = segments?.ensure(); return [ ...prev, { id: crypto.randomUUID(), role: "assistant", content: "", isStreaming: true, reasoning: chunk, reasoningStreaming: true, ...(activitySegmentId ? { activitySegmentId } : {}), ...turn, createdAt: Date.now(), }, ]; } /** * Find the most recent assistant placeholder that an incoming answer * delta should adopt instead of spawning a parallel row. We look for an * empty-content assistant turn that is still marked ``isStreaming`` — * typically created earlier by ``reasoning_delta``. Anything else means * the model already produced an answer in a previous turn, so the new * delta belongs in a fresh row. */ function findActiveAssistantPlaceholderIndex( prev: UIMessage[], turn: UIMessageTurnFields = {}, ): number | null { const last = prev[prev.length - 1]; if (!last) return null; if (last.role !== "assistant" || last.kind === "trace") return null; if (last.content.length > 0) return null; if (!last.isStreaming) return null; if (!matchesTurn(last, turn)) return null; return prev.length - 1; } function replaceMessageAt(prev: UIMessage[], index: number, message: UIMessage): UIMessage[] { const next = prev.slice(); next[index] = message; return next; } /** * Close the active reasoning stream segment, if any. Idempotent: a * ``reasoning_end`` with no preceding deltas is a harmless no-op. */ function closeReasoningStream(prev: UIMessage[]): UIMessage[] { for (let i = prev.length - 1; i >= 0; i -= 1) { const candidate = prev[i]; if (!candidate.reasoningStreaming) continue; const latencyMs = candidate.latencyMs === undefined && Number.isFinite(candidate.createdAt) && candidate.createdAt > 1_000_000_000_000 ? Math.max(0, Math.round(Date.now() - candidate.createdAt)) : candidate.latencyMs; const merged: UIMessage = { ...candidate, reasoningStreaming: false, ...(latencyMs !== undefined ? { latencyMs } : {}), }; return [...prev.slice(0, i), merged, ...prev.slice(i + 1)]; } return prev; } function isReasoningOnlyPlaceholder(message: UIMessage): boolean { return ( message.role === "assistant" && message.kind !== "trace" && message.content.trim().length === 0 && !!message.reasoning && !message.reasoningStreaming && !message.media?.length ); } function isToolTrace(message: UIMessage | undefined): boolean { return message?.kind === "trace"; } function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] { return prev.filter((message, index) => { if (!isReasoningOnlyPlaceholder(message)) return true; // A reasoning-only assistant row immediately followed by tool traces is // the live equivalent of a persisted assistant tool-call message with // empty content, reasoning_content, and tool_calls. Keep it so live render // and history replay stay isomorphic. return isToolTrace(prev[index + 1]); }); } function stampLastAssistantLatency( prev: UIMessage[], latencyMs: number, turnId?: string, ): UIMessage[] { for (let i = prev.length - 1; i >= 0; i -= 1) { const m = prev[i]; if ( m.role === "assistant" && m.kind !== "trace" && (!turnId || !m.turnId || m.turnId === turnId) ) { const merged: UIMessage = { ...m, latencyMs, isStreaming: false }; return [...prev.slice(0, i), merged, ...prev.slice(i + 1)]; } } return prev; } function absorbCompleteAssistantMessage( prev: UIMessage[], message: Omit, ): UIMessage[] { const last = prev[prev.length - 1]; if (!last || !isReasoningOnlyPlaceholder(last) || !matchesTurn(last, message)) { return [ ...prev, { id: crypto.randomUUID(), role: "assistant", createdAt: Date.now(), ...message, }, ]; } return [ ...prev.slice(0, -1), { ...last, ...message, isStreaming: false, reasoningStreaming: false, }, ]; } function fileEditKey(edit: Pick): string { if (edit.call_id) return `${edit.call_id}|${edit.tool}`; return `${edit.tool}|${edit.path}`; } function toolEventFileEditKey(event: ToolProgressEvent): string | null { const fn = (event as { function?: { name?: unknown } }).function; const name = typeof event.name === "string" ? event.name : typeof fn?.name === "string" ? fn.name : ""; const callId = typeof event.call_id === "string" ? event.call_id : ""; if (!name || !callId || !FILE_EDIT_TOOL_NAMES.has(name)) return null; return `${callId}|${name}`; } function hasFileEditForToolEvent(messages: UIMessage[], event: ToolProgressEvent): boolean { const key = toolEventFileEditKey(event); if (!key) return false; return messages.some((message) => message.fileEdits?.some((edit) => fileEditKey(edit) === key), ); } function filterCoveredFileEditToolEvents( messages: UIMessage[], events: ToolProgressEvent[], ): ToolProgressEvent[] { if (events.length === 0) return events; return events.filter((event) => !hasFileEditForToolEvent(messages, event)); } function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]): UIMessage { const incomingKeys = new Set(edits.map(fileEditKey)); const events = message.toolEvents ?? []; if (!events.length || incomingKeys.size === 0) return message; const removedTraceLines = new Set(); const keptEvents: ToolProgressEvent[] = []; let changed = false; for (const event of events) { const key = toolEventFileEditKey(event); if (key && incomingKeys.has(key)) { changed = true; for (const line of toolTraceLinesFromEvents([event])) { removedTraceLines.add(line); } continue; } keptEvents.push(event); } if (!changed) return message; const previousTraces = message.traces?.length ? message.traces : message.content ? [message.content] : []; const nextTraces = previousTraces.filter((line) => !removedTraceLines.has(line)); return { ...message, traces: nextTraces, content: nextTraces[nextTraces.length - 1] ?? "", toolEvents: keptEvents.length ? keptEvents : undefined, }; } function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null { if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null; const inferredStatus = edit.phase === "error" ? "error" : edit.phase === "end" ? "done" : "editing"; const normalized: UIFileEdit = { ...edit, call_id: edit.call_id || `${edit.tool}:${edit.path}`, added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0, deleted: Number.isFinite(edit.deleted) ? Math.max(0, Math.round(edit.deleted)) : 0, status: edit.status === "error" || edit.status === "done" || edit.status === "editing" ? edit.status : inferredStatus, }; if (edit.pending && !edit.path) normalized.pending = true; return normalized; } function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] { const next = [...(existing ?? [])]; const indexByKey = new Map(next.map((edit, index) => [fileEditKey(edit), index])); for (const raw of incoming) { const edit = normalizeFileEdit(raw); if (!edit) continue; const key = fileEditKey(edit); const existingIndex = indexByKey.get(key); if (existingIndex === undefined) { indexByKey.set(key, next.length); next.push(edit); continue; } const merged = { ...next[existingIndex], ...edit }; if (edit.path && !edit.pending) delete merged.pending; next[existingIndex] = merged; } return next; } function findFileEditTraceIndex( prev: UIMessage[], segmentId: string | null, incoming: UIFileEdit[], ): number | null { const incomingKeys = new Set(incoming.map(fileEditKey)); for (let i = prev.length - 1; i >= 0; i -= 1) { const candidate = prev[i]; if (candidate.role === "user") break; if (candidate.kind !== "trace") continue; if (segmentId && candidate.activitySegmentId === segmentId) return i; for (const existing of candidate.fileEdits ?? []) { if (incomingKeys.has(fileEditKey(existing))) return i; } for (const event of candidate.toolEvents ?? []) { const key = toolEventFileEditKey(event); if (key && incomingKeys.has(key)) return i; } } return null; } /** * Subscribe to a chat by ID. Returns the in-memory message list for the chat, * a streaming flag, and a ``send`` function. Initial history must be seeded * separately (e.g. via ``fetchWebuiThread``) since the server only replays * live events. */ /** Payload passed to ``send`` when the user attaches one or more images. * * ``media`` is handed to the wire client verbatim; ``preview`` powers the * optimistic user bubble (blob URLs so the preview appears before the server * acks the frame). Keeping the two separate lets the bubble re-use the local * blob URL even after the server persists the file under a different name. */ export interface SendImage { media: OutboundMedia; preview: UIImage; } export interface SendOptions { imageGeneration?: OutboundImageGeneration; cliApps?: OutboundCliAppMention[]; mcpPresets?: OutboundMcpPresetMention[]; workspaceScope?: WorkspaceScopePayload | null; } export function useNanobotStream( chatId: string | null, initialMessages: UIMessage[] = [], hasPendingToolCalls = false, onTurnEnd?: () => void, ): { messages: UIMessage[]; isStreaming: boolean; /** Unix epoch seconds when the current user turn started (WebSocket ``goal_status``). */ runStartedAt: number | null; /** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */ goalState: GoalStateWsPayload | undefined; send: (content: string, images?: SendImage[], options?: SendOptions) => void; stop: () => void; setMessages: React.Dispatch>; /** Latest transport-level fault raised since the last ``dismissStreamError``. * ``null`` when there is nothing to show. */ streamError: StreamError | null; /** Clear the current ``streamError`` (e.g. after the user dismisses the * notification or starts a fresh action). */ dismissStreamError: () => void; } { const { client } = useClient(); const [messages, setMessages] = useState(initialMessages); /** If the last loaded message is a trace row (e.g. "Using 2 tools"), * the model was still processing when the page loaded — keep the * loading spinner alive so the user sees the model is active. */ const initialStreaming = initialMessages.length > 0 ? initialMessages[initialMessages.length - 1].kind === "trace" : false; const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls); /** Unix epoch seconds when the current user turn started; cleared on ``idle``. */ const [runStartedAt, setRunStartedAt] = useState(null); const [goalState, setGoalState] = useState(undefined); const [streamError, setStreamError] = useState(null); const buffer = useRef(null); const activeAssistantRef = useRef(null); const closedAssistantStreamIdsRef = useRef>(new Set()); const activitySegmentRef = useRef(null); const fileEditSegmentRef = useRef(null); const activitySegmentCounterRef = useRef(0); const pendingStreamEventsRef = useRef([]); const streamFrameRef = useRef(null); const suppressStreamUntilTurnEndRef = useRef(false); /** Timer that defers ``isStreaming = false`` after ``stream_end``. * * When the model finishes a text segment and calls a tool, the server * sends ``stream_end`` but the agent is still "thinking" while the tool * executes. By deferring the flag reset by a short window (1 s) we keep * the loading spinner alive across tool-call boundaries without needing * backend changes. */ const streamEndTimerRef = useRef | null>(null); useEffect(() => { return client.onError((err) => setStreamError(err)); }, [client]); const dismissStreamError = useCallback(() => setStreamError(null), []); const clearPendingStreamWork = useCallback(() => { if (streamFrameRef.current !== null) { window.cancelAnimationFrame(streamFrameRef.current); streamFrameRef.current = null; } pendingStreamEventsRef.current = []; }, []); const createActivitySegmentId = useCallback((activate = true) => { activitySegmentCounterRef.current += 1; const id = `activity-${activitySegmentCounterRef.current}`; if (activate) activitySegmentRef.current = id; return id; }, []); const freshActivitySegmentId = useCallback( () => createActivitySegmentId(true), [createActivitySegmentId], ); const detachedActivitySegmentId = useCallback( () => createActivitySegmentId(false), [createActivitySegmentId], ); const ensureActivitySegmentId = useCallback(() => { if (activitySegmentRef.current) return activitySegmentRef.current; return freshActivitySegmentId(); }, [freshActivitySegmentId]); const clearActivitySegment = useCallback(() => { activitySegmentRef.current = null; fileEditSegmentRef.current = null; }, []); const closeActiveAssistantStream = useCallback(() => { const closedStreamId = buffer.current?.messageId ?? activeAssistantRef.current?.id; if (closedStreamId) closedAssistantStreamIdsRef.current.add(closedStreamId); buffer.current = null; activeAssistantRef.current = null; return !!closedStreamId; }, []); const resolveActiveAssistantIndex = useCallback(( prev: UIMessage[], turn: UIMessageTurnFields = {}, ): number | null => { const cursor = activeAssistantRef.current; if (!cursor) return null; const indexed = prev[cursor.index]; if ( indexed?.id === cursor.id && indexed.role === "assistant" && indexed.kind !== "trace" && indexed.isStreaming && matchesTurn(indexed, turn) ) { return cursor.index; } const idx = prev.findIndex((m) => m.id === cursor.id); if (idx === -1) { activeAssistantRef.current = null; return null; } const found = prev[idx]; if ( found.role !== "assistant" || found.kind === "trace" || !found.isStreaming || !matchesTurn(found, turn) ) { activeAssistantRef.current = null; return null; } activeAssistantRef.current = { id: cursor.id, index: idx }; return idx; }, []); const appendAnswerChunk = useCallback( (prev: UIMessage[], chunk: string, turn: UIMessageTurnFields = {}): UIMessage[] => { let next = prev; let targetIndex = resolveActiveAssistantIndex(next, turn); if (targetIndex === null) { targetIndex = findActiveAssistantPlaceholderIndex(next, turn); } if (targetIndex === null) { targetIndex = findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current, turn); } if (targetIndex === null) { const id = crypto.randomUUID(); next = [ ...next, { id, role: "assistant", content: "", isStreaming: true, createdAt: Date.now(), }, ]; targetIndex = next.length - 1; } const target = next[targetIndex]; const merged: UIMessage = { ...target, content: target.content + chunk, isStreaming: true, ...turn, }; closedAssistantStreamIdsRef.current.delete(merged.id); activeAssistantRef.current = { id: merged.id, index: targetIndex }; buffer.current = { messageId: merged.id }; return replaceMessageAt(next, targetIndex, merged); }, [resolveActiveAssistantIndex], ); const applyPendingStreamEvents = useCallback( (prev: UIMessage[], events: PendingStreamEvent[]): UIMessage[] => { let next = prev; for (const event of events) { if (event.kind === "delta") { next = appendAnswerChunk(next, event.text, event.turn); } else { if (closeActiveAssistantStream()) clearActivitySegment(); next = attachReasoningChunk( next, event.text, { ensure: ensureActivitySegmentId }, event.turn, ); } } return next; }, [appendAnswerChunk, clearActivitySegment, closeActiveAssistantStream, ensureActivitySegmentId], ); const flushPendingStreamEvents = useCallback((options?: { closeAnswerSegment?: boolean; finalAnswerText?: string; turn?: UIMessageTurnFields; }) => { if (streamFrameRef.current !== null) { window.cancelAnimationFrame(streamFrameRef.current); streamFrameRef.current = null; } const events = pendingStreamEventsRef.current; const finalAnswerText = options?.finalAnswerText; const turn = options?.turn ?? {}; if (events.length === 0 && finalAnswerText === undefined) { if (options?.closeAnswerSegment) closeActiveAssistantStream(); return; } pendingStreamEventsRef.current = []; setMessages((prev) => { let next = events.length > 0 ? applyPendingStreamEvents(prev, events) : prev; if (finalAnswerText !== undefined) { const targetIndex = resolveActiveAssistantIndex(next, turn) ?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current, turn); if (targetIndex !== null) { const target = next[targetIndex]; next = replaceMessageAt(next, targetIndex, { ...target, content: finalAnswerText, isStreaming: true, ...turn, }); } else { const id = crypto.randomUUID(); closedAssistantStreamIdsRef.current.add(id); next = [ ...next, { id, role: "assistant", content: finalAnswerText, isStreaming: true, ...turn, createdAt: Date.now(), }, ]; } } if (options?.closeAnswerSegment) closeActiveAssistantStream(); return next; }); }, [applyPendingStreamEvents, closeActiveAssistantStream, resolveActiveAssistantIndex]); const schedulePendingStreamFlush = useCallback(() => { if (streamFrameRef.current !== null) return; streamFrameRef.current = window.requestAnimationFrame(() => { streamFrameRef.current = null; const events = pendingStreamEventsRef.current; if (events.length === 0) return; pendingStreamEventsRef.current = []; setMessages((prev) => applyPendingStreamEvents(prev, events)); }); }, [applyPendingStreamEvents]); // Reset local state when switching chats. Do not reset on every // ``initialMessages`` update: a brand-new chat can receive an empty/404 // history response after the optimistic first message has already rendered. useEffect(() => { setMessages(initialMessages); setIsStreaming( (initialMessages.length > 0 ? initialMessages[initialMessages.length - 1].kind === "trace" : false) || hasPendingToolCalls, ); setStreamError(null); setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null); setGoalState(chatId ? client.getGoalState(chatId) : undefined); buffer.current = null; activeAssistantRef.current = null; closedAssistantStreamIdsRef.current.clear(); clearActivitySegment(); clearPendingStreamWork(); suppressStreamUntilTurnEndRef.current = false; if (streamEndTimerRef.current !== null) { clearTimeout(streamEndTimerRef.current); streamEndTimerRef.current = null; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [chatId, client, clearActivitySegment, clearPendingStreamWork]); useEffect(() => { if (hasPendingToolCalls) setIsStreaming(true); }, [hasPendingToolCalls]); useEffect(() => { if (!chatId) return; const handle = (ev: InboundEvent) => { // Any incoming event while the debounce timer is alive means the model // is still working (e.g. tool result arrived, more text to stream). // Cancel the pending "stream ended" timer so we don't hide the spinner. if (streamEndTimerRef.current !== null) { clearTimeout(streamEndTimerRef.current); streamEndTimerRef.current = null; } if (ev.event === "delta") { if (suppressStreamUntilTurnEndRef.current) return; const chunk = typeof ev.text === "string" ? ev.text : ""; if (!chunk) return; clearActivitySegment(); setIsStreaming(true); pendingStreamEventsRef.current.push({ kind: "delta", text: chunk, turn: turnFieldsFromEvent(ev, "answer"), }); schedulePendingStreamFlush(); return; } if (ev.event === "reasoning_delta") { if (suppressStreamUntilTurnEndRef.current) return; const chunk = ev.text; if (!chunk) return; if (fileEditSegmentRef.current) clearActivitySegment(); setIsStreaming(true); pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk, turn: turnFieldsFromEvent(ev, "reasoning"), }); schedulePendingStreamFlush(); return; } if (ev.event === "stream_end") { flushPendingStreamEvents({ closeAnswerSegment: true, ...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}), turn: turnFieldsFromEvent(ev, "answer"), }); if (suppressStreamUntilTurnEndRef.current) return; // stream_end only means the text segment finished — the model may // still be executing tools. Do NOT reset isStreaming here; the // definitive "turn is complete" signal is ``turn_end``. return; } const shouldCloseAnswerBeforeEvent = ev.event === "file_edit" || ( ev.event === "message" && (ev.kind === "tool_hint" || ev.kind === "progress") ); flushPendingStreamEvents({ closeAnswerSegment: shouldCloseAnswerBeforeEvent }); if (ev.event === "reasoning_end") { if (suppressStreamUntilTurnEndRef.current) return; setMessages((prev) => closeReasoningStream(prev)); return; } if (ev.event === "goal_state") { setGoalState(ev.goal_state); return; } if (ev.event === "goal_status") { if (ev.status === "running" && typeof ev.started_at === "number") { setRunStartedAt(ev.started_at); } else { setRunStartedAt(null); } return; } if (ev.event === "turn_end") { if ("goal_state" in ev && ev.goal_state != null && typeof ev.goal_state === "object") { setGoalState(ev.goal_state); } setRunStartedAt(null); // Definitive signal that the turn is fully complete. Cancel any // pending debounce timer and stop the loading indicator immediately. if (streamEndTimerRef.current !== null) { clearTimeout(streamEndTimerRef.current); streamEndTimerRef.current = null; } setIsStreaming(false); setMessages((prev) => { let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)); finalized = pruneReasoningOnlyPlaceholders(finalized); if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) { finalized = stampLastAssistantLatency( finalized, Math.round(ev.latency_ms), ev.turn_id, ); } buffer.current = null; activeAssistantRef.current = null; clearActivitySegment(); closedAssistantStreamIdsRef.current.clear(); return finalized; }); suppressStreamUntilTurnEndRef.current = false; onTurnEnd?.(); return; } if (ev.event === "message") { if ( suppressStreamUntilTurnEndRef.current && (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning") ) { return; } // Back-compat: a legacy ``kind: "reasoning"`` message (no streaming // partner) is treated as one complete delta + immediate end so the // bubble renders identically to the streaming path. if (ev.kind === "reasoning") { const line = ev.text; if (!line) return; if (fileEditSegmentRef.current) clearActivitySegment(); setMessages((prev) => closeReasoningStream(attachReasoningChunk( prev, line, { ensure: ensureActivitySegmentId }, turnFieldsFromEvent(ev, "reasoning"), ))); return; } // Intermediate agent breadcrumbs (tool-call hints, raw progress). // Attach them to the last trace row if it was the last emitted item // so a sequence of calls collapses into one compact trace group. if (ev.kind === "tool_hint" || ev.kind === "progress") { const structuredEvents = normalizeToolProgressEvents(ev.tool_events); const turn = turnFieldsFromEvent(ev, "activity"); setMessages((prev) => { const segmentId = ensureActivitySegmentId(); const base = prev; const visibleStructuredEvents = filterCoveredFileEditToolEvents(base, structuredEvents); const structuredLines = toolTraceLinesFromEvents(visibleStructuredEvents); const lines = structuredLines.length > 0 ? structuredLines : structuredEvents.length > 0 ? [] : ev.text ? [ev.text] : []; if (lines.length === 0) return base; const last = base[base.length - 1]; if ( last && last.kind === "trace" && !last.isStreaming && (!last.activitySegmentId || last.activitySegmentId === segmentId) ) { const previousTraces = last.traces?.length ? last.traces : last.content ? [last.content] : []; const mergedLines = visibleStructuredEvents.length > 0 ? mergeUniqueToolTraceLines(previousTraces, structuredLines) : null; const merged: UIMessage = { ...last, traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines], content: mergedLines ? mergedLines.traces[mergedLines.traces.length - 1] : lines[lines.length - 1], toolEvents: visibleStructuredEvents.length ? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents) : last.toolEvents, activitySegmentId: last.activitySegmentId ?? segmentId, ...turn, }; return [...base.slice(0, -1), merged]; } return [ ...base, { id: crypto.randomUUID(), role: "tool", kind: "trace", content: lines[lines.length - 1], traces: lines, ...(visibleStructuredEvents.length ? { toolEvents: visibleStructuredEvents } : {}), activitySegmentId: segmentId, ...turn, createdAt: Date.now(), }, ]; }); return; } const media = ev.media_urls?.length ? ev.media_urls.map((m) => toMediaAttachment(m)) : ev.media?.map((url) => toMediaAttachment({ url })); const hasMedia = !!media && media.length > 0; // A complete (non-streamed) assistant message. If a stream was in // flight, drop the placeholder so we don't render the text twice. // Do NOT reset isStreaming here — only ``turn_end`` signals that // the full turn (all tool calls + final text) is complete. clearActivitySegment(); setMessages((prev) => { const activeId = buffer.current?.messageId; buffer.current = null; activeAssistantRef.current = null; const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev; const content = ev.text; const lat = typeof ev.latency_ms === "number" && ev.latency_ms >= 0 ? Math.round(ev.latency_ms) : undefined; return absorbCompleteAssistantMessage(filtered, { content, ...(hasMedia ? { media } : {}), ...(lat !== undefined ? { latencyMs: lat } : {}), ...turnFieldsFromEvent(ev, "answer"), }); }); if (hasMedia) { suppressStreamUntilTurnEndRef.current = true; } return; } if (ev.event === "file_edit") { const edits = Array.isArray(ev.edits) ? ev.edits : []; if (edits.length === 0) return; const normalized = mergeFileEdits(undefined, edits); if (normalized.length === 0) return; const turn = turnFieldsFromEvent(ev, "activity"); const opensFileEditPhase = normalized.some( (edit) => edit.status === "editing" || edit.phase === "start", ); let eventSegmentId = fileEditSegmentRef.current; if (!eventSegmentId && opensFileEditPhase) { eventSegmentId = detachedActivitySegmentId(); fileEditSegmentRef.current = eventSegmentId; } setMessages((prev) => { let segmentId = eventSegmentId; const base = prev; const targetIndex = findFileEditTraceIndex(base, segmentId, normalized); if (targetIndex !== null) { const target = base[targetIndex]; segmentId = target.activitySegmentId ?? segmentId ?? detachedActivitySegmentId(); if (opensFileEditPhase) fileEditSegmentRef.current = segmentId; const cleanedTarget = stripCoveredFileEditToolHints(target, normalized); const merged: UIMessage = { ...cleanedTarget, fileEdits: mergeFileEdits(cleanedTarget.fileEdits, normalized), activitySegmentId: segmentId, ...turn, }; return replaceMessageAt(base, targetIndex, merged); } segmentId = segmentId ?? detachedActivitySegmentId(); if (opensFileEditPhase) fileEditSegmentRef.current = segmentId; return [ ...base, { id: crypto.randomUUID(), role: "tool", kind: "trace", content: "", traces: [], fileEdits: normalized, activitySegmentId: segmentId, ...turn, createdAt: Date.now(), }, ]; }); return; } // ``attached`` / ``error`` frames aren't actionable here; the client // shell handles them separately. }; const unsub = client.onChat(chatId, handle); return () => { unsub(); buffer.current = null; activeAssistantRef.current = null; closedAssistantStreamIdsRef.current.clear(); clearActivitySegment(); clearPendingStreamWork(); if (streamEndTimerRef.current !== null) { clearTimeout(streamEndTimerRef.current); streamEndTimerRef.current = null; } }; }, [ chatId, client, clearActivitySegment, clearPendingStreamWork, detachedActivitySegmentId, ensureActivitySegmentId, flushPendingStreamEvents, onTurnEnd, schedulePendingStreamFlush, ]); const send = useCallback( (content: string, images?: SendImage[], options?: SendOptions) => { if (!chatId) return; const hasImages = !!images && images.length > 0; // Text is optional when images are attached — the agent will still see // the image blocks via ``media`` paths. if (!hasImages && !content.trim()) return; flushPendingStreamEvents(); const turnId = crypto.randomUUID(); const previews = hasImages ? images!.map((i) => i.preview) : undefined; setMessages((prev) => { buffer.current = null; activeAssistantRef.current = null; closedAssistantStreamIdsRef.current.clear(); clearActivitySegment(); return [ ...pruneReasoningOnlyPlaceholders(prev), { id: crypto.randomUUID(), role: "user", content, turnId, turnPhase: "user", turnSeq: 0, createdAt: Date.now(), ...(previews ? { images: previews } : {}), ...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}), ...(options?.mcpPresets?.length ? { mcpPresets: options.mcpPresets } : {}), }, ]; }); // Mark streaming immediately so the UI shows the loading indicator // right away, before the first delta arrives from the server. setIsStreaming(true); const wireMedia = hasImages ? images!.map((i) => i.media) : undefined; client.sendMessage(chatId, content, wireMedia, { ...options, turnId }); }, [chatId, clearActivitySegment, client, flushPendingStreamEvents], ); const stop = useCallback(() => { if (!chatId) return; flushPendingStreamEvents(); setIsStreaming(false); setMessages((prev) => { buffer.current = null; activeAssistantRef.current = null; closedAssistantStreamIdsRef.current.clear(); clearActivitySegment(); return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)); }); suppressStreamUntilTurnEndRef.current = false; client.sendMessage(chatId, "/stop"); }, [chatId, clearActivitySegment, client, flushPendingStreamEvents]); return { messages, isStreaming, runStartedAt, goalState, send, stop, setMessages, streamError, dismissStreamError, }; }