dsh-smooth-stream 0.3.2 → 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 +1110 -349
- 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 +1 -0
- package/src/client/TypewriterAssistantNodeView.tsx +148 -18
- package/src/client/TypewriterToolNodeView.tsx +183 -8
- package/src/client/index.ts +25 -8
- package/src/client/teleprompterGlide.ts +753 -233
- 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
|
}
|
|
@@ -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 {
|
|
@@ -129,6 +223,8 @@ function AnimatedReasoning({
|
|
|
129
223
|
thinkAutoExpand,
|
|
130
224
|
shouldHoldBack,
|
|
131
225
|
followSpeedCpsRef,
|
|
226
|
+
followRevealScaleRef,
|
|
227
|
+
onExpandedChange,
|
|
132
228
|
t,
|
|
133
229
|
}: {
|
|
134
230
|
text: string
|
|
@@ -137,6 +233,8 @@ function AnimatedReasoning({
|
|
|
137
233
|
thinkAutoExpand: boolean
|
|
138
234
|
shouldHoldBack: () => boolean
|
|
139
235
|
followSpeedCpsRef?: { current: number } | undefined
|
|
236
|
+
followRevealScaleRef?: { current: number } | undefined
|
|
237
|
+
onExpandedChange?: ((expanded: boolean) => void) | undefined
|
|
140
238
|
t: AssistantProps['t']
|
|
141
239
|
}) {
|
|
142
240
|
const reduced = usePrefersReducedMotion()
|
|
@@ -147,16 +245,21 @@ function AnimatedReasoning({
|
|
|
147
245
|
preset,
|
|
148
246
|
shouldHoldBack,
|
|
149
247
|
speedCpsRef: followSpeedCpsRef,
|
|
248
|
+
revealScaleRef: followRevealScaleRef,
|
|
150
249
|
})
|
|
151
250
|
const shown = running && !reduced ? displayed : text
|
|
152
251
|
const summary = running ? latestLine(shown) : firstLine(text)
|
|
153
252
|
|
|
154
|
-
|
|
253
|
+
useLayoutEffect(() => {
|
|
155
254
|
// Only the running state owns disclosure while auto-expand is on; with it
|
|
156
255
|
// off, a manual toggle is never wrestled back by the stream.
|
|
157
256
|
if (thinkAutoExpand) setExpanded(running)
|
|
158
257
|
}, [running, thinkAutoExpand])
|
|
159
258
|
|
|
259
|
+
useLayoutEffect(() => {
|
|
260
|
+
onExpandedChange?.(expanded)
|
|
261
|
+
}, [expanded, onExpandedChange])
|
|
262
|
+
|
|
160
263
|
useEffect(() => {
|
|
161
264
|
const element = summaryRef.current
|
|
162
265
|
if (element === null) return
|
|
@@ -224,12 +327,29 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
224
327
|
}) {
|
|
225
328
|
const data = node.data
|
|
226
329
|
const streaming = data.status === 'running'
|
|
330
|
+
const reduced = usePrefersReducedMotion()
|
|
227
331
|
const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
|
|
228
332
|
const rootSpeedRef = useRef(35)
|
|
229
|
-
const
|
|
230
|
-
|
|
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
|
|
231
342
|
if (!reasoningOwnsSpeed) rootSpeedRef.current = 35
|
|
232
|
-
|
|
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
|
+
)
|
|
233
353
|
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
|
234
354
|
? node.location.turn
|
|
235
355
|
: undefined
|
|
@@ -273,6 +393,9 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
273
393
|
streaming={streaming}
|
|
274
394
|
announce={index === last}
|
|
275
395
|
ownFollow={!streaming && index === lastFollow}
|
|
396
|
+
followSpeedCpsRef={index === lastFollow ? rootSpeedRef : undefined}
|
|
397
|
+
followRevealScaleRef={index === lastFollow ? rootRevealScaleRef : undefined}
|
|
398
|
+
onPredictiveChange={index === lastFollow ? updateTextPrediction : undefined}
|
|
276
399
|
preset={preset}
|
|
277
400
|
shouldHoldBack={shouldHoldBack}
|
|
278
401
|
/>,
|
|
@@ -288,6 +411,8 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
288
411
|
thinkAutoExpand={thinkAutoExpand}
|
|
289
412
|
shouldHoldBack={shouldHoldBack}
|
|
290
413
|
followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
|
|
414
|
+
followRevealScaleRef={reasoningOwnsSpeed && index === last ? rootRevealScaleRef : undefined}
|
|
415
|
+
onExpandedChange={index === reasoningTailIndex ? updateReasoningExpanded : undefined}
|
|
291
416
|
t={t}
|
|
292
417
|
/>,
|
|
293
418
|
)
|
|
@@ -323,7 +448,12 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
323
448
|
|
|
324
449
|
return (
|
|
325
450
|
<div ref={guardRef} className={css.root} data-streaming={streaming || undefined}>
|
|
326
|
-
<FollowHost
|
|
451
|
+
<FollowHost
|
|
452
|
+
active={streaming && !reduced}
|
|
453
|
+
speedCpsRef={rootSpeedRef}
|
|
454
|
+
revealScaleRef={rootRevealScaleRef}
|
|
455
|
+
predictiveRef={rootPredictiveRef}
|
|
456
|
+
>
|
|
327
457
|
<div className={css.body}>
|
|
328
458
|
{rendered}
|
|
329
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
|
)
|
package/src/client/index.ts
CHANGED
|
@@ -28,9 +28,22 @@ type AssistantProps = ChatNodeViewProps<'assistant-step'>
|
|
|
28
28
|
const STREAM_MODES: readonly string[] = ['typewriter', 'teleprompter']
|
|
29
29
|
const STREAM_PRESETS: readonly string[] = ['realtime', 'balanced', 'silky']
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* The assistant renderer owns its own character queue and conversation
|
|
33
|
+
* follower, so wrapping it again would create two scroll owners. Human input
|
|
34
|
+
* stays immediate; every Agent-owned output renderer goes through the same
|
|
35
|
+
* generic follow boundary. This is deliberately keyed by the owner that
|
|
36
|
+
* provides the renderer, not by individual tool names, so new Context,
|
|
37
|
+
* Command, and Tool rows are covered automatically.
|
|
38
|
+
*/
|
|
32
39
|
const SKIP_WRAP = new Set(['assistant-step', 'user', 'steering', 'command-input'])
|
|
33
40
|
|
|
41
|
+
/** React function/class or an exotic component such as memo/forwardRef/lazy. */
|
|
42
|
+
function isWrappableComponent(value: unknown): value is ComponentType<FollowWrapProps> {
|
|
43
|
+
return typeof value === 'function'
|
|
44
|
+
|| (value !== null && typeof value === 'object' && '$$typeof' in value)
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
/**
|
|
35
48
|
* Read the Host-bridged boot config. The inline script is produced by this
|
|
36
49
|
* plugin's Host half from a schema-validated value, so only the structural
|
|
@@ -60,16 +73,19 @@ function readBootConfig(): StreamConfig {
|
|
|
60
73
|
}
|
|
61
74
|
|
|
62
75
|
/**
|
|
63
|
-
* Wrap every keyed Chat row except
|
|
76
|
+
* Wrap every Agent-owned keyed Chat row except the assistant renderer in
|
|
77
|
+
* place. A second
|
|
64
78
|
* register with the same `children` table throws because the child slot is
|
|
65
79
|
* already declared, and only the winning entry receives `renderSlot`;
|
|
66
80
|
* swapping `entry.component` keeps the original children, locale, and inject
|
|
67
81
|
* seats. `assistant-step` is replaced below so text and Think use the
|
|
68
|
-
* typewriter reveal.
|
|
82
|
+
* typewriter reveal. The wrapper owns only the shared layout-growth/follow
|
|
83
|
+
* lifecycle; the Harness keeps each renderer's controls, disclosures, and
|
|
84
|
+
* cards intact.
|
|
69
85
|
* @param ctx - Browser context carrying the slot registry.
|
|
70
86
|
* @returns Restorer that puts the original components back.
|
|
71
87
|
*/
|
|
72
|
-
function
|
|
88
|
+
function wrapAgentChatRows(ctx: ClientContext): () => void {
|
|
73
89
|
const restores: Array<() => void> = []
|
|
74
90
|
const wrapped = new WeakSet<object>()
|
|
75
91
|
|
|
@@ -78,7 +94,7 @@ function wrapGrowingChatRows(ctx: ClientContext): () => void {
|
|
|
78
94
|
const key = entry.options.key
|
|
79
95
|
if (key === undefined || SKIP_WRAP.has(key)) continue
|
|
80
96
|
const current = entry.component
|
|
81
|
-
if (
|
|
97
|
+
if (!isWrappableComponent(current) || wrapped.has(current)) continue
|
|
82
98
|
const inner = current as ComponentType<FollowWrapProps>
|
|
83
99
|
const next = wrapFollowNodeView(inner)
|
|
84
100
|
wrapped.add(next)
|
|
@@ -145,8 +161,9 @@ class PreferenceCell {
|
|
|
145
161
|
/**
|
|
146
162
|
* Register the typewriter renderer after the conversation package declares the
|
|
147
163
|
* keyed Chat node seat. A lower priority shadows the built-in assistant row;
|
|
148
|
-
* every other
|
|
149
|
-
* workflow runs share
|
|
164
|
+
* every other keyed renderer is wrapped in place so Context, commands, Tool
|
|
165
|
+
* cards, retries, and workflow runs share one extensible follow boundary. The
|
|
166
|
+
* Host-bridged configuration
|
|
150
167
|
* selects the render direction, smoothing preset, and glide speed; the
|
|
151
168
|
* plugin-owned settings RPC supplies the live auto-expand preference when the
|
|
152
169
|
* settings surface is composed.
|
|
@@ -199,7 +216,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
199
216
|
})
|
|
200
217
|
}
|
|
201
218
|
ctx.slots.inject('conversation.chat.node', () => {
|
|
202
|
-
const unwrap =
|
|
219
|
+
const unwrap = wrapAgentChatRows(ctx)
|
|
203
220
|
const unshadow = ctx.slots.register({
|
|
204
221
|
name: 'conversation.chat.node',
|
|
205
222
|
key: 'assistant-step',
|