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,614 @@
/**
* @file Workspace.laneReorderRename.test.tsx
* @description Integration tests for lane strip reordering, persistence to
* localStorage, and inline lane rename functionality. Covers the three paths
* to reorder (drag, Alt+Arrow, and refetch with stored order), and rename
* commit/cancel/error paths.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
const recordedCalls: Array<{ method: string; path: string; payload?: unknown }> = [];
type LaneFixture = {
id: number;
cwd: string;
title: string;
pipeline_name: string;
pipeline_nodes: never[];
detected_signal: string | null;
run_id: string | null;
};
let lanesToReturn: LaneFixture[] = [
{
id: 1,
cwd: "/workspace/a",
title: "Lane A",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
{
id: 2,
cwd: "/workspace/b",
title: "Lane B",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
{
id: 3,
cwd: "/workspace/c",
title: "Lane C",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
];
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const recordCall = (method: string, path: string, payload?: unknown) => {
recordedCalls.push({ method, path, payload });
};
return {
...actual,
api: {
lanes: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes");
return {
lanes: lanesToReturn,
counts: { total: lanesToReturn.length, running: 0, needs_you: 0, dead: 0 },
};
}),
ensure: vi.fn(),
action: vi.fn(),
git: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/git`);
return { available: false };
}),
runtime: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/runtime`);
return { available: false };
}),
stage: vi.fn(),
pipelines: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes/pipelines");
return { pipelines: [{ id: "default", name: "default", nodes: [] }] };
}),
update: vi.fn().mockImplementation(async (id: number, patch: Record<string, unknown>) => {
recordCall("PATCH", `/api/lanes/${id}`, patch);
const lane = lanesToReturn.find((l) => l.id === id);
const updated = { ...lane, ...patch } as LaneFixture;
// Update in place for subsequent reads
const idx = lanesToReturn.findIndex((l) => l.id === id);
if (idx >= 0) lanesToReturn[idx] = updated;
return { lane: updated };
}),
features: {
list: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/features`);
return { features: [] };
}),
show: vi.fn(),
},
proof: {
list: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/proof`);
return { features: [] };
}),
imageUrl: vi.fn(),
delete: vi.fn(),
},
},
aiRouters: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/ai-routers");
return { items: [] };
}),
},
run: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/list");
return { runs: [], items: [] };
}),
history: vi.fn().mockResolvedValue({ items: [] }),
binary: vi.fn().mockResolvedValue({ found: true, path: "/usr/bin/claude" }),
cwds: vi.fn().mockResolvedValue({ items: [] }),
files: vi.fn().mockResolvedValue({ items: [] }),
start: vi.fn(),
get: vi.fn(),
send: vi.fn(),
kill: vi.fn(),
},
ccConfig: {
commands: vi.fn().mockResolvedValue({ items: [] }),
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
file: vi.fn().mockResolvedValue({ text: "", content: "" }),
},
sessions: {
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
transcript: vi.fn().mockResolvedValue({ messages: [] }),
},
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
vi.mock("../../components/run/TerminalView", () => ({
TerminalView: ({ runId }: { runId: string }) => (
<div data-testid="terminal-view" data-run-id={runId} />
),
}));
import { Workspace } from "../Workspace";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
if (typeof globalThis !== "undefined") {
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
if (typeof Element !== "undefined") {
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
}
}
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
async function renderWorkspace() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
await settle();
return utils;
}
beforeEach(() => {
recordedCalls.length = 0;
lanesToReturn = [
{
id: 1,
cwd: "/workspace/a",
title: "Lane A",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
{
id: 2,
cwd: "/workspace/b",
title: "Lane B",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
{
id: 3,
cwd: "/workspace/c",
title: "Lane C",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
];
localStorage.clear();
vi.clearAllMocks();
});
afterEach(() => {
recordedCalls.length = 0;
localStorage.clear();
vi.clearAllMocks();
});
describe("Workspace — lane reordering", () => {
it("persists lane order to localStorage when dragging a tile", async () => {
await renderWorkspace();
// Verify initial order is A, B, C (strip includes "Add lane" prefix and ids)
expect(screen.getByTestId("lane-strip").textContent).toMatch(/Lane A.*Lane B.*Lane C/);
// Drag tile 3 (Lane C) to position 0
const tile1 = screen.getByTestId("lane-tile-1");
const tile3 = screen.getByTestId("lane-tile-3");
fireEvent.dragStart(tile3, { dataTransfer: { setData: vi.fn(), effectAllowed: "" } });
fireEvent.dragOver(tile1, { dataTransfer: { dropEffect: "", preventDefault: () => {} } });
fireEvent.drop(tile1, {
dataTransfer: { getData: vi.fn(() => "2"), preventDefault: () => {} },
});
await settle();
// Check localStorage has the new order
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
expect(stored).toBeDefined();
const order = JSON.parse(stored!);
expect(order).toEqual([3, 1, 2]);
});
it("applies stored order when lanes are fetched", async () => {
// Pre-populate localStorage with a custom order
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 3, 1]));
await renderWorkspace();
// The lane strip should reflect the stored order: B, C, A
const strip = screen.getByTestId("lane-strip");
const tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
const laneIds = tiles
.map((el) => el.getAttribute("data-testid"))
.filter((id) => id && id.match(/lane-tile-\d+/))
.map((id) => id!.replace("lane-tile-", ""));
expect(laneIds).toEqual(["2", "3", "1"]);
});
it("appends new lanes to the end, preserving user order", async () => {
// Start with a custom order
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 1]));
await renderWorkspace();
let strip = screen.getByTestId("lane-strip");
let tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
let laneIds = tiles
.map((el) => el.getAttribute("data-testid"))
.filter((id) => id && id.match(/lane-tile-\d+/))
.map((id) => id!.replace("lane-tile-", ""));
expect(laneIds).toEqual(["2", "1", "3"]); // 3 is new, appended at end
});
it("ignores stored ids for lanes that no longer exist", async () => {
// Store an order that includes a lane ID that will be removed
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 999, 1]));
// Remove lane 2 from the server response
lanesToReturn = lanesToReturn.filter((l) => l.id !== 2);
await renderWorkspace();
// The strip should show lane 1 and 3 in stored order, skipping 999
const strip = screen.getByTestId("lane-strip");
const tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
const laneIds = tiles
.map((el) => el.getAttribute("data-testid"))
.filter((id) => id && id.match(/lane-tile-\d+/))
.map((id) => id!.replace("lane-tile-", ""));
// Expected: skip 999 and 2, show 1, then 3 (the remaining fresh lane)
expect(laneIds).toEqual(["1", "3"]);
});
it("reorders with Alt+Arrow keyboard shortcut", async () => {
await renderWorkspace();
// Get tile 2 (Lane B) and move it right to position 2
const tile2 = screen.getByTestId("lane-tile-2");
fireEvent.keyDown(tile2, { key: "ArrowRight", altKey: true });
await settle();
// Check localStorage
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
const order = JSON.parse(stored!);
// Tile 2 was at index 1, moving right puts it at index 2
expect(order).toEqual([1, 3, 2]);
});
});
describe("Workspace — lane rename", () => {
it("enters rename mode when the pencil icon is clicked", async () => {
const user = userEvent.setup();
await renderWorkspace();
// Click the first lane to select it (so its detail panel shows)
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
// Find the rename button (displays the lane title with a pencil icon)
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
// The input should appear with the current title
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.value).toBe("Lane A");
});
it("commits a rename when Enter is pressed", async () => {
const user = userEvent.setup();
await renderWorkspace();
// Select lane 1 and open rename
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
await user.clear(input);
await user.type(input, "New Lane Name");
fireEvent.keyDown(input, { key: "Enter" });
await settle();
// Should have called update with trimmed title
await waitFor(() => {
const updateCall = recordedCalls.find(
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
);
expect(updateCall).toBeDefined();
expect(updateCall?.payload).toEqual({ title: "New Lane Name" });
});
// Input should be gone, button should be back
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
expect(screen.getByTestId("lane-rename")).toBeInTheDocument();
});
it("cancels rename when Escape is pressed", async () => {
const user = userEvent.setup();
await renderWorkspace();
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
await user.type(input, "should not save");
fireEvent.keyDown(input, { key: "Escape" });
await settle();
// Input should be gone
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
// No API call should have been made
const updateCalls = recordedCalls.filter(
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
);
expect(updateCalls).toHaveLength(0);
});
it("exits rename mode when focus leaves (blur/tab away), attempting to commit", async () => {
const user = userEvent.setup();
await renderWorkspace();
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
expect(input).toBeInTheDocument();
// Move focus away (tab)
await user.tab();
await settle();
// Input should be gone (rename mode exited)
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
// The rename button should be back
expect(screen.getByTestId("lane-rename")).toBeInTheDocument();
});
it("trims whitespace from the renamed title", async () => {
const user = userEvent.setup();
await renderWorkspace();
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
await user.clear(input);
await user.type(input, " Trimmed ");
fireEvent.keyDown(input, { key: "Enter" });
await settle();
// Should send trimmed title
await waitFor(() => {
const updateCall = recordedCalls.find(
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
);
expect(updateCall?.payload).toEqual({ title: "Trimmed" });
});
});
it("clears the title (puts cwd in its place) when empty name is submitted", async () => {
const user = userEvent.setup();
await renderWorkspace();
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
await user.clear(input);
// Leave it empty (or with just whitespace)
await user.type(input, " ");
fireEvent.keyDown(input, { key: "Enter" });
await settle();
// Should send empty string
await waitFor(() => {
const updateCall = recordedCalls.find(
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
);
expect(updateCall?.payload).toEqual({ title: "" });
});
});
it("does not call the API if the title has not changed", async () => {
const user = userEvent.setup();
await renderWorkspace();
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
// Don't change the title, just press Enter
fireEvent.keyDown(input, { key: "Enter" });
await settle();
// Should NOT have called update (or at least not with a changed value)
const updateCalls = recordedCalls.filter(
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
);
expect(updateCalls).toHaveLength(0);
});
it("allows the user to retry after an error, starting fresh", async () => {
const user = userEvent.setup();
await renderWorkspace();
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
// Enter and exit rename mode twice to verify the state resets
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
let input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
expect(input.value).toBe("Lane A");
fireEvent.keyDown(input, { key: "Escape" });
await settle();
// Enter again to verify we can re-enter
await user.click(screen.getByTestId("lane-rename"));
await settle();
input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
expect(input.value).toBe("Lane A");
});
it("respects max length of 200 characters (enforced by server)", async () => {
const user = userEvent.setup();
await renderWorkspace();
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
// HTML input has maxLength=200, so typing more should be capped
expect(input.maxLength).toBe(200);
});
});
describe("Workspace — lane order and rename interaction", () => {
it("preserves custom order after renaming a lane", async () => {
const user = userEvent.setup();
// Set custom order
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([3, 2, 1]));
await renderWorkspace();
// Verify order is C, B, A
let strip = screen.getByTestId("lane-strip");
let tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
let laneIds = tiles
.map((el) => el.getAttribute("data-testid"))
.filter((id) => id && id.match(/lane-tile-\d+/))
.map((id) => id!.replace("lane-tile-", ""));
expect(laneIds).toEqual(["3", "2", "1"]);
// Click lane 2 (which is now at position 1)
const tile2 = screen.getByTestId("lane-tile-2");
await user.click(tile2);
await settle();
// Rename it
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
await user.clear(input);
await user.type(input, "Renamed B");
fireEvent.keyDown(input, { key: "Enter" });
await settle();
// Wait for the refresh
await waitFor(() => {
const updateCall = recordedCalls.find(
(c) => c.path === "/api/lanes/2" && c.method === "PATCH"
);
expect(updateCall).toBeDefined();
});
// Order should still be preserved
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
const order = JSON.parse(stored!);
expect(order).toEqual([3, 2, 1]);
});
});