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/README.en.md +41 -7
- package/README.md +40 -6
- package/lib/client.js +1616 -1102
- package/lib/client.js.map +1 -1
- package/lib/index.js +1 -1
- package/package.json +17 -13
- package/src/client/AnimatedDisclosure.tsx +14 -1
- package/src/client/FollowHost.tsx +21 -4
- package/src/client/TypewriterAssistantNodeView.module.css +28 -1
- package/src/client/TypewriterAssistantNodeView.tsx +36 -3
- package/src/client/auto-collapse-controller.ts +134 -39
- package/src/client/clientStore.ts +41 -0
- package/src/client/debugRuntime.ts +1 -1
- package/src/client/smooth-stream-card-controller.ts +1 -1
- package/src/client/teleprompterGlide.ts +878 -114
- package/src/client/useSmoothStreamContent.ts +71 -15
- package/src/settings.ts +1 -1
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,
|
|
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
|
-
"
|
|
39
|
-
"visuallyHidden": "I17U7q_visuallyHidden",
|
|
27
|
+
"thinkChevron": "I17U7q_thinkChevron",
|
|
40
28
|
"root": "I17U7q_root",
|
|
41
|
-
"
|
|
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
|
-
"
|
|
45
|
-
"
|
|
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
|
-
"
|
|
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:
|
|
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 =
|
|
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/
|
|
403
|
+
//#region src/client/teleprompterGlide.ts
|
|
382
404
|
/**
|
|
383
|
-
*
|
|
405
|
+
* Conversation-port follow while an assistant reply streams.
|
|
384
406
|
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
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
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
456
|
-
const
|
|
457
|
-
const
|
|
458
|
-
const
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
*
|
|
492
|
-
*
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|
579
|
+
const advancePx = input.lag - lag;
|
|
512
580
|
return {
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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 =
|
|
778
|
+
function ensureRunway(port, surfaces, runwayPx = 72) {
|
|
1031
779
|
const status = turnStatusOf(port);
|
|
1032
780
|
const composer = port.querySelector("[data-composer-seat]");
|
|
1033
|
-
|
|
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
|
-
|
|
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 ===
|
|
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 === "" ? `${
|
|
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:
|
|
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
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
*
|
|
1104
|
-
*
|
|
1105
|
-
*
|
|
1106
|
-
*
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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
|
-
*
|
|
1548
|
+
* Stream-smoothing reveal hook.
|
|
1120
1549
|
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
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
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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
|
-
/**
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
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
|
-
/**
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
if (
|
|
1207
|
-
|
|
1208
|
-
|
|
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
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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
|
-
*
|
|
1689
|
+
* Smooth a chunked content stream into a reveal-paced display string.
|
|
1227
1690
|
*
|
|
1228
|
-
* @param
|
|
1229
|
-
* @param
|
|
1230
|
-
* @
|
|
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
|
|
1239
|
-
const
|
|
1240
|
-
const
|
|
1241
|
-
const
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
1246
|
-
}, [
|
|
1247
|
-
(0, react.
|
|
1248
|
-
if (
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
const
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
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
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
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
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
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
|
|
1967
|
+
rafId = requestAnimationFrame(frame);
|
|
1473
1968
|
return () => {
|
|
1474
1969
|
cancelAnimationFrame(rafId);
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
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
|
-
|
|
1976
|
+
debugRuntime.clearFps();
|
|
1570
1977
|
};
|
|
1571
|
-
}, [
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
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
|
|
1599
|
-
if (!Number.isFinite(
|
|
1600
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
this.
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
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
|
-
|
|
2474
|
-
|
|
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.
|
|
2477
|
-
|
|
2478
|
-
|
|
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
|
-
"
|
|
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 =
|
|
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
|
-
"
|
|
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
|
-
"
|
|
3933
|
-
"
|
|
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
|
-
"
|
|
4436
|
+
"primaryButton": "m4_tdG_primaryButton",
|
|
3940
4437
|
"statusLive": "m4_tdG_statusLive",
|
|
3941
|
-
"
|
|
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
|