Files
AgentHub/client/src/components/lanes/LaneCard.tsx
T
nntrivi2001 d397d3a1f6 feat(lanes): split panes on one lane, in-place file editing, hunk review bar
- Split view: every pane shows the selected lane, each in its own tmux
  session (`slot` 0-3 on POST /api/lanes/:id/start; slot 0 stays the
  lane's `run_id`, slots 1-3 are `agenthub-lane-<id>-<slot>`). A pane's
  Active runs hides what the other panes show; stop/reset/remove kill
  every slot. No per-pane lane picker any more.
- Whole-file editor: PUT /api/lanes/:id/git/file (lane-file-write.js)
  with the read path's guards, existing text files only, and a baseSha
  stale check (409 ESTALE). A file it cannot take says why.
- Per-hunk review bar under each hunk's last changed line: prev/next,
  "n of N", Undo, Keep. Review diffs no longer nest their own scroll box
  (unless windowed), so navigation moves the one real scroller.
- Files tree lists untracked-not-ignored files and colours entries by git
  status (M/U/A/D/R), folders holding changes get a dot.
- Changes panel: Unstage all on the single commit/Stage-all row; sizes
  Narrow (30%) / Medium / Full screen, no 70/30.
- Lane card: the needs-you banner is gone (the strip tile keeps it).
2026-09-24 15:49:39 +07:00

666 lines
25 KiB
TypeScript

/**
* @file One lane's card: 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. The working copy — branch, uncommitted counts, the
* git action bar — is not here either: it heads the Changes panel
* (`LaneGitHeader`), beside the diff it describes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, 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, LaneRuntime, NamedLock } from "../../lib/types";
/** How often a mounted card re-probes its stack. Faster than the Changes panel's
* 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 Changes panel's git facts (slow, since lock state changes infrequently). */
const LOCKS_REFRESH_MS = 30_000;
/**
* The lane's own application stack. Same shape and same silence as the git
* facts in LaneGitHeader: 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 runtime poller 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>
);
}
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 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}`}
// Every row below is conditional: a lane with nothing to report
// renders no frame at all rather than an empty box.
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden"
>
{(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>
)}
{/* No needs-you banner here: the strip card already flags it, and the
console directly below is where the prompt actually waits. */}
{/* 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>
)}
{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-700 dark: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);
}}
/>
)}
</>
);
}