fix(desktop): recover minimized sidebars from their existing controls

Co-authored-by: wukangcheng1994 <160389295+wukangcheng1994@users.noreply.github.com>
This commit is contained in:
brooklyn!
2026-09-11 04:00:24 -07:00
parent 8c74118c4a
commit 477194879c
3 changed files with 193 additions and 50 deletions

View File

@@ -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)
}
/**

View File

@@ -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')
}

View File

@@ -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())
}
}
})
})