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

@ -213,6 +213,10 @@ export function StickyPromptTracker({
export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<ScrollBoxHandle | null>; t: Theme }) {
useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
// Quantize the scroll snapshot to the values the thumb actually renders
// with — thumbTop + thumbSize + viewport height. Streaming drives
// scrollHeight up by ~1 row at a time, but the quantized thumb usually
// doesn't move, so we skip thousands of render cycles mid-turn.
() => {
const s = scrollRef.current
@ -220,7 +224,14 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
return NaN
}
return `${s.getScrollTop() + s.getPendingDelta()}:${s.getViewportHeight()}:${s.getScrollHeight()}`
const vp = Math.max(0, s.getViewportHeight())
const total = Math.max(vp, s.getScrollHeight())
const top = Math.max(0, s.getScrollTop() + s.getPendingDelta())
const thumb = total > vp ? Math.max(1, Math.round((vp * vp) / total)) : vp
const travel = Math.max(1, vp - thumb)
const thumbTop = total > vp ? Math.round((top / Math.max(1, total - vp)) * travel) : 0
return `${thumbTop}:${thumb}:${vp}`
},
() => ''
)