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:
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user