diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 94591ad8..0673e786 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1071,7 +1071,10 @@ const TimelineList = React.memo(({ // resize settles. maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing || endPinningReleased ? 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 // reading. Size restoration applies only during a width // resize — see the observer above. diff --git a/packages/ui/src/components/chat/lib/streamTextCommit.test.ts b/packages/ui/src/components/chat/lib/streamTextCommit.test.ts new file mode 100644 index 00000000..8ee852fc --- /dev/null +++ b/packages/ui/src/components/chat/lib/streamTextCommit.test.ts @@ -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(''); + }); +}); diff --git a/packages/ui/src/components/chat/lib/streamTextCommit.ts b/packages/ui/src/components/chat/lib/streamTextCommit.ts new file mode 100644 index 00000000..15aa0d19 --- /dev/null +++ b/packages/ui/src/components/chat/lib/streamTextCommit.ts @@ -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; +}; diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index 1276687a..1922100c 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -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 diff --git a/packages/ui/src/components/chat/message/parts/assistantTextVisibility.ts b/packages/ui/src/components/chat/message/parts/assistantTextVisibility.ts index f5f50adc..74817e1a 100644 --- a/packages/ui/src/components/chat/message/parts/assistantTextVisibility.ts +++ b/packages/ui/src/components/chat/message/parts/assistantTextVisibility.ts @@ -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: { diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 36d774f6..fb732efd 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -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); }