383b9ae231
- 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
2121 lines
80 KiB
TypeScript
2121 lines
80 KiB
TypeScript
/**
|
||
* @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);
|