Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/src/app/chat/right-rail/preview-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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).
Expand All @@ -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)
Expand Down
30 changes: 28 additions & 2 deletions apps/desktop/src/components/pane-shell/tree/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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') {
Expand All @@ -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) }
Expand Down
127 changes: 127 additions & 0 deletions apps/desktop/src/components/pane-shell/tree/pane-share-memory.test.ts
Original file line number Diff line number Diff line change
@@ -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])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -235,6 +236,7 @@ export function startDragSession(e: ReactPointerEvent<HTMLElement>, 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
Expand Down Expand Up @@ -275,6 +277,9 @@ export function startDragSession(e: ReactPointerEvent<HTMLElement>, 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)
Expand Down Expand Up @@ -339,6 +344,8 @@ export function startDragSession(e: ReactPointerEvent<HTMLElement>, spec: DragSe
ghost = null
releaseEscapeLayer?.()
releaseEscapeLayer = null
releaseGuests?.()
releaseGuests = null

try {
handle.releasePointerCapture?.(pointerId)
Expand Down
45 changes: 33 additions & 12 deletions apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand All @@ -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)),
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
Loading
Loading