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:
@@ -1071,7 +1071,10 @@ const TimelineList = React.memo(({
|
|||||||
// resize settles.
|
// resize settles.
|
||||||
maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing || endPinningReleased
|
maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing || endPinningReleased
|
||||||
? false
|
? false
|
||||||
: { animated: false, on: { dataChange: true, itemLayout: true, layout: true, footerLayout: true } }}
|
// Animated: with block-level reveal the content grows in
|
||||||
|
// paragraph/line steps, and the animated follow turns each
|
||||||
|
// step into a glide — reveal and scroll read as one motion.
|
||||||
|
: { animated: true, on: { dataChange: true, itemLayout: true, layout: true, footerLayout: true } }}
|
||||||
// Prepending older history must not move what the user is
|
// Prepending older history must not move what the user is
|
||||||
// reading. Size restoration applies only during a width
|
// reading. Size restoration applies only during a width
|
||||||
// resize — see the observer above.
|
// resize — see the observer above.
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { commitStreamedText } from './streamTextCommit';
|
||||||
|
|
||||||
|
describe('commitStreamedText', () => {
|
||||||
|
test('holds an incomplete short paragraph entirely', () => {
|
||||||
|
expect(commitStreamedText('An unfinished thought abo')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('commits up to the last complete line', () => {
|
||||||
|
expect(commitStreamedText('First paragraph.\n\nSecond par')).toBe('First paragraph.\n\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reveals code fences line by line', () => {
|
||||||
|
const text = '```py\nprint("a")\nprint("b';
|
||||||
|
expect(commitStreamedText(text)).toBe('```py\nprint("a")\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('releases a long held paragraph at the last sentence boundary', () => {
|
||||||
|
const sentence = 'A finished sentence lives here. ';
|
||||||
|
const text = sentence.repeat(12) + 'and an unfinished trail';
|
||||||
|
expect(commitStreamedText(text)).toBe(sentence.repeat(12));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the last word boundary without sentences', () => {
|
||||||
|
const words = 'word '.repeat(70);
|
||||||
|
const text = words + 'unfinishe';
|
||||||
|
expect(commitStreamedText(text)).toBe(words);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps unbreakable runs intact rather than splitting them', () => {
|
||||||
|
const run = 'x'.repeat(400);
|
||||||
|
expect(commitStreamedText(run)).toBe(run);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty input stays empty', () => {
|
||||||
|
expect(commitStreamedText('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Block-level streaming reveal.
|
||||||
|
//
|
||||||
|
// Token-by-token streaming mutates the trailing paragraph in place on every
|
||||||
|
// tick: words rewrap, the last line jitters, and the reader's eye fights the
|
||||||
|
// motion. Committing only up to the last COMPLETE line keeps every rendered
|
||||||
|
// block immutable once it appears — 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 the only remaining motion is the follow scroll.
|
||||||
|
//
|
||||||
|
// A paragraph with no newline for a long stretch must not stall the stream,
|
||||||
|
// so once the held tail outgrows a threshold it is committed at the last
|
||||||
|
// sentence boundary (falling back to the last word boundary).
|
||||||
|
|
||||||
|
const HOLD_MAX_CHARS = 320;
|
||||||
|
|
||||||
|
const SENTENCE_END = /[.!?…][)"'»”’]?\s/g;
|
||||||
|
|
||||||
|
export const commitStreamedText = (text: string): string => {
|
||||||
|
if (text.length === 0) return text;
|
||||||
|
|
||||||
|
const lastNewline = text.lastIndexOf('\n');
|
||||||
|
const committed = lastNewline === -1 ? '' : text.slice(0, lastNewline + 1);
|
||||||
|
const held = text.slice(committed.length);
|
||||||
|
|
||||||
|
if (held.length <= HOLD_MAX_CHARS) {
|
||||||
|
return committed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The held paragraph got long: release it up to the last finished
|
||||||
|
// sentence so the block still never mutates mid-sentence.
|
||||||
|
let lastSentenceEnd = -1;
|
||||||
|
for (const match of held.matchAll(SENTENCE_END)) {
|
||||||
|
lastSentenceEnd = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
if (lastSentenceEnd > 0) {
|
||||||
|
return committed + held.slice(0, lastSentenceEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No sentence boundary either (a URL, a very long token run): release up
|
||||||
|
// to the last word boundary, keeping only the incomplete word held.
|
||||||
|
const lastSpace = held.lastIndexOf(' ');
|
||||||
|
if (lastSpace > 0) {
|
||||||
|
return committed + held.slice(0, lastSpace + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
||||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||||
|
import { commitStreamedText } from '../../lib/streamTextCommit';
|
||||||
import type { StreamPhase } from '../types';
|
import type { StreamPhase } from '../types';
|
||||||
|
|
||||||
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal';
|
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal';
|
||||||
@@ -431,11 +432,14 @@ const ReasoningPart = React.memo(({
|
|||||||
const time = partWithText.time;
|
const time = partWithText.time;
|
||||||
const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed';
|
const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed';
|
||||||
const isStreaming = chatRenderMode === 'live' && canBeStreaming && typeof time?.end !== 'number';
|
const isStreaming = chatRenderMode === 'live' && canBeStreaming && typeof time?.end !== 'number';
|
||||||
const throttledText = useStreamingTextThrottle({
|
const throttledTextRaw = useStreamingTextThrottle({
|
||||||
text: textContent,
|
text: textContent,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
identityKey: `${messageId}:${part.id ?? 'reasoning'}`,
|
identityKey: `${messageId}:${part.id ?? 'reasoning'}`,
|
||||||
});
|
});
|
||||||
|
// Same block-level reveal as assistant text: a shown reasoning paragraph
|
||||||
|
// never mutates in place.
|
||||||
|
const throttledText = isStreaming ? commitStreamedText(throttledTextRaw) : throttledTextRaw;
|
||||||
|
|
||||||
// Show reasoning even if time.end isn't set yet (during streaming)
|
// Show reasoning even if time.end isn't set yet (during streaming)
|
||||||
// Only hide if there's no text content
|
// Only hide if there's no text content
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
|
import { commitStreamedText } from '../../lib/streamTextCommit';
|
||||||
|
|
||||||
export const resolveAssistantDisplayText = (input: {
|
export const resolveAssistantDisplayText = (input: {
|
||||||
textContent: string;
|
textContent: string;
|
||||||
throttledTextContent: string;
|
throttledTextContent: string;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}): string => {
|
}): string => {
|
||||||
return input.isStreaming ? input.throttledTextContent : input.textContent;
|
// While streaming, reveal whole blocks only: rendering stops at the last
|
||||||
|
// complete line so a shown paragraph never mutates in place. The held
|
||||||
|
// tail lands with the next line break (or the finalize pass).
|
||||||
|
return input.isStreaming
|
||||||
|
? commitStreamedText(input.throttledTextContent)
|
||||||
|
: input.textContent;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shouldRenderAssistantText = (input: {
|
export const shouldRenderAssistantText = (input: {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import {
|
import {
|
||||||
CHAT_LIST_ANCHOR_OFFSET,
|
CHAT_LIST_ANCHOR_OFFSET,
|
||||||
getAnchoredTurnMetrics,
|
getAnchoredTurnMetrics,
|
||||||
|
resolveTimelineIsAtEnd,
|
||||||
type TimelineListMeasurementState,
|
type TimelineListMeasurementState,
|
||||||
type TimelineScrollMode,
|
type TimelineScrollMode,
|
||||||
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
||||||
@@ -204,9 +205,14 @@ export const useChatTimelineScroll = ({
|
|||||||
liveFollowGenerationRef.current = null;
|
liveFollowGenerationRef.current = null;
|
||||||
setUserOwnsScroll(true);
|
setUserOwnsScroll(true);
|
||||||
// The end may already have been left by our own movement, in which
|
// 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
|
// case no further at-end transition will fire — and while an animated
|
||||||
// gesture — show the pill immediately, no debounce.
|
// follow glide trails the live edge, isAtEndRef is deliberately not
|
||||||
if (!isAtEndRef.current) {
|
// 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();
|
cancelShowButtonTimer();
|
||||||
setShowScrollButton(true);
|
setShowScrollButton(true);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user