fa416b5e6b
Task-by-task plan extracting LaneConsolePane from Workspace.tsx and adding a 1/2/4-pane layout toggle with localStorage persistence.
1089 lines
44 KiB
Markdown
1089 lines
44 KiB
Markdown
# 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.
|