diff --git a/apps/desktop/src/components/pane-shell/tree/model.ts b/apps/desktop/src/components/pane-shell/tree/model.ts index 06810cb4c5858..2f87f807779cf 100644 --- a/apps/desktop/src/components/pane-shell/tree/model.ts +++ b/apps/desktop/src/components/pane-shell/tree/model.ts @@ -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) } diff --git a/apps/desktop/src/components/pane-shell/tree/pane-share-memory.test.ts b/apps/desktop/src/components/pane-shell/tree/pane-share-memory.test.ts new file mode 100644 index 0000000000000..2438d4775f73e --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/pane-share-memory.test.ts @@ -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]) + }) +}) diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index daf7d77fe2330..dca7d28a2e472 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -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 = readJson>(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 void> = {} const paneOpeners: Record 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)