# 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.