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 = [
{