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
@@ -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,
sessionId: "sess-live",
mode: "conversation",
cwd: "/tmp/live",
model: "claude-opus-5",
status: "running",
prompt: "the live prompt",
startedAt: 3000,
startedAt: "2000-01-01T00:50:00Z",
endedAt: null,
},
],
@@ -44,7 +42,6 @@ function historyItem(over: Partial<DashboardRunHistoryItem>): DashboardRunHistor
return {
id: PAST_ID,
session_id: "sess-past",
mode: "conversation",
cwd: "/tmp/past",
model: "sonnet",
status: "completed",
@@ -64,7 +61,6 @@ const PAST = historyItem({});
const HEADLESS = historyItem({
id: HEADLESS_ID,
session_id: "sess-headless",
mode: "headless",
cwd: "/tmp/headless",
prompt_preview: "the headless prompt",
started_at: new Date(1000).toISOString(),
@@ -95,10 +91,9 @@ function row(id: string, over: Partial<UnifiedRunRow> = {}): UnifiedRunRow {
return {
id,
sessionId: `sess-${id}`,
mode: "conversation",
cwd: `/tmp/${id}`,
model: "sonnet",
status: "completed",
status: "abandoned",
promptPreview: `prompt of ${id}`,
startedAt: 1000,
endedAt: 2000,
@@ -216,39 +211,31 @@ describe("RunsModal", () => {
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)]);
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
expect(spies.onResume).toHaveBeenCalledWith(PAST);
expect(spies.onView).not.toHaveBeenCalled();
});
it("fires view — not resume — for a finished headless row", () => {
const { spies } = renderModal([row(HEADLESS_ID, { mode: "headless" })]);
expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull();
it("fires view for a finished row", () => {
const { spies } = renderModal([row(HEADLESS_ID)]);
fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel")));
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 = [
row("a", { status: "running", isLive: true, promptPreview: "alpha" }),
row("b", { status: "error", promptPreview: "bravo" }),
row("c", { status: "completed", mode: "headless", promptPreview: "charlie" }),
row("b", { status: "killed", promptPreview: "bravo" }),
row("c", { status: "abandoned", promptPreview: "charlie" }),
];
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.queryByText("alpha")).toBeNull();
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(
screen.getByPlaceholderText(
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>> = {}) {
const spies = {
onModeChange: vi.fn(),
onPromptChange: vi.fn(),
onCwdChange: vi.fn(),
onModelChange: vi.fn(),
@@ -52,7 +51,7 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
const utils = render(
<MemoryRouter>
<RunSetup
mode="conversation"
laneId={1}
prompt="do the thing"
cwd="/Users/tester"
cwdSuggestions={SUGGESTIONS}
@@ -95,15 +94,6 @@ beforeEach(() => {
});
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", () => {
const { spies } = renderSetup({ prompt: "" });
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);
});
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: " " });
expect(runButton().disabled).toBe(true);
unmount();
@@ -179,12 +169,6 @@ describe("RunSetup — missing binary and other blocked states", () => {
const noCwd = renderSetup({ cwd: "" });
expect(runButton().disabled).toBe(true);
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", () => {
@@ -237,7 +221,7 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
rerender(
<MemoryRouter>
<RunSetup
mode="conversation"
laneId={1}
prompt="do the thing"
cwd="/Users/tester"
cwdSuggestions={SUGGESTIONS}
@@ -251,7 +235,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
slashCommands={[]}
runHistory={[]}
laneCwd="/Users/tester/lane-b"
onModeChange={vi.fn()}
onPromptChange={vi.fn()}
onCwdChange={vi.fn()}
onModelChange={vi.fn()}