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/README.en.md +10 -13
- package/README.md +10 -13
- package/lib/client.js +1315 -357
- package/lib/client.js.map +1 -1
- package/package.json +9 -2
- package/src/client/FollowHost.tsx +49 -4
- package/src/client/TypewriterAssistantNodeView.module.css +1 -0
- package/src/client/TypewriterAssistantNodeView.tsx +328 -21
- package/src/client/TypewriterToolNodeView.tsx +183 -8
- package/src/client/index.ts +25 -8
- package/src/client/teleprompterGlide.ts +814 -231
- package/src/client/useProgressiveDomText.ts +206 -0
- package/src/client/useSmoothStreamContent.ts +139 -73
- package/src/config.ts +3 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-smooth-stream",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "DeepSeek Harness
|
|
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
|
|
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
|
|
19
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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>}
|