Files
Claude-Code-Monitor/docs/superpowers/plans/2026-08-14-split-terminal-view.md
T
nntrivi2001 fa416b5e6b docs(plan): add implementation plan for split terminal view
Task-by-task plan extracting LaneConsolePane from Workspace.tsx and
adding a 1/2/4-pane layout toggle with localStorage persistence.
2026-08-14 11:19:01 +07:00

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 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
// 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), 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
// 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: 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:

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

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

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.