fix(desktop): keep the tab strip on a lone workspace in auto mode

Every launch lands on a lone workspace chat, and auto mode hid its strip,
so the session tab and its "+" were gone until the app-wide default was
set to always. A lone main tile now keeps its strip on auto; Hide tabs and
an app-wide "never" still hide it. The siblingMainZone input and the
$mainTileZoneCount store it fed are no longer needed and are removed.
This commit is contained in:
Hermes Agent
2026-09-25 01:19:32 -05:00
committed by brooklyn!
parent cd69be6909
commit 39e20f4d55
5 changed files with 28 additions and 58 deletions

View File

@@ -40,7 +40,7 @@ afterEach(() => disposers.splice(0).forEach(dispose => dispose()))
const zoneOf = (paneId: string) => findGroupOfPane($layoutTree.get()!, paneId)!
describe('dragging a session tab into its own zone', () => {
it('leaves BOTH chat zones with a strip, and main chromeless again once the tile is gone', () => {
it('leaves BOTH chat zones with a strip, and main keeps its own once the tile is gone', () => {
expect(tabStripVisibleForGroup(zoneOf('workspace'))).toBe(true)
moveTreePane('session-tile:b', { groupId: 'grp-main', pos: 'right' })
@@ -53,6 +53,7 @@ describe('dragging a session tab into its own zone', () => {
removeTreePane('session-tile:b')
expect(tabStripVisibleForGroup(zoneOf('workspace'))).toBe(false)
// A lone workspace is the session switcher's home: tab and "+" stay (#89350).
expect(tabStripVisibleForGroup(zoneOf('workspace'))).toBe(true)
})
})

View File

@@ -11,8 +11,8 @@ const toolPanel = (): StripPane => ({ collapsePane: true, placement: 'bottom' })
const sideChrome = (): StripPane => ({ collapsePane: false, placement: 'right' })
describe('auto (no stored choice)', () => {
it('gives a lone workspace no strip and a stack of two a strip', () => {
expect(resolveTabStripVisible({ shown: [workspace()] })).toBe(false)
it('gives a lone workspace its strip so the tab and + stay on screen', () => {
expect(resolveTabStripVisible({ shown: [workspace()] })).toBe(true)
expect(resolveTabStripVisible({ shown: [workspace(), sideChrome()] })).toBe(true)
})
@@ -85,19 +85,16 @@ describe('a full-page view', () => {
})
})
// Dragging a session out of the chat strip into its own zone left main a lone
// workspace: the tile kept its tab (stranded), main lost its tab and its "+",
// and the two chats side by side read as "the tabs disappeared". Tiles are a
// tabbed workflow — a main zone keeps its strip while another main zone exists.
describe('a lone main tile beside another main zone', () => {
// 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).
describe('a lone workspace', () => {
it('keeps its strip on auto, and only on auto', () => {
expect(resolveTabStripVisible({ shown: [workspace()], siblingMainZone: true })).toBe(true)
// The whole-window chat is still chromeless, and standing side chrome
// never joins the tabbed workflow.
expect(resolveTabStripVisible({ shown: [workspace()], siblingMainZone: false })).toBe(false)
expect(resolveTabStripVisible({ shown: [sideChrome()], siblingMainZone: true })).toBe(false)
expect(resolveTabStripVisible({ 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()], siblingMainZone: true })).toBe(false)
expect(resolveTabStripVisible({ mode: 'never', shown: [workspace()] })).toBe(false)
})
})
@@ -123,14 +120,14 @@ describe('tabStripVisibleForZone', () => {
isCollapsePane: id => id === 'terminal',
mode,
paneFor: id => contributions[id],
shown,
siblingMainZone: false
shown
})
afterEach(() => setTabStripDefault('auto'))
it('reads placement, uncloseable and collapse off the contributions', () => {
expect(visible(['workspace'])).toBe(false)
expect(visible(['workspace'])).toBe(true)
expect(visible(['workspace'], 'never')).toBe(false)
expect(visible(['tile:a'], 'never')).toBe(true)
expect(visible(['terminal'], 'never')).toBe(true)
})
@@ -140,6 +137,7 @@ describe('tabStripVisibleForZone', () => {
expect(visible(['workspace'])).toBe(true)
setTabStripDefault('never')
expect(visible(['workspace'])).toBe(false)
expect(visible(['workspace', 'terminal'])).toBe(false)
})

View File

@@ -34,16 +34,13 @@ export interface StripZone {
/** Panes currently rendered as chips — chrome-hidden and narrow-collapsed
* panes are already filtered out. */
shown: readonly StripPane[]
/** ANOTHER zone in the layout also hosts a main tile (a chat, a page, a
* preview). Auto reads it as "the user is working in tiles". */
siblingMainZone?: boolean
}
/**
* 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 a lone chat is free to be chromeless. Hide-only chrome (sessions
* 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.
@@ -90,19 +87,17 @@ export function resolveTabStripVisible(zone: StripZone): boolean {
return zone.mode === 'always'
}
// Auto: a lone pane is not a "tab", so it goes without a strip; two or more
// need one to switch between them.
// Auto: two or more panes need a strip to switch between them.
if (zone.shown.length > 1) {
return true
}
// …except a lone MAIN tile once a second main zone exists. Dragging a
// session out of the chat strip into its own zone left the workspace alone
// in main: the tile kept its tab (stranded), main lost its tab and its "+",
// and two chats side by side read as "my tabs disappeared". Tiles are a
// tabbed workflow, so every main zone keeps its strip while there is more
// than one; a chat that is the whole window is still chromeless.
return Boolean(zone.siblingMainZone) && zone.shown.some(pane => pane.placement === 'main')
// 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')
}
/**
@@ -120,13 +115,10 @@ export function tabStripVisibleForZone(zone: {
paneFor: (id: string) => Contribution | undefined
/** Panes currently rendered as chips. */
shown: readonly string[]
/** Another zone in the layout hosts a main tile too (`$mainTileZoneCount`). */
siblingMainZone: boolean
}): boolean {
return resolveTabStripVisible({
headerVeto: paneChrome(zone.paneFor(zone.active)).headerVeto,
mode: effectiveTabStripMode(zone.mode),
siblingMainZone: zone.siblingMainZone,
shown: zone.shown.map(id => {
const chrome = paneChrome(zone.paneFor(id))

View File

@@ -48,7 +48,6 @@ import type { DropPosition, GroupNode } from '../model'
import {
$dropHint,
$hiddenTreePanes,
$mainTileZoneCount,
$narrowViewport,
$newSessionTabAction,
$panesWithCloser,
@@ -264,9 +263,6 @@ export function TreeGroup({
const hiddenPanes = useStore($hiddenTreePanes)
const narrow = useStore($narrowViewport)
// A count that moves only when a main zone appears or goes — NOT the tree
// itself (see the note above `targetPane` on why zones never subscribe to it).
const mainTileZoneCount = useStore($mainTileZoneCount)
const workspaceMode = useStore($workspaceMode)
const workspaceOwnerKey = useStore($workspaceOwnerKey)
const newSessionTabAction = useStore($newSessionTabAction)
@@ -381,8 +377,7 @@ export function TreeGroup({
isCollapsePane,
mode: node.tabStrip,
paneFor,
shown,
siblingMainZone: mainTileZoneCount > (shown.some(id => paneChrome(paneFor(id)).placement === 'main') ? 1 : 0)
shown
})
// A group collapses ALONG its parent split's axis. In a row that means the

View File

@@ -8,7 +8,7 @@ import { atom, computed, type ReadableAtom } from 'nanostores'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '@/app/layout-constants'
import { setPluginEnabled } from '@/contrib/plugins-store'
import { $registryVersion, registry } from '@/contrib/registry'
import { registry } from '@/contrib/registry'
import { translateNow } from '@/i18n'
import { LAYOUT_KEYS } from '@/lib/layout-persistence'
import { Codecs } from '@/lib/persisted'
@@ -847,21 +847,6 @@ export function shownPanesInGroup(group: { panes: readonly string[] }): string[]
})
}
/** How many zones currently show a MAIN tile (a chat, a page, a preview). A
* count, not a list, so it notifies only when a main zone appears or goes —
* every TreeGroup reads it, and a sash drag rewrites the tree once per frame.
* Registry-versioned because a freshly adopted session tile is in the tree
* before its contribution registers `placement: 'main'`. */
export const $mainTileZoneCount = computed(
[$layoutTree, $hiddenTreePanes, $registryVersion],
(tree: LayoutNode | null) =>
tree
? groupLeafIds(tree).filter(id =>
shownPanesInGroup({ panes: findGroup(tree, id)?.panes ?? [] }).some(isMainStripPane)
).length
: 0
)
/** Is this zone showing a tab strip right now? The store's adapter over the
* shared resolver — TreeGroup answers the same question from its own render
* inputs, so the toggle command and the strip on screen cannot disagree about
@@ -875,8 +860,7 @@ export function tabStripVisibleForGroup(group: GroupNode): boolean {
isCollapsePane,
mode: group.tabStrip,
paneFor: (id: string) => registered.find(c => c.id === id),
shown,
siblingMainZone: $mainTileZoneCount.get() > (shown.some(isMainStripPane) ? 1 : 0)
shown
})
}