dsh-smooth-stream 0.4.3 → 0.6.0

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/lib/index.js CHANGED
@@ -163,8 +163,9 @@ const STREAM_SETTINGS_NS = "smooth-stream";
163
163
  /** Defaults shared by the Host schema and the client-side fallback. */
164
164
  const DEFAULT_STREAM_SETTINGS = {
165
165
  enabled: true,
166
+ controlScroll: true,
167
+ motionPreference: "auto",
166
168
  thinkAutoExpand: true,
167
- autoCollapse: true,
168
169
  debugEnabled: false,
169
170
  debugTuning: {
170
171
  revealScale: 1,
@@ -199,8 +200,13 @@ const Config = Schema.object({
199
200
  */
200
201
  const StreamSettingsSchema = Schema.object({
201
202
  enabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.enabled),
203
+ controlScroll: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.controlScroll),
204
+ motionPreference: Schema.union([
205
+ Schema.const("auto"),
206
+ Schema.const("force-smooth"),
207
+ Schema.const("force-reduced")
208
+ ]).default(DEFAULT_STREAM_SETTINGS.motionPreference),
202
209
  thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
203
- autoCollapse: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.autoCollapse),
204
210
  debugEnabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.debugEnabled),
205
211
  debugTuning: Schema.object({
206
212
  revealScale: Schema.number().min(.25).max(2).default(DEFAULT_STREAM_SETTINGS.debugTuning.revealScale),
@@ -240,8 +246,9 @@ function apply(ctx, config) {
240
246
  installation: installation.kind,
241
247
  writable: connectionCtx.settings.writable,
242
248
  enabled: settings.enabled,
249
+ controlScroll: settings.controlScroll,
250
+ motionPreference: settings.motionPreference,
243
251
  thinkAutoExpand: settings.thinkAutoExpand,
244
- autoCollapse: settings.autoCollapse,
245
252
  canUpgrade: installation.kind === "npm"
246
253
  };
247
254
  };
@@ -263,11 +270,11 @@ function apply(ctx, config) {
263
270
  value: view()
264
271
  };
265
272
  if (endpoint === STREAM_SETTINGS_RPC.write) {
266
- if (typeof payload !== "object" || payload === null || Array.isArray(payload) || typeof payload.enabled !== "boolean" || typeof payload.thinkAutoExpand !== "boolean" || typeof payload.autoCollapse !== "boolean") return {
273
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload) || typeof payload.enabled !== "boolean" || typeof payload.controlScroll !== "boolean" || typeof payload.thinkAutoExpand !== "boolean") return {
267
274
  ok: false,
268
275
  error: {
269
276
  code: "settings-rejected",
270
- message: "enabled, thinkAutoExpand and autoCollapse must be booleans",
277
+ message: "enabled, controlScroll and thinkAutoExpand must be booleans",
271
278
  details: { ns: STREAM_SETTINGS_NS }
272
279
  }
273
280
  };
@@ -281,6 +288,14 @@ function apply(ctx, config) {
281
288
  };
282
289
  try {
283
290
  const next = payload;
291
+ if (next.motionPreference !== void 0 && next.motionPreference !== "auto" && next.motionPreference !== "force-smooth" && next.motionPreference !== "force-reduced") return {
292
+ ok: false,
293
+ error: {
294
+ code: "settings-rejected",
295
+ message: "motionPreference must be one of auto | force-smooth | force-reduced",
296
+ details: { ns: STREAM_SETTINGS_NS }
297
+ }
298
+ };
284
299
  const hasDebug = next.debugEnabled !== void 0 || next.debugTuning !== void 0;
285
300
  if (hasDebug && (typeof next.debugEnabled !== "boolean" || !validDebugTuning(next.debugTuning))) return {
286
301
  ok: false,
@@ -292,8 +307,9 @@ function apply(ctx, config) {
292
307
  };
293
308
  await scope.update({
294
309
  enabled: next.enabled,
310
+ controlScroll: next.controlScroll,
311
+ ...next.motionPreference === void 0 ? {} : { motionPreference: next.motionPreference },
295
312
  thinkAutoExpand: next.thinkAutoExpand,
296
- autoCollapse: next.autoCollapse,
297
313
  ...hasDebug ? {
298
314
  debugEnabled: next.debugEnabled,
299
315
  debugTuning: next.debugTuning
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-smooth-stream",
3
- "version": "0.4.3",
3
+ "version": "0.6.0",
4
4
  "description": "Fluid streaming rendering and silky scrolling for DeepSeek Harness replies, including Markdown, code blocks, tables, and tool results.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -81,12 +81,18 @@
81
81
  "scripts": {
82
82
  "benchmark": "node scripts/run-stream-benchmark.mjs",
83
83
  "build": "tsdown",
84
+ "dev:watch": "tsdown --watch",
85
+ "host:sync": "pnpm build && node scripts/check-lib-fresh.mjs",
84
86
  "build:repro": "esbuild repro/main.tsx --bundle --format=esm --platform=browser --loader:.css=local-css --alias:@deepseek-ai/dsh-client-runtime=./repro/shims/client-runtime.ts --alias:@deepseek-ai/dsh-client-runtime/client=./repro/shims/client-runtime.ts --alias:@deepseek-ai/dsh-client-ui-primitives=./repro/shims/primitives.tsx --outfile=repro/bundle.js",
87
+ "build:stress": "esbuild repro/stress-120fps.tsx --bundle --format=esm --platform=browser --loader:.css=local-css --alias:@deepseek-ai/dsh-client-runtime=./repro/shims/client-runtime.ts --alias:@deepseek-ai/dsh-client-runtime/client=./repro/shims/client-runtime.ts --alias:@deepseek-ai/dsh-client-ui-primitives=./repro/shims/primitives.tsx --outfile=repro/stress-120fps.bundle.js",
88
+ "stress:120fps": "node scripts/stress-test-120fps.mjs",
89
+ "test:rebound": "node scripts/verify-y-rebound.mjs",
85
90
  "prepare": "tsdown",
86
91
  "test": "vitest run",
87
92
  "typecheck": "tsc -p tsconfig.json --pretty false",
88
- "test:smooth": "node scripts/probe-tailbob.mjs 1200 20 && node scripts/verify-overflow.mjs --runs 3",
89
- "probe:overlap": "node scripts/probe-overlap.mjs"
93
+ "test:smooth": "node scripts/verify-y-rebound.mjs && node scripts/probe-tailbob.mjs 1200 20 && node scripts/verify-overflow.mjs --runs 3",
94
+ "probe:overlap": "node scripts/probe-overlap.mjs",
95
+ "probe:stability": "node scripts/verify-follow-stability.mjs"
90
96
  },
91
97
  "peerDependencies": {
92
98
  "@deepseek-ai/cordis": "^4.0.1",
@@ -139,12 +145,12 @@
139
145
  "devDependencies": {
140
146
  "@deepseek-ai/cordis": "4.0.1",
141
147
  "@deepseek-ai/dsh-client-connection": "0.1.0-rc.6",
142
- "@deepseek-ai/dsh-client-locale": "0.1.0-rc.6",
148
+ "@deepseek-ai/dsh-client-locale": "0.1.2-alpha.3",
143
149
  "@deepseek-ai/dsh-client-runtime": "0.1.0-rc.6",
144
150
  "@deepseek-ai/dsh-client-store": "0.1.2-alpha.3",
145
151
  "@deepseek-ai/dsh-client-ui-attachment": "0.1.0-rc.6",
146
152
  "@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.6",
147
- "@deepseek-ai/dsh-client-ui-primitives": "0.1.0-rc.6",
153
+ "@deepseek-ai/dsh-client-ui-primitives": "0.1.2-alpha.3",
148
154
  "@deepseek-ai/dsh-client-ui-settings": "0.1.0-rc.6",
149
155
  "@deepseek-ai/dsh-client-ui-settings-plugins": "0.1.0-rc.6",
150
156
  "@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.6",
@@ -0,0 +1,43 @@
1
+ /*
2
+ * Opaque Agent rows (tools, retries, context, etc.) cannot reuse the
3
+ * assistant's character queue. This is a single compositor-friendly reveal:
4
+ * the row arrives with a short rise/fade and a left-to-right mask, echoing the
5
+ * assistant's reading cadence without re-typing status text or countdowns.
6
+ */
7
+ .surface {
8
+ min-width: 0;
9
+ }
10
+
11
+ .surface[data-entrance='active'] {
12
+ animation: dsh-smooth-stream-agent-row-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
13
+ will-change: opacity, transform;
14
+ }
15
+
16
+ @keyframes dsh-smooth-stream-agent-row-in {
17
+ 0% {
18
+ opacity: 0;
19
+ transform: translate3d(0, 4px, 0);
20
+ clip-path: inset(0 100% 0 0);
21
+ }
22
+
23
+ 55% {
24
+ opacity: 1;
25
+ clip-path: inset(0 18% 0 0);
26
+ }
27
+
28
+ 100% {
29
+ opacity: 1;
30
+ transform: translate3d(0, 0, 0);
31
+ clip-path: inset(0 0 0 0);
32
+ }
33
+ }
34
+
35
+ @media (prefers-reduced-motion: reduce) {
36
+ .surface[data-entrance='active'] {
37
+ animation: none;
38
+ opacity: 1;
39
+ transform: none;
40
+ clip-path: none;
41
+ will-change: auto;
42
+ }
43
+ }
@@ -19,7 +19,10 @@ export function FollowHost({
19
19
  revealScaleRef,
20
20
  predictive = true,
21
21
  predictiveRef,
22
+ controlScroll = true,
22
23
  hostRef,
24
+ className,
25
+ entranceActive,
23
26
  children,
24
27
  }: {
25
28
  active: boolean
@@ -32,8 +35,13 @@ export function FollowHost({
32
35
  revealScaleRef?: { current: number } | undefined
33
36
  predictive?: boolean
34
37
  predictiveRef?: { current: boolean } | undefined
38
+ /** False leaves conversation bottom-follow to the Host. */
39
+ controlScroll?: boolean
35
40
  hostRef?: { current: HTMLDivElement | null } | undefined
36
41
  children: ReactNode
42
+ className?: string | undefined
43
+ /** Optional marker for a generic row's one-shot entrance animation. */
44
+ entranceActive?: boolean | undefined
37
45
  }) {
38
46
  const localRootRef = useRef<HTMLDivElement>(null)
39
47
  const rootRef = hostRef ?? localRootRef
@@ -48,6 +56,7 @@ export function FollowHost({
48
56
  predictiveRef,
49
57
  entranceExtentRef,
50
58
  revealedCharsRef,
59
+ controlScroll,
51
60
  )
52
61
  useEffect(() => {
53
62
  if (onGrowth === undefined || typeof ResizeObserver === 'undefined') return
@@ -79,5 +88,13 @@ export function FollowHost({
79
88
  if (growthFrame !== null) cancelAnimationFrame(growthFrame)
80
89
  }
81
90
  }, [onGrowth])
82
- return <div ref={rootRef} className={css.follow}>{children}</div>
91
+ return (
92
+ <div
93
+ ref={rootRef}
94
+ className={className === undefined ? css.follow : `${css.follow} ${className}`}
95
+ data-entrance={entranceActive === undefined ? undefined : entranceActive ? 'active' : 'idle'}
96
+ >
97
+ {children}
98
+ </div>
99
+ )
83
100
  }
@@ -157,6 +157,40 @@
157
157
  outline-offset: 2px;
158
158
  }
159
159
 
160
+ .choiceRow {
161
+ display: flex;
162
+ flex-wrap: wrap;
163
+ gap: 6px 14px;
164
+ margin: 2px 0 0;
165
+ padding: 0;
166
+ }
167
+
168
+ .choice {
169
+ display: flex;
170
+ align-items: center;
171
+ gap: 5px;
172
+ font-size: 12px;
173
+ line-height: 1.5;
174
+ color: var(--dsw-alias-label-secondary);
175
+ cursor: pointer;
176
+ }
177
+
178
+ .choiceInput {
179
+ width: 14px;
180
+ height: 14px;
181
+ accent-color: var(--dsw-alias-brand-primary);
182
+ cursor: pointer;
183
+ }
184
+
185
+ .choice:has(.choiceInput:disabled) {
186
+ cursor: default;
187
+ opacity: 0.55;
188
+ }
189
+
190
+ .choiceInput:disabled {
191
+ cursor: default;
192
+ }
193
+
160
194
  .hint {
161
195
  margin: 0;
162
196
  font-size: 12px;
@@ -73,29 +73,58 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
73
73
  </label>
74
74
  <label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
75
75
  <span className={css.fieldHead}>
76
- <span className={css.label}>{t('thinkAutoExpand')}</span>
76
+ <span className={css.label}>{t('controlScroll')}</span>
77
77
  <input
78
78
  type="checkbox"
79
79
  className={css.toggle}
80
- checked={state.thinkAutoExpand}
80
+ checked={state.controlScroll}
81
81
  disabled={!state.writable || state.saving || !state.enabled}
82
- onChange={(event) => { props.edit({ thinkAutoExpand: event.target.checked }) }}
82
+ onChange={(event) => { props.edit({ controlScroll: event.target.checked }) }}
83
83
  />
84
84
  </span>
85
- <span className={css.hint}>{t('thinkAutoExpandHint')}</span>
85
+ <span className={css.hint}>{t('controlScrollHint')}</span>
86
86
  </label>
87
- <label className={css.field}>
87
+ {/* Motion preference is a radio group, so this row is a div:
88
+ nesting the choice labels inside a field <label> would be
89
+ illegal HTML (label within label) and browsers route the
90
+ inner click away, so React never sees onChange. */}
91
+ <div className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
92
+ <span className={css.fieldHead}>
93
+ <span className={css.label}>{t('motionPreference')}</span>
94
+ </span>
95
+ <span className={css.hint}>{t('motionPreferenceHint')}</span>
96
+ <span className={css.choiceRow} role="radiogroup" aria-label={t('motionPreference')}>
97
+ {([
98
+ ['auto', 'motionAuto', 'motionAutoHint'],
99
+ ['force-smooth', 'motionForceSmooth', 'motionForceSmoothHint'],
100
+ ['force-reduced', 'motionForceReduced', 'motionForceReducedHint'],
101
+ ] as const).map(([value, label, hint]) => (
102
+ <label key={value} className={css.choice} title={t(hint)}>
103
+ <input
104
+ type="radio"
105
+ className={css.choiceInput}
106
+ name="smooth-stream-motion"
107
+ checked={state.motionPreference === value}
108
+ disabled={!state.writable || state.saving || !state.enabled}
109
+ onChange={() => { props.edit({ motionPreference: value }) }}
110
+ />
111
+ {t(label)}
112
+ </label>
113
+ ))}
114
+ </span>
115
+ </div>
116
+ <label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
88
117
  <span className={css.fieldHead}>
89
- <span className={css.label}>{t('autoCollapse')}</span>
118
+ <span className={css.label}>{t('thinkAutoExpand')}</span>
90
119
  <input
91
120
  type="checkbox"
92
121
  className={css.toggle}
93
- checked={state.autoCollapse}
94
- disabled={!state.writable || state.saving}
95
- onChange={(event) => { props.edit({ autoCollapse: event.target.checked }) }}
122
+ checked={state.thinkAutoExpand}
123
+ disabled={!state.writable || state.saving || !state.enabled}
124
+ onChange={(event) => { props.edit({ thinkAutoExpand: event.target.checked }) }}
96
125
  />
97
126
  </span>
98
- <span className={css.hint}>{t('autoCollapseHint')}</span>
127
+ <span className={css.hint}>{t('thinkAutoExpandHint')}</span>
99
128
  </label>
100
129
  <label className={state.debugAvailable ? css.field : `${css.field} ${css.fieldDisabled}`}>
101
130
  <span className={css.fieldHead}>
@@ -1,4 +1,4 @@
1
- import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'
1
+ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode, type RefObject } 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'
@@ -8,11 +8,11 @@ import { useSmoothStreamContent, type StreamSmoothingPreset } from './useSmoothS
8
8
  import { useFpsGuard } from './useFpsGuard.ts'
9
9
  import { FollowHost } from './FollowHost.tsx'
10
10
  import { DEFAULT_STREAM_CONFIG, type StreamMode } from '../config.ts'
11
- import { DEFAULT_STREAM_SETTINGS } from '../settings.ts'
11
+ import { DEFAULT_STREAM_SETTINGS, type StreamMotionPreference } from '../settings.ts'
12
12
  import css from './TypewriterAssistantNodeView.module.css'
13
13
 
14
14
  type AssistantProps = ChatNodeViewProps<'assistant-step'>
15
- type MarkdownProps = Pick<ComponentProps<typeof MarkdownText>, 'codeLabels' | 'fileMentions' | 'text'>
15
+ type MarkdownProps = Pick<ComponentProps<typeof MarkdownText>, 'labels' | 'fileMentions' | 'text'>
16
16
 
17
17
  function usePrefersReducedMotion(): boolean {
18
18
  const [reduced, setReduced] = useState(
@@ -28,8 +28,25 @@ function usePrefersReducedMotion(): boolean {
28
28
  return reduced
29
29
  }
30
30
 
31
+ /**
32
+ * Resolve whether the reveal engine should stay off for this view. The OS
33
+ * preference wins only in `auto` mode: `force-smooth` keeps the engine on
34
+ * machines where a system-wide reduce-motion switch (or a forced browser
35
+ * flag) would otherwise silently bypass smoothing, and `force-reduced`
36
+ * disables it even when the OS asks for motion. Credit: three-state design
37
+ * proposed by @Zn-Dk in #21/#22.
38
+ */
39
+ function useMotionReduced(preference: StreamMotionPreference): boolean {
40
+ const system = usePrefersReducedMotion()
41
+ if (preference === 'force-smooth') return false
42
+ if (preference === 'force-reduced') return true
43
+ return system
44
+ }
45
+
31
46
  interface AnimatedMarkdownTextProps extends MarkdownProps {
32
47
  streaming: boolean
48
+ /** Whether the resolved reduced-motion gate keeps the reveal engine off. */
49
+ motionReduced: boolean
33
50
  /** True on the last text block: that block owns conversation follow. */
34
51
  ownFollow: boolean
35
52
  followSpeedCpsRef?: { current: number } | undefined
@@ -38,6 +55,7 @@ interface AnimatedMarkdownTextProps extends MarkdownProps {
38
55
  onPredictiveChange?: ((predictive: boolean) => void) | undefined
39
56
  preset: StreamSmoothingPreset
40
57
  shouldHoldBack: () => boolean
58
+ controlScroll?: boolean
41
59
  }
42
60
 
43
61
  /** Conservative fallback before the streaming Markdown tail has geometry. */
@@ -286,9 +304,10 @@ const StreamAnnouncement = memo(function StreamAnnouncement({
286
304
  */
287
305
  function AnimatedMarkdownText({
288
306
  text,
289
- codeLabels,
307
+ labels,
290
308
  fileMentions,
291
309
  streaming,
310
+ motionReduced,
292
311
  ownFollow,
293
312
  followSpeedCpsRef,
294
313
  followRevealedCharsRef,
@@ -296,8 +315,9 @@ function AnimatedMarkdownText({
296
315
  onPredictiveChange,
297
316
  preset,
298
317
  shouldHoldBack,
318
+ controlScroll = true,
299
319
  }: AnimatedMarkdownTextProps) {
300
- const reduced = usePrefersReducedMotion()
320
+ const reduced = motionReduced
301
321
  const [typing, setTyping] = useState(streaming)
302
322
  const localSpeedCpsRef = useRef(35)
303
323
  const followRootRef = useRef<HTMLDivElement>(null)
@@ -348,6 +368,14 @@ function AnimatedMarkdownText({
348
368
  if (typing && !streaming && shown.length === text.length) setTyping(false)
349
369
  }, [shown, streaming, text, typing])
350
370
 
371
+ // A row can mount before the projection flips its assistant step to
372
+ // `running`, which would freeze `typing` at false forever and leave the
373
+ // reveal engine off for the whole reply. Re-arm on the rising edge so late
374
+ // stream starts are still smoothed. (Adopted from #22 by @Zn-Dk.)
375
+ useEffect(() => {
376
+ if (streaming) setTyping(true)
377
+ }, [streaming])
378
+
351
379
  return (
352
380
  <FollowHost
353
381
  active={live && ownFollow}
@@ -355,6 +383,7 @@ function AnimatedMarkdownText({
355
383
  revealedCharsRef={followRevealedCharsRef}
356
384
  revealScaleRef={followRevealScaleRef}
357
385
  predictive={streaming}
386
+ controlScroll={controlScroll}
358
387
  hostRef={followRootRef}
359
388
  >
360
389
  <MarkdownText
@@ -363,7 +392,7 @@ function AnimatedMarkdownText({
363
392
  // Rendering `text` early bypasses the drain and teleports the tail.
364
393
  text={live ? shown : text}
365
394
  streaming={live}
366
- codeLabels={codeLabels}
395
+ labels={labels}
367
396
  fileMentions={live ? undefined : fileMentions}
368
397
  />
369
398
  </FollowHost>
@@ -384,6 +413,61 @@ function imageLabels(t: AssistantProps['t']): MessageImageLabels {
384
413
  }
385
414
  }
386
415
 
416
+ /**
417
+ * Apply searchable hidden state without unmounting a stable subtree — the
418
+ * Host's completion fold hides the answer-inline reasoning this way, so the
419
+ * takeover renderer must reproduce it to stay inside the contract: the row
420
+ * disappears into the Host's process summary and comes back on find-in-page.
421
+ * (Mirrors the Harness chat kit's `useSearchableHidden`.)
422
+ */
423
+ function useSearchableHidden(
424
+ hidden: boolean,
425
+ reveal: () => void,
426
+ ): RefObject<HTMLDivElement> {
427
+ const ref = useRef<HTMLDivElement>(null)
428
+ useLayoutEffect(() => {
429
+ const element = ref.current
430
+ if (element === null) return
431
+ if (hidden && element.contains(element.ownerDocument.activeElement)) {
432
+ reveal()
433
+ return
434
+ }
435
+ if (hidden) element.setAttribute('hidden', 'until-found')
436
+ else element.removeAttribute('hidden')
437
+ }, [hidden, reveal])
438
+ useEffect(() => {
439
+ const element = ref.current
440
+ if (element === null) return
441
+ element.addEventListener('beforematch', reveal)
442
+ return () => { element.removeEventListener('beforematch', reveal) }
443
+ }, [reveal])
444
+ return ref
445
+ }
446
+
447
+ /**
448
+ * One answer-inline reasoning block wrapped in the Host's fold contract. The
449
+ * Host computes the fold decision (turn closed, compact transcript, this node
450
+ * is the answer) and delivers it through the `turnProcess` owner prop; when
451
+ * folded the block hides into the Host's "thought" summary row instead of
452
+ * staying mounted above the answer.
453
+ */
454
+ function FoldableReasoning({
455
+ hidden,
456
+ reveal,
457
+ children,
458
+ }: {
459
+ hidden: boolean
460
+ reveal: () => void
461
+ children: ReactNode
462
+ }) {
463
+ const ref = useSearchableHidden(hidden, reveal)
464
+ return (
465
+ <div ref={ref} data-turn-process-inline={hidden || undefined}>
466
+ {children}
467
+ </div>
468
+ )
469
+ }
470
+
387
471
  function firstLine(text: string): string {
388
472
  const newline = text.indexOf('\n')
389
473
  return newline === -1 ? text : text.slice(0, newline)
@@ -409,6 +493,7 @@ function AnimatedReasoning({
409
493
  running,
410
494
  preset,
411
495
  thinkAutoExpand,
496
+ motionReduced,
412
497
  shouldHoldBack,
413
498
  followSpeedCpsRef,
414
499
  followRevealScaleRef,
@@ -418,12 +503,13 @@ function AnimatedReasoning({
418
503
  running: boolean
419
504
  preset: StreamSmoothingPreset
420
505
  thinkAutoExpand: boolean
506
+ motionReduced: boolean
421
507
  shouldHoldBack: () => boolean
422
508
  followSpeedCpsRef?: { current: number } | undefined
423
509
  followRevealScaleRef?: { current: number } | undefined
424
510
  t: AssistantProps['t']
425
511
  }) {
426
- const reduced = usePrefersReducedMotion()
512
+ const reduced = motionReduced
427
513
  const [expanded, setExpanded] = useState(running && thinkAutoExpand)
428
514
  const [autoClosed, setAutoClosed] = useState(false)
429
515
  const summaryRef = useRef<HTMLSpanElement>(null)
@@ -514,11 +600,14 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
514
600
  scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec,
515
601
  maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec,
516
602
  thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
603
+ controlScroll = true,
604
+ motionPreference = DEFAULT_STREAM_SETTINGS.motionPreference,
517
605
  node,
518
606
  useTurnData,
519
607
  openFile,
520
608
  loadImage,
521
609
  fileMentions,
610
+ turnProcess,
522
611
  t,
523
612
  }: AssistantProps & {
524
613
  mode?: StreamMode
@@ -527,10 +616,22 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
527
616
  scrollSpeedPxPerSec?: number
528
617
  maxScrollSpeedPxPerSec?: number
529
618
  thinkAutoExpand?: boolean
619
+ controlScroll?: boolean
620
+ motionPreference?: StreamMotionPreference
530
621
  }) {
531
622
  const data = node.data
532
623
  const streaming = data.status === 'running'
533
- const reduced = usePrefersReducedMotion()
624
+ const reduced = useMotionReduced(motionPreference)
625
+ // The Host's completion-fold decision for THIS node's inline reasoning:
626
+ // only the answer step folds, only in compact-transcript mode, and only
627
+ // once the turn has closed (foldable implies it). Mirrors the built-in
628
+ // assistant renderer so the takeover keeps the official behavior.
629
+ const reasoningHidden = turnProcess !== undefined
630
+ && turnProcess.foldable
631
+ && turnProcess.spec.answerStep === data.step
632
+ && turnProcess.spec.inlineReasoning
633
+ && !turnProcess.open
634
+ const revealProcess = useCallback(() => { turnProcess?.setOpen(true) }, [turnProcess])
534
635
  const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
535
636
  const rootSpeedRef = useRef(35)
536
637
  const rootRevealedCharsRef = useRef(0)
@@ -568,7 +669,10 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
568
669
  () => owner === undefined ? undefined : fileMentions(owner),
569
670
  [fileMentions, owner],
570
671
  )
571
- const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
672
+ const markdownLabels = useMemo(() => ({
673
+ code: { copyLabel: t('copy'), copiedLabel: t('copied') },
674
+ footnotes: t('markdown.footnotes'),
675
+ }), [t])
572
676
  const imageLoader: ImageLoader = loadImage ?? (async () => {
573
677
  throw new Error(t('image.serviceUnavailable'))
574
678
  })
@@ -597,9 +701,10 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
597
701
  <AnimatedMarkdownText
598
702
  key={index}
599
703
  text={block.text}
600
- codeLabels={codeLabels}
704
+ labels={markdownLabels}
601
705
  fileMentions={mentions}
602
706
  streaming={streaming}
707
+ motionReduced={reduced}
603
708
  ownFollow={!streaming && index === lastFollow}
604
709
  followSpeedCpsRef={index === lastFollow ? rootSpeedRef : undefined}
605
710
  followRevealedCharsRef={index === lastFollow ? rootRevealedCharsRef : undefined}
@@ -607,22 +712,25 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
607
712
  onPredictiveChange={index === lastFollow ? updateTextPrediction : undefined}
608
713
  preset={preset}
609
714
  shouldHoldBack={shouldHoldBack}
715
+ controlScroll={controlScroll}
610
716
  />,
611
717
  )
612
718
  break
613
719
  case 'reasoning':
614
720
  rendered.push(
615
- <AnimatedReasoning
616
- key={index}
617
- text={block.text}
618
- running={streaming && index === last}
619
- preset={preset}
620
- thinkAutoExpand={thinkAutoExpand}
621
- shouldHoldBack={shouldHoldBack}
622
- followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
623
- followRevealScaleRef={reasoningOwnsSpeed && index === last ? rootRevealScaleRef : undefined}
624
- t={t}
625
- />,
721
+ <FoldableReasoning key={index} hidden={reasoningHidden} reveal={revealProcess}>
722
+ <AnimatedReasoning
723
+ text={block.text}
724
+ running={streaming && index === last}
725
+ preset={preset}
726
+ thinkAutoExpand={thinkAutoExpand}
727
+ motionReduced={reduced}
728
+ shouldHoldBack={shouldHoldBack}
729
+ followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
730
+ followRevealScaleRef={reasoningOwnsSpeed && index === last ? rootRevealScaleRef : undefined}
731
+ t={t}
732
+ />
733
+ </FoldableReasoning>,
626
734
  )
627
735
  break
628
736
  case 'image': {
@@ -663,6 +771,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
663
771
  revealedCharsRef={rootRevealedCharsRef}
664
772
  revealScaleRef={rootRevealScaleRef}
665
773
  predictiveRef={rootPredictiveRef}
774
+ controlScroll={controlScroll}
666
775
  >
667
776
  <div className={css.body}>
668
777
  {rendered}
@@ -2,6 +2,7 @@ import { createElement, useCallback, useEffect, useLayoutEffect, useRef, useStat
2
2
  import { FollowHost } from './FollowHost.tsx'
3
3
  import { useProgressiveDomText } from './useProgressiveDomText.ts'
4
4
  import { hasRecentConversationFollow } from './teleprompterGlide.ts'
5
+ import entranceCss from './AgentRowEntrance.module.css'
5
6
 
6
7
  /** Props forwarded through a follow wrap; extra kit seats pass through. */
7
8
  export type FollowWrapProps = {
@@ -118,8 +119,12 @@ function liveAgentTailMode(root: HTMLElement): 'turn' | 'handoff' | null {
118
119
  * @param Inner - The already-registered row component.
119
120
  * @returns A follow-hosted row.
120
121
  */
121
- export function wrapFollowNodeView(Inner: ComponentType<FollowWrapProps>) {
122
+ export function wrapFollowNodeView(
123
+ Inner: ComponentType<FollowWrapProps>,
124
+ useControlScroll?: () => boolean,
125
+ ) {
122
126
  return function TypewriterFollowNodeView(props: FollowWrapProps) {
127
+ const controlScroll = useControlScroll?.() ?? true
123
128
  const speedCpsRef = useRef(35)
124
129
  const hostRef = useRef<HTMLDivElement>(null)
125
130
  const growing = isGrowingChatNode(props.node)
@@ -215,12 +220,15 @@ export function wrapFollowNodeView(Inner: ComponentType<FollowWrapProps>) {
215
220
  onGrowth={followable ? onGrowth : undefined}
216
221
  entranceExtentRef={growthExtentRef}
217
222
  speedCpsRef={speedCpsRef}
223
+ controlScroll={controlScroll}
218
224
  // Generic Agent rows reveal and spring their measured growth, but do
219
225
  // not continuously reserve space for a future Markdown line wrap.
220
226
  // Keeping that assistant-only prediction here creates an idle gap
221
227
  // above TurnStatus and a visible return when a short Tool row settles.
222
228
  predictive={false}
223
229
  hostRef={hostRef}
230
+ className={entranceCss.surface}
231
+ entranceActive={entering || growthPulse}
224
232
  >
225
233
  {createElement(Inner, props)}
226
234
  </FollowHost>