fix(desktop): reopen docked tiles at their last split share

This commit is contained in:
Brooklyn Nicholson 2026-08-10 04:52:45 -05:00
parent d67337ee1a
commit 92ed8be923
3 changed files with 218 additions and 5 deletions

View File

@ -113,6 +113,27 @@ export function allPaneIds(node: LayoutNode): string[] {
return node.type === 'group' ? [...node.panes] : node.children.flatMap(allPaneIds)
}
/** The split whose DIRECT child carries `childId`, or null. */
export function findParentSplit(node: LayoutNode, childId: string): SplitNode | null {
if (node.type !== 'split') {
return null
}
if (node.children.some(child => child.id === childId)) {
return node
}
for (const child of node.children) {
const hit = findParentSplit(child, childId)
if (hit) {
return hit
}
}
return null
}
// ---------------------------------------------------------------------------
// Structural edits (pure)
// ---------------------------------------------------------------------------
@ -225,7 +246,11 @@ export function insertAtGroup(
before?: null | string,
/** Front the inserted pane TRUE for a gesture (drop/reveal), FALSE for silent
* adoption (logs stacking into the terminal zone must not steal its tab). */
activate: boolean = true
activate: boolean = true,
/** Edge splits only: the [target zone, added pane] weight pair (default
* even). Lets a re-opened tile take the share it held when it closed
* instead of half the anchor zone. */
edgeWeights?: readonly [number, number]
): LayoutNode | null {
const walk = (n: LayoutNode): LayoutNode => {
if (n.type === 'group') {
@ -252,8 +277,9 @@ export function insertAtGroup(
const leading = pos === 'left' || pos === 'top'
const added = group([paneId])
const children = leading ? [added, n] : [n, added]
const [targetWeight, addedWeight] = edgeWeights ?? [1, 1]
return split(orientation, children, [1, 1])
return split(orientation, children, leading ? [addedWeight, targetWeight] : [targetWeight, addedWeight])
}
return { ...n, children: n.children.map(walk) }

View File

@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Closing and re-opening a docked tile (the in-app browser) must respect the
// size the user left it at. Adoption's edge insert used to split the anchor
// zone [1, 1] every time, so each agent-triggered browser open re-took half
// the chat — "it keeps squishing my convo". The share the pane held against
// its seam neighbor is remembered on removal and re-applied on re-insert.
describe('tile split-share memory across close/reopen', () => {
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 registerBrowser = () =>
registry.register({
id: 'preview-tile:url:browser',
area: 'panes',
title: 'Browser',
data: { placement: 'main', dock: { pane: 'workspace', pos: 'right' } },
render: () => null
})
tree.declareDefaultTree(model.group(['workspace'], { id: 'grp-main' }))
tree.watchContributedPanes()
return { model, registerBrowser, registry, tree }
}
/** The root row's weights, normalized to shares of their sum. */
function rowShares(root: import('@/components/pane-shell/tree/model').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)
}
it('first open splits the anchor evenly', async () => {
const { registerBrowser, tree } = await setup()
registerBrowser()
expect(rowShares(tree.$layoutTree.get()!)).toEqual([0.5, 0.5])
})
it('reopening restores the share the pane was closed at', async () => {
const { registerBrowser, tree } = await setup()
const dispose = registerBrowser()
// The user drags the seam: browser down to a quarter of the pair.
const root = tree.$layoutTree.get()!
if (root.type !== 'split') {
throw new Error('expected a split root')
}
tree.setTreeSplitWeights(root.id, [3, 1])
// Close (the mirror disposes the contribution, then removes the pane)…
dispose()
tree.removeTreePane('preview-tile:url:browser')
expect(tree.$layoutTree.get()!.type).toBe('group')
// …and re-open: adoption re-docks at the remembered quarter, not [1, 1].
registerBrowser()
const shares = rowShares(tree.$layoutTree.get()!)
expect(shares[0]).toBeCloseTo(0.75)
expect(shares[1]).toBeCloseTo(0.25)
})
it('a stacked tab records no share (its removal changes no geometry)', async () => {
const { model, tree } = await setup()
const { registry } = await import('@/contrib/registry')
// Stacks INTO the workspace zone instead of splitting beside it.
const dispose = registry.register({
id: 'preview-tile:file:notes',
area: 'panes',
title: 'notes',
data: { placement: 'main', dock: { pane: 'workspace', pos: 'center' } },
render: () => null
})
expect(tree.$layoutTree.get()!.type).toBe('group')
dispose()
tree.removeTreePane('preview-tile:file:notes')
// Re-register docking to an EDGE: no remembered share exists, so the
// split falls back to the even default.
registry.register({
id: 'preview-tile:file:notes',
area: 'panes',
title: 'notes',
data: { placement: 'main', dock: { pane: 'workspace', pos: 'right' } },
render: () => null
})
expect(model.allPaneIds(tree.$layoutTree.get()!)).toContain('preview-tile:file:notes')
expect(rowShares(tree.$layoutTree.get()!)).toEqual([0.5, 0.5])
})
})

View File

@ -20,6 +20,7 @@ import {
type DropPosition,
findGroup,
findGroupOfPane,
findParentSplit,
groupLeafIds,
type GroupNode,
insertAtGroup,
@ -196,6 +197,55 @@ function setDismissed(paneId: string, dismissed: boolean) {
}
}
// SPLIT-SHARE MEMORY — a tile pane that leaves the tree (the browser closed,
// a page tile closed) records the share it held against its seam neighbor, so
// re-opening it docks at the size the user left it. Without this every
// re-open split the anchor zone [1, 1] again: each agent-triggered browser
// open re-took half the chat, whatever the user had resized it to.
const PANE_SHARE_KEY = 'hermes.desktop.paneShare.v1'
const paneShares: Record<string, number> = readJson<Record<string, number>>(PANE_SHARE_KEY) ?? {}
const validShare = (share: unknown): share is number =>
typeof share === 'number' && Number.isFinite(share) && share > 0 && share < 1
function rememberPaneShare(tree: LayoutNode, paneId: string) {
const zone = findGroupOfPane(tree, paneId)
// Only a pane ALONE in its zone owns the zone's track — a stacked tab's
// removal doesn't change geometry, so there's no share to remember.
if (!zone || zone.panes.length !== 1) {
return
}
const parent = findParentSplit(tree, zone.id)
if (!parent) {
return
}
// The previous sibling is the seam partner a re-dock will split again (a
// trailing dock lands the tile right of / below its anchor); the pane at
// index 0 pairs with the sibling after it instead.
const at = parent.children.findIndex(child => child.id === zone.id)
const partner = at > 0 ? at - 1 : at + 1
const pair = (parent.weights[at] ?? 1) + (parent.weights[partner] ?? 1)
const share = pair > 0 ? (parent.weights[at] ?? 1) / pair : null
if (validShare(share)) {
paneShares[paneId] = share
writeJson(PANE_SHARE_KEY, paneShares)
}
}
/** 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 {
const share = paneShares[paneId]
return validShare(share) ? [1 - share, share] : undefined
}
const paneClosers: Record<string, () => void> = {}
const paneOpeners: Record<string, () => void> = {}
@ -635,6 +685,7 @@ export function removeTreePane(paneId: string) {
const tree = $layoutTree.get()
if (tree) {
rememberPaneShare(tree, paneId)
commit(removePane(tree, paneId))
}
}
@ -697,6 +748,7 @@ export function dismissTreePane(paneId: string) {
if (tree) {
setDismissed(paneId, true)
rememberPaneShare(tree, paneId)
commit(removePane(tree, paneId))
}
}
@ -1102,8 +1154,18 @@ function adoptContributedPanes(): void {
// drag but wrong for adoption into a zone whose bar the user hid.
const hostHeaderHidden = findGroup(next, target)?.headerHidden === true
// Silent adoption: don't front over the zone's active tab — a reveal does.
next = insertAtGroup(next, target, pane.id, dock?.pos ?? 'center', dock?.before, false) ?? next
// 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.
next =
insertAtGroup(
next,
target,
pane.id,
dock?.pos ?? 'center',
dock?.before,
false,
recalledEdgeWeights(pane.id)
) ?? next
// An adopted pane ARRIVES with its chip showing — a surprise zone with
// zero chrome has no obvious handle to drag or close. (Explicit reveal;
@ -1214,7 +1276,7 @@ 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)
: insertAtGroup(tree, anchor.id, paneId, pos, undefined, true, recalledEdgeWeights(paneId))
if (next && next !== tree) {
commit(next)