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 (