@@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from 'react' import DsMarkdown, { ConfigProvider } from 'ds-markdown' import { katexPlugin } from 'ds-markdown/plugins' @@ -12,19 +13,103 @@ interface DsMarkdownContentProps { const STREAMING_INTERVAL = 20 +function getNextRevealLength(currentLength: number, target: string): number { + if (currentLength >= target.length) { + return target.length + } + + const rest = target.slice(currentLength) + + if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) { + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + const first = segmenter.segment(rest)[Symbol.iterator]().next().value + return currentLength + (first?.segment.length ?? 1) + } + + const chars = Array.from(rest) + return currentLength + (chars[0]?.length ?? 1) +} + +function getCommonPrefixLength(a: string, b: string): number { + const max = Math.min(a.length, b.length) + let index = 0 + while (index < max && a[index] === b[index]) { + index += 1 + } + return index +} + export function DsMarkdownContent({ content, streaming }: DsMarkdownContentProps) { + const sessionRef = useRef(streaming) + const [displayed, setDisplayed] = useState(content) + + useEffect(() => { + if (streaming) { + sessionRef.current = true + } + }, [streaming]) + + useEffect(() => { + if (content === displayed) { + if (!streaming) { + sessionRef.current = false + } + return + } + + // Outside a stream session — show full content immediately (history, static messages). + if (!sessionRef.current && !streaming) { + setDisplayed(content) + return + } + + if (content.startsWith(displayed)) { + return + } + + if (displayed.startsWith(content)) { + setDisplayed(content) + return + } + + // Content was rewritten (waiting → first token, done split, etc.) — keep progress. + setDisplayed(content.slice(0, getCommonPrefixLength(displayed, content))) + }, [content, displayed, streaming]) + + useEffect(() => { + if (!sessionRef.current && !streaming) { + return + } + + if (!content.startsWith(displayed) || displayed === content) { + return + } + + const timer = window.setTimeout(() => { + setDisplayed(content.slice(0, getNextRevealLength(displayed.length, content))) + }, STREAMING_INTERVAL) + + return () => window.clearTimeout(timer) + }, [content, displayed, streaming]) + + const animating = displayed !== content + return ( - {content} + {displayed} ) @@ -472,7 +472,16 @@ export function useChatModel( const { think, main } = splitFinalStreamContent(content, runtime.thinkContent) clearChatStreamSession(currentChat) - runtime.modelContent = main + + // Keep typewriter prefix continuity: ds-markdown resets and dumps the + // whole string when the new content is not a prefix of what was streamed. + const waitingContent = getWaitingForModelContent(modelType) + const prevMain = + runtime.modelContent && runtime.modelContent !== waitingContent ? runtime.modelContent : '' + const nextMain = + !prevMain || main === prevMain || main.startsWith(prevMain) ? main : prevMain + + runtime.modelContent = nextMain runtime.thinkContent = think setMessages((prev) => { @@ -483,7 +492,7 @@ export function useChatModel( const modelUid = findModelMessageUid(prev, inputUuid) return prev.map((message) => message.uid === modelUid - ? { ...message, content: main, think: think || undefined } + ? { ...message, content: nextMain, think: think || undefined } : message ) }) @@ -29,7 +29,7 @@ interface IMessagesList { paginationLoading?: boolean } -const STREAM_STICK_BOTTOM_THRESHOLD_PX = 200 +const STREAM_STICK_BOTTOM_THRESHOLD_PX = 100 const GENERATION_SPACER_TOP_OFFSET_PX = 16 export const ChatMessagesList: React.FC = memo( @@ -61,6 +61,8 @@ export const ChatMessagesList: React.FC = memo( const initialScrollTimeoutRef = useRef | null>(null) const isRestoringScrollRef = useRef(false) const scrollBottomRef = useRef(0) + const stickToBottomRef = useRef(true) + const streamStickObserverRef = useRef(null) const lastSeenLastUidRef = useRef(null) const isGenerating = loading && !paginationLoading @@ -238,6 +240,7 @@ export const ChatMessagesList: React.FC = memo( return () => { prependRestoreObserverRef.current?.disconnect() initialScrollObserverRef.current?.disconnect() + streamStickObserverRef.current?.disconnect() generationObserverRef.current?.disconnect() if (prependRestoreTimeoutRef.current) { clearTimeout(prependRestoreTimeoutRef.current) @@ -248,6 +251,43 @@ export const ChatMessagesList: React.FC = memo( } }, []) + // Typewriter grows DOM height without updating messageResponse, so stick-to-bottom + // must observe content size — not only React message snapshots. + useEffect(() => { + const block = paginationScroll.current + const contentEl = messagesContentRef.current + const lastMessage = messageResponse?.[messageResponse.length - 1] + const isStreamMessage = + Boolean(lastMessage?.from_model) && Boolean(lastMessage?.uid.startsWith('streaming:')) + + streamStickObserverRef.current?.disconnect() + streamStickObserverRef.current = null + + if (!block || !contentEl || !isStreamMessage) { + return + } + + const stickToBottomIfNeeded = () => { + if (!stickToBottomRef.current) { + return + } + + block.scrollTop = block.scrollHeight + scrollBottomRef.current = 0 + } + + const observer = new ResizeObserver(stickToBottomIfNeeded) + streamStickObserverRef.current = observer + observer.observe(contentEl) + + return () => { + observer.disconnect() + if (streamStickObserverRef.current === observer) { + streamStickObserverRef.current = null + } + } + }, [messageResponse]) + useEffect(() => { if (!messageResponse?.length) { prevMessagesSnapshotRef.current = null @@ -315,6 +355,7 @@ export const ChatMessagesList: React.FC = memo( const prev = prevMessagesSnapshotRef.current if (!prev) { + stickToBottomRef.current = true if (isGenerating) { applyGenerationSpacer(block) block.scrollTo({ @@ -348,6 +389,7 @@ export const ChatMessagesList: React.FC = memo( if (prepended && scrollAnchorRef.current) { restorePrependScroll(block) } else if (appended) { + stickToBottomRef.current = true if (isGenerating) { applyGenerationSpacer(block) } @@ -355,11 +397,12 @@ export const ChatMessagesList: React.FC = memo( top: block.scrollHeight, behavior: 'smooth', }) - } else if (streamingGrowth && scrollBottomRef.current < STREAM_STICK_BOTTOM_THRESHOLD_PX) { + } else if (streamingGrowth && stickToBottomRef.current) { applyGenerationSpacer(block) // While the spacer absorbs growth, scrollHeight stays stable; only stick when it collapses. if (generationSpacerHeightRef.current === 0) { block.scrollTop = block.scrollHeight + scrollBottomRef.current = 0 } } @@ -387,6 +430,7 @@ export const ChatMessagesList: React.FC = memo( const distanceFromBottom = block.scrollHeight - block.scrollTop - block.clientHeight scrollBottomRef.current = distanceFromBottom + stickToBottomRef.current = distanceFromBottom < STREAM_STICK_BOTTOM_THRESHOLD_PX setScrollBottom(distanceFromBottom) if (messageResponse?.length && block.scrollTop === 0 && getMessagesPagination && !paginationLoading && !isRestoringScrollRef.current) {