diff --git a/docs/superpowers/plans/2026-08-14-split-terminal-view.md b/docs/superpowers/plans/2026-08-14-split-terminal-view.md new file mode 100644 index 0000000..afeb048 --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-split-terminal-view.md @@ -0,0 +1,1088 @@ +# Split Terminal View Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the Workspace page show 1, 2, or 4 lanes' terminals side by side instead of only ever one. + +**Architecture:** Extract the existing single-lane run console (RunSetup/TerminalView switcher plus all its state and handlers, currently inline in `client/src/pages/Workspace.tsx`) into a new self-contained `LaneConsolePane` component. Workspace renders 1/2/4 instances of it in a CSS grid, keyed by a `paneLaneIds` array persisted to `localStorage`. Layout 1 keeps today's exact behavior (pane bound to the lane strip's `selectedLaneId`, no visible per-pane picker); layouts 2 and 4 give each pane its own lane-select dropdown, independent of the strip. + +**Tech Stack:** React + TypeScript (client/), Vitest + Testing Library for tests, existing `../lib/api` REST client, `localStorage` for persistence (no new dependency). + +## Global Constraints + +- No server/API changes — this is a client-only feature (per the approved spec, docs/superpowers/specs/2026-08-14-split-terminal-view-design.md). +- No synchronized input across panes — each `TerminalView` keeps its own independent WebSocket connection. +- Layout 1 must remain behaviorally and structurally identical to today's Workspace (existing `Workspace.test.tsx` assertions about `console-body`, `lane-detail` nesting, and the no-`/stage`-call invariant must still pass unmodified where they test layout-1 behavior). +- Layout + pane lane selections persist to `localStorage` under key `ccam.workspace.splitView`; a persisted lane id that no longer exists in the loaded lane list falls back to unselected for that pane. +- Every new/modified `.ts`/`.tsx` file must carry the project's file header (see `.claude/skills/file-headers/`). +- Run `npm run test:client` before finishing; this is a client-only change so `npm run test:server` is not required, but do not skip `test:client`. + +--- + +### Task 1: `localStorage` helper for split-view state + +**Files:** +- Create: `client/src/lib/splitViewStorage.ts` +- Test: `client/src/lib/__tests__/splitViewStorage.test.ts` + +**Interfaces:** +- Produces: `SplitLayout = 1 | 2 | 4`, `SplitViewState = { layout: SplitLayout; paneLaneIds: (number | null)[] }`, `readSplitViewState(): SplitViewState`, `writeSplitViewState(state: SplitViewState): void`, `defaultSplitViewState(): SplitViewState`. +- Consumes: nothing (leaf module). + +This follows the existing `localStorage` convention in the codebase (e.g. `client/src/hooks/useTheme.ts`'s `readStoredTheme`/`writeStoredTheme`): a module-level `STORAGE_KEY`, JSON in/out, `try/catch` swallowing quota/parse/disabled-storage errors and falling back to a safe default. + +- [ ] **Step 1: Write the failing test** + +```typescript +// client/src/lib/__tests__/splitViewStorage.test.ts +import { describe, it, expect, beforeEach } from "vitest"; +import { + readSplitViewState, + writeSplitViewState, + defaultSplitViewState, +} from "../splitViewStorage"; + +describe("splitViewStorage", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("returns the default state when nothing is stored", () => { + expect(readSplitViewState()).toEqual(defaultSplitViewState()); + }); + + it("defaults to a single unselected pane", () => { + expect(defaultSplitViewState()).toEqual({ layout: 1, paneLaneIds: [null] }); + }); + + it("round-trips a written state", () => { + writeSplitViewState({ layout: 4, paneLaneIds: [1, 2, null, null] }); + expect(readSplitViewState()).toEqual({ layout: 4, paneLaneIds: [1, 2, null, null] }); + }); + + it("falls back to the default when stored JSON is malformed", () => { + localStorage.setItem("ccam.workspace.splitView", "{not json"); + expect(readSplitViewState()).toEqual(defaultSplitViewState()); + }); + + it("falls back to the default when the stored layout is not 1, 2, or 4", () => { + localStorage.setItem("ccam.workspace.splitView", JSON.stringify({ layout: 3, paneLaneIds: [] })); + expect(readSplitViewState()).toEqual(defaultSplitViewState()); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd client && npx vitest run src/lib/__tests__/splitViewStorage.test.ts` +Expected: FAIL — `splitViewStorage` module not found. + +- [ ] **Step 3: Write minimal implementation** + +```typescript +/** + * @file splitViewStorage.ts + * @description Persists the Workspace page's split-terminal layout (1/2/4 + * panes) and each pane's chosen lane id to localStorage, so the layout + * survives a page reload. Follows the same read/write-with-fallback + * convention as useTheme.ts. + * @author Nguyễn Ngọc Trí Vĩ + */ + +export type SplitLayout = 1 | 2 | 4; + +export interface SplitViewState { + layout: SplitLayout; + paneLaneIds: (number | null)[]; +} + +const STORAGE_KEY = "ccam.workspace.splitView"; + +export function defaultSplitViewState(): SplitViewState { + return { layout: 1, paneLaneIds: [null] }; +} + +function isValidLayout(value: unknown): value is SplitLayout { + return value === 1 || value === 2 || value === 4; +} + +function isValidState(value: unknown): value is SplitViewState { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return ( + isValidLayout(v.layout) && + Array.isArray(v.paneLaneIds) && + v.paneLaneIds.every((id) => id === null || typeof id === "number") + ); +} + +export function readSplitViewState(): SplitViewState { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return defaultSplitViewState(); + const parsed: unknown = JSON.parse(raw); + return isValidState(parsed) ? parsed : defaultSplitViewState(); + } catch { + return defaultSplitViewState(); + } +} + +export function writeSplitViewState(state: SplitViewState): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + /* ignore quota / disabled storage */ + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd client && npx vitest run src/lib/__tests__/splitViewStorage.test.ts` +Expected: PASS (all 5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add client/src/lib/splitViewStorage.ts client/src/lib/__tests__/splitViewStorage.test.ts +git commit -m "feat(workspace): add localStorage helper for split-view layout state" +``` + +--- + +### Task 2: Extract `LaneConsolePane` + +**Files:** +- Create: `client/src/components/run/LaneConsolePane.tsx` +- Test: `client/src/components/run/__tests__/LaneConsolePane.test.tsx` +- Modify: `client/src/pages/Workspace.tsx:87-101` (remove the per-run state declared here — done in Task 3, not this task, to keep this task's diff reviewable in isolation: this task only *adds* the new component and its own tests, without touching Workspace.tsx yet) + +**Interfaces:** +- Consumes: `RunSetup` (`client/src/components/run/RunSetup.tsx`), `TerminalView` (`client/src/components/run/TerminalView.tsx`), `ActiveRunsSwitcher` (`client/src/components/run/RunHistory.tsx`), `api` (`client/src/lib/api.ts`), types `Lane`, `RunHandle`, `RunListResponse`, `DashboardRunHistoryItem`, `CwdSuggestion`, `EffortLevel`, `PermissionMode`, `Session`. +- Produces: `export function LaneConsolePane(props: LaneConsolePaneProps)`. Task 3 imports this and stops rendering the old inline console. + +This is the core extraction. Move (not rewrite) the following from `Workspace.tsx` into the new component's body, verbatim except for the renames/prop-plumbing called out below: + +- State: `prompt`, `model`, `permissionMode`, `effort`, `cwd`, `resumeSession`, `handle`, `busy`, `error`, `activeRuns` stays a **prop** (see below — not per-pane state), `runHistory`. +- Handlers: `attachToRun` (Workspace.tsx:440-456), `onStartFromSetup` (Workspace.tsx:539-631, renamed `onStart` internally), `newRun` (Workspace.tsx:633-638), `onResumeFromHistory` (Workspace.tsx:244-308), `refreshList` (Workspace.tsx:194-211). +- JSX: the `Header` + binary-missing banner + error banner + `RunSetup`/`TerminalView` switch currently at Workspace.tsx:749-827 (the `consoleSection` body). Move the `Header` function itself (Workspace.tsx:1079-1132) into this file too — it is only ever used here. + +**What does NOT move (stays a prop, supplied by Workspace):** +- `lanes: Lane[]` — read-only, needed to resolve `args.cwd` against existing lanes in `onStartFromSetup`/`onResumeFromHistory`. +- `onLaneCreated: (lane: Lane) => void` — called wherever the old code did `setLanes((prev) => ...)` after `api.lanes.ensure()` returns a lane not yet in the page's list. +- `binaryStatus: { found: boolean; path: string | null } | null` and `cwdSuggestions: CwdSuggestion[]` — these are global (not lane-specific) probes fetched once at the Workspace level; duplicating one `api.run.binary()`/`api.run.cwds()` call per pane would be 4 redundant identical requests for a 4-pane layout. +- `activeRuns: RunListResponse | null` — the list of *all* live runs across every lane. Also global, also fetched once at Workspace level and handed down, to avoid N redundant `api.run.list()` calls per render. +- `wsConnected: boolean` — from `eventBus`, already page-level in Workspace. +- `laneId: number | null` — which lane this pane currently shows. `null` means "no lane picked yet" (only reachable in 2/4-pane mode). +- `showLaneSelector: boolean` — Workspace passes `false` for the single layout-1 pane (no dropdown, exactly like today) and `true` for each pane in layout 2/4. +- `onLaneIdChange: (id: number) => void` — called both when the pane's own dropdown changes (when `showLaneSelector` is true) AND internally whenever `onStartFromSetup`/`onResumeFromHistory` resolves a lane whose id differs from the current `laneId` prop (mirrors the old `setSelectedLaneId(ownedLane.id)` call at Workspace.tsx:559). Workspace wires this differently per mode (Task 3/4). + +**Empty-pane state:** when `laneId === null`, render a placeholder with just the lane dropdown (only reachable when `showLaneSelector` is true, since layout 1 always has a lane bound to it before mount — `Workspace` never mounts a layout-1 pane with a null laneId once at least one lane exists, matching today's `!currentLane` fallback which already handles the zero-lanes case at Workspace.tsx:1072). + +- [ ] **Step 1: Write the failing test** + +```typescript +// client/src/components/run/__tests__/LaneConsolePane.test.tsx +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { LaneConsolePane } from "../LaneConsolePane"; +import { api } from "../../../lib/api"; +import type { Lane } from "../../../lib/types"; + +vi.mock("../TerminalView", () => ({ + TerminalView: ({ runId }: { runId: string }) => ( +
+ ), +})); + +vi.mock("../../../lib/api", () => ({ + api: { + lanes: { + ensure: vi.fn(), + action: vi.fn(), + list: vi.fn(), + }, + run: { + list: vi.fn().mockResolvedValue({ items: [] }), + history: vi.fn().mockResolvedValue({ items: [] }), + get: vi.fn(), + start: vi.fn(), + }, + }, + RUN_MODEL_CHOICES: [], + RUN_EFFORT_CHOICES: [], +})); + +const LANE: Lane = { + id: 1, + title: "demo", + cwd: "/workspace/a", + branch: null, + kind: "adopted", + source_repo: null, + pipeline: "default", + session_id: null, + run_id: null, + stage: "idle", + stage_since: null, + status: "idle", + gate_decision: null, + ci_status: null, + needs_action: null, + links: {}, + stages: {}, + notes: null, + pipeline_name: "Default", + pipeline_nodes: [], + progress: 0, + stage_seconds: null, + last_event_seconds: null, + liveness: "idle" as Lane["liveness"], + detected_stage: null, + detected_signal: null, + slot: null, + ports: {}, + active_feature_id: null, +}; + +function baseProps() { + return { + lanes: [LANE], + laneId: 1, + showLaneSelector: false, + onLaneIdChange: vi.fn(), + onLaneCreated: vi.fn(), + binaryStatus: { found: true, path: "/usr/local/bin/claude" }, + cwdSuggestions: [], + activeRuns: { items: [] }, + wsConnected: true, + }; +} + +describe("LaneConsolePane", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("starts a run through /api/lanes//start, not /api/run/start", async () => { + (api.lanes.action as ReturnType).mockResolvedValue({ + lane: { ...LANE, run_id: "run-1" }, + }); + (api.run.get as ReturnType).mockResolvedValue({ + id: "run-1", + laneId: 1, + status: "running", + cwd: "/workspace/a", + model: null, + permissionMode: null, + effort: null, + resumeSessionId: null, + sessionId: null, + startedAt: null, + promptPreview: null, + }); + + render(); + + fireEvent.change(screen.getByLabelText(/cwd/i), { target: { value: "/workspace/a" } }); + fireEvent.click(screen.getByRole("button", { name: /run|start/i })); + + await waitFor(() => expect(api.lanes.action).toHaveBeenCalledWith(1, "start", expect.any(Object))); + expect(api.run.start).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-1")); + }); + + it("shows a lane dropdown only when showLaneSelector is true", () => { + const { rerender } = render(); + expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument(); + + rerender(); + expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument(); + }); + + it("renders an empty placeholder with just a picker when laneId is null", () => { + render(); + expect(screen.getByTestId("pane-empty")).toBeInTheDocument(); + expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument(); + expect(screen.queryByTestId("console-body")).not.toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd client && npx vitest run src/components/run/__tests__/LaneConsolePane.test.tsx` +Expected: FAIL — `LaneConsolePane` module not found. + +- [ ] **Step 3: Write the implementation** + +```typescript +/** + * @file LaneConsolePane.tsx + * @description One lane's run console: the RunSetup ↔ TerminalView switcher, + * moved out of Workspace.tsx so the Workspace page can render 1, 2, or 4 of + * these side by side (split terminal view). Owns its own prompt/cwd/model/ + * permissionMode/effort/resumeSession/handle/busy/runHistory state — nothing + * is shared between panes. `lanes`, `binaryStatus`, `cwdSuggestions`, and + * `activeRuns` are supplied as props because they are global, not + * lane-specific, and fetching them per pane would mean N redundant identical + * requests for an N-pane layout. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Play, AlertCircle } from "lucide-react"; +import { api } from "../../lib/api"; +import type { + CwdSuggestion, + DashboardRunHistoryItem, + EffortLevel, + PermissionMode, + RunHandle, + RunListResponse, + RunStartArgs, +} from "../../lib/api"; +import type { Session, Lane } from "../../lib/types"; +import { TerminalView } from "./TerminalView"; +import { RunSetup } from "./RunSetup"; +import { ActiveRunsSwitcher } from "./RunHistory"; + +export interface LaneConsolePaneProps { + lanes: Lane[]; + laneId: number | null; + showLaneSelector: boolean; + onLaneIdChange: (id: number) => void; + onLaneCreated: (lane: Lane) => void; + binaryStatus: { found: boolean; path: string | null } | null; + cwdSuggestions: CwdSuggestion[]; + activeRuns: RunListResponse | null; + wsConnected: boolean; +} + +export function LaneConsolePane({ + lanes, + laneId, + showLaneSelector, + onLaneIdChange, + onLaneCreated, + binaryStatus, + cwdSuggestions, + activeRuns, + wsConnected, +}: LaneConsolePaneProps) { + const { t } = useTranslation("run"); + const { t: tLanes } = useTranslation("lanes"); + + const [prompt, setPrompt] = useState(""); + const [model, setModel] = useState(""); + const [permissionMode, setPermissionMode] = useState("acceptEdits"); + const [effort, setEffort] = useState(""); + const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? ""); + const [resumeSession, setResumeSession] = useState(null); + const [handle, setHandle] = useState(null); + const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null); + const [error, setError] = useState(null); + const [runHistory, setRunHistory] = useState([]); + + const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null; + + const refreshList = useCallback(() => { + if (laneId !== null) { + api.run + .history(50, { laneId }) + .then((r) => setRunHistory(r.items)) + .catch(() => undefined); + } else { + api.run + .history(50) + .then((r) => setRunHistory(r.items)) + .catch(() => undefined); + } + }, [laneId]); + + const attachToRun = useCallback( + async (id: string) => { + if (busy) return; + setBusy("attach"); + setError(null); + try { + const fetched = await api.run.get(id); + setHandle(fetched); + } catch (err: unknown) { + const m = err instanceof Error ? err.message : "unknown"; + setError(t("errors.attachFailed", { message: m })); + } finally { + setBusy(null); + } + }, + [busy, t] + ); + + const onStartFromSetup = useCallback( + async (args: RunStartArgs) => { + if (busy) return; + setBusy("start"); + setError(null); + try { + const effectiveCwd = args.cwd || undefined; + + if (!effectiveCwd) { + throw new Error(t("errors.cwdRequired")); + } + + // Resolve the lane from the cwd the user actually typed, not from + // args.laneId — RunSetup always supplies this pane's laneId (a + // required prop), which would otherwise silently start a run in the + // wrong lane whenever the user types a cwd different from the one + // this pane currently shows. + const ownedLane = lanes.find((l) => l.cwd === effectiveCwd); + let targetLaneId: number; + if (ownedLane) { + targetLaneId = ownedLane.id; + if (ownedLane.id !== laneId) onLaneIdChange(ownedLane.id); + } else { + try { + const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd }); + targetLaneId = ensureResult.lane.id; + onLaneIdChange(ensureResult.lane.id); + onLaneCreated(ensureResult.lane); + } catch (err) { + throw new Error( + t("errors.laneCreateFailed", { + message: err instanceof Error ? err.message : "unknown", + }) + ); + } + } + + let laneStartResult; + try { + laneStartResult = await api.lanes.action(targetLaneId, "start", { + prompt: args.initialPrompt || "", + 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); + 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 fetched = await api.run.get(laneStartResult.lane.run_id); + setHandle(fetched); + refreshList(); + } catch { + try { + await attachToRun(laneStartResult.lane.run_id); + refreshList(); + } catch (fallbackErr: unknown) { + 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, t, lanes, laneId, onLaneIdChange, onLaneCreated, attachToRun, refreshList] + ); + + const onResumeFromHistory = useCallback( + async (item: DashboardRunHistoryItem) => { + if (!item.session_id) return; + if (busy) return; + setBusy("start"); + setError(null); + try { + let fetched: RunHandle; + + if (item.cwd) { + const effectiveCwd = item.cwd; + let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd)?.id; + + if (!targetLaneId) { + const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd }); + targetLaneId = ensureResult.lane.id; + onLaneCreated(ensureResult.lane); + } + + const laneStartResult = await api.lanes.action(targetLaneId, "start", { + prompt: "", + model: item.model || undefined, + permissionMode: item.permission_mode || undefined, + effort: item.effort || undefined, + resumeSessionId: item.session_id, + }); + + if (!laneStartResult.lane?.run_id) { + throw new Error("No run_id returned from lane start"); + } + + fetched = await api.run.get(laneStartResult.lane.run_id); + onLaneIdChange(targetLaneId); + } else { + fetched = await api.run.start({ + laneId: 0, + initialPrompt: "", + cwd: undefined, + model: item.model || undefined, + permissionMode: item.permission_mode || undefined, + effort: item.effort || undefined, + resumeSessionId: item.session_id, + }); + } + + setHandle(fetched); + setResumeSession(null); + refreshList(); + } catch (err) { + const msg = err instanceof Error ? err.message : "unknown"; + setError(t("errors.startFailed", { message: msg })); + } finally { + setBusy(null); + } + }, + [busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange] + ); + + const onViewFromHistory = useCallback( + (item: DashboardRunHistoryItem) => { + if (item.session_id) void onResumeFromHistory(item); + }, + [onResumeFromHistory] + ); + + const newRun = useCallback(() => { + setHandle(null); + setPrompt(""); + setResumeSession(null); + setError(null); + }, []); + + if (laneId === null) { + return ( +
+ +

{tLanes("splitView.emptyPane")}

+
+ ); + } + + return ( +
+ {showLaneSelector && ( + + )} + +
+
+ +
+
+

{t("title")}

+

{t("subtitle")}

+
+ +
+ + {binaryStatus && !binaryStatus.found && ( +
+ + {t("binary.missing")} +
+ )} + + {error && ( +
+ + {error} +
+ )} + + {!handle ? ( + + ) : ( +
+ + +
+ )} +
+ ); +} +``` + +Add the two new i18n keys this introduces (`splitView.pickLane`, `splitView.emptyPane`, `splitView.paneLaneLabel`) to the `lanes` namespace JSON files under `client/src/i18n/` (check `client/src/i18n/index.ts` for the locale list; add the same three keys to every locale file that namespace already has, in English for locales without existing translations — do not leave any locale file missing the key, since `RunSetup`'s own strings follow this pattern already and a missing key renders the raw key string). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd client && npx vitest run src/components/run/__tests__/LaneConsolePane.test.tsx` +Expected: PASS (all 3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add client/src/components/run/LaneConsolePane.tsx client/src/components/run/__tests__/LaneConsolePane.test.tsx client/src/i18n/ +git commit -m "feat(workspace): extract LaneConsolePane from the inline run console" +``` + +--- + +### Task 3: Wire layout 1 through `LaneConsolePane` (no behavior change) + +**Files:** +- Modify: `client/src/pages/Workspace.tsx` + +**Interfaces:** +- Consumes: `LaneConsolePane` from Task 2, `readSplitViewState`/`writeSplitViewState`/`defaultSplitViewState` from Task 1. +- Produces: Workspace still renders exactly one console, now via `LaneConsolePane`, bound to `selectedLaneId` — this task introduces zero visible/behavioral change, so every existing `Workspace.test.tsx` test must keep passing without modification. This is the safety checkpoint before Task 4 adds the actual split-view UI. + +Remove from `Workspace.tsx`: the state at lines 87-101 that moved — `prompt`, `model`, `permissionMode`, `effort`, `cwd` (see note below — it is dropped, not kept), `resumeSession`, `handle`, `busy`, `error`, `runHistory`; the handlers `attachToRun` (440-456), `onStartFromSetup` (539-631), `newRun` (633-638), `onResumeFromHistory` (244-308), the lane-id-only branch of `refreshList` (194-211); the `consoleSection` JSX (749-827) and the `Header` function (1079-1132). + +**Keep in Workspace.tsx:** `binaryStatus`, `cwdSuggestions` (still needed by `AddLaneModal`), `activeRuns` (now only used for the props passed to the pane(s), no longer for a page-owned form), the mount-time fetch effects for all of those, `lanes`, `counts`, `selectedLaneId`, and everything under "PAGE-LEVEL STATE" already unrelated to the console (feature picker, proof gallery, pipeline template picker, `handleLaneAction`, `handlePipelineChange`). + +Note on `cwd`: the page-level `cwd` state was only ever used to (a) seed the config form and (b) drive the lane strip's `onSelect` (`setCwd(l.cwd)` at Workspace.tsx:899). Since the form's own `cwd` now lives inside `LaneConsolePane`, drop the page-level `cwd` state entirely and remove the `setCwd(l.cwd)` call at the lane strip's `onSelect` handler (Workspace.tsx:897-900) — `LaneConsolePane` seeds its own `cwd` from `lanes.find(l => l.id === laneId)?.cwd` on mount (see Task 2's `useState(() => ...)` initializer), so switching `selectedLaneId` already gets the new lane's cwd once the pane remounts. + +The home-directory-prefill effect (Workspace.tsx:138-152) previously wrote to that page-level `cwd`. Keep the effect in Workspace.tsx but retarget what it seeds: rename its target to a new state var `defaultCwd: string` (`setDefaultCwd(preferred.path)` instead of `setCwd(...)`), and pass `defaultCwd` down as a new `LaneConsolePane` prop, used only in the `cwd` initializer when the lane has no cwd of its own — i.e. `lanes.find(...)?.cwd ?? defaultCwd ?? ""`. Add `defaultCwd?: string` to `LaneConsolePaneProps` in this task (a one-line addition to Task 2's interface, made here since Task 2 is already committed). + +- [ ] **Step 1: Add `defaultCwd` to `LaneConsolePaneProps` and its initializer** + +In `client/src/components/run/LaneConsolePane.tsx`, add `defaultCwd?: string;` to `LaneConsolePaneProps`, destructure it, and change: + +```typescript +const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? ""); +``` + +to: + +```typescript +const [cwd, setCwd] = useState( + () => lanes.find((l) => l.id === laneId)?.cwd ?? defaultCwd ?? "" +); +``` + +- [ ] **Step 2: Replace the per-run state block in Workspace.tsx** + +Delete lines 87-101 (`prompt` through `cwdSuggestions`) and replace with: + +```typescript + // Run state kept at page level: shared across every pane, or drives the + // lane strip itself rather than any one pane's form. + const [error, setError] = useState(null); + const [activeRuns, setActiveRuns] = useState(null); + const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>( + null + ); + const [cwdSuggestions, setCwdSuggestions] = useState([]); + const [defaultCwd, setDefaultCwd] = useState(""); +``` + +(`error` stays page-level only for lane-action errors already tracked separately as `laneActionError` — check whether the removed `error`/`setError` calls at Workspace.tsx:249, 302, 324, 444, 450, 482, 625 were all inside the handlers that moved. They were (all inside `onResumeFromHistory`, `start`, `attachToRun`, `onStartFromSetup`) — so this page-level `error` state and its two JSX banners at Workspace.tsx:774-784 become dead and must be deleted, not kept. Remove the `error`/`setError` declaration entirely along with its rendering block; do not keep an unused page-level `error`.) + +- [ ] **Step 3: Update the home-directory prefill effect** + +In the mount effect (Workspace.tsx:138-152), change the body that previously called `setCwd`: + +```typescript + const home = r.items.find((s) => s.kind === "home"); + const dashboard = r.items.find((s) => s.kind === "dashboard"); + const preferred = home || dashboard; + if (preferred) { + setDefaultCwd(preferred.path); + } +``` + +- [ ] **Step 4: Remove the extracted handlers and the `consoleSection`/`Header` definitions** + +Delete: `onResumeFromHistory` (244-308), `start` (321-438) — this was the manual-form `start` handler now fully superseded by `LaneConsolePane`'s internal `onStartFromSetup`, `attachToRun` (440-456), `onStartFromSetup` (539-631), `newRun` (633-638), the `consoleSection` const (749-827), and the `Header` function (1079-1132). Delete the now-unused imports this leaves behind: `RunSetup`, `TerminalView`, `ActiveRunsSwitcher`, `Play` (check if `Play` is used elsewhere in the file before removing — grep first), `RunHandle`, `RunStartArgs`, `PermissionMode`, `EffortLevel`, `Session` if no longer referenced. + +- [ ] **Step 5: Render `LaneConsolePane` in place of `consoleSection`** + +Replace every reference to `{consoleSection}` (Workspace.tsx:1044 and 1072) with: + +```typescript + setSelectedLaneId(id)} + onLaneCreated={(lane) => + setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane])) + } + binaryStatus={binaryStatus} + cwdSuggestions={cwdSuggestions} + activeRuns={activeRuns} + wsConnected={wsConnected} + defaultCwd={defaultCwd} +/> +``` + +Import it: `import { LaneConsolePane } from "../components/run/LaneConsolePane";` + +- [ ] **Step 6: Remove the `setCwd(l.cwd)` call from the lane strip click handler** + +At Workspace.tsx:897-900, change: + +```typescript +onSelect={() => { + setSelectedLaneId(l.id); + setCwd(l.cwd); +}} +``` + +to: + +```typescript +onSelect={() => setSelectedLaneId(l.id)} +``` + +- [ ] **Step 7: Run the full existing Workspace test suite unmodified** + +Run: `cd client && npx vitest run src/pages/__tests__/Workspace.test.tsx` +Expected: PASS — every test listed in the design/plan research (lane strip, starting a run via `/api/lanes/:id/start`, `ensure`-before-start, no `/stage` calls, counters, console attaching under `lane-detail`, console reachable with zero lanes) still passes because layout 1's rendered DOM is unchanged (`LaneConsolePane` renders the identical `data-testid="console-body"` wrapper and the same `Header`/`RunSetup`/`TerminalView` markup that used to be inline). + +If any test fails, it is a genuine behavior regression from this refactor (not a test that needs updating) — stop and fix the component, since this task's whole point is zero behavior change. + +- [ ] **Step 8: Commit** + +```bash +git add client/src/pages/Workspace.tsx +git commit -m "refactor(workspace): render the run console through LaneConsolePane" +``` + +--- + +### Task 4: Add the layout toggle and split-view grid + +**Files:** +- Modify: `client/src/pages/Workspace.tsx` +- Modify: `client/src/pages/__tests__/Workspace.test.tsx` + +**Interfaces:** +- Consumes: `SplitLayout`, `SplitViewState`, `readSplitViewState`, `writeSplitViewState` from Task 1; `LaneConsolePane` from Task 2/3. +- Produces: a layout toggle (1/2/4 buttons) and a CSS grid of that many `LaneConsolePane` instances, replacing the single layout-1 pane whenever `splitView.layout !== 1`. + +- [ ] **Step 1: Write the failing tests** + +Add to `client/src/pages/__tests__/Workspace.test.tsx` (near the other console tests, after the existing `renderWorkspace()` helper — reuse it): + +```typescript +describe("split terminal view", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("defaults to a single pane with no layout toggle pressed state implying 2 or 4", async () => { + await renderWorkspace(); + expect(screen.getAllByTestId("console-body")).toHaveLength(1); + expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0); + }); + + it("switching to 2-pane layout renders two independent panes with lane pickers", async () => { + await renderWorkspace(); + fireEvent.click(screen.getByRole("button", { name: /2/i, name: /split.*2|2.*pane/i })); + expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(2); + expect(screen.getAllByTestId("pane-lane-select")).toHaveLength(2); + }); + + it("switching to 4-pane layout renders four panes", async () => { + await renderWorkspace(); + fireEvent.click(screen.getByRole("button", { name: /4.*pane/i })); + expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(4); + }); + + it("persists the layout and pane selections to localStorage across remounts", async () => { + const { unmount } = await renderWorkspace(); + fireEvent.click(screen.getByRole("button", { name: /2.*pane/i })); + const select = screen.getAllByTestId("pane-lane-select")[1]; + fireEvent.change(select, { target: { value: String(LANES[1].id) } }); + unmount(); + + await renderWorkspace(); + expect(screen.getAllByTestId("pane-lane-select")).toHaveLength(2); + expect((screen.getAllByTestId("pane-lane-select")[1] as HTMLSelectElement).value).toBe( + String(LANES[1].id) + ); + }); + + it("falls back to unselected when a persisted lane id no longer exists", async () => { + localStorage.setItem( + "ccam.workspace.splitView", + JSON.stringify({ layout: 2, paneLaneIds: [9999, null] }) + ); + await renderWorkspace(); + expect(screen.getAllByTestId("pane-empty")).toHaveLength(1); + }); +}); +``` + +(Adjust the button `name` matchers and the `LANES` fixture reference to whatever names the existing test file's mock lane fixtures already use — read the top of `Workspace.test.tsx` for the actual fixture variable name, e.g. it may be called `mockLanes` or similar, before finalizing this step; do not invent a fixture that doesn't exist in the file.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd client && npx vitest run src/pages/__tests__/Workspace.test.tsx -t "split terminal view"` +Expected: FAIL — no layout toggle buttons exist yet, `pane-lane-select` never renders more than once. + +- [ ] **Step 3: Implement the layout toggle and grid** + +In `Workspace.tsx`, add state initialized from storage: + +```typescript +const [splitView, setSplitView] = useState(() => readSplitViewState()); + +const setLayout = useCallback((layout: SplitLayout) => { + setSplitView((prev) => { + const paneLaneIds = Array.from( + { length: layout }, + (_, i) => prev.paneLaneIds[i] ?? null + ); + const next = { layout, paneLaneIds }; + writeSplitViewState(next); + return next; + }); +}, []); + +const setPaneLaneId = useCallback((index: number, id: number) => { + setSplitView((prev) => { + const paneLaneIds = [...prev.paneLaneIds]; + paneLaneIds[index] = id; + const next = { ...prev, paneLaneIds }; + writeSplitViewState(next); + return next; + }); +}, []); +``` + +Drop any `paneLaneIds` entry whose lane id no longer exists in `lanes` once lanes have loaded (covers the "stale persisted lane" test): + +```typescript +useEffect(() => { + if (!lanes.length) return; + setSplitView((prev) => { + const paneLaneIds = prev.paneLaneIds.map((id) => + id !== null && lanes.some((l) => l.id === id) ? id : null + ); + if (paneLaneIds.every((id, i) => id === prev.paneLaneIds[i])) return prev; + const next = { ...prev, paneLaneIds }; + writeSplitViewState(next); + return next; + }); +}, [lanes]); +``` + +Add the toggle UI next to the console area (place it just above where `LaneConsolePane`/the grid renders, replacing the single hard-coded `` from Task 3 Step 5): + +```typescript +
+ {([1, 2, 4] as const).map((n) => ( + + ))} +
+{splitView.layout === 1 ? ( + setSelectedLaneId(id)} + onLaneCreated={(lane) => + setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane])) + } + binaryStatus={binaryStatus} + cwdSuggestions={cwdSuggestions} + activeRuns={activeRuns} + wsConnected={wsConnected} + defaultCwd={defaultCwd} + /> +) : ( +
+ {splitView.paneLaneIds.map((id, i) => ( + setPaneLaneId(i, newId)} + onLaneCreated={(lane) => + setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane])) + } + binaryStatus={binaryStatus} + cwdSuggestions={cwdSuggestions} + activeRuns={activeRuns} + wsConnected={wsConnected} + defaultCwd={defaultCwd} + /> + ))} +
+)} +``` + +Import `SplitLayout`, `SplitViewState`, `readSplitViewState`, `writeSplitViewState` from `../lib/splitViewStorage`. + +Add the two new i18n keys this introduces (`splitView.paneCount`, with `count` interpolation) to every locale file in the `lanes` namespace, same as Task 2's note. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd client && npx vitest run src/pages/__tests__/Workspace.test.tsx` +Expected: PASS — both the pre-existing tests (unaffected, still layout 1 by default) and the new "split terminal view" describe block. + +- [ ] **Step 5: Run the full client test suite** + +Run: `npm run test:client` +Expected: PASS with no regressions elsewhere (e.g. snapshot tests in `client/src/pages/__tests__/screens.snapshot.test.tsx` — if the Workspace screenshot/snapshot changed because of the new toggle buttons, regenerate with `cd client && npx vitest run -u` and review the diff before accepting it, per this repo's testing policy). + +- [ ] **Step 6: Commit** + +```bash +git add client/src/pages/Workspace.tsx client/src/pages/__tests__/Workspace.test.tsx client/src/i18n/ +git commit -m "feat(workspace): add 1/2/4-pane split terminal view toggle" +``` + +--- + +### Task 5: Update docs + +**Files:** +- Modify: `docs/LANES.md:306-321` (the "## The Workspace page (`/run`)" section) + +**Interfaces:** none — documentation only. + +- [ ] **Step 1: Add a split-view bullet** + +In `docs/LANES.md`, in the "## The Workspace page (`/run`)" section, after the existing "**Terminal**" bullet (line 312), add: + +```markdown +- **Split view** — a layout toggle (1 / 2 / 4 panes) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2x2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`ccam.workspace.splitView`) across reloads. +``` + +- [ ] **Step 2: Verify doc accuracy against the implementation** + +Re-read the bullet against the actual `Workspace.tsx`/`LaneConsolePane.tsx` behavior from Task 4 and confirm every claim (grid classes, storage key, layout-1-vs-2/4 binding difference) matches exactly what was implemented — fix any drift. + +- [ ] **Step 3: Commit** + +```bash +git add docs/LANES.md +git commit -m "docs(lanes): document the Workspace split terminal view" +``` + +--- + +### Task 6: Final verification + +**Files:** none (verification only). + +- [ ] **Step 1: Run the full client suite** + +Run: `npm run test:client` +Expected: PASS, zero failures. + +- [ ] **Step 2: Type-check** + +Run: `cd client && npx tsc --noEmit` +Expected: no errors (catches any leftover unused-import or prop-mismatch from the Workspace.tsx refactor in Task 3). + +- [ ] **Step 3: File header audit** + +Run: `bash .claude/skills/file-headers/scripts/check-headers.sh` +Expected: exit 0 — confirms `LaneConsolePane.tsx` and `splitViewStorage.ts` carry the required header. + +- [ ] **Step 4: Manual smoke check** + +Run `npm run dev`, open the Workspace page, and confirm: layout 1 looks identical to before this plan; switching to 2 shows two independently-selectable panes; starting a run in one pane doesn't affect the other; switching to 4 and back to 1 doesn't lose the layout-1 pane's bound lane; reloading the page after picking 4-pane with two lanes selected restores that same layout and selection.