dsh-smooth-stream 0.3.2 → 0.3.3

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 CHANGED
@@ -10,7 +10,7 @@ window.__ModuleLoader__.load({
10
10
  let react_jsx_runtime = require("react/jsx-runtime");
11
11
  let _deepseek_ai_dsh_client_runtime_client = require("@deepseek-ai/dsh-client-runtime/client");
12
12
  //#region \0dsh-css:/Users/dzlin/work/project/dsh-smooth-stream/src/client/TypewriterAssistantNodeView.module.css.mjs
13
- const css$1 = ".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 css$1 = ".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{contain:layout style;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}}";
14
14
  const tagId$1 = "dsh-smooth-stream/TypewriterAssistantNodeView.module.css";
15
15
  if (typeof document !== "undefined") {
16
16
  let tag = document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]");
@@ -23,27 +23,27 @@ window.__ModuleLoader__.load({
23
23
  tag.textContent = css$1;
24
24
  }
25
25
  var TypewriterAssistantNodeView_module_css_default = {
26
- "body": "I17U7q_body",
27
- "disclosureRow": "I17U7q_disclosureRow",
28
- "disclosureIconIdle": "I17U7q_disclosureIconIdle",
29
- "stopped": "I17U7q_stopped",
30
- "root": "I17U7q_root",
31
26
  "disclosureRoot": "I17U7q_disclosureRoot",
27
+ "root": "I17U7q_root",
32
28
  "thinkRow": "I17U7q_thinkRow",
33
- "thinkLeading": "I17U7q_thinkLeading",
34
- "follow": "I17U7q_follow",
29
+ "thinkChevron": "I17U7q_thinkChevron",
30
+ "disclosureRow": "I17U7q_disclosureRow",
31
+ "disclosureLeading": "I17U7q_disclosureLeading",
32
+ "dsh-smooth-stream-think-sweep": "I17U7q_dsh-smooth-stream-think-sweep",
35
33
  "disclosureTitle": "I17U7q_disclosureTitle",
36
- "visuallyHidden": "I17U7q_visuallyHidden",
34
+ "disclosureIconIdle": "I17U7q_disclosureIconIdle",
37
35
  "thinkSeparator": "I17U7q_thinkSeparator",
36
+ "thinkSummary": "I17U7q_thinkSummary",
37
+ "follow": "I17U7q_follow",
38
38
  "thinkTitle": "I17U7q_thinkTitle",
39
39
  "disclosureContent": "I17U7q_disclosureContent",
40
- "think": "I17U7q_think",
41
40
  "thinkBody": "I17U7q_thinkBody",
41
+ "thinkLeading": "I17U7q_thinkLeading",
42
42
  "disclosureChevronHover": "I17U7q_disclosureChevronHover",
43
- "thinkChevron": "I17U7q_thinkChevron",
44
- "dsh-smooth-stream-think-sweep": "I17U7q_dsh-smooth-stream-think-sweep",
45
- "disclosureLeading": "I17U7q_disclosureLeading",
46
- "thinkSummary": "I17U7q_thinkSummary"
43
+ "stopped": "I17U7q_stopped",
44
+ "visuallyHidden": "I17U7q_visuallyHidden",
45
+ "body": "I17U7q_body",
46
+ "think": "I17U7q_think"
47
47
  };
48
48
  //#endregion
49
49
  //#region src/client/AnimatedDisclosure.tsx
@@ -123,7 +123,6 @@ window.__ModuleLoader__.load({
123
123
  maxActiveCps: 360,
124
124
  maxCps: 240,
125
125
  maxFlushCps: 480,
126
- minCommitIntervalMs: 16,
127
126
  minCps: 24,
128
127
  settleAfterMs: 280,
129
128
  settleDrainMaxMs: 420,
@@ -139,7 +138,6 @@ window.__ModuleLoader__.load({
139
138
  maxActiveCps: 480,
140
139
  maxCps: 320,
141
140
  maxFlushCps: 640,
142
- minCommitIntervalMs: 16,
143
141
  minCps: 32,
144
142
  settleAfterMs: 200,
145
143
  settleDrainMaxMs: 280,
@@ -155,7 +153,6 @@ window.__ModuleLoader__.load({
155
153
  maxActiveCps: 280,
156
154
  maxCps: 180,
157
155
  maxFlushCps: 400,
158
- minCommitIntervalMs: 16,
159
156
  minCps: 20,
160
157
  settleAfterMs: 360,
161
158
  settleDrainMaxMs: 520,
@@ -163,23 +160,28 @@ window.__ModuleLoader__.load({
163
160
  targetBufferMs: 56
164
161
  }
165
162
  };
166
- const MAX_COMMIT_INTERVAL_MS = 16;
167
- const QUEUE_FRAME_MS = 16.67;
163
+ const QUEUE_ACCEL_EXPONENT = 1.25;
164
+ const QUEUE_PRESSURE_FACTOR = .85;
168
165
  const CATCHUP_SECONDS = .15;
169
- const clamp$1 = (value, min, max) => {
166
+ const clamp = (value, min, max) => {
170
167
  return Math.min(max, Math.max(min, value));
171
168
  };
172
- /**
173
- * Demo per-frame reveal: drain `backlog / 8` characters, at least 1, scaled
174
- * by frame time. A small queue types one glyph per frame; a burst raises the
175
- * step so the display does not fall behind.
176
- * @param backlog - Unrevealed characters.
177
- * @param dtMs - Frame delta in ms.
178
- * @returns Characters to reveal this frame.
179
- */
180
- function computeQueueReveal(backlog, dtMs) {
181
- if (backlog <= 0 || dtMs <= 0) return 0;
182
- return Math.min(backlog, Math.max(1, Math.ceil(backlog / 8 * (dtMs / QUEUE_FRAME_MS))));
169
+ /** Float-debt queue integration from `ultimate_stream_physics_scroller.html`. */
170
+ function computeAdaptiveQueueStep(backlog, dtMs, debt, revealScale = 1) {
171
+ if (backlog <= 0 || dtMs <= 0) return {
172
+ revealChars: 0,
173
+ debt: 0,
174
+ speedCps: 0
175
+ };
176
+ const speedCps = Math.min(600, 90 + Math.pow(backlog, QUEUE_ACCEL_EXPONENT) * QUEUE_PRESSURE_FACTOR);
177
+ const effectiveScale = clamp(revealScale, .05, 1);
178
+ const accumulated = Math.max(0, debt) + speedCps * effectiveScale * (dtMs / 1e3);
179
+ const revealChars = Math.min(backlog, Math.floor(accumulated));
180
+ return {
181
+ revealChars,
182
+ debt: revealChars >= backlog ? 0 : accumulated - revealChars,
183
+ speedCps
184
+ };
183
185
  }
184
186
  /** Counts user-perceived characters (code points), not UTF-16 units. */
185
187
  const countChars = (text) => {
@@ -191,9 +193,20 @@ window.__ModuleLoader__.load({
191
193
  function computeSettleDrain(config, input) {
192
194
  if (input.inputActive || !input.settling) return 0;
193
195
  const overflowCps = Math.max(0, input.backlog - 300) * 1e3 / 2;
194
- const drainTargetMs = clamp$1(input.backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs);
196
+ const drainTargetMs = clamp(input.backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs);
195
197
  const settleCps = input.backlog * 1e3 / drainTargetMs;
196
- return clamp$1(Math.max(settleCps, overflowCps), config.flushCps, config.maxFlushCps);
198
+ return clamp(Math.max(settleCps, overflowCps), config.flushCps, config.maxFlushCps);
199
+ }
200
+ /** Fixed velocity that closes a producer-complete queue within its deadline. */
201
+ function computeCompletionDrain(config, backlog) {
202
+ if (backlog <= 0) return 0;
203
+ const drainTargetMs = clamp(backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs);
204
+ const deadlineCps = backlog * 1e3 / drainTargetMs;
205
+ return Math.max(deadlineCps, computeSettleDrain(config, {
206
+ backlog,
207
+ inputActive: false,
208
+ settling: true
209
+ }));
197
210
  }
198
211
  /**
199
212
  * Drain rate multiplier once the input ends: leftover backlog reveals at
@@ -203,18 +216,18 @@ window.__ModuleLoader__.load({
203
216
  /** Pure per-frame reveal decision shared by the loop and its tests. */
204
217
  function computeRevealStep(config, input, dtSeconds) {
205
218
  const trackedCps = Math.max(input.emaCps, input.arrivalCpsEma);
206
- const baseCps = clamp$1(trackedCps, config.minCps, config.maxFlushCps);
219
+ const baseCps = clamp(trackedCps, config.minCps, config.maxFlushCps);
207
220
  const targetLagChars = input.inputActive ? Math.max(2, Math.round(baseCps * config.targetBufferMs / 1e3)) : 0;
208
221
  let currentCps;
209
- 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;
222
+ if (input.steadyCps !== void 0) currentCps = input.inputActive || input.settling ? clamp(input.steadyCps * (input.inputActive ? 1 : SETTLE_DRAIN_MULTIPLIER), config.minCps, config.maxFlushCps) : 0;
210
223
  else if (input.inputActive) {
211
224
  const overflow = Math.max(0, input.backlog - 32);
212
225
  const catchup = overflow > 0 ? overflow / CATCHUP_SECONDS : 0;
213
- currentCps = clamp$1(baseCps * 1.08 + catchup, config.minCps, config.maxFlushCps);
226
+ currentCps = clamp(baseCps * 1.08 + catchup, config.minCps, config.maxFlushCps);
214
227
  } else if (input.settling) currentCps = computeSettleDrain(config, input);
215
228
  else {
216
229
  const idleFlushCps = Math.max(config.flushCps, baseCps * 1.8, input.arrivalCpsEma * .8);
217
- currentCps = clamp$1(idleFlushCps, config.flushCps, config.maxFlushCps);
230
+ currentCps = clamp(idleFlushCps, config.flushCps, config.maxFlushCps);
218
231
  }
219
232
  const minRevealChars = input.inputActive ? 1 : 2;
220
233
  return {
@@ -229,35 +242,35 @@ window.__ModuleLoader__.load({
229
242
  * @param options - Preset, guard, and steady-rate wiring.
230
243
  * @returns The displayed content, revealed at the smoothed cadence.
231
244
  */
232
- function useSmoothStreamContent(content, { enabled = true, preset = "balanced", shouldHoldBack, steadyCps, defaultCps, speedCpsRef } = {}) {
245
+ function useSmoothStreamContent(content, { enabled = true, inputComplete = false, preset = "balanced", shouldHoldBack, steadyCps, defaultCps, speedCpsRef, revealScaleRef } = {}) {
233
246
  const config = PRESET_CONFIG[preset];
234
247
  const seedCps = defaultCps ?? config.defaultCps;
235
- const [displayedContent, setDisplayedContent] = (0, react.useState)(content);
236
- const displayedContentRef = (0, react.useRef)(content);
237
- const displayedCountRef = (0, react.useRef)(countChars(content));
238
- const targetContentRef = (0, react.useRef)(content);
239
- const targetCharsRef = (0, react.useRef)([...content]);
240
- const targetCountRef = (0, react.useRef)(countChars(content));
248
+ const initialContent = enabled ? "" : content;
249
+ const [displayedContent, setDisplayedContent] = (0, react.useState)(initialContent);
250
+ const displayedContentRef = (0, react.useRef)(initialContent);
251
+ const displayedCountRef = (0, react.useRef)(countChars(initialContent));
252
+ const targetContentRef = (0, react.useRef)(initialContent);
253
+ const targetCharsRef = (0, react.useRef)([...initialContent]);
254
+ const targetCountRef = (0, react.useRef)(countChars(initialContent));
241
255
  const emaCpsRef = (0, react.useRef)(seedCps);
242
256
  const lastInputTsRef = (0, react.useRef)(0);
243
- const lastInputCountRef = (0, react.useRef)(countChars(content));
257
+ const lastInputCountRef = (0, react.useRef)(countChars(initialContent));
244
258
  const chunkSizeEmaRef = (0, react.useRef)(1);
245
259
  const arrivalCpsEmaRef = (0, react.useRef)(seedCps);
246
260
  const rafRef = (0, react.useRef)(null);
247
261
  const lastFrameTsRef = (0, react.useRef)(null);
248
- const wakeTimerRef = (0, react.useRef)(null);
262
+ const queueDebtRef = (0, react.useRef)(0);
263
+ const settleCpsRef = (0, react.useRef)(null);
249
264
  const holdBackRef = (0, react.useRef)(shouldHoldBack);
250
265
  const speedOutRef = (0, react.useRef)(speedCpsRef);
251
266
  speedOutRef.current = speedCpsRef;
267
+ const revealScaleOutRef = (0, react.useRef)(revealScaleRef);
268
+ revealScaleOutRef.current = revealScaleRef;
269
+ const inputCompleteRef = (0, react.useRef)(inputComplete);
270
+ inputCompleteRef.current = inputComplete;
252
271
  (0, react.useEffect)(() => {
253
272
  holdBackRef.current = shouldHoldBack;
254
273
  }, [shouldHoldBack]);
255
- const clearWakeTimer = (0, react.useCallback)(() => {
256
- if (wakeTimerRef.current !== null) {
257
- clearTimeout(wakeTimerRef.current);
258
- wakeTimerRef.current = null;
259
- }
260
- }, []);
261
274
  const stopFrameLoop = (0, react.useCallback)(() => {
262
275
  if (rafRef.current !== null) {
263
276
  cancelAnimationFrame(rafRef.current);
@@ -265,20 +278,9 @@ window.__ModuleLoader__.load({
265
278
  }
266
279
  lastFrameTsRef.current = null;
267
280
  }, []);
268
- const stopScheduling = (0, react.useCallback)(() => {
269
- stopFrameLoop();
270
- clearWakeTimer();
271
- }, [clearWakeTimer, stopFrameLoop]);
272
281
  const startFrameLoopRef = (0, react.useRef)(() => {});
273
- const scheduleFrameWake = (0, react.useCallback)((delayMs) => {
274
- clearWakeTimer();
275
- wakeTimerRef.current = setTimeout(() => {
276
- wakeTimerRef.current = null;
277
- startFrameLoopRef.current();
278
- }, Math.max(1, Math.ceil(delayMs)));
279
- }, [clearWakeTimer]);
280
282
  const syncImmediate = (0, react.useCallback)((nextContent) => {
281
- stopScheduling();
283
+ stopFrameLoop();
282
284
  const chars = [...nextContent];
283
285
  const now = performance.now();
284
286
  targetContentRef.current = nextContent;
@@ -286,43 +288,64 @@ window.__ModuleLoader__.load({
286
288
  targetCountRef.current = chars.length;
287
289
  displayedContentRef.current = nextContent;
288
290
  displayedCountRef.current = chars.length;
291
+ queueDebtRef.current = 0;
292
+ settleCpsRef.current = null;
293
+ const speedOut = speedOutRef.current;
294
+ if (speedOut !== void 0) speedOut.current = seedCps;
289
295
  setDisplayedContent(nextContent);
290
296
  emaCpsRef.current = seedCps;
291
297
  chunkSizeEmaRef.current = 1;
292
298
  arrivalCpsEmaRef.current = seedCps;
293
299
  lastInputTsRef.current = now;
294
300
  lastInputCountRef.current = chars.length;
295
- }, [seedCps, stopScheduling]);
301
+ }, [seedCps, stopFrameLoop]);
302
+ (0, react.useLayoutEffect)(() => {
303
+ if (!enabled || !inputComplete || displayedContentRef.current === content) return;
304
+ syncImmediate(content);
305
+ }, [
306
+ content,
307
+ enabled,
308
+ inputComplete,
309
+ syncImmediate
310
+ ]);
296
311
  const startFrameLoop = (0, react.useCallback)(() => {
297
- clearWakeTimer();
298
312
  if (rafRef.current !== null) return;
299
- const tick = () => {
313
+ const tick = (now) => {
300
314
  const targetCount = targetCountRef.current;
301
315
  const displayedCount = displayedCountRef.current;
302
316
  const backlog = targetCount - displayedCount;
303
317
  if (backlog <= 0) {
318
+ queueDebtRef.current = 0;
319
+ settleCpsRef.current = null;
320
+ const speedOut = speedOutRef.current;
321
+ if (speedOut !== void 0) speedOut.current = seedCps;
304
322
  stopFrameLoop();
305
323
  return;
306
324
  }
307
- const now = performance.now();
308
325
  if (lastFrameTsRef.current === null) {
309
326
  lastFrameTsRef.current = now;
310
327
  rafRef.current = requestAnimationFrame(tick);
311
328
  return;
312
329
  }
313
- const commitIntervalMs = Math.min(MAX_COMMIT_INTERVAL_MS, config.minCommitIntervalMs);
314
330
  const frameIntervalMs = Math.max(0, now - lastFrameTsRef.current);
315
- if (frameIntervalMs < commitIntervalMs) {
316
- rafRef.current = requestAnimationFrame(tick);
317
- return;
318
- }
319
331
  const dtSeconds = Math.max(.001, Math.min(frameIntervalMs / 1e3, .12));
320
332
  lastFrameTsRef.current = now;
321
333
  const idleMs = now - lastInputTsRef.current;
322
- const inputActive = idleMs <= config.activeInputWindowMs;
323
- const settling = !inputActive && idleMs >= config.settleAfterMs;
334
+ const producerComplete = inputCompleteRef.current;
335
+ const inputActive = !producerComplete && idleMs <= config.activeInputWindowMs;
336
+ const settling = producerComplete || !inputActive && idleMs >= config.settleAfterMs;
337
+ if (!producerComplete) settleCpsRef.current = null;
324
338
  let revealChars;
325
- if (steadyCps !== void 0) {
339
+ let revealSpeedCps;
340
+ let nextQueueDebt = 0;
341
+ if (producerComplete) {
342
+ const settleCps = settleCpsRef.current ?? computeCompletionDrain(config, backlog);
343
+ settleCpsRef.current = settleCps;
344
+ const accumulated = Math.max(0, queueDebtRef.current) + settleCps * dtSeconds;
345
+ revealChars = Math.min(backlog, Math.floor(accumulated));
346
+ revealSpeedCps = settleCps;
347
+ nextQueueDebt = revealChars >= backlog ? 0 : accumulated - revealChars;
348
+ } else if (steadyCps !== void 0) {
326
349
  const step = computeRevealStep(config, {
327
350
  backlog,
328
351
  chunkSizeEma: chunkSizeEmaRef.current,
@@ -333,15 +356,23 @@ window.__ModuleLoader__.load({
333
356
  steadyCps
334
357
  }, dtSeconds);
335
358
  revealChars = Math.min(step.revealChars, backlog);
336
- } else revealChars = computeQueueReveal(backlog, frameIntervalMs);
359
+ revealSpeedCps = frameIntervalMs > 0 ? revealChars * 1e3 / frameIntervalMs : 0;
360
+ } else {
361
+ const step = computeAdaptiveQueueStep(backlog, frameIntervalMs, queueDebtRef.current, revealScaleOutRef.current?.current ?? 1);
362
+ revealChars = step.revealChars;
363
+ revealSpeedCps = step.speedCps;
364
+ nextQueueDebt = step.debt;
365
+ }
337
366
  if (holdBackRef.current?.() === true) {
338
367
  rafRef.current = requestAnimationFrame(tick);
339
368
  return;
340
369
  }
370
+ queueDebtRef.current = nextQueueDebt;
341
371
  const speedOut = speedOutRef.current;
342
- if (speedOut !== void 0 && frameIntervalMs > 0) {
343
- const instantCps = revealChars * 1e3 / frameIntervalMs;
344
- speedOut.current = speedOut.current * .92 + instantCps * .08;
372
+ if (speedOut !== void 0) speedOut.current = revealSpeedCps;
373
+ if (revealChars <= 0) {
374
+ rafRef.current = requestAnimationFrame(tick);
375
+ return;
345
376
  }
346
377
  const nextCount = displayedCount + revealChars;
347
378
  const segment = targetCharsRef.current.slice(displayedCount, nextCount).join("");
@@ -360,9 +391,7 @@ window.__ModuleLoader__.load({
360
391
  rafRef.current = requestAnimationFrame(tick);
361
392
  }, [
362
393
  config,
363
- scheduleFrameWake,
364
394
  stopFrameLoop,
365
- clearWakeTimer,
366
395
  steadyCps
367
396
  ]);
368
397
  (0, react.useEffect)(() => {
@@ -385,12 +414,13 @@ window.__ModuleLoader__.load({
385
414
  targetContentRef.current = content;
386
415
  targetCharsRef.current.push(...appendedChars);
387
416
  targetCountRef.current += appendedCount;
417
+ settleCpsRef.current = null;
388
418
  const hadSample = lastInputTsRef.current > 0;
389
419
  const deltaChars = targetCountRef.current - lastInputCountRef.current;
390
420
  const deltaMs = Math.max(1, now - lastInputTsRef.current);
391
421
  if (hadSample && deltaChars > 0) {
392
422
  const instantCps = deltaChars * 1e3 / deltaMs;
393
- const normalizedInstantCps = clamp$1(instantCps, config.minCps, config.maxFlushCps * 3);
423
+ const normalizedInstantCps = clamp(instantCps, config.minCps, config.maxFlushCps * 3);
394
424
  const chunkEmaAlpha = .45;
395
425
  chunkSizeEmaRef.current = chunkSizeEmaRef.current * .55 + appendedCount * chunkEmaAlpha;
396
426
  arrivalCpsEmaRef.current = arrivalCpsEmaRef.current * .55 + normalizedInstantCps * chunkEmaAlpha;
@@ -408,9 +438,9 @@ window.__ModuleLoader__.load({
408
438
  ]);
409
439
  (0, react.useEffect)(() => {
410
440
  return () => {
411
- stopScheduling();
441
+ stopFrameLoop();
412
442
  };
413
- }, [stopScheduling]);
443
+ }, [stopFrameLoop]);
414
444
  return displayedContent;
415
445
  }
416
446
  //#endregion
@@ -493,289 +523,465 @@ window.__ModuleLoader__.load({
493
523
  /**
494
524
  * Conversation-port follow while an assistant reply streams.
495
525
  *
496
- * The demo's damped-spring lerp is driven from a float `animatedH` — the
497
- * smoothed content height — never from the rounded `scrollTop` the browser
498
- * reports back. A wrap raises the content height by a line; if the engine
499
- * were allowed to snap `scrollTop` to the new floor, the previous line would
500
- * hop up in one frame. This overlay:
526
+ * A sub-stepped spring physics engine drives a float `animatedH`, rather
527
+ * than restarting native smooth-scroll animations as every glyph lands.
528
+ * Remaining lag rides a small compositor transform while the real scrollport
529
+ * stays at its floor. The transform is bounded by the measured paint gap
530
+ * before conversation chrome. This follower:
501
531
  *
502
- * - owns follow via `data-follow-owned` so ChatView does not snap;
532
+ * - marks programmatic writes via `data-follow-owned` for compatible hosts;
503
533
  * - sets `overflow-anchor: none` so CSS scroll-anchoring does not snap;
504
534
  * - restores `animatedH` in a ResizeObserver (before paint) so a layout
505
535
  * pass cannot flash a snapped frame;
506
- * - while the port has scroll room, writes the interpolated lag as
507
- * `translate3d` on `[data-chat-transcript]` (message rows only), so the
508
- * turn-status chrome stays pinned at the floor;
509
- * - clips only the part of the translated surface that crosses the
510
- * turn-status line, preserving the normal gap above that line;
511
- * - leaves `[data-chat-turn-status]` in normal flow at every height, so its
512
- * layout gap cannot be consumed by an interpolated transform.
536
+ * - expresses safe lag as a compositor transform on message rows;
537
+ * - opens a speed-adaptive layout runway before fast output wraps, preserving
538
+ * the reference spring constants at every reveal speed;
539
+ * - catches up any lag that cannot fit before turn status / composer chrome,
540
+ * so fixed chrome never has to counter-shift and the host stays at-bottom;
541
+ * - never clips or overlays streamed text.
513
542
  *
514
- * A real reader unpin first writes the effective visual top (`engine - lag`)
515
- * into `scrollTop` before clearing the transform, so the frame stays
516
- * continuous. Lifecycle completion is different: without a reader gesture,
517
- * the last owner settles at the floor and preserves bottom-follow.
543
+ * A real reader gesture receives the effective visual position before the
544
+ * transform clears. Lifecycle completion instead settles at the floor.
518
545
  *
519
- * Unpin is a real gesture (wheel / touch / pointer / key) that leaves the
520
- * floor; a `scrollTop` delta from our own write must not release the pin.
546
+ * Directional wheel/touch intent unpins immediately; pointer/key input falls
547
+ * back to an upward scroll delta from the engine's own written position. A
548
+ * reader release re-acquires only after returning to the real floor.
521
549
  */
522
550
  /**
523
- * Matches ChatView's `FOLLOW_OWNED_ATTR`. The overlay writes the last
524
- * programmatic `scrollTop` here so ChatView yields snap-follow and does not
525
- * treat the write as reader input.
551
+ * Programmatic follow marker retained for hosts that recognize external
552
+ * scroll ownership. Current Harness also sees the write land at the floor.
526
553
  */
527
554
  const FOLLOW_OWNED_ATTR = "data-follow-owned";
528
- /** Floor / ceiling of the per-frame lerp fraction before the dt term. */
529
- const FOLLOW_LERP_MIN = .05;
530
- const FOLLOW_LERP_MAX = .25;
531
- const FOLLOW_SPEED_FACTOR_MIN = .7;
532
- const FOLLOW_SPEED_FACTOR_MAX = 2.2;
555
+ /** Sub-pixel settle threshold; clearing below this cannot produce a visible rebound. */
556
+ const FOLLOW_SETTLE_EPSILON_PX = .25;
557
+ /** Lowest reveal rate retained while the spring is short on paint room. */
558
+ const FOLLOW_REVEAL_MIN_SCALE = .55;
559
+ /** Safe-lag occupancy band over which reveal pressure is progressively reduced. */
560
+ const FOLLOW_BACKPRESSURE_START_RATIO = .25;
533
561
  const GESTURE_EVENTS = [
534
562
  "wheel",
563
+ "touchstart",
535
564
  "touchmove",
565
+ "touchend",
566
+ "touchcancel",
536
567
  "pointerdown",
537
568
  "keydown"
538
569
  ];
539
- function clamp(value, min, max) {
540
- return Math.min(max, Math.max(min, value));
570
+ /** Visible runway needed for the current reveal pressure. */
571
+ function computeFollowReserve(speedCps, runwayPx = 48) {
572
+ const available = Math.max(0, runwayPx);
573
+ if (available <= 0) return 0;
574
+ if (speedCps <= 90) return 0;
575
+ const normalized = Math.min(1, Math.max(0, (speedCps - 90) / 510));
576
+ const minimum = Math.min(available, 16);
577
+ return minimum + normalized * (available - minimum);
541
578
  }
542
579
  /**
543
- * One spring-lerp frame from the silky markdown demo.
580
+ * Reveal-rate multiplier needed to retain one-wrap headroom for the spring.
581
+ * Throttling starts only after a quarter of the safe transform is occupied;
582
+ * a constrained paint lands at the minimum immediately so the next reveal
583
+ * commit cannot keep feeding an already-full visual buffer.
584
+ */
585
+ function computeFollowRevealScale(lagPx, capacityPx, constrained = false) {
586
+ if (constrained) return FOLLOW_REVEAL_MIN_SCALE;
587
+ if (!Number.isFinite(capacityPx)) return 1;
588
+ if (capacityPx <= 0) return lagPx > 0 ? FOLLOW_REVEAL_MIN_SCALE : 1;
589
+ const ratio = Math.min(1, Math.max(0, lagPx / capacityPx));
590
+ if (ratio >= .75) return FOLLOW_REVEAL_MIN_SCALE;
591
+ const progress = Math.min(1, Math.max(0, (ratio - FOLLOW_BACKPRESSURE_START_RATIO) / .5));
592
+ return 1 - .44999999999999996 * (progress * progress * (3 - 2 * progress));
593
+ }
594
+ /**
595
+ * Semi-implicit spring integration with four substeps per <=32ms slice.
544
596
  * @param dtMs - Frame delta in ms.
545
- * @param input - Current lag (from the float top, not the rounded engine top) and reveal-speed EMA.
546
- * @returns The fractional advance and the lerp fraction.
597
+ * @param input - Current visible lag and carried physics velocity.
598
+ * @returns The position advance, its fraction, and next velocity.
547
599
  */
548
600
  function computeFollowStep(dtMs, input) {
549
601
  if (input.lag <= .1 || dtMs <= 0) return {
550
602
  advancePx: 0,
551
- lerpStep: 0
603
+ lerpStep: 0,
604
+ velocityPxPerSec: 0
552
605
  };
553
- const speedFactor = clamp((input.speedEma > 0 ? input.speedEma : 35) / 35, FOLLOW_SPEED_FACTOR_MIN, FOLLOW_SPEED_FACTOR_MAX);
554
- const lerpStep = clamp(input.lag / 160 * speedFactor, FOLLOW_LERP_MIN, FOLLOW_LERP_MAX) * (1 - Math.exp(-dtMs / 18));
606
+ let lag = input.lag;
607
+ let velocity = Math.max(0, input.velocityPxPerSec ?? 0);
608
+ const elapsedMs = Math.min(32, dtMs);
609
+ const slices = Math.max(1, Math.ceil(elapsedMs / 32));
610
+ const subDt = elapsedMs / 1e3 / slices / 4;
611
+ for (let slice = 0; slice < slices; slice += 1) for (let substep = 0; substep < 4; substep += 1) {
612
+ const acceleration = (130 * lag - 24 * velocity) / 1;
613
+ velocity = Math.max(0, velocity + acceleration * subDt);
614
+ const advance = velocity * subDt;
615
+ if (advance >= lag) return {
616
+ advancePx: input.lag,
617
+ lerpStep: 1,
618
+ velocityPxPerSec: 0
619
+ };
620
+ lag -= advance;
621
+ }
622
+ const advancePx = input.lag - lag;
555
623
  return {
556
- advancePx: input.lag * lerpStep,
557
- lerpStep
624
+ advancePx,
625
+ lerpStep: advancePx / input.lag,
626
+ velocityPxPerSec: velocity
558
627
  };
559
628
  }
560
- /** The message-rows box the lag transform rides on, when the host has one. */
561
- function shiftRootOf(port) {
562
- return port.querySelector("[data-chat-transcript]");
563
- }
564
- /**
565
- * Row wrappers that carry only messages/tools. Hosts without a transcript
566
- * box keep the turn-status chrome as a sibling of the rows inside
567
- * `[data-chat-flow]`, so shifting that whole flow would drag the chrome
568
- * along; shifting the rows individually leaves every non-message sibling
569
- * (turn status, steering bubbles) pinned while the text glides.
570
- *
571
- * Only the outermost rows are shifted: a tool call nests its subcalls as
572
- * descendant `[data-chat-anchor-key]` rows, and writing the lag on each of
573
- * them would double (or further multiply) the shift, tearing the subcalls
574
- * away from their parent every frame. Descendants ride the parent's
575
- * transform instead.
576
- */
577
- function shiftRowsOf(port) {
578
- return [...port.querySelectorAll("[data-chat-anchor-key]")].filter((row) => row.parentElement?.closest("[data-chat-anchor-key]") === null);
579
- }
580
629
  /** Element whose resize signals flow growth for the before-paint restore. */
581
630
  function resizeProxyOf(port) {
582
631
  return port.querySelector("[data-chat-transcript]") ?? port.querySelector("[data-chat-flow]");
583
632
  }
584
- /** True when the glide expresses its lag as transforms instead of engine top. */
585
- function hasShiftSurface(port) {
586
- return shiftRootOf(port) !== null || shiftRowsOf(port).length > 0;
633
+ /** Outermost message surfaces; nested tool rows ride their parent. */
634
+ function shiftSurfacesOf(port) {
635
+ const transcript = port.querySelector("[data-chat-transcript]");
636
+ if (transcript !== null) return [transcript];
637
+ return [...port.querySelectorAll("[data-chat-anchor-key]")].filter((row) => row.parentElement?.closest("[data-chat-anchor-key]") === null);
587
638
  }
588
- /** Cached natural gap from a shifted surface's bottom to the status line. */
589
- const statusGapCache = /* @__PURE__ */ new WeakMap();
590
639
  function currentShiftOf(element) {
591
- return Number(/translate3d\(0, ([\d.]+)px, 0\)/.exec(element.style.transform)?.[1] ?? 0);
592
- }
593
- function statusGapBelow(surface, chrome) {
594
- const cached = statusGapCache.get(surface);
595
- if (cached?.chrome === chrome) return cached.px;
596
- const naturalBottom = surface.getBoundingClientRect().bottom - currentShiftOf(surface);
597
- const px = Math.max(0, chrome.getBoundingClientRect().top - naturalBottom);
598
- statusGapCache.set(surface, {
599
- chrome,
600
- px
601
- });
602
- return px;
640
+ return Number(/translate3d\(0(?:px)?,\s*(-?[\d.]+)px,\s*0(?:px)?\)/.exec(element.style.transform)?.[1] ?? 0);
603
641
  }
604
- function setShift(element, px, paintAllowance) {
642
+ function setShift(element, px) {
605
643
  if (Math.abs(px) > .01) {
644
+ if (Math.abs(currentShiftOf(element) - px) <= .01 && element.style.willChange === "transform" && element.style.clipPath === "") return;
606
645
  element.style.transform = `translate3d(0, ${px}px, 0)`;
607
646
  element.style.willChange = "transform";
608
- const bottomClip = paintAllowance === void 0 ? 0 : Math.max(0, px - paintAllowance);
609
- element.style.clipPath = bottomClip > .01 ? `inset(0 0 ${bottomClip}px 0)` : "";
610
647
  } else {
648
+ if (element.style.transform === "" && element.style.willChange === "" && element.style.clipPath === "") return;
611
649
  element.style.transform = "";
612
650
  element.style.willChange = "";
613
- element.style.clipPath = "";
614
651
  }
652
+ element.style.clipPath = "";
653
+ }
654
+ function turnStatusOf(port) {
655
+ return port.querySelector("[data-chat-turn-status], [data-chat-flow] > [role=\"status\"]");
656
+ }
657
+ /** Height committed by one newly mounted Chat row, including its flex gap. */
658
+ function entranceExtentOf(root) {
659
+ const row = root.closest("[data-chat-flow-key]") ?? root;
660
+ const rect = row.getBoundingClientRect();
661
+ const height = Math.max(0, rect.height, rect.bottom - rect.top, row.offsetHeight);
662
+ let previous = row.previousElementSibling;
663
+ while (previous instanceof HTMLElement) {
664
+ const previousRect = previous.getBoundingClientRect();
665
+ if (previousRect.height > 0 || previousRect.bottom > previousRect.top) return Math.max(height, rect.bottom - previousRect.bottom);
666
+ previous = previous.previousElementSibling;
667
+ }
668
+ return height;
669
+ }
670
+ const followRunways = /* @__PURE__ */ new WeakMap();
671
+ const followPaintLimits = /* @__PURE__ */ new WeakMap();
672
+ function invalidatePaintLimit(port) {
673
+ followPaintLimits.delete(port);
674
+ }
675
+ /** Logical position and velocity survive a React owner handoff and finish. */
676
+ const followMotionStates = /* @__PURE__ */ new WeakMap();
677
+ function restoreRunway(port) {
678
+ const runway = followRunways.get(port);
679
+ if (runway === void 0) return;
680
+ runway.element.style[runway.property] = runway.original;
681
+ followRunways.delete(port);
682
+ invalidatePaintLimit(port);
683
+ }
684
+ function ensureRunway(port, surfaces) {
685
+ const status = turnStatusOf(port);
686
+ const composer = port.querySelector("[data-composer-seat]");
687
+ const target = status === null ? {
688
+ element: composer === null ? void 0 : surfaces.at(-1),
689
+ property: "marginBottom"
690
+ } : {
691
+ element: status,
692
+ property: "marginTop"
693
+ };
694
+ if (target.element === void 0) {
695
+ restoreRunway(port);
696
+ return;
697
+ }
698
+ const element = target.element;
699
+ const current = followRunways.get(port);
700
+ if (current?.element === element && current.property === target.property) return;
701
+ restoreRunway(port);
702
+ const beforeHeight = port.scrollHeight;
703
+ const original = element.style[target.property];
704
+ element.style[target.property] = original === "" ? `48px` : `calc(${original} + 48px)`;
705
+ const offset = Math.max(0, port.scrollHeight - beforeHeight);
706
+ followRunways.set(port, {
707
+ element,
708
+ offset,
709
+ property: target.property,
710
+ original
711
+ });
712
+ invalidatePaintLimit(port);
713
+ }
714
+ function runwayOffsetOf(port) {
715
+ return followRunways.get(port)?.offset ?? 0;
716
+ }
717
+ /** Available paint room below the last message before fixed conversation chrome. */
718
+ function safeShiftLimit(port, surfaces) {
719
+ const last = surfaces.at(-1);
720
+ if (last === void 0) return 0;
721
+ const status = turnStatusOf(port);
722
+ const composer = port.querySelector("[data-composer-seat]");
723
+ const cached = followPaintLimits.get(port);
724
+ if (cached !== void 0 && performance.now() - cached.measuredAtMs <= 250 && cached.clientHeight === port.clientHeight && cached.surface === last && cached.status === status && cached.composer === composer) return cached.limit;
725
+ const ceiling = [status, composer].filter((element) => element !== null).map((element) => ({
726
+ element,
727
+ rect: element.getBoundingClientRect()
728
+ })).filter(({ rect }) => Number.isFinite(rect.top) && Number.isFinite(rect.bottom) && rect.bottom > rect.top).sort((first, second) => first.rect.top - second.rect.top)[0];
729
+ if (ceiling === void 0) return status === null && composer === null ? Number.POSITIVE_INFINITY : runwayOffsetOf(port);
730
+ const ceilingTop = ceiling.rect.top - currentShiftOf(ceiling.element);
731
+ const naturalBottom = last.getBoundingClientRect().bottom - currentShiftOf(last);
732
+ const limit = Math.max(0, ceilingTop - naturalBottom - 1);
733
+ followPaintLimits.set(port, {
734
+ clientHeight: port.clientHeight,
735
+ limit,
736
+ measuredAtMs: performance.now(),
737
+ composer,
738
+ status,
739
+ surface: last
740
+ });
741
+ return limit;
742
+ }
743
+ function setFollowScrollTop(port, nextTop) {
744
+ if (Math.abs(port.scrollTop - nextTop) > .01) port.scrollTop = nextTop;
745
+ followScrollLedgers.set(port, port.scrollTop);
746
+ const ownedTop = String(port.scrollTop);
747
+ if (port.getAttribute(FOLLOW_OWNED_ATTR) !== ownedTop) port.setAttribute(FOLLOW_OWNED_ATTR, ownedTop);
615
748
  }
616
749
  /**
617
- * The running-turn label. It stays in normal flow so the host column's gap
618
- * always separates it from the transcript.
750
+ * Last scrollTop this engine wrote or accepted, per port. Reader intent is a
751
+ * real upward delta from this ledger; a key press or touch while pinned
752
+ * (typing in the composer) must not release the pin, because a released pin
753
+ * can never re-acquire while content streams away from the reader position.
619
754
  */
620
- function statusChromeOf(port) {
621
- return port.querySelector("[data-chat-turn-status], [data-chat-flow] > [role=\"status\"]");
755
+ const followScrollLedgers = /* @__PURE__ */ new WeakMap();
756
+ const followActivityAt = /* @__PURE__ */ new WeakMap();
757
+ /** Whether this port was owned recently enough to identify a closing tail row. */
758
+ function hasRecentConversationFollow(port, windowMs = 250) {
759
+ const last = followActivityAt.get(port);
760
+ return last !== void 0 && performance.now() - last <= windowMs;
761
+ }
762
+ function readerScrolledUp(port) {
763
+ return port.scrollTop < (followScrollLedgers.get(port) ?? 0) - 8;
622
764
  }
623
765
  /**
624
- * Render the single smoothed extent `animatedH`. Once the port has scroll
625
- * room the engine is pinned at the floor and the lag rides
626
- * `[data-chat-transcript]` (or each message row when the host has no
627
- * transcript box). Before that, message rows and status chrome remain in
628
- * normal flow. Without any shift surface the engine carries the interpolated
629
- * top itself.
766
+ * Paint a bounded visual lag and return the effective logical extent.
767
+ *
768
+ * This is the final geometry invariant, not merely an animation preference:
769
+ * any lag beyond the real gap to status/composer chrome is caught up in the
770
+ * same frame. Carrying that excess in `scrollTop` would move the transcript
771
+ * toward fixed chrome and also make the host expose jump-to-bottom.
630
772
  */
631
- function applyVisual(port, animatedH) {
773
+ function applyVisual(port, animatedH, reservePx, velocityPxPerSec = 0) {
774
+ const surfaces = shiftSurfacesOf(port);
775
+ ensureRunway(port, surfaces);
632
776
  const contentHeight = Math.max(0, port.scrollHeight);
777
+ const runwayOffset = runwayOffsetOf(port);
778
+ const targetHeight = Math.max(0, contentHeight - runwayOffset);
633
779
  const floor = Math.max(0, contentHeight - port.clientHeight);
634
- const extent = Math.min(contentHeight, Math.max(0, animatedH));
635
- const lag = contentHeight - extent;
636
- port.style.overflowAnchor = "none";
637
- port.style.scrollBehavior = "auto";
638
- const chrome = statusChromeOf(port);
639
- if (chrome !== null) setShift(chrome, 0);
640
- const shiftRoot = shiftRootOf(port);
641
- if (shiftRoot !== null) {
642
- if (floor <= 0) {
643
- port.scrollTop = 0;
644
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop));
645
- setShift(shiftRoot, 0);
646
- return;
647
- }
648
- port.scrollTop = floor;
649
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop));
650
- setShift(shiftRoot, lag, chrome === null ? void 0 : statusGapBelow(shiftRoot, chrome));
651
- return;
652
- }
653
- const rows = shiftRowsOf(port);
654
- if (rows.length > 0) {
655
- if (floor <= 0) {
656
- port.scrollTop = 0;
657
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop));
658
- for (const row of rows) setShift(row, 0);
659
- return;
660
- }
661
- port.scrollTop = floor;
662
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop));
663
- const last = rows.length - 1;
664
- for (const [index, row] of rows.entries()) setShift(row, lag, index === last && chrome !== null ? statusGapBelow(row, chrome) : void 0);
665
- return;
780
+ const extent = Math.min(targetHeight, Math.max(0, animatedH));
781
+ if (port.style.overflowAnchor !== "none") port.style.overflowAnchor = "none";
782
+ if (port.style.scrollBehavior !== "auto") port.style.scrollBehavior = "auto";
783
+ if (floor <= 0) {
784
+ setFollowScrollTop(port, 0);
785
+ followMotionStates.set(port, {
786
+ capacityPx: Number.POSITIVE_INFINITY,
787
+ constrained: false,
788
+ extent: targetHeight,
789
+ lagPx: 0,
790
+ reservePx: 0,
791
+ velocityPxPerSec: 0
792
+ });
793
+ for (const surface of surfaces) setShift(surface, 0);
794
+ const status = turnStatusOf(port);
795
+ if (status !== null) setShift(status, 0);
796
+ return targetHeight;
666
797
  }
667
- port.scrollTop = Math.min(floor, Math.max(0, extent));
668
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop));
798
+ setFollowScrollTop(port, floor);
799
+ const limit = floor > 0 ? safeShiftLimit(port, surfaces) : 0;
800
+ const visibleReserve = Math.min(runwayOffset, Math.max(0, reservePx));
801
+ const baselineShift = runwayOffset - visibleReserve;
802
+ const requestedLag = Math.max(0, targetHeight - extent);
803
+ const shift = Math.min(baselineShift + requestedLag, Math.max(0, limit));
804
+ const effectiveLag = Math.max(0, shift - baselineShift);
805
+ const capacityPx = Math.max(0, limit - baselineShift);
806
+ const effectiveExtent = targetHeight - effectiveLag;
807
+ followMotionStates.set(port, {
808
+ capacityPx,
809
+ constrained: requestedLag > effectiveLag + FOLLOW_SETTLE_EPSILON_PX,
810
+ extent: effectiveExtent,
811
+ lagPx: effectiveLag,
812
+ reservePx: visibleReserve,
813
+ velocityPxPerSec
814
+ });
815
+ for (const surface of surfaces) setShift(surface, shift);
816
+ const status = turnStatusOf(port);
817
+ if (status !== null) setShift(status, 0);
818
+ return effectiveExtent;
669
819
  }
670
- function clearVisual(port) {
820
+ function clearMotion(port) {
671
821
  port.removeAttribute(FOLLOW_OWNED_ATTR);
672
822
  port.style.overflowAnchor = "";
673
823
  port.style.scrollBehavior = "";
674
- const shiftRoot = shiftRootOf(port);
675
- if (shiftRoot !== null) {
676
- statusGapCache.delete(shiftRoot);
677
- shiftRoot.style.transform = "";
678
- shiftRoot.style.willChange = "";
679
- shiftRoot.style.clipPath = "";
680
- } else for (const row of shiftRowsOf(port)) {
681
- statusGapCache.delete(row);
682
- row.style.transform = "";
683
- row.style.willChange = "";
684
- row.style.clipPath = "";
685
- }
686
- const chrome = statusChromeOf(port);
687
- if (chrome !== null) {
688
- chrome.style.transform = "";
689
- chrome.style.willChange = "";
690
- chrome.style.clipPath = "";
691
- }
824
+ for (const surface of shiftSurfacesOf(port)) setShift(surface, 0);
825
+ const status = turnStatusOf(port);
826
+ if (status !== null) setShift(status, 0);
692
827
  }
693
- function settleAtFloor(port) {
694
- port.scrollTop = Math.max(0, port.scrollHeight - port.clientHeight);
695
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop));
828
+ function clearVisual(port) {
829
+ clearMotion(port);
830
+ restoreRunway(port);
831
+ followMotionStates.delete(port);
832
+ invalidatePaintLimit(port);
696
833
  }
697
- /** Live follow hosts per conversation port so one unmount does not clear another. */
698
- const followOwners = /* @__PURE__ */ new WeakMap();
699
- function acquireFollow(port) {
700
- followOwners.set(port, (followOwners.get(port) ?? 0) + 1);
834
+ /** Keep an already-promoted surface at zero until one stable final paint lands. */
835
+ function holdCompositorAtRest(element) {
836
+ element.style.transform = "translate3d(0, 0px, 0)";
837
+ element.style.willChange = "transform";
838
+ element.style.clipPath = "";
701
839
  }
702
- function releaseFollow(port) {
703
- const next = (followOwners.get(port) ?? 1) - 1;
704
- if (next <= 0) {
705
- followOwners.delete(port);
706
- return 0;
840
+ /** Remove equal offsets, land on the floor, then retire the compositor quietly. */
841
+ function finishAtNaturalFloor(port) {
842
+ const surfaces = shiftSurfacesOf(port);
843
+ const status = turnStatusOf(port);
844
+ const promoted = [...surfaces, ...status === null ? [] : [status]].filter((element) => element.style.transform !== "" || element.style.willChange === "transform");
845
+ const promotedSet = new Set(promoted);
846
+ restoreRunway(port);
847
+ settleAtFloor(port);
848
+ port.removeAttribute(FOLLOW_OWNED_ATTR);
849
+ port.style.overflowAnchor = "";
850
+ port.style.scrollBehavior = "";
851
+ for (const surface of surfaces) if (promotedSet.has(surface)) holdCompositorAtRest(surface);
852
+ else setShift(surface, 0);
853
+ if (status !== null) {
854
+ if (promotedSet.has(status)) holdCompositorAtRest(status);
855
+ else setShift(status, 0);
707
856
  }
708
- followOwners.set(port, next);
709
- return next;
857
+ followMotionStates.delete(port);
858
+ if (promoted.length === 0) return;
859
+ requestAnimationFrame(() => {
860
+ requestAnimationFrame(() => {
861
+ if (port.hasAttribute(FOLLOW_OWNED_ATTR)) return;
862
+ for (const element of promoted) if (Math.abs(currentShiftOf(element)) <= .01) setShift(element, 0);
863
+ });
864
+ });
865
+ }
866
+ function settleAtFloor(port) {
867
+ setFollowScrollTop(port, Math.max(0, port.scrollHeight - port.clientHeight));
710
868
  }
869
+ /** Only the newest active follower may write one port's shared visual state. */
870
+ const followLeaders = /* @__PURE__ */ new WeakMap();
871
+ let followGeneration = 0;
711
872
  /**
712
873
  * Own the conversation scrollport's bottom-follow while `active` is true.
713
874
  *
714
875
  * @param rootRef - An element inside the conversation scrollport.
715
876
  * @param active - True while the reply is still revealing.
716
877
  * @param speedCpsRef - Live reveal-rate EMA from the smoother.
878
+ * @param revealScaleRef - Optional backpressure control for text reveal.
879
+ * @param predictive - Whether to reserve paint room ahead of growth.
880
+ * @param entrance - Whether the first committed row height should glide in.
881
+ * @param onEntranceSettled - Releases a one-shot entrance owner after catch-up.
882
+ * @param predictiveRef - Optional live visibility gate for predictive runway.
883
+ * @param entranceExtentRef - Optional measured growth delta for a generic row.
717
884
  */
718
- function useConversationFollow(rootRef, active, speedCpsRef) {
885
+ function useConversationFollow(rootRef, active, speedCpsRef, revealScaleRef, predictive = true, entrance = false, onEntranceSettled, predictiveRef, entranceExtentRef) {
719
886
  const activeRef = (0, react.useRef)(active);
887
+ const entranceRef = (0, react.useRef)(entrance);
888
+ const onEntranceSettledRef = (0, react.useRef)(onEntranceSettled);
889
+ entranceRef.current = entrance;
890
+ onEntranceSettledRef.current = onEntranceSettled;
720
891
  (0, react.useEffect)(() => {
721
892
  activeRef.current = active;
722
893
  }, [active]);
723
- (0, react.useEffect)(() => {
894
+ (0, react.useLayoutEffect)(() => {
724
895
  if (!active) return;
896
+ const owner = {};
897
+ const generation = ++followGeneration;
725
898
  let rafId = 0;
726
899
  let last = performance.now();
727
900
  let following = true;
728
901
  let primed = false;
729
902
  let animatedH = 0;
903
+ let reservePx = 0;
904
+ let velocityPxPerSec = 0;
730
905
  let interacting = false;
906
+ let readerGestureIntent = false;
907
+ let readerReleased = false;
908
+ let touchStartY = null;
731
909
  let interactTimer = null;
732
910
  let port = null;
733
911
  let resize = null;
734
912
  let holding = null;
735
- let awayPx = 0;
913
+ let entrancePending = entranceRef.current;
914
+ const finishEntrance = () => {
915
+ if (!entrancePending) return;
916
+ entrancePending = false;
917
+ onEntranceSettledRef.current?.();
918
+ };
919
+ const updateRevealScale = (next, elapsedMs, urgent = false) => {
920
+ if (revealScaleRef === void 0) return;
921
+ const state = followMotionStates.get(next);
922
+ const target = state === void 0 ? 1 : computeFollowRevealScale(state.lagPx, state.capacityPx, state.constrained);
923
+ const current = Math.min(1, Math.max(FOLLOW_REVEAL_MIN_SCALE, revealScaleRef.current));
924
+ if (target < current || urgent) {
925
+ revealScaleRef.current = Math.min(current, target);
926
+ return;
927
+ }
928
+ const releaseStep = 1 - Math.exp(-Math.max(0, elapsedMs) / 240);
929
+ revealScaleRef.current = current + (target - current) * releaseStep;
930
+ };
931
+ const releaseRevealScale = () => {
932
+ if (revealScaleRef !== void 0) revealScaleRef.current = 1;
933
+ };
934
+ const isLeader = (next) => followLeaders.get(next)?.owner === owner;
736
935
  const hold = (next) => {
737
- if (holding === next) return;
738
- if (holding !== null) releaseFollow(holding);
739
- acquireFollow(next);
936
+ followActivityAt.set(next, performance.now());
937
+ if (holding === next && isLeader(next)) return;
740
938
  holding = next;
939
+ const leader = followLeaders.get(next);
940
+ if (leader === void 0 || generation > leader.generation) followLeaders.set(next, {
941
+ generation,
942
+ owner
943
+ });
741
944
  };
742
945
  const drop = (next) => {
743
- if (holding === next) {
744
- releaseFollow(next);
745
- holding = null;
946
+ if (holding === next) holding = null;
947
+ if (isLeader(next)) {
948
+ clearMotion(next);
949
+ followMotionStates.delete(next);
950
+ releaseRevealScale();
746
951
  }
747
- clearVisual(next);
748
952
  };
749
- /**
750
- * Give the reader the visual position the glide was showing before the
751
- * transforms go away. While transforms carry the lag, the engine sits at
752
- * the floor and the effective visual top is `engine - lag`; writing that
753
- * keeps the handover frame-continuous, and the write shows up in
754
- * ChatView's ledger as reader movement, disarming its snap-follow.
755
- * Without a shift surface the engine already holds the interpolated top,
756
- * so there is nothing to compensate.
757
- */
758
953
  const handBackVisual = (next) => {
759
- if (!hasShiftSurface(next)) return;
954
+ const shift = currentShiftOf(shiftSurfacesOf(next).at(-1) ?? next);
955
+ const visualTop = Math.max(0, next.scrollTop - Math.max(0, shift));
760
956
  const floor = Math.max(0, next.scrollHeight - next.clientHeight);
761
- if (floor <= 0) return;
762
- const lag = Math.max(0, next.scrollHeight - animatedH);
763
- next.scrollTop = Math.min(floor, Math.max(0, next.scrollTop - lag));
957
+ next.scrollTop = Math.min(visualTop, Math.max(0, floor - 26));
958
+ followScrollLedgers.set(next, next.scrollTop);
764
959
  };
765
960
  const markGesture = (event) => {
766
961
  interacting = true;
767
- if (event instanceof WheelEvent && event.deltaY < 0) awayPx += -event.deltaY;
768
- else if (event.type === "touchmove" || event.type === "keydown") awayPx += 8;
962
+ if (event.type === "wheel") {
963
+ const deltaY = event.deltaY;
964
+ if (Number.isFinite(deltaY) && deltaY < 0) readerGestureIntent = true;
965
+ } else if (event.type === "touchstart") touchStartY = event.touches[0]?.clientY ?? null;
966
+ else if (event.type === "touchmove") {
967
+ const touch = event.touches[0];
968
+ if (touch !== void 0) {
969
+ if (touchStartY === null) touchStartY = touch.clientY;
970
+ if (touch.clientY - touchStartY > 1) readerGestureIntent = true;
971
+ }
972
+ } else if (event.type === "touchend" || event.type === "touchcancel") touchStartY = null;
769
973
  if (interactTimer !== null) clearTimeout(interactTimer);
770
974
  interactTimer = setTimeout(() => {
771
975
  interacting = false;
976
+ readerGestureIntent = false;
772
977
  interactTimer = null;
773
- awayPx = 0;
774
978
  }, 800);
775
979
  };
776
980
  const restoreBeforePaint = () => {
777
- if (!following || port === null) return;
778
- applyVisual(port, animatedH);
981
+ if (!following || port === null || !isLeader(port)) return;
982
+ invalidatePaintLimit(port);
983
+ animatedH = applyVisual(port, animatedH, reservePx, velocityPxPerSec);
984
+ updateRevealScale(port, 0, true);
779
985
  };
780
986
  const bindPort = (next) => {
781
987
  if (port === next) return;
@@ -784,6 +990,7 @@ window.__ModuleLoader__.load({
784
990
  resize?.disconnect();
785
991
  }
786
992
  port = next;
993
+ invalidatePaintLimit(port);
787
994
  for (const name of GESTURE_EVENTS) port.addEventListener(name, markGesture, { passive: true });
788
995
  if (typeof ResizeObserver !== "undefined") {
789
996
  resize = new ResizeObserver(restoreBeforePaint);
@@ -794,48 +1001,96 @@ window.__ModuleLoader__.load({
794
1001
  };
795
1002
  const frame = (now) => {
796
1003
  rafId = requestAnimationFrame(frame);
797
- const dt = Math.min(50, now - last);
1004
+ const elapsedMs = Math.max(.001, now - last);
1005
+ const dt = Math.min(32, elapsedMs);
798
1006
  last = now;
799
1007
  const root = rootRef.current;
800
1008
  if (root === null) return;
801
1009
  const nextPort = root.closest("[data-conversation-scroll]");
802
1010
  if (nextPort === null) return;
803
1011
  bindPort(nextPort);
1012
+ if (nextPort.clientHeight <= 0) return;
804
1013
  const reportedLag = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight) - nextPort.scrollTop;
805
1014
  const extent = Math.min(nextPort.scrollHeight, Math.max(0, nextPort.scrollHeight - reportedLag));
806
1015
  if (!primed) {
807
- animatedH = extent;
808
- following = reportedLag <= 25;
1016
+ const inherited = nextPort.hasAttribute(FOLLOW_OWNED_ATTR) ? followMotionStates.get(nextPort) : void 0;
1017
+ if (inherited === void 0) {
1018
+ const entranceExtent = entrancePending ? entranceExtentRef?.current ?? entranceExtentOf(root) : 0;
1019
+ animatedH = Math.max(0, nextPort.scrollHeight - entranceExtent);
1020
+ reservePx = 0;
1021
+ velocityPxPerSec = 0;
1022
+ following = Math.max(0, reportedLag - entranceExtent) <= 25;
1023
+ } else {
1024
+ animatedH = Math.min(nextPort.scrollHeight, inherited.extent);
1025
+ reservePx = inherited.reservePx;
1026
+ velocityPxPerSec = inherited.velocityPxPerSec;
1027
+ following = true;
1028
+ }
809
1029
  if (following) {
810
1030
  hold(nextPort);
811
- applyVisual(nextPort, animatedH);
812
- }
1031
+ if (isLeader(nextPort)) {
1032
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec);
1033
+ updateRevealScale(nextPort, elapsedMs);
1034
+ const runwayOffset = runwayOffsetOf(nextPort);
1035
+ if (Math.max(0, nextPort.scrollHeight - animatedH - runwayOffset) <= .25) finishEntrance();
1036
+ } else finishEntrance();
1037
+ } else finishEntrance();
813
1038
  primed = true;
814
1039
  return;
815
1040
  }
816
- if (!following && !interacting && reportedLag <= 25) {
1041
+ if (!following && !interacting && reportedLag <= (readerReleased ? 1 : 25)) {
817
1042
  following = true;
1043
+ readerReleased = false;
818
1044
  animatedH = extent;
1045
+ reservePx = 0;
1046
+ velocityPxPerSec = 0;
1047
+ followScrollLedgers.set(nextPort, nextPort.scrollTop);
819
1048
  hold(nextPort);
820
- } else if (following && interacting && awayPx >= 8) {
1049
+ } else if (following && interacting && (readerGestureIntent || readerScrolledUp(nextPort))) {
821
1050
  following = false;
822
- awayPx = 0;
1051
+ readerGestureIntent = false;
1052
+ readerReleased = true;
823
1053
  handBackVisual(nextPort);
824
1054
  animatedH = nextPort.scrollHeight;
1055
+ reservePx = 0;
1056
+ velocityPxPerSec = 0;
825
1057
  drop(nextPort);
1058
+ finishEntrance();
1059
+ }
1060
+ if (!activeRef.current || !following) {
1061
+ followScrollLedgers.set(nextPort, nextPort.scrollTop);
1062
+ return;
826
1063
  }
827
- if (!activeRef.current || !following) return;
828
1064
  hold(nextPort);
829
- const lag = nextPort.scrollHeight - animatedH;
1065
+ if (!isLeader(nextPort)) {
1066
+ finishEntrance();
1067
+ return;
1068
+ }
1069
+ const runwayOffset = runwayOffsetOf(nextPort);
1070
+ const contentHeight = nextPort.scrollHeight;
1071
+ const lag = Math.max(0, contentHeight - animatedH - runwayOffset);
1072
+ const predictGrowth = predictiveRef?.current ?? predictive;
1073
+ const reserveTarget = !predictGrowth ? 0 : computeFollowReserve(speedCpsRef.current, runwayOffset);
1074
+ const reserveStep = 1 - Math.exp(-elapsedMs / 180);
1075
+ reservePx += (reserveTarget - reservePx) * reserveStep;
830
1076
  const step = computeFollowStep(dt, {
831
1077
  lag,
832
- speedEma: speedCpsRef.current
1078
+ speedEma: speedCpsRef.current,
1079
+ velocityPxPerSec
833
1080
  });
834
- if (lag <= .1) animatedH = nextPort.scrollHeight;
835
- else animatedH = Math.min(nextPort.scrollHeight, animatedH + step.advancePx);
836
- applyVisual(nextPort, animatedH);
1081
+ if (lag <= .1) {
1082
+ animatedH = contentHeight - runwayOffset;
1083
+ velocityPxPerSec = 0;
1084
+ } else {
1085
+ const minimumLag = predictGrowth ? 0 : Math.max(0, reservePx);
1086
+ animatedH = Math.min(contentHeight - runwayOffset - minimumLag, animatedH + step.advancePx);
1087
+ velocityPxPerSec = step.velocityPxPerSec;
1088
+ }
1089
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec);
1090
+ updateRevealScale(nextPort, elapsedMs);
1091
+ if (Math.max(0, nextPort.scrollHeight - animatedH - runwayOffsetOf(nextPort)) <= .25) finishEntrance();
837
1092
  };
838
- rafId = requestAnimationFrame(frame);
1093
+ frame(performance.now());
839
1094
  return () => {
840
1095
  cancelAnimationFrame(rafId);
841
1096
  if (interactTimer !== null) clearTimeout(interactTimer);
@@ -843,31 +1098,125 @@ window.__ModuleLoader__.load({
843
1098
  if (port !== null) for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture);
844
1099
  const host = rootRef.current?.closest("[data-conversation-scroll]") ?? port;
845
1100
  if (host === null) return;
846
- const remaining = holding === host ? releaseFollow(host) : followOwners.get(host) ?? 0;
847
1101
  holding = null;
848
- if (remaining === 0) {
849
- if (following) {
850
- if (interacting && awayPx >= 8) handBackVisual(host);
851
- else settleAtFloor(host);
852
- }
1102
+ if (!isLeader(host)) return;
1103
+ const preserveReader = interacting && (readerGestureIntent || readerScrolledUp(host));
1104
+ if (!following || !primed) {
853
1105
  clearVisual(host);
1106
+ followLeaders.delete(host);
1107
+ releaseRevealScale();
1108
+ return;
1109
+ }
1110
+ if (preserveReader) {
1111
+ handBackVisual(host);
1112
+ clearVisual(host);
1113
+ followLeaders.delete(host);
1114
+ releaseRevealScale();
1115
+ return;
854
1116
  }
1117
+ ensureRunway(host, shiftSurfacesOf(host));
1118
+ const completionRunway = runwayOffsetOf(host);
1119
+ const completionMinimumLag = Math.max(0, reservePx);
1120
+ animatedH = Math.min(animatedH, host.scrollHeight - completionRunway - completionMinimumLag);
1121
+ settleAtFloor(host);
1122
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec);
1123
+ const runwayOffset = runwayOffsetOf(host);
1124
+ if (Math.max(0, host.scrollHeight - animatedH - runwayOffset) <= .25 && reservePx <= .25) {
1125
+ finishAtNaturalFloor(host);
1126
+ followLeaders.delete(host);
1127
+ releaseRevealScale();
1128
+ return;
1129
+ }
1130
+ for (const name of GESTURE_EVENTS) host.addEventListener(name, markGesture, { passive: true });
1131
+ const stopSettleListeners = () => {
1132
+ for (const name of GESTURE_EVENTS) host.removeEventListener(name, markGesture);
1133
+ if (interactTimer !== null) {
1134
+ clearTimeout(interactTimer);
1135
+ interactTimer = null;
1136
+ }
1137
+ };
1138
+ let settleLast = performance.now();
1139
+ const settleFrame = (now) => {
1140
+ if (!isLeader(host)) {
1141
+ stopSettleListeners();
1142
+ return;
1143
+ }
1144
+ if (interacting && (readerGestureIntent || readerScrolledUp(host))) {
1145
+ readerGestureIntent = false;
1146
+ handBackVisual(host);
1147
+ clearVisual(host);
1148
+ followLeaders.delete(host);
1149
+ releaseRevealScale();
1150
+ stopSettleListeners();
1151
+ return;
1152
+ }
1153
+ const dt = Math.min(32, Math.max(0, now - settleLast));
1154
+ settleLast = now;
1155
+ const runwayOffset = runwayOffsetOf(host);
1156
+ const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset);
1157
+ const reserveStep = 1 - Math.exp(-dt / 180);
1158
+ reservePx += (0 - reservePx) * reserveStep;
1159
+ if (lag <= .25 && reservePx <= .25) {
1160
+ animatedH = host.scrollHeight - runwayOffset;
1161
+ reservePx = 0;
1162
+ velocityPxPerSec = 0;
1163
+ finishAtNaturalFloor(host);
1164
+ followLeaders.delete(host);
1165
+ releaseRevealScale();
1166
+ stopSettleListeners();
1167
+ return;
1168
+ }
1169
+ const step = computeFollowStep(dt, {
1170
+ lag,
1171
+ speedEma: speedCpsRef.current,
1172
+ velocityPxPerSec
1173
+ });
1174
+ const minimumLag = Math.max(0, reservePx);
1175
+ animatedH = Math.min(host.scrollHeight - runwayOffset - minimumLag, animatedH + step.advancePx);
1176
+ velocityPxPerSec = step.velocityPxPerSec;
1177
+ settleAtFloor(host);
1178
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec);
1179
+ requestAnimationFrame(settleFrame);
1180
+ };
1181
+ requestAnimationFrame(settleFrame);
855
1182
  };
856
1183
  }, [
857
1184
  active,
858
1185
  rootRef,
859
- speedCpsRef
1186
+ speedCpsRef,
1187
+ revealScaleRef,
1188
+ predictive,
1189
+ predictiveRef
860
1190
  ]);
861
1191
  }
862
1192
  //#endregion
863
1193
  //#region src/client/FollowHost.tsx
864
1194
  /**
865
1195
  * Document-flow host that owns conversation-port follow while `active`.
866
- * Shared by assistant blocks and every other growing Chat row.
1196
+ * Shared by assistant blocks and every other Agent Chat row. `onGrowth` lets
1197
+ * generic wrapped renderers re-arm one glide when their DOM grows without
1198
+ * requiring a business-kind-specific lifecycle predicate.
867
1199
  */
868
- function FollowHost({ active, speedCpsRef, children }) {
869
- const rootRef = (0, react.useRef)(null);
870
- useConversationFollow(rootRef, active, speedCpsRef);
1200
+ function FollowHost({ active, entrance = false, onEntranceSettled, onGrowth, entranceExtentRef, speedCpsRef, revealScaleRef, predictive = true, predictiveRef, hostRef, children }) {
1201
+ const localRootRef = (0, react.useRef)(null);
1202
+ const rootRef = hostRef ?? localRootRef;
1203
+ useConversationFollow(rootRef, active || entrance, speedCpsRef, revealScaleRef, predictive, entrance, onEntranceSettled, predictiveRef, entranceExtentRef);
1204
+ (0, react.useEffect)(() => {
1205
+ if (onGrowth === void 0 || typeof ResizeObserver === "undefined") return;
1206
+ const root = rootRef.current;
1207
+ if (root === null) return;
1208
+ let previousHeight = null;
1209
+ const observer = new ResizeObserver((entries) => {
1210
+ const nextHeight = entries[0]?.contentRect.height ?? root.getBoundingClientRect().height;
1211
+ if (!Number.isFinite(nextHeight)) return;
1212
+ if (previousHeight !== null && nextHeight > previousHeight + .5) onGrowth(nextHeight - previousHeight);
1213
+ previousHeight = nextHeight;
1214
+ });
1215
+ observer.observe(root);
1216
+ return () => {
1217
+ observer.disconnect();
1218
+ };
1219
+ }, [onGrowth]);
871
1220
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
872
1221
  ref: rootRef,
873
1222
  className: TypewriterAssistantNodeView_module_css_default.follow,
@@ -907,6 +1256,45 @@ window.__ModuleLoader__.load({
907
1256
  }, []);
908
1257
  return reduced;
909
1258
  }
1259
+ /** Conservative fallback before the streaming Markdown tail has geometry. */
1260
+ const PREDICTIVE_WRAP_FALLBACK_CHARS = 32;
1261
+ function approximateInlineWidth(text, emPx) {
1262
+ let width = 0;
1263
+ for (const char of text) if (/\s/u.test(char)) width += emPx * .33;
1264
+ else if (/^[\x00-\x7f]$/u.test(char)) width += emPx * .56;
1265
+ else width += emPx;
1266
+ return width;
1267
+ }
1268
+ /** Whether buffered source can reach a new visual line before it drains. */
1269
+ function pendingTextCanGrow(root, pending) {
1270
+ if (pending === "") return false;
1271
+ if (/[\r\n]/u.test(pending)) return true;
1272
+ const pendingChars = [...pending];
1273
+ if (root === null || typeof document.createTreeWalker !== "function" || typeof NodeFilter === "undefined") return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS;
1274
+ const rootRect = root.getBoundingClientRect();
1275
+ const rootWidth = Math.max(0, rootRect.width, rootRect.right - rootRect.left, root.clientWidth);
1276
+ if (rootWidth <= 0) return pendingChars.length >= PREDICTIVE_WRAP_FALLBACK_CHARS;
1277
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1278
+ let tail = null;
1279
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) if ((node.textContent ?? "").length > 0) tail = node;
1280
+ const parent = tail?.parentElement ?? root;
1281
+ const fontSize = Number.parseFloat(getComputedStyle(parent).fontSize) || 14;
1282
+ const pendingWidth = approximateInlineWidth(pending, fontSize);
1283
+ if (tail === null || typeof document.createRange !== "function") return pendingWidth >= rootWidth;
1284
+ try {
1285
+ const length = tail.textContent?.length ?? 0;
1286
+ if (length <= 0) return pendingWidth >= rootWidth;
1287
+ const range = document.createRange();
1288
+ range.setStart(tail, Math.max(0, length - 1));
1289
+ range.setEnd(tail, length);
1290
+ const tailRect = range.getBoundingClientRect();
1291
+ const contentRight = rootRect.right;
1292
+ if (!Number.isFinite(tailRect.right) || tailRect.right <= rootRect.left || contentRight <= rootRect.left) return pendingWidth >= rootWidth;
1293
+ return pendingWidth >= Math.max(0, contentRight - tailRect.right) + fontSize * .35;
1294
+ } catch {
1295
+ return pendingWidth >= rootWidth;
1296
+ }
1297
+ }
910
1298
  /**
911
1299
  * Smooth streaming text arm. While the reply runs, the accumulated source is
912
1300
  * revealed through the smoother at a rate that tracks the model's arrival
@@ -918,18 +1306,39 @@ window.__ModuleLoader__.load({
918
1306
  * drains, the settled full parse (KaTeX math, fence highlighting, file
919
1307
  * mentions) swaps in exactly once.
920
1308
  */
921
- function AnimatedMarkdownText({ text, codeLabels, fileMentions, streaming, announce, ownFollow, preset, shouldHoldBack }) {
1309
+ function AnimatedMarkdownText({ text, codeLabels, fileMentions, streaming, announce, ownFollow, followSpeedCpsRef, followRevealScaleRef, onPredictiveChange, preset, shouldHoldBack }) {
922
1310
  const reduced = usePrefersReducedMotion();
923
1311
  const [typing, setTyping] = (0, react.useState)(streaming);
924
- const speedCpsRef = (0, react.useRef)(35);
1312
+ const localSpeedCpsRef = (0, react.useRef)(35);
1313
+ const followRootRef = (0, react.useRef)(null);
1314
+ const predictionSourceRef = (0, react.useRef)(null);
1315
+ const predictionStateRef = (0, react.useRef)(false);
1316
+ const speedCpsRef = followSpeedCpsRef ?? localSpeedCpsRef;
925
1317
  const displayed = useSmoothStreamContent(text, {
926
1318
  enabled: typing && !reduced,
1319
+ inputComplete: !streaming,
927
1320
  preset,
928
1321
  shouldHoldBack,
929
- speedCpsRef
1322
+ speedCpsRef,
1323
+ revealScaleRef: followRevealScaleRef
930
1324
  });
931
1325
  const shown = reduced ? text : displayed;
932
1326
  const live = typing && !reduced;
1327
+ (0, react.useLayoutEffect)(() => {
1328
+ if (onPredictiveChange === void 0) return;
1329
+ const pending = text.slice(shown.length);
1330
+ const sourceChanged = predictionSourceRef.current !== text;
1331
+ const next = !live || !streaming || pending === "" ? false : sourceChanged ? pendingTextCanGrow(followRootRef.current, pending) : predictionStateRef.current;
1332
+ predictionSourceRef.current = text;
1333
+ predictionStateRef.current = next;
1334
+ onPredictiveChange(next);
1335
+ }, [
1336
+ live,
1337
+ onPredictiveChange,
1338
+ shown,
1339
+ streaming,
1340
+ text
1341
+ ]);
933
1342
  (0, react.useEffect)(() => {
934
1343
  if (typing && !streaming && shown.length === text.length) setTyping(false);
935
1344
  }, [
@@ -938,24 +1347,23 @@ window.__ModuleLoader__.load({
938
1347
  text,
939
1348
  typing
940
1349
  ]);
941
- if (live) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [announce && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1350
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [live && announce && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
942
1351
  className: TypewriterAssistantNodeView_module_css_default.visuallyHidden,
943
1352
  "aria-live": "polite",
944
1353
  children: text
945
1354
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FollowHost, {
946
- active: ownFollow,
1355
+ active: live && ownFollow,
947
1356
  speedCpsRef,
1357
+ revealScaleRef: followRevealScaleRef,
1358
+ predictive: streaming,
1359
+ hostRef: followRootRef,
948
1360
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MarkdownText, {
949
- text: shown,
950
- streaming: true,
951
- codeLabels
1361
+ text: live ? shown : text,
1362
+ streaming: live,
1363
+ codeLabels,
1364
+ fileMentions: live ? void 0 : fileMentions
952
1365
  })
953
1366
  })] });
954
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MarkdownText, {
955
- text,
956
- codeLabels,
957
- fileMentions
958
- });
959
1367
  }
960
1368
  function imageLabels(t) {
961
1369
  return {
@@ -988,7 +1396,7 @@ window.__ModuleLoader__.load({
988
1396
  * or the assistant node settling — not when the rest of the reply is
989
1397
  * still streaming.
990
1398
  */
991
- function AnimatedReasoning({ text, running, preset, thinkAutoExpand, shouldHoldBack, followSpeedCpsRef, t }) {
1399
+ function AnimatedReasoning({ text, running, preset, thinkAutoExpand, shouldHoldBack, followSpeedCpsRef, followRevealScaleRef, onExpandedChange, t }) {
992
1400
  const reduced = usePrefersReducedMotion();
993
1401
  const [expanded, setExpanded] = (0, react.useState)(running && thinkAutoExpand);
994
1402
  const summaryRef = (0, react.useRef)(null);
@@ -996,13 +1404,17 @@ window.__ModuleLoader__.load({
996
1404
  enabled: running && !reduced,
997
1405
  preset,
998
1406
  shouldHoldBack,
999
- speedCpsRef: followSpeedCpsRef
1407
+ speedCpsRef: followSpeedCpsRef,
1408
+ revealScaleRef: followRevealScaleRef
1000
1409
  });
1001
1410
  const shown = running && !reduced ? displayed : text;
1002
1411
  const summary = running ? latestLine(shown) : firstLine(text);
1003
- (0, react.useEffect)(() => {
1412
+ (0, react.useLayoutEffect)(() => {
1004
1413
  if (thinkAutoExpand) setExpanded(running);
1005
1414
  }, [running, thinkAutoExpand]);
1415
+ (0, react.useLayoutEffect)(() => {
1416
+ onExpandedChange?.(expanded);
1417
+ }, [expanded, onExpandedChange]);
1006
1418
  (0, react.useEffect)(() => {
1007
1419
  const element = summaryRef.current;
1008
1420
  if (element === null) return;
@@ -1058,12 +1470,25 @@ window.__ModuleLoader__.load({
1058
1470
  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, thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand, node, useTurnData, openFile, loadImage, fileMentions, t }) {
1059
1471
  const data = node.data;
1060
1472
  const streaming = data.status === "running";
1473
+ const reduced = usePrefersReducedMotion();
1061
1474
  const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming);
1062
1475
  const rootSpeedRef = (0, react.useRef)(35);
1063
- const reasoningOwnsSpeed = streaming && data.blocks[data.blocks.length - 1]?.kind === "reasoning";
1064
- (0, react.useEffect)(() => {
1476
+ const rootRevealScaleRef = (0, react.useRef)(1);
1477
+ const reasoningTailIndex = streaming && data.blocks[data.blocks.length - 1]?.kind === "reasoning" ? data.blocks.length - 1 : -1;
1478
+ const reasoningOwnsSpeed = reasoningTailIndex !== -1;
1479
+ const rootPredictiveRef = (0, react.useRef)(false);
1480
+ const previousReasoningTailRef = (0, react.useRef)(-1);
1481
+ if (reasoningTailIndex !== previousReasoningTailRef.current) {
1482
+ rootPredictiveRef.current = reasoningOwnsSpeed ? thinkAutoExpand : false;
1065
1483
  if (!reasoningOwnsSpeed) rootSpeedRef.current = 35;
1066
- }, [reasoningOwnsSpeed]);
1484
+ previousReasoningTailRef.current = reasoningTailIndex;
1485
+ }
1486
+ const updateReasoningExpanded = (0, react.useMemo)(() => (expanded) => {
1487
+ rootPredictiveRef.current = expanded;
1488
+ }, []);
1489
+ const updateTextPrediction = (0, react.useMemo)(() => (predictive) => {
1490
+ rootPredictiveRef.current = predictive;
1491
+ }, []);
1067
1492
  const turn = node.location.kind === "turn" || node.location.kind === "step" ? node.location.turn : void 0;
1068
1493
  const tail = useTurnData("turn-tail");
1069
1494
  const owner = (0, react.useMemo)(() => {
@@ -1108,6 +1533,9 @@ window.__ModuleLoader__.load({
1108
1533
  streaming,
1109
1534
  announce: index === last,
1110
1535
  ownFollow: !streaming && index === lastFollow,
1536
+ followSpeedCpsRef: index === lastFollow ? rootSpeedRef : void 0,
1537
+ followRevealScaleRef: index === lastFollow ? rootRevealScaleRef : void 0,
1538
+ onPredictiveChange: index === lastFollow ? updateTextPrediction : void 0,
1111
1539
  preset,
1112
1540
  shouldHoldBack
1113
1541
  }, index));
@@ -1120,6 +1548,8 @@ window.__ModuleLoader__.load({
1120
1548
  thinkAutoExpand,
1121
1549
  shouldHoldBack,
1122
1550
  followSpeedCpsRef: reasoningOwnsSpeed && index === last ? rootSpeedRef : void 0,
1551
+ followRevealScaleRef: reasoningOwnsSpeed && index === last ? rootRevealScaleRef : void 0,
1552
+ onExpandedChange: index === reasoningTailIndex ? updateReasoningExpanded : void 0,
1123
1553
  t
1124
1554
  }, index));
1125
1555
  break;
@@ -1153,8 +1583,10 @@ window.__ModuleLoader__.load({
1153
1583
  className: TypewriterAssistantNodeView_module_css_default.root,
1154
1584
  "data-streaming": streaming || void 0,
1155
1585
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FollowHost, {
1156
- active: streaming,
1586
+ active: streaming && !reduced,
1157
1587
  speedCpsRef: rootSpeedRef,
1588
+ revealScaleRef: rootRevealScaleRef,
1589
+ predictiveRef: rootPredictiveRef,
1158
1590
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1159
1591
  className: TypewriterAssistantNodeView_module_css_default.body,
1160
1592
  children: [rendered, data.status === "interrupted" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -1166,11 +1598,210 @@ window.__ModuleLoader__.load({
1166
1598
  });
1167
1599
  });
1168
1600
  //#endregion
1601
+ //#region src/client/useProgressiveDomText.ts
1602
+ /**
1603
+ * Progressive text reveal for opaque Agent renderers.
1604
+ *
1605
+ * Slot renderers own arbitrary React trees, so the generic integration cannot
1606
+ * clone or classify their business components. This hook leaves that tree and
1607
+ * all of its event handlers in place, and only paces visible Text node data
1608
+ * while the row belongs to the live Agent turn. No clip, mask, overlay, or
1609
+ * duplicate accessibility tree is introduced.
1610
+ */
1611
+ const SKIP_TEXT_SELECTOR = [
1612
+ "[aria-hidden=\"true\"]",
1613
+ "[aria-live]",
1614
+ "[contenteditable=\"true\"]",
1615
+ "script",
1616
+ "style",
1617
+ "textarea"
1618
+ ].join(",");
1619
+ function revealable(node, root) {
1620
+ if (node.data.trim() === "") return false;
1621
+ const parent = node.parentElement;
1622
+ return parent !== null && root.contains(parent) && parent.closest(SKIP_TEXT_SELECTOR) === null;
1623
+ }
1624
+ function commonPrefix(left, right) {
1625
+ const limit = Math.min(left.length, right.length);
1626
+ let index = 0;
1627
+ while (index < limit && left[index] === right[index]) index += 1;
1628
+ return index;
1629
+ }
1630
+ /**
1631
+ * Pace text inside a renderer whose React component is intentionally opaque.
1632
+ * Initial content is revealed only for a genuinely new row; later mutations
1633
+ * stay paced until `enabled` becomes false, at which point the full renderer
1634
+ * content is restored synchronously before paint.
1635
+ */
1636
+ function useProgressiveDomText(rootRef, enabled, revealInitial, speedCpsRef, onSettled) {
1637
+ (0, react.useLayoutEffect)(() => {
1638
+ if (!enabled) return;
1639
+ const root = rootRef.current;
1640
+ if (root === null || typeof document === "undefined") return;
1641
+ const records = /* @__PURE__ */ new Map();
1642
+ const pending = /* @__PURE__ */ new Set();
1643
+ const internalWrites = /* @__PURE__ */ new WeakMap();
1644
+ let rafId = 0;
1645
+ let lastFrame = null;
1646
+ let debt = 0;
1647
+ let stopped = false;
1648
+ let announcedSettled = false;
1649
+ const announceSettled = () => {
1650
+ lastFrame = null;
1651
+ debt = 0;
1652
+ speedCpsRef.current = 35;
1653
+ if (announcedSettled) return;
1654
+ announcedSettled = true;
1655
+ onSettled?.();
1656
+ };
1657
+ const write = (node, value) => {
1658
+ if (node.data === value) return;
1659
+ internalWrites.set(node, value);
1660
+ node.data = value;
1661
+ };
1662
+ const enqueue = (node, full, preserve) => {
1663
+ if (!revealable(node, root)) return;
1664
+ const chars = [...full];
1665
+ const preserved = preserve === void 0 ? 0 : Math.min(preserve.shown, commonPrefix(preserve.chars, chars));
1666
+ const record = {
1667
+ chars,
1668
+ full,
1669
+ shown: preserved
1670
+ };
1671
+ records.set(node, record);
1672
+ if (preserved < chars.length) {
1673
+ pending.add(node);
1674
+ announcedSettled = false;
1675
+ } else pending.delete(node);
1676
+ write(node, chars.slice(0, preserved).join(""));
1677
+ };
1678
+ const visit = (from, reveal) => {
1679
+ if (from.nodeType === Node.TEXT_NODE) {
1680
+ const text = from;
1681
+ if (reveal) enqueue(text, text.data, records.get(text));
1682
+ return;
1683
+ }
1684
+ const walker = document.createTreeWalker(from, NodeFilter.SHOW_TEXT);
1685
+ let current = walker.nextNode();
1686
+ while (current !== null) {
1687
+ const text = current;
1688
+ if (reveal) enqueue(text, text.data, records.get(text));
1689
+ current = walker.nextNode();
1690
+ }
1691
+ };
1692
+ const scheduleFrame = () => {
1693
+ if (stopped || pending.size === 0 || rafId !== 0) return;
1694
+ rafId = requestAnimationFrame(frame);
1695
+ };
1696
+ const frame = (now) => {
1697
+ rafId = 0;
1698
+ if (stopped) return;
1699
+ if (pending.size === 0) {
1700
+ announceSettled();
1701
+ return;
1702
+ }
1703
+ announcedSettled = false;
1704
+ if (lastFrame === null) {
1705
+ lastFrame = now;
1706
+ scheduleFrame();
1707
+ return;
1708
+ }
1709
+ const elapsed = Math.max(0, now - lastFrame);
1710
+ lastFrame = now;
1711
+ let backlog = 0;
1712
+ for (const node of pending) {
1713
+ const record = records.get(node);
1714
+ if (record !== void 0) backlog += record.chars.length - record.shown;
1715
+ }
1716
+ const step = computeAdaptiveQueueStep(backlog, elapsed, debt);
1717
+ debt = step.debt;
1718
+ speedCpsRef.current = step.speedCps;
1719
+ let remaining = step.revealChars;
1720
+ for (const node of [...pending]) {
1721
+ if (remaining <= 0) break;
1722
+ const record = records.get(node);
1723
+ if (record === void 0 || !node.isConnected) {
1724
+ pending.delete(node);
1725
+ continue;
1726
+ }
1727
+ const amount = Math.min(remaining, record.chars.length - record.shown);
1728
+ const shown = record.shown + amount;
1729
+ const next = {
1730
+ ...record,
1731
+ shown
1732
+ };
1733
+ records.set(node, next);
1734
+ write(node, next.chars.slice(0, shown).join(""));
1735
+ remaining -= amount;
1736
+ if (shown >= next.chars.length) pending.delete(node);
1737
+ }
1738
+ if (pending.size === 0) announceSettled();
1739
+ else scheduleFrame();
1740
+ };
1741
+ if (revealInitial) visit(root, true);
1742
+ const observer = typeof MutationObserver === "undefined" ? null : new MutationObserver((mutations) => {
1743
+ for (const mutation of mutations) {
1744
+ if (mutation.type === "characterData") {
1745
+ const node = mutation.target;
1746
+ if (internalWrites.get(node) === node.data) {
1747
+ internalWrites.delete(node);
1748
+ continue;
1749
+ }
1750
+ enqueue(node, node.data, records.get(node));
1751
+ continue;
1752
+ }
1753
+ for (const added of mutation.addedNodes) visit(added, true);
1754
+ }
1755
+ if (pending.size === 0) announceSettled();
1756
+ else scheduleFrame();
1757
+ });
1758
+ observer?.observe(root, {
1759
+ childList: true,
1760
+ characterData: true,
1761
+ subtree: true
1762
+ });
1763
+ if (pending.size === 0) announceSettled();
1764
+ else scheduleFrame();
1765
+ return () => {
1766
+ stopped = true;
1767
+ cancelAnimationFrame(rafId);
1768
+ observer?.disconnect();
1769
+ for (const [node, record] of records) {
1770
+ const controlled = record.chars.slice(0, record.shown).join("");
1771
+ if (node.isConnected && node.data === controlled) write(node, record.full);
1772
+ }
1773
+ pending.clear();
1774
+ speedCpsRef.current = 35;
1775
+ };
1776
+ }, [
1777
+ enabled,
1778
+ onSettled,
1779
+ revealInitial,
1780
+ rootRef,
1781
+ speedCpsRef
1782
+ ]);
1783
+ }
1784
+ //#endregion
1169
1785
  //#region src/client/TypewriterToolNodeView.tsx
1786
+ function openAgentLocation(node) {
1787
+ if (node === null || typeof node !== "object" || !("location" in node)) return false;
1788
+ const location = node.location;
1789
+ if (location === null || typeof location !== "object" || !("kind" in location)) return false;
1790
+ const kind = location.kind;
1791
+ if (!("turn" in location)) return false;
1792
+ const turn = location.turn;
1793
+ if (turn === null || typeof turn !== "object" || !("status" in turn)) return false;
1794
+ if (kind === "turn") return turn.status === "open";
1795
+ if (kind !== "step" || !("step" in location)) return false;
1796
+ const step = location.step;
1797
+ return step !== null && typeof step === "object" && "status" in step && step.status === "open";
1798
+ }
1170
1799
  /**
1171
- * True while a Chat node is still growing: an assistant/workflow `status:
1172
- * 'running'` payload, a Tool root that has not settled (`kind` absent), or a
1173
- * model-retry whose current attempt is still `scheduled`.
1800
+ * True while a Chat node has an explicitly unfinished lifecycle: an
1801
+ * assistant/workflow `status: 'running'` payload, a Tool root that has not
1802
+ * settled (`kind` absent), or a model-retry whose current attempt is still
1803
+ * `scheduled`. Open-but-otherwise-unknown rows are covered by
1804
+ * `isFollowableChatNode` below.
1174
1805
  * @param node - The Chat node's view `node` prop.
1175
1806
  * @returns whether this row should own conversation follow.
1176
1807
  */
@@ -1179,6 +1810,11 @@ window.__ModuleLoader__.load({
1179
1810
  const data = node.data;
1180
1811
  if (data === null || typeof data !== "object") return false;
1181
1812
  if ("status" in data && data.status === "running") return true;
1813
+ if ("kind" in data && data.kind === "command" && "outcome" in data && data.outcome === null) return true;
1814
+ if ("command" in data) {
1815
+ const command = data.command;
1816
+ if (command !== null && typeof command === "object" && "outcome" in command && command.outcome === null) return true;
1817
+ }
1182
1818
  if ("root" in data) {
1183
1819
  const root = data.root;
1184
1820
  if (root !== null && typeof root === "object" && !("kind" in root)) return true;
@@ -1190,18 +1826,128 @@ window.__ModuleLoader__.load({
1190
1826
  return false;
1191
1827
  }
1192
1828
  /**
1193
- * Wrap a prior Chat node renderer so a growing row shares conversation
1194
- * follow. Presentation stays with the wrapped component; kit seats
1195
- * (`renderSlot`, locale, inject) pass through unchanged.
1829
+ * True for any Agent-owned Chat row in the currently open turn/step. This is
1830
+ * the extensibility boundary: a newly registered Context, Command, Tool, or
1831
+ * workflow renderer is followed without adding another kind-specific branch.
1832
+ */
1833
+ function isFollowableChatNode(node) {
1834
+ return isGrowingChatNode(node) || openAgentLocation(node);
1835
+ }
1836
+ /**
1837
+ * True when a newly mounted Agent-owned row belongs to the current open
1838
+ * Turn/Step, or is itself an unresolved growing lifecycle.
1839
+ * @param node - The Chat node's view `node` prop.
1840
+ * @returns whether its initial height should enter through conversation follow.
1841
+ */
1842
+ function shouldAnimateChatNodeEntrance(node) {
1843
+ return isFollowableChatNode(node);
1844
+ }
1845
+ /** Runtime-only fallback for unknown or terminal rows at the active flow tip. */
1846
+ function liveAgentTailMode(root) {
1847
+ const port = root.closest("[data-conversation-scroll]");
1848
+ if (port === null) return null;
1849
+ const row = root.closest("[data-chat-flow-key]");
1850
+ if (row !== null) {
1851
+ let sibling = row.nextElementSibling;
1852
+ while (sibling !== null) {
1853
+ if (sibling instanceof HTMLElement && sibling.hasAttribute("data-chat-flow-key")) return null;
1854
+ sibling = sibling.nextElementSibling;
1855
+ }
1856
+ }
1857
+ const flow = root.closest("[data-chat-flow]");
1858
+ if (flow !== null && [...flow.children].some((child) => child instanceof HTMLElement && child.getAttribute("role") === "status" && !child.hasAttribute("data-chat-flow-key"))) return "turn";
1859
+ return hasRecentConversationFollow(port) ? "handoff" : null;
1860
+ }
1861
+ /**
1862
+ * Wrap a prior Agent Chat renderer so its entrance and later growth share
1863
+ * conversation follow. Presentation stays with the wrapped component; kit
1864
+ * seats (`renderSlot`, locale, inject) pass through unchanged.
1196
1865
  * @param Inner - The already-registered row component.
1197
1866
  * @returns A follow-hosted row.
1198
1867
  */
1199
1868
  function wrapFollowNodeView(Inner) {
1200
1869
  return function TypewriterFollowNodeView(props) {
1201
1870
  const speedCpsRef = (0, react.useRef)(35);
1871
+ const hostRef = (0, react.useRef)(null);
1872
+ const growing = isGrowingChatNode(props.node);
1873
+ const structurallyFollowable = isFollowableChatNode(props.node);
1874
+ const structuralRef = (0, react.useRef)(structurallyFollowable);
1875
+ const [runtimeFollowable, setRuntimeFollowable] = (0, react.useState)(false);
1876
+ const runtimePersistentRef = (0, react.useRef)(false);
1877
+ const runtimeHandledRef = (0, react.useRef)(false);
1878
+ const followable = structurallyFollowable || runtimeFollowable;
1879
+ const revealInitialRef = (0, react.useRef)(true);
1880
+ const [entering, setEntering] = (0, react.useState)(() => shouldAnimateChatNodeEntrance(props.node));
1881
+ const [growthPulse, setGrowthPulse] = (0, react.useState)(false);
1882
+ const followableRef = (0, react.useRef)(false);
1883
+ const growingRef = (0, react.useRef)(growing);
1884
+ const entranceActiveRef = (0, react.useRef)(entering || growthPulse);
1885
+ const growthExtentRef = (0, react.useRef)(null);
1886
+ const mountedRef = (0, react.useRef)(true);
1887
+ const pulseTimerRef = (0, react.useRef)(null);
1888
+ structuralRef.current = structurallyFollowable;
1889
+ followableRef.current = followable;
1890
+ growingRef.current = growing;
1891
+ entranceActiveRef.current = entering || growthPulse;
1892
+ const finishRuntimeReveal = (0, react.useCallback)(() => {
1893
+ if (structuralRef.current || runtimePersistentRef.current) return;
1894
+ runtimeHandledRef.current = true;
1895
+ setRuntimeFollowable(false);
1896
+ }, []);
1897
+ useProgressiveDomText(hostRef, followable, revealInitialRef.current, speedCpsRef, runtimeFollowable ? finishRuntimeReveal : void 0);
1898
+ (0, react.useLayoutEffect)(() => {
1899
+ if (structurallyFollowable) return;
1900
+ const root = hostRef.current;
1901
+ if (root === null) return;
1902
+ const mode = liveAgentTailMode(root);
1903
+ if (runtimeFollowable) {
1904
+ if (runtimePersistentRef.current && mode !== "turn") {
1905
+ runtimePersistentRef.current = false;
1906
+ runtimeHandledRef.current = true;
1907
+ setRuntimeFollowable(false);
1908
+ }
1909
+ return;
1910
+ }
1911
+ if (runtimeHandledRef.current || mode === null) return;
1912
+ runtimePersistentRef.current = mode === "turn";
1913
+ setRuntimeFollowable(true);
1914
+ setEntering(true);
1915
+ }, [runtimeFollowable, structurallyFollowable]);
1916
+ const finishEntrance = (0, react.useCallback)(() => {
1917
+ growthExtentRef.current = null;
1918
+ if (pulseTimerRef.current !== null) {
1919
+ clearTimeout(pulseTimerRef.current);
1920
+ pulseTimerRef.current = null;
1921
+ }
1922
+ setEntering(false);
1923
+ setGrowthPulse(false);
1924
+ }, []);
1925
+ const onGrowth = (0, react.useCallback)((deltaPx) => {
1926
+ if (!mountedRef.current || !followableRef.current || growingRef.current || entranceActiveRef.current) return;
1927
+ growthExtentRef.current = deltaPx;
1928
+ setGrowthPulse(true);
1929
+ if (pulseTimerRef.current !== null) clearTimeout(pulseTimerRef.current);
1930
+ pulseTimerRef.current = setTimeout(() => {
1931
+ pulseTimerRef.current = null;
1932
+ setGrowthPulse(false);
1933
+ }, 1200);
1934
+ }, []);
1935
+ (0, react.useEffect)(() => {
1936
+ mountedRef.current = true;
1937
+ return () => {
1938
+ mountedRef.current = false;
1939
+ if (pulseTimerRef.current !== null) clearTimeout(pulseTimerRef.current);
1940
+ };
1941
+ }, []);
1202
1942
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FollowHost, {
1203
- active: isGrowingChatNode(props.node),
1943
+ active: growing,
1944
+ entrance: entering || growthPulse,
1945
+ onEntranceSettled: finishEntrance,
1946
+ onGrowth: followable ? onGrowth : void 0,
1947
+ entranceExtentRef: growthExtentRef,
1204
1948
  speedCpsRef,
1949
+ predictive: false,
1950
+ hostRef,
1205
1951
  children: (0, react.createElement)(Inner, props)
1206
1952
  });
1207
1953
  };
@@ -1221,31 +1967,31 @@ window.__ModuleLoader__.load({
1221
1967
  tag.textContent = css;
1222
1968
  }
1223
1969
  var SmoothStreamCard_module_css_default = {
1224
- "name": "XNGeRG_name",
1225
- "failure": "XNGeRG_failure",
1226
- "save": "XNGeRG_save",
1227
- "footer": "XNGeRG_footer",
1228
- "update": "XNGeRG_update",
1970
+ "card": "XNGeRG_card",
1971
+ "hint": "XNGeRG_hint",
1972
+ "failed": "XNGeRG_failed",
1973
+ "field": "XNGeRG_field",
1974
+ "chevron": "XNGeRG_chevron",
1229
1975
  "headText": "XNGeRG_headText",
1976
+ "version": "XNGeRG_version",
1977
+ "body": "XNGeRG_body",
1230
1978
  "fieldHead": "XNGeRG_fieldHead",
1231
- "chevronOpen": "XNGeRG_chevronOpen",
1232
- "cardOpen": "XNGeRG_cardOpen",
1233
- "updateCopy": "XNGeRG_updateCopy",
1979
+ "toggle": "XNGeRG_toggle",
1234
1980
  "readOnly": "XNGeRG_readOnly",
1981
+ "header": "XNGeRG_header",
1235
1982
  "pending": "XNGeRG_pending",
1236
- "body": "XNGeRG_body",
1237
1983
  "label": "XNGeRG_label",
1238
- "updateRow": "XNGeRG_updateRow",
1239
- "failed": "XNGeRG_failed",
1240
- "toggle": "XNGeRG_toggle",
1241
- "version": "XNGeRG_version",
1242
- "hint": "XNGeRG_hint",
1243
- "chevron": "XNGeRG_chevron",
1244
- "description": "XNGeRG_description",
1245
- "header": "XNGeRG_header",
1984
+ "update": "XNGeRG_update",
1246
1985
  "discard": "XNGeRG_discard",
1247
- "field": "XNGeRG_field",
1248
- "card": "XNGeRG_card"
1986
+ "footer": "XNGeRG_footer",
1987
+ "failure": "XNGeRG_failure",
1988
+ "name": "XNGeRG_name",
1989
+ "description": "XNGeRG_description",
1990
+ "cardOpen": "XNGeRG_cardOpen",
1991
+ "chevronOpen": "XNGeRG_chevronOpen",
1992
+ "updateRow": "XNGeRG_updateRow",
1993
+ "save": "XNGeRG_save",
1994
+ "updateCopy": "XNGeRG_updateCopy"
1249
1995
  };
1250
1996
  //#endregion
1251
1997
  //#region src/client/SmoothStreamCard.tsx
@@ -1644,13 +2390,24 @@ window.__ModuleLoader__.load({
1644
2390
  "balanced",
1645
2391
  "silky"
1646
2392
  ];
1647
- /** Human-authored rows stay on the built-in renderer; `assistant-step` is replaced. */
2393
+ /**
2394
+ * The assistant renderer owns its own character queue and conversation
2395
+ * follower, so wrapping it again would create two scroll owners. Human input
2396
+ * stays immediate; every Agent-owned output renderer goes through the same
2397
+ * generic follow boundary. This is deliberately keyed by the owner that
2398
+ * provides the renderer, not by individual tool names, so new Context,
2399
+ * Command, and Tool rows are covered automatically.
2400
+ */
1648
2401
  const SKIP_WRAP = /* @__PURE__ */ new Set([
1649
2402
  "assistant-step",
1650
2403
  "user",
1651
2404
  "steering",
1652
2405
  "command-input"
1653
2406
  ]);
2407
+ /** React function/class or an exotic component such as memo/forwardRef/lazy. */
2408
+ function isWrappableComponent(value) {
2409
+ return typeof value === "function" || value !== null && typeof value === "object" && "$$typeof" in value;
2410
+ }
1654
2411
  /**
1655
2412
  * Read the Host-bridged boot config. The inline script is produced by this
1656
2413
  * plugin's Host half from a schema-validated value, so only the structural
@@ -1670,16 +2427,19 @@ window.__ModuleLoader__.load({
1670
2427
  return raw;
1671
2428
  }
1672
2429
  /**
1673
- * Wrap every keyed Chat row except `assistant-step` in place. A second
2430
+ * Wrap every Agent-owned keyed Chat row except the assistant renderer in
2431
+ * place. A second
1674
2432
  * register with the same `children` table throws because the child slot is
1675
2433
  * already declared, and only the winning entry receives `renderSlot`;
1676
2434
  * swapping `entry.component` keeps the original children, locale, and inject
1677
2435
  * seats. `assistant-step` is replaced below so text and Think use the
1678
- * typewriter reveal.
2436
+ * typewriter reveal. The wrapper owns only the shared layout-growth/follow
2437
+ * lifecycle; the Harness keeps each renderer's controls, disclosures, and
2438
+ * cards intact.
1679
2439
  * @param ctx - Browser context carrying the slot registry.
1680
2440
  * @returns Restorer that puts the original components back.
1681
2441
  */
1682
- function wrapGrowingChatRows(ctx) {
2442
+ function wrapAgentChatRows(ctx) {
1683
2443
  const restores = [];
1684
2444
  const wrapped = /* @__PURE__ */ new WeakSet();
1685
2445
  const wrapAll = () => {
@@ -1687,7 +2447,7 @@ window.__ModuleLoader__.load({
1687
2447
  const key = entry.options.key;
1688
2448
  if (key === void 0 || SKIP_WRAP.has(key)) continue;
1689
2449
  const current = entry.component;
1690
- if (typeof current !== "function" || wrapped.has(current)) continue;
2450
+ if (!isWrappableComponent(current) || wrapped.has(current)) continue;
1691
2451
  const inner = current;
1692
2452
  const next = wrapFollowNodeView(inner);
1693
2453
  wrapped.add(next);
@@ -1750,8 +2510,9 @@ window.__ModuleLoader__.load({
1750
2510
  /**
1751
2511
  * Register the typewriter renderer after the conversation package declares the
1752
2512
  * keyed Chat node seat. A lower priority shadows the built-in assistant row;
1753
- * every other growing row is wrapped in place so Tool cards, retries, and
1754
- * workflow runs share conversation follow. The Host-bridged configuration
2513
+ * every other keyed renderer is wrapped in place so Context, commands, Tool
2514
+ * cards, retries, and workflow runs share one extensible follow boundary. The
2515
+ * Host-bridged configuration
1755
2516
  * selects the render direction, smoothing preset, and glide speed; the
1756
2517
  * plugin-owned settings RPC supplies the live auto-expand preference when the
1757
2518
  * settings surface is composed.
@@ -1797,7 +2558,7 @@ window.__ModuleLoader__.load({
1797
2558
  });
1798
2559
  };
1799
2560
  ctx.slots.inject("conversation.chat.node", () => {
1800
- const unwrap = wrapGrowingChatRows(ctx);
2561
+ const unwrap = wrapAgentChatRows(ctx);
1801
2562
  const unshadow = ctx.slots.register({
1802
2563
  name: "conversation.chat.node",
1803
2564
  key: "assistant-step",