From d49bbba46aa472e6b3a8aa629fc4b9997e929a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 15 May 2026 02:17:21 -0400 Subject: [PATCH] Add pane tab bulk close actions --- .../features/editor/EditorPaneBar.test.tsx | 199 ++++++++++++++++++ .../src/features/editor/EditorPaneBar.tsx | 88 ++++++++ 2 files changed, 287 insertions(+) diff --git a/apps/desktop/src/features/editor/EditorPaneBar.test.tsx b/apps/desktop/src/features/editor/EditorPaneBar.test.tsx index 2fb57907..f3c0f751 100644 --- a/apps/desktop/src/features/editor/EditorPaneBar.test.tsx +++ b/apps/desktop/src/features/editor/EditorPaneBar.test.tsx @@ -81,6 +81,16 @@ function createChatSession(sessionId: string, title: string) { }; } +function createNoteTab(id: string, title: string) { + return { + id, + kind: "note" as const, + noteId: `notes/${id}`, + title, + content: title, + }; +} + describe("EditorPaneBar", () => { beforeEach(() => { if (typeof window.PointerEvent === "undefined") { @@ -219,6 +229,195 @@ describe("EditorPaneBar", () => { ).not.toBeInTheDocument(); }); + it("closes other tabs only in the pane that opened the tab context menu", async () => { + const user = userEvent.setup(); + useEditorStore.getState().hydrateWorkspace( + [ + { + id: "primary", + tabs: [ + createNoteTab("tab-a", "Alpha"), + createNoteTab("tab-c", "Gamma"), + createNoteTab("tab-d", "Delta"), + ], + activeTabId: "tab-a", + }, + { + id: "secondary", + tabs: [ + createNoteTab("tab-b", "Beta"), + createNoteTab("tab-e", "Epsilon"), + ], + activeTabId: "tab-b", + }, + ], + "primary", + ); + + renderComponent(); + + const tabButton = document.querySelector( + '[data-pane-tab-id="tab-c"]', + ) as HTMLElement | null; + expect(tabButton).not.toBeNull(); + fireEvent.contextMenu(tabButton!); + await user.click( + await screen.findByRole("button", { name: "Close Others" }), + ); + + await waitFor(() => { + expect( + useEditorStore + .getState() + .panes.find((pane) => pane.id === "primary") + ?.tabs.map((tab) => tab.id), + ).toEqual(["tab-c"]); + }); + expect( + useEditorStore + .getState() + .panes.find((pane) => pane.id === "secondary") + ?.tabs.map((tab) => tab.id), + ).toEqual(["tab-b", "tab-e"]); + }); + + it("closes tabs to the right only in the pane that opened the tab context menu", async () => { + const user = userEvent.setup(); + useEditorStore.getState().hydrateWorkspace( + [ + { + id: "primary", + tabs: [ + createNoteTab("tab-a", "Alpha"), + createNoteTab("tab-c", "Gamma"), + createNoteTab("tab-d", "Delta"), + ], + activeTabId: "tab-a", + }, + { + id: "secondary", + tabs: [ + createNoteTab("tab-b", "Beta"), + createNoteTab("tab-e", "Epsilon"), + ], + activeTabId: "tab-b", + }, + ], + "primary", + ); + + renderComponent(); + + const tabButton = document.querySelector( + '[data-pane-tab-id="tab-c"]', + ) as HTMLElement | null; + expect(tabButton).not.toBeNull(); + fireEvent.contextMenu(tabButton!); + await user.click( + await screen.findByRole("button", { + name: "Close Tabs to the Right", + }), + ); + + await waitFor(() => { + expect( + useEditorStore + .getState() + .panes.find((pane) => pane.id === "primary") + ?.tabs.map((tab) => tab.id), + ).toEqual(["tab-a", "tab-c"]); + }); + expect( + useEditorStore + .getState() + .panes.find((pane) => pane.id === "secondary") + ?.tabs.map((tab) => tab.id), + ).toEqual(["tab-b", "tab-e"]); + }); + + it("keeps all tabs open when close others confirmation is cancelled", async () => { + const user = userEvent.setup(); + useEditorStore.getState().hydrateWorkspace( + [ + { + id: "primary", + tabs: [ + createNoteTab("tab-a", "Alpha"), + { + id: "tab-chat", + kind: "ai-chat", + sessionId: "session-busy", + title: "Chat", + }, + createNoteTab("tab-c", "Gamma"), + ], + activeTabId: "tab-a", + }, + { + id: "secondary", + tabs: [createNoteTab("tab-b", "Beta")], + activeTabId: "tab-b", + }, + ], + "primary", + ); + useChatStore.setState({ + sessionsById: { + "session-busy": { + ...createChatSession("session-busy", "Busy agent"), + status: "streaming", + }, + }, + }); + vi.mocked(confirm).mockResolvedValue(false); + + renderComponent(); + + const tabButton = document.querySelector( + '[data-pane-tab-id="tab-a"]', + ) as HTMLElement | null; + expect(tabButton).not.toBeNull(); + fireEvent.contextMenu(tabButton!); + await user.click( + await screen.findByRole("button", { name: "Close Others" }), + ); + + await waitFor(() => { + expect(confirm).toHaveBeenCalledTimes(1); + }); + expect( + useEditorStore + .getState() + .panes.find((pane) => pane.id === "primary") + ?.tabs.map((tab) => tab.id), + ).toEqual(["tab-a", "tab-chat", "tab-c"]); + expect( + useEditorStore + .getState() + .panes.find((pane) => pane.id === "secondary") + ?.tabs.map((tab) => tab.id), + ).toEqual(["tab-b"]); + }); + + it("disables pane tab bulk-close actions when they do not apply", async () => { + renderComponent(); + + const tabButton = document.querySelector( + '[data-pane-tab-id="tab-a"]', + ) as HTMLElement | null; + expect(tabButton).not.toBeNull(); + fireEvent.contextMenu(tabButton!); + + expect( + await screen.findByRole("button", { name: "Close Others" }), + ).toBeDisabled(); + expect( + await screen.findByRole("button", { + name: "Close Tabs to the Right", + }), + ).toBeDisabled(); + }); + it("pins a tab per pane and renders it as icon-only chrome", async () => { const user = userEvent.setup(); useEditorStore.getState().hydrateWorkspace( diff --git a/apps/desktop/src/features/editor/EditorPaneBar.tsx b/apps/desktop/src/features/editor/EditorPaneBar.tsx index 9b4e0f5a..184d79af 100644 --- a/apps/desktop/src/features/editor/EditorPaneBar.tsx +++ b/apps/desktop/src/features/editor/EditorPaneBar.tsx @@ -401,6 +401,75 @@ export function EditorPaneBar({ paneId, isFocused }: EditorPaneBarProps) { }, [closeTab], ); + const closeTabIdsWithProtection = useCallback(async (tabIds: string[]) => { + const currentTabs = selectEditorWorkspaceTabs(useEditorStore.getState()); + const tabsToClose = tabIds + .map( + (tabId) => + currentTabs.find((candidate) => candidate.id === tabId) ?? + null, + ) + .filter((tab): tab is (typeof currentTabs)[number] => tab !== null); + + if (tabsToClose.length === 0) { + return; + } + + const affected = findActiveSessionsAffectedByClose( + tabsToClose, + useChatStore.getState().sessionsById, + ); + const confirmationMessage = + getCloseTabsConfirmationMessage(affected); + if ( + confirmationMessage !== null && + !(await confirm(confirmationMessage)) + ) { + return; + } + + for (const tabId of tabIds) { + useEditorStore.getState().closeTab(tabId, { + reason: "bulk-user", + }); + } + }, []); + const closeOtherTabsInPane = useCallback( + async (tabId: string) => { + const currentPane = selectEditorPaneState( + useEditorStore.getState(), + paneId, + ); + const tabIds = currentPane.tabs + .filter((tab) => tab.id !== tabId) + .map((tab) => tab.id); + + await closeTabIdsWithProtection(tabIds); + }, + [closeTabIdsWithProtection, paneId], + ); + const closeTabsToTheRightInPane = useCallback( + async (tabId: string) => { + const currentPane = selectEditorPaneState( + useEditorStore.getState(), + paneId, + ); + const tabIndex = currentPane.tabs.findIndex( + (tab) => tab.id === tabId, + ); + if (tabIndex === -1) { + return; + } + + const tabIds = currentPane.tabs + .slice(tabIndex + 1) + .map((tab) => tab.id) + .reverse(); + + await closeTabIdsWithProtection(tabIds); + }, + [closeTabIdsWithProtection, paneId], + ); return ( <> @@ -857,6 +926,9 @@ export function EditorPaneBar({ paneId, isFocused }: EditorPaneBarProps) { const targetTabPinned = pinnedTabIdSet.has( targetTab.id, ); + const targetTabIndex = pane.tabs.findIndex( + (candidate) => candidate.id === targetTab.id, + ); const entries: ContextMenuEntry[] = [ { @@ -869,6 +941,22 @@ export function EditorPaneBar({ paneId, isFocused }: EditorPaneBarProps) { action: () => void requestCloseTab(targetTab.id), }, + { + label: "Close Others", + disabled: pane.tabs.length <= 1, + action: () => + void closeOtherTabsInPane(targetTab.id), + }, + { + label: "Close Tabs to the Right", + disabled: + targetTabIndex === -1 || + targetTabIndex >= pane.tabs.length - 1, + action: () => + void closeTabsToTheRightInPane( + targetTab.id, + ), + }, ]; if (!targetTabPinned && isChatTab(targetTab)) {