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
@@ -0,0 +1,75 @@
/**
* @file laneOrderStorage.test.ts
* @description Tests for the Workspace lane strip's stored card order: the
* storage round-trip, and the two rules that keep a stale stored order from
* losing a lane — unknown ids land at the end, vanished ids are skipped.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { readLaneOrder, writeLaneOrder, sortByStoredOrder, moveLane } from "../laneOrderStorage";
const lanes = (...ids: number[]) => ids.map((id) => ({ id }));
describe("laneOrderStorage", () => {
beforeEach(() => localStorage.clear());
it("returns an empty order when nothing is stored", () => {
expect(readLaneOrder()).toEqual([]);
});
it("round-trips a written order", () => {
writeLaneOrder([3, 1, 2]);
expect(readLaneOrder()).toEqual([3, 1, 2]);
});
it("falls back to an empty order when stored JSON is malformed", () => {
localStorage.setItem("agenthub.workspace.laneOrder", "{not json");
expect(readLaneOrder()).toEqual([]);
});
it("drops non-numeric entries from a stored order", () => {
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([1, "2", null, 3]));
expect(readLaneOrder()).toEqual([1, 3]);
});
});
describe("sortByStoredOrder", () => {
it("keeps server order when nothing is stored", () => {
expect(sortByStoredOrder(lanes(1, 2, 3), [])).toEqual(lanes(1, 2, 3));
});
it("applies the stored order", () => {
expect(sortByStoredOrder(lanes(1, 2, 3), [3, 1, 2])).toEqual(lanes(3, 1, 2));
});
it("appends lanes the stored order has never seen, in server order", () => {
expect(sortByStoredOrder(lanes(1, 2, 3, 4), [3, 1])).toEqual(lanes(3, 1, 2, 4));
});
it("skips stored ids whose lane no longer exists", () => {
expect(sortByStoredOrder(lanes(1, 3), [3, 2, 1])).toEqual(lanes(3, 1));
});
it("does not mutate the input", () => {
const input = lanes(1, 2, 3);
sortByStoredOrder(input, [3, 2, 1]);
expect(input.map((l) => l.id)).toEqual([1, 2, 3]);
});
});
describe("moveLane", () => {
it("moves a card forward", () => {
expect(moveLane([1, 2, 3], 0, 2)).toEqual([2, 3, 1]);
});
it("moves a card backward", () => {
expect(moveLane([1, 2, 3], 2, 0)).toEqual([3, 1, 2]);
});
it("returns the order unchanged for a no-op or out-of-range move", () => {
expect(moveLane([1, 2, 3], 1, 1)).toEqual([1, 2, 3]);
expect(moveLane([1, 2, 3], 0, 3)).toEqual([1, 2, 3]);
expect(moveLane([1, 2, 3], -1, 0)).toEqual([1, 2, 3]);
});
});
+69
View File
@@ -0,0 +1,69 @@
/**
* @file laneOrderStorage.ts
* @description Persists the Workspace lane strip's user-chosen card order to
* localStorage, the same read/validate/fallback convention as
* splitViewStorage.ts.
*
* The order lives in the browser rather than the lanes table on purpose: it is
* a per-viewer preference, like the split-pane layout beside it, and nothing
* server-side (the CLI's `agenthub lanes`, the pipeline-stats picker) has an
* opinion about it. Ceiling: the order does not follow the user to another
* browser or to the CLI — move it to a `lanes.sort_order` column if it ever
* must.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const STORAGE_KEY = "agenthub.workspace.laneOrder";
export function readLaneOrder(): number[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((id): id is number => typeof id === "number");
} catch {
return [];
}
}
export function writeLaneOrder(ids: number[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
} catch {
/* ignore quota / disabled storage */
}
}
/**
* Applies a stored id order to a freshly fetched lane list.
*
* Ids the stored order never saw — a lane created since, or created in another
* tab — keep their server order (id ascending) and land at the END, so a new
* lane appears predictably rather than wherever a stale index puts it. Stored
* ids naming a lane that no longer exists are simply skipped.
*
* @param lanes Lanes in server order.
* @param order Stored id order.
* @returns A new array; the input is never mutated.
*/
export function sortByStoredOrder<T extends { id: number }>(lanes: T[], order: number[]): T[] {
const rank = new Map(order.map((id, i) => [id, i]));
const known = lanes
.filter((l) => rank.has(l.id))
.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
const fresh = lanes.filter((l) => !rank.has(l.id));
return [...known, ...fresh];
}
/**
* Moves the lane at `from` to index `to`, returning the resulting id order.
* Out-of-range indices yield the unchanged order rather than a hole.
*/
export function moveLane(ids: number[], from: number, to: number): number[] {
if (from === to || from < 0 || to < 0 || from >= ids.length || to >= ids.length) return ids;
const next = [...ids];
next.splice(to, 0, ...next.splice(from, 1));
return next;
}
+14 -2
View File
@@ -2592,11 +2592,23 @@ export type LaneGitFacts =
dirty: number;
/** Files git has never seen. */
untracked: number;
/** Raw `git status --porcelain` lines, capped; the counts stay exact. */
files: string[];
/** The files behind the counts, capped; the counts themselves stay exact. */
files: LaneGitFile[];
}
| { available: false };
/** One uncommitted file, as `GET /api/lanes/:id/git` reports it. */
export interface LaneGitFile {
/** Path relative to the repo root; for a rename, the new path. */
path: string;
/** The two-character `git status --porcelain` code, e.g. ` M`, `??`, `A `. */
status: string;
/** Lines added, or null when git has no delta for it (untracked, binary). */
added: number | null;
/** Lines removed, or null for the same reasons. */
deleted: number | null;
}
/** Aggregate counts across all lanes. */
export interface LaneCounts {
total: number;