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
+17 -6
View File
@@ -412,7 +412,12 @@ router.post("/", sameOriginGuard, (req, res) => {
broadcastLane(lane.id);
res.status(201).json({ lane: payload(lane) });
} catch (err) {
if (err.code === "EBADCWD" || err.code === "EBADPIPELINE" || err.code === "EBADKIND") {
if (
err.code === "EBADCWD" ||
err.code === "EBADPIPELINE" ||
err.code === "EBADKIND" ||
err.code === "EBADFIELD"
) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
@@ -432,12 +437,18 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
try {
lane = lanesLib.updateLane(req.params.id, req.body || {});
} catch (err) {
// A bad `kind` or `pipeline` is invalid input, not a server fault — every
// sibling route answers 400 here, so this one must too instead of throwing
// into Express.
if (err.code === "EBADKIND" || err.code === "EBADPIPELINE") {
// A bad `kind`, `pipeline` or field value is invalid input, not a server
// fault — every sibling route answers 400 here, so this one must too
// instead of throwing into Express. The SQLITE_CONSTRAINT family lands
// here for the same reason: a NOT NULL column patched to null is the
// caller's mistake, and enumerating which columns those are in JS would
// just be the schema written twice.
if (err.code === "EBADKIND" || err.code === "EBADPIPELINE" || err.code === "EBADFIELD") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
if (String(err.code || "").startsWith("SQLITE_CONSTRAINT")) {
return res.status(400).json({ error: { code: "EBADFIELD", message: err.message } });
}
return res.status(500).json({ error: { code: err.code, message: err.message } });
}
broadcastLane(lane.id);
@@ -690,7 +701,7 @@ router.get("/:id/integrations/:name", (req, res) => {
* the uncommitted counts. Read-only, so no same-origin guard — that guard
* exists for the destructive actions.
*
* Deliberately NOT part of `GET /api/lanes`: this shells out to git three
* Deliberately NOT part of `GET /api/lanes`: this shells out to git five
* times, and that payload is polled and re-broadcast on every hook-driven
* lane_update. Any failure — no such directory, not a repo, git itself
* erroring — is reported as `available: false` rather than a 500, because a