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
+119 -182
View File
@@ -62,59 +62,13 @@ import type {
WSMessage,
} from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { useRunStream } from "../hooks/useRunStream";
import { BUILTIN_SLASH_COMMANDS, RunConsole } from "../components/run/RunConsole";
import type { SlashCommand } from "../components/run/RunConsole";
import { TerminalView } from "../components/run/TerminalView";
import { RunSetup } from "../components/run/RunSetup";
import { ActiveRunsSwitcher } from "../components/run/RunHistory";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard from "../components/lanes/LaneCard";
import LaneStripCard from "../components/lanes/LaneStripCard";
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 ──────────────────────────────────────────────────────────────
@@ -139,7 +93,6 @@ export function Workspace() {
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
// Run state
const [mode, setMode] = useState<RunMode>("conversation");
const [prompt, setPrompt] = useState("");
const [model, setModel] = useState("");
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
@@ -147,37 +100,7 @@ export function Workspace() {
const [cwd, setCwd] = useState("");
const [resumeSession, setResumeSession] = useState<Session | null>(null);
const [handle, setHandle] = useState<RunHandle | null>(null);
// Live stream state (envelopes + typewriter view) and the WS subscription
// 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 [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
const [error, setError] = useState<string | null>(null);
const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
@@ -185,7 +108,7 @@ export function Workspace() {
null
);
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
const refreshLanes = useCallback(async () => {
@@ -388,7 +311,7 @@ export function Workspace() {
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 {
// No cwd: resume as a non-lane run (backward compatibility).
// These runs stay outside the lane system and are cleaned up
@@ -405,8 +328,6 @@ export function Workspace() {
}
setHandle(fetched);
setEnvelopes(transcriptToEnvelopes(transcript.messages));
setFollowUp("");
setResumeSession(null);
refreshList();
} catch (err) {
@@ -421,16 +342,14 @@ export function Workspace() {
// 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
// navigating away. Seeds the chat view with the past messages and a
// synthetic completed handle so the UI renders as read-only (no Stop
// button, no follow-up input - both are gated on isLive).
// navigating away. Sets a synthetic completed handle so the UI renders
// as read-only (no Stop button, no follow-up input - both are gated on isLive).
const onViewFromHistory = useCallback(
async (item: DashboardRunHistoryItem) => {
if (!item.session_id) return;
if (busy) return;
setError(null);
try {
const transcript = await api.sessions.transcript(item.session_id, { limit: 200 });
const synthetic: RunHandle = {
id: item.id,
pid: null,
@@ -449,14 +368,11 @@ export function Workspace() {
signal: null,
error: null,
sessionId: item.session_id,
envelopeCount: transcript.messages.length,
envelopeCount: 0,
stdoutTail: "",
stderrTail: "",
};
setHandle(synthetic);
setEnvelopes(transcriptToEnvelopes(transcript.messages));
setMode(item.mode);
setFollowUp("");
setResumeSession(null);
} catch (err) {
const msg = err instanceof Error ? err.message : "unknown";
@@ -466,21 +382,11 @@ export function Workspace() {
[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 () => {
if (!prompt.trim() || busy) return;
setBusy("start");
setError(null);
setEnvelopes([]);
try {
// Resume always uses conversation mode (server enforces this too).
const effectiveMode: RunMode = resumeSession ? "conversation" : mode;
const effectiveCwd = resumeSession?.cwd || cwd || undefined;
// Expand /user-or-project slash commands client-side so the model
// receives the rendered template, matching what the CLI does.
@@ -532,7 +438,6 @@ export function Workspace() {
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: expandedPrompt,
mode: effectiveMode,
model: model || undefined,
permissionMode,
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
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);
// Optimistic user-turn injection so the chat shows your prompt right away.
setEnvelopes([{ type: "user", message: { content: prompt } } as UserMessage]);
refreshList();
} catch (attachErr: unknown) {
// 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,
mode,
cwd,
model,
permissionMode,
@@ -608,40 +510,8 @@ export function Workspace() {
setBusy("attach");
setError(null);
try {
const fetched = await api.run.get(id, { envelopes: true });
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 */
}
}
const fetched = await api.run.get(id);
setHandle(fetched);
setEnvelopes(envelopesToUse);
setFollowUp("");
} catch (err: unknown) {
const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.attachFailed", { message: m }));
@@ -735,27 +605,106 @@ export function Workspace() {
void start();
}, [binaryStatus, prompt, cwd, busy, handle, start]);
const send = useCallback(async () => {
if (!handle || !followUp.trim() || busy) return;
setBusy("send");
setError(null);
try {
const expanded = await maybeExpandSlashCommand(followUp, slashCommands);
await api.run.send(handle.id, expanded);
// The user envelope is appended optimistically when the WS ack arrives
// (so deduping is consistent with stream order). Clear the input now.
setFollowUp("");
} catch (err: unknown) {
const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.sendFailed", { message: m }));
} finally {
setBusy(null);
}
}, [handle, followUp, busy, t, slashCommands]);
const onStartFromSetup = useCallback(
async (args: any) => {
if (busy) return;
setBusy("start");
setError(null);
try {
const expandedPrompt = await maybeExpandSlashCommand(
args.initialPrompt || "",
slashCommands
);
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) {
const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.startFailed", { message: m }));
} finally {
setBusy(null);
}
},
[busy, slashCommands, t, lanes, selectedLaneId, refreshLanes, attachToRun, refreshList]
);
const stop = useCallback(async () => {
if (!handle || busy) return;
setBusy("stop");
setBusy("kill");
setError(null);
try {
await api.run.kill(handle.id);
@@ -769,8 +718,6 @@ export function Workspace() {
const newRun = useCallback(() => {
setHandle(null);
setEnvelopes([]);
setFollowUp("");
setPrompt("");
setResumeSession(null);
setError(null);
@@ -929,13 +876,7 @@ export function Workspace() {
{!handle ? (
// Config card uses normal page flow - page scrolls if needed.
<RunSetup
mode={mode}
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);
}}
laneId={currentLane?.id || 0}
prompt={prompt}
onPromptChange={setPrompt}
cwd={cwd}
@@ -949,7 +890,7 @@ export function Workspace() {
onEffortChange={setEffort}
binaryFound={binaryStatus?.found ?? true}
busy={busy === "start"}
onStart={start}
onStart={onStartFromSetup}
activeRuns={activeRuns}
laneCwd={currentLane?.cwd}
resumeSession={resumeSession}
@@ -959,23 +900,19 @@ export function Workspace() {
onResumeFromHistory={onResumeFromHistory}
/>
) : (
// Run session is wrapped in a flex container so its inner chat panel
// can take all remaining viewport height; long chats scroll inside.
// Run session is wrapped in a flex container so the terminal panel
// can take all remaining viewport height.
<div className="flex-1 min-h-0 flex flex-col">
<RunConsole
handle={handle}
envelopes={displayEnvelopes}
mode={handle.mode}
isLive={isLive}
hasFinished={hasFinished}
followUp={followUp}
onFollowUpChange={setFollowUp}
busy={busy}
onSend={send}
onStop={stop}
onNewRun={newRun}
slashCommands={slashCommands}
<TerminalView
runId={handle.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
/>
<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>