diff --git a/apps/desktop/src/app/contrib/layout-sides.ts b/apps/desktop/src/app/contrib/layout-sides.ts
index 1e1e2baca9..3200902998 100644
--- a/apps/desktop/src/app/contrib/layout-sides.ts
+++ b/apps/desktop/src/app/contrib/layout-sides.ts
@@ -42,10 +42,15 @@ export function bindLayoutSides() {
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))
+ 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
index aaf1eb85b9..28a4749aa4 100644
--- 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
@@ -28,6 +28,7 @@ async function boot() {
] as const) {
registry.register({ id, area: 'panes', data: { placement }, render: () => null })
}
+
registry.register({
id: 'bots',
area: 'panes',
@@ -56,6 +57,7 @@ async function boot() {
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()),
@@ -114,7 +116,10 @@ it('restores independently customized modes through real pane bindings and reloa
// 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.moveTreePane('files', {
+ groupId: model.findGroupOfPane(tree.$layoutTree.get()!, 'workspace')!.id,
+ pos: 'center'
+ })
tree.dismissTreePane('review')
const root = tree.$layoutTree.get()!
@@ -122,7 +127,10 @@ it('restores independently customized modes through real pane bindings and reloa
throw new Error('fixture must have a split')
}
- tree.setTreeSplitWeights(root.id, root.weights.map((weight, i) => weight + i))
+ tree.setTreeSplitWeights(
+ root.id,
+ root.weights.map((weight, i) => weight + i)
+ )
const advanced = app.snapshot()
mode.setInterfaceMode('simple')
const simple = app.snapshot()
@@ -139,47 +147,53 @@ it('restores independently customized modes through real pane bindings and reloa
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')
+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)
+ const restoredTree = reloaded.tree.$layoutTree.get()
+ const expectedTree = normalize(legacyTree)
+ assert(restoredTree && expectedTree)
+ expect(reloaded.model.allPaneIds(restoredTree)).toEqual(reloaded.model.allPaneIds(expectedTree))
}
-
- 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)
- const restoredTree = reloaded.tree.$layoutTree.get()
- const expectedTree = normalize(legacyTree)
- assert(restoredTree && expectedTree)
- expect(reloaded.model.allPaneIds(restoredTree)).toEqual(reloaded.model.allPaneIds(expectedTree))
-})
+)
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
index 0f230140f8..dd9c047ca8 100644
--- 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
@@ -10,6 +10,7 @@ it.each([false, true])('restores floating geometry and collapse with keep-alive=
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',
@@ -17,6 +18,7 @@ it.each([false, true])('restores floating geometry and collapse with keep-alive=
data: { placement: 'floating', width: 240, height: 180, lifecycleKeepAlive: keepAlive },
render: () =>
})
+
try {
const view = render()
const card = () => view.container.querySelector('[data-floating-pane="card"]')!
@@ -33,10 +35,12 @@ it.each([false, true])('restores floating geometry and collapse with keep-alive=
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()
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 aae3f3b6e2..890b85ca46 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
@@ -80,10 +80,12 @@ function FloatingPane({ pane }: { pane: Contribution }) {
(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]
@@ -124,22 +126,25 @@ function FloatingPane({ pane }: { pane: Contribution }) {
event.preventDefault()
}, [])
- const onPointerMove = useCallback((event: ReactPointerEvent) => {
- const from = drag.current
+ const onPointerMove = useCallback(
+ (event: ReactPointerEvent) => {
+ const from = drag.current
- if (!from) {
- return
- }
+ if (!from) {
+ return
+ }
- drag.current = { x: event.clientX, y: event.clientY }
+ drag.current = { x: event.clientX, y: event.clientY }
- setRect(current =>
- clampFloatingRect(
- { ...current, x: current.x + event.clientX - from.x, y: current.y + event.clientY - from.y },
- viewport.current
+ setRect(current =>
+ clampFloatingRect(
+ { ...current, x: current.x + event.clientX - from.x, y: current.y + event.clientY - from.y },
+ viewport.current
+ )
)
- )
- }, [setRect])
+ },
+ [setRect]
+ )
const onPointerUp = useCallback(
(event: ReactPointerEvent) => {
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
index 0065868e14..c313e832da 100644
--- 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
@@ -27,6 +27,7 @@ import { LayoutTreeRoot } from '.'
// 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)
@@ -37,6 +38,7 @@ 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) {
@@ -55,6 +57,7 @@ class ResizeObserverProbe {
function resize(target: HTMLElement, width: number, height: number) {
const size = { inlineSize: width, blockSize: height }
+
const entry: ResizeObserverEntry = {
target,
contentRect: new DOMRectReadOnly(0, 0, width, height),
@@ -108,6 +111,7 @@ function registerPane(id: string, keepAlive = true, data: Record {
registerPane('live')
registerPane('background')
- disposers.push(registry.register({
- area: 'panes', id: 'workspace', data: { placement: 'main' }, render: () =>
- }))
+ 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()
@@ -240,7 +251,11 @@ it('routes DOM group targeting to the new zone without adding duplicate drop zon
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'])
+ expect(
+ snapshotZones()
+ .map(zone => zone.id)
+ .sort()
+ ).toEqual(['new-zone', 'other'])
})
it('reloads only the explicit epoch target, even while it is hidden', () => {
@@ -275,6 +290,7 @@ it('keeps live tabs outside the bounded cache while ordinary tabs still park', (
setTree(group(panes, { id: 'zone' }))
render()
const page = guest()!
+
for (const active of ['a', 'b', 'c', 'd']) {
setTree(group(panes, { id: 'zone', active }))
}
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
index fc7f3d2f59..a2d015f7e6 100644
--- 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
@@ -45,6 +45,7 @@ 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.
@@ -73,6 +74,7 @@ export function KeepAlivePanes({ children }: { children: ReactNode }) {
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)
@@ -105,6 +107,7 @@ interface KeepAlivePaneHostProps {
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.
@@ -119,6 +122,7 @@ const KeepAlivePaneHost = memo(function KeepAlivePaneHost({ pane, placement, epo
},
[placement.visible]
)
+
useResizeObserver(rememberSize, ref)
return (
@@ -190,10 +194,6 @@ export function KeepAlivePaneSlot({
)
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 7c143cc138..a7513d5e65 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
@@ -33,6 +33,7 @@ export function NarrowOverlays() {
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.
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 f4f671ab34..616f831f2b 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
@@ -368,6 +368,7 @@ 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)
diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts
index 53ba7d427b..7bb0c8bb69 100644
--- a/apps/desktop/src/components/pane-shell/tree/store.ts
+++ b/apps/desktop/src/components/pane-shell/tree/store.ts
@@ -70,7 +70,9 @@ function persist(tree: LayoutNode | null) {
export const $layoutTree = modeLayout.atom(
LAYOUT_KEYS.tree,
() => defaultTrees[modeLayout.mode],
- Codecs.json(parsed => (isLayoutNode(parsed) ? normalize(migratePersistedTree(parsed)) : defaultTrees[modeLayout.mode])),
+ Codecs.json(parsed =>
+ isLayoutNode(parsed) ? normalize(migratePersistedTree(parsed)) : defaultTrees[modeLayout.mode]
+ ),
true
)
@@ -196,7 +198,11 @@ const paneSetCodec = {
encode: (value: ReadonlySet) => Codecs.stringArray.encode([...value])
}
-export const $dismissedPanes = modeLayout.atom>(LAYOUT_KEYS.dismissed, () => new Set(), paneSetCodec)
+export const $dismissedPanes = modeLayout.atom>(
+ LAYOUT_KEYS.dismissed,
+ () => new Set(),
+ paneSetCodec
+)
function setDismissed(paneId: string, dismissed: boolean) {
const next = toggledSet($dismissedPanes.get(), paneId, dismissed)
@@ -288,7 +294,11 @@ 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.
-export const $hiddenStripTabs = modeLayout.atom>(LAYOUT_KEYS.hiddenTabs, () => new Set(), paneSetCodec)
+export const $hiddenStripTabs = modeLayout.atom>(
+ LAYOUT_KEYS.hiddenTabs,
+ () => new Set(),
+ paneSetCodec
+)
export function isStripTabHidden(paneId: string): boolean {
return $hiddenStripTabs.get().has(paneId)
@@ -1137,6 +1147,7 @@ export function bindTreeSideVisibility(
if (!hasPersistedSides) {
setTreeSideCollapsed(side, !$open.get())
}
+
$open.listen(open => setTreeSideCollapsed(side, !open))
}
diff --git a/apps/desktop/src/lib/layout-persistence.test.ts b/apps/desktop/src/lib/layout-persistence.test.ts
index c7b6fda874..b38859699a 100644
--- a/apps/desktop/src/lib/layout-persistence.test.ts
+++ b/apps/desktop/src/lib/layout-persistence.test.ts
@@ -51,6 +51,7 @@ it('retains legacy data in memory when migration writes fail and retries on the
if (key === 'hermes.desktop.layoutModeScopes.v1') {
throw new DOMException('Full', 'QuotaExceededError')
}
+
storage.setItem(key, value)
}
})
diff --git a/apps/desktop/src/lib/layout-persistence.ts b/apps/desktop/src/lib/layout-persistence.ts
index aeeb04d63f..730b861c0a 100644
--- a/apps/desktop/src/lib/layout-persistence.ts
+++ b/apps/desktop/src/lib/layout-persistence.ts
@@ -77,7 +77,7 @@ export function createLayoutPersistence(initialMode: InterfaceMode, persistent:
if (persistent) {
// Explicit empties keep migration retries from reviving cleared state.
- writeKey(keyFor(key), mode === 'simple' ? raw ?? 'null' : raw)
+ writeKey(keyFor(key), mode === 'simple' ? (raw ?? 'null') : raw)
}
}
}
@@ -89,12 +89,13 @@ export function createLayoutPersistence(initialMode: InterfaceMode, persistent:
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
+ const raw = persistent ? (readKey(keyFor(key)) ?? (mode === 'simple' ? (legacy.get(key) ?? null) : null)) : null
if (raw !== null) {
try {
@@ -143,6 +144,7 @@ export function createLayoutPersistence(initialMode: InterfaceMode, persistent:
},
onRestore(listener: () => void) {
listeners.add(listener)
+
return () => void listeners.delete(listener)
},
write,