/** * @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`). 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`). 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 call still running, else "Thinking…" — and for how long. * @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 { memo, useEffect, 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, } from "lucide-react"; import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types"; /** Per-sender visual treatment for a transcript row. A JSONL `type:"user"` line * is not always the human (tool results, harness task-notifications, the * orchestrator's task to a subagent) — each sender gets its own label, icon, * and accent so attribution is unambiguous. */ const SENDER_STYLES: Record< TranscriptSender, { icon: typeof User; avatarRing: string; accentBar: string; headerText: string } > = { user: { icon: User, avatarRing: "bg-syntax-builtin/15 text-syntax-builtin ring-1 ring-syntax-builtin/30", accentBar: "before:bg-syntax-builtin/40", headerText: "text-syntax-builtin", }, assistant: { icon: Bot, avatarRing: "bg-syntax-keyword/15 text-syntax-keyword ring-1 ring-syntax-keyword/30", accentBar: "before:bg-syntax-keyword/40", headerText: "text-syntax-keyword", }, orchestrator: { icon: Workflow, avatarRing: "bg-syntax-string/15 text-syntax-string ring-1 ring-syntax-string/30", accentBar: "before:bg-syntax-string/40", headerText: "text-syntax-string", }, system: { icon: Cog, avatarRing: "bg-surface-4 text-fg-secondary ring-1 ring-border-light/30", accentBar: "before:bg-surface-4/40", headerText: "text-fg-secondary", }, tool: { icon: Terminal, avatarRing: "bg-status-warning/15 text-status-warning ring-1 ring-status-warning/30", accentBar: "before:bg-status-warning/40", headerText: "text-status-warning", }, }; import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock"; import { MarkdownContent } from "./MarkdownContent"; import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format"; import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments"; interface MessageListProps { messages: TranscriptMessage[]; loading: boolean; /** Opens a file named in the "Edited N files" card. */ onOpenFile?: (path: string) => void; /** The session is running in a pane: show the step in progress. */ live?: 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; } /** 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. */ 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)} )}
); } /** Compact pill for /command invocations parsed out of TUI markup. */ function CommandPill({ display }: { display: string }) { return (
› {display}
); } /** 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: "edits"; key: string; blocks: TranscriptContent[] }; const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]); 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[] = []; const endTurn = (key: string) => { if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits }); edits = []; }; 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; const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user"); if (sender === "user") { endTurn(key); return items.push({ kind: "user", key, msg }); } if (sender !== "assistant") return items.push({ kind: "row", key, msg }); msg.content.forEach((block, b) => { const last = items[items.length - 1]; if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block); 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. */ function EditedFiles({ blocks, toolResultMap, onOpenFile, }: { blocks: TranscriptContent[]; toolResultMap: Map; onOpenFile?: (path: string) => void; }) { const { t } = useTranslation("sessions"); // `counted`: a result carried its +/- lines (an older server sends none). const files = new Map(); 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; 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); } if (files.size === 0) return 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} ); return (
{t("conversation.edited", { count: files.size })} {counts(added, removed)}
{[...files].map(([p, f]) => ( ))}
); } export function MessageList({ messages, loading, onOpenFile, live = false }: MessageListProps) { const { t } = useTranslation("sessions"); const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]); const pending = useMemo( () => (live ? workingOn(messages, toolResultMap) : null), [live, messages, toolResultMap] ); const keys = useMemo(() => messageKeys(messages), [messages]); const items = useMemo(() => chatItems(messages, keys), [messages, keys]); 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 "edits": // The card closes a finished turn; one still working has none yet. if (pending && item === items[items.length - 1]) return null; return ( ); } })} {pending && }
); } // 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; /** What the model is doing while its turn is not over: the call still * waiting for its result (`block`), else thinking (`block: null`); `since` * is that line's time. Null once the turn ended. */ export function workingOn( messages: TranscriptMessage[], results: Map, now = Date.now() ): { block: TranscriptContent | null; since: string | null } | null { 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 since = msg.timestamp ?? null; const fresh = !since || now - parseDate(since).getTime() < IDLE_AFTER_MS; if (msg.type === "assistant") { const call = [...msg.content].reverse().find((b) => b.type === "tool_use"); if (call?.id && !results.has(call.id)) return { block: call, since }; // "tool_use": a call follows. "end_turn" (or none, from an older server): over. return msg.stop_reason === "tool_use" && fresh ? { block: null, since } : null; } // 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; return fresh ? { block: null, since } : null; } return null; } /** Claude Desktop's live line: the step running now and for how long. */ function RunningStep({ block, since }: { block: TranscriptContent | null; since: string | null }) { const { t } = useTranslation("sessions"); const [now, setNow] = useState(() => Date.now()); useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, []); const input = block?.input as Record | undefined; const title = !block ? t("conversation.working") : typeof input?.description === "string" && input.description ? input.description : [block.name, buildSummary(block)].filter(Boolean).join(" "); return (
{title} {since && ( {formatMs(now - parseDate(since).getTime())} )}
); } const meta = "text-[10px] font-mono text-fg-muted"; // What the prompt box sends for an attached image: its saved path under the // drop dir (`/agenthub-drops-/<16 hex>[-name].png`, server/routes/run.js). const DROP_IMAGE = /(?<=^|\s)\S*[\\/]agenthub-drops--?\d+[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi; /** A user turn's text without its dropped-image paths, and those images' names. */ export function splitDropImages(text: string): { text: string; images: string[] } { const images: string[] = []; const rest = text.replace(DROP_IMAGE, (_all, name: string) => { images.push(name); return ""; }); return { text: images.length ? rest.replace(/[ \t]+$/gm, "").trim() : text, images }; } 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 hasText = blocks.some((b) => b?.text); return (
{images.length > 0 && (
{images.map((name) => ( ))}
)} {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 ? (

{stripAnsi(text).trim()}

) : ( renderTextBlock(text, b) ); })}
)} {long && ( )} {msg.timestamp && ( {formatLocalTime(msg.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) ) : ( )}
{msg.model && {formatModelName(msg.model)}} {msg.usage && ( ↓ {fmt(promptTokens(msg.usage))} · ↑ {fmt(msg.usage.output_tokens)} )} {msg.timestamp && {formatLocalTime(msg.timestamp)}}
); }); /** 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", }; /** "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 [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(" "); return (
{open && (
{thinking ? (
) : ( <>
{/* 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. */ const WorkGroup = memo(function WorkGroup({ blocks, toolResultMap, }: { blocks: TranscriptContent[]; toolResultMap: ReturnType; }) { return (
{blocks.map((block, i) => ( ))}
); }); /** 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. */ const MessageRow = memo(function MessageRow({ 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 style = SENDER_STYLES[sender] ?? SENDER_STYLES.user; const SenderIcon = style.icon; return (
{/* Avatar */}
{/* Message body */}
{/* Header line */}
{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; })}
); });