fix(desktop): reopen docked tiles at their last split share
This commit is contained in:
parent
d67337ee1a
commit
92ed8be923
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue