dsh-smooth-stream 0.3.1 → 0.3.3
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 +12 -10
- package/README.md +12 -10
- package/lib/client.js +1140 -347
- package/lib/client.js.map +1 -1
- package/package.json +3 -1
- package/src/client/FollowHost.tsx +49 -4
- package/src/client/TypewriterAssistantNodeView.module.css +3 -2
- package/src/client/TypewriterAssistantNodeView.tsx +160 -23
- package/src/client/TypewriterToolNodeView.tsx +183 -8
- package/src/client/index.ts +25 -8
- package/src/client/teleprompterGlide.ts +763 -213
- 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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-smooth-stream",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "DeepSeek Harness (dsh) plugin: silky streaming reveal, no flicker. dsh 丝滑流式渲染插件。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
},
|
|
75
75
|
"scripts": {
|
|
76
76
|
"build": "tsdown",
|
|
77
|
+
"build:repro": "esbuild repro/main.tsx --bundle --format=esm --platform=browser --loader:.css=local-css --outfile=repro/bundle.js",
|
|
77
78
|
"prepare": "tsdown",
|
|
78
79
|
"test": "vitest run",
|
|
79
80
|
"typecheck": "tsc -p tsconfig.json --pretty false"
|
|
@@ -146,6 +147,7 @@
|
|
|
146
147
|
"@types/node": "^22.20.0",
|
|
147
148
|
"@types/react": "~18.3.1",
|
|
148
149
|
"@types/react-dom": "~18.3.0",
|
|
150
|
+
"esbuild": "^0.28.2",
|
|
149
151
|
"jsdom": "^26.1.0",
|
|
150
152
|
"lightningcss": "^1.30.1",
|
|
151
153
|
"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
|
}
|
|
@@ -218,11 +218,12 @@
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
/*
|
|
221
|
-
* Follow host: ordinary document flow. The
|
|
222
|
-
*
|
|
221
|
+
* Follow host: ordinary document flow. The assistant node owns follow during
|
|
222
|
+
* streaming; the last text block takes over while its settled queue drains
|
|
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'
|
|
@@ -32,10 +32,73 @@ interface AnimatedMarkdownTextProps extends MarkdownProps {
|
|
|
32
32
|
announce: boolean
|
|
33
33
|
/** True on the last text block: that block owns conversation follow. */
|
|
34
34
|
ownFollow: boolean
|
|
35
|
+
followSpeedCpsRef?: { current: number } | undefined
|
|
36
|
+
followRevealScaleRef?: { current: number } | undefined
|
|
37
|
+
onPredictiveChange?: ((predictive: boolean) => void) | undefined
|
|
35
38
|
preset: StreamSmoothingPreset
|
|
36
39
|
shouldHoldBack: () => boolean
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
/** Conservative fallback before the streaming Markdown tail has geometry. */
|
|
43
|
+
const PREDICTIVE_WRAP_FALLBACK_CHARS = 32
|
|
44
|
+
|
|
45
|
+
function approximateInlineWidth(text: string, emPx: number): number {
|
|
46
|
+
let width = 0
|
|
47
|
+
for (const char of text) {
|
|
48
|
+
if (/\s/u.test(char)) width += emPx * 0.33
|
|
49
|
+
else if (/^[\x00-\x7f]$/u.test(char)) width += emPx * 0.56
|
|
50
|
+
else width += emPx
|
|
51
|
+
}
|
|
52
|
+
return width
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether buffered source can reach a new visual line before it drains. */
|
|
56
|
+
function pendingTextCanGrow(root: HTMLElement | null, pending: string): boolean {
|
|
57
|
+
if (pending === '') return false
|
|
58
|
+
if (/[\r\n]/u.test(pending)) return true
|
|
59
|
+
const pendingChars = [...pending]
|
|
60
|
+
if (
|
|
61
|
+
root === null
|
|
62
|
+
|| typeof document.createTreeWalker !== 'function'
|
|
63
|
+
|| typeof NodeFilter === 'undefined'
|
|
64
|
+
) {
|
|
65
|
+
return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const rootRect = root.getBoundingClientRect()
|
|
69
|
+
const rootWidth = Math.max(0, rootRect.width, rootRect.right - rootRect.left, root.clientWidth)
|
|
70
|
+
if (rootWidth <= 0) return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS
|
|
71
|
+
|
|
72
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
|
73
|
+
let tail: Text | null = null
|
|
74
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
75
|
+
if ((node.textContent ?? '').length > 0) tail = node as Text
|
|
76
|
+
}
|
|
77
|
+
const parent = tail?.parentElement ?? root
|
|
78
|
+
const fontSize = Number.parseFloat(getComputedStyle(parent).fontSize) || 14
|
|
79
|
+
const pendingWidth = approximateInlineWidth(pending, fontSize)
|
|
80
|
+
if (tail === null || typeof document.createRange !== 'function') {
|
|
81
|
+
return pendingWidth >= rootWidth
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const length = tail.textContent?.length ?? 0
|
|
86
|
+
if (length <= 0) return pendingWidth >= rootWidth
|
|
87
|
+
const range = document.createRange()
|
|
88
|
+
range.setStart(tail, Math.max(0, length - 1))
|
|
89
|
+
range.setEnd(tail, length)
|
|
90
|
+
const tailRect = range.getBoundingClientRect()
|
|
91
|
+
const contentRight = rootRect.right
|
|
92
|
+
if (!Number.isFinite(tailRect.right) || tailRect.right <= rootRect.left || contentRight <= rootRect.left) {
|
|
93
|
+
return pendingWidth >= rootWidth
|
|
94
|
+
}
|
|
95
|
+
const remainingWidth = Math.max(0, contentRight - tailRect.right)
|
|
96
|
+
return pendingWidth >= remainingWidth + fontSize * 0.35
|
|
97
|
+
} catch {
|
|
98
|
+
return pendingWidth >= rootWidth
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
39
102
|
/**
|
|
40
103
|
* Smooth streaming text arm. While the reply runs, the accumulated source is
|
|
41
104
|
* revealed through the smoother at a rate that tracks the model's arrival
|
|
@@ -54,38 +117,69 @@ function AnimatedMarkdownText({
|
|
|
54
117
|
streaming,
|
|
55
118
|
announce,
|
|
56
119
|
ownFollow,
|
|
120
|
+
followSpeedCpsRef,
|
|
121
|
+
followRevealScaleRef,
|
|
122
|
+
onPredictiveChange,
|
|
57
123
|
preset,
|
|
58
124
|
shouldHoldBack,
|
|
59
125
|
}: AnimatedMarkdownTextProps) {
|
|
60
126
|
const reduced = usePrefersReducedMotion()
|
|
61
127
|
const [typing, setTyping] = useState(streaming)
|
|
62
|
-
const
|
|
128
|
+
const localSpeedCpsRef = useRef(35)
|
|
129
|
+
const followRootRef = useRef<HTMLDivElement>(null)
|
|
130
|
+
const predictionSourceRef = useRef<string | null>(null)
|
|
131
|
+
const predictionStateRef = useRef(false)
|
|
132
|
+
const speedCpsRef = followSpeedCpsRef ?? localSpeedCpsRef
|
|
63
133
|
const displayed = useSmoothStreamContent(text, {
|
|
64
134
|
enabled: typing && !reduced,
|
|
135
|
+
inputComplete: !streaming,
|
|
65
136
|
preset,
|
|
66
137
|
shouldHoldBack,
|
|
67
138
|
speedCpsRef,
|
|
139
|
+
revealScaleRef: followRevealScaleRef,
|
|
68
140
|
})
|
|
69
141
|
const shown = reduced ? text : displayed
|
|
70
142
|
const live = typing && !reduced
|
|
71
143
|
|
|
144
|
+
useLayoutEffect(() => {
|
|
145
|
+
if (onPredictiveChange === undefined) return
|
|
146
|
+
const pending = text.slice(shown.length)
|
|
147
|
+
const sourceChanged = predictionSourceRef.current !== text
|
|
148
|
+
const next = !live || !streaming || pending === ''
|
|
149
|
+
? false
|
|
150
|
+
: sourceChanged
|
|
151
|
+
? pendingTextCanGrow(followRootRef.current, pending)
|
|
152
|
+
: predictionStateRef.current
|
|
153
|
+
predictionSourceRef.current = text
|
|
154
|
+
predictionStateRef.current = next
|
|
155
|
+
onPredictiveChange(next)
|
|
156
|
+
}, [live, onPredictiveChange, shown, streaming, text])
|
|
157
|
+
|
|
72
158
|
// The stream closed: keep revealing the remaining queue, then swap to the
|
|
73
159
|
// settled parse exactly once. The markdown tree stays mounted until then.
|
|
74
160
|
useEffect(() => {
|
|
75
161
|
if (typing && !streaming && shown.length === text.length) setTyping(false)
|
|
76
162
|
}, [shown, streaming, text, typing])
|
|
77
163
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
164
|
+
return (
|
|
165
|
+
<>
|
|
166
|
+
{live && announce && <span className={css.visuallyHidden} aria-live="polite">{text}</span>}
|
|
167
|
+
<FollowHost
|
|
168
|
+
active={live && ownFollow}
|
|
169
|
+
speedCpsRef={speedCpsRef}
|
|
170
|
+
revealScaleRef={followRevealScaleRef}
|
|
171
|
+
predictive={streaming}
|
|
172
|
+
hostRef={followRootRef}
|
|
173
|
+
>
|
|
174
|
+
<MarkdownText
|
|
175
|
+
text={live ? shown : text}
|
|
176
|
+
streaming={live}
|
|
177
|
+
codeLabels={codeLabels}
|
|
178
|
+
fileMentions={live ? undefined : fileMentions}
|
|
179
|
+
/>
|
|
180
|
+
</FollowHost>
|
|
181
|
+
</>
|
|
182
|
+
)
|
|
89
183
|
}
|
|
90
184
|
|
|
91
185
|
function imageLabels(t: AssistantProps['t']): MessageImageLabels {
|
|
@@ -128,6 +222,9 @@ function AnimatedReasoning({
|
|
|
128
222
|
preset,
|
|
129
223
|
thinkAutoExpand,
|
|
130
224
|
shouldHoldBack,
|
|
225
|
+
followSpeedCpsRef,
|
|
226
|
+
followRevealScaleRef,
|
|
227
|
+
onExpandedChange,
|
|
131
228
|
t,
|
|
132
229
|
}: {
|
|
133
230
|
text: string
|
|
@@ -135,35 +232,43 @@ function AnimatedReasoning({
|
|
|
135
232
|
preset: StreamSmoothingPreset
|
|
136
233
|
thinkAutoExpand: boolean
|
|
137
234
|
shouldHoldBack: () => boolean
|
|
235
|
+
followSpeedCpsRef?: { current: number } | undefined
|
|
236
|
+
followRevealScaleRef?: { current: number } | undefined
|
|
237
|
+
onExpandedChange?: ((expanded: boolean) => void) | undefined
|
|
138
238
|
t: AssistantProps['t']
|
|
139
239
|
}) {
|
|
140
240
|
const reduced = usePrefersReducedMotion()
|
|
141
241
|
const [expanded, setExpanded] = useState(running && thinkAutoExpand)
|
|
142
242
|
const summaryRef = useRef<HTMLSpanElement>(null)
|
|
143
|
-
const speedCpsRef = useRef(35)
|
|
144
243
|
const displayed = useSmoothStreamContent(text, {
|
|
145
244
|
enabled: running && !reduced,
|
|
146
245
|
preset,
|
|
147
246
|
shouldHoldBack,
|
|
148
|
-
speedCpsRef,
|
|
247
|
+
speedCpsRef: followSpeedCpsRef,
|
|
248
|
+
revealScaleRef: followRevealScaleRef,
|
|
149
249
|
})
|
|
150
250
|
const shown = running && !reduced ? displayed : text
|
|
151
251
|
const summary = running ? latestLine(shown) : firstLine(text)
|
|
152
252
|
|
|
153
|
-
|
|
253
|
+
useLayoutEffect(() => {
|
|
154
254
|
// Only the running state owns disclosure while auto-expand is on; with it
|
|
155
255
|
// off, a manual toggle is never wrestled back by the stream.
|
|
156
256
|
if (thinkAutoExpand) setExpanded(running)
|
|
157
257
|
}, [running, thinkAutoExpand])
|
|
158
258
|
|
|
259
|
+
useLayoutEffect(() => {
|
|
260
|
+
onExpandedChange?.(expanded)
|
|
261
|
+
}, [expanded, onExpandedChange])
|
|
262
|
+
|
|
159
263
|
useEffect(() => {
|
|
160
264
|
const element = summaryRef.current
|
|
161
265
|
if (element === null) return
|
|
162
266
|
element.scrollLeft = running ? element.scrollWidth - element.clientWidth : 0
|
|
163
267
|
}, [running, summary])
|
|
164
268
|
|
|
269
|
+
// Preserve FollowHost's layout wrapper without mounting a second scroll owner.
|
|
165
270
|
return (
|
|
166
|
-
<
|
|
271
|
+
<div className={css.follow}>
|
|
167
272
|
<div className={css.think} data-variant="think" data-state={running ? 'running' : 'ok'}>
|
|
168
273
|
{running && <span className={css.visuallyHidden}>{t('row.running')}</span>}
|
|
169
274
|
<AnimatedDisclosure
|
|
@@ -185,7 +290,7 @@ function AnimatedReasoning({
|
|
|
185
290
|
<div className={css.thinkBody}>{shown}</div>
|
|
186
291
|
</AnimatedDisclosure>
|
|
187
292
|
</div>
|
|
188
|
-
</
|
|
293
|
+
</div>
|
|
189
294
|
)
|
|
190
295
|
}
|
|
191
296
|
|
|
@@ -194,10 +299,10 @@ function AnimatedReasoning({
|
|
|
194
299
|
* streaming is revealed by the smoother through the Harness Markdown
|
|
195
300
|
* renderer at a rate that tracks arrival. Reasoning blocks keep the
|
|
196
301
|
* built-in Think disclosure and only receive a smoothed text feed; the
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* reveals when the frame rate is degraded. Settled text
|
|
200
|
-
* full Markdown pipeline.
|
|
302
|
+
* outer node owns conversation-port follow while streaming; the final text
|
|
303
|
+
* block keeps ownership while its settled reveal queue drains. The FPS guard
|
|
304
|
+
* holds offscreen reveals when the frame rate is degraded. Settled text
|
|
305
|
+
* renders with the full Markdown pipeline.
|
|
201
306
|
*/
|
|
202
307
|
export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNodeView({
|
|
203
308
|
mode: _mode = DEFAULT_STREAM_CONFIG.mode,
|
|
@@ -222,8 +327,29 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
222
327
|
}) {
|
|
223
328
|
const data = node.data
|
|
224
329
|
const streaming = data.status === 'running'
|
|
330
|
+
const reduced = usePrefersReducedMotion()
|
|
225
331
|
const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
|
|
226
332
|
const rootSpeedRef = useRef(35)
|
|
333
|
+
const rootRevealScaleRef = useRef(1)
|
|
334
|
+
const reasoningTailIndex = streaming && data.blocks[data.blocks.length - 1]?.kind === 'reasoning'
|
|
335
|
+
? data.blocks.length - 1
|
|
336
|
+
: -1
|
|
337
|
+
const reasoningOwnsSpeed = reasoningTailIndex !== -1
|
|
338
|
+
const rootPredictiveRef = useRef(false)
|
|
339
|
+
const previousReasoningTailRef = useRef(-1)
|
|
340
|
+
if (reasoningTailIndex !== previousReasoningTailRef.current) {
|
|
341
|
+
rootPredictiveRef.current = reasoningOwnsSpeed ? thinkAutoExpand : false
|
|
342
|
+
if (!reasoningOwnsSpeed) rootSpeedRef.current = 35
|
|
343
|
+
previousReasoningTailRef.current = reasoningTailIndex
|
|
344
|
+
}
|
|
345
|
+
const updateReasoningExpanded = useMemo(
|
|
346
|
+
() => (expanded: boolean): void => { rootPredictiveRef.current = expanded },
|
|
347
|
+
[],
|
|
348
|
+
)
|
|
349
|
+
const updateTextPrediction = useMemo(
|
|
350
|
+
() => (predictive: boolean): void => { rootPredictiveRef.current = predictive },
|
|
351
|
+
[],
|
|
352
|
+
)
|
|
227
353
|
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
|
228
354
|
? node.location.turn
|
|
229
355
|
: undefined
|
|
@@ -267,6 +393,9 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
267
393
|
streaming={streaming}
|
|
268
394
|
announce={index === last}
|
|
269
395
|
ownFollow={!streaming && index === lastFollow}
|
|
396
|
+
followSpeedCpsRef={index === lastFollow ? rootSpeedRef : undefined}
|
|
397
|
+
followRevealScaleRef={index === lastFollow ? rootRevealScaleRef : undefined}
|
|
398
|
+
onPredictiveChange={index === lastFollow ? updateTextPrediction : undefined}
|
|
270
399
|
preset={preset}
|
|
271
400
|
shouldHoldBack={shouldHoldBack}
|
|
272
401
|
/>,
|
|
@@ -281,6 +410,9 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
281
410
|
preset={preset}
|
|
282
411
|
thinkAutoExpand={thinkAutoExpand}
|
|
283
412
|
shouldHoldBack={shouldHoldBack}
|
|
413
|
+
followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
|
|
414
|
+
followRevealScaleRef={reasoningOwnsSpeed && index === last ? rootRevealScaleRef : undefined}
|
|
415
|
+
onExpandedChange={index === reasoningTailIndex ? updateReasoningExpanded : undefined}
|
|
284
416
|
t={t}
|
|
285
417
|
/>,
|
|
286
418
|
)
|
|
@@ -316,7 +448,12 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
316
448
|
|
|
317
449
|
return (
|
|
318
450
|
<div ref={guardRef} className={css.root} data-streaming={streaming || undefined}>
|
|
319
|
-
<FollowHost
|
|
451
|
+
<FollowHost
|
|
452
|
+
active={streaming && !reduced}
|
|
453
|
+
speedCpsRef={rootSpeedRef}
|
|
454
|
+
revealScaleRef={rootRevealScaleRef}
|
|
455
|
+
predictiveRef={rootPredictiveRef}
|
|
456
|
+
>
|
|
320
457
|
<div className={css.body}>
|
|
321
458
|
{rendered}
|
|
322
459
|
{data.status === 'interrupted' && <span className={css.stopped}>{t('message.stopped')}</span>}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { createElement, useRef, type ComponentType } from 'react'
|
|
1
|
+
import { createElement, useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from 'react'
|
|
2
2
|
import { FollowHost } from './FollowHost.tsx'
|
|
3
|
+
import { useProgressiveDomText } from './useProgressiveDomText.ts'
|
|
4
|
+
import { hasRecentConversationFollow } from './teleprompterGlide.ts'
|
|
3
5
|
|
|
4
6
|
/** Props forwarded through a follow wrap; extra kit seats pass through. */
|
|
5
7
|
export type FollowWrapProps = {
|
|
@@ -7,10 +9,29 @@ export type FollowWrapProps = {
|
|
|
7
9
|
renderSlot?: unknown
|
|
8
10
|
} & Record<string, unknown>
|
|
9
11
|
|
|
12
|
+
function openAgentLocation(node: unknown): boolean {
|
|
13
|
+
if (node === null || typeof node !== 'object' || !('location' in node)) return false
|
|
14
|
+
const location = (node as { location: unknown }).location
|
|
15
|
+
if (location === null || typeof location !== 'object' || !('kind' in location)) return false
|
|
16
|
+
const kind = (location as { kind: unknown }).kind
|
|
17
|
+
if (!('turn' in location)) return false
|
|
18
|
+
const turn = (location as { turn: unknown }).turn
|
|
19
|
+
if (turn === null || typeof turn !== 'object' || !('status' in turn)) return false
|
|
20
|
+
if (kind === 'turn') return (turn as { status: unknown }).status === 'open'
|
|
21
|
+
if (kind !== 'step' || !('step' in location)) return false
|
|
22
|
+
const step = (location as { step: unknown }).step
|
|
23
|
+
return step !== null
|
|
24
|
+
&& typeof step === 'object'
|
|
25
|
+
&& 'status' in step
|
|
26
|
+
&& (step as { status: unknown }).status === 'open'
|
|
27
|
+
}
|
|
28
|
+
|
|
10
29
|
/**
|
|
11
|
-
* True while a Chat node
|
|
12
|
-
* 'running'` payload, a Tool root that has not
|
|
13
|
-
* model-retry whose current attempt is still
|
|
30
|
+
* True while a Chat node has an explicitly unfinished lifecycle: an
|
|
31
|
+
* assistant/workflow `status: 'running'` payload, a Tool root that has not
|
|
32
|
+
* settled (`kind` absent), or a model-retry whose current attempt is still
|
|
33
|
+
* `scheduled`. Open-but-otherwise-unknown rows are covered by
|
|
34
|
+
* `isFollowableChatNode` below.
|
|
14
35
|
* @param node - The Chat node's view `node` prop.
|
|
15
36
|
* @returns whether this row should own conversation follow.
|
|
16
37
|
*/
|
|
@@ -19,6 +40,21 @@ export function isGrowingChatNode(node: unknown): boolean {
|
|
|
19
40
|
const data = (node as { data: unknown }).data
|
|
20
41
|
if (data === null || typeof data !== 'object') return false
|
|
21
42
|
if ('status' in data && (data as { status: unknown }).status === 'running') return true
|
|
43
|
+
if (
|
|
44
|
+
'kind' in data
|
|
45
|
+
&& (data as { kind: unknown }).kind === 'command'
|
|
46
|
+
&& 'outcome' in data
|
|
47
|
+
&& (data as { outcome: unknown }).outcome === null
|
|
48
|
+
) return true
|
|
49
|
+
if ('command' in data) {
|
|
50
|
+
const command = (data as { command: unknown }).command
|
|
51
|
+
if (
|
|
52
|
+
command !== null
|
|
53
|
+
&& typeof command === 'object'
|
|
54
|
+
&& 'outcome' in command
|
|
55
|
+
&& (command as { outcome: unknown }).outcome === null
|
|
56
|
+
) return true
|
|
57
|
+
}
|
|
22
58
|
if ('root' in data) {
|
|
23
59
|
const root = (data as { root: unknown }).root
|
|
24
60
|
if (root !== null && typeof root === 'object' && !('kind' in root)) return true
|
|
@@ -36,17 +72,156 @@ export function isGrowingChatNode(node: unknown): boolean {
|
|
|
36
72
|
}
|
|
37
73
|
|
|
38
74
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
75
|
+
* True for any Agent-owned Chat row in the currently open turn/step. This is
|
|
76
|
+
* the extensibility boundary: a newly registered Context, Command, Tool, or
|
|
77
|
+
* workflow renderer is followed without adding another kind-specific branch.
|
|
78
|
+
*/
|
|
79
|
+
export function isFollowableChatNode(node: unknown): boolean {
|
|
80
|
+
return isGrowingChatNode(node) || openAgentLocation(node)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* True when a newly mounted Agent-owned row belongs to the current open
|
|
85
|
+
* Turn/Step, or is itself an unresolved growing lifecycle.
|
|
86
|
+
* @param node - The Chat node's view `node` prop.
|
|
87
|
+
* @returns whether its initial height should enter through conversation follow.
|
|
88
|
+
*/
|
|
89
|
+
export function shouldAnimateChatNodeEntrance(node: unknown): boolean {
|
|
90
|
+
return isFollowableChatNode(node)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Runtime-only fallback for unknown or terminal rows at the active flow tip. */
|
|
94
|
+
function liveAgentTailMode(root: HTMLElement): 'turn' | 'handoff' | null {
|
|
95
|
+
const port = root.closest<HTMLElement>('[data-conversation-scroll]')
|
|
96
|
+
if (port === null) return null
|
|
97
|
+
const row = root.closest<HTMLElement>('[data-chat-flow-key]')
|
|
98
|
+
if (row !== null) {
|
|
99
|
+
let sibling = row.nextElementSibling
|
|
100
|
+
while (sibling !== null) {
|
|
101
|
+
if (sibling instanceof HTMLElement && sibling.hasAttribute('data-chat-flow-key')) return null
|
|
102
|
+
sibling = sibling.nextElementSibling
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const flow = root.closest<HTMLElement>('[data-chat-flow]')
|
|
106
|
+
const hasTurnStatus = flow !== null && [...flow.children].some(child =>
|
|
107
|
+
child instanceof HTMLElement
|
|
108
|
+
&& child.getAttribute('role') === 'status'
|
|
109
|
+
&& !child.hasAttribute('data-chat-flow-key'))
|
|
110
|
+
if (hasTurnStatus) return 'turn'
|
|
111
|
+
return hasRecentConversationFollow(port) ? 'handoff' : null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Wrap a prior Agent Chat renderer so its entrance and later growth share
|
|
116
|
+
* conversation follow. Presentation stays with the wrapped component; kit
|
|
117
|
+
* seats (`renderSlot`, locale, inject) pass through unchanged.
|
|
42
118
|
* @param Inner - The already-registered row component.
|
|
43
119
|
* @returns A follow-hosted row.
|
|
44
120
|
*/
|
|
45
121
|
export function wrapFollowNodeView(Inner: ComponentType<FollowWrapProps>) {
|
|
46
122
|
return function TypewriterFollowNodeView(props: FollowWrapProps) {
|
|
47
123
|
const speedCpsRef = useRef(35)
|
|
124
|
+
const hostRef = useRef<HTMLDivElement>(null)
|
|
125
|
+
const growing = isGrowingChatNode(props.node)
|
|
126
|
+
const structurallyFollowable = isFollowableChatNode(props.node)
|
|
127
|
+
const structuralRef = useRef(structurallyFollowable)
|
|
128
|
+
const [runtimeFollowable, setRuntimeFollowable] = useState(false)
|
|
129
|
+
const runtimePersistentRef = useRef(false)
|
|
130
|
+
const runtimeHandledRef = useRef(false)
|
|
131
|
+
const followable = structurallyFollowable || runtimeFollowable
|
|
132
|
+
const revealInitialRef = useRef(true)
|
|
133
|
+
const [entering, setEntering] = useState(() => shouldAnimateChatNodeEntrance(props.node))
|
|
134
|
+
const [growthPulse, setGrowthPulse] = useState(false)
|
|
135
|
+
const followableRef = useRef(false)
|
|
136
|
+
const growingRef = useRef(growing)
|
|
137
|
+
const entranceActiveRef = useRef(entering || growthPulse)
|
|
138
|
+
const growthExtentRef = useRef<number | null>(null)
|
|
139
|
+
const mountedRef = useRef(true)
|
|
140
|
+
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
141
|
+
structuralRef.current = structurallyFollowable
|
|
142
|
+
followableRef.current = followable
|
|
143
|
+
growingRef.current = growing
|
|
144
|
+
entranceActiveRef.current = entering || growthPulse
|
|
145
|
+
const finishRuntimeReveal = useCallback(() => {
|
|
146
|
+
if (structuralRef.current || runtimePersistentRef.current) return
|
|
147
|
+
runtimeHandledRef.current = true
|
|
148
|
+
setRuntimeFollowable(false)
|
|
149
|
+
}, [])
|
|
150
|
+
useProgressiveDomText(
|
|
151
|
+
hostRef,
|
|
152
|
+
followable,
|
|
153
|
+
revealInitialRef.current,
|
|
154
|
+
speedCpsRef,
|
|
155
|
+
runtimeFollowable ? finishRuntimeReveal : undefined,
|
|
156
|
+
)
|
|
157
|
+
useLayoutEffect(() => {
|
|
158
|
+
if (structurallyFollowable) return
|
|
159
|
+
const root = hostRef.current
|
|
160
|
+
if (root === null) return
|
|
161
|
+
const mode = liveAgentTailMode(root)
|
|
162
|
+
if (runtimeFollowable) {
|
|
163
|
+
if (runtimePersistentRef.current && mode !== 'turn') {
|
|
164
|
+
runtimePersistentRef.current = false
|
|
165
|
+
runtimeHandledRef.current = true
|
|
166
|
+
setRuntimeFollowable(false)
|
|
167
|
+
}
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (runtimeHandledRef.current || mode === null) return
|
|
171
|
+
runtimePersistentRef.current = mode === 'turn'
|
|
172
|
+
setRuntimeFollowable(true)
|
|
173
|
+
setEntering(true)
|
|
174
|
+
}, [runtimeFollowable, structurallyFollowable])
|
|
175
|
+
const finishEntrance = useCallback(() => {
|
|
176
|
+
growthExtentRef.current = null
|
|
177
|
+
if (pulseTimerRef.current !== null) {
|
|
178
|
+
clearTimeout(pulseTimerRef.current)
|
|
179
|
+
pulseTimerRef.current = null
|
|
180
|
+
}
|
|
181
|
+
setEntering(false)
|
|
182
|
+
setGrowthPulse(false)
|
|
183
|
+
}, [])
|
|
184
|
+
const onGrowth = useCallback((deltaPx: number) => {
|
|
185
|
+
// A row with an explicit streaming lifecycle keeps its original owner
|
|
186
|
+
// through completion. For every other Agent row, a pure layout increase
|
|
187
|
+
// is enough evidence to glide it; renderer kind and payload shape are
|
|
188
|
+
// intentionally irrelevant.
|
|
189
|
+
if (
|
|
190
|
+
!mountedRef.current
|
|
191
|
+
|| !followableRef.current
|
|
192
|
+
|| growingRef.current
|
|
193
|
+
|| entranceActiveRef.current
|
|
194
|
+
) return
|
|
195
|
+
growthExtentRef.current = deltaPx
|
|
196
|
+
setGrowthPulse(true)
|
|
197
|
+
if (pulseTimerRef.current !== null) clearTimeout(pulseTimerRef.current)
|
|
198
|
+
pulseTimerRef.current = setTimeout(() => {
|
|
199
|
+
pulseTimerRef.current = null
|
|
200
|
+
setGrowthPulse(false)
|
|
201
|
+
}, 1200)
|
|
202
|
+
}, [])
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
mountedRef.current = true
|
|
205
|
+
return () => {
|
|
206
|
+
mountedRef.current = false
|
|
207
|
+
if (pulseTimerRef.current !== null) clearTimeout(pulseTimerRef.current)
|
|
208
|
+
}
|
|
209
|
+
}, [])
|
|
48
210
|
return (
|
|
49
|
-
<FollowHost
|
|
211
|
+
<FollowHost
|
|
212
|
+
active={growing}
|
|
213
|
+
entrance={entering || growthPulse}
|
|
214
|
+
onEntranceSettled={finishEntrance}
|
|
215
|
+
onGrowth={followable ? onGrowth : undefined}
|
|
216
|
+
entranceExtentRef={growthExtentRef}
|
|
217
|
+
speedCpsRef={speedCpsRef}
|
|
218
|
+
// Generic Agent rows reveal and spring their measured growth, but do
|
|
219
|
+
// not continuously reserve space for a future Markdown line wrap.
|
|
220
|
+
// Keeping that assistant-only prediction here creates an idle gap
|
|
221
|
+
// above TurnStatus and a visible return when a short Tool row settles.
|
|
222
|
+
predictive={false}
|
|
223
|
+
hostRef={hostRef}
|
|
224
|
+
>
|
|
50
225
|
{createElement(Inner, props)}
|
|
51
226
|
</FollowHost>
|
|
52
227
|
)
|