diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 28b2a26f9a..8a5b0b1fde 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -161,7 +161,7 @@ export function useMainApp(gw: GatewayClient) { [historyItems, messageId] ) - const virtualHistory = useVirtualHistory(scrollRef, virtualRows) + const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols) const scrollWithSelection = useCallback( (delta: number) => { @@ -306,12 +306,26 @@ export function useMainApp(gw: GatewayClient) { return } - const onResize = () => - rpc('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid }) + let timer: null | ReturnType = null + + const onResize = () => { + if (timer) { + clearTimeout(timer) + } + + timer = setTimeout(() => { + timer = null + void rpc('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid }) + }, 100) + } stdout.on('resize', onResize) return () => { + if (timer) { + clearTimeout(timer) + } + stdout.off('resize', onResize) } }, [rpc, stdout, ui.sid]) diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index efa2642df3..3d1d27c056 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -15,6 +15,7 @@ const OVERSCAN = 40 const MAX_MOUNTED = 260 const COLD_START = 40 const QUANTUM = OVERSCAN >> 1 +const FREEZE_RENDERS = 2 const upperBound = (arr: number[], target: number) => { let lo = 0, @@ -31,6 +32,7 @@ const upperBound = (arr: number[], target: number) => { export function useVirtualHistory( scrollRef: RefObject, items: readonly { key: string }[], + columns: number, { estimate = ESTIMATE, overscan = OVERSCAN, maxMounted = MAX_MOUNTED, coldStartCount = COLD_START } = {} ) { const nodes = useRef(new Map()) @@ -40,6 +42,34 @@ export function useVirtualHistory( const [hasScrollRef, setHasScrollRef] = useState(false) const metrics = useRef({ sticky: true, top: 0, vp: 0 }) + // Resize handling — scale cached heights by oldCols/newCols so post-resize + // offsets stay roughly aligned with (still-unknown) real Yoga heights. + // Clearing the cache instead would force a pessimistic back-walk that mounts + // ~190 rows at once (viewport+overscan at 1-row estimate), each a fresh + // marked.lexer + syntax highlight = ~3ms; ~600ms React commit block. Freeze + // the mount range for FREEZE_RENDERS so warm useMemo results survive while + // the layout effect writes post-resize real heights back into cache. + // skipMeasurement prevents that first post-resize useLayoutEffect from + // poisoning the cache with pre-resize Yoga values (Yoga's stored heights + // are from the frame BEFORE this render's calculateLayout with new width). + const prevColumns = useRef(columns) + const skipMeasurement = useRef(false) + const prevRange = useRef(null) + const freezeRenders = useRef(0) + + if (prevColumns.current !== columns && prevColumns.current > 0 && columns > 0) { + const ratio = prevColumns.current / columns + + prevColumns.current = columns + + for (const [k, h] of heights.current) { + heights.current.set(k, Math.max(1, Math.round(h * ratio))) + } + + skipMeasurement.current = true + freezeRenders.current = FREEZE_RENDERS + } + useLayoutEffect(() => { setHasScrollRef(Boolean(scrollRef.current)) }, [scrollRef]) @@ -97,10 +127,19 @@ export function useVirtualHistory( const vp = Math.max(0, scrollRef.current?.getViewportHeight() ?? 0) const sticky = scrollRef.current?.isSticky() ?? true + const frozenRange = freezeRenders.current > 0 ? prevRange.current : null + let start = 0, end = items.length - if (items.length > 0) { + if (frozenRange) { + // Columns just changed. Reuse the pre-resize mount range so already-mounted + // MessageRows keep their warm memos (marked.lexer, syntax highlight). Clamp + // to n in case messages were removed (/clear, compaction) mid-freeze. + ;[start, end] = frozenRange + start = Math.min(start, items.length) + end = Math.min(end, items.length) + } else if (items.length > 0) { if (vp <= 0) { start = Math.max(0, items.length - coldStartCount) } else { @@ -113,6 +152,12 @@ export function useVirtualHistory( sticky ? (start = Math.max(0, end - maxMounted)) : (end = Math.min(items.length, start + maxMounted)) } + if (freezeRenders.current > 0) { + freezeRenders.current-- + } else { + prevRange.current = [start, end] + } + const measureRef = useCallback((key: string) => { let fn = refs.current.get(key) @@ -127,18 +172,25 @@ export function useVirtualHistory( useLayoutEffect(() => { let dirty = false - for (let i = start; i < end; i++) { - const k = items[i]?.key + if (skipMeasurement.current) { + // First render after a column change — Yoga heights still reflect the + // pre-resize layout. Writing them into cache would overwrite the scaled + // estimates with stale pre-resize values. Next render's Yoga is correct. + skipMeasurement.current = false + } else { + for (let i = start; i < end; i++) { + const k = items[i]?.key - if (!k) { - continue - } + if (!k) { + continue + } - const h = Math.ceil((nodes.current.get(k) as MeasuredNode | undefined)?.yogaNode?.getComputedHeight?.() ?? 0) + const h = Math.ceil((nodes.current.get(k) as MeasuredNode | undefined)?.yogaNode?.getComputedHeight?.() ?? 0) - if (h > 0 && heights.current.get(k) !== h) { - heights.current.set(k, h) - dirty = true + if (h > 0 && heights.current.get(k) !== h) { + heights.current.set(k, h) + dirty = true + } } }