Compare commits

..

20 Commits

Author SHA1 Message Date
nntrivi2001 2c29504c75 test(lanes): stop lane-lifecycle from leaking real tmux + claude processes
Four cases in lane-lifecycle.test.js call /start without stubbing PATH,
so they spawn the real system `claude` binary in a real tmux session
to simulate a stuck/live run. Each then mocks tmux's own exec calls to
fake has-session/kill-session for the app's checks, but never touches
the real spawned process — the mock only fools the app, not the OS.
Two of these leaked past every prior test run undetected (ccam-lane-22,
ccam-lane-24), surfacing in the dashboard's live "Dashboard runs" list
with no DB record and a garbage started_at, and reappearing in a
Workspace split pane pointed at a deleted temp directory.

Stub a lightweight fake `claude` on PATH (same pattern already used
correctly elsewhere in this file) instead of spawning the real CLI, and
explicitly kill the real tmux session in each test's teardown since the
app-level mock never reaches the OS process.
2026-08-14 17:28:54 +07:00
nntrivi2001 39572aa04c fix(workspace): stop the pipeline map from overflowing the detail panel
The lane-detail section is a flex-1 row item next to the lane list, but
lacked min-w-0. A flex item's intrinsic min-width defaults to its
content size, so PipelineMap's 16-node row (which relies on flex-1
min-w-0 truncate per node to shrink) pushed the whole panel wider than
its allotted space instead of compressing, spilling nodes off-screen.
2026-08-14 17:08:09 +07:00
nntrivi2001 3ae0d00b0c fix(workspace): stop false stage-mismatch warning, cap info panel height
Undeclared lanes default stage to the DB sentinel "idle", which never
matches a pipeline node — skip the mismatch warning in that case
instead of showing a false lane-action-failed banner. Also cap the
expandable lane-info block so it can't squeeze the console/split-view
out of the fixed-height detail panel.
2026-08-14 16:56:52 +07:00
nntrivi2001 a2b5fa4669 fix(workspace): move lane list to a vertical column beside detail panel
The detail panel (LaneCard/PipelineMap/proof gallery/console) could
grow tall enough to visually crowd out the lane-strip carousel above
it once a run was attached in split view. Move the lane list into its
own scrolling vertical column beside the detail panel instead of
stacking it above, so neither can cover the other; also collapse the
info block by default in 2/4-pane split view (toggle to expand) and
move the pane-count control into the detail header.
2026-08-14 16:39:15 +07:00
nntrivi2001 bab19e2f36 docs(lanes): document the Workspace split terminal view 2026-08-14 13:42:13 +07:00
nntrivi2001 6f22aed47c refactor: dedupe split-view render, strengthen persistence test
- Extract ConsoleArea helper to eliminate ~60 lines of duplicated layout
  toggle + grid rendering shared between currentLane and !currentLane branches
- Fix persistence test to validate the component's actual write path instead
  of manually re-seeding localStorage (proves writeSplitViewState is called)
- Remove orphaned grid/pane code left by incomplete merge
2026-08-14 13:36:02 +07:00
nntrivi2001 d542fbbf4b fix: satisfy strict null checks in new split-view tests
Use proper guards before indexing getAllByTestId results. Store the array
in a variable first and assert the element is defined before using it, to
satisfy noUncheckedIndexedAccess strictness.
2026-08-14 13:26:26 +07:00
nntrivi2001 764dc6a7b5 test(workspace): add split terminal view tests with corrected expectations
Added comprehensive tests for the split-terminal-view feature:
- Default 1-pane layout (no multi-pane UI)
- Switching to 2-pane and 4-pane layouts
- Persisting layout and lane selections to localStorage
- Fallback behavior when persisted lane IDs no longer exist

Fixed test expectation: when a persisted lane ID no longer exists and falls back
to unselected (null), both panes render as empty (pane-empty), not just one.

Updated Run snapshot to reflect the new layout toggle buttons.
2026-08-14 13:17:25 +07:00
nntrivi2001 06817b7901 feat(workspace): add 1/2/4-pane split terminal view toggle 2026-08-14 12:55:37 +07:00
nntrivi2001 14f116bf00 fix: keep RunSetup reachable with zero lanes, fix snapshot handling
Fixes regression from Tasks 1-2:
1. Run.defaultCwd.test.tsx was failing because LaneConsolePane wasn't
   rendering when no lane was selected (in layout-1 with zero lanes).
2. Added fallback rendering of LaneConsolePane in Workspace when
   !currentLane, so RunSetup stays reachable on fresh install.
3. Fixed early-return condition in LaneConsolePane to only show
   dropdown-only placeholder when showLaneSelector=true && laneId=null
   (split-view mode), not in layout-1 mode.
4. Added useEffect to sync cwd state with defaultCwd when it changes
   asynchronously from parent.
5. Updated LaneConsolePane tests to reflect correct behavior.
6. Regenerated snapshot to match layout-1 output.
2026-08-14 12:32:49 +07:00
nntrivi2001 6dda604362 refactor(workspace): fix viewportLocked regression and wire run status callback 2026-08-14 12:11:36 +07:00
nntrivi2001 22ce61bcfe refactor(workspace): fix viewportLocked regression and wire run status callback 2026-08-14 12:09:46 +07:00
nntrivi2001 8a61a2b359 fix: restore live/offline indicator dropped during Header extraction
The wsConnected prop was destructured but unused. This restores the
live/offline status pill next to the page title, matching the original
Header function design and consuming the prop properly.
2026-08-14 11:42:32 +07:00
nntrivi2001 b1d43bf098 feat(workspace): extract LaneConsolePane from the inline run console 2026-08-14 11:36:38 +07:00
nntrivi2001 11b779479d fix: add missing copyright header to splitViewStorage.test.ts 2026-08-14 11:28:43 +07:00
nntrivi2001 18a1ecb6f9 feat(workspace): add localStorage helper for split-view layout state 2026-08-14 11:25:37 +07:00
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
nntrivi2001 0f15800b23 docs(spec): add split terminal view design 2026-08-14 11:09:33 +07:00
nntrivi2001 43f29ee904 fix(ccam-open): always rebuild the dashboard bundle
/ccam-open built only when client/dist was missing, so a stale bundle
silently outlived code fixes (seen 2026-08-13: fix b0bfc66 wasn't served
until a --force rebuild). Pass --force unconditionally.
2026-08-13 15:38:50 +07:00
nntrivi2001 18a42873b2 fix(mcp): fall back to lane cwd when source_repo is null
Adopted lanes have source_repo = null; lane-mcp.js passed it raw to
readSourceMcpServers and threw ENOMCPCONFIG. Match the existing fallback
pattern in lane-env.js/lane-profile.js.
2026-08-13 15:36:36 +07:00
17 changed files with 2658 additions and 1050 deletions
@@ -1,9 +1,9 @@
/** /**
* @file The compact lane tile used in the Workspace carousel. It carries only * @file The compact lane tile used in the Workspace's vertical lane list. It
* what you need to pick a lane — which lane, is it alive, what stage, how far — * carries only what you need to pick a lane — which lane, is it alive, what
* because the full card, its controls and its working-copy facts live in the * stage, how far — because the full card, its controls and its working-copy
* detail panel below. Keeping the tile small is what lets a dozen lanes stay * facts live in the detail panel beside it. Keeping the tile small and full
* scannable in one horizontal row. * width is what lets many lanes stay scannable in one scrolling column.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -42,7 +42,7 @@ export default function LaneStripCard({
aria-pressed={selected} aria-pressed={selected}
onClick={onSelect} onClick={onSelect}
title={lane.cwd} title={lane.cwd}
className={`w-56 shrink-0 snap-start rounded-lg border p-3 text-left shadow-sm transition-colors ${ className={`w-full shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${
selected selected
? "border-accent bg-accent/10" ? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3" : "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
@@ -0,0 +1,403 @@
/**
* @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, useEffect, 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;
defaultCwd?: string;
onHasActiveRunChange?: (active: boolean) => void;
}
export function LaneConsolePane({
lanes,
laneId,
showLaneSelector,
onLaneIdChange,
onLaneCreated,
binaryStatus,
cwdSuggestions,
activeRuns,
wsConnected,
defaultCwd,
onHasActiveRunChange,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
const { t: tCommon } = useTranslation("common");
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 ?? defaultCwd ?? "");
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;
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
}, [handle, onHasActiveRunChange]);
useEffect(() => {
if (!currentLane && defaultCwd && cwd === "") {
setCwd(defaultCwd);
}
}, [defaultCwd, currentLane, cwd]);
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 && showLaneSelector) {
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) => 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>
)}
<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">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{tCommon("live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")}
</span>
)}
</div>
<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 ?? 0}
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>
);
}
@@ -0,0 +1,147 @@
/**
* @file LaneConsolePane.test.tsx
* @description Test suite for the LaneConsolePane component
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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()} />);
// Set cwd and prompt
const cwdInput = screen.getByPlaceholderText(/type to search/i);
fireEvent.change(cwdInput, { target: { value: "/workspace/a" } });
const promptTextarea = screen.getByPlaceholderText(/ask claude/i);
fireEvent.change(promptTextarea, { target: { value: "test prompt" } });
// Find and click the Run button (the main start button in RunSetup)
fireEvent.click(screen.getByRole("button", { name: /^run$/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 RunSetup when laneId is null and showLaneSelector is false (layout-1, fresh install)", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={false} />);
expect(screen.getByTestId("console-body")).toBeInTheDocument();
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
});
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
});
});
+6
View File
@@ -90,6 +90,8 @@
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked", "git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
"kind.adopted": "adopted", "kind.adopted": "adopted",
"kind.managed": "managed", "kind.managed": "managed",
"laneDetail.hide": "Hide details",
"laneDetail.show": "Lane details",
"laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}", "laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}",
"locks.held_one": "{{count}} lock held", "locks.held_one": "{{count}} lock held",
"locks.held_other": "{{count}} locks held", "locks.held_other": "{{count}} locks held",
@@ -120,6 +122,10 @@
"features.archived": "archived", "features.archived": "archived",
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.", "features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
"proof.ticketReport": "Task report", "proof.ticketReport": "Task report",
"splitView.emptyPane": "No lane selected for this pane.",
"splitView.paneLaneLabel": "Pane lane selector",
"splitView.paneCount": "{{count}} pane",
"splitView.pickLane": "Pick a lane",
"statusDead": "DEAD", "statusDead": "DEAD",
"title": "Lanes", "title": "Lanes",
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message" "tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message"
+6
View File
@@ -90,6 +90,8 @@
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi", "git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
"kind.adopted": "đã nhận", "kind.adopted": "đã nhận",
"kind.managed": "được quản lý", "kind.managed": "được quản lý",
"laneDetail.hide": "Ẩn chi tiết",
"laneDetail.show": "Chi tiết lane",
"laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}", "laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}",
"locks.held_one": "Đang giữ {{count}} khóa", "locks.held_one": "Đang giữ {{count}} khóa",
"locks.held_other": "Đang giữ {{count}} khóa", "locks.held_other": "Đang giữ {{count}} khóa",
@@ -120,6 +122,10 @@
"features.archived": "đã lưu trữ", "features.archived": "đã lưu trữ",
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.", "features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
"proof.ticketReport": "Báo cáo nhiệm vụ", "proof.ticketReport": "Báo cáo nhiệm vụ",
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
"splitView.paneCount": "{{count}} ô",
"splitView.pickLane": "Chọn lane",
"statusDead": "ĐÃ CHẾT", "statusDead": "ĐÃ CHẾT",
"title": "Làn đường", "title": "Làn đường",
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn" "tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn"
@@ -0,0 +1,44 @@
/**
* @file splitViewStorage.test.ts
* @description Tests for the splitViewStorage module.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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());
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* @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 */
}
}
File diff suppressed because it is too large Load Diff
+56 -1
View File
@@ -7,7 +7,7 @@
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, waitFor } from "@testing-library/react"; import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
@@ -633,3 +633,58 @@ describe("Workspace — proof gallery", () => {
expect(gallery).toBeNull(); expect(gallery).toBeNull();
}); });
}); });
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.*pane/i }));
await settle();
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 }));
await settle();
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 }));
await settle();
const selects = screen.getAllByTestId("pane-lane-select");
const select = selects[1];
expect(select).toBeDefined();
fireEvent.change(select!, { target: { value: String(lanesToReturn[1]!.id) } });
await settle();
unmount();
await renderWorkspace();
const persistedSelects = screen.getAllByTestId("pane-lane-select");
expect(persistedSelects).toHaveLength(2);
expect((persistedSelects[1] as HTMLSelectElement).value).toBe(String(lanesToReturn[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();
// Lane 9999 doesn't exist, so it falls back to null (unselected).
// The second pane is already null. Both render as pane-empty.
expect(screen.getAllByTestId("pane-empty")).toHaveLength(2);
});
});
@@ -5762,180 +5762,295 @@ exports[`screen snapshots > Run 1`] = `
</button> </button>
</div> </div>
<div <div
class="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1" class="flex min-h-0 flex-1 gap-4"
data-testid="lane-strip"
>
<p
class="text-sm text-fg-muted"
>
No lanes yet. Create one from a working directory:
<code>
ccam lanes add --cwd $(pwd)
</code>
</p>
</div>
<div
class="flex min-h-0 flex-col gap-2"
> >
<div <div
class="flex min-h-0 flex-1 flex-col gap-5" class="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
data-testid="console-body" data-testid="lane-strip"
> >
<header <p
class="flex items-start gap-3" class="text-sm text-fg-muted"
> >
<div No lanes yet. Create one from a working directory:
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
> <code>
<svg ccam lanes add --cwd $(pwd)
class="lucide lucide-play w-4.5 h-4.5 text-accent" </code>
fill="none" </p>
height="24" </div>
stroke="currentColor" <div
stroke-linecap="round" class="flex min-h-0 flex-1 flex-col gap-2"
stroke-linejoin="round" >
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="6 3 20 12 6 21 6 3"
/>
</svg>
</div>
<div
class="min-w-0 flex-1"
>
<div
class="flex items-center gap-2"
>
<h1
class="text-lg font-semibold text-fg-primary"
>
Run Claude
</h1>
<span
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
>
<span
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
/>
Live
</span>
</div>
<p
class="text-xs text-fg-muted max-w-3xl"
>
Spin up a Claude Code session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.
</p>
</div>
<button
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
disabled=""
>
<svg
class="lucide lucide-list-ordered w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 12h11"
/>
<path
d="M10 18h11"
/>
<path
d="M10 6h11"
/>
<path
d="M4 10h2"
/>
<path
d="M4 6h1v4"
/>
<path
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
/>
</svg>
Active runs
</button>
</header>
<div <div
class="rounded-xl border border-border bg-surface-1" class="flex items-center gap-1.5"
> >
<div <button
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]" aria-pressed="true"
class="rounded border px-2 py-1 text-xs border-accent bg-accent/15 text-accent"
type="button"
>
1 pane
</button>
<button
aria-pressed="false"
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
type="button"
>
2 pane
</button>
<button
aria-pressed="false"
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
type="button"
>
4 pane
</button>
</div>
<div
class="flex min-h-0 flex-1 flex-col gap-5"
data-testid="console-body"
>
<header
class="flex items-start gap-3"
> >
<div <div
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5" class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
> >
<button <svg
aria-pressed="true" class="lucide lucide-play w-4.5 h-4.5 text-accent"
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent" fill="none"
title="Start a fresh Claude Code session." height="24"
type="button" stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
> >
New session <polygon
</button> points="6 3 20 12 6 21 6 3"
<button />
aria-pressed="false" </svg>
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary"
title="Pick a session from your history and continue the conversation. Cwd is locked to the original."
type="button"
>
Resume existing session
</button>
</div> </div>
</div>
<div
class="px-4 py-3 border-b border-border"
>
<label
class="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5"
>
Prompt
</label>
<textarea
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
placeholder="Ask Claude anything…"
rows="5"
/>
<div <div
class="mt-1 text-[10px] text-fg-muted" class="min-w-0 flex-1"
> >
Cmd+Enter / Ctrl+Enter to send
· / for slash commands · @ for file references
</div>
</div>
<div
class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4"
>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Working directory
</label>
<div <div
title="Absolute path. Defaults to the dashboard's own cwd." class="flex items-center gap-2"
> >
<h1
class="text-lg font-semibold text-fg-primary"
>
Run Claude
</h1>
<span
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
>
<span
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
/>
Live
</span>
</div>
<p
class="text-xs text-fg-muted max-w-3xl"
>
Spin up a Claude Code session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.
</p>
</div>
<button
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
disabled=""
>
<svg
class="lucide lucide-list-ordered w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 12h11"
/>
<path
d="M10 18h11"
/>
<path
d="M10 6h11"
/>
<path
d="M4 10h2"
/>
<path
d="M4 6h1v4"
/>
<path
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
/>
</svg>
Active runs
</button>
</header>
<div
class="rounded-xl border border-border bg-surface-1"
>
<div
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]"
>
<div
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
>
<button
aria-pressed="true"
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent"
title="Start a fresh Claude Code session."
type="button"
>
New session
</button>
<button
aria-pressed="false"
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary"
title="Pick a session from your history and continue the conversation. Cwd is locked to the original."
type="button"
>
Resume existing session
</button>
</div>
</div>
<div
class="px-4 py-3 border-b border-border"
>
<label
class="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5"
>
Prompt
</label>
<textarea
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
placeholder="Ask Claude anything…"
rows="5"
/>
<div
class="mt-1 text-[10px] text-fg-muted"
>
Cmd+Enter / Ctrl+Enter to send
· / for slash commands · @ for file references
</div>
</div>
<div
class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4"
>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Working directory
</label>
<div <div
class="relative" title="Absolute path. Defaults to the dashboard's own cwd."
> >
<div <div
class="relative" class="relative"
> >
<div
class="relative"
>
<svg
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
/>
</svg>
<input
autocomplete="off"
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
placeholder="Type to search or paste an absolute path…"
spellcheck="false"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Model
</label>
<div
class="space-y-1.5"
>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
Inherit from settings
</span>
<svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
</div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Permission mode
</label>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
acceptEdits (recommended)
</span>
<svg <svg
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none" class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none" fill="none"
height="24" height="24"
stroke="currentColor" stroke="currentColor"
@@ -5947,30 +6062,18 @@ exports[`screen snapshots > Run 1`] = `
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" d="m6 9 6 6 6-6"
/> />
</svg> </svg>
<input </button>
autocomplete="off"
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
placeholder="Type to search or paste an absolute path…"
spellcheck="false"
type="text"
value=""
/>
</div>
</div> </div>
</div> </div>
</div> <div>
<div> <label
<label class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1" >
> Thinking effort
Model </label>
</label>
<div
class="space-y-1.5"
>
<div <div
class="relative" class="relative"
> >
@@ -5981,7 +6084,7 @@ exports[`screen snapshots > Run 1`] = `
<span <span
class="truncate" class="truncate"
> >
Inherit from settings Default (model decides)
</span> </span>
<svg <svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0" class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
@@ -6003,109 +6106,35 @@ exports[`screen snapshots > Run 1`] = `
</div> </div>
</div> </div>
</div> </div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Permission mode
</label>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
acceptEdits (recommended)
</span>
<svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Thinking effort
</label>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
Default (model decides)
</span>
<svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
</div>
<div
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
>
<div <div
class="flex items-center gap-3 text-[11px] min-w-0" class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
/>
<button
class="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
> >
<svg <div
class="lucide lucide-play w-3.5 h-3.5" class="flex items-center gap-3 text-[11px] min-w-0"
fill="none" />
height="24" <button
stroke="currentColor" class="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
stroke-linecap="round" disabled=""
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
> >
<polygon <svg
points="6 3 20 12 6 21 6 3" class="lucide lucide-play w-3.5 h-3.5"
/> fill="none"
</svg> height="24"
Run stroke="currentColor"
</button> stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="6 3 20 12 6 21 6 3"
/>
</svg>
Run
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
+1
View File
@@ -310,6 +310,7 @@ The dashboard web UI merges lanes and runs into a single **Workspace** page acce
- **Header** — the page title and four counters (`lanes`, `running`, `needs you`, `dead`), plus Add lane. The `needs you` and `dead` counters appear only when they are non-zero, so a quiet header means nothing is waiting on a human. - **Header** — the page title and four counters (`lanes`, `running`, `needs you`, `dead`), plus Add lane. The `needs you` and `dead` counters appear only when they are non-zero, so a quiet header means nothing is waiting on a human.
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment. - **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. - **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling.
- **Split view** — a layout toggle (1 / 2 / 4 panes) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 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.
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage with a progress bar and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row. - **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage with a progress bar and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row.
Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`. Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,74 @@
# Split terminal view for the Workspace console
**Status:** approved 2026-08-14.
## Problem
`Workspace.tsx` renders exactly one lane's run console at a time: a single
`RunSetup`/`TerminalView` switcher (client/src/pages/Workspace.tsx:789-824)
driven by page-level state (`selectedLaneId`, `prompt`, `cwd`, `model`,
`permissionMode`, `effort`, `resumeSession`, `handle`, `busy`, `activeRuns`,
`runHistory`, `cwdSuggestions`). Lanes are independent working directories
that can each have their own live tmux/PTY session running concurrently on
the server (`server/lib/pty-attach.js`), but the dashboard can only show one
at a time — comparing two lanes' output means switching back and forth.
The user wants to view multiple lanes' terminals side by side: 1 pane (today's
behavior), 2 panes (left/right), or 4 panes (2x2 grid).
## Approach
**Extract a self-contained `LaneConsolePane` component.** Move the existing
RunSetup/TerminalView switcher and all its state out of `Workspace.tsx` into
its own component that owns one lane's run lifecycle independently. Each
pane gets its own `laneId` (chosen via a dropdown in the pane header, listing
all lanes, not just ones with an active run) and manages its own
prompt/cwd/model/permissionMode/effort/resumeSession/handle/busy/activeRuns/
runHistory state — nothing is shared across panes.
Workspace keeps a `paneLaneIds: (number | null)[]` array sized to the current
layout (1, 2, or 4) and renders that many `LaneConsolePane` instances in a
CSS grid. This is the only viable approach given the existing state model is
single-lane; the alternative (keeping one shared state object indexed by
lane) would require rewriting every handler in Workspace.tsx to be
lane-aware and is a much larger, riskier diff for the same result.
## Layout
A layout toggle (1 / 2 / 4 buttons) sits next to the existing console
header. Grid via CSS:
- **1**: full width — identical to today.
- **2**: `grid-cols-2` — left/right.
- **4**: `grid-cols-2 grid-rows-2` — four corners.
Each pane has a small header with a lane-select dropdown. If the selected
lane has no active run, the pane shows a compact `RunSetup` (reused
component, same as today's pre-run form) so the user can start one directly
from the pane. If it has an active run, the pane shows `TerminalView` as
today.
## Persistence
The chosen layout mode and each pane's selected `laneId` are saved to
`localStorage` (e.g. key `ccam.workspace.splitView`) and restored on next
visit to Workspace. If a persisted lane no longer exists, that pane falls
back to unselected (dropdown placeholder).
## Non-goals
- No server/API changes — this is purely a client-side rendering feature.
Each lane's run already exists independently server-side; this just lets
the UI display more than one at once.
- No synchronized input across panes (typing in one pane's terminal does not
affect others) — each `TerminalView` keeps its own independent WebSocket
connection, unchanged from today's single-instance behavior.
## Testing
- `client/src/pages/__tests__/Workspace.test.tsx` currently mocks
`TerminalView` and exercises the single-console flow; update it (or add a
sibling test file) to cover: layout toggle, per-pane lane dropdown,
starting a run from within a pane, and multiple panes rendering
independent `TerminalView`/`RunSetup` instances.
- Run `npm run test:client` before considering this done.
+10 -11
View File
@@ -1,19 +1,19 @@
--- ---
description: Build the dashboard UI if needed and print its URL description: Rebuild the dashboard UI and print its URL
--- ---
Build the dashboard bundle if it is not there yet, then print the URL. The Rebuild the dashboard bundle, then print the URL. Always forces a rebuild so a
bootstrap already builds it on session start, so this is usually a no-op — use stale bundle (e.g. after a fix commit landed but the bootstrap's build predates
it to force a rebuild, or to finish the build if the bootstrap's own attempt it) never serves silently. Also finishes the build if the bootstrap's own
failed (check `~/.claude/agent-dashboard/runtime/client-build.log`). attempt failed (check `~/.claude/agent-dashboard/runtime/client-build.log`).
```bash ```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-open.js" node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-open.js" --force
``` ```
The first run installs the client toolchain and takes a few minutes; later runs Install + build takes a few minutes if the client toolchain isn't already
print the URL immediately. No server restart is needed — the server already installed; otherwise the rebuild itself takes ~10-15s. No server restart is
serves from that directory. needed — the server already serves from that directory.
Then help the user open it: Then help the user open it:
@@ -25,5 +25,4 @@ uname -s
- `Linux``xdg-open <url>` - `Linux``xdg-open <url>`
- otherwise → tell them to open the URL in a browser. - otherwise → tell them to open the URL in a browser.
Keep the output to a few lines. Pass `--force` to the script only if the user Keep the output to a few lines.
asks for a rebuild.
+61
View File
@@ -80,6 +80,29 @@ function makeRunChild({ exitsOnKill }) {
return child; return child;
} }
// Puts a fake `claude` binary on PATH so a real `/start` spawns a real tmux
// session running THIS script instead of the system Claude Code CLI. Tests
// that mock tmux's own exec calls (to simulate a stuck/live session) still
// spawn this real process underneath — without the stub, that spawn launches
// the actual `claude` binary and, because the mock replaces the app's own
// kill-session call, the real process is never actually terminated, leaking
// a live tmux session + CLI process for good. Returns the restore function.
function stubClaudeBinary(name) {
const bin = path.join(ROOT, `${name}-bin`);
const claude = path.join(bin, "claude");
fs.mkdirSync(bin, { recursive: true });
fs.writeFileSync(
claude,
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n"
);
fs.chmodSync(claude, 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
return () => {
process.env.PATH = originalPath;
};
}
async function waitForProvisioning(id) { async function waitForProvisioning(id) {
const deadline = Date.now() + 5000; const deadline = Date.now() + 5000;
let response; let response;
@@ -818,6 +841,7 @@ describe("destructive lane lifecycle actions", () => {
fs.writeFileSync(sentinel, "still here\n"); fs.writeFileSync(sentinel, "still here\n");
// Start a run for the lane // Start a run for the lane
const restorePath = stubClaudeBinary("await-timeout");
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "stuck" }); const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "stuck" });
assert.equal(started.status, 200); assert.equal(started.status, 200);
const runId = started.body.lane.run_id; const runId = started.body.lane.run_id;
@@ -848,6 +872,15 @@ describe("destructive lane lifecycle actions", () => {
assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n"); assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n");
} finally { } finally {
tmux.__reset(); tmux.__reset();
// The mocked kill-session above only fools the app's own check — the
// real tmux session + claude stub spawned above is still alive and
// must be killed for real, or it leaks past this test run.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
} }
await request("DELETE", `/api/lanes/${lane.id}`); await request("DELETE", `/api/lanes/${lane.id}`);
}); });
@@ -889,6 +922,7 @@ describe("destructive lane lifecycle actions", () => {
const lane = await createManagedLane("start-twice"); const lane = await createManagedLane("start-twice");
// Start a run for the lane // Start a run for the lane
const restorePath = stubClaudeBinary("start-twice");
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" }); const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" });
assert.equal(started.status, 200); assert.equal(started.status, 200);
const runId = started.body.lane.run_id; const runId = started.body.lane.run_id;
@@ -915,6 +949,15 @@ describe("destructive lane lifecycle actions", () => {
assert.equal(after.body.lane.run_id, runId); assert.equal(after.body.lane.run_id, runId);
} finally { } finally {
tmux.__reset(); tmux.__reset();
// The real tmux session behind the "first" run is never reset/killed
// in this test, mocked or otherwise — kill it for real so it doesn't
// leak past this test run.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
} }
await request("DELETE", `/api/lanes/${lane.id}`); await request("DELETE", `/api/lanes/${lane.id}`);
@@ -1134,6 +1177,7 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
const lane = await adoptedLane("release-moved-on"); const lane = await adoptedLane("release-moved-on");
// Create a run for this lane. // Create a run for this lane.
const restorePath = stubClaudeBinary("release-moved-on");
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" }); const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
assert.equal(started.status, 200, JSON.stringify(started.body)); assert.equal(started.status, 200, JSON.stringify(started.body));
const runId = started.body.lane.run_id; const runId = started.body.lane.run_id;
@@ -1161,6 +1205,14 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
assert.equal(after.status, "running"); assert.equal(after.status, "running");
} finally { } finally {
tmux.__reset(); tmux.__reset();
// The app never calls kill-session here (healing preserves the "live"
// run) — kill the real tmux session directly so it doesn't leak.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
} }
}); });
@@ -1169,6 +1221,7 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
const lane = await adoptedLane("release-stale-run"); const lane = await adoptedLane("release-stale-run");
// Start a run for this lane. // Start a run for this lane.
const restorePath = stubClaudeBinary("release-stale-run");
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" }); const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
assert.equal(started.status, 200, JSON.stringify(started.body)); assert.equal(started.status, 200, JSON.stringify(started.body));
const runId = started.body.lane.run_id; const runId = started.body.lane.run_id;
@@ -1195,6 +1248,14 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
assert.equal(after.status, "idle", "status should be idle after run is gone"); assert.equal(after.status, "idle", "status should be idle after run is gone");
} finally { } finally {
tmux.__reset(); tmux.__reset();
// The app believes the session is already gone and never calls
// kill-session — kill the real tmux session directly so it doesn't leak.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
} }
}); });
}); });
+7
View File
@@ -79,6 +79,13 @@ describe("syncMcp — reading and relocating", () => {
`${lane.cwd}/.playwright-mcp/profiles/default` `${lane.cwd}/.playwright-mcp/profiles/default`
); );
}); });
it("falls back to the lane's own cwd when source_repo is null (adopted lane)", async () => {
const lane = makeLane(null);
writeClaudeJson({ [lane.cwd]: { mcpServers: { playwright: { command: "npx", args: [] } } } });
const result = await laneMcp.syncMcp(lane);
assert.deepEqual(result.servers, ["playwright"]);
});
}); });
describe("syncMcp — Playwright output-dir pinning", () => { describe("syncMcp — Playwright output-dir pinning", () => {
+4 -3
View File
@@ -120,8 +120,9 @@ function excludeFromGit(laneDir, line) {
* @returns {{servers: string[], profilesSeeded: string[]}} * @returns {{servers: string[], profilesSeeded: string[]}}
*/ */
async function syncMcp(lane) { async function syncMcp(lane) {
const sourceServers = readSourceMcpServers(lane.source_repo); const sourceRepo = lane.source_repo || lane.cwd;
const relocated = relocate(sourceServers, lane.source_repo, lane.cwd); const sourceServers = readSourceMcpServers(sourceRepo);
const relocated = relocate(sourceServers, sourceRepo, lane.cwd);
pinPlaywrightOutputDir(relocated, lane.cwd); pinPlaywrightOutputDir(relocated, lane.cwd);
fs.writeFileSync( fs.writeFileSync(
@@ -130,7 +131,7 @@ async function syncMcp(lane) {
); );
excludeFromGit(lane.cwd, ".mcp.json"); excludeFromGit(lane.cwd, ".mcp.json");
const profilesSeeded = seedProfiles(lane.source_repo, lane.cwd); const profilesSeeded = seedProfiles(sourceRepo, lane.cwd);
return { servers: Object.keys(relocated), profilesSeeded }; return { servers: Object.keys(relocated), profilesSeeded };
} }