fix(webui): preserve named pane groups

This commit is contained in:
Xubin Ren
2026-08-29 14:22:19 +08:00
parent 1fe14f2ee6
commit caab883f9f
4 changed files with 136 additions and 7 deletions
+9 -2
View File
@@ -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 }
));
}
+101
View File
@@ -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 = [
{
+20 -2
View File
@@ -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"],