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
|
@@ -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',
|