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
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user