diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 2a15d5380..8fc89e055 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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()); diff --git a/webui/src/components/workbench/workbench-model.ts b/webui/src/components/workbench/workbench-model.ts index 03a0d721d..4e36fe4fd 100644 --- a/webui/src/components/workbench/workbench-model.ts +++ b/webui/src/components/workbench/workbench-model.ts @@ -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 } )); } diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 501691f07..1aecd6685 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -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(); + + 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 = [ { diff --git a/webui/src/tests/workbench-model.test.ts b/webui/src/tests/workbench-model.test.ts index 6b1511b66..c10fd2b60 100644 --- a/webui/src/tests/workbench-model.test.ts +++ b/webui/src/tests/workbench-model.test.ts @@ -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"],