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,
|
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
|
||||||
|
|||||||
@@ -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 =>
|
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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user