feat(lanes): add lane tools menu and header liveness/age indicator

Move the rare destructive lane actions behind a LaneToolsMenu next to
the lane header, and show a liveness dot + status label + stage age
inline in the header instead of only in the card grid.
This commit is contained in:
2026-08-21 16:22:47 +07:00
parent 937c63361b
commit 16bef7f9a1
6 changed files with 315 additions and 412 deletions
+182 -268
View File
@@ -1,13 +1,16 @@
/**
* @file One lane's card: title, progress bar with time-on-phase, branch/CI/PR
* facts, the "needs you" banner sourced from Claude Code's Notification hook,
* and the control row — which ends in the two deletions (lane, history) as
* plain buttons, each gated by DestructiveLaneModal. A dead lane (its driving
* session went silent while it should have been working) is called out
* loudly — that is the failure this view exists to catch. Stage, kind, and
* the "auto: <stage>" chip are NOT repeated here — this card only ever
* renders inside the Workspace lane-detail section, whose header above it
* already shows them.
* @file One lane's card: progress bar, branch/CI/PR facts, the "needs you"
* banner sourced from Claude Code's Notification hook, and (for managed
* lanes only) a ⋯ menu holding the rare `reset worktree` action behind
* DestructiveLaneModal. A dead lane (its driving session went silent while
* it should have been working) is called out loudly — that is the failure
* this view exists to catch. Stage, kind, and the "auto: <stage>" chip are
* NOT repeated here — this card only ever renders inside the Workspace
* lane-detail section, whose header above it already shows them. The repo
* tooling dropdown (`LaneToolsMenu`, exported from this file) and the
* remove-lane button live in Workspace's page header / pipeline-picker row
* instead of here, so they stay reachable and un-clipped regardless of this
* card's own scroll area.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -73,7 +76,7 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null {
* `bump` lets an up/down action re-read immediately instead of waiting out the
* poll interval.
*/
function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null {
export function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null {
const [runtime, setRuntime] = useState<LaneRuntime | null>(null);
useEffect(() => {
@@ -164,78 +167,34 @@ function useLaneIntegrations(
return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
}
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
idle: "bg-surface-4",
dead: "bg-status-danger",
};
function since(sec: number | null): string {
export function since(sec: number | null): string {
if (sec === null) return "—";
if (sec < 60) return `${sec}s`;
if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
}
export default function LaneCard({
lane,
onAction,
childWorktrees,
onSelectLane,
}: {
lane: Lane;
onAction: (action: string, body?: Record<string, unknown>) => void;
/** Other lanes whose `source_repo` is this lane's `cwd` — populated only
* when this lane is itself a source repo (typically an adopted one) that
* other lanes were provisioned as worktrees from. */
childWorktrees?: Lane[];
/** Jumps the Workspace page's selection to another lane's card. */
onSelectLane?: (id: number) => void;
}) {
/**
* Repo tooling (agents install / MCP sync / dev-sync check) for one lane,
* collapsed into a single dropdown. Lives outside LaneCard so it can be
* placed in the Workspace page header, next to "Add lane" — it polls its
* own runtime state independently of LaneCard's, same pattern as the
* separately-polled git/locks facts elsewhere on this page.
*/
export function LaneToolsMenu({ lane }: { lane: Lane }) {
const { t } = useTranslation(["lanes"]);
const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>(
null
);
const [menuOpen, setMenuOpen] = useState(false);
const [runtimeBump, setRuntimeBump] = useState(0);
const [runtimeBusy, setRuntimeBusy] = useState<"up" | "down" | null>(null);
const [bootLine, setBootLine] = useState<string | null>(null);
const [toolsMenuOpen, setToolsMenuOpen] = useState(false);
const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(
null
);
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
const git = useLaneGitFacts(lane.id);
const runtime = useLaneRuntime(lane.id, runtimeBump);
const locks = useLaneLocks(lane.slot);
const integrations = useLaneIntegrations(lane.id, runtime?.available === true);
/**
* Boot or stop the lane's stack. Deliberately NOT routed through `onAction`:
* that prop drives the lane's Claude run, and folding a second lifecycle into
* it would make "stop" ambiguous about which thing it stops.
*
* `up` answers 202 and keeps booting in the background, so the button stays
* busy until the server's `lane_runtime` message says the attempt finished —
* resolving the request is not the same as the stack being up.
*/
const runtimeAction = async (which: "up" | "down") => {
setRuntimeBusy(which);
setBootLine(null);
try {
if (which === "down") {
await api.lanes.down(lane.id);
setRuntimeBusy(null);
} else {
await api.lanes.up(lane.id);
}
} catch {
// The failure surfaces as the runtime row's lastError on the next read; a
// toast here would say the same thing twice.
setRuntimeBusy(null);
} finally {
setRuntimeBump((n) => n + 1);
}
};
const runtime = useLaneRuntime(lane.id, 0);
const runLaneAction = async (
which: "agents" | "mcp" | "sync-check",
@@ -277,6 +236,107 @@ export default function LaneCard({
});
});
if (!runtime?.available) return null;
return (
<div className="relative">
<button
type="button"
data-testid="lane-tools-menu"
aria-haspopup="menu"
aria-expanded={toolsMenuOpen}
disabled={laneActionBusy !== null}
onClick={(e) => {
e.stopPropagation();
setToolsMenuOpen((v) => !v);
}}
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary disabled:opacity-50"
>
{laneActionBusy ? t("actions.busy") : t("actions.tools")}
</button>
{toolsMenuOpen && (
<div
role="menu"
className="absolute right-0 z-10 mt-1 min-w-44 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
>
<button
type="button"
role="menuitem"
data-testid="lane-agents-install"
onClick={(e) => {
e.stopPropagation();
setToolsMenuOpen(false);
void handleAgentsInstall();
}}
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
>
{t("actions.agentsInstall")}
</button>
<button
type="button"
role="menuitem"
data-testid="lane-mcp-sync"
onClick={(e) => {
e.stopPropagation();
setToolsMenuOpen(false);
void handleMcpSync();
}}
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
>
{t("actions.mcpSync")}
</button>
<button
type="button"
role="menuitem"
data-testid="lane-sync-check"
onClick={(e) => {
e.stopPropagation();
setToolsMenuOpen(false);
void handleSyncCheck();
}}
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
>
{t("actions.syncCheck")}
</button>
</div>
)}
{laneActionResult && (
<p
data-testid="lane-action-result"
className="absolute right-0 top-full mt-1 w-max max-w-xs truncate rounded border border-border-light bg-surface-0 px-2 py-1 text-[11px] text-fg-secondary shadow-lg shadow-black/40"
>
{laneActionResult}
</p>
)}
</div>
);
}
export default function LaneCard({
lane,
onAction,
childWorktrees,
onSelectLane,
}: {
lane: Lane;
onAction: (action: string, body?: Record<string, unknown>) => void;
/** Other lanes whose `source_repo` is this lane's `cwd` — populated only
* when this lane is itself a source repo (typically an adopted one) that
* other lanes were provisioned as worktrees from. */
childWorktrees?: Lane[];
/** Jumps the Workspace page's selection to another lane's card. */
onSelectLane?: (id: number) => void;
}) {
const { t } = useTranslation(["lanes"]);
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [runtimeBump, setRuntimeBump] = useState(0);
const [bootLine, setBootLine] = useState<string | null>(null);
const git = useLaneGitFacts(lane.id);
const runtime = useLaneRuntime(lane.id, runtimeBump);
const locks = useLaneLocks(lane.slot);
const integrations = useLaneIntegrations(lane.id, runtime?.available === true);
/**
* Live boot feedback. A build can run for minutes, and a card showing only a
* disabled button through all of it reads as a hang. The hook's own output
@@ -291,7 +351,6 @@ export default function LaneCard({
setBootLine(data.line);
}
if (msg.type === "lane_runtime") {
setRuntimeBusy(null);
setBootLine(null);
setRuntimeBump((n) => n + 1);
}
@@ -303,52 +362,27 @@ export default function LaneCard({
<>
<div
data-testid={`lane-card-${lane.id}`}
className="flex flex-col rounded-xl border border-border bg-surface-4 p-4 transition-colors hover:border-border-light"
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light"
>
{/* Identity strip: which lane, and is it alive. Kept on one line and in
uppercase so a wall of cards can be scanned vertically. */}
<div className="mb-3 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
{t("cardId", { id: lane.id })}
</span>
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
{/* i18next returns the KEY on a miss, so `|| raw` never fires and a
non-standard status rendered as the literal "status.foo".
defaultValue makes it degrade to the raw status instead. */}
{lane.liveness === "dead"
? t("statusDead")
: t(`status.${lane.status}`, { defaultValue: lane.status })}
</span>
</div>
<h3 className="mb-3 text-[15px] font-semibold leading-snug text-fg-primary">
{lane.title || lane.cwd}
</h3>
{/* Progress line: how far through the pipeline and how long the
current stage has been sitting there. The stage's own name is
already in the Workspace header above; not repeated here. */}
<div className="mb-2 flex items-center gap-2 text-xs">
<div
className={`h-1 flex-1 overflow-hidden rounded-full ${
lane.progress > 0 ? "bg-surface-2" : "bg-transparent"
}`}
>
<div
data-testid="lane-progress-fill"
className="h-full rounded-full bg-blue-600 transition-[width]"
style={{ width: `${lane.progress}%` }}
/>
</div>
{lane.progress > 0 && (
{/* Progress line: how far through the pipeline. The stage's own name
and how long it's been running are already in the Workspace
header above; not repeated here. Omitted at 0% — an empty bar
is not information, just a blank row above a dead/idle lane. */}
{lane.progress > 0 && (
<div className="mb-1.5 flex items-center gap-2 text-xs">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-2">
<div
data-testid="lane-progress-fill"
className="h-full rounded-full bg-blue-600 transition-[width]"
style={{ width: `${lane.progress}%` }}
/>
</div>
<span className="tabular-nums text-fg-secondary">{lane.progress}%</span>
)}
<span className="tabular-nums text-fg-muted">{since(lane.stage_seconds)}</span>
</div>
</div>
)}
{lane.ci_status && (
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px]">
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
CI {lane.ci_status}
</span>
@@ -356,7 +390,7 @@ export default function LaneCard({
)}
{lane.needs_action && (
<div className="mb-3 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
<div className="mb-2 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1 text-xs text-status-warning">
⚠ {lane.needs_action}
</div>
)}
@@ -368,7 +402,7 @@ export default function LaneCard({
{runtime?.available && runtime.provisioned && (
<div
data-testid={`lane-runtime-${lane.id}`}
className="mb-3 rounded border border-border bg-surface-2/50 px-2 py-1.5 font-mono text-[11px]"
className="mb-2 rounded border border-border bg-surface-2/50 px-2 py-1 font-mono text-[11px]"
>
<div className="mb-1 flex items-center justify-between">
<span className="text-fg-muted">{t("runtime.slot", { slot: runtime.slot })}</span>
@@ -427,7 +461,7 @@ export default function LaneCard({
{locks.length > 0 && (
<div
className="mb-3 flex items-center gap-1 text-xs text-status-warning"
className="mb-2 flex items-center gap-1 text-xs text-status-warning"
data-testid={`lane-locks-${lane.id}`}
title={locks.map((l) => `${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")}
>
@@ -435,7 +469,7 @@ export default function LaneCard({
</div>
)}
<dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary">
<dl className="mb-2 space-y-0.5 font-mono text-[11px] text-fg-secondary">
{git?.available && (
<div data-testid="lane-git" className="space-y-1">
<div className="truncate">
@@ -463,7 +497,7 @@ export default function LaneCard({
{childWorktrees && childWorktrees.length > 0 && (
<div
data-testid="lane-child-worktrees"
className="mb-3 space-y-1 text-[11px] text-fg-secondary"
className="mb-2 space-y-1 text-[11px] text-fg-secondary"
>
<div className="text-fg-muted">
{t("worktrees.heading", { count: childWorktrees.length })}
@@ -486,7 +520,7 @@ export default function LaneCard({
)}
{integrations && (
<div className="mb-2 flex items-center gap-1.5 text-[10px]">
<div className="mb-1 flex items-center gap-1.5 text-[10px]">
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
<span
key={name}
@@ -504,169 +538,49 @@ export default function LaneCard({
)}
{/* mt-auto pins the controls to the bottom so cards of differing height
in one grid row still line their buttons up. */}
<div className="mt-auto flex items-center gap-1 border-t border-border pt-2 text-xs">
{(["start", "stop", "clear"] as const).map((a) => (
<button
key={a}
type="button"
data-testid={`lane-action-${a}`}
onClick={(e) => {
e.stopPropagation();
onAction(a);
}}
className={`rounded px-2 py-1 transition-colors ${
a === "start"
? "bg-blue-600/15 text-blue-400 hover:bg-blue-600/25"
: "text-fg-secondary hover:bg-surface-2 hover:text-fg-secondary"
}`}
title={a === "start" ? t("tooltipStart") : undefined}
>
{t(`action.${a}`)}
</button>
))}
{/* Stack controls, only for a lane whose repo declares a profile.
Separate from start/stop above: those drive the lane's Claude run,
these drive the application it is working on. */}
{runtime?.available && (
<button
type="button"
data-testid="lane-runtime-toggle"
disabled={runtimeBusy !== null}
onClick={(e) => {
e.stopPropagation();
void runtimeAction(runtime.provisioned && runtime.up ? "down" : "up");
}}
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
title={t("runtime.toggleTitle")}
>
{runtimeBusy
? t(`runtime.busy.${runtimeBusy}`)
: runtime.provisioned && runtime.up
? t("runtime.stop")
: t("runtime.boot")}
</button>
)}
{runtime?.available && (
<>
in one grid row still line their buttons up. Repo tooling and the
remove button live in the Workspace page header/pipeline row now —
`reset` is the one destructive action left here, since it only
applies to managed lanes and is rare enough to stay behind ⋯. */}
{lane.kind === "managed" && (
<div className="mt-auto flex items-center justify-end gap-1 border-t border-border pt-2 text-xs">
<div className="relative">
<button
type="button"
data-testid="lane-agents-install"
disabled={laneActionBusy !== null}
data-testid="lane-more"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={(e) => {
e.stopPropagation();
void handleAgentsInstall();
setMenuOpen((v) => !v);
}}
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
title={t("moreActions")}
>
{laneActionBusy === "agents" ? t("actions.busy") : t("actions.agentsInstall")}
⋯
</button>
<button
type="button"
data-testid="lane-mcp-sync"
disabled={laneActionBusy !== null}
onClick={(e) => {
e.stopPropagation();
void handleMcpSync();
}}
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
>
{laneActionBusy === "mcp" ? t("actions.busy") : t("actions.mcpSync")}
</button>
<button
type="button"
data-testid="lane-sync-check"
disabled={laneActionBusy !== null}
onClick={(e) => {
e.stopPropagation();
void handleSyncCheck();
}}
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
>
{laneActionBusy === "sync-check" ? t("actions.busy") : t("actions.syncCheck")}
</button>
</>
)}
{/* Deleting the lane and deleting its history are each their own
button, by request: hiding "delete" behind a ⋯ made it unfindable,
and the one label that read as "delete" was `clear`. Neither fires
directly — both open DestructiveLaneModal, which shows the exact
counts and demands a confirmation. `reset` stays in the menu; it
is the rare one and only exists for managed lanes. */}
<div className="ml-auto flex items-center gap-1">
<button
type="button"
data-testid="lane-action-purge"
onClick={(e) => {
e.stopPropagation();
setDestructiveAction("purge");
}}
className="rounded px-2 py-1 text-status-danger/80 transition-colors hover:bg-status-danger/10 hover:text-status-danger"
>
{t("action.purge")}
</button>
<button
type="button"
data-testid="lane-action-remove"
onClick={(e) => {
e.stopPropagation();
setDestructiveAction("remove");
}}
className="rounded px-2 py-1 text-status-danger transition-colors hover:bg-status-danger/15 hover:text-status-danger"
>
{lane.kind === "adopted" ? t("action.forget") : t("action.remove")}
</button>
{/* Adopted lanes have no worktree to reset, so the menu would be
empty — it is not rendered at all rather than opening onto
nothing. */}
{lane.kind === "managed" && (
<div className="relative">
<button
type="button"
data-testid="lane-more"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={(e) => {
e.stopPropagation();
setMenuOpen((v) => !v);
}}
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
title={t("moreActions")}
{menuOpen && (
<div
role="menu"
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
>
⋯
</button>
{menuOpen && (
<div
role="menu"
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
<button
type="button"
role="menuitem"
data-testid="lane-action-reset"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDestructiveAction("reset");
}}
className="block w-full px-3 py-1.5 text-left text-status-warning hover:bg-status-warning/10"
>
<button
type="button"
role="menuitem"
data-testid="lane-action-reset"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDestructiveAction("reset");
}}
className="block w-full px-3 py-1.5 text-left text-status-warning hover:bg-status-warning/10"
>
{t("action.reset")}
</button>
</div>
)}
</div>
)}
{t("action.reset")}
</button>
</div>
)}
</div>
</div>
</div>
{laneActionResult && (
<p
data-testid="lane-action-result"
className="mt-1 truncate text-[11px] text-fg-secondary"
>
{laneActionResult}
</p>
)}
</div>
@@ -11,7 +11,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LaneCard from "../LaneCard";
import LaneCard, { LaneToolsMenu } from "../LaneCard";
import type { Lane, LaneRuntime } from "../../../lib/types";
import { api } from "../../../lib/api";
@@ -85,16 +85,6 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
} as Lane;
}
describe("LaneCard status badge", () => {
for (const status of ["idle", "running", "provisioning", "failed"] as const) {
it(`renders a real word for status "${status}", not the raw key`, () => {
render(<LaneCard lane={makeLane({ status })} onAction={vi.fn()} />);
expect(screen.queryByText(`status.${status}`)).not.toBeInTheDocument();
expect(screen.queryByText(status.toUpperCase())).not.toBeInTheDocument();
});
}
});
describe("LaneCard child worktrees", () => {
it("lists worktrees provisioned from this lane and jumps to one on click", async () => {
const onSelectLane = vi.fn();
@@ -148,10 +138,9 @@ describe("LaneCard rebuilt layout", () => {
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
});
it("shows the progress percentage and the time on stage", () => {
it("shows the progress percentage", () => {
render(<LaneCard lane={full()} onAction={vi.fn()} />);
expect(screen.getByText("48%")).toBeInTheDocument();
expect(screen.getByText("2m 32s")).toBeInTheDocument();
});
it("gives the progress bar a width matching the lane's progress", () => {
@@ -164,35 +153,14 @@ describe("LaneCard rebuilt layout", () => {
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
});
it("fires a plain action with its own name", async () => {
const onAction = vi.fn();
render(<LaneCard lane={full()} onAction={onAction} />);
await userEvent.setup().click(screen.getByTestId("lane-action-stop"));
expect(onAction).toHaveBeenCalledWith("stop");
});
it("shows both deletions as buttons, and keeps reset behind the menu", async () => {
it("keeps reset behind the ⋯ menu rather than showing it directly", async () => {
render(<LaneCard lane={full()} onAction={vi.fn()} />);
// Deleting the lane and deleting its history are the two the user goes
// looking for, so they are visible without opening anything. Reset is not.
expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
expect(screen.getByTestId("lane-action-purge")).toBeInTheDocument();
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
await userEvent.setup().click(screen.getByTestId("lane-more"));
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
});
it("routes both deletions through the modal rather than firing them", async () => {
const onAction = vi.fn();
render(<LaneCard lane={full()} onAction={onAction} />);
const user = userEvent.setup();
await user.click(screen.getByTestId("lane-action-remove"));
expect(onAction).not.toHaveBeenCalled();
await user.click(screen.getByTestId("lane-action-purge"));
expect(onAction).not.toHaveBeenCalled();
});
it("routes reset through the confirmation modal rather than firing it", async () => {
const onAction = vi.fn();
render(<LaneCard lane={full()} onAction={onAction} />);
@@ -202,18 +170,17 @@ describe("LaneCard rebuilt layout", () => {
expect(onAction).not.toHaveBeenCalled();
});
it("offers reset only for a managed lane, and drops the menu entirely without it", async () => {
it("offers the ⋯ menu only for a managed lane", async () => {
const user = userEvent.setup();
const { unmount } = render(<LaneCard lane={full()} onAction={vi.fn()} />);
await user.click(screen.getByTestId("lane-more"));
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
unmount();
// An adopted lane has nothing left in the menu, so there is no ⋯ to open.
// An adopted lane has no worktree to reset, so there is no ⋯ to open.
render(<LaneCard lane={full({ kind: "adopted" })} onAction={vi.fn()} />);
expect(screen.queryByTestId("lane-more")).toBeNull();
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
});
});
@@ -249,17 +216,17 @@ describe("LaneCard git block", () => {
it("renders the card with no git block and no error when git is unavailable", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
render(<LaneCard lane={makeLane({ title: "plain dir lane" })} onAction={vi.fn()} />);
render(<LaneCard lane={makeLane({ id: 2 })} onAction={vi.fn()} />);
expect(await screen.findByText("plain dir lane")).toBeInTheDocument();
expect(await screen.findByTestId("lane-card-2")).toBeInTheDocument();
expect(screen.queryByTestId("lane-git")).toBeNull();
});
it("swallows a rejected request instead of surfacing an error", async () => {
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
render(<LaneCard lane={makeLane({ title: "offline lane" })} onAction={vi.fn()} />);
render(<LaneCard lane={makeLane({ id: 3 })} onAction={vi.fn()} />);
expect(await screen.findByText("offline lane")).toBeInTheDocument();
expect(await screen.findByTestId("lane-card-3")).toBeInTheDocument();
expect(screen.queryByTestId("lane-git")).toBeNull();
expect(screen.queryByText(/network down/)).toBeNull();
});
@@ -299,11 +266,10 @@ describe("LaneCard — the lane's own application stack", () => {
it("shows nothing at all for a lane whose repo declares no profile", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
render(<LaneCard lane={makeLane({ title: "no profile" })} onAction={vi.fn()} />);
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
expect(await screen.findByText("no profile")).toBeInTheDocument();
expect(await screen.findByTestId("lane-card-1")).toBeInTheDocument();
expect(screen.queryByTestId("lane-runtime-1")).toBeNull();
expect(screen.queryByTestId("lane-runtime-toggle")).toBeNull();
});
it("lists each declared port with its listening state", async () => {
@@ -327,42 +293,6 @@ describe("LaneCard — the lane's own application stack", () => {
expect(screen.getByText(/8003/)).toBeInTheDocument();
});
it("stops the stack through /down, never through the run's onAction prop", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime);
vi.mocked(api.lanes.down).mockResolvedValue({
ok: true,
killed: [4242],
runtime: { available: false },
});
const onAction = vi.fn();
render(<LaneCard lane={makeLane()} onAction={onAction} />);
await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle"));
await waitFor(() => expect(api.lanes.down).toHaveBeenCalledWith(1));
expect(api.lanes.up).not.toHaveBeenCalled();
expect(onAction).not.toHaveBeenCalled();
});
it("boots a provisioned-but-down lane and stays busy past the 202", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({
...upRuntime,
services: [{ name: "web", pid: 4242, alive: false }],
ports: { api: { port: 8003, expected: 8003, listening: false } },
up: false,
healthy: false,
});
vi.mocked(api.lanes.up).mockResolvedValue({ ok: true, laneId: 1 });
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle"));
await waitFor(() => expect(api.lanes.up).toHaveBeenCalledWith(1));
// The request resolving is not the stack being up: the server answered 202
// and is still booting, so the button must not go idle yet.
await waitFor(() => expect(screen.getByTestId("lane-runtime-toggle")).toBeDisabled());
});
it("surfaces the last boot error when nothing is streaming", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({
...upRuntime,
@@ -398,7 +328,7 @@ describe("LaneCard — named locks", () => {
});
});
describe("agents install / mcp sync / integration badges / sync check", () => {
describe("LaneCard — integration badges", () => {
beforeEach(() => {
// Only the fields this describe block's assertions read; `as LaneRuntime`
// keeps the double from having to restate a shape the component never
@@ -424,13 +354,33 @@ describe("agents install / mcp sync / integration badges / sync check", () => {
expect(devQc.className).not.toContain("status-success");
});
it("hides integration badges when the lane has no profile", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
});
});
describe("LaneToolsMenu", () => {
beforeEach(() => {
vi.mocked(api.lanes.runtime).mockResolvedValue({
available: true as const,
provisioned: true as const,
up: false,
slot: 1,
profileDir: "/work/demo/.ccam/profile",
ports: {},
} as unknown as LaneRuntime);
});
it("clicking Install agents calls the API and shows the result", async () => {
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({
installed: ["qc-local.md", "senior-gate-reviewer.md"],
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const button = await screen.findByTestId("lane-agents-install");
await userEvent.click(button);
render(<LaneToolsMenu lane={makeLane()} />);
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
await userEvent.click(await screen.findByTestId("lane-agents-install"));
await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1));
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("qc-local.md");
});
@@ -440,9 +390,9 @@ describe("agents install / mcp sync / integration badges / sync check", () => {
servers: ["playwright"],
profilesSeeded: [],
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const button = await screen.findByTestId("lane-mcp-sync");
await userEvent.click(button);
render(<LaneToolsMenu lane={makeLane()} />);
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
await userEvent.click(await screen.findByTestId("lane-mcp-sync"));
await waitFor(() => expect(api.lanes.mcpSync).toHaveBeenCalledWith(1));
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("playwright");
});
@@ -453,9 +403,9 @@ describe("agents install / mcp sync / integration badges / sync check", () => {
devDelta: ["a.txt", "b.txt"],
overlap: [],
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const button = await screen.findByTestId("lane-sync-check");
await userEvent.click(button);
render(<LaneToolsMenu lane={makeLane()} />);
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
await userEvent.click(await screen.findByTestId("lane-sync-check"));
await waitFor(() => expect(api.lanes.syncBaseCheck).toHaveBeenCalledWith(1));
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("2");
});
@@ -465,19 +415,16 @@ describe("agents install / mcp sync / integration badges / sync check", () => {
code: 5,
collisions: [{ file: "002_a.sql", collidesWith: "002_b.sql", suggestion: "003_a.sql" }],
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const button = await screen.findByTestId("lane-sync-check");
await userEvent.click(button);
render(<LaneToolsMenu lane={makeLane()} />);
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
await userEvent.click(await screen.findByTestId("lane-sync-check"));
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
});
it("hides all four additions when the lane has no profile", async () => {
it("renders nothing when the lane has no profile", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const { container } = render(<LaneToolsMenu lane={makeLane()} />);
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
expect(screen.queryByTestId("lane-agents-install")).toBeNull();
expect(screen.queryByTestId("lane-mcp-sync")).toBeNull();
expect(screen.queryByTestId("lane-sync-check")).toBeNull();
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
expect(container).toBeEmptyDOMElement();
});
});
+2 -1
View File
@@ -1,6 +1,6 @@
{
"action.clear": "clear state",
"action.forget": "delete lane",
"action.forget": "Delete lane",
"action.purge": "delete history",
"action.remove": "delete lane + worktree",
"action.reset": "reset worktree",
@@ -16,6 +16,7 @@
"actions.syncCheck": "Check dev sync",
"actions.syncCheckClean": "DEV_DELTA: {{count}} file(s), overlap: {{overlap}}",
"actions.syncCheckCollision": "Migration collision: {{file}} → rename to {{suggestion}}",
"actions.tools": "Tools",
"add": "Add lane",
"addLane": "Create a lane from a working directory",
"addLaneBaseLabel": "Branch to fork from",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"action.clear": "Dọn trạng thái",
"action.forget": "Xóa làn",
"action.forget": "Xóa lane",
"action.purge": "Xóa lịch sử",
"action.remove": "Xóa làn + worktree",
"action.reset": "Đặt lại worktree",
@@ -16,6 +16,7 @@
"actions.syncCheck": "Kiểm tra đồng bộ dev",
"actions.syncCheckClean": "DEV_DELTA: {{count}} file, trùng: {{overlap}}",
"actions.syncCheckCollision": "Trùng migration: {{file}} → đổi tên thành {{suggestion}}",
"actions.tools": "Công cụ",
"add": "Thêm lane",
"addLane": "Tạo lane từ một thư mục làm việc",
"addLaneBaseLabel": "Nhánh để tạo nhánh mới",
+55 -19
View File
@@ -48,9 +48,10 @@ import type { Lane, LaneFeature, LaneCounts, ProofFeature, Session, WSMessage }
import { eventBus } from "../lib/eventBus";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard from "../components/lanes/LaneCard";
import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard";
import LaneStripCard from "../components/lanes/LaneStripCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
@@ -182,6 +183,7 @@ export function Workspace() {
>([]);
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
// Run state kept at page level: shared across every pane, or drives the
// lane strip itself rather than any one pane's form.
@@ -511,14 +513,17 @@ export function Workspace() {
</span>
)}
</div>
<button
onClick={() => setAddLaneOpen(true)}
className="ml-auto flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
title={tLanes("addLane")}
>
<Plus className="h-3.5 w-3.5" />
{tLanes("add")}
</button>
<div className="ml-auto flex items-center gap-2">
{currentLane && <LaneToolsMenu lane={currentLane} />}
<button
onClick={() => setAddLaneOpen(true)}
className="flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
title={tLanes("addLane")}
>
<Plus className="h-3.5 w-3.5" />
{tLanes("add")}
</button>
</div>
</div>
{/* Lane strip + detail stack vertically: the strip is a horizontally
@@ -549,13 +554,24 @@ export function Workspace() {
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 min-w-0 items-center gap-2">
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
{currentLane.liveness === "dead"
? tLanes("statusDead")
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
</span>
<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>
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{since(currentLane.stage_seconds)}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
@@ -572,9 +588,6 @@ export function Workspace() {
{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>
<select
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
@@ -634,6 +647,16 @@ export function Workspace() {
))}
</select>
)}
<button
type="button"
data-testid="lane-action-remove"
onClick={() => setRemoveConfirmOpen(true)}
className="ml-auto rounded px-2 py-0.5 text-status-danger transition-colors hover:bg-status-danger/15"
>
{currentLane.kind === "adopted"
? tLanes("action.forget")
: tLanes("action.remove")}
</button>
</div>
<div className="mb-3">
<LaneCard
@@ -749,6 +772,19 @@ export function Workspace() {
)}
</div>
{currentLane && removeConfirmOpen && (
<DestructiveLaneModal
lane={currentLane}
action="remove"
open
onClose={() => setRemoveConfirmOpen(false)}
onConfirm={(body) => {
setRemoveConfirmOpen(false);
void handleLaneAction(currentLane.id, "remove", body);
}}
/>
)}
<AddLaneModal
open={addLaneOpen}
cwdSuggestions={cwdSuggestions}
@@ -5735,31 +5735,35 @@ exports[`screen snapshots > Run 1`] = `
running
</span>
</div>
<button
class="ml-auto flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
title="Create a lane from a working directory"
<div
class="ml-auto flex items-center gap-2"
>
<svg
class="lucide lucide-plus h-3.5 w-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"
<button
class="flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
title="Create a lane from a working directory"
>
<path
d="M5 12h14"
/>
<path
d="M12 5v14"
/>
</svg>
Add lane
</button>
<svg
class="lucide lucide-plus h-3.5 w-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="M5 12h14"
/>
<path
d="M12 5v14"
/>
</svg>
Add lane
</button>
</div>
</div>
<div
class="flex min-h-0 flex-1 flex-col gap-4"