00a2aedb09
Two gaps left by the AI Router feature: a profile could be saved without ever
being reachable, and a lane's router could only be chosen at creation.
**POST /api/ai-routers/test** answers the question saving cannot. It sends one
Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages`
with a 15s timeout — the wire shape a claude lane's eight env keys point at, so
what is tested is what will actually run. A router that answers is a 200 here
whatever it replies: `{ ok, status, message }` carries the router's own words,
and `status` is null when nothing answered at all. Only bad input is a 4xx.
The stored key is only ever sent to the endpoint it was stored against. A blank
`apiKey` falls back to the key of the profile named by `name`, but only while
the submitted `baseUrl` still equals the saved one — so testing a retyped
endpoint needs a retyped key (`EBADKEY`). Without that rule this route would be
a way to make the server POST a saved API key to any URL a browser names.
Network failures report `error.cause`, not `error.message`: node's fetch reports
every transport failure as the same useless "fetch failed" and hides the part
the user can act on (ECONNREFUSED, ENOTFOUND, a TLS error) one level down.
**The lane card carries an AI Router select.** The Add-lane modal was the only
place a router was chosen, and a lane outlives its creation. Switching goes
through `POST /api/lanes/:id/router/apply` — never a lane PATCH — because the
DB column and the lane's own config on disk may only move together. A failed
apply puts the select back rather than leaving the card claiming a router the
lane did not get. Hidden when `native` is the only profile: a select with one
option is a control that cannot do anything.
Adds `Lane.router_profile` to the client type (the payload already carried it).
701 lines
25 KiB
TypeScript
701 lines
25 KiB
TypeScript
/**
|
|
* @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
|
|
* card also carries the lane's AI Router picker — the Add-lane modal is the
|
|
* only other place one is chosen, and a lane outlives its creation. 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>
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
|
import { api } from "../../lib/api";
|
|
import { eventBus } from "../../lib/eventBus";
|
|
import { keepIfSame } from "../../lib/keepIfSame";
|
|
import type { AiRouter } from "../../lib/api";
|
|
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
|
|
|
|
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
|
* each call is three git subprocesses server-side, and a branch name does not
|
|
* change on the timescale the lane list is polled at. */
|
|
const GIT_REFRESH_MS = 30_000;
|
|
|
|
/** How often a mounted card re-probes its stack. Faster than the git refresh
|
|
* because a stack dying is exactly what the user needs to see, and slower than
|
|
* the lane poll because each call opens a socket per declared port. */
|
|
const RUNTIME_REFRESH_MS = 10_000;
|
|
|
|
/** How often a mounted card re-reads locks held by this lane. Same refresh rate
|
|
* as the git facts (slow, since lock state changes infrequently). */
|
|
const LOCKS_REFRESH_MS = 30_000;
|
|
|
|
/**
|
|
* The lane's own working copy, fetched per card rather than folded into the
|
|
* polled lane list. Absent facts are not an error state: a lane may point at a
|
|
* plain directory, and it renders as a card without a git row.
|
|
*/
|
|
function useLaneGitFacts(laneId: number): LaneGitFacts | null {
|
|
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
const read = () => {
|
|
api.lanes
|
|
.git(laneId)
|
|
.then((f) => {
|
|
if (alive) setFacts(keepIfSame(f));
|
|
})
|
|
.catch(() => {
|
|
// Silent: a card that cannot read git shows the rest of itself. An
|
|
// error banner here would fire on every lane on every server blip.
|
|
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
|
|
});
|
|
};
|
|
read();
|
|
const timer = setInterval(read, GIT_REFRESH_MS);
|
|
return () => {
|
|
alive = false;
|
|
clearInterval(timer);
|
|
};
|
|
}, [laneId]);
|
|
|
|
return facts;
|
|
}
|
|
|
|
/**
|
|
* The lane's own application stack. Same shape and same silence as the git
|
|
* facts above: a lane without a `.agenthub/profile` simply has no runtime row, which
|
|
* is the common case and not an error worth a banner.
|
|
*
|
|
* `bump` lets an up/down action re-read immediately instead of waiting out the
|
|
* poll interval.
|
|
*/
|
|
export function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null {
|
|
const [runtime, setRuntime] = useState<LaneRuntime | null>(null);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
const read = () => {
|
|
api.lanes
|
|
.runtime(laneId)
|
|
.then((r) => {
|
|
if (alive) setRuntime(keepIfSame(r));
|
|
})
|
|
.catch(() => {
|
|
if (alive) setRuntime(keepIfSame<LaneRuntime>({ available: false }));
|
|
});
|
|
};
|
|
read();
|
|
const timer = setInterval(read, RUNTIME_REFRESH_MS);
|
|
return () => {
|
|
alive = false;
|
|
clearInterval(timer);
|
|
};
|
|
}, [laneId, bump]);
|
|
|
|
return runtime;
|
|
}
|
|
|
|
/**
|
|
* Locks held by THIS lane, polled the same way runtime/git facts are.
|
|
*/
|
|
function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
|
const [locks, setLocks] = useState<NamedLock[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!laneSlot) return;
|
|
let alive = true;
|
|
const holder = `lane${laneSlot}`;
|
|
const read = () => {
|
|
api.locks
|
|
.list()
|
|
.then((data) => {
|
|
if (alive) setLocks(keepIfSame(data.locks.filter((l) => l.holder === holder)));
|
|
})
|
|
.catch(() => {
|
|
/* fails silently, same contract as the git/runtime pollers */
|
|
});
|
|
};
|
|
read();
|
|
const timer = setInterval(read, LOCKS_REFRESH_MS);
|
|
return () => {
|
|
alive = false;
|
|
clearInterval(timer);
|
|
};
|
|
}, [laneSlot]);
|
|
|
|
return locks;
|
|
}
|
|
|
|
const INTEGRATION_NAMES = ["tracker", "dev_qc", "ci_wait"] as const;
|
|
|
|
function useLaneIntegrations(
|
|
laneId: number,
|
|
available: boolean
|
|
): Record<(typeof INTEGRATION_NAMES)[number], boolean> | null {
|
|
const [state, setState] = useState<Record<string, boolean> | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!available) {
|
|
setState(null);
|
|
return;
|
|
}
|
|
let alive = true;
|
|
Promise.all(INTEGRATION_NAMES.map((name) => api.lanes.integration(laneId, name)))
|
|
.then((results) => {
|
|
if (!alive) return;
|
|
const next: Record<string, boolean> = {};
|
|
INTEGRATION_NAMES.forEach((name, i) => {
|
|
next[name] = results[i]?.enabled ?? false;
|
|
});
|
|
setState(next);
|
|
})
|
|
.catch(() => {
|
|
if (alive) setState(null);
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [laneId, available]);
|
|
|
|
return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
|
|
}
|
|
|
|
/**
|
|
* Which AI Router this lane runs against, and the one place it can be changed
|
|
* after the lane exists — the Add-lane modal is the only other one, and a lane
|
|
* outlives the moment it was created.
|
|
*
|
|
* Applying writes the lane's own config on disk as well as the DB column, so it
|
|
* goes through `POST /lanes/:id/router/apply` and never a PATCH. A failure puts
|
|
* the select back: the row must not claim a router the lane did not get.
|
|
*
|
|
* Rendered only when there is something to choose. A user who has never opened
|
|
* the AI Router tab has exactly the built-in `native` profile, and a select
|
|
* with one option is a control that cannot do anything.
|
|
*/
|
|
function LaneRouterRow({ lane }: { lane: Lane }) {
|
|
const { t } = useTranslation(["lanes"]);
|
|
const [routers, setRouters] = useState<AiRouter[] | null>(null);
|
|
const [current, setCurrent] = useState(lane.router_profile || "native");
|
|
const [busy, setBusy] = useState(false);
|
|
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
|
|
|
|
// The polled lane list is the source of truth; a switch elsewhere (the lane
|
|
// was reset to native by a profile delete, say) has to land here too.
|
|
useEffect(() => {
|
|
setCurrent(lane.router_profile || "native");
|
|
setNote(null);
|
|
}, [lane.id, lane.router_profile]);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
api.aiRouters
|
|
.list()
|
|
.then((r) => {
|
|
if (alive) setRouters(r.items);
|
|
})
|
|
.catch(() => {
|
|
// Silent, same contract as the git/runtime pollers above: a card that
|
|
// cannot list routers simply shows no router row.
|
|
if (alive) setRouters([]);
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, []);
|
|
|
|
if (!routers || routers.length <= 1) return null;
|
|
|
|
const change = async (name: string) => {
|
|
const previous = current;
|
|
setCurrent(name);
|
|
setBusy(true);
|
|
setNote(null);
|
|
try {
|
|
await api.lanes.routerApply(lane.id, name);
|
|
setNote({ ok: true, text: t("router.applied") });
|
|
} catch (err) {
|
|
setCurrent(previous);
|
|
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mb-2 text-[11px]" data-testid={`lane-router-${lane.id}`}>
|
|
<label className="flex items-center gap-1.5 text-fg-muted">
|
|
{t("router.label")}
|
|
<select
|
|
value={current}
|
|
disabled={busy}
|
|
onChange={(e) => void change(e.target.value)}
|
|
className="flex-1 truncate rounded border border-border-light bg-surface-2 px-1.5 py-0.5 text-fg-secondary disabled:opacity-50"
|
|
>
|
|
{routers.map((r) => (
|
|
<option key={r.name} value={r.name}>
|
|
{r.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{note && (
|
|
<p className={`mt-1 ${note.ok ? "text-fg-muted" : "text-status-danger"}`}>{note.text}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
|
active: "bg-status-success",
|
|
idle: "bg-surface-4",
|
|
dead: "bg-status-danger",
|
|
};
|
|
|
|
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`;
|
|
}
|
|
|
|
/**
|
|
* 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 [toolsMenuOpen, setToolsMenuOpen] = useState(false);
|
|
const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(
|
|
null
|
|
);
|
|
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
|
|
const runtime = useLaneRuntime(lane.id, 0);
|
|
|
|
const runLaneAction = async (
|
|
which: "agents" | "mcp" | "sync-check",
|
|
fn: () => Promise<string>
|
|
) => {
|
|
setLaneActionBusy(which);
|
|
setLaneActionResult(null);
|
|
try {
|
|
setLaneActionResult(await fn());
|
|
} catch (err) {
|
|
setLaneActionResult(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setLaneActionBusy(null);
|
|
}
|
|
};
|
|
|
|
const handleAgentsInstall = () =>
|
|
runLaneAction("agents", async () => {
|
|
const result = await api.lanes.agentsInstall(lane.id);
|
|
return t("actions.agentsInstallResult", { files: result.installed.join(", ") });
|
|
});
|
|
|
|
const handleMcpSync = () =>
|
|
runLaneAction("mcp", async () => {
|
|
const result = await api.lanes.mcpSync(lane.id);
|
|
return t("actions.mcpSyncResult", { servers: result.servers.join(", ") || "none" });
|
|
});
|
|
|
|
const handleSyncCheck = () =>
|
|
runLaneAction("sync-check", async () => {
|
|
const result = await api.lanes.syncBaseCheck(lane.id);
|
|
if (result.code === 5 && result.collisions && result.collisions.length > 0) {
|
|
const c = result.collisions[0]!;
|
|
return t("actions.syncCheckCollision", { file: c.file, suggestion: c.suggestion });
|
|
}
|
|
return t("actions.syncCheckClean", {
|
|
count: result.devDelta?.length ?? 0,
|
|
overlap: result.overlap?.length ? result.overlap.join(", ") : "none",
|
|
});
|
|
});
|
|
|
|
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
|
|
* lines are the honest progress indicator.
|
|
*/
|
|
useEffect(
|
|
() =>
|
|
eventBus.subscribe((msg) => {
|
|
const data = msg.data as { laneId?: number; line?: string } | undefined;
|
|
if (!data || data.laneId !== lane.id) return;
|
|
if (msg.type === "lane_hook_output" && typeof data.line === "string") {
|
|
setBootLine(data.line);
|
|
}
|
|
if (msg.type === "lane_runtime") {
|
|
setBootLine(null);
|
|
setRuntimeBump((n) => n + 1);
|
|
}
|
|
}),
|
|
[lane.id]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
data-testid={`lane-card-${lane.id}`}
|
|
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light"
|
|
>
|
|
{/* 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>
|
|
</div>
|
|
)}
|
|
|
|
{(lane.ci_status || lane.tool === "codex") && (
|
|
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
|
|
{lane.ci_status && (
|
|
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
|
|
CI {lane.ci_status}
|
|
</span>
|
|
)}
|
|
{lane.tool === "codex" && (
|
|
<span className="rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
|
|
Codex
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{lane.needs_action && (
|
|
<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>
|
|
)}
|
|
|
|
{/* Runtime strip: the lane's own app stack, shown only for lanes that
|
|
declare a profile. A port that drifted from `base + slot` is called
|
|
out — the number is otherwise predictable from the slot, and silently
|
|
serving on a different one is exactly the surprise worth flagging. */}
|
|
{runtime?.available && runtime.provisioned && (
|
|
<div
|
|
data-testid={`lane-runtime-${lane.id}`}
|
|
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>
|
|
<span
|
|
className={runtime.healthy ? "text-status-success" : "text-fg-muted"}
|
|
data-testid="lane-runtime-state"
|
|
>
|
|
{runtime.healthy
|
|
? t("runtime.healthy")
|
|
: runtime.up
|
|
? t("runtime.partial")
|
|
: t("runtime.down")}
|
|
</span>
|
|
</div>
|
|
{Object.entries(runtime.ports).map(([name, info]) => (
|
|
<div key={name} className="flex items-center gap-1.5 truncate">
|
|
<span
|
|
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
|
info.listening ? "bg-status-success" : "bg-surface-4"
|
|
}`}
|
|
/>
|
|
<span className="text-fg-secondary">{name}</span>
|
|
<span className="text-fg-muted">:{info.port ?? "—"}</span>
|
|
{info.port !== null && info.port !== info.expected && (
|
|
<span
|
|
className="truncate text-status-warning/80"
|
|
title={t("runtime.steppedAsideTitle", { expected: info.expected })}
|
|
>
|
|
⚠ {info.expected}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
{/* While a boot is in flight the hook's own latest line IS the
|
|
progress bar — a build can take minutes, and a disabled button
|
|
with nothing moving reads as a hang. */}
|
|
{bootLine !== null && (
|
|
<div
|
|
data-testid="lane-runtime-bootline"
|
|
className="mt-1 truncate text-fg-muted"
|
|
title={bootLine}
|
|
>
|
|
{bootLine}
|
|
</div>
|
|
)}
|
|
{bootLine === null && runtime.lastError && (
|
|
<div
|
|
className="mt-1 truncate text-status-danger/90"
|
|
title={runtime.lastError.message}
|
|
>
|
|
{runtime.lastError.code || "error"}: {runtime.lastError.message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{locks.length > 0 && (
|
|
<div
|
|
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(", ")}
|
|
>
|
|
🔒 {t("locks.held", { count: locks.length })}
|
|
</div>
|
|
)}
|
|
|
|
<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">
|
|
⑂ {git.branch}
|
|
<span className="ml-2 text-fg-muted">{git.head}</span>
|
|
</div>
|
|
<div className="truncate text-fg-muted" title={git.subject}>
|
|
{git.subject}
|
|
</div>
|
|
{(git.dirty > 0 || git.untracked > 0) && (
|
|
<div className="text-status-warning/80">
|
|
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* The lane's own recorded branch, shown only when git could not be
|
|
read — otherwise it duplicates the live branch above. */}
|
|
{!git?.available && lane.branch && <div className="truncate">⑂ {lane.branch}</div>}
|
|
<div className="truncate text-fg-muted" title={lane.cwd}>
|
|
{lane.cwd}
|
|
</div>
|
|
</dl>
|
|
|
|
<LaneRouterRow lane={lane} />
|
|
|
|
{childWorktrees && childWorktrees.length > 0 && (
|
|
<div
|
|
data-testid="lane-child-worktrees"
|
|
className="mb-2 space-y-1 text-[11px] text-fg-secondary"
|
|
>
|
|
<div className="text-fg-muted">
|
|
{t("worktrees.heading", { count: childWorktrees.length })}
|
|
</div>
|
|
<ul className="space-y-0.5">
|
|
{childWorktrees.map((w) => (
|
|
<li key={w.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelectLane?.(w.id)}
|
|
className="truncate text-left text-blue-400 hover:underline"
|
|
title={w.cwd}
|
|
>
|
|
#{w.id} {w.title || w.cwd} · {w.status}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{integrations && (
|
|
<div className="mb-1 flex items-center gap-1.5 text-[10px]">
|
|
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
|
|
<span
|
|
key={name}
|
|
data-testid={`lane-integration-${name}`}
|
|
className={`rounded-full px-2 py-0.5 ${
|
|
integrations[name]
|
|
? "bg-status-success/10 text-status-success"
|
|
: "bg-surface-2 text-fg-muted"
|
|
}`}
|
|
>
|
|
{t(`integrations.${name}`)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* mt-auto pins the controls to the bottom so cards of differing height
|
|
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-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")}
|
|
>
|
|
⋯
|
|
</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"
|
|
>
|
|
{t("action.reset")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{destructiveAction && (
|
|
<DestructiveLaneModal
|
|
lane={lane}
|
|
action={destructiveAction}
|
|
open
|
|
onClose={() => setDestructiveAction(null)}
|
|
onConfirm={(body) => {
|
|
setDestructiveAction(null);
|
|
onAction(destructiveAction, body);
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|