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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -242,6 +242,38 @@ export function undismissTreePanes(paneIds: Iterable<string>): 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<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>>(
|
||||
LAYOUT_KEYS.shares,
|
||||
() => ({}),
|
||||
@@ -253,6 +285,14 @@ const $paneShares = modeLayout.atom<Record<string, number>>(
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user