dsh-smooth-stream 0.5.0 → 0.6.1

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,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'
@@ -6,9 +6,10 @@ import { AnimatedDisclosure } from './AnimatedDisclosure.tsx'
6
6
  import { notifyFollowCommit } from './teleprompterGlide.ts'
7
7
  import { useSmoothStreamContent, type StreamSmoothingPreset } from './useSmoothStreamContent.ts'
8
8
  import { useFpsGuard } from './useFpsGuard.ts'
9
+ import { useLogarithmicFade } from './useLogarithmicFade.ts'
9
10
  import { FollowHost } from './FollowHost.tsx'
10
11
  import { DEFAULT_STREAM_CONFIG, type StreamMode } from '../config.ts'
11
- import { DEFAULT_STREAM_SETTINGS } from '../settings.ts'
12
+ import { DEFAULT_STREAM_SETTINGS, type StreamMotionPreference } from '../settings.ts'
12
13
  import css from './TypewriterAssistantNodeView.module.css'
13
14
 
14
15
  type AssistantProps = ChatNodeViewProps<'assistant-step'>
@@ -28,8 +29,26 @@ function usePrefersReducedMotion(): boolean {
28
29
  return reduced
29
30
  }
30
31
 
32
+ /**
33
+ * Resolve whether the reveal engine should stay off for this view. The OS
34
+ * preference wins only in `auto` mode: `force-smooth` keeps the engine on
35
+ * machines where a system-wide reduce-motion switch (or a forced browser
36
+ * flag) would otherwise silently bypass smoothing, and `force-reduced`
37
+ * disables it even when the OS asks for motion. Credit: three-state design
38
+ * proposed by @Zn-Dk in #21/#22.
39
+ */
40
+ function useMotionReduced(preference: StreamMotionPreference): boolean {
41
+ const system = usePrefersReducedMotion()
42
+ if (preference === 'force-smooth') return false
43
+ if (preference === 'force-reduced') return true
44
+ return system
45
+ }
46
+
31
47
  interface AnimatedMarkdownTextProps extends MarkdownProps {
32
48
  streaming: boolean
49
+ logarithmicFade: boolean
50
+ /** Whether the resolved reduced-motion gate keeps the reveal engine off. */
51
+ motionReduced: boolean
33
52
  /** True on the last text block: that block owns conversation follow. */
34
53
  ownFollow: boolean
35
54
  followSpeedCpsRef?: { current: number } | undefined
@@ -290,6 +309,8 @@ function AnimatedMarkdownText({
290
309
  labels,
291
310
  fileMentions,
292
311
  streaming,
312
+ logarithmicFade,
313
+ motionReduced,
293
314
  ownFollow,
294
315
  followSpeedCpsRef,
295
316
  followRevealedCharsRef,
@@ -299,7 +320,7 @@ function AnimatedMarkdownText({
299
320
  shouldHoldBack,
300
321
  controlScroll = true,
301
322
  }: AnimatedMarkdownTextProps) {
302
- const reduced = usePrefersReducedMotion()
323
+ const reduced = motionReduced
303
324
  const [typing, setTyping] = useState(streaming)
304
325
  const localSpeedCpsRef = useRef(35)
305
326
  const followRootRef = useRef<HTMLDivElement>(null)
@@ -319,6 +340,7 @@ function AnimatedMarkdownText({
319
340
  })
320
341
  const shown = reduced ? text : displayed
321
342
  const live = typing && !reduced
343
+ useLogarithmicFade(followRootRef, logarithmicFade && !reduced, live, speedCpsRef)
322
344
 
323
345
  useEffect(() => {
324
346
  const root = followRootRef.current
@@ -350,6 +372,14 @@ function AnimatedMarkdownText({
350
372
  if (typing && !streaming && shown.length === text.length) setTyping(false)
351
373
  }, [shown, streaming, text, typing])
352
374
 
375
+ // A row can mount before the projection flips its assistant step to
376
+ // `running`, which would freeze `typing` at false forever and leave the
377
+ // reveal engine off for the whole reply. Re-arm on the rising edge so late
378
+ // stream starts are still smoothed. (Adopted from #22 by @Zn-Dk.)
379
+ useEffect(() => {
380
+ if (streaming) setTyping(true)
381
+ }, [streaming])
382
+
353
383
  return (
354
384
  <FollowHost
355
385
  active={live && ownFollow}
@@ -387,6 +417,61 @@ function imageLabels(t: AssistantProps['t']): MessageImageLabels {
387
417
  }
388
418
  }
389
419
 
420
+ /**
421
+ * Apply searchable hidden state without unmounting a stable subtree — the
422
+ * Host's completion fold hides the answer-inline reasoning this way, so the
423
+ * takeover renderer must reproduce it to stay inside the contract: the row
424
+ * disappears into the Host's process summary and comes back on find-in-page.
425
+ * (Mirrors the Harness chat kit's `useSearchableHidden`.)
426
+ */
427
+ function useSearchableHidden(
428
+ hidden: boolean,
429
+ reveal: () => void,
430
+ ): RefObject<HTMLDivElement> {
431
+ const ref = useRef<HTMLDivElement>(null)
432
+ useLayoutEffect(() => {
433
+ const element = ref.current
434
+ if (element === null) return
435
+ if (hidden && element.contains(element.ownerDocument.activeElement)) {
436
+ reveal()
437
+ return
438
+ }
439
+ if (hidden) element.setAttribute('hidden', 'until-found')
440
+ else element.removeAttribute('hidden')
441
+ }, [hidden, reveal])
442
+ useEffect(() => {
443
+ const element = ref.current
444
+ if (element === null) return
445
+ element.addEventListener('beforematch', reveal)
446
+ return () => { element.removeEventListener('beforematch', reveal) }
447
+ }, [reveal])
448
+ return ref
449
+ }
450
+
451
+ /**
452
+ * One answer-inline reasoning block wrapped in the Host's fold contract. The
453
+ * Host computes the fold decision (turn closed, compact transcript, this node
454
+ * is the answer) and delivers it through the `turnProcess` owner prop; when
455
+ * folded the block hides into the Host's "thought" summary row instead of
456
+ * staying mounted above the answer.
457
+ */
458
+ function FoldableReasoning({
459
+ hidden,
460
+ reveal,
461
+ children,
462
+ }: {
463
+ hidden: boolean
464
+ reveal: () => void
465
+ children: ReactNode
466
+ }) {
467
+ const ref = useSearchableHidden(hidden, reveal)
468
+ return (
469
+ <div ref={ref} data-turn-process-inline={hidden || undefined}>
470
+ {children}
471
+ </div>
472
+ )
473
+ }
474
+
390
475
  function firstLine(text: string): string {
391
476
  const newline = text.indexOf('\n')
392
477
  return newline === -1 ? text : text.slice(0, newline)
@@ -412,6 +497,8 @@ function AnimatedReasoning({
412
497
  running,
413
498
  preset,
414
499
  thinkAutoExpand,
500
+ motionReduced,
501
+ logarithmicFade,
415
502
  shouldHoldBack,
416
503
  followSpeedCpsRef,
417
504
  followRevealScaleRef,
@@ -421,15 +508,20 @@ function AnimatedReasoning({
421
508
  running: boolean
422
509
  preset: StreamSmoothingPreset
423
510
  thinkAutoExpand: boolean
511
+ motionReduced: boolean
512
+ logarithmicFade: boolean
424
513
  shouldHoldBack: () => boolean
425
514
  followSpeedCpsRef?: { current: number } | undefined
426
515
  followRevealScaleRef?: { current: number } | undefined
427
516
  t: AssistantProps['t']
428
517
  }) {
429
- const reduced = usePrefersReducedMotion()
518
+ const reduced = motionReduced
430
519
  const [expanded, setExpanded] = useState(running && thinkAutoExpand)
431
520
  const [autoClosed, setAutoClosed] = useState(false)
432
521
  const summaryRef = useRef<HTMLSpanElement>(null)
522
+ const fadeRootRef = useRef<HTMLDivElement>(null)
523
+ const localFadeSpeedRef = useRef(35)
524
+ const fadeSpeedRef = followSpeedCpsRef ?? localFadeSpeedRef
433
525
  const commitAnchorRef = useRef<HTMLDivElement>(null)
434
526
  // The running→false flip is the AUTO-close: it collapses instantly and the
435
527
  // follower's settle spring absorbs the height step. A later manual toggle
@@ -438,12 +530,13 @@ function AnimatedReasoning({
438
530
  enabled: running && !reduced,
439
531
  preset,
440
532
  shouldHoldBack,
441
- speedCpsRef: followSpeedCpsRef,
533
+ speedCpsRef: fadeSpeedRef,
442
534
  revealScaleRef: followRevealScaleRef,
443
535
  onRevealCommit: () => { notifyFollowCommit(commitAnchorRef.current) },
444
536
  })
445
537
  const shown = running && !reduced ? displayed : text
446
538
  const summary = running ? latestLine(shown) : firstLine(text)
539
+ useLogarithmicFade(fadeRootRef, logarithmicFade && !reduced && expanded, running, fadeSpeedRef)
447
540
 
448
541
  useLayoutEffect(() => {
449
542
  // Only the running state owns disclosure while auto-expand is on; with it
@@ -493,7 +586,7 @@ function AnimatedReasoning({
493
586
  </>
494
587
  )}
495
588
  >
496
- <div className={css.thinkBody}>{shown}</div>
589
+ <div ref={fadeRootRef} className={css.thinkBody}>{shown}</div>
497
590
  </AnimatedDisclosure>
498
591
  </div>
499
592
  </div>
@@ -517,12 +610,15 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
517
610
  scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec,
518
611
  maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec,
519
612
  thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
613
+ logarithmicFade = DEFAULT_STREAM_SETTINGS.logarithmicFade,
520
614
  controlScroll = true,
615
+ motionPreference = DEFAULT_STREAM_SETTINGS.motionPreference,
521
616
  node,
522
617
  useTurnData,
523
618
  openFile,
524
619
  loadImage,
525
620
  fileMentions,
621
+ turnProcess,
526
622
  t,
527
623
  }: AssistantProps & {
528
624
  mode?: StreamMode
@@ -531,11 +627,23 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
531
627
  scrollSpeedPxPerSec?: number
532
628
  maxScrollSpeedPxPerSec?: number
533
629
  thinkAutoExpand?: boolean
630
+ logarithmicFade?: boolean
534
631
  controlScroll?: boolean
632
+ motionPreference?: StreamMotionPreference
535
633
  }) {
536
634
  const data = node.data
537
635
  const streaming = data.status === 'running'
538
- const reduced = usePrefersReducedMotion()
636
+ const reduced = useMotionReduced(motionPreference)
637
+ // The Host's completion-fold decision for THIS node's inline reasoning:
638
+ // only the answer step folds, only in compact-transcript mode, and only
639
+ // once the turn has closed (foldable implies it). Mirrors the built-in
640
+ // assistant renderer so the takeover keeps the official behavior.
641
+ const reasoningHidden = turnProcess !== undefined
642
+ && turnProcess.foldable
643
+ && turnProcess.spec.answerStep === data.step
644
+ && turnProcess.spec.inlineReasoning
645
+ && !turnProcess.open
646
+ const revealProcess = useCallback(() => { turnProcess?.setOpen(true) }, [turnProcess])
539
647
  const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
540
648
  const rootSpeedRef = useRef(35)
541
649
  const rootRevealedCharsRef = useRef(0)
@@ -608,6 +716,8 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
608
716
  labels={markdownLabels}
609
717
  fileMentions={mentions}
610
718
  streaming={streaming}
719
+ logarithmicFade={logarithmicFade && data.status !== 'interrupted'}
720
+ motionReduced={reduced}
611
721
  ownFollow={!streaming && index === lastFollow}
612
722
  followSpeedCpsRef={index === lastFollow ? rootSpeedRef : undefined}
613
723
  followRevealedCharsRef={index === lastFollow ? rootRevealedCharsRef : undefined}
@@ -621,17 +731,20 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
621
731
  break
622
732
  case 'reasoning':
623
733
  rendered.push(
624
- <AnimatedReasoning
625
- key={index}
626
- text={block.text}
627
- running={streaming && index === last}
628
- preset={preset}
629
- thinkAutoExpand={thinkAutoExpand}
630
- shouldHoldBack={shouldHoldBack}
631
- followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
632
- followRevealScaleRef={reasoningOwnsSpeed && index === last ? rootRevealScaleRef : undefined}
633
- t={t}
634
- />,
734
+ <FoldableReasoning key={index} hidden={reasoningHidden} reveal={revealProcess}>
735
+ <AnimatedReasoning
736
+ text={block.text}
737
+ running={streaming && index === last}
738
+ preset={preset}
739
+ thinkAutoExpand={thinkAutoExpand}
740
+ logarithmicFade={logarithmicFade && data.status !== 'interrupted'}
741
+ motionReduced={reduced}
742
+ shouldHoldBack={shouldHoldBack}
743
+ followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
744
+ followRevealScaleRef={reasoningOwnsSpeed && index === last ? rootRevealScaleRef : undefined}
745
+ t={t}
746
+ />
747
+ </FoldableReasoning>,
635
748
  )
636
749
  break
637
750
  case 'image': {
@@ -157,7 +157,9 @@ class SettingsCell {
157
157
  pending === this.pending
158
158
  && next.enabled === this.value.enabled
159
159
  && next.controlScroll === this.value.controlScroll
160
+ && next.motionPreference === this.value.motionPreference
160
161
  && next.thinkAutoExpand === this.value.thinkAutoExpand
162
+ && next.logarithmicFade === this.value.logarithmicFade
161
163
  && next.debugEnabled === this.value.debugEnabled
162
164
  && next.debugTuning === this.value.debugTuning
163
165
  ) return
@@ -269,7 +271,9 @@ export function apply(ctx: ClientContext): void {
269
271
  scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
270
272
  maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
271
273
  thinkAutoExpand: preferences.thinkAutoExpand,
274
+ logarithmicFade: preferences.logarithmicFade,
272
275
  controlScroll: preferences.controlScroll,
276
+ motionPreference: preferences.motionPreference,
273
277
  })
274
278
  }
275
279
  ctx.slots.inject('conversation.chat.node', () => {
@@ -8,7 +8,12 @@ export type SmoothStreamLocaleKey =
8
8
  | 'title' | 'description'
9
9
  | 'enabled' | 'enabledHint'
10
10
  | 'controlScroll' | 'controlScrollHint'
11
+ | 'motionPreference' | 'motionPreferenceHint'
12
+ | 'motionAuto' | 'motionAutoHint'
13
+ | 'motionForceSmooth' | 'motionForceSmoothHint'
14
+ | 'motionForceReduced' | 'motionForceReducedHint'
11
15
  | 'thinkAutoExpand' | 'thinkAutoExpandHint'
16
+ | 'logarithmicFade' | 'logarithmicFadeHint'
12
17
  | 'debugEnabled' | 'debugEnabledHint' | 'debugUnavailable'
13
18
  | 'debugPanelTitle' | 'debugPanelToggle' | 'debugPanelClose' | 'debugGuide'
14
19
  | 'debugLive' | 'debugIdle' | 'debugUnsaved'
@@ -37,7 +42,17 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
37
42
  enabledHint: 'Let this plugin render and follow streaming replies. Turn off to use the built-in Harness renderer.',
38
43
  controlScroll: 'Take over scrolling',
39
44
  controlScrollHint: 'On by default; smooth-stream writes the conversation scroll position. Turn off to leave bottom-follow to Harness.',
45
+ motionPreference: 'Motion',
46
+ motionPreferenceHint: 'How streaming responds to the system reduce-motion setting.',
47
+ motionAuto: 'Follow system',
48
+ motionAutoHint: 'Reduced-motion systems get raw text (accessibility first).',
49
+ motionForceSmooth: 'Always smooth',
50
+ motionForceSmoothHint: 'Keep the smoothing engine even when the system asks for reduced motion.',
51
+ motionForceReduced: 'Always raw',
52
+ motionForceReducedHint: 'Render raw text even when the system allows motion.',
40
53
  thinkAutoExpand: 'Auto-expand thinking',
54
+ logarithmicFade: 'Logarithmic fade',
55
+ logarithmicFadeHint: 'Fade new answer and expanded thinking text into view. Follows the motion preference.',
41
56
  thinkAutoExpandHint: 'Open the thinking block while it streams. Turn off to keep it collapsed.',
42
57
  debugEnabled: 'Show render diagnostics',
43
58
  debugEnabledHint: 'Show live streaming and scroll metrics on the right side of the chat. Tune values there, then save them here.',
@@ -117,7 +132,17 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
117
132
  enabledHint: '由本插件渲染并跟随流式回复;关闭后使用 Harness 内置渲染。',
118
133
  controlScroll: '接管滚动',
119
134
  controlScrollHint: '默认开启,由丝滑流式写入会话滚动位置;关闭后贴底滚动交给 Harness。',
135
+ motionPreference: '动效偏好',
136
+ motionPreferenceHint: '流式渲染如何响应系统的「减少动态效果」设置。',
137
+ motionAuto: '跟随系统',
138
+ motionAutoHint: '系统开启减少动态效果时直接呈现原始文本(优先无障碍)。',
139
+ motionForceSmooth: '始终平滑',
140
+ motionForceSmoothHint: '即使系统要求减少动态效果,仍保持平滑流式渲染。',
141
+ motionForceReduced: '始终原始',
142
+ motionForceReducedHint: '即使系统允许动效,也直接呈现原始文本。',
120
143
  thinkAutoExpand: '自动展开思考',
144
+ logarithmicFade: '对数淡入',
145
+ logarithmicFadeHint: '让回答正文和展开的思考文字由淡变实,遵循动效偏好。',
121
146
  thinkAutoExpandHint: '思考块在流式时自动展开;关闭后保持折叠,可手动展开。',
122
147
  debugEnabled: '显示渲染调试面板',
123
148
  debugEnabledHint: '在聊天右侧显示流式渲染和滚动的实时参数,可在面板中调节并在这里保存。',
@@ -5,6 +5,7 @@ import {
5
5
  DEFAULT_STREAM_DEBUG_TUNING,
6
6
  DEFAULT_STREAM_SETTINGS,
7
7
  type StreamDebugTuning,
8
+ type StreamMotionPreference,
8
9
  type StreamSettings,
9
10
  } from '../settings.ts'
10
11
  import type {
@@ -23,7 +24,9 @@ export interface SmoothStreamCardState {
23
24
  failed: boolean
24
25
  enabled: boolean
25
26
  controlScroll: boolean
27
+ motionPreference: StreamMotionPreference
26
28
  thinkAutoExpand: boolean
29
+ logarithmicFade: boolean
27
30
  debugEnabled: boolean
28
31
  debugTuning: StreamDebugTuning
29
32
  debugAvailable: boolean
@@ -52,7 +55,7 @@ export class SmoothStreamCardController {
52
55
  private readonly store = createSnapshotStore<SmoothStreamCardState>(this.projection())
53
56
  private loaded: StreamSettingsView | undefined
54
57
  private loadedDebug: StreamDebugSettingsView | undefined
55
- private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> | undefined
58
+ private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> | undefined
56
59
  private stagedDebug: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'> | undefined
57
60
  private saving = false
58
61
  private failed = false
@@ -90,12 +93,14 @@ export class SmoothStreamCardController {
90
93
  hooks: { smoothStreamCard: this.store },
91
94
  edit: (patch) => {
92
95
  if (this.saving) return
93
- if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.thinkAutoExpand !== undefined) {
96
+ if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.motionPreference !== undefined || patch.thinkAutoExpand !== undefined || patch.logarithmicFade !== undefined) {
94
97
  this.stagedBase = {
95
98
  ...this.baseValues(),
96
99
  ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
97
100
  ...(patch.controlScroll === undefined ? {} : { controlScroll: patch.controlScroll }),
101
+ ...(patch.motionPreference === undefined ? {} : { motionPreference: patch.motionPreference }),
98
102
  ...(patch.thinkAutoExpand === undefined ? {} : { thinkAutoExpand: patch.thinkAutoExpand }),
103
+ ...(patch.logarithmicFade === undefined ? {} : { logarithmicFade: patch.logarithmicFade }),
99
104
  }
100
105
  }
101
106
  if (this.loadedDebug !== undefined && (patch.debugEnabled !== undefined || patch.debugTuning !== undefined)) {
@@ -140,11 +145,13 @@ export class SmoothStreamCardController {
140
145
  }
141
146
  }
142
147
 
143
- private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> {
148
+ private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> {
144
149
  return this.stagedBase ?? {
145
150
  enabled: this.loaded?.enabled ?? DEFAULT_STREAM_SETTINGS.enabled,
146
151
  controlScroll: this.loaded?.controlScroll ?? DEFAULT_STREAM_SETTINGS.controlScroll,
152
+ motionPreference: this.loaded?.motionPreference ?? DEFAULT_STREAM_SETTINGS.motionPreference,
147
153
  thinkAutoExpand: this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
154
+ logarithmicFade: this.loaded?.logarithmicFade ?? DEFAULT_STREAM_SETTINGS.logarithmicFade,
148
155
  }
149
156
  }
150
157
 
@@ -24,11 +24,13 @@ function settingsView(value: unknown): StreamSettingsView {
24
24
  || typeof data.writable !== 'boolean'
25
25
  || typeof data.enabled !== 'boolean'
26
26
  || typeof data.controlScroll !== 'boolean'
27
+ || !['auto', 'force-smooth', 'force-reduced'].includes(data.motionPreference as string)
27
28
  || typeof data.thinkAutoExpand !== 'boolean'
29
+ || (data.logarithmicFade !== undefined && typeof data.logarithmicFade !== 'boolean')
28
30
  || typeof data.canUpgrade !== 'boolean') {
29
31
  throw new Error('dsh-smooth-stream: malformed settings response')
30
32
  }
31
- return data as unknown as StreamSettingsView
33
+ return { ...data, logarithmicFade: data.logarithmicFade ?? true } as unknown as StreamSettingsView
32
34
  }
33
35
 
34
36
  function upgradeView(value: unknown): StreamUpgradeView {
@@ -68,7 +70,7 @@ function accepted(result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>):
68
70
  /** Narrow client contract consumed by the staged settings-card controller. */
69
71
  export interface SmoothStreamSettingsApi {
70
72
  read(): Promise<StreamSettingsView>
71
- write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView>
73
+ write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView>
72
74
  readDebug(): Promise<StreamDebugSettingsView>
73
75
  writeDebug(settings: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>): Promise<StreamDebugSettingsView>
74
76
  upgrade(): Promise<StreamUpgradeView>
@@ -80,14 +82,16 @@ export function createSmoothStreamSettingsApi(connection: ConnectionHandle): Smo
80
82
  async read(): Promise<StreamSettingsView> {
81
83
  return settingsView(accepted(await connection.rpc.call(STREAM_SETTINGS_RPC_CHANNEL, STREAM_SETTINGS_RPC.read, {})))
82
84
  },
83
- async write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView> {
85
+ async write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView> {
84
86
  return settingsView(accepted(await connection.rpc.call(
85
87
  STREAM_SETTINGS_RPC_CHANNEL,
86
88
  STREAM_SETTINGS_RPC.write,
87
89
  {
88
90
  enabled: settings.enabled,
89
91
  controlScroll: settings.controlScroll,
92
+ motionPreference: settings.motionPreference,
90
93
  thinkAutoExpand: settings.thinkAutoExpand,
94
+ logarithmicFade: settings.logarithmicFade,
91
95
  ...(settings.debugEnabled === undefined || settings.debugTuning === undefined
92
96
  ? {}
93
97
  : { debugEnabled: settings.debugEnabled, debugTuning: settings.debugTuning }),
@@ -780,8 +780,9 @@ function measureReadingAnchor(port: HTMLElement): { anchor: HTMLElement; index:
780
780
  return null
781
781
  }
782
782
  // Host-caused motion only: the engine's own shift glide and floor changes
783
- // cancel out (renderedΔ = topΔ − shiftΔ + floorΔ).
784
- const delta = (top - stored.top) - (shift - stored.shift) + (pad - stored.pad)
783
+ // cancel out (renderedΔ = topΔ + scrollΔ − shiftΔ + padΔ).
784
+ const scrollDelta = scrollTop - stored.scrollTop
785
+ const delta = (top - stored.top) + scrollDelta - (shift - stored.shift) + (pad - stored.pad)
785
786
  if (stored.element === anchor) {
786
787
  // Refresh the baseline to the current rendered position on every
787
788
  // measurement: between guard passes the viewport legitimately moves
@@ -2492,25 +2493,14 @@ export function useConversationFollow(
2492
2493
  // measure the extent against it):
2493
2494
  let settleQuietMs = 0
2494
2495
  let settleSig = ''
2495
- const lagBeforeCompletionPaint = Math.max(
2496
- 0,
2497
- host.scrollHeight - animatedH - runwayOffsetOf(host),
2498
- )
2499
- const currentRunway = runwayOffsetOf(host)
2500
- const currentShift = Math.abs(currentShiftOf(shiftSurfacesOf(host).at(-1) ?? host))
2501
- if (
2502
- !activeRef.current
2503
- && lagBeforeCompletionPaint <= FOLLOW_SLACK_PX
2504
- && currentRunway <= FOLLOW_SETTLE_EPSILON_PX
2505
- && currentShift <= FOLLOW_SETTLE_EPSILON_PX
2506
- // A live completion pad must retire through the settle's glide first;
2507
- // fast-finishing here would freeze it in place as visible bottom gap.
2508
- && flowPadOf(host) <= FOLLOW_SETTLE_EPSILON_PX
2509
- ) {
2496
+ if (!activeRef.current) {
2510
2497
  followTraceUntilMs = Math.max(followTraceUntilMs, performance.now() + 10000)
2511
2498
  followTrace('fast-gate', { sh: host.scrollHeight, st: Math.round(host.scrollTop), pad: Math.round(flowPadOf(host)) })
2512
- finishAtNaturalFloor(host, !startedAsEntrance)
2499
+ restoreRunway(host)
2500
+ setFlowPad(host, 0)
2501
+ finishAtNaturalFloor(host, !startedAsEntrance, true)
2513
2502
  followLeaders.delete(host)
2503
+ followCompletionSettle.delete(host)
2514
2504
  releaseRevealScale()
2515
2505
  debugRuntime.reportFollow(host, null)
2516
2506
  return
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Turn-process fold face for the DSH 0.1.2 completion-collapse contract.
3
+ *
4
+ * The Host seat hands every keyed Chat renderer a `turnProcess` owner prop
5
+ * when the node belongs to a projected Turn: the fold decision lives in the
6
+ * conversation snapshot (turn closed + compact transcript), and the renderer
7
+ * is expected to hide its answer-inline reasoning while folded. The pinned
8
+ * type-only dependency predates the field, so the face is declared here and
9
+ * merged into the owner-props interface; erases at compile time.
10
+ */
11
+ export interface TurnProcessOwnerFace {
12
+ /** Answer anchor of the projection; null while the answer has not landed. */
13
+ readonly spec: { readonly answerStep: number | null, readonly inlineReasoning: boolean }
14
+ /** Whether this node's process content may fold (turn closed + compact mode). */
15
+ readonly foldable: boolean
16
+ /** Current fold state, persisted by the Host across sessions. */
17
+ readonly open: boolean
18
+ /** Fold or unfold from the renderer side (summary click, find-in-page reveal). */
19
+ setOpen(open: boolean): void
20
+ }
21
+
22
+ declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
23
+ interface ChatNodeOwnerProps {
24
+ /** Present only when the node belongs to a projected Turn (0.1.2+). */
25
+ turnProcess?: TurnProcessOwnerFace | undefined
26
+ }
27
+ }