From a0b9ba0065f0339b7cb96d2c5be50ef659ff10cb Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 28 Aug 2026 18:25:20 -0700 Subject: [PATCH 1/9] Add reopen closed tab shortcut --- .../plugin/PluginPanelRightPanelHost.tsx | 6 + .../secondary-panel/useThreadFileTabs.test.ts | 98 +++++++++++- .../secondary-panel/useThreadFileTabs.ts | 144 +++++++++++++++++- apps/app/src/lib/app-command-metadata.ts | 5 + apps/app/src/views/RootComposeView.tsx | 6 + .../views/thread-detail/ThreadDetailView.tsx | 6 + apps/desktop/src/desktop-menu-shortcuts.ts | 6 + apps/desktop/src/main.ts | 10 ++ apps/desktop/src/menu.ts | 9 ++ .../test/desktop-menu-shortcuts.test.ts | 3 + apps/desktop/test/menu.test.ts | 26 ++++ .../references/frontend-core-slots.md | 3 +- .../src/services/system/app-keybindings.ts | 18 +-- .../test/system/app-keybindings.test.ts | 20 ++- packages/domain/src/app-keybindings.ts | 1 + 15 files changed, 342 insertions(+), 19 deletions(-) diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx index 17d24ad199..74b0db7c5a 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx @@ -278,6 +278,7 @@ export function PluginPanelRightPanelHost({ closeTab, openTab, orderedSecondaryFileTabs, + reopenClosedTab, reorderTab, updateBrowserTab, } = useThreadFileTabs({ @@ -487,6 +488,11 @@ export function PluginPanelRightPanelHost({ openNewTab(); return true; }); + useAppCommandHandler("panel.reopenClosedTab", () => { + if (!isFocused || panel === null || !reopenClosedTab()) return false; + revealPanel(); + return true; + }); const [togglePortalTarget, setTogglePortalTarget] = useState(null); diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts index 11eb6a8cf9..32d7613b66 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts @@ -16,7 +16,10 @@ import { FIXED_PANEL_TABS_STATE_STORAGE_VERSION, } from "@/lib/fixed-panel-tabs-state"; import { buildFileOpenerPanelTab } from "@/components/plugin/file-opener-tabs"; -import { useThreadFileTabs } from "./useThreadFileTabs"; +import { + resetRecentlyClosedPanelTabsForTest, + useThreadFileTabs, +} from "./useThreadFileTabs"; import { resetPluginSlotStoreForTest, setPluginSlotRegistrations, @@ -82,12 +85,105 @@ afterEach(() => { cleanup(); queryClient.clear(); window.localStorage.clear(); + resetRecentlyClosedPanelTabsForTest(); resetPluginSlotStoreForTest(); syncMocks.scheduleLocalThreadTabsMigration.mockClear(); syncMocks.scheduleThreadTabsPersistence.mockClear(); syncMocks.useThreadTabs.mockClear(); }); +describe("useThreadFileTabs recently closed tabs", () => { + it("reopens closed tabs in reverse close order and restores their positions", () => { + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "recently-closed", + syncThreadId: null, + environmentId: "env_1", + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + let firstTabId = ""; + let secondTabId = ""; + act(() => { + firstTabId = + result.current.openTab({ + kind: "browser", + url: "https://first.example", + })?.id ?? ""; + secondTabId = + result.current.openTab({ + kind: "browser", + url: "https://second.example", + })?.id ?? ""; + }); + act(() => { + result.current.closeTab(firstTabId); + result.current.closeTab(secondTabId); + }); + + expect(result.current.orderedSecondaryFileTabs).toHaveLength(0); + let didReopen = false; + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(true); + expect(result.current.activeBrowserTab?.id).toBe(secondTabId); + + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(true); + expect(result.current.activeBrowserTab?.id).toBe(firstTabId); + expect( + result.current.orderedSecondaryFileTabs.map((tab) => tab.id), + ).toEqual([firstTabId, secondTabId]); + + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(false); + }); + + it("does not reopen a launcher tab or a file reopened another way", () => { + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "recently-closed-launcher", + syncThreadId: null, + environmentId: "env_1", + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + const fileRequest = { + kind: "workspace-file-preview" as const, + tab: { + lineRange: null, + path: "src/index.ts", + source: { kind: "working-tree" as const }, + statusLabel: null, + }, + }; + + act(() => { + const launcher = result.current.openTab({ kind: "new-tab" }); + result.current.closeTab(launcher?.id ?? ""); + }); + expect(result.current.reopenClosedTab()).toBe(false); + + let fileTabId = ""; + act(() => { + fileTabId = result.current.openTab(fileRequest)?.id ?? ""; + }); + act(() => result.current.closeTab(fileTabId)); + act(() => { + result.current.openTab(fileRequest); + }); + expect(result.current.reopenClosedTab()).toBe(false); + }); +}); + describe("useThreadFileTabs terminal pruning", () => { it("keeps root-compose file tabs local", () => { const { result } = renderThreadHook(() => diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts index ffb4c254f9..d46fb2ef31 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts @@ -133,8 +133,84 @@ type SecondaryPanelTab = | NewTabFixedPanelTab | PluginPanelFixedPanelTab; +type ReopenableSecondaryPanelTab = Exclude< + SecondaryPanelTab, + NewTabFixedPanelTab +>; + +interface RecentlyClosedPanelTab { + index: number; + tab: ReopenableSecondaryPanelTab; +} + type OpenResolvedTabBehavior = "open" | "replace-new-tab"; +const MAX_RECENTLY_CLOSED_PANEL_TABS = 25; +const recentlyClosedPanelTabs = new Map(); + +function isReopenableSecondaryPanelTab( + tab: FixedPanelTab, +): tab is ReopenableSecondaryPanelTab { + switch (tab.kind) { + case "workspace-file-preview": + case "host-file-preview": + case "thread-storage-file-preview": + case "browser": + case "plugin-panel": + return true; + case "thread-info": + case "git-diff": + case "plugin-page-fixed": + case "new-tab": + case "terminal": + return false; + } +} + +function rememberClosedPanelTab( + panelStateId: string, + entry: RecentlyClosedPanelTab, +): void { + const stack = recentlyClosedPanelTabs.get(panelStateId) ?? []; + stack.push(entry); + if (stack.length > MAX_RECENTLY_CLOSED_PANEL_TABS) { + stack.splice(0, stack.length - MAX_RECENTLY_CLOSED_PANEL_TABS); + } + recentlyClosedPanelTabs.set(panelStateId, stack); +} + +function forgetClosedPanelTab(panelStateId: string, tabId: string): void { + const stack = recentlyClosedPanelTabs.get(panelStateId); + if (stack === undefined) return; + const next = stack.filter((entry) => entry.tab.id !== tabId); + if (next.length === 0) { + recentlyClosedPanelTabs.delete(panelStateId); + return; + } + recentlyClosedPanelTabs.set(panelStateId, next); +} + +function takeClosedPanelTab( + panelStateId: string, + openTabIds: ReadonlySet, +): RecentlyClosedPanelTab | null { + const stack = recentlyClosedPanelTabs.get(panelStateId); + if (stack === undefined) return null; + while (stack.length > 0) { + const entry = stack.pop(); + if (entry !== undefined && !openTabIds.has(entry.tab.id)) { + if (stack.length === 0) recentlyClosedPanelTabs.delete(panelStateId); + return entry; + } + } + recentlyClosedPanelTabs.delete(panelStateId); + return null; +} + +export function resetRecentlyClosedPanelTabsForTest(): void { + recentlyClosedPanelTabs.clear(); +} + function createStorageTab( environmentId: string | null, tab: ThreadStorageFileTabState, @@ -260,8 +336,11 @@ export function useThreadFileTabs({ syncThreadId, ); const recordRecentItem = useRecordThreadRecentItem(panelStateId); - const isPanelStateResolved = - panelStateId !== null && panelStateId !== undefined; + const resolvedPanelStateId = + typeof panelStateId === "string" && panelStateId.length > 0 + ? panelStateId + : null; + const isPanelStateResolved = resolvedPanelStateId !== null; const resolvedFileOwnerThreadId = fileOwnerThreadId !== undefined ? fileOwnerThreadId @@ -442,6 +521,10 @@ export function useThreadFileTabs({ }); if (tab === null) return null; + if (resolvedPanelStateId !== null) { + forgetClosedPanelTab(resolvedPanelStateId, tab.id); + } + if ( request.kind === "workspace-file-preview" && request.tab.source.kind === "working-tree" @@ -468,6 +551,7 @@ export function useThreadFileTabs({ projectId, resolvedEnvironmentId, resolvedFileOwnerThreadId, + resolvedPanelStateId, updateFixedPanelTabsState, ], ); @@ -497,13 +581,55 @@ export function useThreadFileTabs({ const closeTab = useCallback( (tabId: string) => { - updateFixedPanelTabsState((state) => - closeSecondaryPanelTabInState(state, tabId), - ); + updateFixedPanelTabsState((state) => { + const tabIndex = state.secondary.tabs.findIndex( + (tab) => tab.id === tabId, + ); + const tab = state.secondary.tabs[tabIndex]; + const next = closeSecondaryPanelTabInState(state, tabId); + if ( + next !== state && + resolvedPanelStateId !== null && + tab !== undefined && + isReopenableSecondaryPanelTab(tab) + ) { + rememberClosedPanelTab(resolvedPanelStateId, { + index: tabIndex, + tab, + }); + } + return next; + }); }, - [updateFixedPanelTabsState], + [resolvedPanelStateId, updateFixedPanelTabsState], ); + const reopenClosedTab = useCallback((): boolean => { + if (resolvedPanelStateId === null) return false; + let didReopen = false; + updateFixedPanelTabsState((state) => { + const entry = takeClosedPanelTab( + resolvedPanelStateId, + new Set(state.secondary.tabs.map((tab) => tab.id)), + ); + if (entry === null) return state; + const index = Math.max( + 0, + Math.min(entry.index, state.secondary.tabs.length), + ); + const tabs = [...state.secondary.tabs]; + tabs.splice(index, 0, entry.tab); + didReopen = true; + return setSecondaryPanelTabsInState({ + activeTabId: entry.tab.id, + isOpen: true, + state, + tabs, + }); + }); + return didReopen; + }, [resolvedPanelStateId, updateFixedPanelTabsState]); + const openPluginPanel = useCallback( ({ pluginId, actionId, title, paramsJson }: OpenPluginPanelArgs) => { const tab = createPluginPanelFixedPanelTab({ @@ -512,6 +638,9 @@ export function useThreadFileTabs({ pluginId, title, }); + if (resolvedPanelStateId !== null) { + forgetClosedPanelTab(resolvedPanelStateId, tab.id); + } updateFixedPanelTabsState((state) => { const existing = findSecondaryPanelTab(state.secondary.tabs, tab.id); if (existing !== null && existing.kind === "plugin-panel") { @@ -527,7 +656,7 @@ export function useThreadFileTabs({ return replaceNewTabWithSecondaryPanelTabInState({ state, tab }); }); }, - [updateFixedPanelTabsState], + [resolvedPanelStateId, updateFixedPanelTabsState], ); const selectFileSearchResult = useCallback( @@ -680,6 +809,7 @@ export function useThreadFileTabs({ openPluginPanel, openTab, orderedSecondaryFileTabs, + reopenClosedTab, reorderTab, selectFileSearchResult, updateBrowserTab, diff --git a/apps/app/src/lib/app-command-metadata.ts b/apps/app/src/lib/app-command-metadata.ts index 1fa5e5f80d..fcff375a79 100644 --- a/apps/app/src/lib/app-command-metadata.ts +++ b/apps/app/src/lib/app-command-metadata.ts @@ -97,6 +97,11 @@ export const APP_COMMAND_GROUPS: readonly AppCommandGroup[] = [ "New panel tab", "Open a tab in the secondary panel.", ), + command( + "panel.reopenClosedTab", + "Reopen closed panel tab", + "Reopen the most recently closed secondary panel tab.", + ), command( "panel.close", "Close panel tab", diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 7393cfcbc3..e45aa5b3cc 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -1018,6 +1018,7 @@ function RootComposeSurface({ openPluginPanel, openTab, orderedSecondaryFileTabs, + reopenClosedTab, reorderTab, selectFileSearchResult, updateBrowserTab, @@ -1358,6 +1359,11 @@ function RootComposeSurface({ handleOpenNewTab(); return true; }); + useAppCommandHandler("panel.reopenClosedTab", () => { + if (!isFocusedPane || !reopenClosedTab()) return false; + openCompactDrawer(); + return true; + }); useAppCommandHandler("file.quickOpen", () => { if (!isFocusedPane) return false; handleOpenNewTab(); diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 6cc5443e97..943adb42ce 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -681,6 +681,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { openTab, openPluginPanel, orderedSecondaryFileTabs, + reopenClosedTab, reorderTab, selectFileSearchResult, updateBrowserTab, @@ -1554,6 +1555,11 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { handleOpenNewTab(); return true; }); + useAppCommandHandler("panel.reopenClosedTab", () => { + if (!isFocused || !reopenClosedTab()) return false; + openCompactDrawer(); + return true; + }); useAppCommandHandler("file.quickOpen", () => { if (!isFocused) return false; handleOpenNewTab(); diff --git a/apps/desktop/src/desktop-menu-shortcuts.ts b/apps/desktop/src/desktop-menu-shortcuts.ts index 2041a9e1c9..c9d1216df6 100644 --- a/apps/desktop/src/desktop-menu-shortcuts.ts +++ b/apps/desktop/src/desktop-menu-shortcuts.ts @@ -10,6 +10,7 @@ export interface ApplicationMenuAccelerators { openNewTab: string | undefined; openNewThread: string | undefined; openSettings: string | undefined; + reopenClosedTab: string | undefined; } export const DEFAULT_APPLICATION_MENU_ACCELERATORS: ApplicationMenuAccelerators = @@ -19,6 +20,7 @@ export const DEFAULT_APPLICATION_MENU_ACCELERATORS: ApplicationMenuAccelerators openNewTab: "CommandOrControl+T", openNewThread: "CommandOrControl+N", openSettings: "CommandOrControl+,", + reopenClosedTab: "CommandOrControl+Shift+T", }; const ELECTRON_KEY_NAMES: Readonly> = { @@ -92,5 +94,9 @@ export function resolveApplicationMenuAccelerators( openNewTab: acceleratorForCommand(keybindings, "panel.newTab"), openNewThread: acceleratorForCommand(keybindings, "thread.new"), openSettings: acceleratorForCommand(keybindings, "settings.open"), + reopenClosedTab: acceleratorForCommand( + keybindings, + "panel.reopenClosedTab", + ), }; } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b005970cd3..89bfc9082c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -741,6 +741,16 @@ function installCurrentApplicationMenu(): void { ); } }, + reopenClosedTab() { + const browserWindow = getFocusedApplicationWindow(); + if (browserWindow !== null) { + sendToApplicationRenderer( + browserWindow, + BB_DESKTOP_APP_COMMAND_CHANNEL, + "panel.reopenClosedTab", + ); + } + }, openSettings() { const browserWindow = getFocusedApplicationWindow(); if (browserWindow !== null) { diff --git a/apps/desktop/src/menu.ts b/apps/desktop/src/menu.ts index 995d34a61a..50a0e8588f 100644 --- a/apps/desktop/src/menu.ts +++ b/apps/desktop/src/menu.ts @@ -9,6 +9,7 @@ import type { ConnectServerSyncSkipReason } from "./connect-server-sync.js"; const SERVER_DAEMON_LOGS_MENU_LABEL = "Server & Daemon Logs"; const OPEN_NEW_TAB_MENU_LABEL = "New Tab"; +const REOPEN_CLOSED_TAB_MENU_LABEL = "Reopen Closed Tab"; const NEW_THREAD_MENU_LABEL = "New Thread"; const NEW_WINDOW_MENU_LABEL = "New Window"; const CLOSE_WINDOW_MENU_LABEL = "Close Window"; @@ -44,6 +45,7 @@ export interface InstallApplicationMenuArgs { openNewTab(): void; openNewThread(): void; openSettings(): void; + reopenClosedTab(): void; reloadWindow( browserWindow: BaseWindow | undefined, ignoreCache: boolean, @@ -153,6 +155,13 @@ export function buildApplicationMenuTemplate( }, label: OPEN_NEW_TAB_MENU_LABEL, }, + { + accelerator: args.accelerators.reopenClosedTab, + click() { + args.reopenClosedTab(); + }, + label: REOPEN_CLOSED_TAB_MENU_LABEL, + }, { accelerator: args.accelerators.openNewThread, click() { diff --git a/apps/desktop/test/desktop-menu-shortcuts.test.ts b/apps/desktop/test/desktop-menu-shortcuts.test.ts index 16ab95695b..ea796d6a61 100644 --- a/apps/desktop/test/desktop-menu-shortcuts.test.ts +++ b/apps/desktop/test/desktop-menu-shortcuts.test.ts @@ -34,6 +34,7 @@ describe("desktop menu shortcuts", () => { openNewTab: "CommandOrControl+T", openNewThread: "CommandOrControl+N", openSettings: "CommandOrControl+,", + reopenClosedTab: "CommandOrControl+Shift+T", }); }); @@ -55,9 +56,11 @@ describe("desktop menu shortcuts", () => { binding("thread.new", "n", { mod: true }), binding("thread.new", "u", { mod: true, shift: true }), binding("settings.open", ",", { mod: true }), + binding("panel.reopenClosedTab", "t", { mod: true, shift: true }), ]); expect(accelerators.openNewThread).toBe("CommandOrControl+Shift+U"); expect(accelerators.openSettings).toBe("CommandOrControl+,"); + expect(accelerators.reopenClosedTab).toBe("CommandOrControl+Shift+T"); expect(accelerators.openNewTab).toBeUndefined(); }); }); diff --git a/apps/desktop/test/menu.test.ts b/apps/desktop/test/menu.test.ts index 30c58bff5b..e94b80ef83 100644 --- a/apps/desktop/test/menu.test.ts +++ b/apps/desktop/test/menu.test.ts @@ -26,6 +26,7 @@ function menuArgs( openNewTab: undefined, openNewThread: undefined, openSettings: undefined, + reopenClosedTab: undefined, }, closeWindowOrSideTab: () => {}, connectServersSkipReason: null, @@ -36,6 +37,7 @@ function menuArgs( openNewThread: () => {}, openServerDaemonLogs: () => {}, openSettings: () => {}, + reopenClosedTab: () => {}, reloadWindow, selectServer: () => {}, serverDaemonLogsMenuEnabled: false, @@ -55,6 +57,30 @@ function findServerSubmenu( } describe("application menu", () => { + it("reopens the last closed tab from the File menu", () => { + const reopenClosedTab = vi.fn(); + const template = buildApplicationMenuTemplate( + menuArgs(() => {}, { + accelerators: { + closeWindowOrSideTab: undefined, + createNewWindow: undefined, + openNewTab: undefined, + openNewThread: undefined, + openSettings: undefined, + reopenClosedTab: "CommandOrControl+Shift+T", + }, + reopenClosedTab, + }), + ); + const fileMenu = template.find((item) => item.label === "File"); + const submenu = fileMenu?.submenu as MenuItemConstructorOptions[]; + const reopen = submenu.find((item) => item.label === "Reopen Closed Tab"); + + expect(reopen?.accelerator).toBe("CommandOrControl+Shift+T"); + reopen?.click?.({} as never, {} as BaseWindow, {} as never); + expect(reopenClosedTab).toHaveBeenCalledTimes(1); + }); + it("closes a native panel when Electron omits its window", () => { vi.mocked(Menu.sendActionToFirstResponder).mockClear(); const closeWindowOrSideTab = vi.fn(); diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-core-slots.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-core-slots.md index 5402f90f6b..8d9bb4ae48 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-core-slots.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-core-slots.md @@ -87,7 +87,8 @@ Slot props contracts (versioned, additive-only): main body; it must not mount a second panel layout or register Browser and Terminal itself. BB owns the desktop split, compact drawer, header/panel toggle, resizing, tab strip, persistence, and the shared `panel.toggle`, - `panel.newTab`, and `terminal.open` keyboard commands. + `panel.newTab`, `panel.reopenClosedTab`, and `terminal.open` keyboard + commands. New tab is a transient host launcher. On a plugin page it offers Browser (when the desktop browser is available) and Terminal; it does not offer diff --git a/apps/server/src/services/system/app-keybindings.ts b/apps/server/src/services/system/app-keybindings.ts index 4cdc0707a0..d5be8caa8e 100644 --- a/apps/server/src/services/system/app-keybindings.ts +++ b/apps/server/src/services/system/app-keybindings.ts @@ -190,6 +190,15 @@ export const DEFAULT_APP_KEYBINDINGS: AppDefaultKeybindings = [ ), binding("pane.close", "x", { mod: true, shift: true }, splitWithoutModal), binding("panel.newTab", "t", { mod: true }, mainWithoutModal), + binding( + "panel.reopenClosedTab", + "t", + { mod: true, shift: true }, + { + ...mainWithoutModal, + desktopOnly: true, + }, + ), binding("panel.close", "w", { mod: true }, mainWithoutModal), binding("panel.toggle", "j", { mod: true }, mainWithoutModal), binding("file.quickOpen", "p", { mod: true }, mainWithoutModal), @@ -208,15 +217,6 @@ export const DEFAULT_APP_KEYBINDINGS: AppDefaultKeybindings = [ { mod: true, shift: true }, mainWithoutModal, ), - binding( - "terminal.open", - "t", - { mod: true, shift: true }, - { - ...mainWithoutModal, - desktopOnly: true, - }, - ), binding( "composer.focus", "c", diff --git a/apps/server/test/system/app-keybindings.test.ts b/apps/server/test/system/app-keybindings.test.ts index 7626b45e9d..c8f64cb5db 100644 --- a/apps/server/test/system/app-keybindings.test.ts +++ b/apps/server/test/system/app-keybindings.test.ts @@ -151,6 +151,24 @@ describe("app keybindings", () => { desktopOnly: true, shortcut: { key: "n", mod: true, shift: true }, }); + expect( + config.keybindings.find( + (binding) => binding.command === "panel.reopenClosedTab", + ), + ).toMatchObject({ + desktopOnly: true, + shortcut: { key: "t", mod: true, shift: true }, + }); + expect( + config.keybindings.filter( + (binding) => binding.command === "terminal.open", + ), + ).toMatchObject([ + { + desktopOnly: false, + shortcut: { key: "Enter", mod: true, shift: true }, + }, + ]); expect( assignedDefaultKeybindings .filter((binding) => binding.command === "thread.previous") @@ -448,7 +466,7 @@ describe("app keybindings", () => { "thread.next", ...THREAD_JUMP_APP_COMMAND_IDS, ...PANE_FOCUS_APP_COMMAND_IDS, - "terminal.open", + "panel.reopenClosedTab", "browser.focusLocation", "browser.reload", "browser.find", diff --git a/packages/domain/src/app-keybindings.ts b/packages/domain/src/app-keybindings.ts index b44513db0e..caea53c63e 100644 --- a/packages/domain/src/app-keybindings.ts +++ b/packages/domain/src/app-keybindings.ts @@ -54,6 +54,7 @@ export const APP_COMMAND_IDS = [ "settings.openServers", "sidebar.toggle", "panel.newTab", + "panel.reopenClosedTab", "panel.close", "panel.toggle", "file.quickOpen", From 27519de9a7390ddc13f2938688e0b9ce6799768d Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 28 Aug 2026 18:35:56 -0700 Subject: [PATCH 2/9] Fix shortcut contract assertions --- apps/server/test/system/app-keybindings.test.ts | 1 - packages/plugin-api-map/sdk-public-api.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/server/test/system/app-keybindings.test.ts b/apps/server/test/system/app-keybindings.test.ts index c8f64cb5db..0f58d13d19 100644 --- a/apps/server/test/system/app-keybindings.test.ts +++ b/apps/server/test/system/app-keybindings.test.ts @@ -294,7 +294,6 @@ describe("app keybindings", () => { })), ).toEqual([ { desktopOnly: false, key: "Enter" }, - { desktopOnly: true, key: "t" }, ]); expect( assignedDefaultKeybindings.find( diff --git a/packages/plugin-api-map/sdk-public-api.json b/packages/plugin-api-map/sdk-public-api.json index 098cbc0ed2..0e629d42cf 100644 --- a/packages/plugin-api-map/sdk-public-api.json +++ b/packages/plugin-api-map/sdk-public-api.json @@ -3,7 +3,7 @@ "entries": { ".": { "types": "bundled-types/bb-plugin-sdk.d.ts", - "sha256": "70077a510684588d29816ecb221ca99476640c7972c31730025064904265535e" + "sha256": "0370a27a5dce9425889f1ee5952c8da3ed48f83390f5724cc3db1225cce3d71d" }, "./ai-services": { "types": "bundled-types/bb-plugin-sdk-ai-services.d.ts", From 0a99b6e25f5cc6f99732dbfeedc33458de00ed18 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 28 Aug 2026 19:58:39 -0700 Subject: [PATCH 3/9] Validate reopened storage tabs --- .../secondary-panel/useThreadFileTabs.test.ts | 67 +++++++++++++++++++ .../secondary-panel/useThreadFileTabs.ts | 34 ++++++++-- 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts index 32d7613b66..7d91b8e59d 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts @@ -182,6 +182,73 @@ describe("useThreadFileTabs recently closed tabs", () => { }); expect(result.current.reopenClosedTab()).toBe(false); }); + + it("skips deleted current-thread storage files while preserving owner-valid history", () => { + let storageFiles: readonly { path: string }[] = [ + { path: "available.md" }, + { path: "deleted.md" }, + ]; + const { result, rerender } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "recently-closed-storage", + syncThreadId: "thr_current", + environmentId: "env_1", + storageFiles, + terminalSessions: undefined, + }), + ); + + let availableTabId = ""; + let foreignTabId = ""; + let deletedTabId = ""; + act(() => { + availableTabId = + result.current.openTab({ + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: "available.md" }, + })?.id ?? ""; + foreignTabId = + result.current.openTab({ + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: "foreign.md" }, + threadId: "thr_foreign", + })?.id ?? ""; + deletedTabId = + result.current.openTab({ + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: "deleted.md" }, + })?.id ?? ""; + }); + act(() => { + result.current.closeTab(availableTabId); + result.current.closeTab(foreignTabId); + result.current.closeTab(deletedTabId); + }); + act(() => { + storageFiles = [{ path: "available.md" }]; + rerender(); + }); + + let didReopen = false; + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(true); + expect(result.current.activeStorageFilePath).toBe("foreign.md"); + expect(result.current.activeStorageFileThreadId).toBe("thr_foreign"); + + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(true); + expect(result.current.activeStorageFilePath).toBe("available.md"); + expect(result.current.activeStorageFileThreadId).toBe("thr_current"); + + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(false); + }); }); describe("useThreadFileTabs terminal pruning", () => { diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts index d46fb2ef31..0f6cf983c5 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts @@ -193,12 +193,17 @@ function forgetClosedPanelTab(panelStateId: string, tabId: string): void { function takeClosedPanelTab( panelStateId: string, openTabIds: ReadonlySet, + isTabValid?: (tab: ReopenableSecondaryPanelTab) => boolean, ): RecentlyClosedPanelTab | null { const stack = recentlyClosedPanelTabs.get(panelStateId); if (stack === undefined) return null; while (stack.length > 0) { const entry = stack.pop(); - if (entry !== undefined && !openTabIds.has(entry.tab.id)) { + if ( + entry !== undefined && + !openTabIds.has(entry.tab.id) && + (isTabValid === undefined || isTabValid(entry.tab)) + ) { if (stack.length === 0) recentlyClosedPanelTabs.delete(panelStateId); return entry; } @@ -348,6 +353,13 @@ export function useThreadFileTabs({ const resolvedEnvironmentId = isPanelStateResolved ? environmentId : undefined; + const knownStoragePaths = useMemo( + () => + storageFiles === undefined + ? null + : new Set(storageFiles.map((file) => file.path)), + [storageFiles], + ); useEffect(() => { if (!resolvedFileOwnerThreadId) return; @@ -442,13 +454,12 @@ export function useThreadFileTabs({ ]); useEffect(() => { - if (!isPanelStateResolved || !storageFiles) return; + if (!isPanelStateResolved || knownStoragePaths === null) return; updateFixedPanelTabsState((state) => { - const knownPaths = new Set(storageFiles.map((file) => file.path)); const pruned = setPrunedSecondaryTabs({ activeTabId: state.secondary.activeTabId, tabs: pruneStorageTabs({ - knownPaths, + knownPaths: knownStoragePaths, tabs: state.secondary.tabs, threadId: resolvedFileOwnerThreadId, }), @@ -462,8 +473,8 @@ export function useThreadFileTabs({ }); }, [ isPanelStateResolved, + knownStoragePaths, resolvedFileOwnerThreadId, - storageFiles, updateFixedPanelTabsState, ]); @@ -611,6 +622,12 @@ export function useThreadFileTabs({ const entry = takeClosedPanelTab( resolvedPanelStateId, new Set(state.secondary.tabs.map((tab) => tab.id)), + (tab) => + tab.kind !== "thread-storage-file-preview" || + knownStoragePaths === null || + (tab.threadId !== null && + tab.threadId !== resolvedFileOwnerThreadId) || + knownStoragePaths.has(tab.path), ); if (entry === null) return state; const index = Math.max( @@ -628,7 +645,12 @@ export function useThreadFileTabs({ }); }); return didReopen; - }, [resolvedPanelStateId, updateFixedPanelTabsState]); + }, [ + knownStoragePaths, + resolvedFileOwnerThreadId, + resolvedPanelStateId, + updateFixedPanelTabsState, + ]); const openPluginPanel = useCallback( ({ pluginId, actionId, title, paramsJson }: OpenPluginPanelArgs) => { From 2df4e8b81f70cdfca76ba635949a53de636269b0 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 28 Aug 2026 23:43:08 -0700 Subject: [PATCH 4/9] Filter closed tab history by environment --- .../secondary-panel/useThreadFileTabs.test.ts | 54 +++++++++++++++++++ .../secondary-panel/useThreadFileTabs.ts | 15 ++++-- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts index 7d91b8e59d..0d731ca0fc 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts @@ -249,6 +249,60 @@ describe("useThreadFileTabs recently closed tabs", () => { }); expect(didReopen).toBe(false); }); + + it("skips workspace history from the previous environment", () => { + let environmentId = "env_1"; + const { result, rerender } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "recently-closed-environment-switch", + syncThreadId: null, + environmentId, + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + let browserTabId = ""; + let workspaceTabId = ""; + act(() => { + browserTabId = + result.current.openTab({ + kind: "browser", + url: "https://example.com", + })?.id ?? ""; + workspaceTabId = + result.current.openTab({ + kind: "workspace-file-preview", + tab: { + lineRange: null, + path: "src/index.ts", + source: { kind: "working-tree" }, + statusLabel: null, + }, + })?.id ?? ""; + }); + act(() => { + result.current.closeTab(browserTabId); + result.current.closeTab(workspaceTabId); + }); + act(() => { + environmentId = "env_2"; + rerender(); + }); + + let didReopen = false; + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(true); + expect(result.current.activeBrowserTab?.id).toBe(browserTabId); + expect(result.current.activeWorkspaceFilePath).toBeNull(); + + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(false); + }); }); describe("useThreadFileTabs terminal pruning", () => { diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts index 0f6cf983c5..611fc7f1d2 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts @@ -623,11 +623,14 @@ export function useThreadFileTabs({ resolvedPanelStateId, new Set(state.secondary.tabs.map((tab) => tab.id)), (tab) => - tab.kind !== "thread-storage-file-preview" || - knownStoragePaths === null || - (tab.threadId !== null && - tab.threadId !== resolvedFileOwnerThreadId) || - knownStoragePaths.has(tab.path), + (tab.kind !== "workspace-file-preview" || + preserveWorkspaceTabsAcrossContexts || + tab.environmentId === resolvedEnvironmentId) && + (tab.kind !== "thread-storage-file-preview" || + knownStoragePaths === null || + (tab.threadId !== null && + tab.threadId !== resolvedFileOwnerThreadId) || + knownStoragePaths.has(tab.path)), ); if (entry === null) return state; const index = Math.max( @@ -647,6 +650,8 @@ export function useThreadFileTabs({ return didReopen; }, [ knownStoragePaths, + preserveWorkspaceTabsAcrossContexts, + resolvedEnvironmentId, resolvedFileOwnerThreadId, resolvedPanelStateId, updateFixedPanelTabsState, From 709c433953f53d72b0a3cec0b612c0346b107d11 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 28 Aug 2026 23:53:13 -0700 Subject: [PATCH 5/9] Scope closed tab history to its owner --- .../secondary-panel/useThreadFileTabs.test.ts | 150 ++++++++++++++---- .../secondary-panel/useThreadFileTabs.ts | 111 +++++++++++-- 2 files changed, 212 insertions(+), 49 deletions(-) diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts index 0d731ca0fc..d4bac05bc9 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts @@ -183,7 +183,7 @@ describe("useThreadFileTabs recently closed tabs", () => { expect(result.current.reopenClosedTab()).toBe(false); }); - it("skips deleted current-thread storage files while preserving owner-valid history", () => { + it("skips storage history with a deleted path or different owner", () => { let storageFiles: readonly { path: string }[] = [ { path: "available.md" }, { path: "deleted.md" }, @@ -230,13 +230,6 @@ describe("useThreadFileTabs recently closed tabs", () => { }); let didReopen = false; - act(() => { - didReopen = result.current.reopenClosedTab(); - }); - expect(didReopen).toBe(true); - expect(result.current.activeStorageFilePath).toBe("foreign.md"); - expect(result.current.activeStorageFileThreadId).toBe("thr_foreign"); - act(() => { didReopen = result.current.reopenClosedTab(); }); @@ -250,43 +243,133 @@ describe("useThreadFileTabs recently closed tabs", () => { expect(didReopen).toBe(false); }); - it("skips workspace history from the previous environment", () => { + it.each([ + { + changedContext: { + environmentId: "env_2", + fileOwnerThreadId: "thr_1", + projectHostId: "host_1", + projectId: "proj_1", + }, + dimension: "environment", + }, + { + changedContext: { + environmentId: "env_1", + fileOwnerThreadId: "thr_1", + projectHostId: "host_1", + projectId: "proj_2", + }, + dimension: "project", + }, + { + changedContext: { + environmentId: "env_1", + fileOwnerThreadId: "thr_2", + projectHostId: "host_1", + projectId: "proj_1", + }, + dimension: "file owner", + }, + { + changedContext: { + environmentId: "env_1", + fileOwnerThreadId: "thr_1", + projectHostId: "host_2", + projectId: "proj_1", + }, + dimension: "project host", + }, + ])( + "skips workspace history from a different $dimension", + ({ changedContext, dimension }) => { + let context = { + environmentId: "env_1", + fileOwnerThreadId: "thr_1", + projectHostId: "host_1", + projectId: "proj_1", + }; + const { result, rerender } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: `recently-closed-${dimension}`, + syncThreadId: null, + environmentId: context.environmentId, + fileOwnerThreadId: context.fileOwnerThreadId, + projectHostId: context.projectHostId, + projectId: context.projectId, + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + let workspaceTabId = ""; + act(() => { + workspaceTabId = + result.current.openTab({ + kind: "workspace-file-preview", + tab: { + lineRange: null, + path: "src/index.ts", + source: { kind: "working-tree" }, + statusLabel: null, + }, + })?.id ?? ""; + }); + act(() => result.current.closeTab(workspaceTabId)); + act(() => { + context = changedContext; + rerender(); + }); + + let didReopen = false; + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(false); + expect(result.current.activeWorkspaceFilePath).toBeNull(); + }, + ); + + it("restores the nearest history entry owned by the current context", () => { let environmentId = "env_1"; const { result, rerender } = renderThreadHook(() => useThreadFileTabs({ - panelStateId: "recently-closed-environment-switch", + panelStateId: "recently-closed-context-order", syncThreadId: null, environmentId, + fileOwnerThreadId: "thr_1", + projectHostId: "host_1", + projectId: "proj_1", storageFiles: undefined, terminalSessions: undefined, }), ); - let browserTabId = ""; - let workspaceTabId = ""; - act(() => { - browserTabId = - result.current.openTab({ - kind: "browser", - url: "https://example.com", - })?.id ?? ""; - workspaceTabId = - result.current.openTab({ - kind: "workspace-file-preview", - tab: { - lineRange: null, - path: "src/index.ts", - source: { kind: "working-tree" }, - statusLabel: null, - }, - })?.id ?? ""; - }); + const openAndCloseWorkspaceFile = (path: string) => { + let tabId = ""; + act(() => { + tabId = + result.current.openTab({ + kind: "workspace-file-preview", + tab: { + lineRange: null, + path, + source: { kind: "working-tree" }, + statusLabel: null, + }, + })?.id ?? ""; + }); + act(() => result.current.closeTab(tabId)); + }; + + openAndCloseWorkspaceFile("src/env-one.ts"); act(() => { - result.current.closeTab(browserTabId); - result.current.closeTab(workspaceTabId); + environmentId = "env_2"; + rerender(); }); + openAndCloseWorkspaceFile("src/env-two.ts"); act(() => { - environmentId = "env_2"; + environmentId = "env_1"; rerender(); }); @@ -295,8 +378,7 @@ describe("useThreadFileTabs recently closed tabs", () => { didReopen = result.current.reopenClosedTab(); }); expect(didReopen).toBe(true); - expect(result.current.activeBrowserTab?.id).toBe(browserTabId); - expect(result.current.activeWorkspaceFilePath).toBeNull(); + expect(result.current.activeWorkspaceFilePath).toBe("src/env-one.ts"); act(() => { didReopen = result.current.reopenClosedTab(); diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts index 611fc7f1d2..225020a81b 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts @@ -22,6 +22,7 @@ import { import { usePluginSlots } from "@/lib/plugin-slots"; import { useFileOpenerPreferenceValue } from "@/lib/file-opener-preference"; import { + createFileOpenerOriginalTab, createFileOpenerTabForRequest, fileOpenerIdFromActionId, parseFileOpenerParams, @@ -139,10 +140,24 @@ type ReopenableSecondaryPanelTab = Exclude< >; interface RecentlyClosedPanelTab { + context: RecentlyClosedPanelTabContext; index: number; tab: ReopenableSecondaryPanelTab; } +interface RecentlyClosedPanelTabContext { + environmentId: string | null | undefined; + fileOwnerThreadId: string | null; + projectHostId: string | null; + projectId: string | null; +} + +interface IsRecentlyClosedPanelTabValidArgs { + currentContext: RecentlyClosedPanelTabContext; + entry: RecentlyClosedPanelTab; + knownStoragePaths: ReadonlySet | null; +} + type OpenResolvedTabBehavior = "open" | "replace-new-tab"; const MAX_RECENTLY_CLOSED_PANEL_TABS = 25; @@ -190,10 +205,62 @@ function forgetClosedPanelTab(panelStateId: string, tabId: string): void { recentlyClosedPanelTabs.set(panelStateId, next); } +function areRecentlyClosedPanelTabContextsEqual( + first: RecentlyClosedPanelTabContext, + second: RecentlyClosedPanelTabContext, +): boolean { + return ( + first.environmentId === second.environmentId && + first.fileOwnerThreadId === second.fileOwnerThreadId && + first.projectHostId === second.projectHostId && + first.projectId === second.projectId + ); +} + +function isRecentlyClosedPanelTabValid({ + currentContext, + entry, + knownStoragePaths, +}: IsRecentlyClosedPanelTabValidArgs): boolean { + if (!areRecentlyClosedPanelTabContextsEqual(entry.context, currentContext)) { + return false; + } + const originalTab = + entry.tab.kind === "plugin-panel" + ? createFileOpenerOriginalTab(entry.tab) + : null; + const tab = originalTab ?? entry.tab; + switch (tab.kind) { + case "workspace-file-preview": + return ( + tab.environmentId === currentContext.environmentId && + tab.projectId === + (currentContext.environmentId === null + ? currentContext.projectId + : null) + ); + case "host-file-preview": + return ( + tab.hostId !== null || + (tab.environmentId === currentContext.environmentId && + tab.threadId === currentContext.fileOwnerThreadId) + ); + case "thread-storage-file-preview": + return ( + tab.threadId === currentContext.fileOwnerThreadId && + (knownStoragePaths === null || knownStoragePaths.has(tab.path)) + ); + case "browser": + return tab.environmentId === currentContext.environmentId; + case "plugin-panel": + return true; + } +} + function takeClosedPanelTab( panelStateId: string, openTabIds: ReadonlySet, - isTabValid?: (tab: ReopenableSecondaryPanelTab) => boolean, + isEntryValid?: (entry: RecentlyClosedPanelTab) => boolean, ): RecentlyClosedPanelTab | null { const stack = recentlyClosedPanelTabs.get(panelStateId); if (stack === undefined) return null; @@ -202,7 +269,7 @@ function takeClosedPanelTab( if ( entry !== undefined && !openTabIds.has(entry.tab.id) && - (isTabValid === undefined || isTabValid(entry.tab)) + (isEntryValid === undefined || isEntryValid(entry)) ) { if (stack.length === 0) recentlyClosedPanelTabs.delete(panelStateId); return entry; @@ -360,6 +427,20 @@ export function useThreadFileTabs({ : new Set(storageFiles.map((file) => file.path)), [storageFiles], ); + const recentlyClosedPanelTabContext = useMemo( + () => ({ + environmentId: resolvedEnvironmentId, + fileOwnerThreadId: resolvedFileOwnerThreadId, + projectHostId, + projectId, + }), + [ + projectHostId, + projectId, + resolvedEnvironmentId, + resolvedFileOwnerThreadId, + ], + ); useEffect(() => { if (!resolvedFileOwnerThreadId) return; @@ -605,6 +686,7 @@ export function useThreadFileTabs({ isReopenableSecondaryPanelTab(tab) ) { rememberClosedPanelTab(resolvedPanelStateId, { + context: recentlyClosedPanelTabContext, index: tabIndex, tab, }); @@ -612,7 +694,11 @@ export function useThreadFileTabs({ return next; }); }, - [resolvedPanelStateId, updateFixedPanelTabsState], + [ + recentlyClosedPanelTabContext, + resolvedPanelStateId, + updateFixedPanelTabsState, + ], ); const reopenClosedTab = useCallback((): boolean => { @@ -622,15 +708,12 @@ export function useThreadFileTabs({ const entry = takeClosedPanelTab( resolvedPanelStateId, new Set(state.secondary.tabs.map((tab) => tab.id)), - (tab) => - (tab.kind !== "workspace-file-preview" || - preserveWorkspaceTabsAcrossContexts || - tab.environmentId === resolvedEnvironmentId) && - (tab.kind !== "thread-storage-file-preview" || - knownStoragePaths === null || - (tab.threadId !== null && - tab.threadId !== resolvedFileOwnerThreadId) || - knownStoragePaths.has(tab.path)), + (entry) => + isRecentlyClosedPanelTabValid({ + currentContext: recentlyClosedPanelTabContext, + entry, + knownStoragePaths, + }), ); if (entry === null) return state; const index = Math.max( @@ -650,9 +733,7 @@ export function useThreadFileTabs({ return didReopen; }, [ knownStoragePaths, - preserveWorkspaceTabsAcrossContexts, - resolvedEnvironmentId, - resolvedFileOwnerThreadId, + recentlyClosedPanelTabContext, resolvedPanelStateId, updateFixedPanelTabsState, ]); From 85596c81bd120c41c3b40d506232c99729c67bc6 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 29 Aug 2026 00:40:06 -0700 Subject: [PATCH 6/9] Key closed tab history by context --- .../secondary-panel/useThreadFileTabs.test.ts | 10 + .../secondary-panel/useThreadFileTabs.ts | 172 ++++++++++-------- 2 files changed, 104 insertions(+), 78 deletions(-) diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts index d4bac05bc9..21f7be7336 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts @@ -384,6 +384,16 @@ describe("useThreadFileTabs recently closed tabs", () => { didReopen = result.current.reopenClosedTab(); }); expect(didReopen).toBe(false); + + act(() => { + environmentId = "env_2"; + rerender(); + }); + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(true); + expect(result.current.activeWorkspaceFilePath).toBe("src/env-two.ts"); }); }); diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts index 225020a81b..37d2bf18ee 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts @@ -140,28 +140,31 @@ type ReopenableSecondaryPanelTab = Exclude< >; interface RecentlyClosedPanelTab { - context: RecentlyClosedPanelTabContext; index: number; tab: ReopenableSecondaryPanelTab; } -interface RecentlyClosedPanelTabContext { +interface RecentlyClosedPanelContext { environmentId: string | null | undefined; fileOwnerThreadId: string | null; + panelStateId: string; projectHostId: string | null; projectId: string | null; } -interface IsRecentlyClosedPanelTabValidArgs { - currentContext: RecentlyClosedPanelTabContext; - entry: RecentlyClosedPanelTab; - knownStoragePaths: ReadonlySet | null; +interface IsReopenablePanelTabOwnedByContextArgs { + context: RecentlyClosedPanelContext; + tab: ReopenableSecondaryPanelTab; } +type RecentlyClosedPanelContextKey = string; type OpenResolvedTabBehavior = "open" | "replace-new-tab"; const MAX_RECENTLY_CLOSED_PANEL_TABS = 25; -const recentlyClosedPanelTabs = new Map(); +const recentlyClosedPanelTabs = new Map< + RecentlyClosedPanelContextKey, + RecentlyClosedPanelTab[] +>(); function isReopenableSecondaryPanelTab( tab: FixedPanelTab, @@ -183,86 +186,88 @@ function isReopenableSecondaryPanelTab( } function rememberClosedPanelTab( - panelStateId: string, + contextKey: RecentlyClosedPanelContextKey, entry: RecentlyClosedPanelTab, ): void { - const stack = recentlyClosedPanelTabs.get(panelStateId) ?? []; + const stack = recentlyClosedPanelTabs.get(contextKey) ?? []; stack.push(entry); if (stack.length > MAX_RECENTLY_CLOSED_PANEL_TABS) { stack.splice(0, stack.length - MAX_RECENTLY_CLOSED_PANEL_TABS); } - recentlyClosedPanelTabs.set(panelStateId, stack); + recentlyClosedPanelTabs.set(contextKey, stack); } -function forgetClosedPanelTab(panelStateId: string, tabId: string): void { - const stack = recentlyClosedPanelTabs.get(panelStateId); +function forgetClosedPanelTab( + contextKey: RecentlyClosedPanelContextKey, + tabId: string, +): void { + const stack = recentlyClosedPanelTabs.get(contextKey); if (stack === undefined) return; const next = stack.filter((entry) => entry.tab.id !== tabId); if (next.length === 0) { - recentlyClosedPanelTabs.delete(panelStateId); + recentlyClosedPanelTabs.delete(contextKey); return; } - recentlyClosedPanelTabs.set(panelStateId, next); + recentlyClosedPanelTabs.set(contextKey, next); } -function areRecentlyClosedPanelTabContextsEqual( - first: RecentlyClosedPanelTabContext, - second: RecentlyClosedPanelTabContext, -): boolean { - return ( - first.environmentId === second.environmentId && - first.fileOwnerThreadId === second.fileOwnerThreadId && - first.projectHostId === second.projectHostId && - first.projectId === second.projectId - ); +function buildRecentlyClosedPanelContextKey( + context: RecentlyClosedPanelContext, +): RecentlyClosedPanelContextKey { + return JSON.stringify(context); } -function isRecentlyClosedPanelTabValid({ - currentContext, - entry, - knownStoragePaths, -}: IsRecentlyClosedPanelTabValidArgs): boolean { - if (!areRecentlyClosedPanelTabContextsEqual(entry.context, currentContext)) { - return false; - } +function isReopenablePanelTabOwnedByContext({ + context, + tab: reopenableTab, +}: IsReopenablePanelTabOwnedByContextArgs): boolean { const originalTab = - entry.tab.kind === "plugin-panel" - ? createFileOpenerOriginalTab(entry.tab) + reopenableTab.kind === "plugin-panel" + ? createFileOpenerOriginalTab(reopenableTab) : null; - const tab = originalTab ?? entry.tab; + const tab = originalTab ?? reopenableTab; switch (tab.kind) { case "workspace-file-preview": return ( - tab.environmentId === currentContext.environmentId && + tab.environmentId === context.environmentId && tab.projectId === - (currentContext.environmentId === null - ? currentContext.projectId - : null) + (context.environmentId === null ? context.projectId : null) ); case "host-file-preview": return ( tab.hostId !== null || - (tab.environmentId === currentContext.environmentId && - tab.threadId === currentContext.fileOwnerThreadId) + (tab.environmentId === context.environmentId && + tab.threadId === context.fileOwnerThreadId) ); case "thread-storage-file-preview": - return ( - tab.threadId === currentContext.fileOwnerThreadId && - (knownStoragePaths === null || knownStoragePaths.has(tab.path)) - ); + return tab.threadId === context.fileOwnerThreadId; case "browser": - return tab.environmentId === currentContext.environmentId; + return tab.environmentId === context.environmentId; case "plugin-panel": return true; } } +function isRecentlyClosedPanelTabAvailable( + tab: ReopenableSecondaryPanelTab, + knownStoragePaths: ReadonlySet | null, +): boolean { + const originalTab = + tab.kind === "plugin-panel" ? createFileOpenerOriginalTab(tab) : null; + const resourceTab = originalTab ?? tab; + return ( + resourceTab.kind !== "thread-storage-file-preview" || + knownStoragePaths === null || + knownStoragePaths.has(resourceTab.path) + ); +} + function takeClosedPanelTab( - panelStateId: string, + contextKey: RecentlyClosedPanelContextKey, openTabIds: ReadonlySet, isEntryValid?: (entry: RecentlyClosedPanelTab) => boolean, ): RecentlyClosedPanelTab | null { - const stack = recentlyClosedPanelTabs.get(panelStateId); + const stack = recentlyClosedPanelTabs.get(contextKey); if (stack === undefined) return null; while (stack.length > 0) { const entry = stack.pop(); @@ -271,11 +276,11 @@ function takeClosedPanelTab( !openTabIds.has(entry.tab.id) && (isEntryValid === undefined || isEntryValid(entry)) ) { - if (stack.length === 0) recentlyClosedPanelTabs.delete(panelStateId); + if (stack.length === 0) recentlyClosedPanelTabs.delete(contextKey); return entry; } } - recentlyClosedPanelTabs.delete(panelStateId); + recentlyClosedPanelTabs.delete(contextKey); return null; } @@ -427,20 +432,32 @@ export function useThreadFileTabs({ : new Set(storageFiles.map((file) => file.path)), [storageFiles], ); - const recentlyClosedPanelTabContext = useMemo( - () => ({ - environmentId: resolvedEnvironmentId, - fileOwnerThreadId: resolvedFileOwnerThreadId, - projectHostId, - projectId, - }), + const recentlyClosedPanelContext = useMemo( + () => + resolvedPanelStateId === null + ? null + : { + environmentId: resolvedEnvironmentId, + fileOwnerThreadId: resolvedFileOwnerThreadId, + panelStateId: resolvedPanelStateId, + projectHostId, + projectId, + }, [ projectHostId, projectId, resolvedEnvironmentId, resolvedFileOwnerThreadId, + resolvedPanelStateId, ], ); + const recentlyClosedPanelContextKey = useMemo( + () => + recentlyClosedPanelContext === null + ? null + : buildRecentlyClosedPanelContextKey(recentlyClosedPanelContext), + [recentlyClosedPanelContext], + ); useEffect(() => { if (!resolvedFileOwnerThreadId) return; @@ -613,8 +630,8 @@ export function useThreadFileTabs({ }); if (tab === null) return null; - if (resolvedPanelStateId !== null) { - forgetClosedPanelTab(resolvedPanelStateId, tab.id); + if (recentlyClosedPanelContextKey !== null) { + forgetClosedPanelTab(recentlyClosedPanelContextKey, tab.id); } if ( @@ -643,7 +660,7 @@ export function useThreadFileTabs({ projectId, resolvedEnvironmentId, resolvedFileOwnerThreadId, - resolvedPanelStateId, + recentlyClosedPanelContextKey, updateFixedPanelTabsState, ], ); @@ -681,12 +698,16 @@ export function useThreadFileTabs({ const next = closeSecondaryPanelTabInState(state, tabId); if ( next !== state && - resolvedPanelStateId !== null && + recentlyClosedPanelContext !== null && + recentlyClosedPanelContextKey !== null && tab !== undefined && - isReopenableSecondaryPanelTab(tab) + isReopenableSecondaryPanelTab(tab) && + isReopenablePanelTabOwnedByContext({ + context: recentlyClosedPanelContext, + tab, + }) ) { - rememberClosedPanelTab(resolvedPanelStateId, { - context: recentlyClosedPanelTabContext, + rememberClosedPanelTab(recentlyClosedPanelContextKey, { index: tabIndex, tab, }); @@ -695,25 +716,21 @@ export function useThreadFileTabs({ }); }, [ - recentlyClosedPanelTabContext, - resolvedPanelStateId, + recentlyClosedPanelContext, + recentlyClosedPanelContextKey, updateFixedPanelTabsState, ], ); const reopenClosedTab = useCallback((): boolean => { - if (resolvedPanelStateId === null) return false; + if (recentlyClosedPanelContextKey === null) return false; let didReopen = false; updateFixedPanelTabsState((state) => { const entry = takeClosedPanelTab( - resolvedPanelStateId, + recentlyClosedPanelContextKey, new Set(state.secondary.tabs.map((tab) => tab.id)), (entry) => - isRecentlyClosedPanelTabValid({ - currentContext: recentlyClosedPanelTabContext, - entry, - knownStoragePaths, - }), + isRecentlyClosedPanelTabAvailable(entry.tab, knownStoragePaths), ); if (entry === null) return state; const index = Math.max( @@ -733,8 +750,7 @@ export function useThreadFileTabs({ return didReopen; }, [ knownStoragePaths, - recentlyClosedPanelTabContext, - resolvedPanelStateId, + recentlyClosedPanelContextKey, updateFixedPanelTabsState, ]); @@ -746,8 +762,8 @@ export function useThreadFileTabs({ pluginId, title, }); - if (resolvedPanelStateId !== null) { - forgetClosedPanelTab(resolvedPanelStateId, tab.id); + if (recentlyClosedPanelContextKey !== null) { + forgetClosedPanelTab(recentlyClosedPanelContextKey, tab.id); } updateFixedPanelTabsState((state) => { const existing = findSecondaryPanelTab(state.secondary.tabs, tab.id); @@ -764,7 +780,7 @@ export function useThreadFileTabs({ return replaceNewTabWithSecondaryPanelTabInState({ state, tab }); }); }, - [resolvedPanelStateId, updateFixedPanelTabsState], + [recentlyClosedPanelContextKey, updateFixedPanelTabsState], ); const selectFileSearchResult = useCallback( From a4a2333ae4ef4992661540cbc35be91ffdde9dd2 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 29 Aug 2026 01:29:04 -0700 Subject: [PATCH 7/9] Fix closed tab restoration defects --- .../sidebarSplitLayout.test.ts | 34 +++ .../secondary-panel/sidebarSplitLayout.ts | 34 ++- .../secondary-panel/useThreadFileTabs.test.ts | 179 +++++++++++++- .../secondary-panel/useThreadFileTabs.ts | 225 ++++++++++++++---- .../secondary-panel/useThreadStorageViewer.ts | 15 ++ apps/app/src/views/RootComposeView.tsx | 24 +- .../views/thread-detail/ThreadDetailView.tsx | 4 +- apps/desktop/src/desktop-browser-view.ts | 12 + apps/desktop/src/main.ts | 5 + .../test/desktop-browser-main-ipc.test.ts | 2 + .../test/desktop-browser-view-manager.test.ts | 44 ++++ 11 files changed, 511 insertions(+), 67 deletions(-) diff --git a/apps/app/src/components/secondary-panel/sidebarSplitLayout.test.ts b/apps/app/src/components/secondary-panel/sidebarSplitLayout.test.ts index ad23efe953..0d7893785a 100644 --- a/apps/app/src/components/secondary-panel/sidebarSplitLayout.test.ts +++ b/apps/app/src/components/secondary-panel/sidebarSplitLayout.test.ts @@ -333,6 +333,40 @@ describe("sidebar split layout", () => { ).toContain("terminal-a"); }); + it("restores closed tabs to their canonical order", () => { + const firstTabId = "browser:first"; + const secondTabId = "browser:second"; + let state = createSidebarSplitState( + [SIDEBAR_FIXED_INFO_TAB_ID, firstTabId, secondTabId], + secondTabId, + ); + + state = reconcileSidebarSplitState( + state, + [SIDEBAR_FIXED_INFO_TAB_ID, secondTabId], + secondTabId, + ); + state = reconcileSidebarSplitState( + state, + [SIDEBAR_FIXED_INFO_TAB_ID], + SIDEBAR_FIXED_INFO_TAB_ID, + ); + state = reconcileSidebarSplitState( + state, + [SIDEBAR_FIXED_INFO_TAB_ID, secondTabId], + secondTabId, + ); + state = reconcileSidebarSplitState( + state, + [SIDEBAR_FIXED_INFO_TAB_ID, firstTabId, secondTabId], + firstTabId, + ); + + expect( + getSidebarGroupForPane(state, state.layout.focusedPaneId)?.tabIds, + ).toEqual([SIDEBAR_FIXED_INFO_TAB_ID, firstTabId, secondTabId]); + }); + it("keeps a New Tab replacement in its existing split pane", () => { const newTabId = "new-tab:launcher"; const terminalTabId = "terminal:term-a:none"; diff --git a/apps/app/src/components/secondary-panel/sidebarSplitLayout.ts b/apps/app/src/components/secondary-panel/sidebarSplitLayout.ts index 8b353b57db..8834dca5f1 100644 --- a/apps/app/src/components/secondary-panel/sidebarSplitLayout.ts +++ b/apps/app/src/components/secondary-panel/sidebarSplitLayout.ts @@ -171,6 +171,34 @@ function preserveSidebarSplitStateIdentity( return areSidebarSplitStatesEqual(current, next) ? current : next; } +function insertMissingTabsInAvailableOrder( + tabIds: readonly string[], + missingTabIds: readonly string[], + availableTabIds: readonly string[], +): string[] { + const next = [...tabIds]; + for (const missingTabId of missingTabIds) { + const availableIndex = availableTabIds.indexOf(missingTabId); + const followingTabId = availableTabIds + .slice(availableIndex + 1) + .find((tabId) => next.includes(tabId)); + if (followingTabId !== undefined) { + next.splice(next.indexOf(followingTabId), 0, missingTabId); + continue; + } + const precedingTabId = availableTabIds + .slice(0, availableIndex) + .reverse() + .find((tabId) => next.includes(tabId)); + const insertAt = + precedingTabId === undefined + ? next.length + : next.indexOf(precedingTabId) + 1; + next.splice(insertAt, 0, missingTabId); + } + return next; +} + export function isCanonicalSidebarSplitState( state: SidebarSplitState, availableTabIds: readonly string[], @@ -610,7 +638,11 @@ export function reconcileSidebarSplitState( ...next.groups, [focusedGroup.id]: { ...focusedGroup, - tabIds: [...focusedGroup.tabIds, ...missing], + tabIds: insertMissingTabsInAvailableOrder( + focusedGroup.tabIds, + missing, + available, + ), activeTabId: focusedGroup.tabIds.length === 0 ? activeTabId diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts index 21f7be7336..dc5b6676ad 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts @@ -61,6 +61,16 @@ function renderThreadHook(hook: () => Result) { return renderHook(hook, { wrapper: QueryWrapper }); } +function createDeferred() { + let resolve = (_value: T) => { + throw new Error("Deferred promise was not initialized"); + }; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + function terminalSession(overrides: TerminalSessionOverrides): TerminalSession { return { id: "term_1", @@ -184,10 +194,13 @@ describe("useThreadFileTabs recently closed tabs", () => { }); it("skips storage history with a deleted path or different owner", () => { - let storageFiles: readonly { path: string }[] = [ - { path: "available.md" }, - { path: "deleted.md" }, - ]; + let storageFiles = { + files: [ + { name: "available.md", path: "available.md" }, + { name: "deleted.md", path: "deleted.md" }, + ], + truncated: false, + }; const { result, rerender } = renderThreadHook(() => useThreadFileTabs({ panelStateId: "recently-closed-storage", @@ -225,7 +238,10 @@ describe("useThreadFileTabs recently closed tabs", () => { result.current.closeTab(deletedTabId); }); act(() => { - storageFiles = [{ path: "available.md" }]; + storageFiles = { + files: [{ name: "available.md", path: "available.md" }], + truncated: false, + }; rerender(); }); @@ -243,6 +259,154 @@ describe("useThreadFileTabs recently closed tabs", () => { expect(didReopen).toBe(false); }); + it("does not consume or transiently restore storage history before exact validation", async () => { + const validation = createDeferred(); + const storageFileExists = vi.fn(() => validation.promise); + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "recently-closed-storage-loading", + syncThreadId: "thr_current", + environmentId: "env_1", + storageFileExists, + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + let storageTabId = ""; + act(() => { + storageTabId = + result.current.openTab({ + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: "still-here.md" }, + })?.id ?? ""; + }); + act(() => result.current.closeTab(storageTabId)); + + let didHandle = false; + act(() => { + didHandle = result.current.reopenClosedTab(); + }); + expect(didHandle).toBe(true); + expect(result.current.orderedSecondaryFileTabs).toHaveLength(0); + expect(storageFileExists).toHaveBeenCalledWith("still-here.md"); + + await act(async () => { + validation.resolve(true); + await validation.promise; + await Promise.resolve(); + }); + expect(result.current.activeStorageFilePath).toBe("still-here.md"); + }); + + it("checks a path omitted from a truncated inventory and skips it when deleted", async () => { + const storageFileExists = vi.fn(async () => false); + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "recently-closed-storage-truncated", + syncThreadId: "thr_current", + environmentId: "env_1", + storageFileExists, + storageFiles: { files: [], truncated: true }, + terminalSessions: undefined, + }), + ); + + let browserTabId = ""; + let storageTabId = ""; + act(() => { + browserTabId = + result.current.openTab({ + kind: "browser", + url: "https://fallback.example", + })?.id ?? ""; + storageTabId = + result.current.openTab({ + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: "deleted-after-close.md" }, + })?.id ?? ""; + }); + act(() => { + result.current.closeTab(browserTabId); + result.current.closeTab(storageTabId); + }); + act(() => { + result.current.reopenClosedTab(); + }); + + await waitFor(() => { + expect(result.current.activeBrowserTab?.id).toBe(browserTabId); + }); + expect(storageFileExists).toHaveBeenCalledWith("deleted-after-close.md"); + expect(result.current.activeStorageFilePath).toBeNull(); + }); + + it("restores a valid path omitted from a truncated inventory", async () => { + const storageFileExists = vi.fn(async () => true); + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "recently-closed-storage-truncated-valid", + syncThreadId: "thr_current", + environmentId: "env_1", + storageFileExists, + storageFiles: { files: [], truncated: true }, + terminalSessions: undefined, + }), + ); + + let storageTabId = ""; + act(() => { + storageTabId = + result.current.openTab({ + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: "after-page-one.md" }, + })?.id ?? ""; + }); + act(() => result.current.closeTab(storageTabId)); + act(() => { + result.current.reopenClosedTab(); + }); + + await waitFor(() => { + expect(result.current.activeStorageFilePath).toBe("after-page-one.md"); + }); + expect(storageFileExists).toHaveBeenCalledWith("after-page-one.md"); + }); + + it("keeps an open storage tab when the inventory is truncated", () => { + const threadId = "storage-truncated-open-tab"; + const storageTab = createThreadStorageFilePreviewFixedPanelTab({ + environmentId: "env_1", + isPinned: false, + tab: { lineRange: null, path: "after-page-one.md" }, + threadId, + }); + const state = createEmptyFixedPanelTabsState({ + secondary: { + activeTabId: storageTab.id, + isOpen: true, + tabs: [storageTab], + }, + lastUsedAt: Date.now(), + }); + window.localStorage.setItem( + getFixedPanelTabsStateStorageKey({ threadId }), + serializeFixedPanelTabsState({ state }), + ); + + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: threadId, + syncThreadId: threadId, + environmentId: "env_1", + storageFiles: { files: [], truncated: true }, + terminalSessions: undefined, + }), + ); + + expect(result.current.activeStorageFilePath).toBe("after-page-one.md"); + }); + it.each([ { changedContext: { @@ -1043,7 +1207,10 @@ describe("useThreadFileTabs file opener diversion", () => { panelStateId: "opener-storage-search", syncThreadId: "thr_storage_search", environmentId: "env_1", - storageFiles: [{ path: "artifacts/notes.md" }], + storageFiles: { + files: [{ name: "notes.md", path: "artifacts/notes.md" }], + truncated: false, + }, terminalSessions: undefined, }), ); diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts index 37d2bf18ee..3e1c30ed42 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts @@ -1,5 +1,8 @@ -import { useCallback, useEffect, useMemo } from "react"; -import type { TerminalSession } from "@bb/server-contract"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { + TerminalSession, + ThreadStorageFileListResponse, +} from "@bb/server-contract"; import { useFixedPanelTabsState, useUpdateFixedPanelTabsState, @@ -13,6 +16,7 @@ import { createWorkspaceFilePreviewFixedPanelTab, type BrowserFixedPanelTab, type FixedPanelTab, + type FixedPanelTabsState, type HostFilePreviewFixedPanelTab, type NewTabFixedPanelTab, type PluginPanelFixedPanelTab, @@ -67,14 +71,13 @@ interface UseThreadFileTabsParams { projectHostId?: string | null; projectId?: string | null; retainedTerminalId?: string | null; - storageFiles: readonly ThreadStorageFileListItem[] | undefined; + storageFileExists?: (path: string) => Promise; + storageFiles: + | Pick + | undefined; terminalSessions: readonly TerminalSession[] | undefined; } -interface ThreadStorageFileListItem { - path: string; -} - interface FileSearchWorkspaceSelection { source: "workspace"; path: string; @@ -157,6 +160,27 @@ interface IsReopenablePanelTabOwnedByContextArgs { tab: ReopenableSecondaryPanelTab; } +interface StorageFileInventory { + knownPaths: ReadonlySet; + truncated: boolean; +} + +type RecentlyClosedPanelTabAvailability = + | "available" + | "missing" + | "unresolved"; + +type TakeClosedPanelTabResult = + | { kind: "available"; entry: RecentlyClosedPanelTab } + | { kind: "unresolved"; entry: RecentlyClosedPanelTab } + | { kind: "empty" }; + +function isRecentlyClosedPanelTab( + entry: RecentlyClosedPanelTab | null, +): entry is RecentlyClosedPanelTab { + return entry !== null; +} + type RecentlyClosedPanelContextKey = string; type OpenResolvedTabBehavior = "open" | "replace-new-tab"; @@ -200,15 +224,17 @@ function rememberClosedPanelTab( function forgetClosedPanelTab( contextKey: RecentlyClosedPanelContextKey, tabId: string, -): void { +): boolean { const stack = recentlyClosedPanelTabs.get(contextKey); - if (stack === undefined) return; + if (stack === undefined) return false; + const wasTop = stack.at(-1)?.tab.id === tabId; const next = stack.filter((entry) => entry.tab.id !== tabId); if (next.length === 0) { recentlyClosedPanelTabs.delete(contextKey); - return; + return wasTop; } recentlyClosedPanelTabs.set(contextKey, next); + return wasTop; } function buildRecentlyClosedPanelContextKey( @@ -248,40 +274,55 @@ function isReopenablePanelTabOwnedByContext({ } } -function isRecentlyClosedPanelTabAvailable( +function storagePathForRecentlyClosedPanelTab( tab: ReopenableSecondaryPanelTab, - knownStoragePaths: ReadonlySet | null, -): boolean { +): string | null { const originalTab = tab.kind === "plugin-panel" ? createFileOpenerOriginalTab(tab) : null; const resourceTab = originalTab ?? tab; - return ( - resourceTab.kind !== "thread-storage-file-preview" || - knownStoragePaths === null || - knownStoragePaths.has(resourceTab.path) - ); + return resourceTab.kind === "thread-storage-file-preview" + ? resourceTab.path + : null; +} + +function recentlyClosedPanelTabAvailability( + tab: ReopenableSecondaryPanelTab, + storageInventory: StorageFileInventory | null, +): RecentlyClosedPanelTabAvailability { + const storagePath = storagePathForRecentlyClosedPanelTab(tab); + if (storagePath === null) return "available"; + if (storageInventory === null) return "unresolved"; + if (storageInventory.knownPaths.has(storagePath)) return "available"; + return storageInventory.truncated ? "unresolved" : "missing"; } function takeClosedPanelTab( contextKey: RecentlyClosedPanelContextKey, openTabIds: ReadonlySet, - isEntryValid?: (entry: RecentlyClosedPanelTab) => boolean, -): RecentlyClosedPanelTab | null { + availability: ( + entry: RecentlyClosedPanelTab, + ) => RecentlyClosedPanelTabAvailability, +): TakeClosedPanelTabResult { const stack = recentlyClosedPanelTabs.get(contextKey); - if (stack === undefined) return null; + if (stack === undefined) return { kind: "empty" }; while (stack.length > 0) { - const entry = stack.pop(); - if ( - entry !== undefined && - !openTabIds.has(entry.tab.id) && - (isEntryValid === undefined || isEntryValid(entry)) - ) { - if (stack.length === 0) recentlyClosedPanelTabs.delete(contextKey); - return entry; + const entry = stack.at(-1); + if (entry === undefined) break; + if (openTabIds.has(entry.tab.id)) { + stack.pop(); + continue; } + const entryAvailability = availability(entry); + if (entryAvailability === "unresolved") { + return { kind: "unresolved", entry }; + } + stack.pop(); + if (entryAvailability === "missing") continue; + if (stack.length === 0) recentlyClosedPanelTabs.delete(contextKey); + return { kind: "available", entry }; } recentlyClosedPanelTabs.delete(contextKey); - return null; + return { kind: "empty" }; } export function resetRecentlyClosedPanelTabsForTest(): void { @@ -401,6 +442,7 @@ export function useThreadFileTabs({ projectHostId = null, projectId = null, retainedTerminalId = null, + storageFileExists, storageFiles, terminalSessions, }: UseThreadFileTabsParams) { @@ -425,11 +467,14 @@ export function useThreadFileTabs({ const resolvedEnvironmentId = isPanelStateResolved ? environmentId : undefined; - const knownStoragePaths = useMemo( + const storageInventory = useMemo( () => storageFiles === undefined ? null - : new Set(storageFiles.map((file) => file.path)), + : { + knownPaths: new Set(storageFiles.files.map((file) => file.path)), + truncated: storageFiles.truncated, + }, [storageFiles], ); const recentlyClosedPanelContext = useMemo( @@ -458,6 +503,19 @@ export function useThreadFileTabs({ : buildRecentlyClosedPanelContextKey(recentlyClosedPanelContext), [recentlyClosedPanelContext], ); + const recentlyClosedPanelContextKeyRef = useRef( + recentlyClosedPanelContextKey, + ); + const pendingStorageValidationRef = useRef(null); + const isMountedRef = useRef(true); + recentlyClosedPanelContextKeyRef.current = recentlyClosedPanelContextKey; + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); useEffect(() => { if (!resolvedFileOwnerThreadId) return; @@ -552,12 +610,18 @@ export function useThreadFileTabs({ ]); useEffect(() => { - if (!isPanelStateResolved || knownStoragePaths === null) return; + if ( + !isPanelStateResolved || + storageInventory === null || + storageInventory.truncated + ) { + return; + } updateFixedPanelTabsState((state) => { const pruned = setPrunedSecondaryTabs({ activeTabId: state.secondary.activeTabId, tabs: pruneStorageTabs({ - knownPaths: knownStoragePaths, + knownPaths: storageInventory.knownPaths, tabs: state.secondary.tabs, threadId: resolvedFileOwnerThreadId, }), @@ -571,8 +635,8 @@ export function useThreadFileTabs({ }); }, [ isPanelStateResolved, - knownStoragePaths, resolvedFileOwnerThreadId, + storageInventory, updateFixedPanelTabsState, ]); @@ -724,33 +788,98 @@ export function useThreadFileTabs({ const reopenClosedTab = useCallback((): boolean => { if (recentlyClosedPanelContextKey === null) return false; - let didReopen = false; - updateFixedPanelTabsState((state) => { - const entry = takeClosedPanelTab( - recentlyClosedPanelContextKey, - new Set(state.secondary.tabs.map((tab) => tab.id)), - (entry) => - isRecentlyClosedPanelTabAvailable(entry.tab, knownStoragePaths), - ); - if (entry === null) return state; + const contextKey = recentlyClosedPanelContextKey; + + const restoreEntry = ( + state: FixedPanelTabsState, + entry: RecentlyClosedPanelTab, + ) => { const index = Math.max( 0, Math.min(entry.index, state.secondary.tabs.length), ); const tabs = [...state.secondary.tabs]; tabs.splice(index, 0, entry.tab); - didReopen = true; return setSecondaryPanelTabsInState({ activeTabId: entry.tab.id, isOpen: true, state, tabs, }); - }); - return didReopen; + }; + + const attemptReopen = (): boolean => { + let didReopen = false; + let unresolvedEntry: RecentlyClosedPanelTab | null = null; + updateFixedPanelTabsState((state) => { + const result = takeClosedPanelTab( + contextKey, + new Set(state.secondary.tabs.map((tab) => tab.id)), + (entry) => + recentlyClosedPanelTabAvailability(entry.tab, storageInventory), + ); + if (result.kind === "empty") return state; + if (result.kind === "unresolved") { + unresolvedEntry = result.entry; + return state; + } + didReopen = true; + return restoreEntry(state, result.entry); + }); + if (didReopen) return true; + if ( + !isRecentlyClosedPanelTab(unresolvedEntry) || + storageFileExists === undefined + ) { + return false; + } + + const entry = unresolvedEntry; + const storagePath = storagePathForRecentlyClosedPanelTab(entry.tab); + if (storagePath === null) return false; + const validationKey = `${contextKey}:${entry.tab.id}`; + if (pendingStorageValidationRef.current === validationKey) return true; + pendingStorageValidationRef.current = validationKey; + void storageFileExists(storagePath) + .then((exists) => { + if ( + !isMountedRef.current || + recentlyClosedPanelContextKeyRef.current !== contextKey || + pendingStorageValidationRef.current !== validationKey + ) { + return; + } + pendingStorageValidationRef.current = null; + if (!exists) { + const wasTop = forgetClosedPanelTab(contextKey, entry.tab.id); + if (wasTop) attemptReopen(); + return; + } + updateFixedPanelTabsState((state) => { + const result = takeClosedPanelTab( + contextKey, + new Set(state.secondary.tabs.map((tab) => tab.id)), + (candidate) => + candidate.tab.id === entry.tab.id ? "available" : "unresolved", + ); + return result.kind === "available" + ? restoreEntry(state, result.entry) + : state; + }); + }) + .catch(() => { + if (pendingStorageValidationRef.current === validationKey) { + pendingStorageValidationRef.current = null; + } + }); + return true; + }; + + return attemptReopen(); }, [ - knownStoragePaths, recentlyClosedPanelContextKey, + storageFileExists, + storageInventory, updateFixedPanelTabsState, ]); diff --git a/apps/app/src/components/secondary-panel/useThreadStorageViewer.ts b/apps/app/src/components/secondary-panel/useThreadStorageViewer.ts index dfd2538668..aadec29e0b 100644 --- a/apps/app/src/components/secondary-panel/useThreadStorageViewer.ts +++ b/apps/app/src/components/secondary-panel/useThreadStorageViewer.ts @@ -1,4 +1,6 @@ +import { useCallback } from "react"; import type { FixedPanelTab } from "@/lib/fixed-panel-tabs-state"; +import { sdk } from "@/lib/sdk"; import { DEFAULT_THREAD_STORAGE_FILE_LIST_OPTIONS } from "@/lib/thread-storage-files"; import { useThreadStorageFiles } from "../../hooks/queries/thread-queries"; @@ -24,8 +26,21 @@ export function useThreadStorageViewer({ enabled: hasThread && fileListEnabled, }, ); + const checkThreadStorageFileExists = useCallback( + async (path: string): Promise => { + if (!threadId) return false; + const result = await sdk.threads.storageFiles({ + limit: "1", + query: path, + threadId, + }); + return result.files.some((file) => file.path === path); + }, + [threadId], + ); return { + checkThreadStorageFileExists, isThreadStorageFilesLoading, threadStorageFilesError, threadStorageFiles, diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index e45aa5b3cc..f86d415bc5 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -950,16 +950,17 @@ function RootComposeSurface({ : rootPanelHostPathTerminalTarget, [rootPanelEnvironmentId, rootPanelHostPathTerminalTarget], ); - const { threadStorageFiles: rootThreadStorageFiles } = useThreadStorageViewer( - { - fileListEnabled: shouldLoadThreadStorageFileList({ - hasThread: rootPanelThreadId !== null, - isSecondaryPanelOpen, - secondaryTabs: fixedPanelTabsState.secondary.tabs, - }), - threadId: rootPanelThreadId ?? undefined, - }, - ); + const { + checkThreadStorageFileExists: checkRootThreadStorageFileExists, + threadStorageFiles: rootThreadStorageFiles, + } = useThreadStorageViewer({ + fileListEnabled: shouldLoadThreadStorageFileList({ + hasThread: rootPanelThreadId !== null, + isSecondaryPanelOpen, + secondaryTabs: fixedPanelTabsState.secondary.tabs, + }), + threadId: rootPanelThreadId ?? undefined, + }); const environmentTerminalsListQuery = useEnvironmentTerminals( rootPanelEnvironmentId ?? "", { @@ -1031,7 +1032,8 @@ function RootComposeSurface({ projectHostId: rootProjectHostId, projectId: isProjectless ? null : projectId, retainedTerminalId, - storageFiles: rootThreadStorageFiles?.files, + storageFileExists: checkRootThreadStorageFileExists, + storageFiles: rootThreadStorageFiles, terminalSessions: loadedTerminalSessions, }); const rootPluginPanelActions = usePluginNewThreadPanelActions({ diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 943adb42ce..a6e810b75b 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -656,6 +656,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { secondaryTabs: fixedPanelTabsState.secondary.tabs, }); const { + checkThreadStorageFileExists, isThreadStorageFilesLoading, refetchThreadStorageFiles, threadStorageFiles, @@ -690,7 +691,8 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { syncThreadId: threadId, environmentId: thread?.environmentId, retainedTerminalId, - storageFiles: threadStorageFiles?.files, + storageFileExists: checkThreadStorageFileExists, + storageFiles: threadStorageFiles, terminalSessions: terminalsListQuery.data?.sessions, }); const pluginPanelActions = usePluginPanelActions({ diff --git a/apps/desktop/src/desktop-browser-view.ts b/apps/desktop/src/desktop-browser-view.ts index a9b1f8059a..637a8c57d1 100644 --- a/apps/desktop/src/desktop-browser-view.ts +++ b/apps/desktop/src/desktop-browser-view.ts @@ -157,6 +157,7 @@ export interface DesktopBrowserViewManager { ): void; beginWindowResize(hostWindow: DesktopBrowserHostWindow): void; endWindowResize(hostWindow: DesktopBrowserHostWindow): void; + prepareWindowReload(hostWindow: DesktopBrowserHostWindow): void; releaseWindow(hostWebContentsId: number): void; destroyAll(): void; } @@ -805,6 +806,17 @@ export function createDesktopBrowserViewManager( }); } }, + prepareWindowReload(hostWindow) { + resizingHostIds.delete(hostWindow.webContents.id); + const prefix = `${hostWindow.webContents.id}:`; + for (const [key, entry] of entries.entries()) { + if (!key.startsWith(prefix) || entry.view.webContents.isDestroyed()) { + continue; + } + entry.visible = false; + applyEntryVisibility(entry, hostWindow); + } + }, releaseWindow(hostWebContentsId) { resizingHostIds.delete(hostWebContentsId); const prefix = `${hostWebContentsId}:`; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 89bfc9082c..cae2675e5a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -475,6 +475,10 @@ function registerApplicationRendererReloadShortcut( return; } event.preventDefault(); + const browserWindow = resolveApplicationWindow(webContents); + if (browserWindow !== null) { + desktopBrowserViewManager?.prepareWindowReload(browserWindow); + } if (shortcut === "force-reload") { webContents.reloadIgnoringCache(); } else { @@ -765,6 +769,7 @@ function installCurrentApplicationMenu(): void { if (!(browserWindow instanceof BrowserWindow)) { return; } + desktopBrowserViewManager?.prepareWindowReload(browserWindow); if (ignoreCache) { browserWindow.webContents.reloadIgnoringCache(); } else { diff --git a/apps/desktop/test/desktop-browser-main-ipc.test.ts b/apps/desktop/test/desktop-browser-main-ipc.test.ts index 211164f83b..4e7fe9594b 100644 --- a/apps/desktop/test/desktop-browser-main-ipc.test.ts +++ b/apps/desktop/test/desktop-browser-main-ipc.test.ts @@ -125,6 +125,8 @@ class RecordingDesktopBrowserViewManager implements DesktopBrowserViewManager { this.beginWindowResizeCalls.push(hostWindow); } + prepareWindowReload(): void {} + destroyAll(): void { this.destroyAllCalls.push("destroyAll"); } diff --git a/apps/desktop/test/desktop-browser-view-manager.test.ts b/apps/desktop/test/desktop-browser-view-manager.test.ts index 277a0abbc8..30880b290f 100644 --- a/apps/desktop/test/desktop-browser-view-manager.test.ts +++ b/apps/desktop/test/desktop-browser-view-manager.test.ts @@ -1597,6 +1597,50 @@ describe("DesktopBrowserViewManager", () => { expect(focusedView.webContents.focusCalls).toBe(1); }); + it("hides only the reloading window's browser views until they reattach", () => { + const manager = createDesktopBrowserViewManager({ + partition: "persist:test", + }); + const reloadingWindow = new FakeHostWindow({ + contentBounds: { width: 900, height: 600 }, + webContentsId: 83, + }); + const otherWindow = new FakeHostWindow({ + contentBounds: { width: 900, height: 600 }, + webContentsId: 84, + }); + attachBrowserTab({ + manager, + hostWindow: reloadingWindow, + tabId: "browser:reloading", + url: "https://example.com/reloading", + }); + attachBrowserTab({ + manager, + hostWindow: otherWindow, + tabId: "browser:other", + url: "https://example.com/other", + }); + const reloadingView = requireFakeView(0); + const otherView = requireFakeView(1); + + manager.prepareWindowReload(reloadingWindow); + + expect(reloadingView.visible).toBe(false); + expect(otherView.visible).toBe(true); + manager.attach({ + hostWindow: reloadingWindow, + request: { + tabId: "browser:reloading", + url: "https://example.com/reloading", + bounds: { x: 100, y: 50, width: 500, height: 350 }, + visible: true, + }, + }); + expect(electronMock.fakeViews).toHaveLength(2); + expect(reloadingView.visible).toBe(true); + }); + it("allows clipboard-sanitized-write but denies clipboard-read and device permissions", () => { expect(isAllowedBrowserPermission("clipboard-sanitized-write")).toBe(true); expect(isAllowedBrowserPermission("clipboard-read")).toBe(false); From a2dcee60bda518fcdf8bc7b8509d245417d407dc Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 29 Aug 2026 01:39:47 -0700 Subject: [PATCH 8/9] Fix closed tab history type boundaries --- .../secondary-panel/useThreadFileTabs.test.ts | 4 +-- .../secondary-panel/useThreadFileTabs.ts | 31 ++++++++++--------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts index dc5b6676ad..a3bf820d68 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts @@ -62,9 +62,7 @@ function renderThreadHook(hook: () => Result) { } function createDeferred() { - let resolve = (_value: T) => { - throw new Error("Deferred promise was not initialized"); - }; + let resolve!: (value: T) => void; const promise = new Promise((nextResolve) => { resolve = nextResolve; }); diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts index 3e1c30ed42..ada31526db 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts @@ -1,4 +1,10 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, +} from "react"; import type { TerminalSession, ThreadStorageFileListResponse, @@ -175,12 +181,6 @@ type TakeClosedPanelTabResult = | { kind: "unresolved"; entry: RecentlyClosedPanelTab } | { kind: "empty" }; -function isRecentlyClosedPanelTab( - entry: RecentlyClosedPanelTab | null, -): entry is RecentlyClosedPanelTab { - return entry !== null; -} - type RecentlyClosedPanelContextKey = string; type OpenResolvedTabBehavior = "open" | "replace-new-tab"; @@ -508,7 +508,11 @@ export function useThreadFileTabs({ ); const pendingStorageValidationRef = useRef(null); const isMountedRef = useRef(true); - recentlyClosedPanelContextKeyRef.current = recentlyClosedPanelContextKey; + + useLayoutEffect(() => { + recentlyClosedPanelContextKeyRef.current = recentlyClosedPanelContextKey; + pendingStorageValidationRef.current = null; + }, [recentlyClosedPanelContextKey]); useEffect(() => { isMountedRef.current = true; @@ -810,7 +814,7 @@ export function useThreadFileTabs({ const attemptReopen = (): boolean => { let didReopen = false; - let unresolvedEntry: RecentlyClosedPanelTab | null = null; + const unresolvedEntries: RecentlyClosedPanelTab[] = []; updateFixedPanelTabsState((state) => { const result = takeClosedPanelTab( contextKey, @@ -820,21 +824,18 @@ export function useThreadFileTabs({ ); if (result.kind === "empty") return state; if (result.kind === "unresolved") { - unresolvedEntry = result.entry; + unresolvedEntries.push(result.entry); return state; } didReopen = true; return restoreEntry(state, result.entry); }); if (didReopen) return true; - if ( - !isRecentlyClosedPanelTab(unresolvedEntry) || - storageFileExists === undefined - ) { + const entry = unresolvedEntries.at(0); + if (entry === undefined || storageFileExists === undefined) { return false; } - const entry = unresolvedEntry; const storagePath = storagePathForRecentlyClosedPanelTab(entry.tab); if (storagePath === null) return false; const validationKey = `${contextKey}:${entry.tab.id}`; From e82f32cb5d961299d69be7f2f1bfde80b49a1270 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 29 Aug 2026 01:50:41 -0700 Subject: [PATCH 9/9] Preserve restored tab placement --- .../secondary-panel/SidebarSplitContainer.tsx | 25 +++++- .../sidebarSplitLayout.test.ts | 29 +++++-- .../secondary-panel/sidebarSplitLayout.ts | 77 +++++++++++++++++++ 3 files changed, 123 insertions(+), 8 deletions(-) diff --git a/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx b/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx index d243fc7521..aa9babb980 100644 --- a/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx +++ b/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx @@ -33,6 +33,7 @@ import { createSidebarSplitState, focusSidebarPane, getSidebarGroupForPane, + getSidebarTabPlacement, isCanonicalSidebarSplitState, moveSidebarPaneToSide, moveSidebarTab, @@ -43,6 +44,7 @@ import { reorderSidebarTab, replaceSidebarTab, resizeSidebarSplit, + restoreSidebarTabPlacement, selectSidebarTab, serializeSidebarSplitState, setSidebarPaneMaximized, @@ -50,6 +52,7 @@ import { sidebarSplitStorageKey, toggleSidebarPaneMaximize, type SidebarSplitState, + type SidebarTabPlacement, type SidebarTabGroup, } from "./sidebarSplitLayout"; import type { SecondaryPanelTabReorderRequest } from "./secondaryPanelTab"; @@ -131,6 +134,8 @@ export function SidebarSplitContainer({ value: initialStorageValue, }); const previousActiveTabId = useRef(activeTabId); + const previousAvailableTabIds = useRef(availableTabIds); + const removedTabPlacements = useRef(new Map()); const previousFullScreen = useRef(isFullScreen); const dimsInactiveSplits = useAtomValue(dimInactiveSplitsAtom); const [resizeCursor, setResizeCursor] = @@ -146,20 +151,38 @@ export function SidebarSplitContainer({ useEffect(() => { const previousExternalActiveTabId = previousActiveTabId.current; + const previousAvailable = previousAvailableTabIds.current; const shouldFollowExternalSelection = previousExternalActiveTabId !== activeTabId; previousActiveTabId.current = activeTabId; + previousAvailableTabIds.current = availableTabIds; const current = stateRef.current; + const availableTabIdSet = new Set(availableTabIds); + for (const tabId of previousAvailable) { + if (availableTabIdSet.has(tabId)) continue; + const placement = getSidebarTabPlacement(current, tabId); + if (placement !== null) { + removedTabPlacements.current.set(tabId, placement); + } + } const withActiveTabReplacement = shouldFollowExternalSelection && !availableTabIds.includes(previousExternalActiveTabId) ? replaceSidebarTab(current, previousExternalActiveTabId, activeTabId) : current; - const reconciled = reconcileSidebarSplitState( + let reconciled = reconcileSidebarSplitState( withActiveTabReplacement, availableTabIds, activeTabId, ); + const previousAvailableTabIdSet = new Set(previousAvailable); + for (const tabId of availableTabIds) { + if (previousAvailableTabIdSet.has(tabId)) continue; + const placement = removedTabPlacements.current.get(tabId); + if (placement === undefined) continue; + reconciled = restoreSidebarTabPlacement(reconciled, tabId, placement); + removedTabPlacements.current.delete(tabId); + } const activePane = shouldFollowExternalSelection ? listPanes(reconciled.layout.root).find((pane) => getSidebarGroupForPane(reconciled, pane.paneId)?.tabIds.includes( diff --git a/apps/app/src/components/secondary-panel/sidebarSplitLayout.test.ts b/apps/app/src/components/secondary-panel/sidebarSplitLayout.test.ts index 0d7893785a..0e253e06d1 100644 --- a/apps/app/src/components/secondary-panel/sidebarSplitLayout.test.ts +++ b/apps/app/src/components/secondary-panel/sidebarSplitLayout.test.ts @@ -10,6 +10,7 @@ import { SIDEBAR_FIXED_DIFF_TAB_ID, SIDEBAR_FIXED_INFO_TAB_ID, createSidebarSplitState, + getSidebarTabPlacement, focusSidebarPane, getSidebarGroupForPane, isCanonicalSidebarSplitState, @@ -20,6 +21,7 @@ import { reconcileSidebarSplitState, removeSidebarSplit, reorderSidebarTab, + restoreSidebarTabPlacement, replaceSidebarTab, resizeSidebarSplit, selectSidebarTab, @@ -333,33 +335,46 @@ describe("sidebar split layout", () => { ).toContain("terminal-a"); }); - it("restores closed tabs to their canonical order", () => { + it("restores closed tabs to their prior visible order", () => { const firstTabId = "browser:first"; const secondTabId = "browser:second"; let state = createSidebarSplitState( [SIDEBAR_FIXED_INFO_TAB_ID, firstTabId, secondTabId], secondTabId, ); + const firstPlacement = getSidebarTabPlacement(state, firstTabId); + if (firstPlacement === null) throw new Error("Missing first tab placement"); state = reconcileSidebarSplitState( state, [SIDEBAR_FIXED_INFO_TAB_ID, secondTabId], secondTabId, ); + const secondPlacement = getSidebarTabPlacement(state, secondTabId); + if (secondPlacement === null) + throw new Error("Missing second tab placement"); state = reconcileSidebarSplitState( state, [SIDEBAR_FIXED_INFO_TAB_ID], SIDEBAR_FIXED_INFO_TAB_ID, ); - state = reconcileSidebarSplitState( - state, - [SIDEBAR_FIXED_INFO_TAB_ID, secondTabId], + state = restoreSidebarTabPlacement( + reconcileSidebarSplitState( + state, + [SIDEBAR_FIXED_INFO_TAB_ID, secondTabId], + secondTabId, + ), secondTabId, + secondPlacement, ); - state = reconcileSidebarSplitState( - state, - [SIDEBAR_FIXED_INFO_TAB_ID, firstTabId, secondTabId], + state = restoreSidebarTabPlacement( + reconcileSidebarSplitState( + state, + [SIDEBAR_FIXED_INFO_TAB_ID, secondTabId, firstTabId], + firstTabId, + ), firstTabId, + firstPlacement, ); expect( diff --git a/apps/app/src/components/secondary-panel/sidebarSplitLayout.ts b/apps/app/src/components/secondary-panel/sidebarSplitLayout.ts index 8834dca5f1..8ccfa4f7cd 100644 --- a/apps/app/src/components/secondary-panel/sidebarSplitLayout.ts +++ b/apps/app/src/components/secondary-panel/sidebarSplitLayout.ts @@ -53,6 +53,13 @@ export interface SidebarSplitState { maximizedPaneId: string | null; } +export interface SidebarTabPlacement { + followingTabId: string | null; + groupId: string; + index: number; + precedingTabId: string | null; +} + interface SidebarSplitIds { groupId: string; paneId: string; @@ -171,6 +178,76 @@ function preserveSidebarSplitStateIdentity( return areSidebarSplitStatesEqual(current, next) ? current : next; } +export function getSidebarTabPlacement( + state: SidebarSplitState, + tabId: string, +): SidebarTabPlacement | null { + const group = Object.values(state.groups).find((candidate) => + candidate.tabIds.includes(tabId), + ); + if (group === undefined) return null; + const index = group.tabIds.indexOf(tabId); + return { + followingTabId: group.tabIds[index + 1] ?? null, + groupId: group.id, + index, + precedingTabId: group.tabIds[index - 1] ?? null, + }; +} + +export function restoreSidebarTabPlacement( + state: SidebarSplitState, + tabId: string, + placement: SidebarTabPlacement, +): SidebarSplitState { + const currentGroup = Object.values(state.groups).find((group) => + group.tabIds.includes(tabId), + ); + if (currentGroup === undefined) return state; + const placedGroup = state.groups[placement.groupId]; + const targetGroup = + placedGroup !== undefined && + (placedGroup.id === currentGroup.id || currentGroup.tabIds.length > 1) + ? placedGroup + : currentGroup; + const groups = Object.fromEntries( + Object.entries(state.groups).map(([groupId, group]) => { + const tabIds = group.tabIds.filter((candidate) => candidate !== tabId); + return [ + groupId, + { + ...group, + tabIds, + activeTabId: + group.activeTabId === tabId + ? (tabIds[0] ?? targetGroup.activeTabId) + : group.activeTabId, + }, + ]; + }), + ); + const nextTargetGroup = groups[targetGroup.id]; + if (nextTargetGroup === undefined) return state; + const followingIndex = + placement.followingTabId === null + ? -1 + : nextTargetGroup.tabIds.indexOf(placement.followingTabId); + const precedingIndex = + placement.precedingTabId === null + ? -1 + : nextTargetGroup.tabIds.indexOf(placement.precedingTabId); + const insertAt = + followingIndex >= 0 + ? followingIndex + : precedingIndex >= 0 + ? precedingIndex + 1 + : Math.min(placement.index, nextTargetGroup.tabIds.length); + const tabIds = [...nextTargetGroup.tabIds]; + tabIds.splice(insertAt, 0, tabId); + groups[targetGroup.id] = { ...nextTargetGroup, tabIds }; + return { ...state, groups }; +} + function insertMissingTabsInAvailableOrder( tabIds: readonly string[], missingTabIds: readonly string[],