From 8661e637dd2814a88f7992ac86d2aa2b3b49ac54 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Fri, 7 Aug 2026 17:04:13 +0700 Subject: [PATCH 1/2] fix(desktop): keep tab strip when a closeable preview is open Sticky "Hide tab bar" was winning over closeable main tiles, so an in-app Browser/preview could open with no strip and no close control. --- .../pane-shell/tree/renderer/lone-header.ts | 47 ++++++++++++++++--- .../pane-shell/tree/renderer/tree-group.tsx | 15 ++++-- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.ts b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.ts index 4fe0d9775751..b826f12877b0 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.ts @@ -14,6 +14,18 @@ export interface LoneHeaderChrome { uncloseable?: boolean } +/** True when any shown pane is a closeable main-strip tile (preview / session / page). */ +export function hasCloseableMainTile( + shown: readonly string[], + chromeOf: (id: string) => LoneHeaderChrome +): boolean { + return shown.some(id => { + const chrome = chromeOf(id) + + return !chrome.uncloseable && chrome.placement === 'main' + }) +} + export function forceLoneHeaderForPanes( shown: readonly string[], chromeOf: (id: string) => LoneHeaderChrome, @@ -21,15 +33,36 @@ export function forceLoneHeaderForPanes( ): boolean { // "This pane can be closed, so it must expose the ✕." Only the uncloseable // workspace is exempt; standing side chrome (files / sessions) isn't 'main'. - if ( - shown.some(id => { - const chrome = chromeOf(id) - - return !chrome.uncloseable && chrome.placement === 'main' - }) - ) { + if (hasCloseableMainTile(shown, chromeOf)) { return true } return shown.length === 1 && isCollapsePane(shown[0]) } + +/** + * Whether the zone tab strip should stay hidden. + * + * `headerHiddenFlag` is the user's sticky "Hide tab bar" choice. That preference + * still applies to tool-only zones, but it must not win over a closeable main + * tile (in-app Browser / preview / session): otherwise the strip and close control + * disappear with no recovery surface on the body. + */ +export function resolveZoneHeaderHidden(options: { + headerVeto?: boolean + /** `node.headerHidden` — explicit sticky hide, or undefined for auto. */ + headerHiddenFlag?: boolean + shownLength: number + forceLoneHeader: boolean + hasCloseableMainTile: boolean +}): boolean { + if (options.headerVeto) { + return true + } + + if (options.hasCloseableMainTile) { + return false + } + + return options.headerHiddenFlag ?? (options.shownLength <= 1 && !options.forceLoneHeader) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index e356aacac046..a1338de36c35 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -69,7 +69,7 @@ import { } from '../tab-selection' import { type DoubleTapContext, startPaneDrag } from './drag-session' -import { forceLoneHeaderForPanes } from './lone-header' +import { forceLoneHeaderForPanes, hasCloseableMainTile, resolveZoneHeaderHidden } from './lone-header' import { useActiveTabVisible } from './tab-strip-scroll' import { paneChrome } from './track-model' @@ -247,11 +247,20 @@ export function TreeGroup({ // always shows its header (it IS the header). // Session-tile ids force the header even before chrome registers — cycling // onto a freshly-split tile used to land headerless ("name card missing"). - const forceLoneHeader = forceLoneHeaderForPanes(shown, id => paneChrome(paneFor(id)), isCollapsePane) + const chromeOf = (id: string) => paneChrome(paneFor(id)) + const forceLoneHeader = forceLoneHeaderForPanes(shown, chromeOf, isCollapsePane) + const closeableMainTile = hasCloseableMainTile(shown, chromeOf) // A full-page view (headerVeto) suppresses the strip while it's the active // pane — a page is not a tab-able surface; the bar returns with the chat. - const headerHidden = paneChrome(active).headerVeto || (node.headerHidden ?? (shown.length <= 1 && !forceLoneHeader)) + // Sticky "Hide tab bar" must not trap a closeable preview/Browser without ✕. + const headerHidden = resolveZoneHeaderHidden({ + forceLoneHeader, + hasCloseableMainTile: closeableMainTile, + headerHiddenFlag: node.headerHidden, + headerVeto: Boolean(paneChrome(active).headerVeto), + shownLength: shown.length + }) // A group collapses ALONG its parent split's axis. In a row that means the // WIDTH collapses — a full-width horizontal header would strand a tall From 932bcff8e2b8135555e24f94f517889acbead042 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Fri, 7 Aug 2026 17:04:13 +0700 Subject: [PATCH 2/2] test(desktop): cover sticky header hide vs preview close X Lock the trap where headerHidden:true must not hide the strip while a closeable preview/Browser tile is shown, while tool-only sticky hide stays. --- .../tree/renderer/lone-header.test.ts | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.test.ts b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.test.ts index a16ad0e67a30..41bc54ebbc4d 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.test.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { forceLoneHeaderForPanes } from './lone-header' +import { forceLoneHeaderForPanes, hasCloseableMainTile, resolveZoneHeaderHidden } from './lone-header' describe('forceLoneHeaderForPanes', () => { const chrome = @@ -35,3 +35,76 @@ describe('forceLoneHeaderForPanes', () => { expect(forceLoneHeaderForPanes(['files'], chrome('right'), noCollapse)).toBe(false) }) }) + +describe('resolveZoneHeaderHidden', () => { + const mainChrome = () => ({ placement: 'main' as const, uncloseable: false }) + const workspaceChrome = () => ({ placement: 'main' as const, uncloseable: true }) + + it('keeps the strip visible when a sticky hide meets a closeable preview', () => { + const shown = ['workspace', 'preview-tile:url:x'] + const chromeOf = (id: string) => (id === 'workspace' ? workspaceChrome() : mainChrome()) + + expect( + resolveZoneHeaderHidden({ + forceLoneHeader: forceLoneHeaderForPanes(shown, chromeOf, () => false), + hasCloseableMainTile: hasCloseableMainTile(shown, chromeOf), + headerHiddenFlag: true, + shownLength: shown.length + }) + ).toBe(false) + }) + + it('keeps the strip visible for a lone Browser/preview tile even when sticky-hidden', () => { + const shown = ['preview-tile:url:x'] + const chromeOf = mainChrome + + expect( + resolveZoneHeaderHidden({ + forceLoneHeader: forceLoneHeaderForPanes(shown, chromeOf, () => false), + hasCloseableMainTile: hasCloseableMainTile(shown, chromeOf), + headerHiddenFlag: true, + shownLength: shown.length + }) + ).toBe(false) + }) + + it('still honors sticky hide for a tool-only zone', () => { + const shown = ['terminal', 'logs'] + + expect( + resolveZoneHeaderHidden({ + forceLoneHeader: forceLoneHeaderForPanes(shown, () => ({}), id => id === 'terminal' || id === 'logs'), + hasCloseableMainTile: false, + headerHiddenFlag: true, + shownLength: shown.length + }) + ).toBe(true) + }) + + it('still auto-hides a lone uncloseable workspace', () => { + const shown = ['workspace'] + const chromeOf = workspaceChrome + + expect( + resolveZoneHeaderHidden({ + forceLoneHeader: forceLoneHeaderForPanes(shown, chromeOf, () => false), + hasCloseableMainTile: hasCloseableMainTile(shown, chromeOf), + shownLength: shown.length + }) + ).toBe(true) + }) + + it('still honors headerVeto for full-page views', () => { + const shown = ['preview-tile:url:x'] + const chromeOf = mainChrome + + expect( + resolveZoneHeaderHidden({ + forceLoneHeader: true, + hasCloseableMainTile: hasCloseableMainTile(shown, chromeOf), + headerVeto: true, + shownLength: shown.length + }) + ).toBe(true) + }) +})