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)) {