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/client.js +1508 -362
- package/lib/client.js.map +1 -1
- package/lib/index.js +102 -8
- package/package.json +1 -1
- package/src/client/AgentRowEntrance.module.css +1 -4
- package/src/client/AnimatedDisclosure.tsx +3 -3
- package/src/client/DebugPanel.tsx +34 -15
- package/src/client/FrameCoordinator.ts +180 -0
- package/src/client/SmoothStreamCard.tsx +28 -3
- package/src/client/StreamBuffer.ts +145 -0
- package/src/client/TypewriterAssistantNodeView.module.css +82 -12
- package/src/client/TypewriterAssistantNodeView.tsx +216 -102
- package/src/client/debugRuntime.ts +58 -0
- package/src/client/harnessIcons.ts +27 -0
- package/src/client/index.ts +151 -11
- package/src/client/locales.ts +85 -0
- package/src/client/smooth-stream-card-controller.ts +7 -3
- package/src/client/smooth-stream-settings-api.ts +4 -2
- package/src/client/teleprompterGlide.ts +491 -52
- package/src/client/useDecoupledMarkdown.ts +173 -0
- package/src/client/useFpsGuard.ts +9 -6
- package/src/client/useLogarithmicFade.ts +439 -60
- package/src/client/useProgressiveDomText.ts +27 -11
- package/src/client/useSmoothStreamContent.ts +5 -13
- package/src/config.ts +1 -1
- package/src/plugin.ts +83 -8
- package/src/settings-api.ts +2 -0
- package/src/settings-channel.ts +34 -8
- package/src/settings.ts +11 -0
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: "
|
|
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
|
-
|
|
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
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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")
|
|
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
|
|
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, {
|
|
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.
|
|
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
|
|
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 {
|
|
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
|
-
? <
|
|
77
|
+
? <IconChevronDown className={chevronClassName} />
|
|
78
78
|
: (
|
|
79
79
|
<>
|
|
80
80
|
<span className={css.disclosureIconIdle}>{icon}</span>
|
|
81
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
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"><
|
|
197
|
+
<span aria-hidden="true"><IconRefresh /></span>
|
|
173
198
|
{t(state.upgrading ? 'updating' : 'update')}
|
|
174
199
|
</button>
|
|
175
200
|
</div>
|