Files
Claude-Code-Monitor/client/src/components/run/RunConsole.tsx
T
nntrivi2001 b673363351 feat(theme): dark/light mode with a Radix Colors-based palette
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.

- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
  tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
  fg.primary/secondary/muted, status.success/danger/warning. One class flip
  on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
  prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
  scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
  every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
  client/src onto the new tokens, so every badge/button/component pulls the
  same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
  constants — slate/blue/green/red/amber steps 1-12 — adopted after three
  rounds of hand-picked values that kept overshooting (flat, then too dark,
  then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
  dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
  visual language (border + text + translucent wash of the same status
  color); `current` alone stays a solid accent fill, the one state that
  gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
  Workspace lane-detail header already showing them.

Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
2026-07-31 10:54:31 +07:00

1098 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-status-danger" : pct >= 80 ? "text-status-warning" : "text-fg-secondary";
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-fg-muted">
<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-status-success/70" title={t("tokens.cacheRead")}>
{formatNum(stats.cacheReadTokens)}
</span>
)}
{stats.costUsd != null && (
<span className="text-fg-secondary">${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-surface-4/10 text-fg-secondary border-border-light/30"
: s === "user"
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
: s === "project"
? "bg-status-success/10 text-status-success border-status-success/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-fg-primary placeholder:text-fg-muted 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-fg-muted 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-fg-muted">{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-fg-primary">/{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-fg-muted 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-fg-muted flex-shrink-0" />
<span className="font-mono text-[11px] text-fg-secondary 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-fg-muted">{init.model}</span>}
{props.handle.sessionId && (
<span className="truncate text-fg-muted">{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-status-danger hover:text-status-danger 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-fg-secondary hover:text-fg-primary 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-fg-muted">{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-fg-muted 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-fg-muted" />
<div className="text-sm font-medium text-fg-secondary">{t("empty.title")}</div>
<div className="text-xs text-fg-muted 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-fg-secondary border-border",
icon: Clock as typeof Play,
};
const config: Record<string, { color: string; icon: typeof Play }> = {
spawning: {
color: "bg-status-warning/15 text-status-warning border-status-warning/30",
icon: RefreshCw,
},
running: {
color: "bg-status-success/15 text-status-success border-status-success/30",
icon: Sparkles,
},
completed: {
color: "bg-status-success/15 text-status-success border-status-success/30",
icon: CheckCircle2,
},
error: {
color: "bg-status-danger/15 text-status-danger border-status-danger/30",
icon: XCircle,
},
killed: { color: "bg-surface-4/15 text-fg-secondary border-border-light/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-fg-secondary 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-fg-secondary">
{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-fg-secondary 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-status-warning" aria-hidden>
</span>
<span className="font-medium text-status-warning">{toolUse.name}</span>
{summary && <span className="truncate text-fg-muted">{summary}</span>}
</button>
{open && (
<pre className="mt-0.5 ml-1.5 max-h-72 overflow-auto border-l border-status-warning/25 pl-3 whitespace-pre-wrap break-words text-fg-secondary">
{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-status-danger" : "text-status-success/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-fg-muted">
<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-fg-muted">
{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-status-danger" : "text-status-success/90"
}`}
>
<span className="select-none opacity-60" aria-hidden>
{" "}
</span>
{isError ? t("status.error") : t("status.completed")}
{parts.length > 0 && <span className="text-fg-muted"> · {parts.join(" · ")}</span>}
</div>
);
}