mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
fix(webui): preserve named pane groups
This commit is contained in:
+9
-2
@@ -2154,9 +2154,16 @@ function Shell({
|
||||
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
|
||||
const deletingActive = activeKey !== null && deletingKeys.has(activeKey);
|
||||
const currentIndex = topicSessions.findIndex((s) => s.key === activeKey);
|
||||
const availableKeys = new Set(topicSessions.map((session) => session.key));
|
||||
const siblingFallbackKey = deletingActive
|
||||
? activeTabState?.paneKeys.find((key) => (
|
||||
!deletingKeys.has(key) && availableKeys.has(key)
|
||||
)) ?? null
|
||||
: null;
|
||||
const fallbackKey = deletingActive
|
||||
? (
|
||||
topicSessions.slice(currentIndex + 1).find((session) => (
|
||||
siblingFallbackKey
|
||||
?? topicSessions.slice(currentIndex + 1).find((session) => (
|
||||
!deletingKeys.has(session.key)
|
||||
))?.key
|
||||
?? topicSessions.slice(0, Math.max(0, currentIndex)).reverse().find((session) => (
|
||||
@@ -2191,7 +2198,7 @@ function Shell({
|
||||
} catch (e) {
|
||||
console.error("Failed to delete session", e);
|
||||
}
|
||||
}, [pendingDelete, deleteChat, activeKey, navigate, topicSessions]);
|
||||
}, [pendingDelete, deleteChat, activeKey, activeTabState, navigate, topicSessions]);
|
||||
|
||||
const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => {
|
||||
const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
|
||||
|
||||
@@ -72,9 +72,10 @@ function normalizeTab(value: unknown): WorkbenchTabState {
|
||||
...requestedLayoutPaneKeys,
|
||||
...paneKeys.filter((key) => !requestedLayoutPaneKeys.includes(key)),
|
||||
];
|
||||
const title = normalizeTitle(candidate.title);
|
||||
return {
|
||||
explicit: candidate.explicit === true,
|
||||
title: normalizeTitle(candidate.title),
|
||||
explicit: candidate.explicit === true || title !== null,
|
||||
title,
|
||||
paneKeys,
|
||||
layoutPaneKeys,
|
||||
layout: isLayout(candidate.layout) ? candidate.layout : "columns",
|
||||
@@ -309,7 +310,9 @@ export function renameWorkbenchTab(
|
||||
const normalized = normalizeTitle(title);
|
||||
if (!normalized) return state;
|
||||
return updateTab(state, tabKey, (tab) => (
|
||||
tab.title === normalized ? tab : { ...tab, title: normalized }
|
||||
tab.title === normalized && tab.explicit
|
||||
? tab
|
||||
: { ...tab, explicit: true, title: normalized }
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -3414,6 +3414,23 @@ describe("App layout", () => {
|
||||
expect(restoredGrid).toHaveAttribute("data-layout", "rows");
|
||||
});
|
||||
|
||||
const alphaGroupButton = within(sidebar).getByRole("button", {
|
||||
name: "Group: Alpha",
|
||||
});
|
||||
const alphaGroup = alphaGroupButton.closest("[data-sidebar-tab-group]") as HTMLElement;
|
||||
fireEvent.pointerDown(within(alphaGroup).getByLabelText("Topic actions for Alpha"), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
|
||||
const renameDialog = await screen.findByRole("dialog", { name: "Rename group" });
|
||||
fireEvent.change(within(renameDialog).getByPlaceholderText("Group name"), {
|
||||
target: { value: "Research" },
|
||||
});
|
||||
fireEvent.click(within(renameDialog).getByRole("button", { name: "Save" }));
|
||||
expect(await within(sidebar).findByRole("button", { name: "Group: Research" }))
|
||||
.toBeInTheDocument();
|
||||
|
||||
fireEvent.pointerDown(within(sidebar).getByRole("button", {
|
||||
name: "New topic pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
@@ -3421,9 +3438,93 @@ describe("App layout", () => {
|
||||
name: "Remove",
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1));
|
||||
const researchGroup = within(sidebar).getByRole("button", {
|
||||
name: "Group: Research",
|
||||
}).closest("[data-sidebar-tab-group]") as HTMLElement;
|
||||
expect(within(researchGroup).getByRole("list", { name: "Panes in Research" }))
|
||||
.toBeInTheDocument();
|
||||
expect(within(researchGroup).getByRole("button", { name: "Alpha" }))
|
||||
.toBeInTheDocument();
|
||||
expect(within(sidebar).getAllByRole("button", { name: "New topic" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps a named group and its remaining pane active after deleting a pane", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:new-pane",
|
||||
channel: "websocket",
|
||||
chatId: "new-pane",
|
||||
createdAt: "2026-08-05T12:00:00Z",
|
||||
updatedAt: "2026-08-05T12:00:00Z",
|
||||
title: "New topic",
|
||||
preview: "",
|
||||
},
|
||||
{
|
||||
key: "websocket:unrelated",
|
||||
channel: "websocket",
|
||||
chatId: "unrelated",
|
||||
createdAt: "2026-08-05T11:00:00Z",
|
||||
updatedAt: "2026-08-05T11:00:00Z",
|
||||
title: "Unrelated",
|
||||
preview: "",
|
||||
},
|
||||
{
|
||||
key: "websocket:alpha",
|
||||
channel: "websocket",
|
||||
chatId: "alpha",
|
||||
createdAt: "2026-08-05T10:00:00Z",
|
||||
updatedAt: "2026-08-05T10:00:00Z",
|
||||
title: "Alpha",
|
||||
preview: "",
|
||||
},
|
||||
];
|
||||
window.history.replaceState(null, "", "/#/chat/websocket%3Anew-pane");
|
||||
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: false,
|
||||
title: "Research",
|
||||
paneKeys: ["websocket:alpha", "websocket:new-pane"],
|
||||
layoutPaneKeys: ["websocket:alpha", "websocket:new-pane"],
|
||||
layout: "columns",
|
||||
splitRatios: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 404 };
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
expect(await within(sidebar).findByRole("button", { name: "Group: Research" }))
|
||||
.toBeInTheDocument();
|
||||
fireEvent.pointerDown(within(sidebar).getByRole("button", {
|
||||
name: "New topic pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Delete" }));
|
||||
expect(await screen.findByText("Delete this topic?")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(deleteChatSpy).toHaveBeenCalledWith("websocket:new-pane"));
|
||||
await waitFor(() => expect(window.location.hash).toBe("#/chat/websocket%3Aalpha"));
|
||||
expect(within(sidebar).getByRole("button", { name: "Group: Research" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByTestId("pane-grid").children).toHaveLength(1);
|
||||
expect(screen.getByTestId("pane-grid").firstElementChild)
|
||||
.toHaveAttribute("aria-label", "Alpha");
|
||||
}, 15_000);
|
||||
|
||||
it("opens search from the keyboard shortcut", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("workbench model", () => {
|
||||
state = renameWorkbenchTab(state, tabKey, "Research");
|
||||
|
||||
expect(workbenchTab(state, tabKey)).toEqual({
|
||||
explicit: false,
|
||||
explicit: true,
|
||||
title: "Research",
|
||||
paneKeys: ["pane-a", "pane-b"],
|
||||
layoutPaneKeys: ["pane-a", "pane-b"],
|
||||
@@ -62,6 +62,24 @@ describe("workbench model", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a named group when a pane is detached", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||
state = renameWorkbenchTab(state, tabKey, "Research");
|
||||
state = detachWorkbenchPane(state, tabKey, "pane-b");
|
||||
|
||||
expect(workbenchTab(state, tabKey)).toEqual({
|
||||
explicit: true,
|
||||
title: "Research",
|
||||
paneKeys: ["pane-a"],
|
||||
layoutPaneKeys: ["pane-a"],
|
||||
layout: "columns",
|
||||
splitRatios: [],
|
||||
});
|
||||
expect(normalizeWorkbenchState(state)).toEqual(state);
|
||||
expect(reconcileWorkbench(state, new Set(["pane-a"]))).toEqual(state);
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -198,7 +216,7 @@ describe("workbench model", () => {
|
||||
);
|
||||
|
||||
expect(workbenchTab(reconciled, "alpha")).toEqual({
|
||||
explicit: false,
|
||||
explicit: true,
|
||||
title: "Alpha",
|
||||
paneKeys: ["pane-a", "pane-b"],
|
||||
layoutPaneKeys: ["pane-a", "pane-b"],
|
||||
|
||||
Reference in New Issue
Block a user