feat(lanes): reorder and rename lane cards, and show each file's line delta

The lane strip's tiles are now drag-reorderable (Alt+←/→ is the keyboard
path), and a lane's name can be set in place from the detail header. The
"Lane 23 ·" prefix is gone from both — the bare `#23` stays on the tile,
because that number is how the CLI addresses a lane.

Card order is a per-viewer preference, not a lane fact: it lives in
localStorage (`agenthub.workspace.laneOrder`) beside the split-view layout
and writes nothing server-side, so `GET /api/lanes` and `agenthub lanes`
keep answering in id order. Renaming is the opposite — it is PATCH
/api/lanes/:id with {title}, the one lane column a user now types into
directly.

That made a patch value reachable by hand for the first time, so
`bindable` now rejects any non-scalar for every PATCHABLE column with
EBADFIELD, and the route answers 400 for that and for the
SQLITE_CONSTRAINT family, rather than handing better-sqlite3 a datatype
throw and reporting 500 on plain bad input.

The uncommitted-files tooltip became a real panel. `GET /api/lanes/:id/git`
now returns `files` as {path, status, added, deleted} records — deltas from
one `git diff --numstat HEAD`, null where git has none to report (untracked,
binary, renamed) — and the card renders them portalled to the body, so the
Workspace's own scroll box cannot clip the list.
This commit is contained in:
2026-09-17 15:07:37 +07:00
parent b5ca531800
commit 5bd1c0323f
22 changed files with 1552 additions and 95 deletions
+24
View File
@@ -143,6 +143,30 @@ describe("/api/lanes", () => {
assert.equal((await request("GET", `/api/lanes/${laneId}`)).status, 404);
});
// The Workspace's inline rename is a text field PATCHing this route, so a
// title is the one lane column a user types directly. Bad input on any
// patchable column is a 400: reaching better-sqlite3 with a non-scalar
// raises a datatype error Express can only report as a 500.
it("renames a lane, and answers 400 rather than 500 for unbindable input", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-rename-route" });
const id = created.body.lane.id;
const named = await request("PATCH", `/api/lanes/${id}`, { title: " checkout flow " });
assert.equal(named.status, 200);
assert.equal(named.body.lane.title, "checkout flow");
const cleared = await request("PATCH", `/api/lanes/${id}`, { title: "" });
assert.equal(cleared.body.lane.title, "");
for (const patch of [{ title: { evil: true } }, { notes: { a: 1 } }, { ci_status: true }]) {
const bad = await request("PATCH", `/api/lanes/${id}`, patch);
assert.equal(bad.status, 400, `expected 400 for ${JSON.stringify(patch)}`);
assert.equal(bad.body.error.code, "EBADFIELD");
}
// A NOT NULL column patched to null is the caller's mistake too.
const notNull = await request("PATCH", `/api/lanes/${id}`, { status: null });
assert.equal(notNull.status, 400);
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("rejects cross-origin lane deletion", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-delete" });
const id = created.body.lane.id;
+42
View File
@@ -358,6 +358,48 @@ describe("lanes lib", () => {
assert.equal(after.tool, "codex");
assert.equal(after.title, "x");
});
// The Workspace's inline rename PATCHes `title` straight from a text field,
// so the column is now reachable by anything same-origin. A non-string would
// reach better-sqlite3 as a datatype throw and surface as a 500 on what is
// plain bad input.
it("normalizes a title on create and on update", () => {
const lane = lanes.createLane({ cwd: "/tmp/title-norm", title: " spaced " });
assert.equal(lane.title, "spaced");
assert.equal(lanes.updateLane(lane.id, { title: 42 }).title, "42");
assert.equal(lanes.updateLane(lane.id, { title: "x".repeat(500) }).title.length, 200);
// null clears rather than violating the NOT NULL, and the empty string is
// a real value: it puts the lane's cwd back in the UI's place for it.
assert.equal(lanes.updateLane(lane.id, { title: null }).title, "");
assert.equal(lanes.updateLane(lane.id, { title: "" }).title, "");
});
// Every free-text column, not just the one the rename field exposes: a
// non-scalar reaches better-sqlite3 as a datatype throw, which Express can
// only report as a 500 on what is plainly the caller's mistake.
it("rejects a non-scalar patch value on any patchable column", () => {
const lane = lanes.createLane({ cwd: "/tmp/patch-scalar" });
for (const patch of [
{ title: { evil: true } },
{ notes: { a: 1 } },
{ branch: ["main"] },
{ ci_status: true },
{ session_id: () => "x" },
]) {
assert.throws(
() => lanes.updateLane(lane.id, patch),
(err) => err.code === "EBADFIELD",
`expected EBADFIELD for ${JSON.stringify(Object.keys(patch))}`
);
}
assert.throws(
() => lanes.createLane({ cwd: "/tmp/patch-scalar-create", title: { evil: true } }),
(err) => err.code === "EBADFIELD"
);
// `links` is the one patchable column that IS an object — it is stored as
// JSON, so it must keep working.
assert.deepEqual(lanes.updateLane(lane.id, { links: { pr: "u" } }).links, { pr: "u" });
});
});
const { withLaneLock } = require("../lib/lane-lock");
+10 -2
View File
@@ -100,8 +100,16 @@ describe("worktree", () => {
assert.equal(s.dirty, 1);
assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
assert.match(s.head, /^[0-9a-f]{7,40}$/);
// The same lines the counts came from, so a caller can name the files.
assert.deepEqual(s.files.sort(), [" M README.md", "?? scratch.txt"]);
// The files the counts came from, each with its porcelain status code and
// its line delta. An UNTRACKED file has no delta to report — git has never
// seen it — so its counts are null rather than invented.
assert.deepEqual(
s.files.sort((a, b) => a.path.localeCompare(b.path)),
[
{ path: "README.md", status: " M", added: 1, deleted: 0 },
{ path: "scratch.txt", status: "??", added: null, deleted: null },
]
);
// The fixture has no remotes. Given the lane's base branch, unpushedCount
// measures base..HEAD — this worktree has committed nothing of its own, so 0.
assert.equal(await wt.unpushedCount(dir, "main"), 0);