5ba9e07d78
Closes #19, #20, #21, #27. #19 — a saved auth profile now reports how much of its account's quota is already spent, which is the only number that decides which one is worth switching to. Nothing under CLAUDE_HOME records it, so it comes from the same endpoint the CLI's own /usage reads, answered for whichever account's OAuth token is presented. It is a POST and a button, never a poll: every profile that is not the active one is normally expired, so answering means refreshing it first, and a refresh ROTATES the stored refresh token. The rotated pair is written straight back — dropping it would leave the profile holding a credential Anthropic has already retired, i.e. an account needing a fresh login. The token is read, put in an Authorization header, and never returned, logged or echoed; what leaves the server is two percentages and two reset times. #20 — a settings patch that changes nothing wrote nothing but still took a timestamped backup, so opening the config panel quietly grew a backup directory inside every lane. patchSettings now compares the rendered output with what is already on disk and returns early, fixed once where all callers route through. A lane's git exclude also picks up .claude/.cc-config-backups/ beside settings.local.json. #21 — the router apply route was the only mutating lane route that never broadcast its lane. The write reached disk and the column, but the client's cached row kept the old router_profile forever: the picker looked right until the user switched lanes and back, at which point the effect re-read the stale row and flipped the selection to the previous profile. One broadcastLane, plus a lane-switch guard so an apply that resolves after the user has moved on no longer paints its result onto whichever lane is now on screen. #27 — Add-lane in worktree mode left the dialog up after a clean setup with a dead confirm button and no obvious way out. It closes itself when nothing failed, and when something did, the summary stays and the confirm becomes a real Close.
833 lines
32 KiB
TypeScript
833 lines
32 KiB
TypeScript
/**
|
||
* @file One lane's card: 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. Neither is
|
||
* progress: the pipeline map directly below this card draws the same number
|
||
* as named, coloured nodes, so a bar and a percent beside it were a worse
|
||
* second telling of it. 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 { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
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, LaneGitFile, LaneRuntime, NamedLock } from "../../lib/types";
|
||
|
||
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
||
* each call is five 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;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
export 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]);
|
||
|
||
// One instance of this row serves every lane — it lives in the Workspace's
|
||
// lane-detail header, and switching lanes swaps its `lane` prop rather than
|
||
// remounting it. So an apply that is still in flight when the user moves on
|
||
// would land its rollback and its note on whatever lane is showing by then:
|
||
// lane B's failed switch putting lane B's old selection into lane A's picker.
|
||
const shownLane = useRef(lane.id);
|
||
useEffect(() => {
|
||
shownLane.current = lane.id;
|
||
}, [lane.id]);
|
||
|
||
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 laneId = lane.id;
|
||
const previous = current;
|
||
setCurrent(name);
|
||
setBusy(true);
|
||
setNote(null);
|
||
try {
|
||
await api.lanes.routerApply(laneId, name);
|
||
if (shownLane.current !== laneId) return;
|
||
setNote({ ok: true, text: t("router.applied") });
|
||
} catch (err) {
|
||
if (shownLane.current !== laneId) return;
|
||
setCurrent(previous);
|
||
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
|
||
} finally {
|
||
// `busy` belongs to the control on screen, not to the request, so it is
|
||
// cleared either way — a lane switch must never leave the picker locked.
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<label
|
||
className="flex items-center gap-1.5 text-xs text-fg-muted"
|
||
data-testid={`lane-router-${lane.id}`}
|
||
>
|
||
{t("router.label")}
|
||
<select
|
||
value={current}
|
||
disabled={busy}
|
||
onChange={(e) => void change(e.target.value)}
|
||
className="max-w-40 truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-fg-secondary disabled:opacity-50"
|
||
>
|
||
{routers.map((r) => (
|
||
<option key={r.name} value={r.name}>
|
||
{r.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{/* The applied-note is a tooltip, not a line of its own: it fires on a
|
||
click the user just made, and a wrapped sentence under the control
|
||
would push every row below it down for the life of the selection.
|
||
A failure still gets visible colour, because that one is not
|
||
self-evident from the select. */}
|
||
{note && (
|
||
<span className={note.ok ? "text-fg-muted" : "text-status-danger"} title={note.text}>
|
||
{note.ok ? "✓" : "⚠"}
|
||
</span>
|
||
)}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
/** Porcelain status code -> the letter shown and the colour it is shown in.
|
||
* Keyed on the code's first meaningful character, so an index-side change
|
||
* (`M `), a worktree-side one (` M`) and both (`MM`) read the same. */
|
||
const GIT_STATUS_STYLE: Record<string, string> = {
|
||
M: "text-status-warning",
|
||
A: "text-status-success",
|
||
D: "text-status-danger",
|
||
R: "text-accent",
|
||
C: "text-accent",
|
||
U: "text-status-danger",
|
||
"?": "text-fg-muted",
|
||
};
|
||
|
||
/**
|
||
* The file list behind a lane's uncommitted counts: which files, what happened
|
||
* to each, and how many lines it gained and lost.
|
||
*
|
||
* It replaced a native `title` tooltip, which could show the same paths but
|
||
* neither colour them nor align the deltas — and a plain porcelain dump is not
|
||
* something you read at a glance.
|
||
*
|
||
* PORTALLED to `document.body` and `position: fixed`, for the same reason this
|
||
* file's own header says the tools dropdown was moved out of the card: a lane
|
||
* card renders inside the Workspace's `max-h-[45vh] overflow-y-auto` box, and
|
||
* an absolutely positioned panel would be clipped by it for every card that is
|
||
* not at the very top. It flips above its anchor rather than off the bottom of
|
||
* the window, and clamps to the right edge.
|
||
*/
|
||
function LaneGitFilesPanel({
|
||
files,
|
||
hidden,
|
||
anchor,
|
||
onPointerEnter,
|
||
onPointerLeave,
|
||
}: {
|
||
files: LaneGitFile[];
|
||
hidden: number;
|
||
/** The counts button this panel hangs off. */
|
||
anchor: HTMLElement | null;
|
||
onPointerEnter: () => void;
|
||
onPointerLeave: () => void;
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
const panelRef = useRef<HTMLDivElement>(null);
|
||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||
|
||
// After the first paint, when the panel's real height is known: place it
|
||
// under the anchor, or above it when that would run off the bottom.
|
||
useLayoutEffect(() => {
|
||
if (!anchor || !panelRef.current) return;
|
||
const a = anchor.getBoundingClientRect();
|
||
const p = panelRef.current.getBoundingClientRect();
|
||
const gap = 4;
|
||
const top =
|
||
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
|
||
setPos({
|
||
top: Math.max(8, top),
|
||
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
|
||
});
|
||
}, [anchor, files]);
|
||
// Totals are over the LISTED files only — the cap means the rest are unknown,
|
||
// and a total that silently omits them would be a wrong number rather than a
|
||
// partial one. `hidden` says how many are missing.
|
||
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
|
||
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
|
||
|
||
return createPortal(
|
||
<div
|
||
ref={panelRef}
|
||
data-testid="lane-git-files"
|
||
role="group"
|
||
aria-label={t("git.filesHeading", { count: files.length })}
|
||
// The pointer has to be able to travel INTO the panel to scroll it, so
|
||
// the panel answers the same enter/leave pair the trigger does.
|
||
onMouseEnter={onPointerEnter}
|
||
onMouseLeave={onPointerLeave}
|
||
style={{
|
||
position: "fixed",
|
||
zIndex: 60,
|
||
top: pos?.top ?? 0,
|
||
left: pos?.left ?? 0,
|
||
// Hidden for the one frame before it has been measured, so it never
|
||
// flashes at the top-left corner.
|
||
visibility: pos ? "visible" : "hidden",
|
||
}}
|
||
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
|
||
>
|
||
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
|
||
<span className="text-fg-secondary">{t("git.filesHeading", { count: files.length })}</span>
|
||
<span className="ml-auto shrink-0 font-mono tabular-nums">
|
||
<span className="text-status-success">+{added}</span>{" "}
|
||
<span className="text-status-danger">−{deleted}</span>
|
||
</span>
|
||
</div>
|
||
<ul className="space-y-0.5 font-mono text-[11px]">
|
||
{files.map((f) => {
|
||
const code = f.status.trim()[0] || "?";
|
||
return (
|
||
<li key={f.path} className="flex items-baseline gap-2">
|
||
<span
|
||
className={`w-3 shrink-0 font-semibold ${GIT_STATUS_STYLE[code] || "text-fg-muted"}`}
|
||
title={f.status}
|
||
>
|
||
{code}
|
||
</span>
|
||
{/* The DIRECTORY truncates, never the filename: the name is what
|
||
you scan for, the path is only there to disambiguate it. */}
|
||
<span className="flex min-w-0 flex-1 items-baseline" title={f.path}>
|
||
<span className="truncate text-fg-muted">{f.path.replace(/[^/]+$/, "")}</span>
|
||
<span className="shrink-0 text-fg-primary">{f.path.split("/").pop()}</span>
|
||
</span>
|
||
<span className="shrink-0 tabular-nums">
|
||
{f.added === null && f.deleted === null ? (
|
||
<span className="text-fg-muted">{t("git.noDelta")}</span>
|
||
) : (
|
||
<>
|
||
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
|
||
<span className="text-status-danger">−{f.deleted ?? 0}</span>
|
||
</>
|
||
)}
|
||
</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
{hidden > 0 && (
|
||
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
||
{t("git.filesMore", { count: hidden })}
|
||
</p>
|
||
)}
|
||
</div>,
|
||
document.body
|
||
);
|
||
}
|
||
|
||
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 [filesOpen, setFilesOpen] = useState(false);
|
||
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
|
||
// Closing is DELAYED: the panel is portalled away from the trigger, so the
|
||
// pointer leaves the trigger before it can land on the panel it is heading
|
||
// for. Any enter — trigger or panel — cancels the pending close.
|
||
const filesCloseTimer = useRef<number | undefined>(undefined);
|
||
const openFiles = useCallback(() => {
|
||
window.clearTimeout(filesCloseTimer.current);
|
||
setFilesOpen(true);
|
||
}, []);
|
||
const closeFiles = useCallback(() => {
|
||
window.clearTimeout(filesCloseTimer.current);
|
||
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
|
||
}, []);
|
||
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
|
||
const git = useLaneGitFacts(lane.id);
|
||
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||
const locks = useLaneLocks(lane.slot);
|
||
|
||
/**
|
||
* 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"
|
||
>
|
||
{(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>
|
||
)}
|
||
|
||
{/* One line, because every part of it is short and the panel is
|
||
narrow: branch, head, subject, and the uncommitted counts read as a
|
||
sentence rather than a four-row block. The cwd is NOT here — the
|
||
lane-detail header above this card already spells it out. */}
|
||
{git?.available && (
|
||
<div className="mb-2">
|
||
<div
|
||
data-testid="lane-git"
|
||
className="flex items-baseline gap-2 truncate font-mono text-[11px] text-fg-secondary"
|
||
title={git.subject}
|
||
>
|
||
<span className="shrink-0">⑂ {git.branch}</span>
|
||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||
<span className="truncate text-fg-muted">{git.subject}</span>
|
||
{(git.dirty > 0 || git.untracked > 0) && (
|
||
<button
|
||
type="button"
|
||
ref={setCountsAnchor}
|
||
data-testid="lane-git-counts"
|
||
aria-expanded={filesOpen}
|
||
onMouseEnter={openFiles}
|
||
onMouseLeave={closeFiles}
|
||
// Focus and click open it too: hover is not a gesture a
|
||
// keyboard or a touchscreen has.
|
||
onFocus={openFiles}
|
||
onBlur={closeFiles}
|
||
onClick={() => setFilesOpen((v) => !v)}
|
||
className="shrink-0 cursor-help text-status-warning/80 underline decoration-dotted underline-offset-2"
|
||
>
|
||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{filesOpen && git.files?.length > 0 && (
|
||
<LaneGitFilesPanel
|
||
files={git.files}
|
||
hidden={git.dirty + git.untracked - git.files.length}
|
||
anchor={countsAnchor}
|
||
onPointerEnter={openFiles}
|
||
onPointerLeave={closeFiles}
|
||
/>
|
||
)}
|
||
</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="mb-2 truncate font-mono text-[11px] text-fg-secondary">
|
||
⑂ {lane.branch}
|
||
</div>
|
||
)}
|
||
|
||
{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>
|
||
)}
|
||
|
||
{/* 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);
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|