fix(tui): sticky prompt correctness + scrollbar re-render thrash

Sticky prompt:
The loop was skipping `first` (the first row in the viewport) when
looking for a user message scrolled above the top edge. If `first`
itself was a user row that had just ticked above the viewport, we'd
fall through the early-return guard (`role === 'user' && !above`),
then walk from `first - 1` backward — never rechecking `first`, never
finding anything, returning '' and leaving the sticky empty. This is
why it felt "stuck" at the start: one-turn sessions with the user row
exactly at/near the top never surfaced the breadcrumb.

Collapsed the two branches into one loop starting at `first`: nearest
user wins — still-on-screen → empty (redundant to echo), already
above → text. Same semantics, covers the gap.

Scrollbar:
`useSyncExternalStore` snapshot was `scrollTop:vp:scrollHeight` —
scrollHeight ticks up by ~1 row on every streamed chunk, forcing a
re-render per chunk. Quantized snapshot to the displayed values
(`thumbTop:thumbSize:vp`) so we only re-render when the bar actually
changes. Drops render count per turn by ~100x during streaming and
stops the "constantly resizes" flicker.
This commit is contained in:
Brooklyn Nicholson 2026-04-16 21:07:19 -05:00
parent 40f2368875
commit c74017f405
2 changed files with 18 additions and 8 deletions

View file

@ -28,16 +28,15 @@ export const stickyPromptFromViewport = (
const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1))
const aboveViewport = (i: number) => (offsets[i] ?? 0) + 1 < top
if (messages[first]?.role === 'user' && !aboveViewport(first)) {
return ''
}
for (let i = first - 1; i >= 0; i--) {
if (messages[i]?.role !== 'user' || !aboveViewport(i)) {
// Walk backward from the first visible row. The nearest user message wins:
// if it's still on screen, no sticky is needed; if it's already scrolled
// above the top, its text becomes the floating breadcrumb.
for (let i = first; i >= 0; i--) {
if (messages[i]?.role !== 'user') {
continue
}
return userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim()
return aboveViewport(i) ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() : ''
}
return ''