From 68f75db5e558ea2de94dc2bdd312f5090c7058c3 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 22 Sep 2026 16:21:34 -0500 Subject: [PATCH 1/6] fix(desktop): restore independent Simple and Advanced layouts --- apps/desktop/e2e/interface-mode.spec.ts | 37 ++-- apps/desktop/src/app/contrib/controller.tsx | 60 +----- .../desktop/src/app/contrib/layout-presets.ts | 2 +- apps/desktop/src/app/contrib/layout-sides.ts | 51 +++++ .../tree/mode-layout-memory.test.ts | 182 ++++++++++++++++++ .../renderer/floating-mode-memory.test.tsx | 46 +++++ .../tree/renderer/floating-panes.test.tsx | 9 +- .../tree/renderer/floating-panes.tsx | 81 ++++---- .../src/components/pane-shell/tree/store.ts | 163 +++++++++------- .../src/lib/layout-persistence.test.ts | 84 ++++++++ apps/desktop/src/lib/layout-persistence.ts | 167 ++++++++++++++++ apps/desktop/src/store/interface-mode.ts | 16 +- apps/desktop/src/store/layout.ts | 6 +- apps/desktop/src/store/panes.ts | 62 ++---- website/docs/user-guide/desktop.md | 10 +- 15 files changed, 739 insertions(+), 237 deletions(-) create mode 100644 apps/desktop/src/app/contrib/layout-sides.ts create mode 100644 apps/desktop/src/components/pane-shell/tree/mode-layout-memory.test.ts create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/floating-mode-memory.test.tsx create mode 100644 apps/desktop/src/lib/layout-persistence.test.ts create mode 100644 apps/desktop/src/lib/layout-persistence.ts diff --git a/apps/desktop/e2e/interface-mode.spec.ts b/apps/desktop/e2e/interface-mode.spec.ts index 9339a29474..a59e2d0e3a 100644 --- a/apps/desktop/e2e/interface-mode.spec.ts +++ b/apps/desktop/e2e/interface-mode.spec.ts @@ -6,11 +6,13 @@ * install would notice: the instrumentation leaves (titlebar tools beyond * Settings + Layout editor, the statusbar, the machinery rows in the sidebar), * the one-key reveal still opens the terminal for the session, and on the way - * back every stored preference is byte-identical — nothing under - * `hermes.desktop.*` changed except the mode key itself, which is gone again. + * back shared preferences remain byte-identical. Arrangement records belong + * to each mode and are covered by the layout-memory regression suite. * * Prerequisite: `npm run build` must have been run so dist/ exists. */ +import { LAYOUT_KEYS } from '../src/lib/layout-persistence' + import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' import { expect, test } from './test' @@ -28,18 +30,23 @@ test.afterAll(async () => { fixture = null }) -/** Every `hermes.desktop.*` record except the mode key — the user's actual preferences. */ +/** Shared preferences exclude the mode selector and its arrangement records. */ async function preferenceSnapshot(page: MockBackendFixture['page']): Promise> { - return page.evaluate((modeKey: string) => { - const out: Record = {} - for (let i = 0; i < window.localStorage.length; i += 1) { - const key = window.localStorage.key(i) - if (key && key.startsWith('hermes.desktop.') && key !== modeKey) { - out[key] = window.localStorage.getItem(key) ?? '' + return page.evaluate( + ({ modeKey, layoutKeys }) => { + const arrangement = new Set(layoutKeys.flatMap(key => [key, `${key}.simple`])) + arrangement.add('hermes.desktop.layoutModeScopes.v1') + const out: Record = {} + for (let i = 0; i < window.localStorage.length; i += 1) { + const key = window.localStorage.key(i) + if (key && key.startsWith('hermes.desktop.') && key !== modeKey && !arrangement.has(key)) { + out[key] = window.localStorage.getItem(key) ?? '' + } } - } - return out - }, MODE_KEY) + return out + }, + { modeKey: MODE_KEY, layoutKeys: Object.values(LAYOUT_KEYS) } + ) } async function pickMode(page: MockBackendFixture['page'], mode: 'Simple' | 'Advanced'): Promise { @@ -73,7 +80,7 @@ test('Simple hides the machinery, keeps the doors, and leaves preferences untouc // Statusbar and the sidebar's machinery rows are gone; the conversation isn't. await expect(page.locator('[data-slot="statusbar"]')).toHaveCount(0) await expect(page.getByRole('button', { name: /^Scheduled jobs/ })).toHaveCount(0) - await expect(page.getByRole('button', { name: /^Capabilities/ })).toHaveCount(0) + await expect(page.getByRole('button', { name: /^Capabilities/ })).toBeVisible() await expect(page.locator('textarea, [contenteditable="true"]').first()).toBeVisible() // Simple is a default, not a lock: the terminal reveal still answers for the session. @@ -81,13 +88,13 @@ test('Simple hides the machinery, keeps the doors, and leaves preferences untouc await page.keyboard.press('Control+`') await expect(page.locator('[data-terminal]').first()).toBeVisible() - // Simple wrote exactly one thing: the mode key. + // Mode selection may save arrangements, never the shared preferences. expect(await page.evaluate((key: string) => window.localStorage.getItem(key), MODE_KEY)).not.toBeNull() expect(await preferenceSnapshot(page)).toEqual(before) await pickMode(page, 'Advanced') - // Everything is back, the session reveal is cleared, and the disk looks like we were never here. + // Advanced is back and the session-only reveal has not changed shared preferences. await expect(appControls.getByRole('button', { name: 'HUD mode', exact: true })).toBeVisible() await expect(page.locator('[data-slot="statusbar"]')).toBeVisible() await expect(page.locator('[data-terminal]').first()).toBeHidden() diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 23936436f8..0c24c06015 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -17,12 +17,10 @@ import { $layoutTree, bindPaneVisibility, bindToolPaneCollapse, - bindTreeSideVisibility, declareDefaultTree, dismissTreePane, isPaneVisible, markCollapsePane, - mirrorLayoutTree, paneRootSide, registerLayoutResetHandler, registerPaneCloser, @@ -63,7 +61,6 @@ import { setYoloEnabled } from '@/lib/yolo-session' import { $interfaceMode, $showsAdvancedChrome, setModeContext, toggleSimpleMode } from '@/store/interface-mode' import { $fileBrowserOpen, - $panesFlipped, $sidebarOpen, FILE_BROWSER_DEFAULT_WIDTH, FILE_BROWSER_MAX_WIDTH, @@ -110,7 +107,8 @@ import { HudShell } from '../hud/hud-shell' import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store' import { $workspaceIsPage, WORKSPACE_PAGE_HEADER_AREA } from '../routes' -import { DEFAULT_TREE, registerLayoutPresets } from './layout-presets' +import { BASIC_TREE, DEFAULT_TREE, registerLayoutPresets } from './layout-presets' +import { bindLayoutSides } from './layout-sides' import { FilesPane, LogsPane, ReviewPaneContent } from './panes' import { ContribWiring, WiredPane } from './wiring' @@ -448,7 +446,7 @@ registry.registerMany([ registerLayoutPresets() -declareDefaultTree(DEFAULT_TREE) +declareDefaultTree(DEFAULT_TREE, BASIC_TREE) // Bundled plugins load AFTER core, so a same-id contribution from a plugin // deliberately overrides the core default (last writer wins). Third-party @@ -552,57 +550,7 @@ registerLayoutResetHandler(stackSessionTilesIntoMain) // bindToolPaneCollapse — so the boot rule it encodes is testable against the // real function instead of a copy. See its docblock for the semantics. -// SIDES have one source of truth: the TREE. The legacy $panesFlipped flag is -// DERIVED from where the sessions zone actually sits (TitlebarControls maps -// its left/right buttons through it), so dragging sessions across — or -// applying a mirrored preset — remaps the buttons automatically. The flip -// action (⌘\ / titlebar) mirrors the tree only when they disagree. -const sessionsOnRight = () => { - const tree = $layoutTree.get() - - if (!tree) { - return null - } - - const order = allPaneIds(tree) - const sessions = order.indexOf('sessions') - const main = order.indexOf('workspace') - - return sessions >= 0 && main >= 0 ? sessions > main : null -} - -$layoutTree.subscribe(() => { - const flipped = sessionsOnRight() - - if (flipped !== null && flipped !== $panesFlipped.get()) { - $panesFlipped.set(flipped) - } -}) - -$panesFlipped.listen(flipped => { - const current = sessionsOnRight() - - if (current !== null && current !== flipped) { - mirrorLayoutTree() - } -}) - -// Side toggles (titlebar buttons, ⌘B / ⌘J) collapse a whole edge of the main -// zone — everything on it hides together, whatever has been rearranged there. -// Which store owns which edge follows the panes: mirrored, the sidebar sits -// on the right and $sidebarOpen goes with it (see sidebarSide in store/layout). -const $leftEdgeOpen = computed([$panesFlipped, $sidebarOpen, $fileBrowserOpen], (flipped, sidebar, files) => - flipped ? files : sidebar -) - -const $rightEdgeOpen = computed([$panesFlipped, $sidebarOpen, $fileBrowserOpen], (flipped, sidebar, files) => - flipped ? sidebar : files -) - -bindTreeSideVisibility('left', $leftEdgeOpen, open => ($panesFlipped.get() ? setFileBrowserOpen : setSidebarOpen)(open)) -bindTreeSideVisibility('right', $rightEdgeOpen, open => - ($panesFlipped.get() ? setSidebarOpen : setFileBrowserOpen)(open) -) +bindLayoutSides() // Workspace-scoped surfaces: the file tree and git diff only mean something // inside a project. A detached chat (no cwd) hides them — their zones diff --git a/apps/desktop/src/app/contrib/layout-presets.ts b/apps/desktop/src/app/contrib/layout-presets.ts index c5aa882c62..9b3ad9b8c5 100644 --- a/apps/desktop/src/app/contrib/layout-presets.ts +++ b/apps/desktop/src/app/contrib/layout-presets.ts @@ -53,7 +53,7 @@ const FOCUS_TREE = split( // files a right column that ⌘J / ⌘G open. A tree that simply omitted them was // a lie — applying it adopts every missing pane back in as workspace tabs, // which is Focus. -const BASIC_TREE = split( +export const BASIC_TREE = split( 'row', [ group(['sessions']), diff --git a/apps/desktop/src/app/contrib/layout-sides.ts b/apps/desktop/src/app/contrib/layout-sides.ts new file mode 100644 index 0000000000..1e1e2baca9 --- /dev/null +++ b/apps/desktop/src/app/contrib/layout-sides.ts @@ -0,0 +1,51 @@ +import { computed } from 'nanostores' + +import { allPaneIds } from '@/components/pane-shell/tree/model' +import { $layoutTree, bindTreeSideVisibility, mirrorLayoutTree } from '@/components/pane-shell/tree/store' +import { modeLayout } from '@/store/interface-mode' +import { $fileBrowserOpen, $panesFlipped, $sidebarOpen, setFileBrowserOpen, setSidebarOpen } from '@/store/layout' + +/** Side toggles belong to panes, not physical edges. Derive the flip from the + * tree so dragging sessions or choosing a mirrored preset remaps the buttons. */ +export function bindLayoutSides() { + const sessionsOnRight = () => { + const tree = $layoutTree.get() + + if (!tree) { + return null + } + + const order = allPaneIds(tree) + const sessions = order.indexOf('sessions') + const main = order.indexOf('workspace') + + return sessions >= 0 && main >= 0 ? sessions > main : null + } + + $layoutTree.subscribe(() => { + const flipped = sessionsOnRight() + + if (flipped !== null && flipped !== $panesFlipped.get()) { + $panesFlipped.set(flipped) + } + }) + + $panesFlipped.listen(flipped => { + const current = sessionsOnRight() + + // Restoration replaces the tree; a transient mismatch is not a flip gesture. + if (!modeLayout.restoring && current !== null && current !== flipped) { + mirrorLayoutTree() + } + }) + + const $leftEdgeOpen = computed([$panesFlipped, $sidebarOpen, $fileBrowserOpen], (flipped, sidebar, files) => + flipped ? files : sidebar + ) + const $rightEdgeOpen = computed([$panesFlipped, $sidebarOpen, $fileBrowserOpen], (flipped, sidebar, files) => + flipped ? sidebar : files + ) + + bindTreeSideVisibility('left', $leftEdgeOpen, open => ($panesFlipped.get() ? setFileBrowserOpen : setSidebarOpen)(open)) + bindTreeSideVisibility('right', $rightEdgeOpen, open => ($panesFlipped.get() ? setSidebarOpen : setFileBrowserOpen)(open)) +} diff --git a/apps/desktop/src/components/pane-shell/tree/mode-layout-memory.test.ts b/apps/desktop/src/components/pane-shell/tree/mode-layout-memory.test.ts new file mode 100644 index 0000000000..6c34625913 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/mode-layout-memory.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +import type { LayoutNode } from './model' + +beforeEach(() => { + window.localStorage.clear() + vi.resetModules() +}) + +async function boot() { + const mode = await import('@/store/interface-mode') + const panes = await import('@/store/panes') + const layout = await import('@/store/layout') + const tree = await import('./store') + const model = await import('./model') + const { registry } = await import('@/contrib/registry') + const presets = await import('./presets') + const { registerLayoutPresets, DEFAULT_TREE, BASIC_TREE } = await import('@/app/contrib/layout-presets') + const terminal = await import('@/app/right-sidebar/store') + const { bindLayoutSides } = await import('@/app/contrib/layout-sides') + + for (const [id, placement] of [ + ['sessions', 'left'], + ['workspace', 'main'], + ['files', 'right'], + ['review', 'right'], + ['terminal', 'bottom'] + ] as const) { + registry.register({ id, area: 'panes', data: { placement }, render: () => null }) + } + registry.register({ + id: 'bots', + area: 'panes', + render: () => null, + data: { placement: 'left', dock: { pane: 'sessions', pos: 'center' } } + }) + registerLayoutPresets() + tree.declareDefaultTree(DEFAULT_TREE, BASIC_TREE) + tree.watchContributedPanes() + bindLayoutSides() + tree.bindPaneVisibility( + 'files', + layout.$fileBrowserOpen, + () => layout.setFileBrowserOpen(false), + () => layout.setFileBrowserOpen(true) + ) + tree.bindToolPaneCollapse( + 'terminal', + terminal.$terminalTakeover, + () => terminal.setTerminalTakeover(false), + () => terminal.setTerminalTakeover(true), + mode.$showsAdvancedChrome + ) + + const apply = (id: string) => { + const preset = registry.getArea('layouts').find(p => p.id === id)! + presets.applyLayoutPreset(id, preset.data as LayoutNode) + } + const snapshot = () => ({ + tree: structuredClone(tree.$layoutTree.get()), + panes: structuredClone(panes.$paneStates.get()), + preset: tree.$activePresetId.get(), + hidden: [...tree.$hiddenStripTabs.get()], + dismissed: [...tree.$dismissedPanes.get()], + placed: [...tree.$userPlacedPanes.get()], + collapsed: [...tree.$collapsedTreeSides.get()].sort(), + flipped: layout.$panesFlipped.get() + }) + + return { mode, panes, layout, tree, model, registry, presets, terminal, apply, snapshot } +} + +it('restores independently customized modes through real pane bindings and reloads', async () => { + let app = await boot() + const { mode, panes, layout, tree, model, registry, presets, terminal } = app + const decks = registry.getArea('layouts').filter(p => presets.layoutPresetTier(p.id) === 'advanced') + + // Every Advanced preset must survive both Simple sidebar arrangements. + for (const deck of decks) { + for (const simpleId of ['sidebar-left', 'sidebar-right']) { + mode.setInterfaceMode('advanced') + app.apply(deck.id) + tree.setStripTabHidden('bots', true) + panes.setPaneWidthOverride('chat-sidebar', 317) + panes.setPaneHeightOverride('terminal', 243) + tree.setTreeGroupMinimized(model.findGroupOfPane(tree.$layoutTree.get()!, 'terminal')!.id, true) + layout.setSidebarOpen(false) + const advanced = app.snapshot() + const saved = window.localStorage.getItem('hermes.desktop.layoutTree.v2') + + mode.setInterfaceMode('simple') + app.apply(simpleId) + panes.setPaneWidthOverride('chat-sidebar', 225) + tree.setStripTabHidden('bots', false) + layout.setSidebarOpen(true) + const simple = app.snapshot() + expect(window.localStorage.getItem('hermes.desktop.layoutTree.v2')).toBe(saved) + + terminal.$terminalInjection.set('pending fixture command') + layout.$rightRailActiveTabId.set('file:fixture.txt') + mode.setInterfaceMode('advanced') + expect(app.snapshot()).toEqual(advanced) + expect(tree.$hiddenTreePanes.get().has('bots')).toBe(true) + expect(terminal.$terminalInjection.get()).toBe('pending fixture command') + expect(layout.$rightRailActiveTabId.get()).toBe('file:fixture.txt') + mode.setInterfaceMode('simple') + expect(app.snapshot()).toEqual(simple) + expect(tree.$hiddenTreePanes.get().has('bots')).toBe(false) + mode.setInterfaceMode('simple') + expect(app.snapshot()).toEqual(simple) + } + } + + // A moved/customized tree, dismissals and an unfinished resize survive too. + mode.setInterfaceMode('advanced') + app.apply('default') + tree.moveTreePane('files', { groupId: model.findGroupOfPane(tree.$layoutTree.get()!, 'workspace')!.id, pos: 'center' }) + tree.dismissTreePane('review') + const root = tree.$layoutTree.get()! + + if (root.type !== 'split') { + throw new Error('fixture must have a split') + } + + tree.setTreeSplitWeights(root.id, root.weights.map((weight, i) => weight + i)) + const advanced = app.snapshot() + mode.setInterfaceMode('simple') + const simple = app.snapshot() + vi.resetModules() + app = await boot() + expect(app.snapshot()).toEqual({ ...simple, tree: app.model.normalize(simple.tree!) }) + app.mode.setInterfaceMode('advanced') + expect(app.snapshot()).toEqual({ ...advanced, tree: app.model.normalize(advanced.tree!) }) + + // Newly opened shared work is adopted, not lost when restoring an older tree. + app.mode.setInterfaceMode('simple') + app.registry.register({ id: 'new-shared-pane', area: 'panes', data: { placement: 'main' }, render: () => null }) + app.mode.setInterfaceMode('advanced') + expect(app.model.allPaneIds(app.tree.$layoutTree.get()!)).toContain('new-shared-pane') +}) + +it.each(['advanced', 'simple'] as const)('keeps legacy %s data and never re-inherits it after migration', async initialMode => { + const { group, split, normalize } = await import('./model') + const legacyTree = split( + 'row', + [group(['sessions', 'bots']), group(['workspace']), group(['files', 'review', 'terminal'])], + [2, 8, 3] + ) + const legacyPanes = { 'chat-sidebar': { open: false, widthOverride: 301 }, 'file-browser': { open: true } } + const raw = JSON.stringify(legacyTree) + window.localStorage.setItem('hermes.desktop.layoutTree.v2', raw) + window.localStorage.setItem('hermes.desktop.paneStates.v1', JSON.stringify(legacyPanes)) + window.localStorage.setItem('hermes.desktop.layoutPreset.active', 'custom') + window.localStorage.setItem('hermes.desktop.hiddenStripTabs.v1', '["bots"]') + if (initialMode === 'simple') { + window.localStorage.setItem('hermes.desktop.interfaceMode.v1', 'simple') + } + + const { mode, tree, panes, layout } = await boot() + expect(mode.$interfaceMode.get()).toBe(initialMode) + expect(panes.$paneStates.get()).toEqual(legacyPanes) + expect(tree.$hiddenStripTabs.get().has('bots')).toBe(true) + // Boot collapse may update the tree, so preserve the settled arrangement. + const settled = structuredClone(tree.$layoutTree.get()) + mode.setInterfaceMode(initialMode === 'simple' ? 'advanced' : 'simple') + expect(tree.$collapsedTreeSides.get().has(layout.sidebarSide())).toBe(!layout.$sidebarOpen.get()) + expect(tree.$collapsedTreeSides.get().has(layout.fileBrowserSide())).toBe(!layout.$fileBrowserOpen.get()) + mode.setInterfaceMode(initialMode) + expect(tree.$layoutTree.get()).toEqual(settled) + expect(panes.$paneStates.get()).toEqual(legacyPanes) + + mode.setInterfaceMode('simple') + tree.setStripTabHidden('bots', false) + panes.setPaneWidthOverride('chat-sidebar', 210) + vi.resetModules() + const reloaded = await boot() + expect(reloaded.tree.$hiddenStripTabs.get().size).toBe(0) + expect(reloaded.panes.$paneStates.get()['chat-sidebar'].widthOverride).toBe(210) + reloaded.mode.setInterfaceMode('advanced') + expect(reloaded.panes.$paneStates.get()).toEqual(legacyPanes) + expect(reloaded.model.allPaneIds(reloaded.tree.$layoutTree.get()!)).toEqual(reloaded.model.allPaneIds(normalize(legacyTree))) +}) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/floating-mode-memory.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/floating-mode-memory.test.tsx new file mode 100644 index 0000000000..0f230140f8 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/floating-mode-memory.test.tsx @@ -0,0 +1,46 @@ +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' + +afterEach(cleanup) + +it.each([false, true])('restores floating geometry and collapse with keep-alive=%s', async keepAlive => { + window.localStorage.clear() + vi.resetModules() + window.localStorage.setItem('hermes.desktop.floatingPanes.v1', JSON.stringify({ card: { x: 37, y: 71 } })) + const { registry } = await import('@/contrib/registry') + const { setInterfaceMode } = await import('@/store/interface-mode') + const { FloatingPanes } = await import('./floating-panes') + const dispose = registry.register({ + area: 'panes', + id: 'card', + title: 'Card', + data: { placement: 'floating', width: 240, height: 180, lifecycleKeepAlive: keepAlive }, + render: () => + }) + try { + const view = render() + const card = () => view.container.querySelector('[data-floating-pane="card"]')! + expect(card().style.left).toBe('37px') + expect(card().style.top).toBe('71px') + const input = view.getByRole('textbox') as HTMLInputElement + fireEvent.change(input, { target: { value: 'unsaved' } }) + act(() => setInterfaceMode('simple')) + expect(card().style.left).not.toBe('37px') + const simplePosition = card().style.left + fireEvent.click(view.getByRole('button')) + expect(input.isConnected).toBe(keepAlive) + act(() => setInterfaceMode('advanced')) + expect(card().style.left).toBe('37px') + expect(card().style.top).toBe('71px') + expect(view.getByRole('textbox')).toBeDefined() + if (keepAlive) { + expect(view.getByRole('textbox')).toBe(input) + expect(input.value).toBe('unsaved') + } + act(() => setInterfaceMode('simple')) + expect(card().style.left).toBe(simplePosition) + expect(view.queryByRole('textbox')).toBeNull() + } finally { + act(dispose) + } +}) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/floating-panes.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/floating-panes.test.tsx index 8306b14f71..6f9c68e689 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/floating-panes.test.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/floating-panes.test.tsx @@ -9,10 +9,10 @@ import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { registry } from '@/contrib/registry' import { reactRoot } from '@/test/react-root' -import { FloatingPanes } from './floating-panes' +let registry: typeof import('@/contrib/registry').registry +let FloatingPanes: typeof import('./floating-panes').FloatingPanes const mount = reactRoot() let disposers: (() => void)[] = [] @@ -54,8 +54,11 @@ function registerHud(data: Record) { } describe('FloatingPanes (live DOM)', () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules() window.localStorage.clear() + registry = (await import('@/contrib/registry')).registry + FloatingPanes = (await import('./floating-panes')).FloatingPanes resizeWindow(1440, 900) // setPointerCapture / releasePointerCapture don't exist in jsdom. Element.prototype.setPointerCapture = vi.fn() diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/floating-panes.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/floating-panes.tsx index 4aa74695ca..aae3f3b6e2 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/floating-panes.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/floating-panes.tsx @@ -9,7 +9,7 @@ */ import { useStore } from '@nanostores/react' -import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react' +import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef } from 'react' import { HUD_SURFACE } from '@/app/floating-hud' import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar' @@ -18,9 +18,12 @@ import { Codicon } from '@/components/ui/codicon' import { ContribBoundary, ContribRender } from '@/contrib/react/boundary' import { useContributions } from '@/contrib/react/use-contributions' import type { Contribution } from '@/contrib/types' -import { readJson, writeJson } from '@/lib/storage' +import { LAYOUT_KEYS } from '@/lib/layout-persistence' +import { Codecs } from '@/lib/persisted' import { cn } from '@/lib/utils' +import { modeLayout } from '@/store/interface-mode' +import { hiddenPaneProps, PaneLifecycleContext, PaneVisibleContext } from '../../pane-visibility' import { $hiddenTreePanes } from '../store' import { @@ -32,10 +35,9 @@ import { type FloatingViewport, reflowRect } from './floating-rect' +import { PaneBody } from './pane-body' import { paneChrome } from './track-model' -const POSITIONS_KEY = 'hermes.desktop.floatingPanes.v1' - const DEFAULT_SIZE = { width: 240, height: 180 } interface StoredRect { @@ -44,7 +46,14 @@ interface StoredRect { collapsed?: boolean } -const readStored = (): Record => readJson>(POSITIONS_KEY) ?? {} +const $positions = modeLayout.atom>( + LAYOUT_KEYS.floating, + () => ({}), + Codecs.json(value => + value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} + ), + true +) const viewportNow = (): FloatingViewport => ({ width: window.innerWidth, @@ -61,21 +70,30 @@ function FloatingPane({ pane }: { pane: Contribution }) { height: floatingPx(chrome.height, DEFAULT_SIZE.height) } - const [rect, setRect] = useState(() => { - const stored = readStored()[pane.id] - const spawned = anchoredRect(anchor, size, viewportNow()) - - return stored ? { ...spawned, x: stored.x, y: stored.y } : spawned - }) - - const [collapsed, setCollapsed] = useState(() => readStored()[pane.id]?.collapsed ?? false) - + const stored = useStore($positions)[pane.id] + const rect = { ...anchoredRect(anchor, size, viewportNow()), ...stored } + const collapsed = stored?.collapsed ?? false const drag = useRef<{ x: number; y: number } | null>(null) const viewport = useRef(viewportNow()) + const setRect = useCallback( + (update: (current: FloatingRect) => FloatingRect) => { + const positions = $positions.get() + const current = positions[pane.id] + const next = update({ + ...anchoredRect(anchor, { width: size.width, height: size.height }, viewport.current), + ...current + }) + $positions.set({ ...positions, [pane.id]: { x: next.x, y: next.y, collapsed: current?.collapsed } }) + }, + [pane.id, anchor, size.width, size.height] + ) + const persist = useCallback( (next: FloatingRect, nextCollapsed: boolean) => { - writeJson(POSITIONS_KEY, { ...readStored(), [pane.id]: { x: next.x, y: next.y, collapsed: nextCollapsed } }) + const positions = { ...$positions.get(), [pane.id]: { x: next.x, y: next.y, collapsed: nextCollapsed } } + $positions.set(positions) + modeLayout.write(LAYOUT_KEYS.floating, JSON.stringify(positions)) }, [pane.id] ) @@ -88,7 +106,7 @@ function FloatingPane({ pane }: { pane: Contribution }) { setRect(current => reflowRect(current, anchor, viewport.current, next)) viewport.current = next - }, [anchor]) + }, [anchor, setRect]) useEffect(() => { window.addEventListener('resize', handleResize) @@ -121,7 +139,7 @@ function FloatingPane({ pane }: { pane: Contribution }) { viewport.current ) ) - }, []) + }, [setRect]) const onPointerUp = useCallback( (event: ReactPointerEvent) => { @@ -131,21 +149,12 @@ function FloatingPane({ pane }: { pane: Contribution }) { drag.current = null event.currentTarget.releasePointerCapture?.(event.pointerId) - setRect(current => { - persist(current, collapsed) - - return current - }) + persist({ ...rect, ...$positions.get()[pane.id] }, collapsed) }, - [collapsed, persist] + [pane.id, rect, collapsed, persist] ) - const toggleCollapsed = () => - setCollapsed(current => { - persist(rect, !current) - - return !current - }) + const toggleCollapsed = () => persist(rect, !collapsed) return (
- {!collapsed && ( -
- {pane.render && } -
+ {(!collapsed || chrome.lifecycleKeepAlive) && ( + )}
) diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index 42d05c42ed..53ba7d427b 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -10,7 +10,10 @@ import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '@/app/layout-constants' import { setPluginEnabled } from '@/contrib/plugins-store' import { $registryVersion, registry } from '@/contrib/registry' import { translateNow } from '@/i18n' -import { readJson, readKey, writeJson, writeKey } from '@/lib/storage' +import { LAYOUT_KEYS } from '@/lib/layout-persistence' +import { Codecs } from '@/lib/persisted' +import { writeKey } from '@/lib/storage' +import { type InterfaceMode, modeLayout } from '@/store/interface-mode' import { notify } from '@/store/notifications' import { clearAllPaneSizeOverrides } from '@/store/panes' import { isBrowserWindow, isSecondaryWindow } from '@/store/windows' @@ -46,20 +49,9 @@ import { tabStripVisibleForZone } from './renderer/strip-visibility' // v2: v1 trees were saved against placeholder panes with index-order zone // assignment (chat could land in a corner cell). Retire them wholesale. -const STORAGE_KEY = 'hermes.desktop.layoutTree.v2' - writeKey('hermes.desktop.layoutTree.v1', null) -let defaultTree: LayoutNode | null = null - -function loadPersisted(): LayoutNode | null { - const parsed = readJson(STORAGE_KEY) - - // Canonicalize on load: bring attributes onto the current schema (see - // migratePersistedTree — the retired `headerHidden` is dropped here) and - // re-flatten the structure. - return isLayoutNode(parsed) ? normalize(migratePersistedTree(parsed)) : null -} +const defaultTrees: Record = { advanced: null, simple: null } function persist(tree: LayoutNode | null) { // A secondary window (single-chat pop-out) shares the origin's localStorage; @@ -69,23 +61,31 @@ function persist(tree: LayoutNode | null) { return } - writeJson(STORAGE_KEY, tree) + modeLayout.write(LAYOUT_KEYS.tree, tree === null ? null : JSON.stringify(tree)) } /** The live tree (null until a default is declared). A secondary window ignores * the persisted (primary) layout and boots to the default — nothing but its * own routed session. */ -export const $layoutTree = atom(isSecondaryWindow() || isBrowserWindow() ? null : loadPersisted()) +export const $layoutTree = modeLayout.atom( + LAYOUT_KEYS.tree, + () => defaultTrees[modeLayout.mode], + Codecs.json(parsed => (isLayoutNode(parsed) ? normalize(migratePersistedTree(parsed)) : defaultTrees[modeLayout.mode])), + true +) /** * Which layout preset the current tree came from; `'custom'` after the user * rearranges anything. Drives the picker's active highlight. */ -export const $activePresetId = atom(readKey('hermes.desktop.layoutPreset.active') ?? 'default') +export const $activePresetId = modeLayout.atom( + LAYOUT_KEYS.preset, + () => (modeLayout.mode === 'simple' ? 'sidebar-left' : 'default'), + Codecs.text +) export function markActivePreset(id: string) { $activePresetId.set(id) - writeKey('hermes.desktop.layoutPreset.active', id) } /** Pane id being dragged (tree drag session), null when idle. Also set to the @@ -148,7 +148,7 @@ export function setTreePaneHidden(paneId: string, hidden: boolean) { // Callers that want user-intent semantics (open the side, front the tab) // must call `revealTreePane` explicitly. We still front the pane in its // group so it's visible the next time the column is shown. - if (!hidden) { + if (!hidden && !modeLayout.restoring) { frontPaneInGroup(paneId) } } @@ -191,24 +191,18 @@ function frontPaneInGroup(paneId: string) { * - closing the sole pane from a plugin disables that plugin, preserving the * discoverable Capabilities → Plugins recovery path for single-pane plugins. */ -const DISMISSED_KEY = 'hermes.desktop.dismissedPanes.v1' - -function loadDismissed(): ReadonlySet { - return new Set(readJson(DISMISSED_KEY) ?? []) +const paneSetCodec = { + decode: (raw: string): ReadonlySet => new Set(Codecs.stringArray.decode(raw)), + encode: (value: ReadonlySet) => Codecs.stringArray.encode([...value]) } -export const $dismissedPanes = atom>(loadDismissed()) - -function saveDismissed(next: ReadonlySet) { - $dismissedPanes.set(next) - writeJson(DISMISSED_KEY, next.size === 0 ? null : [...next]) -} +export const $dismissedPanes = modeLayout.atom>(LAYOUT_KEYS.dismissed, () => new Set(), paneSetCodec) function setDismissed(paneId: string, dismissed: boolean) { const next = toggledSet($dismissedPanes.get(), paneId, dismissed) if (next) { - saveDismissed(next) + $dismissedPanes.set(next) } } @@ -230,7 +224,7 @@ export function undismissTreePanes(paneIds: Iterable): void { } if (next.size !== dismissed.size) { - saveDismissed(next) + $dismissedPanes.set(next) } } @@ -239,13 +233,19 @@ export function undismissTreePanes(paneIds: Iterable): void { // re-opening it docks at the size the user left it. Without this every // re-open split the anchor zone [1, 1] again: each agent-triggered browser // open re-took half the chat, whatever the user had resized it to. -const PANE_SHARE_KEY = 'hermes.desktop.paneShare.v1' - -const paneShares: Record = readJson>(PANE_SHARE_KEY) ?? {} - const validShare = (share: unknown): share is number => typeof share === 'number' && Number.isFinite(share) && share > 0 && share < 1 +const $paneShares = modeLayout.atom>( + LAYOUT_KEYS.shares, + () => ({}), + Codecs.json(value => + value && typeof value === 'object' + ? Object.fromEntries(Object.entries(value).filter(([, share]) => validShare(share))) + : {} + ) +) + function rememberPaneShare(tree: LayoutNode, paneId: string) { const zone = findGroupOfPane(tree, paneId) @@ -270,15 +270,14 @@ function rememberPaneShare(tree: LayoutNode, paneId: string) { const share = pair > 0 ? (parent.weights[at] ?? 1) / pair : null if (validShare(share)) { - paneShares[paneId] = share - writeJson(PANE_SHARE_KEY, paneShares) + $paneShares.set({ ...$paneShares.get(), [paneId]: share }) } } /** The [target, added] weight pair a re-inserted pane's edge split should get, * or undefined for the even default. Persisted state is untrusted. */ function recalledEdgeWeights(paneId: string): [number, number] | undefined { - const share = paneShares[paneId] + const share = $paneShares.get()[paneId] return validShare(share) ? [1 - share, share] : undefined } @@ -289,14 +288,7 @@ function recalledEdgeWeights(paneId: string): [number, number] | undefined { // Persisted separately from `$hiddenTreePanes` (whose persistence each side // binding owns) so a hidden Bots tab stays hidden across launches even though // dock enforcement re-adopts the pane into the sessions zone every boot. -const HIDDEN_STRIP_TAB_KEY = 'hermes.desktop.hiddenStripTabs.v1' - -export const $hiddenStripTabs = atom>(new Set(readJson(HIDDEN_STRIP_TAB_KEY) ?? [])) - -function saveHiddenStripTabs(next: ReadonlySet) { - $hiddenStripTabs.set(next) - writeJson(HIDDEN_STRIP_TAB_KEY, next.size === 0 ? null : [...next]) -} +export const $hiddenStripTabs = modeLayout.atom>(LAYOUT_KEYS.hiddenTabs, () => new Set(), paneSetCodec) export function isStripTabHidden(paneId: string): boolean { return $hiddenStripTabs.get().has(paneId) @@ -335,7 +327,7 @@ export function setStripTabHidden(paneId: string, hidden: boolean): boolean { const next = toggledSet($hiddenStripTabs.get(), paneId, hidden) if (next) { - saveHiddenStripTabs(next) + $hiddenStripTabs.set(next) } setTreePaneHidden(paneId, hidden) @@ -345,9 +337,11 @@ export function setStripTabHidden(paneId: string, hidden: boolean): boolean { // Boot hydration: re-apply persisted hides through the same chrome-hidden set // the strips render from ($hiddenTreePanes starts empty every launch). -for (const paneId of $hiddenStripTabs.get()) { - setTreePaneHidden(paneId, true) -} +$hiddenStripTabs.subscribe((hidden, previous) => { + for (const paneId of new Set([...hidden, ...(previous ?? [])])) { + setTreePaneHidden(paneId, hidden.has(paneId)) + } +}) const paneClosers: Record void> = {} const paneOpeners: Record void> = {} @@ -994,7 +988,19 @@ export function closeTreePane(paneId: string) { */ export type TreeSide = 'left' | 'right' -export const $collapsedTreeSides = atom>(new Set()) +export const $collapsedTreeSides = modeLayout.atom>(LAYOUT_KEYS.collapsed, () => new Set(), { + decode: raw => { + const sides: unknown = JSON.parse(raw) + + if (!Array.isArray(sides) || !sides.every(side => side === 'left' || side === 'right')) { + throw new Error('Invalid collapsed sides') + } + + return new Set(sides) + }, + encode: value => JSON.stringify([...value]) +}) +const hasPersistedSides = modeLayout.has(LAYOUT_KEYS.collapsed) // Side visibility is DERIVED from an app store (the binding owns persistence // + button state). Reveals un-collapse the column directly instead of writing @@ -1002,6 +1008,15 @@ export const $collapsedTreeSides = atom>(new Set()) // so a neighbour's reveal must not press it. Layout reset still reopens every // side through its setter, because there the toggles SHOULD move. const sideOpeners: Partial void>> = {} +const sideVisibility: Partial boolean>> = {} + +modeLayout.onRestore(() => { + if (!modeLayout.has(LAYOUT_KEYS.collapsed)) { + $collapsedTreeSides.set( + new Set((Object.keys(sideVisibility) as TreeSide[]).filter(side => !sideVisibility[side]?.())) + ) + } +}) export function setTreeSideCollapsed(side: TreeSide, collapsed: boolean) { const next = toggledSet($collapsedTreeSides.get(), side, collapsed) @@ -1013,7 +1028,7 @@ export function setTreeSideCollapsed(side: TreeSide, collapsed: boolean) { // Opening a side is an intent to SEE it — heal any pane of that side that a // stale dismissal record removed from the tree, so ⌘B/⌘J can never press on // nothing. Closing chrome panes is NEVER permanent (main parity). - if (!collapsed) { + if (!collapsed && !modeLayout.restoring) { restoreDismissedSidePanes(side) } } @@ -1117,7 +1132,11 @@ export function bindTreeSideVisibility( setOpen: (open: boolean) => void ) { sideOpeners[side] = setOpen - setTreeSideCollapsed(side, !$open.get()) + sideVisibility[side] = () => $open.get() + + if (!hasPersistedSides) { + setTreeSideCollapsed(side, !$open.get()) + } $open.listen(open => setTreeSideCollapsed(side, !open)) } @@ -1147,7 +1166,7 @@ export function revealTreePane(paneId: string) { // Reveal beats a hide too: clear the persisted hide-only record, or the // pane pops back hidden on the next launch even though it's on screen now. if ($hiddenStripTabs.get().has(paneId)) { - saveHiddenStripTabs(toggledSet($hiddenStripTabs.get(), paneId, false) ?? $hiddenStripTabs.get()) + $hiddenStripTabs.set(toggledSet($hiddenStripTabs.get(), paneId, false) ?? $hiddenStripTabs.get()) } const side = treeSideOfPane(paneId) @@ -1284,17 +1303,19 @@ function adoptMissingPanes(target: LayoutNode, source: LayoutNode): LayoutNode { * persisted customization; a persisted tree from an older default adopts any * panes it's missing. */ -export function declareDefaultTree(tree: LayoutNode) { - defaultTree = tree +export function declareDefaultTree(tree: LayoutNode, simpleTree: LayoutNode = tree) { + defaultTrees.advanced = tree + defaultTrees.simple = simpleTree + const defaultTree = defaultTrees[modeLayout.mode]! const current = $layoutTree.get() if (!current) { - $layoutTree.set(tree) + $layoutTree.set(defaultTree) return } - const next = adoptMissingPanes(current, tree) + const next = adoptMissingPanes(current, defaultTree) if (next !== current) { commit(next) @@ -1524,6 +1545,7 @@ export function adoptContributedPanes(): void { /** Adopt now + on every registry change (call once from the app root). */ export function watchContributedPanes(): void { adoptContributedPanes() + modeLayout.onRestore(adoptContributedPanes) registry.subscribe(adoptContributedPanes) } @@ -1543,20 +1565,13 @@ function commit(next: LayoutNode | null) { // Presets and resets hand placement back to the app. // --------------------------------------------------------------------------- -const USER_PLACED_KEY = 'hermes.desktop.userPlacedPanes.v1' - -export const $userPlacedPanes = atom>(new Set(readJson(USER_PLACED_KEY) ?? [])) - -function saveUserPlaced(next: ReadonlySet) { - $userPlacedPanes.set(next) - writeJson(USER_PLACED_KEY, next.size === 0 ? null : [...next]) -} +export const $userPlacedPanes = modeLayout.atom>(LAYOUT_KEYS.placed, () => new Set(), paneSetCodec) function markPaneUserPlaced(paneId: string) { const next = toggledSet($userPlacedPanes.get(), paneId, true) if (next) { - saveUserPlaced(next) + $userPlacedPanes.set(next) } } @@ -1647,7 +1662,7 @@ export function applyTree(tree: LayoutNode, presetId: string, resting: readonly // previous arrangement would distort it. Same for user-placed pins: picking // a layout hands pane placement back to the app (auto-docking resumes). clearAllPaneSizeOverrides() - saveUserPlaced(new Set()) + $userPlacedPanes.set(new Set()) commit(previous ? adoptMissingPanes(tree, previous) : tree) markActivePreset(presetId) @@ -1949,7 +1964,11 @@ export function bindToolPaneCollapse( setPaneCollapsed(paneId, true) } - $open.listen(isOpen => (isOpen ? revealTreePane(paneId) : setPaneCollapsed(paneId, true))) + $open.listen(isOpen => { + if (!modeLayout.restoring) { + isOpen ? revealTreePane(paneId) : setPaneCollapsed(paneId, true) + } + }) registerPaneCloser(paneId, close) registerPaneOpener(paneId, open) @@ -2115,8 +2134,8 @@ export function resetLayoutTree() { clearAllPaneSizeOverrides() // Reset restores EVERYTHING — closed panes included — and hands pane // placement back to the app (user-placed pins cleared). - saveDismissed(new Set()) - saveUserPlaced(new Set()) + $dismissedPanes.set(new Set()) + $userPlacedPanes.set(new Set()) // Hide-only chrome tabs (sessions / Bots) come back too — clear their // persisted hides through the setter so $hiddenTreePanes agrees. @@ -2124,8 +2143,8 @@ export function resetLayoutTree() { setStripTabHidden(paneId, false) } - $layoutTree.set(defaultTree) - markActivePreset('default') + $layoutTree.set(defaultTrees[modeLayout.mode]) + markActivePreset(modeLayout.mode === 'simple' ? 'sidebar-left' : 'default') // Owners PRE-PLACE their panes into the fresh default (session tiles stack // into main as tabs) FIRST, so generic adoption sees them already in-tree // and never scatters them to their old edges. diff --git a/apps/desktop/src/lib/layout-persistence.test.ts b/apps/desktop/src/lib/layout-persistence.test.ts new file mode 100644 index 0000000000..c7b6fda874 --- /dev/null +++ b/apps/desktop/src/lib/layout-persistence.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { createLayoutPersistence, LAYOUT_KEYS } from './layout-persistence' +import { Codecs } from './persisted' + +beforeEach(() => window.localStorage.clear()) +afterEach(() => vi.unstubAllGlobals()) + +const stored = () => + Object.fromEntries( + Array.from({ length: window.localStorage.length }, (_, i) => { + const key = window.localStorage.key(i)! + + return [key, window.localStorage.getItem(key)] + }) + ) + +it('retains legacy data in memory when migration writes fail and retries on the next launch', () => { + const legacy = { sidebar: { open: false, widthOverride: 311 } } + window.localStorage.setItem(LAYOUT_KEYS.panes, JSON.stringify(legacy)) + const storage = window.localStorage + vi.stubGlobal('localStorage', { + getItem: storage.getItem.bind(storage), + removeItem: storage.removeItem.bind(storage), + setItem: () => { + throw new DOMException('Full', 'QuotaExceededError') + } + }) + const scope = createLayoutPersistence('simple', true) + const $panes = scope.atom(LAYOUT_KEYS.panes, () => ({}), Codecs.json()) + expect($panes.get()).toEqual(legacy) + $panes.set({ sidebar: { open: true, widthOverride: 220 } }) + scope.change('advanced', () => {}) + expect($panes.get()).toEqual(legacy) + scope.change('simple', () => {}) + expect($panes.get()).toEqual({ sidebar: { open: true, widthOverride: 220 } }) + expect(window.localStorage.getItem('hermes.desktop.layoutModeScopes.v1')).toBeNull() + expect(JSON.parse(window.localStorage.getItem(LAYOUT_KEYS.panes)!)).toEqual(legacy) + + vi.unstubAllGlobals() + createLayoutPersistence('simple', true) + expect(JSON.parse(window.localStorage.getItem(`${LAYOUT_KEYS.panes}.simple`)!)).toEqual(legacy) + expect(window.localStorage.getItem('hermes.desktop.layoutModeScopes.v1')).toBe('true') + + window.localStorage.clear() + window.localStorage.setItem(LAYOUT_KEYS.hiddenTabs, '["bots"]') + vi.stubGlobal('localStorage', { + getItem: storage.getItem.bind(storage), + removeItem: storage.removeItem.bind(storage), + setItem: (key: string, value: string) => { + if (key === 'hermes.desktop.layoutModeScopes.v1') { + throw new DOMException('Full', 'QuotaExceededError') + } + storage.setItem(key, value) + } + }) + const first = createLayoutPersistence('simple', true) + first.atom(LAYOUT_KEYS.hiddenTabs, () => [] as string[], Codecs.stringArray).set([]) + const reloaded = createLayoutPersistence('simple', true) + expect(reloaded.atom(LAYOUT_KEYS.hiddenTabs, () => [] as string[], Codecs.stringArray).get()).toEqual([]) +}) + +it('isolates invalid records and keeps auxiliary layouts off primary storage', () => { + window.localStorage.setItem(LAYOUT_KEYS.preset, 'user-custom') + window.localStorage.setItem(`${LAYOUT_KEYS.panes}.simple`, 'invalid-json') + const primary = createLayoutPersistence('advanced', true) + const $panes = primary.atom(LAYOUT_KEYS.panes, () => ({ width: 200 })) + $panes.set({ width: 300 }) + primary.change('simple', () => {}) + expect($panes.get()).toEqual({ width: 200 }) + primary.change('advanced', () => {}) + expect($panes.get()).toEqual({ width: 300 }) + + const before = stored() + const aux = createLayoutPersistence('simple', false) + const $preset = aux.atom(LAYOUT_KEYS.preset, () => 'default', Codecs.text) + expect($preset.get()).toBe('default') + $preset.set('auxiliary') + aux.change('advanced', () => {}) + $preset.set('auxiliary-advanced') + aux.change('simple', () => {}) + expect($preset.get()).toBe('auxiliary') + expect(stored()).toEqual(before) +}) diff --git a/apps/desktop/src/lib/layout-persistence.ts b/apps/desktop/src/lib/layout-persistence.ts new file mode 100644 index 0000000000..aeeb04d63f --- /dev/null +++ b/apps/desktop/src/lib/layout-persistence.ts @@ -0,0 +1,167 @@ +import { atom, type WritableAtom } from 'nanostores' + +import type { InterfaceMode } from '@/store/interface-mode' + +import { type Codec, Codecs } from './persisted' +import { readKey, writeKey } from './storage' + +interface LayoutEntry { + capture(): void + restore(): void +} + +export const LAYOUT_KEYS = { + floating: 'hermes.desktop.floatingPanes.v1', + tree: 'hermes.desktop.layoutTree.v2', + preset: 'hermes.desktop.layoutPreset.active', + panes: 'hermes.desktop.paneStates.v1', + dismissed: 'hermes.desktop.dismissedPanes.v1', + shares: 'hermes.desktop.paneShare.v1', + hiddenTabs: 'hermes.desktop.hiddenStripTabs.v1', + placed: 'hermes.desktop.userPlacedPanes.v1', + flipped: 'hermes.desktop.panesFlipped', + collapsed: 'hermes.desktop.collapsedTreeSides.v1' +} as const + +function migrateLayoutScopes(initialMode: InterfaceMode) { + const marker = 'hermes.desktop.layoutModeScopes.v1' + const legacy = new Map() + + if (readKey(marker) !== null) { + return legacy + } + + let saved = true + + if (initialMode === 'simple') { + for (const key of Object.values(LAYOUT_KEYS)) { + const raw = readKey(key) + + if (raw === null) { + continue + } + + legacy.set(key, raw) + + if (readKey(`${key}.simple`) === null) { + writeKey(`${key}.simple`, raw) + saved = readKey(`${key}.simple`) === raw && saved + } + } + } + + if (saved) { + writeKey(marker, 'true') + } + + return legacy +} + +/** Layout state switches as a unit: changing visibility must not edit either + * tree while the other stores are still loading. Advanced keeps the legacy + * keys; Simple writes only its own namespace. */ +export function createLayoutPersistence(initialMode: InterfaceMode, persistent: boolean) { + const entries: LayoutEntry[] = [] + const listeners = new Set<() => void>() + const snapshots = new Set() + let mode = initialMode + let restoring = false + + const legacy = persistent ? migrateLayoutScopes(initialMode) : new Map() + + const keyFor = (key: string) => (mode === 'advanced' ? key : `${key}.simple`) + + function write(key: string, raw: null | string) { + if (!restoring) { + snapshots.add(keyFor(key)) + + if (persistent) { + // Explicit empties keep migration retries from reviving cleared state. + writeKey(keyFor(key), mode === 'simple' ? raw ?? 'null' : raw) + } + } + } + + function scopedAtom( + key: string, + fallback: () => T, + codec: Codec = Codecs.json(), + manual = false + ): WritableAtom { + const memory = new Map() + const load = (): T => { + if (memory.has(mode)) { + return memory.get(mode)! + } + + const raw = persistent ? readKey(keyFor(key)) ?? (mode === 'simple' ? legacy.get(key) ?? null : null) : null + + if (raw !== null) { + try { + const value = codec.decode(raw) + snapshots.add(keyFor(key)) + + return value + } catch { + // Invalid storage falls back only within this mode. + } + } + + return fallback() + } + + const $value = atom(load()) + + if (!manual) { + $value.listen(value => write(key, codec.encode(value))) + } + + entries.push({ + capture() { + const value = $value.get() + memory.set(mode, value) + write(key, codec.encode(value)) + }, + restore() { + $value.set(load()) + } + }) + + return $value + } + + return { + atom: scopedAtom, + get mode() { + return mode + }, + get restoring() { + return restoring + }, + has(key: string) { + return snapshots.has(keyFor(key)) + }, + onRestore(listener: () => void) { + listeners.add(listener) + return () => void listeners.delete(listener) + }, + write, + change(next: InterfaceMode, publishMode: () => void) { + if (mode === next) { + return + } + + entries.forEach(entry => entry.capture()) + mode = next + restoring = true + + try { + publishMode() + entries.forEach(entry => entry.restore()) + listeners.forEach(listener => listener()) + } finally { + restoring = false + } + } + } +} diff --git a/apps/desktop/src/store/interface-mode.ts b/apps/desktop/src/store/interface-mode.ts index 2d4b9c7e2b..df59f03e3f 100644 --- a/apps/desktop/src/store/interface-mode.ts +++ b/apps/desktop/src/store/interface-mode.ts @@ -1,8 +1,10 @@ import { atom, computed, type ReadableAtom, type WritableAtom } from 'nanostores' +import { createLayoutPersistence } from '@/lib/layout-persistence' import { type Codec, persistentAtom } from '@/lib/persisted' import type { SidebarRowMeta } from '@/store/layout' import type { ToolViewMode } from '@/store/tool-view' +import { isBrowserWindow, isHudWindow, isSecondaryWindow } from '@/store/windows' // Interface mode: does this window show the machinery, or just the // conversation? Two answers. ADVANCED is the app as it has always been — every @@ -19,10 +21,9 @@ import type { ToolViewMode } from '@/store/tool-view' // effective(surface) = sessionReveal ?? policy[mode] ?? userPreference // // `policy.advanced` is empty, so Advanced falls through to the user's own -// atoms by construction and nothing is ever written on an existing install. -// Simple SHADOWS a preference rather than overwriting it, so switching back -// restores the exact prior setup with nothing to snapshot or repair. A toggle -// pressed while a surface is shadowed (⌃` in Simple) lands in the session +// atoms by construction. Simple SHADOWS a display preference rather than +// overwriting it; modeLayout separately owns the arrangement's storage scope. +// A toggle pressed while a surface is shadowed (⌃` in Simple) lands in the session // layer — the terminal appears now, and the next launch is Simple's resting // state again — so a mode is a default, not a lock, and no session reveal can // pollute a preference the user set in the other mode. @@ -55,8 +56,13 @@ export const $interfaceMode = persistentAtom( modeCodec ) +export const modeLayout = createLayoutPersistence( + $interfaceMode.get(), + !isSecondaryWindow() && !isBrowserWindow() && !isHudWindow() +) + export function setInterfaceMode(mode: InterfaceMode) { - $interfaceMode.set(mode) + modeLayout.change(mode, () => $interfaceMode.set(mode)) } /** The ⌘K row and the rebindable `view.toggleSimpleMode` action. */ diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index a158f35511..2be5eb1a59 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -10,9 +10,10 @@ import { } from '@/components/pane-shell/tree/store' import { matchesQuery } from '@/hooks/use-media-query' import { connectionScopedAtom } from '@/lib/connection-scoped' +import { LAYOUT_KEYS } from '@/lib/layout-persistence' import { type Codec, Codecs, persistentAtom } from '@/lib/persisted' import { arraysEqual, insertUniqueId, readKey } from '@/lib/storage' -import { modeBound } from '@/store/interface-mode' +import { modeBound, modeLayout } from '@/store/interface-mode' import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride } from './panes' import { $showAllProfiles, setShowAllProfiles } from './profile' @@ -59,7 +60,6 @@ const SIDEBAR_WORKSPACE_COLLAPSED_STORAGE_KEY = 'hermes.desktop.workspaceCollaps const SIDEBAR_WORKSPACE_NODE_OPEN_STORAGE_KEY = 'hermes.desktop.workspaceNodeOpen' const SIDEBAR_DISMISSED_AUTO_PROJECTS_STORAGE_KEY = 'hermes.desktop.dismissedAutoProjects' const SIDEBAR_DISMISSED_WORKTREES_STORAGE_KEY = 'hermes.desktop.dismissedWorktrees' -const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped' const RIGHT_RAIL_ACTIVE_TAB_STORAGE_KEY = 'hermes.desktop.rightRailActiveTab' export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar' @@ -434,7 +434,7 @@ export const $sidebarViewCustomized: ReadableAtom = computed( // When true, the sessions sidebar moves to the right and the file browser + // preview rail move to the left — a mirror of the default layout. -export const $panesFlipped = persistentAtom(PANES_FLIPPED_STORAGE_KEY, false, Codecs.bool) +export const $panesFlipped = modeLayout.atom(LAYOUT_KEYS.flipped, () => false, Codecs.bool) export const $isSidebarResizing = atom(false) export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE) diff --git a/apps/desktop/src/store/panes.ts b/apps/desktop/src/store/panes.ts index a0da806ee2..17ffb14511 100644 --- a/apps/desktop/src/store/panes.ts +++ b/apps/desktop/src/store/panes.ts @@ -1,4 +1,8 @@ -import { atom, computed, type ReadableAtom } from 'nanostores' +import { computed, type ReadableAtom } from 'nanostores' + +import { LAYOUT_KEYS } from '@/lib/layout-persistence' +import { Codecs } from '@/lib/persisted' +import { modeLayout } from '@/store/interface-mode' export interface PaneStateSnapshot { open: boolean @@ -12,8 +16,6 @@ export interface PaneRegisterDefaults { widthOverride?: number } -const STORAGE_KEY = 'hermes.desktop.paneStates.v1' - function isSnapshot(value: unknown): value is PaneStateSnapshot { if (!value || typeof value !== 'object') { return false @@ -34,52 +36,19 @@ function isSnapshot(value: unknown): value is PaneStateSnapshot { return widthOk && heightOk } -function load(): Record { - if (typeof window === 'undefined') { - return {} - } +const paneDefaults: Record = {} - try { - const raw = window.localStorage.getItem(STORAGE_KEY) - - if (raw) { - const parsed = JSON.parse(raw) as unknown - - if (parsed && typeof parsed === 'object') { - const out: Record = {} - - for (const [id, value] of Object.entries(parsed as Record)) { - if (isSnapshot(value)) { - out[id] = { open: value.open, widthOverride: value.widthOverride, heightOverride: value.heightOverride } - } - } - - return out - } +export const $paneStates = modeLayout.atom>( + LAYOUT_KEYS.panes, + () => ({ ...paneDefaults }), + Codecs.json(parsed => { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ...paneDefaults } } - } catch { - // Treat unparseable persisted state as missing. - } - return {} -} - -// Persists both open state and resize width; load() validates each snapshot. -function persist(states: Record) { - if (typeof window === 'undefined') { - return - } - - try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(states)) - } catch { - // Storage failures are nonfatal. - } -} - -export const $paneStates = atom>(load()) - -$paneStates.subscribe(persist) + return { ...paneDefaults, ...Object.fromEntries(Object.entries(parsed).filter(([, value]) => isSnapshot(value))) } + }) +) // Cached per-pane derived atoms keep useStore subscriptions referentially stable. function memoized( @@ -108,6 +77,7 @@ export const $paneWidthOverride = (id: string) => memoized(widthCache, id, s => export const $paneHeightOverride = (id: string) => memoized(heightCache, id, s => s?.heightOverride) export function ensurePaneRegistered(id: string, defaults: PaneRegisterDefaults) { + paneDefaults[id] = { ...defaults } const current = $paneStates.get() if (current[id] !== undefined) { diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index 862361e64c..67fe84f888 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -132,12 +132,16 @@ The app is built for working on several things at once: The layout editor (titlebar button, or **Cmd/Ctrl+Shift+\\**) opens with an **Interface mode** choice — also under **Settings → Appearance → Window & layout** and as *Simple mode* in the command palette. It changes what is shown, not what Hermes can do. -- **Advanced** (default) is the app exactly as you have set it up. Picking it writes nothing. -- **Simple** is chat-first: the statusbar, profile rail, terminal, file browser and review panes, the technical tool-call view, inline code diffs, and the Artifacts / Scheduled jobs rows rest out of the way (Capabilities and Messaging stay — they are how you set Hermes up); thinking starts collapsed; session rows show the title, a preview and when they were last active. The titlebar keeps Settings and the layout editor. Simple is always the *Basic* arrangement; its one layout choice is *Sidebar left* or *Sidebar right*. The templates and saved layouts are Advanced. +- **Advanced** (default) is the app as you have set it up. Users who have not explicitly selected Simple stay in Advanced. +- **Simple** is chat-first: the statusbar, profile rail, terminal, file browser and review panes, the technical tool-call view, inline code diffs, and the Artifacts / Scheduled jobs rows rest out of the way (Capabilities and Messaging stay — they are how you set Hermes up); thinking starts collapsed; session rows show the title, a preview and when they were last active. The titlebar keeps Settings and the layout editor. Simple's layout picker offers *Sidebar left* or *Sidebar right*. The templates and saved layouts are Advanced. A layout says what is on screen, not just where things sit: applying one opens every pane it places and closes the ones it leaves *resting*, so **Ctrl+`**, **Cmd/Ctrl+J** and **Cmd/Ctrl+G** always agree with what you see. *Basic* is sessions and chat with the terminal resting as a collapsed rail under the chat and the file browser and review resting in a right column — **Ctrl+`** opens the terminal under the chat, **Cmd/Ctrl+J** opens the tree on the right. *Focus* keeps files and review as tabs behind the chat, with the same terminal rail. *Default*, *Terminal deck* and *Quad* open everything they place. A layout you save remembers which of its panes were closed. -Simple shadows your preferences instead of overwriting them, so switching back to Advanced restores every setting exactly. Every keybind still works in Simple — **Ctrl+`**, **Cmd/Ctrl+J** and **Cmd/Ctrl+G** open the terminal, file browser and review for the current session, and the next launch is quiet again. With more than one profile the profile rail stays, since it is then the only way to switch. First-run onboarding sets the mode from the layout you pick: *Basic* starts in Simple, *Elite* in Advanced; skipping leaves it on Advanced. +Each mode remembers its own arrangement: pane positions, sizes, active tabs, hidden tabs, dismissals, collapsed sides and floating-card positions. Returning to a mode restores that arrangement rather than reapplying a preset. Simple starts with the sidebar on the left; moving it to the right does not change Advanced. Conversations, drafts, previews and running work stay shared. + +Existing layouts are retained on upgrade. Advanced continues using the original storage keys. If you already explicitly selected Simple, its current layout is copied into Simple's separate storage without deleting the originals. An older arrangement that was overwritten before this separation cannot be reconstructed. + +Simple shadows your display preferences instead of overwriting them. Every keybind still works in Simple — **Ctrl+`**, **Cmd/Ctrl+J** and **Cmd/Ctrl+G** open the terminal, file browser and review for the current session, and the next launch is quiet again. With more than one profile the profile rail stays, since it is then the only way to switch. First-run onboarding sets the mode from the layout you pick: *Basic* starts in Simple, *Elite* in Advanced; skipping leaves it on Advanced. #### Minimize to tray From ff7642c26859b957e804d46bde63e7333c644c7b Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 22 Sep 2026 16:21:47 -0500 Subject: [PATCH 2/6] fix(desktop): preserve live pane guests across layout changes --- .../pane-shell/tree/renderer/drag-session.ts | 4 +- .../pane-shell/tree/renderer/index.tsx | 41 ++- .../tree/renderer/keep-alive-panes.test.tsx | 333 ++++++++++++++++++ .../tree/renderer/keep-alive-panes.tsx | 199 +++++++++++ .../tree/renderer/narrow-overlays.tsx | 38 +- .../pane-shell/tree/renderer/tree-group.tsx | 17 +- 6 files changed, 606 insertions(+), 26 deletions(-) create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.test.tsx create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.tsx diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts b/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts index cfce03016f..73eda7922e 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts @@ -49,7 +49,9 @@ const DRAG_THRESHOLD_PX = 4 const CENTER_RADIUS = 0.62 export function snapshotZones(): EngineZone[] { - return [...document.querySelectorAll('[data-tree-group]')].map(el => { + // Stable guest bodies carry group identity for focus/tooltip lookup, but + // only the tree's placement owns the full zone (including its tab strip). + return [...document.querySelectorAll('[data-tree-group]:not([data-pane-host])')].map(el => { const r = el.getBoundingClientRect() return { id: el.dataset.treeGroup!, rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom } } diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx index d2f59868cf..eef088eb5b 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx @@ -31,6 +31,7 @@ import { ZoneEditor } from '../zone-editor' import { TreeEditBar } from './edit-bar' import { FloatingPanes } from './floating-panes' +import { KeepAlivePanes } from './keep-alive-panes' import { NarrowOverlays } from './narrow-overlays' import { TreeNode } from './tree-node' @@ -46,10 +47,6 @@ export function LayoutTreeRoot({ children, titlebar = false }: { children?: Reac // main pane's geometry in plain CSS. useEffect(publishWorkspaceGeometry, []) - if (!tree) { - return null - } - return (
{/* THE SEAM INVARIANT: boundaries are drawn by the tree (one sash @@ -72,20 +69,28 @@ export function LayoutTreeRoot({ children, titlebar = false }: { children?: Reac display: none; } `} - - - {/* Non-tiling panes: fixed cards above the tree, outside every zone. */} - - - - {children} + + {tree && ( + + )} + {tree && } + + {tree && ( + <> + {/* Non-tiling panes: fixed cards above the tree, outside every zone. */} + + + + {children} + + )}
) } diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.test.tsx new file mode 100644 index 0000000000..3edd1437b8 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.test.tsx @@ -0,0 +1,333 @@ +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { createElement, useEffect } from 'react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { registry } from '@/contrib/registry' +import { stubMenuDomApis } from '@/test/jsdom' + +import { PANE_TOGGLE_REVEAL_EVENT } from '../..' +import { usePaneGroup, usePaneLifecycle, usePaneVisible } from '../../pane-visibility' +import { group, type LayoutNode, split } from '../model' +import { + $activeTreeGroup, + $collapsedTreeSides, + $dismissedPanes, + $hiddenTreePanes, + $hoveredTreeGroup, + $layoutTree, + $narrowViewport, + $treePaneEpochs, + reloadTreePane +} from '../store' + +import { LayoutTreeRoot } from '.' +import { snapshotZones } from './drag-session' + +// A native guest has a lifetime beyond React. DOM identity alone misses a +// detach/reparent (even moveBefore destroys Electron's webview guest). +const disconnected: HTMLElement[] = [] +class LiveGuest extends HTMLElement { + disconnectedCallback() { + disconnected.push(this) + } +} +customElements.define('pane-live-guest', LiveGuest) + +// jsdom has no layout. Deliver real observer notifications to the production +// shared observer; the browser harness separately checks CSS anchor geometry. +const observers = new Set() +class ResizeObserverProbe { + targets = new Set() + constructor(readonly callback: ResizeObserverCallback) { + observers.add(this) + } + observe(target: Element) { + this.targets.add(target) + } + unobserve(target: Element) { + this.targets.delete(target) + } + disconnect() { + this.targets.clear() + } +} + +function resize(target: HTMLElement, width: number, height: number) { + for (const observer of observers) { + if (observer.targets.has(target)) { + observer.callback([{ target, contentRect: { width, height } } as ResizeObserverEntry], observer) + } + } +} + +const disposers: (() => void)[] = [] +const mounts = new Map() +const unmounts = new Map() +const renders = new Map() + +function registerPane(id: string, keepAlive = true, data: Record = {}) { + function Probe() { + const groupId = usePaneGroup() + const visible = usePaneVisible() + const lifecycle = usePaneLifecycle() + renders.set(id, (renders.get(id) ?? 0) + 1) + useEffect(() => { + mounts.set(id, (mounts.get(id) ?? 0) + 1) + + return () => { + unmounts.set(id, (unmounts.get(id) ?? 0) + 1) + } + }, []) + + return createElement( + 'pane-live-guest', + { + 'data-guest': id, + 'data-group': groupId, + 'data-visible': String(visible), + 'data-lifecycle': lifecycle + }, + + ) + } + + const dispose = registry.register({ + area: 'panes', + id, + title: id, + data: { ...data, lifecycleKeepAlive: keepAlive }, + render: Probe + }) + disposers.push(dispose) + + return dispose +} + +function setTree(tree: LayoutNode | null) { + act(() => $layoutTree.set(tree)) +} + +const guest = (id = 'live') => document.querySelector(`[data-guest="${id}"]`) + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverProbe) + stubMenuDomApis() + vi.stubGlobal('CSS', { ...globalThis.CSS, escape: (value: string) => value }) + $hiddenTreePanes.set(new Set()) + $dismissedPanes.set(new Set()) + $collapsedTreeSides.set(new Set()) + $treePaneEpochs.set({}) + $narrowViewport.set(false) + disconnected.length = 0 + mounts.clear() + unmounts.clear() + renders.clear() +}) + +afterEach(() => { + cleanup() + disposers.splice(0).forEach(dispose => dispose()) + $layoutTree.set(null) + vi.unstubAllGlobals() +}) + +it('keeps a live body continuously connected across replacement IDs, ancestry and missing placement', () => { + registerPane('live') + registerPane('plain', false) + setTree(group(['live'], { id: 'original' })) + render() + const page = guest()! + const input = page.querySelector('input')! + input.value = 'unsaved page state' + + setTree(split('row', [ + group(['plain'], { id: 'chat' }), + split('column', [group(['live'], { id: 'replacement' }), group([], { id: 'empty' })]) + ])) + expect(guest()).toBe(page) + expect(page.dataset.group).toBe('replacement') + expect(page.dataset.visible).toBe('true') + expect(input.value).toBe('unsaved page state') + + setTree(group(['plain'], { id: 'other-layout' })) + expect(guest()).toBe(page) + expect(page.dataset.visible).toBe('false') + expect(page.dataset.lifecycle).toBe('hot-hidden') + expect(page.closest('[data-pane-hidden]')?.hasAttribute('inert')).toBe(true) + + setTree(group(['live'], { id: 'minimized-replacement', minimized: true })) + expect(guest()).toBe(page) + expect(page.dataset.group).toBe('minimized-replacement') + expect(page.dataset.visible).toBe('false') + + setTree(null) + expect(guest()).toBe(page) + expect(page.dataset.visible).toBe('false') + + setTree(group(['live'], { id: 'restored' })) + expect(guest()).toBe(page) + expect(page.dataset.group).toBe('restored') + expect(page.closest('[data-pane-hidden]')).toBeNull() + expect(mounts.get('live')).toBe(1) + expect(unmounts.get('live') ?? 0).toBe(0) + expect(disconnected).not.toContain(page) +}) + +it('destroys removed contributions and does not revive their old activation on re-registration', () => { + const remove = registerPane('live') + registerPane('plain', false) + setTree(group(['live', 'plain'], { id: 'zone' })) + render() + const page = guest()! + setTree(group(['live', 'plain'], { active: 'plain', id: 'zone' })) + act(remove) + expect(guest()).toBeNull() + expect(unmounts.get('live')).toBe(1) + expect(disconnected).toContain(page) + act(() => { + registerPane('live') + }) + expect(guest()).toBeNull() + setTree(group(['live', 'plain'], { active: 'live', id: 'zone' })) + expect(guest()).not.toBe(page) + expect(mounts.get('live')).toBe(2) +}) + +it('gates hidden side guests without mounting never-activated tabs or disconnecting live ones', () => { + registerPane('live') + registerPane('background') + disposers.push(registry.register({ + area: 'panes', id: 'workspace', data: { placement: 'main' }, render: () =>
+ })) + setTree(split('row', [group(['workspace']), group(['live', 'background'], { id: 'side' })])) + $collapsedTreeSides.set(new Set(['right'])) + render() + expect(guest()).toBeNull() + act(() => $collapsedTreeSides.set(new Set())) + const page = guest()! + expect(page).not.toBeNull() + expect(guest('background')).toBeNull() + act(() => $collapsedTreeSides.set(new Set(['right']))) + expect(guest()).toBe(page) + expect(page.dataset.visible).toBe('false') + expect(page.closest('[data-pane-hidden]')?.hasAttribute('inert')).toBe(true) + act(() => $collapsedTreeSides.set(new Set())) + expect(guest()).toBe(page) + expect(page.dataset.visible).toBe('true') + expect(disconnected).not.toContain(page) +}) + +it('routes DOM group targeting to the new zone without adding duplicate drop zones', () => { + registerPane('live') + registerPane('plain', false) + setTree(group(['live'], { id: 'old-zone' })) + render() + const page = guest()! + setTree(split('row', [group(['plain'], { id: 'other' }), group(['live'], { id: 'new-zone', tabStrip: 'always' })])) + expect(page.closest('[data-tree-group]')?.getAttribute('data-tree-group')).toBe('new-zone') + expect(page.closest('[data-zone-header]')).not.toBeNull() + fireEvent.pointerDown(page) + expect($activeTreeGroup.get()).toBe('new-zone') + fireEvent.pointerOver(page) + expect($hoveredTreeGroup.get()).toBe('new-zone') + expect(snapshotZones().map(zone => zone.id).sort()).toEqual(['new-zone', 'other']) +}) + +it('reloads only the explicit epoch target, even while it is hidden', () => { + registerPane('live') + registerPane('other') + setTree(split('row', [group(['live']), group(['other'])])) + render() + const page = guest()! + const other = guest('other')! + const host = page.parentElement + act(() => $hiddenTreePanes.set(new Set(['live']))) + expect(guest()).toBe(page) + expect(page.dataset.visible).toBe('false') + act(() => reloadTreePane('live')) + expect(guest()).not.toBe(page) + expect(guest()!.parentElement).toBe(host) + expect(guest('other')).toBe(other) + expect(mounts.get('live')).toBe(2) + expect(unmounts.get('live')).toBe(1) + expect(disconnected).toContain(page) + expect(disconnected).not.toContain(other) +}) + +it('keeps live tabs outside the bounded cache while ordinary tabs still park', () => { + registerPane('live') + + for (const id of ['a', 'b', 'c', 'd']) { + registerPane(id, false) + } + + const panes = ['live', 'a', 'b', 'c', 'd'] + setTree(group(panes, { id: 'zone' })) + render() + const page = guest()! + for (const active of ['a', 'b', 'c', 'd']) { + setTree(group(panes, { id: 'zone', active })) + } + + expect(guest()).toBe(page) + expect(page.dataset.lifecycle).toBe('hot-hidden') + expect(guest('a')).toBeNull() + expect(guest('d')).not.toBeNull() + setTree(group(panes, { id: 'zone', active: 'd', minimized: true })) + expect(guest()).toBe(page) + expect(guest('d')).toBeNull() + expect(disconnected).not.toContain(page) +}) + +it('remembers the last visible viewport without rerendering a live guest on resize', () => { + registerPane('live') + setTree(group(['live'], { id: 'zone' })) + render() + const page = guest()! + const host = page.closest('[data-pane-host]')! + const priorRenders = renders.get('live') + act(() => resize(host, 640, 480)) + expect(renders.get('live')).toBe(priorRenders) + expect(host.style.getPropertyValue('--pane-kept-width')).toBe('640px') + expect(host.style.getPropertyValue('--pane-kept-height')).toBe('480px') + setTree(group([], { id: 'absent' })) + act(() => resize(host, 0, 0)) + expect(host.style.getPropertyValue('--pane-kept-width')).toBe('640px') + expect(host.style.getPropertyValue('--pane-kept-height')).toBe('480px') + expect(host.style.width).toBe('var(--pane-kept-width, 0px)') + expect(host.style.height).toBe('var(--pane-kept-height, 0px)') + expect(guest()).toBe(page) +}) + +it('uses the same live guest when a collapsible pane moves into the narrow overlay', () => { + registerPane('live', true, { collapsible: true, placement: 'right' }) + registerPane('workspace', false, { placement: 'main' }) + setTree(split('row', [group(['workspace']), group(['live'], { id: 'side' })])) + render() + const page = guest()! + act(() => $narrowViewport.set(true)) + expect(guest()).toBe(page) + expect(page.dataset.visible).toBe('false') + fireEvent(window, new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: 'live', mode: 'open' } })) + expect(document.querySelectorAll('[data-guest="live"]')).toHaveLength(1) + expect(guest()).toBe(page) + expect(page.dataset.visible).toBe('true') + fireEvent(window, new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: 'live', mode: 'close' } })) + expect(guest()).toBe(page) + expect(page.dataset.visible).toBe('false') + // A hover reveal must treat the overlay chrome and its sibling guest as + // one hover boundary, but leaving BOTH still dismisses it. + fireEvent.mouseEnter(document.querySelector('.absolute.inset-y-0.z-30')!) + const chrome = document.querySelector('[data-narrow-overlay]')! + const host = page.closest('[data-pane-host]')! + fireEvent.mouseLeave(chrome, { relatedTarget: host }) + expect(page.dataset.visible).toBe('true') + fireEvent.mouseLeave(host, { relatedTarget: chrome }) + expect(page.dataset.visible).toBe('true') + fireEvent.mouseLeave(host, { relatedTarget: document.body }) + expect(page.dataset.visible).toBe('false') + act(() => $narrowViewport.set(false)) + expect(guest()).toBe(page) + expect(page.dataset.visible).toBe('true') + expect(disconnected).not.toContain(page) +}) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.tsx new file mode 100644 index 0000000000..fc7f3d2f59 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.tsx @@ -0,0 +1,199 @@ +import { useStore } from '@nanostores/react' +import { + createContext, + type CSSProperties, + memo, + type MouseEventHandler, + type ReactNode, + useCallback, + useContext, + useId, + useLayoutEffect, + useRef, + useState +} from 'react' + +import { ContribBoundary, ContribRender } from '@/contrib/react/boundary' +import { useContributions } from '@/contrib/react/use-contributions' +import type { Contribution } from '@/contrib/types' +import { useResizeObserver } from '@/hooks/use-resize-observer' +import { cn } from '@/lib/utils' + +import { $layoutEditMode } from '../../edit-mode' +import { hiddenPaneProps, PaneGroupContext, PaneLifecycleContext, PaneVisibleContext } from '../../pane-visibility' +import { $collapsedTreeSides, $treePaneEpochs, paneRootSide } from '../store' + +import { paneChrome } from './track-model' + +interface Placement { + anchor: string + groupId: string + headerVisible: boolean + visible: boolean + overlay?: boolean + onMouseLeave?: MouseEventHandler +} + +type Placements = ReadonlyMap +type PlacePane = (paneId: string, placement: Placement) => () => void +const PlacementContext = createContext(null) + +/** Own the live bodies outside the replaceable split/group tree. Neither React + * remounts nor DOM moves are safe for a native guest: even moveBefore destroys + * an Electron webview's guest. Only its CSS anchor and contexts may change. */ +export function KeepAlivePanes({ children }: { children: ReactNode }) { + const panes = useContributions('panes') + const epochs = useStore($treePaneEpochs) + const [placements, setPlacements] = useState(() => new Map()) + const place = useCallback((id, placement) => { + setPlacements(previous => { + // Restored background tabs are lazy until first activation. + if (!placement.visible && !previous.has(id)) { + return previous + } + + return new Map(previous).set(id, placement) + }) + + return () => { + setPlacements(previous => { + // A retiring slot must not hide a newer placement of the same pane. + if (previous.get(id) !== placement) { + return previous + } + + return new Map(previous).set(id, { ...placement, visible: false }) + }) + } + }, []) + + useLayoutEffect(() => { + setPlacements(previous => { + // Contribution removal is destruction; placement removal is only Hide. + const present = new Set( + panes.filter(pane => pane.render && paneChrome(pane).lifecycleKeepAlive).map(pane => pane.id) + ) + const retained = [...previous].filter(([id]) => present.has(id)) + + return retained.length === previous.size ? previous : new Map(retained) + }) + }, [panes]) + + return ( + + {children} + {/* Map insertion order never follows tree order: React must not move a + surviving guest host when panes or groups are rearranged. */} + {[...placements].map(([id, placement]) => { + const pane = panes.find(candidate => candidate.id === id) + + if (!pane?.render || !paneChrome(pane).lifecycleKeepAlive) { + return null + } + + return + })} + + ) +} + +interface KeepAlivePaneHostProps { + pane: Contribution + placement: Placement + epoch: number +} + +const KeepAlivePaneHost = memo(function KeepAlivePaneHost({ pane, placement, epoch }: KeepAlivePaneHostProps) { + const ref = useRef(null) + // Retain native guest coordinates while its placement is absent/minimized. + // CSS follows the slot during resize; recording dimensions never re-renders + // a guest or moves its connected host. + const rememberSize = useCallback( + (entries: readonly ResizeObserverEntry[]) => { + const rect = entries[0]?.contentRect + + if (placement.visible && rect && rect.width > 0 && rect.height > 0 && ref.current) { + ref.current.style.setProperty('--pane-kept-width', `${rect.width}px`) + ref.current.style.setProperty('--pane-kept-height', `${rect.height}px`) + } + }, + [placement.visible] + ) + useResizeObserver(rememberSize, ref) + + return ( +
+ + + + + {pane.render && } + + + + +
+ ) +}) + +export const useStablePaneHosts = () => useContext(PlacementContext) !== null + +interface KeepAlivePaneSlotProps extends Omit { + paneId: string +} + +/** The tree owns placement, never the body. Slots may freely unmount or move. */ +export function KeepAlivePaneSlot({ + paneId, + groupId, + headerVisible, + visible, + overlay, + onMouseLeave +}: KeepAlivePaneSlotProps) { + const place = useContext(PlacementContext) + const sides = useStore($collapsedTreeSides) + const editing = useStore($layoutEditMode) + const side = paneRootSide(paneId) + const shown = visible && (overlay || editing || side === null || !sides.has(side)) + const id = useId() + const anchor = `--pane-${id.replace(/[^a-zA-Z0-9_-]/g, '-')}` + + useLayoutEffect( + () => place?.(paneId, { anchor, groupId, headerVisible, visible: shown, overlay, onMouseLeave }), + [place, paneId, anchor, groupId, headerVisible, shown, overlay, onMouseLeave] + ) + + return ( +
+ ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx index 7dea362904..7c143cc138 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx @@ -7,7 +7,7 @@ */ import { useStore } from '@nanostores/react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { type MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { $chatOnboardingSolo } from '@/components/onboarding-chat/assembly' import { PaneTab, PaneTabLabel, PaneTabStrip } from '@/components/ui/pane-tab' @@ -18,9 +18,11 @@ import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape import { cn } from '@/lib/utils' import { PANE_TOGGLE_REVEAL_EVENT } from '../..' +import { NO_PANE_GROUP } from '../../pane-visibility' import { allPaneIds, findGroupOfPane } from '../model' import { $hiddenTreePanes, $layoutTree, $narrowViewport } from '../store' +import { KeepAlivePaneSlot, useStablePaneHosts } from './keep-alive-panes' import { paneChrome } from './track-model' export function NarrowOverlays() { @@ -28,8 +30,20 @@ export function NarrowOverlays() { const solo = useStore($chatOnboardingSolo) const tree = useStore($layoutTree) const panes = useContributions('panes') + const stableHosts = useStablePaneHosts() const hiddenPanes = useStore($hiddenTreePanes) const [reveal, setReveal] = useState<{ id: string; pinned: boolean } | null>(null) + const onMouseLeave = useCallback>(event => { + // The overlay's chrome and its stable guest are DOM siblings, but one + // hover boundary. Crossing between them must not dismiss an unpinned pane. + const next = event.relatedTarget + + if (next instanceof Element && next.closest('[data-narrow-overlay], [data-pane-overlay]')) { + return + } + + setReveal(current => (current?.pinned ? current : null)) + }, []) // Own an Escape layer only while something is revealed, so Escape closes the // overlay only when it's the top layer (never under a dialog / edit mode). @@ -158,7 +172,8 @@ export function NarrowOverlays() { // panes beneath it — a see-through overlay reads as text bleeding // through text. Contract: `[data-glass-opaque]` in styles.css. data-glass-opaque="" - onMouseLeave={() => setReveal(current => (current?.pinned ? current : null))} + data-narrow-overlay="" + onMouseLeave={onMouseLeave} // Match the pane's docked width (sessions ~237px, files its rail // width) instead of a fat fixed 20rem — capped for tiny screens. style={{ width: `min(${(revealed.data as { width?: string } | undefined)?.width ?? '18rem'}, 85vw)` }} @@ -185,9 +200,22 @@ export function NarrowOverlays() { ))} )} - - {revealed.render && } - + {stableHosts && paneChrome(revealed).lifecycleKeepAlive ? ( +
+ 1} + onMouseLeave={onMouseLeave} + overlay + paneId={revealed.id} + visible + /> +
+ ) : ( + + {revealed.render && } + + )}
)} 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 d6977dc135..f4f671ab34 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 @@ -85,6 +85,7 @@ import { } from '../tab-selection' import { startPaneDrag } from './drag-session' +import { KeepAlivePaneSlot, useStablePaneHosts } from './keep-alive-panes' import { PaneBody } from './pane-body' import { usePanelTitlebar } from './panel-titlebar' import { tabStripVisibleForZone } from './strip-visibility' @@ -252,6 +253,7 @@ export function TreeGroup({ // workspace). const [menuPane, setMenuPane] = useState(undefined) const panes = useContributions('panes') + const stableHosts = useStablePaneHosts() // Coarse drag flag only (set once at drag start/end). The per-frame drop // HINT lives in ZoneDropOverlay so a moving pointer re-renders the tiny // overlay, not every zone's header/body (and not the menuDirections walk). @@ -366,6 +368,8 @@ export function TreeGroup({ const mountedPanes = node.minimized ? keptPanes.filter(id => Boolean(paneChrome(paneFor(id)).lifecycleKeepAlive)) : keptPanes + const hostedPanes = stableHosts ? node.panes.filter(id => paneChrome(paneFor(id)).lifecycleKeepAlive) : [] + const inlinePanes = mountedPanes.filter(id => !stableHosts || !paneChrome(paneFor(id)).lifecycleKeepAlive) // ONE header style: the app's compact pane-header. Whether this zone shows // it is the resolver's call, not this component's — see strip-visibility.ts @@ -775,15 +779,24 @@ export function TreeGroup({ scroll positions and measurements survive the round-trip — which also makes a hidden layer's rect identical to the visible one's, hence the marker document-wide lookups filter on (see pane-visibility.ts). */} - {(!node.minimized || mountedPanes.length > 0) && ( + {(!node.minimized || mountedPanes.length > 0 || hostedPanes.length > 0) && (