/** * @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. * @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, useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; import i18n from "../../i18n"; import { ChevronDown, ChevronRight, Bot, User, Brain, ScrollText, Terminal, Info, AlertTriangle, Pencil, Workflow, Cog, } 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 } from "./ToolCallBlock"; import { MarkdownContent } from "./MarkdownContent"; import { fmt, formatModelName } from "../../lib/format"; import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments"; interface MessageListProps { messages: TranscriptMessage[]; loading: 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; } /** 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 (
); } } } /** 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; }); } export function MessageList({ messages, loading }: MessageListProps) { const { t } = useTranslation("sessions"); const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]); const keys = useMemo(() => messageKeys(messages), [messages]); if (loading) { return (
{t("conversation.loading")}
); } if (messages.length === 0) { return
{t("conversation.empty")}
; } return (
{messages.map((msg, idx) => { // Session lifecycle markers (e.g. /rename) render as a centered chip, // not as a user/assistant row. if (msg.type === "session_event") { return ; } // Skip user messages that are purely tool_result - they're rendered inside ToolCallBlock if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) { return null; } return ; })}
); } /** 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(msg.usage.input_tokens)} · ↑ {fmt(msg.usage.output_tokens)} )} {msg.timestamp && ( {formatLocalTime(msg.timestamp)} )}
{/* Content blocks */} {msg.content.map((block, bIdx) => { if (block.type === "text" && block.text) { // Detect task notifications, collapsed by default if (isTaskNotification(block.text)) { return ( } title={t("conversation.taskNotification")} borderClass="border-syntax-property/20" bgClass="bg-syntax-property/5" textClass="text-syntax-property" /> ); } // Detect skill content, collapsed by default if (isSkillContent(block.text)) { const pathMatch = block.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(block.text)) { const segments = parseTuiSegments(block.text); return (
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
); } return (
); } 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; })}
); });