import type { BusEvent, ConversationInfo, PendingQuestion, ToolSnapshot, TurnUsage, } from "$lib/api/types"; export type ToolStatus = "running" | "done" | "error" | "aborted"; export type TurnStatus = "running" | "done" | "interrupted" | "error"; export interface ToolNode { children: string[]; endedAt: string | null; id: string; input: unknown; name: string; parent: string | null; result: string; startedAt: string; status: ToolStatus; } export interface Turn { endedAt: string | null; id: string; itemOrigin: string | null; nodes: Record; origin: string; // A tool call came between two pieces of text; the next piece starts a // paragraph, as the final message will show it. paused: boolean; resultSubtype: string | null; roots: string[]; says: string[]; startedAt: string; status: TurnStatus; text: string; thinking: number; usage: TurnUsage | null; userText: string | null; } interface Cursor { event: BusEvent; parent: string | null; ts: string; turnId: string | null; } const MAX_TURNS = 30; function str(value: unknown): string | null { return typeof value === "string" ? value : null; } export function joinText(head: string, tail: string, paused: boolean): string { if (paused && head.trim() && !head.endsWith("\n")) { return `${head.trimEnd()}\n\n${tail.trimStart()}`; } return head + tail; } export function isLive(turn: Turn): boolean { return ( turn.status === "running" || Object.values(turn.nodes).some((node) => node.status === "running") ); } function stopStatus(turn: Turn, stop: string | null): TurnStatus { if (stop === "end_turn") { return turn.status === "error" ? "error" : "done"; } return stop === "interrupted" ? "interrupted" : "error"; } // Bus events in, turns → tool calls → subagent tool calls out. Reactive // through $state so the tree re-renders per node, not per event. export class ActivityModel { conversation = $state(null); turns = $state([]); question = $state(null); // A subagent still working after its turn ended: nothing on this page // knows the turn, so its tool calls hang under the launching Agent node // of a turn named after that node. private readonly handlers: Record void> = { "conversation.created": (c) => this.onConversation(c), "conversation.updated": (c) => this.onConversation(c), question: (c) => this.onQuestion(c), "question.answered": () => { this.question = null; }, "question.timeout": () => { this.question = null; }, reply: (c) => this.onReply(c), result: (c) => this.onResult(c), say: (c) => this.onSay(c), stream: (c) => this.onStream(c), tool: (c) => this.onTool(c), "tool.result": (c) => this.onToolResult(c), "turn.end": (c) => this.onTurnEnd(c), "turn.start": (c) => this.onTurnStart(c), }; get running(): Turn | null { return this.turns.find((turn) => turn.status === "running") ?? null; } // A turn is live while it runs or while a subagent it launched still // works: the CLI reports subagent tool calls after the turn's own end. get live(): Turn[] { return this.turns.filter((turn) => isLive(turn)); } setConversation(info: ConversationInfo): void { this.conversation = info; this.question = info.question; const now = new Date().toISOString(); for (const turn of this.turns) { if (turn.status === "running" && turn.id !== info.running_turn) { this.closeTurn(turn, "interrupted", now); } } if (info.turn) { const turn = this.ensureTurn( info.turn.id, info.turn.started_at ?? now, info.turn.origin ); turn.userText = info.turn.text ?? turn.userText; for (const tool of info.turn.tools) { this.applySnapshot(turn, tool); } } else if (info.running_turn) { this.ensureTurn(info.running_turn, now, null); } } apply(event: BusEvent): void { const handler = this.handlers[event.type]; if (!handler) { return; } handler({ event, parent: str(event.parent_tool_use_id), ts: event.ts ?? new Date().toISOString(), turnId: str(event.turn_id), }); } private applySnapshot(turn: Turn, tool: ToolSnapshot): void { const node = this.ensureNode( turn, tool.tool_use_id, tool.name, tool.parent_tool_use_id, tool.started_at ); node.input = tool.input; if (tool.ended_at) { node.status = tool.is_error ? "error" : "done"; node.endedAt = tool.ended_at; node.result = tool.content ?? ""; } } private onTurnStart({ event, ts, turnId }: Cursor): void { if (!turnId) { return; } const turn = this.ensureTurn(turnId, ts, str(event.origin)); turn.status = "running"; turn.startedAt = ts; turn.itemOrigin = str(event.item_origin); turn.userText = str(event.text) ?? turn.userText; if (turn.origin === "task") { this.settleAgents(ts); } } // The report is in: whatever Agent node still runs has finished. private settleAgents(ts: string): void { for (const turn of this.turns) { if (turn.origin === "agent" && turn.status === "running") { turn.status = "done"; turn.endedAt = ts; } for (const node of Object.values(turn.nodes)) { const agent = SUBAGENT_TOOLS.has(node.name) || node.name === "?"; if (node.status === "running" && node.parent === null && agent) { node.status = "done"; node.endedAt = ts; } } } } // Subagent traffic belongs to the turn that launched the subagent, whatever // turn the gateway was in when it arrived. private turnFor(turnId: string, ts: string, parent: string | null): Turn { if (parent) { const owner = this.turns.find((turn) => parent in turn.nodes); if (owner) { return owner; } const current = this.turns.find((turn) => turn.id === turnId); if (current?.status !== "running") { return this.ensureTurn(`agent:${parent}`, ts, "agent"); } } return this.ensureTurn(turnId, ts, null); } private onStream({ event, ts, turnId, parent }: Cursor): void { const raw = event.event; if (!(turnId && raw && typeof raw === "object")) { return; } const turn = this.turnFor(turnId, ts, parent); const sdk = raw as Record; if (sdk.type === "content_block_start") { const block = sdk.content_block as Record | undefined; if (block?.type === "tool_use" && typeof block.id === "string") { this.ensureNode(turn, block.id, str(block.name) ?? "?", parent, ts); } else if (block?.type === "thinking" && parent === null) { turn.thinking += 1; } return; } if (sdk.type === "content_block_delta" && parent === null) { const delta = sdk.delta as Record | undefined; const progress = this.conversation?.thinking_is_progress === true; const text = delta?.type === "thinking_delta" && progress ? delta.thinking : delta?.text; if (typeof text === "string" && text) { turn.text = joinText(turn.text, text, turn.paused); turn.paused = false; } } } private onTool({ event, ts, turnId, parent }: Cursor): void { if (!(turnId && typeof event.tool_use_id === "string")) { return; } const turn = this.turnFor(turnId, ts, parent); if (parent === null) { turn.paused = true; } const node = this.ensureNode( turn, event.tool_use_id, str(event.name) ?? "?", parent, ts ); node.input = event.input ?? null; } private onToolResult({ event, ts, turnId, parent }: Cursor): void { if (!(turnId && typeof event.tool_use_id === "string")) { return; } const turn = this.turnFor(turnId, ts, parent); const node = this.ensureNode(turn, event.tool_use_id, "?", parent, ts); node.status = event.is_error ? "error" : "done"; node.endedAt = ts; node.result = str(event.content) ?? ""; } private onResult({ event, ts, turnId }: Cursor): void { if (!turnId) { return; } const turn = this.ensureTurn(turnId, ts, null); turn.resultSubtype = str(event.subtype); if (event.is_error) { turn.status = "error"; } } private onTurnEnd({ event, ts, turnId }: Cursor): void { if (!turnId) { return; } const turn = this.ensureTurn(turnId, ts, str(event.origin)); this.closeTurn(turn, stopStatus(turn, str(event.stop)), ts); turn.usage = event.usage && typeof event.usage === "object" ? (event.usage as TurnUsage) : null; } private onReply({ event, ts, turnId }: Cursor): void { if (!turnId) { return; } const turn = this.ensureTurn(turnId, ts, null); turn.userText = str(event.user_text); const text = str(event.text); if (text) { turn.text = text; } } private onSay({ event, ts, turnId }: Cursor): void { const text = str(event.text); if (!text) { return; } const turn = turnId ? this.ensureTurn(turnId, ts, null) : this.turns[0]; turn?.says.push(text); } private onQuestion({ event }: Cursor): void { if (typeof event.question_id === "string") { this.question = { id: event.question_id, questions: Array.isArray(event.questions) ? event.questions : [], }; } } private onConversation({ event }: Cursor): void { if (!this.conversation || event.id !== this.conversation.id) { return; } this.conversation = { ...this.conversation, ...(event as unknown as Partial), }; } // The turn's own tool calls die with it; a subagent's keep going until // their own results arrive. private closeTurn(turn: Turn, status: TurnStatus, ts: string): void { turn.status = status; turn.endedAt = ts; for (const node of Object.values(turn.nodes)) { if (node.status === "running" && node.parent === null) { node.status = "aborted"; node.endedAt = ts; } } } private ensureTurn(id: string, ts: string, origin: string | null): Turn { const existing = this.turns.find((t) => t.id === id); if (existing) { if (origin && existing.origin === "?") { existing.origin = origin; } return existing; } const turn: Turn = { endedAt: null, id, itemOrigin: null, nodes: {}, origin: origin ?? "?", paused: false, resultSubtype: null, roots: [], says: [], startedAt: ts, status: "running", text: "", thinking: 0, usage: null, userText: null, }; this.turns.unshift(turn); if (this.turns.length > MAX_TURNS) { this.turns.length = MAX_TURNS; } return this.turns[0]; } private ensureNode( turn: Turn, id: string, name: string, parent: string | null, ts: string ): ToolNode { const existing = turn.nodes[id]; if (existing) { if (existing.name === "?" && name !== "?") { existing.name = name; } return existing; } turn.nodes[id] = { children: [], endedAt: null, id, input: null, name, parent, result: "", startedAt: ts, status: "running", }; if (parent) { this.ensureNode(turn, parent, "?", null, ts).children.push(id); } else { turn.roots.push(id); } return turn.nodes[id]; } } function pick(input: unknown, ...keys: string[]): string | null { if (!input || typeof input !== "object") { return null; } const record = input as Record; for (const key of keys) { const value = record[key]; if (typeof value === "string" && value.trim()) { return value; } } return null; } const WHITESPACE = /\s+/g; const KEYS_BY_TOOL: Record = { Agent: ["description", "prompt"], Bash: ["command", "description"], Edit: ["file_path"], Glob: ["pattern"], Grep: ["pattern"], MultiEdit: ["file_path"], NotebookEdit: ["notebook_path"], Read: ["file_path", "notebook_path"], Skill: ["skill"], Task: ["description", "prompt"], WebFetch: ["url"], WebSearch: ["query"], Write: ["file_path"], }; export const SUBAGENT_TOOLS = new Set(["Task", "Agent"]); export function summarizeInput(name: string, input: unknown): string { if (input === null || input === undefined) { return ""; } const keys = KEYS_BY_TOOL[name] ?? (name.startsWith("mcp__gateway__") ? ["text", "title", "id"] : []); let summary = keys.length > 0 ? pick(input, ...keys) : null; if (summary === null && typeof input === "object") { const first = Object.values(input as Record).find( (value) => typeof value === "string" && value.trim() ); summary = typeof first === "string" ? first : JSON.stringify(input); } return (summary ?? String(input)).replace(WHITESPACE, " ").trim(); } export function toolLabel(name: string): string { return name.startsWith("mcp__") ? name.slice(5).replace("__", " · ") : name; } // How a turn was started, as a word for the header. export const ORIGIN_LABELS: Record = { agent: "subagent at work", inject: "inject", task: "subagent report", user: "user", };