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
+87 -75
View File
@@ -5,14 +5,13 @@
* the Run page and the Workspace page can both mount the same panel.
*
* What lives here:
* - `RunSetup` — mode (conversation / headless), fresh-vs-resume source, the
* prompt editor, and the cwd / model / permission-mode / effort fields,
* plus the concurrency hint and the Start button. Its disabled state is
* driven by the `binaryFound` prop, so a missing `claude` binary is a
* surfaced state here rather than a probe of its own.
* above the panel, with its own localStorage-persisted minimized state.
* - `RunSetup` — fresh-vs-resume source, the prompt editor, and the cwd /
* model / permission-mode / effort fields, plus the concurrency hint and
* the Start button. Its disabled state is driven by the `binaryFound` prop,
* so a missing `claude` binary is a surfaced state here rather than a probe
* of its own.
* - 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
* lifecycle — the page owns `api.run.start` and hands the result back through
@@ -45,18 +44,53 @@ import type {
RunListResponse,
EffortLevel,
PermissionMode,
RunMode,
RunStartArgs,
} from "../../lib/api";
import type { Session } from "../../lib/types";
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) ────────────────────────
interface RunSetupProps {
mode: RunMode;
onModeChange: (m: RunMode) => void;
laneId: number;
prompt: string;
onPromptChange: (s: string) => void;
cwd: string;
@@ -70,7 +104,7 @@ interface RunSetupProps {
onEffortChange: (e: EffortLevel) => void;
binaryFound: boolean;
busy: boolean;
onStart: () => void;
onStart: (args: RunStartArgs) => void;
activeRuns: RunListResponse | null;
resumeSession: Session | null;
onResumeSessionChange: (s: Session | null) => void;
@@ -85,74 +119,45 @@ interface RunSetupProps {
export function RunSetup(props: RunSetupProps) {
const { t } = useTranslation("run");
const atCap =
props.activeRuns != null && props.activeRuns.activeCount >= props.activeRuns.maxConcurrent;
const atCap = false; // TODO: re-add when concurrency info is available
const isResume = !!props.resumeSession;
const [resumePicked, setResumePicked] = useState(isResume);
// Keep "resume picked" in sync with the parent. Two cases:
// 1. Parent set a resume session (e.g. user clicked Resume in the runs
// modal) - flip the radio so the picker is shown and the selection
// is visible.
// 2. Parent cleared the session and mode flipped to headless - clear
// the radio so the form is honest.
// Keep "resume picked" in sync with the parent. Parent set a resume session
// (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
// is shown and the selection is visible.
useEffect(() => {
if (isResume && !resumePicked) setResumePicked(true);
else if (!isResume && resumePicked && props.mode === "headless") setResumePicked(false);
}, [isResume, resumePicked, props.mode]);
}, [isResume, resumePicked]);
return (
<div className="rounded-xl border border-border bg-surface-1">
{/* Mode and source on one line. Both are two-way choices made once at
spawn time, so a segmented row carries them; the longer explanations
live in each button's title rather than in a paragraph. */}
{/* Fresh vs resume source picker */}
<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")}`}
active={!resumePicked}
label={t("resume.freshOption")}
title={t("resume.freshHint")}
onClick={() => {
props.onModeChange("headless");
setResumePicked(false);
props.onResumeSessionChange(null);
}}
/>
<Seg
active={resumePicked}
label={t("resume.resumeOption")}
title={t("resume.resumeHint")}
onClick={() => setResumePicked(true)}
/>
</div>
{props.mode === "conversation" && (
<>
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={!resumePicked}
label={t("resume.freshOption")}
title={t("resume.freshHint")}
onClick={() => {
setResumePicked(false);
props.onResumeSessionChange(null);
}}
/>
<Seg
active={resumePicked}
label={t("resume.resumeOption")}
title={t("resume.resumeHint")}
onClick={() => setResumePicked(true)}
/>
</div>
{resumePicked && (
<div className="min-w-0 flex-1">
<SessionPicker
selected={props.resumeSession}
onSelect={props.onResumeSessionChange}
cwd={props.laneCwd}
/>
</div>
)}
</>
{resumePicked && (
<div className="min-w-0 flex-1">
<SessionPicker
selected={props.resumeSession}
onSelect={props.onResumeSessionChange}
cwd={props.laneCwd}
/>
</div>
)}
</div>
@@ -164,8 +169,8 @@ export function RunSetup(props: RunSetupProps) {
<PromptEditor
value={props.prompt}
onChange={props.onPromptChange}
onSubmit={props.onStart}
placeholder={t("fields.promptPlaceholder")}
onSubmit={() => handleStart(props)}
placeholder={t("fields.promptPlaceholderTerminal")}
rows={5}
slashCommands={props.slashCommands}
fileCwd={props.resumeSession?.cwd || props.cwd}
@@ -234,17 +239,12 @@ export function RunSetup(props: RunSetupProps) {
{atCap ? (
<span className="inline-flex items-center gap-1.5 text-status-warning">
<AlertCircle className="w-3.5 h-3.5" />
{t("concurrency.atCap", { max: props.activeRuns?.maxConcurrent ?? 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 })}
{t("concurrency.atCap", { max: 0 })}
</span>
) : null}
</div>
<button
onClick={props.onStart}
onClick={() => handleStart(props)}
disabled={
!props.binaryFound ||
!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`
* instead of a hint line, which is what keeps the row to one line. */
function Seg({