Files
AgentHub/client/src/components/conversation/MessageList.tsx
T
nntrivi2001 383b9ae231 feat(chat): terminal chat parity for every tool; Desktop-like artifacts
- The chat over a terminal pane now covers Claude, Codex, omp, Cursor and
  Antigravity: Cursor's agent transcript and agy's step log
  (brain/<id>/.system_generated/logs/transcript_full.jsonl) are read as
  conversations; Cursor sessions are claimed by their workspace
- Replies appear as soon as they are written: the projects-tree watcher
  broadcasts `transcript_changed` (at most every 250 ms per session) and the
  chat re-reads the transcript while the terminal underneath draws
- Stop shows from the pane's own busy line (Claude/Codex `esc to interrupt`,
  agy `… esc to cancel`), or fresh output for omp/Cursor — no hooks needed
- Permission prompts read off the terminal become a card with the tool, its
  command and "Deny with a note"; Codex gets Permission mode / Model buttons
  that open its own numbered pickers, answered from the menu card
- Artifacts: cards for pages/documents/diagrams/tables/React components, a
  Preview | Code panel with versions, copy, download, save to the Artifacts
  Hub, full screen; sandboxed iframes without allow-same-origin
- Claude panes get CLAUDE_CODE_ENABLE_TODO_TOOLS=1 so the Progress panel has a
  plan; multi-question pane menus read their Next row and question tabs
- The realtime chat bridge (structured stdio, /api/chat-bridge, /ws-chat)
  from the unreleased ad9d475 is not kept
2026-10-21 15:44:17 +07:00

2121 lines
80 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. A live chat also lists the
* subagents it launched in the background that have not reported back yet
* (an `Agent` call whose result is "Async agent launched…" and no
* `<task-notification>` naming its call since), like the terminal's footer:
* their turn returns at once, so without the list a turn that ended reads
* "Done" while they still work (issue #81) — it then reads "Replied in …".
* Given the `sessionId`, each line also shows what that agent is doing now
* and how large its context is, read off the tail of its own transcript.
* @author Nguyễn Ngọc Trí Vĩ <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,
Asterisk,
Copy,
X,
Search,
Globe,
Wrench,
Image as ImageIcon,
Code2,
Table2,
} from "lucide-react";
import { artifactKind, isArtifact, type ArtifactKind } from "../../lib/artifactView";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
import { api } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
/** The icon of a transcript row's sender. A JSONL `type:"user"` line is not
* always the human (tool results, harness task-notifications, the
* orchestrator's task to a subagent): each sender is named, quietly, as a
* note beside the conversation rather than a turn of it. */
const SENDER_ICONS: Record<TranscriptSender, typeof User> = {
user: User,
assistant: Bot,
orchestrator: Workflow,
system: Cog,
tool: Terminal,
};
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
import { dragAttachments, projectAttachment } from "../../lib/promptAttach";
import { TOKEN_TONE, promptTokens as splitPrompt } from "../../lib/promptTokens";
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
import {
AgentChatBox,
AgentContext,
AgentStepBody,
agentRef,
isAgentCall,
type AgentLinks,
} from "./AgentDetails";
interface MessageListProps {
messages: TranscriptMessage[];
loading: boolean;
/** Opens a file named in the "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** Opens an artifact card's file in the chat's artifact panel. */
onOpenArtifact?: (path: string) => void;
/** The session is running in a pane: show the step in progress. */
live?: boolean;
/** The CLI is asking the user something (a permission prompt): the live
* line says so instead of "Thinking…". */
waiting?: boolean;
/** Shows the pane's terminal, where that question is answered. */
onOpenTerminal?: () => void;
/** Bumped by the chat's Stop: the turn on screen is over, even when the
* transcript never says so. */
stopped?: number;
/** The session whose MAIN transcript this is: its background agents'
* own transcripts are read for their progress. */
sessionId?: string;
/** Which agent transcript each Agent call launched, and how to open one. */
agentLinks?: AgentLinks;
/** A subagent's conversation inside its step: tight spacing, and the task
* the orchestrator gave it reads as the prompt it is. */
compact?: boolean;
}
/** Build a map from tool_use id → tool_result for matching */
function buildToolResultMap(messages: TranscriptMessage[]): Map<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 }
| { kind: "command"; key: string; name: string | null; output: string | null; error: boolean };
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
/** A line that is only Claude Code's local-command plumbing — the caveat it
* writes first, the command, what the command printed — or null for
* anything else (prose around the tags, a skill's body). */
export function localCommand(
msg: TranscriptMessage
): { name: string | null; output: string | null; error: boolean } | null {
const texts = msg.content.filter((c) => c.type === "text" && c.text).map((c) => c.text!);
if (!texts.length || texts.length !== msg.content.length) return null;
const text = texts.join("\n");
if (!hasTuiTags(text)) return null;
let name: string | null = null;
let output: string | null = null;
let error = false;
for (const seg of parseTuiSegments(text)) {
if (seg.kind === "command") name = seg.display;
else if (seg.kind === "stdout" || seg.kind === "stderr") {
output = [output, stripAnsi(seg.text).trim()].filter(Boolean).join("\n");
error ||= seg.kind === "stderr";
} else if (seg.kind === "text" ? seg.text.trim() : seg.kind !== "caveat") return null;
}
return { name, output, error };
}
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
const items: Item[] = [];
// The file-changing calls of the turn so far, closed into one card when the
// human speaks again (or the list ends).
let edits: TranscriptContent[] = [];
// The turn's prompt time, its newest assistant line and its output so far,
// for the "Done in …" line under a turn that ended.
let start: string | null = null;
let reply: TranscriptMessage | null = null;
let output = outputCounter();
const endTurn = (key: string) => {
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits });
edits = [];
if (start && reply?.timestamp && reply.stop_reason === "end_turn") {
const ms = parseDate(reply.timestamp).getTime() - parseDate(start).getTime();
items.push({ kind: "done", key: `done:${key}`, ms, output: output.total });
}
reply = null;
output = outputCounter();
};
messages.forEach((msg, idx) => {
const key = keys[idx]!;
if (msg.type === "session_event") return items.push({ kind: "event", key, msg });
// Purely tool_result: shown inside its call's block.
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) return;
// A local command (`/clear`, `/model`) is one quiet line with what it
// printed; its caveat is CLI plumbing. A command whose output never
// follows is a skill the model answers: it stays the prompt it is.
const local = msg.type === "assistant" ? null : localCommand(msg);
if (local) {
const last = items[items.length - 1];
if (local.name === null && local.output === null) return;
if (local.name === null) {
if (last?.kind === "command" && last.output === null) {
last.output = local.output;
last.error = local.error;
return;
}
return items.push({ kind: "command", key, ...local });
}
const next = messages[idx + 1] ? localCommand(messages[idx + 1]!) : null;
if (local.output !== null || (next && next.name === null && next.output !== null)) {
endTurn(key);
start = null;
return items.push({ kind: "command", key, ...local });
}
}
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
if (sender === "user") {
endTurn(key);
start = msg.timestamp ?? null;
return items.push({ kind: "user", key, msg });
}
if (sender !== "assistant") {
// Claude Code's line for the image a prompt attached: the image joins that prompt.
const paths = sender === "system" ? imageSourcePaths(msg) : null;
const prompt = paths ? [...items].reverse().find((i) => i.kind === "user") : undefined;
if (paths && prompt?.kind === "user") {
prompt.msg = {
...prompt.msg,
content: [
...prompt.msg.content.map((c) =>
c.type === "text" && c.text
? { ...c, text: c.text.replace(/\[Image #\d+\]/g, "") }
: c
),
{ type: "text", text: paths.join("\n") },
],
};
return;
}
return items.push({ kind: "row", key, msg });
}
reply = msg;
if (msg.usage) output.add(msg.usage);
msg.content.forEach((block, b) => {
const last = items[items.length - 1];
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
// A question to the user is a turn of the conversation, not hidden work.
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
items.push({ kind: "ask", key: `${key}:${b}`, block });
} else if (block.type === "tool_use") {
if (last?.kind === "work") last.blocks.push(block);
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
items.push({ kind: "text", key: `${key}:${b}`, msg, text: block.text });
}
});
});
endTurn(keys[keys.length - 1] ?? "end");
return items;
}
/** Claude Desktop's end-of-turn card: the files the turn changed, each with
* the lines added and removed. A call still running or failed is left out. */
const ARTIFACT_ICON: Partial<Record<ArtifactKind, typeof FileText>> = {
html: Globe,
svg: ImageIcon,
markdown: FileText,
mermaid: Workflow,
react: Code2,
csv: Table2,
};
/** Claude Desktop's artifact card: what the model made, opened beside the chat. */
function ArtifactCard({ path, onOpen }: { path: string; onOpen?: (path: string) => void }) {
const { t } = useTranslation("sessions");
const kind = artifactKind(path);
const Icon = ARTIFACT_ICON[kind] ?? FileText;
const name = path.split(/[\\/]/).pop() || path;
return (
<button
type="button"
data-testid="artifact-card"
title={path}
disabled={!onOpen}
onClick={() => onOpen?.(path)}
draggable
onDragStart={(e) => dragAttachments(e, [projectAttachment(path)])}
className="flex w-full max-w-md items-center gap-3 rounded-xl border border-border bg-surface-1 px-3 py-2.5 text-left transition-colors enabled:hover:border-border-light enabled:hover:bg-surface-2"
>
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border border-border bg-surface-2">
<Icon className="h-4 w-4 text-fg-secondary" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-fg-primary">
{name.replace(/\.[A-Za-z0-9]+$/, "")}
</span>
<span className="block truncate text-xs text-fg-muted">
{t(`conversation.artifact.${kind}`, { defaultValue: kind })}
{onOpen ? ` · ${t("conversation.artifact.open")}` : ""}
</span>
</span>
</button>
);
}
function EditedFilesCard({
blocks,
toolResultMap,
onOpenFile,
onOpenArtifact,
pending = false,
}: {
blocks: TranscriptContent[];
toolResultMap: Map<string, TranscriptContent>;
onOpenFile?: (path: string) => void;
/** Opens an artifact (a page, document, diagram…) in the chat's own panel. */
onOpenArtifact?: (path: string) => void;
/** The turn is still working: only its artifact cards so far. */
pending?: boolean;
}) {
const { t } = useTranslation("sessions");
// `counted`: a result carried its +/- lines (an older server sends none).
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
// Written whole in this turn (a Write): a component made here is an artifact.
const created = new Set<string>();
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;
if (b.name === "Write") created.add(p);
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += r.diff?.added ?? 0;
f.removed += r.diff?.removed ?? 0;
f.counted ||= !!r.diff;
files.set(p, f);
}
const artifacts = [...files.keys()].filter((p) => isArtifact(p, created.has(p)));
const cards = artifacts.length > 0 && (
<div data-testid="artifact-cards" className="flex flex-col gap-2">
{artifacts.map((p) => (
<ArtifactCard key={p} path={p} onOpen={onOpenArtifact ?? onOpenFile} />
))}
</div>
);
if (pending) return cards || null;
for (const p of artifacts) files.delete(p);
if (files.size === 0) return cards || null;
let added = 0;
let removed = 0;
let counted = false;
files.forEach((f) => {
added += f.added;
removed += f.removed;
counted ||= f.counted;
});
const counts = (a: number, r: number, show = counted) =>
show && (
<span className="flex-shrink-0 font-mono text-xs">
<span className="text-status-success">+{a}</span>{" "}
<span className="text-status-danger">−{r}</span>
</span>
);
const list = (
<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>
);
return cards ? (
<div className="flex flex-col gap-2">
{cards}
{list}
</div>
) : (
list
);
}
const EditedFiles = memo(
EditedFilesCard,
(p, n) =>
p.onOpenFile === n.onOpenFile &&
p.onOpenArtifact === n.onOpenArtifact &&
p.pending === n.pending &&
sameBlocks(p.blocks, n.blocks) &&
sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
);
const NO_AGENT_LINKS: AgentLinks = { ids: new Map(), notices: new Map(), live: false };
function MessageListView({
messages,
loading,
onOpenFile,
onOpenArtifact,
live = false,
waiting = false,
onOpenTerminal,
stopped = 0,
sessionId,
agentLinks,
compact = false,
}: MessageListProps) {
const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
const keys = useMemo(() => messageKeys(messages), [messages]);
const lastKey = keys[keys.length - 1] ?? null;
// The newest line when Stop was pressed. Esc before the model wrote anything
// puts the prompt back in Claude Code's input box and writes no
// "[Request interrupted…]" line (issue #74), so the transcript still ends on
// the prompt: no live line until a newer one arrives.
const [stop, setStop] = useState<{ n: number; on: string | null }>({ n: stopped, on: null });
if (stop.n !== stopped) setStop({ n: stopped, on: lastKey });
const halted = stop.on !== null && stop.on === lastKey;
const pending = useMemo(
() => (live && !halted ? workingOn(messages, toolResultMap) : null),
[live, halted, messages, toolResultMap]
);
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
const items = useMemo(
() =>
chatItems(
compact
? messages.map((m) =>
m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m
)
: messages,
keys
),
[messages, keys, compact]
);
const agents = useMemo(
() => (live ? backgroundAgents(messages, toolResultMap) : []),
[live, messages, toolResultMap]
);
if (loading) {
return (
<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}>
<AgentContext.Provider value={agentLinks ?? NO_AGENT_LINKS}>
<div
className={
compact
? "space-y-3 py-1 pr-2"
: "mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 py-6"
}
>
{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 shows
// only the artifacts it made so far, as Claude Desktop does.
return (
<EditedFiles
key={item.key}
blocks={item.blocks}
toolResultMap={toolResultMap}
onOpenFile={onOpenFile}
onOpenArtifact={onOpenArtifact}
pending={!!pending && item === items[items.length - 1]}
/>
);
case "done":
// A subagent's thread: its time is in the facts line above it.
if (compact) return null;
return (
<TurnDone
key={item.key}
ms={item.ms}
output={item.output}
agents={item === items[items.length - 1] ? agents.length : 0}
latest={item === items[items.length - 1]}
/>
);
case "command":
return (
<LocalCommand
key={item.key}
name={item.name}
output={item.output}
error={item.error}
/>
);
}
})}
{pending && (
<RunningStep
{...pending}
start={stats?.start ?? null}
tokens={stats?.output ?? 0}
waiting={waiting}
onOpenTerminal={onOpenTerminal}
/>
)}
{agents.length > 0 && (
<BackgroundAgents agents={agents} sessionId={sessionId} toolResultMap={toolResultMap} />
)}
</div>
</AgentContext.Provider>
</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;
const LOCAL_COMMAND_OUTPUT = /^\s*<local-command-(?:stdout|stderr)>/;
/** 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;
// A local command's output (`/clear`, `/model`): the CLI answered it, no turn follows.
if (newest && LOCAL_COMMAND_OUTPUT.test(text)) return null;
if (newest) alive = fresh;
// The prompt that opened the turn: nothing before it is this turn's.
if (text) break;
}
if (blocks.length) return { blocks, since, last };
return alive ? { blocks, since: null, last } : null;
}
// A launch older than this with no notification is from a run that ended
// without one (the CLI quit, a resume of an old session): not running now.
const AGENT_STALE_MS = 6 * 60 * 60_000;
const NOTIFIED_CALL = /<tool-use-id>([^<]+)<\/tool-use-id>/g;
// The launch result names the agent's own transcript (`agent-<id>.jsonl`).
const LAUNCHED_ID = /agentId:\s*([\w-]+)/;
type BackgroundAgent = { block: TranscriptContent; since: string | null; agentId: string | null };
/** The subagents launched in the background that have not reported back: an
* Agent call whose result says it was launched async, with no
* `<task-notification>` naming that call since (any status — completed,
* failed, killed — means it stopped). `since` is its launch time. */
export function backgroundAgents(
messages: TranscriptMessage[],
results: Map<string, TranscriptContent>,
now = Date.now()
): BackgroundAgent[] {
const running = new Map<string, BackgroundAgent>();
for (const msg of messages) {
if (msg.type === "assistant") {
for (const b of msg.content) {
if (b.type !== "tool_use" || !b.id || (b.name !== "Agent" && b.name !== "Task")) continue;
// The output is the result content as JSON when it is a block list.
const output = results.get(b.id)?.output;
if (!output?.includes("Async agent launched")) continue;
const at = msg.timestamp ?? null;
if (at && now - parseDate(at).getTime() > AGENT_STALE_MS) continue;
running.set(b.id, { block: b, since: at, agentId: output.match(LAUNCHED_ID)?.[1] ?? null });
}
continue;
}
if (!running.size) continue;
for (const c of msg.content) {
if (c.type !== "text" || !c.text || !isTaskNotification(c.text)) continue;
for (const m of c.text.matchAll(NOTIFIED_CALL)) running.delete(m[1]!.trim());
}
}
return [...running.values()];
}
/** Output tokens counted once per API call: Claude Code writes one line per
* content block, each repeating its call's usage — consecutive lines with
* the same prompt size are one call. */
function outputCounter() {
let total = 0;
let call: string | null = null;
let callOutput = 0;
return {
add(u: NonNullable<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;
}
/** The time now, ticking every second while `ref`'s box is on screen: a
* hidden tab or a hidden pane (another workspace, kept mounted) shows no
* clock, so the re-render is skipped until it is shown again. */
function useClock(ref: React.RefObject<HTMLElement | null>): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => {
const el = ref.current;
if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0))
return;
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, [ref]);
return now;
}
/** A call's one-line title: its description (Claude Code's Bash
* `description`), else the tool and its target. */
function stepTitle(block: TranscriptContent): string {
const input = block.input as Record<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 [showAll, setShowAll] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
const now = useClock(boxRef);
const counted = useCountUp(tokens);
const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0);
const first = blocks[0];
const step = first ? age(since) : 0;
const quiet = !waiting && age(last) > QUIET_MS;
const title = waiting
? first
? t("conversation.waitingFor", { step: stepTitle(first) })
: t("conversation.waiting")
: first
? stepTitle(first)
: t("conversation.working");
const extra = [
start && formatMs(age(start)),
quiet && !first && t("conversation.quiet", { time: formatMs(age(last)) }),
].filter(Boolean);
// The output so far is a detail: shown on hover, like the turn's own end line.
const written = tokens > 0 ? t("conversation.turnTokens", { tokens: fmt(counted) }) : null;
const amber = waiting || quiet;
return (
<div
ref={boxRef}
data-testid="chat-running-step"
className="group animate-fade-in 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-4 w-4 flex-shrink-0 text-status-warning" />
) : (
// Claude Desktop's turning spark in place of a spinner.
<Asterisk
strokeWidth={2.5}
className={`h-4 w-4 flex-shrink-0 animate-[spin_2.4s_linear_infinite] ${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 text-xs tabular-nums opacity-80">{extra.join(" · ")}</span>
)}
{written && (
<span className="flex-shrink-0 text-xs tabular-nums opacity-0 transition-opacity group-hover:opacity-80">
{written}
</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>
);
}
// How often a running agent's transcript tail is re-read, and how much of it
// is kept: enough for its newest step and its newest usage.
const AGENT_TAIL_MS = 4000;
const AGENT_TAIL_KEEP = 40;
/** The newest lines of each agent's own transcript (by its `agentId`),
* re-read while `ref`'s box is on screen — only the lines after those
* already read (`after`). A failed read leaves the line without detail. */
function useAgentTails(
sessionId: string | undefined,
ids: string[],
ref: React.RefObject<HTMLElement | null>
): Map<string, TranscriptMessage[]> {
const [tails, setTails] = useState(() => new Map<string, TranscriptMessage[]>());
const lastRef = useRef(new Map<string, number>());
const key = ids.join(",");
useEffect(() => {
if (!sessionId || !key) return;
let cancelled = false;
let busy = false;
const read = async () => {
const el = ref.current;
if (
busy ||
document.visibilityState !== "visible" ||
(el && el.getClientRects().length === 0)
)
return;
busy = true;
await Promise.all(
key.split(",").map(async (id) => {
const last = lastRef.current.get(id);
try {
const r = await api.sessions.transcript(sessionId, {
agent_id: id,
...(last ? { after: last } : { limit: 20 }),
});
if (cancelled || !r.messages.length) return;
lastRef.current.set(id, r.last_line);
setTails((prev) => {
const next = new Map(prev);
next.set(id, [...(prev.get(id) ?? []), ...r.messages].slice(-AGENT_TAIL_KEEP));
return next;
});
} catch {
// Non-fatal: the line keeps its description and time.
}
})
);
busy = false;
};
void read();
const timer = window.setInterval(() => void read(), AGENT_TAIL_MS);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [sessionId, key, ref]);
return tails;
}
/** What an agent's transcript tail says it is doing: the call it waits on
* (or none while it thinks), and its newest prompt size — its context. */
export function agentProgress(
messages: TranscriptMessage[],
now = Date.now()
): { step: string | null; context: number } {
let context = 0;
for (let i = messages.length - 1; i >= 0 && !context; i--) {
const u = messages[i]!.usage;
if (messages[i]!.type === "assistant" && u) context = promptTokens(u);
}
const first = workingOn(messages, buildToolResultMap(messages), now)?.blocks[0];
return { step: first ? stepTitle(first) : null, context };
}
/** The subagents still working in the background, one line each like the
* terminal's footer: its type, its task and how long it has run — with its
* step now and its context once its own transcript was read. */
function BackgroundAgents({
agents,
sessionId,
toolResultMap,
}: {
agents: BackgroundAgent[];
sessionId?: string;
toolResultMap: ReturnType<typeof buildToolResultMap>;
}) {
const { t } = useTranslation("sessions");
const links = useContext(AgentContext);
const boxRef = useRef<HTMLDivElement>(null);
const now = useClock(boxRef);
const ids = useMemo(() => agents.flatMap((a) => (a.agentId ? [a.agentId] : [])), [agents]);
const tails = useAgentTails(sessionId, ids, boxRef);
// The agents whose own conversation is open under their line.
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
return (
<div
ref={boxRef}
data-testid="chat-background-agents"
className="overflow-hidden rounded-xl border border-border text-sm"
>
<div className="flex items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-fg-primary">
<Bot className="h-3.5 w-3.5 text-fg-muted" />
<span className="flex-1">{t("conversation.agentsRunning", { count: agents.length })}</span>
</div>
<ul className="divide-y divide-border">
{agents.map(({ block, since, agentId }, i) => {
const input = (block.input ?? {}) as Record<string, unknown>;
const type = typeof input.subagent_type === "string" ? input.subagent_type : "Agent";
const desc = typeof input.description === "string" ? input.description : "";
const tail = agentId ? tails.get(agentId) : undefined;
const { step, context } = tail ? agentProgress(tail, now) : { step: null, context: 0 };
const ref = agentRef(block, block.id ? toolResultMap.get(block.id) : null, links);
const canOpen = !!ref.agentId && !!links.render;
const shown = canOpen && !!block.id && expanded.has(block.id);
const row = (
<>
<div className="flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />
<span className="flex-shrink-0 rounded bg-surface-3/60 px-1.5 py-0.5 font-mono text-xs text-fg-muted">
{type}
</span>
<span className="min-w-0 flex-1 truncate" title={desc}>
{desc}
</span>
{[
since && formatMs(now - parseDate(since).getTime()),
context > 0 && `↓ ${t("conversation.agentContext", { tokens: fmt(context) })}`,
]
.filter(Boolean)
.map((v, j) => (
<span key={j} className="flex-shrink-0 font-mono text-xs text-fg-muted">
{v}
</span>
))}
{canOpen && (
<ChevronRight
className={`h-3.5 w-3.5 flex-shrink-0 text-fg-muted transition-transform ${shown ? "rotate-90" : ""}`}
/>
)}
</div>
{tail && (
<div
data-testid="chat-background-agent-step"
className="ml-[1.375rem] mt-0.5 flex items-center gap-1 text-xs text-fg-muted"
>
<CornerDownRight className="h-3 w-3 flex-shrink-0" />
<span className="min-w-0 truncate">{step ?? t("conversation.working")}</span>
</div>
)}
</>
);
return (
<li
key={block.id ?? i}
data-testid="chat-background-agent"
className="px-3 py-2 text-fg-secondary"
>
{canOpen ? (
<button
type="button"
aria-expanded={shown}
title={t("conversation.agent.open")}
onClick={() => block.id && toggle(block.id)}
className="block w-full text-left transition-colors hover:text-fg-primary"
>
{row}
</button>
) : (
row
)}
{shown && <AgentChatBox agent={ref} />}
</li>
);
})}
</ul>
</div>
);
}
/** Under a turn that ended: how long it took and the output it made — or,
* while subagents it launched still work (`agents`), that only the reply is
* done. */
/** An option's label carries the model's " (Recommended)" marker into the
* answer the CLI records; the user's pick reads without it (every picked
* label of a multi-select answer too). */
function stripRecommended(answer: string | undefined): string | undefined {
return answer?.replace(/\s*\(recommended\)/gi, "").trim();
}
/** An AskUserQuestion: each question with the answer the user picked or
* typed (the result's `answers`, minus the " (Recommended)" marker),
* "declined" when they refused it, no answer yet while the CLI still asks. */
function AskCard({
block,
result,
}: {
block: TranscriptContent;
result: TranscriptContent | null;
}) {
const { t } = useTranslation("sessions");
const input = block.input as { questions?: unknown } | undefined;
const questions = (Array.isArray(input?.questions) ? input.questions : [])
.map((q) => (q && typeof q === "object" ? (q as { question?: unknown }).question : null))
.filter((q): q is string => typeof q === "string");
return (
<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>
);
}
/** A local command as Claude Desktop marks a context change: one centred line
* between rules, a short output beside it, a longer one a click away. */
function LocalCommand({
name,
output,
error,
}: {
name: string | null;
output: string | null;
error: boolean;
}) {
const [open, setOpen] = useState(false);
const text = output ?? "";
const short = !text.includes("\n") && text.length <= 80;
return (
<div data-testid="chat-command" className="my-2 animate-fade-in text-xs text-fg-muted">
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-border" />
<span className="flex min-w-0 max-w-[80%] items-center gap-2">
{name && <span className="shrink-0 font-mono text-fg-secondary">{name}</span>}
{text && short && (
<span className={`truncate ${error ? "text-status-danger" : ""}`}>{text}</span>
)}
{text && !short && (
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className={`inline-flex items-center gap-0.5 hover:text-fg-secondary ${error ? "text-status-danger" : ""}`}
>
{text.split("\n")[0]!.slice(0, 60)}
<ChevronRight className={`h-3 w-3 transition-transform ${open ? "rotate-90" : ""}`} />
</button>
)}
</span>
<span className="h-px flex-1 bg-border" />
</div>
{open && (
<pre className="mt-2 max-h-72 animate-fade-in overflow-auto whitespace-pre-wrap break-words rounded-lg bg-surface-2 px-3 py-2 font-mono leading-relaxed text-fg-secondary">
{text}
</pre>
)}
</div>
);
}
/** The end of a turn: how long it took and what it wrote. Only the newest
* turn shows it; an older one on hover. */
function TurnDone({
ms,
output,
agents = 0,
latest = false,
}: {
ms: number;
output: number;
agents?: number;
latest?: boolean;
}) {
const { t } = useTranslation("sessions");
return (
<div
data-testid="chat-turn-done"
className={`-mt-1 flex items-center gap-1.5 text-[11px] text-fg-muted transition-opacity ${
latest || agents > 0 ? "opacity-80" : "opacity-0 hover:opacity-80"
}`}
>
{agents > 0 ? (
<Loader2 className="h-3 w-3 flex-shrink-0 animate-spin text-accent" />
) : (
<Check className="h-3 w-3 flex-shrink-0" />
)}
{[
t(agents > 0 ? "conversation.turnReplied" : "conversation.turnDone", {
time: formatMs(ms),
}),
output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }),
agents > 0 && t("conversation.agentsStill", { count: agents }),
]
.filter(Boolean)
.join(" · ")}
</div>
);
}
// What the prompt box sends for an attached file: its saved path (an `@`
// mention for claude) 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.replace(/^@/, "")); // a claude prompt's `@` mention
return "";
});
return {
text:
images.length || files.length
? rest
.replace(/\[Image #\d+\]/g, "")
.replace(/[ \t]+$/gm, "")
.trim()
: text,
images,
files,
};
}
/** The drop paths of a meta message made only of `[Image: source: <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);
const copy = blocks
.map((b) => b?.text && stripAnsi(b.text).trim())
.filter(Boolean)
.join("\n\n");
return (
<div
data-testid="chat-user"
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
// A new prompt opens a new turn: more room above it than inside a turn.
className="group mt-5 flex animate-fade-in flex-col items-end gap-1 first:mt-0"
>
{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="min-w-0 max-w-[85%] rounded-2xl bg-surface-2 px-4 py-2.5 text-[15px] text-fg-primary">
<div
className={`space-y-2 ${
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="mt-1 inline-flex items-center gap-0.5 text-xs text-fg-muted hover:text-fg-secondary"
>
{open ? t("conversation.showLess") : t("conversation.showMore")}
<ChevronDown className={`h-3 w-3 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
)}
</div>
)}
<MessageActions text={copy} timestamp={msg.timestamp} />
</div>
);
});
/** Copy, with a tick for a moment once it landed. */
function CopyAction({ text }: { text: string }) {
const { t } = useTranslation("sessions");
const [done, setDone] = useState(false);
return (
<button
type="button"
aria-label={t("conversation.copy")}
title={t("conversation.copy")}
onClick={async () => {
if (!(await copyText(text, true))) return;
setDone(true);
window.setTimeout(() => setDone(false), 1500);
}}
className="rounded-md p-1 transition-colors hover:bg-surface-2 hover:text-fg-secondary"
>
{done ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
/** A message's hover bar, as Claude Desktop has it: copy, then the time and
* any detail (the model, tokens in its tooltip). */
function MessageActions({
text,
timestamp,
detail,
detailTitle,
}: {
text: string;
timestamp?: string | null;
detail?: string | null;
detailTitle?: string;
}) {
return (
<div
data-testid="chat-actions"
title={detailTitle}
className="flex h-6 items-center gap-1.5 text-[11px] text-fg-muted opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100"
>
{text && <CopyAction text={text} />}
{detail && <span>{detail}</span>}
{timestamp && <span className="tabular-nums">{formatLocalTime(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 animate-fade-in"
>
{hasTuiTags(text) || isSkillContent(text) || isTaskNotification(text) ? (
renderTextBlock(text, 0)
) : (
<MarkdownContent text={stripAnsi(text)} prose />
)}
<MessageActions
text={stripAnsi(text).trim()}
timestamp={msg.timestamp}
detail={msg.model ? formatModelName(msg.model) : null}
detailTitle={
msg.usage
? `↓ ${fmt(promptTokens(msg.usage))} · ↑ ${fmt(msg.usage.output_tokens)}\n${usageTitle(msg.usage)}`
: undefined
}
/>
</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 animate-slide-up 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",
};
/** Each kind's mark on the steps' timeline. */
const KIND_ICON: Record<string, typeof User> = {
commands: Terminal,
edits: Pencil,
reads: FileText,
searches: Search,
web: Globe,
agents: Bot,
tools: Wrench,
};
/** "Ran 3 commands, edited 2 files" — or "Thinking" when it only thought. */
const KIND_ORDER = ["commands", "edits", "reads", "searches", "web", "agents", "tools"];
export function workLabel(blocks: TranscriptContent[]): string {
const counts = new Map<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 agent = isAgentCall(block);
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(" ");
const Icon = thinking
? Brain
: (KIND_ICON[WORK_KIND[(block.name ?? "").toLowerCase()] ?? "tools"] ?? Wrench);
return (
// One stop on the timeline: its kind's mark, the rule down to the next.
<div className="group/step flex gap-2.5">
<div className="flex flex-col items-center">
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center ${
result?.is_error ? "text-status-danger" : "text-fg-muted"
}`}
>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="my-1 w-px flex-1 bg-border group-last/step:hidden" />
</div>
<div className="min-w-0 flex-1 pb-3 group-last/step:pb-0">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className={`flex min-h-5 w-full min-w-0 items-center gap-1.5 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"}`}
/>
{result &&
(result.is_error ? (
<X className="ml-auto h-3.5 w-3.5 shrink-0" />
) : (
<Check className="ml-auto h-3.5 w-3.5 shrink-0 opacity-60" />
))}
</button>
{open && (
<div className="mt-2 animate-fade-in space-y-2">
{thinking ? (
<div className="max-h-60 overflow-auto text-fg-muted">
<MarkdownContent text={block.text ?? ""} dense />
</div>
) : agent ? (
<AgentStepBody block={block} result={result} />
) : (
<>
<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>
</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" className="animate-fade-in">
<Disclosure label={workLabel(blocks)}>
<div className="pl-0.5">
{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 SenderIcon = SENDER_ICONS[sender] ?? User;
return (
<div
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 120px" }}
// A note beside the conversation, not a turn of it: no avatar, no bubble.
className="group animate-fade-in border-l-2 border-border py-0.5 pl-3"
>
<div className="min-w-0 space-y-1.5">
<div className="flex flex-wrap items-center gap-1.5 text-xs text-fg-muted">
<SenderIcon className="h-3.5 w-3.5 flex-shrink-0" />
<span className="font-medium">{t(`conversation.sender.${sender}`)}</span>
<span className="flex items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100">
{msg.model && <span>{formatModelName(msg.model)}</span>}
{msg.usage && (
<span title={usageTitle(msg.usage)} className="tabular-nums">
↓ {fmt(promptTokens(msg.usage))} · ↑ {fmt(msg.usage.output_tokens)}
</span>
)}
</span>
{msg.timestamp && (
<span className="ml-auto tabular-nums opacity-0 transition-opacity group-hover:opacity-100">
{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}>
<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="inline-flex items-center gap-1.5 text-xs text-fg-muted transition-colors hover:text-fg-secondary"
>
<Brain className="h-3.5 w-3.5" />
<span>{t("conversation.thinking")}</span>
{!isExpanded && (
<span className="tabular-nums opacity-70">
{t("conversation.chars", {
count: block.text.length,
formatted: block.text.length.toLocaleString(),
})}
</span>
)}
<ChevronRight
className={`h-3.5 w-3.5 transition-transform duration-150 ${
isExpanded ? "rotate-90" : ""
}`}
/>
</button>
{isExpanded && (
<div className="mt-1.5 animate-fade-in text-fg-muted">
<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);