From 2a75bec6079be44212b91d0ec895e8c7e4e50161 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 23 May 2026 14:49:26 -0500 Subject: [PATCH] fix(tui): recompute virtual tail after width resize Avoid preserving a frozen virtual transcript range when wrapped rows shrink enough that the old tail window no longer covers the viewport. --- .../virtualHistoryOffsetCache.test.ts | 50 +++++++++++++++++-- ui-tui/src/hooks/useVirtualHistory.ts | 23 ++++++++- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts b/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts index 3560e23575f..f946db99b75 100644 --- a/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts +++ b/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts @@ -8,6 +8,7 @@ import { useVirtualHistory, virtualHistorySnapshotKey } from '../hooks/useVirtua interface Item { height: number + heightAfterResize?: number key: string } @@ -49,12 +50,17 @@ const viewportIsMounted = (items: readonly Item[], virtualHistory: ReturnType= span.top && bottom <= span.bottom } +const itemHeightForColumns = (item: Item | undefined, columns: number) => + columns >= 80 ? (item?.heightAfterResize ?? item?.height ?? 1) : (item?.height ?? 1) + function Harness({ + columns = 80, expose, height = 10, items, maxMounted = 16 }: { + columns?: number expose: React.MutableRefObject height?: number items: readonly Item[] @@ -62,9 +68,9 @@ function Harness({ }) { const scrollRef = useRef(null) - const virtualHistory = useVirtualHistory(scrollRef, items, 80, { + const virtualHistory = useVirtualHistory(scrollRef, items, columns, { coldStartCount: 16, - estimateHeight: index => items[index]?.height ?? 1, + estimateHeight: index => itemHeightForColumns(items[index], columns), maxMounted, overscan: 2 }) @@ -85,7 +91,11 @@ function Harness({ .map(item => React.createElement( Box, - { height: item.height, key: item.key, ref: virtualHistory.measureRef(item.key) }, + { + height: itemHeightForColumns(item, columns), + key: item.key, + ref: virtualHistory.measureRef(item.key) + }, React.createElement(Text, null, item.key) ) ), @@ -139,6 +149,40 @@ describe('useVirtualHistory offset cache reuse', () => { } }) + it('recomputes tail coverage when wrapped rows shrink after a width resize', async () => { + const items = Array.from({ length: 100 }, (_, index) => ({ + height: 4, + heightAfterResize: 1, + key: `item-${index}` + })) + + const expose = { current: null as Exposed | null } + const streams = makeStreams() + + const instance = renderSync( + React.createElement(Harness, { columns: 40, expose, height: 10, items, maxMounted: 80 }), + { + patchConsole: false, + stderr: streams.stderr as NodeJS.WriteStream, + stdin: streams.stdin as NodeJS.ReadStream, + stdout: streams.stdout as NodeJS.WriteStream + } + ) + + try { + await delay(20) + instance.rerender(React.createElement(Harness, { columns: 80, expose, height: 10, items, maxMounted: 80 })) + await delay(80) + + const resizedItems = items.map(item => ({ height: item.heightAfterResize!, key: item.key })) + + expect(viewportIsMounted(resizedItems, expose.current!.virtualHistory, expose.current!.scroll!)).toBe(true) + } finally { + instance.unmount() + instance.cleanup() + } + }) + it('recomputes offsets after a mounted row height changes', async () => { const tall = [ { height: 6, key: 'a' }, diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index ad78220c10c..592d20e9a07 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -248,8 +248,26 @@ export function useVirtualHistory( // During a freeze, drop the frozen range if items shrank past its start // (/clear, compaction) — clamping would collapse to an empty mount and // flash blank. Fall through to the normal path in that case. - const frozenRange = - freezeRenders.current > 0 && prevRange.current && prevRange.current[0] < n ? prevRange.current : null + const frozenRangeCandidate = + freezeRenders.current > 0 && prevRange.current && prevRange.current[0] < n + ? ([prevRange.current[0], Math.min(prevRange.current[1], n)] as const) + : null + + // Width grows can shrink wrapped rows enough that the old tail window no + // longer covers the viewport. In that case freezing preserves stale spacers + // and visually cuts off the last message, so recompute immediately. + const frozenRange = (() => { + if (!frozenRangeCandidate || vp <= 0) { + return frozenRangeCandidate + } + + const visibleTop = sticky && !recentManual ? Math.max(0, total - vp) : target + const visibleBottom = visibleTop + vp + const rangeTop = offsets[frozenRangeCandidate[0]] ?? 0 + const rangeBottom = offsets[frozenRangeCandidate[1]] ?? total + + return rangeTop <= visibleTop && rangeBottom >= visibleBottom ? frozenRangeCandidate : null + })() let start = 0 let end = n @@ -464,6 +482,7 @@ export function useVirtualHistory( if (skipMeasurement.current) { skipMeasurement.current = false + bumpMeasuredHeightVersion(n => n + 1) } else { for (let i = effStart; i < effEnd; i++) { const k = items[i]?.key