Compare commits
4 Commits
bab19e2f36
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c29504c75 | |||
| 39572aa04c | |||
| 3ae0d00b0c | |||
| a2b5fa4669 |
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -54,39 +54,16 @@ import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ConsoleArea({
|
||||
lanes,
|
||||
selectedLaneId,
|
||||
function SplitLayoutToggle({
|
||||
splitView,
|
||||
setLayout,
|
||||
setPaneLaneId,
|
||||
binaryStatus,
|
||||
cwdSuggestions,
|
||||
activeRuns,
|
||||
wsConnected,
|
||||
defaultCwd,
|
||||
onHasActiveRunChange,
|
||||
onLaneCreated,
|
||||
onLaneIdChange,
|
||||
}: {
|
||||
lanes: Lane[];
|
||||
selectedLaneId: number | null;
|
||||
splitView: SplitViewState;
|
||||
setLayout: (layout: SplitLayout) => void;
|
||||
setPaneLaneId: (index: number, id: number) => void;
|
||||
binaryStatus: { found: boolean; path: string | null } | null;
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
activeRuns: RunListResponse | null;
|
||||
wsConnected: boolean;
|
||||
defaultCwd: string;
|
||||
onHasActiveRunChange: (val: boolean) => void;
|
||||
onLaneCreated: (lane: Lane) => void;
|
||||
onLaneIdChange: (id: number | null) => void;
|
||||
}) {
|
||||
const { t: tLanes } = useTranslation("lanes");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{([1, 2, 4] as const).map((n) => (
|
||||
<button
|
||||
@@ -104,6 +81,43 @@ function ConsoleArea({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsoleArea({
|
||||
lanes,
|
||||
selectedLaneId,
|
||||
splitView,
|
||||
setLayout,
|
||||
setPaneLaneId,
|
||||
binaryStatus,
|
||||
cwdSuggestions,
|
||||
activeRuns,
|
||||
wsConnected,
|
||||
defaultCwd,
|
||||
onHasActiveRunChange,
|
||||
onLaneCreated,
|
||||
onLaneIdChange,
|
||||
showToggle = true,
|
||||
}: {
|
||||
lanes: Lane[];
|
||||
selectedLaneId: number | null;
|
||||
splitView: SplitViewState;
|
||||
setLayout: (layout: SplitLayout) => void;
|
||||
setPaneLaneId: (index: number, id: number) => void;
|
||||
binaryStatus: { found: boolean; path: string | null } | null;
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
activeRuns: RunListResponse | null;
|
||||
wsConnected: boolean;
|
||||
defaultCwd: string;
|
||||
onHasActiveRunChange: (val: boolean) => void;
|
||||
onLaneCreated: (lane: Lane) => void;
|
||||
onLaneIdChange: (id: number | null) => void;
|
||||
showToggle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{showToggle && <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />}
|
||||
{splitView.layout === 1 ? (
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
@@ -175,6 +189,11 @@ export function Workspace() {
|
||||
|
||||
// Split view state
|
||||
const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState());
|
||||
// Info block (pickers, LaneCard, PipelineMap, proof gallery) is always shown
|
||||
// in single-pane view; in split view it starts collapsed so tall content
|
||||
// never crowds the pane grid, and the user expands it on demand.
|
||||
const [infoExpanded, setInfoExpanded] = useState(false);
|
||||
const showInfo = splitView.layout === 1 || infoExpanded;
|
||||
|
||||
const setLayout = useCallback((layout: SplitLayout) => {
|
||||
setSplitView((prev) => {
|
||||
@@ -420,7 +439,10 @@ export function Workspace() {
|
||||
try {
|
||||
const { lane } = await api.lanes.update(id, { pipeline });
|
||||
await refreshLanes();
|
||||
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
|
||||
// `stage` defaults to the DB sentinel "idle" until a driving session ever
|
||||
// calls `ccam stage` — that's the normal state for most lanes and never
|
||||
// matches a real pipeline node, so it isn't a mismatch worth surfacing.
|
||||
if (lane.stage !== "idle" && !lane.pipeline_nodes.some((n) => n.state === "current")) {
|
||||
setLaneActionError(
|
||||
tLanes("pipelinePicker.stageMismatch", {
|
||||
stage: lane.stage,
|
||||
@@ -488,11 +510,14 @@ export function Workspace() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lane carousel: pick a lane here, read it below. Horizontal scroll with
|
||||
snap so a dozen lanes stay in one row instead of a wall of cards. */}
|
||||
{/* Lane list + detail live side by side so neither can cover the other:
|
||||
the list is a scrolling column (holds any number of lanes), the
|
||||
detail/console area to its right never has to compete with it for
|
||||
vertical space. */}
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div
|
||||
data-testid="lane-strip"
|
||||
className="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1"
|
||||
className="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
|
||||
>
|
||||
{lanes.map((l) => (
|
||||
<LaneStripCard
|
||||
@@ -509,17 +534,42 @@ export function Workspace() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The selected lane's pipeline, full width — the thing you actually
|
||||
come to this page to read. */}
|
||||
{/* The selected lane's pipeline — the thing you actually come to this
|
||||
page to read, beside the list rather than stacked under it. */}
|
||||
{currentLane && (
|
||||
<section data-testid="lane-detail" className="card p-4">
|
||||
<section
|
||||
data-testid="lane-detail"
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col card p-4"
|
||||
>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-semibold text-fg-primary">
|
||||
{tLanes("laneHeader", {
|
||||
id: currentLane.id,
|
||||
title: currentLane.title || currentLane.cwd,
|
||||
pipeline: currentLane.pipeline_name,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{splitView.layout !== 1 && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-detail-toggle"
|
||||
aria-expanded={infoExpanded}
|
||||
onClick={() => setInfoExpanded((v) => !v)}
|
||||
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary"
|
||||
>
|
||||
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
|
||||
</button>
|
||||
)}
|
||||
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
|
||||
</div>
|
||||
</div>
|
||||
{showInfo && (
|
||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
|
||||
{tLanes("cardId", { id: currentLane.id })}
|
||||
</span>
|
||||
<span className="truncate text-sm font-semibold text-fg-primary">
|
||||
{currentLane.title || currentLane.cwd}
|
||||
</span>
|
||||
<select
|
||||
data-testid="pipeline-picker"
|
||||
aria-label={tLanes("pipelinePicker.label")}
|
||||
@@ -592,7 +642,9 @@ export function Workspace() {
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<PipelineMap
|
||||
nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
|
||||
nodes={
|
||||
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
|
||||
}
|
||||
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
||||
/>
|
||||
{viewedFeature && (
|
||||
@@ -642,7 +694,9 @@ export function Workspace() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 border-t border-border pt-3">
|
||||
<ConsoleArea
|
||||
lanes={lanes}
|
||||
selectedLaneId={selectedLaneId}
|
||||
@@ -659,13 +713,14 @@ export function Workspace() {
|
||||
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
||||
}
|
||||
onLaneIdChange={setSelectedLaneId}
|
||||
showToggle={false}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!currentLane && (
|
||||
<div className="flex min-h-0 flex-col gap-2">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<ConsoleArea
|
||||
lanes={lanes}
|
||||
selectedLaneId={selectedLaneId}
|
||||
@@ -685,6 +740,7 @@ export function Workspace() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddLaneModal
|
||||
open={addLaneOpen}
|
||||
|
||||
@@ -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,7 +5779,7 @@ 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"
|
||||
@@ -6136,6 +6139,7 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
@@ -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