fix(desktop): keep Close and + on a lone workspace even when tabs are hidden

A lone workspace pane is stranded without its strip: Close still empties it
to a draft and + still opens a tab, so a chromeless workspace is a dead zone
for both handles. Treat any lone main pane as stranded, workspace included,
which also makes the auto-only lone-main clause unnecessary.

Approach from #107444 by @abundantbeing.

Co-authored-by: abundantbeing <beingsabundant@gmail.com>
This commit is contained in:
Hermes Agent
2026-09-25 17:56:10 -05:00
committed by brooklyn!
parent 39e20f4d55
commit 3273971a59
2 changed files with 28 additions and 31 deletions

View File

@@ -44,11 +44,16 @@ describe('no dead zone', () => {
expect(resolveTabStripVisible({ mode: 'never', shown: [toolPanel()] })).toBe(true)
})
it('keeps the strip for a lone workspace even when the zone says never', () => {
// Workspace cannot leave the tree, but Close still empties it to a draft
// and + still opens a tab. Chromeless is a dead zone for those handles.
expect(resolveTabStripVisible({ mode: 'never', shown: [workspace()] })).toBe(true)
})
it('still hides a zone that cannot strand anything', () => {
// The workspace is uncloseable, a stack is reachable by tab cycling, and
// hide-only chrome (sessions / Bots) keeps its panes + ⌘⌥T — the invariant
// protects handles, it does not veto hiding as such.
expect(resolveTabStripVisible({ mode: 'never', shown: [workspace()] })).toBe(false)
// A stack is reachable by tab cycling, and hide-only chrome (sessions /
// Bots) keeps its panes + ⌘⌥T — the invariant protects handles, it does
// not veto hiding as such.
expect(resolveTabStripVisible({ mode: 'never', shown: [toolPanel(), toolPanel()] })).toBe(false)
expect(resolveTabStripVisible({ mode: 'never', shown: [sideChrome()] })).toBe(false)
expect(resolveTabStripVisible({ mode: 'never', shown: [sideChrome(), sideChrome()] })).toBe(false)
@@ -86,15 +91,13 @@ describe('a full-page view', () => {
})
// Every launch and client update lands on a lone workspace. Chromeless, that
// layout had no session switcher and no "+" until the app-wide default was
// flipped to always — the "tab header disappeared" reports (#89350).
// layout had no session switcher, no Close and no "+" (#89350).
describe('a lone workspace', () => {
it('keeps its strip on auto, and only on auto', () => {
it('keeps its strip whatever the zone says', () => {
expect(resolveTabStripVisible({ shown: [workspace()] })).toBe(true)
expect(resolveTabStripVisible({ mode: 'never', shown: [workspace()] })).toBe(true)
// Standing side chrome never joins the tabbed workflow.
expect(resolveTabStripVisible({ shown: [sideChrome()] })).toBe(false)
// Hide tabs (menu row / ⌘⌥T) still wins — the workspace strands nothing.
expect(resolveTabStripVisible({ mode: 'never', shown: [workspace()] })).toBe(false)
})
})
@@ -127,7 +130,7 @@ describe('tabStripVisibleForZone', () => {
it('reads placement, uncloseable and collapse off the contributions', () => {
expect(visible(['workspace'])).toBe(true)
expect(visible(['workspace'], 'never')).toBe(false)
expect(visible(['workspace'], 'never')).toBe(true)
expect(visible(['tile:a'], 'never')).toBe(true)
expect(visible(['terminal'], 'never')).toBe(true)
})
@@ -137,15 +140,15 @@ describe('tabStripVisibleForZone', () => {
expect(visible(['workspace'])).toBe(true)
setTabStripDefault('never')
expect(visible(['workspace'])).toBe(false)
expect(visible(['workspace'])).toBe(true)
expect(visible(['workspace', 'terminal'])).toBe(false)
})
it("lets a zone's own choice beat the app default", () => {
setTabStripDefault('never')
expect(visible(['workspace'], 'always')).toBe(true)
expect(visible(['workspace', 'terminal'], 'always')).toBe(true)
setTabStripDefault('always')
expect(visible(['workspace'], 'never')).toBe(false)
expect(visible(['workspace', 'terminal'], 'never')).toBe(false)
})
})

View File

@@ -38,12 +38,13 @@ export interface StripZone {
/**
* A pane is STRANDED without a strip when the strip is the only thing carrying
* its handle: a lone closeable tile needs its ✕, a lone tool panel needs a chip
* to grab. The uncloseable workspace is not strandable — it cannot be closed
* or lost, so an explicit `never` may hide its strip. Hide-only chrome (sessions
* / Bots) is the same: the panes stay, Show/Hide is a separate verb, and a
* hidden strip comes back via ⌘⌥T. Treating it as stranded at any count made
* Hide tabs a silent no-op on the sessions sidebar.
* its handle: a lone main tile needs its ✕ and +, a lone tool panel needs a
* chip to grab. The workspace cannot leave the tree, but Close still empties
* it to a draft and + still opens a tab — chromeless is a dead zone for those
* handles. Hide-only chrome (sessions / Bots) is different: the panes stay,
* Show/Hide is a separate verb, and a hidden strip comes back via ⌘⌥T.
* Treating side chrome as stranded at any count made Hide tabs a silent no-op
* on the sessions sidebar.
*
* This outranks an explicit `never` on purpose. "Hide the strip" is a request
* about chrome, never a request to make a surface unreachable, and a zone that
@@ -64,7 +65,7 @@ function stranded(shown: readonly StripPane[]): boolean {
const [only] = shown
return only.collapsePane || (!only.uncloseable && only.placement === 'main')
return only.collapsePane || only.placement === 'main'
}
export function resolveTabStripVisible(zone: StripZone): boolean {
@@ -87,17 +88,10 @@ export function resolveTabStripVisible(zone: StripZone): boolean {
return zone.mode === 'always'
}
// Auto: two or more panes need a strip to switch between them.
if (zone.shown.length > 1) {
return true
}
// A lone MAIN tile keeps its strip too. The strip is the session switcher:
// its tab names the chat and its "+" opens the next one, so a chromeless
// lone workspace — the layout every launch and update lands on — read as
// "my tabs disappeared". Standing side chrome still goes without; hiding
// the workspace's strip remains an explicit choice (`never`, ⌘⌥T).
return zone.shown.some(pane => pane.placement === 'main')
// Auto: two or more panes need a strip to switch between them. A lone main
// tile never reaches here (it is stranded above), so what is left alone is
// standing side chrome, which goes without.
return zone.shown.length > 1
}
/**