mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +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 hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
|
||||||
const deletingActive = activeKey !== null && deletingKeys.has(activeKey);
|
const deletingActive = activeKey !== null && deletingKeys.has(activeKey);
|
||||||
const currentIndex = topicSessions.findIndex((s) => s.key === 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
|
const fallbackKey = deletingActive
|
||||||
? (
|
? (
|
||||||
topicSessions.slice(currentIndex + 1).find((session) => (
|
siblingFallbackKey
|
||||||
|
?? topicSessions.slice(currentIndex + 1).find((session) => (
|
||||||
!deletingKeys.has(session.key)
|
!deletingKeys.has(session.key)
|
||||||
))?.key
|
))?.key
|
||||||
?? topicSessions.slice(0, Math.max(0, currentIndex)).reverse().find((session) => (
|
?? topicSessions.slice(0, Math.max(0, currentIndex)).reverse().find((session) => (
|
||||||
@@ -2191,7 +2198,7 @@ function Shell({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to delete session", 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 onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => {
|
||||||
const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
|
const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
|
||||||
|
|||||||
@@ -72,9 +72,10 @@ function normalizeTab(value: unknown): WorkbenchTabState {
|
|||||||
...requestedLayoutPaneKeys,
|
...requestedLayoutPaneKeys,
|
||||||
...paneKeys.filter((key) => !requestedLayoutPaneKeys.includes(key)),
|
...paneKeys.filter((key) => !requestedLayoutPaneKeys.includes(key)),
|
||||||
];
|
];
|
||||||
|
const title = normalizeTitle(candidate.title);
|
||||||
return {
|
return {
|
||||||
explicit: candidate.explicit === true,
|
explicit: candidate.explicit === true || title !== null,
|
||||||
title: normalizeTitle(candidate.title),
|
title,
|
||||||
paneKeys,
|
paneKeys,
|
||||||
layoutPaneKeys,
|
layoutPaneKeys,
|
||||||
layout: isLayout(candidate.layout) ? candidate.layout : "columns",
|
layout: isLayout(candidate.layout) ? candidate.layout : "columns",
|
||||||
@@ -309,7 +310,9 @@ export function renameWorkbenchTab(
|
|||||||
const normalized = normalizeTitle(title);
|
const normalized = normalizeTitle(title);
|
||||||
if (!normalized) return state;
|
if (!normalized) return state;
|
||||||
return updateTab(state, tabKey, (tab) => (
|
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");
|
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", {
|
fireEvent.pointerDown(within(sidebar).getByRole("button", {
|
||||||
name: "New topic pane actions",
|
name: "New topic pane actions",
|
||||||
}), { button: 0, ctrlKey: false });
|
}), { button: 0, ctrlKey: false });
|
||||||
@@ -3421,9 +3438,93 @@ describe("App layout", () => {
|
|||||||
name: "Remove",
|
name: "Remove",
|
||||||
}));
|
}));
|
||||||
await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1));
|
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);
|
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 () => {
|
it("opens search from the keyboard shortcut", async () => {
|
||||||
mockSessions = [
|
mockSessions = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ describe("workbench model", () => {
|
|||||||
state = renameWorkbenchTab(state, tabKey, "Research");
|
state = renameWorkbenchTab(state, tabKey, "Research");
|
||||||
|
|
||||||
expect(workbenchTab(state, tabKey)).toEqual({
|
expect(workbenchTab(state, tabKey)).toEqual({
|
||||||
explicit: false,
|
explicit: true,
|
||||||
title: "Research",
|
title: "Research",
|
||||||
paneKeys: ["pane-a", "pane-b"],
|
paneKeys: ["pane-a", "pane-b"],
|
||||||
layoutPaneKeys: ["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", () => {
|
it("detaches a pane without persisting its standalone projection", () => {
|
||||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||||
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||||
@@ -198,7 +216,7 @@ describe("workbench model", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(workbenchTab(reconciled, "alpha")).toEqual({
|
expect(workbenchTab(reconciled, "alpha")).toEqual({
|
||||||
explicit: false,
|
explicit: true,
|
||||||
title: "Alpha",
|
title: "Alpha",
|
||||||
paneKeys: ["pane-a", "pane-b"],
|
paneKeys: ["pane-a", "pane-b"],
|
||||||
layoutPaneKeys: ["pane-a", "pane-b"],
|
layoutPaneKeys: ["pane-a", "pane-b"],
|
||||||
|
|||||||
Reference in New Issue
Block a user