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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-smooth-stream",
3
- "version": "0.3.2",
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
  }
@@ -223,6 +223,7 @@
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 {
@@ -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
- useEffect(() => {
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 reasoningOwnsSpeed = streaming && data.blocks[data.blocks.length - 1]?.kind === 'reasoning'
230
- useEffect(() => {
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
- }, [reasoningOwnsSpeed])
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 active={streaming} speedCpsRef={rootSpeedRef}>
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 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',