feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)

Usage panel (status bar):
- every account shows its plan (Claude subscriptionType, Codex
  chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier)
- new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity
  (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs
  agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's
  own file, sent only to its own host while valid, never refreshed
- in-flight quota reads are shared between tabs

AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added;
Gemini CLI, Hermes and OpenClaw removed.

Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat
on an editor selection, Settings in three groups, JetBrains-style Changes
panel (ticked files/hunks, ignore, blame, file history), terminal font.

--no-verify: :3000 is taken by another dev server (lane-detect tests);
both suites run by hand — server 2636/2639 (the 3 are :3000), client
1131/1131.
This commit is contained in:
2026-10-08 12:22:14 +07:00
parent 6ef9a5d4b7
commit a9c71cd905
133 changed files with 8007 additions and 1830 deletions
+186 -17
View File
@@ -13,34 +13,54 @@
* text), its edit in a stronger blue — the same scheme as the read-only diff in
* LaneDiffView. Syntax colours come from the dashboard's own per-line
* tokenizer (`lib/highlight.ts`), like every other code view here.
* Given `onBlock`, each changed block also gets the read-only diff's Undo /
* Keep bar under its last line (Undo asks twice) in place of its Revert; the
* panel turns it off while the editor holds unsaved text, because git acts on
* the file on disk.
* Given `onBlock`, each changed block also gets the read-only diff's bar under
* its last line — Undo (asks twice) and the block's commit checkbox
* (`blockIncluded`) — in place of its Revert; the panel turns it off while
* the editor holds unsaved text, because git acts on the file on disk.
* Given `blame`, a gutter left of the line numbers names each line's last
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
* a function that moves the cursor to the next / previous changed block.
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
* Chat chip above it, which hands the panel the selection's 1-based lines.
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
* CodeMirror stays out of the main bundle, and one mount is one view.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { EditorState, RangeSetBuilder, StateEffect, StateField, Text } from "@codemirror/state";
import {
Compartment,
EditorSelection,
EditorState,
RangeSetBuilder,
StateEffect,
StateField,
Text,
} from "@codemirror/state";
import {
Decoration,
EditorView,
ViewPlugin,
WidgetType,
drawSelection,
gutter,
GutterMarker,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
showTooltip,
type DecorationSet,
type Tooltip,
type ViewUpdate,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { unifiedMergeView, type Chunk } from "@codemirror/merge";
import { chunksOf, splitDiff } from "./splitDiff";
import { highlight, tokenClass } from "../../lib/highlight";
import type { LaneBlame } from "../../lib/types";
type Blame = Extract<LaneBlame, { available: true }>;
/**
* True when a letter or digit of a line (`text`, starting at document offset
@@ -76,7 +96,7 @@ function decorate(view: EditorView, lang: string): DecorationSet {
const doc = view.state.doc;
let last = 0;
for (const { from, to } of view.visibleRanges) {
for (let pos = from; pos <= to; ) {
for (let pos = from; pos <= to;) {
const line = doc.lineAt(pos);
pos = line.to + 1;
if (line.number <= last) continue;
@@ -151,7 +171,52 @@ const diffColours = (lang: string) =>
/** A changed block by line (1-based; a count of 0 is a pure insertion or
* removal): what the panel matches against git's own change blocks. */
export type EditorBlock = { oldFrom: number; oldCount: number; newFrom: number; newCount: number };
export type BlockAction = "keep" | "undo";
export type BlockAction = "include" | "undo";
/** One Annotate cell: the line's commit date and author, its subject on hover. */
class BlameMarker extends GutterMarker {
constructor(
readonly text: string,
readonly title: string
) {
super();
}
override eq(other: BlameMarker) {
return other.text === this.text && other.title === this.title;
}
override toDOM() {
const el = document.createElement("span");
el.textContent = this.text;
el.title = this.title;
return el;
}
}
/** The Annotate gutter for the editable file's lines (the B side). ponytail:
* lines are matched by number, so unsaved edits above a line shift its
* annotation until the file is saved and re-annotated. */
function blameGutter(blame: Blame, notCommitted: string, onClick: (sha: string) => void) {
const shaAt = (state: EditorState, pos: number) => blame.lines[state.doc.lineAt(pos).number - 1];
return gutter({
class: "cm-ah-blame",
lineMarker(view, line) {
const sha = shaAt(view.state, line.from);
if (sha === undefined) return null;
const c = sha ? blame.commits[sha] : undefined;
return c
? new BlameMarker(`${c.date.slice(0, 10)} ${c.author}`, `${c.short} ${c.summary}`)
: new BlameMarker(notCommitted, notCommitted);
},
initialSpacer: () => new BlameMarker("0000-00-00 Author Name", ""),
domEventHandlers: {
mousedown(view, line) {
const sha = shaAt(view.state, line.from);
if (sha) onClick(sha);
return !!sha;
},
},
});
}
/** A chunk side's first line and line count. `to` is the start of the line
* after the chunk (or the end + 1), so its last line holds `to - 1`. */
@@ -220,6 +285,26 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
});
}
/** Add to Chat above a non-empty selection; `build` makes its DOM from the
* selection's first and last 1-based line. A selection ending at a line's
* start (a whole-line drag) does not take that line. */
function chatChip(build: (start: number, end: number) => HTMLElement) {
return StateField.define<Tooltip | null>({
create: () => null,
update(tip, tr) {
if (!tr.docChanged && !tr.selection) return tip;
const { from, to, empty } = tr.state.selection.main;
if (empty) return null;
const doc = tr.state.doc;
const start = doc.lineAt(from).number;
const last = doc.lineAt(to);
const end = to === last.from && last.number > start ? last.number - 1 : last.number;
return { pos: from, above: true, create: () => ({ dom: build(start, end) }) };
},
provide: (f) => showTooltip.from(f),
});
}
const CHIP = "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
const UNDO =
"border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
@@ -234,6 +319,8 @@ const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
const M = "&.cm-merge-b.cm-merge-b";
const A = "&.cm-merge-a.cm-merge-a";
const theme = EditorView.theme({
// The chip draws its own box; drop its wrapper's default tooltip one.
".cm-tooltip.cm-ah-chat": { border: "none", background: "transparent" },
"&": {
height: "100%",
fontSize: "11px",
@@ -278,6 +365,13 @@ const theme = EditorView.theme({
background: "rgb(var(--surface-2))",
},
".cm-chunkButtons": { zIndex: "1" },
".cm-ah-blame .cm-gutterElement": {
color: "rgb(var(--fg-muted))",
cursor: "pointer",
fontSize: "10px",
paddingRight: "6px",
whiteSpace: "nowrap",
},
});
/** The Revert button on a changed block (unified); @codemirror/merge wires
@@ -304,6 +398,11 @@ export default function DiffEditor({
onChange,
onSave,
onBlock,
blockIncluded,
blame,
onBlameClick,
navRef,
onAddToChat,
}: {
value: string;
/** The text the diff is taken against; read once, at mount. */
@@ -318,14 +417,24 @@ export default function DiffEditor({
revertLabel: string;
onChange: (text: string) => void;
onSave: () => void;
/** Keep / Undo one changed block in git; absent = no bars. */
/** Undo one changed block in git, or flip its commit checkbox; absent = no bars. */
onBlock?: (action: BlockAction, block: EditorBlock) => void;
/** Whether a block is ticked for the commit (its checkbox's state). */
blockIncluded?: (block: EditorBlock) => boolean;
/** Annotate: each line's last commit; absent = no gutter. */
blame?: Blame;
onBlameClick?: (sha: string) => void;
/** Set to "go to the next (1) / previous (-1) changed block". */
navRef?: { current: ((dir: 1 | -1) => void) | null };
/** Add the selected lines (1-based, inclusive) to the chat; absent = no chip. */
onAddToChat?: (start: number, end: number) => void;
}) {
const { t } = useTranslation("lanes");
const host = useRef<HTMLDivElement>(null);
const view = useRef<EditorView | null>(null);
const cb = useRef({ onChange, onSave, onBlock, t });
cb.current = { onChange, onSave, onBlock, t };
const cb = useRef({ onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t });
cb.current = { onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t };
const blameSlot = useRef(new Compartment()).current;
/** The diff view's review bar, as DOM: pinned to the visible right edge,
* zero height so it never pushes lines (or a split view's alignment). */
@@ -349,11 +458,13 @@ export default function DiffEditor({
return b;
};
const undo = chip(tr("changes.undoHunkShort"), tr("changes.undoHunk"), UNDO);
const keep = chip(
tr("changes.stageHunkShort"),
tr("changes.stageHunk"),
"border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35"
);
// The block's commit checkbox, a real one so it reads like the diff's.
const include = bar.appendChild(document.createElement("input"));
include.type = "checkbox";
include.checked = cb.current.blockIncluded?.(block) ?? true;
include.title = tr("changes.includeBlock");
include.setAttribute("aria-label", tr("changes.includeBlock"));
include.className = "h-3 w-3 cursor-pointer";
// Two clicks, like every undo git cannot give back; disarms after 3 s.
let timer = 0;
const arm = (on: boolean) => {
@@ -374,7 +485,7 @@ export default function DiffEditor({
arm(true);
timer = window.setTimeout(() => arm(false), 3000);
};
keep.onclick = () => cb.current.onBlock?.("keep", block);
include.onchange = () => cb.current.onBlock?.("include", block);
return wrap;
};
@@ -405,6 +516,28 @@ export default function DiffEditor({
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
}),
blockBars(Text.of(original.split("\n")), buildBar),
blameSlot.of([]),
...(onAddToChat
? [
chatChip((start, end) => {
const b = document.createElement("button");
b.type = "button";
b.dataset.testid = "lane-editor-add-to-chat";
b.textContent = cb.current.t("changes.addToChat");
b.className =
"rounded border border-border-light bg-surface-1 px-1.5 py-0.5 font-sans text-[11px] text-fg-primary shadow-card hover:bg-surface-2";
// Keep the editor's focus (and so its selection) through the click.
b.onmousedown = (e) => e.preventDefault();
b.onclick = () => cb.current.onAddToChat?.(start, end);
// CodeMirror makes the returned element the `.cm-tooltip`, whose
// themed rule above would strip the button's own border and fill.
const wrap = document.createElement("div");
wrap.className = "cm-ah-chat";
wrap.append(b);
return wrap;
}),
]
: []),
];
let v: EditorView;
let destroy: () => void;
@@ -446,11 +579,47 @@ export default function DiffEditor({
}
v.focus();
view.current = v;
return destroy;
if (navRef) {
// The B side's chunks, unified or split: the cursor goes to the start
// of the next / previous one and it scrolls into view.
navRef.current = (dir) => {
const ed = view.current;
const chunks = ed ? (chunksOf(ed.state)?.chunks ?? []) : [];
if (!ed || !chunks.length) return;
const at = ed.state.selection.main.head;
const next =
dir === 1
? chunks.find((c) => c.fromB > at)
: [...chunks].reverse().find((c) => c.fromB < ed.state.doc.lineAt(at).from);
if (!next) return;
ed.dispatch({
selection: EditorSelection.cursor(next.fromB),
effects: EditorView.scrollIntoView(next.fromB, { y: "center" }),
});
ed.focus();
};
}
return () => {
if (navRef) navRef.current = null;
destroy();
};
// One mount per view: the parent keys this component by file and shape.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Annotate on / off without remounting the view.
useEffect(() => {
view.current?.dispatch({
effects: blameSlot.reconfigure(
blame
? blameGutter(blame, cb.current.t("changes.notCommitted"), (sha) =>
cb.current.onBlameClick?.(sha)
)
: []
),
});
}, [blame, blameSlot]);
const bars = !!onBlock;
useEffect(() => {
view.current?.dispatch({ effects: barsOn.of(bars) });
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -247,7 +247,7 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
if (hit) return hit;
const lines = hunk.lines;
const emph = new Map<number, [number, number]>();
for (let i = 0; i < lines.length; ) {
for (let i = 0; i < lines.length;) {
if (lines[i]!.type === "ctx") {
i += 1;
continue;
@@ -440,7 +440,8 @@ const startAtRow = (hunk: LaneDiffHunk, i: number) =>
* block's last line. A context rather than a prop because lines render from
* four places; `changes` is what the bar's "n of N" and its arrows walk. */
const HunkActions = createContext<{
actions: (change: LaneDiffChange) => ReactNode;
/** null = no bar; the anchors still mark each block for navigation. */
actions: ((change: LaneDiffChange) => ReactNode) | null;
changes: LaneDiffChange[];
go: (index: number) => void;
} | null>(null);
@@ -457,7 +458,7 @@ function useAnchor(change?: LaneDiffChange) {
function HunkBar({ change }: { change: LaneDiffChange }) {
const { t } = useTranslation(["lanes"]);
const ctx = useContext(HunkActions);
if (!ctx) return null;
if (!ctx?.actions) return null;
const index = ctx.changes.indexOf(change);
const total = ctx.changes.length;
const nav =
@@ -700,8 +701,7 @@ export function clampScroll(v: number, scrollSize: number, clientSize: number) {
}
type Axis =
| ["scrollLeft", "scrollWidth", "clientWidth"]
| ["scrollTop", "scrollHeight", "clientHeight"];
["scrollLeft", "scrollWidth", "clientWidth"] | ["scrollTop", "scrollHeight", "clientHeight"];
const AXES: Axis[] = [
["scrollLeft", "scrollWidth", "clientWidth"],
["scrollTop", "scrollHeight", "clientHeight"],
@@ -931,7 +931,9 @@ export function LaneDiffView({
const at = items.findIndex((it) => it.hunk === target.hunk && it.i === target.firstLine);
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
};
const review = hunkActions && hunks ? { actions: hunkActions, changes, go } : null;
// Provided without actions too: every block's first row carries its
// `data-change-index`, which the panel's F7 / Shift+F7 walk in any range.
const review = hunks ? { actions: hunkActions ?? null, changes, go } : null;
if (file.binary)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
@@ -1,7 +1,8 @@
/**
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
* sidebar: the ones the workspace OWNS and has not archived
* (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other
* (`GET /api/sessions?lane=<id>&archived=0`, newest first, the ones with a live
* pane above the ended ones) — never the other
* sessions that ran in its folder. A row's dot
* shows its state (`sessionDot`: live pane, needs you, error) and its right
* edge the AI tool the session ran on (`sessions.tool`); double-click
@@ -277,10 +278,12 @@ export function WorkspaceSessionList({
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
const q = query.trim().toLowerCase();
const shown = (sessions ?? []).filter(
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
);
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
// Live sessions first, ended ones under them — each group stays newest
// first (the sort is stable over the server's order).
const shown = (sessions ?? [])
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
.sort((a, b) => Number(!!runFor(b)) - Number(!!runFor(a)));
// A live tab whose session has no row yet: shown as a New session at the
// top, like Claude Desktop, until its first hook brings the real row. Not
// during a search — it has no name to match.
@@ -1,11 +1,13 @@
/**
* @file Tests for the Changes panel's CodeMirror editor: which changed lines
* count as edited in place (blue) rather than added or removed whole, and the
* editor's own diff classes on a real mount, and the per-block Undo / Keep bar.
* editor's own diff classes on a real mount, the per-block Undo / Keep bar,
* and the Add to Chat chip on a selection.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, waitFor } from "@testing-library/react";
import { EditorView } from "@codemirror/view";
import DiffEditor, { keepsText } from "../DiffEditor";
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
@@ -166,7 +168,39 @@ describe("DiffEditor", () => {
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
});
it("puts an Undo / Keep bar under each block, by line, in place of Revert", async () => {
it("annotates each line in a gutter, and hands a click its commit", async () => {
const onBlameClick = vi.fn();
const props = {
value: "a\nnew",
original: "a",
lang: "plain",
label: "Edit",
revertLabel: "Revert",
onChange: vi.fn(),
onSave: vi.fn(),
onBlameClick,
};
const sha = "f".repeat(40);
const blame = {
available: true as const,
path: "x",
commits: {
[sha]: { short: "fffffff", author: "Vi", date: "2026-10-01T00:00:00Z", summary: "s" },
},
lines: [sha, null],
};
const { container, rerender } = render(<DiffEditor {...props} />);
expect(container.querySelector(".cm-ah-blame")).toBeNull();
rerender(<DiffEditor {...props} blame={blame} />);
await waitFor(() => expect(container.querySelector(".cm-ah-blame")).not.toBeNull());
// The first element is CodeMirror's invisible width spacer.
const cells = [...container.querySelectorAll(".cm-ah-blame .cm-gutterElement span")].slice(1);
expect(cells.map((c) => c.textContent)).toEqual(["2026-10-01 Vi", "Not committed yet"]);
fireEvent.mouseDown(cells[0]!.parentElement!);
expect(onBlameClick).toHaveBeenCalledWith(sha);
});
it("puts an Undo + commit-checkbox bar under each block, by line, in place of Revert", async () => {
const onBlock = vi.fn();
const props = {
value: "a\nB\nc\nnew\ne",
@@ -177,7 +211,9 @@ describe("DiffEditor", () => {
onChange: vi.fn(),
onSave: vi.fn(),
};
const { container, rerender } = render(<DiffEditor {...props} onBlock={onBlock} />);
const { container, rerender } = render(
<DiffEditor {...props} onBlock={onBlock} blockIncluded={(b) => b.oldFrom === 2} />
);
await waitFor(() =>
expect(container.querySelectorAll('[data-testid="lane-editor-block-bar"]')).toHaveLength(2)
);
@@ -188,8 +224,10 @@ describe("DiffEditor", () => {
const [first, second] = [
...container.querySelectorAll<HTMLElement>('[data-testid="lane-editor-block-bar"]'),
];
fireEvent.click(second!.querySelectorAll("button")[1]!);
expect(onBlock).toHaveBeenLastCalledWith("keep", {
const boxes = [first, second].map((b) => b!.querySelector<HTMLInputElement>("input")!);
expect(boxes.map((b) => b.checked)).toEqual([true, false]);
fireEvent.click(boxes[1]!);
expect(onBlock).toHaveBeenLastCalledWith("include", {
oldFrom: 4,
oldCount: 1,
newFrom: 4,
@@ -216,4 +254,40 @@ describe("DiffEditor", () => {
"false"
);
});
it("offers Add to Chat on a selection, with its 1-based line range", async () => {
const onAddToChat = vi.fn();
const { container } = render(
<DiffEditor
value={"a\nb\nc\nd"}
original={"a\nb\nc\nd"}
lang="plain"
label="Edit"
revertLabel="Revert"
onChange={vi.fn()}
onSave={vi.fn()}
onAddToChat={onAddToChat}
/>
);
const view = EditorView.findFromDOM(container.querySelector<HTMLElement>(".cm-editor")!)!;
expect(container.querySelector('[data-testid="lane-editor-add-to-chat"]')).toBeNull();
// b..c, ending at the start of d: d is not taken.
view.dispatch({ selection: { anchor: 2, head: 6 } });
const chip = await waitFor(() => {
const b = container.querySelector<HTMLButtonElement>(
'[data-testid="lane-editor-add-to-chat"]'
);
expect(b).not.toBeNull();
return b!;
});
// The chip keeps its own box: the tooltip theme strips the WRAPPER's, so
// the button must not be the `.cm-tooltip` itself.
expect(chip.classList.contains("cm-tooltip")).toBe(false);
fireEvent.click(chip);
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
view.dispatch({ selection: { anchor: 6 } });
await waitFor(() =>
expect(container.querySelector('[data-testid="lane-editor-add-to-chat"]')).toBeNull()
);
});
});
@@ -6,7 +6,11 @@
* once, the range toggle changes which comparison is requested, History opens
* one commit, and Files quick-opens a whole file. Also pins the per-file git
* writes — including discard needing two clicks before the server hears
* `confirm` — and the degrade for a cwd git cannot read.
* `confirm` — the JetBrains commit (ticked files, Changes ticked and
* Unversioned Files not by default, Commit and Push, Add to VCS / .gitignore,
* a change block's own checkbox making a hunk-level commit), a file's
* history, Annotate, next/previous file, and the degrade for a cwd git
* cannot read.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -70,6 +74,7 @@ vi.mock("../../../lib/api", () => ({
gitLs: vi.fn(),
gitTree: vi.fn(),
gitTreeAll: vi.fn(),
gitBlame: vi.fn(),
},
},
}));
@@ -125,6 +130,11 @@ async function toDiff(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
}
/** The overview row's checkbox for one path. */
function tickOf(path: string) {
return screen.getByRole("checkbox", { name: `Include ${path} in the commit` });
}
/** The overview row's discard button for one path. */
function discardOf(path: string) {
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
@@ -133,7 +143,6 @@ function discardOf(path: string) {
}
beforeEach(() => {
window.localStorage.removeItem("agenthub.changes.neverStage.7");
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.changes).mockReset();
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
@@ -280,60 +289,36 @@ describe("LaneChangesPanel", () => {
).toBeInTheDocument();
});
it("stages one file and asks the server for that path alone", async () => {
it("adds an unversioned file to VCS, for that path alone, and ticks it", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
// Both rows offer it (one modified, one untracked); the first is the
// modified file the assertion below names.
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
await user.click(stage!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
});
it("keeps a never-stage file out of Stage all, without telling git", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [keep] = screen.getAllByRole("button", { name: /Never stage/ });
await user.click(keep!); // the first row: server/lib/git-ops.js
expect(api.lanes.gitAction).not.toHaveBeenCalled();
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([
"server/lib/git-ops.js",
]);
await user.click(screen.getByRole("button", { name: "Stage all (1)" }));
// Only the untracked row offers it: a tracked file is already in VCS.
const [add, ...more] = screen.getAllByRole("button", { name: "Add to VCS" });
expect(more).toHaveLength(0);
expect(tickOf("notes.txt")).not.toBeChecked();
await user.click(add!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
// And back: the row's button now offers the way out.
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
await waitFor(() => expect(tickOf("notes.txt")).toBeChecked());
});
it("draws a never-stage file orange in the Files tree", async () => {
window.localStorage.setItem("agenthub.changes.neverStage.7", JSON.stringify(["notes.txt"]));
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [
{ name: "notes.txt", path: "notes.txt", dir: false, status: "??" },
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
],
truncated: false,
});
it("a group's box unticks, then ticks, every file in it, without telling git", async () => {
const user = userEvent.setup();
render(<Tabbed laneId={7} />);
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
expect(await row("notes.txt")).toHaveAttribute("data-tree-kept", "true");
expect(await row("notes.txt")).toHaveClass("text-kept");
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
expect(await row("a.txt")).toHaveClass("text-status-warning");
await user.click(tickOf("docs/API.md"));
const group = screen.getByRole("checkbox", { name: "Tick every file in Changes" });
expect(group).toBePartiallyChecked();
await user.click(group); // some ticked -> untick all
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
await user.click(group); // -> tick all
expect(tickOf("docs/API.md")).toBeChecked();
expect(tickOf("server/lib/git-ops.js")).toBeChecked();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
it("lists changes as a folder tree: a folder folds, and its box unticks every file in it", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
@@ -341,16 +326,15 @@ describe("LaneChangesPanel", () => {
const folder = within(list).getByTitle("server/lib");
expect(folder).toHaveTextContent("1 file");
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
const box = within(folder.parentElement!).getByRole("checkbox", {
name: "Tick every file in server/lib",
});
expect(box).toBeChecked();
await user.click(box);
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
await user.click(folder);
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
await user.click(
within(folder.parentElement!).getByRole("button", {
name: "Stage every file in this folder",
})
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
@@ -373,7 +357,7 @@ describe("LaneChangesPanel", () => {
expect(screen.getByTestId("lane-git-bar")).toBeInTheDocument();
expect(api.lanes.git).toHaveBeenCalledTimes(1);
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
const [stage] = screen.getAllByRole("button", { name: "Add to VCS" });
await user.click(stage!);
// The header's counts moved with the index: waiting out its 30s poll
// would leave them wrong beside the list that just changed.
@@ -420,43 +404,61 @@ describe("LaneChangesPanel", () => {
await waitFor(() => expect(api.lanes.gitLog).toHaveBeenCalledTimes(1));
});
it("stages every file the index lacks in one call, from above the list", async () => {
it("commits exactly the ticked files: Changes ticked, Unversioned Files not, by default", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const actions = screen.getByTestId("lane-changes-actions");
// Commit box and Stage all come BEFORE the file list.
// The commit box comes BEFORE the file list.
expect(actions.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByTestId("lane-changes-commit")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Stage all (2)" }));
// The already-staged docs/API.md is not sent again.
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
await user.click(tickOf("notes.txt"));
await user.click(tickOf("docs/API.md"));
await user.type(screen.getByLabelText("Commit message"), "fix: picked");
await user.click(screen.getByRole("button", { name: "Commit (2)" }));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
message: "fix: picked",
paths: ["server/lib/git-ops.js", "notes.txt"],
});
});
it("Commit and Push commits the ticked files, then pushes", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "feat: ship");
await user.click(screen.getByRole("button", { name: "Commit and Push" }));
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledTimes(2));
expect(vi.mocked(api.lanes.gitAction).mock.calls).toEqual([
[7, "commit", { message: "feat: ship", paths: ["server/lib/git-ops.js", "docs/API.md"] }],
[7, "push", {}],
]);
});
it("fills the commit box with the AI-suggested message", async () => {
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByTestId("lane-changes-suggest-message"));
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7);
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7, [
"server/lib/git-ops.js",
"docs/API.md",
]);
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
});
it("lists staged files apart from the changes the next commit leaves behind", async () => {
it("lists tracked changes apart from Unversioned Files, staged or not", async () => {
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
expect(within(staged).getByTitle("docs/API.md")).toBeInTheDocument();
expect(within(staged).queryByText("notes.txt")).toBeNull();
expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
const tracked = list.querySelector('[data-group="changes"]') as HTMLElement;
const unversioned = list.querySelector('[data-group="unversioned"]') as HTMLElement;
expect(within(tracked).getByTitle("docs/API.md")).toBeInTheDocument();
expect(within(tracked).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(tracked).queryByText("notes.txt")).toBeNull();
expect(within(unversioned).getByText("notes.txt")).toBeInTheDocument();
expect(screen.getByText("Unversioned Files")).toBeInTheDocument();
});
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
@@ -627,21 +629,21 @@ describe("LaneChangesPanel", () => {
});
});
it("right-click on a changed file offers stage, a two-click discard and copy path", async () => {
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const row = within(list).getByTitle("server/lib/git-ops.js");
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
const menu = () => within(screen.getByTestId("lane-changes-file-menu"));
// Commit File: that file alone is ticked, and the message box has focus.
await right();
await user.click(
within(screen.getByTestId("lane-changes-file-menu")).getByText("Stage this file")
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
vi.mocked(api.lanes.gitAction).mockClear();
await user.click(menu().getByText("Commit File…"));
expect(tickOf("server/lib/git-ops.js")).toBeChecked();
expect(tickOf("docs/API.md")).not.toBeChecked();
expect(screen.getByLabelText("Commit message")).toHaveFocus();
expect(screen.getByRole("button", { name: "Commit (1)" })).toBeInTheDocument();
await right();
await user.click(screen.getByTestId("lane-changes-menu-discard"));
@@ -654,10 +656,123 @@ describe("LaneChangesPanel", () => {
});
await right();
await user.click(
within(screen.getByTestId("lane-changes-file-menu")).getByText("Copy relative path")
);
await user.click(menu().getByText("Copy relative path"));
expect(await navigator.clipboard.readText()).toBe("server/lib/git-ops.js");
// Copy as Patch: that file's diff, read for its path, as a unified patch.
vi.mocked(api.lanes.changes).mockResolvedValueOnce(
changes({
files: [{ ...changes().files![0]!, hunks: [HUNK] } as never],
})
);
await right();
await user.click(menu().getByText("Copy as Patch to Clipboard"));
await waitFor(async () =>
expect(await navigator.clipboard.readText()).toBe(
[
"diff --git a/server/lib/git-ops.js b/server/lib/git-ops.js",
"--- a/server/lib/git-ops.js",
"+++ b/server/lib/git-ops.js",
"@@ -1,2 +1,3 @@",
" keep",
"-old line",
"+new line",
"",
].join("\n")
)
);
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
});
});
it("right-click on an unversioned file offers Add to VCS and Add to .gitignore", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const row = within(list).getByTitle("notes.txt");
const menu = () => within(screen.getByTestId("lane-changes-file-menu"));
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(menu().getByText("Add to .gitignore"));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "ignore", { paths: ["notes.txt"] });
await user.pointer({ keys: "[MouseRight]", target: row });
expect(menu().getByText("Add to VCS")).toBeInTheDocument();
// A tracked file is already in VCS, and git would not ignore it anyway.
await user.keyboard("{Escape}");
await user.pointer({
keys: "[MouseRight]",
target: within(list).getByTitle("docs/API.md"),
});
expect(menu().queryByText("Add to .gitignore")).toBeNull();
expect(menu().queryByText("Add to VCS")).toBeNull();
});
it("Show History lists one file's commits and opens the file at a commit", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
await user.pointer({
keys: "[MouseRight]",
target: within(list).getByTitle("server/lib/git-ops.js"),
});
await user.click(
within(screen.getByTestId("lane-changes-file-menu")).getByText("Show History")
);
await waitFor(() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
limit: 50,
file: "server/lib/git-ops.js",
})
);
expect(screen.getByTestId("lane-changes-history-file")).toHaveTextContent(
"server/lib/git-ops.js"
);
await user.click(await screen.findByText("feat: the commit"));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenLastCalledWith(7, {
range: "commit",
sha: "a".repeat(40),
path: "server/lib/git-ops.js",
context: 3,
})
);
});
it("walks to the next file from an open one, in list order", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
await user.click(await screen.findByRole("button", { name: "Next file" }));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenLastCalledWith(7, {
range: "work",
path: "notes.txt",
context: 3,
})
);
expect(screen.getByRole("button", { name: "Previous file" })).toBeEnabled();
});
it("annotates the open file with who last changed each line", async () => {
vi.mocked(api.lanes.gitBlame).mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
commits: {},
lines: [],
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
await user.click(await screen.findByRole("button", { name: "Annotate" }));
expect(api.lanes.gitBlame).toHaveBeenCalledWith(7, "server/lib/git-ops.js");
expect(screen.getByRole("button", { name: "Annotate" })).toHaveAttribute(
"aria-pressed",
"true"
);
});
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
@@ -851,7 +966,10 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByTestId("lane-changes-amend"));
await user.click(screen.getByRole("button", { name: "Amend" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "amend", { sha: "aaaaaaa" })
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "amend", {
sha: "aaaaaaa",
paths: ["server/lib/git-ops.js", "docs/API.md"],
})
);
});
@@ -938,7 +1056,7 @@ describe("LaneChangesPanel", () => {
expect(screen.getByText("src")).toBeInTheDocument();
});
it("highlights modified and untracked files in the Files tree, and folders holding them", async () => {
it("colours the Files tree like JetBrains: modified blue, unversioned orange, added green", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
@@ -948,6 +1066,8 @@ describe("LaneChangesPanel", () => {
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
{ name: "new.txt", path: "new.txt", dir: false, status: "??" },
{ name: "keep.txt", path: "keep.txt", dir: false },
{ name: "add.txt", path: "add.txt", dir: false, status: "A " },
{ name: "gone.txt", path: "gone.txt", dir: false, status: " D" },
],
truncated: false,
});
@@ -958,9 +1078,12 @@ describe("LaneChangesPanel", () => {
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
expect(await row("a.txt")).toHaveAttribute("data-tree-status", "M");
expect(await row("a.txt")).toHaveClass("text-status-warning");
expect(await row("a.txt")).toHaveClass("text-diff-mod");
expect(await row("new.txt")).toHaveAttribute("data-tree-status", "U");
expect(await row("new.txt")).toHaveClass("text-status-success");
expect(await row("new.txt")).toHaveClass("text-kept");
expect(await row("add.txt")).toHaveClass("text-status-success");
expect(await row("gone.txt")).toHaveClass("text-fg-muted");
expect(await row("src")).toHaveClass("text-diff-mod");
expect(await row("src")).toHaveAttribute("data-tree-status", "dir");
expect(await row("keep.txt")).not.toHaveAttribute("data-tree-status");
expect(await row("docs")).not.toHaveAttribute("data-tree-status");
@@ -1069,18 +1192,6 @@ describe("LaneChangesPanel", () => {
expect(screen.getByTestId("lane-file-editor")).toHaveValue("xy");
});
it("unstages every staged file in one call, in the same pinned bar as Stage all and commit", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const row = screen.getByTestId("lane-changes-actions");
expect(row).toContainElement(screen.getByTestId("lane-changes-commit"));
expect(row).toContainElement(screen.getByRole("button", { name: "Stage all (2)" }));
await user.click(screen.getByRole("button", { name: "Unstage all (1)" }));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "unstage", { paths: ["docs/API.md"] });
});
it("switches the diff to side-by-side", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -1157,22 +1268,14 @@ describe("LaneChangesPanel", () => {
);
});
it("commits the index from the overview, never the whole tree", async () => {
it("cannot commit with nothing ticked", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
// The box exists only because a file is staged — with a clean index there
// is nothing this button could mean.
const box = await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: the staged half");
await user.click(screen.getByRole("button", { name: "Commit 1 staged" }));
expect(box).toBeInTheDocument();
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
message: "fix: the staged half",
only: "staged",
});
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: nothing");
await user.click(screen.getByRole("checkbox", { name: "Tick every file in Changes" }));
expect(screen.getByRole("button", { name: "Commit (0)" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Commit and Push" })).toBeDisabled();
});
it("drops a half-typed commit message on a lane switch", async () => {
@@ -1185,24 +1288,10 @@ describe("LaneChangesPanel", () => {
expect(screen.getByLabelText("Commit message")).toHaveValue("");
});
it("hides the commit box when nothing is staged", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
unstaged: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
);
it("hides the commit box when there is nothing to commit", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(changes({ files: [] }));
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await screen.findByText("Nothing uncommitted.");
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
});
@@ -1292,22 +1381,7 @@ describe("LaneChangesPanel", () => {
await screen.findByTestId("lane-changes-review");
};
it("stages one hunk by its @@ line, at the context the diff was read with", async () => {
const user = userEvent.setup();
await openReview(user);
// Two hunks on the tracked file; the untracked one has none to act on.
const stage = screen.getAllByRole("button", { name: "Stage this hunk" });
expect(stage).toHaveLength(2);
await user.click(stage[1]!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -9,2 +10,3 @@",
block: 0,
context: 3,
});
});
it("gives each +/- block inside ONE hunk its own bar and acts on that block only (#50)", async () => {
it("gives each +/- block inside ONE hunk its own bar and checkbox (#50)", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
@@ -1350,13 +1424,35 @@ describe("LaneChangesPanel", () => {
expect.stringContaining("1 of 2"),
expect.stringContaining("2 of 2"),
]);
await user.click(screen.getAllByRole("button", { name: "Stage this hunk" })[1]!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -1,4 +1,5 @@",
block: 1,
context: 3,
});
const boxes = screen.getAllByRole("checkbox", { name: "Include this change in the commit" });
expect(boxes).toHaveLength(2);
await user.click(boxes[1]!);
expect(boxes[0]).toBeChecked();
expect(boxes[1]).not.toBeChecked();
expect(tickOf("server/lib/git-ops.js")).toBePartiallyChecked();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
const user = userEvent.setup();
await openReview(user);
// The second hunk's only block unticked: the file is partial.
await user.click(
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
);
expect(tickOf("server/lib/git-ops.js")).toBePartiallyChecked();
await user.click(screen.getByRole("button", { name: "Changes" }));
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: half");
await user.click(screen.getByRole("button", { name: "Commit (1)" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
message: "fix: half",
paths: [],
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
context: 3,
})
);
});
it("hangs one review bar under each actionable hunk, counting them", async () => {
@@ -1398,14 +1494,18 @@ describe("LaneChangesPanel", () => {
});
});
it("stages a whole file from its review header without folding it", async () => {
it("unticks a whole file from its review header without folding it", async () => {
const user = userEvent.setup();
await openReview(user);
const [stageFile] = screen.getAllByRole("button", { name: "Stage this file" });
await user.click(stageFile!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
expect(screen.queryByRole("button", { name: "Stage this file" })).toBeNull();
await user.click(tickOf("server/lib/git-ops.js"));
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
// Every block of it follows the file.
for (const box of screen.getAllByRole("checkbox", {
name: "Include this change in the commit",
}))
expect(box).not.toBeChecked();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
expect(
screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })
).toBeInTheDocument();
@@ -1615,7 +1715,7 @@ describe("LaneChangesPanel", () => {
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
const [stage] = screen.getAllByRole("button", { name: "Add to VCS" });
await user.click(stage!);
rerender(<LaneChangesPanel laneId={8} />);
@@ -1732,6 +1832,8 @@ describe("a merge in progress (issue #45)", () => {
// Not offered as an ordinary stage: that would mark it resolved blind.
expect(screen.getAllByText("a.txt")).toHaveLength(1);
expect(screen.getByRole("button", { name: "Complete merge" })).toBeDisabled();
// A partial commit cannot finish a merge: Complete is the way out.
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
await user.click(within(group).getByRole("button", { name: "Take theirs" }));
await waitFor(() =>