dsh-smooth-stream 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-smooth-stream",
3
- "version": "0.3.2",
4
- "description": "DeepSeek Harness (dsh) plugin: silky streaming reveal, no flicker. dsh 丝滑流式渲染插件。",
3
+ "version": "0.3.4",
4
+ "description": "Fluid streaming rendering and silky scrolling for DeepSeek Harness replies, including Markdown, code blocks, tables, and tool results.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "engines": {
@@ -23,6 +23,11 @@
23
23
  "cordis",
24
24
  "streaming",
25
25
  "smooth-streaming",
26
+ "fluid-streaming",
27
+ "streaming-renderer",
28
+ "markdown-streaming",
29
+ "smooth-scrolling",
30
+ "smooth-scroll",
26
31
  "smooth-stream",
27
32
  "typewriter",
28
33
  "typewriter-effect",
@@ -74,6 +79,7 @@
74
79
  },
75
80
  "scripts": {
76
81
  "build": "tsdown",
82
+ "build:repro": "esbuild repro/main.tsx --bundle --format=esm --platform=browser --loader:.css=local-css --outfile=repro/bundle.js",
77
83
  "prepare": "tsdown",
78
84
  "test": "vitest run",
79
85
  "typecheck": "tsc -p tsconfig.json --pretty false"
@@ -146,6 +152,7 @@
146
152
  "@types/node": "^22.20.0",
147
153
  "@types/react": "~18.3.1",
148
154
  "@types/react-dom": "~18.3.0",
155
+ "esbuild": "^0.28.2",
149
156
  "jsdom": "^26.1.0",
150
157
  "lightningcss": "^1.30.1",
151
158
  "react": "^18.3.1",
@@ -1,21 +1,66 @@
1
- import { useRef, type ReactNode } from 'react'
1
+ import { useEffect, useRef, type ReactNode } from 'react'
2
2
  import { useConversationFollow } from './teleprompterGlide.ts'
3
3
  import css from './TypewriterAssistantNodeView.module.css'
4
4
 
5
5
  /**
6
6
  * Document-flow host that owns conversation-port follow while `active`.
7
- * Shared by assistant blocks and every other growing Chat row.
7
+ * Shared by assistant blocks and every other Agent Chat row. `onGrowth` lets
8
+ * generic wrapped renderers re-arm one glide when their DOM grows without
9
+ * requiring a business-kind-specific lifecycle predicate.
8
10
  */
9
11
  export function FollowHost({
10
12
  active,
13
+ entrance = false,
14
+ onEntranceSettled,
15
+ onGrowth,
16
+ entranceExtentRef,
11
17
  speedCpsRef,
18
+ revealScaleRef,
19
+ predictive = true,
20
+ predictiveRef,
21
+ hostRef,
12
22
  children,
13
23
  }: {
14
24
  active: boolean
25
+ entrance?: boolean
26
+ onEntranceSettled?: (() => void) | undefined
27
+ onGrowth?: ((deltaPx: number) => void) | undefined
28
+ entranceExtentRef?: { current: number | null } | undefined
15
29
  speedCpsRef: { current: number }
30
+ revealScaleRef?: { current: number } | undefined
31
+ predictive?: boolean
32
+ predictiveRef?: { current: boolean } | undefined
33
+ hostRef?: { current: HTMLDivElement | null } | undefined
16
34
  children: ReactNode
17
35
  }) {
18
- const rootRef = useRef<HTMLDivElement>(null)
19
- useConversationFollow(rootRef, active, speedCpsRef)
36
+ const localRootRef = useRef<HTMLDivElement>(null)
37
+ const rootRef = hostRef ?? localRootRef
38
+ useConversationFollow(
39
+ rootRef,
40
+ active || entrance,
41
+ speedCpsRef,
42
+ revealScaleRef,
43
+ predictive,
44
+ entrance,
45
+ onEntranceSettled,
46
+ predictiveRef,
47
+ entranceExtentRef,
48
+ )
49
+ useEffect(() => {
50
+ if (onGrowth === undefined || typeof ResizeObserver === 'undefined') return
51
+ const root = rootRef.current
52
+ if (root === null) return
53
+ let previousHeight: number | null = null
54
+ const observer = new ResizeObserver(entries => {
55
+ const nextHeight = entries[0]?.contentRect.height ?? root.getBoundingClientRect().height
56
+ if (!Number.isFinite(nextHeight)) return
57
+ if (previousHeight !== null && nextHeight > previousHeight + 0.5) {
58
+ onGrowth(nextHeight - previousHeight)
59
+ }
60
+ previousHeight = nextHeight
61
+ })
62
+ observer.observe(root)
63
+ return () => { observer.disconnect() }
64
+ }, [onGrowth])
20
65
  return <div ref={rootRef} className={css.follow}>{children}</div>
21
66
  }
@@ -223,6 +223,7 @@
223
223
  * (see useConversationFollow).
224
224
  */
225
225
  .follow {
226
+ contain: layout style;
226
227
  min-width: 0;
227
228
  }
228
229
 
@@ -1,4 +1,4 @@
1
- import { memo, useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'
1
+ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'
2
2
  import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
3
3
  import { ImageGallery, type ImageLoader, type MessageImageLabels } from '@deepseek-ai/dsh-client-ui-attachment'
4
4
  import type { ChatNodeViewProps, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -29,13 +29,248 @@ function usePrefersReducedMotion(): boolean {
29
29
 
30
30
  interface AnimatedMarkdownTextProps extends MarkdownProps {
31
31
  streaming: boolean
32
- announce: boolean
33
32
  /** True on the last text block: that block owns conversation follow. */
34
33
  ownFollow: boolean
34
+ followSpeedCpsRef?: { current: number } | undefined
35
+ followRevealScaleRef?: { current: number } | undefined
36
+ onPredictiveChange?: ((predictive: boolean) => void) | undefined
35
37
  preset: StreamSmoothingPreset
36
38
  shouldHoldBack: () => boolean
37
39
  }
38
40
 
41
+ /** Conservative fallback before the streaming Markdown tail has geometry. */
42
+ const PREDICTIVE_WRAP_FALLBACK_CHARS = 32
43
+ const STREAM_ANNOUNCEMENT_INTERVAL_MS = 800
44
+ const STREAM_ANNOUNCEMENT_MAX_CHARS = 320
45
+
46
+ interface PendingTextGeometry {
47
+ root: HTMLElement
48
+ visibleText: string
49
+ fontSize: number
50
+ wrapThresholdWidth: number | null
51
+ }
52
+
53
+ function approximateInlineWidth(text: string, emPx: number): number {
54
+ let width = 0
55
+ for (const char of text) {
56
+ if (/\s/u.test(char)) width += emPx * 0.33
57
+ else if (/^[\x00-\x7f]$/u.test(char)) width += emPx * 0.56
58
+ else width += emPx
59
+ }
60
+ return width
61
+ }
62
+
63
+ function measurePendingTextGeometry(root: HTMLElement, visibleText: string): PendingTextGeometry {
64
+ if (
65
+ typeof document.createTreeWalker !== 'function'
66
+ || typeof NodeFilter === 'undefined'
67
+ ) {
68
+ return { root, visibleText, fontSize: 14, wrapThresholdWidth: null }
69
+ }
70
+ const rootRect = root.getBoundingClientRect()
71
+ const rootWidth = Math.max(0, rootRect.width, rootRect.right - rootRect.left, root.clientWidth)
72
+ if (rootWidth <= 0) return { root, visibleText, fontSize: 14, wrapThresholdWidth: null }
73
+
74
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
75
+ let tail: Text | null = null
76
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
77
+ if ((node.textContent ?? '').length > 0) tail = node as Text
78
+ }
79
+ const parent = tail?.parentElement ?? root
80
+ const fontSize = Number.parseFloat(getComputedStyle(parent).fontSize) || 14
81
+ if (tail === null || typeof document.createRange !== 'function') {
82
+ return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
83
+ }
84
+
85
+ try {
86
+ const length = tail.textContent?.length ?? 0
87
+ if (length <= 0) return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
88
+ const range = document.createRange()
89
+ range.setStart(tail, Math.max(0, length - 1))
90
+ range.setEnd(tail, length)
91
+ const tailRect = range.getBoundingClientRect()
92
+ const contentRight = rootRect.right
93
+ if (!Number.isFinite(tailRect.right) || tailRect.right <= rootRect.left || contentRight <= rootRect.left) {
94
+ return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
95
+ }
96
+ const remainingWidth = Math.max(0, contentRight - tailRect.right)
97
+ return {
98
+ root,
99
+ visibleText,
100
+ fontSize,
101
+ wrapThresholdWidth: remainingWidth + fontSize * 0.35,
102
+ }
103
+ } catch {
104
+ return { root, visibleText, fontSize, wrapThresholdWidth: rootWidth }
105
+ }
106
+ }
107
+
108
+ /** Whether buffered source can reach a new visual line before it drains. */
109
+ function pendingTextCanGrow(
110
+ root: HTMLElement | null,
111
+ visibleText: string,
112
+ pending: string,
113
+ geometryRef: { current: PendingTextGeometry | null },
114
+ ): boolean {
115
+ if (pending === '') return false
116
+ if (/[\r\n]/u.test(pending)) return true
117
+ const pendingChars = [...pending]
118
+ if (root === null) return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS
119
+
120
+ let geometry = geometryRef.current
121
+ if (geometry?.root !== root || geometry.visibleText !== visibleText) {
122
+ geometry = measurePendingTextGeometry(root, visibleText)
123
+ geometryRef.current = geometry
124
+ }
125
+ if (geometry.wrapThresholdWidth === null) {
126
+ return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS
127
+ }
128
+ return approximateInlineWidth(pending, geometry.fontSize) >= geometry.wrapThresholdWidth
129
+ }
130
+
131
+ function announcementChunkEnd(source: string, start: number): number {
132
+ const hardEnd = Math.min(source.length, start + STREAM_ANNOUNCEMENT_MAX_CHARS)
133
+ if (hardEnd === source.length) return hardEnd
134
+ const softStart = start + Math.floor(STREAM_ANNOUNCEMENT_MAX_CHARS * 0.6)
135
+ for (let index = hardEnd - 1; index >= softStart; index -= 1) {
136
+ if (/[\s.,;:!?]/u.test(source[index] ?? '')) return index + 1
137
+ }
138
+ return hardEnd
139
+ }
140
+
141
+ /** Pace screen-reader updates independently from visual reveal frames. */
142
+ interface StreamAnnouncementState {
143
+ text: string
144
+ revision: number
145
+ present: boolean
146
+ }
147
+
148
+ /** A commit-driven live region isolated from the visible Markdown subtree. */
149
+ const StreamAnnouncement = memo(function StreamAnnouncement({
150
+ text,
151
+ active,
152
+ }: {
153
+ text: string
154
+ active: boolean
155
+ }) {
156
+ const [announcement, setAnnouncement] = useState<StreamAnnouncementState>({
157
+ text: '',
158
+ revision: 0,
159
+ present: active,
160
+ })
161
+ const sourceRef = useRef(text)
162
+ const activeRef = useRef(active)
163
+ const announcedOffsetRef = useRef(active ? 0 : text.length)
164
+ const drainSourceRef = useRef<string | null>(null)
165
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
166
+
167
+ useEffect(() => {
168
+ const clearTimer = (): void => {
169
+ if (timerRef.current === null) return
170
+ clearTimeout(timerRef.current)
171
+ timerRef.current = null
172
+ }
173
+
174
+ const publishNext = (source: string): boolean => {
175
+ const start = Math.min(announcedOffsetRef.current, source.length)
176
+ const end = announcementChunkEnd(source, start)
177
+ if (end <= start) return false
178
+ announcedOffsetRef.current = end
179
+ setAnnouncement(previous => ({
180
+ text: source.slice(start, end),
181
+ revision: previous.revision + 1,
182
+ present: true,
183
+ }))
184
+ return end < source.length
185
+ }
186
+
187
+ const hideAfterLinger = (): void => {
188
+ timerRef.current = setTimeout(() => {
189
+ timerRef.current = null
190
+ if (activeRef.current) return
191
+ drainSourceRef.current = null
192
+ setAnnouncement(previous => ({ ...previous, present: false }))
193
+ }, STREAM_ANNOUNCEMENT_INTERVAL_MS)
194
+ }
195
+
196
+ const drainNext = (): void => {
197
+ const source = drainSourceRef.current
198
+ if (source === null) return
199
+ if (!publishNext(source)) {
200
+ hideAfterLinger()
201
+ return
202
+ }
203
+ timerRef.current = setTimeout(() => {
204
+ timerRef.current = null
205
+ if (activeRef.current) return
206
+ drainNext()
207
+ }, STREAM_ANNOUNCEMENT_INTERVAL_MS)
208
+ }
209
+
210
+ const scheduleLive = (): void => {
211
+ if (timerRef.current !== null || announcedOffsetRef.current >= sourceRef.current.length) return
212
+ timerRef.current = setTimeout(() => {
213
+ timerRef.current = null
214
+ if (!activeRef.current) return
215
+ const source = sourceRef.current
216
+ if (publishNext(source)) scheduleLive()
217
+ }, STREAM_ANNOUNCEMENT_INTERVAL_MS)
218
+ }
219
+
220
+ const wasActive = activeRef.current
221
+ const previousSource = sourceRef.current
222
+ const continuingDrain = !active && !wasActive && drainSourceRef.current !== null
223
+ if (
224
+ !continuingDrain
225
+ && (!text.startsWith(previousSource) || announcedOffsetRef.current > text.length)
226
+ ) {
227
+ announcedOffsetRef.current = 0
228
+ }
229
+ sourceRef.current = text
230
+ activeRef.current = active
231
+
232
+ if (active) {
233
+ if (!wasActive) {
234
+ clearTimer()
235
+ drainSourceRef.current = null
236
+ setAnnouncement(previous => ({
237
+ text: '',
238
+ revision: previous.revision + 1,
239
+ present: true,
240
+ }))
241
+ }
242
+ scheduleLive()
243
+ return
244
+ }
245
+
246
+ if (!wasActive) {
247
+ if (drainSourceRef.current === null) announcedOffsetRef.current = text.length
248
+ return
249
+ }
250
+
251
+ clearTimer()
252
+ drainSourceRef.current = text
253
+ drainNext()
254
+ }, [active, text])
255
+
256
+ useEffect(() => () => {
257
+ if (timerRef.current !== null) clearTimeout(timerRef.current)
258
+ timerRef.current = null
259
+ }, [])
260
+
261
+ if (!active && !announcement.present) return null
262
+ const activating = active && !activeRef.current
263
+ return (
264
+ <span
265
+ className={css.visuallyHidden}
266
+ aria-live="polite"
267
+ aria-atomic="true"
268
+ >
269
+ <span key={announcement.revision}>{activating ? '' : announcement.text}</span>
270
+ </span>
271
+ )
272
+ })
273
+
39
274
  /**
40
275
  * Smooth streaming text arm. While the reply runs, the accumulated source is
41
276
  * revealed through the smoother at a rate that tracks the model's arrival
@@ -52,40 +287,78 @@ function AnimatedMarkdownText({
52
287
  codeLabels,
53
288
  fileMentions,
54
289
  streaming,
55
- announce,
56
290
  ownFollow,
291
+ followSpeedCpsRef,
292
+ followRevealScaleRef,
293
+ onPredictiveChange,
57
294
  preset,
58
295
  shouldHoldBack,
59
296
  }: AnimatedMarkdownTextProps) {
60
297
  const reduced = usePrefersReducedMotion()
61
298
  const [typing, setTyping] = useState(streaming)
62
- const speedCpsRef = useRef(35)
299
+ const localSpeedCpsRef = useRef(35)
300
+ const followRootRef = useRef<HTMLDivElement>(null)
301
+ const predictionSourceRef = useRef<string | null>(null)
302
+ const predictionStateRef = useRef(false)
303
+ const predictionGeometryRef = useRef<PendingTextGeometry | null>(null)
304
+ const speedCpsRef = followSpeedCpsRef ?? localSpeedCpsRef
63
305
  const displayed = useSmoothStreamContent(text, {
64
306
  enabled: typing && !reduced,
307
+ inputComplete: !streaming,
65
308
  preset,
66
309
  shouldHoldBack,
67
310
  speedCpsRef,
311
+ revealScaleRef: followRevealScaleRef,
68
312
  })
69
313
  const shown = reduced ? text : displayed
70
314
  const live = typing && !reduced
71
315
 
316
+ useEffect(() => {
317
+ const root = followRootRef.current
318
+ if (root === null || typeof ResizeObserver === 'undefined') return
319
+ const observer = new ResizeObserver(() => {
320
+ if (predictionGeometryRef.current?.root === root) predictionGeometryRef.current = null
321
+ })
322
+ observer.observe(root)
323
+ return () => { observer.disconnect() }
324
+ }, [])
325
+
326
+ useLayoutEffect(() => {
327
+ if (onPredictiveChange === undefined) return
328
+ const pending = text.slice(shown.length)
329
+ const sourceChanged = predictionSourceRef.current !== text
330
+ const next = !live || !streaming || pending === ''
331
+ ? false
332
+ : sourceChanged
333
+ ? pendingTextCanGrow(followRootRef.current, shown, pending, predictionGeometryRef)
334
+ : predictionStateRef.current
335
+ predictionSourceRef.current = text
336
+ predictionStateRef.current = next
337
+ onPredictiveChange(next)
338
+ }, [live, onPredictiveChange, shown, streaming, text])
339
+
72
340
  // The stream closed: keep revealing the remaining queue, then swap to the
73
341
  // settled parse exactly once. The markdown tree stays mounted until then.
74
342
  useEffect(() => {
75
343
  if (typing && !streaming && shown.length === text.length) setTyping(false)
76
344
  }, [shown, streaming, text, typing])
77
345
 
78
- if (live) {
79
- return (
80
- <>
81
- {announce && <span className={css.visuallyHidden} aria-live="polite">{text}</span>}
82
- <FollowHost active={ownFollow} speedCpsRef={speedCpsRef}>
83
- <MarkdownText text={shown} streaming codeLabels={codeLabels} />
84
- </FollowHost>
85
- </>
86
- )
87
- }
88
- return <MarkdownText text={text} codeLabels={codeLabels} fileMentions={fileMentions} />
346
+ return (
347
+ <FollowHost
348
+ active={live && ownFollow}
349
+ speedCpsRef={speedCpsRef}
350
+ revealScaleRef={followRevealScaleRef}
351
+ predictive={streaming}
352
+ hostRef={followRootRef}
353
+ >
354
+ <MarkdownText
355
+ text={live ? shown : text}
356
+ streaming={live}
357
+ codeLabels={codeLabels}
358
+ fileMentions={live ? undefined : fileMentions}
359
+ />
360
+ </FollowHost>
361
+ )
89
362
  }
90
363
 
91
364
  function imageLabels(t: AssistantProps['t']): MessageImageLabels {
@@ -129,6 +402,7 @@ function AnimatedReasoning({
129
402
  thinkAutoExpand,
130
403
  shouldHoldBack,
131
404
  followSpeedCpsRef,
405
+ followRevealScaleRef,
132
406
  t,
133
407
  }: {
134
408
  text: string
@@ -137,6 +411,7 @@ function AnimatedReasoning({
137
411
  thinkAutoExpand: boolean
138
412
  shouldHoldBack: () => boolean
139
413
  followSpeedCpsRef?: { current: number } | undefined
414
+ followRevealScaleRef?: { current: number } | undefined
140
415
  t: AssistantProps['t']
141
416
  }) {
142
417
  const reduced = usePrefersReducedMotion()
@@ -147,11 +422,12 @@ function AnimatedReasoning({
147
422
  preset,
148
423
  shouldHoldBack,
149
424
  speedCpsRef: followSpeedCpsRef,
425
+ revealScaleRef: followRevealScaleRef,
150
426
  })
151
427
  const shown = running && !reduced ? displayed : text
152
428
  const summary = running ? latestLine(shown) : firstLine(text)
153
429
 
154
- useEffect(() => {
430
+ useLayoutEffect(() => {
155
431
  // Only the running state owns disclosure while auto-expand is on; with it
156
432
  // off, a manual toggle is never wrestled back by the stream.
157
433
  if (thinkAutoExpand) setExpanded(running)
@@ -224,12 +500,30 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
224
500
  }) {
225
501
  const data = node.data
226
502
  const streaming = data.status === 'running'
503
+ const reduced = usePrefersReducedMotion()
227
504
  const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
228
505
  const rootSpeedRef = useRef(35)
229
- const reasoningOwnsSpeed = streaming && data.blocks[data.blocks.length - 1]?.kind === 'reasoning'
230
- useEffect(() => {
506
+ const rootRevealScaleRef = useRef(1)
507
+ const reasoningTailIndex = streaming && data.blocks[data.blocks.length - 1]?.kind === 'reasoning'
508
+ ? data.blocks.length - 1
509
+ : -1
510
+ const reasoningOwnsSpeed = reasoningTailIndex !== -1
511
+ const rootPredictiveRef = useRef(false)
512
+ const previousReasoningTailRef = useRef(-1)
513
+ if (reasoningTailIndex !== previousReasoningTailRef.current) {
514
+ // Think growth is already paced by its own text reveal. Opening additional
515
+ // speculative runway here exposes that runway as an empty gap above the
516
+ // fixed turn status, especially when reasoning arrives in fast bursts.
517
+ // The follower still smooths real height growth within the measured gap
518
+ // and catches up any unsafe remainder in the same frame.
519
+ rootPredictiveRef.current = false
231
520
  if (!reasoningOwnsSpeed) rootSpeedRef.current = 35
232
- }, [reasoningOwnsSpeed])
521
+ previousReasoningTailRef.current = reasoningTailIndex
522
+ }
523
+ const updateTextPrediction = useMemo(
524
+ () => (predictive: boolean): void => { rootPredictiveRef.current = predictive },
525
+ [],
526
+ )
233
527
  const turn = node.location.kind === 'turn' || node.location.kind === 'step'
234
528
  ? node.location.turn
235
529
  : undefined
@@ -251,6 +545,10 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
251
545
  || data.status === 'interrupted'
252
546
  || data.blocks.some(block => block.kind !== 'tool-call')
253
547
  if (!hasVisible) return null
548
+ const announcementText = data.blocks
549
+ .filter(block => block.kind === 'text')
550
+ .map(block => block.text)
551
+ .join('\n')
254
552
 
255
553
  const rendered: ReactNode[] = []
256
554
  const last = data.blocks.length - 1
@@ -271,8 +569,10 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
271
569
  codeLabels={codeLabels}
272
570
  fileMentions={mentions}
273
571
  streaming={streaming}
274
- announce={index === last}
275
572
  ownFollow={!streaming && index === lastFollow}
573
+ followSpeedCpsRef={index === lastFollow ? rootSpeedRef : undefined}
574
+ followRevealScaleRef={index === lastFollow ? rootRevealScaleRef : undefined}
575
+ onPredictiveChange={index === lastFollow ? updateTextPrediction : undefined}
276
576
  preset={preset}
277
577
  shouldHoldBack={shouldHoldBack}
278
578
  />,
@@ -288,6 +588,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
288
588
  thinkAutoExpand={thinkAutoExpand}
289
589
  shouldHoldBack={shouldHoldBack}
290
590
  followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
591
+ followRevealScaleRef={reasoningOwnsSpeed && index === last ? rootRevealScaleRef : undefined}
291
592
  t={t}
292
593
  />,
293
594
  )
@@ -323,7 +624,13 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
323
624
 
324
625
  return (
325
626
  <div ref={guardRef} className={css.root} data-streaming={streaming || undefined}>
326
- <FollowHost active={streaming} speedCpsRef={rootSpeedRef}>
627
+ <StreamAnnouncement text={announcementText} active={streaming && !reduced} />
628
+ <FollowHost
629
+ active={streaming && !reduced}
630
+ speedCpsRef={rootSpeedRef}
631
+ revealScaleRef={rootRevealScaleRef}
632
+ predictiveRef={rootPredictiveRef}
633
+ >
327
634
  <div className={css.body}>
328
635
  {rendered}
329
636
  {data.status === 'interrupted' && <span className={css.stopped}>{t('message.stopped')}</span>}