feat(webui): add tabbed pane workbench (#5322)

This commit is contained in:
chengyongru
2026-08-12 14:45:12 +08:00
committed by GitHub
parent 1656664a47
commit 4b5319b760
38 changed files with 5706 additions and 527 deletions
+467 -3
View File
@@ -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
View File
@@ -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 () => {
+46
View File
@@ -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",
+392
View File
@@ -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"]);
});
});
+69
View File
@@ -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" },
}));
});
});
+220
View File
@@ -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);
});
});