fix(desktop): restore independent Simple and Advanced layouts

This commit is contained in:
brooklyn!
2026-09-22 16:21:34 -05:00
parent ade4814466
commit 68f75db5e5
15 changed files with 739 additions and 237 deletions

View File

@@ -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<Record<string, string>> {
return page.evaluate((modeKey: string) => {
const out: Record<string, string> = {}
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<string, string> = {}
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<void> {
@@ -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()

View File

@@ -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

View File

@@ -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']),

View File

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

View File

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

View File

@@ -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: () => <input aria-label="Floating draft" defaultValue="original" />
})
try {
const view = render(<FloatingPanes />)
const card = () => view.container.querySelector<HTMLElement>('[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)
}
})

View File

@@ -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<string, unknown>) {
}
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()

View File

@@ -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<string, StoredRect> => readJson<Record<string, StoredRect>>(POSITIONS_KEY) ?? {}
const $positions = modeLayout.atom<Record<string, StoredRect>>(
LAYOUT_KEYS.floating,
() => ({}),
Codecs.json(value =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, StoredRect>) : {}
),
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<FloatingRect>(() => {
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<FloatingViewport>(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<HTMLElement>) => {
@@ -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 (
<div
@@ -177,10 +186,16 @@ function FloatingPane({ pane }: { pane: Contribution }) {
</button>
</header>
{!collapsed && (
<div className="min-h-0 flex-1 overflow-auto">
<ContribBoundary id={pane.id}>{pane.render && <ContribRender render={pane.render} />}</ContribBoundary>
</div>
{(!collapsed || chrome.lifecycleKeepAlive) && (
<PaneBody hidden={collapsed}>
<div className="h-full overflow-auto" inert={collapsed || undefined} {...hiddenPaneProps(collapsed)}>
<PaneLifecycleContext value={collapsed ? 'hot-hidden' : 'visible'}>
<PaneVisibleContext value={!collapsed}>
<ContribBoundary id={pane.id}>{pane.render && <ContribRender render={pane.render} />}</ContribBoundary>
</PaneVisibleContext>
</PaneLifecycleContext>
</div>
</PaneBody>
)}
</div>
)

View File

@@ -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<unknown>(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<InterfaceMode, LayoutNode | null> = { 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<LayoutNode | null>(isSecondaryWindow() || isBrowserWindow() ? null : loadPersisted())
export const $layoutTree = modeLayout.atom<LayoutNode | null>(
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<string>(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<string> {
return new Set(readJson<string[]>(DISMISSED_KEY) ?? [])
const paneSetCodec = {
decode: (raw: string): ReadonlySet<string> => new Set(Codecs.stringArray.decode(raw)),
encode: (value: ReadonlySet<string>) => Codecs.stringArray.encode([...value])
}
export const $dismissedPanes = atom<ReadonlySet<string>>(loadDismissed())
function saveDismissed(next: ReadonlySet<string>) {
$dismissedPanes.set(next)
writeJson(DISMISSED_KEY, next.size === 0 ? null : [...next])
}
export const $dismissedPanes = modeLayout.atom<ReadonlySet<string>>(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<string>): void {
}
if (next.size !== dismissed.size) {
saveDismissed(next)
$dismissedPanes.set(next)
}
}
@@ -239,13 +233,19 @@ export function undismissTreePanes(paneIds: Iterable<string>): 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<string, number> = readJson<Record<string, number>>(PANE_SHARE_KEY) ?? {}
const validShare = (share: unknown): share is number =>
typeof share === 'number' && Number.isFinite(share) && share > 0 && share < 1
const $paneShares = modeLayout.atom<Record<string, number>>(
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<ReadonlySet<string>>(new Set(readJson<string[]>(HIDDEN_STRIP_TAB_KEY) ?? []))
function saveHiddenStripTabs(next: ReadonlySet<string>) {
$hiddenStripTabs.set(next)
writeJson(HIDDEN_STRIP_TAB_KEY, next.size === 0 ? null : [...next])
}
export const $hiddenStripTabs = modeLayout.atom<ReadonlySet<string>>(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<string, () => void> = {}
const paneOpeners: Record<string, () => void> = {}
@@ -994,7 +988,19 @@ export function closeTreePane(paneId: string) {
*/
export type TreeSide = 'left' | 'right'
export const $collapsedTreeSides = atom<ReadonlySet<TreeSide>>(new Set())
export const $collapsedTreeSides = modeLayout.atom<ReadonlySet<TreeSide>>(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<TreeSide>(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<ReadonlySet<TreeSide>>(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<Record<TreeSide, (open: boolean) => void>> = {}
const sideVisibility: Partial<Record<TreeSide, () => 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<ReadonlySet<string>>(new Set(readJson<string[]>(USER_PLACED_KEY) ?? []))
function saveUserPlaced(next: ReadonlySet<string>) {
$userPlacedPanes.set(next)
writeJson(USER_PLACED_KEY, next.size === 0 ? null : [...next])
}
export const $userPlacedPanes = modeLayout.atom<ReadonlySet<string>>(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.

View File

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

View File

@@ -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<string, string>()
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<string>()
let mode = initialMode
let restoring = false
const legacy = persistent ? migrateLayoutScopes(initialMode) : new Map<string, string>()
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<T>(
key: string,
fallback: () => T,
codec: Codec<T> = Codecs.json<T>(),
manual = false
): WritableAtom<T> {
const memory = new Map<InterfaceMode, T>()
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<T>(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
}
}
}
}

View File

@@ -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<InterfaceMode>(
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. */

View File

@@ -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<boolean> = 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)

View File

@@ -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<string, PaneStateSnapshot> {
if (typeof window === 'undefined') {
return {}
}
const paneDefaults: Record<string, PaneStateSnapshot> = {}
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<string, PaneStateSnapshot> = {}
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
if (isSnapshot(value)) {
out[id] = { open: value.open, widthOverride: value.widthOverride, heightOverride: value.heightOverride }
}
}
return out
}
export const $paneStates = modeLayout.atom<Record<string, PaneStateSnapshot>>(
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<string, PaneStateSnapshot>) {
if (typeof window === 'undefined') {
return
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(states))
} catch {
// Storage failures are nonfatal.
}
}
export const $paneStates = atom<Record<string, PaneStateSnapshot>>(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<T>(
@@ -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) {

View File

@@ -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