@@ -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) {