@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import DsMarkdown, { ConfigProvider } from 'ds-markdown'
import { katexPlugin } from 'ds-markdown/plugins'
@@ -13,29 +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 [isTypingComplete, setIsTypingComplete] = useState(!streaming)
- const typingEnabled = streaming || !isTypingComplete
+ const sessionRef = useRef(streaming)
+ const [displayed, setDisplayed] = useState(content)
useEffect(() => {
if (streaming) {
- setIsTypingComplete(false)
+ 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 (
setIsTypingComplete(true)}
>
- {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
export const ChatMessagesList: React.FC = memo(
({ messageResponse, onLoadImage, setResendValue, setQuoteValue, modelTitle, device, modelType, botParams, getMessagesPagination, deleteMessage, loading, paginationLoading = false }) => {
@@ -54,6 +54,9 @@ export const ChatMessagesList: React.FC = memo(
const initialScrollTimeoutRef = useRef | null>(null)
const isRestoringScrollRef = useRef(false)
const scrollBottomRef = useRef(0)
+ const stickToBottomRef = useRef(true)
+ const streamContentRef = useRef(null)
+ const streamStickObserverRef = useRef(null)
const lastSeenLastUidRef = useRef(null)
const getMessageTopInViewport = (block: HTMLDivElement, el: HTMLElement) =>
@@ -178,6 +181,7 @@ export const ChatMessagesList: React.FC = memo(
return () => {
prependRestoreObserverRef.current?.disconnect()
initialScrollObserverRef.current?.disconnect()
+ streamStickObserverRef.current?.disconnect()
if (prependRestoreTimeoutRef.current) {
clearTimeout(prependRestoreTimeoutRef.current)
}
@@ -187,6 +191,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 = streamContentRef.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
@@ -220,6 +261,7 @@ export const ChatMessagesList: React.FC = memo(
const prev = prevMessagesSnapshotRef.current
if (!prev) {
+ stickToBottomRef.current = true
scrollToBottom(block, true)
prevMessagesSnapshotRef.current = snapshot
return
@@ -245,12 +287,14 @@ export const ChatMessagesList: React.FC = memo(
if (prepended && scrollAnchorRef.current) {
restorePrependScroll(block)
} else if (appended) {
+ stickToBottomRef.current = true
block.scrollTo({
top: block.scrollHeight,
behavior: 'smooth',
})
- } else if (streamingGrowth && scrollBottomRef.current < STREAM_STICK_BOTTOM_THRESHOLD_PX) {
+ } else if (streamingGrowth && stickToBottomRef.current) {
block.scrollTop = block.scrollHeight
+ scrollBottomRef.current = 0
}
prevMessagesSnapshotRef.current = snapshot
@@ -277,6 +321,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) {
@@ -366,38 +411,40 @@ export const ChatMessagesList: React.FC = memo(
)}
- {messageResponse?.length === 0 && status === 'authenticated' ? (
- <>{desktop && modelType !== 'deepl' && }>
- ) : (
- messageResponse?.map((message, idx) => {
- const isStreaming =
- loading &&
- !paginationLoading &&
- message.from_model &&
- message.uid.startsWith('streaming:') &&
- idx === messageResponse.length - 1
-
- return (
-
- )
- })
- )}
+
+ {messageResponse?.length === 0 && status === 'authenticated' ? (
+ <>{desktop && modelType !== 'deepl' && }>
+ ) : (
+ messageResponse?.map((message, idx) => {
+ const isStreaming =
+ loading &&
+ !paginationLoading &&
+ message.from_model &&
+ message.uid.startsWith('streaming:') &&
+ idx === messageResponse.length - 1
+
+ return (
+
+ )
+ })
+ )}
+
>
)