dsh-smooth-stream 0.1.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 ADDED
@@ -0,0 +1,1288 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-smooth-stream",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
9
+ let _deepseek_ai_dsh_client_ui_attachment = require("@deepseek-ai/dsh-client-ui-attachment");
10
+ let react_jsx_runtime = require("react/jsx-runtime");
11
+ //#region \0dsh-css:/Users/dzlin/work/project/dsh-smooth-stream/src/client/TypewriterAssistantNodeView.module.css.mjs
12
+ const css = ".I17U7q_root{min-width:0;color:var(--dsw-alias-label-primary);flex-direction:column;font-size:16px;line-height:28px;display:flex}.I17U7q_body{flex-direction:column;gap:16px;min-width:0;display:flex}.I17U7q_think{flex-direction:column;display:flex}.I17U7q_thinkRow{position:relative;overflow:hidden}.I17U7q_think[data-state=running] .I17U7q_thinkRow:after{content:\"\";inset-block:0;background:linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, transparent 100%);pointer-events:none;width:300px;animation:2.6s ease-out infinite I17U7q_dsh-smooth-stream-think-sweep;position:absolute;left:0}@keyframes I17U7q_dsh-smooth-stream-think-sweep{0%{left:-300px}90%,to{left:100%}}.I17U7q_thinkLeading{flex-shrink:0}.I17U7q_thinkChevron{color:var(--dsw-alias-label-secondary)}.I17U7q_thinkTitle{font-weight:400}.I17U7q_thinkSeparator{background:var(--dsw-alias-label-caption);border-radius:1px;flex:none;width:2px;height:2px;margin:0 8px}.I17U7q_thinkSummary{min-width:0;color:var(--dsw-alias-label-tertiary);text-overflow:ellipsis;white-space:nowrap;flex:auto;font-size:14px;line-height:24px;overflow:hidden}.I17U7q_thinkSummary[data-follow-end]{text-overflow:clip}.I17U7q_thinkBody{color:var(--dsw-alias-label-tertiary);white-space:pre-wrap;word-break:break-word;padding:4px 0 4px 22px;font-size:14px;line-height:24px}.I17U7q_disclosureRoot{flex-direction:column;width:100%;min-width:0;display:flex}.I17U7q_disclosureRow{cursor:pointer;align-items:center;min-width:0;height:24px;display:flex;position:relative;overflow:hidden}.I17U7q_disclosureLeading{width:16px;height:16px;color:var(--dsw-alias-label-tertiary);flex:none;justify-content:center;align-items:center;margin-right:6px;display:inline-flex;position:relative}.I17U7q_disclosureIconIdle{opacity:1;transition:opacity .1s;display:inline-flex}.I17U7q_disclosureChevronHover{opacity:0;margin:auto;transition:opacity .1s;position:absolute;inset:0}.I17U7q_disclosureRow:hover .I17U7q_disclosureIconIdle{opacity:0}.I17U7q_disclosureRow:hover .I17U7q_disclosureChevronHover{opacity:1}.I17U7q_disclosureTitle{color:var(--dsw-alias-label-secondary);flex:none;font-size:14px;line-height:24px}.I17U7q_disclosureContent{visibility:visible;transition:grid-template-rows var(--ds-transition-duration,.2s) var(--ds-ease-in-out,cubic-bezier(.4, 0, .2, 1)), visibility 0s;grid-template-rows:1fr;display:grid}.I17U7q_disclosureContent[data-collapsed]{visibility:hidden;transition:grid-template-rows var(--ds-transition-duration,.2s) var(--ds-ease-in-out,cubic-bezier(.4, 0, .2, 1)), visibility 0s var(--ds-transition-duration,.2s);grid-template-rows:0fr}.I17U7q_disclosureContent>*{min-height:0;overflow:hidden}@media (prefers-reduced-motion:reduce){.I17U7q_think[data-state=running] .I17U7q_thinkRow:after{animation:none}.I17U7q_disclosureContent,.I17U7q_disclosureContent[data-collapsed]{transition:none}}.I17U7q_stopped{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-tertiary);border-radius:6px;align-self:flex-start;padding:0 6px;font-size:11px;line-height:18px}.I17U7q_visuallyHidden{clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.I17U7q_follow{min-width:0}@supports (text-box-trim:trim-both){.I17U7q_root :is(p,h1,h2,h3,h4,h5,h6,li,blockquote){text-box-trim:trim-both;text-box-edge:text}}";
13
+ const tagId = "dsh-smooth-stream/TypewriterAssistantNodeView.module.css";
14
+ if (typeof document !== "undefined") {
15
+ let tag = document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]");
16
+ if (tag === null) {
17
+ tag = document.createElement("style");
18
+ tag.dataset.plugin = "dsh-smooth-stream";
19
+ tag.dataset.pluginCss = tagId;
20
+ document.head.appendChild(tag);
21
+ }
22
+ tag.textContent = css;
23
+ }
24
+ var TypewriterAssistantNodeView_module_css_default = {
25
+ "disclosureChevronHover": "I17U7q_disclosureChevronHover",
26
+ "think": "I17U7q_think",
27
+ "thinkSeparator": "I17U7q_thinkSeparator",
28
+ "disclosureTitle": "I17U7q_disclosureTitle",
29
+ "thinkSummary": "I17U7q_thinkSummary",
30
+ "dsh-smooth-stream-think-sweep": "I17U7q_dsh-smooth-stream-think-sweep",
31
+ "disclosureRoot": "I17U7q_disclosureRoot",
32
+ "disclosureIconIdle": "I17U7q_disclosureIconIdle",
33
+ "disclosureContent": "I17U7q_disclosureContent",
34
+ "thinkLeading": "I17U7q_thinkLeading",
35
+ "thinkRow": "I17U7q_thinkRow",
36
+ "thinkChevron": "I17U7q_thinkChevron",
37
+ "disclosureRow": "I17U7q_disclosureRow",
38
+ "disclosureLeading": "I17U7q_disclosureLeading",
39
+ "thinkTitle": "I17U7q_thinkTitle",
40
+ "stopped": "I17U7q_stopped",
41
+ "thinkBody": "I17U7q_thinkBody",
42
+ "visuallyHidden": "I17U7q_visuallyHidden",
43
+ "body": "I17U7q_body",
44
+ "follow": "I17U7q_follow",
45
+ "root": "I17U7q_root"
46
+ };
47
+ //#endregion
48
+ //#region src/client/AnimatedDisclosure.tsx
49
+ /** Class-name join for optional overlay classes over the chrome defaults. */
50
+ function cx(...parts) {
51
+ return parts.filter((part) => part !== void 0 && part !== "").join(" ");
52
+ }
53
+ /**
54
+ * Render one disclosure header whose expanded body is height-animated.
55
+ * @param props - Visual content, controlled open state, and the toggle
56
+ * callback fired by row click and Enter/Space.
57
+ * @returns the animated disclosure row.
58
+ */
59
+ function AnimatedDisclosure({ icon, title, open, onToggle, collapsedContent, children, rowClassName, leadingClassName, titleClassName, chevronClassName }) {
60
+ const toggleFromKeyboard = (event) => {
61
+ if (event.key !== "Enter" && event.key !== " ") return;
62
+ event.preventDefault();
63
+ onToggle();
64
+ };
65
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
66
+ className: TypewriterAssistantNodeView_module_css_default.disclosureRoot,
67
+ "data-open": open || void 0,
68
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
69
+ className: cx(TypewriterAssistantNodeView_module_css_default.disclosureRow, rowClassName),
70
+ "data-disclosure-row": true,
71
+ "data-expandable": "",
72
+ role: "button",
73
+ tabIndex: 0,
74
+ "aria-expanded": open,
75
+ onClick: onToggle,
76
+ onKeyDown: toggleFromKeyboard,
77
+ children: [
78
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
79
+ className: cx(TypewriterAssistantNodeView_module_css_default.disclosureLeading, leadingClassName),
80
+ children: open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { className: chevronClassName }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
81
+ className: TypewriterAssistantNodeView_module_css_default.disclosureIconIdle,
82
+ children: icon
83
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { className: cx(chevronClassName, TypewriterAssistantNodeView_module_css_default.disclosureChevronHover) })] })
84
+ }),
85
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
86
+ className: cx(TypewriterAssistantNodeView_module_css_default.disclosureTitle, titleClassName),
87
+ children: title
88
+ }),
89
+ !open && collapsedContent
90
+ ]
91
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
92
+ className: TypewriterAssistantNodeView_module_css_default.disclosureContent,
93
+ "data-disclosure-content": true,
94
+ "data-collapsed": open ? void 0 : "",
95
+ children
96
+ })]
97
+ });
98
+ }
99
+ //#endregion
100
+ //#region src/client/useSmoothStreamContent.ts
101
+ /**
102
+ * Stream-smoothing reveal hook.
103
+ *
104
+ * Buffers the model's chunked text and reveals it at a cadence that tracks
105
+ * the observed arrival rate, so a long reply never dumps whole paragraphs at
106
+ * once and a fast stream never stutters. Port of lobe-ui's smoother: EMA
107
+ * arrival cps + chunk size, backlog pressure, commit-interval widening with
108
+ * tail length, and a flush-speed settle drain once the input idles. The
109
+ * reveal decision is the pure {@link computeRevealStep} for unit tests.
110
+ *
111
+ * `shouldHoldBack` is the performance guard's veto: while it returns true the
112
+ * loop keeps measuring but skips the DOM commit, so an offscreen reply never
113
+ * competes with visible frames when the frame rate is degraded.
114
+ */
115
+ const PRESET_CONFIG = {
116
+ balanced: {
117
+ activeInputWindowMs: 220,
118
+ defaultCps: 80,
119
+ emaAlpha: .35,
120
+ flushCps: 180,
121
+ largeAppendChars: 120,
122
+ maxActiveCps: 360,
123
+ maxCps: 240,
124
+ maxFlushCps: 480,
125
+ minCommitIntervalMs: 16,
126
+ minCps: 24,
127
+ settleAfterMs: 280,
128
+ settleDrainMaxMs: 420,
129
+ settleDrainMinMs: 120,
130
+ targetBufferMs: 40
131
+ },
132
+ realtime: {
133
+ activeInputWindowMs: 140,
134
+ defaultCps: 120,
135
+ emaAlpha: .45,
136
+ flushCps: 240,
137
+ largeAppendChars: 180,
138
+ maxActiveCps: 480,
139
+ maxCps: 320,
140
+ maxFlushCps: 640,
141
+ minCommitIntervalMs: 16,
142
+ minCps: 32,
143
+ settleAfterMs: 200,
144
+ settleDrainMaxMs: 280,
145
+ settleDrainMinMs: 100,
146
+ targetBufferMs: 24
147
+ },
148
+ silky: {
149
+ activeInputWindowMs: 280,
150
+ defaultCps: 64,
151
+ emaAlpha: .28,
152
+ flushCps: 140,
153
+ largeAppendChars: 100,
154
+ maxActiveCps: 280,
155
+ maxCps: 180,
156
+ maxFlushCps: 400,
157
+ minCommitIntervalMs: 16,
158
+ minCps: 20,
159
+ settleAfterMs: 360,
160
+ settleDrainMaxMs: 520,
161
+ settleDrainMinMs: 160,
162
+ targetBufferMs: 56
163
+ }
164
+ };
165
+ const MAX_COMMIT_INTERVAL_MS = 16;
166
+ const QUEUE_FRAME_MS = 16.67;
167
+ const CATCHUP_SECONDS = .15;
168
+ const clamp$1 = (value, min, max) => {
169
+ return Math.min(max, Math.max(min, value));
170
+ };
171
+ /**
172
+ * Demo per-frame reveal: drain `backlog / 8` characters, at least 1, scaled
173
+ * by frame time. A small queue types one glyph per frame; a burst raises the
174
+ * step so the display does not fall behind.
175
+ * @param backlog - Unrevealed characters.
176
+ * @param dtMs - Frame delta in ms.
177
+ * @returns Characters to reveal this frame.
178
+ */
179
+ function computeQueueReveal(backlog, dtMs) {
180
+ if (backlog <= 0 || dtMs <= 0) return 0;
181
+ return Math.min(backlog, Math.max(1, Math.ceil(backlog / 8 * (dtMs / QUEUE_FRAME_MS))));
182
+ }
183
+ /** Counts user-perceived characters (code points), not UTF-16 units. */
184
+ const countChars = (text) => {
185
+ let count = 0;
186
+ for (const char of text) count += 1;
187
+ return count;
188
+ };
189
+ /** Pure settle-drain decision shared by the frame loop and its tests. */
190
+ function computeSettleDrain(config, input) {
191
+ if (input.inputActive || !input.settling) return 0;
192
+ const overflowCps = Math.max(0, input.backlog - 300) * 1e3 / 2;
193
+ const drainTargetMs = clamp$1(input.backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs);
194
+ const settleCps = input.backlog * 1e3 / drainTargetMs;
195
+ return clamp$1(Math.max(settleCps, overflowCps), config.flushCps, config.maxFlushCps);
196
+ }
197
+ /**
198
+ * Drain rate multiplier once the input ends: leftover backlog reveals at
199
+ * this multiple of the steady rate, so the end never drags.
200
+ */
201
+ const SETTLE_DRAIN_MULTIPLIER = 1.8;
202
+ /** Pure per-frame reveal decision shared by the loop and its tests. */
203
+ function computeRevealStep(config, input, dtSeconds) {
204
+ const trackedCps = Math.max(input.emaCps, input.arrivalCpsEma);
205
+ const baseCps = clamp$1(trackedCps, config.minCps, config.maxFlushCps);
206
+ const targetLagChars = input.inputActive ? Math.max(2, Math.round(baseCps * config.targetBufferMs / 1e3)) : 0;
207
+ let currentCps;
208
+ if (input.steadyCps !== void 0) currentCps = input.inputActive || input.settling ? clamp$1(input.steadyCps * (input.inputActive ? 1 : SETTLE_DRAIN_MULTIPLIER), config.minCps, config.maxFlushCps) : 0;
209
+ else if (input.inputActive) {
210
+ const overflow = Math.max(0, input.backlog - 32);
211
+ const catchup = overflow > 0 ? overflow / CATCHUP_SECONDS : 0;
212
+ currentCps = clamp$1(baseCps * 1.08 + catchup, config.minCps, config.maxFlushCps);
213
+ } else if (input.settling) currentCps = computeSettleDrain(config, input);
214
+ else {
215
+ const idleFlushCps = Math.max(config.flushCps, baseCps * 1.8, input.arrivalCpsEma * .8);
216
+ currentCps = clamp$1(idleFlushCps, config.flushCps, config.maxFlushCps);
217
+ }
218
+ const minRevealChars = input.inputActive ? 1 : 2;
219
+ return {
220
+ revealChars: Math.max(minRevealChars, Math.round(currentCps * dtSeconds)),
221
+ targetLagChars
222
+ };
223
+ }
224
+ /**
225
+ * Smooth a chunked content stream into a reveal-paced display string.
226
+ *
227
+ * @param content - The full accumulated input so far.
228
+ * @param options - Preset, guard, and steady-rate wiring.
229
+ * @returns The displayed content, revealed at the smoothed cadence.
230
+ */
231
+ function useSmoothStreamContent(content, { enabled = true, preset = "balanced", shouldHoldBack, steadyCps, defaultCps, speedCpsRef } = {}) {
232
+ const config = PRESET_CONFIG[preset];
233
+ const seedCps = defaultCps ?? config.defaultCps;
234
+ const [displayedContent, setDisplayedContent] = (0, react.useState)(content);
235
+ const displayedContentRef = (0, react.useRef)(content);
236
+ const displayedCountRef = (0, react.useRef)(countChars(content));
237
+ const targetContentRef = (0, react.useRef)(content);
238
+ const targetCharsRef = (0, react.useRef)([...content]);
239
+ const targetCountRef = (0, react.useRef)(countChars(content));
240
+ const emaCpsRef = (0, react.useRef)(seedCps);
241
+ const lastInputTsRef = (0, react.useRef)(0);
242
+ const lastInputCountRef = (0, react.useRef)(countChars(content));
243
+ const chunkSizeEmaRef = (0, react.useRef)(1);
244
+ const arrivalCpsEmaRef = (0, react.useRef)(seedCps);
245
+ const rafRef = (0, react.useRef)(null);
246
+ const lastFrameTsRef = (0, react.useRef)(null);
247
+ const wakeTimerRef = (0, react.useRef)(null);
248
+ const holdBackRef = (0, react.useRef)(shouldHoldBack);
249
+ const speedOutRef = (0, react.useRef)(speedCpsRef);
250
+ speedOutRef.current = speedCpsRef;
251
+ (0, react.useEffect)(() => {
252
+ holdBackRef.current = shouldHoldBack;
253
+ }, [shouldHoldBack]);
254
+ const clearWakeTimer = (0, react.useCallback)(() => {
255
+ if (wakeTimerRef.current !== null) {
256
+ clearTimeout(wakeTimerRef.current);
257
+ wakeTimerRef.current = null;
258
+ }
259
+ }, []);
260
+ const stopFrameLoop = (0, react.useCallback)(() => {
261
+ if (rafRef.current !== null) {
262
+ cancelAnimationFrame(rafRef.current);
263
+ rafRef.current = null;
264
+ }
265
+ lastFrameTsRef.current = null;
266
+ }, []);
267
+ const stopScheduling = (0, react.useCallback)(() => {
268
+ stopFrameLoop();
269
+ clearWakeTimer();
270
+ }, [clearWakeTimer, stopFrameLoop]);
271
+ const startFrameLoopRef = (0, react.useRef)(() => {});
272
+ const scheduleFrameWake = (0, react.useCallback)((delayMs) => {
273
+ clearWakeTimer();
274
+ wakeTimerRef.current = setTimeout(() => {
275
+ wakeTimerRef.current = null;
276
+ startFrameLoopRef.current();
277
+ }, Math.max(1, Math.ceil(delayMs)));
278
+ }, [clearWakeTimer]);
279
+ const syncImmediate = (0, react.useCallback)((nextContent) => {
280
+ stopScheduling();
281
+ const chars = [...nextContent];
282
+ const now = performance.now();
283
+ targetContentRef.current = nextContent;
284
+ targetCharsRef.current = chars;
285
+ targetCountRef.current = chars.length;
286
+ displayedContentRef.current = nextContent;
287
+ displayedCountRef.current = chars.length;
288
+ setDisplayedContent(nextContent);
289
+ emaCpsRef.current = seedCps;
290
+ chunkSizeEmaRef.current = 1;
291
+ arrivalCpsEmaRef.current = seedCps;
292
+ lastInputTsRef.current = now;
293
+ lastInputCountRef.current = chars.length;
294
+ }, [seedCps, stopScheduling]);
295
+ const startFrameLoop = (0, react.useCallback)(() => {
296
+ clearWakeTimer();
297
+ if (rafRef.current !== null) return;
298
+ const tick = () => {
299
+ const targetCount = targetCountRef.current;
300
+ const displayedCount = displayedCountRef.current;
301
+ const backlog = targetCount - displayedCount;
302
+ if (backlog <= 0) {
303
+ stopFrameLoop();
304
+ return;
305
+ }
306
+ const now = performance.now();
307
+ if (lastFrameTsRef.current === null) {
308
+ lastFrameTsRef.current = now;
309
+ rafRef.current = requestAnimationFrame(tick);
310
+ return;
311
+ }
312
+ const commitIntervalMs = Math.min(MAX_COMMIT_INTERVAL_MS, config.minCommitIntervalMs);
313
+ const frameIntervalMs = Math.max(0, now - lastFrameTsRef.current);
314
+ if (frameIntervalMs < commitIntervalMs) {
315
+ rafRef.current = requestAnimationFrame(tick);
316
+ return;
317
+ }
318
+ const dtSeconds = Math.max(.001, Math.min(frameIntervalMs / 1e3, .12));
319
+ lastFrameTsRef.current = now;
320
+ const idleMs = now - lastInputTsRef.current;
321
+ const inputActive = idleMs <= config.activeInputWindowMs;
322
+ const settling = !inputActive && idleMs >= config.settleAfterMs;
323
+ let revealChars;
324
+ if (steadyCps !== void 0) {
325
+ const step = computeRevealStep(config, {
326
+ backlog,
327
+ chunkSizeEma: chunkSizeEmaRef.current,
328
+ arrivalCpsEma: arrivalCpsEmaRef.current,
329
+ emaCps: emaCpsRef.current,
330
+ inputActive,
331
+ settling,
332
+ steadyCps
333
+ }, dtSeconds);
334
+ revealChars = Math.min(step.revealChars, backlog);
335
+ } else revealChars = computeQueueReveal(backlog, frameIntervalMs);
336
+ if (holdBackRef.current?.() === true) {
337
+ rafRef.current = requestAnimationFrame(tick);
338
+ return;
339
+ }
340
+ const speedOut = speedOutRef.current;
341
+ if (speedOut !== void 0 && frameIntervalMs > 0) {
342
+ const instantCps = revealChars * 1e3 / frameIntervalMs;
343
+ speedOut.current = speedOut.current * .92 + instantCps * .08;
344
+ }
345
+ const nextCount = displayedCount + revealChars;
346
+ const segment = targetCharsRef.current.slice(displayedCount, nextCount).join("");
347
+ if (segment) {
348
+ const nextDisplayed = displayedContentRef.current + segment;
349
+ displayedContentRef.current = nextDisplayed;
350
+ displayedCountRef.current = nextCount;
351
+ setDisplayedContent(nextDisplayed);
352
+ } else {
353
+ displayedContentRef.current = targetContentRef.current;
354
+ displayedCountRef.current = targetCount;
355
+ setDisplayedContent(targetContentRef.current);
356
+ }
357
+ rafRef.current = requestAnimationFrame(tick);
358
+ };
359
+ rafRef.current = requestAnimationFrame(tick);
360
+ }, [
361
+ config,
362
+ scheduleFrameWake,
363
+ stopFrameLoop,
364
+ clearWakeTimer,
365
+ steadyCps
366
+ ]);
367
+ (0, react.useEffect)(() => {
368
+ startFrameLoopRef.current = startFrameLoop;
369
+ }, [startFrameLoop]);
370
+ (0, react.useEffect)(() => {
371
+ if (!enabled) {
372
+ syncImmediate(content);
373
+ return;
374
+ }
375
+ const prevTargetContent = targetContentRef.current;
376
+ if (content === prevTargetContent) return;
377
+ const now = performance.now();
378
+ if (!content.startsWith(prevTargetContent)) {
379
+ syncImmediate(content);
380
+ return;
381
+ }
382
+ const appendedChars = [...content.slice(prevTargetContent.length)];
383
+ const appendedCount = appendedChars.length;
384
+ targetContentRef.current = content;
385
+ targetCharsRef.current.push(...appendedChars);
386
+ targetCountRef.current += appendedCount;
387
+ const hadSample = lastInputTsRef.current > 0;
388
+ const deltaChars = targetCountRef.current - lastInputCountRef.current;
389
+ const deltaMs = Math.max(1, now - lastInputTsRef.current);
390
+ if (hadSample && deltaChars > 0) {
391
+ const instantCps = deltaChars * 1e3 / deltaMs;
392
+ const normalizedInstantCps = clamp$1(instantCps, config.minCps, config.maxFlushCps * 3);
393
+ const chunkEmaAlpha = .45;
394
+ chunkSizeEmaRef.current = chunkSizeEmaRef.current * .55 + appendedCount * chunkEmaAlpha;
395
+ arrivalCpsEmaRef.current = arrivalCpsEmaRef.current * .55 + normalizedInstantCps * chunkEmaAlpha;
396
+ emaCpsRef.current = emaCpsRef.current * (1 - config.emaAlpha) + normalizedInstantCps * config.emaAlpha;
397
+ }
398
+ lastInputTsRef.current = now;
399
+ lastInputCountRef.current = targetCountRef.current;
400
+ startFrameLoop();
401
+ }, [
402
+ content,
403
+ enabled,
404
+ config,
405
+ startFrameLoop,
406
+ syncImmediate
407
+ ]);
408
+ (0, react.useEffect)(() => {
409
+ return () => {
410
+ stopScheduling();
411
+ };
412
+ }, [stopScheduling]);
413
+ return displayedContent;
414
+ }
415
+ //#endregion
416
+ //#region src/client/useFpsGuard.ts
417
+ /**
418
+ * Performance guard for the streaming reveal.
419
+ *
420
+ * Feeds an EMA-smoothed frame-rate monitor from a rAF loop while streaming
421
+ * and tracks whether the reply is on-screen. The returned `shouldHoldBack`
422
+ * predicate is true only while the frame rate is below the threshold AND the
423
+ * reply is offscreen — exactly the spec's "skip offscreen DOM updates when
424
+ * FPS < 30" rule. The smoother consumes the predicate as its commit veto.
425
+ */
426
+ const FPS_THRESHOLD = 30;
427
+ const FPS_ALPHA = .12;
428
+ const RECOVER_FRAMES = 6;
429
+ const MAX_FRAME_MS = 100;
430
+ function useFpsGuard(active) {
431
+ const fpsRef = (0, react.useRef)({
432
+ emaMs: 0,
433
+ lastMs: 0,
434
+ healthyRun: 0,
435
+ degraded: false
436
+ });
437
+ const visibleRef = (0, react.useRef)(true);
438
+ const elementRef = (0, react.useRef)(null);
439
+ (0, react.useEffect)(() => {
440
+ if (!active) return;
441
+ let rafId = 0;
442
+ const frame = (now) => {
443
+ rafId = requestAnimationFrame(frame);
444
+ const fps = fpsRef.current;
445
+ if (fps.lastMs === 0) {
446
+ fps.lastMs = now;
447
+ return;
448
+ }
449
+ const delta = Math.min(MAX_FRAME_MS, Math.max(1, now - fps.lastMs));
450
+ fps.lastMs = now;
451
+ fps.emaMs = fps.emaMs === 0 ? delta : fps.emaMs + FPS_ALPHA * (delta - fps.emaMs);
452
+ if (1e3 / fps.emaMs < FPS_THRESHOLD) {
453
+ fps.healthyRun = 0;
454
+ fps.degraded = true;
455
+ } else if (fps.degraded) {
456
+ fps.healthyRun += 1;
457
+ if (fps.healthyRun >= RECOVER_FRAMES) fps.degraded = false;
458
+ }
459
+ };
460
+ rafId = requestAnimationFrame(frame);
461
+ return () => {
462
+ cancelAnimationFrame(rafId);
463
+ fpsRef.current = {
464
+ emaMs: 0,
465
+ lastMs: 0,
466
+ healthyRun: 0,
467
+ degraded: false
468
+ };
469
+ };
470
+ }, [active]);
471
+ const ref = (0, react.useCallback)((element) => {
472
+ elementRef.current = element;
473
+ }, []);
474
+ (0, react.useEffect)(() => {
475
+ const element = elementRef.current;
476
+ if (element === null || typeof IntersectionObserver === "undefined") return;
477
+ const observer = new IntersectionObserver((entries) => {
478
+ for (const entry of entries) visibleRef.current = entry.isIntersecting;
479
+ }, { rootMargin: "120px 0px" });
480
+ observer.observe(element);
481
+ return () => observer.disconnect();
482
+ });
483
+ return {
484
+ ref,
485
+ shouldHoldBack: (0, react.useCallback)(() => {
486
+ return active && fpsRef.current.degraded && !visibleRef.current;
487
+ }, [active])
488
+ };
489
+ }
490
+ //#endregion
491
+ //#region src/client/teleprompterGlide.ts
492
+ /**
493
+ * Conversation-port follow while an assistant reply streams.
494
+ *
495
+ * The demo's damped-spring lerp is driven from a float `animatedH` — the
496
+ * smoothed content height — never from the rounded `scrollTop` the browser
497
+ * reports back. A wrap raises the content height by a line; if the engine
498
+ * were allowed to snap `scrollTop` to the new floor, the previous line would
499
+ * hop up in one frame. This overlay:
500
+ *
501
+ * - owns follow via `data-follow-owned` so ChatView does not snap;
502
+ * - sets `overflow-anchor: none` so CSS scroll-anchoring does not snap;
503
+ * - restores `animatedH` in a ResizeObserver (before paint) so a layout
504
+ * pass cannot flash a snapped frame;
505
+ * - while the port has scroll room, writes the interpolated lag as
506
+ * `translate3d` on `[data-chat-transcript]` (message rows only), so the
507
+ * turn-status chrome stays pinned at the floor;
508
+ * - before the port has scroll room (content shorter than the viewport), the
509
+ * same lag is a content-height lag written as a negative translate on
510
+ * `[data-chat-turn-status]`, so the status label descends smoothly with
511
+ * each wrap instead of hopping a line at a time.
512
+ *
513
+ * Handing the port back to the reader (unpin, or the owner going inactive)
514
+ * first writes the effective visual top (`engine - lag`) into `scrollTop`
515
+ * before clearing the transform, so the frame stays continuous. That write
516
+ * also lands in ChatView's observed-top ledger as reader input beyond
517
+ * `FOLLOW_THRESHOLD`, releasing its native snap-follow instead of fighting
518
+ * the gesture for the port.
519
+ *
520
+ * Unpin is a real gesture (wheel / touch / pointer / key) that leaves the
521
+ * floor; a `scrollTop` delta from our own write must not release the pin.
522
+ */
523
+ /**
524
+ * Matches ChatView's `FOLLOW_OWNED_ATTR`. The overlay writes the last
525
+ * programmatic `scrollTop` here so ChatView yields snap-follow and does not
526
+ * treat the write as reader input.
527
+ */
528
+ const FOLLOW_OWNED_ATTR = "data-follow-owned";
529
+ /** Floor / ceiling of the per-frame lerp fraction before the dt term. */
530
+ const FOLLOW_LERP_MIN = .05;
531
+ const FOLLOW_LERP_MAX = .25;
532
+ const FOLLOW_SPEED_FACTOR_MIN = .7;
533
+ const FOLLOW_SPEED_FACTOR_MAX = 2.2;
534
+ const GESTURE_EVENTS = [
535
+ "wheel",
536
+ "touchmove",
537
+ "pointerdown",
538
+ "keydown"
539
+ ];
540
+ function clamp(value, min, max) {
541
+ return Math.min(max, Math.max(min, value));
542
+ }
543
+ /**
544
+ * One spring-lerp frame from the silky markdown demo.
545
+ * @param dtMs - Frame delta in ms.
546
+ * @param input - Current lag (from the float top, not the rounded engine top) and reveal-speed EMA.
547
+ * @returns The fractional advance and the lerp fraction.
548
+ */
549
+ function computeFollowStep(dtMs, input) {
550
+ if (input.lag <= .1 || dtMs <= 0) return {
551
+ advancePx: 0,
552
+ lerpStep: 0
553
+ };
554
+ const speedFactor = clamp((input.speedEma > 0 ? input.speedEma : 35) / 35, FOLLOW_SPEED_FACTOR_MIN, FOLLOW_SPEED_FACTOR_MAX);
555
+ const lerpStep = clamp(input.lag / 160 * speedFactor, FOLLOW_LERP_MIN, FOLLOW_LERP_MAX) * (1 - Math.exp(-dtMs / 18));
556
+ return {
557
+ advancePx: input.lag * lerpStep,
558
+ lerpStep
559
+ };
560
+ }
561
+ /** The message-rows box the lag transform rides on, when the host has one. */
562
+ function shiftRootOf(port) {
563
+ return port.querySelector("[data-chat-transcript]");
564
+ }
565
+ /**
566
+ * Row wrappers that carry only messages/tools. Hosts without a transcript
567
+ * box keep the turn-status chrome as a sibling of the rows inside
568
+ * `[data-chat-flow]`, so shifting that whole flow would drag the chrome
569
+ * along; shifting the rows individually leaves every non-message sibling
570
+ * (turn status, steering bubbles) pinned while the text glides.
571
+ *
572
+ * Only the outermost rows are shifted: a tool call nests its subcalls as
573
+ * descendant `[data-chat-anchor-key]` rows, and writing the lag on each of
574
+ * them would double (or further multiply) the shift, tearing the subcalls
575
+ * away from their parent every frame. Descendants ride the parent's
576
+ * transform instead.
577
+ */
578
+ function shiftRowsOf(port) {
579
+ return [...port.querySelectorAll("[data-chat-anchor-key]")].filter((row) => row.parentElement?.closest("[data-chat-anchor-key]") === null);
580
+ }
581
+ /** Element whose resize signals flow growth for the before-paint restore. */
582
+ function resizeProxyOf(port) {
583
+ return port.querySelector("[data-chat-transcript]") ?? port.querySelector("[data-chat-flow]");
584
+ }
585
+ /** True when the glide expresses its lag as transforms instead of engine top. */
586
+ function hasShiftSurface(port) {
587
+ return shiftRootOf(port) !== null || shiftRowsOf(port).length > 0;
588
+ }
589
+ function setShift(element, px, negative = false) {
590
+ const value = negative ? -px : px;
591
+ if (Math.abs(value) > .01) {
592
+ element.style.transform = `translate3d(0, ${value}px, 0)`;
593
+ element.style.willChange = "transform";
594
+ } else {
595
+ element.style.transform = "";
596
+ element.style.willChange = "";
597
+ }
598
+ }
599
+ /**
600
+ * The running-turn label. Before the port scrolls (content shorter than the
601
+ * viewport) the content-height lag has no `scrollTop` room to ride, so it is
602
+ * expressed as a negative translate on this sibling: the label descends
603
+ * smoothly with each wrap instead of hopping down a line at a time.
604
+ */
605
+ function chromeShiftOf(port) {
606
+ return port.querySelector("[data-chat-turn-status]");
607
+ }
608
+ /**
609
+ * Render the single smoothed extent `animatedH`. Before the port scrolls
610
+ * (content shorter than the viewport) `animatedH` is clamped to the content
611
+ * height and the content-height lag rides a negative translate on the
612
+ * turn-status label; once the port has scroll room the engine is pinned at
613
+ * the floor and the lag rides `[data-chat-transcript]` (or each message row
614
+ * when the host has no transcript box) so the chrome stays put. Without any
615
+ * shift surface the engine itself carries the interpolated top.
616
+ */
617
+ function applyVisual(port, animatedH) {
618
+ const contentHeight = Math.max(0, port.scrollHeight);
619
+ const floor = Math.max(0, contentHeight - port.clientHeight);
620
+ const extent = Math.min(contentHeight, Math.max(0, animatedH));
621
+ const lag = contentHeight - extent;
622
+ port.style.overflowAnchor = "none";
623
+ port.style.scrollBehavior = "auto";
624
+ const shiftRoot = shiftRootOf(port);
625
+ if (shiftRoot !== null) {
626
+ if (floor <= 0) {
627
+ port.scrollTop = 0;
628
+ port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop));
629
+ const chrome = chromeShiftOf(port);
630
+ if (chrome !== null) setShift(chrome, lag, true);
631
+ setShift(shiftRoot, 0);
632
+ return;
633
+ }
634
+ port.scrollTop = floor;
635
+ port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop));
636
+ setShift(shiftRoot, lag);
637
+ return;
638
+ }
639
+ const rows = shiftRowsOf(port);
640
+ if (rows.length > 0) {
641
+ if (floor <= 0) {
642
+ port.scrollTop = 0;
643
+ port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop));
644
+ const chrome = chromeShiftOf(port);
645
+ if (chrome !== null) setShift(chrome, lag, true);
646
+ for (const row of rows) setShift(row, 0);
647
+ return;
648
+ }
649
+ port.scrollTop = floor;
650
+ port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop));
651
+ for (const row of rows) setShift(row, lag);
652
+ return;
653
+ }
654
+ port.scrollTop = Math.min(floor, Math.max(0, extent));
655
+ port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop));
656
+ }
657
+ function clearVisual(port) {
658
+ port.removeAttribute(FOLLOW_OWNED_ATTR);
659
+ port.style.overflowAnchor = "";
660
+ port.style.scrollBehavior = "";
661
+ const shiftRoot = shiftRootOf(port);
662
+ if (shiftRoot !== null) {
663
+ shiftRoot.style.transform = "";
664
+ shiftRoot.style.willChange = "";
665
+ } else for (const row of shiftRowsOf(port)) {
666
+ row.style.transform = "";
667
+ row.style.willChange = "";
668
+ }
669
+ const chrome = chromeShiftOf(port);
670
+ if (chrome !== null) {
671
+ chrome.style.transform = "";
672
+ chrome.style.willChange = "";
673
+ }
674
+ }
675
+ /** Live follow hosts per conversation port so one unmount does not clear another. */
676
+ const followOwners = /* @__PURE__ */ new WeakMap();
677
+ function acquireFollow(port) {
678
+ followOwners.set(port, (followOwners.get(port) ?? 0) + 1);
679
+ }
680
+ function releaseFollow(port) {
681
+ const next = (followOwners.get(port) ?? 1) - 1;
682
+ if (next <= 0) {
683
+ followOwners.delete(port);
684
+ return 0;
685
+ }
686
+ followOwners.set(port, next);
687
+ return next;
688
+ }
689
+ /**
690
+ * Own the conversation scrollport's bottom-follow while `active` is true.
691
+ *
692
+ * @param rootRef - An element inside the conversation scrollport.
693
+ * @param active - True while the reply is still revealing.
694
+ * @param speedCpsRef - Live reveal-rate EMA from the smoother.
695
+ */
696
+ function useConversationFollow(rootRef, active, speedCpsRef) {
697
+ const activeRef = (0, react.useRef)(active);
698
+ (0, react.useEffect)(() => {
699
+ activeRef.current = active;
700
+ }, [active]);
701
+ (0, react.useEffect)(() => {
702
+ if (!active) return;
703
+ let rafId = 0;
704
+ let last = performance.now();
705
+ let following = true;
706
+ let primed = false;
707
+ let animatedH = 0;
708
+ let interacting = false;
709
+ let interactTimer = null;
710
+ let port = null;
711
+ let resize = null;
712
+ let holding = null;
713
+ let awayPx = 0;
714
+ const hold = (next) => {
715
+ if (holding === next) return;
716
+ if (holding !== null) releaseFollow(holding);
717
+ acquireFollow(next);
718
+ holding = next;
719
+ };
720
+ const drop = (next) => {
721
+ if (holding === next) {
722
+ releaseFollow(next);
723
+ holding = null;
724
+ }
725
+ clearVisual(next);
726
+ };
727
+ /**
728
+ * Give the reader the visual position the glide was showing before the
729
+ * transforms go away. While transforms carry the lag, the engine sits at
730
+ * the floor and the effective visual top is `engine - lag`; writing that
731
+ * keeps the handover frame-continuous, and the write shows up in
732
+ * ChatView's ledger as reader movement, disarming its snap-follow.
733
+ * Without a shift surface the engine already holds the interpolated top,
734
+ * so there is nothing to compensate.
735
+ */
736
+ const handBackVisual = (next) => {
737
+ if (!hasShiftSurface(next)) return;
738
+ const floor = Math.max(0, next.scrollHeight - next.clientHeight);
739
+ if (floor <= 0) return;
740
+ const lag = Math.max(0, next.scrollHeight - animatedH);
741
+ next.scrollTop = Math.min(floor, Math.max(0, next.scrollTop - lag));
742
+ };
743
+ const markGesture = (event) => {
744
+ interacting = true;
745
+ if (event instanceof WheelEvent && event.deltaY < 0) awayPx += -event.deltaY;
746
+ else if (event.type === "touchmove" || event.type === "keydown") awayPx += 8;
747
+ if (interactTimer !== null) clearTimeout(interactTimer);
748
+ interactTimer = setTimeout(() => {
749
+ interacting = false;
750
+ interactTimer = null;
751
+ awayPx = 0;
752
+ }, 800);
753
+ };
754
+ const restoreBeforePaint = () => {
755
+ if (!following || port === null) return;
756
+ applyVisual(port, animatedH);
757
+ };
758
+ const bindPort = (next) => {
759
+ if (port === next) return;
760
+ if (port !== null) {
761
+ for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture);
762
+ resize?.disconnect();
763
+ }
764
+ port = next;
765
+ for (const name of GESTURE_EVENTS) port.addEventListener(name, markGesture, { passive: true });
766
+ if (typeof ResizeObserver !== "undefined") {
767
+ resize = new ResizeObserver(restoreBeforePaint);
768
+ resize.observe(port);
769
+ const proxy = resizeProxyOf(port);
770
+ if (proxy !== null) resize.observe(proxy);
771
+ }
772
+ };
773
+ const frame = (now) => {
774
+ rafId = requestAnimationFrame(frame);
775
+ const dt = Math.min(50, now - last);
776
+ last = now;
777
+ const root = rootRef.current;
778
+ if (root === null) return;
779
+ const nextPort = root.closest("[data-conversation-scroll]");
780
+ if (nextPort === null) return;
781
+ bindPort(nextPort);
782
+ const reportedLag = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight) - nextPort.scrollTop;
783
+ const extent = Math.min(nextPort.scrollHeight, Math.max(0, nextPort.scrollHeight - reportedLag));
784
+ if (!primed) {
785
+ animatedH = extent;
786
+ following = reportedLag <= 25;
787
+ if (following) {
788
+ hold(nextPort);
789
+ applyVisual(nextPort, animatedH);
790
+ }
791
+ primed = true;
792
+ return;
793
+ }
794
+ if (!following && !interacting && reportedLag <= 25) {
795
+ following = true;
796
+ animatedH = extent;
797
+ hold(nextPort);
798
+ } else if (following && interacting && awayPx >= 8) {
799
+ following = false;
800
+ awayPx = 0;
801
+ handBackVisual(nextPort);
802
+ animatedH = nextPort.scrollHeight;
803
+ drop(nextPort);
804
+ }
805
+ if (!activeRef.current || !following) return;
806
+ hold(nextPort);
807
+ const lag = nextPort.scrollHeight - animatedH;
808
+ const step = computeFollowStep(dt, {
809
+ lag,
810
+ speedEma: speedCpsRef.current
811
+ });
812
+ if (lag <= .1) animatedH = nextPort.scrollHeight;
813
+ else animatedH = Math.min(nextPort.scrollHeight, animatedH + step.advancePx);
814
+ applyVisual(nextPort, animatedH);
815
+ };
816
+ rafId = requestAnimationFrame(frame);
817
+ return () => {
818
+ cancelAnimationFrame(rafId);
819
+ if (interactTimer !== null) clearTimeout(interactTimer);
820
+ resize?.disconnect();
821
+ if (port !== null) for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture);
822
+ const host = rootRef.current?.closest("[data-conversation-scroll]") ?? port;
823
+ if (host === null) return;
824
+ if (following) handBackVisual(host);
825
+ const remaining = holding === host ? releaseFollow(host) : followOwners.get(host) ?? 0;
826
+ holding = null;
827
+ if (remaining === 0) clearVisual(host);
828
+ };
829
+ }, [
830
+ active,
831
+ rootRef,
832
+ speedCpsRef
833
+ ]);
834
+ }
835
+ //#endregion
836
+ //#region src/client/FollowHost.tsx
837
+ /**
838
+ * Document-flow host that owns conversation-port follow while `active`.
839
+ * Shared by assistant blocks and every other growing Chat row.
840
+ */
841
+ function FollowHost({ active, speedCpsRef, children }) {
842
+ const rootRef = (0, react.useRef)(null);
843
+ useConversationFollow(rootRef, active, speedCpsRef);
844
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
845
+ ref: rootRef,
846
+ className: TypewriterAssistantNodeView_module_css_default.follow,
847
+ children
848
+ });
849
+ }
850
+ //#endregion
851
+ //#region src/config.ts
852
+ /** Defaults shared by the Host schema and the client-side fallback. */
853
+ const DEFAULT_STREAM_CONFIG = {
854
+ mode: "typewriter",
855
+ preset: "balanced",
856
+ revealCharsPerSec: 80,
857
+ scrollSpeedPxPerSec: 48,
858
+ maxScrollSpeedPxPerSec: 1e3
859
+ };
860
+ /**
861
+ * Window global the Host writes into the served index HTML. The browser boot
862
+ * graph carries no per-entry config, so this inline script is the only
863
+ * Host-to-client configuration channel for a composed web plugin.
864
+ */
865
+ const STREAM_BOOT_GLOBAL = "__DSH_SMOOTH_STREAM_CONFIG__";
866
+ //#endregion
867
+ //#region src/client/TypewriterAssistantNodeView.tsx
868
+ function usePrefersReducedMotion() {
869
+ const [reduced, setReduced] = (0, react.useState)(() => typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true);
870
+ (0, react.useEffect)(() => {
871
+ if (typeof window === "undefined" || window.matchMedia === void 0) return;
872
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
873
+ const onChange = () => setReduced(query.matches);
874
+ query.addEventListener("change", onChange);
875
+ return () => query.removeEventListener("change", onChange);
876
+ }, []);
877
+ return reduced;
878
+ }
879
+ /**
880
+ * Smooth streaming text arm. While the reply runs, the accumulated source is
881
+ * revealed through the smoother at a rate that tracks the model's arrival
882
+ * and rendered by the Harness `MarkdownText`
883
+ * streaming arm (incremental parse, frozen non-tail blocks), so there is no
884
+ * raw-text tail and no text-to-markdown swap: the tree stays markdown
885
+ * throughout. The last text block owns conversation-port follow so wraps
886
+ * glide instead of snapping. Once the stream closes and the reveal queue
887
+ * drains, the settled full parse (KaTeX math, fence highlighting, file
888
+ * mentions) swaps in exactly once.
889
+ */
890
+ function AnimatedMarkdownText({ text, codeLabels, fileMentions, streaming, announce, ownFollow, preset, shouldHoldBack }) {
891
+ const reduced = usePrefersReducedMotion();
892
+ const [typing, setTyping] = (0, react.useState)(streaming);
893
+ const speedCpsRef = (0, react.useRef)(35);
894
+ const displayed = useSmoothStreamContent(text, {
895
+ enabled: typing && !reduced,
896
+ preset,
897
+ shouldHoldBack,
898
+ speedCpsRef
899
+ });
900
+ const shown = reduced ? text : displayed;
901
+ const live = typing && !reduced;
902
+ (0, react.useEffect)(() => {
903
+ if (typing && !streaming && shown.length === text.length) setTyping(false);
904
+ }, [
905
+ shown,
906
+ streaming,
907
+ text,
908
+ typing
909
+ ]);
910
+ if (live) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [announce && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
911
+ className: TypewriterAssistantNodeView_module_css_default.visuallyHidden,
912
+ "aria-live": "polite",
913
+ children: text
914
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FollowHost, {
915
+ active: ownFollow,
916
+ speedCpsRef,
917
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MarkdownText, {
918
+ text: shown,
919
+ streaming: true,
920
+ codeLabels
921
+ })
922
+ })] });
923
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MarkdownText, {
924
+ text,
925
+ codeLabels,
926
+ fileMentions
927
+ });
928
+ }
929
+ function imageLabels(t) {
930
+ return {
931
+ image: t("image.label"),
932
+ open: t("image.openOriginal"),
933
+ openNamed: (label) => t("image.openOriginalLabel", { label }),
934
+ loading: t("image.loading"),
935
+ loadFailed: t("image.loadFailed"),
936
+ lightbox: {
937
+ dialog: t("image.preview"),
938
+ close: t("image.closePreview")
939
+ }
940
+ };
941
+ }
942
+ function firstLine(text) {
943
+ const newline = text.indexOf("\n");
944
+ return newline === -1 ? text : text.slice(0, newline);
945
+ }
946
+ function latestLine(text) {
947
+ const visible = text.trimEnd();
948
+ const newline = visible.lastIndexOf("\n");
949
+ return newline === -1 ? visible : visible.slice(newline + 1);
950
+ }
951
+ /**
952
+ * Built-in Think disclosure with a smoothed `text` feed. Chevron and row
953
+ * click stay on the disclosure chrome, which the plugin's AnimatedDisclosure
954
+ * renders with a height-animated body (the harness primitive would mount and
955
+ * unmount it, which cannot glide). The row opens only while this block is
956
+ * the streaming tail and closes as soon as thinking ends — a later block,
957
+ * or the assistant node settling — not when the rest of the reply is
958
+ * still streaming.
959
+ */
960
+ function AnimatedReasoning({ text, running, preset, shouldHoldBack, t }) {
961
+ const reduced = usePrefersReducedMotion();
962
+ const [expanded, setExpanded] = (0, react.useState)(running);
963
+ const summaryRef = (0, react.useRef)(null);
964
+ const speedCpsRef = (0, react.useRef)(35);
965
+ const displayed = useSmoothStreamContent(text, {
966
+ enabled: running && !reduced,
967
+ preset,
968
+ shouldHoldBack,
969
+ speedCpsRef
970
+ });
971
+ const shown = running && !reduced ? displayed : text;
972
+ const summary = running ? latestLine(shown) : firstLine(text);
973
+ (0, react.useEffect)(() => {
974
+ setExpanded(running);
975
+ }, [running]);
976
+ (0, react.useEffect)(() => {
977
+ const element = summaryRef.current;
978
+ if (element === null) return;
979
+ element.scrollLeft = running ? element.scrollWidth - element.clientWidth : 0;
980
+ }, [running, summary]);
981
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FollowHost, {
982
+ active: running,
983
+ speedCpsRef,
984
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
985
+ className: TypewriterAssistantNodeView_module_css_default.think,
986
+ "data-variant": "think",
987
+ "data-state": running ? "running" : "ok",
988
+ children: [running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
989
+ className: TypewriterAssistantNodeView_module_css_default.visuallyHidden,
990
+ children: t("row.running")
991
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnimatedDisclosure, {
992
+ rowClassName: TypewriterAssistantNodeView_module_css_default.thinkRow,
993
+ leadingClassName: TypewriterAssistantNodeView_module_css_default.thinkLeading,
994
+ titleClassName: TypewriterAssistantNodeView_module_css_default.thinkTitle,
995
+ chevronClassName: TypewriterAssistantNodeView_module_css_default.thinkChevron,
996
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconThinkOutline14, { size: 14 }),
997
+ title: "Think",
998
+ open: expanded,
999
+ onToggle: () => {
1000
+ setExpanded((value) => !value);
1001
+ },
1002
+ collapsedContent: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1003
+ className: TypewriterAssistantNodeView_module_css_default.thinkSeparator,
1004
+ "aria-hidden": true
1005
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1006
+ ref: summaryRef,
1007
+ className: TypewriterAssistantNodeView_module_css_default.thinkSummary,
1008
+ "data-follow-end": running || void 0,
1009
+ children: summary
1010
+ })] }),
1011
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1012
+ className: TypewriterAssistantNodeView_module_css_default.thinkBody,
1013
+ children: shown
1014
+ })
1015
+ })]
1016
+ })
1017
+ });
1018
+ }
1019
+ /**
1020
+ * Assistant node renderer for the typewriter overlay. Text observed while
1021
+ * streaming is revealed by the smoother through the Harness Markdown
1022
+ * renderer at a rate that tracks arrival. Reasoning blocks keep the
1023
+ * built-in Think disclosure and only receive a smoothed text feed; the
1024
+ * last text or reasoning block owns conversation-port follow so wraps
1025
+ * glide. The FPS guard holds offscreen
1026
+ * reveals when the frame rate is degraded. Settled text renders with the
1027
+ * full Markdown pipeline.
1028
+ */
1029
+ const TypewriterAssistantNodeView = (0, react.memo)(function TypewriterAssistantNodeView({ mode: _mode = DEFAULT_STREAM_CONFIG.mode, preset = DEFAULT_STREAM_CONFIG.preset, revealCharsPerSec: _revealCharsPerSec = DEFAULT_STREAM_CONFIG.revealCharsPerSec, scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec, maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec, node, useTurnData, openFile, loadImage, fileMentions, t }) {
1030
+ const data = node.data;
1031
+ const streaming = data.status === "running";
1032
+ const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming);
1033
+ const rootSpeedRef = (0, react.useRef)(35);
1034
+ const turn = node.location.kind === "turn" || node.location.kind === "step" ? node.location.turn : void 0;
1035
+ const tail = useTurnData("turn-tail");
1036
+ const owner = (0, react.useMemo)(() => {
1037
+ if (turn?.status !== "closed" || data.finalNode === void 0) return void 0;
1038
+ if (tail?.closing?.finalNode.seq !== data.finalNode.seq) return void 0;
1039
+ return {
1040
+ turn,
1041
+ seq: data.finalNode.seq,
1042
+ openFile
1043
+ };
1044
+ }, [
1045
+ data.finalNode,
1046
+ openFile,
1047
+ tail,
1048
+ turn
1049
+ ]);
1050
+ const mentions = (0, react.useMemo)(() => owner === void 0 ? void 0 : fileMentions(owner), [fileMentions, owner]);
1051
+ const codeLabels = (0, react.useMemo)(() => ({
1052
+ copyLabel: t("copy"),
1053
+ copiedLabel: t("copied")
1054
+ }), [t]);
1055
+ const imageLoader = loadImage ?? (async () => {
1056
+ throw new Error(t("image.serviceUnavailable"));
1057
+ });
1058
+ if (!(streaming || data.status === "interrupted" || data.blocks.some((block) => block.kind !== "tool-call"))) return null;
1059
+ const rendered = [];
1060
+ const last = data.blocks.length - 1;
1061
+ let lastFollow = -1;
1062
+ for (let index = 0; index < data.blocks.length; index += 1) {
1063
+ const kind = data.blocks[index]?.kind;
1064
+ if (kind === "text" || kind === "reasoning") lastFollow = index;
1065
+ }
1066
+ for (let index = 0; index < data.blocks.length; index += 1) {
1067
+ const block = data.blocks[index];
1068
+ if (block === void 0) continue;
1069
+ switch (block.kind) {
1070
+ case "text":
1071
+ rendered.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnimatedMarkdownText, {
1072
+ text: block.text,
1073
+ codeLabels,
1074
+ fileMentions: mentions,
1075
+ streaming,
1076
+ announce: index === last,
1077
+ ownFollow: !streaming && index === lastFollow,
1078
+ preset,
1079
+ shouldHoldBack
1080
+ }, index));
1081
+ break;
1082
+ case "reasoning":
1083
+ rendered.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnimatedReasoning, {
1084
+ text: block.text,
1085
+ running: streaming && index === last,
1086
+ preset,
1087
+ shouldHoldBack,
1088
+ t
1089
+ }, index));
1090
+ break;
1091
+ case "image": {
1092
+ const start = index;
1093
+ const group = [block];
1094
+ while (index + 1 < data.blocks.length) {
1095
+ const next = data.blocks[index + 1];
1096
+ if (next === void 0 || next.kind !== "image") break;
1097
+ group.push(next);
1098
+ index += 1;
1099
+ }
1100
+ rendered.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_attachment.ImageGallery, {
1101
+ images: group,
1102
+ load: imageLoader,
1103
+ align: "start",
1104
+ labels: imageLabels(t)
1105
+ }, start));
1106
+ break;
1107
+ }
1108
+ case "tool-call": break;
1109
+ case "other": rendered.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.JsonBlock, {
1110
+ label: t("message.unknownBlock"),
1111
+ payload: block.block,
1112
+ truncatedLabel: (total) => t("json.truncated", { total })
1113
+ }, index));
1114
+ }
1115
+ }
1116
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1117
+ ref: guardRef,
1118
+ className: TypewriterAssistantNodeView_module_css_default.root,
1119
+ "data-streaming": streaming || void 0,
1120
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FollowHost, {
1121
+ active: streaming,
1122
+ speedCpsRef: rootSpeedRef,
1123
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1124
+ className: TypewriterAssistantNodeView_module_css_default.body,
1125
+ children: [rendered, data.status === "interrupted" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1126
+ className: TypewriterAssistantNodeView_module_css_default.stopped,
1127
+ children: t("message.stopped")
1128
+ })]
1129
+ })
1130
+ })
1131
+ });
1132
+ });
1133
+ //#endregion
1134
+ //#region src/client/TypewriterToolNodeView.tsx
1135
+ /**
1136
+ * True while a Chat node is still growing: an assistant/workflow `status:
1137
+ * 'running'` payload, a Tool root that has not settled (`kind` absent), or a
1138
+ * model-retry whose current attempt is still `scheduled`.
1139
+ * @param node - The Chat node's view `node` prop.
1140
+ * @returns whether this row should own conversation follow.
1141
+ */
1142
+ function isGrowingChatNode(node) {
1143
+ if (node === null || typeof node !== "object" || !("data" in node)) return false;
1144
+ const data = node.data;
1145
+ if (data === null || typeof data !== "object") return false;
1146
+ if ("status" in data && data.status === "running") return true;
1147
+ if ("root" in data) {
1148
+ const root = data.root;
1149
+ if (root !== null && typeof root === "object" && !("kind" in root)) return true;
1150
+ }
1151
+ if ("current" in data) {
1152
+ const current = data.current;
1153
+ if (current !== null && typeof current === "object" && "retryState" in current && current.retryState === "scheduled") return true;
1154
+ }
1155
+ return false;
1156
+ }
1157
+ /**
1158
+ * Wrap a prior Chat node renderer so a growing row shares conversation
1159
+ * follow. Presentation stays with the wrapped component; kit seats
1160
+ * (`renderSlot`, locale, inject) pass through unchanged.
1161
+ * @param Inner - The already-registered row component.
1162
+ * @returns A follow-hosted row.
1163
+ */
1164
+ function wrapFollowNodeView(Inner) {
1165
+ return function TypewriterFollowNodeView(props) {
1166
+ const speedCpsRef = (0, react.useRef)(35);
1167
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FollowHost, {
1168
+ active: isGrowingChatNode(props.node),
1169
+ speedCpsRef,
1170
+ children: (0, react.createElement)(Inner, props)
1171
+ });
1172
+ };
1173
+ }
1174
+ //#endregion
1175
+ //#region src/client/index.ts
1176
+ /** Cordis services required by the browser half. */
1177
+ const inject = ["slots"];
1178
+ const STREAM_MODES = ["typewriter", "teleprompter"];
1179
+ const STREAM_PRESETS = [
1180
+ "realtime",
1181
+ "balanced",
1182
+ "silky"
1183
+ ];
1184
+ /** Human-authored rows stay on the built-in renderer; `assistant-step` is replaced. */
1185
+ const SKIP_WRAP = /* @__PURE__ */ new Set([
1186
+ "assistant-step",
1187
+ "user",
1188
+ "steering",
1189
+ "command-input"
1190
+ ]);
1191
+ /**
1192
+ * Read the Host-bridged boot config. The inline script is produced by this
1193
+ * plugin's Host half from a schema-validated value, so only the structural
1194
+ * guarantees that could break between the two halves are re-checked: the
1195
+ * global is absent when the client runs without its Host entry (defaults
1196
+ * apply), and any present-but-malformed value fails loudly instead of
1197
+ * rendering a half-configured view.
1198
+ * @returns The resolved configuration for the assistant node view.
1199
+ */
1200
+ function readBootConfig() {
1201
+ const raw = globalThis[STREAM_BOOT_GLOBAL];
1202
+ if (raw === void 0) {
1203
+ console.info("[dsh-smooth-stream] no host config bridge; using defaults");
1204
+ return DEFAULT_STREAM_CONFIG;
1205
+ }
1206
+ if (typeof raw !== "object" || raw === null || !STREAM_MODES.includes(raw.mode) || !STREAM_PRESETS.includes(raw.preset) || typeof raw.revealCharsPerSec !== "number" || typeof raw.scrollSpeedPxPerSec !== "number" || typeof raw.maxScrollSpeedPxPerSec !== "number") throw new Error(`[dsh-smooth-stream] malformed ${STREAM_BOOT_GLOBAL} boot global: ${JSON.stringify(raw)}`);
1207
+ return raw;
1208
+ }
1209
+ /**
1210
+ * Wrap every keyed Chat row except `assistant-step` in place. A second
1211
+ * register with the same `children` table throws because the child slot is
1212
+ * already declared, and only the winning entry receives `renderSlot`;
1213
+ * swapping `entry.component` keeps the original children, locale, and inject
1214
+ * seats. `assistant-step` is replaced below so text and Think use the
1215
+ * typewriter reveal.
1216
+ * @param ctx - Browser context carrying the slot registry.
1217
+ * @returns Restorer that puts the original components back.
1218
+ */
1219
+ function wrapGrowingChatRows(ctx) {
1220
+ const restores = [];
1221
+ const wrapped = /* @__PURE__ */ new WeakSet();
1222
+ const wrapAll = () => {
1223
+ for (const entry of ctx.slots.entries("conversation.chat.node")) {
1224
+ const key = entry.options.key;
1225
+ if (key === void 0 || SKIP_WRAP.has(key)) continue;
1226
+ const current = entry.component;
1227
+ if (typeof current !== "function" || wrapped.has(current)) continue;
1228
+ const inner = current;
1229
+ const next = wrapFollowNodeView(inner);
1230
+ wrapped.add(next);
1231
+ entry.component = next;
1232
+ restores.push(() => {
1233
+ if (entry.component === next) entry.component = inner;
1234
+ });
1235
+ }
1236
+ };
1237
+ wrapAll();
1238
+ const off = ctx.on("slots/changed", (key) => {
1239
+ if (key === "conversation.chat.node") wrapAll();
1240
+ });
1241
+ return () => {
1242
+ off();
1243
+ for (const restore of restores) restore();
1244
+ };
1245
+ }
1246
+ /**
1247
+ * Register the typewriter renderer after the conversation package declares the
1248
+ * keyed Chat node seat. A lower priority shadows the built-in assistant row;
1249
+ * every other growing row is wrapped in place so Tool cards, retries, and
1250
+ * workflow runs share conversation follow. The Host-bridged configuration
1251
+ * selects the render direction, smoothing preset, and glide speed.
1252
+ * @param ctx - Browser context carrying the shared slot registry.
1253
+ */
1254
+ function apply(ctx) {
1255
+ const config = readBootConfig();
1256
+ const configured = function StreamConfiguredView(props) {
1257
+ return (0, react.createElement)(TypewriterAssistantNodeView, {
1258
+ ...props,
1259
+ mode: config.mode,
1260
+ preset: config.preset,
1261
+ revealCharsPerSec: config.revealCharsPerSec,
1262
+ scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
1263
+ maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec
1264
+ });
1265
+ };
1266
+ ctx.slots.inject("conversation.chat.node", () => {
1267
+ const unwrap = wrapGrowingChatRows(ctx);
1268
+ const unshadow = ctx.slots.register({
1269
+ name: "conversation.chat.node",
1270
+ key: "assistant-step",
1271
+ priority: -100,
1272
+ locale: "conversation",
1273
+ registrant: "dsh-smooth-stream"
1274
+ }, configured);
1275
+ return () => {
1276
+ unshadow();
1277
+ unwrap();
1278
+ };
1279
+ });
1280
+ }
1281
+ //#endregion
1282
+ exports.apply = apply;
1283
+ exports.inject = inject;
1284
+ return module.exports;
1285
+ }
1286
+ });
1287
+
1288
+ //# sourceMappingURL=client.js.map