Compare commits
18 Commits
43f29ee904
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c29504c75 | |||
| 39572aa04c | |||
| 3ae0d00b0c | |||
| a2b5fa4669 | |||
| bab19e2f36 | |||
| 6f22aed47c | |||
| d542fbbf4b | |||
| 764dc6a7b5 | |||
| 06817b7901 | |||
| 14f116bf00 | |||
| 6dda604362 | |||
| 22ce61bcfe | |||
| 8a61a2b359 | |||
| b1d43bf098 | |||
| 11b779479d | |||
| 18a1ecb6f9 | |||
| fa416b5e6b | |||
| 0f15800b23 |
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
+253
-620
@@ -37,36 +37,130 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Play, AlertCircle, X, Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import type {
|
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||||
CwdSuggestion,
|
import type { Lane, LaneFeature, LaneCounts, ProofFeature, WSMessage } from "../lib/types";
|
||||||
DashboardRunHistoryItem,
|
|
||||||
EffortLevel,
|
|
||||||
PermissionMode,
|
|
||||||
RunHandle,
|
|
||||||
RunListResponse,
|
|
||||||
RunStartArgs,
|
|
||||||
} from "../lib/api";
|
|
||||||
import type { Session, Lane, LaneFeature, LaneCounts, ProofFeature, WSMessage } from "../lib/types";
|
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { TerminalView } from "../components/run/TerminalView";
|
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||||
import { RunSetup } from "../components/run/RunSetup";
|
|
||||||
import { ActiveRunsSwitcher } from "../components/run/RunHistory";
|
|
||||||
import PipelineMap from "../components/lanes/PipelineMap";
|
import PipelineMap from "../components/lanes/PipelineMap";
|
||||||
import LaneCard from "../components/lanes/LaneCard";
|
import LaneCard from "../components/lanes/LaneCard";
|
||||||
import LaneStripCard from "../components/lanes/LaneStripCard";
|
import LaneStripCard from "../components/lanes/LaneStripCard";
|
||||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||||
|
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
|
||||||
|
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function Workspace() {
|
function SplitLayoutToggle({
|
||||||
const { t } = useTranslation("run");
|
splitView,
|
||||||
|
setLayout,
|
||||||
|
}: {
|
||||||
|
splitView: SplitViewState;
|
||||||
|
setLayout: (layout: SplitLayout) => void;
|
||||||
|
}) {
|
||||||
|
const { t: tLanes } = useTranslation("lanes");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConsoleArea({
|
||||||
|
lanes,
|
||||||
|
selectedLaneId,
|
||||||
|
splitView,
|
||||||
|
setLayout,
|
||||||
|
setPaneLaneId,
|
||||||
|
binaryStatus,
|
||||||
|
cwdSuggestions,
|
||||||
|
activeRuns,
|
||||||
|
wsConnected,
|
||||||
|
defaultCwd,
|
||||||
|
onHasActiveRunChange,
|
||||||
|
onLaneCreated,
|
||||||
|
onLaneIdChange,
|
||||||
|
showToggle = true,
|
||||||
|
}: {
|
||||||
|
lanes: Lane[];
|
||||||
|
selectedLaneId: number | null;
|
||||||
|
splitView: SplitViewState;
|
||||||
|
setLayout: (layout: SplitLayout) => void;
|
||||||
|
setPaneLaneId: (index: number, id: number) => void;
|
||||||
|
binaryStatus: { found: boolean; path: string | null } | null;
|
||||||
|
cwdSuggestions: CwdSuggestion[];
|
||||||
|
activeRuns: RunListResponse | null;
|
||||||
|
wsConnected: boolean;
|
||||||
|
defaultCwd: string;
|
||||||
|
onHasActiveRunChange: (val: boolean) => void;
|
||||||
|
onLaneCreated: (lane: Lane) => void;
|
||||||
|
onLaneIdChange: (id: number | null) => void;
|
||||||
|
showToggle?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showToggle && <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />}
|
||||||
|
{splitView.layout === 1 ? (
|
||||||
|
<LaneConsolePane
|
||||||
|
lanes={lanes}
|
||||||
|
laneId={selectedLaneId}
|
||||||
|
showLaneSelector={false}
|
||||||
|
onLaneIdChange={onLaneIdChange}
|
||||||
|
onLaneCreated={onLaneCreated}
|
||||||
|
binaryStatus={binaryStatus}
|
||||||
|
cwdSuggestions={cwdSuggestions}
|
||||||
|
activeRuns={activeRuns}
|
||||||
|
wsConnected={wsConnected}
|
||||||
|
defaultCwd={defaultCwd}
|
||||||
|
onHasActiveRunChange={onHasActiveRunChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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={onLaneCreated}
|
||||||
|
binaryStatus={binaryStatus}
|
||||||
|
cwdSuggestions={cwdSuggestions}
|
||||||
|
activeRuns={activeRuns}
|
||||||
|
wsConnected={wsConnected}
|
||||||
|
defaultCwd={defaultCwd}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Workspace() {
|
||||||
const { t: tLanes } = useTranslation("lanes");
|
const { t: tLanes } = useTranslation("lanes");
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
||||||
|
|
||||||
// Lane state
|
// Lane state
|
||||||
@@ -83,22 +177,42 @@ export function Workspace() {
|
|||||||
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
||||||
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
||||||
|
|
||||||
// Run state
|
// Run state kept at page level: shared across every pane, or drives the
|
||||||
const [prompt, setPrompt] = useState("");
|
// lane strip itself rather than any one pane's form.
|
||||||
const [model, setModel] = useState("");
|
|
||||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
|
|
||||||
const [effort, setEffort] = useState<EffortLevel>("");
|
|
||||||
const [cwd, setCwd] = useState("");
|
|
||||||
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 [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
|
const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
|
||||||
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
|
|
||||||
const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>(
|
const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||||
|
const [defaultCwd, setDefaultCwd] = useState<string>("");
|
||||||
|
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
|
||||||
|
|
||||||
|
// Split view state
|
||||||
|
const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState());
|
||||||
|
// Info block (pickers, LaneCard, PipelineMap, proof gallery) is always shown
|
||||||
|
// in single-pane view; in split view it starts collapsed so tall content
|
||||||
|
// never crowds the pane grid, and the user expands it on demand.
|
||||||
|
const [infoExpanded, setInfoExpanded] = useState(false);
|
||||||
|
const showInfo = splitView.layout === 1 || infoExpanded;
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
|
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
|
||||||
const refreshLanes = useCallback(async () => {
|
const refreshLanes = useCallback(async () => {
|
||||||
@@ -126,10 +240,6 @@ export function Workspace() {
|
|||||||
.list()
|
.list()
|
||||||
.then(setActiveRuns)
|
.then(setActiveRuns)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
api.run
|
|
||||||
.history(50)
|
|
||||||
.then((r) => setRunHistory(r.items))
|
|
||||||
.catch(() => undefined);
|
|
||||||
api.lanes
|
api.lanes
|
||||||
.pipelines()
|
.pipelines()
|
||||||
.then((r) => setPipelineTemplates(r.pipelines))
|
.then((r) => setPipelineTemplates(r.pipelines))
|
||||||
@@ -150,7 +260,7 @@ export function Workspace() {
|
|||||||
const dashboard = r.items.find((s) => s.kind === "dashboard");
|
const dashboard = r.items.find((s) => s.kind === "dashboard");
|
||||||
const preferred = home || dashboard;
|
const preferred = home || dashboard;
|
||||||
if (preferred) {
|
if (preferred) {
|
||||||
setCwd((current) => current || preferred.path);
|
setDefaultCwd(preferred.path);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
@@ -191,24 +301,26 @@ export function Workspace() {
|
|||||||
[refreshLanes]
|
[refreshLanes]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clean up stale pane lane IDs when lanes load
|
||||||
|
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]);
|
||||||
|
|
||||||
const refreshList = useCallback(() => {
|
const refreshList = useCallback(() => {
|
||||||
api.run
|
api.run
|
||||||
.list()
|
.list()
|
||||||
.then(setActiveRuns)
|
.then(setActiveRuns)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
// Fetch history for the selected lane only
|
}, []);
|
||||||
if (selectedLaneId !== null) {
|
|
||||||
api.run
|
|
||||||
.history(50, { laneId: selectedLaneId })
|
|
||||||
.then((r) => setRunHistory(r.items))
|
|
||||||
.catch(() => undefined);
|
|
||||||
} else {
|
|
||||||
api.run
|
|
||||||
.history(50)
|
|
||||||
.then((r) => setRunHistory(r.items))
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
|
||||||
}, [selectedLaneId]);
|
|
||||||
|
|
||||||
// Background poll so the run list and history reflect external changes
|
// Background poll so the run list and history reflect external changes
|
||||||
// (server-boot reconciliation, sibling tabs, direct DB edits) even when
|
// (server-boot reconciliation, sibling tabs, direct DB edits) even when
|
||||||
@@ -237,406 +349,6 @@ export function Workspace() {
|
|||||||
};
|
};
|
||||||
}, [refreshList]);
|
}, [refreshList]);
|
||||||
|
|
||||||
// Resume a run from the persistent history list. Routes through the lane
|
|
||||||
// system: ensure a lane for the cwd, then start a new claude process
|
|
||||||
// in resumeSessionId mode. If cwd is null, resume as a non-lane run
|
|
||||||
// (non-lane runs are those created before this feature or via the CLI).
|
|
||||||
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) {
|
|
||||||
// Resume through a lane: ensure the lane exists, then start on it
|
|
||||||
const effectiveCwd = item.cwd;
|
|
||||||
let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd)?.id;
|
|
||||||
|
|
||||||
if (!targetLaneId) {
|
|
||||||
// Ensure lane for this cwd
|
|
||||||
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
|
|
||||||
targetLaneId = ensureResult.lane.id;
|
|
||||||
setLanes((prev) => {
|
|
||||||
const exists = prev.some((l) => l.id === ensureResult.lane.id);
|
|
||||||
return exists ? prev : [...prev, ensureResult.lane];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start on the lane with resume
|
|
||||||
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);
|
|
||||||
} else {
|
|
||||||
// No cwd: resume as a non-lane run (backward compatibility).
|
|
||||||
// These runs stay outside the lane system and are cleaned up
|
|
||||||
// by their own expiry, not by lane release.
|
|
||||||
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
// View a past run: navigate to the SessionDetail page which shows the transcript.
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const onViewFromHistory = useCallback(
|
|
||||||
(item: DashboardRunHistoryItem) => {
|
|
||||||
if (item.session_id) {
|
|
||||||
navigate(`/sessions/${encodeURIComponent(item.session_id)}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[navigate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
|
||||||
if (!prompt.trim() || busy) return;
|
|
||||||
setBusy("start");
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const effectiveCwd = resumeSession?.cwd || cwd || undefined;
|
|
||||||
|
|
||||||
// Determine which lane to use. If no lane is selected or the cwd
|
|
||||||
// doesn't belong to the selected lane, ensure a lane for this cwd first.
|
|
||||||
if (!effectiveCwd) {
|
|
||||||
throw new Error(t("errors.cwdRequired"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetLaneId = selectedLaneId;
|
|
||||||
if (
|
|
||||||
targetLaneId === null ||
|
|
||||||
(selectedLaneId !== null &&
|
|
||||||
lanes.find((l) => l.id === selectedLaneId)?.cwd !== effectiveCwd)
|
|
||||||
) {
|
|
||||||
// Check if any existing lane owns this cwd
|
|
||||||
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd);
|
|
||||||
if (ownedLane) {
|
|
||||||
targetLaneId = ownedLane.id;
|
|
||||||
setSelectedLaneId(ownedLane.id);
|
|
||||||
} else {
|
|
||||||
// Ensure a new lane for this cwd
|
|
||||||
try {
|
|
||||||
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
|
|
||||||
targetLaneId = ensureResult.lane.id;
|
|
||||||
setSelectedLaneId(ensureResult.lane.id);
|
|
||||||
setLanes((prev) => {
|
|
||||||
const exists = prev.some((l) => l.id === ensureResult.lane.id);
|
|
||||||
return exists ? prev : [...prev, ensureResult.lane];
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(
|
|
||||||
t("errors.laneCreateFailed", {
|
|
||||||
message: err instanceof Error ? err.message : "unknown",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetLaneId === null) {
|
|
||||||
throw new Error(t("errors.noLaneSelected"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start the run on the target lane
|
|
||||||
let laneStartResult;
|
|
||||||
try {
|
|
||||||
laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
|
||||||
prompt: prompt || "",
|
|
||||||
model: model || undefined,
|
|
||||||
permissionMode,
|
|
||||||
resumeSessionId: resumeSession?.id,
|
|
||||||
effort: effort || undefined,
|
|
||||||
});
|
|
||||||
} catch (laneErr: unknown) {
|
|
||||||
// Check for 409 ERUNLIVE — the lane already has a live run
|
|
||||||
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
|
|
||||||
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
|
|
||||||
// Re-fetch the lane to get its current run_id. Read the run id off
|
|
||||||
// the response, not off `lanes` - refreshLanes() only schedules a
|
|
||||||
// setState, so the render-scope `lanes` array here is still the
|
|
||||||
// pre-409 snapshot and would never carry the live run.
|
|
||||||
const fresh = await api.lanes.list().catch(() => null);
|
|
||||||
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
|
|
||||||
await refreshLanes();
|
|
||||||
if (updatedLane?.run_id) {
|
|
||||||
// Attach to the already-running run
|
|
||||||
await attachToRun(updatedLane.run_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw laneErr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The response is { lane? } per the API. Read the run_id from the lane.
|
|
||||||
if (!laneStartResult.lane?.run_id) {
|
|
||||||
throw new Error(t("errors.noRunIdReturned"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the full RunHandle for the new run; fall back to attachToRun if fetch fails
|
|
||||||
try {
|
|
||||||
const handle = await api.run.get(laneStartResult.lane.run_id);
|
|
||||||
setHandle(handle);
|
|
||||||
refreshList();
|
|
||||||
} catch (attachErr: unknown) {
|
|
||||||
// Run started but we can't fetch the handle. Attach to the run via the existing path.
|
|
||||||
try {
|
|
||||||
await attachToRun(laneStartResult.lane.run_id);
|
|
||||||
refreshList();
|
|
||||||
} catch (fallbackErr: unknown) {
|
|
||||||
// Even attach failed. Refresh lanes and report the attach error.
|
|
||||||
await refreshLanes();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
prompt,
|
|
||||||
cwd,
|
|
||||||
model,
|
|
||||||
permissionMode,
|
|
||||||
busy,
|
|
||||||
refreshList,
|
|
||||||
t,
|
|
||||||
resumeSession,
|
|
||||||
selectedLaneId,
|
|
||||||
lanes,
|
|
||||||
]);
|
|
||||||
|
|
||||||
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Honor `?session=<id>` deep-links from /sessions and /sessions/:id -
|
|
||||||
// map the session id to a live run handle and attach to it instead of
|
|
||||||
// dropping the user on the new-run config card. Strip the param once
|
|
||||||
// consumed so a refresh of the Run page doesn't keep re-attaching.
|
|
||||||
const attachAttemptedRef = useRef<Set<string>>(new Set());
|
|
||||||
useEffect(() => {
|
|
||||||
const sid = searchParams.get("session");
|
|
||||||
if (!sid) return;
|
|
||||||
if (handle && handle.sessionId === sid) {
|
|
||||||
// Already attached to this session - just clean the URL.
|
|
||||||
const next = new URLSearchParams(searchParams);
|
|
||||||
next.delete("session");
|
|
||||||
setSearchParams(next, { replace: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (attachAttemptedRef.current.has(sid)) return;
|
|
||||||
attachAttemptedRef.current.add(sid);
|
|
||||||
api.run
|
|
||||||
.list()
|
|
||||||
.then((list) => {
|
|
||||||
const target = list.items.find((h) => h.sessionId === sid && h.status === "running");
|
|
||||||
if (target) {
|
|
||||||
void attachToRun(target.id);
|
|
||||||
} else {
|
|
||||||
setError(
|
|
||||||
t(
|
|
||||||
"errors.sessionRunNotFound",
|
|
||||||
"No active dashboard run is driving this session right now."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => undefined)
|
|
||||||
.finally(() => {
|
|
||||||
const next = new URLSearchParams(searchParams);
|
|
||||||
next.delete("session");
|
|
||||||
setSearchParams(next, { replace: true });
|
|
||||||
});
|
|
||||||
}, [searchParams, setSearchParams, handle, attachToRun, t]);
|
|
||||||
|
|
||||||
// Prefill the prompt box from `?prompt=<text>` (e.g. Tabby's Ask handoff).
|
|
||||||
// Apply once, then strip the param so a later refresh doesn't overwrite edits
|
|
||||||
// the user has since made to the prompt. When `?autostart=1` is also present
|
|
||||||
// (Tabby's "ask" path), arm a pending flag so the run fires automatically
|
|
||||||
// once preflight is ready - see the autostart effect below.
|
|
||||||
const promptPrefilledRef = useRef(false);
|
|
||||||
const pendingAutostartRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (promptPrefilledRef.current) return;
|
|
||||||
const p = searchParams.get("prompt");
|
|
||||||
if (!p) return;
|
|
||||||
promptPrefilledRef.current = true;
|
|
||||||
if (searchParams.get("autostart") === "1") pendingAutostartRef.current = true;
|
|
||||||
setPrompt(p);
|
|
||||||
const next = new URLSearchParams(searchParams);
|
|
||||||
next.delete("prompt");
|
|
||||||
next.delete("autostart");
|
|
||||||
setSearchParams(next, { replace: true });
|
|
||||||
}, [searchParams, setSearchParams]);
|
|
||||||
|
|
||||||
// Autostart a deep-linked prompt once preflight has settled. We wait for the
|
|
||||||
// binary probe (can't spawn without `claude`), the prefilled prompt, and the
|
|
||||||
// defaulted cwd so the spawn matches exactly what the manual Start button
|
|
||||||
// would do. Fires at most once; if `claude` isn't found or a run is already
|
|
||||||
// in flight, it disarms and leaves the prompt prefilled for a manual Start.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingAutostartRef.current) return;
|
|
||||||
if (binaryStatus === null) return; // probe still pending
|
|
||||||
if (!binaryStatus.found) {
|
|
||||||
pendingAutostartRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (busy || handle) {
|
|
||||||
pendingAutostartRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!prompt.trim() || !cwd) return; // wait for prefill + cwd default
|
|
||||||
pendingAutostartRef.current = false;
|
|
||||||
void start();
|
|
||||||
}, [binaryStatus, prompt, cwd, busy, handle, start]);
|
|
||||||
|
|
||||||
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 the currently-selected lane's id
|
|
||||||
// (a required prop), which would otherwise silently start a run in the wrong
|
|
||||||
// lane whenever the user types a cwd different from the one currently selected.
|
|
||||||
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd);
|
|
||||||
let targetLaneId: number;
|
|
||||||
if (ownedLane) {
|
|
||||||
targetLaneId = ownedLane.id;
|
|
||||||
if (ownedLane.id !== args.laneId) setSelectedLaneId(ownedLane.id);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
|
|
||||||
targetLaneId = ensureResult.lane.id;
|
|
||||||
setSelectedLaneId(ensureResult.lane.id);
|
|
||||||
setLanes((prev) => {
|
|
||||||
const exists = prev.some((l) => l.id === ensureResult.lane.id);
|
|
||||||
return exists ? prev : [...prev, ensureResult.lane];
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(
|
|
||||||
t("errors.laneCreateFailed", {
|
|
||||||
message: err instanceof Error ? err.message : "unknown",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetLaneId) {
|
|
||||||
throw new Error(t("errors.noLaneSelected"));
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
await refreshLanes();
|
|
||||||
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 handle = await api.run.get(laneStartResult.lane.run_id);
|
|
||||||
setHandle(handle);
|
|
||||||
refreshList();
|
|
||||||
} catch (attachErr: unknown) {
|
|
||||||
try {
|
|
||||||
await attachToRun(laneStartResult.lane.run_id);
|
|
||||||
refreshList();
|
|
||||||
} catch (fallbackErr: unknown) {
|
|
||||||
await refreshLanes();
|
|
||||||
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, selectedLaneId, refreshLanes, attachToRun, refreshList, prompt]
|
|
||||||
);
|
|
||||||
|
|
||||||
const newRun = useCallback(() => {
|
|
||||||
setHandle(null);
|
|
||||||
setPrompt("");
|
|
||||||
setResumeSession(null);
|
|
||||||
setError(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
|
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
|
||||||
|
|
||||||
// Feature list follows the selected lane, resets the viewer on lane switch.
|
// Feature list follows the selected lane, resets the viewer on lane switch.
|
||||||
@@ -696,15 +408,10 @@ export function Workspace() {
|
|||||||
? proofFeatures.find((f) => f.slug === activeFeatureSlug)
|
? proofFeatures.find((f) => f.slug === activeFeatureSlug)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Only lock the page to the viewport when we're showing a live run session.
|
// Viewport locked only when a live run is showing (TerminalView needs locked
|
||||||
// The config-card screen needs normal page flow so the form is fully
|
// viewport for chat scrolling). The config-card screen needs normal page flow
|
||||||
// reachable on short windows. The run-session screen, however, owns the
|
// so the form is fully reachable on short windows.
|
||||||
// chat panel and we want long chats to scroll inside the panel - never the
|
const viewportLocked = paneHasActiveRun;
|
||||||
// page - so we constrain only that case.
|
|
||||||
// Only a live console needs the viewport-locked shell that lets its chat
|
|
||||||
// panel scroll internally. Collapsed, the page is an ordinary scrolling
|
|
||||||
// document and the lane grid gets the whole height.
|
|
||||||
const viewportLocked = !!handle;
|
|
||||||
|
|
||||||
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
||||||
setLaneActionError(null);
|
setLaneActionError(null);
|
||||||
@@ -732,7 +439,10 @@ export function Workspace() {
|
|||||||
try {
|
try {
|
||||||
const { lane } = await api.lanes.update(id, { pipeline });
|
const { lane } = await api.lanes.update(id, { pipeline });
|
||||||
await refreshLanes();
|
await refreshLanes();
|
||||||
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
|
// `stage` defaults to the DB sentinel "idle" until a driving session ever
|
||||||
|
// calls `ccam stage` — that's the normal state for most lanes and never
|
||||||
|
// matches a real pipeline node, so it isn't a mismatch worth surfacing.
|
||||||
|
if (lane.stage !== "idle" && !lane.pipeline_nodes.some((n) => n.state === "current")) {
|
||||||
setLaneActionError(
|
setLaneActionError(
|
||||||
tLanes("pipelinePicker.stageMismatch", {
|
tLanes("pipelinePicker.stageMismatch", {
|
||||||
stage: lane.stage,
|
stage: lane.stage,
|
||||||
@@ -746,89 +456,6 @@ export function Workspace() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const consoleSection = (
|
|
||||||
<>
|
|
||||||
{/* Always attached under the pipeline - no header, no collapse. The
|
|
||||||
pipeline panel above already names the lane; unmounting RunConsole
|
|
||||||
would throw away a live run's rendered history and scroll
|
|
||||||
position, so this stays mounted for the page's whole life. */}
|
|
||||||
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
|
|
||||||
<Header
|
|
||||||
activeRuns={activeRuns}
|
|
||||||
currentHandleId={handle?.id || null}
|
|
||||||
onAttach={attachToRun}
|
|
||||||
wsConnected={wsConnected}
|
|
||||||
runHistory={runHistory}
|
|
||||||
onResumeFromHistory={onResumeFromHistory}
|
|
||||||
onViewFromHistory={onViewFromHistory}
|
|
||||||
onRefresh={refreshList}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
<button
|
|
||||||
onClick={() => setError(null)}
|
|
||||||
className="text-status-danger/70 hover:text-status-danger p-0.5"
|
|
||||||
>
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!handle ? (
|
|
||||||
// Config card uses normal page flow - page scrolls if needed.
|
|
||||||
<RunSetup
|
|
||||||
laneId={currentLane?.id || 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}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
// Run session is wrapped in a flex container so the terminal panel
|
|
||||||
// can take all remaining viewport height.
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
@@ -883,21 +510,21 @@ export function Workspace() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lane carousel: pick a lane here, read it below. Horizontal scroll with
|
{/* Lane list + detail live side by side so neither can cover the other:
|
||||||
snap so a dozen lanes stay in one row instead of a wall of cards. */}
|
the list is a scrolling column (holds any number of lanes), the
|
||||||
|
detail/console area to its right never has to compete with it for
|
||||||
|
vertical space. */}
|
||||||
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
<div
|
<div
|
||||||
data-testid="lane-strip"
|
data-testid="lane-strip"
|
||||||
className="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1"
|
className="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
|
||||||
>
|
>
|
||||||
{lanes.map((l) => (
|
{lanes.map((l) => (
|
||||||
<LaneStripCard
|
<LaneStripCard
|
||||||
key={l.id}
|
key={l.id}
|
||||||
lane={l}
|
lane={l}
|
||||||
selected={selectedLaneId === l.id}
|
selected={selectedLaneId === l.id}
|
||||||
onSelect={() => {
|
onSelect={() => setSelectedLaneId(l.id)}
|
||||||
setSelectedLaneId(l.id);
|
|
||||||
setCwd(l.cwd);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{!lanes.length && (
|
{!lanes.length && (
|
||||||
@@ -907,17 +534,42 @@ export function Workspace() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The selected lane's pipeline, full width — the thing you actually
|
{/* The selected lane's pipeline — the thing you actually come to this
|
||||||
come to this page to read. */}
|
page to read, beside the list rather than stacked under it. */}
|
||||||
{currentLane && (
|
{currentLane && (
|
||||||
<section data-testid="lane-detail" className="card p-4">
|
<section
|
||||||
|
data-testid="lane-detail"
|
||||||
|
className="flex min-h-0 min-w-0 flex-1 flex-col card p-4"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="truncate text-sm font-semibold text-fg-primary">
|
||||||
|
{tLanes("laneHeader", {
|
||||||
|
id: currentLane.id,
|
||||||
|
title: currentLane.title || currentLane.cwd,
|
||||||
|
pipeline: currentLane.pipeline_name,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{splitView.layout !== 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-detail-toggle"
|
||||||
|
aria-expanded={infoExpanded}
|
||||||
|
onClick={() => setInfoExpanded((v) => !v)}
|
||||||
|
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showInfo && (
|
||||||
|
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||||
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
|
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
|
||||||
{tLanes("cardId", { id: currentLane.id })}
|
{tLanes("cardId", { id: currentLane.id })}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-sm font-semibold text-fg-primary">
|
|
||||||
{currentLane.title || currentLane.cwd}
|
|
||||||
</span>
|
|
||||||
<select
|
<select
|
||||||
data-testid="pipeline-picker"
|
data-testid="pipeline-picker"
|
||||||
aria-label={tLanes("pipelinePicker.label")}
|
aria-label={tLanes("pipelinePicker.label")}
|
||||||
@@ -990,7 +642,9 @@ export function Workspace() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<PipelineMap
|
<PipelineMap
|
||||||
nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
|
nodes={
|
||||||
|
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
|
||||||
|
}
|
||||||
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
||||||
/>
|
/>
|
||||||
{viewedFeature && (
|
{viewedFeature && (
|
||||||
@@ -1040,12 +694,54 @@ export function Workspace() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
|
</div>
|
||||||
{consoleSection}
|
)}
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2 border-t border-border pt-3">
|
||||||
|
<ConsoleArea
|
||||||
|
lanes={lanes}
|
||||||
|
selectedLaneId={selectedLaneId}
|
||||||
|
splitView={splitView}
|
||||||
|
setLayout={setLayout}
|
||||||
|
setPaneLaneId={setPaneLaneId}
|
||||||
|
binaryStatus={binaryStatus}
|
||||||
|
cwdSuggestions={cwdSuggestions}
|
||||||
|
activeRuns={activeRuns}
|
||||||
|
wsConnected={wsConnected}
|
||||||
|
defaultCwd={defaultCwd}
|
||||||
|
onHasActiveRunChange={setPaneHasActiveRun}
|
||||||
|
onLaneCreated={(lane) =>
|
||||||
|
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
||||||
|
}
|
||||||
|
onLaneIdChange={setSelectedLaneId}
|
||||||
|
showToggle={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!currentLane && (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||||
|
<ConsoleArea
|
||||||
|
lanes={lanes}
|
||||||
|
selectedLaneId={selectedLaneId}
|
||||||
|
splitView={splitView}
|
||||||
|
setLayout={setLayout}
|
||||||
|
setPaneLaneId={setPaneLaneId}
|
||||||
|
binaryStatus={binaryStatus}
|
||||||
|
cwdSuggestions={cwdSuggestions}
|
||||||
|
activeRuns={activeRuns}
|
||||||
|
wsConnected={wsConnected}
|
||||||
|
defaultCwd={defaultCwd}
|
||||||
|
onHasActiveRunChange={setPaneHasActiveRun}
|
||||||
|
onLaneCreated={(lane) =>
|
||||||
|
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
||||||
|
}
|
||||||
|
onLaneIdChange={setSelectedLaneId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<AddLaneModal
|
<AddLaneModal
|
||||||
open={addLaneOpen}
|
open={addLaneOpen}
|
||||||
cwdSuggestions={cwdSuggestions}
|
cwdSuggestions={cwdSuggestions}
|
||||||
@@ -1065,69 +761,6 @@ export function Workspace() {
|
|||||||
{tLanes("actionError", { message: laneActionError })}
|
{tLanes("actionError", { message: laneActionError })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No lane selected (none exist, or nothing picked yet): the console has
|
|
||||||
nowhere to attach, so it falls back to page level. Without this the
|
|
||||||
start form would be unreachable on a fresh install. */}
|
|
||||||
{!currentLane && <div className="flex min-h-0 flex-col gap-2">{consoleSection}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Header ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Header({
|
|
||||||
activeRuns,
|
|
||||||
currentHandleId,
|
|
||||||
onAttach,
|
|
||||||
wsConnected,
|
|
||||||
runHistory,
|
|
||||||
onResumeFromHistory,
|
|
||||||
onViewFromHistory,
|
|
||||||
onRefresh,
|
|
||||||
}: {
|
|
||||||
activeRuns: RunListResponse | null;
|
|
||||||
currentHandleId: string | null;
|
|
||||||
onAttach: (id: string) => void;
|
|
||||||
wsConnected: boolean;
|
|
||||||
runHistory: DashboardRunHistoryItem[];
|
|
||||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
|
||||||
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
|
|
||||||
onRefresh: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation("run");
|
|
||||||
const { t: tCommon } = useTranslation("common");
|
|
||||||
return (
|
|
||||||
<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={currentHandleId}
|
|
||||||
onAttach={onAttach}
|
|
||||||
runHistory={runHistory}
|
|
||||||
onResumeFromHistory={onResumeFromHistory}
|
|
||||||
onViewFromHistory={onViewFromHistory}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,7 +5762,10 @@ 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"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
|
||||||
data-testid="lane-strip"
|
data-testid="lane-strip"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
@@ -5776,8 +5779,33 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex min-h-0 flex-col gap-2"
|
class="flex min-h-0 flex-1 flex-col gap-2"
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
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
|
<div
|
||||||
class="flex min-h-0 flex-1 flex-col gap-5"
|
class="flex min-h-0 flex-1 flex-col gap-5"
|
||||||
data-testid="console-body"
|
data-testid="console-body"
|
||||||
@@ -6111,6 +6139,7 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user