dsh-smooth-stream 0.4.2 → 0.5.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
@@ -1,4 +1,3 @@
1
- import { settingsNamespace } from "@deepseek-ai/dsh-settings";
2
1
  import Schema from "@deepseek-ai/schemastery";
3
2
  import { readFileSync } from "node:fs";
4
3
  import { basename, dirname, isAbsolute, join } from "node:path";
@@ -164,8 +163,8 @@ const STREAM_SETTINGS_NS = "smooth-stream";
164
163
  /** Defaults shared by the Host schema and the client-side fallback. */
165
164
  const DEFAULT_STREAM_SETTINGS = {
166
165
  enabled: true,
166
+ controlScroll: true,
167
167
  thinkAutoExpand: true,
168
- autoCollapse: true,
169
168
  debugEnabled: false,
170
169
  debugTuning: {
171
170
  revealScale: 1,
@@ -200,8 +199,8 @@ const Config = Schema.object({
200
199
  */
201
200
  const StreamSettingsSchema = Schema.object({
202
201
  enabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.enabled),
202
+ controlScroll: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.controlScroll),
203
203
  thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
204
- autoCollapse: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.autoCollapse),
205
204
  debugEnabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.debugEnabled),
206
205
  debugTuning: Schema.object({
207
206
  revealScale: Schema.number().min(.25).max(2).default(DEFAULT_STREAM_SETTINGS.debugTuning.revealScale),
@@ -229,7 +228,8 @@ function apply(ctx, config) {
229
228
  httpCtx.effect(() => httpCtx.webServer.tapIndex((html) => injectStreamConfig(html, config)), "dsh-smooth-stream: boot config bridge");
230
229
  });
231
230
  ctx.inject(["settings"], (settingsCtx) => {
232
- const scope = settingsCtx.settings.register(settingsNamespace(STREAM_SETTINGS_NS), StreamSettingsSchema, { applies: "live" });
231
+ const settingsNamespace = STREAM_SETTINGS_NS;
232
+ const scope = settingsCtx.settings.register(settingsNamespace, StreamSettingsSchema, { applies: "live" });
233
233
  settingsCtx.inject(["connection"], (connectionCtx) => {
234
234
  let upgrade;
235
235
  const view = () => {
@@ -240,8 +240,8 @@ function apply(ctx, config) {
240
240
  installation: installation.kind,
241
241
  writable: connectionCtx.settings.writable,
242
242
  enabled: settings.enabled,
243
+ controlScroll: settings.controlScroll,
243
244
  thinkAutoExpand: settings.thinkAutoExpand,
244
- autoCollapse: settings.autoCollapse,
245
245
  canUpgrade: installation.kind === "npm"
246
246
  };
247
247
  };
@@ -263,11 +263,11 @@ function apply(ctx, config) {
263
263
  value: view()
264
264
  };
265
265
  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 {
266
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload) || typeof payload.enabled !== "boolean" || typeof payload.controlScroll !== "boolean" || typeof payload.thinkAutoExpand !== "boolean") return {
267
267
  ok: false,
268
268
  error: {
269
269
  code: "settings-rejected",
270
- message: "enabled, thinkAutoExpand and autoCollapse must be booleans",
270
+ message: "enabled, controlScroll and thinkAutoExpand must be booleans",
271
271
  details: { ns: STREAM_SETTINGS_NS }
272
272
  }
273
273
  };
@@ -292,8 +292,8 @@ function apply(ctx, config) {
292
292
  };
293
293
  await scope.update({
294
294
  enabled: next.enabled,
295
+ controlScroll: next.controlScroll,
295
296
  thinkAutoExpand: next.thinkAutoExpand,
296
- autoCollapse: next.autoCollapse,
297
297
  ...hasDebug ? {
298
298
  debugEnabled: next.debugEnabled,
299
299
  debugTuning: next.debugTuning
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-smooth-stream",
3
- "version": "0.4.2",
3
+ "version": "0.5.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
  }
@@ -73,29 +73,29 @@ 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
+ <label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
88
88
  <span className={css.fieldHead}>
89
- <span className={css.label}>{t('autoCollapse')}</span>
89
+ <span className={css.label}>{t('thinkAutoExpand')}</span>
90
90
  <input
91
91
  type="checkbox"
92
92
  className={css.toggle}
93
- checked={state.autoCollapse}
94
- disabled={!state.writable || state.saving}
95
- onChange={(event) => { props.edit({ autoCollapse: event.target.checked }) }}
93
+ checked={state.thinkAutoExpand}
94
+ disabled={!state.writable || state.saving || !state.enabled}
95
+ onChange={(event) => { props.edit({ thinkAutoExpand: event.target.checked }) }}
96
96
  />
97
97
  </span>
98
- <span className={css.hint}>{t('autoCollapseHint')}</span>
98
+ <span className={css.hint}>{t('thinkAutoExpandHint')}</span>
99
99
  </label>
100
100
  <label className={state.debugAvailable ? css.field : `${css.field} ${css.fieldDisabled}`}>
101
101
  <span className={css.fieldHead}>
@@ -12,7 +12,7 @@ import { DEFAULT_STREAM_SETTINGS } 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(
@@ -38,6 +38,7 @@ interface AnimatedMarkdownTextProps extends MarkdownProps {
38
38
  onPredictiveChange?: ((predictive: boolean) => void) | undefined
39
39
  preset: StreamSmoothingPreset
40
40
  shouldHoldBack: () => boolean
41
+ controlScroll?: boolean
41
42
  }
42
43
 
43
44
  /** Conservative fallback before the streaming Markdown tail has geometry. */
@@ -286,7 +287,7 @@ const StreamAnnouncement = memo(function StreamAnnouncement({
286
287
  */
287
288
  function AnimatedMarkdownText({
288
289
  text,
289
- codeLabels,
290
+ labels,
290
291
  fileMentions,
291
292
  streaming,
292
293
  ownFollow,
@@ -296,6 +297,7 @@ function AnimatedMarkdownText({
296
297
  onPredictiveChange,
297
298
  preset,
298
299
  shouldHoldBack,
300
+ controlScroll = true,
299
301
  }: AnimatedMarkdownTextProps) {
300
302
  const reduced = usePrefersReducedMotion()
301
303
  const [typing, setTyping] = useState(streaming)
@@ -355,6 +357,7 @@ function AnimatedMarkdownText({
355
357
  revealedCharsRef={followRevealedCharsRef}
356
358
  revealScaleRef={followRevealScaleRef}
357
359
  predictive={streaming}
360
+ controlScroll={controlScroll}
358
361
  hostRef={followRootRef}
359
362
  >
360
363
  <MarkdownText
@@ -363,7 +366,7 @@ function AnimatedMarkdownText({
363
366
  // Rendering `text` early bypasses the drain and teleports the tail.
364
367
  text={live ? shown : text}
365
368
  streaming={live}
366
- codeLabels={codeLabels}
369
+ labels={labels}
367
370
  fileMentions={live ? undefined : fileMentions}
368
371
  />
369
372
  </FollowHost>
@@ -514,6 +517,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
514
517
  scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec,
515
518
  maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec,
516
519
  thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
520
+ controlScroll = true,
517
521
  node,
518
522
  useTurnData,
519
523
  openFile,
@@ -527,6 +531,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
527
531
  scrollSpeedPxPerSec?: number
528
532
  maxScrollSpeedPxPerSec?: number
529
533
  thinkAutoExpand?: boolean
534
+ controlScroll?: boolean
530
535
  }) {
531
536
  const data = node.data
532
537
  const streaming = data.status === 'running'
@@ -568,7 +573,10 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
568
573
  () => owner === undefined ? undefined : fileMentions(owner),
569
574
  [fileMentions, owner],
570
575
  )
571
- const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
576
+ const markdownLabels = useMemo(() => ({
577
+ code: { copyLabel: t('copy'), copiedLabel: t('copied') },
578
+ footnotes: t('markdown.footnotes'),
579
+ }), [t])
572
580
  const imageLoader: ImageLoader = loadImage ?? (async () => {
573
581
  throw new Error(t('image.serviceUnavailable'))
574
582
  })
@@ -597,7 +605,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
597
605
  <AnimatedMarkdownText
598
606
  key={index}
599
607
  text={block.text}
600
- codeLabels={codeLabels}
608
+ labels={markdownLabels}
601
609
  fileMentions={mentions}
602
610
  streaming={streaming}
603
611
  ownFollow={!streaming && index === lastFollow}
@@ -607,6 +615,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
607
615
  onPredictiveChange={index === lastFollow ? updateTextPrediction : undefined}
608
616
  preset={preset}
609
617
  shouldHoldBack={shouldHoldBack}
618
+ controlScroll={controlScroll}
610
619
  />,
611
620
  )
612
621
  break
@@ -663,6 +672,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
663
672
  revealedCharsRef={rootRevealedCharsRef}
664
673
  revealScaleRef={rootRevealScaleRef}
665
674
  predictiveRef={rootPredictiveRef}
675
+ controlScroll={controlScroll}
666
676
  >
667
677
  <div className={css.body}>
668
678
  {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>
@@ -13,29 +13,88 @@
13
13
 
14
14
  type ClientRuntimeClient = typeof import('@deepseek-ai/dsh-client-runtime/client')
15
15
 
16
+ export interface SnapshotStoreLike<T> {
17
+ getSnapshot(): T
18
+ subscribe(listener: (state: T) => void): () => void
19
+ update(mutator: (draft: T) => void): void
20
+ set(next: T): void
21
+ }
22
+
23
+ function fallbackSnapshotStore<T extends object>(init: T): SnapshotStoreLike<T> {
24
+ let state = init
25
+ const listeners = new Set<(state: T) => void>()
26
+ return {
27
+ getSnapshot: () => state,
28
+ subscribe: listener => {
29
+ listeners.add(listener)
30
+ return () => { listeners.delete(listener) }
31
+ },
32
+ update: mutator => {
33
+ const draft = Object.create(
34
+ Object.getPrototypeOf(state),
35
+ Object.getOwnPropertyDescriptors(state),
36
+ ) as T
37
+ mutator(draft)
38
+ state = draft
39
+ for (const listener of [...listeners]) listener(state)
40
+ },
41
+ set: next => {
42
+ state = next
43
+ for (const listener of [...listeners]) listener(state)
44
+ },
45
+ }
46
+ }
47
+
16
48
  /**
17
49
  * Runtime require: the module-loader factory scopes its own resolver over the
18
50
  * booting kernel's table; under Node (vitest) there is no global require, so
19
51
  * fall back to `createRequire`.
20
52
  */
21
- function pickRequire(): NodeRequire {
22
- if (typeof require === 'function') return require
53
+ function pickRequire(): NodeRequire | null {
54
+ if (typeof require === 'function') {
55
+ try {
56
+ return require
57
+ } catch {
58
+ return null
59
+ }
60
+ }
23
61
  if (typeof process !== 'undefined' && typeof process.getBuiltinModule === 'function') {
24
- const { createRequire } = process.getBuiltinModule('node:module')
25
- return createRequire(process.cwd() + '/package.json')
62
+ try {
63
+ const { createRequire } = process.getBuiltinModule('node:module')
64
+ return createRequire(process.cwd() + '/package.json')
65
+ } catch {
66
+ return null
67
+ }
26
68
  }
27
- throw new TypeError('dsh-smooth-stream: no client module resolver available')
69
+ return null
28
70
  }
29
71
 
30
- let clientStore: ClientRuntimeClient
72
+ let clientStore: Partial<ClientRuntimeClient> | undefined
31
73
 
32
74
  try {
33
- // 0.1.2+ seeds the split store package directly into the module table.
34
- clientStore = pickRequire()('@deepseek-ai/dsh-client-store') as ClientRuntimeClient
75
+ const req = pickRequire()
76
+ if (req !== null) {
77
+ try {
78
+ // 0.1.2+ seeds the split store package directly into the module table.
79
+ clientStore = req('@deepseek-ai/dsh-client-store') as ClientRuntimeClient
80
+ } catch {
81
+ try {
82
+ // ≤ 0.1.1 owns the store engine on the client-runtime client face.
83
+ clientStore = req('@deepseek-ai/dsh-client-runtime/client') as ClientRuntimeClient
84
+ } catch {
85
+ // Fallback below
86
+ }
87
+ }
88
+ }
35
89
  } catch {
36
- // ≤ 0.1.1 owns the store engine on the client-runtime client face.
37
- clientStore = pickRequire()('@deepseek-ai/dsh-client-runtime/client') as ClientRuntimeClient
90
+ // Fallback below
91
+ }
92
+
93
+ export interface SnapshotStore<T> {
94
+ getSnapshot(): T
95
+ subscribe(listener: () => void): () => void
96
+ update(mutator: (draft: T) => void): void
97
+ set(next: T): void
38
98
  }
39
99
 
40
- export const createSnapshotStore = clientStore.createSnapshotStore
41
- export type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
100
+ export const createSnapshotStore = (clientStore?.createSnapshotStore ?? fallbackSnapshotStore) as <T extends object>(init: T) => SnapshotStore<T>
@@ -7,7 +7,6 @@ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client
7
7
  import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
8
8
  import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
9
9
  import { TypewriterAssistantNodeView } from './TypewriterAssistantNodeView.tsx'
10
- import { AutoCollapseController } from './auto-collapse-controller.ts'
11
10
  import { wrapFollowNodeView, type FollowWrapProps } from './TypewriterToolNodeView.tsx'
12
11
  import { SmoothStreamCard } from './SmoothStreamCard.tsx'
13
12
  import { SmoothStreamCardController } from './smooth-stream-card-controller.ts'
@@ -88,7 +87,10 @@ function readBootConfig(): StreamConfig {
88
87
  * @param ctx - Browser context carrying the slot registry.
89
88
  * @returns Restorer that puts the original components back.
90
89
  */
91
- function wrapAgentChatRows(ctx: ClientContext): () => void {
90
+ function wrapAgentChatRows(
91
+ ctx: ClientContext,
92
+ useControlScroll: () => boolean,
93
+ ): () => void {
92
94
  const restores: Array<() => void> = []
93
95
  const wrapped = new WeakSet<object>()
94
96
 
@@ -99,7 +101,7 @@ function wrapAgentChatRows(ctx: ClientContext): () => void {
99
101
  const current = entry.component
100
102
  if (!isWrappableComponent(current) || wrapped.has(current)) continue
101
103
  const inner = current as ComponentType<FollowWrapProps>
102
- const next = wrapFollowNodeView(inner)
104
+ const next = wrapFollowNodeView(inner, useControlScroll)
103
105
  wrapped.add(next)
104
106
  entry.component = next
105
107
  restores.push(() => {
@@ -154,8 +156,8 @@ class SettingsCell {
154
156
  if (
155
157
  pending === this.pending
156
158
  && next.enabled === this.value.enabled
159
+ && next.controlScroll === this.value.controlScroll
157
160
  && next.thinkAutoExpand === this.value.thinkAutoExpand
158
- && next.autoCollapse === this.value.autoCollapse
159
161
  && next.debugEnabled === this.value.debugEnabled
160
162
  && next.debugTuning === this.value.debugTuning
161
163
  ) return
@@ -169,11 +171,6 @@ class SettingsCell {
169
171
  return !this.pending && this.value.enabled
170
172
  }
171
173
 
172
- /** Whether finished turns should fold behind a summary row right now. */
173
- autoCollapseActive(): boolean {
174
- return !this.pending && this.value.autoCollapse
175
- }
176
-
177
174
  readonly getSnapshot = (): StreamSettings => this.value
178
175
 
179
176
  readonly subscribe = (listener: () => void): () => void => {
@@ -196,25 +193,11 @@ class SettingsCell {
196
193
  export function apply(ctx: ClientContext): void {
197
194
  const config = readBootConfig()
198
195
  const settings = new SettingsCell()
199
-
200
- // Auto-collapse works at the DOM level and is independent of the renderer
201
- // takeover: it also folds finished turns when the built-in Harness renderer
202
- // owns the conversation. The lifecycle rides the guaranteed `slots` service
203
- // (this entry's own inject list) and follows the same live settings cell, so
204
- // flipping the preference restores every folded row immediately.
205
- ctx.inject(['slots'], () => {
206
- const autoCollapse = new AutoCollapseController()
207
- const syncAutoCollapse = (): void => {
208
- if (settings.autoCollapseActive()) autoCollapse.start()
209
- else autoCollapse.stop()
210
- }
211
- const unsubscribe = settings.subscribe(syncAutoCollapse)
212
- syncAutoCollapse()
213
- return () => {
214
- unsubscribe()
215
- autoCollapse.stop()
216
- }
217
- })
196
+ const useControlScroll = (): boolean => useSyncExternalStore(
197
+ settings.subscribe,
198
+ () => settings.getSnapshot().controlScroll,
199
+ () => settings.getSnapshot().controlScroll,
200
+ )
218
201
 
219
202
  // The card talks to the plugin-owned loopback RPC, so the core settings
220
203
  // namespace allowlist cannot make it disappear. The stream still applies
@@ -286,6 +269,7 @@ export function apply(ctx: ClientContext): void {
286
269
  scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
287
270
  maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
288
271
  thinkAutoExpand: preferences.thinkAutoExpand,
272
+ controlScroll: preferences.controlScroll,
289
273
  })
290
274
  }
291
275
  ctx.slots.inject('conversation.chat.node', () => {
@@ -298,7 +282,7 @@ export function apply(ctx: ClientContext): void {
298
282
  return
299
283
  }
300
284
  if (releaseTakeover !== undefined) return
301
- const unwrap = wrapAgentChatRows(ctx)
285
+ const unwrap = wrapAgentChatRows(ctx, useControlScroll)
302
286
  const unshadow = ctx.slots.register({
303
287
  name: 'conversation.chat.node',
304
288
  key: 'assistant-step',
@@ -7,8 +7,8 @@ export const NS = 'settings.smoothStream'
7
7
  export type SmoothStreamLocaleKey =
8
8
  | 'title' | 'description'
9
9
  | 'enabled' | 'enabledHint'
10
+ | 'controlScroll' | 'controlScrollHint'
10
11
  | 'thinkAutoExpand' | 'thinkAutoExpandHint'
11
- | 'autoCollapse' | 'autoCollapseHint'
12
12
  | 'debugEnabled' | 'debugEnabledHint' | 'debugUnavailable'
13
13
  | 'debugPanelTitle' | 'debugPanelToggle' | 'debugPanelClose' | 'debugGuide'
14
14
  | 'debugLive' | 'debugIdle' | 'debugUnsaved'
@@ -35,10 +35,10 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
35
35
  description: 'How replies are revealed while they stream.',
36
36
  enabled: 'Enable smooth streaming',
37
37
  enabledHint: 'Let this plugin render and follow streaming replies. Turn off to use the built-in Harness renderer.',
38
+ controlScroll: 'Take over scrolling',
39
+ controlScrollHint: 'On by default; smooth-stream writes the conversation scroll position. Turn off to leave bottom-follow to Harness.',
38
40
  thinkAutoExpand: 'Auto-expand thinking',
39
41
  thinkAutoExpandHint: 'Open the thinking block while it streams. Turn off to keep it collapsed.',
40
- autoCollapse: 'Collapse finished work',
41
- autoCollapseHint: 'When a reply finishes, fold its thinking, tools, and intermediate output behind one “Processed” summary so only the final answer shows. Click the summary to expand again; works with streaming on or off.',
42
42
  debugEnabled: 'Show render diagnostics',
43
43
  debugEnabledHint: 'Show live streaming and scroll metrics on the right side of the chat. Tune values there, then save them here.',
44
44
  debugUnavailable: 'Live diagnostics require a newer plugin Host.',
@@ -115,10 +115,10 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
115
115
  description: '回复在流式输出时如何逐字展现。',
116
116
  enabled: '启用丝滑流式渲染',
117
117
  enabledHint: '由本插件渲染并跟随流式回复;关闭后使用 Harness 内置渲染。',
118
+ controlScroll: '接管滚动',
119
+ controlScrollHint: '默认开启,由丝滑流式写入会话滚动位置;关闭后贴底滚动交给 Harness。',
118
120
  thinkAutoExpand: '自动展开思考',
119
121
  thinkAutoExpandHint: '思考块在流式时自动展开;关闭后保持折叠,可手动展开。',
120
- autoCollapse: '完成后自动折叠',
121
- autoCollapseHint: '回复处理完成后,把思考、工具与过程输出折叠为一行“已处理”摘要,只展示最终回复;点击摘要可再次展开。开启或关闭流式渲染均可用,默认开启。',
122
122
  debugEnabled: '显示渲染调试面板',
123
123
  debugEnabledHint: '在聊天右侧显示流式渲染和滚动的实时参数,可在面板中调节并在这里保存。',
124
124
  debugUnavailable: '当前 Host 版本不支持实时调试,请先更新插件 Host。',
@@ -22,8 +22,8 @@ export interface SmoothStreamCardState {
22
22
  saving: boolean
23
23
  failed: boolean
24
24
  enabled: boolean
25
+ controlScroll: boolean
25
26
  thinkAutoExpand: boolean
26
- autoCollapse: boolean
27
27
  debugEnabled: boolean
28
28
  debugTuning: StreamDebugTuning
29
29
  debugAvailable: boolean
@@ -52,7 +52,7 @@ export class SmoothStreamCardController {
52
52
  private readonly store = createSnapshotStore<SmoothStreamCardState>(this.projection())
53
53
  private loaded: StreamSettingsView | undefined
54
54
  private loadedDebug: StreamDebugSettingsView | undefined
55
- private stagedBase: Pick<StreamSettings, 'enabled' | 'thinkAutoExpand' | 'autoCollapse'> | undefined
55
+ private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> | undefined
56
56
  private stagedDebug: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'> | undefined
57
57
  private saving = false
58
58
  private failed = false
@@ -90,12 +90,12 @@ export class SmoothStreamCardController {
90
90
  hooks: { smoothStreamCard: this.store },
91
91
  edit: (patch) => {
92
92
  if (this.saving) return
93
- if (patch.enabled !== undefined || patch.thinkAutoExpand !== undefined || patch.autoCollapse !== undefined) {
93
+ if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.thinkAutoExpand !== undefined) {
94
94
  this.stagedBase = {
95
95
  ...this.baseValues(),
96
96
  ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
97
+ ...(patch.controlScroll === undefined ? {} : { controlScroll: patch.controlScroll }),
97
98
  ...(patch.thinkAutoExpand === undefined ? {} : { thinkAutoExpand: patch.thinkAutoExpand }),
98
- ...(patch.autoCollapse === undefined ? {} : { autoCollapse: patch.autoCollapse }),
99
99
  }
100
100
  }
101
101
  if (this.loadedDebug !== undefined && (patch.debugEnabled !== undefined || patch.debugTuning !== undefined)) {
@@ -140,11 +140,11 @@ export class SmoothStreamCardController {
140
140
  }
141
141
  }
142
142
 
143
- private baseValues(): Pick<StreamSettings, 'enabled' | 'thinkAutoExpand' | 'autoCollapse'> {
143
+ private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> {
144
144
  return this.stagedBase ?? {
145
145
  enabled: this.loaded?.enabled ?? DEFAULT_STREAM_SETTINGS.enabled,
146
+ controlScroll: this.loaded?.controlScroll ?? DEFAULT_STREAM_SETTINGS.controlScroll,
146
147
  thinkAutoExpand: this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
147
- autoCollapse: this.loaded?.autoCollapse ?? DEFAULT_STREAM_SETTINGS.autoCollapse,
148
148
  }
149
149
  }
150
150