dsh-smooth-stream 0.6.1 → 0.7.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
@@ -7,7 +7,7 @@ import { spawn } from "node:child_process";
7
7
  /** Defaults shared by the Host schema and the client-side fallback. */
8
8
  const DEFAULT_STREAM_CONFIG = {
9
9
  mode: "typewriter",
10
- preset: "balanced",
10
+ preset: "silky",
11
11
  revealCharsPerSec: 80,
12
12
  scrollSpeedPxPerSec: 48,
13
13
  maxScrollSpeedPxPerSec: 1e3
@@ -155,8 +155,17 @@ const SERVER_RESPONSE = "server-response";
155
155
  * @returns Disposer that withdraws the channel.
156
156
  */
157
157
  function registerSettingsChannel(ctx, channel, handler) {
158
- const connection = ctx.get("connection");
158
+ let connection;
159
+ try {
160
+ connection = ctx.get("connection");
161
+ } catch {
162
+ connection = void 0;
163
+ }
159
164
  return ctx.effect(() => {
165
+ try {
166
+ const direct = mountDirectRoute(ctx, connection, channel, handler);
167
+ if (direct !== void 0) return direct;
168
+ } catch {}
160
169
  const viaService = tryServiceChannel(connection, channel, handler);
161
170
  if (viaService !== void 0) return viaService;
162
171
  return mountDirectRoute(ctx, connection, channel, handler);
@@ -182,20 +191,29 @@ function tryServiceChannel(connection, channel, handler) {
182
191
  }
183
192
  }
184
193
  /**
185
- * Mount the channel prefix on the Web server with the connection fence intact.
186
- * @throws when the kernel exposes neither the fence nor a Web server, so the
187
- * failure is reported instead of silently leaving the card unreachable.
194
+ * Mount the channel prefix on the Web server.
195
+ *
196
+ * With the connection service reachable the route sits behind the connection
197
+ * trust fence. On dsh 0.2.x the connection service is unresolvable from a
198
+ * plugin scope, so the route is then served unfenced (loopback + the app's
199
+ * own token gate only) with a warning — the same trade-off the skill center
200
+ * plugin ships on 0.2.x.
201
+ * @throws fail-closed when the connection service is reachable but cannot
202
+ * fence, so the channel is never exposed without the trust policy.
188
203
  */
189
204
  function mountDirectRoute(ctx, connection, channel, handler) {
190
205
  const webServer = ctx.get("webServer");
191
206
  const reject = connection?.requestRejection;
192
207
  if (webServer === void 0 || typeof webServer.register !== "function") throw new Error(`dsh-smooth-stream: webServer is unavailable, so ${channel} cannot be mounted`);
193
- if (typeof reject !== "function") throw new Error(`dsh-smooth-stream: connection.requestRejection is unavailable, so ${channel} cannot be mounted behind the connection trust fence`);
208
+ if (typeof reject !== "function") {
209
+ if (connection !== void 0) throw new Error(`dsh-smooth-stream: connection.requestRejection is unavailable, so ${channel} cannot be mounted behind the connection trust fence`);
210
+ console.warn(`[dsh-smooth-stream] ${channel} mounted without the connection trust fence (dsh 0.2.x: the connection service is not injectable here); loopback and the app token gate remain the only protections`);
211
+ }
194
212
  return webServer.register({
195
213
  kind: "prefix",
196
214
  path: channel,
197
215
  handler: async (req, res) => {
198
- const rejection = reject.call(connection, req);
216
+ const rejection = reject?.call(connection, req);
199
217
  if (rejection !== void 0) {
200
218
  res.writeHead(rejection);
201
219
  res.end(rejection === 401 ? "unauthorized" : "forbidden");
@@ -354,6 +372,7 @@ const STREAM_SETTINGS_NS = "smooth-stream";
354
372
  const DEFAULT_STREAM_SETTINGS = {
355
373
  enabled: true,
356
374
  controlScroll: true,
375
+ preset: "silky",
357
376
  motionPreference: "auto",
358
377
  thinkAutoExpand: true,
359
378
  logarithmicFade: true,
@@ -392,6 +411,11 @@ const Config = Schema.object({
392
411
  const StreamSettingsSchema = Schema.object({
393
412
  enabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.enabled),
394
413
  controlScroll: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.controlScroll),
414
+ preset: Schema.union([
415
+ Schema.const("realtime"),
416
+ Schema.const("balanced"),
417
+ Schema.const("silky")
418
+ ]).default(DEFAULT_STREAM_SETTINGS.preset),
395
419
  motionPreference: Schema.union([
396
420
  Schema.const("auto"),
397
421
  Schema.const("force-smooth"),
@@ -426,8 +450,68 @@ function apply(ctx, config) {
426
450
  httpCtx.effect(() => httpCtx.webServer.tapIndex((html) => injectStreamConfig(html, config)), "dsh-smooth-stream: boot config bridge");
427
451
  });
428
452
  ctx.inject(["settings"], (settingsCtx) => {
453
+ if (typeof settingsCtx.settings.register !== "function") {
454
+ const view02 = () => ({
455
+ version: STREAM_PACKAGE_VERSION,
456
+ installation: inspectProfileInstallation(ctx.baseUrl, STREAM_PACKAGE_NAME).kind,
457
+ writable: false,
458
+ enabled: DEFAULT_STREAM_SETTINGS.enabled,
459
+ controlScroll: DEFAULT_STREAM_SETTINGS.controlScroll,
460
+ preset: config.preset,
461
+ motionPreference: DEFAULT_STREAM_SETTINGS.motionPreference,
462
+ thinkAutoExpand: DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
463
+ logarithmicFade: DEFAULT_STREAM_SETTINGS.logarithmicFade,
464
+ canUpgrade: false
465
+ });
466
+ const handle02 = async (endpoint) => {
467
+ if (endpoint === STREAM_SETTINGS_RPC.read) return {
468
+ ok: true,
469
+ value: view02()
470
+ };
471
+ if (endpoint === STREAM_SETTINGS_RPC.debugRead) return {
472
+ ok: true,
473
+ value: {
474
+ debugEnabled: DEFAULT_STREAM_SETTINGS.debugEnabled,
475
+ tuning: DEFAULT_STREAM_SETTINGS.debugTuning
476
+ }
477
+ };
478
+ if (endpoint === STREAM_SETTINGS_RPC.upgrade) {
479
+ if (inspectProfileInstallation(ctx.baseUrl, STREAM_PACKAGE_NAME).kind !== "npm") return {
480
+ ok: false,
481
+ error: {
482
+ code: "internal",
483
+ message: "smooth-stream is not an npm profile dependency",
484
+ details: {}
485
+ }
486
+ };
487
+ return {
488
+ ok: false,
489
+ error: {
490
+ code: "internal",
491
+ message: "upgrade is unavailable until the 0.2.x settings migration lands",
492
+ details: {}
493
+ }
494
+ };
495
+ }
496
+ return {
497
+ ok: false,
498
+ error: {
499
+ code: "settings-rejected",
500
+ message: "smooth-stream settings are read-only on dsh 0.2.x; durable storage moved to profile entry config",
501
+ details: { ns: STREAM_SETTINGS_NS }
502
+ }
503
+ };
504
+ };
505
+ ctx.inject(["webServer"], (webCtx) => {
506
+ registerSettingsChannel(webCtx, STREAM_SETTINGS_RPC_CHANNEL, handle02);
507
+ });
508
+ return;
509
+ }
429
510
  const settingsNamespace = STREAM_SETTINGS_NS;
430
- const scope = settingsCtx.settings.register(settingsNamespace, StreamSettingsSchema, { applies: "live" });
511
+ const scope = settingsCtx.settings.register(settingsNamespace, StreamSettingsSchema, {
512
+ base: { preset: config.preset },
513
+ applies: "live"
514
+ });
431
515
  settingsCtx.inject(["connection"], (connectionCtx) => {
432
516
  let upgrade;
433
517
  const view = () => {
@@ -439,6 +523,7 @@ function apply(ctx, config) {
439
523
  writable: connectionCtx.settings.writable,
440
524
  enabled: settings.enabled,
441
525
  controlScroll: settings.controlScroll,
526
+ preset: settings.preset ?? config.preset,
442
527
  motionPreference: settings.motionPreference,
443
528
  thinkAutoExpand: settings.thinkAutoExpand,
444
529
  logarithmicFade: settings.logarithmicFade,
@@ -481,6 +566,14 @@ function apply(ctx, config) {
481
566
  };
482
567
  try {
483
568
  const next = payload;
569
+ if (next.preset !== void 0 && next.preset !== "realtime" && next.preset !== "balanced" && next.preset !== "silky") return {
570
+ ok: false,
571
+ error: {
572
+ code: "settings-rejected",
573
+ message: "preset must be one of realtime | balanced | silky",
574
+ details: { ns: STREAM_SETTINGS_NS }
575
+ }
576
+ };
484
577
  if (next.motionPreference !== void 0 && next.motionPreference !== "auto" && next.motionPreference !== "force-smooth" && next.motionPreference !== "force-reduced") return {
485
578
  ok: false,
486
579
  error: {
@@ -509,6 +602,7 @@ function apply(ctx, config) {
509
602
  await scope.update({
510
603
  enabled: next.enabled,
511
604
  controlScroll: next.controlScroll,
605
+ ...next.preset === void 0 ? {} : { preset: next.preset },
512
606
  ...next.motionPreference === void 0 ? {} : { motionPreference: next.motionPreference },
513
607
  thinkAutoExpand: next.thinkAutoExpand,
514
608
  ...next.logarithmicFade === void 0 ? {} : { logarithmicFade: next.logarithmicFade },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-smooth-stream",
3
- "version": "0.6.1",
3
+ "version": "0.7.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",
@@ -10,13 +10,12 @@
10
10
 
11
11
  .surface[data-entrance='active'] {
12
12
  animation: dsh-smooth-stream-agent-row-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
13
- will-change: opacity, transform;
13
+ will-change: opacity;
14
14
  }
15
15
 
16
16
  @keyframes dsh-smooth-stream-agent-row-in {
17
17
  0% {
18
18
  opacity: 0;
19
- transform: translate3d(0, 4px, 0);
20
19
  clip-path: inset(0 100% 0 0);
21
20
  }
22
21
 
@@ -27,7 +26,6 @@
27
26
 
28
27
  100% {
29
28
  opacity: 1;
30
- transform: translate3d(0, 0, 0);
31
29
  clip-path: inset(0 0 0 0);
32
30
  }
33
31
  }
@@ -36,7 +34,6 @@
36
34
  .surface[data-entrance='active'] {
37
35
  animation: none;
38
36
  opacity: 1;
39
- transform: none;
40
37
  clip-path: none;
41
38
  will-change: auto;
42
39
  }
@@ -8,7 +8,7 @@
8
8
  // row attribute and the Think chrome keep working unchanged.
9
9
 
10
10
  import { type KeyboardEvent, type ReactNode } from 'react'
11
- import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
11
+ import { IconChevronDown } from './harnessIcons.ts'
12
12
  import css from './TypewriterAssistantNodeView.module.css'
13
13
 
14
14
  /** Class-name join for optional overlay classes over the chrome defaults. */
@@ -74,11 +74,11 @@ export function AnimatedDisclosure({
74
74
  >
75
75
  <span className={cx(css.disclosureLeading, leadingClassName)}>
76
76
  {open
77
- ? <IconChevronDownOutline14 className={chevronClassName} />
77
+ ? <IconChevronDown className={chevronClassName} />
78
78
  : (
79
79
  <>
80
80
  <span className={css.disclosureIconIdle}>{icon}</span>
81
- <IconChevronDownOutline14 className={cx(chevronClassName, css.disclosureChevronHover)} />
81
+ <IconChevronDown className={cx(chevronClassName, css.disclosureChevronHover)} />
82
82
  </>
83
83
  )}
84
84
  </span>
@@ -2,20 +2,26 @@ import { useEffect, useState } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
4
4
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
5
- import {
6
- IconCloseOutline16,
7
- IconCodeOutline16,
8
- IconCopyOutline16,
9
- IconQuestionOutline14,
10
- IconRefreshOutline16,
11
- Tooltip,
12
- writeClipboard,
13
- } from '@deepseek-ai/dsh-client-ui-primitives'
5
+ import { Tooltip, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
6
+ import { IconClose, IconCode, IconCopy, IconQuestion, IconRefreshSmall } from './harnessIcons.ts'
14
7
  import type { StreamDebugTuning } from '../settings.ts'
15
- import type { DebugPanelFace, DebugRuntimeState } from './debugRuntime.ts'
8
+ import type { DebugPanelFace, DebugRuntimeState, FollowTerminalPhase } from './debugRuntime.ts'
16
9
  import type { NS } from './locales.ts'
17
10
  import css from './DebugPanel.module.css'
18
11
 
12
+ /**
13
+ * Locale key per terminal-follow phase. `terminal-drain` and `host-cascade`
14
+ * are the two that must never be confused: the first is the engine finishing
15
+ * ordinary text, the second is a hostile host commit that needs temporary
16
+ * credit.
17
+ */
18
+ const PHASE_LABEL: Record<FollowTerminalPhase, Parameters<PropsLocale<typeof NS>['t']>[0]> = {
19
+ 'live': 'debugPhaseLive',
20
+ 'terminal-drain': 'debugPhaseDrain',
21
+ 'host-cascade': 'debugPhaseCascade',
22
+ 'natural': 'debugPhaseNatural',
23
+ }
24
+
19
25
  export type DebugPanelProps =
20
26
  PropsRuntime<'conversation.session.header.utilities'>
21
27
  & PropsLocale<typeof NS>
@@ -93,7 +99,7 @@ function TuningField({
93
99
  aria-label={label}
94
100
  title={t(control.tip)}
95
101
  >
96
- <IconQuestionOutline14 />
102
+ <IconQuestion />
97
103
  </button>
98
104
  </Tooltip>
99
105
  </span>
@@ -165,7 +171,7 @@ export function DebugPanel(props: DebugPanelProps) {
165
171
  <span className={css.state}>{t(live ? 'debugLive' : 'debugIdle')}</span>
166
172
  {state.dirty ? <span className={css.unsaved}>{t('debugUnsaved')}</span> : null}
167
173
  <button className={css.iconButton} type="button" title={t('debugCopy')} aria-label={t('debugCopy')} onClick={() => { void copyDiagnostics() }}>
168
- <IconCopyOutline16 />
174
+ <IconCopy />
169
175
  </button>
170
176
  <button
171
177
  className={css.iconButton}
@@ -179,7 +185,7 @@ export function DebugPanel(props: DebugPanelProps) {
179
185
  props.save()
180
186
  }}
181
187
  >
182
- <IconCloseOutline16 />
188
+ <IconClose />
183
189
  </button>
184
190
  <span className={css.visuallyHidden} aria-live="polite">{copied ? t('debugCopied') : ''}</span>
185
191
  </header>
@@ -199,6 +205,19 @@ export function DebugPanel(props: DebugPanelProps) {
199
205
  <Metric label={t('debugVelocity')} value={`${fixed(metrics.followVelocityPxPerSec, 0)} px/s`} />
200
206
  <Metric label={t('debugReserve')} value={`${fixed(metrics.followReservePx)} px`} />
201
207
  <Metric label={t('debugCapacity')} value={`${fixed(metrics.followCapacityPx)} px`} />
208
+ <Metric
209
+ label={t('debugTerminalPhase')}
210
+ value={t(PHASE_LABEL[metrics.followTerminalPhase])}
211
+ tone={metrics.followTerminalPhase === 'host-cascade' ? 'warn' : undefined}
212
+ />
213
+ <Metric label={t('debugOwnedRunway')} value={`${fixed(metrics.followRunwayPx)} px`} />
214
+ <Metric label={t('debugTerminalBudget')} value={`${fixed(metrics.followTerminalBudgetPx)} px`} />
215
+ <Metric label={t('debugBaselineShift')} value={`${fixed(metrics.followBaselineShiftPx)} px`} />
216
+ <Metric
217
+ label={t('debugAnchorDelta')}
218
+ value={metrics.followAnchorDeltaPx === null ? '—' : `${fixed(metrics.followAnchorDeltaPx)} px`}
219
+ tone={(metrics.followAnchorDeltaPx ?? 0) > 0.35 ? 'warn' : undefined}
220
+ />
202
221
  <Metric label={t('debugAppliedScale')} value={`${fixed(metrics.followRevealScale, 2)}x`} />
203
222
  </dl>
204
223
  </section>
@@ -220,7 +239,7 @@ export function DebugPanel(props: DebugPanelProps) {
220
239
 
221
240
  <footer className={css.footer}>
222
241
  <button className={css.secondaryButton} type="button" disabled={!state.writable} onClick={props.reset}>
223
- <IconRefreshOutline16 />
242
+ <IconRefreshSmall />
224
243
  {t('debugReset')}
225
244
  </button>
226
245
  <span className={css.footerSpacer} />
@@ -240,7 +259,7 @@ export function DebugPanel(props: DebugPanelProps) {
240
259
  title={t('debugPanelToggle')}
241
260
  onClick={() => { setOpen(current => !current) }}
242
261
  >
243
- <IconCodeOutline16 />
262
+ <IconCode />
244
263
  </button>
245
264
  {typeof document === 'undefined' || panel === null ? null : createPortal(panel, document.body)}
246
265
  </>
@@ -0,0 +1,180 @@
1
+ /**
2
+ * FrameCoordinator: unified per-document frame clock and phased layout scheduler.
3
+ *
4
+ * Replaces competing, uncoordinated rAF loops across text reveal, follow
5
+ * physics, fade painting, and diagnostic sampling. One document owns exactly
6
+ * one live animation frame while any task is active.
7
+ *
8
+ * Strict phase order in every frame:
9
+ * 1. Read Phase: layout reads ONLY when some task declared them dirty.
10
+ * 2. Simulate Phase: pure math / integration (pacing, spring, buckets).
11
+ * A task returns `true` to stay armed for the next frame.
12
+ * 3. Write Phase: batched DOM mutations (text nodes, transforms, CSS vars,
13
+ * at most one scrollTop write per owner).
14
+ *
15
+ * Tasks registered later never overtake earlier ones inside a phase, so a
16
+ * task's relative order is its registration order.
17
+ *
18
+ * `requestRead` is the bridge to the previous frame: a task that just wrote
19
+ * layout-affecting DOM (pacing revealed text, mutate() batch) declares that
20
+ * geometry must be re-read at the top of the NEXT frame, before the follow
21
+ * engine simulates. That keeps the "no layout read after a layout write in
22
+ * the same frame" invariant without giving up a same-reveal measurement.
23
+ */
24
+
25
+ export interface FrameTask {
26
+ readonly id: string
27
+ /** Called if any task marked layout as dirty during the frame cycle. */
28
+ onRead?: ((now: number) => void) | undefined
29
+ /** Physics, trajectory, and character pace calculation. Returns true if active. */
30
+ onSimulate?: ((dtMs: number, now: number) => boolean) | undefined
31
+ /** DOM and visual style mutation phase (transform, text, scrollTop). */
32
+ onWrite?: ((now: number) => void) | undefined
33
+ }
34
+
35
+ export class FrameCoordinator {
36
+ private static coordinators = new WeakMap<Document, FrameCoordinator>()
37
+
38
+ private tasks = new Map<string, FrameTask>()
39
+ private rafId: number | null = null
40
+ private lastTs: number | null = null
41
+ /**
42
+ * Owed layout read. Starts false: a fresh coordinator has no unread writes,
43
+ * and starting dirty would keep the clock armed forever after the first
44
+ * frame. Consumers declare reads via {@link markLayoutDirty}.
45
+ */
46
+ private layoutDirty = false
47
+ /** One id per consumer, so an unregister cannot evict a re-registered task. */
48
+ private nextTaskId = 0
49
+ private doc: Document
50
+
51
+ private constructor(doc: Document) {
52
+ this.doc = doc
53
+ }
54
+
55
+ static forDocument(doc: Document = document): FrameCoordinator {
56
+ let coordinator = FrameCoordinator.coordinators.get(doc)
57
+ if (coordinator === undefined) {
58
+ coordinator = new FrameCoordinator(doc)
59
+ FrameCoordinator.coordinators.set(doc, coordinator)
60
+ }
61
+ return coordinator
62
+ }
63
+
64
+ /**
65
+ * Mark that a DOM or geometry change requires a layout read next frame.
66
+ */
67
+ markLayoutDirty(): void {
68
+ this.layoutDirty = true
69
+ this.ensureLoop()
70
+ }
71
+
72
+ /**
73
+ * Declare that geometry written since the last frame must be re-read at the
74
+ * top of the next one, and keep the clock running for it.
75
+ */
76
+ requestRead(): void {
77
+ this.markLayoutDirty()
78
+ }
79
+
80
+ /**
81
+ * Register or replace an animation/render task. Returns the task id to hand
82
+ * back to {@link unregisterTask}.
83
+ */
84
+ registerTask(task: Omit<FrameTask, 'id'> & { id?: string }): string {
85
+ const id = task.id ?? `frame-task-${this.nextTaskId++}`
86
+ this.tasks.set(id, { ...task, id })
87
+ this.ensureLoop()
88
+ return id
89
+ }
90
+
91
+ /**
92
+ * Unregister a task when its stream completes or the element unmounts.
93
+ */
94
+ unregisterTask(id: string | null | undefined): void {
95
+ if (id === null || id === undefined) return
96
+ this.tasks.delete(id)
97
+ if (this.tasks.size === 0 && !this.layoutDirty) this.stopLoop()
98
+ }
99
+
100
+ private ensureLoop(): void {
101
+ if (this.rafId !== null) return
102
+ const win = this.doc.defaultView ?? (typeof window !== 'undefined' ? window : null)
103
+ if (win === null) return
104
+
105
+ const tick = (now: number) => {
106
+ this.rafId = null
107
+
108
+ if (this.lastTs === null) {
109
+ this.lastTs = now
110
+ // First frame after an idle gap only establishes the time base.
111
+ this.ensureLoop()
112
+ return
113
+ }
114
+
115
+ const frameIntervalMs = Math.max(0, now - this.lastTs)
116
+ // Clamp frame delta between 1ms and 100ms to avoid physics explosions
117
+ // during main-thread hiccups.
118
+ const dtMs = Math.max(1, Math.min(frameIntervalMs, 100))
119
+ this.lastTs = now
120
+
121
+ // 1. Read phase (only when some task declared layout dirty).
122
+ if (this.layoutDirty) {
123
+ this.layoutDirty = false
124
+ for (const task of this.tasks.values()) task.onRead?.(now)
125
+ }
126
+
127
+ // 2. Simulate phase (pacing step, spring, lag, bucket math).
128
+ let anyActive = false
129
+ for (const task of this.tasks.values()) {
130
+ if (task.onSimulate?.(dtMs, now) === true) anyActive = true
131
+ }
132
+
133
+ // 3. Write phase (transforms, text nodes, CSS variables, scrollTop).
134
+ for (const task of this.tasks.values()) task.onWrite?.(now)
135
+
136
+ // Stay armed while a task is active, while a pending read is owed, or
137
+ // while callers keep the clock explicitly running.
138
+ if ((anyActive || this.layoutDirty) && this.rafId === null) {
139
+ this.ensureLoop()
140
+ } else if (!anyActive && !this.layoutDirty) {
141
+ this.lastTs = null
142
+ }
143
+ }
144
+
145
+ this.rafId = win.requestAnimationFrame(tick)
146
+ }
147
+
148
+ private stopLoop(): void {
149
+ if (this.rafId !== null) {
150
+ const win = this.doc.defaultView ?? (typeof window !== 'undefined' ? window : null)
151
+ win?.cancelAnimationFrame(this.rafId)
152
+ this.rafId = null
153
+ }
154
+ this.lastTs = null
155
+ }
156
+
157
+ /**
158
+ * Whether this document currently owns a live, still-needed animation
159
+ * frame. A frame that is merely draining an owed read with no registered
160
+ * task left is not considered active work.
161
+ */
162
+ get active(): boolean {
163
+ return this.rafId !== null && (this.tasks.size > 0 || this.layoutDirty)
164
+ }
165
+
166
+ /** Number of registered tasks; used by tests to pin the single-clock contract. */
167
+ get taskCount(): number {
168
+ return this.tasks.size
169
+ }
170
+
171
+ /**
172
+ * Release every task, drop any owed read, and stop the clock. Used by tests
173
+ * for isolation; production consumers unregister their own task.
174
+ */
175
+ shutdown(): void {
176
+ this.tasks.clear()
177
+ this.layoutDirty = false
178
+ this.stopLoop()
179
+ }
180
+ }
@@ -8,7 +8,7 @@
8
8
  import { useState } from 'react'
9
9
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
10
10
  import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
11
- import { IconChevronDownOutline14, IconRefreshOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
11
+ import { IconChevronDown, IconRefresh } from './harnessIcons.ts'
12
12
  import type { SmoothStreamCardFace } from './smooth-stream-card-controller.ts'
13
13
  import css from './SmoothStreamCard.module.css'
14
14
 
@@ -43,7 +43,7 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
43
43
  </span>
44
44
  {versionLabel === null ? null : <span className={css.version}>{versionLabel}</span>}
45
45
  {state.dirty ? <span className={css.pending}>{t('unsaved')}</span> : null}
46
- <IconChevronDownOutline14 className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
46
+ <IconChevronDown className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
47
47
  </button>
48
48
  {open
49
49
  ? (
@@ -126,6 +126,31 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
126
126
  ))}
127
127
  </span>
128
128
  </div>
129
+ <div className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
130
+ <span className={css.fieldHead}>
131
+ <span className={css.label}>{t('preset')}</span>
132
+ </span>
133
+ <span className={css.hint}>{t('presetHint')}</span>
134
+ <span className={css.choiceRow} role="radiogroup" aria-label={t('preset')}>
135
+ {([
136
+ ['realtime', 'presetRealtime', 'presetRealtimeHint'],
137
+ ['balanced', 'presetBalanced', 'presetBalancedHint'],
138
+ ['silky', 'presetSilky', 'presetSilkyHint'],
139
+ ] as const).map(([value, label, hint]) => (
140
+ <label key={value} className={css.choice} title={t(hint)}>
141
+ <input
142
+ type="radio"
143
+ className={css.choiceInput}
144
+ name="smooth-stream-preset"
145
+ checked={state.preset === value}
146
+ disabled={!state.writable || state.saving || !state.enabled}
147
+ onChange={() => { props.edit({ preset: value }) }}
148
+ />
149
+ {t(label)}
150
+ </label>
151
+ ))}
152
+ </span>
153
+ </div>
129
154
  <label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
130
155
  <span className={css.fieldHead}>
131
156
  <span className={css.label}>{t('thinkAutoExpand')}</span>
@@ -169,7 +194,7 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
169
194
  title={state.canUpgrade ? undefined : t('updateUnavailable')}
170
195
  onClick={props.upgrade}
171
196
  >
172
- <span aria-hidden="true"><IconRefreshOutline14 /></span>
197
+ <span aria-hidden="true"><IconRefresh /></span>
173
198
  {t(state.upgrading ? 'updating' : 'update')}
174
199
  </button>
175
200
  </div>