Compare commits
19 Commits
43f29ee904
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 174c650624 | |||
| 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
|
||||
* what you need to pick a lane — which lane, is it alive, what stage, how far —
|
||||
* because the full card, its controls and its working-copy facts live in the
|
||||
* detail panel below. Keeping the tile small is what lets a dozen lanes stay
|
||||
* scannable in one horizontal row.
|
||||
* @file The compact lane tile used in the Workspace's vertical lane list. It
|
||||
* carries only what you need to pick a lane — which lane, is it alive, what
|
||||
* stage, how far — because the full card, its controls and its working-copy
|
||||
* facts live in the detail panel beside it. Keeping the tile small and full
|
||||
* width is what lets many lanes stay scannable in one scrolling column.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function LaneStripCard({
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
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
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* @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`,
|
||||
* `activeRuns`, and `externalSessions` 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;
|
||||
/** Active Claude Code sessions started outside the dashboard, listed in the
|
||||
* active-runs switcher alongside dashboard runs. */
|
||||
externalSessions?: Session[];
|
||||
wsConnected: boolean;
|
||||
defaultCwd?: string;
|
||||
onHasActiveRunChange?: (active: boolean) => void;
|
||||
}
|
||||
|
||||
export function LaneConsolePane({
|
||||
lanes,
|
||||
laneId,
|
||||
showLaneSelector,
|
||||
onLaneIdChange,
|
||||
onLaneCreated,
|
||||
binaryStatus,
|
||||
cwdSuggestions,
|
||||
activeRuns,
|
||||
externalSessions,
|
||||
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}
|
||||
externalSessions={externalSessions}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,16 @@
|
||||
* status / mode chip filters, a free-text search, and the per-row Attach /
|
||||
* Resume / View actions.
|
||||
*
|
||||
* Props only: no API call of its own. The page passes `activeRuns` and
|
||||
* `runHistory` in and gets attach / resume / view / refresh back out through
|
||||
* callbacks; the 2 s refresh ticker the modal runs just calls `onRefresh`.
|
||||
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
|
||||
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
|
||||
* "Active runs" counts everything actually running. They carry no tmux session
|
||||
* the dashboard can attach to, so their only action is Resume, which spawns a
|
||||
* fresh tmux-backed `claude --resume <session>` in that cwd.
|
||||
*
|
||||
* Props only: no API call of its own. The page passes `activeRuns`,
|
||||
* `runHistory` and `externalSessions` in and gets attach / resume / view /
|
||||
* refresh back out through callbacks; the 2 s refresh ticker the modal runs
|
||||
* just calls `onRefresh`.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -34,6 +41,7 @@ import {
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
|
||||
import type { Session } from "../../lib/types";
|
||||
|
||||
// Minimal StatusPill component (from deleted RunConsole)
|
||||
function StatusPill({
|
||||
@@ -74,6 +82,10 @@ export interface UnifiedRunRow {
|
||||
startedAt: number;
|
||||
endedAt: number | null;
|
||||
isLive: boolean;
|
||||
/** Live Claude Code session this dashboard did not spawn — no tmux session to
|
||||
* attach to, so Resume (a fresh `claude --resume` in its cwd) is the only
|
||||
* action. */
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export function ActiveRunsSwitcher({
|
||||
@@ -81,6 +93,7 @@ export function ActiveRunsSwitcher({
|
||||
currentHandleId,
|
||||
onAttach,
|
||||
runHistory,
|
||||
externalSessions = [],
|
||||
onResumeFromHistory,
|
||||
onViewFromHistory,
|
||||
onRefresh,
|
||||
@@ -89,6 +102,9 @@ export function ActiveRunsSwitcher({
|
||||
currentHandleId: string | null;
|
||||
onAttach: (id: string) => void;
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
/** Sessions with `status: "active"` from GET /api/sessions. Remote-source and
|
||||
* cwd-less sessions are ignored — neither can be resumed on this machine. */
|
||||
externalSessions?: Session[];
|
||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
onRefresh: () => void;
|
||||
@@ -111,14 +127,18 @@ export function ActiveRunsSwitcher({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Merge live in-memory handles + persistent history into one row list.
|
||||
// Live entries dedupe past-history entries with the same id.
|
||||
const rows: UnifiedRunRow[] = useMemo(() => {
|
||||
// Merge live in-memory handles + persistent history + externally started
|
||||
// sessions into one row list. Live entries dedupe past-history entries with
|
||||
// the same id; a session id already covered by a run row is never repeated as
|
||||
// an external row.
|
||||
const { rows, historyItems } = useMemo(() => {
|
||||
const out: UnifiedRunRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
const seenSessions = new Set<string>();
|
||||
if (activeRuns) {
|
||||
for (const r of activeRuns.items) {
|
||||
seen.add(r.id);
|
||||
if (r.sessionId) seenSessions.add(r.sessionId);
|
||||
out.push({
|
||||
id: r.id,
|
||||
sessionId: r.sessionId,
|
||||
@@ -135,6 +155,7 @@ export function ActiveRunsSwitcher({
|
||||
for (const h of runHistory) {
|
||||
if (seen.has(h.id)) continue;
|
||||
seen.add(h.id);
|
||||
if (h.session_id) seenSessions.add(h.session_id);
|
||||
const startedTs = new Date(h.started_at).getTime() || 0;
|
||||
const endedTs = h.ended_at ? new Date(h.ended_at).getTime() : null;
|
||||
out.push({
|
||||
@@ -149,9 +170,49 @@ export function ActiveRunsSwitcher({
|
||||
isLive: h.isLive,
|
||||
});
|
||||
}
|
||||
// Externally started sessions: shown as live rows, and mirrored as
|
||||
// synthetic history items so the existing resume path (which only reads
|
||||
// session_id / cwd / model) works on them unchanged.
|
||||
const synthetic: DashboardRunHistoryItem[] = [];
|
||||
for (const s of externalSessions) {
|
||||
if (!s.cwd) continue;
|
||||
if (s.source && s.source !== "local") continue;
|
||||
if (seenSessions.has(s.id)) continue;
|
||||
seenSessions.add(s.id);
|
||||
synthetic.push({
|
||||
id: `session:${s.id}`,
|
||||
session_id: s.id,
|
||||
cwd: s.cwd,
|
||||
model: s.model,
|
||||
permission_mode: null,
|
||||
effort: null,
|
||||
resume_session_id: null,
|
||||
prompt_preview: s.name,
|
||||
status: "running",
|
||||
exit_code: null,
|
||||
started_at: s.started_at,
|
||||
ended_at: null,
|
||||
isLive: true,
|
||||
});
|
||||
out.push({
|
||||
id: `session:${s.id}`,
|
||||
sessionId: s.id,
|
||||
cwd: s.cwd,
|
||||
model: s.model,
|
||||
status: "running",
|
||||
promptPreview: s.name || "",
|
||||
startedAt: new Date(s.started_at).getTime() || 0,
|
||||
endedAt: null,
|
||||
isLive: true,
|
||||
external: true,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => b.startedAt - a.startedAt);
|
||||
return out;
|
||||
}, [activeRuns, runHistory]);
|
||||
return {
|
||||
rows: out,
|
||||
historyItems: synthetic.length ? [...runHistory, ...synthetic] : runHistory,
|
||||
};
|
||||
}, [activeRuns, runHistory, externalSessions]);
|
||||
|
||||
const liveCount = rows.filter((r) => r.isLive).length;
|
||||
const totalCount = rows.length;
|
||||
@@ -196,7 +257,7 @@ export function ActiveRunsSwitcher({
|
||||
setOpen(false);
|
||||
onViewFromHistory(item);
|
||||
}}
|
||||
runHistory={runHistory}
|
||||
runHistory={historyItems}
|
||||
onClose={() => setOpen(false)}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
@@ -467,8 +528,10 @@ function UnifiedRunRowView({
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
// Without mode distinction, offer resume for any finished run with a session
|
||||
const canResume = !!row.sessionId && !row.isLive;
|
||||
// Without mode distinction, offer resume for any finished run with a session.
|
||||
// An external session is live but has no attachable tmux session, so Resume
|
||||
// (a new tmux-backed `claude --resume` in its cwd) is what it gets instead.
|
||||
const canResume = !!row.sessionId && (!row.isLive || !!row.external);
|
||||
const canView = !!row.sessionId && !row.isLive;
|
||||
return (
|
||||
<div
|
||||
@@ -484,13 +547,21 @@ function UnifiedRunRowView({
|
||||
{t("runs.liveBadge", "live")}
|
||||
</span>
|
||||
)}
|
||||
{row.external && (
|
||||
<span
|
||||
className="text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
|
||||
title={t("runs.externalHint")}
|
||||
>
|
||||
{t("runs.externalBadge")}
|
||||
</span>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<span className="text-[10px] font-semibold text-accent bg-accent/10 border border-accent/25 px-1.5 py-0.5 rounded-full">
|
||||
{t("runs.currentBadge", "current")}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto inline-flex items-center gap-1.5">
|
||||
{row.isLive && !isCurrent && (
|
||||
{row.isLive && !row.external && !isCurrent && (
|
||||
<button
|
||||
onClick={onAttach}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-status-success/40 bg-status-success/10 hover:bg-status-success/20 text-status-success px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
||||
@@ -502,6 +573,7 @@ function UnifiedRunRowView({
|
||||
{canResume && (
|
||||
<button
|
||||
onClick={onResume}
|
||||
title={row.external ? t("runs.externalHint") : undefined}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ActiveRunsSwitcher, RunsModal, type UnifiedRunRow } from "../RunHistory";
|
||||
import type { DashboardRunHistoryItem, RunListResponse } from "../../../lib/api";
|
||||
import type { Session } from "../../../lib/types";
|
||||
|
||||
const LIVE_ID = "run-live";
|
||||
const PAST_ID = "run-past";
|
||||
@@ -67,6 +68,21 @@ const HEADLESS = historyItem({
|
||||
started_at: new Date(1000).toISOString(),
|
||||
});
|
||||
|
||||
function externalSession(over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "sess-external",
|
||||
name: "the external prompt",
|
||||
status: "active",
|
||||
cwd: "/tmp/external",
|
||||
model: "claude-opus-5",
|
||||
started_at: new Date(3000).toISOString(),
|
||||
ended_at: null,
|
||||
updated_at: new Date(3000).toISOString(),
|
||||
source: "local",
|
||||
...over,
|
||||
} as unknown as Session;
|
||||
}
|
||||
|
||||
function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRunsSwitcher>> = {}) {
|
||||
const spies = {
|
||||
onAttach: vi.fn(),
|
||||
@@ -188,6 +204,44 @@ describe("ActiveRunsSwitcher", () => {
|
||||
expect(screen.queryByText("stale copy")).toBeNull();
|
||||
});
|
||||
|
||||
it("counts and lists a session started outside the dashboard, resumable not attachable", () => {
|
||||
const { spies } = renderSwitcher({
|
||||
activeRuns: null,
|
||||
runHistory: [],
|
||||
externalSessions: [externalSession()],
|
||||
});
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
|
||||
expect(screen.getByText("the external prompt")).toBeTruthy();
|
||||
expect(screen.getByText(i18n.t("run:runs.externalBadge"))).toBeTruthy();
|
||||
// No tmux session of ours to attach to — Resume is the only action.
|
||||
expect(screen.queryByText(i18n.t("run:runs.attachLabel", "Attach"))).toBeNull();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
expect(spies.onResumeFromHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "session:sess-external",
|
||||
session_id: "sess-external",
|
||||
cwd: "/tmp/external",
|
||||
status: "running",
|
||||
isLive: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("skips external sessions already covered by a run, remote ones, and cwd-less ones", () => {
|
||||
renderSwitcher({
|
||||
runHistory: [],
|
||||
externalSessions: [
|
||||
externalSession({ id: "sess-live" }), // same session as the live run
|
||||
externalSession({ id: "sess-remote", source: "remote-1" }),
|
||||
externalSession({ id: "sess-nocwd", cwd: null }),
|
||||
],
|
||||
});
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
|
||||
expect(screen.getByText("the live prompt")).toBeTruthy();
|
||||
expect(screen.queryByText("the external prompt")).toBeNull();
|
||||
expect(screen.queryByText(i18n.t("run:runs.externalBadge"))).toBeNull();
|
||||
});
|
||||
|
||||
it("fires attach with the run id of the row that was clicked", () => {
|
||||
const { spies } = renderSwitcher();
|
||||
openModal();
|
||||
|
||||
@@ -90,6 +90,8 @@
|
||||
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
|
||||
"kind.adopted": "adopted",
|
||||
"kind.managed": "managed",
|
||||
"laneDetail.hide": "Hide details",
|
||||
"laneDetail.show": "Lane details",
|
||||
"laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}",
|
||||
"locks.held_one": "{{count}} lock held",
|
||||
"locks.held_other": "{{count}} locks held",
|
||||
@@ -120,6 +122,10 @@
|
||||
"features.archived": "archived",
|
||||
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
|
||||
"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",
|
||||
"title": "Lanes",
|
||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message"
|
||||
|
||||
@@ -116,7 +116,9 @@
|
||||
"runs": {
|
||||
"allSessionsLink": "See all Claude Code sessions →",
|
||||
"attached": "Attached to existing run",
|
||||
"scopeNote": "Only shows runs you started from this dashboard.",
|
||||
"externalBadge": "external",
|
||||
"externalHint": "Started outside the dashboard, so there is no terminal to attach to. Resume opens a new tmux-backed `claude --resume` of this session in its folder.",
|
||||
"scopeNote": "Runs started from this dashboard, plus Claude Code sessions running outside it.",
|
||||
"started": "Started {{when}}",
|
||||
"switcher": "Active runs",
|
||||
"switcherEmpty": "No active runs",
|
||||
|
||||
@@ -90,6 +90,8 @@
|
||||
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
|
||||
"kind.adopted": "đã nhận",
|
||||
"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}}",
|
||||
"locks.held_one": "Đang giữ {{count}} khóa",
|
||||
"locks.held_other": "Đang giữ {{count}} khóa",
|
||||
@@ -120,6 +122,10 @@
|
||||
"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.",
|
||||
"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",
|
||||
"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"
|
||||
|
||||
@@ -115,7 +115,9 @@
|
||||
"runs": {
|
||||
"allSessionsLink": "Xem tất cả phiên Claude Code →",
|
||||
"attached": "Đã gắn vào run đang chạy",
|
||||
"scopeNote": "Chỉ hiển thị các run bạn khởi chạy từ dashboard này.",
|
||||
"externalBadge": "ngoài dashboard",
|
||||
"externalHint": "Phiên này khởi chạy ngoài dashboard nên không có terminal để attach. Resume sẽ mở một `claude --resume` mới trong tmux tại đúng thư mục đó.",
|
||||
"scopeNote": "Các run khởi chạy từ dashboard này, cùng những phiên Claude Code đang chạy bên ngoài.",
|
||||
"started": "Bắt đầu lúc {{when}}",
|
||||
"switcher": "Run đang chạy",
|
||||
"switcherEmpty": "Không có run đang chạy",
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
+269
-623
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
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 userEvent from "@testing-library/user-event";
|
||||
|
||||
@@ -633,3 +633,58 @@ describe("Workspace — proof gallery", () => {
|
||||
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>
|
||||
</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"
|
||||
>
|
||||
<p
|
||||
@@ -5776,8 +5779,33 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</p>
|
||||
</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
|
||||
class="flex min-h-0 flex-1 flex-col gap-5"
|
||||
data-testid="console-body"
|
||||
@@ -6111,6 +6139,7 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
@@ -310,10 +310,23 @@ 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
|
||||
|
||||
### Active runs list
|
||||
|
||||
The **Active runs** button in the console header opens the merged run list. It shows three sources in one place, newest first:
|
||||
|
||||
1. live in-memory tmux runs from `GET /api/run`,
|
||||
2. persisted dashboard runs from `GET /api/run/history`,
|
||||
3. Claude Code sessions running **outside** the dashboard — `GET /api/sessions?status=active`, i.e. a `claude` the user started by hand in a terminal tab. These carry an amber `external` badge, and the button's live count includes them, so two hand-started agents read as "2 active runs".
|
||||
|
||||
An external session is deduped against a dashboard run with the same `session_id`, and sessions from a remote data source (`source !== "local"`) or without a `cwd` are skipped — neither can be resumed on this machine.
|
||||
|
||||
External rows have **no Attach action**: the dashboard owns no tmux session for them, so there is no PTY to bridge. Their action is **Resume**, which does what resuming from history does — `POST /api/lanes/ensure` for the session's `cwd`, then `POST /api/lanes/:id/start` with `resumeSessionId` — spawning a *new* tmux-backed `claude --resume <session>` in that folder. The original terminal keeps running; resuming gives you a second Claude Code process on the same transcript, not a view of the first one.
|
||||
|
||||
The UI operates on a working directory (`cwd`), not a lane id. Starting a run in a `cwd` that no lane owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a lane for that path; a `cwd` an existing lane already owns is matched from the loaded lane list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`.
|
||||
|
||||
### Finding or adopting a lane: `POST /api/lanes/ensure`
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const deadline = Date.now() + 5000;
|
||||
let response;
|
||||
@@ -818,6 +841,7 @@ describe("destructive lane lifecycle actions", () => {
|
||||
fs.writeFileSync(sentinel, "still here\n");
|
||||
|
||||
// Start a run for the lane
|
||||
const restorePath = stubClaudeBinary("await-timeout");
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "stuck" });
|
||||
assert.equal(started.status, 200);
|
||||
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");
|
||||
} finally {
|
||||
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}`);
|
||||
});
|
||||
@@ -889,6 +922,7 @@ describe("destructive lane lifecycle actions", () => {
|
||||
const lane = await createManagedLane("start-twice");
|
||||
|
||||
// Start a run for the lane
|
||||
const restorePath = stubClaudeBinary("start-twice");
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" });
|
||||
assert.equal(started.status, 200);
|
||||
const runId = started.body.lane.run_id;
|
||||
@@ -915,6 +949,15 @@ describe("destructive lane lifecycle actions", () => {
|
||||
assert.equal(after.body.lane.run_id, runId);
|
||||
} finally {
|
||||
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}`);
|
||||
@@ -1134,6 +1177,7 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
||||
const lane = await adoptedLane("release-moved-on");
|
||||
|
||||
// Create a run for this lane.
|
||||
const restorePath = stubClaudeBinary("release-moved-on");
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
|
||||
assert.equal(started.status, 200, JSON.stringify(started.body));
|
||||
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");
|
||||
} finally {
|
||||
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");
|
||||
|
||||
// Start a run for this lane.
|
||||
const restorePath = stubClaudeBinary("release-stale-run");
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
|
||||
assert.equal(started.status, 200, JSON.stringify(started.body));
|
||||
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");
|
||||
} finally {
|
||||
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