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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user