diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index dcea643049..0dafa7bc94 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -19,6 +19,7 @@ import { bindToolPaneCollapse, declareDefaultTree, dismissTreePane, + hydrateContributedPanes, isPaneVisible, markCollapsePane, paneRootSide, @@ -31,8 +32,7 @@ import { setStripTabHidden, targetZoneTabStripVisible, togglePaneVisible, - toggleTargetZoneTabStrip, - watchContributedPanes + toggleTargetZoneTabStrip } from '@/components/pane-shell/tree/store' import { $workspaceOwnerLabels, workspaceOwnerTitle } from '@/components/pane-shell/workspace-scope' import { SidebarProvider } from '@/components/ui/sidebar' @@ -455,8 +455,10 @@ declareDefaultTree(DEFAULT_TREE, BASIC_TREE) discoverBundledPlugins() // Plugin panes join the tree by their `placement` hint the moment they -// register — incl. runtime plugins arriving seconds after boot. -watchContributedPanes() +// register — incl. runtime plugins arriving seconds after boot. The FIRST +// 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 // main. A popped-out Browser and the HUD have no layout tree — registering diff --git a/apps/desktop/src/components/pane-shell/tree/pane-reload-share.test.ts b/apps/desktop/src/components/pane-shell/tree/pane-reload-share.test.ts new file mode 100644 index 0000000000..657220644b --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/pane-reload-share.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index 8b74f5f395..ae5ca3c99d 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -242,6 +242,38 @@ export function undismissTreePanes(paneIds: Iterable): void { const validShare = (share: unknown): share is number => 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>( + 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>( LAYOUT_KEYS.shares, () => ({}), @@ -253,6 +285,14 @@ const $paneShares = modeLayout.atom>( ) 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) // 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 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 }) + + // 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, * 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] - 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 // 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. @@ -1527,7 +1597,8 @@ export function adoptContributedPanes(): void { if (target) { // 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 // 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?.before, false, - recalledEdgeWeights(pane.id) + recalledEdgeWeights(pane.id, anchor) ) ?? next } } @@ -1570,6 +1641,24 @@ export function watchContributedPanes(): void { 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) { if (!next) { return @@ -1645,7 +1734,15 @@ export function dockPaneBeside(paneId: string, anchorPaneId: string) { const next = findGroupOfPane(tree, paneId) ? 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) { commit(next) diff --git a/apps/desktop/src/lib/layout-persistence.ts b/apps/desktop/src/lib/layout-persistence.ts index 730b861c0a..6e5a2a5415 100644 --- a/apps/desktop/src/lib/layout-persistence.ts +++ b/apps/desktop/src/lib/layout-persistence.ts @@ -17,6 +17,7 @@ export const LAYOUT_KEYS = { panes: 'hermes.desktop.paneStates.v1', dismissed: 'hermes.desktop.dismissedPanes.v1', shares: 'hermes.desktop.paneShare.v1', + sharePartners: 'hermes.desktop.paneSharePartners.v1', hiddenTabs: 'hermes.desktop.hiddenStripTabs.v1', placed: 'hermes.desktop.userPlacedPanes.v1', flipped: 'hermes.desktop.panesFlipped', diff --git a/apps/desktop/src/store/session-states.test.ts b/apps/desktop/src/store/session-states.test.ts index e8d735377c..a6bdaf4c20 100644 --- a/apps/desktop/src/store/session-states.test.ts +++ b/apps/desktop/src/store/session-states.test.ts @@ -963,6 +963,42 @@ describe('dropTilesForProfile', () => { ) 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', () => { diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index df13ff6bdf..b3fbb29ced 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -1098,7 +1098,16 @@ function parseTileList(value: unknown): StoredTile[] { const raw = t as SessionTile 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, dir: raw.dir, ownerProfile: typeof raw.ownerProfile === 'string' ? normalizeProfileKey(raw.ownerProfile) : undefined,