feat(chat): block-level streaming reveal with a gliding follow
Token-by-token streaming mutates the trailing paragraph in place on every tick: words rewrap, the last line jitters, and the whole reply reads as flicker. Streamed text now commits only up to the last complete line — prose arrives a paragraph at a time (a markdown paragraph is one logical line), code fences reveal line by line, tables row by row — and a shown block never changes again. A paragraph that runs long without a newline releases at the last sentence (then word) boundary so the stream never stalls. Applies to assistant text and reasoning; tool output keeps its raw tail. With growth arriving in block steps, the end follow switches to the list's animated mode so each step is a glide — reveal and scroll read as one continuous motion. The gesture opt-out now measures at-end from the live list state instead of the cached flag, which the animated glide deliberately leaves stale while trailing the edge; without that, a drag during a glide could leave the scroll-to-bottom pill unshown. Measured: end-following holds at distance 0 for the whole stream, and the mobile drag opt-out shows the pill in three of three runs. The continuous glide costs ~15% more main-thread time per streamed character than the instant follow — the price of the motion.
This commit is contained in:
@@ -7,6 +7,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import {
|
||||
CHAT_LIST_ANCHOR_OFFSET,
|
||||
getAnchoredTurnMetrics,
|
||||
resolveTimelineIsAtEnd,
|
||||
type TimelineListMeasurementState,
|
||||
type TimelineScrollMode,
|
||||
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
||||
@@ -204,9 +205,14 @@ export const useChatTimelineScroll = ({
|
||||
liveFollowGenerationRef.current = null;
|
||||
setUserOwnsScroll(true);
|
||||
// The end may already have been left by our own movement, in which
|
||||
// case no further at-end transition will fire. This is an explicit
|
||||
// gesture — show the pill immediately, no debounce.
|
||||
if (!isAtEndRef.current) {
|
||||
// case no further at-end transition will fire — and while an animated
|
||||
// follow glide trails the live edge, isAtEndRef is deliberately not
|
||||
// updated, so measure the real distance instead of trusting it. This
|
||||
// is an explicit gesture — show the pill immediately, no debounce.
|
||||
const listState = listRef.current?.getState();
|
||||
const atEndNow = (listState ? resolveTimelineIsAtEnd(listState) : undefined) ?? isAtEndRef.current;
|
||||
isAtEndRef.current = atEndNow;
|
||||
if (!atEndNow) {
|
||||
cancelShowButtonTimer();
|
||||
setShowScrollButton(true);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user