From 39621fbbfbe45710618f98526642c03a145750db Mon Sep 17 00:00:00 2001 From: Dolverin <5910064+Dolverin@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:16:22 +0200 Subject: [PATCH] fix(desktop): keep session tab strips visible Session switcher zones now ignore lone-pane auto-hide and stale persisted header hiding while full-page header vetoes continue to win. The dead-end Hide header action and double-tap persistence are disabled only for chat strips; tool zones retain their existing behavior. Co-authored-by: leonphull <249432715+leonphull@users.noreply.github.com> --- .../tree/renderer/lone-header.test.ts | 61 ++++++++++++++++- .../pane-shell/tree/renderer/lone-header.ts | 21 ++++++ .../session-strip-visibility.test.tsx | 67 +++++++++++++++++++ .../tree/renderer/tool-panel-close.test.tsx | 13 ++++ .../tree/renderer/tree-group.test.tsx | 25 +++++++ .../pane-shell/tree/renderer/tree-group.tsx | 47 +++++++++---- 6 files changed, 219 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/session-strip-visibility.test.tsx 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..363da65cffc1 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, resolveZoneHeaderHidden } from './lone-header' describe('forceLoneHeaderForPanes', () => { const chrome = @@ -35,3 +35,62 @@ describe('forceLoneHeaderForPanes', () => { expect(forceLoneHeaderForPanes(['files'], chrome('right'), noCollapse)).toBe(false) }) }) + +describe('resolveZoneHeaderHidden', () => { + it('keeps a lone session workspace strip visible', () => { + expect( + resolveZoneHeaderHidden({ + forceLoneHeader: false, + headerVeto: false, + persistedHidden: undefined, + sessionStrip: true, + shownCount: 1 + }) + ).toBe(false) + }) + + it('ignores a persisted hide for session strips', () => { + expect( + resolveZoneHeaderHidden({ + forceLoneHeader: true, + headerVeto: false, + persistedHidden: true, + sessionStrip: true, + shownCount: 2 + }) + ).toBe(false) + }) + + it('still honors a full-page header veto', () => { + expect( + resolveZoneHeaderHidden({ + forceLoneHeader: false, + headerVeto: true, + persistedHidden: false, + sessionStrip: true, + shownCount: 1 + }) + ).toBe(true) + }) + + it('preserves explicit and automatic hiding for non-session zones', () => { + expect( + resolveZoneHeaderHidden({ + forceLoneHeader: true, + headerVeto: false, + persistedHidden: true, + sessionStrip: false, + shownCount: 1 + }) + ).toBe(true) + expect( + resolveZoneHeaderHidden({ + forceLoneHeader: false, + headerVeto: false, + persistedHidden: undefined, + sessionStrip: false, + shownCount: 1 + }) + ).toBe(true) + }) +}) 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..e73e9813e758 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 @@ -33,3 +33,24 @@ export function forceLoneHeaderForPanes( return shown.length === 1 && isCollapsePane(shown[0]) } + +export interface ZoneHeaderVisibilityInput { + forceLoneHeader: boolean + headerVeto: boolean + persistedHidden?: boolean + sessionStrip: boolean + shownCount: number +} + +/** Resolve header visibility without letting the chat switcher become a dead end. */ +export function resolveZoneHeaderHidden(input: ZoneHeaderVisibilityInput): boolean { + if (input.headerVeto) { + return true + } + + if (input.sessionStrip) { + return false + } + + return input.persistedHidden ?? (input.shownCount <= 1 && !input.forceLoneHeader) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/session-strip-visibility.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/session-strip-visibility.test.tsx new file mode 100644 index 000000000000..a0f872ecc251 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/session-strip-visibility.test.tsx @@ -0,0 +1,67 @@ +import { useStore } from '@nanostores/react' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import { registry } from '@/contrib/registry' +import { stubResizeObserver } from '@/test/jsdom' + +import { group } from '../model' +import { $layoutTree } from '../store' + +import { TreeGroup } from './tree-group' + +function LiveTreeGroup() { + useStore($layoutTree) + + return +} + +beforeAll(() => { + stubResizeObserver() + vi.stubGlobal('CSS', { ...globalThis.CSS, escape: (value: string) => value }) + Element.prototype.hasPointerCapture ??= () => false + Element.prototype.setPointerCapture ??= () => undefined + Element.prototype.releasePointerCapture ??= () => undefined + HTMLElement.prototype.scrollIntoView ??= () => undefined +}) + +let disposePane: (() => void) | undefined + +afterEach(() => { + cleanup() + disposePane?.() + disposePane = undefined +}) + +const groupNode = () => + $layoutTree.get() as { headerHidden?: boolean; minimized?: boolean; panes: string[] } + +const doubleTap = (target: Element) => { + for (let i = 0; i < 2; i++) { + fireEvent.pointerDown(target, { button: 0, clientX: 10, clientY: 10, pointerType: 'mouse' }) + fireEvent.pointerUp(window, { button: 0, clientX: 10, clientY: 10, pointerType: 'mouse' }) + } +} + +describe('session strip visibility', () => { + it('undoes the first-tap collapse without persisting a header hide', () => { + const paneId = 'session-tile:test' + disposePane = registry.register({ + area: 'panes', + data: { placement: 'main' }, + id: paneId, + render: () => null, + title: 'Session' + }) + $layoutTree.set(group([paneId], { active: paneId, id: 'grp-session' })) + render() + + const strip = globalThis.document.querySelector('[data-zone-tabstrip="grp-session"]') + expect(strip).toBeTruthy() + + doubleTap(strip!) + + expect(groupNode().minimized).not.toBe(true) + expect(groupNode().headerHidden).not.toBe(true) + }) +}) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tool-panel-close.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tool-panel-close.test.tsx index 49f8e4824e0e..9140b866749b 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tool-panel-close.test.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tool-panel-close.test.tsx @@ -101,6 +101,7 @@ describe('right-clicking a tool panel tab', () => { openContextMenu(tab!) expect(await screen.findByRole('menuitem', { name: /^close$/i })).toBeTruthy() + expect(await screen.findByRole('menuitem', { name: /^hide header$/i })).toBeTruthy() }) it('offers Close while the zone is MINIMIZED to its rail', async () => { @@ -119,6 +120,18 @@ describe('right-clicking a tool panel tab', () => { }) }) +describe('right-clicking the session workspace tab', () => { + it('does not offer the dead-end Hide header action', async () => { + declareDefaultTree(group(['workspace'], { active: 'workspace', id: 'grp-main' })) + render() + + openContextMenu(tabEl('workspace')!) + + expect(await screen.findByRole('menu')).toBeTruthy() + expect(screen.queryByRole('menuitem', { name: /^hide header$/i })).toBeNull() + }) +}) + describe('⌘W over a focused tool panel', () => { it('closes the logs tab and the toggle brings it back', async () => { const { closeActiveTab } = await import('@/app/chat/close-tab') diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx index 8241ae1b50f8..8853a604f18a 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx @@ -35,6 +35,16 @@ function terminalGroup(minimized: boolean): GroupNode { } } +function workspaceGroup(): GroupNode { + return { + active: 'workspace', + headerHidden: true, + id: 'workspace-zone', + panes: ['workspace'], + type: 'group' + } +} + const toggle = (label: string) => globalThis.document.querySelector( `[data-tree-group="terminal-zone"] button[aria-label="${label}"]` @@ -73,4 +83,19 @@ describe('TreeGroup', () => { expect(toggle('Restore').querySelector('i')!.className).toContain('codicon-chevron-up') }) + + it('keeps the session strip visible for a lone workspace with persisted hide', () => { + disposePane = registry.register({ + area: 'panes', + data: { placement: 'main', uncloseable: true }, + id: 'workspace', + render: () =>
Workspace
, + title: 'Workspace' + }) + vi.stubGlobal('CSS', { escape: (value: string) => value }) + + render() + + expect(globalThis.document.querySelector('[data-tree-group="workspace-zone"][data-zone-header]')).not.toBeNull() + }) }) 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 481dfd02e5d5..f9ab145ef14f 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 @@ -72,7 +72,7 @@ import { } from '../tab-selection' import { type DoubleTapContext, startPaneDrag } from './drag-session' -import { forceLoneHeaderForPanes } from './lone-header' +import { forceLoneHeaderForPanes, resolveZoneHeaderHidden } from './lone-header' import { useActiveTabVisible } from './tab-strip-scroll' import { paneChrome } from './track-model' @@ -82,6 +82,7 @@ import { paneChrome } from './track-model' * a pane with no domain menu of its own (the file tree, a terminal, the main * tab on a fresh draft) falls through to this one. */ function ZoneMenu({ + canHideHeader = true, children, closable, minimizable = true, @@ -90,6 +91,8 @@ function ZoneMenu({ nodeId, targetPane }: { + /** False for chat switchers, whose strip is the only session-tab recovery surface. */ + canHideHeader?: boolean children: ReactNode /** The pane the menu closes (the right-clicked chip / the active pane); * undefined = not closable (the main zone). */ @@ -155,12 +158,13 @@ function ZoneMenu({ ) })()} - - {renderActionItem(kit, { - icon: headerHidden ? 'eye' : 'eye-closed', - label: headerHidden ? t.zones.showHeader : t.zones.hideHeader, - onSelect: () => setTreeGroupHeaderHidden(nodeId, !headerHidden) - })} + {(canHideHeader || minimizable) && } + {canHideHeader && + renderActionItem(kit, { + icon: headerHidden ? 'eye' : 'eye-closed', + label: headerHidden ? t.zones.showHeader : t.zones.hideHeader, + onSelect: () => setTreeGroupHeaderHidden(nodeId, !headerHidden) + })} {minimizable && renderActionItem(kit, { // Same action-direction contract as the strip button below: the @@ -263,16 +267,25 @@ export function TreeGroup({ // tile in its own zone is unclosable (the "3rd tile has no tab" trap); // - a TOOL PANEL (terminal/logs — a collapse pane) dragged out of the main // stack, else it's a dead zone with no tab to grab or ✕ to close. - // The uncloseable workspace and side chrome (sessions/files) keep the clean - // no-tab default. Double-click toggles it either way; a minimized group - // always shows its header (it IS the header). + // The session switcher (workspace + session tiles) always keeps its strip: + // hiding the only session navigation surface is an inescapable dead end. + // Standing side chrome (sessions/files) keeps the clean no-tab default. A + // minimized group 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 sessionStrip = shown.some(isSessionStripPane) + const canHideHeader = !sessionStrip // 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)) + const headerHidden = resolveZoneHeaderHidden({ + forceLoneHeader, + headerVeto: Boolean(paneChrome(active).headerVeto), + persistedHidden: node.headerHidden, + sessionStrip, + shownCount: 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 @@ -298,6 +311,11 @@ export function TreeGroup({ key: `hide-header-${node.id}`, onDoubleTap: () => { setTreeGroupMinimized(node.id, false) + + if (!canHideHeader) { + return + } + setTreeGroupHeaderHidden(node.id, true) } } @@ -347,6 +365,7 @@ export function TreeGroup({ // Same menu on the header strip and the edit veil — one prop bag. const zoneMenu = { + canHideHeader, closable, headerHidden, minimizable, @@ -357,9 +376,9 @@ export function TreeGroup({ // NO body double-click toggle: virtualized content (the thread) recreates // its nodes between clicks, so the gesture was hopelessly unreliable. The - // bar's lifecycle is explicit instead — gaining a tab sticky-shows it - // (insertAtGroup pins headerHidden false), the main tab's context menu - // hides it, and full-page views veto it via paneChrome.headerVeto. + // bar's lifecycle is explicit instead — the session switcher stays visible, + // tool/side zones may hide from their own strip, and full-page views veto it + // via paneChrome.headerVeto. return (