diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index b6997554efbc..131932b15d6f 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -602,12 +602,12 @@ describe("rightPanelStore", () => { }); }); - it("closing the final terminal pane removes its surface and closes the panel", () => { + it("closing the final terminal pane removes its surface and keeps the panel open", () => { useRightPanelStore.getState().openTerminal(refA, "term-1"); useRightPanelStore.getState().closeTerminal(refA, "terminal:term-1", "term-1"); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ - isOpen: false, + isOpen: true, activeSurfaceId: null, surfaces: [], }); @@ -623,10 +623,52 @@ describe("rightPanelStore", () => { ); }); - it("closing the final surface closes the panel", () => { + it("closing the final surface keeps the panel open on the empty state", () => { useRightPanelStore.getState().openTerminal(refA, "term-1"); useRightPanelStore.getState().closeSurface(refA, "terminal:term-1"); + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: null, + surfaces: [], + }); + }); + + it("closing a file opened from the explorer keeps the panel open on the empty state", () => { + useRightPanelStore.getState().open(refA, "files"); + useRightPanelStore.getState().openFile(refA, "src/index.ts"); + useRightPanelStore.getState().closeSurface(refA, "file:src/index.ts"); + + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: null, + surfaces: [], + }); + }); + + it("closing the final surface of the pull-request list's shared panel closes it", () => { + const pullRequestsPanelRef = scopeThreadRef( + "env-1" as EnvironmentId, + ThreadId.make("pull-requests-panel"), + ); + const target = { projectId: "project-1", repository: "owner/repo", number: 1 }; + useRightPanelStore.getState().openPullRequest(pullRequestsPanelRef, target); + useRightPanelStore.getState().closeSurface(pullRequestsPanelRef, pullRequestSurfaceId(target)); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, pullRequestsPanelRef), + ).toEqual({ + isOpen: false, + activeSurfaceId: null, + surfaces: [], + }); + }); + + it("closing the final surface of a hidden panel leaves it hidden", () => { + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().close(refA); + useRightPanelStore.getState().closeSurface(refA, "terminal:term-1"); + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ isOpen: false, activeSurfaceId: null, @@ -670,14 +712,14 @@ describe("rightPanelStore", () => { }); }); - it("closing all surfaces closes the panel", () => { + it("closing all surfaces keeps the panel open on the empty state", () => { useRightPanelStore.getState().openBrowser(refA, "tab-a"); useRightPanelStore.getState().openFile(refA, "src/index.ts"); useRightPanelStore.getState().closeAllSurfaces(refA); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ - isOpen: false, + isOpen: true, activeSurfaceId: null, surfaces: [], }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 27d5ded5d272..766b1f0a6f36 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -76,6 +76,23 @@ const RIGHT_PANEL_STORAGE_VERSION = 11; */ const isPullRequestsPanelKey = (threadKey: string) => threadKey.endsWith(":pull-requests-panel"); +/** + * Closing tabs never hides a thread's panel. An open panel with no surfaces is a + * real state that renders the empty-state picker, and it is already the state the + * panel opens in on a thread that has no tabs yet, so emptying it by closing the + * last tab lands there too instead of collapsing. Visibility stays owned by + * `close`/`toggleVisibility`. + * + * The pull-request list's shared panel is the exception: it renders only while a + * change request is selected, so emptying that one still closes it rather than + * leaving an open panel with nothing to show. + */ +const isOpenAfterClose = ( + current: ThreadRightPanelState, + ref: ScopedThreadRef, + remaining: number, +): boolean => current.isOpen && (remaining > 0 || !isPullRequestsPanelKey(scopedThreadKey(ref))); + export interface ThreadRightPanelState { isOpen: boolean; activeSurfaceId: string | null; @@ -471,7 +488,7 @@ export const useRightPanelStore = create()( const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; return { ...current, - isOpen: surfaces.length > 0 && current.isOpen, + isOpen: isOpenAfterClose(current, ref, surfaces.length), surfaces, activeSurfaceId: current.activeSurfaceId === surfaceId @@ -511,12 +528,16 @@ export const useRightPanelStore = create()( if (index < 0) return current; const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); if (current.activeSurfaceId !== surfaceId) { - return { ...current, isOpen: surfaces.length > 0 && current.isOpen, surfaces }; + return { + ...current, + isOpen: isOpenAfterClose(current, ref, surfaces.length), + surfaces, + }; } const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; return { ...current, - isOpen: surfaces.length > 0 && current.isOpen, + isOpen: isOpenAfterClose(current, ref, surfaces.length), surfaces, activeSurfaceId: fallback?.id ?? null, }; @@ -556,7 +577,12 @@ export const useRightPanelStore = create()( byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => current.surfaces.length === 0 ? current - : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, + : { + ...current, + isOpen: isOpenAfterClose(current, ref, 0), + surfaces: [], + activeSurfaceId: null, + }, ), })), reconcileBrowserSurfaces: (ref, tabIds) =>