dsh-smooth-stream 0.4.1 → 0.4.2

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
@@ -8,10 +8,9 @@ window.__ModuleLoader__.load({
8
8
  let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
9
9
  let _deepseek_ai_dsh_client_ui_attachment = require("@deepseek-ai/dsh-client-ui-attachment");
10
10
  let react_jsx_runtime = require("react/jsx-runtime");
11
- let _deepseek_ai_dsh_client_runtime_client = require("@deepseek-ai/dsh-client-runtime/client");
12
11
  let react_dom = require("react-dom");
13
12
  //#region \0dsh-css:/Users/dzlin/work/project/dsh-smooth-stream/src/client/TypewriterAssistantNodeView.module.css.mjs
14
- const css$2 = ".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}}";
13
+ const css$2 = ".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[data-no-transition]{transition:none}.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}.I17U7q_root :not(pre)>code{line-height:inherit;white-space:normal;overflow-wrap:anywhere;vertical-align:baseline;display:inline}@supports (text-box-trim:trim-both){.I17U7q_root :is(p,h1,h2,h3,h4,h5,h6,blockquote){text-box-trim:trim-both;text-box-edge:text}}";
15
14
  const tagId$2 = "dsh-smooth-stream/TypewriterAssistantNodeView.module.css";
16
15
  if (typeof document !== "undefined") {
17
16
  let tag = document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$2) + "]");
@@ -24,27 +23,27 @@ window.__ModuleLoader__.load({
24
23
  tag.textContent = css$2;
25
24
  }
26
25
  var TypewriterAssistantNodeView_module_css_default = {
27
- "thinkSeparator": "I17U7q_thinkSeparator",
28
- "body": "I17U7q_body",
29
- "disclosureIconIdle": "I17U7q_disclosureIconIdle",
30
- "disclosureTitle": "I17U7q_disclosureTitle",
31
- "think": "I17U7q_think",
32
- "disclosureContent": "I17U7q_disclosureContent",
33
- "thinkLeading": "I17U7q_thinkLeading",
34
- "disclosureRow": "I17U7q_disclosureRow",
35
- "stopped": "I17U7q_stopped",
36
- "thinkChevron": "I17U7q_thinkChevron",
37
26
  "thinkSummary": "I17U7q_thinkSummary",
38
- "dsh-smooth-stream-think-sweep": "I17U7q_dsh-smooth-stream-think-sweep",
39
- "visuallyHidden": "I17U7q_visuallyHidden",
27
+ "thinkChevron": "I17U7q_thinkChevron",
40
28
  "root": "I17U7q_root",
41
- "disclosureRoot": "I17U7q_disclosureRoot",
29
+ "disclosureIconIdle": "I17U7q_disclosureIconIdle",
30
+ "disclosureChevronHover": "I17U7q_disclosureChevronHover",
31
+ "visuallyHidden": "I17U7q_visuallyHidden",
32
+ "thinkSeparator": "I17U7q_thinkSeparator",
42
33
  "disclosureLeading": "I17U7q_disclosureLeading",
43
34
  "thinkRow": "I17U7q_thinkRow",
44
- "follow": "I17U7q_follow",
45
- "disclosureChevronHover": "I17U7q_disclosureChevronHover",
35
+ "disclosureContent": "I17U7q_disclosureContent",
36
+ "disclosureRoot": "I17U7q_disclosureRoot",
37
+ "thinkTitle": "I17U7q_thinkTitle",
38
+ "think": "I17U7q_think",
39
+ "stopped": "I17U7q_stopped",
46
40
  "thinkBody": "I17U7q_thinkBody",
47
- "thinkTitle": "I17U7q_thinkTitle"
41
+ "body": "I17U7q_body",
42
+ "disclosureRow": "I17U7q_disclosureRow",
43
+ "dsh-smooth-stream-think-sweep": "I17U7q_dsh-smooth-stream-think-sweep",
44
+ "thinkLeading": "I17U7q_thinkLeading",
45
+ "disclosureTitle": "I17U7q_disclosureTitle",
46
+ "follow": "I17U7q_follow"
48
47
  };
49
48
  //#endregion
50
49
  //#region src/client/AnimatedDisclosure.tsx
@@ -58,7 +57,7 @@ window.__ModuleLoader__.load({
58
57
  * callback fired by row click and Enter/Space.
59
58
  * @returns the animated disclosure row.
60
59
  */
61
- function AnimatedDisclosure({ icon, title, open, onToggle, collapsedContent, children, rowClassName, leadingClassName, titleClassName, chevronClassName }) {
60
+ function AnimatedDisclosure({ icon, title, open, onToggle, collapsedContent, children, rowClassName, leadingClassName, titleClassName, chevronClassName, bodyTransition = true }) {
62
61
  const toggleFromKeyboard = (event) => {
63
62
  if (event.key !== "Enter" && event.key !== " ") return;
64
63
  event.preventDefault();
@@ -94,6 +93,7 @@ window.__ModuleLoader__.load({
94
93
  className: TypewriterAssistantNodeView_module_css_default.disclosureContent,
95
94
  "data-disclosure-content": true,
96
95
  "data-collapsed": open ? void 0 : "",
96
+ "data-no-transition": bodyTransition ? void 0 : "",
97
97
  children
98
98
  })]
99
99
  });
@@ -118,7 +118,7 @@ window.__ModuleLoader__.load({
118
118
  springStiffness: 130,
119
119
  springDamping: 24,
120
120
  springMass: 1,
121
- runwayPx: 48,
121
+ runwayPx: 72,
122
122
  reserveResponseMs: 180,
123
123
  backpressureMinScale: .55
124
124
  };
@@ -131,6 +131,28 @@ window.__ModuleLoader__.load({
131
131
  debugTuning: DEFAULT_STREAM_DEBUG_TUNING
132
132
  };
133
133
  //#endregion
134
+ //#region src/client/clientStore.ts
135
+ /**
136
+ * Runtime require: the module-loader factory scopes its own resolver over the
137
+ * booting kernel's table; under Node (vitest) there is no global require, so
138
+ * fall back to `createRequire`.
139
+ */
140
+ function pickRequire() {
141
+ if (typeof require === "function") return require;
142
+ if (typeof process !== "undefined" && typeof process.getBuiltinModule === "function") {
143
+ const { createRequire } = process.getBuiltinModule("node:module");
144
+ return createRequire(process.cwd() + "/package.json");
145
+ }
146
+ throw new TypeError("dsh-smooth-stream: no client module resolver available");
147
+ }
148
+ let clientStore;
149
+ try {
150
+ clientStore = pickRequire()("@deepseek-ai/dsh-client-store");
151
+ } catch {
152
+ clientStore = pickRequire()("@deepseek-ai/dsh-client-runtime/client");
153
+ }
154
+ const createSnapshotStore = clientStore.createSnapshotStore;
155
+ //#endregion
134
156
  //#region src/client/debugRuntime.ts
135
157
  /**
136
158
  * Shared browser-side diagnostics state.
@@ -170,7 +192,7 @@ window.__ModuleLoader__.load({
170
192
  tuning: { ...DEFAULT_STREAM_DEBUG_TUNING },
171
193
  metrics: EMPTY_METRICS
172
194
  };
173
- const store = (0, _deepseek_ai_dsh_client_runtime_client.createSnapshotStore)(INITIAL_STATE);
195
+ const store = createSnapshotStore(INITIAL_STATE);
174
196
  const streamMetrics = /* @__PURE__ */ new Map();
175
197
  const followMetrics = /* @__PURE__ */ new Map();
176
198
  let actions;
@@ -378,556 +400,195 @@ window.__ModuleLoader__.load({
378
400
  }
379
401
  };
380
402
  //#endregion
381
- //#region src/client/useSmoothStreamContent.ts
403
+ //#region src/client/teleprompterGlide.ts
382
404
  /**
383
- * Stream-smoothing reveal hook.
405
+ * Conversation-port follow while an assistant reply streams.
384
406
  *
385
- * Buffers the model's chunked text and reveals it at a cadence that tracks
386
- * the observed arrival rate, so a long reply never dumps whole paragraphs at
387
- * once and a fast stream never stutters. Port of lobe-ui's smoother: EMA
388
- * arrival cps + chunk size, backlog pressure, commit-interval widening with
389
- * tail length, and a flush-speed settle drain once the input idles. The
390
- * reveal decision is the pure {@link computeRevealStep} for unit tests.
407
+ * A sub-stepped spring physics engine drives a float `animatedH`, rather
408
+ * than restarting native smooth-scroll animations as every glyph lands.
409
+ * Remaining lag rides a small compositor transform while the real scrollport
410
+ * stays at its floor. The transform is bounded by the measured paint gap
411
+ * before conversation chrome. This follower:
391
412
  *
392
- * `shouldHoldBack` is the performance guard's veto: while it returns true the
393
- * loop keeps measuring but skips the DOM commit, so an offscreen reply never
394
- * competes with visible frames when the frame rate is degraded.
413
+ * - marks programmatic writes via `data-follow-owned` for compatible hosts;
414
+ * - sets `overflow-anchor: none` so CSS scroll-anchoring does not snap;
415
+ * - restores `animatedH` in a ResizeObserver (before paint) so a layout
416
+ * pass cannot flash a snapped frame;
417
+ * - expresses safe lag as a compositor transform on message rows;
418
+ * - opens a speed-adaptive layout runway before fast output wraps, preserving
419
+ * the reference spring constants at every reveal speed;
420
+ * - catches up any lag that cannot fit before turn status / composer chrome,
421
+ * so fixed chrome never has to counter-shift and the host stays at-bottom;
422
+ * - never clips or overlays streamed text.
423
+ *
424
+ * A real reader gesture receives the effective visual position before the
425
+ * transform clears. Lifecycle completion instead settles at the floor.
426
+ *
427
+ * Directional wheel/touch intent unpins immediately; pointer/key input falls
428
+ * back to an upward scroll delta from the engine's own written position. A
429
+ * reader release re-acquires only after returning to the real floor.
395
430
  */
396
- const PRESET_CONFIG = {
397
- balanced: {
398
- activeInputWindowMs: 220,
399
- defaultCps: 80,
400
- emaAlpha: .35,
401
- flushCps: 180,
402
- largeAppendChars: 120,
403
- maxActiveCps: 360,
404
- maxCps: 240,
405
- maxFlushCps: 480,
406
- minCps: 24,
407
- settleAfterMs: 280,
408
- settleDrainMaxMs: 420,
409
- settleDrainMinMs: 120,
410
- targetBufferMs: 40
411
- },
412
- realtime: {
413
- activeInputWindowMs: 140,
414
- defaultCps: 120,
415
- emaAlpha: .45,
416
- flushCps: 240,
417
- largeAppendChars: 180,
418
- maxActiveCps: 480,
419
- maxCps: 320,
420
- maxFlushCps: 640,
421
- minCps: 32,
422
- settleAfterMs: 200,
423
- settleDrainMaxMs: 280,
424
- settleDrainMinMs: 100,
425
- targetBufferMs: 24
426
- },
427
- silky: {
428
- activeInputWindowMs: 280,
429
- defaultCps: 64,
430
- emaAlpha: .28,
431
- flushCps: 140,
432
- largeAppendChars: 100,
433
- maxActiveCps: 280,
434
- maxCps: 180,
435
- maxFlushCps: 400,
436
- minCps: 20,
437
- settleAfterMs: 360,
438
- settleDrainMaxMs: 520,
439
- settleDrainMinMs: 160,
440
- targetBufferMs: 56
431
+ /**
432
+ * Programmatic follow marker retained for hosts that recognize external
433
+ * scroll ownership. Current Harness also sees the write land at the floor.
434
+ */
435
+ const FOLLOW_OWNED_ATTR = "data-follow-owned";
436
+ /** Sub-pixel settle threshold; clearing below this cannot produce a visible rebound. */
437
+ const FOLLOW_SETTLE_EPSILON_PX = .25;
438
+ /** Runway size emitted by bundles before the 72px predictive runway. */
439
+ const LEGACY_RUNWAY_PX = 48;
440
+ /** Safe-lag occupancy band over which reveal pressure is progressively reduced. */
441
+ const FOLLOW_BACKPRESSURE_START_RATIO = .1;
442
+ /** Effective-scroll acceleration budget, in px/ms². */
443
+ const FOLLOW_TRAJECTORY_ACCELERATION = 22e-5;
444
+ const GESTURE_EVENTS = [
445
+ "wheel",
446
+ "touchstart",
447
+ "touchmove",
448
+ "touchend",
449
+ "touchcancel",
450
+ "pointerdown",
451
+ "keydown"
452
+ ];
453
+ /** Visible runway needed for the current reveal pressure. */
454
+ function computeFollowReserve(speedCps, runwayPx = 72) {
455
+ const available = Math.max(0, runwayPx);
456
+ if (available <= 0) return 0;
457
+ if (speedCps <= 20) return 0;
458
+ const normalized = Math.min(1, Math.max(0, (speedCps - 20) / 580));
459
+ const minimum = Math.min(available, 31);
460
+ return minimum + normalized * (available - minimum);
461
+ }
462
+ /**
463
+ * Character-domain feed-forward for the stepped layout floor. Callers only
464
+ * provide committed reveal progress and the measured floor; wrap capacity and
465
+ * line height stay local to this module and adapt when a real wrap lands.
466
+ */
467
+ var FollowRevealPhaseTracker = class {
468
+ charsPerLine;
469
+ lineHeightPx;
470
+ floorPx = null;
471
+ wrapRevealCount = 0;
472
+ lastRevealCount = 0;
473
+ constructor({ seedCharsPerLine = 50, seedLineHeightPx = 26 } = {}) {
474
+ this.charsPerLine = Math.max(1, seedCharsPerLine);
475
+ this.lineHeightPx = Math.max(1, seedLineHeightPx);
476
+ }
477
+ advance(floorPx, revealedChars) {
478
+ const nextFloor = Math.max(0, floorPx);
479
+ const nextRevealCount = Math.max(0, revealedChars);
480
+ if (this.floorPx === null || nextRevealCount < this.lastRevealCount) {
481
+ this.floorPx = nextFloor;
482
+ this.wrapRevealCount = nextRevealCount;
483
+ this.lastRevealCount = nextRevealCount;
484
+ return this.snapshot(nextFloor, 0);
485
+ }
486
+ const floorDelta = nextFloor - this.floorPx;
487
+ const lineStepThreshold = Math.max(4, this.lineHeightPx * .5);
488
+ if (floorDelta >= lineStepThreshold) {
489
+ const wrappedLines = Math.max(1, Math.round(floorDelta / this.lineHeightPx));
490
+ const sampledLineHeight = floorDelta / wrappedLines;
491
+ const revealedSinceWrap = nextRevealCount - this.wrapRevealCount;
492
+ if (revealedSinceWrap >= this.charsPerLine * .5) {
493
+ const sampledCharsPerLine = revealedSinceWrap / wrappedLines;
494
+ const alpha = .25;
495
+ this.charsPerLine += (sampledCharsPerLine - this.charsPerLine) * alpha;
496
+ this.lineHeightPx += (sampledLineHeight - this.lineHeightPx) * alpha;
497
+ this.wrapRevealCount = nextRevealCount;
498
+ } else this.wrapRevealCount = nextRevealCount;
499
+ } else if (floorDelta <= -lineStepThreshold) this.wrapRevealCount = nextRevealCount;
500
+ this.floorPx = nextFloor;
501
+ this.lastRevealCount = nextRevealCount;
502
+ const phase = Math.min(1, Math.max(0, (nextRevealCount - this.wrapRevealCount) / this.charsPerLine));
503
+ return this.snapshot(nextFloor, phase);
504
+ }
505
+ snapshot(floorPx, phase) {
506
+ return {
507
+ targetPx: floorPx + this.lineHeightPx * phase,
508
+ phase,
509
+ charsPerLine: this.charsPerLine,
510
+ lineHeightPx: this.lineHeightPx
511
+ };
441
512
  }
442
513
  };
443
- const QUEUE_ACCEL_EXPONENT = 1.25;
444
- const CATCHUP_SECONDS = .15;
445
- const clamp = (value, min, max) => {
446
- return Math.min(max, Math.max(min, value));
447
- };
448
- /** Float-debt queue integration from `ultimate_stream_physics_scroller.html`. */
449
- function computeAdaptiveQueueStep(backlog, dtMs, debt, revealScale = 1, tuning = DEFAULT_STREAM_DEBUG_TUNING) {
450
- if (backlog <= 0 || dtMs <= 0) return {
451
- revealChars: 0,
452
- debt: 0,
453
- speedCps: 0
514
+ /** Advance a continuous effective scroll position behind a stepped floor. */
515
+ function computeFollowTrajectoryStep(dtMs, input) {
516
+ if (dtMs <= 0) return {
517
+ positionPx: input.positionPx,
518
+ shiftPx: Math.max(0, (input.paintFloorPx ?? input.targetPx) - input.positionPx),
519
+ velocityPxPerMs: input.velocityPxPerMs
454
520
  };
455
- const speedCps = Math.min(tuning.maxRevealCps, 90 + Math.pow(backlog, QUEUE_ACCEL_EXPONENT) * tuning.queuePressure);
456
- const effectiveScale = clamp(revealScale * tuning.revealScale, .05, 2);
457
- const accumulated = Math.max(0, debt) + speedCps * effectiveScale * (dtMs / 1e3);
458
- const revealChars = Math.min(backlog, Math.floor(accumulated));
521
+ const elapsedMs = Math.min(32, dtMs);
522
+ const currentLagPx = input.targetPx - input.positionPx;
523
+ const maxLagPx = Math.max(0, input.maxLagPx);
524
+ const minLagPx = Math.min(Math.max(0, input.minLagPx), maxLagPx);
525
+ const centerLagPx = (minLagPx + maxLagPx) / 2;
526
+ const desiredVelocity = Math.max(0, input.targetVelocityPxPerMs + (currentLagPx - centerLagPx) / 120);
527
+ const maxVelocityChange = FOLLOW_TRAJECTORY_ACCELERATION * elapsedMs;
528
+ const velocityPxPerMs = desiredVelocity >= input.velocityPxPerMs ? Math.min(desiredVelocity, input.velocityPxPerMs + maxVelocityChange) : Math.max(desiredVelocity, input.velocityPxPerMs - maxVelocityChange);
529
+ const minPosition = input.targetPx - maxLagPx;
530
+ const maxPosition = input.targetPx - minLagPx;
531
+ const frameAdvancePx = velocityPxPerMs * elapsedMs;
532
+ const boundedPositionPx = Math.min(maxPosition, Math.max(minPosition, input.positionPx + frameAdvancePx));
533
+ const positionPx = Math.max(input.positionPx, boundedPositionPx);
459
534
  return {
460
- revealChars,
461
- debt: revealChars >= backlog ? 0 : accumulated - revealChars,
462
- speedCps
535
+ positionPx,
536
+ shiftPx: Math.max(0, (input.paintFloorPx ?? input.targetPx) - positionPx),
537
+ velocityPxPerMs
463
538
  };
464
539
  }
465
- /** Counts user-perceived characters (code points), not UTF-16 units. */
466
- const countChars = (text) => {
467
- let count = 0;
468
- for (const char of text) count += 1;
469
- return count;
470
- };
471
- /** Pure settle-drain decision shared by the frame loop and its tests. */
472
- function computeSettleDrain(config, input) {
473
- if (input.inputActive || !input.settling) return 0;
474
- const overflowCps = Math.max(0, input.backlog - 300) * 1e3 / 2;
475
- const drainTargetMs = clamp(input.backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs);
476
- const settleCps = input.backlog * 1e3 / drainTargetMs;
477
- return clamp(Math.max(settleCps, overflowCps), config.flushCps, config.maxFlushCps);
478
- }
479
- /** Fixed velocity that closes a producer-complete queue within its deadline. */
480
- function computeCompletionDrain(config, backlog) {
481
- if (backlog <= 0) return 0;
482
- const drainTargetMs = clamp(backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs);
483
- const deadlineCps = backlog * 1e3 / drainTargetMs;
484
- return Math.max(deadlineCps, computeSettleDrain(config, {
485
- backlog,
486
- inputActive: false,
487
- settling: true
488
- }));
489
- }
490
540
  /**
491
- * Drain rate multiplier once the input ends: leftover backlog reveals at
492
- * this multiple of the steady rate, so the end never drags.
541
+ * Reveal-rate multiplier needed to retain one-wrap headroom for the spring.
542
+ * Throttling starts only after a quarter of the safe transform is occupied;
543
+ * a constrained paint lands at the minimum immediately so the next reveal
544
+ * commit cannot keep feeding an already-full visual buffer.
493
545
  */
494
- const SETTLE_DRAIN_MULTIPLIER = 1.8;
495
- /** Pure per-frame reveal decision shared by the loop and its tests. */
496
- function computeRevealStep(config, input, dtSeconds) {
497
- const trackedCps = Math.max(input.emaCps, input.arrivalCpsEma);
498
- const baseCps = clamp(trackedCps, config.minCps, config.maxFlushCps);
499
- const targetLagChars = input.inputActive ? Math.max(2, Math.round(baseCps * config.targetBufferMs / 1e3)) : 0;
500
- let currentCps;
501
- if (input.steadyCps !== void 0) currentCps = input.inputActive || input.settling ? clamp(input.steadyCps * (input.inputActive ? 1 : SETTLE_DRAIN_MULTIPLIER), config.minCps, config.maxFlushCps) : 0;
502
- else if (input.inputActive) {
503
- const overflow = Math.max(0, input.backlog - 32);
504
- const catchup = overflow > 0 ? overflow / CATCHUP_SECONDS : 0;
505
- currentCps = clamp(baseCps * 1.08 + catchup, config.minCps, config.maxFlushCps);
506
- } else if (input.settling) currentCps = computeSettleDrain(config, input);
507
- else {
508
- const idleFlushCps = Math.max(config.flushCps, baseCps * 1.8, input.arrivalCpsEma * .8);
509
- currentCps = clamp(idleFlushCps, config.flushCps, config.maxFlushCps);
546
+ function computeFollowRevealScale(lagPx, capacityPx, constrained = false, tuning = DEFAULT_STREAM_DEBUG_TUNING) {
547
+ if (constrained) return tuning.backpressureMinScale;
548
+ if (!Number.isFinite(capacityPx)) return 1;
549
+ if (capacityPx <= 0) return lagPx > 0 ? tuning.backpressureMinScale : 1;
550
+ const ratio = Math.min(1, Math.max(0, lagPx / capacityPx));
551
+ if (ratio >= .75) return tuning.backpressureMinScale;
552
+ const progress = Math.min(1, Math.max(0, (ratio - FOLLOW_BACKPRESSURE_START_RATIO) / .65));
553
+ const eased = progress * progress * (3 - 2 * progress);
554
+ return 1 - (1 - tuning.backpressureMinScale) * eased;
555
+ }
556
+ /** Semi-implicit spring integration with four substeps per <=32ms slice. */
557
+ function computeFollowStep(dtMs, input, tuning = DEFAULT_STREAM_DEBUG_TUNING) {
558
+ if (input.lag <= .1 || dtMs <= 0) return {
559
+ advancePx: 0,
560
+ lerpStep: 0,
561
+ velocityPxPerSec: 0
562
+ };
563
+ let lag = input.lag;
564
+ let velocity = Math.max(0, input.velocityPxPerSec ?? 0);
565
+ const elapsedMs = Math.min(32, dtMs);
566
+ const slices = Math.max(1, Math.ceil(elapsedMs / 32));
567
+ const subDt = elapsedMs / 1e3 / slices / 4;
568
+ for (let slice = 0; slice < slices; slice += 1) for (let substep = 0; substep < 4; substep += 1) {
569
+ const acceleration = (tuning.springStiffness * lag - tuning.springDamping * velocity) / tuning.springMass;
570
+ velocity = Math.max(0, velocity + acceleration * subDt);
571
+ const advance = velocity * subDt;
572
+ if (advance >= lag) return {
573
+ advancePx: input.lag,
574
+ lerpStep: 1,
575
+ velocityPxPerSec: 0
576
+ };
577
+ lag -= advance;
510
578
  }
511
- const minRevealChars = input.inputActive ? 1 : 2;
579
+ const advancePx = input.lag - lag;
512
580
  return {
513
- revealChars: Math.max(minRevealChars, Math.round(currentCps * dtSeconds)),
514
- targetLagChars
581
+ advancePx,
582
+ lerpStep: advancePx / input.lag,
583
+ velocityPxPerSec: velocity
515
584
  };
516
585
  }
586
+ /** Element whose resize signals flow growth for the before-paint restore. */
587
+ function resizeProxyOf(port) {
588
+ return port.querySelector("[data-chat-transcript]") ?? port.querySelector("[data-chat-flow]");
589
+ }
517
590
  /**
518
- * Smooth a chunked content stream into a reveal-paced display string.
519
- *
520
- * @param content - The full accumulated input so far.
521
- * @param options - Preset, guard, and steady-rate wiring.
522
- * @returns The displayed content, revealed at the smoothed cadence.
523
- */
524
- function useSmoothStreamContent(content, { enabled = true, inputComplete = false, preset = "balanced", shouldHoldBack, steadyCps, defaultCps, speedCpsRef, revealScaleRef } = {}) {
525
- const config = PRESET_CONFIG[preset];
526
- const seedCps = defaultCps ?? config.defaultCps;
527
- const initialContent = enabled ? "" : content;
528
- const [displayedContent, setDisplayedContent] = (0, react.useState)(initialContent);
529
- const displayedContentRef = (0, react.useRef)(initialContent);
530
- const displayedCountRef = (0, react.useRef)(countChars(initialContent));
531
- const targetContentRef = (0, react.useRef)(initialContent);
532
- const targetCharsRef = (0, react.useRef)([...initialContent]);
533
- const targetCountRef = (0, react.useRef)(countChars(initialContent));
534
- const emaCpsRef = (0, react.useRef)(seedCps);
535
- const lastInputTsRef = (0, react.useRef)(0);
536
- const lastInputCountRef = (0, react.useRef)(countChars(initialContent));
537
- const chunkSizeEmaRef = (0, react.useRef)(1);
538
- const arrivalCpsEmaRef = (0, react.useRef)(seedCps);
539
- const rafRef = (0, react.useRef)(null);
540
- const lastFrameTsRef = (0, react.useRef)(null);
541
- const queueDebtRef = (0, react.useRef)(0);
542
- const settleCpsRef = (0, react.useRef)(null);
543
- const holdBackRef = (0, react.useRef)(shouldHoldBack);
544
- const speedOutRef = (0, react.useRef)(speedCpsRef);
545
- speedOutRef.current = speedCpsRef;
546
- const revealScaleOutRef = (0, react.useRef)(revealScaleRef);
547
- revealScaleOutRef.current = revealScaleRef;
548
- const inputCompleteRef = (0, react.useRef)(inputComplete);
549
- inputCompleteRef.current = inputComplete;
550
- const streamIdRef = (0, react.useRef)(`stream-${Math.random().toString(36).slice(2)}`);
551
- (0, react.useEffect)(() => {
552
- holdBackRef.current = shouldHoldBack;
553
- }, [shouldHoldBack]);
554
- const stopFrameLoop = (0, react.useCallback)(() => {
555
- if (rafRef.current !== null) {
556
- cancelAnimationFrame(rafRef.current);
557
- rafRef.current = null;
558
- }
559
- lastFrameTsRef.current = null;
560
- }, []);
561
- const startFrameLoopRef = (0, react.useRef)(() => {});
562
- const syncImmediate = (0, react.useCallback)((nextContent) => {
563
- stopFrameLoop();
564
- const chars = [...nextContent];
565
- const now = performance.now();
566
- targetContentRef.current = nextContent;
567
- targetCharsRef.current = chars;
568
- targetCountRef.current = chars.length;
569
- displayedContentRef.current = nextContent;
570
- displayedCountRef.current = chars.length;
571
- queueDebtRef.current = 0;
572
- settleCpsRef.current = null;
573
- const speedOut = speedOutRef.current;
574
- if (speedOut !== void 0) speedOut.current = seedCps;
575
- setDisplayedContent(nextContent);
576
- emaCpsRef.current = seedCps;
577
- chunkSizeEmaRef.current = 1;
578
- arrivalCpsEmaRef.current = seedCps;
579
- lastInputTsRef.current = now;
580
- lastInputCountRef.current = chars.length;
581
- }, [seedCps, stopFrameLoop]);
582
- (0, react.useLayoutEffect)(() => {
583
- if (!enabled || !inputComplete || displayedContentRef.current === content) return;
584
- syncImmediate(content);
585
- }, [
586
- content,
587
- enabled,
588
- inputComplete,
589
- syncImmediate
590
- ]);
591
- const startFrameLoop = (0, react.useCallback)(() => {
592
- if (rafRef.current !== null) return;
593
- const tick = (now) => {
594
- const targetCount = targetCountRef.current;
595
- const displayedCount = displayedCountRef.current;
596
- const backlog = targetCount - displayedCount;
597
- if (backlog <= 0) {
598
- queueDebtRef.current = 0;
599
- settleCpsRef.current = null;
600
- const speedOut = speedOutRef.current;
601
- if (speedOut !== void 0) speedOut.current = seedCps;
602
- debugRuntime.reportStream(streamIdRef.current, null);
603
- stopFrameLoop();
604
- return;
605
- }
606
- if (lastFrameTsRef.current === null) {
607
- lastFrameTsRef.current = now;
608
- rafRef.current = requestAnimationFrame(tick);
609
- return;
610
- }
611
- const frameIntervalMs = Math.max(0, now - lastFrameTsRef.current);
612
- const dtSeconds = Math.max(.001, Math.min(frameIntervalMs / 1e3, .12));
613
- lastFrameTsRef.current = now;
614
- const idleMs = now - lastInputTsRef.current;
615
- const producerComplete = inputCompleteRef.current;
616
- const debugTuning = debugRuntime.activeTuning();
617
- const inputActive = !producerComplete && idleMs <= config.activeInputWindowMs;
618
- const settling = producerComplete || !inputActive && idleMs >= config.settleAfterMs;
619
- if (!producerComplete) settleCpsRef.current = null;
620
- let revealChars;
621
- let revealSpeedCps;
622
- let nextQueueDebt = 0;
623
- if (producerComplete) {
624
- const settleCps = settleCpsRef.current ?? computeCompletionDrain(config, backlog);
625
- settleCpsRef.current = settleCps;
626
- const accumulated = Math.max(0, queueDebtRef.current) + settleCps * dtSeconds;
627
- revealChars = Math.min(backlog, Math.floor(accumulated));
628
- revealSpeedCps = settleCps;
629
- nextQueueDebt = revealChars >= backlog ? 0 : accumulated - revealChars;
630
- } else if (steadyCps !== void 0) {
631
- const step = computeRevealStep(config, {
632
- backlog,
633
- chunkSizeEma: chunkSizeEmaRef.current,
634
- arrivalCpsEma: arrivalCpsEmaRef.current,
635
- emaCps: emaCpsRef.current,
636
- inputActive,
637
- settling,
638
- steadyCps
639
- }, dtSeconds);
640
- revealChars = Math.min(Math.round(step.revealChars * debugTuning.revealScale), backlog);
641
- revealSpeedCps = frameIntervalMs > 0 ? revealChars * 1e3 / frameIntervalMs : 0;
642
- } else {
643
- const step = computeAdaptiveQueueStep(backlog, frameIntervalMs, queueDebtRef.current, revealScaleOutRef.current?.current ?? 1, debugTuning);
644
- revealChars = step.revealChars;
645
- revealSpeedCps = step.speedCps;
646
- nextQueueDebt = step.debt;
647
- }
648
- debugRuntime.reportStream(streamIdRef.current, {
649
- backlog,
650
- speedCps: revealSpeedCps,
651
- targetChars: targetCount,
652
- displayedChars: displayedCount,
653
- active: !producerComplete
654
- });
655
- if (holdBackRef.current?.() === true) {
656
- rafRef.current = requestAnimationFrame(tick);
657
- return;
658
- }
659
- queueDebtRef.current = nextQueueDebt;
660
- const speedOut = speedOutRef.current;
661
- if (speedOut !== void 0) speedOut.current = revealSpeedCps;
662
- if (revealChars <= 0) {
663
- rafRef.current = requestAnimationFrame(tick);
664
- return;
665
- }
666
- const nextCount = displayedCount + revealChars;
667
- const segment = targetCharsRef.current.slice(displayedCount, nextCount).join("");
668
- if (segment) {
669
- const nextDisplayed = displayedContentRef.current + segment;
670
- displayedContentRef.current = nextDisplayed;
671
- displayedCountRef.current = nextCount;
672
- setDisplayedContent(nextDisplayed);
673
- } else {
674
- displayedContentRef.current = targetContentRef.current;
675
- displayedCountRef.current = targetCount;
676
- setDisplayedContent(targetContentRef.current);
677
- }
678
- rafRef.current = requestAnimationFrame(tick);
679
- };
680
- rafRef.current = requestAnimationFrame(tick);
681
- }, [
682
- config,
683
- stopFrameLoop,
684
- steadyCps
685
- ]);
686
- (0, react.useEffect)(() => {
687
- startFrameLoopRef.current = startFrameLoop;
688
- }, [startFrameLoop]);
689
- (0, react.useEffect)(() => {
690
- if (!enabled) {
691
- syncImmediate(content);
692
- return;
693
- }
694
- const prevTargetContent = targetContentRef.current;
695
- if (content === prevTargetContent) return;
696
- const now = performance.now();
697
- if (!content.startsWith(prevTargetContent)) {
698
- syncImmediate(content);
699
- return;
700
- }
701
- const appendedChars = [...content.slice(prevTargetContent.length)];
702
- const appendedCount = appendedChars.length;
703
- targetContentRef.current = content;
704
- targetCharsRef.current.push(...appendedChars);
705
- targetCountRef.current += appendedCount;
706
- settleCpsRef.current = null;
707
- const hadSample = lastInputTsRef.current > 0;
708
- const deltaChars = targetCountRef.current - lastInputCountRef.current;
709
- const deltaMs = Math.max(1, now - lastInputTsRef.current);
710
- if (hadSample && deltaChars > 0) {
711
- const instantCps = deltaChars * 1e3 / deltaMs;
712
- const normalizedInstantCps = clamp(instantCps, config.minCps, config.maxFlushCps * 3);
713
- const chunkEmaAlpha = .45;
714
- chunkSizeEmaRef.current = chunkSizeEmaRef.current * .55 + appendedCount * chunkEmaAlpha;
715
- arrivalCpsEmaRef.current = arrivalCpsEmaRef.current * .55 + normalizedInstantCps * chunkEmaAlpha;
716
- emaCpsRef.current = emaCpsRef.current * (1 - config.emaAlpha) + normalizedInstantCps * config.emaAlpha;
717
- }
718
- lastInputTsRef.current = now;
719
- lastInputCountRef.current = targetCountRef.current;
720
- startFrameLoop();
721
- }, [
722
- content,
723
- enabled,
724
- config,
725
- startFrameLoop,
726
- syncImmediate
727
- ]);
728
- (0, react.useEffect)(() => {
729
- return () => {
730
- stopFrameLoop();
731
- debugRuntime.reportStream(streamIdRef.current, null);
732
- };
733
- }, [stopFrameLoop]);
734
- return displayedContent;
735
- }
736
- //#endregion
737
- //#region src/client/useFpsGuard.ts
738
- /**
739
- * Performance guard for the streaming reveal.
740
- *
741
- * Feeds an EMA-smoothed frame-rate monitor from a rAF loop while streaming
742
- * and tracks whether the reply is on-screen. The returned `shouldHoldBack`
743
- * predicate is true only while the frame rate is below the threshold AND the
744
- * reply is offscreen — exactly the spec's "skip offscreen DOM updates when
745
- * FPS < 30" rule. The smoother consumes the predicate as its commit veto.
746
- */
747
- const FPS_THRESHOLD = 30;
748
- const FPS_ALPHA = .12;
749
- const RECOVER_FRAMES = 6;
750
- const MAX_FRAME_MS = 100;
751
- function useFpsGuard(active) {
752
- const fpsRef = (0, react.useRef)({
753
- emaMs: 0,
754
- lastMs: 0,
755
- healthyRun: 0,
756
- degraded: false
757
- });
758
- const visibleRef = (0, react.useRef)(true);
759
- const elementRef = (0, react.useRef)(null);
760
- (0, react.useEffect)(() => {
761
- if (!active) return;
762
- let rafId = 0;
763
- let lastDebugReport = 0;
764
- const frame = (now) => {
765
- rafId = requestAnimationFrame(frame);
766
- const fps = fpsRef.current;
767
- if (fps.lastMs === 0) {
768
- fps.lastMs = now;
769
- return;
770
- }
771
- const delta = Math.min(MAX_FRAME_MS, Math.max(1, now - fps.lastMs));
772
- fps.lastMs = now;
773
- fps.emaMs = fps.emaMs === 0 ? delta : fps.emaMs + FPS_ALPHA * (delta - fps.emaMs);
774
- const currentFps = 1e3 / fps.emaMs;
775
- if (currentFps < FPS_THRESHOLD) {
776
- fps.healthyRun = 0;
777
- fps.degraded = true;
778
- } else if (fps.degraded) {
779
- fps.healthyRun += 1;
780
- if (fps.healthyRun >= RECOVER_FRAMES) fps.degraded = false;
781
- }
782
- if (now - lastDebugReport >= 100) {
783
- debugRuntime.reportFps(currentFps, fps.emaMs, fps.degraded);
784
- lastDebugReport = now;
785
- }
786
- };
787
- rafId = requestAnimationFrame(frame);
788
- return () => {
789
- cancelAnimationFrame(rafId);
790
- fpsRef.current = {
791
- emaMs: 0,
792
- lastMs: 0,
793
- healthyRun: 0,
794
- degraded: false
795
- };
796
- debugRuntime.clearFps();
797
- };
798
- }, [active]);
799
- const ref = (0, react.useCallback)((element) => {
800
- elementRef.current = element;
801
- }, []);
802
- (0, react.useEffect)(() => {
803
- const element = elementRef.current;
804
- if (element === null || typeof IntersectionObserver === "undefined") return;
805
- const observer = new IntersectionObserver((entries) => {
806
- for (const entry of entries) visibleRef.current = entry.isIntersecting;
807
- }, { rootMargin: "120px 0px" });
808
- observer.observe(element);
809
- return () => observer.disconnect();
810
- });
811
- return {
812
- ref,
813
- shouldHoldBack: (0, react.useCallback)(() => {
814
- return active && fpsRef.current.degraded && !visibleRef.current;
815
- }, [active])
816
- };
817
- }
818
- //#endregion
819
- //#region src/client/teleprompterGlide.ts
820
- /**
821
- * Conversation-port follow while an assistant reply streams.
822
- *
823
- * A sub-stepped spring physics engine drives a float `animatedH`, rather
824
- * than restarting native smooth-scroll animations as every glyph lands.
825
- * Remaining lag rides a small compositor transform while the real scrollport
826
- * stays at its floor. The transform is bounded by the measured paint gap
827
- * before conversation chrome. This follower:
828
- *
829
- * - marks programmatic writes via `data-follow-owned` for compatible hosts;
830
- * - sets `overflow-anchor: none` so CSS scroll-anchoring does not snap;
831
- * - restores `animatedH` in a ResizeObserver (before paint) so a layout
832
- * pass cannot flash a snapped frame;
833
- * - expresses safe lag as a compositor transform on message rows;
834
- * - opens a speed-adaptive layout runway before fast output wraps, preserving
835
- * the reference spring constants at every reveal speed;
836
- * - catches up any lag that cannot fit before turn status / composer chrome,
837
- * so fixed chrome never has to counter-shift and the host stays at-bottom;
838
- * - never clips or overlays streamed text.
839
- *
840
- * A real reader gesture receives the effective visual position before the
841
- * transform clears. Lifecycle completion instead settles at the floor.
842
- *
843
- * Directional wheel/touch intent unpins immediately; pointer/key input falls
844
- * back to an upward scroll delta from the engine's own written position. A
845
- * reader release re-acquires only after returning to the real floor.
846
- */
847
- /**
848
- * Programmatic follow marker retained for hosts that recognize external
849
- * scroll ownership. Current Harness also sees the write land at the floor.
850
- */
851
- const FOLLOW_OWNED_ATTR = "data-follow-owned";
852
- /** Sub-pixel settle threshold; clearing below this cannot produce a visible rebound. */
853
- const FOLLOW_SETTLE_EPSILON_PX = .25;
854
- /** Safe-lag occupancy band over which reveal pressure is progressively reduced. */
855
- const FOLLOW_BACKPRESSURE_START_RATIO = .25;
856
- const GESTURE_EVENTS = [
857
- "wheel",
858
- "touchstart",
859
- "touchmove",
860
- "touchend",
861
- "touchcancel",
862
- "pointerdown",
863
- "keydown"
864
- ];
865
- /** Visible runway needed for the current reveal pressure. */
866
- function computeFollowReserve(speedCps, runwayPx = 48) {
867
- const available = Math.max(0, runwayPx);
868
- if (available <= 0) return 0;
869
- if (speedCps <= 90) return 0;
870
- const normalized = Math.min(1, Math.max(0, (speedCps - 90) / 510));
871
- const minimum = Math.min(available, 16);
872
- return minimum + normalized * (available - minimum);
873
- }
874
- /**
875
- * Reveal-rate multiplier needed to retain one-wrap headroom for the spring.
876
- * Throttling starts only after a quarter of the safe transform is occupied;
877
- * a constrained paint lands at the minimum immediately so the next reveal
878
- * commit cannot keep feeding an already-full visual buffer.
879
- */
880
- function computeFollowRevealScale(lagPx, capacityPx, constrained = false, tuning = DEFAULT_STREAM_DEBUG_TUNING) {
881
- if (constrained) return tuning.backpressureMinScale;
882
- if (!Number.isFinite(capacityPx)) return 1;
883
- if (capacityPx <= 0) return lagPx > 0 ? tuning.backpressureMinScale : 1;
884
- const ratio = Math.min(1, Math.max(0, lagPx / capacityPx));
885
- if (ratio >= .75) return tuning.backpressureMinScale;
886
- const progress = Math.min(1, Math.max(0, (ratio - FOLLOW_BACKPRESSURE_START_RATIO) / .5));
887
- const eased = progress * progress * (3 - 2 * progress);
888
- return 1 - (1 - tuning.backpressureMinScale) * eased;
889
- }
890
- /**
891
- * Semi-implicit spring integration with four substeps per <=32ms slice.
892
- * @param dtMs - Frame delta in ms.
893
- * @param input - Current visible lag and carried physics velocity.
894
- * @returns The position advance, its fraction, and next velocity.
895
- */
896
- function computeFollowStep(dtMs, input, tuning = DEFAULT_STREAM_DEBUG_TUNING) {
897
- if (input.lag <= .1 || dtMs <= 0) return {
898
- advancePx: 0,
899
- lerpStep: 0,
900
- velocityPxPerSec: 0
901
- };
902
- let lag = input.lag;
903
- let velocity = Math.max(0, input.velocityPxPerSec ?? 0);
904
- const elapsedMs = Math.min(32, dtMs);
905
- const slices = Math.max(1, Math.ceil(elapsedMs / 32));
906
- const subDt = elapsedMs / 1e3 / slices / 4;
907
- for (let slice = 0; slice < slices; slice += 1) for (let substep = 0; substep < 4; substep += 1) {
908
- const acceleration = (tuning.springStiffness * lag - tuning.springDamping * velocity) / tuning.springMass;
909
- velocity = Math.max(0, velocity + acceleration * subDt);
910
- const advance = velocity * subDt;
911
- if (advance >= lag) return {
912
- advancePx: input.lag,
913
- lerpStep: 1,
914
- velocityPxPerSec: 0
915
- };
916
- lag -= advance;
917
- }
918
- const advancePx = input.lag - lag;
919
- return {
920
- advancePx,
921
- lerpStep: advancePx / input.lag,
922
- velocityPxPerSec: velocity
923
- };
924
- }
925
- /** Element whose resize signals flow growth for the before-paint restore. */
926
- function resizeProxyOf(port) {
927
- return port.querySelector("[data-chat-transcript]") ?? port.querySelector("[data-chat-flow]");
928
- }
929
- /**
930
- * Outermost message surfaces; nested tool rows ride their parent.
591
+ * Outermost message surfaces; nested tool rows ride their parent.
931
592
  *
932
593
  * Another plugin may insert its own element as a flow sibling of the Chat rows
933
594
  * (meow-memory's fold bar is one; so is this plugin's own fold summary row).
@@ -945,15 +606,12 @@ window.__ModuleLoader__.load({
945
606
  if (flow === null) return anchored;
946
607
  const status = turnStatusOf(port);
947
608
  const anchoredSet = new Set(anchored);
948
- const __dbg = [...flow.children].filter((child) => child instanceof HTMLElement && child !== status && (anchoredSet.has(child) || child.querySelector("[data-chat-anchor-key]") === null));
949
- console.log("[engine] reached end of shiftSurfacesOf, n=", __dbg.length);
950
- globalThis.__dshssProbe = __dbg.map((e) => `${e.tagName}:${e.getAttribute("data-chat-anchor-key") ?? e.className}`);
951
- return __dbg;
609
+ return [...flow.children].filter((child) => child instanceof HTMLElement && child !== status && (anchoredSet.has(child) || child.querySelector("[data-chat-anchor-key]") === null));
952
610
  }
953
611
  function currentShiftOf(element) {
954
612
  return Number(/translate3d\(0(?:px)?,\s*(-?[\d.]+)px,\s*0(?:px)?\)/.exec(element.style.transform)?.[1] ?? 0);
955
613
  }
956
- function setShift(element, px) {
614
+ function setDirectShift(element, px) {
957
615
  if (Math.abs(px) > .01) {
958
616
  if (Math.abs(currentShiftOf(element) - px) <= .01 && element.style.willChange === "transform" && element.style.clipPath === "") return;
959
617
  element.style.transform = `translate3d(0, ${px}px, 0)`;
@@ -965,9 +623,62 @@ window.__ModuleLoader__.load({
965
623
  }
966
624
  element.style.clipPath = "";
967
625
  }
626
+ function setShift(element, px) {
627
+ if (Math.abs(px) > .01 && element.querySelector("[role=\"tooltip\"]") !== null) {
628
+ setDirectShift(element, 0);
629
+ return;
630
+ }
631
+ setDirectShift(element, px);
632
+ }
968
633
  function turnStatusOf(port) {
969
634
  return port.querySelector("[data-chat-turn-status], [data-chat-flow] > [role=\"status\"]");
970
635
  }
636
+ const FOLLOW_FLOW_FILL_SYMBOL = Symbol.for("dsh-smooth-stream.follow-flow-fill");
637
+ const followFlowFillHost = globalThis;
638
+ const followFlowFills = followFlowFillHost[FOLLOW_FLOW_FILL_SYMBOL] ?? /* @__PURE__ */ new WeakMap();
639
+ followFlowFillHost[FOLLOW_FLOW_FILL_SYMBOL] = followFlowFills;
640
+ const FOLLOW_FLOW_FILL_USERS_SYMBOL = Symbol.for("dsh-smooth-stream.follow-flow-fill-users");
641
+ const followFlowFillUsersHost = globalThis;
642
+ const followFlowFillUsers = followFlowFillUsersHost[FOLLOW_FLOW_FILL_USERS_SYMBOL] ?? /* @__PURE__ */ new WeakMap();
643
+ followFlowFillUsersHost[FOLLOW_FLOW_FILL_USERS_SYMBOL] = followFlowFillUsers;
644
+ function flowElementOf(port) {
645
+ return port.querySelector("[data-chat-transcript]") ?? port.querySelector("[data-chat-flow]");
646
+ }
647
+ function ensureFlowFillsPort(port) {
648
+ const element = flowElementOf(port);
649
+ const owned = followFlowFills.get(port);
650
+ if (element === null) {
651
+ if (owned !== void 0) restoreFlowFill(port);
652
+ return;
653
+ }
654
+ const client = Math.max(0, port.clientHeight);
655
+ let overshoot = owned?.overshootPx;
656
+ if (overshoot === void 0) {
657
+ const pendingOriginal = element.style.minHeight;
658
+ element.style.minHeight = `${client}px`;
659
+ overshoot = Math.max(0, port.scrollHeight - client);
660
+ element.style.minHeight = pendingOriginal;
661
+ if (owned !== void 0) restoreFlowFill(port);
662
+ }
663
+ const target = `${Math.max(0, client - overshoot)}px`;
664
+ if (owned !== void 0) {
665
+ if (owned.element === element && owned.overshootPx === overshoot && element.style.minHeight === target) return;
666
+ restoreFlowFill(port);
667
+ }
668
+ const original = element.style.minHeight;
669
+ element.style.minHeight = target;
670
+ followFlowFills.set(port, {
671
+ element,
672
+ original,
673
+ overshootPx: overshoot
674
+ });
675
+ }
676
+ function restoreFlowFill(port) {
677
+ const owned = followFlowFills.get(port);
678
+ if (owned === void 0) return;
679
+ owned.element.style.minHeight = owned.original;
680
+ followFlowFills.delete(port);
681
+ }
971
682
  /** Height committed by one newly mounted Chat row, including its flex gap. */
972
683
  function entranceExtentOf(root) {
973
684
  const row = root.closest("[data-chat-flow-key]") ?? root;
@@ -991,11 +702,48 @@ window.__ModuleLoader__.load({
991
702
  const followRunways = followRunwayRegistry[FOLLOW_RUNWAYS_SYMBOL] ?? /* @__PURE__ */ new WeakMap();
992
703
  followRunwayRegistry[FOLLOW_RUNWAYS_SYMBOL] = followRunways;
993
704
  const followPaintLimits = /* @__PURE__ */ new WeakMap();
705
+ const followHadChrome = /* @__PURE__ */ new WeakSet();
706
+ /** Last painted shift per port, to spread a wrap's one-line step over frames. */
707
+ const followLastShiftPx = /* @__PURE__ */ new WeakMap();
708
+ /** Last runway offset seen per port, to rebase the extent when the margin size changes. */
709
+ const followRunwayOffsetHistory = /* @__PURE__ */ new WeakMap();
710
+ /** Last observed scroll floor per port, for the slack→overflow runway re-measure. */
711
+ const followFloorHistory = /* @__PURE__ */ new WeakMap();
712
+ /** One-shot flag: the transition frame must paint the full runway as baseline. */
713
+ const followSlackTransition = /* @__PURE__ */ new WeakSet();
994
714
  function invalidatePaintLimit(port) {
995
715
  followPaintLimits.delete(port);
996
716
  }
997
717
  /** Logical position and velocity survive a React owner handoff and finish. */
998
718
  const followMotionStates = /* @__PURE__ */ new WeakMap();
719
+ const followReaderHolds = /* @__PURE__ */ new WeakMap();
720
+ /**
721
+ * Commit-time correction channel. A reveal commit that lands after this
722
+ * frame's ResizeObserver delivery would otherwise paint one intermediate
723
+ * frame — content grown, scrollTop/transform not yet compensated — before
724
+ * the next tick fixes it. Reveal arms call {@link notifyFollowCommit} right
725
+ * after their commit; the leading follower re-runs its geometry in the same
726
+ * task, so the intermediate state never reaches a paint.
727
+ */
728
+ const followCommitListeners = /* @__PURE__ */ new WeakMap();
729
+ function notifyFollowCommit(fromInsidePort) {
730
+ if (fromInsidePort === null) return;
731
+ const port = fromInsidePort.closest("[data-conversation-scroll]");
732
+ const listeners = port === null ? void 0 : followCommitListeners.get(port);
733
+ if (listeners === void 0) return;
734
+ for (const listener of [...listeners]) listener();
735
+ }
736
+ function subscribeFollowCommit(port, fn) {
737
+ let listeners = followCommitListeners.get(port);
738
+ if (listeners === void 0) {
739
+ listeners = /* @__PURE__ */ new Set();
740
+ followCommitListeners.set(port, listeners);
741
+ }
742
+ listeners.add(fn);
743
+ return () => {
744
+ listeners.delete(fn);
745
+ };
746
+ }
999
747
  function restoreRunway(port) {
1000
748
  const runway = followRunways.get(port);
1001
749
  if (runway === void 0) return;
@@ -1007,14 +755,13 @@ window.__ModuleLoader__.load({
1007
755
  if (value === "") return false;
1008
756
  const terms = [...value.matchAll(/([\d.]+)px/g)];
1009
757
  if (terms.length === 0 || value.replaceAll(/calc|px|[\d.+()\s]/g, "") !== "") return false;
1010
- return terms.every(([, raw]) => {
1011
- const px = Number(raw);
1012
- return Number.isFinite(px) && px >= 48 && Math.abs(px % 48) <= Number.EPSILON;
1013
- });
758
+ const values = terms.map(([, raw]) => Number(raw));
759
+ if (values.some((px) => !Number.isFinite(px))) return false;
760
+ return [LEGACY_RUNWAY_PX, 72].some((unit) => values.every((px) => px >= unit && Math.abs(px % unit) <= Number.EPSILON));
1014
761
  }
1015
762
  /** Remove unowned runway residue written by v0.3.3 and earlier bundles. */
1016
763
  function migrateLegacyRunway(port, surfaces, status, composer) {
1017
- if (followRunways.has(port)) return;
764
+ if (followRunways.has(port)) return false;
1018
765
  let migrated = false;
1019
766
  if (status !== null && isLegacyRunway(status.style.marginTop)) {
1020
767
  status.style.marginTop = "";
@@ -1026,13 +773,29 @@ window.__ModuleLoader__.load({
1026
773
  migrated = true;
1027
774
  }
1028
775
  if (migrated) invalidatePaintLimit(port);
776
+ return migrated;
1029
777
  }
1030
- function ensureRunway(port, surfaces, runwayPx = 48) {
778
+ function ensureRunway(port, surfaces, runwayPx = 72) {
1031
779
  const status = turnStatusOf(port);
1032
780
  const composer = port.querySelector("[data-composer-seat]");
1033
- migrateLegacyRunway(port, surfaces, status, composer);
781
+ if (status !== null && followRunways.get(port) === void 0) {
782
+ const inlinePx = Number.parseFloat(status.style.marginTop ?? "") || 0;
783
+ if (Math.abs(inlinePx - 72) <= .5) {
784
+ followRunways.set(port, {
785
+ element: status,
786
+ offset: inlinePx,
787
+ property: "marginTop",
788
+ original: "",
789
+ requestedPx: inlinePx
790
+ });
791
+ invalidatePaintLimit(port);
792
+ }
793
+ }
794
+ const migratedLegacy = migrateLegacyRunway(port, surfaces, status, composer);
1034
795
  const naturalHeight = Math.max(0, port.scrollHeight - runwayOffsetOf(port));
1035
- if (runwayPx <= 0 || port.clientHeight <= 0 || naturalHeight <= port.clientHeight) {
796
+ const existing = followRunways.get(port);
797
+ const requestedRunwayPx = migratedLegacy || existing?.normalizedLegacy === true ? 72 : runwayPx;
798
+ if (requestedRunwayPx <= 0 || port.clientHeight <= 0 || naturalHeight <= port.clientHeight) {
1036
799
  restoreRunway(port);
1037
800
  return;
1038
801
  }
@@ -1049,18 +812,19 @@ window.__ModuleLoader__.load({
1049
812
  }
1050
813
  const element = target.element;
1051
814
  const current = followRunways.get(port);
1052
- if (current?.element === element && current.property === target.property && current.requestedPx === runwayPx) return;
815
+ if (current?.element === element && current.property === target.property && current.requestedPx === requestedRunwayPx) return;
1053
816
  restoreRunway(port);
1054
817
  const beforeHeight = port.scrollHeight;
1055
818
  const original = element.style[target.property];
1056
- element.style[target.property] = original === "" ? `${runwayPx}px` : `calc(${original} + ${runwayPx}px)`;
819
+ element.style[target.property] = original === "" ? `${requestedRunwayPx}px` : `calc(${original} + ${requestedRunwayPx}px)`;
1057
820
  const offset = Math.max(0, port.scrollHeight - beforeHeight);
1058
821
  followRunways.set(port, {
1059
822
  element,
1060
823
  offset,
1061
824
  property: target.property,
1062
825
  original,
1063
- requestedPx: runwayPx
826
+ requestedPx: requestedRunwayPx,
827
+ normalizedLegacy: migratedLegacy || existing?.normalizedLegacy === true
1064
828
  });
1065
829
  invalidatePaintLimit(port);
1066
830
  }
@@ -1073,13 +837,14 @@ window.__ModuleLoader__.load({
1073
837
  if (last === void 0) return 0;
1074
838
  const status = turnStatusOf(port);
1075
839
  const composer = port.querySelector("[data-composer-seat]");
840
+ if (status !== null || composer !== null) followHadChrome.add(port);
1076
841
  const cached = followPaintLimits.get(port);
1077
842
  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;
1078
843
  const ceiling = [status, composer].filter((element) => element !== null).map((element) => ({
1079
844
  element,
1080
845
  rect: element.getBoundingClientRect()
1081
846
  })).filter(({ rect }) => Number.isFinite(rect.top) && Number.isFinite(rect.bottom) && rect.bottom > rect.top).sort((first, second) => first.rect.top - second.rect.top)[0];
1082
- if (ceiling === void 0) return status === null && composer === null ? Number.POSITIVE_INFINITY : runwayOffsetOf(port);
847
+ if (ceiling === void 0) return status === null && composer === null ? followHadChrome.has(port) ? 0 : Number.POSITIVE_INFINITY : runwayOffsetOf(port);
1083
848
  const ceilingTop = ceiling.rect.top - currentShiftOf(ceiling.element);
1084
849
  const naturalBottom = last.getBoundingClientRect().bottom - currentShiftOf(last);
1085
850
  const limit = Math.max(0, ceilingTop - naturalBottom - 1);
@@ -1091,491 +856,1144 @@ window.__ModuleLoader__.load({
1091
856
  status,
1092
857
  surface: last
1093
858
  });
1094
- return limit;
859
+ return limit;
860
+ }
861
+ function setFollowScrollTop(port, nextTop) {
862
+ if (Math.abs(port.scrollTop - nextTop) > .01) port.scrollTop = nextTop;
863
+ followScrollLedgers.set(port, port.scrollTop);
864
+ const ownedTop = String(port.scrollTop);
865
+ if (port.getAttribute(FOLLOW_OWNED_ATTR) !== ownedTop) port.setAttribute(FOLLOW_OWNED_ATTR, ownedTop);
866
+ }
867
+ /**
868
+ * Last scrollTop this engine wrote or accepted, per port. Reader intent is a
869
+ * real upward delta from this ledger; a key press or touch while pinned
870
+ * (typing in the composer) must not release the pin, because a released pin
871
+ * can never re-acquire while content streams away from the reader position.
872
+ */
873
+ const followScrollLedgers = /* @__PURE__ */ new WeakMap();
874
+ const followActivityAt = /* @__PURE__ */ new WeakMap();
875
+ /** Whether this port was owned recently enough to identify a closing tail row. */
876
+ function hasRecentConversationFollow(port, windowMs = 250) {
877
+ const last = followActivityAt.get(port);
878
+ return last !== void 0 && performance.now() - last <= windowMs;
879
+ }
880
+ function readerScrolledUp(port) {
881
+ return port.scrollTop < (followScrollLedgers.get(port) ?? 0) - 8;
882
+ }
883
+ /**
884
+ * Paint a bounded visual lag and return the effective logical extent.
885
+ *
886
+ * This is the final geometry invariant, not merely an animation preference:
887
+ * any lag beyond the real gap to status/composer chrome is caught up in the
888
+ * same frame. Carrying that excess in `scrollTop` would move the transcript
889
+ * toward fixed chrome and also make the host expose jump-to-bottom.
890
+ */
891
+ function applyVisual(port, animatedH, reservePx, velocityPxPerSec = 0, runwayPx = 72, shiftCeilingPx = Number.POSITIVE_INFINITY, promoteAtRest = false, trajectoryShiftPx) {
892
+ const surfaces = shiftSurfacesOf(port);
893
+ ensureFlowFillsPort(port);
894
+ {
895
+ const preFloor = Math.max(0, port.scrollHeight - port.clientHeight);
896
+ const lastFloorSeen = followFloorHistory.get(port);
897
+ if (lastFloorSeen !== void 0 && lastFloorSeen === 0 && preFloor > 0 && followRunways.has(port)) {
898
+ const owned = followRunways.get(port);
899
+ restoreRunway(port);
900
+ ensureRunway(port, surfaces, owned?.requestedPx ?? runwayPx);
901
+ followSlackTransition.add(port);
902
+ }
903
+ if (preFloor !== lastFloorSeen) {
904
+ invalidatePaintLimit(port);
905
+ followFloorHistory.set(port, preFloor);
906
+ }
907
+ }
908
+ ensureRunway(port, surfaces, reservePx);
909
+ const contentHeight2 = Math.max(0, port.scrollHeight);
910
+ const runwayOffset2 = runwayOffsetOf(port);
911
+ const prevOffset2 = followRunwayOffsetHistory.get(port);
912
+ if (prevOffset2 !== void 0 && runwayOffset2 !== prevOffset2) animatedH = Math.max(0, animatedH - (runwayOffset2 - prevOffset2));
913
+ followRunwayOffsetHistory.set(port, runwayOffset2);
914
+ const contentHeight = contentHeight2;
915
+ const runwayOffset = runwayOffset2;
916
+ const targetHeight = Math.max(0, contentHeight - runwayOffset);
917
+ const floor = Math.max(0, contentHeight - port.clientHeight);
918
+ const extent = Math.min(targetHeight, Math.max(0, animatedH));
919
+ if (port.style.overflowAnchor !== "none") port.style.overflowAnchor = "none";
920
+ if (port.style.scrollBehavior !== "auto") port.style.scrollBehavior = "auto";
921
+ if (floor <= 0) {
922
+ followRunwayOffsetHistory.set(port, 0);
923
+ setFollowScrollTop(port, 0);
924
+ followMotionStates.set(port, {
925
+ capacityPx: Number.POSITIVE_INFINITY,
926
+ constrained: false,
927
+ extent: targetHeight,
928
+ lagPx: 0,
929
+ reservePx: 0,
930
+ velocityPxPerSec: 0
931
+ });
932
+ for (const surface of surfaces) setShift(surface, 0);
933
+ followLastShiftPx.set(port, 0);
934
+ const status = turnStatusOf(port);
935
+ if (status !== null) setShift(status, 0);
936
+ return targetHeight;
937
+ }
938
+ const limit = safeShiftLimit(port, surfaces);
939
+ followSlackTransition.delete(port);
940
+ const visibleReserve = Math.min(runwayOffset, Math.max(0, reservePx));
941
+ const baselineShift = runwayOffset - visibleReserve;
942
+ const requestedLag = Math.max(0, targetHeight - extent);
943
+ const availableShift = Math.min(Math.max(0, limit), Math.max(0, shiftCeilingPx));
944
+ const motionShift = Math.min(trajectoryShiftPx ?? baselineShift + requestedLag, availableShift);
945
+ let shift = Math.max(motionShift, promoteAtRest && motionShift <= .01 && availableShift > 0 ? .1 : 0);
946
+ const previousShift = followLastShiftPx.get(port);
947
+ if (previousShift !== void 0 && shift < previousShift - 8) shift = previousShift - 8;
948
+ followLastShiftPx.set(port, shift);
949
+ const effectiveLag = Math.max(0, motionShift - baselineShift);
950
+ const capacityPx = Math.max(0, limit - baselineShift);
951
+ const effectiveExtent = targetHeight - effectiveLag;
952
+ setFollowScrollTop(port, floor);
953
+ followMotionStates.set(port, {
954
+ capacityPx,
955
+ constrained: requestedLag > effectiveLag + FOLLOW_SETTLE_EPSILON_PX,
956
+ extent: effectiveExtent,
957
+ lagPx: effectiveLag,
958
+ reservePx: visibleReserve,
959
+ velocityPxPerSec
960
+ });
961
+ for (const surface of surfaces) setShift(surface, shift);
962
+ const status = turnStatusOf(port);
963
+ if (status !== null) setShift(status, 0);
964
+ return effectiveExtent;
965
+ }
966
+ function clearMotion(port) {
967
+ port.removeAttribute(FOLLOW_OWNED_ATTR);
968
+ port.style.overflowAnchor = "";
969
+ port.style.scrollBehavior = "";
970
+ for (const surface of shiftSurfacesOf(port)) setShift(surface, 0);
971
+ const status = turnStatusOf(port);
972
+ if (status !== null) setShift(status, 0);
973
+ }
974
+ function clearVisual(port) {
975
+ clearMotion(port);
976
+ restoreRunway(port);
977
+ followMotionStates.delete(port);
978
+ followLastShiftPx.delete(port);
979
+ invalidatePaintLimit(port);
980
+ }
981
+ /** Keep an already-promoted surface at zero until one stable final paint lands. */
982
+ function holdCompositorAtRest(element) {
983
+ element.style.transform = "translate3d(0, 0px, 0)";
984
+ element.style.willChange = "transform";
985
+ element.style.clipPath = "";
986
+ }
987
+ /** Remove equal offsets, land on the floor, then retire the compositor quietly. */
988
+ function finishAtNaturalFloor(port, retainCompositor = true) {
989
+ const surfaces = shiftSurfacesOf(port);
990
+ const status = turnStatusOf(port);
991
+ if (!retainCompositor) {
992
+ restoreRunway(port);
993
+ settleAtFloor(port);
994
+ clearMotion(port);
995
+ followMotionStates.delete(port);
996
+ return;
997
+ }
998
+ const promoted = [...surfaces, ...status === null ? [] : [status]].filter((element) => element.style.transform !== "" || element.style.willChange === "transform");
999
+ const promotedSet = new Set(promoted);
1000
+ restoreRunway(port);
1001
+ settleAtFloor(port);
1002
+ port.removeAttribute(FOLLOW_OWNED_ATTR);
1003
+ port.style.overflowAnchor = "";
1004
+ port.style.scrollBehavior = "";
1005
+ for (const surface of surfaces) if (promotedSet.has(surface)) holdCompositorAtRest(surface);
1006
+ else setShift(surface, 0);
1007
+ if (status !== null) {
1008
+ if (promotedSet.has(status)) holdCompositorAtRest(status);
1009
+ else setShift(status, 0);
1010
+ }
1011
+ followMotionStates.delete(port);
1012
+ if (promoted.length === 0) return;
1013
+ requestAnimationFrame(() => {
1014
+ requestAnimationFrame(() => {
1015
+ for (const element of promoted) if (Math.abs(currentShiftOf(element)) <= .01) setShift(element, 0);
1016
+ });
1017
+ });
1095
1018
  }
1096
- function setFollowScrollTop(port, nextTop) {
1097
- if (Math.abs(port.scrollTop - nextTop) > .01) port.scrollTop = nextTop;
1098
- followScrollLedgers.set(port, port.scrollTop);
1099
- const ownedTop = String(port.scrollTop);
1100
- if (port.getAttribute(FOLLOW_OWNED_ATTR) !== ownedTop) port.setAttribute(FOLLOW_OWNED_ATTR, ownedTop);
1019
+ function settleAtFloor(port) {
1020
+ setFollowScrollTop(port, Math.max(0, port.scrollHeight - port.clientHeight));
1021
+ followReaderHolds.delete(port);
1101
1022
  }
1023
+ /** Only the newest active follower may write one port's shared visual state. */
1024
+ const followLeaders = /* @__PURE__ */ new WeakMap();
1025
+ let followGeneration = 0;
1102
1026
  /**
1103
- * Last scrollTop this engine wrote or accepted, per port. Reader intent is a
1104
- * real upward delta from this ledger; a key press or touch while pinned
1105
- * (typing in the composer) must not release the pin, because a released pin
1106
- * can never re-acquire while content streams away from the reader position.
1027
+ * Own the conversation scrollport's bottom-follow while `active` is true.
1028
+ *
1029
+ * @param rootRef - An element inside the conversation scrollport.
1030
+ * @param active - True while the reply is still revealing.
1031
+ * @param speedCpsRef - Live reveal-rate EMA from the smoother.
1032
+ * @param revealScaleRef - Optional backpressure control for text reveal.
1033
+ * @param predictive - Whether to reserve paint room ahead of growth.
1034
+ * @param entrance - Whether the first committed row height should glide in.
1035
+ * @param onEntranceSettled - Releases a one-shot entrance owner after catch-up.
1036
+ * @param predictiveRef - Optional live visibility gate for predictive runway.
1037
+ * @param entranceExtentRef - Optional measured growth delta for a generic row.
1038
+ * @param revealedCharsRef - Committed code-point count for feed-forward phase.
1107
1039
  */
1108
- const followScrollLedgers = /* @__PURE__ */ new WeakMap();
1109
- const followActivityAt = /* @__PURE__ */ new WeakMap();
1110
- /** Whether this port was owned recently enough to identify a closing tail row. */
1111
- function hasRecentConversationFollow(port, windowMs = 250) {
1112
- const last = followActivityAt.get(port);
1113
- return last !== void 0 && performance.now() - last <= windowMs;
1114
- }
1115
- function readerScrolledUp(port) {
1116
- return port.scrollTop < (followScrollLedgers.get(port) ?? 0) - 8;
1040
+ function useConversationFollow(rootRef, active, speedCpsRef, revealScaleRef, predictive = true, entrance = false, onEntranceSettled, predictiveRef, entranceExtentRef, revealedCharsRef) {
1041
+ const activeRef = (0, react.useRef)(active);
1042
+ const entranceRef = (0, react.useRef)(entrance);
1043
+ const onEntranceSettledRef = (0, react.useRef)(onEntranceSettled);
1044
+ entranceRef.current = entrance;
1045
+ onEntranceSettledRef.current = onEntranceSettled;
1046
+ activeRef.current = active;
1047
+ (0, react.useLayoutEffect)(() => {
1048
+ if (!active) return;
1049
+ const startedAsEntrance = entrance;
1050
+ const owner = {};
1051
+ const generation = ++followGeneration;
1052
+ let rafId = 0;
1053
+ let last = performance.now();
1054
+ let following = true;
1055
+ let primed = false;
1056
+ let animatedH = 0;
1057
+ let reservePx = 0;
1058
+ let velocityPxPerSec = 0;
1059
+ let interacting = false;
1060
+ let readerGestureIntent = false;
1061
+ let readerReleased = false;
1062
+ let touchStartY = null;
1063
+ let interactTimer = null;
1064
+ let port = null;
1065
+ let resize = null;
1066
+ let observedTail = null;
1067
+ let statusWasPresent = null;
1068
+ let trajectoryPositionPx = null;
1069
+ let trajectoryVelocityPxPerMs = 0;
1070
+ let trajectoryTargetVelocityPxPerMs = 0;
1071
+ let trajectoryFloorPx = null;
1072
+ let trajectoryGrowthAtMs = null;
1073
+ let trajectoryAccumulatedGrowthPx = 0;
1074
+ let trajectoryAccumulatedGrowthMs = 0;
1075
+ let trajectoryGrowthSamples = 0;
1076
+ let trajectoryWasActive = false;
1077
+ const revealPhase = new FollowRevealPhaseTracker();
1078
+ let holding = null;
1079
+ let entrancePending = entranceRef.current;
1080
+ const finishEntrance = () => {
1081
+ if (!entrancePending) return;
1082
+ entrancePending = false;
1083
+ onEntranceSettledRef.current?.();
1084
+ };
1085
+ const updateRevealScale = (next, elapsedMs, urgent = false) => {
1086
+ if (revealScaleRef === void 0) return;
1087
+ const tuning = debugRuntime.activeTuning();
1088
+ const state = followMotionStates.get(next);
1089
+ const target = state === void 0 ? 1 : computeFollowRevealScale(state.lagPx, state.capacityPx, state.constrained, tuning);
1090
+ const current = Math.min(1, Math.max(tuning.backpressureMinScale, revealScaleRef.current));
1091
+ if (target < current || urgent) {
1092
+ revealScaleRef.current = Math.min(current, target);
1093
+ return;
1094
+ }
1095
+ const releaseStep = 1 - Math.exp(-Math.max(0, elapsedMs) / 240);
1096
+ revealScaleRef.current = current + (target - current) * releaseStep;
1097
+ };
1098
+ const releaseRevealScale = () => {
1099
+ if (revealScaleRef !== void 0) revealScaleRef.current = 1;
1100
+ };
1101
+ const reportFollow = (next, isActive) => {
1102
+ const state = followMotionStates.get(next);
1103
+ debugRuntime.reportFollow(next, {
1104
+ lagPx: state ? state.lagPx : -1,
1105
+ velocityPxPerSec: state?.velocityPxPerSec ?? 0,
1106
+ reservePx: state?.reservePx ?? 0,
1107
+ capacityPx: state ? state.capacityPx : -1,
1108
+ revealScale: revealScaleRef?.current ?? 1,
1109
+ following,
1110
+ constrained: state?.constrained ?? false,
1111
+ scrollTop: next.scrollTop,
1112
+ scrollHeight: next.scrollHeight,
1113
+ clientHeight: next.clientHeight,
1114
+ active: isActive
1115
+ });
1116
+ };
1117
+ const isLeader = (next) => followLeaders.get(next)?.owner === owner;
1118
+ const hold = (next) => {
1119
+ followActivityAt.set(next, performance.now());
1120
+ if (holding === next && isLeader(next)) return;
1121
+ holding = next;
1122
+ const leader = followLeaders.get(next);
1123
+ if (leader === void 0 || generation > leader.generation) followLeaders.set(next, {
1124
+ generation,
1125
+ owner
1126
+ });
1127
+ };
1128
+ const drop = (next) => {
1129
+ if (holding === next) holding = null;
1130
+ if (isLeader(next)) {
1131
+ clearMotion(next);
1132
+ followMotionStates.delete(next);
1133
+ releaseRevealScale();
1134
+ debugRuntime.reportFollow(next, null);
1135
+ }
1136
+ };
1137
+ const handBackVisual = (next) => {
1138
+ const shift = currentShiftOf(shiftSurfacesOf(next).at(-1) ?? next);
1139
+ const transferableShift = shift > .25 ? shift : 0;
1140
+ const visualTop = Math.max(0, next.scrollTop - transferableShift);
1141
+ clearMotion(next);
1142
+ restoreRunway(next);
1143
+ const floor = Math.max(0, next.scrollHeight - next.clientHeight);
1144
+ next.scrollTop = Math.min(visualTop, Math.max(0, floor - 26));
1145
+ followScrollLedgers.set(next, next.scrollTop);
1146
+ };
1147
+ const markGesture = (event) => {
1148
+ interacting = true;
1149
+ if (event.type === "wheel") {
1150
+ const deltaY = event.deltaY;
1151
+ if (Number.isFinite(deltaY) && deltaY < 0) readerGestureIntent = true;
1152
+ } else if (event.type === "touchstart") touchStartY = event.touches[0]?.clientY ?? null;
1153
+ else if (event.type === "touchmove") {
1154
+ const touch = event.touches[0];
1155
+ if (touch !== void 0) {
1156
+ if (touchStartY === null) touchStartY = touch.clientY;
1157
+ if (touch.clientY - touchStartY > 1) readerGestureIntent = true;
1158
+ }
1159
+ } else if (event.type === "touchend" || event.type === "touchcancel") touchStartY = null;
1160
+ if (interactTimer !== null) clearTimeout(interactTimer);
1161
+ interactTimer = setTimeout(() => {
1162
+ interacting = false;
1163
+ readerGestureIntent = false;
1164
+ interactTimer = null;
1165
+ }, 800);
1166
+ };
1167
+ const restoreBeforePaint = () => {
1168
+ if (!following || port === null || !isLeader(port)) return;
1169
+ const tuning = debugRuntime.activeTuning();
1170
+ const predictGrowth = predictiveRef?.current ?? predictive;
1171
+ const floor = Math.max(0, port.scrollHeight - port.clientHeight);
1172
+ if (followFloorHistory.get(port) !== floor) invalidatePaintLimit(port);
1173
+ const isReasoningSurface = rootRef.current?.querySelector("[data-variant=\"think\"]") !== null;
1174
+ const trajectoryShift = predictive && !isReasoningSurface && runwayOffsetOf(port) > 0 && trajectoryPositionPx !== null ? floor - trajectoryPositionPx : void 0;
1175
+ animatedH = applyVisual(port, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx, Number.POSITIVE_INFINITY, !predictGrowth, trajectoryShift);
1176
+ if (trajectoryShift !== void 0) trajectoryPositionPx = floor - currentShiftOf(shiftSurfacesOf(port).at(-1) ?? port);
1177
+ updateRevealScale(port, 0, true);
1178
+ reportFollow(port, activeRef.current);
1179
+ };
1180
+ let unsubscribeCommit = null;
1181
+ const bindPort = (next) => {
1182
+ if (port === next) return;
1183
+ if (port !== null) {
1184
+ for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture);
1185
+ resize?.disconnect();
1186
+ }
1187
+ unsubscribeCommit?.();
1188
+ port = next;
1189
+ invalidatePaintLimit(port);
1190
+ unsubscribeCommit = subscribeFollowCommit(port, () => {
1191
+ restoreBeforePaint();
1192
+ });
1193
+ for (const name of GESTURE_EVENTS) port.addEventListener(name, markGesture, { passive: true });
1194
+ if (typeof ResizeObserver !== "undefined") {
1195
+ resize = new ResizeObserver(restoreBeforePaint);
1196
+ resize.observe(port);
1197
+ const proxy = resizeProxyOf(port);
1198
+ if (proxy !== null) resize.observe(proxy);
1199
+ }
1200
+ };
1201
+ /**
1202
+ * Keep the observer on the flow's TAIL surface. A flow locked to the
1203
+ * viewport by min-height does not resize when content grows inside it —
1204
+ * only the last message row does, and missing that resize means missing
1205
+ * the pre-paint correction for that frame's wrap.
1206
+ */
1207
+ const observeTailSurface = () => {
1208
+ if (resize === null || port === null) return;
1209
+ const tail = shiftSurfacesOf(port).at(-1) ?? null;
1210
+ if (tail === observedTail) return;
1211
+ if (observedTail !== null) resize.unobserve(observedTail);
1212
+ observedTail = tail;
1213
+ if (tail !== null) resize.observe(tail);
1214
+ };
1215
+ const frame = (now) => {
1216
+ rafId = requestAnimationFrame(frame);
1217
+ const elapsedMs = Math.max(.001, now - last);
1218
+ const dt = Math.min(32, elapsedMs);
1219
+ const tuning = debugRuntime.activeTuning();
1220
+ last = now;
1221
+ const root = rootRef.current;
1222
+ if (root === null) return;
1223
+ const nextPort = root.closest("[data-conversation-scroll]");
1224
+ if (nextPort === null) return;
1225
+ bindPort(nextPort);
1226
+ observeTailSurface();
1227
+ if (nextPort.clientHeight <= 0) return;
1228
+ const reportedLag = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight) - nextPort.scrollTop;
1229
+ const extent = Math.min(nextPort.scrollHeight, Math.max(0, nextPort.scrollHeight - reportedLag));
1230
+ if (!primed) {
1231
+ const inherited = nextPort.hasAttribute(FOLLOW_OWNED_ATTR) ? followMotionStates.get(nextPort) : void 0;
1232
+ if (inherited === void 0) {
1233
+ const entranceExtent = entrancePending ? entranceExtentRef?.current ?? entranceExtentOf(root) : 0;
1234
+ const predictGrowth = predictiveRef?.current ?? predictive;
1235
+ animatedH = entrancePending ? Math.max(0, nextPort.scrollHeight - entranceExtent) : nextPort.scrollHeight;
1236
+ const hasStatus = turnStatusOf(nextPort) !== null;
1237
+ reservePx = predictGrowth && (hasStatus || speedCpsRef.current > 90) ? computeFollowReserve(speedCpsRef.current, tuning.runwayPx) : 0;
1238
+ statusWasPresent = hasStatus;
1239
+ velocityPxPerSec = 0;
1240
+ following = !readerScrolledUp(nextPort) && !followReaderHolds.has(nextPort);
1241
+ } else {
1242
+ animatedH = Math.min(nextPort.scrollHeight, inherited.extent);
1243
+ reservePx = inherited.reservePx;
1244
+ velocityPxPerSec = inherited.velocityPxPerSec;
1245
+ following = true;
1246
+ }
1247
+ if (following) {
1248
+ hold(nextPort);
1249
+ if (isLeader(nextPort)) {
1250
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx, Number.POSITIVE_INFINITY, !(predictiveRef?.current ?? predictive));
1251
+ if (predictive && root.querySelector("[data-variant=\"think\"]") === null && runwayOffsetOf(nextPort) > 0) {
1252
+ const floor = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight);
1253
+ const requestedMinLagPx = Math.max(20, runwayOffsetOf(nextPort) - 32);
1254
+ const paintMaxLagPx = Math.max(0, safeShiftLimit(nextPort, shiftSurfacesOf(nextPort)) - 1);
1255
+ const minLagPx = Math.min(requestedMinLagPx, paintMaxLagPx);
1256
+ const currentShift = currentShiftOf(shiftSurfacesOf(nextPort).at(-1) ?? nextPort);
1257
+ trajectoryPositionPx = floor - Math.min(paintMaxLagPx, Math.max(minLagPx, currentShift));
1258
+ trajectoryTargetVelocityPxPerMs = Math.max(0, speedCpsRef.current) * .4 / 1e3;
1259
+ trajectoryVelocityPxPerMs = trajectoryTargetVelocityPxPerMs;
1260
+ trajectoryFloorPx = floor;
1261
+ trajectoryGrowthAtMs = now;
1262
+ trajectoryAccumulatedGrowthPx = 0;
1263
+ trajectoryAccumulatedGrowthMs = 0;
1264
+ trajectoryGrowthSamples = 0;
1265
+ trajectoryWasActive = true;
1266
+ }
1267
+ updateRevealScale(nextPort, elapsedMs);
1268
+ reportFollow(nextPort, activeRef.current);
1269
+ const runwayOffset = runwayOffsetOf(nextPort);
1270
+ if (Math.max(0, nextPort.scrollHeight - animatedH - runwayOffset) <= .25) finishEntrance();
1271
+ } else finishEntrance();
1272
+ } else finishEntrance();
1273
+ primed = true;
1274
+ return;
1275
+ }
1276
+ if (!following && (!interacting || readerReleased && !readerGestureIntent && reportedLag <= 1) && reportedLag <= (readerReleased ? 1 : 25)) {
1277
+ following = true;
1278
+ readerReleased = false;
1279
+ followReaderHolds.delete(nextPort);
1280
+ animatedH = extent;
1281
+ reservePx = 0;
1282
+ velocityPxPerSec = 0;
1283
+ followScrollLedgers.set(nextPort, nextPort.scrollTop);
1284
+ hold(nextPort);
1285
+ } else if (following && interacting && (readerGestureIntent || readerScrolledUp(nextPort))) {
1286
+ following = false;
1287
+ readerGestureIntent = false;
1288
+ readerReleased = true;
1289
+ followReaderHolds.set(nextPort, { atMs: performance.now() });
1290
+ handBackVisual(nextPort);
1291
+ animatedH = nextPort.scrollHeight;
1292
+ reservePx = 0;
1293
+ velocityPxPerSec = 0;
1294
+ drop(nextPort);
1295
+ finishEntrance();
1296
+ }
1297
+ if (!activeRef.current || !following) {
1298
+ followScrollLedgers.set(nextPort, nextPort.scrollTop);
1299
+ reportFollow(nextPort, activeRef.current);
1300
+ return;
1301
+ }
1302
+ hold(nextPort);
1303
+ if (!isLeader(nextPort)) {
1304
+ finishEntrance();
1305
+ return;
1306
+ }
1307
+ const predictGrowth = predictiveRef?.current ?? predictive;
1308
+ const hasStatus = turnStatusOf(nextPort) !== null;
1309
+ const statusJustRemoved = predictGrowth && statusWasPresent === true && !hasStatus;
1310
+ if (statusJustRemoved) reservePx = tuning.runwayPx;
1311
+ statusWasPresent = hasStatus;
1312
+ const reserveEnabled = hasStatus || statusJustRemoved || reservePx > .25 || speedCpsRef.current > 90;
1313
+ const pressureReserveTarget = predictGrowth && reserveEnabled ? computeFollowReserve(speedCpsRef.current, tuning.runwayPx) : 0;
1314
+ const heldReserveTarget = tuning.runwayPx < reservePx ? tuning.runwayPx : Math.max(reservePx, pressureReserveTarget);
1315
+ const effectiveReserveTarget = !predictGrowth ? 0 : statusJustRemoved ? tuning.runwayPx : heldReserveTarget;
1316
+ const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs);
1317
+ reservePx += (effectiveReserveTarget - reservePx) * reserveStep;
1318
+ if (predictGrowth || runwayOffsetOf(nextPort) > .5) ensureRunway(nextPort, shiftSurfacesOf(nextPort), reservePx);
1319
+ const runwayOffset = runwayOffsetOf(nextPort);
1320
+ const contentHeight = nextPort.scrollHeight;
1321
+ const floorNow = Math.max(0, contentHeight - nextPort.clientHeight);
1322
+ const trajectoryActive = predictive && root.querySelector("[data-variant=\"think\"]") === null && runwayOffset > 0;
1323
+ let trajectoryShift;
1324
+ if (trajectoryActive) {
1325
+ const requestedMinLagPx = Math.max(20, runwayOffset - 32);
1326
+ const paintLimit = safeShiftLimit(nextPort, shiftSurfacesOf(nextPort));
1327
+ const maxLagPx = Math.max(0, paintLimit - 1);
1328
+ const minLagPx = Math.min(requestedMinLagPx, maxLagPx);
1329
+ if (trajectoryPositionPx === null || trajectoryFloorPx === null) {
1330
+ const currentShift = currentShiftOf(shiftSurfacesOf(nextPort).at(-1) ?? nextPort);
1331
+ trajectoryPositionPx = floorNow - Math.min(maxLagPx, Math.max(minLagPx, currentShift));
1332
+ trajectoryTargetVelocityPxPerMs = Math.max(0, speedCpsRef.current) * .4 / 1e3;
1333
+ trajectoryVelocityPxPerMs = trajectoryTargetVelocityPxPerMs;
1334
+ trajectoryGrowthAtMs = now;
1335
+ } else if (floorNow > trajectoryFloorPx + .5) {
1336
+ const intervalMs = Math.max(1, now - (trajectoryGrowthAtMs ?? now));
1337
+ if (trajectoryGrowthSamples > 0) {
1338
+ trajectoryAccumulatedGrowthPx += floorNow - trajectoryFloorPx;
1339
+ trajectoryAccumulatedGrowthMs += intervalMs;
1340
+ const measuredVelocity = trajectoryAccumulatedGrowthPx / trajectoryAccumulatedGrowthMs;
1341
+ const targetBlend = 1 - Math.exp(-intervalMs / 240);
1342
+ trajectoryTargetVelocityPxPerMs += (measuredVelocity - trajectoryTargetVelocityPxPerMs) * targetBlend;
1343
+ }
1344
+ trajectoryGrowthSamples += 1;
1345
+ trajectoryGrowthAtMs = now;
1346
+ } else if (floorNow < trajectoryFloorPx - .5) {
1347
+ trajectoryPositionPx = floorNow - minLagPx;
1348
+ trajectoryGrowthAtMs = now;
1349
+ trajectoryAccumulatedGrowthPx = 0;
1350
+ trajectoryAccumulatedGrowthMs = 0;
1351
+ trajectoryGrowthSamples = 0;
1352
+ }
1353
+ trajectoryFloorPx = floorNow;
1354
+ const phaseTarget = revealedCharsRef === void 0 ? floorNow : revealPhase.advance(floorNow, revealedCharsRef.current).targetPx;
1355
+ const trajectoryStep = computeFollowTrajectoryStep(elapsedMs, {
1356
+ positionPx: trajectoryPositionPx,
1357
+ velocityPxPerMs: trajectoryVelocityPxPerMs,
1358
+ targetPx: phaseTarget,
1359
+ targetVelocityPxPerMs: trajectoryTargetVelocityPxPerMs,
1360
+ minLagPx,
1361
+ maxLagPx,
1362
+ paintFloorPx: floorNow
1363
+ });
1364
+ trajectoryPositionPx = trajectoryStep.positionPx;
1365
+ trajectoryVelocityPxPerMs = trajectoryStep.velocityPxPerMs;
1366
+ trajectoryShift = trajectoryStep.shiftPx;
1367
+ trajectoryWasActive = true;
1368
+ const baselineShift = runwayOffset - Math.min(runwayOffset, Math.max(0, reservePx));
1369
+ animatedH = contentHeight - runwayOffset - Math.max(0, trajectoryShift - baselineShift);
1370
+ velocityPxPerSec = trajectoryVelocityPxPerMs * 1e3;
1371
+ } else {
1372
+ if (trajectoryWasActive) {
1373
+ const currentShift = currentShiftOf(shiftSurfacesOf(nextPort).at(-1) ?? nextPort);
1374
+ animatedH = contentHeight - runwayOffset - Math.max(0, currentShift);
1375
+ velocityPxPerSec = trajectoryVelocityPxPerMs * 1e3;
1376
+ trajectoryPositionPx = null;
1377
+ trajectoryWasActive = false;
1378
+ }
1379
+ const lag = Math.max(0, contentHeight - animatedH - runwayOffset);
1380
+ const step = computeFollowStep(dt, {
1381
+ lag,
1382
+ speedEma: speedCpsRef.current,
1383
+ velocityPxPerSec
1384
+ }, tuning);
1385
+ if (lag <= .1) {
1386
+ animatedH = contentHeight - runwayOffset;
1387
+ velocityPxPerSec = 0;
1388
+ } else {
1389
+ const minimumLag = predictGrowth ? 0 : Math.max(0, reservePx);
1390
+ animatedH = Math.min(contentHeight - runwayOffset - minimumLag, animatedH + step.advancePx);
1391
+ velocityPxPerSec = step.velocityPxPerSec;
1392
+ }
1393
+ }
1394
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx, Number.POSITIVE_INFINITY, !predictGrowth, trajectoryShift);
1395
+ if (trajectoryActive) trajectoryPositionPx = floorNow - currentShiftOf(shiftSurfacesOf(nextPort).at(-1) ?? nextPort);
1396
+ updateRevealScale(nextPort, elapsedMs);
1397
+ reportFollow(nextPort, true);
1398
+ if (Math.max(0, nextPort.scrollHeight - animatedH - runwayOffsetOf(nextPort)) <= .25) finishEntrance();
1399
+ };
1400
+ frame(performance.now());
1401
+ return () => {
1402
+ cancelAnimationFrame(rafId);
1403
+ unsubscribeCommit?.();
1404
+ if (interactTimer !== null) clearTimeout(interactTimer);
1405
+ resize?.disconnect();
1406
+ if (port !== null) for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture);
1407
+ const host = rootRef.current?.closest("[data-conversation-scroll]") ?? port;
1408
+ if (host === null) return;
1409
+ holding = null;
1410
+ if (!isLeader(host)) return;
1411
+ const preserveReader = interacting && (readerGestureIntent || readerScrolledUp(host));
1412
+ if (!following || !primed) {
1413
+ if (!following && primed) followReaderHolds.set(host, { atMs: performance.now() });
1414
+ clearVisual(host);
1415
+ followLeaders.delete(host);
1416
+ releaseRevealScale();
1417
+ debugRuntime.reportFollow(host, null);
1418
+ return;
1419
+ }
1420
+ if (preserveReader) {
1421
+ handBackVisual(host);
1422
+ followReaderHolds.set(host, { atMs: performance.now() });
1423
+ clearVisual(host);
1424
+ followLeaders.delete(host);
1425
+ releaseRevealScale();
1426
+ debugRuntime.reportFollow(host, null);
1427
+ return;
1428
+ }
1429
+ const lagBeforeCompletionPaint = Math.max(0, host.scrollHeight - animatedH - runwayOffsetOf(host));
1430
+ if (!activeRef.current && lagBeforeCompletionPaint <= 25) {
1431
+ finishAtNaturalFloor(host, !startedAsEntrance);
1432
+ followLeaders.delete(host);
1433
+ releaseRevealScale();
1434
+ debugRuntime.reportFollow(host, null);
1435
+ return;
1436
+ }
1437
+ const completionShift = currentShiftOf(shiftSurfacesOf(host).at(-1) ?? host);
1438
+ const completionShiftCeiling = completionShift > .25 ? completionShift : Number.POSITIVE_INFINITY;
1439
+ const completionTuning = debugRuntime.activeTuning();
1440
+ const previousCompletionRunway = runwayOffsetOf(host);
1441
+ ensureRunway(host, shiftSurfacesOf(host), Math.max(reservePx, completionTuning.runwayPx));
1442
+ const completionRunway = runwayOffsetOf(host);
1443
+ animatedH = Math.max(0, animatedH - (completionRunway - previousCompletionRunway));
1444
+ settleAtFloor(host);
1445
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, completionRunway, completionShiftCeiling);
1446
+ reportFollow(host, false);
1447
+ const runwayOffset = runwayOffsetOf(host);
1448
+ const remainingLag = Math.max(0, host.scrollHeight - animatedH - runwayOffset);
1449
+ if (remainingLag <= .25 || !activeRef.current && remainingLag <= 25) {
1450
+ finishAtNaturalFloor(host, !startedAsEntrance);
1451
+ followLeaders.delete(host);
1452
+ releaseRevealScale();
1453
+ debugRuntime.reportFollow(host, null);
1454
+ return;
1455
+ }
1456
+ for (const name of GESTURE_EVENTS) host.addEventListener(name, markGesture, { passive: true });
1457
+ const stopSettleListeners = () => {
1458
+ for (const name of GESTURE_EVENTS) host.removeEventListener(name, markGesture);
1459
+ if (interactTimer !== null) {
1460
+ clearTimeout(interactTimer);
1461
+ interactTimer = null;
1462
+ }
1463
+ };
1464
+ let settleLast = performance.now();
1465
+ let settleMarginPx = Math.max(runwayOffsetOf(host), reservePx);
1466
+ let settleMarginRate = settleMarginPx / 160;
1467
+ const settleFrame = (now) => {
1468
+ if (!isLeader(host)) {
1469
+ stopSettleListeners();
1470
+ return;
1471
+ }
1472
+ if (interacting && (readerGestureIntent || readerScrolledUp(host))) {
1473
+ readerGestureIntent = false;
1474
+ handBackVisual(host);
1475
+ clearVisual(host);
1476
+ followLeaders.delete(host);
1477
+ releaseRevealScale();
1478
+ debugRuntime.reportFollow(host, null);
1479
+ stopSettleListeners();
1480
+ return;
1481
+ }
1482
+ const dt = Math.min(32, Math.max(0, now - settleLast));
1483
+ const tuning = debugRuntime.activeTuning();
1484
+ settleLast = now;
1485
+ if (settleMarginPx > 0 && settleMarginRate > 0) {
1486
+ settleMarginPx = Math.max(0, settleMarginPx - settleMarginRate * dt);
1487
+ ensureRunway(host, shiftSurfacesOf(host), settleMarginPx);
1488
+ reservePx = Math.min(reservePx, settleMarginPx);
1489
+ }
1490
+ const runwayOffset = runwayOffsetOf(host);
1491
+ const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset);
1492
+ if (lag <= .25 && settleMarginPx <= .25) {
1493
+ animatedH = host.scrollHeight;
1494
+ reservePx = 0;
1495
+ velocityPxPerSec = 0;
1496
+ followRunways.delete(host);
1497
+ finishAtNaturalFloor(host, !startedAsEntrance);
1498
+ followLeaders.delete(host);
1499
+ releaseRevealScale();
1500
+ debugRuntime.reportFollow(host, null);
1501
+ stopSettleListeners();
1502
+ return;
1503
+ }
1504
+ const step = computeFollowStep(dt, {
1505
+ lag,
1506
+ speedEma: speedCpsRef.current,
1507
+ velocityPxPerSec
1508
+ }, tuning);
1509
+ animatedH = Math.min(host.scrollHeight - runwayOffset, animatedH + step.advancePx);
1510
+ velocityPxPerSec = step.velocityPxPerSec;
1511
+ settleAtFloor(host);
1512
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, Math.max(settleMarginPx, runwayOffset));
1513
+ reportFollow(host, false);
1514
+ requestAnimationFrame(settleFrame);
1515
+ };
1516
+ requestAnimationFrame(settleFrame);
1517
+ };
1518
+ }, [
1519
+ active,
1520
+ rootRef,
1521
+ speedCpsRef,
1522
+ revealScaleRef,
1523
+ predictive,
1524
+ predictiveRef
1525
+ ]);
1526
+ (0, react.useLayoutEffect)(() => {
1527
+ const host = rootRef.current?.closest("[data-conversation-scroll]") ?? null;
1528
+ if (host !== null) followFlowFillUsers.set(host, (followFlowFillUsers.get(host) ?? 0) + 1);
1529
+ return () => {
1530
+ if (host === null) return;
1531
+ const remaining = Math.max(0, (followFlowFillUsers.get(host) ?? 1) - 1);
1532
+ if (remaining > 0) {
1533
+ followFlowFillUsers.set(host, remaining);
1534
+ return;
1535
+ }
1536
+ followFlowFillUsers.delete(host);
1537
+ requestAnimationFrame(() => {
1538
+ requestAnimationFrame(() => {
1539
+ if (!followLeaders.has(host) && !followFlowFillUsers.has(host)) restoreFlowFill(host);
1540
+ });
1541
+ });
1542
+ };
1543
+ }, [rootRef]);
1117
1544
  }
1545
+ //#endregion
1546
+ //#region src/client/useSmoothStreamContent.ts
1118
1547
  /**
1119
- * Paint a bounded visual lag and return the effective logical extent.
1548
+ * Stream-smoothing reveal hook.
1120
1549
  *
1121
- * This is the final geometry invariant, not merely an animation preference:
1122
- * any lag beyond the real gap to status/composer chrome is caught up in the
1123
- * same frame. Carrying that excess in `scrollTop` would move the transcript
1124
- * toward fixed chrome and also make the host expose jump-to-bottom.
1550
+ * Buffers the model's chunked text and reveals it at a cadence that tracks
1551
+ * the observed arrival rate, so a long reply never dumps whole paragraphs at
1552
+ * once and a fast stream never stutters. Port of lobe-ui's smoother: EMA
1553
+ * arrival cps + chunk size, backlog pressure, commit-interval widening with
1554
+ * tail length, and a flush-speed settle drain once the input idles. The
1555
+ * reveal decision is the pure {@link computeRevealStep} for unit tests.
1556
+ *
1557
+ * `shouldHoldBack` is the performance guard's veto: while it returns true the
1558
+ * loop keeps measuring but skips the DOM commit, so an offscreen reply never
1559
+ * competes with visible frames when the frame rate is degraded.
1125
1560
  */
1126
- function applyVisual(port, animatedH, reservePx, velocityPxPerSec = 0, runwayPx = 48) {
1127
- const surfaces = shiftSurfacesOf(port);
1128
- ensureRunway(port, surfaces, runwayPx);
1129
- const contentHeight = Math.max(0, port.scrollHeight);
1130
- const runwayOffset = runwayOffsetOf(port);
1131
- const targetHeight = Math.max(0, contentHeight - runwayOffset);
1132
- const floor = Math.max(0, contentHeight - port.clientHeight);
1133
- const extent = Math.min(targetHeight, Math.max(0, animatedH));
1134
- if (port.style.overflowAnchor !== "none") port.style.overflowAnchor = "none";
1135
- if (port.style.scrollBehavior !== "auto") port.style.scrollBehavior = "auto";
1136
- if (floor <= 0) {
1137
- setFollowScrollTop(port, 0);
1138
- followMotionStates.set(port, {
1139
- capacityPx: Number.POSITIVE_INFINITY,
1140
- constrained: false,
1141
- extent: targetHeight,
1142
- lagPx: 0,
1143
- reservePx: 0,
1144
- velocityPxPerSec: 0
1145
- });
1146
- for (const surface of surfaces) setShift(surface, 0);
1147
- const status = turnStatusOf(port);
1148
- if (status !== null) setShift(status, 0);
1149
- return targetHeight;
1561
+ const PRESET_CONFIG = {
1562
+ balanced: {
1563
+ activeInputWindowMs: 220,
1564
+ defaultCps: 80,
1565
+ emaAlpha: .35,
1566
+ flushCps: 180,
1567
+ largeAppendChars: 120,
1568
+ maxActiveCps: 360,
1569
+ maxCps: 240,
1570
+ maxFlushCps: 480,
1571
+ minCps: 24,
1572
+ settleAfterMs: 280,
1573
+ settleDrainMaxMs: 420,
1574
+ settleDrainMinMs: 120,
1575
+ targetBufferMs: 40
1576
+ },
1577
+ realtime: {
1578
+ activeInputWindowMs: 140,
1579
+ defaultCps: 120,
1580
+ emaAlpha: .45,
1581
+ flushCps: 240,
1582
+ largeAppendChars: 180,
1583
+ maxActiveCps: 480,
1584
+ maxCps: 320,
1585
+ maxFlushCps: 640,
1586
+ minCps: 32,
1587
+ settleAfterMs: 200,
1588
+ settleDrainMaxMs: 280,
1589
+ settleDrainMinMs: 100,
1590
+ targetBufferMs: 24
1591
+ },
1592
+ silky: {
1593
+ activeInputWindowMs: 280,
1594
+ defaultCps: 64,
1595
+ emaAlpha: .28,
1596
+ flushCps: 140,
1597
+ largeAppendChars: 100,
1598
+ maxActiveCps: 280,
1599
+ maxCps: 180,
1600
+ maxFlushCps: 400,
1601
+ minCps: 20,
1602
+ settleAfterMs: 360,
1603
+ settleDrainMaxMs: 520,
1604
+ settleDrainMinMs: 160,
1605
+ targetBufferMs: 56
1150
1606
  }
1151
- setFollowScrollTop(port, floor);
1152
- const limit = floor > 0 ? safeShiftLimit(port, surfaces) : 0;
1153
- const visibleReserve = Math.min(runwayOffset, Math.max(0, reservePx));
1154
- const baselineShift = runwayOffset - visibleReserve;
1155
- const requestedLag = Math.max(0, targetHeight - extent);
1156
- const shift = Math.min(baselineShift + requestedLag, Math.max(0, limit));
1157
- const effectiveLag = Math.max(0, shift - baselineShift);
1158
- const capacityPx = Math.max(0, limit - baselineShift);
1159
- const effectiveExtent = targetHeight - effectiveLag;
1160
- followMotionStates.set(port, {
1161
- capacityPx,
1162
- constrained: requestedLag > effectiveLag + FOLLOW_SETTLE_EPSILON_PX,
1163
- extent: effectiveExtent,
1164
- lagPx: effectiveLag,
1165
- reservePx: visibleReserve,
1166
- velocityPxPerSec
1167
- });
1168
- for (const surface of surfaces) setShift(surface, shift);
1169
- const status = turnStatusOf(port);
1170
- if (status !== null) setShift(status, 0);
1171
- return effectiveExtent;
1172
- }
1173
- function clearMotion(port) {
1174
- port.removeAttribute(FOLLOW_OWNED_ATTR);
1175
- port.style.overflowAnchor = "";
1176
- port.style.scrollBehavior = "";
1177
- for (const surface of shiftSurfacesOf(port)) setShift(surface, 0);
1178
- const status = turnStatusOf(port);
1179
- if (status !== null) setShift(status, 0);
1607
+ };
1608
+ const QUEUE_ACCEL_EXPONENT = 1.25;
1609
+ const CATCHUP_SECONDS = .15;
1610
+ const clamp = (value, min, max) => {
1611
+ return Math.min(max, Math.max(min, value));
1612
+ };
1613
+ /** Float-debt queue integration from `ultimate_stream_physics_scroller.html`. */
1614
+ function computeAdaptiveQueueStep(backlog, dtMs, debt, revealScale = 1, tuning = DEFAULT_STREAM_DEBUG_TUNING) {
1615
+ if (backlog <= 0 || dtMs <= 0) return {
1616
+ revealChars: 0,
1617
+ debt: 0,
1618
+ speedCps: 0
1619
+ };
1620
+ const speedCps = Math.min(tuning.maxRevealCps, 90 + Math.pow(backlog, QUEUE_ACCEL_EXPONENT) * tuning.queuePressure);
1621
+ const effectiveScale = clamp(revealScale * tuning.revealScale, .05, 2);
1622
+ const accumulated = Math.max(0, debt) + speedCps * effectiveScale * (dtMs / 1e3);
1623
+ const revealChars = Math.min(backlog, Math.floor(accumulated));
1624
+ return {
1625
+ revealChars,
1626
+ debt: revealChars >= backlog ? 0 : accumulated - revealChars,
1627
+ speedCps
1628
+ };
1180
1629
  }
1181
- function clearVisual(port) {
1182
- clearMotion(port);
1183
- restoreRunway(port);
1184
- followMotionStates.delete(port);
1185
- invalidatePaintLimit(port);
1630
+ /** Counts user-perceived characters (code points), not UTF-16 units. */
1631
+ const countChars = (text) => {
1632
+ let count = 0;
1633
+ for (const char of text) count += 1;
1634
+ return count;
1635
+ };
1636
+ /** Pure settle-drain decision shared by the frame loop and its tests. */
1637
+ function computeSettleDrain(config, input) {
1638
+ if (input.inputActive || !input.settling) return 0;
1639
+ const overflowCps = Math.max(0, input.backlog - 300) * 1e3 / 2;
1640
+ const drainTargetMs = clamp(input.backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs);
1641
+ const settleCps = input.backlog * 1e3 / drainTargetMs;
1642
+ return clamp(Math.max(settleCps, overflowCps), config.flushCps, config.maxFlushCps);
1186
1643
  }
1187
- /** Keep an already-promoted surface at zero until one stable final paint lands. */
1188
- function holdCompositorAtRest(element) {
1189
- element.style.transform = "translate3d(0, 0px, 0)";
1190
- element.style.willChange = "transform";
1191
- element.style.clipPath = "";
1644
+ /**
1645
+ * Fixed velocity that closes a producer-complete queue within its deadline.
1646
+ * This completion-only target may exceed the live-stream flush ceiling.
1647
+ */
1648
+ function computeCompletionDrain(config, backlog, initialCps = 0) {
1649
+ if (backlog <= 0) return 0;
1650
+ const deadlineSeconds = clamp(backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs) / 1e3;
1651
+ const rampAreaSeconds = SETTLE_RAMP_TAU_S * (1 - Math.exp(-deadlineSeconds / SETTLE_RAMP_TAU_S));
1652
+ const deadlineCps = (backlog - Math.max(0, initialCps) * rampAreaSeconds) / Math.max(.001, deadlineSeconds - rampAreaSeconds);
1653
+ return Math.max(deadlineCps, computeSettleDrain(config, {
1654
+ backlog,
1655
+ inputActive: false,
1656
+ settling: true
1657
+ }));
1192
1658
  }
1193
- /** Remove equal offsets, land on the floor, then retire the compositor quietly. */
1194
- function finishAtNaturalFloor(port) {
1195
- const surfaces = shiftSurfacesOf(port);
1196
- const status = turnStatusOf(port);
1197
- const promoted = [...surfaces, ...status === null ? [] : [status]].filter((element) => element.style.transform !== "" || element.style.willChange === "transform");
1198
- const promotedSet = new Set(promoted);
1199
- restoreRunway(port);
1200
- settleAtFloor(port);
1201
- port.removeAttribute(FOLLOW_OWNED_ATTR);
1202
- port.style.overflowAnchor = "";
1203
- port.style.scrollBehavior = "";
1204
- for (const surface of surfaces) if (promotedSet.has(surface)) holdCompositorAtRest(surface);
1205
- else setShift(surface, 0);
1206
- if (status !== null) {
1207
- if (promotedSet.has(status)) holdCompositorAtRest(status);
1208
- else setShift(status, 0);
1659
+ /**
1660
+ * Drain rate multiplier once the input ends: leftover backlog reveals at
1661
+ * this multiple of the steady rate, so the end never drags.
1662
+ */
1663
+ const SETTLE_DRAIN_MULTIPLIER = 1.8;
1664
+ /** Time constant for ramping the completion-drain velocity up from streaming pace. */
1665
+ const SETTLE_RAMP_TAU_S = .09;
1666
+ /** Pure per-frame reveal decision shared by the loop and its tests. */
1667
+ function computeRevealStep(config, input, dtSeconds) {
1668
+ const trackedCps = Math.max(input.emaCps, input.arrivalCpsEma);
1669
+ const baseCps = clamp(trackedCps, config.minCps, config.maxFlushCps);
1670
+ const targetLagChars = input.inputActive ? Math.max(2, Math.round(baseCps * config.targetBufferMs / 1e3)) : 0;
1671
+ let currentCps;
1672
+ if (input.steadyCps !== void 0) currentCps = input.inputActive || input.settling ? clamp(input.steadyCps * (input.inputActive ? 1 : SETTLE_DRAIN_MULTIPLIER), config.minCps, config.maxFlushCps) : 0;
1673
+ else if (input.inputActive) {
1674
+ const overflow = Math.max(0, input.backlog - 32);
1675
+ const catchup = overflow > 0 ? overflow / CATCHUP_SECONDS : 0;
1676
+ currentCps = clamp(baseCps * 1.08 + catchup, config.minCps, config.maxFlushCps);
1677
+ } else if (input.settling) currentCps = computeSettleDrain(config, input);
1678
+ else {
1679
+ const idleFlushCps = Math.max(config.flushCps, baseCps * 1.8, input.arrivalCpsEma * .8);
1680
+ currentCps = clamp(idleFlushCps, config.flushCps, config.maxFlushCps);
1209
1681
  }
1210
- followMotionStates.delete(port);
1211
- if (promoted.length === 0) return;
1212
- requestAnimationFrame(() => {
1213
- requestAnimationFrame(() => {
1214
- if (port.hasAttribute(FOLLOW_OWNED_ATTR)) return;
1215
- for (const element of promoted) if (Math.abs(currentShiftOf(element)) <= .01) setShift(element, 0);
1216
- });
1217
- });
1218
- }
1219
- function settleAtFloor(port) {
1220
- setFollowScrollTop(port, Math.max(0, port.scrollHeight - port.clientHeight));
1682
+ const minRevealChars = input.inputActive ? 1 : 2;
1683
+ return {
1684
+ revealChars: Math.max(minRevealChars, Math.round(currentCps * dtSeconds)),
1685
+ targetLagChars
1686
+ };
1221
1687
  }
1222
- /** Only the newest active follower may write one port's shared visual state. */
1223
- const followLeaders = /* @__PURE__ */ new WeakMap();
1224
- let followGeneration = 0;
1225
1688
  /**
1226
- * Own the conversation scrollport's bottom-follow while `active` is true.
1689
+ * Smooth a chunked content stream into a reveal-paced display string.
1227
1690
  *
1228
- * @param rootRef - An element inside the conversation scrollport.
1229
- * @param active - True while the reply is still revealing.
1230
- * @param speedCpsRef - Live reveal-rate EMA from the smoother.
1231
- * @param revealScaleRef - Optional backpressure control for text reveal.
1232
- * @param predictive - Whether to reserve paint room ahead of growth.
1233
- * @param entrance - Whether the first committed row height should glide in.
1234
- * @param onEntranceSettled - Releases a one-shot entrance owner after catch-up.
1235
- * @param predictiveRef - Optional live visibility gate for predictive runway.
1236
- * @param entranceExtentRef - Optional measured growth delta for a generic row.
1691
+ * @param content - The full accumulated input so far.
1692
+ * @param options - Preset, guard, and steady-rate wiring.
1693
+ * @returns The displayed content, revealed at the smoothed cadence.
1237
1694
  */
1238
- function useConversationFollow(rootRef, active, speedCpsRef, revealScaleRef, predictive = true, entrance = false, onEntranceSettled, predictiveRef, entranceExtentRef) {
1239
- const activeRef = (0, react.useRef)(active);
1240
- const entranceRef = (0, react.useRef)(entrance);
1241
- const onEntranceSettledRef = (0, react.useRef)(onEntranceSettled);
1242
- entranceRef.current = entrance;
1243
- onEntranceSettledRef.current = onEntranceSettled;
1695
+ function useSmoothStreamContent(content, { enabled = true, inputComplete = false, preset = "balanced", shouldHoldBack, steadyCps, defaultCps, speedCpsRef, revealedCharsRef, revealScaleRef, onRevealCommit } = {}) {
1696
+ const config = PRESET_CONFIG[preset];
1697
+ const seedCps = defaultCps ?? config.defaultCps;
1698
+ const initialContent = enabled ? "" : content;
1699
+ const [displayedContent, setDisplayedContent] = (0, react.useState)(initialContent);
1700
+ const displayedContentRef = (0, react.useRef)(initialContent);
1701
+ const displayedCountRef = (0, react.useRef)(countChars(initialContent));
1702
+ const targetContentRef = (0, react.useRef)(initialContent);
1703
+ const targetCharsRef = (0, react.useRef)([...initialContent]);
1704
+ const targetCountRef = (0, react.useRef)(countChars(initialContent));
1705
+ const emaCpsRef = (0, react.useRef)(seedCps);
1706
+ const lastInputTsRef = (0, react.useRef)(0);
1707
+ const lastInputCountRef = (0, react.useRef)(countChars(initialContent));
1708
+ const chunkSizeEmaRef = (0, react.useRef)(1);
1709
+ const arrivalCpsEmaRef = (0, react.useRef)(seedCps);
1710
+ const rafRef = (0, react.useRef)(null);
1711
+ const lastFrameTsRef = (0, react.useRef)(null);
1712
+ const queueDebtRef = (0, react.useRef)(0);
1713
+ const settleCpsRef = (0, react.useRef)(null);
1714
+ const lastDrainCpsRef = (0, react.useRef)(0);
1715
+ const holdBackRef = (0, react.useRef)(shouldHoldBack);
1716
+ const speedOutRef = (0, react.useRef)(speedCpsRef);
1717
+ speedOutRef.current = speedCpsRef;
1718
+ const revealedCharsOutRef = (0, react.useRef)(revealedCharsRef);
1719
+ revealedCharsOutRef.current = revealedCharsRef;
1720
+ const revealScaleOutRef = (0, react.useRef)(revealScaleRef);
1721
+ revealScaleOutRef.current = revealScaleRef;
1722
+ const onRevealCommitOutRef = (0, react.useRef)(onRevealCommit);
1723
+ onRevealCommitOutRef.current = onRevealCommit;
1724
+ const inputCompleteRef = (0, react.useRef)(inputComplete);
1725
+ inputCompleteRef.current = inputComplete;
1726
+ const streamIdRef = (0, react.useRef)(`stream-${Math.random().toString(36).slice(2)}`);
1244
1727
  (0, react.useEffect)(() => {
1245
- activeRef.current = active;
1246
- }, [active]);
1247
- (0, react.useLayoutEffect)(() => {
1248
- if (!active) return;
1249
- const owner = {};
1250
- const generation = ++followGeneration;
1251
- let rafId = 0;
1252
- let last = performance.now();
1253
- let following = true;
1254
- let primed = false;
1255
- let animatedH = 0;
1256
- let reservePx = 0;
1257
- let velocityPxPerSec = 0;
1258
- let interacting = false;
1259
- let readerGestureIntent = false;
1260
- let readerReleased = false;
1261
- let touchStartY = null;
1262
- let interactTimer = null;
1263
- let port = null;
1264
- let resize = null;
1265
- let holding = null;
1266
- let entrancePending = entranceRef.current;
1267
- const finishEntrance = () => {
1268
- if (!entrancePending) return;
1269
- entrancePending = false;
1270
- onEntranceSettledRef.current?.();
1271
- };
1272
- const updateRevealScale = (next, elapsedMs, urgent = false) => {
1273
- if (revealScaleRef === void 0) return;
1274
- const tuning = debugRuntime.activeTuning();
1275
- const state = followMotionStates.get(next);
1276
- const target = state === void 0 ? 1 : computeFollowRevealScale(state.lagPx, state.capacityPx, state.constrained, tuning);
1277
- const current = Math.min(1, Math.max(tuning.backpressureMinScale, revealScaleRef.current));
1278
- if (target < current || urgent) {
1279
- revealScaleRef.current = Math.min(current, target);
1280
- return;
1281
- }
1282
- const releaseStep = 1 - Math.exp(-Math.max(0, elapsedMs) / 240);
1283
- revealScaleRef.current = current + (target - current) * releaseStep;
1284
- };
1285
- const releaseRevealScale = () => {
1286
- if (revealScaleRef !== void 0) revealScaleRef.current = 1;
1287
- };
1288
- const reportFollow = (next, isActive) => {
1289
- const state = followMotionStates.get(next);
1290
- debugRuntime.reportFollow(next, {
1291
- lagPx: state?.lagPx ?? Math.max(0, next.scrollHeight - next.clientHeight - next.scrollTop),
1292
- velocityPxPerSec: state?.velocityPxPerSec ?? 0,
1293
- reservePx: state?.reservePx ?? 0,
1294
- capacityPx: state?.capacityPx ?? 0,
1295
- revealScale: revealScaleRef?.current ?? 1,
1296
- following,
1297
- constrained: state?.constrained ?? false,
1298
- scrollTop: next.scrollTop,
1299
- scrollHeight: next.scrollHeight,
1300
- clientHeight: next.clientHeight,
1301
- active: isActive
1302
- });
1303
- };
1304
- const isLeader = (next) => followLeaders.get(next)?.owner === owner;
1305
- const hold = (next) => {
1306
- followActivityAt.set(next, performance.now());
1307
- if (holding === next && isLeader(next)) return;
1308
- holding = next;
1309
- const leader = followLeaders.get(next);
1310
- if (leader === void 0 || generation > leader.generation) followLeaders.set(next, {
1311
- generation,
1312
- owner
1313
- });
1314
- };
1315
- const drop = (next) => {
1316
- if (holding === next) holding = null;
1317
- if (isLeader(next)) {
1318
- clearMotion(next);
1319
- followMotionStates.delete(next);
1320
- releaseRevealScale();
1321
- debugRuntime.reportFollow(next, null);
1728
+ holdBackRef.current = shouldHoldBack;
1729
+ }, [shouldHoldBack]);
1730
+ const stopFrameLoop = (0, react.useCallback)(() => {
1731
+ if (rafRef.current !== null) {
1732
+ cancelAnimationFrame(rafRef.current);
1733
+ rafRef.current = null;
1734
+ }
1735
+ lastFrameTsRef.current = null;
1736
+ }, []);
1737
+ const startFrameLoopRef = (0, react.useRef)(() => {});
1738
+ const syncImmediate = (0, react.useCallback)((nextContent) => {
1739
+ stopFrameLoop();
1740
+ const chars = [...nextContent];
1741
+ const now = performance.now();
1742
+ targetContentRef.current = nextContent;
1743
+ targetCharsRef.current = chars;
1744
+ targetCountRef.current = chars.length;
1745
+ displayedContentRef.current = nextContent;
1746
+ displayedCountRef.current = chars.length;
1747
+ queueDebtRef.current = 0;
1748
+ settleCpsRef.current = null;
1749
+ lastDrainCpsRef.current = 0;
1750
+ const speedOut = speedOutRef.current;
1751
+ if (speedOut !== void 0) speedOut.current = seedCps;
1752
+ setDisplayedContent(nextContent);
1753
+ emaCpsRef.current = seedCps;
1754
+ chunkSizeEmaRef.current = 1;
1755
+ arrivalCpsEmaRef.current = seedCps;
1756
+ lastInputTsRef.current = now;
1757
+ lastInputCountRef.current = chars.length;
1758
+ }, [seedCps, stopFrameLoop]);
1759
+ const startFrameLoop = (0, react.useCallback)(() => {
1760
+ if (rafRef.current !== null) return;
1761
+ const tick = (now) => {
1762
+ const targetCount = targetCountRef.current;
1763
+ const displayedCount = displayedCountRef.current;
1764
+ const backlog = targetCount - displayedCount;
1765
+ if (backlog <= 0) {
1766
+ queueDebtRef.current = 0;
1767
+ settleCpsRef.current = null;
1768
+ const speedOut = speedOutRef.current;
1769
+ if (speedOut !== void 0) speedOut.current = seedCps;
1770
+ debugRuntime.reportStream(streamIdRef.current, null);
1771
+ stopFrameLoop();
1772
+ return;
1322
1773
  }
1323
- };
1324
- const handBackVisual = (next) => {
1325
- const shift = currentShiftOf(shiftSurfacesOf(next).at(-1) ?? next);
1326
- const visualTop = Math.max(0, next.scrollTop - Math.max(0, shift));
1327
- clearMotion(next);
1328
- restoreRunway(next);
1329
- const floor = Math.max(0, next.scrollHeight - next.clientHeight);
1330
- next.scrollTop = Math.min(visualTop, Math.max(0, floor - 26));
1331
- followScrollLedgers.set(next, next.scrollTop);
1332
- };
1333
- const markGesture = (event) => {
1334
- interacting = true;
1335
- if (event.type === "wheel") {
1336
- const deltaY = event.deltaY;
1337
- if (Number.isFinite(deltaY) && deltaY < 0) readerGestureIntent = true;
1338
- } else if (event.type === "touchstart") touchStartY = event.touches[0]?.clientY ?? null;
1339
- else if (event.type === "touchmove") {
1340
- const touch = event.touches[0];
1341
- if (touch !== void 0) {
1342
- if (touchStartY === null) touchStartY = touch.clientY;
1343
- if (touch.clientY - touchStartY > 1) readerGestureIntent = true;
1344
- }
1345
- } else if (event.type === "touchend" || event.type === "touchcancel") touchStartY = null;
1346
- if (interactTimer !== null) clearTimeout(interactTimer);
1347
- interactTimer = setTimeout(() => {
1348
- interacting = false;
1349
- readerGestureIntent = false;
1350
- interactTimer = null;
1351
- }, 800);
1352
- };
1353
- const restoreBeforePaint = () => {
1354
- if (!following || port === null || !isLeader(port)) return;
1355
- invalidatePaintLimit(port);
1356
- const tuning = debugRuntime.activeTuning();
1357
- animatedH = applyVisual(port, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx);
1358
- updateRevealScale(port, 0, true);
1359
- reportFollow(port, activeRef.current);
1360
- };
1361
- const bindPort = (next) => {
1362
- if (port === next) return;
1363
- if (port !== null) {
1364
- for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture);
1365
- resize?.disconnect();
1774
+ if (lastFrameTsRef.current === null) {
1775
+ lastFrameTsRef.current = now;
1776
+ rafRef.current = requestAnimationFrame(tick);
1777
+ return;
1366
1778
  }
1367
- port = next;
1368
- invalidatePaintLimit(port);
1369
- for (const name of GESTURE_EVENTS) port.addEventListener(name, markGesture, { passive: true });
1370
- if (typeof ResizeObserver !== "undefined") {
1371
- resize = new ResizeObserver(restoreBeforePaint);
1372
- resize.observe(port);
1373
- const proxy = resizeProxyOf(port);
1374
- if (proxy !== null) resize.observe(proxy);
1779
+ const frameIntervalMs = Math.max(0, now - lastFrameTsRef.current);
1780
+ const dtSeconds = Math.max(.001, Math.min(frameIntervalMs / 1e3, .12));
1781
+ lastFrameTsRef.current = now;
1782
+ const idleMs = now - lastInputTsRef.current;
1783
+ const producerComplete = inputCompleteRef.current;
1784
+ const debugTuning = debugRuntime.activeTuning();
1785
+ const inputActive = !producerComplete && idleMs <= config.activeInputWindowMs;
1786
+ const settling = producerComplete || !inputActive && idleMs >= config.settleAfterMs;
1787
+ if (!producerComplete) settleCpsRef.current = null;
1788
+ let revealChars;
1789
+ let revealSpeedCps;
1790
+ let nextQueueDebt = 0;
1791
+ if (producerComplete) {
1792
+ const previousCps = lastDrainCpsRef.current > 0 ? lastDrainCpsRef.current : Math.max(config.minCps, emaCpsRef.current);
1793
+ const drainTargetCps = settleCpsRef.current ?? computeCompletionDrain(config, backlog, previousCps);
1794
+ settleCpsRef.current = drainTargetCps;
1795
+ const rampedCps = previousCps + (drainTargetCps - previousCps) * (1 - Math.exp(-dtSeconds / SETTLE_RAMP_TAU_S));
1796
+ const settleCps = Math.min(drainTargetCps, Math.max(previousCps, rampedCps));
1797
+ lastDrainCpsRef.current = Math.min(drainTargetCps, rampedCps);
1798
+ const accumulated = Math.max(0, queueDebtRef.current) + settleCps * dtSeconds;
1799
+ revealChars = Math.min(backlog, Math.floor(accumulated));
1800
+ revealSpeedCps = settleCps;
1801
+ nextQueueDebt = revealChars >= backlog ? 0 : accumulated - revealChars;
1802
+ if (revealChars >= backlog) lastDrainCpsRef.current = 0;
1803
+ } else if (steadyCps !== void 0) {
1804
+ const step = computeRevealStep(config, {
1805
+ backlog,
1806
+ chunkSizeEma: chunkSizeEmaRef.current,
1807
+ arrivalCpsEma: arrivalCpsEmaRef.current,
1808
+ emaCps: emaCpsRef.current,
1809
+ inputActive,
1810
+ settling,
1811
+ steadyCps
1812
+ }, dtSeconds);
1813
+ revealChars = Math.min(Math.round(step.revealChars * debugTuning.revealScale), backlog);
1814
+ revealSpeedCps = frameIntervalMs > 0 ? revealChars * 1e3 / frameIntervalMs : 0;
1815
+ } else {
1816
+ const step = computeAdaptiveQueueStep(backlog, frameIntervalMs, queueDebtRef.current, revealScaleOutRef.current?.current ?? 1, debugTuning);
1817
+ revealChars = step.revealChars;
1818
+ revealSpeedCps = step.speedCps;
1819
+ nextQueueDebt = step.debt;
1375
1820
  }
1376
- };
1377
- const frame = (now) => {
1378
- rafId = requestAnimationFrame(frame);
1379
- const elapsedMs = Math.max(.001, now - last);
1380
- const dt = Math.min(32, elapsedMs);
1381
- const tuning = debugRuntime.activeTuning();
1382
- last = now;
1383
- const root = rootRef.current;
1384
- if (root === null) return;
1385
- const nextPort = root.closest("[data-conversation-scroll]");
1386
- if (nextPort === null) return;
1387
- bindPort(nextPort);
1388
- if (nextPort.clientHeight <= 0) return;
1389
- const reportedLag = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight) - nextPort.scrollTop;
1390
- const extent = Math.min(nextPort.scrollHeight, Math.max(0, nextPort.scrollHeight - reportedLag));
1391
- if (!primed) {
1392
- const inherited = nextPort.hasAttribute(FOLLOW_OWNED_ATTR) ? followMotionStates.get(nextPort) : void 0;
1393
- if (inherited === void 0) {
1394
- const entranceExtent = entrancePending ? entranceExtentRef?.current ?? entranceExtentOf(root) : 0;
1395
- animatedH = Math.max(0, nextPort.scrollHeight - entranceExtent);
1396
- reservePx = 0;
1397
- velocityPxPerSec = 0;
1398
- following = Math.max(0, reportedLag - entranceExtent) <= 25;
1399
- } else {
1400
- animatedH = Math.min(nextPort.scrollHeight, inherited.extent);
1401
- reservePx = inherited.reservePx;
1402
- velocityPxPerSec = inherited.velocityPxPerSec;
1403
- following = true;
1404
- }
1405
- if (following) {
1406
- hold(nextPort);
1407
- if (isLeader(nextPort)) {
1408
- animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx);
1409
- updateRevealScale(nextPort, elapsedMs);
1410
- reportFollow(nextPort, activeRef.current);
1411
- const runwayOffset = runwayOffsetOf(nextPort);
1412
- if (Math.max(0, nextPort.scrollHeight - animatedH - runwayOffset) <= .25) finishEntrance();
1413
- } else finishEntrance();
1414
- } else finishEntrance();
1415
- primed = true;
1821
+ debugRuntime.reportStream(streamIdRef.current, {
1822
+ backlog,
1823
+ speedCps: revealSpeedCps,
1824
+ targetChars: targetCount,
1825
+ displayedChars: displayedCount,
1826
+ active: !producerComplete
1827
+ });
1828
+ if (holdBackRef.current?.() === true) {
1829
+ rafRef.current = requestAnimationFrame(tick);
1416
1830
  return;
1417
1831
  }
1418
- if (!following && (!interacting || readerReleased && !readerGestureIntent && reportedLag <= 1) && reportedLag <= (readerReleased ? 1 : 25)) {
1419
- following = true;
1420
- readerReleased = false;
1421
- animatedH = extent;
1422
- reservePx = 0;
1423
- velocityPxPerSec = 0;
1424
- followScrollLedgers.set(nextPort, nextPort.scrollTop);
1425
- hold(nextPort);
1426
- } else if (following && interacting && (readerGestureIntent || readerScrolledUp(nextPort))) {
1427
- following = false;
1428
- readerGestureIntent = false;
1429
- readerReleased = true;
1430
- handBackVisual(nextPort);
1431
- animatedH = nextPort.scrollHeight;
1432
- reservePx = 0;
1433
- velocityPxPerSec = 0;
1434
- drop(nextPort);
1435
- finishEntrance();
1832
+ queueDebtRef.current = nextQueueDebt;
1833
+ const speedOut = speedOutRef.current;
1834
+ if (speedOut !== void 0) speedOut.current = revealSpeedCps;
1835
+ if (revealChars <= 0) {
1836
+ rafRef.current = requestAnimationFrame(tick);
1837
+ return;
1436
1838
  }
1437
- if (!activeRef.current || !following) {
1438
- followScrollLedgers.set(nextPort, nextPort.scrollTop);
1439
- reportFollow(nextPort, activeRef.current);
1839
+ const nextCount = displayedCount + revealChars;
1840
+ const segment = targetCharsRef.current.slice(displayedCount, nextCount).join("");
1841
+ if (segment) {
1842
+ const nextDisplayed = displayedContentRef.current + segment;
1843
+ displayedContentRef.current = nextDisplayed;
1844
+ displayedCountRef.current = nextCount;
1845
+ setDisplayedContent(nextDisplayed);
1846
+ } else {
1847
+ displayedContentRef.current = targetContentRef.current;
1848
+ displayedCountRef.current = targetCount;
1849
+ setDisplayedContent(targetContentRef.current);
1850
+ }
1851
+ rafRef.current = requestAnimationFrame(tick);
1852
+ };
1853
+ rafRef.current = requestAnimationFrame(tick);
1854
+ }, [
1855
+ config,
1856
+ seedCps,
1857
+ stopFrameLoop,
1858
+ steadyCps
1859
+ ]);
1860
+ (0, react.useLayoutEffect)(() => {
1861
+ const revealedCharsOut = revealedCharsOutRef.current;
1862
+ if (revealedCharsOut !== void 0) revealedCharsOut.current = displayedCountRef.current;
1863
+ if (displayedContent === "") return;
1864
+ onRevealCommitOutRef.current?.();
1865
+ }, [displayedContent]);
1866
+ (0, react.useEffect)(() => {
1867
+ startFrameLoopRef.current = startFrameLoop;
1868
+ }, [startFrameLoop]);
1869
+ (0, react.useEffect)(() => {
1870
+ if (!enabled) {
1871
+ syncImmediate(content);
1872
+ return;
1873
+ }
1874
+ const prevTargetContent = targetContentRef.current;
1875
+ if (content === prevTargetContent) return;
1876
+ const now = performance.now();
1877
+ if (!content.startsWith(prevTargetContent)) {
1878
+ syncImmediate(content);
1879
+ return;
1880
+ }
1881
+ const appendedChars = [...content.slice(prevTargetContent.length)];
1882
+ const appendedCount = appendedChars.length;
1883
+ targetContentRef.current = content;
1884
+ targetCharsRef.current.push(...appendedChars);
1885
+ targetCountRef.current += appendedCount;
1886
+ settleCpsRef.current = null;
1887
+ const hadSample = lastInputTsRef.current > 0;
1888
+ const deltaChars = targetCountRef.current - lastInputCountRef.current;
1889
+ const deltaMs = Math.max(1, now - lastInputTsRef.current);
1890
+ if (hadSample && deltaChars > 0) {
1891
+ const instantCps = deltaChars * 1e3 / deltaMs;
1892
+ const normalizedInstantCps = clamp(instantCps, config.minCps, config.maxFlushCps * 3);
1893
+ const chunkEmaAlpha = .45;
1894
+ chunkSizeEmaRef.current = chunkSizeEmaRef.current * .55 + appendedCount * chunkEmaAlpha;
1895
+ arrivalCpsEmaRef.current = arrivalCpsEmaRef.current * .55 + normalizedInstantCps * chunkEmaAlpha;
1896
+ emaCpsRef.current = emaCpsRef.current * (1 - config.emaAlpha) + normalizedInstantCps * config.emaAlpha;
1897
+ }
1898
+ lastInputTsRef.current = now;
1899
+ lastInputCountRef.current = targetCountRef.current;
1900
+ startFrameLoop();
1901
+ }, [
1902
+ content,
1903
+ enabled,
1904
+ config,
1905
+ startFrameLoop,
1906
+ syncImmediate
1907
+ ]);
1908
+ (0, react.useEffect)(() => {
1909
+ return () => {
1910
+ stopFrameLoop();
1911
+ debugRuntime.reportStream(streamIdRef.current, null);
1912
+ };
1913
+ }, [stopFrameLoop]);
1914
+ return displayedContent;
1915
+ }
1916
+ //#endregion
1917
+ //#region src/client/useFpsGuard.ts
1918
+ /**
1919
+ * Performance guard for the streaming reveal.
1920
+ *
1921
+ * Feeds an EMA-smoothed frame-rate monitor from a rAF loop while streaming
1922
+ * and tracks whether the reply is on-screen. The returned `shouldHoldBack`
1923
+ * predicate is true only while the frame rate is below the threshold AND the
1924
+ * reply is offscreen — exactly the spec's "skip offscreen DOM updates when
1925
+ * FPS < 30" rule. The smoother consumes the predicate as its commit veto.
1926
+ */
1927
+ const FPS_THRESHOLD = 30;
1928
+ const FPS_ALPHA = .12;
1929
+ const RECOVER_FRAMES = 6;
1930
+ const MAX_FRAME_MS = 100;
1931
+ function useFpsGuard(active) {
1932
+ const fpsRef = (0, react.useRef)({
1933
+ emaMs: 0,
1934
+ lastMs: 0,
1935
+ healthyRun: 0,
1936
+ degraded: false
1937
+ });
1938
+ const visibleRef = (0, react.useRef)(true);
1939
+ const elementRef = (0, react.useRef)(null);
1940
+ (0, react.useEffect)(() => {
1941
+ if (!active) return;
1942
+ let rafId = 0;
1943
+ let lastDebugReport = 0;
1944
+ const frame = (now) => {
1945
+ rafId = requestAnimationFrame(frame);
1946
+ const fps = fpsRef.current;
1947
+ if (fps.lastMs === 0) {
1948
+ fps.lastMs = now;
1440
1949
  return;
1441
1950
  }
1442
- hold(nextPort);
1443
- if (!isLeader(nextPort)) {
1444
- finishEntrance();
1445
- return;
1951
+ const delta = Math.min(MAX_FRAME_MS, Math.max(1, now - fps.lastMs));
1952
+ fps.lastMs = now;
1953
+ fps.emaMs = fps.emaMs === 0 ? delta : fps.emaMs + FPS_ALPHA * (delta - fps.emaMs);
1954
+ const currentFps = 1e3 / fps.emaMs;
1955
+ if (currentFps < FPS_THRESHOLD) {
1956
+ fps.healthyRun = 0;
1957
+ fps.degraded = true;
1958
+ } else if (fps.degraded) {
1959
+ fps.healthyRun += 1;
1960
+ if (fps.healthyRun >= RECOVER_FRAMES) fps.degraded = false;
1446
1961
  }
1447
- const runwayOffset = runwayOffsetOf(nextPort);
1448
- const contentHeight = nextPort.scrollHeight;
1449
- const lag = Math.max(0, contentHeight - animatedH - runwayOffset);
1450
- const predictGrowth = predictiveRef?.current ?? predictive;
1451
- const reserveTarget = !predictGrowth ? 0 : computeFollowReserve(speedCpsRef.current, tuning.runwayPx);
1452
- const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs);
1453
- reservePx += (reserveTarget - reservePx) * reserveStep;
1454
- const step = computeFollowStep(dt, {
1455
- lag,
1456
- speedEma: speedCpsRef.current,
1457
- velocityPxPerSec
1458
- }, tuning);
1459
- if (lag <= .1) {
1460
- animatedH = contentHeight - runwayOffset;
1461
- velocityPxPerSec = 0;
1462
- } else {
1463
- const minimumLag = predictGrowth ? 0 : Math.max(0, reservePx);
1464
- animatedH = Math.min(contentHeight - runwayOffset - minimumLag, animatedH + step.advancePx);
1465
- velocityPxPerSec = step.velocityPxPerSec;
1962
+ if (now - lastDebugReport >= 100) {
1963
+ debugRuntime.reportFps(currentFps, fps.emaMs, fps.degraded);
1964
+ lastDebugReport = now;
1466
1965
  }
1467
- animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx);
1468
- updateRevealScale(nextPort, elapsedMs);
1469
- reportFollow(nextPort, true);
1470
- if (Math.max(0, nextPort.scrollHeight - animatedH - runwayOffsetOf(nextPort)) <= .25) finishEntrance();
1471
1966
  };
1472
- frame(performance.now());
1967
+ rafId = requestAnimationFrame(frame);
1473
1968
  return () => {
1474
1969
  cancelAnimationFrame(rafId);
1475
- if (interactTimer !== null) clearTimeout(interactTimer);
1476
- resize?.disconnect();
1477
- if (port !== null) for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture);
1478
- const host = rootRef.current?.closest("[data-conversation-scroll]") ?? port;
1479
- if (host === null) return;
1480
- holding = null;
1481
- if (!isLeader(host)) return;
1482
- const preserveReader = interacting && (readerGestureIntent || readerScrolledUp(host));
1483
- if (!following || !primed) {
1484
- clearVisual(host);
1485
- followLeaders.delete(host);
1486
- releaseRevealScale();
1487
- debugRuntime.reportFollow(host, null);
1488
- return;
1489
- }
1490
- if (preserveReader) {
1491
- handBackVisual(host);
1492
- clearVisual(host);
1493
- followLeaders.delete(host);
1494
- releaseRevealScale();
1495
- debugRuntime.reportFollow(host, null);
1496
- return;
1497
- }
1498
- const completionTuning = debugRuntime.activeTuning();
1499
- ensureRunway(host, shiftSurfacesOf(host), completionTuning.runwayPx);
1500
- const completionRunway = runwayOffsetOf(host);
1501
- const completionMinimumLag = Math.max(0, reservePx);
1502
- animatedH = Math.min(animatedH, host.scrollHeight - completionRunway - completionMinimumLag);
1503
- settleAtFloor(host);
1504
- animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, completionTuning.runwayPx);
1505
- reportFollow(host, false);
1506
- const runwayOffset = runwayOffsetOf(host);
1507
- if (Math.max(0, host.scrollHeight - animatedH - runwayOffset) <= .25 && reservePx <= .25) {
1508
- finishAtNaturalFloor(host);
1509
- followLeaders.delete(host);
1510
- releaseRevealScale();
1511
- debugRuntime.reportFollow(host, null);
1512
- return;
1513
- }
1514
- for (const name of GESTURE_EVENTS) host.addEventListener(name, markGesture, { passive: true });
1515
- const stopSettleListeners = () => {
1516
- for (const name of GESTURE_EVENTS) host.removeEventListener(name, markGesture);
1517
- if (interactTimer !== null) {
1518
- clearTimeout(interactTimer);
1519
- interactTimer = null;
1520
- }
1521
- };
1522
- let settleLast = performance.now();
1523
- const settleFrame = (now) => {
1524
- if (!isLeader(host)) {
1525
- stopSettleListeners();
1526
- return;
1527
- }
1528
- if (interacting && (readerGestureIntent || readerScrolledUp(host))) {
1529
- readerGestureIntent = false;
1530
- handBackVisual(host);
1531
- clearVisual(host);
1532
- followLeaders.delete(host);
1533
- releaseRevealScale();
1534
- debugRuntime.reportFollow(host, null);
1535
- stopSettleListeners();
1536
- return;
1537
- }
1538
- const dt = Math.min(32, Math.max(0, now - settleLast));
1539
- const tuning = debugRuntime.activeTuning();
1540
- settleLast = now;
1541
- const runwayOffset = runwayOffsetOf(host);
1542
- const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset);
1543
- const reserveStep = 1 - Math.exp(-dt / tuning.reserveResponseMs);
1544
- reservePx += (0 - reservePx) * reserveStep;
1545
- if (lag <= .25 && reservePx <= .25) {
1546
- animatedH = host.scrollHeight - runwayOffset;
1547
- reservePx = 0;
1548
- velocityPxPerSec = 0;
1549
- finishAtNaturalFloor(host);
1550
- followLeaders.delete(host);
1551
- releaseRevealScale();
1552
- debugRuntime.reportFollow(host, null);
1553
- stopSettleListeners();
1554
- return;
1555
- }
1556
- const step = computeFollowStep(dt, {
1557
- lag,
1558
- speedEma: speedCpsRef.current,
1559
- velocityPxPerSec
1560
- }, tuning);
1561
- const minimumLag = Math.max(0, reservePx);
1562
- animatedH = Math.min(host.scrollHeight - runwayOffset - minimumLag, animatedH + step.advancePx);
1563
- velocityPxPerSec = step.velocityPxPerSec;
1564
- settleAtFloor(host);
1565
- animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx);
1566
- reportFollow(host, false);
1567
- requestAnimationFrame(settleFrame);
1970
+ fpsRef.current = {
1971
+ emaMs: 0,
1972
+ lastMs: 0,
1973
+ healthyRun: 0,
1974
+ degraded: false
1568
1975
  };
1569
- requestAnimationFrame(settleFrame);
1976
+ debugRuntime.clearFps();
1570
1977
  };
1571
- }, [
1572
- active,
1573
- rootRef,
1574
- speedCpsRef,
1575
- revealScaleRef,
1576
- predictive,
1577
- predictiveRef
1578
- ]);
1978
+ }, [active]);
1979
+ const ref = (0, react.useCallback)((element) => {
1980
+ elementRef.current = element;
1981
+ }, []);
1982
+ (0, react.useEffect)(() => {
1983
+ const element = elementRef.current;
1984
+ if (element === null || typeof IntersectionObserver === "undefined") return;
1985
+ const observer = new IntersectionObserver((entries) => {
1986
+ for (const entry of entries) visibleRef.current = entry.isIntersecting;
1987
+ }, { rootMargin: "120px 0px" });
1988
+ observer.observe(element);
1989
+ return () => observer.disconnect();
1990
+ });
1991
+ return {
1992
+ ref,
1993
+ shouldHoldBack: (0, react.useCallback)(() => {
1994
+ return active && fpsRef.current.degraded && !visibleRef.current;
1995
+ }, [active])
1996
+ };
1579
1997
  }
1580
1998
  //#endregion
1581
1999
  //#region src/client/FollowHost.tsx
@@ -1585,24 +2003,38 @@ window.__ModuleLoader__.load({
1585
2003
  * generic wrapped renderers re-arm one glide when their DOM grows without
1586
2004
  * requiring a business-kind-specific lifecycle predicate.
1587
2005
  */
1588
- function FollowHost({ active, entrance = false, onEntranceSettled, onGrowth, entranceExtentRef, speedCpsRef, revealScaleRef, predictive = true, predictiveRef, hostRef, children }) {
2006
+ function FollowHost({ active, entrance = false, onEntranceSettled, onGrowth, entranceExtentRef, speedCpsRef, revealedCharsRef, revealScaleRef, predictive = true, predictiveRef, hostRef, children }) {
1589
2007
  const localRootRef = (0, react.useRef)(null);
1590
2008
  const rootRef = hostRef ?? localRootRef;
1591
- useConversationFollow(rootRef, active || entrance, speedCpsRef, revealScaleRef, predictive, entrance, onEntranceSettled, predictiveRef, entranceExtentRef);
2009
+ useConversationFollow(rootRef, active || entrance, speedCpsRef, revealScaleRef, predictive, entrance, onEntranceSettled, predictiveRef, entranceExtentRef, revealedCharsRef);
1592
2010
  (0, react.useEffect)(() => {
1593
2011
  if (onGrowth === void 0 || typeof ResizeObserver === "undefined") return;
1594
2012
  const root = rootRef.current;
1595
2013
  if (root === null) return;
1596
2014
  let previousHeight = null;
2015
+ let pendingGrowth = 0;
2016
+ let growthFrame = null;
2017
+ const flushGrowth = () => {
2018
+ growthFrame = null;
2019
+ if (pendingGrowth <= 0) return;
2020
+ const delta = pendingGrowth;
2021
+ pendingGrowth = 0;
2022
+ onGrowth(delta);
2023
+ };
1597
2024
  const observer = new ResizeObserver((entries) => {
1598
- const nextHeight = entries[0]?.contentRect.height ?? root.getBoundingClientRect().height;
1599
- if (!Number.isFinite(nextHeight)) return;
1600
- if (previousHeight !== null && nextHeight > previousHeight + .5) onGrowth(nextHeight - previousHeight);
2025
+ const measuredHeight = entries[0]?.contentRect.height;
2026
+ if (measuredHeight === void 0 || !Number.isFinite(measuredHeight)) return;
2027
+ const nextHeight = measuredHeight;
2028
+ if (previousHeight !== null && nextHeight > previousHeight + .5) {
2029
+ pendingGrowth += nextHeight - previousHeight;
2030
+ if (growthFrame === null) growthFrame = requestAnimationFrame(flushGrowth);
2031
+ }
1601
2032
  previousHeight = nextHeight;
1602
2033
  });
1603
2034
  observer.observe(root);
1604
2035
  return () => {
1605
2036
  observer.disconnect();
2037
+ if (growthFrame !== null) cancelAnimationFrame(growthFrame);
1606
2038
  };
1607
2039
  }, [onGrowth]);
1608
2040
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -1845,7 +2277,7 @@ window.__ModuleLoader__.load({
1845
2277
  * drains, the settled full parse (KaTeX math, fence highlighting, file
1846
2278
  * mentions) swaps in exactly once.
1847
2279
  */
1848
- function AnimatedMarkdownText({ text, codeLabels, fileMentions, streaming, ownFollow, followSpeedCpsRef, followRevealScaleRef, onPredictiveChange, preset, shouldHoldBack }) {
2280
+ function AnimatedMarkdownText({ text, codeLabels, fileMentions, streaming, ownFollow, followSpeedCpsRef, followRevealedCharsRef, followRevealScaleRef, onPredictiveChange, preset, shouldHoldBack }) {
1849
2281
  const reduced = usePrefersReducedMotion();
1850
2282
  const [typing, setTyping] = (0, react.useState)(streaming);
1851
2283
  const localSpeedCpsRef = (0, react.useRef)(35);
@@ -1860,7 +2292,11 @@ window.__ModuleLoader__.load({
1860
2292
  preset,
1861
2293
  shouldHoldBack,
1862
2294
  speedCpsRef,
1863
- revealScaleRef: followRevealScaleRef
2295
+ revealedCharsRef: followRevealedCharsRef,
2296
+ revealScaleRef: followRevealScaleRef,
2297
+ onRevealCommit: () => {
2298
+ notifyFollowCommit(followRootRef.current);
2299
+ }
1864
2300
  });
1865
2301
  const shown = reduced ? text : displayed;
1866
2302
  const live = typing && !reduced;
@@ -1901,6 +2337,7 @@ window.__ModuleLoader__.load({
1901
2337
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FollowHost, {
1902
2338
  active: live && ownFollow,
1903
2339
  speedCpsRef,
2340
+ revealedCharsRef: followRevealedCharsRef,
1904
2341
  revealScaleRef: followRevealScaleRef,
1905
2342
  predictive: streaming,
1906
2343
  hostRef: followRootRef,
@@ -1946,18 +2383,27 @@ window.__ModuleLoader__.load({
1946
2383
  function AnimatedReasoning({ text, running, preset, thinkAutoExpand, shouldHoldBack, followSpeedCpsRef, followRevealScaleRef, t }) {
1947
2384
  const reduced = usePrefersReducedMotion();
1948
2385
  const [expanded, setExpanded] = (0, react.useState)(running && thinkAutoExpand);
2386
+ const [autoClosed, setAutoClosed] = (0, react.useState)(false);
1949
2387
  const summaryRef = (0, react.useRef)(null);
2388
+ const commitAnchorRef = (0, react.useRef)(null);
1950
2389
  const displayed = useSmoothStreamContent(text, {
1951
2390
  enabled: running && !reduced,
1952
2391
  preset,
1953
2392
  shouldHoldBack,
1954
2393
  speedCpsRef: followSpeedCpsRef,
1955
- revealScaleRef: followRevealScaleRef
2394
+ revealScaleRef: followRevealScaleRef,
2395
+ onRevealCommit: () => {
2396
+ notifyFollowCommit(commitAnchorRef.current);
2397
+ }
1956
2398
  });
1957
2399
  const shown = running && !reduced ? displayed : text;
1958
2400
  const summary = running ? latestLine(shown) : firstLine(text);
1959
2401
  (0, react.useLayoutEffect)(() => {
1960
- if (thinkAutoExpand) setExpanded(running);
2402
+ if (thinkAutoExpand) {
2403
+ setExpanded(running);
2404
+ setAutoClosed(!running);
2405
+ }
2406
+ notifyFollowCommit(commitAnchorRef.current);
1961
2407
  }, [running, thinkAutoExpand]);
1962
2408
  (0, react.useEffect)(() => {
1963
2409
  const element = summaryRef.current;
@@ -1966,6 +2412,7 @@ window.__ModuleLoader__.load({
1966
2412
  }, [running, summary]);
1967
2413
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1968
2414
  className: TypewriterAssistantNodeView_module_css_default.follow,
2415
+ ref: commitAnchorRef,
1969
2416
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1970
2417
  className: TypewriterAssistantNodeView_module_css_default.think,
1971
2418
  "data-variant": "think",
@@ -1982,8 +2429,10 @@ window.__ModuleLoader__.load({
1982
2429
  title: "Think",
1983
2430
  open: expanded,
1984
2431
  onToggle: () => {
2432
+ setAutoClosed(false);
1985
2433
  setExpanded((value) => !value);
1986
2434
  },
2435
+ bodyTransition: !autoClosed,
1987
2436
  collapsedContent: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1988
2437
  className: TypewriterAssistantNodeView_module_css_default.thinkSeparator,
1989
2438
  "aria-hidden": true
@@ -2017,6 +2466,7 @@ window.__ModuleLoader__.load({
2017
2466
  const reduced = usePrefersReducedMotion();
2018
2467
  const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming);
2019
2468
  const rootSpeedRef = (0, react.useRef)(35);
2469
+ const rootRevealedCharsRef = (0, react.useRef)(0);
2020
2470
  const rootRevealScaleRef = (0, react.useRef)(1);
2021
2471
  const reasoningTailIndex = streaming && data.blocks[data.blocks.length - 1]?.kind === "reasoning" ? data.blocks.length - 1 : -1;
2022
2472
  const reasoningOwnsSpeed = reasoningTailIndex !== -1;
@@ -2075,6 +2525,7 @@ window.__ModuleLoader__.load({
2075
2525
  streaming,
2076
2526
  ownFollow: !streaming && index === lastFollow,
2077
2527
  followSpeedCpsRef: index === lastFollow ? rootSpeedRef : void 0,
2528
+ followRevealedCharsRef: index === lastFollow ? rootRevealedCharsRef : void 0,
2078
2529
  followRevealScaleRef: index === lastFollow ? rootRevealScaleRef : void 0,
2079
2530
  onPredictiveChange: index === lastFollow ? updateTextPrediction : void 0,
2080
2531
  preset,
@@ -2128,6 +2579,7 @@ window.__ModuleLoader__.load({
2128
2579
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FollowHost, {
2129
2580
  active: streaming && !reduced,
2130
2581
  speedCpsRef: rootSpeedRef,
2582
+ revealedCharsRef: rootRevealedCharsRef,
2131
2583
  revealScaleRef: rootRevealScaleRef,
2132
2584
  predictiveRef: rootPredictiveRef,
2133
2585
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -2270,6 +2722,7 @@ window.__ModuleLoader__.load({
2270
2722
  /** True between start() and stop(); the preference toggle re-enters both. */
2271
2723
  running = false;
2272
2724
  lastPassError = "";
2725
+ followDeferredAt = null;
2273
2726
  flow = null;
2274
2727
  /** Stable segment key → summary row + gesture state. */
2275
2728
  segmentStates = /* @__PURE__ */ new Map();
@@ -2322,6 +2775,7 @@ window.__ModuleLoader__.load({
2322
2775
  }
2323
2776
  stop() {
2324
2777
  this.running = false;
2778
+ this.followDeferredAt = null;
2325
2779
  if (this.raf !== 0) cancelAnimationFrame(this.raf);
2326
2780
  this.raf = 0;
2327
2781
  if (this.timer !== 0) clearTimeout(this.timer);
@@ -2352,10 +2806,16 @@ window.__ModuleLoader__.load({
2352
2806
  this.rearmAudit();
2353
2807
  }, AUDIT_TICK_MS);
2354
2808
  }
2355
- /** Body-level observations only need to wake us up near the active flow. */
2809
+ /** Only mutations inside the active flow can affect collapse geometry. */
2356
2810
  shouldSchedule(records) {
2357
2811
  if (records.length === 0 || this.flow === null || !this.flow.isConnected) return true;
2358
- return records.some((record) => nodeWithin(record.target, this.flow) || nodeWithin(this.flow, record.target));
2812
+ const flow = this.flow;
2813
+ return records.some((record) => {
2814
+ const changed = [...record.addedNodes, ...record.removedNodes];
2815
+ if (changed.length > 0 && changed.every((node) => isFloatingOverlayNode(node))) return false;
2816
+ if (record.target === flow || nodeWithin(record.target, flow)) return true;
2817
+ return changed.some((node) => nodeWithin(node, flow));
2818
+ });
2359
2819
  }
2360
2820
  /** Walk each record to its flow child so reply-cache invalidation stays
2361
2821
  * targeted; anything unattributable invalidates conservatively. */
@@ -2452,38 +2912,62 @@ window.__ModuleLoader__.load({
2452
2912
  if (state.row === null || !state.row.isConnected) state.row = this.createProcessedRow(state);
2453
2913
  this.syncProcessedRow(state);
2454
2914
  }
2455
- for (const [key, state] of [...this.segmentStates]) {
2456
- if (completedKeys.has(key)) continue;
2457
- state.row?.remove();
2458
- this.segmentStates.delete(key);
2459
- }
2460
- const desiredHidden = /* @__PURE__ */ new Set();
2461
- for (const segment of segments) {
2462
- const state = this.segmentStates.get(segment.key);
2463
- if (state !== void 0 && !state.expanded) {
2464
- for (const seat of segment.hideSeats) this.hideElement(seat, desiredHidden);
2465
- for (const row of segment.finalThinkRows) this.hideElement(row, desiredHidden);
2466
- if (segment.finalStep !== null) this.restoreElement(segment.finalStep);
2467
- } else {
2468
- for (const seat of segment.hideSeats) this.restoreElement(seat);
2469
- for (const row of segment.finalThinkRows) this.restoreElement(row);
2470
- if (segment.finalStep !== null) this.restoreElement(segment.finalStep);
2915
+ const portNow = flow.closest("[data-conversation-scroll]");
2916
+ if (completedKeys.size > 0 && portNow !== null && hasRecentConversationFollow(portNow, 140)) {
2917
+ const now = Date.now();
2918
+ this.followDeferredAt ??= now;
2919
+ if (now - this.followDeferredAt < 2e3) {
2920
+ this.schedule();
2921
+ return;
2471
2922
  }
2472
2923
  }
2473
- for (const segment of segments) {
2474
- if (!hasVisibleSegmentWork(segment)) {
2924
+ this.followDeferredAt = null;
2925
+ const port = flow.closest("[data-conversation-scroll]");
2926
+ const beforeFloor = Math.max(0, (port?.scrollHeight ?? 0) - (port?.clientHeight ?? 0));
2927
+ const pinnedToFloor = port !== null && beforeFloor - port.scrollTop <= 30;
2928
+ const anchorBefore = lastVisibleAnchorTop(flow);
2929
+ const applyMutations = () => {
2930
+ for (const [key, state] of [...this.segmentStates]) {
2931
+ if (completedKeys.has(key)) continue;
2932
+ state.row?.remove();
2933
+ this.segmentStates.delete(key);
2934
+ }
2935
+ const desiredHidden = /* @__PURE__ */ new Set();
2936
+ for (const segment of segments) {
2475
2937
  const state = this.segmentStates.get(segment.key);
2476
- if (state !== void 0 && state.row !== null) {
2477
- state.row.remove();
2478
- state.row = null;
2938
+ if (state !== void 0 && !state.expanded) {
2939
+ for (const seat of segment.hideSeats) this.hideElement(seat, desiredHidden);
2940
+ for (const row of segment.finalThinkRows) this.hideElement(row, desiredHidden);
2941
+ if (segment.finalStep !== null) this.restoreElement(segment.finalStep);
2942
+ } else {
2943
+ for (const seat of segment.hideSeats) this.restoreElement(seat);
2944
+ for (const row of segment.finalThinkRows) this.restoreElement(row);
2945
+ if (segment.finalStep !== null) this.restoreElement(segment.finalStep);
2946
+ }
2947
+ }
2948
+ for (const segment of segments) {
2949
+ if (!hasVisibleSegmentWork(segment)) {
2950
+ const state = this.segmentStates.get(segment.key);
2951
+ if (state !== void 0 && state.row !== null) {
2952
+ state.row.remove();
2953
+ state.row = null;
2954
+ }
2479
2955
  }
2956
+ for (const seat of segment.hideSeats) this.retainDisplayControl(seat, desiredHidden);
2957
+ for (const row of segment.finalThinkRows) this.retainDisplayControl(row, desiredHidden);
2958
+ if (segment.finalStep !== null) this.retainDisplayControl(segment.finalStep, desiredHidden);
2959
+ }
2960
+ this.restoreUnusedDisplays(desiredHidden);
2961
+ for (const state of this.segmentStates.values()) this.placeProcessedRow(flow, state);
2962
+ };
2963
+ applyMutations();
2964
+ if (port !== null && pinnedToFloor && anchorBefore !== null) {
2965
+ const anchorAfter = lastVisibleAnchorTop(flow);
2966
+ if (anchorAfter !== null) {
2967
+ const delta = anchorAfter - anchorBefore;
2968
+ if (Math.abs(delta) > .5) port.scrollTop += delta;
2480
2969
  }
2481
- for (const seat of segment.hideSeats) this.retainDisplayControl(seat, desiredHidden);
2482
- for (const row of segment.finalThinkRows) this.retainDisplayControl(row, desiredHidden);
2483
- if (segment.finalStep !== null) this.retainDisplayControl(segment.finalStep, desiredHidden);
2484
2970
  }
2485
- this.restoreUnusedDisplays(desiredHidden);
2486
- for (const state of this.segmentStates.values()) this.placeProcessedRow(flow, state);
2487
2971
  for (const key of [...this.runningSince.keys()]) if (!liveSegmentKeys.has(key)) this.runningSince.delete(key);
2488
2972
  for (const key of [...this.completedOnce]) if (!liveSegmentKeys.has(key)) this.completedOnce.delete(key);
2489
2973
  }
@@ -2633,6 +3117,23 @@ window.__ModuleLoader__.load({
2633
3117
  for (let current = node; current !== null; current = current.parentNode) if (current === ancestor) return true;
2634
3118
  return false;
2635
3119
  }
3120
+ /**
3121
+ * Whether a mutation-added/removed node is a `position: fixed` overlay (the
3122
+ * harness Tooltip bubble / a popover) that never affects the flow's collapse
3123
+ * geometry. The primitives Tooltip mounts its fixed bubble as a Fragment
3124
+ * sibling inside the message row — `position: fixed` arrives from its CSS
3125
+ * module class, not inline style — so it appears within `[data-chat-flow]`
3126
+ * even though it is viewport-fixed and layout-neutral. Matching on the
3127
+ * tooltip's structural role instead of computed style keeps the filter
3128
+ * reliable before the node has been laid out.
3129
+ */
3130
+ function isFloatingOverlayNode(node) {
3131
+ if (!(node instanceof HTMLElement)) return false;
3132
+ if (node.getAttribute("role") === "tooltip") return true;
3133
+ if (node.style.position === "fixed") return true;
3134
+ if (node.style.position === "absolute" && node.parentElement?.getAttribute("role") === "tooltip") return true;
3135
+ return false;
3136
+ }
2636
3137
  /** Top-level message order, excluding this plugin's own summary rows. */
2637
3138
  function flowItems(flow) {
2638
3139
  return [...flow.children].filter((el) => el instanceof HTMLElement && !el.classList.contains(PROCESSED_CLASS));
@@ -2840,6 +3341,19 @@ window.__ModuleLoader__.load({
2840
3341
  * tails only carry an end timestamp, which is diffed against the opening user
2841
3342
  * row's timestamp.
2842
3343
  */
3344
+ /**
3345
+ * Viewport-top of the last visible anchored message row, or null when none is
3346
+ * rendered. Used to pin the reader's visual anchor across a fold commit.
3347
+ */
3348
+ function lastVisibleAnchorTop(flow) {
3349
+ const rows = flow.querySelectorAll("[data-chat-anchor-key]");
3350
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
3351
+ const row = rows[index];
3352
+ if (row.getClientRects().length === 0) continue;
3353
+ return row.getBoundingClientRect().top;
3354
+ }
3355
+ return null;
3356
+ }
2843
3357
  function parseTurnDuration(boundary) {
2844
3358
  const text = boundary.textContent ?? "";
2845
3359
  const zh = text.match(/用时\s*(\d+)分(\d+)秒|用时\s*(\d+)秒/);
@@ -3385,32 +3899,32 @@ window.__ModuleLoader__.load({
3385
3899
  tag.textContent = css$1;
3386
3900
  }
3387
3901
  var SmoothStreamCard_module_css_default = {
3388
- "fieldHead": "XNGeRG_fieldHead",
3389
- "label": "XNGeRG_label",
3390
- "version": "XNGeRG_version",
3391
- "header": "XNGeRG_header",
3392
- "body": "XNGeRG_body",
3393
3902
  "failure": "XNGeRG_failure",
3394
3903
  "updateRow": "XNGeRG_updateRow",
3395
- "updateCopy": "XNGeRG_updateCopy",
3396
- "field": "XNGeRG_field",
3397
3904
  "footer": "XNGeRG_footer",
3398
- "name": "XNGeRG_name",
3399
- "failed": "XNGeRG_failed",
3400
- "discard": "XNGeRG_discard",
3401
- "toggle": "XNGeRG_toggle",
3402
- "fieldDisabled": "XNGeRG_fieldDisabled",
3403
- "readOnly": "XNGeRG_readOnly",
3404
- "headText": "XNGeRG_headText",
3405
- "cardOpen": "XNGeRG_cardOpen",
3406
- "save": "XNGeRG_save",
3407
- "hint": "XNGeRG_hint",
3408
- "chevron": "XNGeRG_chevron",
3409
3905
  "update": "XNGeRG_update",
3906
+ "cardOpen": "XNGeRG_cardOpen",
3907
+ "fieldDisabled": "XNGeRG_fieldDisabled",
3908
+ "header": "XNGeRG_header",
3909
+ "chevronOpen": "XNGeRG_chevronOpen",
3910
+ "field": "XNGeRG_field",
3911
+ "version": "XNGeRG_version",
3410
3912
  "pending": "XNGeRG_pending",
3913
+ "chevron": "XNGeRG_chevron",
3914
+ "hint": "XNGeRG_hint",
3915
+ "failed": "XNGeRG_failed",
3411
3916
  "description": "XNGeRG_description",
3917
+ "label": "XNGeRG_label",
3918
+ "fieldHead": "XNGeRG_fieldHead",
3919
+ "updateCopy": "XNGeRG_updateCopy",
3920
+ "save": "XNGeRG_save",
3921
+ "discard": "XNGeRG_discard",
3922
+ "body": "XNGeRG_body",
3923
+ "name": "XNGeRG_name",
3412
3924
  "card": "XNGeRG_card",
3413
- "chevronOpen": "XNGeRG_chevronOpen"
3925
+ "readOnly": "XNGeRG_readOnly",
3926
+ "headText": "XNGeRG_headText",
3927
+ "toggle": "XNGeRG_toggle"
3414
3928
  };
3415
3929
  //#endregion
3416
3930
  //#region src/client/SmoothStreamCard.tsx
@@ -3631,7 +4145,7 @@ window.__ModuleLoader__.load({
3631
4145
  /** Bridges the plugin's protected Host interface onto a staged settings form. */
3632
4146
  var SmoothStreamCardController = class {
3633
4147
  api;
3634
- store = (0, _deepseek_ai_dsh_client_runtime_client.createSnapshotStore)(this.projection());
4148
+ store = createSnapshotStore(this.projection());
3635
4149
  loaded;
3636
4150
  loadedDebug;
3637
4151
  stagedBase;
@@ -3911,34 +4425,34 @@ window.__ModuleLoader__.load({
3911
4425
  tag.textContent = css;
3912
4426
  }
3913
4427
  var DebugPanel_module_css_default = {
3914
- "control": "m4_tdG_control",
3915
- "secondaryButton": "m4_tdG_secondaryButton",
3916
- "infoButton": "m4_tdG_infoButton",
3917
- "footerSpacer": "m4_tdG_footerSpacer",
3918
- "visuallyHidden": "m4_tdG_visuallyHidden",
3919
- "panelHeader": "m4_tdG_panelHeader",
3920
- "metric": "m4_tdG_metric",
3921
4428
  "section": "m4_tdG_section",
3922
- "unsaved": "m4_tdG_unsaved",
3923
- "state": "m4_tdG_state",
4429
+ "panelHeader": "m4_tdG_panelHeader",
3924
4430
  "statusDot": "m4_tdG_statusDot",
3925
- "controlLabel": "m4_tdG_controlLabel",
3926
- "scrollArea": "m4_tdG_scrollArea",
3927
- "numberWrap": "m4_tdG_numberWrap",
3928
- "title": "m4_tdG_title",
3929
- "trigger": "m4_tdG_trigger",
3930
- "footer": "m4_tdG_footer",
3931
4431
  "iconButton": "m4_tdG_iconButton",
3932
- "panel": "m4_tdG_panel",
3933
- "controlHead": "m4_tdG_controlHead",
4432
+ "unsaved": "m4_tdG_unsaved",
4433
+ "infoButton": "m4_tdG_infoButton",
3934
4434
  "triggerActive": "m4_tdG_triggerActive",
3935
- "range": "m4_tdG_range",
3936
- "unit": "m4_tdG_unit",
3937
- "primaryButton": "m4_tdG_primaryButton",
3938
4435
  "number": "m4_tdG_number",
3939
- "metrics": "m4_tdG_metrics",
4436
+ "primaryButton": "m4_tdG_primaryButton",
3940
4437
  "statusLive": "m4_tdG_statusLive",
3941
- "guide": "m4_tdG_guide"
4438
+ "metric": "m4_tdG_metric",
4439
+ "control": "m4_tdG_control",
4440
+ "secondaryButton": "m4_tdG_secondaryButton",
4441
+ "panel": "m4_tdG_panel",
4442
+ "controlHead": "m4_tdG_controlHead",
4443
+ "guide": "m4_tdG_guide",
4444
+ "controlLabel": "m4_tdG_controlLabel",
4445
+ "numberWrap": "m4_tdG_numberWrap",
4446
+ "visuallyHidden": "m4_tdG_visuallyHidden",
4447
+ "footer": "m4_tdG_footer",
4448
+ "trigger": "m4_tdG_trigger",
4449
+ "title": "m4_tdG_title",
4450
+ "metrics": "m4_tdG_metrics",
4451
+ "unit": "m4_tdG_unit",
4452
+ "footerSpacer": "m4_tdG_footerSpacer",
4453
+ "range": "m4_tdG_range",
4454
+ "state": "m4_tdG_state",
4455
+ "scrollArea": "m4_tdG_scrollArea"
3942
4456
  };
3943
4457
  //#endregion
3944
4458
  //#region src/client/DebugPanel.tsx