Task-by-task plan extracting LaneConsolePane from Workspace.tsx and adding a 1/2/4-pane layout toggle with localStorage persistence.
44 KiB
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
TerminalViewkeeps its own independent WebSocket connection. - Layout 1 must remain behaviorally and structurally identical to today's Workspace (existing
Workspace.test.tsxassertions aboutconsole-body,lane-detailnesting, and the no-/stage-call invariant must still pass unmodified where they test layout-1 behavior). - Layout + pane lane selections persist to
localStorageunder keyccam.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/.tsxfile must carry the project's file header (see.claude/skills/file-headers/). - Run
npm run test:clientbefore finishing; this is a client-only change sonpm run test:serveris not required, but do not skiptest: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
// 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
/**
* @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ĩ <vinnt@smartgift.vn>
*/
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<string, unknown>;
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
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), typesLane,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,activeRunsstays a prop (see below — not per-pane state),runHistory. - Handlers:
attachToRun(Workspace.tsx:440-456),onStartFromSetup(Workspace.tsx:539-631, renamedonStartinternally),newRun(Workspace.tsx:633-638),onResumeFromHistory(Workspace.tsx:244-308),refreshList(Workspace.tsx:194-211). - JSX: the
Header+ binary-missing banner + error banner +RunSetup/TerminalViewswitch currently at Workspace.tsx:749-827 (theconsoleSectionbody). Move theHeaderfunction 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 resolveargs.cwdagainst existing lanes inonStartFromSetup/onResumeFromHistory.onLaneCreated: (lane: Lane) => void— called wherever the old code didsetLanes((prev) => ...)afterapi.lanes.ensure()returns a lane not yet in the page's list.binaryStatus: { found: boolean; path: string | null } | nullandcwdSuggestions: CwdSuggestion[]— these are global (not lane-specific) probes fetched once at the Workspace level; duplicating oneapi.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 redundantapi.run.list()calls per render.wsConnected: boolean— fromeventBus, already page-level in Workspace.laneId: number | null— which lane this pane currently shows.nullmeans "no lane picked yet" (only reachable in 2/4-pane mode).showLaneSelector: boolean— Workspace passesfalsefor the single layout-1 pane (no dropdown, exactly like today) andtruefor each pane in layout 2/4.onLaneIdChange: (id: number) => void— called both when the pane's own dropdown changes (whenshowLaneSelectoris true) AND internally wheneveronStartFromSetup/onResumeFromHistoryresolves a lane whose id differs from the currentlaneIdprop (mirrors the oldsetSelectedLaneId(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
// 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 }) => (
<div data-testid="terminal-view" data-run-id={runId} />
),
}));
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/<id>/start, not /api/run/start", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "run-1" },
});
(api.run.get as ReturnType<typeof vi.fn>).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(<LaneConsolePane {...baseProps()} />);
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(<LaneConsolePane {...baseProps()} showLaneSelector />);
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
rerender(<LaneConsolePane {...baseProps()} showLaneSelector={false} />);
expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument();
});
it("renders an empty placeholder with just a picker when laneId is null", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector />);
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
/**
* @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ĩ <vinnt@smartgift.vn>
*/
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<PermissionMode>("acceptEdits");
const [effort, setEffort] = useState<EffortLevel>("");
const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? "");
const [resumeSession, setResumeSession] = useState<Session | null>(null);
const [handle, setHandle] = useState<RunHandle | null>(null);
const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
const [error, setError] = useState<string | null>(null);
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
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 (
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
<select
data-testid="pane-lane-select"
aria-label={tLanes("splitView.paneLaneLabel")}
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-secondary"
value=""
onChange={(e) => e.target.value && onLaneIdChange(Number(e.target.value))}
>
<option value="">{tLanes("splitView.pickLane")}</option>
{lanes.map((l) => (
<option key={l.id} value={l.id}>
{l.title || l.cwd}
</option>
))}
</select>
<p className="text-xs text-fg-muted">{tLanes("splitView.emptyPane")}</p>
</div>
);
}
return (
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
{showLaneSelector && (
<select
data-testid="pane-lane-select"
aria-label={tLanes("splitView.paneLaneLabel")}
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-secondary"
value={laneId}
onChange={(e) => onLaneIdChange(Number(e.target.value))}
>
{lanes.map((l) => (
<option key={l.id} value={l.id}>
{l.title || l.cwd}
</option>
))}
</select>
)}
<header className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
<Play className="w-4.5 h-4.5 text-accent" />
</div>
<div className="min-w-0 flex-1">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
<p className="text-xs text-fg-muted max-w-3xl">{t("subtitle")}</p>
</div>
<ActiveRunsSwitcher
activeRuns={activeRuns}
currentHandleId={handle?.id || null}
onAttach={attachToRun}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onRefresh={refreshList}
/>
</header>
{binaryStatus && !binaryStatus.found && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{t("binary.missing")}</span>
</div>
)}
{error && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 break-all">{error}</span>
</div>
)}
{!handle ? (
<RunSetup
laneId={laneId}
prompt={prompt}
onPromptChange={setPrompt}
cwd={cwd}
onCwdChange={setCwd}
cwdSuggestions={cwdSuggestions}
model={model}
onModelChange={setModel}
permissionMode={permissionMode}
onPermissionModeChange={setPermissionMode}
effort={effort}
onEffortChange={setEffort}
binaryFound={binaryStatus?.found ?? true}
busy={busy === "start"}
onStart={onStartFromSetup}
activeRuns={activeRuns}
laneCwd={currentLane?.cwd}
resumeSession={resumeSession}
onResumeSessionChange={setResumeSession}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
/>
) : (
<div className="flex-1 min-h-0 flex flex-col">
<TerminalView
runId={handle.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
/>
<button
onClick={newRun}
className="mt-3 px-3 py-1.5 text-sm rounded border border-border hover:border-border-light text-fg-secondary hover:text-fg-primary transition-colors"
>
{t("actions.newRun")}
</button>
</div>
)}
</div>
);
}
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
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:
LaneConsolePanefrom Task 2,readSplitViewState/writeSplitViewState/defaultSplitViewStatefrom Task 1. - Produces: Workspace still renders exactly one console, now via
LaneConsolePane, bound toselectedLaneId— this task introduces zero visible/behavioral change, so every existingWorkspace.test.tsxtest 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
defaultCwdtoLaneConsolePanePropsand its initializer
In client/src/components/run/LaneConsolePane.tsx, add defaultCwd?: string; to LaneConsolePaneProps, destructure it, and change:
const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? "");
to:
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:
// 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<string | null>(null);
const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>(
null
);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [defaultCwd, setDefaultCwd] = useState<string>("");
(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:
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/Headerdefinitions
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
LaneConsolePanein place ofconsoleSection
Replace every reference to {consoleSection} (Workspace.tsx:1044 and 1072) with:
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
showLaneSelector={false}
onLaneIdChange={(id) => 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:
onSelect={() => {
setSelectedLaneId(l.id);
setCwd(l.cwd);
}}
to:
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
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,writeSplitViewStatefrom Task 1;LaneConsolePanefrom Task 2/3. -
Produces: a layout toggle (1/2/4 buttons) and a CSS grid of that many
LaneConsolePaneinstances, replacing the single layout-1 pane wheneversplitView.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):
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:
const [splitView, setSplitView] = useState<SplitViewState>(() => 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):
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 <LaneConsolePane .../> from Task 3 Step 5):
<div className="flex items-center gap-1.5">
{([1, 2, 4] as const).map((n) => (
<button
key={n}
type="button"
aria-pressed={splitView.layout === n}
onClick={() => setLayout(n)}
className={`rounded border px-2 py-1 text-xs ${
splitView.layout === n
? "border-accent bg-accent/15 text-accent"
: "border-border text-fg-secondary hover:border-border-light"
}`}
>
{tLanes("splitView.paneCount", { count: n })}
</button>
))}
</div>
{splitView.layout === 1 ? (
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
showLaneSelector={false}
onLaneIdChange={(id) => 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}
/>
) : (
<div
className={`grid flex-1 min-h-0 gap-3 ${
splitView.layout === 2 ? "grid-cols-2" : "grid-cols-2 grid-rows-2"
}`}
>
{splitView.paneLaneIds.map((id, i) => (
<LaneConsolePane
key={i}
lanes={lanes}
laneId={id}
showLaneSelector
onLaneIdChange={(newId) => 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}
/>
))}
</div>
)}
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
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:
- **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
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.