diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index 68e78fcdf2..af0a7a6c08 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -43,7 +43,6 @@ import { } from './model' import { FLOATING_PLACEMENT } from './renderer/floating-rect' import { tabStripVisibleForZone } from './renderer/strip-visibility' -import { rootChildSide } from './renderer/track-model' // v2: v1 trees were saved against placeholder panes with index-order zone // assignment (chat could land in a corner cell). Retire them wholesale. @@ -894,9 +893,21 @@ export function paneRootSide(paneId: string): null | TreeSide { } const panes = registry.getArea('panes') - const child = row.children.find(c => allPaneIds(c).includes(paneId)) + const index = row.children.findIndex(c => allPaneIds(c).includes(paneId)) - return child ? rootChildSide(child, id => panes.find(p => p.id === id)) : null + const mainIndices = row.children.flatMap((child, i) => + allPaneIds(child).some( + id => (panes.find(p => p.id === id)?.data as { placement?: string } | undefined)?.placement === 'main' + ) + ? [i] + : [] + ) + + if (index < 0 || mainIndices.length === 0) { + return null + } + + return index < mainIndices[0] ? 'left' : index > mainIndices[mainIndices.length - 1] ? 'right' : null } /** The closer-less Close: dismiss the pane (removed + remembered; reveal @@ -985,11 +996,52 @@ export function setTreeSideCollapsed(side: TreeSide, collapsed: boolean) { } } +/** Explicit side-open also recovers hide-only tabs, without fronting over Bots. */ +export function restoreHiddenTreeSideTabs(side: TreeSide): void { + for (const paneId of [...$hiddenStripTabs.get()]) { + if (paneRootSide(paneId) === side) { + setStripTabHidden(paneId, false) + } + } +} + +/** Restore minimized zones without changing their active tab (including Bots). */ +export function restoreMinimizedTreeSide(side: TreeSide): boolean { + const tree = $layoutTree.get() + const row = rootRow() + + if (!tree || !row) { + return false + } + + let next = tree + + for (const child of row.children) { + if (paneRootSide(allPaneIds(child)[0]) !== side) { + continue + } + + for (const id of groupLeafIds(child)) { + if (findGroup(next, id)?.minimized) { + next = setGroupMinimized(next, id, false) + } + } + } + + if (next === tree) { + return false + } + + commit(next) + + return true +} + /** * Does the layout have a collapsible root side of `side`? ⌘J's normal target is * the right sidebar; a layout without one (e.g. a terminal-on-bottom preset) - * lets callers fall back to the terminal so ⌘J is never a dead key. Semantic — - * reuses `rootChildSide`, so it tracks a ⌘\ flip / drag like the toggles do. + * lets callers fall back to the terminal so ⌘J is never a dead key. Tracks + * physical position through a ⌘\ flip / drag, just like the toggles. */ export function layoutHasRootSide(side: TreeSide): boolean { const row = rootRow() @@ -998,9 +1050,7 @@ export function layoutHasRootSide(side: TreeSide): boolean { return false } - const panes = registry.getArea('panes') - - return row.children.some(child => rootChildSide(child, id => panes.find(p => p.id === id)) === side) + return row.children.some(child => paneRootSide(allPaneIds(child)[0]) === side) } /** @@ -1049,33 +1099,9 @@ export function bindTreeSideVisibility( $open.listen(open => setTreeSideCollapsed(side, !open)) } -/** The chrome toggle owning `paneId`'s root-row column — SEMANTIC, matching - * the renderer's `rootChildSide`: ⌘B ⇔ the sessions column (left-placement - * panes) wherever it sits, ⌘J ⇔ the other side columns. Null for the main - * column (never side-collapsed). */ +/** The physical column's chrome toggle; main columns never side-collapse. */ export function treeSideOfPane(paneId: string): TreeSide | null { - const row = rootRow() - - if (!row) { - return null - } - - const child = row.children.find(node => allPaneIds(node).includes(paneId)) - - if (!child) { - return null - } - - const placementOf = (id: string) => - (registry.getArea('panes').find(c => c.id === id)?.data as { placement?: string } | undefined)?.placement - - const placements = allPaneIds(child).map(placementOf) - - if (placements.includes('main')) { - return null - } - - return placements.includes('left') ? 'left' : 'right' + return paneRootSide(paneId) } /** diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index e755b772bc..9bcf2e9f46 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -2,13 +2,17 @@ import { atom, computed, type ReadableAtom, type WritableAtom } from 'nanostores import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '@/app/layout-constants' import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell' -import { isPaneVisible, revealTreePane } from '@/components/pane-shell/tree/store' +import { + restoreHiddenTreeSideTabs, + restoreMinimizedTreeSide, + setTreeSideCollapsed +} from '@/components/pane-shell/tree/store' import { matchesQuery } from '@/hooks/use-media-query' import { connectionScopedAtom } from '@/lib/connection-scoped' import { type Codec, Codecs, persistentAtom } from '@/lib/persisted' import { arraysEqual, insertUniqueId, readKey } from '@/lib/storage' -import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, togglePane } from './panes' +import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride } from './panes' import { $showAllProfiles, setShowAllProfiles } from './profile' import type { PullRequestBucket } from './pull-requests' import type { SessionStatusBucket } from './session-dot-state' @@ -529,12 +533,21 @@ function revealNarrowPane(id: string, mode: 'close' | 'open' | 'toggle'): boolea export function setSidebarOpen(open: boolean) { setPaneOpen(CHAT_SIDEBAR_PANE_ID, open) + setTreeSideCollapsed('left', !open) + + if (open) { + restoreMinimizedTreeSide('left') + restoreHiddenTreeSideTabs('left') + } + revealNarrowPane(CHAT_SIDEBAR_PANE_ID, open ? 'open' : 'close') } export function toggleSidebarOpen() { if (!revealNarrowPane(CHAT_SIDEBAR_PANE_ID, 'toggle')) { - togglePane(CHAT_SIDEBAR_PANE_ID) + const open = restoreMinimizedTreeSide('left') || !$sidebarOpen.get() + setPaneOpen(CHAT_SIDEBAR_PANE_ID, open) + setTreeSideCollapsed('left', !open) } } @@ -543,23 +556,20 @@ export function toggleFileBrowserOpen() { return } - // Ask the TREE, not the pane's boolean. `$fileBrowserOpen` stays true while - // the tree pane sits behind a sibling tab in the shared right column (the - // preview rail, the diff) or inside a minimized zone, so ⌘J spent its press - // re-asserting a value it already held and read as a dead key. Only fold the - // side when the tree is genuinely the thing on screen; otherwise bring it - // forward through the reveal path, which fronts and un-minimizes. - if (!isPaneVisible(FILES_PANE_ID) && $fileBrowserOpen.get()) { - revealTreePane(FILES_PANE_ID) - - return - } - - togglePane(FILE_BROWSER_PANE_ID) + const open = restoreMinimizedTreeSide('right') || !$fileBrowserOpen.get() + setPaneOpen(FILE_BROWSER_PANE_ID, open) + setTreeSideCollapsed('right', !open) } export function setFileBrowserOpen(open: boolean) { setPaneOpen(FILE_BROWSER_PANE_ID, open) + setTreeSideCollapsed('right', !open) + + if (open) { + restoreMinimizedTreeSide('right') + restoreHiddenTreeSideTabs('right') + } + revealNarrowPane(FILE_BROWSER_PANE_ID, open ? 'open' : 'close') } diff --git a/apps/desktop/src/store/sidebar-collapse-persistence.test.ts b/apps/desktop/src/store/sidebar-collapse-persistence.test.ts index f260950d1b..8be57878e2 100644 --- a/apps/desktop/src/store/sidebar-collapse-persistence.test.ts +++ b/apps/desktop/src/store/sidebar-collapse-persistence.test.ts @@ -61,4 +61,111 @@ describe('sidebar collapse persistence', () => { s2.bind() expect(s2.leftCollapsed()).toBe(true) }) + + it('explicit open restores hidden strip tabs and minimized groups only on that physical side', async () => { + for (const flipped of [false, true]) { + window.localStorage.clear() + reload() + const { layout, tree, bind } = await loadStores() + const { findGroup, group, split } = await import('@/components/pane-shell/tree/model') + const { registry } = await import('@/contrib/registry') + + const disposers = [ + registry.register({ area: 'panes', id: 'sessions', data: { placement: 'left' } }), + registry.register({ area: 'panes', id: 'bots', data: { placement: 'left' } }), + registry.register({ area: 'panes', id: 'workspace', data: { placement: 'main' } }), + registry.register({ area: 'panes', id: 'files', data: { placement: 'right' } }), + registry.register({ area: 'panes', id: 'review', data: { placement: 'right' } }) + ] + + try { + const sidebar = group(['sessions', 'bots'], { active: 'bots', id: 'sidebar' }) + const main = group(['workspace']) + const other = group(['files', 'review'], { id: 'other' }) + tree.declareDefaultTree(split('row', flipped ? [other, main, sidebar] : [sidebar, main, other])) + bind() + tree.bindTreeSideVisibility('right', layout.$fileBrowserOpen, layout.setFileBrowserOpen) + layout.setFileBrowserOpen(true) + tree.setStripTabHidden('sessions', true) + tree.setStripTabHidden('review', true) + tree.setTreeGroupMinimized('sidebar', true) + tree.setTreeGroupMinimized('other', true) + const open = flipped ? layout.setFileBrowserOpen : layout.setSidebarOpen + + open(true) // already true: must not depend on a nanostores notification + expect(tree.isStripTabHidden('sessions')).toBe(false) + expect(tree.$hiddenTreePanes.get().has('sessions')).toBe(false) + expect(tree.isStripTabHidden('review')).toBe(true) + expect(findGroup(tree.$layoutTree.get()!, 'sidebar')).toMatchObject({ active: 'bots', minimized: false }) + expect(findGroup(tree.$layoutTree.get()!, 'other')?.minimized).toBe(true) + expect(JSON.parse(window.localStorage.getItem('hermes.desktop.hiddenStripTabs.v1')!)).toEqual(['review']) + const restored = tree.$layoutTree.get() + open(true) + expect(tree.$layoutTree.get()).toBe(restored) + tree.setStripTabHidden('sessions', true) + const toggle = flipped ? layout.toggleFileBrowserOpen : layout.toggleSidebarOpen + toggle() + toggle() + expect(tree.isStripTabHidden('sessions')).toBe(true) // ordinary toggles preserve the tab choice + } finally { + disposers.forEach(dispose => dispose()) + } + } + }) + + it('recovers the minimized physical side after reload without changing its active tab', async () => { + for (const flipped of [false, true]) { + window.localStorage.clear() + reload() + const s1 = await loadStores() + const { group, split } = await import('@/components/pane-shell/tree/model') + const sidebar = group(['sessions', 'bots'], { active: 'bots', id: 'sidebar' }) + const main = group(['workspace']) + const files = group(['files'], { id: 'files-zone' }) + s1.tree.declareDefaultTree(split('row', flipped ? [files, main, sidebar] : [sidebar, main, files])) + s1.tree.setTreeGroupMinimized(sidebar.id, true) + s1.layout.setFileBrowserOpen(true) + + reload() + const { layout, tree, bind } = await loadStores() + const { findGroup } = await import('@/components/pane-shell/tree/model') + const { registry } = await import('@/contrib/registry') + + const disposers = [ + registry.register({ area: 'panes', id: 'sessions', data: { placement: 'left' } }), + registry.register({ area: 'panes', id: 'bots', data: { placement: 'left' } }), + registry.register({ area: 'panes', id: 'workspace', data: { placement: 'main' } }), + registry.register({ area: 'panes', id: 'files', data: { placement: 'right' } }) + ] + + try { + bind() + tree.bindTreeSideVisibility('right', layout.$fileBrowserOpen, layout.setFileBrowserOpen) + const side = flipped ? 'right' : 'left' + const toggle = flipped ? layout.toggleFileBrowserOpen : layout.toggleSidebarOpen + const $open = flipped ? layout.$fileBrowserOpen : layout.$sidebarOpen + const otherToggle = flipped ? layout.toggleSidebarOpen : layout.toggleFileBrowserOpen + + // Boot respects minimize; the OTHER physical button must not restore it. + expect(findGroup(tree.$layoutTree.get()!, sidebar.id)?.minimized).toBe(true) + otherToggle() + expect(tree.$collapsedTreeSides.get().has(flipped ? 'left' : 'right')).toBe(true) + expect(findGroup(tree.$layoutTree.get()!, sidebar.id)?.minimized).toBe(true) + + toggle() + expect($open.get()).toBe(true) + expect(tree.$collapsedTreeSides.get().has(side)).toBe(false) + expect(findGroup(tree.$layoutTree.get()!, sidebar.id)).toMatchObject({ active: 'bots', minimized: false }) + toggle() + expect($open.get()).toBe(false) + expect(tree.$collapsedTreeSides.get().has(side)).toBe(true) + toggle() + expect($open.get()).toBe(true) + expect(tree.$collapsedTreeSides.get().has(side)).toBe(false) + expect(findGroup(tree.$layoutTree.get()!, sidebar.id)?.active).toBe('bots') + } finally { + disposers.forEach(dispose => dispose()) + } + } + }) })