f77049c238
Terminal (issue #33): - `convertEol` is gone from xterm. It rewrote every bare LF as CR+LF, so a tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the column it was on) had the rest of that draw land at column 0, stamping a line's first characters into the left margin of the rows below — the torn left edge that only showed up once content scrolled. - The attach carries the client's real size (`?cols=&rows=`, `sizeFromQuery` in `server/websocket.js`, bounded because it reaches a TIOCSWINSZ ioctl). `attach()` spawned `tmux attach-session` at a hardcoded 80x24 on every attach, and tmux sizes the window to the newest client, so `claude` redrew its whole UI at 80 columns until the first `resize` message landed. - The terminal takes the keyboard on mount, so typing reaches tmux without clicking the pane first. - A pane that ends (double Ctrl+C, `exit`, a crash) reports itself through a new `onExit` prop and `LaneConsolePane` runs `newRun()`, so the console returns to the setup form instead of leaving a dead pane behind a "New run" button. Pane environment: - `server/lib/desktop-env.js` forwards the graphical session's DISPLAY / WAYLAND_DISPLAY / XAUTHORITY / XDG_RUNTIME_DIR (from `systemctl --user show-environment`, this process's own values winning) into the pane. The server normally runs as a systemd user service with none of them, so `claude` there could not read the clipboard and answered "no image found in clipboard" on every image paste. Run setup: - `auto` is a permission mode and is now the default everywhere — allowlists, the `--permission-mode` fallback, the select, and the pane's initial state. - The model picker sends the CLI's aliases (`fable`/`opus`/`sonnet`/`haiku`) instead of pinned ids. `claude --model` resolves an alias to the latest model itself, so a release like Fable 5.1 needs no edit here; pricing is unaffected because it keys off the resolved id the transcript reports. - **Create/update readme file** moved from the Add-lane modal into the lane's Tools menu: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first. - The Workspace's no-lane state is a real banner; the lane strip (add tile included) renders only once a lane exists. Refs #32, #33. Tests: npm run test:server (1828 pass), client vitest (558 pass).
907 lines
34 KiB
TypeScript
907 lines
34 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 — agents
|
||
* install / MCP sync / dev-sync check / create-update readme) 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; runId?: 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 {
|
||
const res = await api.lanes.routerApply(laneId, name);
|
||
if (shownLane.current !== laneId) return;
|
||
// A live pane adopted the new base URL/key but kept the model names it
|
||
// resolved at startup, so it would send the OLD router's model to the NEW
|
||
// endpoint and fail with "model not found". Only that case gets the
|
||
// warning and the restart button.
|
||
setNote(
|
||
res.restart_required && res.run_id
|
||
? { ok: false, text: t("router.appliedRestart"), runId: res.run_id }
|
||
: { 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);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Kill the pane and open a fresh one for this lane. Started with the lane id
|
||
* alone on purpose: the previous run's `--model` flag names a model of the
|
||
* router the user just left, so replaying it would re-create the exact
|
||
* mismatch this button exists to clear.
|
||
*/
|
||
const restart = async (runId: string) => {
|
||
const laneId = lane.id;
|
||
setBusy(true);
|
||
try {
|
||
await api.run.kill(runId);
|
||
await api.run.start({ laneId });
|
||
if (shownLane.current !== laneId) return;
|
||
setNote({ ok: true, text: t("router.restarted") });
|
||
} catch (err) {
|
||
if (shownLane.current !== laneId) return;
|
||
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
|
||
} finally {
|
||
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>
|
||
)}
|
||
{/* The one case with an action attached: restarting the pane is what
|
||
actually makes the switch take effect, so it is a button and not
|
||
another sentence the user has to act on somewhere else. */}
|
||
{note?.runId && (
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => void restart(note.runId as string)}
|
||
className="rounded border border-border px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
|
||
>
|
||
{t("router.restart")}
|
||
</button>
|
||
)}
|
||
</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, and the
|
||
* create/update-readme run, which moved here from the Add-lane modal) 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" | "docs" | null
|
||
>(null);
|
||
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
|
||
const runtime = useLaneRuntime(lane.id, 0);
|
||
|
||
const runLaneAction = async (
|
||
which: "agents" | "mcp" | "sync-check" | "docs",
|
||
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(", ") });
|
||
});
|
||
|
||
// A skill only exists inside a CLI session, so there is nothing server-side
|
||
// to call: this opens the lane's own terminal run with the slash command as
|
||
// its first message. Claude-only — codex has no slash commands.
|
||
const handleWriteDocs = () =>
|
||
runLaneAction("docs", async () => {
|
||
await api.run.start({
|
||
laneId: lane.id,
|
||
cwd: lane.cwd,
|
||
initialPrompt: "/sg-all:sg-md-writing",
|
||
});
|
||
return t("actions.writeDocsResult");
|
||
});
|
||
|
||
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>
|
||
{lane.tool !== "codex" && (
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
data-testid="lane-write-docs"
|
||
title={t("actions.writeDocsHint")}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setToolsMenuOpen(false);
|
||
void handleWriteDocs();
|
||
}}
|
||
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
|
||
>
|
||
{t("actions.writeDocs")}
|
||
</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);
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|