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/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 ad23efe953..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,6 +335,53 @@ describe("sidebar split layout", () => { ).toContain("terminal-a"); }); + 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 = restoreSidebarTabPlacement( + reconcileSidebarSplitState( + state, + [SIDEBAR_FIXED_INFO_TAB_ID, secondTabId], + secondTabId, + ), + secondTabId, + secondPlacement, + ); + state = restoreSidebarTabPlacement( + reconcileSidebarSplitState( + state, + [SIDEBAR_FIXED_INFO_TAB_ID, secondTabId, firstTabId], + firstTabId, + ), + firstTabId, + firstPlacement, + ); + + 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..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,104 @@ 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[], + 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 +715,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 11eb6a8cf9..a3bf820d68 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, @@ -58,6 +61,14 @@ function renderThreadHook(hook: () => Result) { return renderHook(hook, { wrapper: QueryWrapper }); } +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + function terminalSession(overrides: TerminalSessionOverrides): TerminalSession { return { id: "term_1", @@ -82,12 +93,472 @@ 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); + }); + + it("skips storage history with a deleted path or different owner", () => { + 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", + 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 = { + files: [{ name: "available.md", path: "available.md" }], + truncated: false, + }; + rerender(); + }); + + let didReopen = false; + 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); + }); + + 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: { + 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-context-order", + syncThreadId: null, + environmentId, + fileOwnerThreadId: "thr_1", + projectHostId: "host_1", + projectId: "proj_1", + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + 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(() => { + environmentId = "env_2"; + rerender(); + }); + openAndCloseWorkspaceFile("src/env-two.ts"); + act(() => { + environmentId = "env_1"; + rerender(); + }); + + let didReopen = false; + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(true); + expect(result.current.activeWorkspaceFilePath).toBe("src/env-one.ts"); + + act(() => { + 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"); + }); +}); + describe("useThreadFileTabs terminal pruning", () => { it("keeps root-compose file tabs local", () => { const { result } = renderThreadHook(() => @@ -734,7 +1205,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 ffb4c254f9..ada31526db 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts @@ -1,5 +1,14 @@ -import { useCallback, useEffect, useMemo } from "react"; -import type { TerminalSession } from "@bb/server-contract"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, +} from "react"; +import type { + TerminalSession, + ThreadStorageFileListResponse, +} from "@bb/server-contract"; import { useFixedPanelTabsState, useUpdateFixedPanelTabsState, @@ -13,6 +22,7 @@ import { createWorkspaceFilePreviewFixedPanelTab, type BrowserFixedPanelTab, type FixedPanelTab, + type FixedPanelTabsState, type HostFilePreviewFixedPanelTab, type NewTabFixedPanelTab, type PluginPanelFixedPanelTab, @@ -22,6 +32,7 @@ import { import { usePluginSlots } from "@/lib/plugin-slots"; import { useFileOpenerPreferenceValue } from "@/lib/file-opener-preference"; import { + createFileOpenerOriginalTab, createFileOpenerTabForRequest, fileOpenerIdFromActionId, parseFileOpenerParams, @@ -66,14 +77,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; @@ -133,8 +143,192 @@ type SecondaryPanelTab = | NewTabFixedPanelTab | PluginPanelFixedPanelTab; +type ReopenableSecondaryPanelTab = Exclude< + SecondaryPanelTab, + NewTabFixedPanelTab +>; + +interface RecentlyClosedPanelTab { + index: number; + tab: ReopenableSecondaryPanelTab; +} + +interface RecentlyClosedPanelContext { + environmentId: string | null | undefined; + fileOwnerThreadId: string | null; + panelStateId: string; + projectHostId: string | null; + projectId: string | null; +} + +interface IsReopenablePanelTabOwnedByContextArgs { + context: RecentlyClosedPanelContext; + 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" }; + +type RecentlyClosedPanelContextKey = string; type OpenResolvedTabBehavior = "open" | "replace-new-tab"; +const MAX_RECENTLY_CLOSED_PANEL_TABS = 25; +const recentlyClosedPanelTabs = new Map< + RecentlyClosedPanelContextKey, + RecentlyClosedPanelTab[] +>(); + +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( + contextKey: RecentlyClosedPanelContextKey, + entry: RecentlyClosedPanelTab, +): void { + 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(contextKey, stack); +} + +function forgetClosedPanelTab( + contextKey: RecentlyClosedPanelContextKey, + tabId: string, +): boolean { + const stack = recentlyClosedPanelTabs.get(contextKey); + 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 wasTop; + } + recentlyClosedPanelTabs.set(contextKey, next); + return wasTop; +} + +function buildRecentlyClosedPanelContextKey( + context: RecentlyClosedPanelContext, +): RecentlyClosedPanelContextKey { + return JSON.stringify(context); +} + +function isReopenablePanelTabOwnedByContext({ + context, + tab: reopenableTab, +}: IsReopenablePanelTabOwnedByContextArgs): boolean { + const originalTab = + reopenableTab.kind === "plugin-panel" + ? createFileOpenerOriginalTab(reopenableTab) + : null; + const tab = originalTab ?? reopenableTab; + switch (tab.kind) { + case "workspace-file-preview": + return ( + tab.environmentId === context.environmentId && + tab.projectId === + (context.environmentId === null ? context.projectId : null) + ); + case "host-file-preview": + return ( + tab.hostId !== null || + (tab.environmentId === context.environmentId && + tab.threadId === context.fileOwnerThreadId) + ); + case "thread-storage-file-preview": + return tab.threadId === context.fileOwnerThreadId; + case "browser": + return tab.environmentId === context.environmentId; + case "plugin-panel": + return true; + } +} + +function storagePathForRecentlyClosedPanelTab( + tab: ReopenableSecondaryPanelTab, +): string | null { + const originalTab = + tab.kind === "plugin-panel" ? createFileOpenerOriginalTab(tab) : null; + const resourceTab = originalTab ?? tab; + 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, + availability: ( + entry: RecentlyClosedPanelTab, + ) => RecentlyClosedPanelTabAvailability, +): TakeClosedPanelTabResult { + const stack = recentlyClosedPanelTabs.get(contextKey); + if (stack === undefined) return { kind: "empty" }; + while (stack.length > 0) { + 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 { kind: "empty" }; +} + +export function resetRecentlyClosedPanelTabsForTest(): void { + recentlyClosedPanelTabs.clear(); +} + function createStorageTab( environmentId: string | null, tab: ThreadStorageFileTabState, @@ -248,6 +442,7 @@ export function useThreadFileTabs({ projectHostId = null, projectId = null, retainedTerminalId = null, + storageFileExists, storageFiles, terminalSessions, }: UseThreadFileTabsParams) { @@ -260,8 +455,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 @@ -269,6 +467,59 @@ export function useThreadFileTabs({ const resolvedEnvironmentId = isPanelStateResolved ? environmentId : undefined; + const storageInventory = useMemo( + () => + storageFiles === undefined + ? null + : { + knownPaths: new Set(storageFiles.files.map((file) => file.path)), + truncated: storageFiles.truncated, + }, + [storageFiles], + ); + 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], + ); + const recentlyClosedPanelContextKeyRef = useRef( + recentlyClosedPanelContextKey, + ); + const pendingStorageValidationRef = useRef(null); + const isMountedRef = useRef(true); + + useLayoutEffect(() => { + recentlyClosedPanelContextKeyRef.current = recentlyClosedPanelContextKey; + pendingStorageValidationRef.current = null; + }, [recentlyClosedPanelContextKey]); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); useEffect(() => { if (!resolvedFileOwnerThreadId) return; @@ -363,13 +614,18 @@ export function useThreadFileTabs({ ]); useEffect(() => { - if (!isPanelStateResolved || !storageFiles) return; + if ( + !isPanelStateResolved || + storageInventory === null || + storageInventory.truncated + ) { + return; + } updateFixedPanelTabsState((state) => { - const knownPaths = new Set(storageFiles.map((file) => file.path)); const pruned = setPrunedSecondaryTabs({ activeTabId: state.secondary.activeTabId, tabs: pruneStorageTabs({ - knownPaths, + knownPaths: storageInventory.knownPaths, tabs: state.secondary.tabs, threadId: resolvedFileOwnerThreadId, }), @@ -384,7 +640,7 @@ export function useThreadFileTabs({ }, [ isPanelStateResolved, resolvedFileOwnerThreadId, - storageFiles, + storageInventory, updateFixedPanelTabsState, ]); @@ -442,6 +698,10 @@ export function useThreadFileTabs({ }); if (tab === null) return null; + if (recentlyClosedPanelContextKey !== null) { + forgetClosedPanelTab(recentlyClosedPanelContextKey, tab.id); + } + if ( request.kind === "workspace-file-preview" && request.tab.source.kind === "working-tree" @@ -468,6 +728,7 @@ export function useThreadFileTabs({ projectId, resolvedEnvironmentId, resolvedFileOwnerThreadId, + recentlyClosedPanelContextKey, updateFixedPanelTabsState, ], ); @@ -497,13 +758,132 @@ 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 && + recentlyClosedPanelContext !== null && + recentlyClosedPanelContextKey !== null && + tab !== undefined && + isReopenableSecondaryPanelTab(tab) && + isReopenablePanelTabOwnedByContext({ + context: recentlyClosedPanelContext, + tab, + }) + ) { + rememberClosedPanelTab(recentlyClosedPanelContextKey, { + index: tabIndex, + tab, + }); + } + return next; + }); }, - [updateFixedPanelTabsState], + [ + recentlyClosedPanelContext, + recentlyClosedPanelContextKey, + updateFixedPanelTabsState, + ], ); + const reopenClosedTab = useCallback((): boolean => { + if (recentlyClosedPanelContextKey === null) return false; + 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); + return setSecondaryPanelTabsInState({ + activeTabId: entry.tab.id, + isOpen: true, + state, + tabs, + }); + }; + + const attemptReopen = (): boolean => { + let didReopen = false; + const unresolvedEntries: RecentlyClosedPanelTab[] = []; + 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") { + unresolvedEntries.push(result.entry); + return state; + } + didReopen = true; + return restoreEntry(state, result.entry); + }); + if (didReopen) return true; + const entry = unresolvedEntries.at(0); + if (entry === undefined || storageFileExists === undefined) { + return false; + } + + 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(); + }, [ + recentlyClosedPanelContextKey, + storageFileExists, + storageInventory, + updateFixedPanelTabsState, + ]); + const openPluginPanel = useCallback( ({ pluginId, actionId, title, paramsJson }: OpenPluginPanelArgs) => { const tab = createPluginPanelFixedPanelTab({ @@ -512,6 +892,9 @@ export function useThreadFileTabs({ pluginId, title, }); + if (recentlyClosedPanelContextKey !== null) { + forgetClosedPanelTab(recentlyClosedPanelContextKey, tab.id); + } updateFixedPanelTabsState((state) => { const existing = findSecondaryPanelTab(state.secondary.tabs, tab.id); if (existing !== null && existing.kind === "plugin-panel") { @@ -527,7 +910,7 @@ export function useThreadFileTabs({ return replaceNewTabWithSecondaryPanelTabInState({ state, tab }); }); }, - [updateFixedPanelTabsState], + [recentlyClosedPanelContextKey, updateFixedPanelTabsState], ); const selectFileSearchResult = useCallback( @@ -680,6 +1063,7 @@ export function useThreadFileTabs({ openPluginPanel, openTab, orderedSecondaryFileTabs, + reopenClosedTab, reorderTab, selectFileSearchResult, updateBrowserTab, 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/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..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 ?? "", { @@ -1018,6 +1019,7 @@ function RootComposeSurface({ openPluginPanel, openTab, orderedSecondaryFileTabs, + reopenClosedTab, reorderTab, selectFileSearchResult, updateBrowserTab, @@ -1030,7 +1032,8 @@ function RootComposeSurface({ projectHostId: rootProjectHostId, projectId: isProjectless ? null : projectId, retainedTerminalId, - storageFiles: rootThreadStorageFiles?.files, + storageFileExists: checkRootThreadStorageFileExists, + storageFiles: rootThreadStorageFiles, terminalSessions: loadedTerminalSessions, }); const rootPluginPanelActions = usePluginNewThreadPanelActions({ @@ -1358,6 +1361,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 4da2acb8ae..2d901d3f62 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -658,6 +658,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { secondaryTabs: fixedPanelTabsState.secondary.tabs, }); const { + checkThreadStorageFileExists, isThreadStorageFilesLoading, refetchThreadStorageFiles, threadStorageFiles, @@ -683,6 +684,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { openTab, openPluginPanel, orderedSecondaryFileTabs, + reopenClosedTab, reorderTab, selectFileSearchResult, updateBrowserTab, @@ -691,7 +693,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({ @@ -1557,6 +1560,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-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/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..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 { @@ -741,6 +745,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) { @@ -755,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/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-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); 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..0f58d13d19 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") @@ -276,7 +294,6 @@ describe("app keybindings", () => { })), ).toEqual([ { desktopOnly: false, key: "Enter" }, - { desktopOnly: true, key: "t" }, ]); expect( assignedDefaultKeybindings.find( @@ -448,7 +465,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", diff --git a/packages/plugin-api-map/sdk-public-api.json b/packages/plugin-api-map/sdk-public-api.json index 7244f556c9..6991f03c82 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": "76c415f99e2336c4a2a17b4a7760e60004a38e279dcfa28025ae0c0fda763545" + "sha256": "fa4442250761eb37dc2cff041754f44277fbfae68afa35eb6743a8b252675720" }, "./ai-services": { "types": "bundled-types/bb-plugin-sdk-ai-services.d.ts",