From 4edfc7afc53b7f5e2bc0830570989a43aed89a47 Mon Sep 17 00:00:00 2001 From: moazessam376-dev Date: Fri, 21 Aug 2026 00:25:39 +0300 Subject: [PATCH] fix(web): keep the right panel open when its last tab closes Closing the last tab in the right panel collapsed the whole panel instead of falling back to the empty-state picker. The panel already has a first-class "open with no surfaces" state: it renders RightPanelEmptyState, and RightPanelTabs hides the add-surface "+" button at zero surfaces because the picker cards replace it. Toggling the panel on a thread with no tabs is exactly that state today. But the store's close paths set isOpen to false once the last surface went away, so the same empty panel reached by closing a tab collapsed instead. It is most visible with files, because openFile deliberately replaces the standalone explorer surface with the file tab: open the explorer, open a file, close it, and the panel disappears even though the explorer was never dismissed. Closing a lone terminal, diff or browser tab did the same. Panel visibility is now owned by close/toggleVisibility, not by tab count. The pull-request list's shared panel keeps its old behaviour, since it only renders while a change request is selected and has no empty state to fall back to. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/rightPanelStore.test.ts | 52 +++++++++++++++++++++++++--- apps/web/src/rightPanelStore.ts | 34 +++++++++++++++--- 2 files changed, 77 insertions(+), 9 deletions(-) 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) =>