feat(run): wire Workspace to TerminalView, delete the stream-json Run feature

Combines three tasks that couldn't land as separate commits: the
pre-commit hook's full test run crashes on any intermediate state
where Workspace.tsx still imports the files being deleted, so the
deletion (old RunConsole/useRunStream/run-spawner/stream-json-parser),
the RunSetup/RunHistory type adjustments, and this file's own
TerminalView wiring had to be staged together and committed as one
hook-passable unit.

- Delete RunConsole.tsx, useRunStream.ts, server/lib/run-spawner.js,
  server/lib/stream-json-parser.js and their tests (Task 8).
- Adjust RunSetup.tsx/RunHistory.tsx to the tmux-backed RunHandle/
  RunStartArgs/DashboardRunHistoryItem shapes, remove mode selection
  UI (Task 9).
- Swap Workspace.tsx's chat-bubble run console for TerminalView
  (xterm.js over /ws-pty/:runId), drop the stream-json envelope
  plumbing, update Start/Resume to the new RunStartArgs payload.
  Create onStartFromSetup handler to work with RunSetup's new callback
  shape. Remove mode state and related plumbing. Remove send/followUp
  state (no longer using old RunConsole chat interface).
- Add promptPlaceholderTerminal i18n key to support RunSetup's new
  placeholder text (Task 10).
- Update Workspace.test.tsx to mock TerminalView component.
- Regenerate screens.snapshot.test.tsx snapshot (only Workspace run
  panel changes: terminal container instead of chat bubbles).
This commit is contained in:
2026-08-12 11:58:38 +07:00
parent f1e7d4245a
commit 2f39f4ec98
16 changed files with 266 additions and 3711 deletions
File diff suppressed because it is too large Load Diff
+33 -35
View File
@@ -33,8 +33,27 @@ import {
RotateCcw, RotateCcw,
Eye, Eye,
} from "lucide-react"; } from "lucide-react";
import type { DashboardRunHistoryItem, RunListResponse, RunMode, RunStatus } from "../../lib/api"; import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
import { ModeBadge, StatusPill } from "./RunConsole";
// Minimal StatusPill component (from deleted RunConsole)
function StatusPill({
status,
}: {
status: RunStatus | "completed" | "error" | "killed" | "abandoned";
}) {
const colors: Record<string, string> = {
running: "bg-status-success/10 text-status-success border-status-success/30",
gone: "bg-surface-3 text-fg-secondary border-border",
completed: "bg-sky-500/10 text-sky-300 border-sky-500/30",
error: "bg-status-danger/10 text-status-danger border-status-danger/30",
killed: "bg-surface-3 text-fg-secondary border-border",
abandoned: "bg-surface-3 text-fg-secondary border-border",
};
const color = colors[status] || colors.abandoned;
return (
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${color}`}>{status}</span>
);
}
type RunStatusFilter = type RunStatusFilter =
| "all" | "all"
@@ -44,15 +63,13 @@ type RunStatusFilter =
| "error" | "error"
| "killed" | "killed"
| "abandoned"; | "abandoned";
type RunModeFilter = "all" | "conversation" | "headless";
export interface UnifiedRunRow { export interface UnifiedRunRow {
id: string; id: string;
sessionId: string | null; sessionId: string | null;
mode: RunMode;
cwd: string; cwd: string;
model: string | null; model: string | null;
status: RunStatus; status: RunStatus | "completed" | "error" | "killed" | "abandoned";
promptPreview: string; promptPreview: string;
startedAt: number; startedAt: number;
endedAt: number | null; endedAt: number | null;
@@ -105,14 +122,13 @@ export function ActiveRunsSwitcher({
out.push({ out.push({
id: r.id, id: r.id,
sessionId: r.sessionId, sessionId: r.sessionId,
mode: r.mode, cwd: r.cwd || "",
cwd: r.cwd,
model: r.model, model: r.model,
status: r.status, status: r.status,
promptPreview: r.prompt || "", promptPreview: "",
startedAt: r.startedAt, startedAt: r.startedAt ? new Date(r.startedAt).getTime() : 0,
endedAt: r.endedAt, endedAt: null,
isLive: r.status === "running" || r.status === "spawning", isLive: r.status === "running",
}); });
} }
} }
@@ -124,7 +140,6 @@ export function ActiveRunsSwitcher({
out.push({ out.push({
id: h.id, id: h.id,
sessionId: h.session_id, sessionId: h.session_id,
mode: h.mode,
cwd: h.cwd, cwd: h.cwd,
model: h.model, model: h.model,
status: h.status, status: h.status,
@@ -138,7 +153,7 @@ export function ActiveRunsSwitcher({
return out; return out;
}, [activeRuns, runHistory]); }, [activeRuns, runHistory]);
const liveCount = activeRuns?.activeCount ?? 0; const liveCount = rows.filter((r) => r.isLive).length;
const totalCount = rows.length; const totalCount = rows.length;
return ( return (
@@ -211,7 +226,6 @@ export function RunsModal({
}) { }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const [statusFilter, setStatusFilter] = useState<RunStatusFilter>("all"); const [statusFilter, setStatusFilter] = useState<RunStatusFilter>("all");
const [modeFilter, setModeFilter] = useState<RunModeFilter>("all");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
// Snappy refresh while the modal is the foreground UI: pull immediately // Snappy refresh while the modal is the foreground UI: pull immediately
@@ -227,25 +241,22 @@ export function RunsModal({
const counts = useMemo(() => { const counts = useMemo(() => {
const byStatus: Record<string, number> = { all: rows.length }; const byStatus: Record<string, number> = { all: rows.length };
const byMode: Record<string, number> = { all: rows.length };
for (const r of rows) { for (const r of rows) {
byStatus[r.status] = (byStatus[r.status] || 0) + 1; byStatus[r.status] = (byStatus[r.status] || 0) + 1;
byMode[r.mode] = (byMode[r.mode] || 0) + 1;
} }
return { byStatus, byMode }; return { byStatus };
}, [rows]); }, [rows]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
return rows.filter((r) => { return rows.filter((r) => {
if (statusFilter !== "all" && r.status !== statusFilter) return false; if (statusFilter !== "all" && r.status !== statusFilter) return false;
if (modeFilter !== "all" && r.mode !== modeFilter) return false;
if (!q) return true; if (!q) return true;
const hay = const hay =
r.promptPreview + "\n" + r.cwd + "\n" + (r.sessionId || "") + "\n" + (r.model || ""); r.promptPreview + "\n" + r.cwd + "\n" + (r.sessionId || "") + "\n" + (r.model || "");
return hay.toLowerCase().includes(q); return hay.toLowerCase().includes(q);
}); });
}, [rows, statusFilter, modeFilter, search]); }, [rows, statusFilter, search]);
const historyById = useMemo(() => { const historyById = useMemo(() => {
const m = new Map<string, DashboardRunHistoryItem>(); const m = new Map<string, DashboardRunHistoryItem>();
@@ -261,7 +272,6 @@ export function RunsModal({
"killed", "killed",
"abandoned", "abandoned",
]; ];
const MODES: RunModeFilter[] = ["all", "conversation", "headless"];
return ( return (
<div <div
@@ -343,16 +353,6 @@ export function RunsModal({
}))} }))}
onChange={(v) => setStatusFilter(v as RunStatusFilter)} onChange={(v) => setStatusFilter(v as RunStatusFilter)}
/> />
<FilterChipGroup
label={t("runs.filterMode", "Mode")}
value={modeFilter}
options={MODES.map((m) => ({
value: m,
label: m === "all" ? t("runs.allLabel", "All") : t(`mode.${m}`),
count: counts.byMode[m] || 0,
}))}
onChange={(v) => setModeFilter(v as RunModeFilter)}
/>
</div> </div>
</div> </div>
@@ -467,10 +467,9 @@ function UnifiedRunRowView({
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}); });
const canResume = row.mode === "conversation" && !!row.sessionId && !row.isLive; // Without mode distinction, offer resume for any finished run with a session
// Headless runs are single-shot, so resume doesn't apply - but the captured const canResume = !!row.sessionId && !row.isLive;
// transcript is still worth viewing. Link to the Session detail page. const canView = !!row.sessionId && !row.isLive;
const canView = row.mode === "headless" && !!row.sessionId && !row.isLive;
return ( return (
<div <div
className={`px-5 py-3 transition-colors ${ className={`px-5 py-3 transition-colors ${
@@ -479,7 +478,6 @@ function UnifiedRunRowView({
> >
<div className="flex items-center gap-2 mb-1.5 flex-wrap"> <div className="flex items-center gap-2 mb-1.5 flex-wrap">
<StatusPill status={row.status} /> <StatusPill status={row.status} />
<ModeBadge mode={row.mode} />
{row.isLive && ( {row.isLive && (
<span className="text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 px-1.5 py-0.5 rounded-full inline-flex items-center gap-1"> <span className="text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 px-1.5 py-0.5 rounded-full inline-flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
+69 -57
View File
@@ -5,14 +5,13 @@
* the Run page and the Workspace page can both mount the same panel. * the Run page and the Workspace page can both mount the same panel.
* *
* What lives here: * What lives here:
* - `RunSetup` — mode (conversation / headless), fresh-vs-resume source, the * - `RunSetup` — fresh-vs-resume source, the prompt editor, and the cwd /
* prompt editor, and the cwd / model / permission-mode / effort fields, * model / permission-mode / effort fields, plus the concurrency hint and
* plus the concurrency hint and the Start button. Its disabled state is * the Start button. Its disabled state is driven by the `binaryFound` prop,
* driven by the `binaryFound` prop, so a missing `claude` binary is a * so a missing `claude` binary is a surfaced state here rather than a probe
* surfaced state here rather than a probe of its own. * of its own.
* above the panel, with its own localStorage-persisted minimized state.
* - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`, * - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`,
* `ModelPicker`, and the small `ModeOption` / `Field` layout helpers. * `ModelPicker`, and the small `Field` layout helper.
* *
* Props only for `RunSetup`: no `/stage` call, no lane API call, and no run * Props only for `RunSetup`: no `/stage` call, no lane API call, and no run
* lifecycle — the page owns `api.run.start` and hands the result back through * lifecycle — the page owns `api.run.start` and hands the result back through
@@ -45,18 +44,53 @@ import type {
RunListResponse, RunListResponse,
EffortLevel, EffortLevel,
PermissionMode, PermissionMode,
RunMode, RunStartArgs,
} from "../../lib/api"; } from "../../lib/api";
import type { Session } from "../../lib/types"; import type { Session } from "../../lib/types";
import { Select } from "../Select"; import { Select } from "../Select";
import { PromptEditor } from "./RunConsole";
import type { SlashCommand } from "./RunConsole"; // Minimal SlashCommand type (from deleted RunConsole)
export interface SlashCommand {
name: string;
source: "project" | "user" | "plugin" | "builtin";
description?: string;
}
// Minimal PromptEditor component (from deleted RunConsole)
interface PromptEditorProps {
value: string;
onChange: (s: string) => void;
onSubmit: () => void;
placeholder: string;
rows?: number;
slashCommands: SlashCommand[];
fileCwd: string;
}
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
onSubmit();
}
};
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={rows}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
/>
);
}
// ── Limitations banner (above the config card) ──────────────────────── // ── Limitations banner (above the config card) ────────────────────────
interface RunSetupProps { interface RunSetupProps {
mode: RunMode; laneId: number;
onModeChange: (m: RunMode) => void;
prompt: string; prompt: string;
onPromptChange: (s: string) => void; onPromptChange: (s: string) => void;
cwd: string; cwd: string;
@@ -70,7 +104,7 @@ interface RunSetupProps {
onEffortChange: (e: EffortLevel) => void; onEffortChange: (e: EffortLevel) => void;
binaryFound: boolean; binaryFound: boolean;
busy: boolean; busy: boolean;
onStart: () => void; onStart: (args: RunStartArgs) => void;
activeRuns: RunListResponse | null; activeRuns: RunListResponse | null;
resumeSession: Session | null; resumeSession: Session | null;
onResumeSessionChange: (s: Session | null) => void; onResumeSessionChange: (s: Session | null) => void;
@@ -85,47 +119,20 @@ interface RunSetupProps {
export function RunSetup(props: RunSetupProps) { export function RunSetup(props: RunSetupProps) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const atCap = const atCap = false; // TODO: re-add when concurrency info is available
props.activeRuns != null && props.activeRuns.activeCount >= props.activeRuns.maxConcurrent;
const isResume = !!props.resumeSession; const isResume = !!props.resumeSession;
const [resumePicked, setResumePicked] = useState(isResume); const [resumePicked, setResumePicked] = useState(isResume);
// Keep "resume picked" in sync with the parent. Two cases: // Keep "resume picked" in sync with the parent. Parent set a resume session
// 1. Parent set a resume session (e.g. user clicked Resume in the runs // (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
// modal) - flip the radio so the picker is shown and the selection // is shown and the selection is visible.
// is visible.
// 2. Parent cleared the session and mode flipped to headless - clear
// the radio so the form is honest.
useEffect(() => { useEffect(() => {
if (isResume && !resumePicked) setResumePicked(true); if (isResume && !resumePicked) setResumePicked(true);
else if (!isResume && resumePicked && props.mode === "headless") setResumePicked(false); }, [isResume, resumePicked]);
}, [isResume, resumePicked, props.mode]);
return ( return (
<div className="rounded-xl border border-border bg-surface-1"> <div className="rounded-xl border border-border bg-surface-1">
{/* Mode and source on one line. Both are two-way choices made once at {/* Fresh vs resume source picker */}
spawn time, so a segmented row carries them; the longer explanations
live in each button's title rather than in a paragraph. */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={props.mode === "conversation"}
label={t("mode.conversation")}
title={t("mode.conversationHint")}
onClick={() => props.onModeChange("conversation")}
/>
<Seg
active={props.mode === "headless"}
label={t("mode.headless")}
title={`${t("mode.headlessHint")}${t("hint.headlessExplain")}`}
onClick={() => {
props.onModeChange("headless");
setResumePicked(false);
}}
/>
</div>
{props.mode === "conversation" && (
<>
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5"> <div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg <Seg
active={!resumePicked} active={!resumePicked}
@@ -152,8 +159,6 @@ export function RunSetup(props: RunSetupProps) {
/> />
</div> </div>
)} )}
</>
)}
</div> </div>
{/* Prompt */} {/* Prompt */}
@@ -164,8 +169,8 @@ export function RunSetup(props: RunSetupProps) {
<PromptEditor <PromptEditor
value={props.prompt} value={props.prompt}
onChange={props.onPromptChange} onChange={props.onPromptChange}
onSubmit={props.onStart} onSubmit={() => handleStart(props)}
placeholder={t("fields.promptPlaceholder")} placeholder={t("fields.promptPlaceholderTerminal")}
rows={5} rows={5}
slashCommands={props.slashCommands} slashCommands={props.slashCommands}
fileCwd={props.resumeSession?.cwd || props.cwd} fileCwd={props.resumeSession?.cwd || props.cwd}
@@ -234,17 +239,12 @@ export function RunSetup(props: RunSetupProps) {
{atCap ? ( {atCap ? (
<span className="inline-flex items-center gap-1.5 text-status-warning"> <span className="inline-flex items-center gap-1.5 text-status-warning">
<AlertCircle className="w-3.5 h-3.5" /> <AlertCircle className="w-3.5 h-3.5" />
{t("concurrency.atCap", { max: props.activeRuns?.maxConcurrent ?? 0 })} {t("concurrency.atCap", { max: 0 })}
</span>
) : props.activeRuns && props.activeRuns.activeCount > 0 ? (
<span className="inline-flex items-center gap-1.5 text-fg-secondary">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
{t("concurrency.active", { count: props.activeRuns.activeCount })}
</span> </span>
) : null} ) : null}
</div> </div>
<button <button
onClick={props.onStart} onClick={() => handleStart(props)}
disabled={ disabled={
!props.binaryFound || !props.binaryFound ||
!props.prompt.trim() || !props.prompt.trim() ||
@@ -270,6 +270,18 @@ export function RunSetup(props: RunSetupProps) {
); );
} }
function handleStart(props: RunSetupProps) {
props.onStart({
laneId: props.laneId,
cwd: props.cwd || undefined,
model: props.model || undefined,
permissionMode: props.permissionMode || undefined,
effort: props.effort || undefined,
resumeSessionId: props.resumeSession?.id || undefined,
initialPrompt: props.prompt || undefined,
});
}
/** One segment of a two-way inline choice. The explanation rides on `title` /** One segment of a two-way inline choice. The explanation rides on `title`
* instead of a hint line, which is what keeps the row to one line. */ * instead of a hint line, which is what keeps the row to one line. */
function Seg({ function Seg({
@@ -1,172 +0,0 @@
/**
* @file RunConsole.test.tsx
* @description Pins the props-only boundary of `RunConsole` after its move out
* of `pages/Run.tsx`: the envelope stream renders from the `envelopes` prop
* (no stream subscription of its own), the token meter rolls up usage from
* those same envelopes, the prompt editor's `/` autocomplete filters and fills
* the prompt through `onFollowUpChange`, and `onSend` / `onStop` fire from the
* send and stop controls.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { MemoryRouter } from "react-router-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RunConsole, type SlashCommand } from "../RunConsole";
import type { Envelope } from "../../../hooks/useRunStream";
import type { RunHandle } from "../../../lib/api";
const HANDLE: RunHandle = {
id: "run-1",
pid: 4242,
mode: "conversation",
cwd: "/tmp/project",
model: "claude-opus-5",
permissionMode: "acceptEdits",
effort: "",
prompt: "hi",
argv: [],
resumeSessionId: null,
status: "running",
startedAt: 1,
endedAt: null,
exitCode: null,
signal: null,
error: null,
sessionId: null,
envelopeCount: 0,
stdoutTail: "",
stderrTail: "",
};
const COMMANDS: SlashCommand[] = [
{ name: "code-review", description: "Review the working diff", source: "project" },
{ name: "compact", description: "Compact the conversation context", source: "builtin" },
{ name: "logout", description: "Sign out", source: "builtin" },
];
/**
* Mount the console with the parent-owned follow-up state it expects, so the
* autocomplete assertions exercise the real controlled-input round trip.
*/
function renderConsole(
props: Partial<React.ComponentProps<typeof RunConsole>> = {},
onFollowUp?: (s: string) => void
) {
const seen = { followUp: "" };
function Harness() {
const [followUp, setFollowUp] = useState("");
seen.followUp = followUp;
return (
<RunConsole
handle={HANDLE}
envelopes={[]}
mode="conversation"
isLive
hasFinished={false}
followUp={followUp}
onFollowUpChange={(s) => {
setFollowUp(s);
onFollowUp?.(s);
}}
busy={null}
onSend={() => {}}
onStop={() => {}}
onNewRun={() => {}}
slashCommands={COMMANDS}
{...props}
/>
);
}
render(
<MemoryRouter>
<Harness />
</MemoryRouter>
);
return seen;
}
describe("RunConsole", () => {
it("renders assistant text from the envelopes prop", () => {
const envelopes: Envelope[] = [
{ type: "user", message: { content: "explain this repo" } },
{ type: "assistant", message: { content: [{ type: "text", text: "Here is the answer." }] } },
] as Envelope[];
renderConsole({ envelopes });
expect(screen.getByText("explain this repo")).toBeInTheDocument();
expect(screen.getByText("Here is the answer.")).toBeInTheDocument();
});
it("shows the empty-stream placeholder when there are no envelopes", () => {
renderConsole({ isLive: false });
expect(screen.getByText("Nothing yet")).toBeInTheDocument();
});
it("shows the token totals computed from the envelopes", () => {
// Transcript-shaped assistant envelope (no `message.id`), which is the
// branch computeTokens folds into the running totals.
const envelopes: Envelope[] = [
{
type: "assistant",
message: {
content: [{ type: "text", text: "done" }],
usage: { input_tokens: 12_000, output_tokens: 2_500, cache_read_input_tokens: 8_000 },
},
},
] as Envelope[];
renderConsole({ envelopes });
// Context gauge: (input + cache read) / default 200k window.
// The CLI-style meter is one status line: context usage as a single label,
// then output and cache-hit figures with terminal glyphs. Input is implied
// by the context total rather than listed separately.
expect(screen.getByText("20.0k / 200k (10%)")).toBeInTheDocument();
expect(screen.getByText("↑2.5k")).toBeInTheDocument(); // Output
expect(screen.getByText("⚡8.0k")).toBeInTheDocument(); // Cache hit
});
it("filters slash commands as the user types and fills the prompt on pick", () => {
const seen = renderConsole();
const textarea = screen.getByRole("textbox");
fireEvent.change(textarea, { target: { value: "/co" } });
expect(screen.getByText("/code-review")).toBeInTheDocument();
expect(screen.getByText("/compact")).toBeInTheDocument();
expect(screen.queryByText("/logout")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("/code-review"));
expect(seen.followUp).toBe("/code-review");
expect(screen.queryByText("/compact")).not.toBeInTheDocument(); // dropdown closed
});
it("fires onSend from the send button with the prompt the parent holds", () => {
const onSend = vi.fn();
const seen = renderConsole({ onSend });
fireEvent.change(screen.getByRole("textbox"), { target: { value: "follow up please" } });
fireEvent.click(screen.getByRole("button", { name: /send/i }));
expect(onSend).toHaveBeenCalledTimes(1);
expect(seen.followUp).toBe("follow up please");
});
it("fires onStop from the stop control while live, and hides it when not", () => {
const onStop = vi.fn();
renderConsole({ onStop });
fireEvent.click(screen.getByRole("button", { name: /stop/i }));
expect(onStop).toHaveBeenCalledTimes(1);
});
it("hides the stop control and the follow-up editor once the run is not live", () => {
renderConsole({ isLive: false, hasFinished: true });
expect(screen.queryByRole("button", { name: /stop/i })).not.toBeInTheDocument();
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
});
});
@@ -29,12 +29,10 @@ const activeRuns = {
{ {
id: LIVE_ID, id: LIVE_ID,
sessionId: "sess-live", sessionId: "sess-live",
mode: "conversation",
cwd: "/tmp/live", cwd: "/tmp/live",
model: "claude-opus-5", model: "claude-opus-5",
status: "running", status: "running",
prompt: "the live prompt", startedAt: "2000-01-01T00:50:00Z",
startedAt: 3000,
endedAt: null, endedAt: null,
}, },
], ],
@@ -44,7 +42,6 @@ function historyItem(over: Partial<DashboardRunHistoryItem>): DashboardRunHistor
return { return {
id: PAST_ID, id: PAST_ID,
session_id: "sess-past", session_id: "sess-past",
mode: "conversation",
cwd: "/tmp/past", cwd: "/tmp/past",
model: "sonnet", model: "sonnet",
status: "completed", status: "completed",
@@ -64,7 +61,6 @@ const PAST = historyItem({});
const HEADLESS = historyItem({ const HEADLESS = historyItem({
id: HEADLESS_ID, id: HEADLESS_ID,
session_id: "sess-headless", session_id: "sess-headless",
mode: "headless",
cwd: "/tmp/headless", cwd: "/tmp/headless",
prompt_preview: "the headless prompt", prompt_preview: "the headless prompt",
started_at: new Date(1000).toISOString(), started_at: new Date(1000).toISOString(),
@@ -95,10 +91,9 @@ function row(id: string, over: Partial<UnifiedRunRow> = {}): UnifiedRunRow {
return { return {
id, id,
sessionId: `sess-${id}`, sessionId: `sess-${id}`,
mode: "conversation",
cwd: `/tmp/${id}`, cwd: `/tmp/${id}`,
model: "sonnet", model: "sonnet",
status: "completed", status: "abandoned",
promptPreview: `prompt of ${id}`, promptPreview: `prompt of ${id}`,
startedAt: 1000, startedAt: 1000,
endedAt: 2000, endedAt: 2000,
@@ -216,39 +211,31 @@ describe("RunsModal", () => {
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID); expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
}); });
it("fires resume with the history item behind a finished conversation row", () => { it("fires resume with the history item behind a finished row", () => {
const { spies } = renderModal([row(PAST_ID)]); const { spies } = renderModal([row(PAST_ID)]);
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption"))); fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
expect(spies.onResume).toHaveBeenCalledWith(PAST); expect(spies.onResume).toHaveBeenCalledWith(PAST);
expect(spies.onView).not.toHaveBeenCalled();
}); });
it("fires view — not resume — for a finished headless row", () => { it("fires view for a finished row", () => {
const { spies } = renderModal([row(HEADLESS_ID, { mode: "headless" })]); const { spies } = renderModal([row(HEADLESS_ID)]);
expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel"))); fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel")));
expect(spies.onView).toHaveBeenCalledWith(HEADLESS); expect(spies.onView).toHaveBeenCalledWith(HEADLESS);
expect(spies.onResume).not.toHaveBeenCalled();
}); });
it("filters by status, by mode and by free text", () => { it("filters by status and by free text", () => {
const rows = [ const rows = [
row("a", { status: "running", isLive: true, promptPreview: "alpha" }), row("a", { status: "running", isLive: true, promptPreview: "alpha" }),
row("b", { status: "error", promptPreview: "bravo" }), row("b", { status: "killed", promptPreview: "bravo" }),
row("c", { status: "completed", mode: "headless", promptPreview: "charlie" }), row("c", { status: "abandoned", promptPreview: "charlie" }),
]; ];
renderModal(rows); renderModal(rows);
fireEvent.click(chip(i18n.t("run:status.error"))); fireEvent.click(chip(i18n.t("run:status.killed")));
expect(screen.getByText("bravo")).toBeTruthy(); expect(screen.getByText("bravo")).toBeTruthy();
expect(screen.queryByText("alpha")).toBeNull(); expect(screen.queryByText("alpha")).toBeNull();
fireEvent.click(allChip(0)); fireEvent.click(allChip(0));
fireEvent.click(chip(i18n.t("run:mode.headless")));
expect(screen.getByText("charlie")).toBeTruthy();
expect(screen.queryByText("bravo")).toBeNull();
fireEvent.click(allChip(1));
fireEvent.change( fireEvent.change(
screen.getByPlaceholderText( screen.getByPlaceholderText(
i18n.t("run:runs.searchPlaceholder", "Search prompt, cwd, model, or session id…") i18n.t("run:runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")
@@ -39,7 +39,6 @@ type Spies = ReturnType<typeof renderSetup>["spies"];
function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> = {}) { function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> = {}) {
const spies = { const spies = {
onModeChange: vi.fn(),
onPromptChange: vi.fn(), onPromptChange: vi.fn(),
onCwdChange: vi.fn(), onCwdChange: vi.fn(),
onModelChange: vi.fn(), onModelChange: vi.fn(),
@@ -52,7 +51,7 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
const utils = render( const utils = render(
<MemoryRouter> <MemoryRouter>
<RunSetup <RunSetup
mode="conversation" laneId={1}
prompt="do the thing" prompt="do the thing"
cwd="/Users/tester" cwd="/Users/tester"
cwdSuggestions={SUGGESTIONS} cwdSuggestions={SUGGESTIONS}
@@ -95,15 +94,6 @@ beforeEach(() => {
}); });
describe("RunSetup — selections report through callbacks", () => { describe("RunSetup — selections report through callbacks", () => {
it("reports the mode from the one-shot / conversation options", () => {
const { spies } = renderSetup();
fireEvent.click(screen.getByText(i18n.t("run:mode.headless")));
expect(spies.onModeChange).toHaveBeenCalledWith("headless");
fireEvent.click(screen.getByText(i18n.t("run:mode.conversation")));
expect(spies.onModeChange).toHaveBeenLastCalledWith("conversation");
onlyCalled(spies, "onModeChange");
});
it("reports the prompt from the editor", () => { it("reports the prompt from the editor", () => {
const { spies } = renderSetup({ prompt: "" }); const { spies } = renderSetup({ prompt: "" });
const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder")); const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder"));
@@ -171,7 +161,7 @@ describe("RunSetup — missing binary and other blocked states", () => {
expect(runButton().disabled).toBe(false); expect(runButton().disabled).toBe(false);
}); });
it("still disables Run without a prompt, without a cwd, or at the concurrency cap", () => { it("still disables Run without a prompt or without a cwd", () => {
const { unmount } = renderSetup({ prompt: " " }); const { unmount } = renderSetup({ prompt: " " });
expect(runButton().disabled).toBe(true); expect(runButton().disabled).toBe(true);
unmount(); unmount();
@@ -179,12 +169,6 @@ describe("RunSetup — missing binary and other blocked states", () => {
const noCwd = renderSetup({ cwd: "" }); const noCwd = renderSetup({ cwd: "" });
expect(runButton().disabled).toBe(true); expect(runButton().disabled).toBe(true);
noCwd.unmount(); noCwd.unmount();
renderSetup({
activeRuns: { items: [], activeCount: 2, maxConcurrent: 2 } as never,
});
expect(runButton().disabled).toBe(true);
expect(screen.getByText(i18n.t("run:concurrency.atCap", { max: 2 }))).toBeTruthy();
}); });
it("shows the Starting… label while busy", () => { it("shows the Starting… label while busy", () => {
@@ -237,7 +221,7 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
rerender( rerender(
<MemoryRouter> <MemoryRouter>
<RunSetup <RunSetup
mode="conversation" laneId={1}
prompt="do the thing" prompt="do the thing"
cwd="/Users/tester" cwd="/Users/tester"
cwdSuggestions={SUGGESTIONS} cwdSuggestions={SUGGESTIONS}
@@ -251,7 +235,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
slashCommands={[]} slashCommands={[]}
runHistory={[]} runHistory={[]}
laneCwd="/Users/tester/lane-b" laneCwd="/Users/tester/lane-b"
onModeChange={vi.fn()}
onPromptChange={vi.fn()} onPromptChange={vi.fn()}
onCwdChange={vi.fn()} onCwdChange={vi.fn()}
onModelChange={vi.fn()} onModelChange={vi.fn()}
@@ -1,174 +0,0 @@
/**
* @file useRunStream.test.tsx
* @description Covers `useRunStream`, the hook that owns the Run page's live
* envelope state: it subscribes to the event bus and folds `run_stream`
* envelopes into an array, forwards `run_status` / `run_input_ack` for the
* subscribed run id to the caller's callbacks, fires the id-agnostic
* `onAnyStatus` for every `run_status`, and disposes its subscription on
* unmount. `eventBus` is exercised for real (it is a plain in-memory pub/sub)
* with only its `subscribe` spied on, so the disposer assertion pins the real
* lifecycle rather than a mock's.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { eventBus } from "../../lib/eventBus";
import type { WSMessage } from "../../lib/types";
import { useRunStream, type Envelope } from "../useRunStream";
/** `run_stream` frame carrying one envelope for `id`. */
function streamMsg(id: string, envelope: unknown): WSMessage {
return { type: "run_stream", data: { id, envelope } } as WSMessage;
}
function statusMsg(id: string, status: string): WSMessage {
return { type: "run_status", data: { id, status, at: 1 } } as WSMessage;
}
const noopOpts = { onStatus: () => {}, onInputAck: () => {}, onAnyStatus: () => {} };
afterEach(() => {
vi.restoreAllMocks();
});
describe("useRunStream", () => {
it("merges envelopes for the subscribed run id in arrival order", () => {
const { result } = renderHook(() => useRunStream("run-1", noopOpts));
act(() => {
eventBus.publish(streamMsg("run-1", { type: "system", subtype: "init" }));
eventBus.publish(streamMsg("run-1", { type: "result", subtype: "success" }));
});
expect(result.current.envelopes.map((e) => (e as { type: string }).type)).toEqual([
"system",
"result",
]);
});
it("ignores an envelope for a different run id", () => {
const { result } = renderHook(() => useRunStream("run-1", noopOpts));
act(() => {
eventBus.publish(streamMsg("run-2", { type: "result" }));
});
expect(result.current.envelopes).toEqual([]);
});
it("updates a streaming assistant envelope in place instead of appending", () => {
const { result } = renderHook(() => useRunStream("run-1", noopOpts));
act(() => {
// message_start seeds the kept envelope + a streaming placeholder.
eventBus.publish(
streamMsg("run-1", {
type: "stream_event",
event: { type: "message_start", message: { id: "m1" } },
})
);
});
expect(result.current.envelopes).toHaveLength(2);
act(() => {
eventBus.publish(
streamMsg("run-1", {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
message: { id: "m1" },
content_block: { type: "text", text: "" },
},
})
);
eventBus.publish(
streamMsg("run-1", {
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
message: { id: "m1" },
delta: { type: "text_delta", text: "hi" },
},
})
);
});
// Still 2 envelopes: the deltas mutated the placeholder, they did not append.
expect(result.current.envelopes).toHaveLength(2);
const placeholder = result.current.envelopes[1] as {
message: { content: { text?: string }[]; _streaming?: boolean };
};
expect(placeholder.message.content[0]?.text).toBe("hi");
expect(placeholder.message._streaming).toBe(true);
});
it("invokes onStatus only for the subscribed run id, onAnyStatus for every run_status", () => {
const onStatus = vi.fn();
const onAnyStatus = vi.fn();
renderHook(() => useRunStream("run-1", { ...noopOpts, onStatus, onAnyStatus }));
act(() => {
eventBus.publish(statusMsg("run-1", "completed"));
eventBus.publish(statusMsg("run-2", "completed"));
});
expect(onStatus).toHaveBeenCalledTimes(1);
expect(onStatus.mock.calls[0]?.[0]).toMatchObject({ id: "run-1", status: "completed" });
expect(onAnyStatus).toHaveBeenCalledTimes(2);
});
it("invokes onInputAck only for the subscribed run id", () => {
const onInputAck = vi.fn();
renderHook(() => useRunStream("run-1", { ...noopOpts, onInputAck }));
act(() => {
eventBus.publish({ type: "run_input_ack", data: { id: "run-2" } } as WSMessage);
eventBus.publish({ type: "run_input_ack", data: { id: "run-1" } } as WSMessage);
});
expect(onInputAck).toHaveBeenCalledTimes(1);
});
it("ignores every frame while the run id is null", () => {
const onAnyStatus = vi.fn();
const { result } = renderHook(() => useRunStream(null, { ...noopOpts, onAnyStatus }));
act(() => {
eventBus.publish(streamMsg("run-1", { type: "result" }));
eventBus.publish(statusMsg("run-1", "completed"));
});
expect(result.current.envelopes).toEqual([]);
expect(onAnyStatus).toHaveBeenCalledTimes(1); // id-agnostic by design
});
it("disposes the event bus subscription on unmount", () => {
const dispose = vi.fn();
const subscribe = vi.spyOn(eventBus, "subscribe").mockReturnValue(dispose);
const { unmount } = renderHook(() => useRunStream("run-1", noopOpts));
expect(subscribe).toHaveBeenCalledTimes(1);
expect(dispose).not.toHaveBeenCalled();
unmount();
expect(dispose).toHaveBeenCalledTimes(1);
});
it("exposes setEnvelopes so the page can seed and clear the list", () => {
const { result } = renderHook(() => useRunStream("run-1", noopOpts));
act(() => {
result.current.setEnvelopes([{ type: "user", message: { content: "hello" } } as Envelope]);
});
expect(result.current.envelopes).toHaveLength(1);
act(() => {
result.current.setEnvelopes([]);
});
expect(result.current.envelopes).toEqual([]);
});
});
-489
View File
@@ -1,489 +0,0 @@
/**
* @file useRunStream.ts
* @description Owns the Run page's live stream-json state. Subscribes to the
* WebSocket event bus and folds every `run_stream` envelope for one run id into
* an envelope array (`mergeEnvelope` and friends, moved here verbatim from
* `pages/Run.tsx`), exposes the typewriter-smoothed view of that array, and
* hands `run_status` / `run_input_ack` back to the caller — the page still owns
* the `RunHandle` and the run-list refresh, so those arrive as callbacks.
*
* The stream-json envelope types live here too, since this hook is what
* produces them; the page imports them for rendering.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { eventBus } from "../lib/eventBus";
import type {
RunInputAckPayload,
RunStatusPayload,
RunStreamPayload,
WSMessage,
} from "../lib/types";
// ── Stream-json envelope shapes (the bits we render) ──────────────────
export type ContentBlock =
| { type: "text"; text: string }
| { type: "thinking"; thinking?: string }
| { type: "tool_use"; id: string; name: string; input: unknown }
| { type: "tool_result"; tool_use_id: string; content: unknown; is_error?: boolean };
export interface AssistantMessage {
type: "assistant";
message?: {
content?: ContentBlock[] | string;
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
};
}
export interface UserMessage {
type: "user";
message?: { content?: ContentBlock[] | string };
}
export interface SystemInit {
type: "system";
subtype: "init";
session_id?: string;
model?: string;
cwd?: string;
tools?: string[];
permissionMode?: string;
}
export interface ResultEnvelope {
type: "result";
subtype?: string;
is_error?: boolean;
duration_ms?: number;
duration_api_ms?: number;
num_turns?: number;
result?: string;
session_id?: string;
total_cost_usd?: number;
usage?: { input_tokens?: number; output_tokens?: number };
}
export type Envelope =
| AssistantMessage
| UserMessage
| SystemInit
| ResultEnvelope
| { type: string; [k: string]: unknown };
// ── Streaming envelope merge ───────────────────────────────────────────
//
// `claude --output-format stream-json --include-partial-messages` emits two
// kinds of assistant output:
//
// 1. `stream_event` envelopes carrying Anthropic Messages API streaming
// events (`message_start`, `content_block_start`, `content_block_delta`,
// `content_block_stop`, `message_delta`, `message_stop`).
// 2. Eventually, a single complete `assistant` envelope summarising the turn.
//
// To make the chat actually stream character-by-character we accumulate the
// `stream_event` deltas into a synthetic assistant envelope. When the real
// `assistant` envelope arrives, we replace the synthetic one with it (their
// content is identical at that point, but the final envelope has authoritative
// usage / metadata).
interface StreamEventEnvelope {
type: "stream_event";
event?: {
type: string;
index?: number;
delta?: {
type: string;
text?: string;
thinking?: string;
partial_json?: string;
};
content_block?: {
type: string;
text?: string;
thinking?: string;
id?: string;
name?: string;
input?: unknown;
};
message?: { id?: string };
};
}
type StreamingAssistantBlock = ContentBlock & {
_partialJson?: string;
};
interface StreamingAssistantMessage {
type: "assistant";
_streamId?: string;
message: {
id?: string;
content: StreamingAssistantBlock[];
_streaming?: boolean;
};
}
function findLastStreamingAssistant(prev: Envelope[]): number {
for (let i = prev.length - 1; i >= 0; i--) {
const env = prev[i] as { type?: string; message?: { _streaming?: boolean } };
if (env?.type === "assistant" && env.message?._streaming) return i;
}
return -1;
}
function findAssistantByMessageId(prev: Envelope[], id: string | undefined): number {
if (!id) return findLastStreamingAssistant(prev);
for (let i = prev.length - 1; i >= 0; i--) {
const env = prev[i] as { type?: string; message?: { id?: string } };
if (env?.type === "assistant" && env.message?.id === id) return i;
}
return findLastStreamingAssistant(prev);
}
function mutateAssistantAt(
prev: Envelope[],
idx: number,
fn: (m: StreamingAssistantMessage["message"]) => StreamingAssistantMessage["message"]
): Envelope[] {
if (idx < 0) return prev;
const env = prev[idx] as StreamingAssistantMessage;
const next = [...prev];
next[idx] = {
...env,
message: fn(env.message || ({ content: [] } as StreamingAssistantMessage["message"])),
};
return next;
}
function mergeEnvelope(prev: Envelope[], envelope: Envelope): Envelope[] {
if (!envelope || typeof envelope !== "object") return prev;
const env = envelope as { type?: string };
if (env.type === "stream_event") {
const sse = envelope as StreamEventEnvelope;
const evt = sse.event;
if (!evt) return prev;
if (evt.type === "message_start") {
const placeholder: StreamingAssistantMessage = {
type: "assistant",
message: {
id: evt.message?.id,
content: [],
_streaming: true,
},
};
// Keep the message_start envelope itself in the array - its
// `event.message.usage` is the only place we get the initial input /
// cache token counts during live streaming. Without it, the meter is
// stuck at zero until the post-reload replay re-injects the same
// envelopes from the server.
return [...prev, envelope, placeholder as unknown as Envelope];
}
if (evt.type === "content_block_start") {
const idx = findAssistantByMessageId(prev, evt.message?.id);
if (idx < 0) return prev;
const blockIdx = evt.index ?? 0;
return mutateAssistantAt(prev, idx, (msg) => {
const blocks = [...(msg.content || [])];
blocks[blockIdx] = { ...(evt.content_block as ContentBlock) };
return { ...msg, content: blocks };
});
}
if (evt.type === "content_block_delta") {
const idx = findAssistantByMessageId(prev, evt.message?.id);
if (idx < 0) return prev;
const blockIdx = evt.index ?? 0;
return mutateAssistantAt(prev, idx, (msg) => {
const blocks = [...(msg.content || [])];
const block = (blocks[blockIdx] || {}) as StreamingAssistantBlock;
const next = { ...block } as StreamingAssistantBlock;
const delta = evt.delta;
if (delta?.type === "text_delta") {
(next as { text?: string }).text =
((next as { text?: string }).text || "") + (delta.text || "");
if (!next.type) (next as { type: string }).type = "text";
} else if (delta?.type === "thinking_delta") {
(next as { thinking?: string }).thinking =
((next as { thinking?: string }).thinking || "") + (delta.thinking || "");
if (!next.type) (next as { type: string }).type = "thinking";
} else if (delta?.type === "input_json_delta") {
// tool_use input streams as JSON-string fragments; accumulate, parse
// best-effort whenever the buffer is valid JSON.
next._partialJson = (next._partialJson || "") + (delta.partial_json || "");
try {
(next as { input?: unknown }).input = JSON.parse(next._partialJson);
} catch {
/* still incomplete JSON - leave previous parsed value */
}
}
blocks[blockIdx] = next;
return { ...msg, content: blocks };
});
}
if (evt.type === "message_stop") {
const idx = findAssistantByMessageId(prev, evt.message?.id);
if (idx < 0) return prev;
return mutateAssistantAt(prev, idx, (msg) => ({ ...msg, _streaming: false }));
}
if (evt.type === "message_delta") {
// message_delta carries the canonical per-message usage update (the
// running output_tokens for this turn). Keep the envelope so
// computeTokens can read it; otherwise the meter sits at the
// message_start placeholder value (output_tokens=4 etc) for the
// entire response.
return [...prev, envelope];
}
// content_block_start/stop and other stream_event subtypes are mutations
// on the placeholder we already track - no usage info, no need to keep
// the envelope itself.
return prev;
}
if (env.type === "assistant") {
// Claude emits the canonical `assistant` envelope BEFORE `message_stop`,
// so the message is still streaming at this point. Two regressions came
// out of replacing the placeholder wholesale here:
// 1. The `_streaming` flag was dropped, making the typewriter snap to
// full text the moment this envelope arrived.
// 2. The final envelope sometimes ships only the `text` content block
// (the `thinking` block we accumulated from `thinking_delta`s
// disappears), so the thinking section vanished as soon as the
// stream finished.
// Fix: when the placeholder was streaming, keep our delta-accumulated
// content (it's the authoritative record of every block) and only pull
// metadata from the incoming envelope. `message_stop` clears `_streaming`
// and the typewriter then reveals any unrevealed tail instantly.
const finalMsg = envelope as { message?: { id?: string; _streaming?: boolean } };
const idx = findAssistantByMessageId(prev, finalMsg.message?.id);
if (idx >= 0) {
const prevEnv = prev[idx] as StreamingAssistantMessage;
const next = [...prev];
if (prevEnv.message?._streaming) {
const incoming = envelope as { message?: Record<string, unknown> };
const incomingMsg = (incoming.message || {}) as Record<string, unknown>;
const accumulatedContent = prevEnv.message?.content || [];
const incomingContent = (incomingMsg as { content?: ContentBlock[] }).content;
// If the canonical envelope happens to carry MORE blocks (e.g. it
// includes a tool_use we hadn't seen as a stream_event yet), prefer
// it. Otherwise keep our accumulated blocks so we don't lose a
// thinking section the canonical envelope omitted.
const content =
Array.isArray(incomingContent) && incomingContent.length > accumulatedContent.length
? incomingContent
: accumulatedContent;
next[idx] = {
...envelope,
message: { ...incomingMsg, content, _streaming: true },
} as Envelope;
} else {
next[idx] = envelope;
}
return next;
}
return [...prev, envelope];
}
return [...prev, envelope];
}
/**
* Smooth out claude's bursty stream by dripping text/thinking deltas a few
* characters per frame. Without this, short responses (where claude emits
* the entire reply in one or two `text_delta` chunks) appear all-at-once.
* The hook returns a derived envelope list with each actively-streaming
* text/thinking block clamped to a displayed length that grows toward the
* server's target via requestAnimationFrame.
*/
function useTypewriterEnvelopes(envelopes: Envelope[]): Envelope[] {
const lengthsRef = useRef<Map<string, number>>(new Map());
const envRef = useRef<Envelope[]>(envelopes);
envRef.current = envelopes;
const [tick, setTick] = useState(0);
const rafRef = useRef<number | null>(null);
const tickFnRef = useRef<(() => void) | null>(null);
if (!tickFnRef.current) {
tickFnRef.current = function tickFn() {
const envs = envRef.current;
const lengths = lengthsRef.current;
let needsAnother = false;
let mutated = false;
for (let ei = 0; ei < envs.length; ei++) {
const env = envs[ei];
if (!env || (env as { type?: string }).type !== "assistant") continue;
const e = env as StreamingAssistantMessage;
const streaming = !!e.message?._streaming;
const blocks = e.message?.content || [];
for (let bi = 0; bi < blocks.length; bi++) {
const b = blocks[bi];
if (!b) continue;
let key: string;
let target: string;
if (b.type === "text") {
key = `${ei}:${bi}:t`;
target = (b as { text?: string }).text || "";
} else if (b.type === "thinking") {
key = `${ei}:${bi}:th`;
target = (b as { thinking?: string }).thinking || "";
} else {
continue;
}
const cur = lengths.get(key) ?? 0;
if (cur >= target.length) continue;
if (streaming) {
// Catch up to target in roughly 0.4s; bigger gaps drip faster.
const remaining = target.length - cur;
const step = Math.max(2, Math.ceil(remaining / 24));
lengths.set(key, Math.min(target.length, cur + step));
needsAnother = true;
mutated = true;
} else {
// Block is no longer streaming → reveal the rest instantly.
lengths.set(key, target.length);
mutated = true;
}
}
}
if (mutated) setTick((t) => (t + 1) & 0xffff);
rafRef.current = needsAnother
? requestAnimationFrame(tickFnRef.current as FrameRequestCallback)
: null;
};
}
// Single long-lived RAF loop. Reads envelopes via ref so new server data
// is picked up without tearing down and rescheduling the loop on every
// websocket message - a previous version restarted on each envelope
// change which dropped frames between bursts and hid the streaming.
useEffect(() => {
rafRef.current = requestAnimationFrame(tickFnRef.current as FrameRequestCallback);
return () => {
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
};
}, []);
// Wake the loop when new envelopes arrive if it's parked (no pending work).
useEffect(() => {
if (rafRef.current == null && envelopes.length > 0) {
rafRef.current = requestAnimationFrame(tickFnRef.current as FrameRequestCallback);
}
}, [envelopes]);
// Reset lengths when envelopes shrink (e.g., the user starts a new run).
useEffect(() => {
if (envelopes.length === 0 && lengthsRef.current.size > 0) {
lengthsRef.current.clear();
}
}, [envelopes.length]);
return useMemo(() => {
const lengths = lengthsRef.current;
return envelopes.map((env, ei) => {
if (!env || (env as { type?: string }).type !== "assistant") return env;
const e = env as StreamingAssistantMessage;
const blocks = e.message?.content || [];
let changed = false;
const nextBlocks = blocks.map((b, bi) => {
if (b.type === "text") {
const full = (b as { text?: string }).text || "";
const len = lengths.get(`${ei}:${bi}:t`) ?? full.length;
if (len < full.length) {
changed = true;
return { ...b, text: full.slice(0, len) };
}
} else if (b.type === "thinking") {
const full = (b as { thinking?: string }).thinking || "";
const len = lengths.get(`${ei}:${bi}:th`) ?? full.length;
if (len < full.length) {
changed = true;
return { ...b, thinking: full.slice(0, len) };
}
}
return b;
});
if (!changed) return env;
return {
...e,
message: { ...e.message, content: nextBlocks },
} as unknown as Envelope;
});
// tick is intentionally a dep so this memo re-runs on each RAF step.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [envelopes, tick]);
}
/**
* Subscribe to the live stream of one run.
*
* `runId` is the id whose frames this hook cares about — `null` while no run is
* attached. `onStatus` and `onInputAck` fire only for a payload matching
* `runId` (mirroring the page's old `handle && p.id === handle.id` guard);
* `onAnyStatus` fires for EVERY `run_status` frame regardless of id, because
* the page's run-list refresh has always been id-agnostic.
*/
export function useRunStream(
runId: string | null,
opts: {
onStatus: (p: RunStatusPayload) => void;
onInputAck: () => void;
onAnyStatus: () => void;
}
): {
envelopes: Envelope[];
setEnvelopes: React.Dispatch<React.SetStateAction<Envelope[]>>;
displayEnvelopes: Envelope[];
} {
const [envelopes, setEnvelopes] = useState<Envelope[]>([]);
const displayEnvelopes = useTypewriterEnvelopes(envelopes);
// Latest callbacks in a ref so the subscription's lifetime depends on the
// run id alone - re-subscribing whenever a caller passes a fresh closure
// would tear down and rebuild the bus handler on every page render.
const optsRef = useRef(opts);
optsRef.current = opts;
// WebSocket subscription - only act on messages for the current run.
useEffect(() => {
return eventBus.subscribe((msg: WSMessage) => {
if (msg.type === "run_stream") {
const p = msg.data as RunStreamPayload;
if (runId && p.id === runId) {
// React 18 auto-batches async setStates, which collapses bursts of
// stream_event deltas (and the final `assistant` envelope that
// follows them) into a single render - visually erasing the
// streaming effect. flushSync forces a commit per envelope so the
// user sees text_delta / thinking_delta chunks paint as they
// arrive instead of all at once.
flushSync(() => {
setEnvelopes((prev) => mergeEnvelope(prev, p.envelope as Envelope));
});
}
} else if (msg.type === "run_status") {
const p = msg.data as RunStatusPayload;
if (runId && p.id === runId) {
optsRef.current.onStatus(p);
}
optsRef.current.onAnyStatus();
} else if (msg.type === "run_input_ack") {
const p = msg.data as RunInputAckPayload;
if (runId && p.id === runId) {
optsRef.current.onInputAck();
}
}
});
}, [runId]);
return { envelopes, setEnvelopes, displayEnvelopes };
}
+2 -1
View File
@@ -74,7 +74,8 @@
"permissionMode": "Permission mode", "permissionMode": "Permission mode",
"permissionPlan": "plan (read-only planning)", "permissionPlan": "plan (read-only planning)",
"prompt": "Prompt", "prompt": "Prompt",
"promptPlaceholder": "Ask Claude anything…" "promptPlaceholder": "Ask Claude anything…",
"promptPlaceholderTerminal": "Ask Claude anything…"
}, },
"footer": { "footer": {
"cost": "Cost", "cost": "Cost",
+2 -1
View File
@@ -73,7 +73,8 @@
"permissionMode": "Permission mode", "permissionMode": "Permission mode",
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)", "permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
"prompt": "Prompt", "prompt": "Prompt",
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…" "promptPlaceholder": "Hỏi Claude bất cứ điều gì…",
"promptPlaceholderTerminal": "Hỏi Claude bất cứ điều gì…"
}, },
"footer": { "footer": {
"cost": "Chi phí", "cost": "Chi phí",
+112 -175
View File
@@ -62,59 +62,13 @@ import type {
WSMessage, WSMessage,
} from "../lib/types"; } from "../lib/types";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import { useRunStream } from "../hooks/useRunStream"; import { TerminalView } from "../components/run/TerminalView";
import { BUILTIN_SLASH_COMMANDS, RunConsole } from "../components/run/RunConsole";
import type { SlashCommand } from "../components/run/RunConsole";
import { RunSetup } from "../components/run/RunSetup"; import { RunSetup } from "../components/run/RunSetup";
import { ActiveRunsSwitcher } from "../components/run/RunHistory"; import { ActiveRunsSwitcher } from "../components/run/RunHistory";
import PipelineMap from "../components/lanes/PipelineMap"; import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard from "../components/lanes/LaneCard"; import LaneCard from "../components/lanes/LaneCard";
import LaneStripCard from "../components/lanes/LaneStripCard"; import LaneStripCard from "../components/lanes/LaneStripCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal"; import { AddLaneModal } from "../components/lanes/AddLaneModal";
import type { ContentBlock, Envelope, UserMessage } from "../hooks/useRunStream";
// Convert past-session transcript messages into envelope shapes so the chat
// view can render the prior conversation alongside live output from the
// resumed run. The shapes are close but not identical (`thinking.text` vs
// `thinking.thinking`, tool_result `id`/`output` vs `tool_use_id`/`content`),
// so each block is mapped individually.
function transcriptToEnvelopes(messages: TranscriptMessage[]): Envelope[] {
const mapBlock = (b: TranscriptContent): ContentBlock | null => {
if (b.type === "text") return { type: "text", text: b.text || "" };
if (b.type === "thinking") return { type: "thinking", thinking: b.text || "" };
if (b.type === "tool_use") {
return { type: "tool_use", id: b.id || "", name: b.name || "", input: b.input };
}
if (b.type === "tool_result") {
return {
type: "tool_result",
tool_use_id: b.id || "",
content: b.output || "",
is_error: !!b.is_error,
};
}
return null;
};
const out: Envelope[] = [];
// Prepend a synthetic system/init envelope carrying the model so the
// context-window heuristic in computeTokens can size the meter correctly
// (e.g., [1m] tag → 1M cap) even when no live `system` envelope has
// arrived yet because the run was loaded from history.
const firstModel = messages.find((m) => m.type === "assistant" && m.model)?.model;
if (firstModel) {
out.push({ type: "system", subtype: "init", model: firstModel } as Envelope);
}
for (const m of messages) {
const content = m.content.map(mapBlock).filter((x): x is ContentBlock => x !== null);
if (content.length === 0) continue;
if (m.type === "assistant") {
out.push({ type: "assistant", message: { content, usage: m.usage } });
} else {
out.push({ type: "user", message: { content } });
}
}
return out;
}
// ── Page ────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────
@@ -139,7 +93,6 @@ export function Workspace() {
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]); const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
// Run state // Run state
const [mode, setMode] = useState<RunMode>("conversation");
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [model, setModel] = useState(""); const [model, setModel] = useState("");
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits"); const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
@@ -147,37 +100,7 @@ export function Workspace() {
const [cwd, setCwd] = useState(""); const [cwd, setCwd] = useState("");
const [resumeSession, setResumeSession] = useState<Session | null>(null); const [resumeSession, setResumeSession] = useState<Session | null>(null);
const [handle, setHandle] = useState<RunHandle | null>(null); const [handle, setHandle] = useState<RunHandle | null>(null);
// Live stream state (envelopes + typewriter view) and the WS subscription const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
// live in useRunStream; the page keeps the handle and the run-list refresh.
// `onAnyStatus` fires for every run_status regardless of id - that is what
// the old inline subscription did with refreshList().
const { setEnvelopes, displayEnvelopes } = useRunStream(handle?.id ?? null, {
onStatus: (p) =>
setHandle((h) =>
h
? {
...h,
status: p.status,
endedAt: p.at,
exitCode: p.exitCode ?? h.exitCode,
sessionId: p.sessionId ?? h.sessionId,
error: p.error ?? h.error,
}
: h
),
onInputAck: () => {
// Optimistically add the user envelope so the chat shows it
// immediately (the spawned `claude` won't echo our user input
// back on stdout in stream-json; we own that side).
setEnvelopes((prev) => [
...prev,
{ type: "user", message: { content: followUpRef.current || "" } } as UserMessage,
]);
},
onAnyStatus: () => refreshList(),
});
const [followUp, setFollowUp] = useState("");
const [busy, setBusy] = useState<"start" | "send" | "stop" | "attach" | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null); const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]); const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
@@ -185,7 +108,7 @@ export function Workspace() {
null null
); );
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]); const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>(BUILTIN_SLASH_COMMANDS); const [slashCommands, setSlashCommands] = useState<any[]>([]);
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount // Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
const refreshLanes = useCallback(async () => { const refreshLanes = useCallback(async () => {
@@ -388,7 +311,7 @@ export function Workspace() {
throw new Error("No run_id returned from lane start"); throw new Error("No run_id returned from lane start");
} }
fetched = await api.run.get(laneStartResult.lane.run_id, { envelopes: true }); fetched = await api.run.get(laneStartResult.lane.run_id);
} else { } else {
// No cwd: resume as a non-lane run (backward compatibility). // No cwd: resume as a non-lane run (backward compatibility).
// These runs stay outside the lane system and are cleaned up // These runs stay outside the lane system and are cleaned up
@@ -405,8 +328,6 @@ export function Workspace() {
} }
setHandle(fetched); setHandle(fetched);
setEnvelopes(transcriptToEnvelopes(transcript.messages));
setFollowUp("");
setResumeSession(null); setResumeSession(null);
refreshList(); refreshList();
} catch (err) { } catch (err) {
@@ -421,16 +342,14 @@ export function Workspace() {
// View a past run inline (no spawn). Headless runs are single-shot, so // View a past run inline (no spawn). Headless runs are single-shot, so
// there's no resume - but the transcript is still worth seeing without // there's no resume - but the transcript is still worth seeing without
// navigating away. Seeds the chat view with the past messages and a // navigating away. Sets a synthetic completed handle so the UI renders
// synthetic completed handle so the UI renders as read-only (no Stop // as read-only (no Stop button, no follow-up input - both are gated on isLive).
// button, no follow-up input - both are gated on isLive).
const onViewFromHistory = useCallback( const onViewFromHistory = useCallback(
async (item: DashboardRunHistoryItem) => { async (item: DashboardRunHistoryItem) => {
if (!item.session_id) return; if (!item.session_id) return;
if (busy) return; if (busy) return;
setError(null); setError(null);
try { try {
const transcript = await api.sessions.transcript(item.session_id, { limit: 200 });
const synthetic: RunHandle = { const synthetic: RunHandle = {
id: item.id, id: item.id,
pid: null, pid: null,
@@ -449,14 +368,11 @@ export function Workspace() {
signal: null, signal: null,
error: null, error: null,
sessionId: item.session_id, sessionId: item.session_id,
envelopeCount: transcript.messages.length, envelopeCount: 0,
stdoutTail: "", stdoutTail: "",
stderrTail: "", stderrTail: "",
}; };
setHandle(synthetic); setHandle(synthetic);
setEnvelopes(transcriptToEnvelopes(transcript.messages));
setMode(item.mode);
setFollowUp("");
setResumeSession(null); setResumeSession(null);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "unknown"; const msg = err instanceof Error ? err.message : "unknown";
@@ -466,21 +382,11 @@ export function Workspace() {
[busy, t] [busy, t]
); );
// Keep latest follow-up in a ref so the WS handler can read it without
// closure staleness during ack injection.
const followUpRef = useRef("");
useEffect(() => {
followUpRef.current = followUp;
}, [followUp]);
const start = useCallback(async () => { const start = useCallback(async () => {
if (!prompt.trim() || busy) return; if (!prompt.trim() || busy) return;
setBusy("start"); setBusy("start");
setError(null); setError(null);
setEnvelopes([]);
try { try {
// Resume always uses conversation mode (server enforces this too).
const effectiveMode: RunMode = resumeSession ? "conversation" : mode;
const effectiveCwd = resumeSession?.cwd || cwd || undefined; const effectiveCwd = resumeSession?.cwd || cwd || undefined;
// Expand /user-or-project slash commands client-side so the model // Expand /user-or-project slash commands client-side so the model
// receives the rendered template, matching what the CLI does. // receives the rendered template, matching what the CLI does.
@@ -532,7 +438,6 @@ export function Workspace() {
try { try {
laneStartResult = await api.lanes.action(targetLaneId, "start", { laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: expandedPrompt, prompt: expandedPrompt,
mode: effectiveMode,
model: model || undefined, model: model || undefined,
permissionMode, permissionMode,
resumeSessionId: resumeSession?.id, resumeSessionId: resumeSession?.id,
@@ -565,10 +470,8 @@ export function Workspace() {
// Fetch the full RunHandle for the new run; fall back to attachToRun if fetch fails // Fetch the full RunHandle for the new run; fall back to attachToRun if fetch fails
try { try {
const handle = await api.run.get(laneStartResult.lane.run_id, { envelopes: true }); const handle = await api.run.get(laneStartResult.lane.run_id);
setHandle(handle); setHandle(handle);
// Optimistic user-turn injection so the chat shows your prompt right away.
setEnvelopes([{ type: "user", message: { content: prompt } } as UserMessage]);
refreshList(); refreshList();
} catch (attachErr: unknown) { } catch (attachErr: unknown) {
// Run started but we can't fetch the handle. Attach to the run via the existing path. // Run started but we can't fetch the handle. Attach to the run via the existing path.
@@ -590,7 +493,6 @@ export function Workspace() {
} }
}, [ }, [
prompt, prompt,
mode,
cwd, cwd,
model, model,
permissionMode, permissionMode,
@@ -608,40 +510,8 @@ export function Workspace() {
setBusy("attach"); setBusy("attach");
setError(null); setError(null);
try { try {
const fetched = await api.run.get(id, { envelopes: true }); const fetched = await api.run.get(id);
const spawnerEnvs = ((fetched.envelopes as Envelope[]) || []).slice();
let envelopesToUse = spawnerEnvs;
// The spawner's in-memory envelope log only contains envelopes that
// came over stdout for this specific spawn. For a resumed run, that
// means prior history is missing - claude --resume reads the prior
// transcript as context but doesn't replay it on stdout. Without
// this, re-attaching to a resumed run after navigating away loses
// everything from before the resume. The session's JSONL transcript
// on disk has the full story (prior + current), so we use it
// whenever it has more user/assistant messages than the spawner has
// seen; otherwise we keep the spawner's log (which is authoritative
// for in-progress streaming since stream_event deltas don't land in
// the transcript file until the turn finishes).
if (fetched.sessionId) {
try {
const transcript = await api.sessions.transcript(fetched.sessionId, { limit: 200 });
const transcriptEnvs = transcriptToEnvelopes(transcript.messages);
const spawnerCanonicalCount = spawnerEnvs.filter((e) => {
const t = (e as { type?: string }).type;
return t === "user" || t === "assistant";
}).length;
if (transcriptEnvs.length > spawnerCanonicalCount) {
envelopesToUse = transcriptEnvs;
}
} catch {
/* transcript fetch failed - keep the spawner's log */
}
}
setHandle(fetched); setHandle(fetched);
setEnvelopes(envelopesToUse);
setFollowUp("");
} catch (err: unknown) { } catch (err: unknown) {
const m = err instanceof Error ? err.message : "unknown"; const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.attachFailed", { message: m })); setError(t("errors.attachFailed", { message: m }));
@@ -735,27 +605,106 @@ export function Workspace() {
void start(); void start();
}, [binaryStatus, prompt, cwd, busy, handle, start]); }, [binaryStatus, prompt, cwd, busy, handle, start]);
const send = useCallback(async () => { const onStartFromSetup = useCallback(
if (!handle || !followUp.trim() || busy) return; async (args: any) => {
setBusy("send"); if (busy) return;
setBusy("start");
setError(null); setError(null);
try { try {
const expanded = await maybeExpandSlashCommand(followUp, slashCommands); const expandedPrompt = await maybeExpandSlashCommand(
await api.run.send(handle.id, expanded); args.initialPrompt || "",
// The user envelope is appended optimistically when the WS ack arrives slashCommands
// (so deduping is consistent with stream order). Clear the input now. );
setFollowUp(""); const effectiveCwd = args.cwd || undefined;
if (!effectiveCwd) {
throw new Error(t("errors.cwdRequired"));
}
let targetLaneId = args.laneId;
if (!targetLaneId) {
// If no lane provided, try to find or create one
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd);
if (ownedLane) {
targetLaneId = ownedLane.id;
setSelectedLaneId(ownedLane.id);
} else {
try {
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
targetLaneId = ensureResult.lane.id;
setSelectedLaneId(ensureResult.lane.id);
setLanes((prev) => {
const exists = prev.some((l) => l.id === ensureResult.lane.id);
return exists ? prev : [...prev, ensureResult.lane];
});
} catch (err) {
throw new Error(
t("errors.laneCreateFailed", {
message: err instanceof Error ? err.message : "unknown",
})
);
}
}
}
if (!targetLaneId) {
throw new Error(t("errors.noLaneSelected"));
}
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: expandedPrompt,
model: args.model || undefined,
permissionMode: args.permissionMode,
resumeSessionId: args.resumeSessionId,
effort: args.effort || undefined,
});
} catch (laneErr: unknown) {
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
await refreshLanes();
if (updatedLane?.run_id) {
await attachToRun(updatedLane.run_id);
return;
}
}
throw laneErr;
}
if (!laneStartResult.lane?.run_id) {
throw new Error(t("errors.noRunIdReturned"));
}
try {
const handle = await api.run.get(laneStartResult.lane.run_id);
setHandle(handle);
refreshList();
} catch (attachErr: unknown) {
try {
await attachToRun(laneStartResult.lane.run_id);
refreshList();
} catch (fallbackErr: unknown) {
await refreshLanes();
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
throw new Error(t("errors.runStartedButNotAttached", { message: attachMsg }));
}
}
} catch (err: unknown) { } catch (err: unknown) {
const m = err instanceof Error ? err.message : "unknown"; const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.sendFailed", { message: m })); setError(t("errors.startFailed", { message: m }));
} finally { } finally {
setBusy(null); setBusy(null);
} }
}, [handle, followUp, busy, t, slashCommands]); },
[busy, slashCommands, t, lanes, selectedLaneId, refreshLanes, attachToRun, refreshList]
);
const stop = useCallback(async () => { const stop = useCallback(async () => {
if (!handle || busy) return; if (!handle || busy) return;
setBusy("stop"); setBusy("kill");
setError(null); setError(null);
try { try {
await api.run.kill(handle.id); await api.run.kill(handle.id);
@@ -769,8 +718,6 @@ export function Workspace() {
const newRun = useCallback(() => { const newRun = useCallback(() => {
setHandle(null); setHandle(null);
setEnvelopes([]);
setFollowUp("");
setPrompt(""); setPrompt("");
setResumeSession(null); setResumeSession(null);
setError(null); setError(null);
@@ -929,13 +876,7 @@ export function Workspace() {
{!handle ? ( {!handle ? (
// Config card uses normal page flow - page scrolls if needed. // Config card uses normal page flow - page scrolls if needed.
<RunSetup <RunSetup
mode={mode} laneId={currentLane?.id || 0}
onModeChange={(m) => {
setMode(m);
// Headless can't resume - clearing keeps the UI honest if the
// user had a session pinned and then switched mode.
if (m === "headless") setResumeSession(null);
}}
prompt={prompt} prompt={prompt}
onPromptChange={setPrompt} onPromptChange={setPrompt}
cwd={cwd} cwd={cwd}
@@ -949,7 +890,7 @@ export function Workspace() {
onEffortChange={setEffort} onEffortChange={setEffort}
binaryFound={binaryStatus?.found ?? true} binaryFound={binaryStatus?.found ?? true}
busy={busy === "start"} busy={busy === "start"}
onStart={start} onStart={onStartFromSetup}
activeRuns={activeRuns} activeRuns={activeRuns}
laneCwd={currentLane?.cwd} laneCwd={currentLane?.cwd}
resumeSession={resumeSession} resumeSession={resumeSession}
@@ -959,23 +900,19 @@ export function Workspace() {
onResumeFromHistory={onResumeFromHistory} onResumeFromHistory={onResumeFromHistory}
/> />
) : ( ) : (
// Run session is wrapped in a flex container so its inner chat panel // Run session is wrapped in a flex container so the terminal panel
// can take all remaining viewport height; long chats scroll inside. // can take all remaining viewport height.
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">
<RunConsole <TerminalView
handle={handle} runId={handle.id}
envelopes={displayEnvelopes} wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
mode={handle.mode}
isLive={isLive}
hasFinished={hasFinished}
followUp={followUp}
onFollowUpChange={setFollowUp}
busy={busy}
onSend={send}
onStop={stop}
onNewRun={newRun}
slashCommands={slashCommands}
/> />
<button
onClick={newRun}
className="mt-3 px-3 py-1.5 text-sm rounded border border-border hover:border-border-light text-fg-secondary hover:text-fg-primary transition-colors"
>
{t("actions.newRun")}
</button>
</div> </div>
)} )}
</div> </div>
@@ -237,6 +237,12 @@ vi.mock("../../lib/eventBus", () => ({
}, },
})); }));
vi.mock("../../components/run/TerminalView", () => ({
TerminalView: ({ runId }: { runId: string }) => (
<div data-testid="terminal-view" data-run-id={runId} />
),
}));
import { Workspace } from "../Workspace"; import { Workspace } from "../Workspace";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
@@ -4722,671 +4722,6 @@ exports[`screen snapshots > Claude Config 1`] = `
</div> </div>
`; `;
exports[`screen snapshots > Dashboard 1`] = `
<div>
<div
class="flex flex-col gap-8 animate-fade-in min-h-[calc(100vh-4rem)]"
>
<div
class="flex flex-wrap items-center justify-between gap-3"
>
<div
class="flex items-center gap-3"
>
<div
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center"
>
<svg
class="lucide lucide-layout-dashboard w-4.5 h-4.5 text-accent"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="9"
rx="1"
width="7"
x="3"
y="3"
/>
<rect
height="5"
rx="1"
width="7"
x="14"
y="3"
/>
<rect
height="9"
rx="1"
width="7"
x="14"
y="12"
/>
<rect
height="5"
rx="1"
width="7"
x="3"
y="16"
/>
</svg>
</div>
<div>
<div
class="flex items-center gap-2"
>
<h1
class="text-lg font-semibold text-fg-primary"
>
Dashboard
</h1>
<span
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
>
<span
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
/>
Live
</span>
</div>
<p
class="text-xs text-fg-muted"
>
Real-time overview of Claude Code agent activity
</p>
</div>
</div>
<div
class="flex items-center gap-3"
>
<div
class="flex bg-surface-2 rounded-lg p-0.5 border border-border"
>
<button
class="px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 bg-accent/15 text-accent shadow-sm"
>
<svg
class="lucide lucide-activity w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"
/>
</svg>
Monitor
</button>
<button
class="px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 text-fg-muted hover:text-fg-secondary"
>
<svg
class="lucide lucide-server w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="8"
rx="2"
ry="2"
width="20"
x="2"
y="2"
/>
<rect
height="8"
rx="2"
ry="2"
width="20"
x="2"
y="14"
/>
<line
x1="6"
x2="6.01"
y1="6"
y2="6"
/>
<line
x1="6"
x2="6.01"
y1="18"
y2="18"
/>
</svg>
Health
</button>
</div>
<button
class="btn-ghost flex-shrink-0"
>
<svg
class="lucide lucide-refresh-cw w-4 h-4"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
/>
<path
d="M21 3v5h-5"
/>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
/>
<path
d="M8 16H3v5"
/>
</svg>
Refresh
</button>
</div>
</div>
<div
class="flex-1 flex flex-col gap-8 min-h-0"
>
<div
class="grid grid-cols-2 md:grid-cols-3 gap-4"
>
<div
class="card p-5"
>
<div
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
>
Total Sessions
</span>
<svg
class="lucide lucide-folder-open w-5 h-5 flex-shrink-0 text-accent"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
/>
</svg>
</div>
<div
class="flex items-end gap-2 min-w-0"
>
<span
class="relative inline-block cursor-default"
>
<span
class="text-2xl font-semibold text-fg-primary truncate"
>
0
</span>
</span>
<span
class="text-xs text-fg-muted mb-1 flex-shrink-0"
>
0 active
</span>
</div>
</div>
<div
class="card p-5"
>
<div
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
>
Active Agents
</span>
<svg
class="lucide lucide-bot w-5 h-5 flex-shrink-0 text-status-success"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 8V4H8"
/>
<rect
height="12"
rx="2"
width="16"
x="4"
y="8"
/>
<path
d="M2 14h2"
/>
<path
d="M20 14h2"
/>
<path
d="M15 13v2"
/>
<path
d="M9 13v2"
/>
</svg>
</div>
<div
class="flex items-end gap-2 min-w-0"
>
<span
class="text-2xl font-semibold text-fg-primary truncate"
>
0
</span>
</div>
</div>
<div
class="card p-5"
>
<div
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
>
Active Subagents
</span>
<svg
class="lucide lucide-git-branch w-5 h-5 flex-shrink-0 text-violet-400"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="6"
x2="6"
y1="3"
y2="15"
/>
<circle
cx="18"
cy="6"
r="3"
/>
<circle
cx="6"
cy="18"
r="3"
/>
<path
d="M18 9a9 9 0 0 1-9 9"
/>
</svg>
</div>
<div
class="flex items-end gap-2 min-w-0"
>
<span
class="text-2xl font-semibold text-fg-primary truncate"
>
0
</span>
<span
class="text-xs text-fg-muted mb-1 flex-shrink-0"
>
0 in active sessions
</span>
</div>
</div>
<div
class="card p-5"
>
<div
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
>
Events Today
</span>
<svg
class="lucide lucide-zap w-5 h-5 flex-shrink-0 text-yellow-400"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"
/>
</svg>
</div>
<div
class="flex items-end gap-2 min-w-0"
>
<span
class="relative inline-block cursor-default"
>
<span
class="text-2xl font-semibold text-fg-primary truncate"
>
0
</span>
</span>
</div>
</div>
<div
class="card p-5"
>
<div
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
>
Total Events
</span>
<svg
class="lucide lucide-activity w-5 h-5 flex-shrink-0 text-violet-400"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"
/>
</svg>
</div>
<div
class="flex items-end gap-2 min-w-0"
>
<span
class="relative inline-block cursor-default"
>
<span
class="text-2xl font-semibold text-fg-primary truncate"
>
0
</span>
</span>
</div>
</div>
<div
class="card p-5"
>
<div
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
>
Total Cost
</span>
<svg
class="lucide lucide-dollar-sign w-5 h-5 flex-shrink-0 text-status-success"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="12"
x2="12"
y1="2"
y2="22"
/>
<path
d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
/>
</svg>
</div>
<div
class="flex items-end gap-2 min-w-0"
>
<span
class="relative inline-block cursor-default"
>
<span
class="text-2xl font-semibold text-fg-primary truncate"
>
$0.00
</span>
</span>
</div>
</div>
</div>
<div
class="grid grid-cols-1 lg:grid-cols-[1fr_auto_1fr] gap-0 min-w-0 flex-1 min-h-0"
>
<div
class="min-w-0 overflow-y-auto pr-6"
>
<div
class="flex items-center justify-between mb-4"
>
<h3
class="text-sm font-medium text-fg-secondary"
>
Active Agents
</h3>
<button
class="btn-ghost text-xs"
>
View Board
<svg
class="lucide lucide-arrow-right w-3 h-3"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12h14"
/>
<path
d="m12 5 7 7-7 7"
/>
</svg>
</button>
</div>
<div
class="flex flex-col items-center justify-center py-20 text-center"
>
<div
class="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5"
>
<svg
class="lucide lucide-bot w-6 h-6 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 8V4H8"
/>
<rect
height="12"
rx="2"
width="16"
x="4"
y="8"
/>
<path
d="M2 14h2"
/>
<path
d="M20 14h2"
/>
<path
d="M15 13v2"
/>
<path
d="M9 13v2"
/>
</svg>
</div>
<h3
class="text-base font-medium text-fg-secondary mb-2"
>
No active agents
</h3>
<p
class="text-sm text-fg-muted max-w-md mb-6"
>
Agents will appear here when a Claude Code session is running.
</p>
</div>
</div>
<div
class="hidden lg:block w-px bg-border self-stretch"
/>
<div
class="min-w-0 overflow-y-auto pl-6"
>
<div
class="flex items-center justify-between mb-4"
>
<h3
class="text-sm font-medium text-fg-secondary"
>
Recent Activity
</h3>
<button
class="btn-ghost text-xs"
>
View All
<svg
class="lucide lucide-arrow-right w-3 h-3"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12h14"
/>
<path
d="m12 5 7 7-7 7"
/>
</svg>
</button>
</div>
<div
class="flex flex-col items-center justify-center py-20 text-center"
>
<div
class="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5"
>
<svg
class="lucide lucide-activity w-6 h-6 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"
/>
</svg>
</div>
<h3
class="text-base font-medium text-fg-secondary mb-2"
>
No activity yet
</h3>
<p
class="text-sm text-fg-muted max-w-md mb-6"
>
Events from Claude Code sessions will stream here in real-time.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`screen snapshots > Kanban board 1`] = ` exports[`screen snapshots > Kanban board 1`] = `
<div> <div>
<div <div
@@ -5875,26 +5210,6 @@ exports[`screen snapshots > Run 1`] = `
<div <div
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]" class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]"
> >
<div
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
>
<button
aria-pressed="true"
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent"
title="Multi-turn - keep typing follow-ups while the agent works."
type="button"
>
Conversation
</button>
<button
aria-pressed="false"
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary"
title="Single prompt, single response. Stdin closes after spawn. — Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits."
type="button"
>
One-shot
</button>
</div>
<div <div
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5" class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
> >
@@ -5924,16 +5239,11 @@ exports[`screen snapshots > Run 1`] = `
> >
Prompt Prompt
</label> </label>
<div
class="relative"
>
<textarea <textarea
class="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" class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
placeholder="Ask Claude anything…" placeholder="Ask Claude anything…"
rows="5" rows="5"
spellcheck="false"
/> />
</div>
<div <div
class="mt-1 text-[10px] text-fg-muted" class="mt-1 text-[10px] text-fg-muted"
> >
-141
View File
@@ -1,141 +0,0 @@
/**
* @file stream-json-parser.test.js
* @description Unit tests for the newline-delimited JSON line buffer used to
* parse `claude --output-format stream-json` output. Verifies chunked input,
* partial lines spanning chunks, malformed lines, empty input, multiple
* objects per chunk, and flush semantics.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { createLineParser } = require("../lib/stream-json-parser");
function collect() {
const objects = [];
const errors = [];
const parser = createLineParser(
(obj) => objects.push(obj),
(err, raw) => errors.push({ message: err.message, raw })
);
return { parser, objects, errors };
}
describe("stream-json-parser", () => {
it("parses a single complete line", () => {
const { parser, objects, errors } = collect();
parser.push('{"type":"system","subtype":"init"}\n');
assert.equal(errors.length, 0);
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "system");
});
it("parses multiple lines in one chunk", () => {
const { parser, objects } = collect();
parser.push('{"type":"a"}\n{"type":"b"}\n{"type":"c"}\n');
assert.deepEqual(
objects.map((o) => o.type),
["a", "b", "c"]
);
});
it("reassembles a JSON object split across two chunks", () => {
const { parser, objects } = collect();
parser.push('{"type":"split","val":');
parser.push('"hello"}\n');
assert.equal(objects.length, 1);
assert.equal(objects[0].val, "hello");
});
it("reassembles a JSON object split across many small chunks", () => {
const { parser, objects } = collect();
const full = '{"type":"chunky","payload":{"deep":{"nested":[1,2,3]}}}\n';
for (const ch of full) parser.push(ch);
assert.equal(objects.length, 1);
assert.deepEqual(objects[0].payload.deep.nested, [1, 2, 3]);
});
it("ignores blank lines between objects", () => {
const { parser, objects, errors } = collect();
parser.push('{"type":"a"}\n\n\n{"type":"b"}\n');
assert.equal(objects.length, 2);
assert.equal(errors.length, 0);
});
it("reports malformed JSON via onError without throwing", () => {
const { parser, objects, errors } = collect();
parser.push("not valid json\n");
parser.push('{"type":"ok"}\n');
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "ok");
assert.equal(errors.length, 1);
assert.match(errors[0].raw, /not valid json/);
});
it("does not emit a partial line until newline arrives", () => {
const { parser, objects } = collect();
parser.push('{"type":"unfinished"');
assert.equal(objects.length, 0);
parser.push("}\n");
assert.equal(objects.length, 1);
});
it("flush() emits trailing line without newline", () => {
const { parser, objects } = collect();
parser.push('{"type":"trailing"}');
assert.equal(objects.length, 0);
parser.flush();
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "trailing");
});
it("flush() on empty buffer is a no-op", () => {
const { parser, objects, errors } = collect();
parser.flush();
assert.equal(objects.length, 0);
assert.equal(errors.length, 0);
});
it("flush() reports malformed trailing line via onError", () => {
const { parser, objects, errors } = collect();
parser.push("garbage{not-json");
parser.flush();
assert.equal(objects.length, 0);
assert.equal(errors.length, 1);
});
it("works without onError callback when input is malformed", () => {
let count = 0;
const parser = createLineParser((_o) => count++);
// No throw expected.
parser.push("garbage\n");
parser.push('{"type":"ok"}\n');
assert.equal(count, 1);
});
it("handles CRLF line endings cleanly (\\r is trimmed before parse)", () => {
const { parser, objects, errors } = collect();
parser.push('{"type":"crlf"}\r\n');
// Note: parser only splits on \n; the \r at end of line stays in the
// line. JSON.parse tolerates trailing whitespace including \r.
assert.equal(errors.length, 0);
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "crlf");
});
it("handles a stream-json envelope with stream_event sub-event shape", () => {
const { parser, objects } = collect();
const env = JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Hello" },
},
session_id: "sess",
});
parser.push(env + "\n");
assert.equal(objects.length, 1);
assert.equal(objects[0].event.delta.text, "Hello");
});
});
-567
View File
@@ -1,567 +0,0 @@
/**
* @file run-spawner.js
* @description Spawns and supervises Claude Code subprocesses for the
* dashboard's Run page. Two modes:
* - "headless" — single-shot. Stdin is closed after spawn; the prompt
* lives in argv via `-p`. Process exits when the model
* finishes the turn.
* - "conversation" — multi-turn. Stdin stays open; follow-up turns are
* delivered via JSON envelopes through stdin and the
* caller can pipe more messages until they kill or the
* child exits naturally.
*
* Conversation mode also supports resuming an existing session via
* `--resume <session-id>`, so the user can continue any prior Claude Code
* conversation from inside the dashboard.
*
* Output is always `--output-format stream-json --verbose` so the parser can
* deliver structured envelopes (system/init, assistant text+tool_use, user
* tool_result, result/success, etc). Each envelope is broadcast over the
* dashboard's existing WebSocket as a `run_stream` message; status changes
* (spawning → running → completed/error/killed) broadcast as `run_status`.
*
* Concurrency is capped (RUN_MAX_CONCURRENT, default 10) — over the cap we
* throw ECONCURRENCY with the running set so the route can return 429.
*
* When a child truly finishes (real exit, or a spawn that never started) the
* handler registered via setRunExitHandler is called once. That inversion is
* how a lane gets released without this module requiring the lane router back.
*
* Each handle keeps a bounded in-memory envelope log (cap 500) so a client
* that attaches late can replay what it missed. Completed handles are reaped
* after 5 min — but the underlying transcripts persist via the normal hook
* ingestion pipeline (every spawned `claude` fires hooks like any other
* session, so the run shows up in /sessions automatically).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
// cross-spawn (not node:child_process): on Windows the npm-installed `claude`
// is a `.cmd` shim that plain spawn can't launch, and the naive fix (`shell:
// true`) would run argv — including the user-controlled prompt/model — through
// cmd.exe, opening a command-injection hole. cross-spawn resolves the shim and
// escapes arguments safely without a shell. On macOS/Linux it is a plain spawn.
const spawn = require("cross-spawn");
const { randomUUID } = require("node:crypto");
const { broadcast } = require("../websocket");
const { createLineParser } = require("./stream-json-parser");
// Persistence is best-effort and optional — load lazily so unit tests that
// don't bring up the full db can still exercise the spawner.
let dashboardRuns = null;
try {
dashboardRuns = require("./dashboard-runs");
} catch {
/* db-less environment, skip persistence */
}
function recordRun(handle) {
if (dashboardRuns) dashboardRuns.recordRun(handle);
}
function patchRun(args) {
if (dashboardRuns) dashboardRuns.patchRun(args);
}
// Whoever owns lanes registers here at boot (routes/lanes.js) so a finished run
// can release its lane. The dependency is inverted deliberately: the lane router
// already requires THIS module, and releasing needs the router's lanePayload /
// lastEventAge to broadcast — requiring it back would be a cycle.
let runExitHandler = null;
function setRunExitHandler(fn) {
runExitHandler = typeof fn === "function" ? fn : null;
}
/** Announce a truly-exited run. Never lets a listener break run bookkeeping. */
function notifyRunExit(handle) {
if (!runExitHandler) return;
try {
runExitHandler({ runId: handle.id, laneId: handle.laneId || null });
} catch {
/* a broken listener is not the run's problem */
}
}
// Effectively uncapped — claude's terminal TUI doesn't gate concurrent
// sessions, so we don't either. The number is high enough that a buggy
// client still can't fork-bomb the host before someone notices, but low
// enough that no human will ever hit it organically. Users who want a
// real cap can set RUN_MAX_CONCURRENT.
const MAX_CONCURRENT_DEFAULT = 10000;
const REAP_AFTER_MS = 5 * 60 * 1000; // keep handle for 5 min after exit
const STDOUT_TAIL_BYTES = 4 * 1024;
const STDERR_TAIL_BYTES = 4 * 1024;
// Cap stored envelopes per handle so a long-running conversation doesn't
// balloon memory. Late-attaching clients get this much history; the full
// transcript is always available via the existing /sessions/<id> view.
const MAX_ENVELOPES_PER_HANDLE = 500;
const handles = new Map();
const reapers = new Map();
function getMaxConcurrent() {
const raw = process.env.RUN_MAX_CONCURRENT;
if (!raw) return MAX_CONCURRENT_DEFAULT;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : MAX_CONCURRENT_DEFAULT;
}
function liveCount() {
let n = 0;
for (const h of handles.values()) {
if (h.status === "spawning" || h.status === "running") n++;
}
return n;
}
function tail(s, n) {
if (typeof s !== "string") return "";
if (s.length <= n) return s;
return s.slice(s.length - n);
}
/**
* Build argv for the `claude` invocation. The two modes have different argv
* shapes because of how Claude Code resolves the first user message:
*
* - HEADLESS: `-p "<prompt>"` carries the prompt; stdin is closed; Claude
* processes one turn and exits.
* - CONVERSATION: `--input-format stream-json` puts Claude in multi-turn
* mode where ALL user turns (including the first) come via stdin. When
* stream-json input is enabled, `-p` is silently ignored — so we OMIT
* it and send the initial prompt over stdin in `spawnRun` immediately
* after the spawn handshake.
*/
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
function buildArgv({ prompt, mode, model, permissionMode, resumeSessionId, effort }) {
const argv = [];
argv.push("--output-format", "stream-json");
argv.push("--verbose");
// Real character-by-character streaming. Without this flag Claude only
// emits the *final* assistant envelope, which makes the UI feel like the
// response arrives all at once. With it, we also receive `stream_event`
// envelopes (Anthropic Messages API streaming events) so the UI can
// render text + thinking deltas as they arrive.
argv.push("--include-partial-messages");
argv.push("--permission-mode", permissionMode || "acceptEdits");
if (mode === "headless") {
argv.push("-p", prompt);
} else {
argv.push("--input-format", "stream-json");
}
if (model) {
argv.push("--model", model);
}
if (effort && EFFORT_LEVELS.has(effort)) {
// Drives thinking depth: higher = more reasoning tokens before the
// assistant turn. Empty / unset means "inherit from the model's default".
argv.push("--effort", effort);
}
if (resumeSessionId) {
argv.push("--resume", resumeSessionId);
}
return argv;
}
/**
* Frame a stream-json user envelope. Used both for the initial conversation-
* mode prompt and for follow-up turns via sendInput.
*/
function userEnvelope(text, id) {
const e = {
type: "user",
message: { role: "user", content: text },
};
if (id) e.id = id;
return JSON.stringify(e) + "\n";
}
/**
* Strip dashboard-internal env vars from the child so the spawned `claude`
* doesn't accidentally pick up our hook-handler context (and to keep the
* child's auth entirely from the user's existing OAuth in $HOME).
*/
function cleanSpawnEnv() {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST;
return env;
}
function attachStreamHandlers(handle) {
const parser = createLineParser(
(envelope) => {
// First parsed envelope means the child is producing output → "running".
if (handle.status === "spawning") {
handle.status = "running";
broadcast("run_status", { id: handle.id, status: "running", at: Date.now() });
patchRun({ id: handle.id, status: "running" });
}
// Capture session_id off the system/init envelope — once we have it the
// dashboard can deep-link to /sessions/<id> on completion.
if (
envelope &&
envelope.type === "system" &&
envelope.subtype === "init" &&
typeof envelope.session_id === "string"
) {
const wasNull = !handle.sessionId;
handle.sessionId = envelope.session_id;
if (wasNull) patchRun({ id: handle.id, sessionId: envelope.session_id });
}
handle.envelopeCount += 1;
handle.envelopes.push(envelope);
// Keep only the most recent N — older entries are still in the disk
// transcript at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl,
// visible via the regular /sessions/<id> dashboard view.
if (handle.envelopes.length > MAX_ENVELOPES_PER_HANDLE) {
handle.envelopes.splice(0, handle.envelopes.length - MAX_ENVELOPES_PER_HANDLE);
}
broadcast("run_stream", { id: handle.id, envelope });
},
(err, raw) => {
handle.stderrBuffer += `[parse-error] ${err.message}: ${raw}\n`;
}
);
handle.child.stdout.on("data", (chunk) => {
const s = chunk.toString("utf8");
handle.stdoutBuffer = tail(handle.stdoutBuffer + s, STDOUT_TAIL_BYTES);
parser.push(s);
});
handle.child.stderr.on("data", (chunk) => {
handle.stderrBuffer = tail(handle.stderrBuffer + chunk.toString("utf8"), STDERR_TAIL_BYTES);
});
handle.child.on("error", (err) => {
// A spawn error has no corresponding `exit` event: the OS never started
// the child, so it can no longer touch the lane directory.
handle.actualExitedAt = Date.now();
handle.status = "error";
handle.error = err.message;
handle.endedAt = Date.now();
broadcast("run_status", {
id: handle.id,
status: "error",
error: err.message,
at: handle.endedAt,
});
patchRun({ id: handle.id, status: "error", endedAt: handle.endedAt });
scheduleReap(handle.id);
// A spawn that never started is just as finished as one that ran: without
// this the lane stays `running` forever with a dead run_id.
notifyRunExit(handle);
});
handle.child.on("exit", (code, signal) => {
parser.flush();
// `killRun` deliberately sets status to `killed` immediately after it
// requests SIGTERM. Keep this separate, exit-only signal so callers that
// must not touch a run's cwd until the OS reaps it can wait truthfully.
handle.actualExitedAt = Date.now();
if (handle.status === "killed") {
// already broadcast — patchRun already happened in stop()
} else {
handle.status = code === 0 ? "completed" : "error";
handle.exitCode = code;
handle.signal = signal;
handle.endedAt = Date.now();
broadcast("run_status", {
id: handle.id,
status: handle.status,
exitCode: code,
sessionId: handle.sessionId || null,
at: handle.endedAt,
});
patchRun({
id: handle.id,
status: handle.status,
exitCode: code,
sessionId: handle.sessionId || null,
endedAt: handle.endedAt,
});
}
scheduleReap(handle.id);
// Fires for a killed run too — killRun only flags `killed` before the OS
// reaps the child; a killed run is a finished run.
notifyRunExit(handle);
});
}
function scheduleReap(id) {
const existing = reapers.get(id);
if (existing) clearTimeout(existing);
const t = setTimeout(() => {
handles.delete(id);
reapers.delete(id);
}, REAP_AFTER_MS);
// Don't keep the process alive just for the reap timer.
if (typeof t.unref === "function") t.unref();
reapers.set(id, t);
}
/**
* @param {object} args
* @param {string} args.prompt
* @param {"headless"|"conversation"} args.mode
* @param {string} [args.cwd]
* @param {string} [args.model]
* @param {string} [args.permissionMode]
* @param {number} [args.laneId] Lane this run was started through; persisted so
* the Workspace page can list one lane's runs. Omitted by POST /api/run.
* @returns handle
*/
function spawnRun(args) {
const { prompt, mode, cwd, model, permissionMode, resumeSessionId, effort, laneId } = args || {};
if (typeof prompt !== "string") {
throw makeErr("EBADPROMPT", "prompt is required");
}
// Empty prompt is allowed only when resuming a conversation — claude
// idles on the resumed transcript until the user types a follow-up.
if (!prompt.trim() && !(mode === "conversation" && resumeSessionId)) {
throw makeErr("EBADPROMPT", "prompt is required");
}
if (mode !== "headless" && mode !== "conversation") {
throw makeErr("EBADMODE", `mode must be "headless" or "conversation"`);
}
if (effort != null && effort !== "" && !EFFORT_LEVELS.has(effort)) {
throw makeErr("EBADEFFORT", `effort must be one of: ${Array.from(EFFORT_LEVELS).join(", ")}`);
}
if (resumeSessionId != null) {
if (typeof resumeSessionId !== "string" || !/^[A-Za-z0-9-]{8,}$/.test(resumeSessionId)) {
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
}
// Resume only makes sense in conversation mode (you want to keep talking).
// Headless `claude --resume` does run, but the UX of "send one prompt and
// exit" on a resumed session is confusing — disallow.
if (mode !== "conversation") {
throw makeErr("EBADMODE", "resumeSessionId requires conversation mode");
}
}
const max = getMaxConcurrent();
if (liveCount() >= max) {
const err = makeErr("ECONCURRENCY", `concurrency limit ${max} reached`);
err.running = Array.from(handles.values())
.filter((h) => h.status === "running" || h.status === "spawning")
.map((h) => ({ id: h.id, pid: h.pid, startedAt: h.startedAt, mode: h.mode }));
throw err;
}
const id = randomUUID();
const argv = buildArgv({ prompt, mode, model, permissionMode, resumeSessionId, effort });
// cross-spawn handles the Windows `.cmd` shim safely (see the require above);
// deliberately no `shell` option, so argv is never parsed by cmd.exe.
const child = spawn("claude", argv, {
env: cleanSpawnEnv(),
cwd: cwd || process.cwd(),
stdio: ["pipe", "pipe", "pipe"],
});
const handle = {
id,
pid: child.pid || null,
mode,
cwd: cwd || process.cwd(),
model: model || null,
permissionMode: permissionMode || "acceptEdits",
effort: effort || null,
prompt,
argv,
resumeSessionId: resumeSessionId || null,
laneId: typeof laneId === "number" ? laneId : null,
status: "spawning",
startedAt: Date.now(),
endedAt: null,
exitCode: null,
signal: null,
error: null,
actualExitedAt: null,
sessionId: resumeSessionId || null, // optimistic; will be confirmed by system/init envelope
envelopeCount: 0,
envelopes: [],
stdoutBuffer: "",
stderrBuffer: "",
child,
};
handles.set(id, handle);
recordRun(handle);
attachStreamHandlers(handle);
if (mode === "headless") {
// Headless: prompt is in argv; close stdin so Claude knows nothing more
// is coming and exits after the one turn.
try {
child.stdin.end();
} catch {
/* ignore */
}
} else if (prompt && prompt.trim()) {
// Conversation: deliver the initial prompt over stdin so Claude in
// stream-json input mode actually starts processing it. Stdin stays
// open for follow-up turns.
try {
child.stdin.write(userEnvelope(prompt));
} catch (err) {
handle.stderrBuffer += `[stdin-write-error] ${err.message}\n`;
}
}
// Conversation with empty prompt (resume scenarios) — leave stdin open;
// claude will idle on the resumed conversation until the user types a
// follow-up via POST /:id/message.
broadcast("run_status", { id, status: "spawning", at: handle.startedAt });
return handle;
}
/**
* Send a follow-up user turn into a running conversation. Throws if the
* handle is not running, not in conversation mode, or stdin is closed.
*/
function sendInput(id, text) {
const handle = handles.get(id);
if (!handle) throw makeErr("ENOTFOUND", "run not found");
if (handle.mode !== "conversation") {
throw makeErr("EWRONGMODE", "only conversation mode accepts follow-up input");
}
if (handle.status !== "running" && handle.status !== "spawning") {
throw makeErr("ENOTRUNNING", `run is ${handle.status}`);
}
if (typeof text !== "string" || !text) {
throw makeErr("EBADINPUT", "text is required");
}
if (!handle.child || !handle.child.stdin || !handle.child.stdin.writable) {
throw makeErr("ESTDINCLOSED", "stdin is not writable");
}
const messageId = randomUUID();
handle.child.stdin.write(userEnvelope(text, messageId));
broadcast("run_input_ack", { id, messageId, at: Date.now() });
return { messageId };
}
function killRun(id) {
const handle = handles.get(id);
if (!handle) return false;
if (handle.status === "completed" || handle.status === "error" || handle.status === "killed") {
return true;
}
if (handle.child && !handle.child.killed) {
try {
handle.child.kill("SIGTERM");
} catch {
/* ignore */
}
setTimeout(() => {
const h = handles.get(id);
if (h && h.child && !h.actualExitedAt) {
try {
h.child.kill("SIGKILL");
} catch {
/* ignore */
}
}
}, 5000).unref?.();
}
handle.status = "killed";
handle.endedAt = Date.now();
broadcast("run_status", { id, status: "killed", at: handle.endedAt });
patchRun({ id, status: "killed", endedAt: handle.endedAt });
scheduleReap(id);
return true;
}
function publicHandle(handle, opts = {}) {
if (!handle) return null;
const out = {
id: handle.id,
pid: handle.pid,
mode: handle.mode,
cwd: handle.cwd,
model: handle.model,
permissionMode: handle.permissionMode,
effort: handle.effort || null,
prompt: handle.prompt,
argv: handle.argv,
resumeSessionId: handle.resumeSessionId || null,
status: handle.status,
startedAt: handle.startedAt,
endedAt: handle.endedAt,
exitCode: handle.exitCode,
signal: handle.signal,
error: handle.error,
actualExitedAt: handle.actualExitedAt,
sessionId: handle.sessionId,
envelopeCount: handle.envelopeCount,
stdoutTail: handle.stdoutBuffer,
stderrTail: handle.stderrBuffer,
};
if (opts.includeEnvelopes) {
out.envelopes = handle.envelopes.slice();
}
return out;
}
function getRun(id, opts = {}) {
return publicHandle(handles.get(id), opts);
}
function listRuns() {
return Array.from(handles.values())
.sort((a, b) => b.startedAt - a.startedAt)
.map(publicHandle);
}
function makeErr(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
// Test seam: inject a fake child (e.g. PassThrough streams) without invoking
// the real `claude` binary. Returns the handle.
function __injectChildForTest({ child, mode = "conversation", prompt = "test" }) {
const id = randomUUID();
const handle = {
id,
pid: 0,
mode,
cwd: process.cwd(),
model: null,
permissionMode: "acceptEdits",
effort: null,
prompt,
argv: ["-p", prompt],
resumeSessionId: null,
status: "spawning",
startedAt: Date.now(),
endedAt: null,
exitCode: null,
signal: null,
error: null,
actualExitedAt: null,
sessionId: null,
envelopeCount: 0,
envelopes: [],
stdoutBuffer: "",
stderrBuffer: "",
child,
};
handles.set(id, handle);
attachStreamHandlers(handle);
return handle;
}
function __reset() {
for (const t of reapers.values()) clearTimeout(t);
reapers.clear();
handles.clear();
}
module.exports = {
spawnRun,
setRunExitHandler,
sendInput,
killRun,
getRun,
listRuns,
liveCount,
getMaxConcurrent,
__injectChildForTest,
__reset,
};
-40
View File
@@ -1,40 +0,0 @@
/**
* @file stream-json-parser.js
* @description Newline-delimited JSON line buffer for parsing `claude
* --output-format stream-json` output. Reassembles arbitrarily chunked stdout
* into discrete JSON envelopes (one per line). Robust to partial writes;
* malformed lines are reported via onError but never throw.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
function createLineParser(onObject, onError) {
let buf = "";
return {
push(chunk) {
buf += chunk;
let nlIdx;
while ((nlIdx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nlIdx).trim();
buf = buf.slice(nlIdx + 1);
if (!line) continue;
try {
onObject(JSON.parse(line));
} catch (err) {
if (typeof onError === "function") onError(err, line);
}
}
},
flush() {
const tail = buf.trim();
buf = "";
if (!tail) return;
try {
onObject(JSON.parse(tail));
} catch (err) {
if (typeof onError === "function") onError(err, tail);
}
},
};
}
module.exports = { createLineParser };