feat(git): switch with uncommitted changes (carry / shelve), Create & switch, and shelves with Restore/Delete

- switch takes changes: carry (plain git switch, 409 ECARRYCONFLICT with files) or shelve (shelf first, re-applied if the switch fails)
- branch-create switch: true runs git switch -c, the changes come along
- shelve / unshelve / shelf-drop + GET /git/shelves: only agenthub-shelf: stash entries, addressed by sha, repo root only, never mid-operation; a clean restore drops the shelf, a conflicted one keeps it
- 409 EAGENTRUNNING until confirm: true while the workspace's agent is working
- git bar: Bring my changes / Shelve and switch choice, Create & switch, restore offer after a switch; Changes panel: Shelve (n) and the Shelves list
- fix: after a switch the Changes panel showed the old branch — the post-write re-read joined an in-flight GET started before the write (request() dedupe); writes now close in-flight GETs, and every git write fires agenthub:git-changed so every view of the workspace re-reads
This commit is contained in:
2026-11-02 11:43:51 +07:00
parent 2b26fe1efa
commit f6eb39eb73
22 changed files with 2005 additions and 77 deletions
@@ -54,6 +54,11 @@
* .gitignore (`ignore`, untracked only). While a merge is open the box is
* hidden: a partial commit cannot finish one, Complete does.
*
* **Shelve (n)** beside Discard sets the ticked files aside (`shelve`, with
* an optional one-line note), and the Shelves list (`LaneShelves`) above the
* file list restores or deletes what was shelved — a git stash under the
* hood, its recovery always a button here.
*
* History rewrites through its commit menu: Revert (any commit), Undo commit
* (the top one; the server refuses a pushed one) and, while another branch's
* history is shown, Cherry-pick onto this branch. The commit row's Amend box
@@ -92,6 +97,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import {
Archive,
ChevronDown,
ChevronLeft,
ChevronRight,
@@ -142,6 +148,8 @@ import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { fetchVia } from "../HtmlFrame";
import { HtmlViewerModal } from "../HtmlViewerModal";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { LaneShelves, withAgentConfirm } from "./LaneShelves";
import { useGitChanged } from "../../lib/gitChanged";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import { dragProjectPath } from "../../lib/promptAttach";
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
@@ -876,6 +884,11 @@ export function LaneChangesPanel({
// Bumped by Refresh and by a git bar action: a pull or a switch moves
// history and the tree too, not just the diff `load` re-reads.
const [reread, setReread] = useState(0);
// Any git write on this workspace — from this panel's bar, another repo's
// panel or the session view — re-reads everything here (`rereadAll`, set
// once it is defined below).
const rereadRef = useRef<(() => void) | null>(null);
useGitChanged(laneId, () => rereadRef.current?.());
const [range, setRange] = useState<LaneChangeRange>("work");
// Local mode: the files are already on this machine — no upload, download
// or File Explorer (the desktop's file manager does all three).
@@ -979,6 +992,9 @@ export function LaneChangesPanel({
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
const pendingScroll = useRef<number | null>(null);
const [message, setMessage] = useState("");
// Shelve (n) opened its note row; the note typed there.
const [shelving, setShelving] = useState(false);
const [shelfNote, setShelfNote] = useState("");
// The commit row amends the last commit instead of making a new one.
const [amend, setAmend] = useState(false);
// The last commit is already on its upstream: the server refuses to amend
@@ -1514,6 +1530,36 @@ export function LaneChangesPanel({
}
};
// Shelve (n): the ticked files (whole files — a stash has no hunks) set
// aside with the note; all of them when every file is ticked.
const shelveTicked = async () => {
if (!tickedPaths.length) return;
const lane = laneId;
setBusy(true);
setError(null);
try {
const all = tickedPaths.length >= files.length;
const res = await withAgentConfirm((confirm) =>
api.lanes.gitAction(target, "shelve", {
...(all ? {} : { paths: tickedPaths }),
...(shelfNote.trim() ? { note: shelfNote.trim() } : {}),
...(confirm ? { confirm } : {}),
})
);
if (laneNow.current !== lane || !res) return;
toast.success(t("shelves.shelved", { count: res.shelf?.files.length ?? tickedPaths.length }));
setShelving(false);
setShelfNote("");
refreshGit();
setReread((n) => n + 1);
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
const mergeAct = async (
action: "resolve" | "merge-commit" | "merge-abort",
@@ -2020,6 +2066,7 @@ export function LaneChangesPanel({
setReread((n) => n + 1);
return load();
};
rereadRef.current = () => void rereadAll();
const [rereading, runReread] = useSpin(rereadAll);
// Files tab transfers (issue #78): a download is a plain attachment link; an
@@ -2679,6 +2726,12 @@ export function LaneChangesPanel({
</div>
)}
{/* Changes set aside (git stash entries AgentHub made): Restore and
Delete, never dropped on their own. Absent while there are none. */}
{git?.available && mode !== "file" && (
<LaneShelves target={target} reread={reread} onChanged={rereadAll} />
)}
{/* In single-file mode the DIFF owns the scrolling, so its horizontal
bar sits at the bottom of the visible box instead of at the bottom of
a 2000-line container nobody has scrolled to yet. Every other mode is
@@ -2893,7 +2946,54 @@ export function LaneChangesPanel({
<Trash2 className="h-3 w-3" aria-hidden />
{t("changes.discardTicked", { count: tickedPaths.length })}
</button>
<button
type="button"
data-testid="lane-changes-shelve"
aria-expanded={shelving}
disabled={busy || tickedPaths.length === 0}
onClick={() => setShelving((v) => !v)}
title={t("shelves.shelveHint")}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Archive className="h-3 w-3" aria-hidden />
{t("shelves.shelveTicked", { count: tickedPaths.length })}
</button>
</div>
{shelving && (
<div
data-testid="lane-changes-shelve-row"
className="flex w-full flex-wrap items-center gap-1.5"
>
<input
value={shelfNote}
autoFocus
maxLength={200}
onChange={(e) => setShelfNote(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) void shelveTicked();
if (e.key === "Escape") setShelving(false);
}}
placeholder={t("shelves.notePlaceholder")}
aria-label={t("shelves.notePlaceholder")}
className="min-w-[10rem] flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<button
type="button"
disabled={busy || tickedPaths.length === 0}
onClick={() => void shelveTicked()}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
{t("shelves.shelveApply", { count: tickedPaths.length })}
</button>
<button
type="button"
onClick={() => setShelving(false)}
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
{t("shelves.cancel")}
</button>
</div>
)}
</div>
)}
{/* The work range splits like JetBrains' Commit window: tracked
+138 -17
View File
@@ -15,7 +15,16 @@
* icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
* worktree`), and these are the ordinary moves a lane needs several times an hour.
* Nothing here decides what an action may do; the server does (pull is always
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
* --ff-only, switch refuses a dirty tree unless told what to do with the
* changes), and this only reports what it said. With uncommitted changes,
* Switch branch asks first — **Bring my changes** (`changes: "carry"`; a
* file git would overwrite comes back as ECARRYCONFLICT, listed, with Shelve
* and switch offered instead) or **Shelve and switch** (`changes: "shelve"`)
* — and the new-branch row offers **Create & switch** (`switch: true`, the
* changes come along; the default on Enter while the tree is dirty) beside
* plain Create. A switch onto a branch that has shelves asks whether to
* restore the newest (never automatically), and a write the server answers
* EAGENTRUNNING is confirmed and resent (`withAgentConfirm`'s dialog).
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
* the user more than any sentence this file could write. The one exception
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
@@ -45,6 +54,7 @@ import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
import { restoreShelf } from "./LaneShelves";
type Action =
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
@@ -128,6 +138,9 @@ export function LaneGitBar({
const [remember, setRemember] = useState(true);
const [publishing, setPublishing] = useState(false);
const [url, setUrl] = useState("");
// A switch with uncommitted changes asks how to take them: set to the
// branch picked (and the files git refused to carry, after ECARRYCONFLICT).
const [choice, setChoice] = useState<{ branch: string; conflict?: string[] } | null>(null);
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
// All of the state above describes ONE lane — a branch picked for lane A,
// applied after switching, would move B's HEAD — so it is reset during
@@ -150,6 +163,7 @@ export function LaneGitBar({
setPassword("");
setPublishing(false);
setUrl("");
setChoice(null);
inFlight.current = false;
}
const laneNow = useRef(laneId);
@@ -168,6 +182,9 @@ export function LaneGitBar({
const lane = laneId;
setBusy(action);
setError(null);
// EAGENTRUNNING: asked AFTER the request settles, so the resend is not
// refused as a second action in flight.
let askAgent = false;
try {
const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {});
// Landed after a lane switch: every write below belongs to the lane
@@ -190,20 +207,50 @@ export function LaneGitBar({
if (action === "switch") setSwitching(false);
else setNewBranch("");
setPicked("");
setChoice(null);
setBranches(null);
await refreshBranches();
} else if ((action === "fetch" || action === "pull") && branches !== null) {
// A fetch may have brought branches the list does not show yet.
await refreshBranches();
}
if (res?.shelved) toast.success(t("gitBar.shelvedOn", { branch: res.shelved.branch }));
onChanged();
// The branch switched to has changes shelved on it: offer them back —
// asking, never restoring on its own.
const offer = action === "switch" ? res?.shelves?.[0] : undefined;
if (offer) {
const yes = await confirmDialog({
title: t("gitBar.restoreOfferTitle"),
message: t("gitBar.restoreOffer", {
branch: offer.branch,
count: res?.shelves?.length ?? 1,
files: offer.files.length,
}),
confirmLabel: t("shelves.restore"),
destructive: false,
});
if (yes && laneNow.current === lane) {
await restoreShelf(gitTarget(lane, session, source), offer);
if (laneNow.current === lane) onChanged();
}
}
} catch (err) {
// The server appends git's own stderr to the message, so this is the
// rejection text verbatim — hence the <pre> below rather than a line.
// ENOREMOTE is the one refusal with no git text to show: the server's
// sentence is English and says less than a translated one.
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
const code = (err as { code?: string })?.code;
if (laneNow.current === lane && code === "EAGENTRUNNING" && body?.confirm !== true) {
askAgent = true;
} else if (laneNow.current === lane && code === "ECARRYCONFLICT" && body?.branch) {
// git would overwrite these files: show them and offer the shelf.
setChoice({
branch: body.branch,
conflict: (err as { files?: string[] }).files ?? [],
});
} else if (laneNow.current === lane && code === "EGITAUTH" && auth) {
setLogin({ action, auth });
setUsername((u) => u || auth.username || "");
setPassword("");
@@ -221,6 +268,15 @@ export function LaneGitBar({
setBusy(null);
}
}
if (askAgent) {
const ok = await confirmDialog({
title: t("shelves.agentTitle"),
message: t("shelves.agentMessage"),
confirmLabel: t("shelves.agentConfirm"),
destructive: false,
});
if (ok && laneNow.current === lane) void run(action, { ...body, confirm: true });
}
};
const refreshBranches = async () => {
@@ -306,6 +362,20 @@ export function LaneGitBar({
if (ok) void run("merge", { branch });
};
// Switch branch: straight away on a clean tree; with changes, ask how.
const switchTo = (branch: string) => {
if (dirty > 0) {
setChoice({ branch });
setError(null);
} else void run("switch", { branch });
};
// Enter in the new-branch box: with changes, Create & switch (they come
// along); on a clean tree, plain Create as before.
const createBranch = (andSwitch: boolean) => {
const branch = newBranch.trim();
if (branch) void run("branch-create", andSwitch ? { branch, switch: true } : { branch });
};
const heldBy = new Map(
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
);
@@ -424,13 +494,13 @@ export function LaneGitBar({
label={t("gitBar.switch")}
icon={<GitBranch className="h-3.5 w-3.5" />}
disabled={!!busy || merging}
// A dirty tree is refused server-side; saying so before the click
// beats a red box after it.
// With changes, Switch branch asks whether to bring or shelve
// them; the hint says so before the click.
title={
merging
? t("gitBar.merging", { op: git.operation ?? "merge" })
: dirty > 0
? t("gitBar.switchDirty")
? t("gitBar.switchDirtyHint", { count: dirty })
: t("gitBar.switchHint")
}
onClick={openSwitch}
@@ -473,9 +543,11 @@ export function LaneGitBar({
busy={busy === "switch"}
// A branch git already has checked out somewhere else cannot be
// switched to — git allows one worktree per branch.
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
onClick={() => void run("switch", { branch: picked })}
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
title={
dirty > 0 ? t("gitBar.switchDirtyHint", { count: dirty }) : t("gitBar.switchHint")
}
onClick={() => switchTo(picked)}
/>
<Btn
label={t("gitBar.merge")}
@@ -521,34 +593,83 @@ export function LaneGitBar({
)
}
/>
{dirty > 0 && (
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
</div>
)}
{switching && choice && (
<div
data-testid="lane-git-switch-choice"
role="group"
aria-label={t("gitBar.choiceTitle", { branch: choice.branch })}
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
>
<p className="break-words text-fg-primary">
{choice.conflict
? t("gitBar.carryConflict", { branch: choice.branch })
: t("gitBar.choiceMessage", { count: dirty, branch: choice.branch })}
</p>
{choice.conflict && choice.conflict.length > 0 && (
<ul
data-testid="lane-git-carry-conflicts"
className="max-h-24 overflow-auto font-mono text-[10px] text-status-warning"
>
{choice.conflict.map((f) => (
<li key={f} className="truncate" title={f}>
{f}
</li>
))}
</ul>
)}
<div className="flex flex-wrap gap-1.5">
{!choice.conflict && (
<Btn
label={t("gitBar.choiceCarry")}
busy={busy === "switch"}
title={t("gitBar.choiceCarryHint")}
onClick={() => void run("switch", { branch: choice.branch, changes: "carry" })}
/>
)}
<Btn
label={t("gitBar.choiceShelve")}
busy={busy === "switch"}
title={t("gitBar.choiceShelveHint")}
onClick={() => void run("switch", { branch: choice.branch, changes: "shelve" })}
/>
<Btn label={t("gitBar.loginCancel")} onClick={() => setChoice(null)} />
</div>
</div>
)}
{switching && (
<div className="flex items-center gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<input
value={newBranch}
onChange={(e) => setNewBranch(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && newBranch.trim())
void run("branch-create", { branch: newBranch.trim() });
if (isSubmitEnter(e)) createBranch(dirty > 0);
}}
placeholder={t("gitBar.branchNewPlaceholder")}
aria-label={t("gitBar.branchNewPlaceholder")}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
className="min-w-[8rem] flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<Btn
label={t("gitBar.branchCreateSwitch")}
icon={<GitBranch className="h-3 w-3" />}
busy={busy === "branch-create"}
disabled={!newBranch.trim()}
// The working tree stays as it is: uncommitted changes come along.
title={t("gitBar.branchCreateSwitchHint")}
onClick={() => createBranch(true)}
/>
<Btn
label={t("gitBar.branchCreate")}
icon={<Plus className="h-3 w-3" />}
busy={busy === "branch-create"}
disabled={!newBranch.trim()}
// Creating does not switch: the point is usually a sibling lane,
// and moving this pane's HEAD under a working agent is not.
// Plain Create does not switch: the point is usually a sibling
// lane, and moving this pane's HEAD under a working agent is not.
title={t("gitBar.branchCreateHint")}
onClick={() => void run("branch-create", { branch: newBranch.trim() })}
onClick={() => createBranch(false)}
/>
</div>
)}
@@ -10,7 +10,9 @@
* It used to sit in the lane card, a column away from the diff it describes —
* a pull there changed files the Changes panel only noticed on its next poll.
* Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`,
* owned by the panel) and refresh each other after every write.
* owned by the panel) and refresh each other after every write. Every other
* reader of the facts (the rail counts, a second panel) hears a write through
* `agenthub:git-changed` (lib/gitChanged.ts), which `useLaneGitFacts` listens to.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -21,6 +23,7 @@ import { GitBranch } from "lucide-react";
import { LaneGitBar } from "./LaneGitBar";
import { api, gitTarget, type GitSource } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame";
import { useGitChanged } from "../../lib/gitChanged";
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
/** How often the mounted panel re-reads its working-copy facts. Slow on
@@ -76,6 +79,9 @@ export function useLaneGitFacts(
clearInterval(timer);
};
}, [laneId, session, source, nonce]);
// A git write from ANY view of this workspace (another panel, the session
// view, a second tab of the same page) moves these facts too.
useGitChanged(laneId, () => setNonce((n) => n + 1));
return [facts, () => setNonce((n) => n + 1)];
}
+241
View File
@@ -0,0 +1,241 @@
/**
* @file The Changes panel's Shelves list: changes set aside by AgentHub (git
* stash entries it made — `GET /api/lanes/:id/git/shelves`), each with its
* branch, note, time and files, and the two buttons that make a shelf safe to
* offer from a dashboard: **Restore** (`unshelve`, which drops the shelf only
* when it applied cleanly; a conflicted restore keeps it and leaves the files
* to the conflict list) and **Delete** (`shelf-drop`, two clicks through
* `useArmed`, sent with `confirm: true`). Collapsed to one line until opened,
* and absent while there are none. Also exports `withAgentConfirm`, the
* one place a git write answered `EAGENTRUNNING` asks the user and is resent
* with `confirm: true`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Archive, ChevronDown, ChevronRight, RotateCcw, Trash2 } from "lucide-react";
import i18n from "../../i18n";
import { api, type GitShelf, type GitTarget } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { useArmed } from "../../hooks/useArmed";
/**
* Run a git write; when the server answers `EAGENTRUNNING` (an agent of the
* workspace is working and the write would move files under it), ask, and
* resend it with `confirm: true`. Resolves null when the user declined.
*/
export async function withAgentConfirm<T>(
send: (confirm: boolean) => Promise<T>
): Promise<T | null> {
try {
return await send(false);
} catch (err) {
if ((err as { code?: string })?.code !== "EAGENTRUNNING") throw err;
const ok = await confirmDialog({
title: i18n.t("lanes:shelves.agentTitle"),
message: i18n.t("lanes:shelves.agentMessage"),
confirmLabel: i18n.t("lanes:shelves.agentConfirm"),
destructive: false,
});
return ok ? send(true) : null;
}
}
/**
* Restore one shelf (`unshelve`), reporting the outcome in a toast. Shared by
* the list below and the git bar's "restore what you shelved here" prompt.
* @returns true when anything changed in the working tree.
*/
export async function restoreShelf(target: GitTarget, shelf: GitShelf): Promise<boolean> {
try {
const res = await withAgentConfirm((confirm) =>
api.lanes.gitAction(target, "unshelve", { sha: shelf.sha, ...(confirm ? { confirm } : {}) })
);
if (!res) return false;
if (res.conflicts?.length) {
toast.info(i18n.t("lanes:shelves.restoredConflicts", { count: res.conflicts.length }));
} else {
toast.success(i18n.t("lanes:shelves.restored"));
}
return true;
} catch (err) {
toast.error(
(err as { code?: string })?.code === "EDIRTY"
? i18n.t("lanes:shelves.restoreDirty")
: err instanceof Error
? err.message
: String(err)
);
return false;
}
}
/** "M", "A", "D", "??" → a colour, matching the Changes list's letters. */
function statusTone(status: string): string {
if (status === "??" || status.startsWith("A")) return "text-status-success";
if (status.startsWith("D")) return "text-status-danger";
return "text-status-warning";
}
export function LaneShelves({
target,
reread,
onChanged,
}: {
target: GitTarget;
/** Bumped by the panel after every git write: the list is read again. */
reread: number;
/** Called after a restore or a delete changed the tree or the list. */
onChanged: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const [shelves, setShelves] = useState<GitShelf[]>([]);
const [open, setOpen] = useState(false);
const [filesOf, setFilesOf] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const del = useArmed();
const key = JSON.stringify(target);
const keyNow = useRef(key);
keyNow.current = key;
const load = useCallback(async () => {
const asked = key;
try {
const r = await api.lanes.gitShelves(target);
if (keyNow.current === asked) setShelves(r.available ? r.shelves : []);
} catch {
if (keyNow.current === asked) setShelves([]);
}
// `target` is described by `key`; a new object with the same content is
// the same tree.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
useEffect(() => {
void load();
}, [load, reread]);
if (!shelves.length) return null;
const restore = async (s: GitShelf) => {
setBusy(s.sha);
try {
if (await restoreShelf(target, s)) onChanged();
await load();
} finally {
setBusy(null);
}
};
const drop = async (s: GitShelf) => {
if (!del.isArmed(s.sha)) {
del.arm(s.sha);
return;
}
del.disarm();
setBusy(s.sha);
try {
await api.lanes.gitAction(target, "shelf-drop", { sha: s.sha, confirm: true });
toast.success(t("shelves.deleted"));
onChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
await load();
}
};
return (
<section data-testid="lane-shelves" className="mb-2 rounded border border-border-light">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1 px-2 py-1 text-left text-[11px] font-medium text-fg-secondary hover:bg-surface-2"
>
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<Archive className="h-3 w-3 text-fg-muted" aria-hidden />
{t("shelves.title", { count: shelves.length })}
</button>
{open && (
<ul className="divide-y divide-border-light border-t border-border-light">
{shelves.map((s) => {
const when = s.createdAt ? new Date(s.createdAt) : null;
const showFiles = filesOf === s.sha;
return (
<li key={s.sha} data-testid="lane-shelf" className="px-2 py-1 text-[11px]">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span
className="min-w-0 max-w-full truncate font-mono font-semibold text-fg-primary"
title={s.branch}
>
{s.branch}
</span>
{when && !Number.isNaN(when.getTime()) && (
<span className="shrink-0 text-fg-muted" title={s.createdAt ?? undefined}>
{when.toLocaleString()}
</span>
)}
<button
type="button"
aria-expanded={showFiles}
onClick={() => setFilesOf(showFiles ? null : s.sha)}
className="shrink-0 text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
>
{t("shelves.files", { count: s.files.length })}
</button>
<div className="ml-auto flex shrink-0 items-center gap-1">
<button
type="button"
disabled={!!busy}
onClick={() => void restore(s)}
title={t("shelves.restoreHint")}
className="flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<RotateCcw className="h-3 w-3" aria-hidden />
{t("shelves.restore")}
</button>
<button
type="button"
disabled={!!busy}
onClick={() => void drop(s)}
title={t("shelves.deleteHint")}
className="flex items-center gap-1 rounded border border-status-danger/40 px-1.5 py-0.5 text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-3 w-3" aria-hidden />
{del.isArmed(s.sha) ? t("shelves.deleteConfirm") : t("shelves.delete")}
</button>
</div>
</div>
{s.note && (
<p className="mt-0.5 break-words text-fg-secondary" title={s.note}>
{s.note}
</p>
)}
{showFiles && (
<ul data-testid="lane-shelf-files" className="mt-1 space-y-0.5 pl-2">
{s.files.map((f) => (
<li key={f.path} className="flex min-w-0 items-center gap-1.5 font-mono">
<span className={`w-4 shrink-0 text-center ${statusTone(f.status)}`}>
{f.status === "??" ? "U" : f.status.slice(0, 1)}
</span>
<span className="min-w-0 truncate text-fg-secondary" title={f.path}>
{f.path}
</span>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
)}
</section>
);
}
export default LaneShelves;
@@ -22,6 +22,7 @@
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { useGitChanged } from "../../lib/gitChanged";
import { endRuns } from "../../lib/stopSession";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -238,6 +239,17 @@ function HomeBody({
live = false;
};
}, [lane.id]);
// A branch moved from the git bar: the main worktree's branch shown here is
// the server's cached list, so it is refreshed rather than re-read.
useGitChanged(lane.id, (action) => {
if (!["switch", "branch-create", "branch-delete"].includes(action)) return;
api.projects
.refresh()
.then(({ projects }) =>
setProject(projects.find((p) => p.workspaces.includes(lane.id)) ?? null)
)
.catch(() => undefined);
});
// The AI menu's Open terminal: a plain shell in the first free slot, opened
// at once — the same start the session view's tab bar `+` makes.
@@ -20,6 +20,7 @@ import userEvent from "@testing-library/user-event";
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
import { api } from "../../../lib/api";
import { emitGitChanged } from "../../../lib/gitChanged";
import type { LaneChanges } from "../../../lib/types";
// The themed confirm needs its host (App); answer it here.
@@ -445,6 +446,84 @@ describe("LaneChangesPanel", () => {
);
});
it("shows the NEW branch everywhere after a switch from the git bar", async () => {
// The repo as the server sees it: which branch, which changes.
let branch = "feat/thing";
vi.mocked(api.lanes.git).mockImplementation(async () => ({
available: true,
branch,
head: "9b3e74a",
subject: "wip",
dirty: 0,
untracked: 0,
files: [],
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
}));
vi.mocked(api.lanes.changes).mockImplementation(async () =>
changes({
files: [
{
path: branch === "other" ? "only-on-other.ts" : "only-on-thing.ts",
status: " M",
untracked: false,
unstaged: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
);
vi.mocked(api.lanes.gitAction).mockImplementation(async (_t, action, body) => {
if (action === "switch") branch = (body as { branch: string }).branch;
return { action, ok: true, output: "", shelves: [] };
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
await screen.findByTitle("only-on-thing.ts");
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "other");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
expect(await screen.findByTitle("only-on-other.ts")).toBeInTheDocument();
expect(screen.queryByTitle("only-on-thing.ts")).toBeNull();
});
it("re-reads when ANOTHER view of the lane changed its git state", async () => {
let branch = "feat/thing";
vi.mocked(api.lanes.git).mockImplementation(async () => ({
available: true,
branch,
head: "9b3e74a",
subject: "wip",
dirty: 0,
untracked: 0,
files: [],
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
}));
render(<LaneChangesPanel laneId={7} />);
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
const before = vi.mocked(api.lanes.changes).mock.calls.length;
branch = "other";
act(() => emitGitChanged(7, "switch"));
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
expect(vi.mocked(api.lanes.changes).mock.calls.length).toBeGreaterThan(before);
// Another workspace's write is not this panel's business.
const now = vi.mocked(api.lanes.git).mock.calls.length;
act(() => emitGitChanged(8, "switch"));
await new Promise((r) => setTimeout(r, 20));
expect(vi.mocked(api.lanes.git).mock.calls.length).toBe(now);
});
it("re-reads history too when Refresh is pressed there", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -3,8 +3,10 @@
* @description Pins the lane git action row: the ahead/behind readout, which
* buttons a repo with no remote may press (and that it may publish to a typed URL),
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
* refuses it too), that branches only the remote has are offered (and the list
* Changes panel's staged-only box owns that), that a dirty tree's switch asks
* whether to bring the changes or shelve them (and offers the shelf when git
* refuses to carry them), Create & switch, the offer to restore a branch's
* shelf after switching to it, the confirm-and-resend of EAGENTRUNNING, that branches only the remote has are offered (and the list
* is re-read after a fetch), and that a failed action shows git's own words rather than
* a generic sentence.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -202,16 +204,114 @@ describe("LaneGitBar", () => {
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
});
it("loads the branch list on demand and refuses to switch while dirty", async () => {
it("loads the branch list on demand and asks how to take the changes while dirty", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
// Picking is harmless; applying is what a dirty tree blocks.
expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Switch branch" }));
// Nothing is sent before the user picks what happens to the changes.
expect(api.lanes.gitAction).not.toHaveBeenCalled();
const choice = screen.getByTestId("lane-git-switch-choice");
expect(choice).toHaveTextContent("3 uncommitted file(s)");
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", {
branch: "development",
changes: "carry",
})
);
});
it("offers Shelve and switch when git refuses to carry the changes", async () => {
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(
Object.assign(new Error("would be overwritten"), {
code: "ECARRYCONFLICT",
files: ["src/a-very-long-path/that/should/truncate.ts"],
})
);
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent(
"src/a-very-long-path/that/should/truncate.ts"
);
expect(screen.queryByRole("button", { name: "Bring my changes" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "switch", {
branch: "development",
changes: "shelve",
})
);
});
it("asks before moving files under a working agent, then resends with confirm", async () => {
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(
Object.assign(new Error("agent"), { code: "EAGENTRUNNING" })
);
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
await waitFor(() =>
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ title: "An agent is working here" })
)
);
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "switch", {
branch: "development",
changes: "shelve",
confirm: true,
})
);
});
it("offers to restore the shelf of the branch switched to, and restores it on Yes", async () => {
const shelf = {
ref: "stash@{0}",
sha: "abc1234def",
branch: "development",
note: "",
createdAt: "2026-11-02T00:00:00.000Z",
files: [{ path: "a.txt", status: "M" }],
};
vi.mocked(api.lanes.gitAction)
.mockResolvedValueOnce({ action: "switch", ok: true, output: "", shelves: [shelf] })
.mockResolvedValueOnce({ action: "unshelve", ok: true, output: "", conflicts: [] });
const user = userEvent.setup();
const onChanged = vi.fn();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={onChanged} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "unshelve", { sha: "abc1234def" })
);
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ title: "Restore shelved changes?" })
);
});
it("creates and switches in one step — the default on Enter while dirty", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.type(await screen.findByLabelText("New branch name…"), "fix/ac-515{Enter}");
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", {
branch: "fix/ac-515",
switch: true,
})
);
});
it("switches branch on a clean tree", async () => {
@@ -0,0 +1,106 @@
/**
* @file LaneShelves.test.tsx
* @description Pins the Changes panel's Shelves list: hidden while there are
* none, one collapsed line otherwise, each shelf's branch, note and files,
* Restore sending `unshelve` by sha (and the conflict outcome told in a
* toast), Delete needing a second click before `shelf-drop` with
* `confirm: true`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneShelves } from "../LaneShelves";
import { api } from "../../../lib/api";
import { toast } from "../../../lib/toast";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../lib/api", () => ({
api: { lanes: { gitAction: vi.fn(), gitShelves: vi.fn() } },
}));
const shelf = {
ref: "stash@{0}",
sha: "abc1234def5678",
branch: "fix/ac-515-a-rather-long-branch-name",
note: "half way through the login fix",
createdAt: "2026-11-02T03:04:05.000Z",
files: [
{ path: "src/login.ts", status: "M" },
{ path: "src/new.ts", status: "??" },
],
};
beforeEach(() => {
vi.mocked(api.lanes.gitShelves)
.mockReset()
.mockResolvedValue({
available: true,
shelves: [shelf],
});
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
action: "unshelve",
ok: true,
output: "",
conflicts: [],
});
});
describe("LaneShelves", () => {
it("renders nothing while there are no shelves", async () => {
vi.mocked(api.lanes.gitShelves).mockResolvedValue({ available: true, shelves: [] });
const { container } = render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
await waitFor(() => expect(api.lanes.gitShelves).toHaveBeenCalledWith(3));
expect(container).toBeEmptyDOMElement();
});
it("lists a shelf with its branch, note and files", async () => {
const user = userEvent.setup();
render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: /Shelves \(1\)/ }));
const row = screen.getByTestId("lane-shelf");
expect(row).toHaveTextContent(shelf.branch);
expect(row).toHaveTextContent(shelf.note);
await user.click(screen.getByRole("button", { name: "2 file(s)" }));
expect(screen.getByTestId("lane-shelf-files")).toHaveTextContent("src/new.ts");
});
it("restores by sha and reports a conflicted restore", async () => {
vi.mocked(api.lanes.gitAction).mockResolvedValueOnce({
action: "unshelve",
ok: true,
output: "",
conflicts: ["src/login.ts"],
kept: true,
});
const user = userEvent.setup();
const onChanged = vi.fn();
render(<LaneShelves target={3} reread={0} onChanged={onChanged} />);
await user.click(await screen.findByRole("button", { name: /Shelves/ }));
await user.click(screen.getByRole("button", { name: "Restore" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "unshelve", { sha: shelf.sha })
);
expect(onChanged).toHaveBeenCalled();
expect(toast.info).toHaveBeenCalledWith(expect.stringContaining("1 conflicted file"));
});
it("deletes only on the second click, with confirm", async () => {
const user = userEvent.setup();
render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: /Shelves/ }));
await user.click(screen.getByRole("button", { name: "Delete" }));
expect(api.lanes.gitAction).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Delete for good?" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "shelf-drop", {
sha: shelf.sha,
confirm: true,
})
);
});
});