Files
Claude-Code-Monitor/client/src/components/run/RunConsole.tsx
T
nntrivi2001 57dc91585d feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
2026-07-30 14:39:03 +07:00

1082 lines
40 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 RunConsole.tsx
* @description The run console: everything that renders one run's live
* conversation and drives its next turn. Moved verbatim out of `pages/Run.tsx`
* (where it was `RunSession`) so the Run page and the Workspace page can both
* mount the same console.
*
* Three pieces live here:
* - the envelope stream — user turns, assistant markdown, thinking, tool
* uses and tool results, plus the result footer;
* - the token / context-window meter rolled up from the envelope log;
* - the prompt editor with its `/` slash-command and `@` file autocomplete.
*
* Props only: no API call except the `@`-file lookup the editor already owned,
* and no stream subscription — `envelopes` arrives as a prop, so the page keeps
* `useRunStream` and both pages share one subscription per run.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Play,
Square,
Send,
RefreshCw,
Sparkles,
Terminal,
CheckCircle2,
XCircle,
Clock,
ExternalLink,
Plus,
AtSign,
Slash as SlashIcon,
FileCode,
} from "lucide-react";
import { api } from "../../lib/api";
import type { RunHandle, RunMode } from "../../lib/api";
import { MarkdownContent } from "../conversation/MarkdownContent";
import type {
AssistantMessage,
ContentBlock,
Envelope,
ResultEnvelope,
SystemInit,
UserMessage,
} from "../../hooks/useRunStream";
// ── Token / context-window meter ──────────────────────────────────────
interface TokenStats {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
costUsd: number | null;
contextWindow: number | null;
}
const DEFAULT_CONTEXT_WINDOW = 200_000;
/**
* Roll up token usage from the in-memory envelope log. Pulls the latest
* `usage` block from `stream_event/message_delta` events (live numbers
* during streaming) and the canonical `result.usage` envelope when the run
* finishes. The 1M-context Opus variants emit `contextWindow` in
* `result.modelUsage`; we surface that to size the meter correctly.
*/
function computeTokens(envelopes: Envelope[]): TokenStats {
// Per-turn rolling counters (overwritten as each new turn's message_start
// arrives). The latest message_start's input + cache numbers reflect the
// current turn's prompt size, which is the right thing to show in the
// "Context" gauge.
let inputTokens = 0;
let cacheReadTokens = 0;
let cacheCreationTokens = 0;
// Output is summed across all completed turns plus the running current
// turn - claude reports output_tokens as a per-turn (per-message) number,
// not cumulative. Without summing, the meter resets every time a new
// `message_start` arrives.
let completedOutputTokens = 0;
let currentTurnOutput = 0;
let costUsd: number | null = null;
let contextWindow: number | null = null;
let sawMessageStart = false;
// While we don't have an authoritative output count from message_delta /
// result, estimate from the char count in the streaming assistant block
// so the meter ticks live as text appears (claude doesn't emit usage on
// every text_delta).
let outputAuthoritativeForCurrent = false;
let streamingChars = 0;
const commitTurn = () => {
completedOutputTokens += currentTurnOutput;
currentTurnOutput = 0;
outputAuthoritativeForCurrent = false;
streamingChars = 0;
};
for (const env of envelopes) {
const e = env as { type?: string };
if (e.type === "stream_event") {
const ev = (
env as {
event?: {
type?: string;
usage?: Record<string, number>;
message?: { usage?: Record<string, number> };
};
}
).event;
if (!ev) continue;
if (ev.type === "message_start") {
// Roll the previous turn's running output into the cumulative total
// before resetting for this new turn.
if (sawMessageStart) commitTurn();
sawMessageStart = true;
const u = ev.message?.usage;
if (u) {
inputTokens = u.input_tokens ?? 0;
cacheReadTokens = u.cache_read_input_tokens ?? 0;
cacheCreationTokens = u.cache_creation_input_tokens ?? 0;
currentTurnOutput = u.output_tokens ?? 0;
}
} else if (ev.type === "message_delta") {
const u = ev.usage;
if (u && typeof u.output_tokens === "number") {
// Authoritative running output for the current turn.
currentTurnOutput = u.output_tokens;
outputAuthoritativeForCurrent = true;
}
}
} else if (e.type === "result") {
const r = env as ResultEnvelope & {
modelUsage?: Record<
string,
{
contextWindow?: number;
inputTokens?: number;
outputTokens?: number;
cacheReadInputTokens?: number;
cacheCreationInputTokens?: number;
}
>;
};
// Result is end-of-run: commit any in-flight current turn first.
if (currentTurnOutput > 0) {
completedOutputTokens += currentTurnOutput;
currentTurnOutput = 0;
outputAuthoritativeForCurrent = false;
}
if (typeof r.total_cost_usd === "number") costUsd = r.total_cost_usd;
if (r.modelUsage && typeof r.modelUsage === "object") {
for (const m of Object.values(r.modelUsage)) {
if (!m || typeof m !== "object") continue;
if (typeof m.contextWindow === "number") contextWindow = m.contextWindow;
// Prefer modelUsage's per-model totals when available - these are
// the canonical per-run numbers.
if (typeof m.inputTokens === "number") inputTokens = m.inputTokens;
if (typeof m.cacheReadInputTokens === "number") cacheReadTokens = m.cacheReadInputTokens;
if (typeof m.cacheCreationInputTokens === "number")
cacheCreationTokens = m.cacheCreationInputTokens;
if (typeof m.outputTokens === "number") {
// modelUsage.outputTokens is the run total for this model - use
// it as the canonical cumulative output, replacing our running
// sum.
completedOutputTokens = m.outputTokens;
}
}
}
} else if (e.type === "system" && (env as SystemInit).model) {
// Heuristic: 1M Opus has [1m] in the model id
const model = (env as SystemInit).model || "";
if (/\[1m\]/i.test(model)) contextWindow = 1_000_000;
} else if (e.type === "assistant") {
const msg = (
env as {
message?: {
_streaming?: boolean;
content?: ContentBlock[];
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
};
}
).message;
if (msg?._streaming) {
streamingChars = 0;
const blocks = msg.content || [];
for (const b of blocks) {
if (b.type === "text") {
streamingChars += ((b as { text?: string }).text || "").length;
} else if (b.type === "thinking") {
streamingChars += ((b as { thinking?: string }).thinking || "").length;
}
}
} else if (msg?.usage) {
// Transcript-derived seed envelopes carry usage but have no
// `message.id` (transcriptToEnvelopes doesn't set one). Live-stream
// canonical envelopes always have an id assigned by message_start,
// and their tokens are already counted via stream_event / commitTurn
// - folding them here would double-count. Use id-presence as the
// discriminator: no id → transcript-seeded → fold; id → live → skip.
const hasId = !!(msg as { id?: string }).id;
if (!hasId) {
const u = msg.usage;
if (typeof u.input_tokens === "number") inputTokens = u.input_tokens;
if (typeof u.cache_read_input_tokens === "number") {
cacheReadTokens = u.cache_read_input_tokens;
}
if (typeof u.cache_creation_input_tokens === "number") {
cacheCreationTokens = u.cache_creation_input_tokens;
}
if (typeof u.output_tokens === "number") {
completedOutputTokens += u.output_tokens;
}
}
}
}
}
// While we don't have an authoritative output count for the current turn,
// surface the char-based estimate so the meter ticks live during streaming.
if (!outputAuthoritativeForCurrent && streamingChars > 0) {
const estimate = Math.ceil(streamingChars / 4);
if (estimate > currentTurnOutput) currentTurnOutput = estimate;
}
return {
inputTokens,
outputTokens: completedOutputTokens + currentTurnOutput,
cacheReadTokens,
cacheCreationTokens,
costUsd,
contextWindow,
};
}
function formatNum(n: number): string {
if (n < 1000) return String(n);
if (n < 100_000) return (n / 1000).toFixed(1) + "k";
if (n < 1_000_000) return Math.round(n / 1000) + "k";
return (n / 1_000_000).toFixed(2) + "M";
}
function TokenMeter({ stats }: { stats: TokenStats }) {
const { t } = useTranslation("run");
const total = stats.inputTokens + stats.cacheReadTokens + stats.cacheCreationTokens;
const cap = stats.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
const pct = Math.min(100, Math.round((total / cap) * 100));
// Colour is the whole warning mechanism here - the meter is one status line,
// so there is no room for a bar plus five labelled figures.
const tone = pct >= 95 ? "text-red-300" : pct >= 80 ? "text-amber-300" : "text-gray-400";
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 border-t border-border px-3 py-1.5 font-mono text-[11.5px] text-gray-500">
<span className="select-none opacity-60" aria-hidden>
</span>
<span className={tone}>{`${formatNum(total)} / ${formatNum(cap)} (${pct}%)`}</span>
<span title={t("tokens.output")}>{formatNum(stats.outputTokens)}</span>
{stats.cacheReadTokens > 0 && (
<span className="text-emerald-400/70" title={t("tokens.cacheRead")}>
{formatNum(stats.cacheReadTokens)}
</span>
)}
{stats.costUsd != null && (
<span className="text-gray-400">${stats.costUsd.toFixed(4)}</span>
)}
</div>
);
}
// ── Slash commands (built-in list + user/project/plugin from API) ─────
export interface SlashCommand {
name: string;
description?: string;
source: "builtin" | "user" | "project" | "plugin";
filePath?: string;
}
// Built-in commands the CLI handles itself. We surface them in autocomplete
// with a "CLI only" tag so users know they won't actually execute when
// sent over stream-json stdin.
export const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
{ name: "help", description: "List available commands", source: "builtin" },
{ name: "clear", description: "Clear the conversation", source: "builtin" },
{ name: "config", description: "Open the interactive config menu", source: "builtin" },
{ name: "model", description: "Change model mid-session", source: "builtin" },
{ name: "compact", description: "Compact the conversation context", source: "builtin" },
{ name: "memory", description: "Edit CLAUDE.md", source: "builtin" },
{ name: "hooks", description: "Manage hooks", source: "builtin" },
{ name: "cost", description: "Show session cost", source: "builtin" },
{ name: "agents", description: "List subagents", source: "builtin" },
{ name: "review", description: "Review current changes", source: "builtin" },
{ name: "release-notes", description: "Show CC release notes", source: "builtin" },
{ name: "permissions", description: "Edit permission rules", source: "builtin" },
{ name: "status", description: "Show session status", source: "builtin" },
{ name: "init", description: "Initialise CLAUDE.md from codebase", source: "builtin" },
{ name: "login", description: "Sign in to Claude", source: "builtin" },
{ name: "logout", description: "Sign out", source: "builtin" },
{ name: "exit", description: "Exit the session", source: "builtin" },
{ name: "mcp", description: "Manage MCP servers", source: "builtin" },
{ name: "plugin", description: "Manage plugins", source: "builtin" },
{ name: "output-style", description: "Change output style", source: "builtin" },
];
function commandSourceLabel(s: SlashCommand["source"]): string {
return s === "builtin"
? "CLI only"
: s === "user"
? "user"
: s === "project"
? "project"
: "plugin";
}
function commandSourceTone(s: SlashCommand["source"]): string {
return s === "builtin"
? "bg-gray-500/10 text-gray-400 border-gray-500/30"
: s === "user"
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
: s === "project"
? "bg-emerald-500/10 text-emerald-300 border-emerald-500/30"
: "bg-violet-500/10 text-violet-300 border-violet-500/30";
}
// ── Autocomplete dropdown for slash + @-files ─────────────────────────
interface AutocompleteState {
kind: "slash" | "file";
query: string;
// The position in the textarea where the trigger character starts (so we
// can replace from there to the cursor on selection).
triggerStart: number;
cursor: number;
}
/**
* Tiered slash-command match scoring. Higher = more relevant. Returns 0 for
* "doesn't match, hide it." Tiers in descending priority:
* 1. Exact name match
* 2. Name starts with query
* 3. Word boundary (after `-` / `_` / `.`) starts with query
* 4. Name contains query (earlier index ranks higher)
* 5. Subsequence match across the name
* 6. Description contains query - only when query is at least 3 chars,
* so a single keystroke can't drag in tangential descriptions.
*/
function scoreSlashMatch(name: string, description: string | undefined, q: string): number {
if (!q) return 1;
const n = name.toLowerCase();
if (n === q) return 1000;
if (n.startsWith(q)) return 800 - Math.min(n.length, 100);
const parts = n.split(/[-_.\s]/);
if (parts.some((p) => p.startsWith(q))) {
return 600 - Math.min(n.length, 100);
}
const idx = n.indexOf(q);
if (idx >= 0) return 400 - Math.min(idx, 100);
if (subsequenceMatch(n, q)) return 200;
if (q.length >= 3) {
const d = (description || "").toLowerCase();
if (d.includes(q)) return 100;
}
return 0;
}
function subsequenceMatch(s: string, q: string): boolean {
let i = 0;
for (let k = 0; k < s.length && i < q.length; k++) {
if (s[k] === q[i]) i++;
}
return i === q.length;
}
function detectAutocomplete(value: string, cursor: number): AutocompleteState | null {
// Look back from the cursor to find the active "token". A token starts at
// the beginning of the line / after whitespace and continues until cursor.
let start = cursor;
while (start > 0) {
const ch = value[start - 1];
if (!ch || /\s/.test(ch)) break;
start--;
}
const tok = value.slice(start, cursor);
if (tok.startsWith("/") && tok.length >= 1) {
// Only trigger for slash if it's at line start OR right after whitespace.
// The detection above already enforces that.
return { kind: "slash", query: tok.slice(1), triggerStart: start, cursor };
}
if (tok.startsWith("@") && tok.length >= 1) {
return { kind: "file", query: tok.slice(1), triggerStart: start, cursor };
}
return null;
}
interface PromptEditorProps {
value: string;
onChange: (s: string) => void;
onSubmit?: () => void;
placeholder?: string;
rows?: number;
slashCommands: SlashCommand[];
fileCwd: string;
autoFocus?: boolean;
}
export function PromptEditor({
value,
onChange,
onSubmit,
placeholder,
rows = 4,
slashCommands,
fileCwd,
autoFocus,
}: PromptEditorProps) {
const { t } = useTranslation("run");
const taRef = useRef<HTMLTextAreaElement | null>(null);
const [state, setState] = useState<AutocompleteState | null>(null);
const [active, setActive] = useState(0);
const [fileSuggestions, setFileSuggestions] = useState<string[]>([]);
const fileFetchRef = useRef<{ q: string; t: number } | null>(null);
// Slash filter - tiered scoring so prefix matches outrank arbitrary
// substring hits, name matches outrank description matches, and shorter
// names break ties when scores are equal.
const slashItems = useMemo(() => {
if (!state || state.kind !== "slash") return [] as SlashCommand[];
const q = state.query.toLowerCase();
const sourceOrder = { project: 0, user: 1, plugin: 2, builtin: 3 } as const;
if (!q) {
return [...slashCommands].sort(
(a, b) => sourceOrder[a.source] - sourceOrder[b.source] || a.name.localeCompare(b.name)
);
}
type Scored = { cmd: SlashCommand; score: number };
const scored: Scored[] = [];
for (const cmd of slashCommands) {
const score = scoreSlashMatch(cmd.name, cmd.description, q);
if (score > 0) scored.push({ cmd, score });
}
return scored
.sort(
(a, b) =>
b.score - a.score ||
sourceOrder[a.cmd.source] - sourceOrder[b.cmd.source] ||
a.cmd.name.length - b.cmd.name.length ||
a.cmd.name.localeCompare(b.cmd.name)
)
.map((s) => s.cmd);
}, [state, slashCommands]);
// File fetch (debounced)
useEffect(() => {
if (!state || state.kind !== "file") return;
const ts = Date.now();
fileFetchRef.current = { q: state.query, t: ts };
const tid = setTimeout(() => {
if (fileFetchRef.current?.t !== ts) return;
api.run
.files(fileCwd, state.query)
.then((r) => setFileSuggestions(r.items))
.catch(() => setFileSuggestions([]));
}, 120);
return () => clearTimeout(tid);
}, [state, fileCwd]);
const items = state?.kind === "file" ? fileSuggestions : slashItems;
useEffect(() => {
if (active >= items.length) setActive(Math.max(0, items.length - 1));
}, [items.length, active]);
const insertChoice = (choice: SlashCommand | string) => {
if (!state || !taRef.current) return;
const ta = taRef.current;
const before = value.slice(0, state.triggerStart);
const after = value.slice(state.cursor);
let inserted: string;
if (state.kind === "slash") {
const c = choice as SlashCommand;
inserted = `/${c.name}`;
} else {
inserted = `@${choice as string}`;
}
const next = before + inserted + (after.startsWith(" ") || after === "" ? "" : " ") + after;
onChange(next);
setState(null);
setActive(0);
// Re-position cursor after the inserted token + a trailing space
requestAnimationFrame(() => {
const pos = before.length + inserted.length + 1;
ta.focus();
ta.setSelectionRange(pos, pos);
});
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (state && items.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => Math.min(items.length - 1, a + 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(0, a - 1));
return;
}
if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
const choice = items[active];
if (choice) insertChoice(choice);
return;
}
if (e.key === "Tab") {
e.preventDefault();
const choice = items[active];
if (choice) insertChoice(choice);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setState(null);
return;
}
}
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
onSubmit?.();
}
};
const onTextareaInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value);
const ta = e.target;
const next = detectAutocomplete(ta.value, ta.selectionStart || 0);
setState(next);
if (!next) setActive(0);
};
const onSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const ta = e.currentTarget;
const next = detectAutocomplete(ta.value, ta.selectionStart || 0);
setState(next);
};
return (
<div className="relative">
<textarea
ref={taRef}
autoFocus={autoFocus}
value={value}
onChange={onTextareaInput}
onKeyDown={onKeyDown}
onSelect={onSelect}
placeholder={placeholder}
rows={rows}
spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-sm text-gray-100 placeholder:text-gray-500 focus:outline-none focus:border-accent/50 resize-y font-sans leading-relaxed"
/>
{state && (
<div className="absolute z-30 left-0 right-0 bottom-full mb-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
<div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-gray-500 inline-flex items-center gap-1.5">
{state.kind === "slash" ? (
<>
<SlashIcon className="w-3 h-3" />
{t("autocomplete.slashHint")}
</>
) : (
<>
<AtSign className="w-3 h-3" />
{t("autocomplete.fileHint")}
</>
)}
</div>
{items.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-gray-500">{t("autocomplete.noMatches")}</div>
) : state.kind === "slash" ? (
(items as SlashCommand[]).map((c, idx) => (
<button
key={`${c.source}:${c.name}`}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertChoice(c)}
onMouseEnter={() => setActive(idx)}
className={`w-full text-left px-3 py-1.5 transition-colors ${
idx === active ? "bg-accent/15" : "hover:bg-surface-3"
}`}
>
<div className="flex items-center gap-2">
<span className="font-mono text-[12px] text-gray-100">/{c.name}</span>
<span
className={`text-[9px] font-mono px-1.5 py-0.5 rounded border ${commandSourceTone(c.source)}`}
>
{commandSourceLabel(c.source)}
</span>
</div>
{c.description && (
<div className="text-[10.5px] text-gray-500 truncate mt-0.5">{c.description}</div>
)}
</button>
))
) : (
(items as string[]).map((p, idx) => (
<button
key={p}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertChoice(p)}
onMouseEnter={() => setActive(idx)}
className={`w-full text-left px-3 py-1.5 transition-colors flex items-center gap-2 ${
idx === active ? "bg-accent/15" : "hover:bg-surface-3"
}`}
>
<FileCode className="w-3 h-3 text-gray-500 flex-shrink-0" />
<span className="font-mono text-[11px] text-gray-200 truncate">{p}</span>
</button>
))
)}
</div>
)}
</div>
);
}
// ── Live run session ─────────────────────────────────────────────────
interface RunConsoleProps {
handle: RunHandle;
envelopes: Envelope[];
mode: RunMode;
isLive: boolean;
hasFinished: boolean;
followUp: string;
onFollowUpChange: (s: string) => void;
busy: "start" | "send" | "stop" | "attach" | null;
onSend: () => void;
onStop: () => void;
onNewRun: () => void;
slashCommands: SlashCommand[];
}
export function RunConsole(props: RunConsoleProps) {
const { t } = useTranslation("run");
const scrollRef = useRef<HTMLDivElement | null>(null);
const [pinnedToBottom, setPinnedToBottom] = useState(true);
// Track whether the user has scrolled away - if so, don't yank them back.
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onScroll = () => {
const distance = el.scrollHeight - (el.scrollTop + el.clientHeight);
setPinnedToBottom(distance < 80);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
// Auto-scroll on new envelopes if the user is pinned to bottom.
useEffect(() => {
if (!pinnedToBottom) return;
const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [props.envelopes.length, pinnedToBottom]);
const result = useMemo(
() => props.envelopes.find((e) => e.type === "result") as ResultEnvelope | undefined,
[props.envelopes]
);
const init = useMemo(
() => props.envelopes.find((e) => e.type === "system") as SystemInit | undefined,
[props.envelopes]
);
const tokenStats = useMemo(() => computeTokens(props.envelopes), [props.envelopes]);
return (
// flex-1 + min-h-0 lets us fill the viewport-locked parent, while the
// inner stream area's overflow-auto keeps long chats scrollable inside
// the panel - never the page.
<div className="flex flex-col flex-1 min-h-0 rounded-lg border border-border bg-black/40">
{/* Toolbar - one dense strip, mono so it reads as a terminal chrome */}
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-1.5 font-mono text-[11.5px]">
<StatusPill status={props.handle.status} />
<ModeBadge mode={props.mode} />
{init?.model && <span className="text-gray-500">{init.model}</span>}
{props.handle.sessionId && (
<span className="truncate text-gray-600">{props.handle.sessionId.slice(0, 8)}</span>
)}
<div className="flex-1" />
{props.isLive && (
<button
onClick={props.onStop}
disabled={props.busy === "stop"}
className="inline-flex items-center gap-1 text-red-300 hover:text-red-200 disabled:opacity-60 transition-colors"
>
<Square className="w-3 h-3" />
{props.busy === "stop" ? t("actions.stopping") : t("actions.stop")}
</button>
)}
{props.handle.sessionId && (
<Link
to={`/sessions/${encodeURIComponent(props.handle.sessionId)}`}
className="inline-flex items-center gap-1 text-gray-400 hover:text-gray-200 transition-colors"
>
<ExternalLink className="w-3 h-3" />
{t("actions.viewSession")}
</Link>
)}
{/* Always available - lets the user leave a running run in the
background and start another one. The original is still in the
Active Runs dropdown for re-attach. */}
<button
onClick={props.onNewRun}
className="inline-flex items-center gap-1 text-accent hover:brightness-125 transition-colors"
>
<Plus className="w-3 h-3" />
{t("actions.newRun")}
</button>
</div>
{/* Stream area */}
<div ref={scrollRef} className="min-h-0 flex-1 space-y-1.5 overflow-auto px-3 py-2">
{props.envelopes.length === 0 && <EmptyStream isLive={props.isLive} />}
{props.envelopes.map((env, i) => (
<EnvelopeRow key={i} envelope={env} />
))}
</div>
{/* Live token / context-window meter */}
<TokenMeter stats={tokenStats} />
{/* Footer banner once finished */}
{props.hasFinished && result && <ResultFooter result={result} />}
{/* Follow-up input - only for conversation mode while live */}
{props.mode === "conversation" && props.isLive && (
<div className="border-t border-border px-3 py-2">
<PromptEditor
value={props.followUp}
onChange={props.onFollowUpChange}
onSubmit={props.onSend}
placeholder={t("fields.promptPlaceholder")}
rows={2}
slashCommands={props.slashCommands}
fileCwd={props.handle.cwd}
/>
<div className="mt-2 flex items-center justify-between">
<div className="text-[10px] text-gray-600">{t("hint.shortcut")} · / · @</div>
<button
onClick={props.onSend}
disabled={!props.followUp.trim() || props.busy === "send"}
className="inline-flex items-center gap-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50"
>
<Send className="w-3 h-3" />
{props.busy === "send" ? t("actions.sending") : t("actions.send")}
</button>
</div>
</div>
)}
</div>
);
}
function EmptyStream({ isLive }: { isLive: boolean }) {
const { t } = useTranslation("run");
if (isLive) {
return (
<div className="text-center py-12 text-gray-500 flex flex-col items-center gap-2">
<RefreshCw className="w-5 h-5 animate-spin" />
<span className="text-xs">{t("status.spawning")}</span>
</div>
);
}
return (
<div className="text-center py-12 flex flex-col items-center gap-2">
<Sparkles className="w-6 h-6 text-gray-600" />
<div className="text-sm font-medium text-gray-400">{t("empty.title")}</div>
<div className="text-xs text-gray-500 max-w-md">{t("empty.body")}</div>
</div>
);
}
export function StatusPill({ status }: { status: string }) {
const { t } = useTranslation("run");
const idle = { color: "bg-surface-3 text-gray-400 border-border", icon: Clock as typeof Play };
const config: Record<string, { color: string; icon: typeof Play }> = {
spawning: { color: "bg-amber-500/15 text-amber-300 border-amber-500/30", icon: RefreshCw },
running: { color: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30", icon: Sparkles },
completed: {
color: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30",
icon: CheckCircle2,
},
error: { color: "bg-red-500/15 text-red-300 border-red-500/30", icon: XCircle },
killed: { color: "bg-gray-500/15 text-gray-400 border-gray-500/30", icon: Square },
abandoned: {
color: "bg-orange-500/10 text-orange-300 border-orange-500/30",
icon: Square,
},
idle,
};
const c = config[status] ?? idle;
const Icon = c.icon;
const animate = status === "spawning" || status === "running";
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium border ${c.color}`}
>
<Icon
className={`w-3 h-3 ${animate ? (status === "spawning" ? "animate-spin" : "animate-pulse") : ""}`}
/>
{t(`status.${status}`)}
</span>
);
}
export function ModeBadge({ mode }: { mode: RunMode }) {
const { t } = useTranslation("run");
return (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-gray-400 border border-border inline-flex items-center gap-1">
{mode === "conversation" ? (
<Terminal className="w-3 h-3" />
) : (
<Sparkles className="w-3 h-3" />
)}
{t(`mode.${mode}`)}
</span>
);
}
// ── Envelope rendering ───────────────────────────────────────────────
function EnvelopeRow({ envelope }: { envelope: Envelope }) {
if (!envelope || typeof envelope !== "object") return null;
switch (envelope.type) {
case "user":
return <UserTurn env={envelope as UserMessage} />;
case "assistant":
return <AssistantTurn env={envelope as AssistantMessage} />;
case "system":
return null; // init metadata is shown in the toolbar
case "result":
return null; // shown in the footer
case "stream_event":
return null; // kept in state for token accounting only - never rendered
default:
// Unknown envelope: render compact JSON for transparency
return <UnknownTurn env={envelope} />;
}
}
function extractText(content: ContentBlock[] | string | undefined): string {
if (!content) return "";
if (typeof content === "string") return content;
return content
.filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
.map((b) => b.text)
.join("\n");
}
function UserTurn({ env }: { env: UserMessage }) {
const content = env.message?.content;
// Tool results live inside user.message.content as { type: "tool_result", ... }.
const toolResults = Array.isArray(content)
? (content.filter((b) => b.type === "tool_result") as Extract<
ContentBlock,
{ type: "tool_result" }
>[])
: [];
const text = extractText(content);
if (toolResults.length > 0 && !text) {
return (
<div className="space-y-2">
{toolResults.map((tr, i) => (
<ToolResultBlock key={i} result={tr} />
))}
</div>
);
}
return (
<div className="flex gap-2 font-mono text-[12.5px] leading-relaxed">
<span className="select-none text-indigo-400" aria-hidden>
&gt;
</span>
<span className="min-w-0 flex-1 whitespace-pre-wrap break-words text-gray-200">
{text || "-"}
</span>
</div>
);
}
function AssistantTurn({ env }: { env: AssistantMessage }) {
const content = env.message?.content;
const blocks = Array.isArray(content)
? content
: content
? [{ type: "text", text: content } as ContentBlock]
: [];
const text = blocks
.filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
.map((b) => b.text)
.join("\n");
const toolUses = blocks.filter(
(b): b is ContentBlock & { type: "tool_use" } => b.type === "tool_use"
);
const thinking = blocks.filter(
(b): b is ContentBlock & { type: "thinking" } => b.type === "thinking"
);
return (
<div className="space-y-1">
{thinking.map((th, i) => (
<ThinkingBlock key={`th-${i}`} text={th.thinking || ""} />
))}
{text && (
// The gutter glyph is the only speaker marker - no avatar, no label
// row. Prose keeps its markdown rendering; only the chrome is gone.
<div className="flex gap-2">
<span className="select-none font-mono text-[12.5px] leading-relaxed text-accent" aria-hidden>
</span>
<div className="min-w-0 flex-1 text-[13px] leading-relaxed text-gray-200 prose-claude">
<MarkdownContent text={text} />
</div>
</div>
)}
{toolUses.map((tu) => (
<ToolUseBlock key={tu.id} toolUse={tu} />
))}
</div>
);
}
function ThinkingBlock({ text }: { text: string }) {
const { t } = useTranslation("run");
const [open, setOpen] = useState(false);
if (!text) return null;
return (
<div className="font-mono text-[12px] leading-relaxed">
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 text-violet-400/70 hover:text-violet-300 transition-colors"
>
<span className="select-none" aria-hidden>
{open ? "\u25be" : "\u25b8"}
</span>
<span className="italic">{t("events.thinking")}</span>
</button>
{open && (
<pre className="mt-0.5 border-l border-violet-500/25 pl-3 ml-1.5 whitespace-pre-wrap break-words text-violet-200/60">
{text}
</pre>
)}
</div>
);
}
function ToolUseBlock({ toolUse }: { toolUse: Extract<ContentBlock, { type: "tool_use" }> }) {
const { t } = useTranslation("run");
const [open, setOpen] = useState(false);
const summary = describeToolInput(toolUse.input);
return (
<div className="font-mono text-[12px] leading-relaxed">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-baseline gap-2 text-left hover:bg-white/[0.03] transition-colors"
title={t("events.tool")}
>
<span className="select-none text-amber-400" aria-hidden>
</span>
<span className="font-medium text-amber-200">{toolUse.name}</span>
{summary && <span className="truncate text-gray-500">{summary}</span>}
</button>
{open && (
<pre className="mt-0.5 ml-1.5 max-h-72 overflow-auto border-l border-amber-500/25 pl-3 whitespace-pre-wrap break-words text-gray-400">
{JSON.stringify(toolUse.input, null, 2)}
</pre>
)}
</div>
);
}
function ToolResultBlock({ result }: { result: Extract<ContentBlock, { type: "tool_result" }> }) {
const { t } = useTranslation("run");
const [open, setOpen] = useState(false);
const text =
typeof result.content === "string"
? result.content
: Array.isArray(result.content)
? result.content
.map((c) => {
if (c == null) return "";
if (typeof c === "string") return c;
const obj = c as { text?: string };
return obj.text || JSON.stringify(c);
})
.join("\n")
: JSON.stringify(result.content);
const lines = text.split("\n").length;
const tone = result.is_error ? "text-red-300" : "text-emerald-300/90";
// First line is the useful one nine times out of ten, so it doubles as the
// collapsed summary instead of a generic "tool result" label.
const firstLine = text.split("\n").find((l) => l.trim()) || "";
return (
<div className={`font-mono text-[12px] leading-relaxed ${tone}`}>
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-baseline gap-2 text-left hover:bg-white/[0.03] transition-colors"
>
<span className="select-none opacity-70" aria-hidden>
{open ? "\u2514" : "\u2514"}
</span>
<span className="truncate opacity-90">{firstLine || t("events.toolResult")}</span>
{lines > 1 && <span className="ml-auto shrink-0 text-[11px] opacity-50">{lines}L</span>}
</button>
{open && (
<pre className="mt-0.5 ml-1.5 max-h-72 overflow-auto border-l border-current/25 pl-3 whitespace-pre-wrap break-words opacity-80">
{text}
</pre>
)}
</div>
);
}
function UnknownTurn({ env }: { env: Envelope }) {
return (
<details className="font-mono text-[11.5px] leading-relaxed text-gray-500">
<summary className="cursor-pointer">? {(env.type as string) || "unknown"}</summary>
<pre className="mt-0.5 ml-1.5 max-h-48 overflow-auto border-l border-border pl-3 whitespace-pre-wrap break-words text-gray-500">
{JSON.stringify(env, null, 2)}
</pre>
</details>
);
}
function describeToolInput(input: unknown): string {
if (!input || typeof input !== "object") return "";
const obj = input as Record<string, unknown>;
// Common Claude Code tool inputs: file_path, path, command, pattern…
for (const k of ["file_path", "path", "command", "pattern", "url", "name"]) {
const v = obj[k];
if (typeof v === "string" && v) return v.length > 80 ? v.slice(0, 80) + "…" : v;
}
return "";
}
function ResultFooter({ result }: { result: ResultEnvelope }) {
const { t } = useTranslation("run");
const isError = result.is_error;
const parts: string[] = [];
if (typeof result.duration_ms === "number") parts.push(`${(result.duration_ms / 1000).toFixed(1)}s`);
if (typeof result.total_cost_usd === "number") parts.push(`$${result.total_cost_usd.toFixed(4)}`);
if (typeof result.num_turns === "number") parts.push(t("footer.turns") + " " + result.num_turns);
return (
<div
className={`border-t border-border px-3 py-1.5 font-mono text-[11.5px] ${
isError ? "text-red-300" : "text-emerald-300/90"
}`}
>
<span className="select-none opacity-60" aria-hidden>
{" "}
</span>
{isError ? t("status.error") : t("status.completed")}
{parts.length > 0 && <span className="text-gray-500"> · {parts.join(" · ")}</span>}
</div>
);
}