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:
@@ -9,6 +9,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||
import { commitStreamedText } from '../../lib/streamTextCommit';
|
||||
import type { StreamPhase } from '../types';
|
||||
|
||||
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 canBeStreaming = streamPhase === undefined || streamPhase !== 'completed';
|
||||
const isStreaming = chatRenderMode === 'live' && canBeStreaming && typeof time?.end !== 'number';
|
||||
const throttledText = useStreamingTextThrottle({
|
||||
const throttledTextRaw = useStreamingTextThrottle({
|
||||
text: textContent,
|
||||
isStreaming,
|
||||
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)
|
||||
// Only hide if there's no text content
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { commitStreamedText } from '../../lib/streamTextCommit';
|
||||
|
||||
export const resolveAssistantDisplayText = (input: {
|
||||
textContent: string;
|
||||
throttledTextContent: string;
|
||||
isStreaming: boolean;
|
||||
}): 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: {
|
||||
|
||||
Reference in New Issue
Block a user