diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index cc088bf2e186..681b1463b720 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Tip } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' import { isDesktopFsRemoteMode } from '@/lib/desktop-fs' +import { guardGuestPointers } from '@/lib/guest-pointer-guard' import { openPreviewTargetInBrowser, remoteHtmlPreviewDocument } from '@/lib/local-preview' import { rafCoalesce } from '@/lib/raf-coalesce' import { cn } from '@/lib/utils' @@ -187,6 +188,8 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = document.body.style.cursor = 'row-resize' document.body.style.userSelect = 'none' + // The webview above the console must not swallow the gesture. + const releaseGuests = guardGuestPointers() // pointermove outpaces 60fps and each setHeight reflows the webview + // console split, so coalesce to one apply per frame (commits on cleanup). @@ -207,6 +210,7 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = active = false resize.finish() + releaseGuests() document.body.style.cursor = previousCursor document.body.style.userSelect = previousUserSelect handle.releasePointerCapture?.(pointerId) diff --git a/apps/desktop/src/components/pane-shell/tree/model.ts b/apps/desktop/src/components/pane-shell/tree/model.ts index 06810cb4c585..2f87f807779c 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 000000000000..38e4c96ea172 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/pane-share-memory.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { LayoutNode } from '@/components/pane-shell/tree/model' + +// 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: 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/renderer/drag-session.ts b/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts index 6e479c679527..826f15d4b917 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts @@ -32,6 +32,7 @@ import type { PointerEvent as ReactPointerEvent } from 'react' import { createDragGhost, type DragGhost } from '@/lib/drag-ghost' import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers' +import { guardGuestPointers } from '@/lib/guest-pointer-guard' import { reorderCommitHaptic, reorderStepHaptic } from '@/lib/reorder' import type { DropPosition } from '../model' @@ -235,6 +236,7 @@ export function startDragSession(e: ReactPointerEvent, spec: DragSe const restoreSelect = document.body.style.userSelect let engaged = false let releaseEscapeLayer: (() => void) | null = null + let releaseGuests: (() => void) | null = null let ghost: DragGhost | null = null let cursor: string | null = null // rAF-coalesced move processing: the raw handler only records the latest @@ -275,6 +277,9 @@ export function startDragSession(e: ReactPointerEvent, spec: DragSe setCursor('grabbing') document.body.style.userSelect = 'none' + // Webview/iframe guests hit-test in their own process — dragging a tab + // across the in-app browser would go silent without this. + releaseGuests = guardGuestPointers() // While dragging, Esc belongs to the drag ALONE — lower layers (edit // mode, overlays) must not also fire on the same press. releaseEscapeLayer = pushEscapeLayer(ESCAPE_PRIORITY.drag) @@ -339,6 +344,8 @@ export function startDragSession(e: ReactPointerEvent, spec: DragSe ghost = null releaseEscapeLayer?.() releaseEscapeLayer = null + releaseGuests?.() + releaseGuests = null try { handle.releasePointerCapture?.(pointerId) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx index 21ca5ecd0a4f..2ed26d311c15 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx @@ -11,6 +11,7 @@ import { type PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef, u import { beginSashDrag, endSashDrag } from '@/components/pane-shell/geometry' import { useContributions } from '@/contrib/react/use-contributions' +import { guardGuestPointers } from '@/lib/guest-pointer-guard' import { rafCoalesce } from '@/lib/raf-coalesce' import { cn } from '@/lib/utils' import { $paneStates, type PaneStateSnapshot, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes' @@ -137,10 +138,10 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo const isCollapsed = (child: LayoutNode) => subtreeGone(child, trackCtx) || (isEmptyZone(child) && !editMode) // Min/max clamps come from a direct GROUP child's panes (the same clamps - // the app's Pane props express) — but ONLY when they can speak for the - // zone: a fixed track (pure sidebar stack) or a single-pane zone. A sidebar - // pane fronted in a mixed flex stack must not cap it. A fixed STACK - // aggregates its panes' clamps (largest-tenant semantics, mirroring the + // the app's Pane props express). Floors apply to every zone; caps only when + // they can speak for the whole zone: a fixed track (pure sidebar stack) or a + // single-pane zone — a sidebar pane fronted in a mixed flex stack must not + // cap it. Stacks aggregate clamps (largest-tenant semantics, mirroring the // max() track basis) — the active tab's caps must never resize the zone. const sizingFor = (child: LayoutNode, track: string | null): PaneSizing | null => { if (child.type !== 'group' || child.panes.length === 0) { @@ -149,20 +150,22 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo const shownIds = shownPaneIds(child, trackCtx) - if (track === null && shownIds.length !== 1) { - return null - } - if (shownIds.length <= 1) { return (paneFor(shownIds[0])?.data as PaneSizing | undefined) ?? null } - // Fixed STACK: floors take the largest declared min; caps stay unbounded - // unless EVERY pane declares one (a single uncapped tenant uncaps the - // zone). Same largest-tenant basis as the track size — never per-tab. + // STACKS aggregate floors with largest-tenant semantics (the zone's track + // is the max() of its panes' sizes, so its floor is the max() of their + // mins) — flex stacks included: the chat zone with session tabs stacked in + // must keep the workspace's min width, or a browser sash can crush the + // conversation down to the generic 80px floor. Caps only speak for a FIXED + // zone; a sidebar pane fronted in a mixed flex stack must not cap it. In a + // fixed stack caps stay unbounded unless EVERY pane declares one (a single + // uncapped tenant uncaps the zone). const all = shownIds.map(id => (paneFor(id)?.data ?? {}) as PaneSizing) - const cap = (pick: (s: PaneSizing) => string | undefined) => (all.every(pick) ? cssMax(all.map(pick)) : undefined) + const cap = (pick: (s: PaneSizing) => string | undefined) => + track !== null && all.every(pick) ? cssMax(all.map(pick)) : undefined return { minWidth: cssMax(all.map(s => s.minWidth)), @@ -258,6 +261,10 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize' document.body.style.userSelect = 'none' + // Webview/iframe guests must not swallow the gesture — without this the + // drag froze the moment the pointer entered the in-app browser, so the + // seam could only shrink it a few px per press. + const releaseGuests = guardGuestPointers() // Suppress :root geometry-var writes for the gesture (see geometry.ts — // each one restyles the whole document; they republish on release). beginSashDrag() @@ -323,13 +330,22 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo const resize = rafCoalesce(previewShift) let lastShift: null | number = null + let done = false const onMove = (ev: PointerEvent) => { lastShift = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start)) resize.push(lastShift) } + // Ends through several racing paths (pointerup, pointercancel, window + // blur, lostpointercapture — releasePointerCapture below fires the + // latter re-entrantly), so it must run exactly once. const cleanup = () => { + if (done) { + return + } + + done = true resize.finish() if (lastShift !== null) { @@ -367,6 +383,7 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo // Geometry vars re-enable AFTER the final store commit above, so the // release publishes exactly one fresh measurement. endSashDrag() + releaseGuests() document.body.style.cursor = restoreCursor document.body.style.userSelect = restoreSelect @@ -379,12 +396,16 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo window.removeEventListener('pointermove', onMove, true) window.removeEventListener('pointerup', cleanup, true) window.removeEventListener('pointercancel', cleanup, true) + window.removeEventListener('blur', cleanup) + handle.removeEventListener('lostpointercapture', cleanup) persistTree() } window.addEventListener('pointermove', onMove, true) window.addEventListener('pointerup', cleanup, true) window.addEventListener('pointercancel', cleanup, true) + window.addEventListener('blur', cleanup) + handle.addEventListener('lostpointercapture', cleanup) }, // trackCtx is derived state rebuilt per render; the drag captures it once. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index daf7d77fe233..dca7d28a2e47 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) diff --git a/apps/desktop/src/lib/guest-pointer-guard.ts b/apps/desktop/src/lib/guest-pointer-guard.ts new file mode 100644 index 000000000000..0b2d039dcb6b --- /dev/null +++ b/apps/desktop/src/lib/guest-pointer-guard.ts @@ -0,0 +1,36 @@ +/** + * Electron guests (and iframes) hit-test in their own process, so a + * pointer-capture drag in the embedder goes silent the moment the cursor + * crosses one — a sash resize froze after a few pixels of travel into the + * in-app browser, and only another press could continue it. While a drag is + * live, make every guest surface transparent to hit-testing (see the + * `guest-pointer-lock` rule in styles.css) so the window-level pointermove / + * pointerup listeners keep receiving the gesture. + */ +let depth = 0 + +/** Suppress pointer events on webview/iframe guests until released. Depth- + * counted so overlapping gestures compose; the returned release is + * idempotent (drags end through several racing paths — pointerup, blur, + * lostpointercapture). */ +export function guardGuestPointers(): () => void { + if (depth === 0) { + document.body.classList.add('guest-pointer-lock') + } + + depth += 1 + let released = false + + return () => { + if (released) { + return + } + + released = true + depth -= 1 + + if (depth === 0) { + document.body.classList.remove('guest-pointer-lock') + } + } +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 75dec6eb6788..a070fa623f6a 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1099,6 +1099,13 @@ code { border-color: var(--dt-destructive); } +/* Drag in progress (sash resize, console resize, tab drag): webview/iframe + guests hit-test in their own process and swallow the gesture the moment the + pointer crosses them — see lib/guest-pointer-guard.ts. */ +body.guest-pointer-lock :is(webview, iframe) { + pointer-events: none; +} + @layer components { /* Chromium 121+ (Electron) prefers standard scrollbar-* over ::-webkit-scrollbar and ignores the latter when both are set — platform