From c74017f405fb4dacb6e09e35906d0f5ca40ba6c7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 21:07:19 -0500 Subject: [PATCH] fix(tui): sticky prompt correctness + scrollbar re-render thrash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui/src/components/appChrome.tsx | 13 ++++++++++++- ui-tui/src/domain/viewport.ts | 13 ++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 1000adbb68..f381057c0c 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -213,6 +213,10 @@ export function StickyPromptTracker({ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject; 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 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}` }, () => '' ) diff --git a/ui-tui/src/domain/viewport.ts b/ui-tui/src/domain/viewport.ts index 783bc52258..3dccc3177f 100644 --- a/ui-tui/src/domain/viewport.ts +++ b/ui-tui/src/domain/viewport.ts @@ -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 ''