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.
@@ -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 is still growing: an assistant/workflow `status:
12
- * 'running'` payload, a Tool root that has not settled (`kind` absent), or a
13
- * model-retry whose current attempt is still `scheduled`.
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
- * Wrap a prior Chat node renderer so a growing row shares conversation
40
- * follow. Presentation stays with the wrapped component; kit seats
41
- * (`renderSlot`, locale, inject) pass through unchanged.
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 active={isGrowingChatNode(props.node)} speedCpsRef={speedCpsRef}>
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
  )
@@ -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
- /** Human-authored rows stay on the built-in renderer; `assistant-step` is replaced. */
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 `assistant-step` in place. A second
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 wrapGrowingChatRows(ctx: ClientContext): () => void {
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 (typeof current !== 'function' || wrapped.has(current)) continue
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 growing row is wrapped in place so Tool cards, retries, and
149
- * workflow runs share conversation follow. The Host-bridged configuration
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 = wrapGrowingChatRows(ctx)
219
+ const unwrap = wrapAgentChatRows(ctx)
203
220
  const unshadow = ctx.slots.register({
204
221
  name: 'conversation.chat.node',
205
222
  key: 'assistant-step',