Skip to content

Commit 70b327f

Browse files
committed
improvement(mothership): smooth streamed text reveal and fix completion flash
Port opencode's paced word-boundary reveal into useSmoothText so streamed text builds smoothly regardless of how the model chunks deltas, and keep it smooth through completion: - Reveal on a steady 24ms timer in tiered steps that snap to word/punctuation boundaries instead of revealing partial tokens. - Drain the lagging tail at the paced cadence on stream end instead of snapping; the consumer holds streaming render until the reveal catches up. - Pin a streamed message to Streamdown's streaming mode for its mounted lifetime so the static-mode swap doesn't remount and re-highlight the message. - Key the assistant row by its owning user message id so the live->persisted id swap no longer remounts the row (whole-message blink) at completion.
1 parent c1d3161 commit 70b327f

3 files changed

Lines changed: 98 additions & 73 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,11 @@ function ChatContentInner({
292292

293293
const displayContent = useMemo(() => sanitizeChatDisplayContent(content), [content])
294294
const streamedContent = useSmoothText(displayContent, isStreaming)
295+
const isRevealing = isStreaming || streamedContent.length < displayContent.length
296+
297+
const streamedThisSession = useRef(false)
298+
if (isStreaming) streamedThisSession.current = true
299+
const keepStreamingTree = isRevealing || streamedThisSession.current
295300

296301
useEffect(() => {
297302
const handler = (e: Event) => {
@@ -308,8 +313,8 @@ function ChatContentInner({
308313
}, [])
309314

310315
const parsed = useMemo(
311-
() => parseSpecialTags(streamedContent, isStreaming),
312-
[streamedContent, isStreaming]
316+
() => parseSpecialTags(streamedContent, isRevealing),
317+
[streamedContent, isRevealing]
313318
)
314319
const hasSpecialContent = parsed.hasPendingTag || parsed.segments.some((s) => s.type !== 'text')
315320

@@ -365,9 +370,9 @@ function ChatContentInner({
365370
className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}
366371
>
367372
<Streamdown
368-
mode={isStreaming ? undefined : 'static'}
369-
animated={isStreaming ? STREAM_ANIMATION : false}
370-
isAnimating={isStreaming}
373+
mode={keepStreamingTree ? undefined : 'static'}
374+
animated={keepStreamingTree ? STREAM_ANIMATION : false}
375+
isAnimating={isRevealing}
371376
components={MARKDOWN_COMPONENTS}
372377
>
373378
{group.markdown}
@@ -383,17 +388,17 @@ function ChatContentInner({
383388
/>
384389
)
385390
})}
386-
{parsed.hasPendingTag && isStreaming && <PendingTagIndicator />}
391+
{parsed.hasPendingTag && isRevealing && <PendingTagIndicator />}
387392
</div>
388393
)
389394
}
390395

391396
return (
392397
<div className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}>
393398
<Streamdown
394-
mode={isStreaming ? undefined : 'static'}
395-
animated={isStreaming ? STREAM_ANIMATION : false}
396-
isAnimating={isStreaming}
399+
mode={keepStreamingTree ? undefined : 'static'}
400+
animated={keepStreamingTree ? STREAM_ANIMATION : false}
401+
isAnimating={isRevealing}
397402
components={MARKDOWN_COMPONENTS}
398403
>
399404
{streamedContent}

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,20 @@ export function MothershipChat({
223223
}
224224
return out
225225
}, [messages])
226+
const assistantTurnKeyByIndex = useMemo(() => {
227+
const out: string[] = []
228+
let lastUserId: string | undefined
229+
let ordinal = 0
230+
for (const [index, message] of messages.entries()) {
231+
if (message.role === 'user') {
232+
lastUserId = message.id
233+
ordinal = 0
234+
} else {
235+
out[index] = lastUserId ? `assistant:${lastUserId}:${ordinal++}` : message.id
236+
}
237+
}
238+
return out
239+
}, [messages])
226240
const initialScrollDoneRef = useRef(false)
227241
const userInputRef = useRef<UserInputHandle>(null)
228242

@@ -297,7 +311,7 @@ export function MothershipChat({
297311
const isLast = index === messages.length - 1
298312
return (
299313
<AssistantMessageRow
300-
key={msg.id}
314+
key={assistantTurnKeyByIndex[index] ?? msg.id}
301315
message={msg}
302316
isStreaming={isStreamActive && isLast}
303317
precedingUserContent={precedingUserContentByIndex[index]}

apps/sim/hooks/use-smooth-text.ts

Lines changed: 69 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,42 @@
11
import { useEffect, useRef, useState } from 'react'
22

33
/**
4-
* Per-frame reveal speed is proportional to how far behind the display is, so a
5-
* large burst drains quickly while a trickle reveals gently. `target / DIVISOR`
6-
* gives an ease-out feel; the clamps keep it from stalling or jumping.
4+
* Paced reveal of a growing string, ported from opencode's `createPacedValue`
5+
* (`packages/ui/src/components/message-part.tsx`). Instead of revealing a fixed
6+
* number of characters per animation frame, it advances on a steady ~24ms timer
7+
* in small tiered steps that SNAP to the next word/punctuation boundary — so
8+
* text appears word-by-word at a calm, even cadence regardless of how bursty the
9+
* upstream model deltas are. The boundary snapping is what keeps it from reading
10+
* as "blocky": a reveal never stops mid-word.
711
*/
8-
const REVEAL_DIVISOR = 6
9-
const MIN_STEP = 1
10-
const MAX_STEP = 400
12+
const PACE_MS = 24
13+
const SNAP = /[\s.,!?;:)\]]/
14+
15+
/**
16+
* Characters to advance per tick as a function of how far the reveal is behind.
17+
* Small backlogs trickle (2–8 chars); large backlogs accelerate but stay capped
18+
* so a burst is spread over several ticks rather than dumped at once.
19+
*/
20+
function step(remaining: number): number {
21+
if (remaining <= 12) return 2
22+
if (remaining <= 48) return 4
23+
if (remaining <= 96) return 8
24+
return Math.min(24, Math.ceil(remaining / 8))
25+
}
26+
27+
/**
28+
* Advance from `start` by `step(...)`, then extend up to 8 more characters to
29+
* land just past the next word/punctuation boundary so the reveal lands on a
30+
* whole word rather than mid-token.
31+
*/
32+
function nextIndex(text: string, start: number): number {
33+
const end = Math.min(text.length, start + step(text.length - start))
34+
const max = Math.min(text.length, end + 8)
35+
for (let i = end; i < max; i++) {
36+
if (SNAP.test(text[i] ?? '')) return i + 1
37+
}
38+
return end
39+
}
1140

1241
/**
1342
* Content already longer than this at mount is assumed to be an in-progress
@@ -29,13 +58,25 @@ interface SmoothTextOptions {
2958
}
3059

3160
/**
32-
* Paces a growing string so it reveals at a steady cadence regardless of how
33-
* bursty the upstream stream is — the client-side analogue of the AI SDK's
34-
* `smoothStream`. Returns the portion of `content` that should be displayed now.
61+
* Paces a growing string so it reveals word-by-word at a steady cadence
62+
* regardless of how bursty the upstream stream is — a React port of opencode's
63+
* paced text rendering. Returns the portion of `content` that should be
64+
* displayed now.
65+
*
66+
* Content that is already complete at mount (history, or a resume past
67+
* {@link RESUME_SKIP_THRESHOLD}) is returned in full and never animates. When a
68+
* live stream ends mid-reveal the remaining tail keeps draining at the paced
69+
* cadence rather than snapping — so the reveal stays smooth right to the end and
70+
* the caller can hold its streaming render until `useSmoothText` reports the
71+
* full string, avoiding a flash on the streaming→static handoff.
3572
*
36-
* While `isStreaming` is false the full string is returned unchanged (history
37-
* and completed turns never animate). When streaming ends mid-reveal the
38-
* remaining tail is shown immediately so nothing is left hidden.
73+
* @remarks
74+
* The reveal loop keys on `hasBacklog` rather than `content` so a new chunk on
75+
* every render does not re-subscribe the timer (and trip React's
76+
* max-update-depth guard); the running tick reads the latest value from a ref.
77+
* If upstream sanitization rewrites earlier text and shrinks the string, the
78+
* cursor is pulled back to the new end so regrowth stays paced instead of
79+
* jumping past it.
3980
*/
4081
export function useSmoothText(
4182
content: string,
@@ -49,15 +90,10 @@ export function useSmoothText(
4990
)
5091

5192
const contentRef = useRef(content)
52-
const streamingRef = useRef(isStreaming)
5393
const revealedRef = useRef(revealed)
54-
const frameRef = useRef<number | null>(null)
94+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
5595
const prevContentRef = useRef(content)
5696

57-
// A non-append rewrite (e.g. a patch replacing earlier text) must be shown in
58-
// full at once — re-revealing a prefix of rewritten content would look like
59-
// the document is retyping itself. Adjust during render so the slice below
60-
// never flashes a stale prefix.
6197
let effectiveRevealed = revealed
6298
if (
6399
snapOnNonAppend &&
@@ -72,72 +108,42 @@ export function useSmoothText(
72108
prevContentRef.current = content
73109

74110
contentRef.current = content
75-
streamingRef.current = isStreaming
76-
77-
// Key the reveal loop to streaming + remaining backlog, NOT to `content`:
78-
// `content` changes on every streamed chunk, and re-subscribing an rAF + setState
79-
// loop on each change is the "a dependency changes on every render" pattern that
80-
// trips React's max-update-depth guard. The running tick reads the latest content
81-
// from `contentRef`, so new chunks are absorbed without per-chunk teardown;
82-
// `hasBacklog` only flips when the reveal falls behind or catches up.
83-
if (!isStreaming && effectiveRevealed !== content.length) {
84-
effectiveRevealed = content.length
85-
revealedRef.current = content.length
86-
}
87111

88112
const hasBacklog = effectiveRevealed < content.length
89113

90114
useEffect(() => {
91-
if (!isStreaming) {
92-
revealedRef.current = contentRef.current.length
93-
setRevealed(contentRef.current.length)
94-
return
95-
}
115+
const run = () => {
116+
timeoutRef.current = null
117+
const text = contentRef.current
118+
const target = text.length
96119

97-
const tick = () => {
98-
const target = contentRef.current.length
99-
// Upstream sanitization can rewrite earlier text and shrink the string;
100-
// pull the cursor back to the new end so regrowth stays paced rather than
101-
// jumping past it.
102120
if (revealedRef.current > target) {
103121
revealedRef.current = target
104122
setRevealed(target)
105123
}
106124
const current = revealedRef.current
125+
if (current >= target) return
107126

108-
if (!streamingRef.current) {
109-
revealedRef.current = target
110-
setRevealed(target)
111-
frameRef.current = null
112-
return
113-
}
114-
if (current >= target) {
115-
frameRef.current = null
116-
return
117-
}
118-
119-
const backlog = target - current
120-
const step = Math.min(MAX_STEP, Math.max(MIN_STEP, Math.ceil(backlog / REVEAL_DIVISOR)))
121-
const next = current + step
127+
const next = nextIndex(text, current)
122128
revealedRef.current = next
123129
setRevealed(next)
124-
frameRef.current = window.requestAnimationFrame(tick)
130+
if (next < target) {
131+
timeoutRef.current = setTimeout(run, PACE_MS)
132+
}
125133
}
126134

127-
if (hasBacklog && frameRef.current === null) {
128-
frameRef.current = window.requestAnimationFrame(tick)
135+
if (hasBacklog && timeoutRef.current === null) {
136+
timeoutRef.current = setTimeout(run, PACE_MS)
129137
}
130138

131139
return () => {
132-
if (frameRef.current !== null) {
133-
window.cancelAnimationFrame(frameRef.current)
134-
frameRef.current = null
140+
if (timeoutRef.current !== null) {
141+
clearTimeout(timeoutRef.current)
142+
timeoutRef.current = null
135143
}
136144
}
137-
}, [isStreaming, hasBacklog])
145+
}, [hasBacklog])
138146

139-
// Content can shrink when upstream sanitization rewrites earlier text; never
140-
// hand back a slice index past the current end.
141147
if (effectiveRevealed >= content.length) return content
142148
return content.slice(0, effectiveRevealed)
143149
}

0 commit comments

Comments
 (0)