From 1dffe0e670727556ad3c051170cb3d0d04bccb96 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 19 Jul 2026 22:27:36 -0500 Subject: [PATCH 1/2] perf(desktop): rAF-coalesce pane + console sash resizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both drag handlers wrote to nanostores on every pointermove — the pane sash via setPaneWidth/HeightOverride / setTreeSplitWeights (relayouts the whole pane tree), the preview console sash via consoleState.setHeight (reflows webview + split). pointermove outpaces 60fps, so that's several store-driven relayouts per frame during a drag. Stash the latest clamped value and apply it once per frame in a requestAnimation- Frame (the same pattern drag-session.ts / use-popout-drag.ts already use); cleanup cancels the pending frame and commits the final position. Behavior identical, just one relayout per frame instead of per event. typecheck + eslint clean; preview-pane tests green. --- .../src/app/chat/right-rail/preview-pane.tsx | 26 ++++++++++++++- .../pane-shell/tree/renderer/tree-split.tsx | 33 ++++++++++++++++--- 2 files changed, 54 insertions(+), 5 deletions(-) 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 ae9d51d1e744..b130f4b35717 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -172,12 +172,26 @@ export function PreviewPane({ document.body.style.cursor = 'row-resize' document.body.style.userSelect = 'none' + // Coalesce height writes to one per frame — pointermove outpaces 60fps and + // each setHeight reflows the webview + console split. + let raf: null | number = null + let pendingHeight: null | number = null + + const flushHeight = () => { + raf = null + + if (pendingHeight !== null) { + consoleState.setHeight(pendingHeight) + } + } + const handleMove = (moveEvent: PointerEvent) => { if (!active) { return } - consoleState.setHeight(clampConsoleHeight(startHeight + startY - moveEvent.clientY)) + pendingHeight = clampConsoleHeight(startHeight + startY - moveEvent.clientY) + raf ??= requestAnimationFrame(flushHeight) } const cleanup = () => { @@ -186,6 +200,16 @@ export function PreviewPane({ } active = false + + if (raf !== null) { + cancelAnimationFrame(raf) + raf = null + } + + if (pendingHeight !== null) { + consoleState.setHeight(pendingHeight) // commit the final height + } + document.body.style.cursor = previousCursor document.body.style.userSelect = previousUserSelect 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 41225a478073..e73300f8daf1 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 @@ -234,9 +234,13 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize' document.body.style.userSelect = 'none' - const onMove = (ev: PointerEvent) => { - const shiftPx = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start)) + // Coalesce store writes to one per frame: pointermove fires far faster than + // 60fps and each write relayouts the whole pane tree. Stash the latest + // clamped shift, apply it in a rAF (as drag-session / use-popout-drag do). + let raf: null | number = null + let pendingShift: null | number = null + const applyShift = (shiftPx: number) => { if (a.fixed) { a.paneIds.forEach(id => setOverride(id, Math.round(a0px + shiftPx))) } @@ -247,15 +251,36 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo if (!a.fixed && !b.fixed) { const weights = [...node.weights] - // Convert the CLAMPED pixel sizes back to weights so the persisted - // weights always agree with what's on screen. + // Clamped px → weights so persisted weights match what's on screen. weights[aIndex] = (a0px + shiftPx) / pxPerWeight weights[bIndex] = (b0px - shiftPx) / pxPerWeight setTreeSplitWeights(node.id, weights) } } + const flush = () => { + raf = null + + if (pendingShift !== null) { + applyShift(pendingShift) + } + } + + const onMove = (ev: PointerEvent) => { + pendingShift = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start)) + raf ??= requestAnimationFrame(flush) + } + const cleanup = () => { + if (raf !== null) { + cancelAnimationFrame(raf) + raf = null + } + + if (pendingShift !== null) { + applyShift(pendingShift) // commit the final position + } + document.body.style.cursor = restoreCursor document.body.style.userSelect = restoreSelect From 358e26a1c21be8fca2f99173a7d3f14ec88f37e2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 19 Jul 2026 22:38:57 -0500 Subject: [PATCH 2/2] refactor(desktop): extract shared rafCoalesce helper for sash drags --- .../src/app/chat/right-rail/preview-pane.tsx | 30 ++++------------ .../pane-shell/tree/renderer/tree-split.tsx | 30 ++++------------ apps/desktop/src/lib/raf-coalesce.ts | 34 +++++++++++++++++++ 3 files changed, 46 insertions(+), 48 deletions(-) create mode 100644 apps/desktop/src/lib/raf-coalesce.ts 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 b130f4b35717..f240c4b20670 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -7,6 +7,7 @@ import { Tip } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' import { isDesktopFsRemoteMode } from '@/lib/desktop-fs' import { Bug } from '@/lib/icons' +import { rafCoalesce } from '@/lib/raf-coalesce' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } from '@/store/preview' @@ -172,26 +173,16 @@ export function PreviewPane({ document.body.style.cursor = 'row-resize' document.body.style.userSelect = 'none' - // Coalesce height writes to one per frame — pointermove outpaces 60fps and - // each setHeight reflows the webview + console split. - let raf: null | number = null - let pendingHeight: null | number = null - - const flushHeight = () => { - raf = null - - if (pendingHeight !== null) { - consoleState.setHeight(pendingHeight) - } - } + // pointermove outpaces 60fps and each setHeight reflows the webview + + // console split, so coalesce to one apply per frame (commits on cleanup). + const resize = rafCoalesce((height: number) => consoleState.setHeight(height)) const handleMove = (moveEvent: PointerEvent) => { if (!active) { return } - pendingHeight = clampConsoleHeight(startHeight + startY - moveEvent.clientY) - raf ??= requestAnimationFrame(flushHeight) + resize.push(clampConsoleHeight(startHeight + startY - moveEvent.clientY)) } const cleanup = () => { @@ -200,16 +191,7 @@ export function PreviewPane({ } active = false - - if (raf !== null) { - cancelAnimationFrame(raf) - raf = null - } - - if (pendingHeight !== null) { - consoleState.setHeight(pendingHeight) // commit the final height - } - + resize.finish() document.body.style.cursor = previousCursor document.body.style.userSelect = previousUserSelect 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 e73300f8daf1..d963d79efb8c 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 @@ -10,6 +10,7 @@ import { useStore } from '@nanostores/react' import { type PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef, useSyncExternalStore } from 'react' import { useContributions } from '@/contrib/react/use-contributions' +import { rafCoalesce } from '@/lib/raf-coalesce' import { cn } from '@/lib/utils' import { $paneStates, type PaneStateSnapshot, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes' @@ -234,12 +235,8 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize' document.body.style.userSelect = 'none' - // Coalesce store writes to one per frame: pointermove fires far faster than - // 60fps and each write relayouts the whole pane tree. Stash the latest - // clamped shift, apply it in a rAF (as drag-session / use-popout-drag do). - let raf: null | number = null - let pendingShift: null | number = null - + // pointermove outpaces 60fps and each write relayouts the whole pane tree, + // so coalesce to one apply per frame (rafCoalesce commits on cleanup). const applyShift = (shiftPx: number) => { if (a.fixed) { a.paneIds.forEach(id => setOverride(id, Math.round(a0px + shiftPx))) @@ -258,29 +255,14 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo } } - const flush = () => { - raf = null - - if (pendingShift !== null) { - applyShift(pendingShift) - } - } + const resize = rafCoalesce(applyShift) const onMove = (ev: PointerEvent) => { - pendingShift = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start)) - raf ??= requestAnimationFrame(flush) + resize.push(Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start))) } const cleanup = () => { - if (raf !== null) { - cancelAnimationFrame(raf) - raf = null - } - - if (pendingShift !== null) { - applyShift(pendingShift) // commit the final position - } - + resize.finish() document.body.style.cursor = restoreCursor document.body.style.userSelect = restoreSelect diff --git a/apps/desktop/src/lib/raf-coalesce.ts b/apps/desktop/src/lib/raf-coalesce.ts new file mode 100644 index 000000000000..788b99d6e834 --- /dev/null +++ b/apps/desktop/src/lib/raf-coalesce.ts @@ -0,0 +1,34 @@ +/** Coalesce a stream of values (pointermove positions, resize deltas) to one + * `apply` per animation frame, so a drag can't drive several layouts per frame. + * `push` records the latest value and schedules a frame; `finish` commits the + * last value and cancels any pending frame (call it on pointerup/cancel). + * `null` is the empty sentinel, so `T` must never legitimately be `null`. */ +export function rafCoalesce(apply: (value: T) => void): { finish: () => void; push: (value: T) => void } { + let frame: null | number = null + let pending: null | T = null + + const flush = () => { + frame = null + + if (pending !== null) { + apply(pending) + } + } + + return { + finish() { + if (frame !== null) { + cancelAnimationFrame(frame) + frame = null + } + + if (pending !== null) { + apply(pending) + } + }, + push(value) { + pending = value + frame ??= requestAnimationFrame(flush) + } + } +}