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:
+119
-182
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user