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.
This commit is contained in:
Brooklyn Nicholson 2026-05-23 14:49:26 -05:00
parent 521c870a05
commit 2a75bec607
2 changed files with 68 additions and 5 deletions

View file

@ -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<ty
return top >= 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<Exposed | null>
height?: number
items: readonly Item[]
@ -62,9 +68,9 @@ function Harness({
}) {
const scrollRef = useRef<ScrollBoxHandle | null>(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' },

View file

@ -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