Compare commits
24 Commits
bcd1259ed2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c29504c75 | |||
| 39572aa04c | |||
| 3ae0d00b0c | |||
| a2b5fa4669 | |||
| bab19e2f36 | |||
| 6f22aed47c | |||
| d542fbbf4b | |||
| 764dc6a7b5 | |||
| 06817b7901 | |||
| 14f116bf00 | |||
| 6dda604362 | |||
| 22ce61bcfe | |||
| 8a61a2b359 | |||
| b1d43bf098 | |||
| 11b779479d | |||
| 18a1ecb6f9 | |||
| fa416b5e6b | |||
| 0f15800b23 | |||
| 43f29ee904 | |||
| 18a42873b2 | |||
| b0bfc66d65 | |||
| 78fb82b257 | |||
| 7e2bb6225f | |||
| 774ee48f19 |
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -29,18 +29,23 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
fit.fit();
|
||||
|
||||
const ws = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
|
||||
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
||||
// would hand onmessage a Blob that the string checks below never match,
|
||||
// silently dropping all terminal output. "arraybuffer" keeps it sync.
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
if (typeof event.data === "string") {
|
||||
// Binary PTY output arrives as text here too (the browser WS API
|
||||
// decodes non-Blob/ArrayBuffer frames as strings) — a JSON control
|
||||
// frame is the only thing that starts with `{"type"`.
|
||||
if (event.data.startsWith('{"type"')) {
|
||||
const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
|
||||
const data = isArrayBuffer ? decoder.decode(event.data as ArrayBuffer) : event.data;
|
||||
if (typeof data === "string") {
|
||||
// A JSON control frame is the only thing that starts with `{"type"`.
|
||||
if (data.startsWith('{"type"')) {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === "exit") {
|
||||
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
|
||||
}
|
||||
@@ -49,7 +54,7 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
/* not JSON — fall through and render as PTY output */
|
||||
}
|
||||
}
|
||||
term.write(event.data);
|
||||
term.write(data);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,15 @@ describe("TerminalView", () => {
|
||||
expect(writeMock).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("decodes binary ArrayBuffer frames (server sends PTY output as binary)", () => {
|
||||
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws.onopen?.();
|
||||
const bytes = new TextEncoder().encode("hello-binary").buffer;
|
||||
ws.onmessage?.({ data: bytes });
|
||||
expect(writeMock).toHaveBeenCalledWith("hello-binary");
|
||||
});
|
||||
|
||||
it("forwards terminal keystrokes as outgoing WS sends", () => {
|
||||
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
+388
-752
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,180 +5762,295 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1"
|
||||
data-testid="lane-strip"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-muted"
|
||||
>
|
||||
No lanes yet. Create one from a working directory:
|
||||
|
||||
<code>
|
||||
ccam lanes add --cwd $(pwd)
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-h-0 flex-col gap-2"
|
||||
class="flex min-h-0 flex-1 gap-4"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-5"
|
||||
data-testid="console-body"
|
||||
class="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
|
||||
data-testid="lane-strip"
|
||||
>
|
||||
<header
|
||||
class="flex items-start gap-3"
|
||||
<p
|
||||
class="text-sm text-fg-muted"
|
||||
>
|
||||
<div
|
||||
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-play w-4.5 h-4.5 text-accent"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polygon
|
||||
points="6 3 20 12 6 21 6 3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<h1
|
||||
class="text-lg font-semibold text-fg-primary"
|
||||
>
|
||||
Run Claude
|
||||
</h1>
|
||||
<span
|
||||
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
|
||||
>
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
|
||||
/>
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-muted max-w-3xl"
|
||||
>
|
||||
Spin up a Claude Code session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
|
||||
disabled=""
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-list-ordered w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10 12h11"
|
||||
/>
|
||||
<path
|
||||
d="M10 18h11"
|
||||
/>
|
||||
<path
|
||||
d="M10 6h11"
|
||||
/>
|
||||
<path
|
||||
d="M4 10h2"
|
||||
/>
|
||||
<path
|
||||
d="M4 6h1v4"
|
||||
/>
|
||||
<path
|
||||
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
|
||||
/>
|
||||
</svg>
|
||||
Active runs
|
||||
</button>
|
||||
</header>
|
||||
No lanes yet. Create one from a working directory:
|
||||
|
||||
<code>
|
||||
ccam lanes add --cwd $(pwd)
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-2"
|
||||
>
|
||||
<div
|
||||
class="rounded-xl border border-border bg-surface-1"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]"
|
||||
<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"
|
||||
>
|
||||
<header
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
|
||||
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
aria-pressed="true"
|
||||
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent"
|
||||
title="Start a fresh Claude Code session."
|
||||
type="button"
|
||||
<svg
|
||||
class="lucide lucide-play w-4.5 h-4.5 text-accent"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
New session
|
||||
</button>
|
||||
<button
|
||||
aria-pressed="false"
|
||||
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary"
|
||||
title="Pick a session from your history and continue the conversation. Cwd is locked to the original."
|
||||
type="button"
|
||||
>
|
||||
Resume existing session
|
||||
</button>
|
||||
<polygon
|
||||
points="6 3 20 12 6 21 6 3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="px-4 py-3 border-b border-border"
|
||||
>
|
||||
<label
|
||||
class="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5"
|
||||
>
|
||||
Prompt
|
||||
</label>
|
||||
<textarea
|
||||
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
placeholder="Ask Claude anything…"
|
||||
rows="5"
|
||||
/>
|
||||
<div
|
||||
class="mt-1 text-[10px] text-fg-muted"
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
Cmd+Enter / Ctrl+Enter to send
|
||||
· / for slash commands · @ for file references
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Working directory
|
||||
</label>
|
||||
<div
|
||||
title="Absolute path. Defaults to the dashboard's own cwd."
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<h1
|
||||
class="text-lg font-semibold text-fg-primary"
|
||||
>
|
||||
Run Claude
|
||||
</h1>
|
||||
<span
|
||||
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
|
||||
>
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
|
||||
/>
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-muted max-w-3xl"
|
||||
>
|
||||
Spin up a Claude Code session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
|
||||
disabled=""
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-list-ordered w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10 12h11"
|
||||
/>
|
||||
<path
|
||||
d="M10 18h11"
|
||||
/>
|
||||
<path
|
||||
d="M10 6h11"
|
||||
/>
|
||||
<path
|
||||
d="M4 10h2"
|
||||
/>
|
||||
<path
|
||||
d="M4 6h1v4"
|
||||
/>
|
||||
<path
|
||||
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
|
||||
/>
|
||||
</svg>
|
||||
Active runs
|
||||
</button>
|
||||
</header>
|
||||
<div
|
||||
class="rounded-xl border border-border bg-surface-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]"
|
||||
>
|
||||
<div
|
||||
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
|
||||
>
|
||||
<button
|
||||
aria-pressed="true"
|
||||
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent"
|
||||
title="Start a fresh Claude Code session."
|
||||
type="button"
|
||||
>
|
||||
New session
|
||||
</button>
|
||||
<button
|
||||
aria-pressed="false"
|
||||
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary"
|
||||
title="Pick a session from your history and continue the conversation. Cwd is locked to the original."
|
||||
type="button"
|
||||
>
|
||||
Resume existing session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="px-4 py-3 border-b border-border"
|
||||
>
|
||||
<label
|
||||
class="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5"
|
||||
>
|
||||
Prompt
|
||||
</label>
|
||||
<textarea
|
||||
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
placeholder="Ask Claude anything…"
|
||||
rows="5"
|
||||
/>
|
||||
<div
|
||||
class="mt-1 text-[10px] text-fg-muted"
|
||||
>
|
||||
Cmd+Enter / Ctrl+Enter to send
|
||||
· / for slash commands · @ for file references
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Working directory
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
title="Absolute path. Defaults to the dashboard's own cwd."
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
autocomplete="off"
|
||||
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
||||
placeholder="Type to search or paste an absolute path…"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Model
|
||||
</label>
|
||||
<div
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Inherit from settings
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Permission mode
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
acceptEdits (recommended)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
@@ -5947,30 +6062,18 @@ exports[`screen snapshots > Run 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
autocomplete="off"
|
||||
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
||||
placeholder="Type to search or paste an absolute path…"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Model
|
||||
</label>
|
||||
<div
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Thinking effort
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
@@ -5981,7 +6084,7 @@ exports[`screen snapshots > Run 1`] = `
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Inherit from settings
|
||||
Default (model decides)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
@@ -6003,109 +6106,35 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Permission mode
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
acceptEdits (recommended)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Thinking effort
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Default (model decides)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-3 text-[11px] min-w-0"
|
||||
/>
|
||||
<button
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled=""
|
||||
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-play w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<div
|
||||
class="flex items-center gap-3 text-[11px] min-w-0"
|
||||
/>
|
||||
<button
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled=""
|
||||
>
|
||||
<polygon
|
||||
points="6 3 20 12 6 21 6 3"
|
||||
/>
|
||||
</svg>
|
||||
Run
|
||||
</button>
|
||||
<svg
|
||||
class="lucide lucide-play w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polygon
|
||||
points="6 3 20 12 6 21 6 3"
|
||||
/>
|
||||
</svg>
|
||||
Run
|
||||
</button>
|
||||
</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.
|
||||
- **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>`.
|
||||
|
||||
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.
|
||||
Generated
+93
-17
@@ -10,12 +10,12 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@lydell/node-pty": "^1.2.0-beta.15",
|
||||
"adm-zip": "^0.5.16",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"express": "^4.21.2",
|
||||
"multer": "^2.0.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"redoc": "^2.5.3",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tar": "^7.4.3",
|
||||
@@ -82,6 +82,98 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lydell/node-pty": {
|
||||
"version": "1.2.0-beta.15",
|
||||
"resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.2.0-beta.15.tgz",
|
||||
"integrity": "sha512-Br8wBxzbxFwdWgk9uQ+rdzE0xfoxOK4QuGH54swhRwc5IxP6H9Y1/bcyazRGvNUs6XkB5qNVkezuKSRxUwZe7A==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.15",
|
||||
"@lydell/node-pty-darwin-x64": "1.2.0-beta.15",
|
||||
"@lydell/node-pty-linux-arm64": "1.2.0-beta.15",
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.15",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.15",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.15"
|
||||
}
|
||||
},
|
||||
"node_modules/@lydell/node-pty-darwin-arm64": {
|
||||
"version": "1.2.0-beta.15",
|
||||
"resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.2.0-beta.15.tgz",
|
||||
"integrity": "sha512-6TSBbzdcLiNTHl1mTuzflqXrkmcC36USVGvERoDgvHk2ItEDaMaFZuAJ1CqPmwYj0DyhCS16TVS8OGK9xZnjyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@lydell/node-pty-darwin-x64": {
|
||||
"version": "1.2.0-beta.15",
|
||||
"resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.2.0-beta.15.tgz",
|
||||
"integrity": "sha512-yDT2oqPqYMBScyuk1U9Rg5VKcrbMOD9o9jWYYamDADA3NSbUISroPChrqYRQ74Y7BQtNH4gqYAiWOZRi5uQZ0Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@lydell/node-pty-linux-arm64": {
|
||||
"version": "1.2.0-beta.15",
|
||||
"resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.2.0-beta.15.tgz",
|
||||
"integrity": "sha512-wkbNF7dYAmtJv+o2+iztVlNwnUB4B0uX0wh/UD+mwMcmE2gNMnW9GChXO7fEE5XJokD0vB5idiHpGegaN+G/sg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@lydell/node-pty-linux-x64": {
|
||||
"version": "1.2.0-beta.15",
|
||||
"resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.2.0-beta.15.tgz",
|
||||
"integrity": "sha512-+U/5AVvHT6W+8OCYcnJgN0Qgc0ycO3TfD6aaFJHK+WHij797f8gsi5dV1HEO9l6YQmWCD+VL5gaLDhx3mxHwCA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@lydell/node-pty-win32-arm64": {
|
||||
"version": "1.2.0-beta.15",
|
||||
"resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.2.0-beta.15.tgz",
|
||||
"integrity": "sha512-pyAk91w7wnnKrD4mrHXtIXRfmzSWV5bEzvRhurXcMCtCc2TJ424ciUskIgWMhAPP6y3KyUnqElj+U6kY3iOt0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@lydell/node-pty-win32-x64": {
|
||||
"version": "1.2.0-beta.15",
|
||||
"resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.2.0-beta.15.tgz",
|
||||
"integrity": "sha512-2f8twEmDVxZ7drchAXjtevpmSPhFok0avAnzXro4t5gmz0xsPNKkoZvymwtuIS3xo7PzQqZOPQ/YzwEMb7oIzQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
|
||||
@@ -1670,12 +1762,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
@@ -1708,16 +1794,6 @@
|
||||
"node": "4.x || >=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
||||
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-readfiles": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/node-readfiles/-/node-readfiles-0.2.0.tgz",
|
||||
|
||||
+1
-1
@@ -89,12 +89,12 @@
|
||||
"docker:down": "docker compose down"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lydell/node-pty": "^1.2.0-beta.15",
|
||||
"adm-zip": "^0.5.16",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"express": "^4.21.2",
|
||||
"multer": "^2.0.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"redoc": "^2.5.3",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tar": "^7.4.3",
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
description: Build the dashboard UI if needed and print its URL
|
||||
description: Rebuild the dashboard UI and print its URL
|
||||
---
|
||||
|
||||
Build the dashboard bundle if it is not there yet, then print the URL. The
|
||||
bootstrap already builds it on session start, so this is usually a no-op — use
|
||||
it to force a rebuild, or to finish the build if the bootstrap's own attempt
|
||||
failed (check `~/.claude/agent-dashboard/runtime/client-build.log`).
|
||||
Rebuild the dashboard bundle, then print the URL. Always forces a rebuild so a
|
||||
stale bundle (e.g. after a fix commit landed but the bootstrap's build predates
|
||||
it) never serves silently. Also finishes the build if the bootstrap's own
|
||||
attempt failed (check `~/.claude/agent-dashboard/runtime/client-build.log`).
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-open.js"
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-open.js" --force
|
||||
```
|
||||
|
||||
The first run installs the client toolchain and takes a few minutes; later runs
|
||||
print the URL immediately. No server restart is needed — the server already
|
||||
serves from that directory.
|
||||
Install + build takes a few minutes if the client toolchain isn't already
|
||||
installed; otherwise the rebuild itself takes ~10-15s. No server restart is
|
||||
needed — the server already serves from that directory.
|
||||
|
||||
Then help the user open it:
|
||||
|
||||
@@ -25,5 +25,4 @@ uname -s
|
||||
- `Linux` → `xdg-open <url>`
|
||||
- otherwise → tell them to open the URL in a browser.
|
||||
|
||||
Keep the output to a few lines. Pass `--force` to the script only if the user
|
||||
asks for a rebuild.
|
||||
Keep the output to a few lines.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +79,13 @@ describe("syncMcp — reading and relocating", () => {
|
||||
`${lane.cwd}/.playwright-mcp/profiles/default`
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the lane's own cwd when source_repo is null (adopted lane)", async () => {
|
||||
const lane = makeLane(null);
|
||||
writeClaudeJson({ [lane.cwd]: { mcpServers: { playwright: { command: "npx", args: [] } } } });
|
||||
const result = await laneMcp.syncMcp(lane);
|
||||
assert.deepEqual(result.servers, ["playwright"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncMcp — Playwright output-dir pinning", () => {
|
||||
|
||||
@@ -78,6 +78,17 @@ describe("pty-attach", () => {
|
||||
assert.deepEqual(fakePty.__writes[0], { resize: [100, 40] });
|
||||
});
|
||||
|
||||
it("forwards a plain text WS frame (keystrokes) to pty.write", () => {
|
||||
// xterm.js's onData hands the browser a plain string, and
|
||||
// WebSocket.send(string) always emits a TEXT frame — so every keystroke
|
||||
// arrives here as non-binary. This must reach the pty, not be dropped as
|
||||
// an unparseable control message.
|
||||
const ws = makeFakeWs();
|
||||
ptyAttach.attach(ws, "ccam-lane-1", { cols: 80, rows: 24 });
|
||||
ws.__emitter.emit("message", Buffer.from("ls -la\r"), { binary: false });
|
||||
assert.deepEqual(fakePty.__writes[0], "ls -la\r");
|
||||
});
|
||||
|
||||
it("sends an exit control message and closes on PTY exit", () => {
|
||||
const ws = makeFakeWs();
|
||||
let closed = false;
|
||||
|
||||
@@ -120,8 +120,9 @@ function excludeFromGit(laneDir, line) {
|
||||
* @returns {{servers: string[], profilesSeeded: string[]}}
|
||||
*/
|
||||
async function syncMcp(lane) {
|
||||
const sourceServers = readSourceMcpServers(lane.source_repo);
|
||||
const relocated = relocate(sourceServers, lane.source_repo, lane.cwd);
|
||||
const sourceRepo = lane.source_repo || lane.cwd;
|
||||
const sourceServers = readSourceMcpServers(sourceRepo);
|
||||
const relocated = relocate(sourceServers, sourceRepo, lane.cwd);
|
||||
pinPlaywrightOutputDir(relocated, lane.cwd);
|
||||
|
||||
fs.writeFileSync(
|
||||
@@ -130,7 +131,7 @@ async function syncMcp(lane) {
|
||||
);
|
||||
excludeFromGit(lane.cwd, ".mcp.json");
|
||||
|
||||
const profilesSeeded = seedProfiles(lane.source_repo, lane.cwd);
|
||||
const profilesSeeded = seedProfiles(sourceRepo, lane.cwd);
|
||||
|
||||
return { servers: Object.keys(relocated), profilesSeeded };
|
||||
}
|
||||
|
||||
+32
-12
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file pty-attach.js
|
||||
* @description Bridges one WebSocket connection to a `node-pty`-backed
|
||||
* @description Bridges one WebSocket connection to an `@lydell/node-pty`-backed
|
||||
* `tmux attach-session` process. Binary WS frames carry raw PTY bytes in
|
||||
* both directions; text WS frames carry small JSON control messages
|
||||
* (`resize`, and an outbound `exit` sent once when the pane process/tmux
|
||||
@@ -71,19 +71,31 @@ function attach(ws, runId, { cols, rows }) {
|
||||
// callback arg (newer) or via `data.binary` on some transports — this
|
||||
// helper's own tests exercise the `{binary}` option shape used above.
|
||||
const binary = typeof isBinary === "boolean" ? isBinary : !!(isBinary && isBinary.binary);
|
||||
const text = data.toString("utf8");
|
||||
if (binary) {
|
||||
pty.write(data.toString("utf8"));
|
||||
pty.write(text);
|
||||
return;
|
||||
}
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(data.toString("utf8"));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
|
||||
pty.resize(msg.cols, msg.rows);
|
||||
// The browser's WebSocket API sends a JS string as a text frame, and
|
||||
// xterm.js's onData callback hands over plain strings — so every
|
||||
// keystroke arrives here as text, not binary. Only a JSON control frame
|
||||
// (matched by this same prefix check the client uses for output) is
|
||||
// NOT keystroke input; everything else must reach the pty or typing
|
||||
// does nothing.
|
||||
if (text.startsWith('{"type"')) {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(text);
|
||||
} catch {
|
||||
pty.write(text);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
|
||||
pty.resize(msg.cols, msg.rows);
|
||||
return;
|
||||
}
|
||||
}
|
||||
pty.write(text);
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
@@ -98,7 +110,15 @@ function attach(ws, runId, { cols, rows }) {
|
||||
}
|
||||
|
||||
// Real spawn implementation — lazy-required so unit tests never load the
|
||||
// native node-pty addon unless they explicitly opt in.
|
||||
__setSpawnImpl((...args) => require("node-pty").spawn(...args));
|
||||
// native PTY addon unless they explicitly opt in. Uses @lydell/node-pty (a
|
||||
// drop-in-API-compatible fork of node-pty) rather than node-pty itself:
|
||||
// node-pty ships prebuilt binaries for darwin/win32 only, so on Linux it
|
||||
// needs a native build via its install script — but the plugin install path
|
||||
// runs `npm install --ignore-scripts` deliberately (see plugin-bootstrap.js)
|
||||
// to avoid requiring a build toolchain on the user's machine. @lydell/node-pty
|
||||
// instead ships the platform binary as a regular optionalDependency
|
||||
// (@lydell/node-pty-linux-x64 etc.), so a plain --ignore-scripts install still
|
||||
// resolves a working native binding with no compiler needed.
|
||||
__setSpawnImpl((...args) => require("@lydell/node-pty").spawn(...args));
|
||||
|
||||
module.exports = { attach, validateRunId, __setSpawnImpl };
|
||||
|
||||
+16
-4
@@ -9,11 +9,15 @@ const { isHostAllowed, isWebSocketAuthorized } = require("./lib/security");
|
||||
let wss = null;
|
||||
|
||||
function initWebSocket(server) {
|
||||
// Express middleware doesn't run on WS upgrades, so enforce the same Host
|
||||
// allowlist (anti DNS-rebinding) and optional token here (GHSA-gr74-4xfh-6jw9).
|
||||
// `noServer: true` + a manual, path-checked `server.on("upgrade", ...)`
|
||||
// rather than the `{server, path}` shorthand: that shorthand's own
|
||||
// internal upgrade listener calls `handleUpgrade` for EVERY upgrade on the
|
||||
// shared http.Server (path filtering happens inside `handleUpgrade`,
|
||||
// which `abortHandshake`s with 400 on a mismatch) — so it was answering,
|
||||
// and killing, `/ws-pty/*` upgrades before the PTY server's own listener
|
||||
// (registered below by `initPtyWebSocket`) ever got a chance to run.
|
||||
wss = new WebSocketServer({
|
||||
server,
|
||||
path: "/ws",
|
||||
noServer: true,
|
||||
maxPayload: 64 * 1024,
|
||||
verifyClient(info, done) {
|
||||
if (!isHostAllowed(info.req.headers.host)) return done(false, 403, "host not allowed");
|
||||
@@ -22,6 +26,14 @@ function initWebSocket(server) {
|
||||
},
|
||||
});
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (url.pathname !== "/ws") return; // not ours — `/ws-pty/*` handles its own path.
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
ws.isAlive = true;
|
||||
ws.on("pong", () => {
|
||||
|
||||
Reference in New Issue
Block a user