From 39e20f4d552718627c5ad8feadfffeeb9245ea5a Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 25 Sep 2026 01:19:32 -0500 Subject: [PATCH] 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. --- .../tree/lone-chat-sibling-zone.test.ts | 5 ++-- .../tree/renderer/strip-visibility.test.ts | 30 +++++++++---------- .../tree/renderer/strip-visibility.ts | 24 +++++---------- .../pane-shell/tree/renderer/tree-group.tsx | 7 +---- .../src/components/pane-shell/tree/store.ts | 20 ++----------- 5 files changed, 28 insertions(+), 58 deletions(-) diff --git a/apps/desktop/src/components/pane-shell/tree/lone-chat-sibling-zone.test.ts b/apps/desktop/src/components/pane-shell/tree/lone-chat-sibling-zone.test.ts index aadb3303a3..addd0c2690 100644 --- a/apps/desktop/src/components/pane-shell/tree/lone-chat-sibling-zone.test.ts +++ b/apps/desktop/src/components/pane-shell/tree/lone-chat-sibling-zone.test.ts @@ -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) }) }) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/strip-visibility.test.ts b/apps/desktop/src/components/pane-shell/tree/renderer/strip-visibility.test.ts index 7a874a8b4c..8485e1dac1 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/strip-visibility.test.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/strip-visibility.test.ts @@ -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) }) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/strip-visibility.ts b/apps/desktop/src/components/pane-shell/tree/renderer/strip-visibility.ts index 7c6242890f..d1bc7b4569 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/strip-visibility.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/strip-visibility.ts @@ -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)) 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 616f831f2b..8738bbe554 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 @@ -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 diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index ae5ca3c99d..6f516d0614 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -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 }) }