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:
@@ -42,12 +42,18 @@
|
||||
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Square, Columns2, Grid2x2 } from "lucide-react";
|
||||
import { Plus, Square, Columns2, Grid2x2, Pencil } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import {
|
||||
readLaneOrder,
|
||||
writeLaneOrder,
|
||||
sortByStoredOrder,
|
||||
moveLane,
|
||||
} from "../lib/laneOrderStorage";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, {
|
||||
@@ -206,6 +212,9 @@ export function Workspace() {
|
||||
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
||||
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
||||
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
|
||||
// Inline rename of the selected lane's title, in the detail header.
|
||||
const [renamingLaneId, setRenamingLaneId] = useState<number | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
|
||||
// Run state kept at page level: shared across every pane, or drives the
|
||||
// lane strip itself rather than any one pane's form.
|
||||
@@ -253,7 +262,10 @@ export function Workspace() {
|
||||
const refreshLanes = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.lanes.list();
|
||||
setLanes(keepIfSame(r.lanes));
|
||||
// The `lanes` state IS the display order: the server answers in id order,
|
||||
// the viewer's stored strip order is applied once here, and the in-place
|
||||
// lane_update merge below preserves whatever order this produced.
|
||||
setLanes(keepIfSame(sortByStoredOrder(r.lanes, readLaneOrder())));
|
||||
setSelectedLaneId((cur) =>
|
||||
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
||||
);
|
||||
@@ -472,6 +484,37 @@ export function Workspace() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Move a lane card within the strip. Purely a viewer preference — it writes
|
||||
* localStorage, never the lane row, so nothing the CLI or another session
|
||||
* reads changes.
|
||||
*/
|
||||
const handleLaneReorder = (from: number, to: number) => {
|
||||
if (from === to || from < 0 || to < 0 || from >= lanes.length || to >= lanes.length) return;
|
||||
const ids = moveLane(
|
||||
lanes.map((l) => l.id),
|
||||
from,
|
||||
to
|
||||
);
|
||||
writeLaneOrder(ids);
|
||||
setLanes(sortByStoredOrder(lanes, ids));
|
||||
};
|
||||
|
||||
/** Commit an inline lane rename. An empty name clears the title, which puts
|
||||
* the cwd back in its place (`lane.title || lane.cwd` everywhere). */
|
||||
const commitRename = async (id: number) => {
|
||||
const title = renameDraft.trim();
|
||||
setRenamingLaneId(null);
|
||||
const lane = lanes.find((l) => l.id === id);
|
||||
if (!lane || title === (lane.title || "")) return;
|
||||
try {
|
||||
await api.lanes.update(id, { title });
|
||||
await refreshLanes();
|
||||
} catch (err) {
|
||||
setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown"));
|
||||
}
|
||||
};
|
||||
|
||||
// Mirrors `agenthub lanes pipeline <template> <id>`: same PATCH, same
|
||||
// stage-mismatch warning when the current declared stage matches no node
|
||||
// in the newly chosen template.
|
||||
@@ -524,12 +567,14 @@ export function Workspace() {
|
||||
<Plus className="h-4 w-4" />
|
||||
{tLanes("add")}
|
||||
</button>
|
||||
{lanes.map((l) => (
|
||||
{lanes.map((l, i) => (
|
||||
<LaneStripCard
|
||||
key={l.id}
|
||||
lane={l}
|
||||
index={i}
|
||||
selected={selectedLaneId === l.id}
|
||||
onSelect={() => setSelectedLaneId(l.id)}
|
||||
onReorder={handleLaneReorder}
|
||||
/>
|
||||
))}
|
||||
{!lanes.length && (
|
||||
@@ -568,15 +613,47 @@ export function Workspace() {
|
||||
? tLanes("statusDead")
|
||||
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
|
||||
title={currentLane.cwd}
|
||||
>
|
||||
{tLanes("laneHeader", {
|
||||
id: currentLane.id,
|
||||
title: currentLane.title || currentLane.cwd,
|
||||
})}
|
||||
</span>
|
||||
{/* The lane's name, and the one place it can be set. No "Lane 23 ·"
|
||||
prefix any more: the id is on the strip tile, where the CLI's
|
||||
`--lane` reader looks for it, and repeating it here only ate
|
||||
the width the name needed. Submitting an empty name clears
|
||||
`title`, which puts the cwd back in its place. */}
|
||||
{renamingLaneId === currentLane.id ? (
|
||||
<input
|
||||
data-testid="lane-rename-input"
|
||||
aria-label={tLanes("renameLabel")}
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
// Blur CANCELS, it does not save — same convention as the
|
||||
// rename field in CcConfig. Committing here too would fire
|
||||
// the PATCH twice for one Enter: the commit unmounts the
|
||||
// focused input, and removing a focused element raises a
|
||||
// native blur before the refetch has moved `lanes`, so the
|
||||
// "unchanged title" guard would not catch the second call.
|
||||
onBlur={() => setRenamingLaneId(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void commitRename(currentLane.id);
|
||||
if (e.key === "Escape") setRenamingLaneId(null);
|
||||
}}
|
||||
maxLength={200}
|
||||
className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-0.5 text-sm font-semibold text-fg-primary"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-rename"
|
||||
onClick={() => {
|
||||
setRenameDraft(currentLane.title || "");
|
||||
setRenamingLaneId(currentLane.id);
|
||||
}}
|
||||
title={`${currentLane.cwd}\n${tLanes("renameLabel")}`}
|
||||
className="group flex min-w-0 items-center gap-1 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<span className="min-w-0 truncate">{currentLane.title || currentLane.cwd}</span>
|
||||
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
)}
|
||||
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
||||
{since(currentLane.stage_seconds)}
|
||||
</span>
|
||||
|
||||
@@ -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