Files
AgentHub/client/src/components/conversation/MessageList.tsx
T
nntrivi2001 1c5521cf2e perf: rest animations, cheaper chat typing and server polls, async per-tool hooks
UI
- No animation runs forever: Sagi moods, the custom pet sprite, status dots
  (pulse-dot, pulse-slow, ping) and the chat shimmer play a few cycles and
  rest. An infinite animation kept the browser drawing 60 frames a second.
- useSagiBrain: frames that only move lastActivityAt by <1 s keep the old
  state; the 1 s tick re-renders only when the mood changes.
- ConversationView and MessageList are memoized and PaneChat passes stable
  callbacks: typing in the chat box no longer re-renders the conversation.
- The chat's 700 ms menu poll reads plain text first and scans the terminal
  cell by cell (reusing one cell) only when a row could be a menu cursor row.

Server
- GET /sessions/:id/transcript?after=N skips lines <= N before JSON.parse.
- GET /sessions/:id/outline is cached until the session's events change.
- broadcastLane uses the cached tmux.liveSessions(); tmux.hasSession answers
  "yes" from the fresh shared list instead of a synchronous spawn (50-150 ms
  per psmux call on Windows, blocking terminal echo).
- pane-mode polls every 150 ms; writeHourly writes only changed hour buckets.
- /api/metrics: agenthub_event_loop_delay_seconds{quantile}.

Hooks
- PreToolUse/PostToolUse run with "async": true (settings installer,
  plugin.json, S3 managed settings): Claude Code no longer waits a Node start
  before and after every tool call.

Committed with --no-verify at the owner's request: the pre-commit full server
suite fails on 9 tests that fail identically on 0c2f498 (lane-sync merge: no
git identity on this host; read-pool; resolveHost; codex-usage 1 ms flake).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:55:53 +07:00

1556 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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: <drop>]` 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* 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,
} 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, 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";
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;
/** 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;
}
/** Build a map from tool_use id → tool_result for matching */
function buildToolResultMap(messages: TranscriptMessage[]): Map<string, TranscriptContent> {
const map = new Map<string, TranscriptContent>();
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<typeof buildToolResultMap>;
/** 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<TranscriptMessage["usage"]>): number {
return (
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
);
}
const usageTitle = (u: NonNullable<TranscriptMessage["usage"]>) =>
`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 <task-notification> tag) */
function isTaskNotification(text: string): boolean {
return text.includes("<task-notification>") || text.includes("<task-id>");
}
/** 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 (
<div className="flex items-center justify-center py-1">
<div className="inline-flex items-center gap-2 text-[11px] text-fg-secondary bg-surface-2/70 border border-surface-3 rounded-full px-3 py-1 max-w-full">
<Pencil className="w-3 h-3 text-violet-700/70 dark:text-violet-300/70 flex-shrink-0" />
<span className="text-fg-muted">Renamed session →</span>
<span className="text-fg-secondary font-medium truncate">{title || "(untitled)"}</span>
{timestamp && (
<span className="text-[10px] text-fg-muted font-mono flex-shrink-0">
{formatLocalTime(timestamp)}
</span>
)}
</div>
</div>
);
}
/** 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" ? (
<Fragment key={i}>{linkify(tok.text)}</Fragment>
) : (
<span
key={i}
className={`rounded-md px-1.5 py-0.5 font-mono text-[0.92em] font-medium ${TOKEN_TONE[tok.kind]}`}
>
{tok.text}
</span>
)
);
}
/** 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 (
<div className="inline-flex max-w-full items-baseline gap-2 rounded-lg border border-accent/25 bg-accent/10 px-2.5 py-1 font-mono text-sm">
<span className="font-semibold text-accent">{name}</span>
{args.length > 0 && (
<span className="break-all text-fg-secondary">{typedText(args.join(" "))}</span>
)}
</div>
);
}
/** 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 (
<div className={`rounded-lg border ${accent} overflow-hidden`}>
<div
className={`flex items-center gap-1.5 px-3 py-1 text-[10px] uppercase tracking-wider border-b border-current/10 ${labelColor}`}
>
<Terminal className="w-3 h-3" />
<span>{stream}</span>
</div>
<pre className="px-3 py-2 text-xs font-mono whitespace-pre-wrap break-words leading-relaxed max-h-96 overflow-y-auto">
{cleaned}
</pre>
</div>
);
}
/** Subtle inline note for the local-command-caveat banner. */
function CaveatBlock({ text }: { text: string }) {
return (
<div className="flex items-start gap-2 rounded-md border border-status-warning/15 bg-status-warning/[0.05] px-3 py-1.5 text-[11px] text-status-warning/70">
<Info className="w-3.5 h-3.5 mt-px flex-shrink-0 opacity-60" />
<span className="leading-relaxed italic">{stripAnsi(text).trim()}</span>
</div>
);
}
/** Render a single segment produced by parseTuiSegments. */
function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
switch (seg.kind) {
case "command":
return <CommandPill key={key} display={seg.display} />;
case "stdout":
return <TerminalBlock key={key} text={seg.text} stream="stdout" />;
case "stderr":
return <TerminalBlock key={key} text={seg.text} stream="stderr" />;
case "caveat":
return <CaveatBlock key={key} text={seg.text} />;
case "system-reminder":
return (
<CollapsibleBlock
key={key}
text={seg.text}
icon={<AlertTriangle className="w-3.5 h-3.5 text-status-warning/70 flex-shrink-0" />}
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 (
<CollapsibleBlock
key={key}
text={seg.text}
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-keyword/70 flex-shrink-0" />}
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 (
<div key={key} className="min-w-0">
<MarkdownContent text={cleaned} />
</div>
);
}
}
}
/** 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 (
<CollapsibleBlock
key={key}
text={text}
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-property/70 flex-shrink-0" />}
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 (
<CollapsibleBlock
key={key}
text={text}
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-builtin/70 flex-shrink-0" />}
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 (
<div key={key} className="space-y-2 min-w-0">
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
</div>
);
}
return (
<div key={key} className="min-w-0">
<MarkdownContent text={stripAnsi(text)} />
</div>
);
}
/** 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 (
<div className={`rounded-lg border ${borderClass} ${bgClass} overflow-hidden`}>
<button
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:opacity-80 transition-colors"
>
{expanded ? (
<ChevronDown className="w-3.5 h-3.5 opacity-60 flex-shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 opacity-60 flex-shrink-0" />
)}
{icon}
<span className={`text-xs ${textClass} truncate`}>{title}</span>
</button>
{expanded && (
<div className="border-t border-current/10 px-3 py-2">
<pre className="text-xs opacity-60 whitespace-pre-wrap break-words leading-relaxed max-h-96 overflow-y-auto">
{text}
</pre>
</div>
)}
</div>
);
}
/** 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<string, number>();
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 };
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[] = [];
// 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;
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. */
function EditedFilesCard({
blocks,
toolResultMap,
onOpenFile,
}: {
blocks: TranscriptContent[];
toolResultMap: Map<string, TranscriptContent>;
onOpenFile?: (path: string) => void;
}) {
const { t } = useTranslation("sessions");
// `counted`: a result carried its +/- lines (an older server sends none).
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
for (const b of blocks) {
const input = (b.input ?? {}) as Record<string, unknown>;
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 && (
<span className="flex-shrink-0 font-mono text-xs">
<span className="text-status-success">+{a}</span>{" "}
<span className="text-status-danger">−{r}</span>
</span>
);
return (
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
<div
draggable
onDragStart={(e) => 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"
>
<Pencil className="h-3.5 w-3.5 text-fg-muted" />
<span className="flex-1">{t("conversation.edited", { count: files.size })}</span>
{counts(added, removed)}
</div>
{[...files].map(([p, f]) => (
<button
key={p}
type="button"
title={p}
disabled={!onOpenFile}
onClick={() => onOpenFile?.(p)}
draggable
onDragStart={(e) => dragAttachments(e, [projectAttachment(p)])}
className="flex w-full items-center gap-2 border-b border-border px-3 py-2 text-left text-sm text-fg-secondary last:border-b-0 enabled:hover:bg-surface-2"
>
<FileText className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate">{p.split("/").pop()}</span>
{counts(f.added, f.removed, f.counted)}
{onOpenFile && <ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />}
</button>
))}
</div>
);
}
const EditedFiles = memo(
EditedFilesCard,
(p, n) =>
p.onOpenFile === n.onOpenFile &&
sameBlocks(p.blocks, n.blocks) &&
sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
);
function MessageListView({
messages,
loading,
onOpenFile,
live = false,
waiting = false,
onOpenTerminal,
stopped = 0,
}: 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(messages, keys), [messages, keys]);
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")}
</div>
);
}
if (messages.length === 0) {
return <div className="text-center py-12 text-fg-muted text-sm">{t("conversation.empty")}</div>;
}
return (
<OpenFileContext.Provider value={onOpenFile}>
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
{items.map((item) => {
switch (item.kind) {
case "event":
return (
<SessionEventRow
key={item.key}
title={item.msg.title}
timestamp={item.msg.timestamp}
/>
);
case "row":
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
case "user":
return <UserBubble key={item.key} msg={item.msg} />;
case "text":
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
case "work":
return (
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
);
case "ask":
return (
<AskCard
key={item.key}
block={item.block}
result={item.block.id ? (toolResultMap.get(item.block.id) ?? null) : null}
/>
);
case "edits":
// The card closes a finished turn; one still working has none yet.
if (pending && item === items[items.length - 1]) return null;
return (
<EditedFiles
key={item.key}
blocks={item.blocks}
toolResultMap={toolResultMap}
onOpenFile={onOpenFile}
/>
);
case "done":
return <TurnDone key={item.key} ms={item.ms} output={item.output} />;
}
})}
{pending && (
<RunningStep
{...pending}
start={stats?.start ?? null}
tokens={stats?.output ?? 0}
waiting={waiting}
onOpenTerminal={onOpenTerminal}
/>
)}
</div>
</OpenFileContext.Provider>
);
}
// 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 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<string, TranscriptContent>,
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;
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;
}
/** 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<TranscriptMessage["usage"]>) {
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;
}
/** 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<string, unknown> | 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 [now, setNow] = useState(() => Date.now());
const [showAll, setShowAll] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// A hidden tab or a hidden pane (another workspace, kept mounted) shows
// no clock: skip the re-render until it is on screen again.
const timer = window.setInterval(() => {
const el = boxRef.current;
if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0))
return;
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, []);
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)),
tokens > 0 && t("conversation.turnTokens", { tokens: fmt(counted) }),
quiet && !first && t("conversation.quiet", { time: formatMs(age(last)) }),
].filter(Boolean);
const amber = waiting || quiet;
return (
<div ref={boxRef} data-testid="chat-running-step" className="space-y-1 text-sm text-fg-muted">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{waiting ? (
<Hand className="h-3.5 w-3.5 flex-shrink-0 text-status-warning" />
) : (
<Loader2
className={`h-3.5 w-3.5 flex-shrink-0 animate-spin ${quiet ? "text-status-warning" : "text-accent"}`}
/>
)}
<span className={`min-w-0 truncate ${amber ? "text-status-warning" : "text-shimmer"}`}>
{title}
</span>
{blocks.length > 1 && (
<button
type="button"
onClick={() => setShowAll((v) => !v)}
aria-expanded={showAll}
className="flex-shrink-0 rounded-full border border-border px-1.5 text-xs hover:bg-surface-2"
>
{t("conversation.moreSteps", { count: blocks.length - 1 })}
</button>
)}
{step > STEP_SHOWN_MS && (
<span
className={`flex-shrink-0 font-mono text-xs ${step > QUIET_MS ? "text-status-warning" : ""}`}
>
{formatMs(step)}
</span>
)}
{extra.length > 0 && (
<span className="flex-shrink-0 font-mono text-xs">{`(${extra.join(" · ")})`}</span>
)}
{waiting && onOpenTerminal && (
<button
type="button"
data-testid="chat-open-terminal"
onClick={onOpenTerminal}
className="flex flex-shrink-0 items-center gap-1 rounded-md border border-status-warning/40 px-2 py-0.5 text-xs text-status-warning hover:bg-status-warning/10"
>
<SquareTerminal className="h-3.5 w-3.5" />
{t("conversation.openTerminal")}
</button>
)}
</div>
{showAll && blocks.length > 1 && (
<ul className="ml-5 space-y-0.5 text-xs">
{blocks.map((b, i) => (
<li key={b.id ?? i} className="truncate">
{stepTitle(b)}
</li>
))}
</ul>
)}
</div>
);
}
/** Under a turn that ended: how long it took and the output it made. */
/** 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 (
<div
data-testid="chat-ask"
className="space-y-2 rounded-xl border border-border px-3 py-2 text-sm"
>
{questions.map((q, i) => {
const answer = stripRecommended(result?.answers?.[q]);
return (
<div key={i}>
<div className="flex items-start gap-1.5 text-fg-secondary">
<MessageCircleQuestion className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<span>{q}</span>
</div>
{answer && (
<div className="ml-5 mt-0.5 flex items-start gap-1.5 font-medium text-fg-primary">
<CornerDownRight className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
<span className="whitespace-pre-wrap">{answer}</span>
</div>
)}
</div>
);
})}
{result?.is_error && (
<div className="text-xs text-fg-muted">{t("conversation.askDeclined")}</div>
)}
</div>
);
}
function TurnDone({ ms, output }: { ms: number; output: number }) {
const { t } = useTranslation("sessions");
return (
<div data-testid="chat-turn-done" className="flex items-center gap-1.5 text-xs text-fg-muted">
<Check className="h-3 w-3 flex-shrink-0" />
{[
t("conversation.turnDone", { time: formatMs(ms) }),
output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }),
]
.filter(Boolean)
.join(" · ")}
</div>
);
}
const meta = "text-[10px] font-mono text-fg-muted";
// What the prompt box sends for an attached file: its saved path under the
// drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
// older `<tmp>/agenthub-drops-<uid>/` 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);
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: <drop>]` 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 (
<button
type="button"
disabled={!open}
onClick={() => open?.(path)}
title={path}
className="flex max-w-64 items-center gap-2 rounded-xl border border-border bg-surface-2 px-3 py-2 text-left text-xs enabled:hover:border-accent/50"
>
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="truncate text-fg-primary">{name}</span>
{ext && <span className="shrink-0 font-mono text-[10px] text-fg-muted">{ext}</span>}
</button>
);
}
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 (
<span className="flex items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-2 py-1 text-xs text-fg-muted">
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{name.replace(/^[0-9a-f]{16}-?/, "") || name}</span>
</span>
);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="block overflow-hidden rounded-xl border border-border bg-surface-2 hover:border-accent/50"
>
<img
src={src}
alt={name}
loading="lazy"
onError={() => setGone(true)}
className="max-h-48 max-w-64 object-contain"
/>
</button>
{open && <ImageLightbox src={src} alt={name} onClose={() => 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);
return (
<div
data-testid="chat-user"
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
className="group flex flex-col items-end gap-1"
>
{images.length + files.length > 0 && (
<div
data-testid="chat-user-images"
className="flex max-w-[85%] flex-wrap justify-end gap-2"
>
{images.map((name) => (
<DropImage key={name} name={name} />
))}
{files.map((path) => (
<DropFile key={path} path={path} />
))}
</div>
)}
{hasText && (
<div
className={`max-w-[85%] min-w-0 space-y-2 rounded-2xl bg-surface-2 px-4 py-2.5 text-sm text-fg-primary ${
long && !open
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
: ""
}`}
>
{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 ? (
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
{typedText(stripAnsi(text).trim())}
</p>
) : (
renderTextBlock(text, b)
);
})}
</div>
)}
{long && (
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="text-xs text-fg-muted hover:text-fg-secondary"
>
{open ? t("conversation.showLess") : t("conversation.showMore")}
</button>
)}
{msg.timestamp && (
<span className={`${meta} opacity-0 transition-opacity group-hover:opacity-100`}>
{formatLocalTime(msg.timestamp)}
</span>
)}
</div>
);
});
/** 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 (
<div
data-testid="chat-assistant"
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 120px" }}
className="group min-w-0"
>
{hasTuiTags(text) || isSkillContent(text) || isTaskNotification(text) ? (
renderTextBlock(text, 0)
) : (
<MarkdownContent text={stripAnsi(text)} prose />
)}
<div
className={`${meta} mt-1 flex gap-2 opacity-0 transition-opacity group-hover:opacity-100`}
>
{msg.model && <span>{formatModelName(msg.model)}</span>}
{msg.usage && (
<span title={usageTitle(msg.usage)}>
↓ {fmt(promptTokens(msg.usage))} · ↑ {fmt(msg.usage.output_tokens)}
</span>
)}
{msg.timestamp && <span>{formatLocalTime(msg.timestamp)}</span>}
</div>
</div>
);
});
/** 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 (
<div>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="inline-flex items-center gap-1 text-sm text-fg-muted transition-colors hover:text-fg-secondary"
>
{label}
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-90" : ""}`} />
</button>
{open && <div className="mt-2 space-y-2">{children}</div>}
</div>
);
}
/** 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<string, string> = {
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<string, number>();
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<string, unknown> | undefined;
const title = thinking
? t("conversation.thinking")
: typeof input?.description === "string" && input.description
? input.description
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
return (
<div className="px-3 py-2">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className={`flex w-full min-w-0 items-center gap-1 text-left text-sm transition-colors hover:text-fg-secondary ${
result?.is_error ? "text-status-danger" : "text-fg-muted"
}`}
>
<span className="truncate">{title}</span>
<ChevronDown
className={`h-3.5 w-3.5 shrink-0 transition-transform ${open ? "" : "-rotate-90"}`}
/>
</button>
{open && (
<div className="mt-2 space-y-2">
{thinking ? (
<div className="max-h-60 overflow-auto text-fg-muted">
<MarkdownContent text={block.text ?? ""} dense />
</div>
) : (
<>
<div className="max-h-60 overflow-auto">
{/* The description is the title already. */}
{renderInput(
input ? { ...block, input: { ...input, description: undefined } } : block
)}
</div>
{result && (
<button
type="button"
aria-expanded={showResult}
onClick={() => setShowResult(!showResult)}
className="inline-flex items-center gap-1 text-xs text-fg-muted hover:text-fg-secondary"
>
{result.is_error ? t("conversation.tool.error") : t("conversation.tool.output")}
<ChevronRight
className={`h-3 w-3 transition-transform ${showResult ? "rotate-90" : ""}`}
/>
</button>
)}
{result && showResult && (
<div className="max-h-60 overflow-auto">
{renderResult(result, block.name ?? "")}
</div>
)}
</>
)}
</div>
)}
</div>
);
}
/** 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<typeof buildToolResultMap>;
}) {
return (
<div data-testid="chat-tools">
<Disclosure label={workLabel(blocks)}>
<div className="divide-y divide-border overflow-hidden rounded-xl border border-border">
{blocks.map((block, i) => (
<WorkStep
key={block.id ?? i}
block={block}
result={block.id ? (toolResultMap.get(block.id) ?? null) : null}
/>
))}
</div>
</Disclosure>
</div>
);
}
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<typeof buildToolResultMap>;
}) {
const { t } = useTranslation("sessions");
const [expandedThinking, setExpandedThinking] = useState<Set<number>>(() => 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 (
<div
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 120px" }}
className={`relative flex gap-3 rounded-xl px-3 py-2.5 hover:bg-surface-2/30 transition-colors before:absolute before:left-0 before:top-3 before:bottom-3 before:w-0.5 before:rounded-full before:opacity-60 ${style.accentBar}`}
>
{/* Avatar */}
<div
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mt-0.5 shadow-sm ${style.avatarRing}`}
>
<SenderIcon className="w-4 h-4" />
</div>
{/* Message body */}
<div className="flex-1 min-w-0 space-y-2">
{/* Header line */}
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-semibold tracking-wide ${style.headerText}`}>
{t(`conversation.sender.${sender}`)}
</span>
{msg.model && (
<span className="text-[10px] text-fg-secondary font-mono bg-surface-3/60 border border-surface-3 rounded px-1.5 py-0.5">
{formatModelName(msg.model)}
</span>
)}
{msg.usage && (
<span
title={usageTitle(msg.usage)}
className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1"
>
<span className="text-status-success/70">↓ {fmt(promptTokens(msg.usage))}</span>
<span className="text-fg-muted">·</span>
<span className="text-syntax-number">↑ {fmt(msg.usage.output_tokens)}</span>
</span>
)}
{msg.timestamp && (
<span className="text-[10px] text-fg-muted ml-auto font-mono">
{formatLocalTime(msg.timestamp)}
</span>
)}
</div>
{/* 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 (
<div
key={bIdx}
className="rounded-lg border border-status-warning/20 bg-status-warning/5 overflow-hidden"
>
<button
aria-expanded={isExpanded}
onClick={() =>
setExpandedThinking((prev) => {
const next = new Set(prev);
if (next.has(thinkKey)) next.delete(thinkKey);
else next.add(thinkKey);
return next;
})
}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-status-warning/10 transition-colors"
>
<ChevronRight
className={`w-3.5 h-3.5 text-status-warning/60 transition-transform duration-150 ${
isExpanded ? "rotate-90" : ""
}`}
/>
<Brain className="w-3.5 h-3.5 text-status-warning/80" />
<span className="text-xs text-status-warning/90 font-medium">
{t("conversation.thinking")}
</span>
{!isExpanded && (
<span className="text-[10px] text-status-warning/40 font-mono ml-auto">
{t("conversation.chars", {
count: block.text.length,
formatted: block.text.length.toLocaleString(),
})}
</span>
)}
</button>
{isExpanded && (
<div className="border-t border-status-warning/10 px-3 py-2 text-status-warning/80">
<MarkdownContent text={block.text} dense />
</div>
)}
</div>
);
}
if (block.type === "tool_use") {
const matchedResult = block.id ? (toolResultMap.get(block.id) ?? null) : null;
return <ToolCallBlock key={bIdx} toolUse={block} toolResult={matchedResult} />;
}
// tool_result blocks rendered inside ToolCallBlock, skip standalone
return null;
})}
</div>
</div>
);
}
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);