dsh-smooth-stream 0.5.0 → 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
@@ -164,6 +164,7 @@ const STREAM_SETTINGS_NS = "smooth-stream";
164
164
  const DEFAULT_STREAM_SETTINGS = {
165
165
  enabled: true,
166
166
  controlScroll: true,
167
+ motionPreference: "auto",
167
168
  thinkAutoExpand: true,
168
169
  debugEnabled: false,
169
170
  debugTuning: {
@@ -200,6 +201,11 @@ const Config = Schema.object({
200
201
  const StreamSettingsSchema = Schema.object({
201
202
  enabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.enabled),
202
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),
203
209
  thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
204
210
  debugEnabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.debugEnabled),
205
211
  debugTuning: Schema.object({
@@ -241,6 +247,7 @@ function apply(ctx, config) {
241
247
  writable: connectionCtx.settings.writable,
242
248
  enabled: settings.enabled,
243
249
  controlScroll: settings.controlScroll,
250
+ motionPreference: settings.motionPreference,
244
251
  thinkAutoExpand: settings.thinkAutoExpand,
245
252
  canUpgrade: installation.kind === "npm"
246
253
  };
@@ -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,
@@ -293,6 +308,7 @@ function apply(ctx, config) {
293
308
  await scope.update({
294
309
  enabled: next.enabled,
295
310
  controlScroll: next.controlScroll,
311
+ ...next.motionPreference === void 0 ? {} : { motionPreference: next.motionPreference },
296
312
  thinkAutoExpand: next.thinkAutoExpand,
297
313
  ...hasDebug ? {
298
314
  debugEnabled: next.debugEnabled,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-smooth-stream",
3
- "version": "0.5.0",
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",
@@ -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;
@@ -84,6 +84,35 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
84
84
  </span>
85
85
  <span className={css.hint}>{t('controlScrollHint')}</span>
86
86
  </label>
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>
87
116
  <label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
88
117
  <span className={css.fieldHead}>
89
118
  <span className={css.label}>{t('thinkAutoExpand')}</span>
@@ -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,7 +8,7 @@ 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'>
@@ -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
@@ -290,6 +307,7 @@ function AnimatedMarkdownText({
290
307
  labels,
291
308
  fileMentions,
292
309
  streaming,
310
+ motionReduced,
293
311
  ownFollow,
294
312
  followSpeedCpsRef,
295
313
  followRevealedCharsRef,
@@ -299,7 +317,7 @@ function AnimatedMarkdownText({
299
317
  shouldHoldBack,
300
318
  controlScroll = true,
301
319
  }: AnimatedMarkdownTextProps) {
302
- const reduced = usePrefersReducedMotion()
320
+ const reduced = motionReduced
303
321
  const [typing, setTyping] = useState(streaming)
304
322
  const localSpeedCpsRef = useRef(35)
305
323
  const followRootRef = useRef<HTMLDivElement>(null)
@@ -350,6 +368,14 @@ function AnimatedMarkdownText({
350
368
  if (typing && !streaming && shown.length === text.length) setTyping(false)
351
369
  }, [shown, streaming, text, typing])
352
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
+
353
379
  return (
354
380
  <FollowHost
355
381
  active={live && ownFollow}
@@ -387,6 +413,61 @@ function imageLabels(t: AssistantProps['t']): MessageImageLabels {
387
413
  }
388
414
  }
389
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
+
390
471
  function firstLine(text: string): string {
391
472
  const newline = text.indexOf('\n')
392
473
  return newline === -1 ? text : text.slice(0, newline)
@@ -412,6 +493,7 @@ function AnimatedReasoning({
412
493
  running,
413
494
  preset,
414
495
  thinkAutoExpand,
496
+ motionReduced,
415
497
  shouldHoldBack,
416
498
  followSpeedCpsRef,
417
499
  followRevealScaleRef,
@@ -421,12 +503,13 @@ function AnimatedReasoning({
421
503
  running: boolean
422
504
  preset: StreamSmoothingPreset
423
505
  thinkAutoExpand: boolean
506
+ motionReduced: boolean
424
507
  shouldHoldBack: () => boolean
425
508
  followSpeedCpsRef?: { current: number } | undefined
426
509
  followRevealScaleRef?: { current: number } | undefined
427
510
  t: AssistantProps['t']
428
511
  }) {
429
- const reduced = usePrefersReducedMotion()
512
+ const reduced = motionReduced
430
513
  const [expanded, setExpanded] = useState(running && thinkAutoExpand)
431
514
  const [autoClosed, setAutoClosed] = useState(false)
432
515
  const summaryRef = useRef<HTMLSpanElement>(null)
@@ -518,11 +601,13 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
518
601
  maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec,
519
602
  thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
520
603
  controlScroll = true,
604
+ motionPreference = DEFAULT_STREAM_SETTINGS.motionPreference,
521
605
  node,
522
606
  useTurnData,
523
607
  openFile,
524
608
  loadImage,
525
609
  fileMentions,
610
+ turnProcess,
526
611
  t,
527
612
  }: AssistantProps & {
528
613
  mode?: StreamMode
@@ -532,10 +617,21 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
532
617
  maxScrollSpeedPxPerSec?: number
533
618
  thinkAutoExpand?: boolean
534
619
  controlScroll?: boolean
620
+ motionPreference?: StreamMotionPreference
535
621
  }) {
536
622
  const data = node.data
537
623
  const streaming = data.status === 'running'
538
- 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])
539
635
  const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
540
636
  const rootSpeedRef = useRef(35)
541
637
  const rootRevealedCharsRef = useRef(0)
@@ -608,6 +704,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
608
704
  labels={markdownLabels}
609
705
  fileMentions={mentions}
610
706
  streaming={streaming}
707
+ motionReduced={reduced}
611
708
  ownFollow={!streaming && index === lastFollow}
612
709
  followSpeedCpsRef={index === lastFollow ? rootSpeedRef : undefined}
613
710
  followRevealedCharsRef={index === lastFollow ? rootRevealedCharsRef : undefined}
@@ -621,17 +718,19 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
621
718
  break
622
719
  case 'reasoning':
623
720
  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
- />,
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>,
635
734
  )
636
735
  break
637
736
  case 'image': {
@@ -157,6 +157,7 @@ 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
161
162
  && next.debugEnabled === this.value.debugEnabled
162
163
  && next.debugTuning === this.value.debugTuning
@@ -270,6 +271,7 @@ export function apply(ctx: ClientContext): void {
270
271
  maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
271
272
  thinkAutoExpand: preferences.thinkAutoExpand,
272
273
  controlScroll: preferences.controlScroll,
274
+ motionPreference: preferences.motionPreference,
273
275
  })
274
276
  }
275
277
  ctx.slots.inject('conversation.chat.node', () => {
@@ -8,6 +8,10 @@ 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'
12
16
  | 'debugEnabled' | 'debugEnabledHint' | 'debugUnavailable'
13
17
  | 'debugPanelTitle' | 'debugPanelToggle' | 'debugPanelClose' | 'debugGuide'
@@ -37,6 +41,14 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
37
41
  enabledHint: 'Let this plugin render and follow streaming replies. Turn off to use the built-in Harness renderer.',
38
42
  controlScroll: 'Take over scrolling',
39
43
  controlScrollHint: 'On by default; smooth-stream writes the conversation scroll position. Turn off to leave bottom-follow to Harness.',
44
+ motionPreference: 'Motion',
45
+ motionPreferenceHint: 'How streaming responds to the system reduce-motion setting.',
46
+ motionAuto: 'Follow system',
47
+ motionAutoHint: 'Reduced-motion systems get raw text (accessibility first).',
48
+ motionForceSmooth: 'Always smooth',
49
+ motionForceSmoothHint: 'Keep the smoothing engine even when the system asks for reduced motion.',
50
+ motionForceReduced: 'Always raw',
51
+ motionForceReducedHint: 'Render raw text even when the system allows motion.',
40
52
  thinkAutoExpand: 'Auto-expand thinking',
41
53
  thinkAutoExpandHint: 'Open the thinking block while it streams. Turn off to keep it collapsed.',
42
54
  debugEnabled: 'Show render diagnostics',
@@ -117,6 +129,14 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
117
129
  enabledHint: '由本插件渲染并跟随流式回复;关闭后使用 Harness 内置渲染。',
118
130
  controlScroll: '接管滚动',
119
131
  controlScrollHint: '默认开启,由丝滑流式写入会话滚动位置;关闭后贴底滚动交给 Harness。',
132
+ motionPreference: '动效偏好',
133
+ motionPreferenceHint: '流式渲染如何响应系统的「减少动态效果」设置。',
134
+ motionAuto: '跟随系统',
135
+ motionAutoHint: '系统开启减少动态效果时直接呈现原始文本(优先无障碍)。',
136
+ motionForceSmooth: '始终平滑',
137
+ motionForceSmoothHint: '即使系统要求减少动态效果,仍保持平滑流式渲染。',
138
+ motionForceReduced: '始终原始',
139
+ motionForceReducedHint: '即使系统允许动效,也直接呈现原始文本。',
120
140
  thinkAutoExpand: '自动展开思考',
121
141
  thinkAutoExpandHint: '思考块在流式时自动展开;关闭后保持折叠,可手动展开。',
122
142
  debugEnabled: '显示渲染调试面板',
@@ -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,6 +24,7 @@ export interface SmoothStreamCardState {
23
24
  failed: boolean
24
25
  enabled: boolean
25
26
  controlScroll: boolean
27
+ motionPreference: StreamMotionPreference
26
28
  thinkAutoExpand: boolean
27
29
  debugEnabled: boolean
28
30
  debugTuning: StreamDebugTuning
@@ -52,7 +54,7 @@ export class SmoothStreamCardController {
52
54
  private readonly store = createSnapshotStore<SmoothStreamCardState>(this.projection())
53
55
  private loaded: StreamSettingsView | undefined
54
56
  private loadedDebug: StreamDebugSettingsView | undefined
55
- private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> | undefined
57
+ private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> | undefined
56
58
  private stagedDebug: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'> | undefined
57
59
  private saving = false
58
60
  private failed = false
@@ -90,11 +92,12 @@ export class SmoothStreamCardController {
90
92
  hooks: { smoothStreamCard: this.store },
91
93
  edit: (patch) => {
92
94
  if (this.saving) return
93
- if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.thinkAutoExpand !== undefined) {
95
+ if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.motionPreference !== undefined || patch.thinkAutoExpand !== undefined) {
94
96
  this.stagedBase = {
95
97
  ...this.baseValues(),
96
98
  ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
97
99
  ...(patch.controlScroll === undefined ? {} : { controlScroll: patch.controlScroll }),
100
+ ...(patch.motionPreference === undefined ? {} : { motionPreference: patch.motionPreference }),
98
101
  ...(patch.thinkAutoExpand === undefined ? {} : { thinkAutoExpand: patch.thinkAutoExpand }),
99
102
  }
100
103
  }
@@ -140,10 +143,11 @@ export class SmoothStreamCardController {
140
143
  }
141
144
  }
142
145
 
143
- private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> {
146
+ private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> {
144
147
  return this.stagedBase ?? {
145
148
  enabled: this.loaded?.enabled ?? DEFAULT_STREAM_SETTINGS.enabled,
146
149
  controlScroll: this.loaded?.controlScroll ?? DEFAULT_STREAM_SETTINGS.controlScroll,
150
+ motionPreference: this.loaded?.motionPreference ?? DEFAULT_STREAM_SETTINGS.motionPreference,
147
151
  thinkAutoExpand: this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
148
152
  }
149
153
  }
@@ -24,6 +24,7 @@ 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'
28
29
  || typeof data.canUpgrade !== 'boolean') {
29
30
  throw new Error('dsh-smooth-stream: malformed settings response')
@@ -68,7 +69,7 @@ function accepted(result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>):
68
69
  /** Narrow client contract consumed by the staged settings-card controller. */
69
70
  export interface SmoothStreamSettingsApi {
70
71
  read(): Promise<StreamSettingsView>
71
- write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView>
72
+ write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView>
72
73
  readDebug(): Promise<StreamDebugSettingsView>
73
74
  writeDebug(settings: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>): Promise<StreamDebugSettingsView>
74
75
  upgrade(): Promise<StreamUpgradeView>
@@ -80,13 +81,14 @@ export function createSmoothStreamSettingsApi(connection: ConnectionHandle): Smo
80
81
  async read(): Promise<StreamSettingsView> {
81
82
  return settingsView(accepted(await connection.rpc.call(STREAM_SETTINGS_RPC_CHANNEL, STREAM_SETTINGS_RPC.read, {})))
82
83
  },
83
- async write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView> {
84
+ async write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView> {
84
85
  return settingsView(accepted(await connection.rpc.call(
85
86
  STREAM_SETTINGS_RPC_CHANNEL,
86
87
  STREAM_SETTINGS_RPC.write,
87
88
  {
88
89
  enabled: settings.enabled,
89
90
  controlScroll: settings.controlScroll,
91
+ motionPreference: settings.motionPreference,
90
92
  thinkAutoExpand: settings.thinkAutoExpand,
91
93
  ...(settings.debugEnabled === undefined || settings.debugTuning === undefined
92
94
  ? {}
@@ -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
+ }
package/src/plugin.ts CHANGED
@@ -56,6 +56,11 @@ export const Config: Schema<Config> = Schema.object({
56
56
  export const StreamSettingsSchema: Schema<StreamSettings> = Schema.object({
57
57
  enabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.enabled),
58
58
  controlScroll: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.controlScroll),
59
+ motionPreference: Schema.union([
60
+ Schema.const('auto'),
61
+ Schema.const('force-smooth'),
62
+ Schema.const('force-reduced'),
63
+ ] as const).default(DEFAULT_STREAM_SETTINGS.motionPreference),
59
64
  thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
60
65
  debugEnabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.debugEnabled),
61
66
  debugTuning: Schema.object({
@@ -118,6 +123,7 @@ export function apply(ctx: Context, config: Config): void {
118
123
  writable: connectionCtx.settings.writable,
119
124
  enabled: settings.enabled,
120
125
  controlScroll: settings.controlScroll,
126
+ motionPreference: settings.motionPreference,
121
127
  thinkAutoExpand: settings.thinkAutoExpand,
122
128
  canUpgrade: installation.kind === 'npm',
123
129
  }
@@ -185,10 +191,26 @@ export function apply(ctx: Context, config: Config): void {
185
191
  const next = payload as {
186
192
  enabled: boolean
187
193
  controlScroll: boolean
194
+ motionPreference?: unknown
188
195
  thinkAutoExpand: boolean
189
196
  debugEnabled?: unknown
190
197
  debugTuning?: unknown
191
198
  }
199
+ if (
200
+ next.motionPreference !== undefined
201
+ && next.motionPreference !== 'auto'
202
+ && next.motionPreference !== 'force-smooth'
203
+ && next.motionPreference !== 'force-reduced'
204
+ ) {
205
+ return {
206
+ ok: false,
207
+ error: {
208
+ code: 'settings-rejected',
209
+ message: 'motionPreference must be one of auto | force-smooth | force-reduced',
210
+ details: { ns: STREAM_SETTINGS_NS },
211
+ },
212
+ }
213
+ }
192
214
  const hasDebug = next.debugEnabled !== undefined || next.debugTuning !== undefined
193
215
  if (hasDebug && (typeof next.debugEnabled !== 'boolean' || !validDebugTuning(next.debugTuning))) {
194
216
  return {
@@ -203,6 +225,7 @@ export function apply(ctx: Context, config: Config): void {
203
225
  await scope.update({
204
226
  enabled: next.enabled,
205
227
  controlScroll: next.controlScroll,
228
+ ...(next.motionPreference === undefined ? {} : { motionPreference: next.motionPreference }),
206
229
  thinkAutoExpand: next.thinkAutoExpand,
207
230
  ...(hasDebug ? { debugEnabled: next.debugEnabled, debugTuning: next.debugTuning } : {}),
208
231
  })
@@ -27,6 +27,8 @@ export interface StreamSettingsView {
27
27
  enabled: boolean
28
28
  /** Whether smooth-stream also owns conversation bottom-follow. */
29
29
  controlScroll: boolean
30
+ /** How the reveal honors the OS reduced-motion preference. */
31
+ motionPreference: import('./settings.ts').StreamMotionPreference
30
32
  /** Current resolved preference. */
31
33
  thinkAutoExpand: boolean
32
34
  /** Whether a fixed npm update command is safe to offer. */
package/src/settings.ts CHANGED
@@ -45,6 +45,18 @@ export const DEFAULT_STREAM_DEBUG_TUNING: StreamDebugTuning = {
45
45
  backpressureMinScale: 0.55,
46
46
  }
47
47
 
48
+ /**
49
+ * How the typewriter reveal responds to the OS reduced-motion preference.
50
+ * Credit: three-state design proposed by @Zn-Dk in #21/#22.
51
+ */
52
+ export type StreamMotionPreference =
53
+ /** Follow the OS preference: reduced-motion users get raw text (accessibility first). */
54
+ | 'auto'
55
+ /** Always run the smoothing engine, ignoring the OS preference. */
56
+ | 'force-smooth'
57
+ /** Always render raw text, ignoring the OS preference. */
58
+ | 'force-reduced'
59
+
48
60
  /**
49
61
  * Preferences a user may set. Deliberately separate from {@link StreamConfig}
50
62
  * because the two change at different times: composition-time values go
@@ -61,6 +73,13 @@ export interface StreamSettings {
61
73
  * scroll ownership to the Harness; text reveal still runs.
62
74
  */
63
75
  controlScroll: boolean
76
+ /**
77
+ * How the reveal honors the OS reduced-motion preference. `auto` preserves
78
+ * the accessibility-first default; `force-smooth` keeps the engine on
79
+ * machines where a system-wide reduce-motion switch (performance tooling,
80
+ * RDP sessions, forced browser flags) would otherwise bypass it silently.
81
+ */
82
+ motionPreference: StreamMotionPreference
64
83
  /**
65
84
  * Whether a reasoning ("Think") block auto-expands while it is the
66
85
  * streaming tail. Off keeps the block collapsed — the user can still open
@@ -77,6 +96,7 @@ export interface StreamSettings {
77
96
  export const DEFAULT_STREAM_SETTINGS: StreamSettings = {
78
97
  enabled: true,
79
98
  controlScroll: true,
99
+ motionPreference: 'auto',
80
100
  thinkAutoExpand: true,
81
101
  debugEnabled: false,
82
102
  debugTuning: DEFAULT_STREAM_DEBUG_TUNING,