diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split-cascade-resize.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split-cascade-resize.test.tsx
new file mode 100644
index 0000000000..b5a77ce5fa
--- /dev/null
+++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split-cascade-resize.test.tsx
@@ -0,0 +1,183 @@
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { registry } from '@/contrib/registry'
+import { $paneStates } from '@/store/panes'
+
+import { group, split, type SplitNode } from '../model'
+import { $hiddenTreePanes, $layoutTree, markCollapsePane, setTreeGroupMinimized } from '../store'
+
+import { TreeSplit } from './tree-split'
+
+class TestResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+const disposers: (() => void)[] = []
+
+beforeAll(() => {
+ vi.stubGlobal('ResizeObserver', TestResizeObserver)
+ vi.stubGlobal('CSS', { ...globalThis.CSS, escape: (value: string) => value })
+ vi.stubGlobal('requestAnimationFrame', () => 1)
+ vi.stubGlobal('cancelAnimationFrame', () => undefined)
+ Element.prototype.hasPointerCapture ??= () => false
+ Element.prototype.setPointerCapture ??= () => undefined
+ Element.prototype.releasePointerCapture ??= () => undefined
+})
+
+beforeEach(() => {
+ window.localStorage.clear()
+ $hiddenTreePanes.set(new Set())
+ $paneStates.set({})
+
+ disposers.push(
+ registry.register({ area: 'panes', data: { placement: 'main' }, id: 'chat', render: () => null, title: 'Chat' }),
+ registry.register({
+ area: 'panes',
+ data: { placement: 'main', width: '100px' },
+ id: 'cron',
+ render: () => null,
+ title: 'Cron'
+ }),
+ registry.register({
+ area: 'panes',
+ data: { placement: 'main' },
+ id: 'browser',
+ render: () => null,
+ title: 'Browser'
+ })
+ )
+})
+
+afterEach(() => {
+ cleanup()
+ $layoutTree.set(null)
+ $paneStates.set({})
+ disposers.splice(0).forEach(dispose => dispose())
+})
+
+function rect(width: number): DOMRect {
+ return {
+ bottom: 600,
+ height: 600,
+ left: 0,
+ right: width,
+ toJSON: () => ({}),
+ top: 0,
+ width,
+ x: 0,
+ y: 0
+ } as DOMRect
+}
+
+function setWidth(element: HTMLElement, width: number) {
+ Object.defineProperty(element, 'getBoundingClientRect', { configurable: true, value: () => rect(width) })
+}
+
+function row(): SplitNode {
+ const tree = $layoutTree.get()
+
+ if (!tree || tree.type !== 'split') {
+ throw new Error('expected root row split')
+ }
+
+ return tree
+}
+
+describe('TreeSplit cascading expansion', () => {
+ it('grows Browser through Cron into Chat after Cron reaches its minimum', () => {
+ const tree = split(
+ 'row',
+ [
+ group(['chat'], { id: 'chat-zone' }),
+ group(['cron'], { id: 'cron-zone' }),
+ group(['browser'], { id: 'browser-zone' })
+ ],
+ [5, 1, 2],
+ 'root-row'
+ )
+
+ $layoutTree.set(tree)
+
+ render()
+
+ const container = document.querySelector('[data-tree-split="root-row"]')!
+ const [chat, cron, browser] = [...container.children] as HTMLElement[]
+ setWidth(container, 800)
+ setWidth(chat, 500)
+ setWidth(cron, 100)
+ setWidth(browser, 200)
+ setWidth(document.querySelector('[data-tree-group="cron-zone"]')!, 100)
+
+ const browserSash = document.querySelectorAll('[role="separator"]')[1]!
+ fireEvent.pointerDown(browserSash, { button: 0, clientX: 600, pointerId: 1, pointerType: 'mouse' })
+ fireEvent.pointerMove(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
+ fireEvent.pointerUp(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
+
+ // Browser's 300px requested growth first takes Cron from 100px to its
+ // 80px floor, then takes the remaining 280px from Chat. The browser gets
+ // every released pixel instead of stopping at Cron's local floor.
+ expect($paneStates.get().cron?.widthOverride).toBe(80)
+ expect(row().weights).toEqual([2.2, 1, 5])
+ })
+ it('commits a regular cascade when an unrelated tool rail is already minimized', () => {
+ markCollapsePane('terminal')
+ disposers.push(
+ registry.register({
+ area: 'panes',
+ data: { maxWidth: '600px', minWidth: '160px', placement: 'right', width: '200px' },
+ id: 'browser',
+ render: () => null,
+ title: 'Browser'
+ }),
+ registry.register({
+ area: 'panes',
+ data: { placement: 'bottom' },
+ id: 'terminal',
+ render: () => null,
+ title: 'Terminal'
+ })
+ )
+
+ const tree = split(
+ 'row',
+ [
+ group(['chat'], { id: 'chat-zone' }),
+ group(['cron'], { id: 'cron-zone' }),
+ group(['browser'], { id: 'browser-zone' }),
+ group(['terminal'], { id: 'terminal-zone' })
+ ],
+ [5, 1, 2, 0.28],
+ 'root-row'
+ )
+
+ $layoutTree.set(tree)
+ $paneStates.set({ browser: { open: true, widthOverride: 200 } })
+ setTreeGroupMinimized('terminal-zone', true)
+
+ render()
+
+ const container = document.querySelector('[data-tree-split="root-row"]')!
+ const [chat, cron, browser, terminal] = [...container.children] as HTMLElement[]
+ setWidth(container, 828)
+ setWidth(chat, 500)
+ setWidth(cron, 100)
+ setWidth(browser, 200)
+ setWidth(terminal, 28)
+ setWidth(document.querySelector('[data-tree-group="cron-zone"]')!, 100)
+ setWidth(document.querySelector('[data-tree-group="browser-zone"]')!, 200)
+ setWidth(document.querySelector('[data-tree-group="terminal-zone"]')!, 28)
+
+ const browserSash = document.querySelectorAll('[role="separator"]')[1]!
+ fireEvent.pointerDown(browserSash, { button: 0, clientX: 600, pointerId: 1, pointerType: 'mouse' })
+ fireEvent.pointerMove(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
+ fireEvent.pointerUp(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
+
+ expect($paneStates.get().cron?.widthOverride).toBe(80)
+ expect($paneStates.get().browser?.widthOverride).toBe(500)
+ expect(row().weights[0]).toBeCloseTo(2.2)
+ expect(row().children[3]).toMatchObject({ id: 'terminal-zone', minimized: true })
+ })
+})
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 15b44edf02..f147b855ea 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
@@ -73,6 +73,7 @@ function useSubtreeOverrides(paneIds: readonly string[]): TrackContext['override
const snapshot = useCallback(() => {
const all = $paneStates.get()
+
const sig = paneIds.map(id => `${id}:${all[id]?.widthOverride ?? ''}:${all[id]?.heightOverride ?? ''}`).join('|')
if (cache.current.sig !== sig) {
@@ -245,15 +246,167 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
return
}
- const a = sideFor(node.children[aIndex], kidA, 'end')
- const b = sideFor(node.children[bIndex], kidB, 'start')
- const a0px = a.fixed ? a.size : sizeOf(kidA)
- const b0px = b.fixed ? b.size : sizeOf(kidB)
- const lo = Math.max(a.min - a0px, b0px - b.max)
- const hi = Math.min(a.max - a0px, b0px - b.min)
+ const tracks = node.children.map((child, index) => {
+ const element = container.children[index] as HTMLElement | undefined
+ if (!element) {
+ return null
+ }
+
+ const side = sideFor(child, element, index <= aIndex ? 'end' : 'start')
+
+ return {
+ ...side,
+ element,
+ index,
+ initial: side.fixed ? side.size : sizeOf(element),
+ // A minimized rail is its 28px strip: it neither donates nor takes,
+ // and its remembered weight must survive the gesture so restoring
+ // it brings back the size it had before it was folded.
+ minimized: child.type === 'group' && Boolean(child.minimized),
+ visible: !isCollapsed(child)
+ }
+ })
+
+ if (tracks.some(track => !track)) {
+ return
+ }
+
+ const sashTracks = tracks as Array>
const setOverride = horizontal ? setPaneWidthOverride : setPaneHeightOverride
+ // The seam is ONE boundary of a whole run: the side the pointer moves
+ // toward gives up space, its seam partner first. When the partner hits
+ // its floor the drag keeps going and takes from the partner's next
+ // visible sibling, and so on — a row behaves like one workspace instead
+ // of a set of unrelated boxes. Plans are absolute (from the sizes at
+ // pointerdown), so every frame is a pure function of the pointer.
+ const planFor = (requestedShift: number, allowCascade: boolean) => {
+ const toward = requestedShift >= 0 ? 1 : -1
+ const targetIndex = toward > 0 ? aIndex : bIndex
+ // The partner is the nearest VISIBLE sibling (`aIndex`/`bIndex`), not
+ // `target ± 1` — a hidden zone parked between them (the closed preview
+ // pane) is display:none and must neither donate nor block.
+ const partnerIndex = toward > 0 ? bIndex : aIndex
+ const target = sashTracks[targetIndex]
+ const next = sashTracks.map(track => track.initial)
+ let remaining = Math.min(Math.abs(requestedShift), Math.max(0, target.max - target.initial))
+ let transferred = 0
+ let cascaded = false
+ // A tool rail is locally resizable at its own seam, but must not make
+ // every unrelated seam in the row local-only. It also remains the
+ // terminal donor when reached only after another regular track.
+ const cascade = allowCascade && !target.collapseId
+
+ for (let donorIndex = partnerIndex; donorIndex >= 0 && donorIndex < sashTracks.length; donorIndex += toward) {
+ const donor = sashTracks[donorIndex]
+
+ if (!donor.visible) {
+ continue
+ }
+
+ if (donor.collapseId && donorIndex !== partnerIndex) {
+ break
+ }
+
+ const take = Math.min(remaining, Math.max(0, donor.initial - donor.min))
+ next[donorIndex] -= take
+ transferred += take
+ remaining -= take
+
+ if (take > 0 && donorIndex !== partnerIndex) {
+ cascaded = true
+ }
+
+ if (remaining === 0 || !cascade) {
+ break
+ }
+ }
+
+ next[targetIndex] += transferred
+
+ return { cascaded, moved: Math.sign(requestedShift) * transferred, sizes: next }
+ }
+
+ // One store commit on release: fixed zones get their px override
+ // (sidebar semantics), the flex run gets weights. Clamped px → weights
+ // so persisted weights match what's on screen.
+ const commitPlan = (plan: ReturnType) => {
+ const weights = [...node.weights]
+ let weightsChanged = false
+
+ sashTracks.forEach((track, index) => {
+ if (!track.visible || track.minimized) {
+ return
+ }
+
+ const px = Math.round(plan.sizes[index])
+
+ if (track.fixed) {
+ if (px !== Math.round(track.initial)) {
+ track.paneIds.forEach(id => setOverride(id, px))
+ }
+ } else {
+ weights[index] = Math.max(0.01, plan.sizes[index] / pxPerWeight)
+ weightsChanged = true
+ }
+ })
+
+ if (weightsChanged) {
+ setTreeSplitWeights(node.id, weights)
+ }
+ }
+
+ const styleSnapshots = sashTracks.map(track => track.element.getAttribute('style'))
+
+ const restoreStyles = () => {
+ sashTracks.forEach((track, index) => {
+ const style = styleSnapshots[index]
+
+ if (style === null) {
+ track.element.removeAttribute('style')
+ } else {
+ track.element.setAttribute('style', style)
+ }
+ })
+ }
+
+ // During the gesture the store is NOT written. setTreeSplitWeights /
+ // setPaneWidthOverride each mint a new tree/pane-state object, and the
+ // resulting commit walks every mounted pane — measured live on a real
+ // 2-session layout: 31 commits across a 58-frame drag, 20.7fps, with
+ // TreeNode at 490ms and Block/Ct re-parsing markdown for 620ms. The
+ // store is written ONCE on release; during the drag the run is
+ // previewed with inline styles on the same wrappers React sizes.
+ //
+ // Preview rules (learned the hard way — a wrong shape here left a
+ // phantom gap where a hidden sidebar lived):
+ // - a FIXED track gets ONLY a flex-basis override. Its wrapper renders
+ // as `flex: 0 1