/** * @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. * * ## Observability * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and * Docker Compose profiles are documented in `monitoring/README.md`. * * ## 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. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/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 { useState, useMemo } from "react"; 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, { label: string; icon: typeof User; avatarRing: string; accentBar: string; headerText: string } > = { user: { label: "User", icon: User, avatarRing: "bg-gradient-to-br from-blue-500/30 to-cyan-500/20 text-blue-200 ring-1 ring-blue-400/30", accentBar: "before:bg-blue-500/40", headerText: "text-blue-200", }, assistant: { label: "Assistant", icon: Bot, avatarRing: "bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-200 ring-1 ring-violet-400/30", accentBar: "before:bg-violet-500/40", headerText: "text-violet-200", }, orchestrator: { label: "Main agent", icon: Workflow, avatarRing: "bg-gradient-to-br from-teal-500/30 to-emerald-500/20 text-teal-200 ring-1 ring-teal-400/30", accentBar: "before:bg-teal-500/40", headerText: "text-teal-200", }, system: { label: "System", icon: Cog, avatarRing: "bg-gradient-to-br from-slate-500/30 to-gray-500/20 text-gray-300 ring-1 ring-slate-400/30", accentBar: "before:bg-slate-500/40", headerText: "text-gray-300", }, tool: { label: "Tool", icon: Terminal, avatarRing: "bg-gradient-to-br from-amber-500/30 to-orange-500/20 text-amber-200 ring-1 ring-amber-400/30", accentBar: "before:bg-amber-500/40", headerText: "text-amber-200", }, }; 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-red-500/30 bg-red-950/30 text-red-200/90" : "border-surface-3 bg-surface-4/60 text-gray-200"; const labelColor = isErr ? "text-red-300/80" : "text-gray-400"; 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="System reminder" borderClass="border-amber-500/20" bgClass="bg-amber-500/5" textClass="text-amber-300/80" /> ); case "persisted-output": return ( } title="Persisted output" borderClass="border-violet-500/20" bgClass="bg-violet-500/5" textClass="text-violet-300/80" /> ); 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}
          
)}
); } export function MessageList({ messages, loading }: MessageListProps) { const [expandedThinking, setExpandedThinking] = useState>(() => new Set()); if (loading) { return (
Loading conversation...
); } if (messages.length === 0) { return (
No conversation records found.
); } const toolResultMap = buildToolResultMap(messages); // Track which user messages are pure tool_result (no text) - we merge those into the preceding assistant message const userMsgHasText = useMemo(() => { const map = new Map(); messages.forEach((msg, idx) => { if (msg.type !== "user") return; const hasText = msg.content.some((c) => c.type === "text"); map.set(idx, hasText); }); return map; }, [messages]); 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" && !userMsgHasText.get(idx)) { return null; } 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 */}
{style.label} {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="Task Notification" borderClass="border-cyan-500/20" bgClass="bg-cyan-500/5" textClass="text-cyan-400/80" /> ); } // 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-blue-500/20" bgClass="bg-blue-500/5" textClass="text-blue-400/80" /> ); } // 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 = idx * 100 + 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; })}
); })}
); }