feat(lanes,ui): per-lane git surface — Changes panel, VS Code-style diff, branch-aware Add lane, redesigned tooltips (#34, #35)

- Changes card beside lane detail with five modes: Files tree, overview,
  Review (whole diff, per-file collapse), single file, commit history.
- LaneDiffView: unified / side-by-side with independent scroll on both axes
  and mirrored scrollLeft, expand/collapse context, intra-line emphasis.
- Staged-only commit box; git actions (fetch/pull/push/stage/unstage/
  discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js.
- Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and
  POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind.
- Add lane: new/existing branch, current branch, worktree list, prefill
  from the git bar's "New lane here"; /api/run/cwds gains a repo kind.
- GlobalTooltip upgrades every native title into a themed anchored bubble;
  chart/info popovers use theme tokens.
This commit is contained in:
2026-09-23 09:18:17 +07:00
parent 32a8887419
commit 1b561c22d9
47 changed files with 7354 additions and 895 deletions
+289 -159
View File
@@ -25,7 +25,19 @@ import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal";
import { api } from "../../lib/api";
import type { AiRouter, CwdSuggestion } from "../../lib/api";
import type { Lane, LaneTool } from "../../lib/types";
import type { Lane, LaneTool, LaneWorktree } from "../../lib/types";
/** Mirrors server/lib/worktree.js `slugify`, for the branch-name PREVIEW only.
* Nothing is sent from it: an empty branch field means "let the server name
* it", which is how `LANE_BRANCH_PREFIX` keeps working. */
function slugPreview(title: string) {
return title
.toLowerCase()
.normalize("NFKD")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
}
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
@@ -77,11 +89,16 @@ export function AddLaneModal({
onClose,
onAdded,
cwdSuggestions,
prefill,
}: {
open: boolean;
onClose: () => void;
/** Called with the newly created (possibly still-provisioning) lane. */
onAdded: (lane: Lane) => void;
/** Fields to open with, for "new lane from this repo" in a lane's git bar.
* A branch here means the sibling worktree checks out THAT branch, which
* is the existing-branch mode — the reason that mode exists (issue #34). */
prefill?: { sourceRepo?: string; branch?: string } | null;
/** The same suggestion list the Run form already fetched (dashboard cwd,
* home, recently-used paths) — reused rather than fetched a second time. */
cwdSuggestions: CwdSuggestion[];
@@ -92,13 +109,17 @@ export function AddLaneModal({
const [title, setTitle] = useState("");
const [branch, setBranch] = useState("");
const [branches, setBranches] = useState<string[] | null>(null);
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
// Two ways to start a worktree lane, and git's own rule decides which is
// possible: "new" forks a branch off `base`, "existing" checks out a branch
// that is already there — which git allows exactly once across all of a
// repo's worktrees, hence the busy markers below.
const [branchMode, setBranchMode] = useState<"new" | "existing">("new");
const [base, setBase] = useState("");
const [pipeline, setPipeline] = useState("default");
// "native" is the built-in profile, not a null state — see server/lib/ai-routers.js.
const [router, setRouter] = useState("native");
const [routers, setRouters] = useState<AiRouter[]>([]);
const [routersLoaded, setRoutersLoaded] = useState(false);
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
const [branchesError, setBranchesError] = useState<string | null>(null);
const [tool, setTool] = useState<LaneTool>("claude");
const [busy, setBusy] = useState(false);
@@ -117,8 +138,9 @@ export function AddLaneModal({
setTitle("");
setBranch("");
setBranches(null);
setWorktrees([]);
setBranchMode("new");
setBase("");
setPipeline("default");
setRouter("native");
setRoutersLoaded(false);
setTool("claude");
@@ -128,23 +150,23 @@ export function AddLaneModal({
setSetupResult(null);
};
// The template a lane is created with is the ONLY chance to get it right
// from here: nothing else in the UI can change it afterwards, so a lane
// silently born on `default` renders an 8-node map for a 16-node workflow.
// Fetched on open (templates are file-backed and can change between opens).
// A prefill only ever seeds the fields ON OPEN: the user may then change
// anything, and re-applying it on every render would fight them for the box.
useEffect(() => {
if (!open || !prefill) return;
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
if (prefill.branch) {
setBranch(prefill.branch);
setBranchMode("existing");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// A session that boots on the vendor's API keeps talking to it for its whole
// life, so the router has to be picked here, before the lane exists.
useEffect(() => {
if (!open) return;
let cancelled = false;
api.lanes
.pipelines()
.then((r) => {
if (!cancelled) setPipelines(r.pipelines);
})
.catch(() => {
// Quiet: the select just falls back to the single `default` option
// below, and the lane still gets created.
if (!cancelled) setPipelines([]);
});
api.aiRouters
.list()
.then((r) => {
@@ -180,6 +202,7 @@ export function AddLaneModal({
const path = sourceRepo.trim();
if (!path) {
setBranches(null);
setWorktrees([]);
setBase("");
setBranchesError(null);
return;
@@ -190,6 +213,7 @@ export function AddLaneModal({
const r = await api.lanes.branches(path);
if (lookedUpFor.current !== path) return; // a newer path superseded this one
setBranches(r.branches);
setWorktrees(r.worktrees || []);
setBase(r.current || r.branches[0] || "");
setBranchesError(null);
} catch {
@@ -197,6 +221,7 @@ export function AddLaneModal({
// Not yet a valid repo path (still being typed, or genuinely wrong) -
// quiet by design, the same way CwdAutocomplete never errors either.
setBranches(null);
setWorktrees([]);
setBase("");
setBranchesError(t("addLaneNotARepo"));
}
@@ -220,7 +245,6 @@ export function AddLaneModal({
const result = await api.lanes.ensure({
cwd: repo,
title: name || undefined,
pipeline,
tool,
});
// Report the lane the INSTANT it exists server-side. Everything after
@@ -250,14 +274,17 @@ export function AddLaneModal({
return;
}
if (!branches || !name || !branch.trim()) return;
// A blank branch in "new" mode is legal and deliberate: the server names
// it `${LANE_BRANCH_PREFIX}${slug}`, which is what the placeholder previews.
if (!branches || !name || (branchMode === "existing" && !branch.trim())) return;
try {
const result = await api.lanes.worktree({
sourceRepo: repo,
title: name,
base: base || undefined,
branch: branch.trim(),
pipeline,
// An existing branch is checked out as it stands; `base` only means
// something when a branch is being forked.
base: branchMode === "new" ? base || undefined : undefined,
branch: branch.trim() || undefined,
tool,
});
onAdded(result.lane);
@@ -348,7 +375,16 @@ export function AddLaneModal({
const done = !!setupResult;
const disabled =
!done &&
(!sourceRepo.trim() || (mode === "worktree" && (!title.trim() || !branches || !branch.trim())));
(!sourceRepo.trim() ||
(mode === "worktree" &&
(!title.trim() || !branches || (branchMode === "existing" && !branch.trim()))));
/** branch name -> the worktree directory already holding it. git allows one
* checkout per branch, so these are the branches this form cannot use. */
const busyBranches = new Map(
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
);
const freeBranches = (branches || []).filter((b) => !busyBranches.has(b));
return (
<ConfirmModal
@@ -422,6 +458,37 @@ export function AddLaneModal({
</p>
</div>
{/* What this repo already has checked out. Without it the form asked
for a branch in the dark: the one fact that decides whether a
branch can be used at all is which worktree is holding it. */}
{mode === "worktree" && worktrees.length > 0 && (
<div
data-testid="add-lane-worktrees"
className="rounded-md border border-border-light bg-surface-0 p-2"
>
<p className="mb-1 text-[10px] font-medium text-fg-secondary">
{t("addLaneWorktreesTitle", { count: worktrees.length })}
</p>
<ul className="space-y-0.5">
{worktrees.slice(0, 8).map((w) => (
<li key={w.path} className="flex items-baseline gap-1.5 text-[10px]">
<span className="shrink-0 font-mono text-fg-primary">
⑂ {w.branch || t("addLaneWorktreeDetached")}
</span>
<span className="truncate text-fg-muted" title={w.path}>
{w.path}
</span>
</li>
))}
{worktrees.length > 8 && (
<li className="text-[10px] text-fg-muted">
{t("addLaneWorktreesMore", { count: worktrees.length - 8 })}
</li>
)}
</ul>
</div>
)}
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
{t("addLaneTitleLabel")}
@@ -431,152 +498,215 @@ export function AddLaneModal({
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("addLaneTitlePlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-pipeline">
{t("addLanePipelineLabel")}
</label>
<select
id="add-lane-pipeline"
value={pipeline}
onChange={(e) => setPipeline(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
>
{(pipelines.length ? pipelines : [{ id: "default", name: "default", nodes: [] }]).map(
(p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
)
)}
</select>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
</div>
<fieldset>
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
<div className="flex gap-2">
{(
[
{ value: "native", label: t("addLaneRouterNative") },
{ value: "router", label: t("addLaneRouterCustom") },
] as const
).map((option) => {
const active = option.value === "native" ? router === "native" : router !== "native";
// Clicking "AI Router" before the list lands would resolve back to
// Native and read as a dead control.
const disabled = option.value === "router" && !routersLoaded;
return (
<label
key={option.value}
// No radio dot: the card itself lights up when selected. That
// leaves the DISABLED state with nothing to show it, since the
// greyed-out dot was carrying it — hence the explicit branch.
// The input only goes visually hidden, so keyboard and screen
// readers still get a real radio group, and the card carries
// its focus ring.
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
disabled
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
: active
? "cursor-pointer border-accent bg-accent/10 text-accent"
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
}`}
>
<input
type="radio"
name="add-lane-router"
className="sr-only"
disabled={disabled}
checked={active}
onChange={() =>
// Switching to "AI Router" lands on the first saved
// profile; with none saved the radio stays on native and
// the select below explains why.
setRouter(
option.value === "native"
? "native"
: routers.find((r) => !r.builtin)?.name || "native"
)
}
/>
{option.label}
</label>
);
})}
</div>
{router !== "native" && (
<select
aria-label={t("addLaneRouterProfileLabel")}
value={router}
onChange={(e) => setRouter(e.target.value)}
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
>
{routers
.filter((r) => !r.builtin)
.map((r) => (
<option key={r.name} value={r.name}>
{r.label}
</option>
))}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">
{!routersLoaded
? t("addLaneRouterLoading")
: routers.some((r) => !r.builtin)
? t("addLaneRouterHint")
: t("addLaneRouterEmptyHint")}
</p>
</fieldset>
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")}
</label>
{branches.length === 0 ? (
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
) : (
<select
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
)}
</div>
)}
{mode === "worktree" && branchesError && !branches && (
<p className="text-[10px] text-status-warning">{branchesError}</p>
)}
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
{t("addLaneBranchLabel")}
</label>
<input
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder={t("addLaneBranchPlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHint")}</p>
<div data-testid="add-lane-branch-section" className="space-y-2">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={branchMode === "new"}
label={t("addLaneBranchModeNew")}
onClick={() => {
setBranchMode("new");
setBranch("");
}}
/>
<Seg
active={branchMode === "existing"}
label={t("addLaneBranchModeExisting")}
onClick={() => {
setBranchMode("existing");
// Land on something usable rather than on an empty select
// that looks like a choice was already made.
setBranch((current) =>
freeBranches.includes(current) ? current : freeBranches[0] || ""
);
}}
/>
{/* The third answer to "which branch": none. A small or personal
project usually wants the lane to BE the repo, working on
whatever is checked out (`main`) — which is exactly Repo
mode, so this jumps there rather than inventing a second way
to do it. git cannot check `main` out in a second worktree
while the repo itself holds it, so a worktree lane could not
offer this even if it wanted to. */}
<Seg
active={false}
label={t("addLaneBranchModeCurrent")}
title={t("addLaneBranchModeCurrentHint")}
onClick={() => setMode("repo")}
/>
</div>
{branchMode === "new" ? (
<>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")}
</label>
{branches.length === 0 ? (
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
) : (
<select
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
)}
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
{t("addLaneBranchLabel")}
</label>
<input
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
// The placeholder is what the SERVER will name the branch
// if this is left empty, so the default is visible without
// the form having to duplicate LANE_BRANCH_PREFIX.
placeholder={
title.trim() ? `feat/${slugPreview(title)}` : t("addLaneBranchPlaceholder")
}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
</div>
</>
) : (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-existing">
{t("addLaneBranchExistingLabel")}
</label>
{freeBranches.length === 0 ? (
<p className="text-[10px] text-status-warning">{t("addLaneNoFreeBranches")}</p>
) : (
<select
id="add-lane-existing"
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary focus:border-accent focus:outline-none"
>
{branches.map((b) => {
const busyAt = busyBranches.get(b);
return (
// Disabled rather than hidden: "why is my branch not
// in the list" is a worse question than seeing it
// greyed out with the worktree that holds it.
<option key={b} value={b} disabled={!!busyAt}>
{busyAt ? t("addLaneBranchBusy", { branch: b, path: busyAt }) : b}
</option>
);
})}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchExistingHint")}</p>
</div>
)}
</div>
)}
{/* The router is the one per-lane extra this form still asks for, so it
sits in the form itself — a disclosure around a single field only
hid it. The pipeline picker is gone entirely: every lane is created
on `default` (the server's own default) and the template is changed
from the lane's own header afterwards. */}
<div className="rounded-md border border-border-light bg-surface-0 p-2">
<div>
<fieldset>
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
<div className="flex gap-2">
{(
[
{ value: "native", label: t("addLaneRouterNative") },
{ value: "router", label: t("addLaneRouterCustom") },
] as const
).map((option) => {
const active =
option.value === "native" ? router === "native" : router !== "native";
// Clicking "AI Router" before the list lands would resolve back to
// Native and read as a dead control.
const disabled = option.value === "router" && !routersLoaded;
return (
<label
key={option.value}
// No radio dot: the card itself lights up when selected. That
// leaves the DISABLED state with nothing to show it, since the
// greyed-out dot was carrying it — hence the explicit branch.
// The input only goes visually hidden, so keyboard and screen
// readers still get a real radio group, and the card carries
// its focus ring.
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
disabled
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
: active
? "cursor-pointer border-accent bg-accent/10 text-accent"
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
}`}
>
<input
type="radio"
name="add-lane-router"
className="sr-only"
disabled={disabled}
checked={active}
onChange={() =>
// Switching to "AI Router" lands on the first saved
// profile; with none saved the radio stays on native and
// the select below explains why.
setRouter(
option.value === "native"
? "native"
: routers.find((r) => !r.builtin)?.name || "native"
)
}
/>
{option.label}
</label>
);
})}
</div>
{router !== "native" && (
<select
aria-label={t("addLaneRouterProfileLabel")}
value={router}
onChange={(e) => setRouter(e.target.value)}
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
>
{routers
.filter((r) => !r.builtin)
.map((r) => (
<option key={r.name} value={r.name}>
{r.label}
</option>
))}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">
{!routersLoaded
? t("addLaneRouterLoading")
: routers.some((r) => !r.builtin)
? t("addLaneRouterHint")
: t("addLaneRouterEmptyHint")}
</p>
</fieldset>
</div>
</div>
{setupResult && (
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>