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:
@@ -1,13 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* @file One lane's card: title, progress bar with time-on-phase, branch/CI/PR
|
* @file One lane's card: progress bar, branch/CI/PR facts, the "needs you"
|
||||||
* facts, the "needs you" banner sourced from Claude Code's Notification hook,
|
* banner sourced from Claude Code's Notification hook, and (for managed
|
||||||
* and the control row — which ends in the two deletions (lane, history) as
|
* lanes only) a ⋯ menu holding the rare `reset worktree` action behind
|
||||||
* plain buttons, each gated by DestructiveLaneModal. A dead lane (its driving
|
* DestructiveLaneModal. A dead lane (its driving session went silent while
|
||||||
* session went silent while it should have been working) is called out
|
* it should have been working) is called out loudly — that is the failure
|
||||||
* loudly — that is the failure this view exists to catch. Stage, kind, and
|
* this view exists to catch. Stage, kind, and the "auto: <stage>" chip are
|
||||||
* the "auto: <stage>" chip are NOT repeated here — this card only ever
|
* NOT repeated here — this card only ever renders inside the Workspace
|
||||||
* renders inside the Workspace lane-detail section, whose header above it
|
* lane-detail section, whose header above it already shows them. The repo
|
||||||
* already shows them.
|
* 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>
|
* @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
|
* `bump` lets an up/down action re-read immediately instead of waiting out the
|
||||||
* poll interval.
|
* 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);
|
const [runtime, setRuntime] = useState<LaneRuntime | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -164,78 +167,34 @@ function useLaneIntegrations(
|
|||||||
return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
|
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",
|
active: "bg-status-success",
|
||||||
idle: "bg-surface-4",
|
idle: "bg-surface-4",
|
||||||
dead: "bg-status-danger",
|
dead: "bg-status-danger",
|
||||||
};
|
};
|
||||||
|
|
||||||
function since(sec: number | null): string {
|
export function since(sec: number | null): string {
|
||||||
if (sec === null) return "—";
|
if (sec === null) return "—";
|
||||||
if (sec < 60) return `${sec}s`;
|
if (sec < 60) return `${sec}s`;
|
||||||
if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}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`;
|
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LaneCard({
|
/**
|
||||||
lane,
|
* Repo tooling (agents install / MCP sync / dev-sync check) for one lane,
|
||||||
onAction,
|
* collapsed into a single dropdown. Lives outside LaneCard so it can be
|
||||||
childWorktrees,
|
* placed in the Workspace page header, next to "Add lane" — it polls its
|
||||||
onSelectLane,
|
* own runtime state independently of LaneCard's, same pattern as the
|
||||||
}: {
|
* separately-polled git/locks facts elsewhere on this page.
|
||||||
lane: Lane;
|
*/
|
||||||
onAction: (action: string, body?: Record<string, unknown>) => void;
|
export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||||
/** 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 { t } = useTranslation(["lanes"]);
|
||||||
const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>(
|
const [toolsMenuOpen, setToolsMenuOpen] = useState(false);
|
||||||
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 [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(
|
const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
|
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
|
||||||
const git = useLaneGitFacts(lane.id);
|
const runtime = useLaneRuntime(lane.id, 0);
|
||||||
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 runLaneAction = async (
|
const runLaneAction = async (
|
||||||
which: "agents" | "mcp" | "sync-check",
|
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
|
* 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
|
* 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);
|
setBootLine(data.line);
|
||||||
}
|
}
|
||||||
if (msg.type === "lane_runtime") {
|
if (msg.type === "lane_runtime") {
|
||||||
setRuntimeBusy(null);
|
|
||||||
setBootLine(null);
|
setBootLine(null);
|
||||||
setRuntimeBump((n) => n + 1);
|
setRuntimeBump((n) => n + 1);
|
||||||
}
|
}
|
||||||
@@ -303,52 +362,27 @@ export default function LaneCard({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
data-testid={`lane-card-${lane.id}`}
|
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"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
|
{/* 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
|
<div
|
||||||
data-testid="lane-progress-fill"
|
data-testid="lane-progress-fill"
|
||||||
className="h-full rounded-full bg-blue-600 transition-[width]"
|
className="h-full rounded-full bg-blue-600 transition-[width]"
|
||||||
style={{ width: `${lane.progress}%` }}
|
style={{ width: `${lane.progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{lane.progress > 0 && (
|
|
||||||
<span className="tabular-nums text-fg-secondary">{lane.progress}%</span>
|
<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 && (
|
{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">
|
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
|
||||||
CI {lane.ci_status}
|
CI {lane.ci_status}
|
||||||
</span>
|
</span>
|
||||||
@@ -356,7 +390,7 @@ export default function LaneCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{lane.needs_action && (
|
{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}
|
⚠ {lane.needs_action}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -368,7 +402,7 @@ export default function LaneCard({
|
|||||||
{runtime?.available && runtime.provisioned && (
|
{runtime?.available && runtime.provisioned && (
|
||||||
<div
|
<div
|
||||||
data-testid={`lane-runtime-${lane.id}`}
|
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">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
<span className="text-fg-muted">{t("runtime.slot", { slot: runtime.slot })}</span>
|
<span className="text-fg-muted">{t("runtime.slot", { slot: runtime.slot })}</span>
|
||||||
@@ -427,7 +461,7 @@ export default function LaneCard({
|
|||||||
|
|
||||||
{locks.length > 0 && (
|
{locks.length > 0 && (
|
||||||
<div
|
<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}`}
|
data-testid={`lane-locks-${lane.id}`}
|
||||||
title={locks.map((l) => `${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")}
|
title={locks.map((l) => `${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")}
|
||||||
>
|
>
|
||||||
@@ -435,7 +469,7 @@ export default function LaneCard({
|
|||||||
</div>
|
</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 && (
|
{git?.available && (
|
||||||
<div data-testid="lane-git" className="space-y-1">
|
<div data-testid="lane-git" className="space-y-1">
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
@@ -463,7 +497,7 @@ export default function LaneCard({
|
|||||||
{childWorktrees && childWorktrees.length > 0 && (
|
{childWorktrees && childWorktrees.length > 0 && (
|
||||||
<div
|
<div
|
||||||
data-testid="lane-child-worktrees"
|
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">
|
<div className="text-fg-muted">
|
||||||
{t("worktrees.heading", { count: childWorktrees.length })}
|
{t("worktrees.heading", { count: childWorktrees.length })}
|
||||||
@@ -486,7 +520,7 @@ export default function LaneCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{integrations && (
|
{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) => (
|
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
|
||||||
<span
|
<span
|
||||||
key={name}
|
key={name}
|
||||||
@@ -504,122 +538,12 @@ export default function LaneCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* mt-auto pins the controls to the bottom so cards of differing height
|
{/* mt-auto pins the controls to the bottom so cards of differing height
|
||||||
in one grid row still line their buttons up. */}
|
in one grid row still line their buttons up. Repo tooling and the
|
||||||
<div className="mt-auto flex items-center gap-1 border-t border-border pt-2 text-xs">
|
remove button live in the Workspace page header/pipeline row now —
|
||||||
{(["start", "stop", "clear"] as const).map((a) => (
|
`reset` is the one destructive action left here, since it only
|
||||||
<button
|
applies to managed lanes and is rare enough to stay behind ⋯. */}
|
||||||
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 && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="lane-agents-install"
|
|
||||||
disabled={laneActionBusy !== null}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
void handleAgentsInstall();
|
|
||||||
}}
|
|
||||||
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{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" && (
|
{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">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -656,17 +580,7 @@ export default function LaneCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{laneActionResult && (
|
|
||||||
<p
|
|
||||||
data-testid="lane-action-result"
|
|
||||||
className="mt-1 truncate text-[11px] text-fg-secondary"
|
|
||||||
>
|
|
||||||
{laneActionResult}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import LaneCard from "../LaneCard";
|
import LaneCard, { LaneToolsMenu } from "../LaneCard";
|
||||||
import type { Lane, LaneRuntime } from "../../../lib/types";
|
import type { Lane, LaneRuntime } from "../../../lib/types";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
|
|
||||||
@@ -85,16 +85,6 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
|
|||||||
} as 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", () => {
|
describe("LaneCard child worktrees", () => {
|
||||||
it("lists worktrees provisioned from this lane and jumps to one on click", async () => {
|
it("lists worktrees provisioned from this lane and jumps to one on click", async () => {
|
||||||
const onSelectLane = vi.fn();
|
const onSelectLane = vi.fn();
|
||||||
@@ -148,10 +138,9 @@ describe("LaneCard rebuilt layout", () => {
|
|||||||
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
|
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()} />);
|
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||||
expect(screen.getByText("48%")).toBeInTheDocument();
|
expect(screen.getByText("48%")).toBeInTheDocument();
|
||||||
expect(screen.getByText("2m 32s")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives the progress bar a width matching the lane's progress", () => {
|
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();
|
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fires a plain action with its own name", async () => {
|
it("keeps reset behind the ⋯ menu rather than showing it directly", 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 () => {
|
|
||||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
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();
|
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
|
||||||
|
|
||||||
await userEvent.setup().click(screen.getByTestId("lane-more"));
|
await userEvent.setup().click(screen.getByTestId("lane-more"));
|
||||||
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
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 () => {
|
it("routes reset through the confirmation modal rather than firing it", async () => {
|
||||||
const onAction = vi.fn();
|
const onAction = vi.fn();
|
||||||
render(<LaneCard lane={full()} onAction={onAction} />);
|
render(<LaneCard lane={full()} onAction={onAction} />);
|
||||||
@@ -202,18 +170,17 @@ describe("LaneCard rebuilt layout", () => {
|
|||||||
expect(onAction).not.toHaveBeenCalled();
|
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 user = userEvent.setup();
|
||||||
const { unmount } = render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
const { unmount } = render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||||
await user.click(screen.getByTestId("lane-more"));
|
await user.click(screen.getByTestId("lane-more"));
|
||||||
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
||||||
unmount();
|
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()} />);
|
render(<LaneCard lane={full({ kind: "adopted" })} onAction={vi.fn()} />);
|
||||||
expect(screen.queryByTestId("lane-more")).toBeNull();
|
expect(screen.queryByTestId("lane-more")).toBeNull();
|
||||||
expect(screen.queryByTestId("lane-action-reset")).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 () => {
|
it("renders the card with no git block and no error when git is unavailable", async () => {
|
||||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
|
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();
|
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("swallows a rejected request instead of surfacing an error", async () => {
|
it("swallows a rejected request instead of surfacing an error", async () => {
|
||||||
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
|
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.queryByTestId("lane-git")).toBeNull();
|
||||||
expect(screen.queryByText(/network down/)).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 () => {
|
it("shows nothing at all for a lane whose repo declares no profile", async () => {
|
||||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
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-1")).toBeNull();
|
||||||
expect(screen.queryByTestId("lane-runtime-toggle")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists each declared port with its listening state", async () => {
|
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();
|
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 () => {
|
it("surfaces the last boot error when nothing is streaming", async () => {
|
||||||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||||
...upRuntime,
|
...upRuntime,
|
||||||
@@ -398,7 +328,7 @@ describe("LaneCard — named locks", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("agents install / mcp sync / integration badges / sync check", () => {
|
describe("LaneCard — integration badges", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Only the fields this describe block's assertions read; `as LaneRuntime`
|
// Only the fields this describe block's assertions read; `as LaneRuntime`
|
||||||
// keeps the double from having to restate a shape the component never
|
// 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");
|
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 () => {
|
it("clicking Install agents calls the API and shows the result", async () => {
|
||||||
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({
|
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({
|
||||||
installed: ["qc-local.md", "senior-gate-reviewer.md"],
|
installed: ["qc-local.md", "senior-gate-reviewer.md"],
|
||||||
});
|
});
|
||||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
render(<LaneToolsMenu lane={makeLane()} />);
|
||||||
const button = await screen.findByTestId("lane-agents-install");
|
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||||
await userEvent.click(button);
|
await userEvent.click(await screen.findByTestId("lane-agents-install"));
|
||||||
await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1));
|
await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1));
|
||||||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("qc-local.md");
|
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"],
|
servers: ["playwright"],
|
||||||
profilesSeeded: [],
|
profilesSeeded: [],
|
||||||
});
|
});
|
||||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
render(<LaneToolsMenu lane={makeLane()} />);
|
||||||
const button = await screen.findByTestId("lane-mcp-sync");
|
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||||
await userEvent.click(button);
|
await userEvent.click(await screen.findByTestId("lane-mcp-sync"));
|
||||||
await waitFor(() => expect(api.lanes.mcpSync).toHaveBeenCalledWith(1));
|
await waitFor(() => expect(api.lanes.mcpSync).toHaveBeenCalledWith(1));
|
||||||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("playwright");
|
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"],
|
devDelta: ["a.txt", "b.txt"],
|
||||||
overlap: [],
|
overlap: [],
|
||||||
});
|
});
|
||||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
render(<LaneToolsMenu lane={makeLane()} />);
|
||||||
const button = await screen.findByTestId("lane-sync-check");
|
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||||
await userEvent.click(button);
|
await userEvent.click(await screen.findByTestId("lane-sync-check"));
|
||||||
await waitFor(() => expect(api.lanes.syncBaseCheck).toHaveBeenCalledWith(1));
|
await waitFor(() => expect(api.lanes.syncBaseCheck).toHaveBeenCalledWith(1));
|
||||||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("2");
|
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("2");
|
||||||
});
|
});
|
||||||
@@ -465,19 +415,16 @@ describe("agents install / mcp sync / integration badges / sync check", () => {
|
|||||||
code: 5,
|
code: 5,
|
||||||
collisions: [{ file: "002_a.sql", collidesWith: "002_b.sql", suggestion: "003_a.sql" }],
|
collisions: [{ file: "002_a.sql", collidesWith: "002_b.sql", suggestion: "003_a.sql" }],
|
||||||
});
|
});
|
||||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
render(<LaneToolsMenu lane={makeLane()} />);
|
||||||
const button = await screen.findByTestId("lane-sync-check");
|
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||||
await userEvent.click(button);
|
await userEvent.click(await screen.findByTestId("lane-sync-check"));
|
||||||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
|
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 });
|
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());
|
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
|
||||||
expect(screen.queryByTestId("lane-agents-install")).toBeNull();
|
expect(container).toBeEmptyDOMElement();
|
||||||
expect(screen.queryByTestId("lane-mcp-sync")).toBeNull();
|
|
||||||
expect(screen.queryByTestId("lane-sync-check")).toBeNull();
|
|
||||||
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"action.clear": "clear state",
|
"action.clear": "clear state",
|
||||||
"action.forget": "delete lane",
|
"action.forget": "Delete lane",
|
||||||
"action.purge": "delete history",
|
"action.purge": "delete history",
|
||||||
"action.remove": "delete lane + worktree",
|
"action.remove": "delete lane + worktree",
|
||||||
"action.reset": "reset worktree",
|
"action.reset": "reset worktree",
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"actions.syncCheck": "Check dev sync",
|
"actions.syncCheck": "Check dev sync",
|
||||||
"actions.syncCheckClean": "DEV_DELTA: {{count}} file(s), overlap: {{overlap}}",
|
"actions.syncCheckClean": "DEV_DELTA: {{count}} file(s), overlap: {{overlap}}",
|
||||||
"actions.syncCheckCollision": "Migration collision: {{file}} → rename to {{suggestion}}",
|
"actions.syncCheckCollision": "Migration collision: {{file}} → rename to {{suggestion}}",
|
||||||
|
"actions.tools": "Tools",
|
||||||
"add": "Add lane",
|
"add": "Add lane",
|
||||||
"addLane": "Create a lane from a working directory",
|
"addLane": "Create a lane from a working directory",
|
||||||
"addLaneBaseLabel": "Branch to fork from",
|
"addLaneBaseLabel": "Branch to fork from",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"action.clear": "Dọn trạng thái",
|
"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.purge": "Xóa lịch sử",
|
||||||
"action.remove": "Xóa làn + worktree",
|
"action.remove": "Xóa làn + worktree",
|
||||||
"action.reset": "Đặt lại worktree",
|
"action.reset": "Đặt lại worktree",
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"actions.syncCheck": "Kiểm tra đồng bộ dev",
|
"actions.syncCheck": "Kiểm tra đồng bộ dev",
|
||||||
"actions.syncCheckClean": "DEV_DELTA: {{count}} file, trùng: {{overlap}}",
|
"actions.syncCheckClean": "DEV_DELTA: {{count}} file, trùng: {{overlap}}",
|
||||||
"actions.syncCheckCollision": "Trùng migration: {{file}} → đổi tên thành {{suggestion}}",
|
"actions.syncCheckCollision": "Trùng migration: {{file}} → đổi tên thành {{suggestion}}",
|
||||||
|
"actions.tools": "Công cụ",
|
||||||
"add": "Thêm lane",
|
"add": "Thêm lane",
|
||||||
"addLane": "Tạo lane từ một thư mục làm việc",
|
"addLane": "Tạo lane từ một thư mục làm việc",
|
||||||
"addLaneBaseLabel": "Nhánh để tạo nhánh mới",
|
"addLaneBaseLabel": "Nhánh để tạo nhánh mới",
|
||||||
|
|||||||
@@ -48,9 +48,10 @@ import type { Lane, LaneFeature, LaneCounts, ProofFeature, Session, WSMessage }
|
|||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||||
import PipelineMap from "../components/lanes/PipelineMap";
|
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 LaneStripCard from "../components/lanes/LaneStripCard";
|
||||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||||
|
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||||
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
|
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
|
||||||
import type { SplitViewState, SplitLayout } 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 [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
||||||
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
||||||
|
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
|
||||||
|
|
||||||
// Run state kept at page level: shared across every pane, or drives the
|
// Run state kept at page level: shared across every pane, or drives the
|
||||||
// lane strip itself rather than any one pane's form.
|
// lane strip itself rather than any one pane's form.
|
||||||
@@ -511,15 +513,18 @@ export function Workspace() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{currentLane && <LaneToolsMenu lane={currentLane} />}
|
||||||
<button
|
<button
|
||||||
onClick={() => setAddLaneOpen(true)}
|
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"
|
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")}
|
title={tLanes("addLane")}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
{tLanes("add")}
|
{tLanes("add")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Lane strip + detail stack vertically: the strip is a horizontally
|
{/* Lane strip + detail stack vertically: the strip is a horizontally
|
||||||
scrolling row (holds any number of lanes), the detail/console area
|
scrolling row (holds any number of lanes), the detail/console area
|
||||||
@@ -549,6 +554,13 @@ export function Workspace() {
|
|||||||
className="flex min-h-0 min-w-0 flex-1 flex-col card p-4"
|
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">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<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">
|
<span className="truncate text-sm font-semibold text-fg-primary">
|
||||||
{tLanes("laneHeader", {
|
{tLanes("laneHeader", {
|
||||||
id: currentLane.id,
|
id: currentLane.id,
|
||||||
@@ -556,6 +568,10 @@ export function Workspace() {
|
|||||||
pipeline: currentLane.pipeline_name,
|
pipeline: currentLane.pipeline_name,
|
||||||
})}
|
})}
|
||||||
</span>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -572,9 +588,6 @@ export function Workspace() {
|
|||||||
{showInfo && (
|
{showInfo && (
|
||||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||||
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
|
|
||||||
{tLanes("cardId", { id: currentLane.id })}
|
|
||||||
</span>
|
|
||||||
<select
|
<select
|
||||||
data-testid="pipeline-picker"
|
data-testid="pipeline-picker"
|
||||||
aria-label={tLanes("pipelinePicker.label")}
|
aria-label={tLanes("pipelinePicker.label")}
|
||||||
@@ -634,6 +647,16 @@ export function Workspace() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<LaneCard
|
<LaneCard
|
||||||
@@ -749,6 +772,19 @@ export function Workspace() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{currentLane && removeConfirmOpen && (
|
||||||
|
<DestructiveLaneModal
|
||||||
|
lane={currentLane}
|
||||||
|
action="remove"
|
||||||
|
open
|
||||||
|
onClose={() => setRemoveConfirmOpen(false)}
|
||||||
|
onConfirm={(body) => {
|
||||||
|
setRemoveConfirmOpen(false);
|
||||||
|
void handleLaneAction(currentLane.id, "remove", body);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<AddLaneModal
|
<AddLaneModal
|
||||||
open={addLaneOpen}
|
open={addLaneOpen}
|
||||||
cwdSuggestions={cwdSuggestions}
|
cwdSuggestions={cwdSuggestions}
|
||||||
|
|||||||
@@ -5735,8 +5735,11 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
running
|
running
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="ml-auto flex items-center gap-2"
|
||||||
|
>
|
||||||
<button
|
<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"
|
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"
|
title="Create a lane from a working directory"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -5761,6 +5764,7 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
Add lane
|
Add lane
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user