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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-smooth-stream",
3
- "version": "0.3.1",
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 growing Chat row.
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 rootRef = useRef<HTMLDivElement>(null)
19
- useConversationFollow(rootRef, active, speedCpsRef)
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 last text block owns the
222
- * conversation scrollport's bottom-follow while the reply reveals
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 speedCpsRef = useRef(35)
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
- if (live) {
79
- return (
80
- <>
81
- {announce && <span className={css.visuallyHidden} aria-live="polite">{text}</span>}
82
- <FollowHost active={ownFollow} speedCpsRef={speedCpsRef}>
83
- <MarkdownText text={shown} streaming codeLabels={codeLabels} />
84
- </FollowHost>
85
- </>
86
- )
87
- }
88
- return <MarkdownText text={text} codeLabels={codeLabels} fileMentions={fileMentions} />
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
- useEffect(() => {
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
- <FollowHost active={running} speedCpsRef={speedCpsRef}>
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
- </FollowHost>
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
- * last text or reasoning block owns conversation-port follow so wraps
198
- * glide. The FPS guard holds offscreen
199
- * reveals when the frame rate is degraded. Settled text renders with the
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 active={streaming} speedCpsRef={rootSpeedRef}>
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 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
  )