feat(webui): add tabbed pane workbench (#5322)
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
||||
ChatSummary,
|
||||
ConnectionStatus,
|
||||
SessionAutomationJob,
|
||||
SidebarStatePayload,
|
||||
WorkspaceScopePayload,
|
||||
} from "@/lib/types";
|
||||
|
||||
@@ -30,6 +31,7 @@ const sessionUpdateHandlers = new Set<(
|
||||
scope?: string,
|
||||
workspaceScope?: WorkspaceScopePayload,
|
||||
) => void>();
|
||||
const sidebarStateUpdateHandlers = new Set<(state: SidebarStatePayload) => void>();
|
||||
let mockSessions: ChatSummary[] = [];
|
||||
const HERO_GREETING_PATTERN =
|
||||
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
|
||||
@@ -164,7 +166,24 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: refreshSpy,
|
||||
createChat: createChatSpy,
|
||||
createChat: async (scope?: WorkspaceScopePayload | null) => {
|
||||
const chatId = await createChatSpy(scope);
|
||||
const now = new Date().toISOString();
|
||||
setSessions((prev: ChatSummary[]) => [
|
||||
{
|
||||
key: `websocket:${chatId}`,
|
||||
channel: "websocket",
|
||||
chatId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
title: "",
|
||||
preview: "",
|
||||
workspaceScope: scope ?? null,
|
||||
},
|
||||
...prev.filter((session) => session.chatId !== chatId),
|
||||
]);
|
||||
return chatId;
|
||||
},
|
||||
forkChat: async () => "fork-chat",
|
||||
getSessionAutomations: getSessionAutomationsSpy,
|
||||
deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
|
||||
@@ -232,6 +251,10 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
|
||||
sessionUpdateHandlers.add(handler);
|
||||
return () => sessionUpdateHandlers.delete(handler);
|
||||
};
|
||||
onSidebarStateUpdate = (handler: (state: SidebarStatePayload) => void) => {
|
||||
sidebarStateUpdateHandlers.add(handler);
|
||||
return () => sidebarStateUpdateHandlers.delete(handler);
|
||||
};
|
||||
onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => {
|
||||
runStatusHandlers.add(handler);
|
||||
return () => runStatusHandlers.delete(handler);
|
||||
@@ -272,7 +295,9 @@ describe("App layout", () => {
|
||||
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
||||
toggleThemeSpy.mockReset();
|
||||
attachSpy.mockReset();
|
||||
setSidebarStateSpy.mockReset().mockResolvedValue({});
|
||||
setSidebarStateSpy.mockReset().mockImplementation(
|
||||
async (state: SidebarStatePayload) => state,
|
||||
);
|
||||
requestMutationSpy.mockReset();
|
||||
discardTemporaryChatSpy.mockReset();
|
||||
let temporaryChatCounter = 0;
|
||||
@@ -283,11 +308,13 @@ describe("App layout", () => {
|
||||
statusHandlers.clear();
|
||||
runStatusHandlers.clear();
|
||||
sessionUpdateHandlers.clear();
|
||||
sidebarStateUpdateHandlers.clear();
|
||||
window.history.replaceState(null, "", "/");
|
||||
setNavigatorPlatform("Linux x86_64");
|
||||
localStorage.removeItem("nanobot-webui.sidebar");
|
||||
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
|
||||
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
|
||||
localStorage.removeItem("nanobot-webui.collapsed-pane-groups.v1");
|
||||
localStorage.removeItem("nanobot-webui.restartStartedAt");
|
||||
localStorage.removeItem("nanobot-webui.restartRoute");
|
||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||
@@ -309,6 +336,7 @@ describe("App layout", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows the auth form without an invalid-password error on first load", async () => {
|
||||
@@ -489,8 +517,9 @@ describe("App layout", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const firstMessage = "keep this first turn visible";
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
|
||||
target: { value: "/model" },
|
||||
target: { value: firstMessage },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
@@ -500,6 +529,7 @@ describe("App layout", () => {
|
||||
`#/chat/${encodeURIComponent("websocket:chat-1")}`,
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText(firstMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates a new temporary chat from the hero each time", async () => {
|
||||
@@ -1653,6 +1683,60 @@ describe("App layout", () => {
|
||||
expect(document.body.style.pointerEvents).not.toBe("none");
|
||||
}, 15_000);
|
||||
|
||||
it("deletes multiple selected topics through one confirmation", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
preview: "First chat",
|
||||
},
|
||||
{
|
||||
key: "websocket:chat-b",
|
||||
channel: "websocket",
|
||||
chatId: "chat-b",
|
||||
createdAt: "2026-04-16T11:00:00Z",
|
||||
updatedAt: "2026-04-16T11:00:00Z",
|
||||
preview: "Second chat",
|
||||
},
|
||||
{
|
||||
key: "websocket:chat-c",
|
||||
channel: "websocket",
|
||||
chatId: "chat-c",
|
||||
createdAt: "2026-04-16T12:00:00Z",
|
||||
updatedAt: "2026-04-16T12:00:00Z",
|
||||
preview: "Third chat",
|
||||
},
|
||||
];
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.pointerDown(within(sidebar).getByLabelText(
|
||||
"Topic actions for First chat",
|
||||
), { button: 0 });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Second chat" }));
|
||||
expect(within(sidebar).getByText("2 selected")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Delete" }));
|
||||
expect(await screen.findByText("Delete 2 conversations?")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(deleteChatSpy).toHaveBeenCalledTimes(2));
|
||||
expect(deleteChatSpy.mock.calls.map(([key]) => key)).toEqual([
|
||||
"websocket:chat-a",
|
||||
"websocket:chat-b",
|
||||
]);
|
||||
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-a");
|
||||
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-b");
|
||||
expect(within(sidebar).getByRole("button", { name: "Third chat" }))
|
||||
.toBeInTheDocument();
|
||||
}, 15_000);
|
||||
|
||||
it("shows localized bound automations in the first delete confirmation", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
@@ -2947,6 +3031,386 @@ describe("App layout", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps panes adjacent and orders tabs by their latest updated pane", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:alpha",
|
||||
channel: "websocket",
|
||||
chatId: "alpha",
|
||||
createdAt: "2026-08-01T10:00:00Z",
|
||||
updatedAt: "2026-08-01T10:00:00Z",
|
||||
title: "Alpha tab",
|
||||
preview: "",
|
||||
},
|
||||
{
|
||||
key: "websocket:alpha-child",
|
||||
channel: "websocket",
|
||||
chatId: "alpha-child",
|
||||
createdAt: "2026-08-05T10:00:00Z",
|
||||
updatedAt: "2026-08-05T10:00:00Z",
|
||||
title: "Alpha child",
|
||||
preview: "",
|
||||
},
|
||||
{
|
||||
key: "websocket:beta",
|
||||
channel: "websocket",
|
||||
chatId: "beta",
|
||||
createdAt: "2026-08-04T10:00:00Z",
|
||||
updatedAt: "2026-08-04T10:00:00Z",
|
||||
title: "Beta tab",
|
||||
preview: "",
|
||||
},
|
||||
];
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string | URL | Request) => {
|
||||
if (String(url) === "/api/webui/sidebar-state") {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
workbench: {
|
||||
version: 1,
|
||||
tabs: {
|
||||
"tab:websocket:alpha": {
|
||||
explicit: true,
|
||||
title: "Alpha tab",
|
||||
paneKeys: ["websocket:alpha", "websocket:alpha-child"],
|
||||
layout: "columns",
|
||||
},
|
||||
"tab:websocket:beta": {
|
||||
explicit: false,
|
||||
title: null,
|
||||
paneKeys: ["websocket:beta"],
|
||||
layout: "columns",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 404 };
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const alphaTab = await within(sidebar).findByRole("button", { name: "Tab: Alpha tab" });
|
||||
const betaTab = within(sidebar).getByRole("button", { name: "Beta tab" });
|
||||
expect(alphaTab.compareDocumentPosition(betaTab) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
.toBeTruthy();
|
||||
|
||||
const alphaGroup = alphaTab.closest("[data-sidebar-tab-group]") as HTMLElement;
|
||||
const paneTitles = within(alphaGroup)
|
||||
.getAllByRole("button")
|
||||
.filter((button) => (
|
||||
button.closest("[data-sidebar-pane]") && button.hasAttribute("title")
|
||||
))
|
||||
.map((button) => button.getAttribute("title"));
|
||||
expect(paneTitles).toEqual(["Alpha child", "Alpha tab"]);
|
||||
});
|
||||
|
||||
it("uses one active pane without workbench editing controls on mobile", async () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: query.includes("max-width: 767px"),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})));
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:alpha",
|
||||
channel: "websocket",
|
||||
chatId: "alpha",
|
||||
createdAt: "2026-08-01T10:00:00Z",
|
||||
updatedAt: "2026-08-01T10:00:00Z",
|
||||
title: "Alpha tab",
|
||||
preview: "",
|
||||
},
|
||||
{
|
||||
key: "websocket:alpha-child",
|
||||
channel: "websocket",
|
||||
chatId: "alpha-child",
|
||||
createdAt: "2026-08-05T10:00:00Z",
|
||||
updatedAt: "2026-08-05T10:00:00Z",
|
||||
title: "Alpha child",
|
||||
preview: "",
|
||||
},
|
||||
];
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/#/chat/websocket%3Aalpha-child",
|
||||
);
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string | URL | Request) => {
|
||||
if (String(url) === "/api/webui/sidebar-state") {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
workbench: {
|
||||
version: 1,
|
||||
tabs: {
|
||||
"tab:websocket:alpha": {
|
||||
explicit: true,
|
||||
title: "Alpha tab",
|
||||
paneKeys: ["websocket:alpha", "websocket:alpha-child"],
|
||||
layout: "bsp",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 404 };
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const grid = await screen.findByTestId("pane-grid");
|
||||
await waitFor(() => expect(Array.from(grid.children).map(
|
||||
(pane) => pane.getAttribute("aria-label"),
|
||||
)).toEqual(["Alpha child"]));
|
||||
expect(screen.queryByRole("button", { name: "Pane layout" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Add pane" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("separator")).not.toBeInTheDocument();
|
||||
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.pointerDown(within(sidebar).getByRole("button", {
|
||||
name: "Alpha child pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
expect(await screen.findByRole("menuitem", { name: "Delete" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: "Remove" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: "Move to" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("materializes a singleton tab without linking it to another pane", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:solo",
|
||||
channel: "websocket",
|
||||
chatId: "solo",
|
||||
createdAt: "2026-08-05T10:00:00Z",
|
||||
updatedAt: "2026-08-05T10:00:00Z",
|
||||
title: "Solo pane",
|
||||
preview: "",
|
||||
},
|
||||
{
|
||||
key: "websocket:other",
|
||||
channel: "websocket",
|
||||
chatId: "other",
|
||||
createdAt: "2026-08-04T10:00:00Z",
|
||||
updatedAt: "2026-08-04T10:00:00Z",
|
||||
title: "Other pane",
|
||||
preview: "",
|
||||
},
|
||||
];
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
act(() => {
|
||||
statusHandlers.forEach((handler) => handler("open"));
|
||||
});
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
expect(within(sidebar).queryByRole("button", { name: "Tab: Solo pane" }))
|
||||
.not.toBeInTheDocument();
|
||||
setSidebarStateSpy.mockClear();
|
||||
|
||||
fireEvent.pointerDown(within(sidebar).getByRole("button", {
|
||||
name: "Topic actions for Solo pane",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" }));
|
||||
|
||||
const tabButton = await within(sidebar).findByRole("button", {
|
||||
name: "Tab: Solo pane",
|
||||
});
|
||||
const tabGroup = tabButton.closest("[data-sidebar-tab-group]") as HTMLElement;
|
||||
expect(within(tabGroup).getByRole("list", { name: "Panes in Solo pane" }))
|
||||
.toBeInTheDocument();
|
||||
expect(within(tabGroup).getAllByRole("button", { name: "Solo pane" }))
|
||||
.toHaveLength(1);
|
||||
expect(within(sidebar).queryByRole("button", { name: "Tab: Other pane" }))
|
||||
.not.toBeInTheDocument();
|
||||
await waitFor(() => expect(setSidebarStateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workbench: expect.objectContaining({
|
||||
tabs: expect.objectContaining({
|
||||
"tab:websocket:solo": expect.objectContaining({ explicit: true }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
));
|
||||
});
|
||||
|
||||
it("restores a created pane group from gateway state after remount", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:solo",
|
||||
channel: "websocket",
|
||||
chatId: "solo",
|
||||
createdAt: "2026-08-05T10:00:00Z",
|
||||
updatedAt: "2026-08-05T10:00:00Z",
|
||||
title: "Solo pane",
|
||||
preview: "",
|
||||
},
|
||||
{
|
||||
key: "websocket:other",
|
||||
channel: "websocket",
|
||||
chatId: "other",
|
||||
createdAt: "2026-08-04T10:00:00Z",
|
||||
updatedAt: "2026-08-04T10:00:00Z",
|
||||
title: "Other pane",
|
||||
preview: "",
|
||||
},
|
||||
];
|
||||
let persistedState: SidebarStatePayload | null = null;
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string | URL | Request) => {
|
||||
if (String(url) === "/api/webui/sidebar-state") {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => persistedState ?? {},
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 404 };
|
||||
}));
|
||||
setSidebarStateSpy.mockImplementation(async (state: SidebarStatePayload) => {
|
||||
persistedState = state;
|
||||
return state;
|
||||
});
|
||||
|
||||
const firstRender = render(<App />);
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
act(() => {
|
||||
statusHandlers.forEach((handler) => handler("open"));
|
||||
});
|
||||
const firstSidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.pointerDown(within(firstSidebar).getByRole("button", {
|
||||
name: "Topic actions for Solo pane",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" }));
|
||||
await waitFor(() => expect(persistedState?.workbench.tabs["tab:websocket:solo"])
|
||||
.toEqual(expect.objectContaining({ explicit: true })));
|
||||
|
||||
firstRender.unmount();
|
||||
connectSpy.mockClear();
|
||||
|
||||
render(<App />);
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const secondSidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
expect(await within(secondSidebar).findByRole("button", { name: "Tab: Solo pane" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps panes and layout scoped to the current topic tab", async () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})));
|
||||
createChatSpy.mockResolvedValueOnce("chat-pane");
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:chat-alpha",
|
||||
channel: "websocket",
|
||||
chatId: "chat-alpha",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
title: "Alpha",
|
||||
preview: "Alpha notes",
|
||||
},
|
||||
{
|
||||
key: "websocket:chat-beta",
|
||||
channel: "websocket",
|
||||
chatId: "chat-beta",
|
||||
createdAt: "2026-04-16T11:00:00Z",
|
||||
updatedAt: "2026-04-16T11:00:00Z",
|
||||
title: "Beta",
|
||||
preview: "Beta notes",
|
||||
},
|
||||
];
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/#/chat/websocket%3Achat-alpha",
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const grid = await screen.findByTestId("pane-grid");
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha"]);
|
||||
expect(screen.queryByRole("button", { name: "Pane layout" }))
|
||||
.not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add pane" }));
|
||||
expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1));
|
||||
|
||||
await waitFor(() => expect(grid.children).toHaveLength(2));
|
||||
expect(screen.getByRole("button", { name: "Pane layout" })).toBeInTheDocument();
|
||||
expect(window.location.hash).toBe("#/chat/websocket%3Achat-pane");
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "New topic"]);
|
||||
|
||||
const activeComposer = screen.getByTestId("active-pane-composer");
|
||||
const paneInput = within(activeComposer).getByRole("textbox", {
|
||||
name: "Message New topic",
|
||||
});
|
||||
expect(paneInput).toHaveClass("min-h-[50px]");
|
||||
fireEvent.change(paneInput, { target: { value: "route this to the new pane" } });
|
||||
fireEvent.keyDown(paneInput, { key: "Enter" });
|
||||
await waitFor(() => expect(sendMessageSpy).toHaveBeenCalled());
|
||||
expect(sendMessageSpy.mock.calls.at(-1)?.[0]).toBe("chat-pane");
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Pane layout" }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
|
||||
expect(grid).toHaveAttribute("data-layout", "rows");
|
||||
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const paneTopicButton = within(sidebar)
|
||||
.getAllByRole("button", { name: "New topic" })
|
||||
.find((button) => button.closest("[data-sidebar-pane]"));
|
||||
expect(paneTopicButton).toBeDefined();
|
||||
expect(paneTopicButton?.closest("[data-sidebar-pane]"))
|
||||
.toHaveAttribute("data-sidebar-pane", "websocket:chat-pane");
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Beta" }));
|
||||
await waitFor(() => {
|
||||
const nextGrid = screen.getByTestId("pane-grid");
|
||||
expect(Array.from(nextGrid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Beta"]);
|
||||
expect(nextGrid).toHaveAttribute("data-layout", "columns");
|
||||
});
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Alpha" }));
|
||||
await waitFor(() => {
|
||||
const restoredGrid = screen.getByTestId("pane-grid");
|
||||
expect(Array.from(restoredGrid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "New topic"]);
|
||||
expect(restoredGrid).toHaveAttribute("data-layout", "rows");
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(within(sidebar).getByRole("button", {
|
||||
name: "New topic pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(screen.getByRole("menuitem", {
|
||||
name: "Remove",
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1));
|
||||
expect(within(sidebar).getAllByRole("button", { name: "New topic" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("opens search from the keyboard shortcut", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
|
||||
+400
-180
@@ -2,7 +2,6 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ChatList } from "@/components/ChatList";
|
||||
import { SESSION_DRAG_TYPE } from "@/lib/session-drag";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
function session(overrides: Partial<ChatSummary>): ChatSummary {
|
||||
@@ -18,48 +17,94 @@ function session(overrides: Partial<ChatSummary>): ChatSummary {
|
||||
};
|
||||
}
|
||||
|
||||
function rect({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}): DOMRect {
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
describe("ChatList", () => {
|
||||
afterEach(() => {
|
||||
localStorage.removeItem("nanobot-webui.collapsed-pane-groups.v1");
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("exposes chats as drag sources", () => {
|
||||
const dataTransfer = {
|
||||
effectAllowed: "",
|
||||
setData: vi.fn(),
|
||||
};
|
||||
it("keeps tabs and panes outside every drag-and-drop protocol", () => {
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[session({ chatId: "root", title: "Root topic" })]}
|
||||
activeKey="websocket:root"
|
||||
paneGroups={{
|
||||
"websocket:root": {
|
||||
tabKey: "websocket:root",
|
||||
title: "Root topic",
|
||||
activePaneKey: "websocket:child",
|
||||
panes: [
|
||||
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||
],
|
||||
},
|
||||
}}
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Tab: Root topic" }))
|
||||
.toHaveAttribute("draggable", "false");
|
||||
expect(screen.getByRole("button", { name: "Research pane" }))
|
||||
.toHaveAttribute("draggable", "false");
|
||||
expect(document.querySelector("[data-pane-drag-overlay]")).not.toBeInTheDocument();
|
||||
expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates a visible tab in place and only moves panes into visible tabs", async () => {
|
||||
const onAttachPane = vi.fn();
|
||||
const onCreateTab = vi.fn();
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[
|
||||
session({ chatId: "active", title: "Active chat" }),
|
||||
session({ chatId: "reference", title: "Reference chat" }),
|
||||
session({ chatId: "solo", title: "Solo pane" }),
|
||||
session({ chatId: "target", title: "Target pane" }),
|
||||
session({ key: "tab:existing", chatId: "group", title: "Existing group" }),
|
||||
session({ key: "tab:fine", chatId: "fine-group", title: "Fine group" }),
|
||||
]}
|
||||
activeKey="websocket:active"
|
||||
activeKey="websocket:solo"
|
||||
paneGroups={{
|
||||
"websocket:solo": {
|
||||
tabKey: "tab:solo",
|
||||
title: "Solo pane",
|
||||
activePaneKey: "websocket:solo",
|
||||
visible: false,
|
||||
panes: [{ key: "websocket:solo", chatId: "solo", title: "Solo pane" }],
|
||||
},
|
||||
"websocket:target": {
|
||||
tabKey: "tab:target",
|
||||
title: "Target pane",
|
||||
activePaneKey: "websocket:target",
|
||||
visible: false,
|
||||
panes: [{ key: "websocket:target", chatId: "target", title: "Target pane" }],
|
||||
},
|
||||
"tab:existing": {
|
||||
tabKey: "tab:existing",
|
||||
title: "Existing group",
|
||||
activePaneKey: "websocket:group-a",
|
||||
visible: true,
|
||||
panes: [
|
||||
{ key: "websocket:group-a", chatId: "group-a", title: "Group A" },
|
||||
{ key: "websocket:group-b", chatId: "group-b", title: "Group B" },
|
||||
{ key: "websocket:group-c", chatId: "group-c", title: "Group C" },
|
||||
{ key: "websocket:group-d", chatId: "group-d", title: "Group D" },
|
||||
],
|
||||
},
|
||||
"tab:fine": {
|
||||
tabKey: "tab:fine",
|
||||
title: "Fine group",
|
||||
activePaneKey: "websocket:fine",
|
||||
visible: true,
|
||||
panes: [{ key: "websocket:fine", chatId: "fine", title: "Fine pane" }],
|
||||
},
|
||||
}}
|
||||
onCreateTab={onCreateTab}
|
||||
onAttachPane={onAttachPane}
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
@@ -68,88 +113,299 @@ describe("ChatList", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Active chat" }))
|
||||
.toHaveAttribute("draggable", "true");
|
||||
const reference = screen.getByRole("button", { name: "Reference chat" });
|
||||
expect(reference).toHaveAttribute("draggable", "true");
|
||||
expect(screen.queryByRole("button", { name: "Tab: Solo pane" }))
|
||||
.not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("Solo pane")).toHaveLength(1);
|
||||
expect(screen.queryByRole("list", { name: "Panes in Solo pane" }))
|
||||
.not.toBeInTheDocument();
|
||||
|
||||
fireEvent.dragStart(reference, { dataTransfer });
|
||||
fireEvent.pointerDown(screen.getByRole("button", {
|
||||
name: "Topic actions for Solo pane",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" }));
|
||||
expect(onCreateTab).toHaveBeenCalledWith("websocket:solo");
|
||||
|
||||
expect(dataTransfer.setData).toHaveBeenCalledWith(
|
||||
SESSION_DRAG_TYPE,
|
||||
"websocket:reference",
|
||||
);
|
||||
fireEvent.dragEnd(reference, { dataTransfer });
|
||||
fireEvent.pointerDown(screen.getByRole("button", {
|
||||
name: "Topic actions for Solo pane",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
const moveTo = await screen.findByRole("menuitem", { name: "Move to" });
|
||||
fireEvent.pointerMove(moveTo, { pointerType: "mouse" });
|
||||
expect(screen.queryByRole("menuitem", { name: "Target pane" }))
|
||||
.not.toBeInTheDocument();
|
||||
const fullTarget = await screen.findByRole("menuitem", {
|
||||
name: "Existing group · 4/4",
|
||||
});
|
||||
expect(fullTarget).toHaveAttribute("aria-disabled", "true");
|
||||
fireEvent.click(fullTarget);
|
||||
expect(onAttachPane).not.toHaveBeenCalled();
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Fine group · 1/4" }));
|
||||
expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:fine");
|
||||
});
|
||||
|
||||
it("reorders chats around a Codex-style insertion line", () => {
|
||||
const onReorderSessions = vi.fn();
|
||||
const sessions = [
|
||||
session({ chatId: "alpha", title: "Alpha" }),
|
||||
session({ chatId: "bravo", title: "Bravo" }),
|
||||
session({ chatId: "charlie", title: "Charlie" }),
|
||||
session({ chatId: "old-a", title: "Old A" }),
|
||||
session({ chatId: "old-b", title: "Old B" }),
|
||||
];
|
||||
const { rerender } = render(
|
||||
<ChatList
|
||||
sessions={sessions}
|
||||
activeKey={null}
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
onReorderSessions={onReorderSessions}
|
||||
archivedKeys={["websocket:old-a", "websocket:old-b"]}
|
||||
sessionOrder={sessions.map((item) => item.key)}
|
||||
/>,
|
||||
);
|
||||
const dataTransfer = {
|
||||
effectAllowed: "",
|
||||
dropEffect: "",
|
||||
setData: vi.fn(),
|
||||
};
|
||||
fireEvent.dragStart(screen.getByRole("button", { name: "Alpha" }), { dataTransfer });
|
||||
const charlieRow = screen.getByRole("button", { name: "Charlie" }).closest("li")!;
|
||||
fireEvent.dragOver(charlieRow, { clientY: 1, dataTransfer });
|
||||
expect(charlieRow.querySelector("[data-session-drop-edge='after']"))
|
||||
.toBeInTheDocument();
|
||||
fireEvent.drop(charlieRow, { clientY: 1, dataTransfer });
|
||||
it("shows every tab's pane membership in a sidebar tab group", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onSelectPane = vi.fn();
|
||||
const onDetachPane = vi.fn();
|
||||
const onDissolveTab = vi.fn();
|
||||
const onRequestRename = vi.fn();
|
||||
const onAttachPane = vi.fn();
|
||||
|
||||
expect(onReorderSessions).toHaveBeenCalledWith([
|
||||
"websocket:bravo",
|
||||
"websocket:charlie",
|
||||
"websocket:alpha",
|
||||
"websocket:old-a",
|
||||
"websocket:old-b",
|
||||
]);
|
||||
|
||||
rerender(
|
||||
render(
|
||||
<ChatList
|
||||
sessions={sessions}
|
||||
activeKey={null}
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
onReorderSessions={onReorderSessions}
|
||||
archivedKeys={["websocket:old-a", "websocket:old-b"]}
|
||||
sessionOrder={[
|
||||
"websocket:bravo",
|
||||
"websocket:charlie",
|
||||
"websocket:alpha",
|
||||
"websocket:old-a",
|
||||
"websocket:old-b",
|
||||
sessions={[
|
||||
session({ chatId: "root", title: "Root topic" }),
|
||||
session({ chatId: "target", title: "Target tab" }),
|
||||
]}
|
||||
sort="manual"
|
||||
activeKey="websocket:root"
|
||||
paneGroups={{
|
||||
"websocket:root": {
|
||||
tabKey: "websocket:root",
|
||||
title: "Root topic",
|
||||
activePaneKey: "websocket:child",
|
||||
panes: [
|
||||
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||
],
|
||||
},
|
||||
"websocket:target": {
|
||||
tabKey: "websocket:target",
|
||||
title: "Target tab",
|
||||
activePaneKey: "websocket:target-child",
|
||||
panes: [
|
||||
{ key: "websocket:target", chatId: "target", title: "Target tab" },
|
||||
{
|
||||
key: "websocket:target-child",
|
||||
chatId: "target-child",
|
||||
title: "Target research",
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
onSelect={onSelect}
|
||||
onSelectPane={onSelectPane}
|
||||
onDetachPane={onDetachPane}
|
||||
onDissolveTab={onDissolveTab}
|
||||
onAttachPane={onAttachPane}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={onRequestRename}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const section = screen.getByRole("region", { name: "Topics" });
|
||||
const text = section.textContent ?? "";
|
||||
expect(text.indexOf("Bravo")).toBeLessThan(text.indexOf("Charlie"));
|
||||
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
|
||||
|
||||
const child = screen.getByRole("button", { name: "Research pane" });
|
||||
expect(child.closest("[data-sidebar-pane]"))
|
||||
.toHaveAttribute("data-sidebar-pane", "websocket:child");
|
||||
expect(child).toHaveAttribute("aria-current", "true");
|
||||
const targetTabRow = screen.getByRole("button", { name: "Tab: Target tab" })
|
||||
.closest("li")!;
|
||||
const targetChild = within(targetTabRow).getByRole("button", {
|
||||
name: "Target research",
|
||||
});
|
||||
expect(targetChild.closest("[data-sidebar-pane]"))
|
||||
.toHaveAttribute("data-sidebar-pane", "websocket:target-child");
|
||||
expect(targetChild).not.toHaveAttribute("aria-current");
|
||||
fireEvent.click(targetChild);
|
||||
expect(onSelectPane).toHaveBeenCalledWith(
|
||||
"websocket:target",
|
||||
"websocket:target-child",
|
||||
);
|
||||
fireEvent.click(child);
|
||||
expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:child");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Root topic" }));
|
||||
expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:root");
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
|
||||
onSelectPane.mockClear();
|
||||
const rootTab = screen.getByRole("button", { name: "Tab: Root topic" });
|
||||
fireEvent.click(rootTab);
|
||||
expect(onSelectPane).not.toHaveBeenCalled();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
expect(rootTab).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(rootTab);
|
||||
expect(rootTab).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", {
|
||||
name: "Topic actions for Root topic",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
expect(await screen.findByRole("menuitem", { name: "Dissolve group" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("menuitem", { name: "Delete all chats" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: "Select" }))
|
||||
.not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Dissolve group" }));
|
||||
expect(onDissolveTab).toHaveBeenCalledWith("websocket:root");
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", {
|
||||
name: "Research pane pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
const moveTo = await screen.findByRole("menuitem", { name: "Move to" });
|
||||
fireEvent.pointerMove(moveTo, { pointerType: "mouse" });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Target tab · 2/4" }));
|
||||
expect(onAttachPane).toHaveBeenCalledWith("websocket:child", "websocket:target");
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", {
|
||||
name: "Research pane pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(await screen.findByRole("menuitem", {
|
||||
name: "Remove",
|
||||
}));
|
||||
expect(onDetachPane).toHaveBeenCalledWith("websocket:root", "websocket:child");
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", {
|
||||
name: "Root topic pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
expect(screen.getByRole("menuitem", { name: "Move to" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("menuitem", { name: "Remove" }))
|
||||
.toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(child).toHaveAttribute("draggable", "false");
|
||||
expect(screen.getByRole("button", { name: "Tab: Target tab" }))
|
||||
.toHaveAttribute("draggable", "false");
|
||||
});
|
||||
|
||||
it("collapses a multi-pane tab into one Chrome-style group header", () => {
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[session({
|
||||
chatId: "root",
|
||||
title: "Root topic",
|
||||
workspaceScope: {
|
||||
project_path: "/Users/me/nanobot",
|
||||
project_name: "nanobot",
|
||||
access_mode: "restricted",
|
||||
},
|
||||
})]}
|
||||
activeKey="websocket:root"
|
||||
paneGroups={{
|
||||
"websocket:root": {
|
||||
tabKey: "websocket:root",
|
||||
title: "Root topic",
|
||||
activePaneKey: "websocket:child",
|
||||
panes: [
|
||||
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||
],
|
||||
},
|
||||
}}
|
||||
onSelect={vi.fn()}
|
||||
onSelectPane={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tabButton = screen.getByRole("button", { name: "Tab: Root topic" });
|
||||
const tabGroup = tabButton.closest("[data-sidebar-tab-group]")!;
|
||||
const tabHeader = tabButton.closest("[data-workbench-tab]")!;
|
||||
const tabSurface = tabButton.closest("[data-workbench-tab-surface]")!;
|
||||
expect(tabGroup).toHaveAttribute("data-sidebar-tab-group", "true");
|
||||
expect(tabHeader).not.toHaveAttribute("data-chat-row");
|
||||
expect(tabHeader).not.toHaveAttribute("data-sidebar-pane");
|
||||
expect(tabButton).not.toHaveAttribute("aria-current");
|
||||
expect(tabButton.querySelector("svg")).not.toBeInTheDocument();
|
||||
const paneList = within(tabGroup).getByRole("list", { name: "Panes in Root topic" });
|
||||
expect(tabSurface).toContainElement(paneList);
|
||||
const activePane = within(tabGroup).getByRole("button", { name: "Research pane" });
|
||||
expect(activePane).toHaveAttribute("aria-current", "true");
|
||||
expect(activePane.closest("[data-sidebar-pane]")).toHaveClass(
|
||||
"bg-sidebar-selected",
|
||||
"rounded-[0.65rem]",
|
||||
);
|
||||
expect(screen.getByRole("button", {
|
||||
name: "Research pane pane actions",
|
||||
})).toHaveClass("opacity-0");
|
||||
expect(within(tabGroup).getByRole("button", { name: "Root topic" }))
|
||||
.not.toHaveAttribute("aria-current");
|
||||
expect(tabGroup).not.toHaveTextContent("2/4");
|
||||
|
||||
const collapse = within(tabGroup).getByRole("button", {
|
||||
name: "Collapse panes in Root topic",
|
||||
});
|
||||
expect(collapse).toHaveAttribute("aria-expanded", "true");
|
||||
fireEvent.click(collapse);
|
||||
|
||||
expect(tabGroup).toHaveAttribute("data-pane-group-collapsed", "true");
|
||||
expect(within(tabGroup).queryByRole("button", { name: "Research pane" }))
|
||||
.not.toBeInTheDocument();
|
||||
expect(within(tabGroup).getByRole("button", {
|
||||
name: "Expand panes in Root topic",
|
||||
})).toHaveAttribute("aria-expanded", "false");
|
||||
expect(within(tabGroup).getByRole("button", { name: "Tab: Root topic" }))
|
||||
.not.toHaveAttribute("aria-current");
|
||||
expect(tabSurface).toHaveClass("bg-sidebar-foreground/[0.045]");
|
||||
|
||||
fireEvent.click(within(tabGroup).getByRole("button", {
|
||||
name: "Expand panes in Root topic",
|
||||
}));
|
||||
expect(within(tabGroup).getByRole("button", { name: "Research pane" }))
|
||||
.toBeInTheDocument();
|
||||
expect(within(tabGroup).getByRole("button", { name: "Root topic" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects a whole tab or individual panes for one bulk delete", async () => {
|
||||
const onRequestDeleteMany = vi.fn();
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[
|
||||
session({ chatId: "root", title: "Root topic" }),
|
||||
session({ chatId: "target", title: "Target tab" }),
|
||||
]}
|
||||
activeKey="websocket:root"
|
||||
paneGroups={{
|
||||
"websocket:root": {
|
||||
tabKey: "websocket:root",
|
||||
title: "Root topic",
|
||||
activePaneKey: "websocket:root",
|
||||
panes: [
|
||||
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||
],
|
||||
},
|
||||
}}
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onRequestDeleteMany={onRequestDeleteMany}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", {
|
||||
name: "Root topic pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
|
||||
|
||||
expect(screen.getByText("1 selected")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Tab: Root topic" }));
|
||||
expect(screen.getByRole("button", { name: "Tab: Root topic" }))
|
||||
.toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Root topic" }))
|
||||
.toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Research pane" }))
|
||||
.toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByText("2 selected")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Target tab" }));
|
||||
expect(screen.getByText("3 selected")).toBeInTheDocument();
|
||||
fireEvent.click(within(screen.getByTestId("delete-selection-bar")).getByRole("button", {
|
||||
name: "Delete",
|
||||
}));
|
||||
|
||||
expect(onRequestDeleteMany).toHaveBeenCalledWith([
|
||||
{ key: "websocket:root", label: "Root topic" },
|
||||
{ key: "websocket:child", label: "Research pane" },
|
||||
{ key: "websocket:target", label: "Target tab" },
|
||||
]);
|
||||
expect(screen.queryByTestId("delete-selection-bar")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows temporary chats separately and lets the user reopen or close them", async () => {
|
||||
@@ -359,40 +615,7 @@ describe("ChatList", () => {
|
||||
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("positions one background highlight and resets it across hidden targets", () => {
|
||||
let revealFrame: FrameRequestCallback | null = null;
|
||||
let resizeObserverCallback: ResizeObserverCallback | null = null;
|
||||
let activeTargetVisible = true;
|
||||
class MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeObserverCallback = callback;
|
||||
}
|
||||
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
revealFrame = callback;
|
||||
return 1;
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
if (this.hasAttribute("data-chat-list-content")) {
|
||||
return rect({ left: 0, top: 0, width: 300, height: 200 });
|
||||
}
|
||||
if (this.getAttribute("data-chat-row") === "websocket:active") {
|
||||
return activeTargetVisible
|
||||
? rect({ left: 8, top: 12, width: 284, height: 32 })
|
||||
: rect({ left: 0, top: 0, width: 0, height: 0 });
|
||||
}
|
||||
if (this.getAttribute("data-chat-row") === "websocket:inactive") {
|
||||
return rect({ left: 8, top: 48, width: 284, height: 40 });
|
||||
}
|
||||
return rect({ left: 0, top: 0, width: 0, height: 0 });
|
||||
},
|
||||
);
|
||||
it("switches row-owned tab highlights without a moving selection surface", () => {
|
||||
const props = {
|
||||
sessions: [
|
||||
session({ chatId: "active", title: "Active topic" }),
|
||||
@@ -412,45 +635,12 @@ describe("ChatList", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const highlight = screen.getByTestId("sessions-selection-highlight");
|
||||
expect(highlight).toHaveClass(
|
||||
"bg-sidebar-foreground/[0.055]",
|
||||
"transition-[transform,width,height]",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(screen.queryByTestId("sessions-selection-highlight-surface"))
|
||||
.not.toBeInTheDocument();
|
||||
expect(resizeObserverCallback).not.toBeNull();
|
||||
|
||||
const activeButton = screen.getByTitle("Active topic");
|
||||
expect(activeButton).toHaveAttribute("aria-current", "page");
|
||||
expect(activeButton.parentElement).toHaveClass("transition-[color]");
|
||||
expect(activeButton.parentElement).not.toHaveClass("transition-colors");
|
||||
expect(activeButton.parentElement).not.toHaveClass(
|
||||
"bg-sidebar-accent",
|
||||
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
|
||||
expect(activeButton.closest("[data-sidebar-tab]")).toHaveClass(
|
||||
"bg-sidebar-selected",
|
||||
);
|
||||
expect(highlight).toHaveClass(
|
||||
"transition-[transform,width,height]",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(highlight).toHaveStyle(
|
||||
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
|
||||
);
|
||||
|
||||
revealFrame?.(0);
|
||||
expect(highlight.style.transitionProperty).toBe("");
|
||||
|
||||
activeTargetVisible = false;
|
||||
resizeObserverCallback?.([], {} as ResizeObserver);
|
||||
expect(highlight).toHaveStyle("opacity: 0");
|
||||
|
||||
activeTargetVisible = true;
|
||||
resizeObserverCallback?.([], {} as ResizeObserver);
|
||||
expect(highlight).toHaveStyle(
|
||||
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
|
||||
);
|
||||
revealFrame?.(0);
|
||||
expect(screen.queryByTestId("sessions-selection-highlight")).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ChatList
|
||||
@@ -461,12 +651,42 @@ describe("ChatList", () => {
|
||||
|
||||
expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current");
|
||||
expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page");
|
||||
expect(highlight).toHaveStyle(
|
||||
"width: 284px; height: 40px; transform: translate3d(8px, 48px, 0)",
|
||||
expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).toHaveClass(
|
||||
"bg-sidebar-selected",
|
||||
);
|
||||
});
|
||||
|
||||
rerender(<ChatList {...props} activeKey={null} />);
|
||||
expect(highlight).toHaveStyle("opacity: 0");
|
||||
it("restores collapsed tabs from the local UI preference", () => {
|
||||
const props = {
|
||||
sessions: [session({ chatId: "root", title: "Root topic" })],
|
||||
activeKey: "websocket:root",
|
||||
paneGroups: {
|
||||
"websocket:root": {
|
||||
tabKey: "websocket:root",
|
||||
title: "Root topic",
|
||||
activePaneKey: "websocket:root",
|
||||
panes: [
|
||||
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||
],
|
||||
},
|
||||
},
|
||||
onSelect: vi.fn(),
|
||||
onRequestDelete: vi.fn(),
|
||||
onTogglePin: vi.fn(),
|
||||
onRequestRename: vi.fn(),
|
||||
onToggleArchive: vi.fn(),
|
||||
};
|
||||
const firstRender = render(<ChatList {...props} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Tab: Root topic" }));
|
||||
expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument();
|
||||
firstRender.unmount();
|
||||
|
||||
render(<ChatList {...props} />);
|
||||
expect(screen.getByRole("button", { name: "Tab: Root topic" }))
|
||||
.toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can collapse a project group and keeps project rename separate from chat titles", async () => {
|
||||
|
||||
@@ -1206,6 +1206,7 @@ describe("NanobotClient", () => {
|
||||
project_name_overrides: {},
|
||||
tags_by_key: {},
|
||||
collapsed_groups: {},
|
||||
workbench: { version: 1, tabs: {} },
|
||||
view: {
|
||||
density: "comfortable",
|
||||
show_previews: false,
|
||||
@@ -1243,6 +1244,51 @@ describe("NanobotClient", () => {
|
||||
await expect(pending).resolves.toEqual(state);
|
||||
});
|
||||
|
||||
it("delivers backend sidebar state updates to every subscriber", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const handler = vi.fn();
|
||||
const state: SidebarStatePayload = {
|
||||
schema_version: 1,
|
||||
pinned_keys: [],
|
||||
archived_keys: [],
|
||||
session_order: [],
|
||||
title_overrides: {},
|
||||
project_name_overrides: {},
|
||||
tags_by_key: {},
|
||||
collapsed_groups: {},
|
||||
workbench: {
|
||||
version: 1,
|
||||
tabs: {
|
||||
"tab:websocket:a": {
|
||||
explicit: true,
|
||||
title: "Research",
|
||||
paneKeys: ["websocket:a", "websocket:b"],
|
||||
layout: "columns",
|
||||
},
|
||||
},
|
||||
},
|
||||
view: {
|
||||
density: "comfortable",
|
||||
show_previews: false,
|
||||
show_timestamps: false,
|
||||
show_archived: false,
|
||||
sort: "updated_desc",
|
||||
},
|
||||
updated_at: "2026-08-11T08:00:00Z",
|
||||
};
|
||||
|
||||
client.onSidebarStateUpdate(handler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().fakeMessage({ event: "sidebar_state_updated", state });
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(state);
|
||||
});
|
||||
|
||||
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
|
||||
import {
|
||||
EMPTY_WORKBENCH_STATE,
|
||||
addWorkbenchPane,
|
||||
setWorkbenchLayout,
|
||||
setWorkbenchPaneLayoutOrder,
|
||||
workbenchTab,
|
||||
workbenchTabForPane,
|
||||
} from "@/components/workbench/workbench-model";
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number): DOMRect {
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
function WorkbenchHarness({
|
||||
initialLayout = "columns",
|
||||
onPaneOrderChange = () => {},
|
||||
onSplitRatiosChange = () => {},
|
||||
}: {
|
||||
initialLayout?: "columns" | "rows";
|
||||
onPaneOrderChange?: (paneKeys: string[]) => void;
|
||||
onSplitRatiosChange?: (splitRatios: number[]) => void;
|
||||
} = {}) {
|
||||
const [activePaneKey, setActivePaneKey] = useState("beta");
|
||||
const [state, setState] = useState(() => {
|
||||
const initial = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "alpha", "beta");
|
||||
const tabKey = workbenchTabForPane(initial, "alpha").tabKey;
|
||||
return setWorkbenchLayout(initial, tabKey, initialLayout);
|
||||
});
|
||||
const tabKey = workbenchTabForPane(state, "alpha").tabKey;
|
||||
const tab = workbenchTab(state, tabKey);
|
||||
if (!tab) return null;
|
||||
const titles: Record<string, string> = { alpha: "Alpha", beta: "Beta" };
|
||||
|
||||
return (
|
||||
<PaneWorkbench
|
||||
panes={tab.layoutPaneKeys.map((key) => ({ key, title: titles[key] }))}
|
||||
activePaneKey={activePaneKey}
|
||||
layout={tab.layout}
|
||||
splitRatios={tab.splitRatios}
|
||||
showLayoutControl
|
||||
onActivatePane={setActivePaneKey}
|
||||
onAddPane={vi.fn()}
|
||||
onLayoutChange={(layout) => setState((current) => (
|
||||
setWorkbenchLayout(current, tabKey, layout)
|
||||
))}
|
||||
onPaneOrderChange={(paneKeys) => {
|
||||
onPaneOrderChange(paneKeys);
|
||||
setState((current) => (
|
||||
setWorkbenchPaneLayoutOrder(current, tabKey, paneKeys)
|
||||
));
|
||||
}}
|
||||
onSplitRatiosChange={onSplitRatiosChange}
|
||||
renderPane={(pane, context) => (
|
||||
<>
|
||||
<button type="button">Focus {pane.title}</button>
|
||||
{context.headerPortalTarget && context.active ? createPortal(
|
||||
context.headerActions,
|
||||
context.headerPortalTarget,
|
||||
) : null}
|
||||
{context.composerPortalTarget ? createPortal(
|
||||
<div hidden={!context.active}>
|
||||
<textarea aria-label={`Composer ${pane.title}`} />
|
||||
</div>,
|
||||
context.composerPortalTarget,
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BspWorkbenchHarness() {
|
||||
const panes = ["alpha", "beta", "gamma", "delta"].map((key) => ({
|
||||
key,
|
||||
title: key,
|
||||
}));
|
||||
return (
|
||||
<PaneWorkbench
|
||||
panes={panes}
|
||||
activePaneKey="delta"
|
||||
layout="bsp"
|
||||
splitRatios={[]}
|
||||
showLayoutControl
|
||||
onActivatePane={vi.fn()}
|
||||
onAddPane={vi.fn()}
|
||||
onLayoutChange={vi.fn()}
|
||||
onPaneOrderChange={vi.fn()}
|
||||
onSplitRatiosChange={vi.fn()}
|
||||
renderPane={(pane) => <span>{pane.title}</span>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("PaneWorkbench", () => {
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
const originalAnimate = HTMLElement.prototype.animate;
|
||||
const animate = vi.fn(() => ({
|
||||
addEventListener: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
}) as unknown as Animation);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})));
|
||||
HTMLElement.prototype.animate = animate;
|
||||
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
|
||||
if (this.dataset.testid === "pane-grid") return rect(0, 0, 1000, 1000);
|
||||
if (!this.classList.contains("workbench-pane")) {
|
||||
return originalGetBoundingClientRect.call(this);
|
||||
}
|
||||
const layout = this.parentElement?.dataset.layout;
|
||||
const index = Array.from(this.parentElement?.children ?? []).indexOf(this);
|
||||
return layout === "rows"
|
||||
? rect(0, index * 500, 1000, 500)
|
||||
: rect(index * 500, 0, 500, 1000);
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
HTMLElement.prototype.animate = originalAnimate;
|
||||
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("focuses without reordering and keeps only the focused composer visible", () => {
|
||||
render(<WorkbenchHarness />);
|
||||
|
||||
const grid = screen.getByTestId("pane-grid");
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
expect(screen.getByLabelText("Composer Beta")).toBeVisible();
|
||||
expect(screen.getByLabelText("Composer Alpha")).not.toBeVisible();
|
||||
|
||||
fireEvent.pointerDown(
|
||||
within(screen.getByRole("region", { name: "Alpha" }))
|
||||
.getByRole("button", { name: "Focus Alpha" }),
|
||||
);
|
||||
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
expect(screen.getByLabelText("Composer Alpha")).toBeVisible();
|
||||
expect(screen.getByLabelText("Composer Beta")).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("moves the focused pane between workspace slots from its bottom handle", () => {
|
||||
const onPaneOrderChange = vi.fn();
|
||||
render(<WorkbenchHarness onPaneOrderChange={onPaneOrderChange} />);
|
||||
|
||||
const grid = screen.getByTestId("pane-grid");
|
||||
const handle = screen.getByRole("button", { name: "Move Beta pane" });
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
|
||||
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 750, clientY: 990 });
|
||||
fireEvent.pointerMove(window, {
|
||||
buttons: 1,
|
||||
pointerId: 1,
|
||||
clientX: 250,
|
||||
clientY: 500,
|
||||
});
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Beta", "Alpha"]);
|
||||
expect(onPaneOrderChange).toHaveBeenCalledOnce();
|
||||
expect(onPaneOrderChange).toHaveBeenCalledWith(["beta", "alpha"]);
|
||||
|
||||
fireEvent.pointerMove(window, {
|
||||
buttons: 1,
|
||||
pointerId: 1,
|
||||
clientX: 750,
|
||||
clientY: 500,
|
||||
});
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
expect(onPaneOrderChange).toHaveBeenLastCalledWith(["alpha", "beta"]);
|
||||
fireEvent.pointerUp(window, { pointerId: 1, clientX: 750, clientY: 500 });
|
||||
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
expect(onPaneOrderChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("moves panes up and down through stable workspace slots", () => {
|
||||
const onPaneOrderChange = vi.fn();
|
||||
render(
|
||||
<WorkbenchHarness initialLayout="rows" onPaneOrderChange={onPaneOrderChange} />,
|
||||
);
|
||||
|
||||
const grid = screen.getByTestId("pane-grid");
|
||||
const handle = screen.getByRole("button", { name: "Move Beta pane" });
|
||||
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 500, clientY: 990 });
|
||||
fireEvent.pointerMove(window, {
|
||||
buttons: 1,
|
||||
pointerId: 1,
|
||||
clientX: 500,
|
||||
clientY: 250,
|
||||
});
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Beta", "Alpha"]);
|
||||
|
||||
fireEvent.pointerMove(window, {
|
||||
buttons: 1,
|
||||
pointerId: 1,
|
||||
clientX: 500,
|
||||
clientY: 750,
|
||||
});
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
expect(onPaneOrderChange.mock.calls).toEqual([
|
||||
[["beta", "alpha"]],
|
||||
[["alpha", "beta"]],
|
||||
]);
|
||||
fireEvent.pointerUp(window, { pointerId: 1, clientX: 500, clientY: 750 });
|
||||
});
|
||||
|
||||
it("moves the focused pane between workspace slots with arrow keys", () => {
|
||||
render(<WorkbenchHarness />);
|
||||
|
||||
const handle = screen.getByRole("button", { name: "Move Beta pane" });
|
||||
act(() => handle.focus());
|
||||
expect(handle).toHaveFocus();
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||
|
||||
expect(Array.from(screen.getByTestId("pane-grid").children)
|
||||
.map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Beta", "Alpha"]);
|
||||
});
|
||||
|
||||
it("previews edge resizing locally and commits one ratio when dragging ends", () => {
|
||||
const onSplitRatiosChange = vi.fn();
|
||||
render(<WorkbenchHarness onSplitRatiosChange={onSplitRatiosChange} />);
|
||||
|
||||
const grid = screen.getByTestId("pane-grid");
|
||||
const separator = screen.getByRole("separator", {
|
||||
name: "Resize pane boundary 1",
|
||||
});
|
||||
fireEvent.pointerDown(separator, {
|
||||
button: 0,
|
||||
pointerId: 7,
|
||||
clientX: 500,
|
||||
clientY: 400,
|
||||
});
|
||||
fireEvent.pointerMove(window, {
|
||||
buttons: 1,
|
||||
pointerId: 7,
|
||||
clientX: 700,
|
||||
clientY: 400,
|
||||
});
|
||||
|
||||
expect(grid.style.gridTemplateColumns)
|
||||
.toBe("minmax(0, 700fr) minmax(0, 300fr)");
|
||||
expect(onSplitRatiosChange).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.pointerUp(window, { pointerId: 7, clientX: 700, clientY: 400 });
|
||||
expect(onSplitRatiosChange).toHaveBeenCalledOnce();
|
||||
expect(onSplitRatiosChange).toHaveBeenCalledWith([0.7]);
|
||||
});
|
||||
|
||||
it("resizes a pane boundary with the matching arrow keys", () => {
|
||||
const onSplitRatiosChange = vi.fn();
|
||||
render(<WorkbenchHarness onSplitRatiosChange={onSplitRatiosChange} />);
|
||||
|
||||
const separator = screen.getByRole("separator", {
|
||||
name: "Resize pane boundary 1",
|
||||
});
|
||||
fireEvent.keyDown(separator, { key: "ArrowLeft" });
|
||||
|
||||
expect(onSplitRatiosChange).toHaveBeenCalledWith([0.47]);
|
||||
});
|
||||
|
||||
it("keeps one shared layout control and animates geometry changes", async () => {
|
||||
render(<WorkbenchHarness />);
|
||||
|
||||
const header = screen.getByTestId("workbench-header-host");
|
||||
expect(within(header).getAllByRole("button", { name: "Pane layout" })).toHaveLength(1);
|
||||
fireEvent.pointerDown(within(header).getByRole("button", { name: "Pane layout" }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
|
||||
expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "rows");
|
||||
await waitFor(() => expect(animate).toHaveBeenCalledTimes(2));
|
||||
|
||||
fireEvent.pointerDown(within(header).getByRole("button", { name: "Pane layout" }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "BSP" }));
|
||||
expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "bsp");
|
||||
await waitFor(() => expect(animate).toHaveBeenCalledTimes(4));
|
||||
});
|
||||
|
||||
it("renders only the active pane and hides workbench controls on mobile", () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: query.includes("max-width: 767px"),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})));
|
||||
const panes = ["alpha", "beta", "gamma", "delta"].map((key) => ({
|
||||
key,
|
||||
title: key,
|
||||
}));
|
||||
const props = {
|
||||
panes,
|
||||
layout: "bsp" as const,
|
||||
showLayoutControl: true,
|
||||
onActivatePane: vi.fn(),
|
||||
onAddPane: vi.fn(),
|
||||
onLayoutChange: vi.fn(),
|
||||
onPaneOrderChange: vi.fn(),
|
||||
renderPane: (pane: { key: string; title: string }, context: {
|
||||
headerActions: ReactNode;
|
||||
}) => <>{context.headerActions}<span>{pane.title}</span></>,
|
||||
};
|
||||
|
||||
const { rerender } = render(<PaneWorkbench {...props} activePaneKey="gamma" />);
|
||||
|
||||
expect(screen.getByTestId("pane-grid").children).toHaveLength(1);
|
||||
expect(screen.getByTestId("workbench-pane-gamma")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Pane layout" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Add pane" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("separator")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<PaneWorkbench {...props} activePaneKey="delta" />);
|
||||
expect(screen.getByTestId("pane-grid").children).toHaveLength(1);
|
||||
expect(screen.getByTestId("workbench-pane-delta")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workbench-pane-gamma")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains why the desktop add-pane control is disabled", () => {
|
||||
render(
|
||||
<PaneWorkbench
|
||||
panes={[{ key: "alpha", title: "Alpha" }]}
|
||||
activePaneKey="alpha"
|
||||
layout="columns"
|
||||
showLayoutControl={false}
|
||||
addPaneDisabled
|
||||
addPaneDisabledLabel="Maximum 4 panes"
|
||||
onActivatePane={vi.fn()}
|
||||
onAddPane={vi.fn()}
|
||||
onLayoutChange={vi.fn()}
|
||||
onPaneOrderChange={vi.fn()}
|
||||
renderPane={(_pane, context) => context.headerActions}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Add pane" }))
|
||||
.toHaveAttribute("title", "Maximum 4 panes");
|
||||
});
|
||||
|
||||
it("fills the workbench through alternating binary splits", () => {
|
||||
render(<BspWorkbenchHarness />);
|
||||
|
||||
const alpha = screen.getByTestId("workbench-pane-alpha");
|
||||
const beta = screen.getByTestId("workbench-pane-beta");
|
||||
const gamma = screen.getByTestId("workbench-pane-gamma");
|
||||
const delta = screen.getByTestId("workbench-pane-delta");
|
||||
expect([alpha.style.gridColumn, alpha.style.gridRow]).toEqual(["1 / 2", "1 / 3"]);
|
||||
expect([beta.style.gridColumn, beta.style.gridRow]).toEqual(["2 / 4", "1 / 2"]);
|
||||
expect([gamma.style.gridColumn, gamma.style.gridRow]).toEqual(["2 / 3", "2 / 3"]);
|
||||
expect([delta.style.gridColumn, delta.style.gridRow]).toEqual(["3 / 4", "2 / 3"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useSidebarState } from "@/hooks/useSidebarState";
|
||||
import type { NanobotClient } from "@/lib/nanobot-client";
|
||||
import type { SidebarStatePayload } from "@/lib/types";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
|
||||
describe("useSidebarState", () => {
|
||||
it("serializes full-state writes so an older request cannot overwrite a newer update", async () => {
|
||||
let resolveFirstWrite: (() => void) | null = null;
|
||||
let sidebarStateUpdateHandler: ((state: SidebarStatePayload) => void) | null = null;
|
||||
const setSidebarState = vi.fn()
|
||||
.mockImplementationOnce((state: SidebarStatePayload) => new Promise<SidebarStatePayload>(
|
||||
(resolve) => {
|
||||
resolveFirstWrite = () => resolve(state);
|
||||
},
|
||||
))
|
||||
.mockImplementation(async (state: SidebarStatePayload) => state);
|
||||
const client = {
|
||||
status: "open" as const,
|
||||
onStatus: () => () => {},
|
||||
onSidebarStateUpdate: (handler: (state: SidebarStatePayload) => void) => {
|
||||
sidebarStateUpdateHandler = handler;
|
||||
return () => {
|
||||
sidebarStateUpdateHandler = null;
|
||||
};
|
||||
},
|
||||
setSidebarState,
|
||||
} as unknown as NanobotClient;
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
}));
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ClientProvider client={client} token="token">
|
||||
{children}
|
||||
</ClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useSidebarState([], false), { wrapper });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => {
|
||||
void result.current.update((current) => ({
|
||||
...current,
|
||||
title_overrides: { "websocket:a": "First" },
|
||||
}));
|
||||
void result.current.update((current) => ({
|
||||
...current,
|
||||
title_overrides: { "websocket:a": "Second" },
|
||||
}));
|
||||
});
|
||||
|
||||
expect(setSidebarState).toHaveBeenCalledTimes(1);
|
||||
act(() => {
|
||||
sidebarStateUpdateHandler?.(setSidebarState.mock.calls[0]?.[0]);
|
||||
});
|
||||
expect(result.current.state.title_overrides).toEqual({
|
||||
"websocket:a": "Second",
|
||||
});
|
||||
act(() => resolveFirstWrite?.());
|
||||
await waitFor(() => expect(setSidebarState).toHaveBeenCalledTimes(2));
|
||||
expect(setSidebarState.mock.calls[1]?.[0]).toEqual(expect.objectContaining({
|
||||
title_overrides: { "websocket:a": "Second" },
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
EMPTY_WORKBENCH_STATE,
|
||||
MAX_WORKBENCH_PANES,
|
||||
addWorkbenchPane,
|
||||
attachWorkbenchPane,
|
||||
createWorkbenchTab,
|
||||
detachWorkbenchPane,
|
||||
dissolveWorkbenchTab,
|
||||
normalizeWorkbenchState,
|
||||
orderWorkbenchTabs,
|
||||
reconcileWorkbench,
|
||||
renameWorkbenchTab,
|
||||
setWorkbenchLayout,
|
||||
setWorkbenchPaneLayoutOrder,
|
||||
setWorkbenchSplitRatios,
|
||||
workbenchTab,
|
||||
workbenchTabForPane,
|
||||
} from "@/components/workbench/workbench-model";
|
||||
|
||||
describe("workbench model", () => {
|
||||
it("derives standalone panes without persisting virtual tabs", () => {
|
||||
const match = workbenchTabForPane(EMPTY_WORKBENCH_STATE, "pane-a");
|
||||
|
||||
expect(match.tabKey).not.toBe("pane-a");
|
||||
expect(match.tab).toEqual({
|
||||
explicit: false,
|
||||
title: null,
|
||||
paneKeys: ["pane-a"],
|
||||
layoutPaneKeys: ["pane-a"],
|
||||
layout: "columns",
|
||||
splitRatios: [],
|
||||
});
|
||||
expect(EMPTY_WORKBENCH_STATE.tabs).toEqual({});
|
||||
});
|
||||
|
||||
it("persists only a visible singleton group", () => {
|
||||
const state = createWorkbenchTab(EMPTY_WORKBENCH_STATE, "pane-a");
|
||||
const match = workbenchTabForPane(state, "pane-a");
|
||||
|
||||
expect(workbenchTab(state, match.tabKey)).toMatchObject({
|
||||
explicit: true,
|
||||
paneKeys: ["pane-a"],
|
||||
});
|
||||
expect(detachWorkbenchPane(state, match.tabKey, "pane-a").tabs).toEqual({});
|
||||
});
|
||||
|
||||
it("materializes a group when a pane is added", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||
state = setWorkbenchLayout(state, tabKey, "main-stack");
|
||||
state = renameWorkbenchTab(state, tabKey, "Research");
|
||||
|
||||
expect(workbenchTab(state, tabKey)).toEqual({
|
||||
explicit: false,
|
||||
title: "Research",
|
||||
paneKeys: ["pane-a", "pane-b"],
|
||||
layoutPaneKeys: ["pane-a", "pane-b"],
|
||||
layout: "main-stack",
|
||||
splitRatios: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("detaches a pane without persisting its standalone projection", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||
state = createWorkbenchTab(state, "pane-a");
|
||||
state = addWorkbenchPane(state, "pane-a", "pane-c");
|
||||
state = detachWorkbenchPane(state, tabKey, "pane-a");
|
||||
|
||||
expect(workbenchTab(state, tabKey)?.paneKeys).toEqual(["pane-b", "pane-c"]);
|
||||
expect(workbenchTabForPane(state, "pane-a").tab.paneKeys).toEqual(["pane-a"]);
|
||||
expect(Object.values(state.tabs).some((tab) => tab.paneKeys.includes("pane-a"))).toBe(false);
|
||||
});
|
||||
|
||||
it("dissolves a group into derived standalone panes", () => {
|
||||
const grouped = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||
const tabKey = workbenchTabForPane(grouped, "pane-a").tabKey;
|
||||
const state = dissolveWorkbenchTab(grouped, tabKey);
|
||||
|
||||
expect(state.tabs).toEqual({});
|
||||
expect(workbenchTabForPane(state, "pane-a").tab.paneKeys).toEqual(["pane-a"]);
|
||||
expect(workbenchTabForPane(state, "pane-b").tab.paneKeys).toEqual(["pane-b"]);
|
||||
});
|
||||
|
||||
it("moves panes symmetrically and removes an implicit singleton source", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||
const sourceTabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||
state = createWorkbenchTab(state, "pane-c");
|
||||
const targetTabKey = workbenchTabForPane(state, "pane-c").tabKey;
|
||||
|
||||
state = attachWorkbenchPane(state, targetTabKey, "pane-a");
|
||||
expect(workbenchTab(state, sourceTabKey)).toBeNull();
|
||||
expect(workbenchTab(state, targetTabKey)?.paneKeys).toEqual(["pane-c", "pane-a"]);
|
||||
|
||||
state = attachWorkbenchPane(state, targetTabKey, "pane-b");
|
||||
expect(workbenchTab(state, targetTabKey)?.paneKeys).toEqual([
|
||||
"pane-c",
|
||||
"pane-a",
|
||||
"pane-b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps membership independent from workspace pane order", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||
state = addWorkbenchPane(state, "pane-a", "pane-c");
|
||||
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||
|
||||
const [ordered] = orderWorkbenchTabs(
|
||||
state,
|
||||
["pane-c", "pane-a", "pane-b"],
|
||||
new Map(),
|
||||
);
|
||||
expect(workbenchTab(state, tabKey)?.paneKeys).toEqual(["pane-a", "pane-b", "pane-c"]);
|
||||
expect(ordered.paneKeys).toEqual(["pane-c", "pane-a", "pane-b"]);
|
||||
|
||||
state = setWorkbenchPaneLayoutOrder(state, tabKey, ["pane-b", "pane-c", "pane-a"]);
|
||||
expect(workbenchTab(state, tabKey)?.layoutPaneKeys).toEqual([
|
||||
"pane-b",
|
||||
"pane-c",
|
||||
"pane-a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stores resize ratios and resets them when geometry changes", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||
state = setWorkbenchSplitRatios(state, tabKey, [0.35]);
|
||||
|
||||
expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([0.35]);
|
||||
state = setWorkbenchLayout(state, tabKey, "rows");
|
||||
expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps groups contiguous and ranks them by their latest pane", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-c");
|
||||
state = addWorkbenchPane(state, "pane-b", "pane-d");
|
||||
const alphaTabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||
const betaTabKey = workbenchTabForPane(state, "pane-b").tabKey;
|
||||
|
||||
const tabs = orderWorkbenchTabs(
|
||||
state,
|
||||
["pane-d", "pane-c", "pane-b", "pane-a", "pane-e"],
|
||||
new Map([
|
||||
["pane-a", "2026-08-01T10:00:00Z"],
|
||||
["pane-b", "2026-08-03T10:00:00Z"],
|
||||
["pane-c", "2026-08-05T10:00:00Z"],
|
||||
["pane-d", "2026-08-04T10:00:00Z"],
|
||||
["pane-e", "2026-08-02T10:00:00Z"],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(tabs.map(({ tabKey, paneKeys }) => ({ tabKey, paneKeys }))).toEqual([
|
||||
{ tabKey: alphaTabKey, paneKeys: ["pane-c", "pane-a"] },
|
||||
{ tabKey: betaTabKey, paneKeys: ["pane-d", "pane-b"] },
|
||||
{ tabKey: workbenchTabForPane(state, "pane-e").tabKey, paneKeys: ["pane-e"] },
|
||||
]);
|
||||
expect(Object.keys(state.tabs)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("caps a group at four panes", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-1");
|
||||
for (let index = 2; index <= MAX_WORKBENCH_PANES; index += 1) {
|
||||
state = addWorkbenchPane(state, "pane-a", `pane-${index}`);
|
||||
}
|
||||
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||
expect(workbenchTab(state, tabKey)?.paneKeys).toEqual([
|
||||
"pane-a",
|
||||
"pane-1",
|
||||
"pane-2",
|
||||
"pane-3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("repairs persisted groups without materializing missing sessions", () => {
|
||||
const state = normalizeWorkbenchState({
|
||||
version: 1,
|
||||
tabs: {
|
||||
alpha: {
|
||||
title: "Alpha",
|
||||
paneKeys: ["pane-a", "pane-b", "pane-b", 9],
|
||||
layout: "unknown",
|
||||
},
|
||||
duplicate: {
|
||||
paneKeys: ["pane-b", "deleted"],
|
||||
layout: "grid",
|
||||
},
|
||||
invisible: {
|
||||
paneKeys: ["pane-c"],
|
||||
layout: "columns",
|
||||
},
|
||||
},
|
||||
});
|
||||
const reconciled = reconcileWorkbench(
|
||||
state,
|
||||
new Set(["pane-a", "pane-b", "pane-c"]),
|
||||
);
|
||||
|
||||
expect(workbenchTab(reconciled, "alpha")).toEqual({
|
||||
explicit: false,
|
||||
title: "Alpha",
|
||||
paneKeys: ["pane-a", "pane-b"],
|
||||
layoutPaneKeys: ["pane-a", "pane-b"],
|
||||
layout: "columns",
|
||||
splitRatios: [],
|
||||
});
|
||||
expect(Object.keys(reconciled.tabs)).toEqual(["alpha"]);
|
||||
expect(workbenchTabForPane(reconciled, "pane-c").tab.paneKeys).toEqual(["pane-c"]);
|
||||
});
|
||||
|
||||
it("keeps persisted state sparse with thousands of standalone sessions", () => {
|
||||
const sessionKeys = Array.from({ length: 2_000 }, (_, index) => `pane-${index}`);
|
||||
const reconciled = reconcileWorkbench(EMPTY_WORKBENCH_STATE, new Set(sessionKeys));
|
||||
const ordered = orderWorkbenchTabs(reconciled, sessionKeys, new Map());
|
||||
|
||||
expect(reconciled.tabs).toEqual({});
|
||||
expect(ordered).toHaveLength(2_000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user