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:
@ -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}`
|
||||
},
|
||||
() => ''
|
||||
)
|
||||
|
||||
@ -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 ''
|
||||
|
||||
Reference in New Issue
Block a user