fix(desktop): preserve tile widths across reload via hydration suspend + partner-validated recall (#108679)

Boot-time pruning recorded share transitions while panes were still
hydrating, and recalled seam weights didn't validate the partner pane,
so a reload could rewrite tile widths. beginLayoutHydration gates
contributed-pane pruning until hydration ends; paneSharePartners.v1
records the seam partner and recalledEdgeWeights falls back to even on
partner mismatch; parseTileList drops self-anchored entries.
This commit is contained in:
Hermes Agent
2026-09-24 19:59:29 -05:00
committed by brooklyn!
parent b401fb2e49
commit df293160ce
6 changed files with 355 additions and 10 deletions

View File

@@ -19,6 +19,7 @@ import {
bindToolPaneCollapse, bindToolPaneCollapse,
declareDefaultTree, declareDefaultTree,
dismissTreePane, dismissTreePane,
hydrateContributedPanes,
isPaneVisible, isPaneVisible,
markCollapsePane, markCollapsePane,
paneRootSide, paneRootSide,
@@ -31,8 +32,7 @@ import {
setStripTabHidden, setStripTabHidden,
targetZoneTabStripVisible, targetZoneTabStripVisible,
togglePaneVisible, togglePaneVisible,
toggleTargetZoneTabStrip, toggleTargetZoneTabStrip
watchContributedPanes
} from '@/components/pane-shell/tree/store' } from '@/components/pane-shell/tree/store'
import { $workspaceOwnerLabels, workspaceOwnerTitle } from '@/components/pane-shell/workspace-scope' import { $workspaceOwnerLabels, workspaceOwnerTitle } from '@/components/pane-shell/workspace-scope'
import { SidebarProvider } from '@/components/ui/sidebar' import { SidebarProvider } from '@/components/ui/sidebar'
@@ -455,8 +455,10 @@ declareDefaultTree(DEFAULT_TREE, BASIC_TREE)
discoverBundledPlugins() discoverBundledPlugins()
// Plugin panes join the tree by their `placement` hint the moment they // Plugin panes join the tree by their `placement` hint the moment they
// register — incl. runtime plugins arriving seconds after boot. // register — incl. runtime plugins arriving seconds after boot. The FIRST
watchContributedPanes() // pass runs as layout hydration: the reload prune→re-register cycle must not
// record split shares (#108679).
hydrateContributedPanes()
// Session + route (page) tiles: persisted splits register panes docked beside // Session + route (page) tiles: persisted splits register panes docked beside
// main. A popped-out Browser and the HUD have no layout tree — registering // main. A popped-out Browser and the HUD have no layout tree — registering

View File

@@ -0,0 +1,200 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LayoutNode, SplitNode } from '@/components/pane-shell/tree/model'
// #108679: a hard reload boots with `registered` empty, so the pane-mirror
// prunes every persisted session-tile pane from the tree — and each removal
// records its share (0.5 for an evenly-split row) as if it were a user
// resize. When the tiles re-dock in anchor order, `recalledEdgeWeights`
// replays those stale shares against a DIFFERENTLY shaped row, so tiles later
// in the re-dock chain land at half weight and the persisted tree drifts
// [1,1,1,1] → [1,1,1,1,0.5,0.5,1]. Two guards close the class:
//
// 1. Share recording is SUSPENDED while the store is hydrating (a boot
// prune is not remembered geometry).
// 2. A recorded share only replays against the seam partner it was recorded
// with — a differently shaped row falls back to the even default.
describe('tile split-share memory across a hard reload', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})
afterEach(() => {
vi.resetModules()
})
async function setup() {
const tree = await import('@/components/pane-shell/tree/store')
const model = await import('@/components/pane-shell/tree/model')
const { registry } = await import('@/contrib/registry')
registry.register({
id: 'workspace',
area: 'panes',
title: 'chat',
data: { placement: 'main', uncloseable: true },
render: () => null
})
const registerTile = (id: string, anchor: string) =>
registry.register({
id,
area: 'panes',
title: id,
data: { placement: 'main', dock: { pane: anchor, pos: 'right' } },
render: () => null
})
tree.declareDefaultTree(model.group(['workspace'], { id: 'grp-main' }))
tree.watchContributedPanes()
return { model, registerTile, registry, tree }
}
/** The root row's weights, normalized to shares of their sum. */
function rowShares(root: LayoutNode) {
if (root.type !== 'split') {
throw new Error('expected a split root')
}
const total = root.weights.reduce((a, b) => a + b, 0)
return root.weights.map(w => w / total)
}
/** All split nodes in the tree (row and column alike). */
function splits(node: LayoutNode, acc: SplitNode[] = []): SplitNode[] {
if (node.type === 'group') {
return acc
}
acc.push(node)
for (const child of node.children) {
splits(child, acc)
}
return acc
}
it('a reload-cycle prune does not record stale half-shares (hydration suspend)', async () => {
const { registerTile, tree } = await setup()
// The reload shape: hydration begins (the mirror's registered map is
// empty), every persisted tile pane is pruned, then the tiles re-register
// and re-dock in anchor order. The prune pass must not remember shares.
tree.beginLayoutHydration()
for (const id of ['session-tile:a', 'session-tile:b', 'session-tile:c', 'session-tile:d']) {
tree.removeTreePane(id)
}
tree.endLayoutHydration()
// Persisted shares stay empty — nothing was recorded by the prune.
expect(tree.$paneShareRecords.get()).toEqual({})
// Re-dock: the tiles come back in anchor order.
registerTile('session-tile:a', 'workspace')
registerTile('session-tile:b', 'session-tile:a')
registerTile('session-tile:c', 'session-tile:b')
registerTile('session-tile:d', 'session-tile:c')
// A reload cycle must land on the same WEIGHTS a fresh dock of the same
// tiles produces — no half-weight drift for tiles later in the re-dock
// chain. (Group ids are regenerated per insert, so compare the weight
// vector only, against a fresh-boot reference of the same tile set.)
const fresh = await setup()
for (const [id, anchor] of [
['session-tile:a', 'workspace'],
['session-tile:b', 'session-tile:a'],
['session-tile:c', 'session-tile:b'],
['session-tile:d', 'session-tile:c']
] as const) {
fresh.registerTile(id, anchor)
}
const afterTree = tree.$layoutTree.get()!
const freshTree = fresh.tree.$layoutTree.get()!
if (afterTree.type !== 'split' || freshTree.type !== 'split') {
throw new Error('expected split roots')
}
expect(afterTree.weights).toEqual(freshTree.weights)
})
it('a stale recorded share does not replay against a different seam partner', async () => {
const { registerTile, tree } = await setup()
// The user really did leave tile A at a quarter of the pair — recorded
// against the WORKSPACE seam.
const disposeA = registerTile('session-tile:a', 'workspace')
const root = tree.$layoutTree.get()!
if (root.type !== 'split') {
throw new Error('expected a split root')
}
tree.setTreeSplitWeights(root.id, [3, 1])
disposeA()
tree.removeTreePane('session-tile:a')
// The share was recorded against the workspace as seam partner.
expect(tree.$paneShareRecords.get()['session-tile:a']).toBeCloseTo(0.25)
// But the tree changes shape before the tile returns: it now re-docks
// beside ANOTHER tile, not the workspace. The remembered share belongs
// to a seam that no longer exists — replay must fall back to even.
registerTile('session-tile:b', 'workspace')
registerTile('session-tile:a', 'session-tile:b')
// The seam holding tile A must be even: the share was recorded against
// the workspace, not against tile B. (The tree may flatten the nested
// seam into the root row — assert on the WEIGHTS of tile A's slot vs its
// row, normalized: an even seam means A's slot equals its neighbor's.)
const root2b = tree.$layoutTree.get()!
if (root2b.type !== 'split') {
throw new Error('expected a split root')
}
const seam = splits(root2b).find(s =>
s.children.some(c => c.type === 'group' && c.panes.includes('session-tile:a'))
)
expect(seam).toBeDefined()
// Tile A's slot must take the same share as its seam neighbor: an even
// split, not the 3:1 recorded against the workspace.
const at = seam!.children.findIndex(c => c.type === 'group' && c.panes.includes('session-tile:a'))
const total = seam!.weights.reduce((x, y) => x + y, 0)
const aShare = seam!.weights[at]! / total
const neighborShare = seam!.weights[at === 0 ? 1 : at - 1]! / total
expect(aShare).toBeCloseTo(neighborShare)
})
it('a genuine user resize still records and replays against the same partner', async () => {
const { registerTile, tree } = await setup()
const disposeA = registerTile('session-tile:a', 'workspace')
const root = tree.$layoutTree.get()!
if (root.type !== 'split') {
throw new Error('expected a split root')
}
tree.setTreeSplitWeights(root.id, [3, 1])
disposeA()
tree.removeTreePane('session-tile:a')
// Same seam partner back: the quarter share replays.
registerTile('session-tile:a', 'workspace')
const shares = rowShares(tree.$layoutTree.get()!)
expect(shares[0]).toBeCloseTo(0.75)
expect(shares[1]).toBeCloseTo(0.25)
})
})

View File

@@ -242,6 +242,38 @@ export function undismissTreePanes(paneIds: Iterable<string>): void {
const validShare = (share: unknown): share is number => const validShare = (share: unknown): share is number =>
typeof share === 'number' && Number.isFinite(share) && share > 0 && share < 1 typeof share === 'number' && Number.isFinite(share) && share > 0 && share < 1
// The seam partner each recorded share was measured against. A share is only
// meaningful against THAT pane: a reload re-docks tiles in anchor order
// against a differently shaped row, and replaying an even-row 0.5 there is
// how tiles later in the re-dock chain came back at half width (#108679).
const $paneSharePartners = modeLayout.atom<Record<string, string>>(
LAYOUT_KEYS.sharePartners,
() => ({}),
Codecs.json(value =>
value && typeof value === 'object'
? Object.fromEntries(
Object.entries(value).filter(([, partner]) => typeof partner === 'string' && partner)
)
: {}
)
)
// True while the persisted tree is being reconciled at boot (the reload
// prune→re-register cycle). Share recording is suspended for its duration: a
// hydration prune is not a user resize, and remembering its geometry is what
// seeded the stale 0.5 shares #108679 replays.
let layoutHydrating = false
/** Suspend share recording while hydration reconciles the persisted tree. */
export function beginLayoutHydration(): void {
layoutHydrating = true
}
/** Resume share recording after hydration settles. */
export function endLayoutHydration(): void {
layoutHydrating = false
}
const $paneShares = modeLayout.atom<Record<string, number>>( const $paneShares = modeLayout.atom<Record<string, number>>(
LAYOUT_KEYS.shares, LAYOUT_KEYS.shares,
() => ({}), () => ({}),
@@ -253,6 +285,14 @@ const $paneShares = modeLayout.atom<Record<string, number>>(
) )
function rememberPaneShare(tree: LayoutNode, paneId: string) { function rememberPaneShare(tree: LayoutNode, paneId: string) {
// A hydration prune must never write remembered geometry (#108679): the
// reload cycle removes every persisted tile and re-docks it moments later,
// and the share it "held" at removal belongs to a row shape that no longer
// exists by the time it returns.
if (layoutHydrating) {
return
}
const zone = findGroupOfPane(tree, paneId) const zone = findGroupOfPane(tree, paneId)
// Only a pane ALONE in its zone owns the zone's track — a stacked tab's // Only a pane ALONE in its zone owns the zone's track — a stacked tab's
@@ -276,18 +316,48 @@ function rememberPaneShare(tree: LayoutNode, paneId: string) {
const share = pair > 0 ? (parent.weights[at] ?? 1) / pair : null const share = pair > 0 ? (parent.weights[at] ?? 1) / pair : null
if (validShare(share)) { if (validShare(share)) {
// The seam partner as a PANE id, for partner-validated recall. A zone
// holding several panes has no single seam pane — its share can never be
// partner-validated, so it records without a partner and falls back to
// even on any mismatched recall.
const partnerGroup = parent.children[partner] as LayoutNode
const partnerPane =
partnerGroup.type === 'group' && partnerGroup.panes.length === 1 ? partnerGroup.panes[0] : null
$paneShares.set({ ...$paneShares.get(), [paneId]: share }) $paneShares.set({ ...$paneShares.get(), [paneId]: share })
// Remember the seam partner only when it is a REAL pane id — a share
// against a nameless or multi-pane zone can never be partner-validated.
if (partnerPane) {
$paneSharePartners.set({ ...$paneSharePartners.get(), [paneId]: partnerPane })
}
} }
} }
/** The [target, added] weight pair a re-inserted pane's edge split should get, /** The [target, added] weight pair a re-inserted pane's edge split should get,
* or undefined for the even default. Persisted state is untrusted. */ * or undefined for the even default. Persisted state is untrusted. */
function recalledEdgeWeights(paneId: string): [number, number] | undefined { function recalledEdgeWeights(paneId: string, anchorPaneId?: string): [number, number] | undefined {
const share = $paneShares.get()[paneId] const share = $paneShares.get()[paneId]
return validShare(share) ? [1 - share, share] : undefined if (!validShare(share)) {
return undefined
}
// Partner validation (#108679): the share was recorded against a specific
// seam neighbor. Replaying it against a different partner docks the pane at
// a share that belonged to another row shape — fall back to even instead.
const partner = $paneSharePartners.get()[paneId]
if (partner && anchorPaneId && partner !== anchorPaneId) {
return undefined
}
return [1 - share, share]
} }
/** The recorded seam shares, for tests and diagnostics. */
export const $paneShareRecords = $paneShares
// HIDE-ONLY STRIP TABS (`hideOnly` chrome: sessions / Bots) — standing chrome // HIDE-ONLY STRIP TABS (`hideOnly` chrome: sessions / Bots) — standing chrome
// whose tab must never grow a ✕. Show/hide replaces Close for them: the zone // whose tab must never grow a ✕. Show/hide replaces Close for them: the zone
// menu's Show/Hide rows and the auto-registered ⌘K toggles both land here. // menu's Show/Hide rows and the auto-registered ⌘K toggles both land here.
@@ -1527,7 +1597,8 @@ export function adoptContributedPanes(): void {
if (target) { if (target) {
// Silent adoption: don't front over the zone's active tab — a reveal // Silent adoption: don't front over the zone's active tab — a reveal
// does. An edge dock re-takes the share the pane held when it closed. // does. An edge dock re-takes the share the pane held when it closed —
// but only against the seam partner it was recorded with (#108679).
// //
// Nothing writes the strip choice afterwards. This used to read the // Nothing writes the strip choice afterwards. This used to read the
// host's hidden flag before the insert and stamp it back on after, purely // host's hidden flag before the insert and stamp it back on after, purely
@@ -1542,7 +1613,7 @@ export function adoptContributedPanes(): void {
dock?.pos ?? 'center', dock?.pos ?? 'center',
dock?.before, dock?.before,
false, false,
recalledEdgeWeights(pane.id) recalledEdgeWeights(pane.id, anchor)
) ?? next ) ?? next
} }
} }
@@ -1570,6 +1641,24 @@ export function watchContributedPanes(): void {
registry.subscribe(adoptContributedPanes) registry.subscribe(adoptContributedPanes)
} }
/** Reconcile the persisted tree with the registry as part of BOOT hydration:
* the reload prune→re-register cycle runs with share recording suspended
* (#108679 — a hydration prune is not a user resize, and its recorded
* shares were what re-docked tiles replayed at half width). Call once from
* the app root, after declareDefaultTree, in place of a bare
* watchContributedPanes() when the surface persists tiles. */
export function hydrateContributedPanes(): void {
beginLayoutHydration()
try {
adoptContributedPanes()
} finally {
endLayoutHydration()
}
watchContributedPanes()
}
function commit(next: LayoutNode | null) { function commit(next: LayoutNode | null) {
if (!next) { if (!next) {
return return
@@ -1645,7 +1734,15 @@ export function dockPaneBeside(paneId: string, anchorPaneId: string) {
const next = findGroupOfPane(tree, paneId) const next = findGroupOfPane(tree, paneId)
? movePaneOp(tree, paneId, { groupId: anchor.id, pos }) ? movePaneOp(tree, paneId, { groupId: anchor.id, pos })
: insertAtGroup(tree, anchor.id, paneId, pos, undefined, true, recalledEdgeWeights(paneId)) : insertAtGroup(
tree,
anchor.id,
paneId,
pos,
undefined,
true,
recalledEdgeWeights(paneId, anchorPaneId)
)
if (next && next !== tree) { if (next && next !== tree) {
commit(next) commit(next)

View File

@@ -17,6 +17,7 @@ export const LAYOUT_KEYS = {
panes: 'hermes.desktop.paneStates.v1', panes: 'hermes.desktop.paneStates.v1',
dismissed: 'hermes.desktop.dismissedPanes.v1', dismissed: 'hermes.desktop.dismissedPanes.v1',
shares: 'hermes.desktop.paneShare.v1', shares: 'hermes.desktop.paneShare.v1',
sharePartners: 'hermes.desktop.paneSharePartners.v1',
hiddenTabs: 'hermes.desktop.hiddenStripTabs.v1', hiddenTabs: 'hermes.desktop.hiddenStripTabs.v1',
placed: 'hermes.desktop.userPlacedPanes.v1', placed: 'hermes.desktop.userPlacedPanes.v1',
flipped: 'hermes.desktop.panesFlipped', flipped: 'hermes.desktop.panesFlipped',

View File

@@ -963,6 +963,42 @@ describe('dropTilesForProfile', () => {
) )
expect(mod.$sessionTiles.get().map(tile => tile.storedSessionId)).toEqual(['bot-remote']) expect(mod.$sessionTiles.get().map(tile => tile.storedSessionId)).toEqual(['bot-remote'])
}) })
// #108679: a tile record whose anchor is its OWN pane id is
// self-referential — the re-dock target can never exist at adoption time,
// so the tile falls through to an arbitrary same-placement neighbor
// instead of the recorded layout. Loading must rewrite it to the
// workspace anchor (the same surface an anchorless tile re-docks against)
// while a REAL cross-tile anchor survives the round-trip.
it('rewrites a self-anchored tile record to the workspace anchor at load', async () => {
window.localStorage.setItem(
TILES_KEY,
JSON.stringify({
default: [
// Self-referential: anchor === the tile's own pane id.
{ anchor: 'session-tile:20260912_080117', storedSessionId: '20260912_080117' },
// Legitimate: docked beside another tile.
{ anchor: 'session-tile:20260912_080117', storedSessionId: '20260912_080118' },
// Legitimate: docked beside the workspace.
{ anchor: 'workspace', storedSessionId: '20260912_080119' }
]
})
)
// Storage is read at module load — reset and re-import after seeding it.
vi.resetModules()
const fresh = await import('@/store/session-states')
const tiles = fresh.$sessionTiles.get()
const byId = new Map(tiles.map(tile => [tile.storedSessionId, tile]))
// The self-anchor is dropped (the mirror re-docks those tiles against
// the workspace by default); the other two anchors round-trip intact.
expect(tiles).toHaveLength(3)
expect(byId.get('20260912_080117')!.anchor).toBeUndefined()
expect(byId.get('20260912_080118')!.anchor).toBe('session-tile:20260912_080117')
expect(byId.get('20260912_080119')!.anchor).toBe('workspace')
})
}) })
describe('releaseSessionTranscript', () => { describe('releaseSessionTranscript', () => {

View File

@@ -1098,7 +1098,16 @@ function parseTileList(value: unknown): StoredTile[] {
const raw = t as SessionTile const raw = t as SessionTile
return { return {
anchor: typeof raw.anchor === 'string' ? raw.anchor : undefined, // #108679: a tile whose anchor is its OWN pane id is
// self-referential — the re-dock target can never exist (the
// pane is not in the tree at adoption time), so the dock falls
// through to an arbitrary same-placement neighbor instead of the
// recorded layout. Rewrite it to the workspace anchor at load,
// the same surface an anchorless tile re-docks against.
anchor:
typeof raw.anchor === 'string' && raw.anchor !== `${TILE_PANE_PREFIX}${raw.storedSessionId}`
? raw.anchor
: undefined,
before: typeof raw.before === 'string' || raw.before === null ? raw.before : undefined, before: typeof raw.before === 'string' || raw.before === null ? raw.before : undefined,
dir: raw.dir, dir: raw.dir,
ownerProfile: typeof raw.ownerProfile === 'string' ? normalizeProfileKey(raw.ownerProfile) : undefined, ownerProfile: typeof raw.ownerProfile === 'string' ? normalizeProfileKey(raw.ownerProfile) : undefined,