/** * @file MessageList.tsx * @description Renders the chronological message stream of a Claude Code * transcript: alternating user / assistant rows with collapsible thinking * blocks, inline ToolCallBlocks for tool_use / tool_result pairs, and * MarkdownContent for prose. Used by ConversationView as the main body of * the Conversation tab on the Session detail page. An image the prompt box * attached reaches the transcript as its saved path; a user turn shows the * image instead (`GET /api/run/drop/:name`) — any other attached file as a * chip that opens its preview through `onOpenFile` —, and Claude Code's own * `[Image: source: ]` line for it joins that turn rather than showing as * a System row; URLs and absolute paths in a turn are links (a path opens * through `onOpenFile`), and its `/command` and `@path` tokens chips coloured * like the prompt box's (`lib/promptTokens.ts`). Each assistant turn that changed * files ends with Claude Desktop's "Edited N files" card: every file with the * lines added and removed (the tool results' `diff`); a click opens the file * when the caller can (`onOpenFile`), and a row (or the card's header, for * every file in it) drags into a prompt box as an `@path` attachment. An * AskUserQuestion is its own card (`AskCard`), not part of the collapsed work * line: each question with the answer the user picked or typed. A thinking block that carries text is * the model's narration to the user (Claude Code ≥ 2.1.280 stores real * thinking empty) and reads as prose, like Claude Desktop shows it; a live * chat (`live`) ends, until its turn is over, with what the model is doing * now — the calls still running (several when it runs them in parallel), * else "Thinking…", or the question the CLI waits on with a way to its * terminal (`waiting`) — and, like the terminal's status line, how long the * turn has run and the output tokens it has made; a call running long shows * its own time, and a line silent for minutes turns amber. A turn that ended * closes with how long it took and its output. A live chat also lists the * subagents it launched in the background that have not reported back yet * (an `Agent` call whose result is "Async agent launched…" and no * `` naming its call since), like the terminal's footer: * their turn returns at once, so without the list a turn that ended reads * "Done" while they still work (issue #81) — it then reads "Replied in …". * Given the `sessionId`, each line also shows what that agent is doing now * and how large its context is, read off the tail of its own transcript. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../../lib/types` * - `./ToolCallBlock` * - `./MarkdownContent` * - `../../lib/format` * - `./tuiSegments` * * ## Public surface * - `MessageList` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **MessageList** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { Fragment, memo, useContext, useEffect, useRef, useState, useMemo } from "react"; import { ImageLightbox } from "../ImageLightbox"; import { useTranslation } from "react-i18next"; import i18n from "../../i18n"; import { ChevronDown, ChevronRight, Bot, User, Brain, ScrollText, Terminal, Info, AlertTriangle, Pencil, Workflow, Cog, FileText, Loader2, Check, Hand, SquareTerminal, MessageCircleQuestion, CornerDownRight, Asterisk, Copy, X, Search, Globe, Wrench, Image as ImageIcon, Code2, Table2, } from "lucide-react"; import { artifactKind, isArtifact, type ArtifactKind } from "../../lib/artifactView"; import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types"; import { api } from "../../lib/api"; import { copyText } from "../../lib/clipboard"; /** The icon of a transcript row's sender. A JSONL `type:"user"` line is not * always the human (tool results, harness task-notifications, the * orchestrator's task to a subagent): each sender is named, quietly, as a * note beside the conversation rather than a turn of it. */ const SENDER_ICONS: Record = { user: User, assistant: Bot, orchestrator: Workflow, system: Cog, tool: Terminal, }; import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock"; import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent"; import { dragAttachments, projectAttachment } from "../../lib/promptAttach"; import { TOKEN_TONE, promptTokens as splitPrompt } from "../../lib/promptTokens"; import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format"; import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments"; import { AgentChatBox, AgentContext, AgentStepBody, agentRef, isAgentCall, type AgentLinks, } from "./AgentDetails"; interface MessageListProps { messages: TranscriptMessage[]; loading: boolean; /** Opens a file named in the "Edited N files" card. */ onOpenFile?: (path: string) => void; /** Opens an artifact card's file in the chat's artifact panel. */ onOpenArtifact?: (path: string) => void; /** The session is running in a pane: show the step in progress. */ live?: boolean; /** The CLI is asking the user something (a permission prompt): the live * line says so instead of "Thinking…". */ waiting?: boolean; /** Shows the pane's terminal, where that question is answered. */ onOpenTerminal?: () => void; /** Bumped by the chat's Stop: the turn on screen is over, even when the * transcript never says so. */ stopped?: number; /** The session whose MAIN transcript this is: its background agents' * own transcripts are read for their progress. */ sessionId?: string; /** Which agent transcript each Agent call launched, and how to open one. */ agentLinks?: AgentLinks; /** A subagent's conversation inside its step: tight spacing, and the task * the orchestrator gave it reads as the prompt it is. */ compact?: boolean; } /** Build a map from tool_use id → tool_result for matching */ function buildToolResultMap(messages: TranscriptMessage[]): Map { const map = new Map(); for (const msg of messages) { if (msg.type !== "user") continue; for (const c of msg.content) { if (c.type === "tool_result" && c.id) { map.set(c.id, c); } } } return map; } type ResultMap = ReturnType; /** The map is rebuilt on every tail append, but a block's own result keeps its * identity (old messages are kept, not copied): comparing just those lets a * memoized row skip the re-render when nothing it shows changed. */ const sameResults = (blocks: TranscriptContent[], a: ResultMap, b: ResultMap) => a === b || blocks.every((bl) => !bl.id || a.get(bl.id) === b.get(bl.id)); const sameBlocks = (a: TranscriptContent[], b: TranscriptContent[]) => a === b || (a.length === b.length && a.every((bl, i) => bl === b[i])); /** The whole prompt a message was answered from: `input_tokens` is only its * uncached tail (a few tokens once the cache is warm), the rest is cache. */ export function promptTokens(u: NonNullable): number { return ( (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) ); } const usageTitle = (u: NonNullable) => `input ${fmt(u.input_tokens || 0)} · cache read ${fmt(u.cache_read_input_tokens || 0)} · cache write ${fmt(u.cache_creation_input_tokens || 0)} · output ${fmt(u.output_tokens || 0)}`; /** Detect if text is skill loading content (starts with "Base directory for this skill:") */ function isSkillContent(text: string): boolean { return text.startsWith("Base directory for this skill:"); } /** Detect if text is a task notification (contains tag) */ function isTaskNotification(text: string): boolean { return text.includes("") || text.includes(""); } /** Format a timestamp as compact local time (e.g. "14:23:01"). */ function formatLocalTime(iso: string): string { try { return new Date(iso).toLocaleTimeString(); } catch { return ""; } } /** Centered marker for a session rename (/rename, `claude -n`, picker Ctrl+R). * These TUI-only commands write no conversation turn, so without this they're * invisible in the transcript. */ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: string | null }) { return (
Renamed session → {title || "(untitled)"} {timestamp && ( {formatLocalTime(timestamp)} )}
); } /** Typed text with its `/command` and `@path` tokens as chips, the rest linkified. */ function typedText(text: string): React.ReactNode[] { return splitPrompt(text).map((tok, i) => tok.kind === "text" ? ( {linkify(tok.text)} ) : ( {tok.text} ) ); } /** Compact pill for /command invocations parsed out of TUI markup: the * command in the prompt box's colour, its arguments after it. */ function CommandPill({ display }: { display: string }) { const [name, ...args] = display.split(" "); return (
{name} {args.length > 0 && ( {typedText(args.join(" "))} )}
); } /** Terminal-style fenced block for stdout/stderr captured from local commands. */ function TerminalBlock({ text, stream }: { text: string; stream: "stdout" | "stderr" }) { const cleaned = stripAnsi(text).replace(/^\n+|\n+$/g, ""); const isErr = stream === "stderr"; const accent = isErr ? "border-status-danger/30 bg-status-danger/30 text-status-danger/90" : "border-surface-3 bg-surface-4/60 text-fg-secondary"; const labelColor = isErr ? "text-status-danger/80" : "text-fg-secondary"; return (
{stream}
        {cleaned}
      
); } /** Subtle inline note for the local-command-caveat banner. */ function CaveatBlock({ text }: { text: string }) { return (
{stripAnsi(text).trim()}
); } /** Render a single segment produced by parseTuiSegments. */ function renderSegment(seg: TuiSegment, key: number): React.ReactNode { switch (seg.kind) { case "command": return ; case "stdout": return ; case "stderr": return ; case "caveat": return ; case "system-reminder": return ( } title={i18n.t("sessions:conversation.systemReminder")} borderClass="border-status-warning/20" bgClass="bg-status-warning/5" textClass="text-status-warning/80" /> ); case "persisted-output": return ( } title={i18n.t("sessions:conversation.persistedOutput")} borderClass="border-syntax-keyword/20" bgClass="bg-syntax-keyword/5" textClass="text-syntax-keyword" /> ); case "text": { const cleaned = stripAnsi(seg.text); if (!cleaned.trim()) return null; return (
); } } } /** A text block of any sender: a task notification or skill body collapsed, * TUI markup as segments, anything else as markdown. */ function renderTextBlock(text: string, key: number): React.ReactNode { // Detect task notifications, collapsed by default if (isTaskNotification(text)) { return ( } title={i18n.t("sessions:conversation.taskNotification")} borderClass="border-syntax-property/20" bgClass="bg-syntax-property/5" textClass="text-syntax-property" /> ); } // Detect skill content, collapsed by default if (isSkillContent(text)) { const pathMatch = text.match(/^Base directory for this skill:\s*(\S+)/); const skillPath = pathMatch ? pathMatch[1]! : "Skill"; return ( } title={skillPath} borderClass="border-syntax-builtin/20" bgClass="bg-syntax-builtin/5" textClass="text-syntax-builtin" /> ); } // Mixed TUI markup: caveat / command / stdout / stderr / system-reminder // can appear inline (sometimes interleaved with prose). Parse the text // into segments and render each with the appropriate visual treatment. if (hasTuiTags(text)) { const segments = parseTuiSegments(text); return (
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
); } return (
); } /** Generic collapsible content block */ function CollapsibleBlock({ text, icon, title, borderClass, bgClass, textClass, }: { text: string; icon: React.ReactNode; title: string; borderClass: string; bgClass: string; textClass: string; }) { const [expanded, setExpanded] = useState(false); return (
{expanded && (
            {text}
          
)}
); } /** A key per message that survives prepend (older history) and append (live * tail): built from the message itself, with an occurrence counter for twins. * The index was the key before, so expanding a block and then loading older * messages moved the expansion onto another message. */ export function messageKeys(messages: TranscriptMessage[]): string[] { const seen = new Map(); return messages.map((m) => { const first = m.content[0]; const sig = first ? `${first.type}:${first.id ?? (first.text ?? "").slice(0, 48)}` : (m.title ?? ""); const base = `${m.timestamp ?? ""}|${m.type}|${m.sender ?? ""}|${sig}`; const n = seen.get(base) ?? 0; seen.set(base, n + 1); return n ? `${base}#${n}` : base; }); } /** One thing on screen, in the Claude Desktop layout: a human message as a * bubble, the assistant's prose bare, and everything it did between two * pieces of prose — tool calls and thinking, across messages (Claude Code * writes each as its own line) — as ONE collapsed line; any other sender * keeps its attributed row. */ type Item = | { kind: "event"; key: string; msg: TranscriptMessage } | { kind: "row"; key: string; msg: TranscriptMessage } | { kind: "user"; key: string; msg: TranscriptMessage } | { kind: "text"; key: string; msg: TranscriptMessage; text: string } | { kind: "work"; key: string; blocks: TranscriptContent[] } | { kind: "ask"; key: string; block: TranscriptContent } | { kind: "edits"; key: string; blocks: TranscriptContent[] } | { kind: "done"; key: string; ms: number; output: number } | { kind: "command"; key: string; name: string | null; output: string | null; error: boolean }; const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]); /** A line that is only Claude Code's local-command plumbing — the caveat it * writes first, the command, what the command printed — or null for * anything else (prose around the tags, a skill's body). */ export function localCommand( msg: TranscriptMessage ): { name: string | null; output: string | null; error: boolean } | null { const texts = msg.content.filter((c) => c.type === "text" && c.text).map((c) => c.text!); if (!texts.length || texts.length !== msg.content.length) return null; const text = texts.join("\n"); if (!hasTuiTags(text)) return null; let name: string | null = null; let output: string | null = null; let error = false; for (const seg of parseTuiSegments(text)) { if (seg.kind === "command") name = seg.display; else if (seg.kind === "stdout" || seg.kind === "stderr") { output = [output, stripAnsi(seg.text).trim()].filter(Boolean).join("\n"); error ||= seg.kind === "stderr"; } else if (seg.kind === "text" ? seg.text.trim() : seg.kind !== "caveat") return null; } return { name, output, error }; } export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] { const items: Item[] = []; // The file-changing calls of the turn so far, closed into one card when the // human speaks again (or the list ends). let edits: TranscriptContent[] = []; // The turn's prompt time, its newest assistant line and its output so far, // for the "Done in …" line under a turn that ended. let start: string | null = null; let reply: TranscriptMessage | null = null; let output = outputCounter(); const endTurn = (key: string) => { if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits }); edits = []; if (start && reply?.timestamp && reply.stop_reason === "end_turn") { const ms = parseDate(reply.timestamp).getTime() - parseDate(start).getTime(); items.push({ kind: "done", key: `done:${key}`, ms, output: output.total }); } reply = null; output = outputCounter(); }; messages.forEach((msg, idx) => { const key = keys[idx]!; if (msg.type === "session_event") return items.push({ kind: "event", key, msg }); // Purely tool_result: shown inside its call's block. if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) return; // A local command (`/clear`, `/model`) is one quiet line with what it // printed; its caveat is CLI plumbing. A command whose output never // follows is a skill the model answers: it stays the prompt it is. const local = msg.type === "assistant" ? null : localCommand(msg); if (local) { const last = items[items.length - 1]; if (local.name === null && local.output === null) return; if (local.name === null) { if (last?.kind === "command" && last.output === null) { last.output = local.output; last.error = local.error; return; } return items.push({ kind: "command", key, ...local }); } const next = messages[idx + 1] ? localCommand(messages[idx + 1]!) : null; if (local.output !== null || (next && next.name === null && next.output !== null)) { endTurn(key); start = null; return items.push({ kind: "command", key, ...local }); } } const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user"); if (sender === "user") { endTurn(key); start = msg.timestamp ?? null; return items.push({ kind: "user", key, msg }); } if (sender !== "assistant") { // Claude Code's line for the image a prompt attached: the image joins that prompt. const paths = sender === "system" ? imageSourcePaths(msg) : null; const prompt = paths ? [...items].reverse().find((i) => i.kind === "user") : undefined; if (paths && prompt?.kind === "user") { prompt.msg = { ...prompt.msg, content: [ ...prompt.msg.content.map((c) => c.type === "text" && c.text ? { ...c, text: c.text.replace(/\[Image #\d+\]/g, "") } : c ), { type: "text", text: paths.join("\n") }, ], }; return; } return items.push({ kind: "row", key, msg }); } reply = msg; if (msg.usage) output.add(msg.usage); msg.content.forEach((block, b) => { const last = items[items.length - 1]; if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block); // A question to the user is a turn of the conversation, not hidden work. if (block.type === "tool_use" && block.name === "AskUserQuestion") { items.push({ kind: "ask", key: `${key}:${b}`, block }); } else if (block.type === "tool_use") { if (last?.kind === "work") last.blocks.push(block); else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] }); } else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) { items.push({ kind: "text", key: `${key}:${b}`, msg, text: block.text }); } }); }); endTurn(keys[keys.length - 1] ?? "end"); return items; } /** Claude Desktop's end-of-turn card: the files the turn changed, each with * the lines added and removed. A call still running or failed is left out. */ const ARTIFACT_ICON: Partial> = { html: Globe, svg: ImageIcon, markdown: FileText, mermaid: Workflow, react: Code2, csv: Table2, }; /** Claude Desktop's artifact card: what the model made, opened beside the chat. */ function ArtifactCard({ path, onOpen }: { path: string; onOpen?: (path: string) => void }) { const { t } = useTranslation("sessions"); const kind = artifactKind(path); const Icon = ARTIFACT_ICON[kind] ?? FileText; const name = path.split(/[\\/]/).pop() || path; return ( ); } function EditedFilesCard({ blocks, toolResultMap, onOpenFile, onOpenArtifact, pending = false, }: { blocks: TranscriptContent[]; toolResultMap: Map; onOpenFile?: (path: string) => void; /** Opens an artifact (a page, document, diagram…) in the chat's own panel. */ onOpenArtifact?: (path: string) => void; /** The turn is still working: only its artifact cards so far. */ pending?: boolean; }) { const { t } = useTranslation("sessions"); // `counted`: a result carried its +/- lines (an older server sends none). const files = new Map(); // Written whole in this turn (a Write): a component made here is an artifact. const created = new Set(); for (const b of blocks) { const input = (b.input ?? {}) as Record; const p = input.file_path ?? input.notebook_path; const r = b.id ? toolResultMap.get(b.id) : undefined; if (typeof p !== "string" || !r || r.is_error) continue; if (b.name === "Write") created.add(p); const f = files.get(p) ?? { added: 0, removed: 0, counted: false }; f.added += r.diff?.added ?? 0; f.removed += r.diff?.removed ?? 0; f.counted ||= !!r.diff; files.set(p, f); } const artifacts = [...files.keys()].filter((p) => isArtifact(p, created.has(p))); const cards = artifacts.length > 0 && (
{artifacts.map((p) => ( ))}
); if (pending) return cards || null; for (const p of artifacts) files.delete(p); if (files.size === 0) return cards || null; let added = 0; let removed = 0; let counted = false; files.forEach((f) => { added += f.added; removed += f.removed; counted ||= f.counted; }); const counts = (a: number, r: number, show = counted) => show && ( +{a}{" "} −{r} ); const list = (
dragAttachments(e, [...files.keys()].map(projectAttachment))} className="flex cursor-grab items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary" > {t("conversation.edited", { count: files.size })} {counts(added, removed)}
{[...files].map(([p, f]) => ( ))}
); return cards ? (
{cards} {list}
) : ( list ); } const EditedFiles = memo( EditedFilesCard, (p, n) => p.onOpenFile === n.onOpenFile && p.onOpenArtifact === n.onOpenArtifact && p.pending === n.pending && sameBlocks(p.blocks, n.blocks) && sameResults(n.blocks, p.toolResultMap, n.toolResultMap) ); const NO_AGENT_LINKS: AgentLinks = { ids: new Map(), notices: new Map(), live: false }; function MessageListView({ messages, loading, onOpenFile, onOpenArtifact, live = false, waiting = false, onOpenTerminal, stopped = 0, sessionId, agentLinks, compact = false, }: MessageListProps) { const { t } = useTranslation("sessions"); const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]); const keys = useMemo(() => messageKeys(messages), [messages]); const lastKey = keys[keys.length - 1] ?? null; // The newest line when Stop was pressed. Esc before the model wrote anything // puts the prompt back in Claude Code's input box and writes no // "[Request interrupted…]" line (issue #74), so the transcript still ends on // the prompt: no live line until a newer one arrives. const [stop, setStop] = useState<{ n: number; on: string | null }>({ n: stopped, on: null }); if (stop.n !== stopped) setStop({ n: stopped, on: lastKey }); const halted = stop.on !== null && stop.on === lastKey; const pending = useMemo( () => (live && !halted ? workingOn(messages, toolResultMap) : null), [live, halted, messages, toolResultMap] ); const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]); const items = useMemo( () => chatItems( compact ? messages.map((m) => m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m ) : messages, keys ), [messages, keys, compact] ); const agents = useMemo( () => (live ? backgroundAgents(messages, toolResultMap) : []), [live, messages, toolResultMap] ); if (loading) { return (
{t("conversation.loading")}
); } if (messages.length === 0) { return
{t("conversation.empty")}
; } return (
{items.map((item) => { switch (item.kind) { case "event": return ( ); case "row": return ; case "user": return ; case "text": return ; case "work": return ( ); case "ask": return ( ); case "edits": // The card closes a finished turn; one still working shows // only the artifacts it made so far, as Claude Desktop does. return ( ); case "done": // A subagent's thread: its time is in the facts line above it. if (compact) return null; return ( ); case "command": return ( ); } })} {pending && ( )} {agents.length > 0 && ( )}
); } // No line for this long and no call running: the turn died with no end // written (a crash, a kill) — stop saying it works. const IDLE_AFTER_MS = 15 * 60_000; const LOCAL_COMMAND_OUTPUT = /^\s*/; /** What the model is doing while its turn is not over: the calls of the turn * still waiting for their results (`blocks`, oldest first — parallel calls * finish in any order), `since` the oldest one's time, `last` the newest * line's; `blocks` is empty while it thinks. Null once the turn ended. */ export function workingOn( messages: TranscriptMessage[], results: Map, now = Date.now() ): { blocks: TranscriptContent[]; since: string | null; last: string | null } | null { const blocks: TranscriptContent[] = []; let since: string | null = null; let last: string | null = null; // The newest line's verdict: undefined until it is found. let alive: boolean | undefined; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]!; const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user"); // A rename marker or a harness line says nothing about the turn. if (msg.type === "session_event" || sender === "system" || sender === "orchestrator") continue; const at = msg.timestamp ?? null; const fresh = !at || now - parseDate(at).getTime() < IDLE_AFTER_MS; const newest = alive === undefined; if (newest) last = at; if (msg.type === "assistant") { // "end_turn": over, whatever the transcript failed to pair. if (newest && msg.stop_reason === "end_turn") return null; for (const b of [...msg.content].reverse()) { if (b.type === "tool_use" && b.id && !results.has(b.id)) { blocks.unshift(b); since = at; } } // "tool_use": a call follows. None (an older server): over. if (newest) alive = msg.stop_reason === "tool_use" && fresh; continue; } // A prompt or a tool result: the model answers it next — unless the user stopped it. const text = msg.content.find((c) => c.type === "text")?.text ?? ""; if (text.startsWith("[Request interrupted")) return null; // A local command's output (`/clear`, `/model`): the CLI answered it, no turn follows. if (newest && LOCAL_COMMAND_OUTPUT.test(text)) return null; if (newest) alive = fresh; // The prompt that opened the turn: nothing before it is this turn's. if (text) break; } if (blocks.length) return { blocks, since, last }; return alive ? { blocks, since: null, last } : null; } // A launch older than this with no notification is from a run that ended // without one (the CLI quit, a resume of an old session): not running now. const AGENT_STALE_MS = 6 * 60 * 60_000; const NOTIFIED_CALL = /([^<]+)<\/tool-use-id>/g; // The launch result names the agent's own transcript (`agent-.jsonl`). const LAUNCHED_ID = /agentId:\s*([\w-]+)/; type BackgroundAgent = { block: TranscriptContent; since: string | null; agentId: string | null }; /** The subagents launched in the background that have not reported back: an * Agent call whose result says it was launched async, with no * `` naming that call since (any status — completed, * failed, killed — means it stopped). `since` is its launch time. */ export function backgroundAgents( messages: TranscriptMessage[], results: Map, now = Date.now() ): BackgroundAgent[] { const running = new Map(); for (const msg of messages) { if (msg.type === "assistant") { for (const b of msg.content) { if (b.type !== "tool_use" || !b.id || (b.name !== "Agent" && b.name !== "Task")) continue; // The output is the result content as JSON when it is a block list. const output = results.get(b.id)?.output; if (!output?.includes("Async agent launched")) continue; const at = msg.timestamp ?? null; if (at && now - parseDate(at).getTime() > AGENT_STALE_MS) continue; running.set(b.id, { block: b, since: at, agentId: output.match(LAUNCHED_ID)?.[1] ?? null }); } continue; } if (!running.size) continue; for (const c of msg.content) { if (c.type !== "text" || !c.text || !isTaskNotification(c.text)) continue; for (const m of c.text.matchAll(NOTIFIED_CALL)) running.delete(m[1]!.trim()); } } return [...running.values()]; } /** Output tokens counted once per API call: Claude Code writes one line per * content block, each repeating its call's usage — consecutive lines with * the same prompt size are one call. */ function outputCounter() { let total = 0; let call: string | null = null; let callOutput = 0; return { add(u: NonNullable) { const key = `${u.input_tokens}:${u.cache_read_input_tokens ?? 0}:${u.cache_creation_input_tokens ?? 0}`; if (key !== call) { total += callOutput; call = key; callOutput = 0; } callOutput = Math.max(callOutput, u.output_tokens || 0); }, get total() { return total + callOutput; }, }; } /** The live turn so far, as the terminal counts it: `start` is the prompt * that opened it, `output` the output tokens of every API call since. */ export function turnStats(messages: TranscriptMessage[]): { start: string | null; output: number } { const output = outputCounter(); for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]!; if (msg.type === "assistant") { if (msg.usage) output.add(msg.usage); continue; } const sender = msg.sender ?? "user"; if (sender === "user" && msg.content.some((c) => c.type === "text")) { return { start: msg.timestamp ?? null, output: output.total }; } } return { start: null, output: output.total }; } // A call running this long shows its own time beside the turn's. const STEP_SHOWN_MS = 30_000; // No new line for this long: the line turns amber — the step may be stuck. const QUIET_MS = 3 * 60_000; /** A number that counts up to its new value instead of jumping (none when * the user asked for reduced motion). */ function useCountUp(value: number): number { const [shown, setShown] = useState(value); const shownRef = useRef(value); useEffect(() => { const from = shownRef.current; if (from === value) return; const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; if (reduce || typeof requestAnimationFrame === "undefined") { shownRef.current = value; setShown(value); return; } const t0 = performance.now(); let raf = 0; const step = (t: number) => { const k = Math.min(1, (t - t0) / 600); shownRef.current = Math.round(from + (value - from) * k); setShown(shownRef.current); if (k < 1) raf = requestAnimationFrame(step); }; raf = requestAnimationFrame(step); return () => cancelAnimationFrame(raf); }, [value]); return shown; } /** The time now, ticking every second while `ref`'s box is on screen: a * hidden tab or a hidden pane (another workspace, kept mounted) shows no * clock, so the re-render is skipped until it is shown again. */ function useClock(ref: React.RefObject): number { const [now, setNow] = useState(() => Date.now()); useEffect(() => { const timer = window.setInterval(() => { const el = ref.current; if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0)) return; setNow(Date.now()); }, 1000); return () => window.clearInterval(timer); }, [ref]); return now; } /** A call's one-line title: its description (Claude Code's Bash * `description`), else the tool and its target. */ function stepTitle(block: TranscriptContent): string { const input = block.input as Record | undefined; return typeof input?.description === "string" && input.description ? input.description : [block.name, buildSummary(block)].filter(Boolean).join(" "); } /** The terminal's live line: the step running now (or the question the CLI * waits on), then the turn's time and output tokens so far. */ function RunningStep({ blocks, since, last, start, tokens, waiting, onOpenTerminal, }: { blocks: TranscriptContent[]; since: string | null; last: string | null; start: string | null; tokens: number; waiting: boolean; onOpenTerminal?: () => void; }) { const { t } = useTranslation("sessions"); const [showAll, setShowAll] = useState(false); const boxRef = useRef(null); const now = useClock(boxRef); const counted = useCountUp(tokens); const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0); const first = blocks[0]; const step = first ? age(since) : 0; const quiet = !waiting && age(last) > QUIET_MS; const title = waiting ? first ? t("conversation.waitingFor", { step: stepTitle(first) }) : t("conversation.waiting") : first ? stepTitle(first) : t("conversation.working"); const extra = [ start && formatMs(age(start)), quiet && !first && t("conversation.quiet", { time: formatMs(age(last)) }), ].filter(Boolean); // The output so far is a detail: shown on hover, like the turn's own end line. const written = tokens > 0 ? t("conversation.turnTokens", { tokens: fmt(counted) }) : null; const amber = waiting || quiet; return (
{waiting ? ( ) : ( // Claude Desktop's turning spark in place of a spinner. )} {title} {blocks.length > 1 && ( )} {step > STEP_SHOWN_MS && ( QUIET_MS ? "text-status-warning" : ""}`} > {formatMs(step)} )} {extra.length > 0 && ( {extra.join(" · ")} )} {written && ( {written} )} {waiting && onOpenTerminal && ( )}
{showAll && blocks.length > 1 && (
    {blocks.map((b, i) => (
  • {stepTitle(b)}
  • ))}
)}
); } // How often a running agent's transcript tail is re-read, and how much of it // is kept: enough for its newest step and its newest usage. const AGENT_TAIL_MS = 4000; const AGENT_TAIL_KEEP = 40; /** The newest lines of each agent's own transcript (by its `agentId`), * re-read while `ref`'s box is on screen — only the lines after those * already read (`after`). A failed read leaves the line without detail. */ function useAgentTails( sessionId: string | undefined, ids: string[], ref: React.RefObject ): Map { const [tails, setTails] = useState(() => new Map()); const lastRef = useRef(new Map()); const key = ids.join(","); useEffect(() => { if (!sessionId || !key) return; let cancelled = false; let busy = false; const read = async () => { const el = ref.current; if ( busy || document.visibilityState !== "visible" || (el && el.getClientRects().length === 0) ) return; busy = true; await Promise.all( key.split(",").map(async (id) => { const last = lastRef.current.get(id); try { const r = await api.sessions.transcript(sessionId, { agent_id: id, ...(last ? { after: last } : { limit: 20 }), }); if (cancelled || !r.messages.length) return; lastRef.current.set(id, r.last_line); setTails((prev) => { const next = new Map(prev); next.set(id, [...(prev.get(id) ?? []), ...r.messages].slice(-AGENT_TAIL_KEEP)); return next; }); } catch { // Non-fatal: the line keeps its description and time. } }) ); busy = false; }; void read(); const timer = window.setInterval(() => void read(), AGENT_TAIL_MS); return () => { cancelled = true; window.clearInterval(timer); }; }, [sessionId, key, ref]); return tails; } /** What an agent's transcript tail says it is doing: the call it waits on * (or none while it thinks), and its newest prompt size — its context. */ export function agentProgress( messages: TranscriptMessage[], now = Date.now() ): { step: string | null; context: number } { let context = 0; for (let i = messages.length - 1; i >= 0 && !context; i--) { const u = messages[i]!.usage; if (messages[i]!.type === "assistant" && u) context = promptTokens(u); } const first = workingOn(messages, buildToolResultMap(messages), now)?.blocks[0]; return { step: first ? stepTitle(first) : null, context }; } /** The subagents still working in the background, one line each like the * terminal's footer: its type, its task and how long it has run — with its * step now and its context once its own transcript was read. */ function BackgroundAgents({ agents, sessionId, toolResultMap, }: { agents: BackgroundAgent[]; sessionId?: string; toolResultMap: ReturnType; }) { const { t } = useTranslation("sessions"); const links = useContext(AgentContext); const boxRef = useRef(null); const now = useClock(boxRef); const ids = useMemo(() => agents.flatMap((a) => (a.agentId ? [a.agentId] : [])), [agents]); const tails = useAgentTails(sessionId, ids, boxRef); // The agents whose own conversation is open under their line. const [expanded, setExpanded] = useState>(() => new Set()); const toggle = (id: string) => setExpanded((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); return (
{t("conversation.agentsRunning", { count: agents.length })}
    {agents.map(({ block, since, agentId }, i) => { const input = (block.input ?? {}) as Record; const type = typeof input.subagent_type === "string" ? input.subagent_type : "Agent"; const desc = typeof input.description === "string" ? input.description : ""; const tail = agentId ? tails.get(agentId) : undefined; const { step, context } = tail ? agentProgress(tail, now) : { step: null, context: 0 }; const ref = agentRef(block, block.id ? toolResultMap.get(block.id) : null, links); const canOpen = !!ref.agentId && !!links.render; const shown = canOpen && !!block.id && expanded.has(block.id); const row = ( <>
    {type} {desc} {[ since && formatMs(now - parseDate(since).getTime()), context > 0 && `↓ ${t("conversation.agentContext", { tokens: fmt(context) })}`, ] .filter(Boolean) .map((v, j) => ( {v} ))} {canOpen && ( )}
    {tail && (
    {step ?? t("conversation.working")}
    )} ); return (
  • {canOpen ? ( ) : ( row )} {shown && }
  • ); })}
); } /** Under a turn that ended: how long it took and the output it made — or, * while subagents it launched still work (`agents`), that only the reply is * done. */ /** An option's label carries the model's " (Recommended)" marker into the * answer the CLI records; the user's pick reads without it (every picked * label of a multi-select answer too). */ function stripRecommended(answer: string | undefined): string | undefined { return answer?.replace(/\s*\(recommended\)/gi, "").trim(); } /** An AskUserQuestion: each question with the answer the user picked or * typed (the result's `answers`, minus the " (Recommended)" marker), * "declined" when they refused it, no answer yet while the CLI still asks. */ function AskCard({ block, result, }: { block: TranscriptContent; result: TranscriptContent | null; }) { const { t } = useTranslation("sessions"); const input = block.input as { questions?: unknown } | undefined; const questions = (Array.isArray(input?.questions) ? input.questions : []) .map((q) => (q && typeof q === "object" ? (q as { question?: unknown }).question : null)) .filter((q): q is string => typeof q === "string"); return (
{questions.map((q, i) => { const answer = stripRecommended(result?.answers?.[q]); return (
{q}
{answer && (
{answer}
)}
); })} {result?.is_error && (
{t("conversation.askDeclined")}
)}
); } /** A local command as Claude Desktop marks a context change: one centred line * between rules, a short output beside it, a longer one a click away. */ function LocalCommand({ name, output, error, }: { name: string | null; output: string | null; error: boolean; }) { const [open, setOpen] = useState(false); const text = output ?? ""; const short = !text.includes("\n") && text.length <= 80; return (
{name && {name}} {text && short && ( {text} )} {text && !short && ( )}
{open && (
          {text}
        
)}
); } /** The end of a turn: how long it took and what it wrote. Only the newest * turn shows it; an older one on hover. */ function TurnDone({ ms, output, agents = 0, latest = false, }: { ms: number; output: number; agents?: number; latest?: boolean; }) { const { t } = useTranslation("sessions"); return (
0 ? "opacity-80" : "opacity-0 hover:opacity-80" }`} > {agents > 0 ? ( ) : ( )} {[ t(agents > 0 ? "conversation.turnReplied" : "conversation.turnDone", { time: formatMs(ms), }), output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }), agents > 0 && t("conversation.agentsStill", { count: agents }), ] .filter(Boolean) .join(" · ")}
); } // What the prompt box sends for an attached file: its saved path (an `@` // mention for claude) under the drop dir (`/drops/<16 hex>[-name].ext`, server/routes/run.js; the // older `/agenthub-drops-/` still in past transcripts). const DROP_PATH = /(?<=^|\s)(\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+))(?=\s|$)/gi; const IMAGE_EXT = /\.(?:png|jpe?g|gif|webp)$/i; // Claude Code's own line for an image it read off a pasted path (`isMeta`). const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/; /** A user turn's text without its dropped-file paths (and Claude Code's * `[Image #N]` placeholders for them), the images' names and the other * files' full paths. */ export function splitDropImages(text: string): { text: string; images: string[]; files: string[]; } { const images: string[] = []; const files: string[] = []; const rest = text.replace(DROP_PATH, (_all, full: string, name: string) => { if (IMAGE_EXT.test(name)) images.push(name); else files.push(full.replace(/^@/, "")); // a claude prompt's `@` mention return ""; }); return { text: images.length || files.length ? rest .replace(/\[Image #\d+\]/g, "") .replace(/[ \t]+$/gm, "") .trim() : text, images, files, }; } /** The drop paths of a meta message made only of `[Image: source: ]` lines. */ function imageSourcePaths(msg: TranscriptMessage): string[] | null { const lines = msg.content.flatMap((c) => (c.type === "text" ? (c.text ?? "").split("\n") : [])); const paths = lines.map((l) => IMAGE_SOURCE.exec(l.trim())?.[1] ?? ""); return paths.length && paths.every((p) => splitDropImages(p).images.length) ? paths : null; } /** A dropped file other than an image: a chip that opens its preview when the * page can (`OpenFileContext`). */ function DropFile({ path }: { path: string }) { const open = useContext(OpenFileContext); const name = path .split(/[\\/]/) .pop()! .replace(/^[0-9a-f]{16}-?/i, ""); const ext = /\.([A-Za-z0-9]+)$/.exec(name)?.[1]?.toUpperCase(); return ( ); } function DropImage({ name }: { name: string }) { const [open, setOpen] = useState(false); const [gone, setGone] = useState(false); const src = `/api/run/drop/${encodeURIComponent(name)}`; // Drops are pruned after a day: the name is all that is left to show. if (gone) return ( {name.replace(/^[0-9a-f]{16}-?/, "") || name} ); return ( <> {open && setOpen(false)} />} ); } /** The human's turn: a bubble on the right, its time on hover. A long one (a * paste, a compaction summary) shows its first lines until opened. */ const LONG_BUBBLE = 800; const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage }) { const { t } = useTranslation("sessions"); const long = msg.content.reduce((n, c) => n + (c.text?.length ?? 0), 0) > LONG_BUBBLE; const [open, setOpen] = useState(false); const blocks = useMemo( () => msg.content.map((block) => block.type === "text" && block.text ? splitDropImages(block.text) : null ), [msg.content] ); const images = blocks.flatMap((b) => b?.images ?? []); const files = blocks.flatMap((b) => b?.files ?? []); const hasText = blocks.some((b) => b?.text); const copy = blocks .map((b) => b?.text && stripAnsi(b.text).trim()) .filter(Boolean) .join("\n\n"); return (
{images.length + files.length > 0 && (
{images.map((name) => ( ))} {files.map((path) => ( ))}
)} {hasText && (
{blocks.map((block, b) => { const text = block?.text; if (!text) return null; // Plain typing reads as typed; anything structured keeps its rendering. const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text); return plain ? (

{typedText(stripAnsi(text).trim())}

) : ( renderTextBlock(text, b) ); })}
{long && ( )}
)}
); }); /** Copy, with a tick for a moment once it landed. */ function CopyAction({ text }: { text: string }) { const { t } = useTranslation("sessions"); const [done, setDone] = useState(false); return ( ); } /** A message's hover bar, as Claude Desktop has it: copy, then the time and * any detail (the model, tokens in its tooltip). */ function MessageActions({ text, timestamp, detail, detailTitle, }: { text: string; timestamp?: string | null; detail?: string | null; detailTitle?: string; }) { return (
{text && } {detail && {detail}} {timestamp && {formatLocalTime(timestamp)}}
); } /** The assistant's prose, bare on the page; model, tokens and time on hover. */ const AssistantText = memo(function AssistantText({ msg, text, }: { msg: TranscriptMessage; text: string; }) { return (
{hasTuiTags(text) || isSkillContent(text) || isTaskNotification(text) ? ( renderTextBlock(text, 0) ) : ( )}
); }); /** A muted line that opens to show what it stands for. */ function Disclosure({ label, children }: { label: string; children: React.ReactNode }) { const [open, setOpen] = useState(false); return (
{open &&
{children}
}
); } /** The kind of work a tool call is, for the group's label (Claude Code and * Codex names); anything else counts as a tool. */ const WORK_KIND: Record = { bash: "commands", shell: "commands", exec_command: "commands", powershell: "commands", edit: "edits", multiedit: "edits", write: "edits", notebookedit: "edits", apply_patch: "edits", read: "reads", grep: "searches", glob: "searches", ls: "searches", websearch: "web", webfetch: "web", task: "agents", agent: "agents", }; /** Each kind's mark on the steps' timeline. */ const KIND_ICON: Record = { commands: Terminal, edits: Pencil, reads: FileText, searches: Search, web: Globe, agents: Bot, tools: Wrench, }; /** "Ran 3 commands, edited 2 files" — or "Thinking" when it only thought. */ const KIND_ORDER = ["commands", "edits", "reads", "searches", "web", "agents", "tools"]; export function workLabel(blocks: TranscriptContent[]): string { const counts = new Map(); for (const b of blocks) { if (b.type !== "tool_use") continue; const kind = WORK_KIND[(b.name ?? "").toLowerCase()] ?? "tools"; counts.set(kind, (counts.get(kind) ?? 0) + 1); } if (!counts.size) return i18n.t("sessions:conversation.thinking"); // Claude Desktop's order: commands, edits, reads, … and other tools last. const text = [...counts] .sort(([a], [b]) => KIND_ORDER.indexOf(a) - KIND_ORDER.indexOf(b)) .map(([kind, count]) => i18n.t(`sessions:conversation.work.${kind}`, { count })) .join(", "); return text.charAt(0).toUpperCase() + text.slice(1); } /** One step inside an opened group, as Claude Desktop lists them: its own * description (Claude Code's Bash `description`) or the tool and target as * the title, the call itself under it in a capped scroll box, the result one * click further. Thinking is a step too, closed. */ function WorkStep({ block, result, }: { block: TranscriptContent; result: TranscriptContent | null; }) { const { t } = useTranslation("sessions"); const thinking = block.type === "thinking"; const agent = isAgentCall(block); const [open, setOpen] = useState(!thinking); const [showResult, setShowResult] = useState(false); const input = block.input as Record | undefined; const title = thinking ? t("conversation.thinking") : typeof input?.description === "string" && input.description ? input.description : [block.name, buildSummary(block)].filter(Boolean).join(" "); const Icon = thinking ? Brain : (KIND_ICON[WORK_KIND[(block.name ?? "").toLowerCase()] ?? "tools"] ?? Wrench); return ( // One stop on the timeline: its kind's mark, the rule down to the next.
{open && (
{thinking ? (
) : agent ? ( ) : ( <>
{/* The description is the title already. */} {renderInput( input ? { ...block, input: { ...input, description: undefined } } : block )}
{result && ( )} {result && showResult && (
{renderResult(result, block.name ?? "")}
)} )}
)}
); } /** What the assistant did between two replies as one line, opening onto * one framed list of its steps. */ function WorkGroupLine({ blocks, toolResultMap, }: { blocks: TranscriptContent[]; toolResultMap: ReturnType; }) { return (
{blocks.map((block, i) => ( ))}
); } const WorkGroup = memo( WorkGroupLine, (p, n) => sameBlocks(p.blocks, n.blocks) && sameResults(n.blocks, p.toolResultMap, n.toolResultMap) ); /** One user/assistant row. Memoized: a live tail append re-renders only the * new rows; its thinking-block expansion is its own state, kept by key. */ function MessageRowView({ msg, toolResultMap, }: { msg: TranscriptMessage; toolResultMap: ReturnType; }) { const { t } = useTranslation("sessions"); const [expandedThinking, setExpandedThinking] = useState>(() => new Set()); const isAssistant = msg.type === "assistant"; // The true sender (classified server-side) drives the label + styling. // Falls back to the coarse type for older payloads without `sender`. const sender: TranscriptSender = msg.sender ?? (isAssistant ? "assistant" : "user"); const SenderIcon = SENDER_ICONS[sender] ?? User; return (
{t(`conversation.sender.${sender}`)} {msg.model && {formatModelName(msg.model)}} {msg.usage && ( ↓ {fmt(promptTokens(msg.usage))} · ↑ {fmt(msg.usage.output_tokens)} )} {msg.timestamp && ( {formatLocalTime(msg.timestamp)} )}
{/* Content blocks */} {msg.content.map((block, bIdx) => { if (block.type === "text" && block.text) return renderTextBlock(block.text, bIdx); if (block.type === "thinking" && block.text) { const thinkKey = bIdx; const isExpanded = expandedThinking.has(thinkKey); return (
{isExpanded && (
)}
); } if (block.type === "tool_use") { const matchedResult = block.id ? (toolResultMap.get(block.id) ?? null) : null; return ; } // tool_result blocks rendered inside ToolCallBlock, skip standalone return null; })}
); } const MessageRow = memo( MessageRowView, (p, n) => p.msg === n.msg && sameResults(n.msg.content, p.toolResultMap, n.toolResultMap) ); /** Memoized: its owner re-renders on scroll state and its parent's typing. */ export const MessageList = memo(MessageListView);