# 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ĩ <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**

```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 }) => (
    <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**

```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ĩ <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**

```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<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`:

```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
<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:

```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<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):

```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 `<LaneConsolePane .../>` from Task 3 Step 5):

```typescript
<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**

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