dsh-smooth-stream 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -10,40 +10,40 @@ window.__ModuleLoader__.load({
10
10
  let react_jsx_runtime = require("react/jsx-runtime");
11
11
  let react_dom = require("react-dom");
12
12
  //#region \0dsh-css:/Users/dzlin/work/project/dsh-smooth-stream/src/client/TypewriterAssistantNodeView.module.css.mjs
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}}";
14
- const tagId$2 = "dsh-smooth-stream/TypewriterAssistantNodeView.module.css";
13
+ const css$3 = ".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}}";
14
+ const tagId$3 = "dsh-smooth-stream/TypewriterAssistantNodeView.module.css";
15
15
  if (typeof document !== "undefined") {
16
- let tag = document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$2) + "]");
16
+ let tag = document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$3) + "]");
17
17
  if (tag === null) {
18
18
  tag = document.createElement("style");
19
19
  tag.dataset.plugin = "dsh-smooth-stream";
20
- tag.dataset.pluginCss = tagId$2;
20
+ tag.dataset.pluginCss = tagId$3;
21
21
  document.head.appendChild(tag);
22
22
  }
23
- tag.textContent = css$2;
23
+ tag.textContent = css$3;
24
24
  }
25
25
  var TypewriterAssistantNodeView_module_css_default = {
26
- "thinkSummary": "I17U7q_thinkSummary",
26
+ "disclosureIconIdle": "I17U7q_disclosureIconIdle",
27
+ "disclosureTitle": "I17U7q_disclosureTitle",
28
+ "thinkRow": "I17U7q_thinkRow",
27
29
  "thinkChevron": "I17U7q_thinkChevron",
28
30
  "root": "I17U7q_root",
29
- "disclosureIconIdle": "I17U7q_disclosureIconIdle",
30
- "disclosureChevronHover": "I17U7q_disclosureChevronHover",
31
+ "body": "I17U7q_body",
32
+ "disclosureRow": "I17U7q_disclosureRow",
33
+ "stopped": "I17U7q_stopped",
31
34
  "visuallyHidden": "I17U7q_visuallyHidden",
32
35
  "thinkSeparator": "I17U7q_thinkSeparator",
33
- "disclosureLeading": "I17U7q_disclosureLeading",
34
- "thinkRow": "I17U7q_thinkRow",
35
- "disclosureContent": "I17U7q_disclosureContent",
36
- "disclosureRoot": "I17U7q_disclosureRoot",
37
- "thinkTitle": "I17U7q_thinkTitle",
36
+ "thinkSummary": "I17U7q_thinkSummary",
38
37
  "think": "I17U7q_think",
39
- "stopped": "I17U7q_stopped",
40
- "thinkBody": "I17U7q_thinkBody",
41
- "body": "I17U7q_body",
42
- "disclosureRow": "I17U7q_disclosureRow",
43
38
  "dsh-smooth-stream-think-sweep": "I17U7q_dsh-smooth-stream-think-sweep",
39
+ "disclosureChevronHover": "I17U7q_disclosureChevronHover",
44
40
  "thinkLeading": "I17U7q_thinkLeading",
45
- "disclosureTitle": "I17U7q_disclosureTitle",
46
- "follow": "I17U7q_follow"
41
+ "disclosureContent": "I17U7q_disclosureContent",
42
+ "follow": "I17U7q_follow",
43
+ "disclosureLeading": "I17U7q_disclosureLeading",
44
+ "thinkTitle": "I17U7q_thinkTitle",
45
+ "disclosureRoot": "I17U7q_disclosureRoot",
46
+ "thinkBody": "I17U7q_thinkBody"
47
47
  };
48
48
  //#endregion
49
49
  //#region src/client/AnimatedDisclosure.tsx
@@ -125,33 +125,67 @@ window.__ModuleLoader__.load({
125
125
  /** Defaults shared by the Host schema and the client-side fallback. */
126
126
  const DEFAULT_STREAM_SETTINGS = {
127
127
  enabled: true,
128
+ controlScroll: true,
128
129
  thinkAutoExpand: true,
129
- autoCollapse: true,
130
130
  debugEnabled: false,
131
131
  debugTuning: DEFAULT_STREAM_DEBUG_TUNING
132
132
  };
133
133
  //#endregion
134
134
  //#region src/client/clientStore.ts
135
+ function fallbackSnapshotStore(init) {
136
+ let state = init;
137
+ const listeners = /* @__PURE__ */ new Set();
138
+ return {
139
+ getSnapshot: () => state,
140
+ subscribe: (listener) => {
141
+ listeners.add(listener);
142
+ return () => {
143
+ listeners.delete(listener);
144
+ };
145
+ },
146
+ update: (mutator) => {
147
+ const draft = Object.create(Object.getPrototypeOf(state), Object.getOwnPropertyDescriptors(state));
148
+ mutator(draft);
149
+ state = draft;
150
+ for (const listener of [...listeners]) listener(state);
151
+ },
152
+ set: (next) => {
153
+ state = next;
154
+ for (const listener of [...listeners]) listener(state);
155
+ }
156
+ };
157
+ }
135
158
  /**
136
159
  * Runtime require: the module-loader factory scopes its own resolver over the
137
160
  * booting kernel's table; under Node (vitest) there is no global require, so
138
161
  * fall back to `createRequire`.
139
162
  */
140
163
  function pickRequire() {
141
- if (typeof require === "function") return require;
142
- if (typeof process !== "undefined" && typeof process.getBuiltinModule === "function") {
164
+ if (typeof require === "function") try {
165
+ return require;
166
+ } catch {
167
+ return null;
168
+ }
169
+ if (typeof process !== "undefined" && typeof process.getBuiltinModule === "function") try {
143
170
  const { createRequire } = process.getBuiltinModule("node:module");
144
171
  return createRequire(process.cwd() + "/package.json");
172
+ } catch {
173
+ return null;
145
174
  }
146
- throw new TypeError("dsh-smooth-stream: no client module resolver available");
175
+ return null;
147
176
  }
148
177
  let clientStore;
149
178
  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;
179
+ const req = pickRequire();
180
+ if (req !== null) try {
181
+ clientStore = req("@deepseek-ai/dsh-client-store");
182
+ } catch {
183
+ try {
184
+ clientStore = req("@deepseek-ai/dsh-client-runtime/client");
185
+ } catch {}
186
+ }
187
+ } catch {}
188
+ const createSnapshotStore = clientStore?.createSnapshotStore ?? fallbackSnapshotStore;
155
189
  //#endregion
156
190
  //#region src/client/debugRuntime.ts
157
191
  /**
@@ -433,8 +467,15 @@ window.__ModuleLoader__.load({
433
467
  * scroll ownership. Current Harness also sees the write land at the floor.
434
468
  */
435
469
  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;
470
+ /**
471
+ * Duration of completion runway retirement. The final pad is visible motion:
472
+ * its shrinking floor brings the transcript down to its natural resting
473
+ * position. 1.5s keeps the default 72px runway at 48px/s (0.8px per 60Hz
474
+ * frame), matching the configured default reading-follow velocity instead of
475
+ * the old 160ms / 450px/s staircase that looked like repeated completion
476
+ * jumps.
477
+ */
478
+ const FOLLOW_RUNWAY_RETIRE_MS = 1500;
438
479
  /** Runway size emitted by bundles before the 72px predictive runway. */
439
480
  const LEGACY_RUNWAY_PX = 48;
440
481
  /** Safe-lag occupancy band over which reveal pressure is progressively reduced. */
@@ -591,7 +632,7 @@ window.__ModuleLoader__.load({
591
632
  * Outermost message surfaces; nested tool rows ride their parent.
592
633
  *
593
634
  * Another plugin may insert its own element as a flow sibling of the Chat rows
594
- * (meow-memory's fold bar is one; so is this plugin's own fold summary row).
635
+ * (meow-memory's fold bar is one).
595
636
  * Such a row carries no `data-chat-anchor-key`, so selecting only anchored
596
637
  * rows would shift the conversation while leaving the foreign row at its
597
638
  * natural offset, letting the shifted rows paint over it. Every direct flow
@@ -705,12 +746,237 @@ window.__ModuleLoader__.load({
705
746
  const followHadChrome = /* @__PURE__ */ new WeakSet();
706
747
  /** Last painted shift per port, to spread a wrap's one-line step over frames. */
707
748
  const followLastShiftPx = /* @__PURE__ */ new WeakMap();
749
+ /** Last settled floor per port, to size the shift decay bound against extent collapse. */
750
+ const followLastFloorPx = /* @__PURE__ */ new WeakMap();
751
+ const followGuardAnchors = /* @__PURE__ */ new WeakMap();
752
+ /**
753
+ * Ports whose completion settle loop owns the follow. The settle drains the
754
+ * reveal, retires the pad and guards the cascade; the swap that ends the
755
+ * turn REMOUNTS the follower arms in the same frame cluster, and a freshly
756
+ * mounted arm primes with a higher generation and would otherwise steal the
757
+ * port mid-drain — its observers miss the cascade mutations (armed after
758
+ * the fact) and its state initialization re-materializes engine space.
759
+ * Ownership is released only when the settle finishes, the reader gestures,
760
+ * or a genuinely NEW turn arrives (a user row joined since the handoff).
761
+ */
762
+ const followCompletionSettle = /* @__PURE__ */ new WeakSet();
763
+ /** User-row count at handoff, for the new-turn check above. */
764
+ const followCompletionSettleRows = /* @__PURE__ */ new WeakMap();
765
+ /** User rows below the flow, or -1 when the host does not label rows with
766
+ * `data-chat-flow-kind` (the engine's own audit benches): the new-turn
767
+ * check is unsupported there and ownership guarding must stay OFF. */
768
+ function countUserRows(port) {
769
+ const flow = flowElementOf(port);
770
+ if (flow === null) return -1;
771
+ let count = 0;
772
+ let sawKind = false;
773
+ for (const child of flow.children) {
774
+ if (!(child instanceof HTMLElement)) continue;
775
+ const kind = child.getAttribute("data-chat-flow-kind");
776
+ if (kind !== null) sawKind = true;
777
+ if (kind === "user") count++;
778
+ }
779
+ return sawKind ? count : -1;
780
+ }
781
+ /** True while this port's completion settle owns the follow and no new turn
782
+ * has arrived since the handoff. */
783
+ function completionSettleGuardsPort(port) {
784
+ if (!followCompletionSettle.has(port)) return false;
785
+ const baseline = followCompletionSettleRows.get(port) ?? -1;
786
+ const current = countUserRows(port);
787
+ return baseline >= 0 && current >= 0 && current <= baseline;
788
+ }
789
+ /**
790
+ * Ports whose completion settle loop owns the follow. A settle loop drains,
791
+ * retires the pad and guards the cascade; an arm that primes while this is
792
+ * set with `active === false` (the settled-side static arm mounting in the
793
+ * very swap frame) must NOT seize leadership — the swap re-renders the node
794
+ * view, a fresh arm would otherwise steal the port mid-drain with
795
+ * `reservePx = ownedBottomSpace` (the pad!), re-materialize an equal runway
796
+ * through applyVisual, double-count the extent and fight the settle's own
797
+ * guard for the rest of the window. A streaming arm (active === true, the
798
+ * next turn) still takes over normally and the settle yields.
799
+ */
800
+ function readingAnchorOf(port) {
801
+ const flow = flowElementOf(port);
802
+ if (flow === null) return null;
803
+ let anchor = null;
804
+ for (const child of flow.children) {
805
+ if (!(child instanceof HTMLElement)) continue;
806
+ if (child.getAttribute("data-chat-flow-kind") === "assistant" || child.querySelector("[data-variant=\"think\"]") !== null) anchor = child;
807
+ }
808
+ return anchor ?? shiftSurfacesOf(port).at(-1) ?? null;
809
+ }
810
+ /**
811
+ * Measure the reading surface's screen delta since the last guard pass.
812
+ * Returns null when there is nothing comparable yet (first observation), or
813
+ * when the anchor changed identity in a way that is NOT the in-place
814
+ * live→settled replacement (a new turn's row joined — nothing jumped,
815
+ * re-seed). Compensation sites must store the POST-compensation held
816
+ * position via `holdGuardAnchor`, or the guard would read its own
817
+ * correction as a fresh jump and oscillate.
818
+ */
819
+ function measureReadingAnchor(port) {
820
+ const anchor = readingAnchorOf(port);
821
+ if (anchor === null) {
822
+ followGuardAnchors.delete(port);
823
+ return null;
824
+ }
825
+ const rect = anchor.getBoundingClientRect();
826
+ if (!(rect.width > 0 || rect.height > 0)) return null;
827
+ const top = rect.top;
828
+ const shift = currentShiftOf(anchor);
829
+ const pad = flowPadOf(port);
830
+ const scrollTop = port.scrollTop;
831
+ const scrollHeight = port.scrollHeight;
832
+ const flow = flowElementOf(port);
833
+ const index = flow === null ? -1 : [...flow.children].indexOf(anchor);
834
+ const stored = followGuardAnchors.get(port);
835
+ if (stored === void 0) {
836
+ followGuardAnchors.set(port, {
837
+ element: anchor,
838
+ top,
839
+ index,
840
+ shift,
841
+ pad,
842
+ scrollTop,
843
+ scrollHeight
844
+ });
845
+ return null;
846
+ }
847
+ if (Math.abs(scrollHeight - stored.scrollHeight) <= .5 && Math.abs(scrollTop - stored.scrollTop) > .5) {
848
+ followScrollLedgers.set(port, scrollTop);
849
+ followGuardAnchors.set(port, {
850
+ element: anchor,
851
+ top,
852
+ index,
853
+ shift,
854
+ pad,
855
+ scrollTop,
856
+ scrollHeight
857
+ });
858
+ return null;
859
+ }
860
+ const delta = top - stored.top - (shift - stored.shift) + (pad - stored.pad);
861
+ if (stored.element === anchor) {
862
+ followGuardAnchors.set(port, {
863
+ element: anchor,
864
+ top,
865
+ index,
866
+ shift,
867
+ pad,
868
+ scrollTop,
869
+ scrollHeight
870
+ });
871
+ return {
872
+ anchor,
873
+ index,
874
+ top,
875
+ delta
876
+ };
877
+ }
878
+ if (!stored.element.isConnected && stored.index === index) return {
879
+ anchor,
880
+ index,
881
+ top,
882
+ delta
883
+ };
884
+ followGuardAnchors.set(port, {
885
+ element: anchor,
886
+ top,
887
+ index,
888
+ shift,
889
+ pad,
890
+ scrollTop,
891
+ scrollHeight
892
+ });
893
+ return null;
894
+ }
895
+ /** Record the position the reader should keep seeing after a correction. */
896
+ function holdGuardAnchor(port, measured, heldTop) {
897
+ followGuardAnchors.set(port, {
898
+ element: measured.anchor,
899
+ index: measured.index,
900
+ top: heldTop,
901
+ shift: currentShiftOf(measured.anchor),
902
+ pad: flowPadOf(port),
903
+ scrollTop: port.scrollTop,
904
+ scrollHeight: port.scrollHeight
905
+ });
906
+ }
708
907
  /** Last runway offset seen per port, to rebase the extent when the margin size changes. */
709
908
  const followRunwayOffsetHistory = /* @__PURE__ */ new WeakMap();
710
909
  /** Last observed scroll floor per port, for the slack→overflow runway re-measure. */
711
910
  const followFloorHistory = /* @__PURE__ */ new WeakMap();
712
911
  /** One-shot flag: the transition frame must paint the full runway as baseline. */
713
912
  const followSlackTransition = /* @__PURE__ */ new WeakSet();
913
+ const followSettlePads = /* @__PURE__ */ new WeakMap();
914
+ /**
915
+ * Retired follower space lives as `padding-bottom` on the FLOW element — the
916
+ * one node the engine already owns styles on (flow-fill min-height) and the
917
+ * host never rewrites. Host completion commits routinely REPLACE row elements
918
+ * (live→settled swap re-keys the assistant row, the status row unmounts), and
919
+ * any engine space written on those rows dies with them, sinking the floor
920
+ * and slamming the pinned transcript for a frame. The flow survives.
921
+ */
922
+ function flowPadOf(port) {
923
+ return followSettlePads.get(port)?.px ?? 0;
924
+ }
925
+ /**
926
+ * After a loss is re-opened as pad, a registry entry whose element is no
927
+ * longer connected claims extent that no longer exists; drop it so the next
928
+ * `ensureRunway` re-measures fresh instead of double-counting.
929
+ */
930
+ function pruneDeadRunway(port) {
931
+ const runway = followRunways.get(port);
932
+ if (runway !== void 0 && !runway.element.isConnected) {
933
+ restoreRunway(port);
934
+ return true;
935
+ }
936
+ return false;
937
+ }
938
+ function setFlowPad(port, px) {
939
+ const flow = flowElementOf(port);
940
+ if (flow === null) return;
941
+ const existing = followSettlePads.get(port);
942
+ const original = existing?.original ?? flow.style.paddingBottom;
943
+ if (px <= .25) {
944
+ if (existing !== void 0) {
945
+ flow.style.paddingBottom = existing.original;
946
+ followSettlePads.delete(port);
947
+ }
948
+ return;
949
+ }
950
+ flow.style.paddingBottom = original === "" ? `${px}px` : `calc(${original} + ${px}px)`;
951
+ followSettlePads.set(port, {
952
+ element: flow,
953
+ original,
954
+ px
955
+ });
956
+ }
957
+ /** Extent the follower owns below the content: the live runway margin plus
958
+ * the retired completion pad. At adopt the pad is reclaimed into the fresh
959
+ * reservation (same frame, pre-paint), so the floor never steps. */
960
+ function ownedBottomSpaceOf(port) {
961
+ return runwayOffsetOf(port) + flowPadOf(port);
962
+ }
963
+ /**
964
+ * Completion-window diagnostics. Armed when a follower hands off to its
965
+ * settle loop; every engine decision and every externally-written scroll
966
+ * position inside the window prints one compact console line, so a live
967
+ * host session can be diffed against the lab without a debugger.
968
+ */
969
+ let followTraceUntilMs = 0;
970
+ function traceActive() {
971
+ return debugRuntime.isEnabled() && performance.now() < followTraceUntilMs;
972
+ }
973
+ function followTrace(event, detail) {
974
+ if (!traceActive()) return;
975
+ console.log(`[dsh-follow] ${event}`, JSON.stringify(detail));
976
+ }
977
+ function hostShOf(port) {
978
+ return port.scrollHeight;
979
+ }
714
980
  function invalidatePaintLimit(port) {
715
981
  followPaintLimits.delete(port);
716
982
  }
@@ -831,6 +1097,31 @@ window.__ModuleLoader__.load({
831
1097
  function runwayOffsetOf(port) {
832
1098
  return followRunways.get(port)?.offset ?? 0;
833
1099
  }
1100
+ /**
1101
+ * Move owned runway margin into the persistent flow pad without changing the
1102
+ * scroll extent. Returns the actual layout pixels removed from the margin.
1103
+ */
1104
+ function transferRunwayToFlowPad(port, requestedPx) {
1105
+ const runway = followRunways.get(port);
1106
+ if (runway === void 0 || requestedPx <= 0 || !runway.element.isConnected) return 0;
1107
+ const nextRequestedPx = Math.max(0, runway.requestedPx - requestedPx);
1108
+ const beforeOffset = runway.offset;
1109
+ const beforeHeight = port.scrollHeight;
1110
+ runway.element.style[runway.property] = nextRequestedPx <= .25 ? runway.original : runway.original === "" ? `${nextRequestedPx}px` : `calc(${runway.original} + ${nextRequestedPx}px)`;
1111
+ const nextOffset = Math.max(0, beforeOffset + port.scrollHeight - beforeHeight);
1112
+ const transferredPx = Math.max(0, beforeOffset - nextOffset);
1113
+ if (nextRequestedPx <= .25 || nextOffset <= .25) followRunways.delete(port);
1114
+ else followRunways.set(port, {
1115
+ ...runway,
1116
+ offset: nextOffset,
1117
+ requestedPx: nextRequestedPx
1118
+ });
1119
+ if (transferredPx > 0) {
1120
+ setFlowPad(port, flowPadOf(port) + transferredPx);
1121
+ invalidatePaintLimit(port);
1122
+ }
1123
+ return transferredPx;
1124
+ }
834
1125
  /** Available paint room below the last message before fixed conversation chrome. */
835
1126
  function safeShiftLimit(port, surfaces) {
836
1127
  const last = surfaces.at(-1);
@@ -859,6 +1150,13 @@ window.__ModuleLoader__.load({
859
1150
  return limit;
860
1151
  }
861
1152
  function setFollowScrollTop(port, nextTop) {
1153
+ const ledger = followScrollLedgers.get(port);
1154
+ if (port.getAttribute(FOLLOW_OWNED_ATTR) === null) port.setAttribute(FOLLOW_OWNED_ATTR, "active");
1155
+ if (ledger !== void 0 && traceActive() && Math.abs(port.scrollTop - ledger) > 1) followTrace("external-scroll", {
1156
+ from: Math.round(port.scrollTop),
1157
+ to: Math.round(nextTop),
1158
+ ledger: Math.round(ledger)
1159
+ });
862
1160
  if (Math.abs(port.scrollTop - nextTop) > .01) port.scrollTop = nextTop;
863
1161
  followScrollLedgers.set(port, port.scrollTop);
864
1162
  const ownedTop = String(port.scrollTop);
@@ -872,6 +1170,19 @@ window.__ModuleLoader__.load({
872
1170
  */
873
1171
  const followScrollLedgers = /* @__PURE__ */ new WeakMap();
874
1172
  const followActivityAt = /* @__PURE__ */ new WeakMap();
1173
+ /**
1174
+ * Scroll ownership must survive follower-arm remounts. Per-closure state let a
1175
+ * new text/tool arm reset the strike counter, so the same host write kept
1176
+ * triggering another engine write and repainting the visible up/down fight.
1177
+ */
1178
+ const followHostScrollPorts = /* @__PURE__ */ new WeakMap();
1179
+ /** Clear host ownership only when a new user turn actually starts. */
1180
+ function resetHostScrollOwnershipForNewTurn(port) {
1181
+ const ownership = followHostScrollPorts.get(port);
1182
+ if (ownership === void 0) return;
1183
+ const userRows = countUserRows(port);
1184
+ if (userRows >= 0 && userRows > ownership.userRows) followHostScrollPorts.delete(port);
1185
+ }
875
1186
  /** Whether this port was owned recently enough to identify a closing tail row. */
876
1187
  function hasRecentConversationFollow(port, windowMs = 250) {
877
1188
  const last = followActivityAt.get(port);
@@ -888,7 +1199,7 @@ window.__ModuleLoader__.load({
888
1199
  * same frame. Carrying that excess in `scrollTop` would move the transcript
889
1200
  * toward fixed chrome and also make the host expose jump-to-bottom.
890
1201
  */
891
- function applyVisual(port, animatedH, reservePx, velocityPxPerSec = 0, runwayPx = 72, shiftCeilingPx = Number.POSITIVE_INFINITY, promoteAtRest = false, trajectoryShiftPx) {
1202
+ function applyVisual(port, animatedH, reservePx, velocityPxPerSec = 0, runwayPx = 72, shiftCeilingPx = Number.POSITIVE_INFINITY, promoteAtRest = false, trajectoryShiftPx, dtMs = 16.7, writeScrollTop = true) {
892
1203
  const surfaces = shiftSurfacesOf(port);
893
1204
  ensureFlowFillsPort(port);
894
1205
  {
@@ -900,10 +1211,7 @@ window.__ModuleLoader__.load({
900
1211
  ensureRunway(port, surfaces, owned?.requestedPx ?? runwayPx);
901
1212
  followSlackTransition.add(port);
902
1213
  }
903
- if (preFloor !== lastFloorSeen) {
904
- invalidatePaintLimit(port);
905
- followFloorHistory.set(port, preFloor);
906
- }
1214
+ if (preFloor !== lastFloorSeen) followFloorHistory.set(port, preFloor);
907
1215
  }
908
1216
  ensureRunway(port, surfaces, reservePx);
909
1217
  const contentHeight2 = Math.max(0, port.scrollHeight);
@@ -920,7 +1228,7 @@ window.__ModuleLoader__.load({
920
1228
  if (port.style.scrollBehavior !== "auto") port.style.scrollBehavior = "auto";
921
1229
  if (floor <= 0) {
922
1230
  followRunwayOffsetHistory.set(port, 0);
923
- setFollowScrollTop(port, 0);
1231
+ if (writeScrollTop) setFollowScrollTop(port, 0);
924
1232
  followMotionStates.set(port, {
925
1233
  capacityPx: Number.POSITIVE_INFINITY,
926
1234
  constrained: false,
@@ -944,15 +1252,25 @@ window.__ModuleLoader__.load({
944
1252
  const motionShift = Math.min(trajectoryShiftPx ?? baselineShift + requestedLag, availableShift);
945
1253
  let shift = Math.max(motionShift, promoteAtRest && motionShift <= .01 && availableShift > 0 ? .1 : 0);
946
1254
  const previousShift = followLastShiftPx.get(port);
947
- if (previousShift !== void 0 && shift < previousShift - 8) shift = previousShift - 8;
1255
+ const previousFloor = followLastFloorPx.get(port);
1256
+ const floorDropPx = Math.max(0, (previousFloor ?? floor) - floor);
1257
+ const maxDecayPx = Math.max(dtMs <= 0 ? 8 : Math.max(1, 8 / 16.67 * dtMs), floorDropPx);
1258
+ followLastFloorPx.set(port, floor);
1259
+ if (previousShift !== void 0 && shift < previousShift - maxDecayPx) shift = previousShift - maxDecayPx;
1260
+ if (followCompletionSettle.has(port) && previousShift !== void 0 && previousFloor !== void 0) {
1261
+ const confirmedGrowthPx = Math.max(0, floor - previousFloor);
1262
+ if (shift > previousShift + confirmedGrowthPx) shift = previousShift + confirmedGrowthPx;
1263
+ }
948
1264
  followLastShiftPx.set(port, shift);
949
- const effectiveLag = Math.max(0, motionShift - baselineShift);
1265
+ const requestedShift = trajectoryShiftPx ?? baselineShift + requestedLag;
1266
+ const effectiveLag = Math.max(0, shift - baselineShift);
950
1267
  const capacityPx = Math.max(0, limit - baselineShift);
951
1268
  const effectiveExtent = targetHeight - effectiveLag;
952
- setFollowScrollTop(port, floor);
1269
+ const isConstrained = requestedShift > availableShift + .25 || limit <= 0 && requestedShift > baselineShift;
1270
+ if (writeScrollTop) setFollowScrollTop(port, floor);
953
1271
  followMotionStates.set(port, {
954
1272
  capacityPx,
955
- constrained: requestedLag > effectiveLag + FOLLOW_SETTLE_EPSILON_PX,
1273
+ constrained: isConstrained,
956
1274
  extent: effectiveExtent,
957
1275
  lagPx: effectiveLag,
958
1276
  reservePx: visibleReserve,
@@ -976,6 +1294,7 @@ window.__ModuleLoader__.load({
976
1294
  restoreRunway(port);
977
1295
  followMotionStates.delete(port);
978
1296
  followLastShiftPx.delete(port);
1297
+ followLastFloorPx.delete(port);
979
1298
  invalidatePaintLimit(port);
980
1299
  }
981
1300
  /** Keep an already-promoted surface at zero until one stable final paint lands. */
@@ -985,20 +1304,27 @@ window.__ModuleLoader__.load({
985
1304
  element.style.clipPath = "";
986
1305
  }
987
1306
  /** Remove equal offsets, land on the floor, then retire the compositor quietly. */
988
- function finishAtNaturalFloor(port, retainCompositor = true) {
1307
+ function finishAtNaturalFloor(port, retainCompositor = true, writeScrollTop = true) {
1308
+ followCompletionSettle.delete(port);
1309
+ followTraceUntilMs = Math.max(followTraceUntilMs, performance.now() + 1e4);
1310
+ followTrace("finish-enter", {
1311
+ sh: hostShOf(port),
1312
+ st: Math.round(port.scrollTop),
1313
+ pad: Math.round(flowPadOf(port)),
1314
+ retain: retainCompositor
1315
+ });
989
1316
  const surfaces = shiftSurfacesOf(port);
990
1317
  const status = turnStatusOf(port);
991
1318
  if (!retainCompositor) {
992
1319
  restoreRunway(port);
993
- settleAtFloor(port);
1320
+ if (writeScrollTop) settleAtFloor(port);
994
1321
  clearMotion(port);
995
1322
  followMotionStates.delete(port);
996
1323
  return;
997
1324
  }
998
1325
  const promoted = [...surfaces, ...status === null ? [] : [status]].filter((element) => element.style.transform !== "" || element.style.willChange === "transform");
999
1326
  const promotedSet = new Set(promoted);
1000
- restoreRunway(port);
1001
- settleAtFloor(port);
1327
+ if (writeScrollTop) settleAtFloor(port);
1002
1328
  port.removeAttribute(FOLLOW_OWNED_ATTR);
1003
1329
  port.style.overflowAnchor = "";
1004
1330
  port.style.scrollBehavior = "";
@@ -1023,6 +1349,8 @@ window.__ModuleLoader__.load({
1023
1349
  /** Only the newest active follower may write one port's shared visual state. */
1024
1350
  const followLeaders = /* @__PURE__ */ new WeakMap();
1025
1351
  let followGeneration = 0;
1352
+ /** Ports with a live streaming arm; completion guards must not fight them. */
1353
+ const followActivePorts = /* @__PURE__ */ new WeakSet();
1026
1354
  /**
1027
1355
  * Own the conversation scrollport's bottom-follow while `active` is true.
1028
1356
  *
@@ -1036,15 +1364,19 @@ window.__ModuleLoader__.load({
1036
1364
  * @param predictiveRef - Optional live visibility gate for predictive runway.
1037
1365
  * @param entranceExtentRef - Optional measured growth delta for a generic row.
1038
1366
  * @param revealedCharsRef - Committed code-point count for feed-forward phase.
1367
+ * @param controlScroll - When false, leave the scrollport entirely to the Host.
1039
1368
  */
1040
- function useConversationFollow(rootRef, active, speedCpsRef, revealScaleRef, predictive = true, entrance = false, onEntranceSettled, predictiveRef, entranceExtentRef, revealedCharsRef) {
1369
+ function useConversationFollow(rootRef, active, speedCpsRef, revealScaleRef, predictive = true, entrance = false, onEntranceSettled, predictiveRef, entranceExtentRef, revealedCharsRef, controlScroll = true) {
1041
1370
  const activeRef = (0, react.useRef)(active);
1042
1371
  const entranceRef = (0, react.useRef)(entrance);
1043
1372
  const onEntranceSettledRef = (0, react.useRef)(onEntranceSettled);
1044
1373
  entranceRef.current = entrance;
1045
1374
  onEntranceSettledRef.current = onEntranceSettled;
1046
1375
  activeRef.current = active;
1376
+ const controlScrollRef = (0, react.useRef)(controlScroll);
1377
+ controlScrollRef.current = controlScroll;
1047
1378
  (0, react.useLayoutEffect)(() => {
1379
+ if (!controlScroll) return;
1048
1380
  if (!active) return;
1049
1381
  const startedAsEntrance = entrance;
1050
1382
  const owner = {};
@@ -1063,8 +1395,10 @@ window.__ModuleLoader__.load({
1063
1395
  let interactTimer = null;
1064
1396
  let port = null;
1065
1397
  let resize = null;
1398
+ let mutations = null;
1066
1399
  let observedTail = null;
1067
1400
  let statusWasPresent = null;
1401
+ let lastStatusHeightPx = 0;
1068
1402
  let trajectoryPositionPx = null;
1069
1403
  let trajectoryVelocityPxPerMs = 0;
1070
1404
  let trajectoryTargetVelocityPxPerMs = 0;
@@ -1120,10 +1454,43 @@ window.__ModuleLoader__.load({
1120
1454
  if (holding === next && isLeader(next)) return;
1121
1455
  holding = next;
1122
1456
  const leader = followLeaders.get(next);
1123
- if (leader === void 0 || generation > leader.generation) followLeaders.set(next, {
1124
- generation,
1125
- owner
1457
+ if ((leader === void 0 || generation > leader.generation) && !completionSettleGuardsPort(next)) {
1458
+ followCompletionSettle.delete(next);
1459
+ followLeaders.set(next, {
1460
+ generation,
1461
+ owner
1462
+ });
1463
+ }
1464
+ };
1465
+ const yieldScrollOwnership = (next, floor) => {
1466
+ if (hostOwnsScroll) return;
1467
+ hostOwnsScroll = true;
1468
+ followHostScrollPorts.set(next, { userRows: countUserRows(next) });
1469
+ followTrace("yield-scroll", {
1470
+ from: Math.round(next.scrollTop),
1471
+ to: Math.round(floor)
1126
1472
  });
1473
+ clearVisual(next);
1474
+ setFollowScrollTop(next, Math.max(0, next.scrollHeight - next.clientHeight));
1475
+ next.removeAttribute(FOLLOW_OWNED_ATTR);
1476
+ followLeaders.delete(next);
1477
+ releaseRevealScale();
1478
+ debugRuntime.reportFollow(next, null);
1479
+ };
1480
+ const detectHostScroll = (next, floor) => {
1481
+ if (followHostScrollPorts.has(next)) {
1482
+ hostOwnsScroll = true;
1483
+ return;
1484
+ }
1485
+ const ledger = followScrollLedgers.get(next);
1486
+ if (ledger === void 0 || Math.abs(next.scrollTop - ledger) <= 1) return;
1487
+ if (Math.abs(next.scrollTop - floor) <= 1) {
1488
+ followScrollLedgers.set(next, next.scrollTop);
1489
+ externalScrollStrikes = 0;
1490
+ return;
1491
+ }
1492
+ externalScrollStrikes += 1;
1493
+ if (externalScrollStrikes >= 2) yieldScrollOwnership(next, floor);
1127
1494
  };
1128
1495
  const drop = (next) => {
1129
1496
  if (holding === next) holding = null;
@@ -1164,16 +1531,102 @@ window.__ModuleLoader__.load({
1164
1531
  interactTimer = null;
1165
1532
  }, 800);
1166
1533
  };
1534
+ /**
1535
+ * Last observed scroll extent, shared by the pre-paint correction and the
1536
+ * settle loop so a host layout shrink is re-opened exactly once no matter
1537
+ * which observer sees it first. `-1` until the first owned observation.
1538
+ */
1539
+ let settleRetiring = false;
1540
+ let hostOwnsScroll = false;
1541
+ let externalScrollStrikes = 0;
1542
+ Number.NEGATIVE_INFINITY;
1543
+ let handedOff = false;
1544
+ /**
1545
+ * Bring the reading surface back down toward its held position after an
1546
+ * upward host push (clamp jump, live→settled swap): spend persistent pad
1547
+ * first — lowering the floor lets the pin carry the text back down —
1548
+ * then raise the compositor shift for whatever pad cannot cover, and
1549
+ * rebase the spring extent so the raise survives the next applyVisual
1550
+ * (which otherwise recomputes the shift from lag and undoes it).
1551
+ */
1552
+ const pullReadingAnchorBack = (host, measured, trace = false) => {
1553
+ holdGuardAnchor(host, measured, measured.top);
1554
+ };
1555
+ /**
1556
+ * THE screen-space anchor hold, shared by every entry point: the
1557
+ * structural-observer path (pre-paint cascade correction), the settle
1558
+ * loop's per-frame poll, and the ACTIVE loop's per-frame poll. The
1559
+ * shift-excluded delta filters the engine's own motion (reveal glide,
1560
+ * wrap lockstep) to ~0, so a live poll may compensate safely — which is
1561
+ * what saves the completion swap: the settled-side arm primes in its
1562
+ * layout effect and steals leadership IN the cascade frame, its own
1563
+ * observers miss the mutations (armed after the fact), and only this
1564
+ * poll sees the jump while it can still be corrected before paint.
1565
+ * Returns the measured delta (null when nothing was comparable).
1566
+ */
1567
+ const enforceReadingAnchor = (host, trace = false) => {
1568
+ const measured = measureReadingAnchor(host);
1569
+ if (measured === null) return null;
1570
+ if (measured.delta > .5) {
1571
+ if (trace) followTrace("anchor-hold", {
1572
+ screenDelta: Math.round(measured.delta),
1573
+ st: Math.round(host.scrollTop)
1574
+ });
1575
+ if (pruneDeadRunway(host)) reservePx = 0;
1576
+ holdGuardAnchor(host, measured, measured.top);
1577
+ } else if (measured.delta < -.5) {
1578
+ if (pruneDeadRunway(host)) reservePx = 0;
1579
+ pullReadingAnchorBack(host, measured, trace);
1580
+ } else holdGuardAnchor(host, measured, measured.top);
1581
+ return measured.delta;
1582
+ };
1583
+ /** Pre-paint correction (observers, commit subscription). */
1167
1584
  const restoreBeforePaint = () => {
1168
- if (!following || port === null || !isLeader(port)) return;
1585
+ if (!following || port === null) return;
1586
+ if (hostOwnsScroll || followHostScrollPorts.has(port)) {
1587
+ hostOwnsScroll = true;
1588
+ followScrollLedgers.set(port, port.scrollTop);
1589
+ return;
1590
+ }
1591
+ if (!activeRef.current) detectHostScroll(port, Math.max(0, port.scrollHeight - port.clientHeight));
1592
+ const leaderless = !isLeader(port);
1593
+ if (!activeRef.current && followActivePorts.has(port)) return;
1594
+ if (leaderless && (followLeaders.has(port) || !handedOff)) return;
1595
+ if (leaderless) {
1596
+ const sharedMotion = followMotionStates.get(port);
1597
+ if (sharedMotion !== void 0) {
1598
+ animatedH = Math.min(port.scrollHeight, Math.max(0, sharedMotion.extent));
1599
+ reservePx = sharedMotion.reservePx;
1600
+ velocityPxPerSec = sharedMotion.velocityPxPerSec;
1601
+ }
1602
+ }
1603
+ pruneDeadRunway(port);
1604
+ if (!settleRetiring) {
1605
+ const measured = measureReadingAnchor(port);
1606
+ if (measured !== null && measured.delta > .5) {
1607
+ if (pruneDeadRunway(port)) reservePx = 0;
1608
+ holdGuardAnchor(port, measured, measured.top);
1609
+ } else if (measured !== null && measured.delta < -.5) {
1610
+ if (pruneDeadRunway(port)) reservePx = 0;
1611
+ if (!activeRef.current) pullReadingAnchorBack(port, measured);
1612
+ else {
1613
+ const released = Math.min(flowPadOf(port), -measured.delta);
1614
+ if (released > .25) {
1615
+ setFlowPad(port, flowPadOf(port) - released);
1616
+ animatedH = Math.max(0, animatedH - released);
1617
+ }
1618
+ holdGuardAnchor(port, measured, measured.top + released);
1619
+ }
1620
+ } else if (measured !== null) holdGuardAnchor(port, measured, measured.top);
1621
+ }
1169
1622
  const tuning = debugRuntime.activeTuning();
1170
1623
  const predictGrowth = predictiveRef?.current ?? predictive;
1171
1624
  const floor = Math.max(0, port.scrollHeight - port.clientHeight);
1172
1625
  if (followFloorHistory.get(port) !== floor) invalidatePaintLimit(port);
1173
1626
  const isReasoningSurface = rootRef.current?.querySelector("[data-variant=\"think\"]") !== null;
1174
1627
  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);
1628
+ animatedH = applyVisual(port, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx, activeRef.current ? Number.POSITIVE_INFINITY : followLastShiftPx.get(port) ?? Number.POSITIVE_INFINITY, !predictGrowth, trajectoryShift, 0, activeRef.current && !hostOwnsScroll);
1629
+ if (trajectoryShift !== void 0) trajectoryPositionPx = floor - (followLastShiftPx.get(port) ?? floor - (trajectoryPositionPx ?? floor));
1177
1630
  updateRevealScale(port, 0, true);
1178
1631
  reportFollow(port, activeRef.current);
1179
1632
  };
@@ -1183,6 +1636,7 @@ window.__ModuleLoader__.load({
1183
1636
  if (port !== null) {
1184
1637
  for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture);
1185
1638
  resize?.disconnect();
1639
+ mutations?.disconnect();
1186
1640
  }
1187
1641
  unsubscribeCommit?.();
1188
1642
  port = next;
@@ -1192,11 +1646,23 @@ window.__ModuleLoader__.load({
1192
1646
  });
1193
1647
  for (const name of GESTURE_EVENTS) port.addEventListener(name, markGesture, { passive: true });
1194
1648
  if (typeof ResizeObserver !== "undefined") {
1195
- resize = new ResizeObserver(restoreBeforePaint);
1649
+ resize = new ResizeObserver(() => restoreBeforePaint());
1196
1650
  resize.observe(port);
1197
1651
  const proxy = resizeProxyOf(port);
1198
1652
  if (proxy !== null) resize.observe(proxy);
1199
1653
  }
1654
+ if (typeof MutationObserver !== "undefined") {
1655
+ const flow = flowElementOf(port);
1656
+ if (flow !== null) {
1657
+ mutations = new MutationObserver(() => {
1658
+ restoreBeforePaint();
1659
+ });
1660
+ mutations.observe(flow, {
1661
+ childList: true,
1662
+ subtree: true
1663
+ });
1664
+ }
1665
+ }
1200
1666
  };
1201
1667
  /**
1202
1668
  * Keep the observer on the flow's TAIL surface. A flow locked to the
@@ -1224,17 +1690,28 @@ window.__ModuleLoader__.load({
1224
1690
  if (nextPort === null) return;
1225
1691
  bindPort(nextPort);
1226
1692
  observeTailSurface();
1693
+ resetHostScrollOwnershipForNewTurn(nextPort);
1694
+ hostOwnsScroll = followHostScrollPorts.has(nextPort);
1695
+ if (activeRef.current) followActivePorts.add(nextPort);
1696
+ else followActivePorts.delete(nextPort);
1227
1697
  if (nextPort.clientHeight <= 0) return;
1228
- const reportedLag = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight) - nextPort.scrollTop;
1698
+ const floor = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight);
1699
+ const reportedLag = floor - nextPort.scrollTop;
1229
1700
  const extent = Math.min(nextPort.scrollHeight, Math.max(0, nextPort.scrollHeight - reportedLag));
1230
1701
  if (!primed) {
1702
+ if (completionSettleGuardsPort(nextPort)) {
1703
+ primed = true;
1704
+ following = false;
1705
+ return;
1706
+ }
1231
1707
  const inherited = nextPort.hasAttribute(FOLLOW_OWNED_ATTR) ? followMotionStates.get(nextPort) : void 0;
1232
1708
  if (inherited === void 0) {
1233
1709
  const entranceExtent = entrancePending ? entranceExtentRef?.current ?? entranceExtentOf(root) : 0;
1234
1710
  const predictGrowth = predictiveRef?.current ?? predictive;
1235
1711
  animatedH = entrancePending ? Math.max(0, nextPort.scrollHeight - entranceExtent) : nextPort.scrollHeight;
1236
1712
  const hasStatus = turnStatusOf(nextPort) !== null;
1237
- reservePx = predictGrowth && (hasStatus || speedCpsRef.current > 90) ? computeFollowReserve(speedCpsRef.current, tuning.runwayPx) : 0;
1713
+ reservePx = Math.max(ownedBottomSpaceOf(nextPort), predictGrowth && (hasStatus || speedCpsRef.current > 90) ? computeFollowReserve(speedCpsRef.current, tuning.runwayPx) : 0);
1714
+ if (!completionSettleGuardsPort(nextPort)) setFlowPad(nextPort, 0);
1238
1715
  statusWasPresent = hasStatus;
1239
1716
  velocityPxPerSec = 0;
1240
1717
  following = !readerScrolledUp(nextPort) && !followReaderHolds.has(nextPort);
@@ -1247,7 +1724,7 @@ window.__ModuleLoader__.load({
1247
1724
  if (following) {
1248
1725
  hold(nextPort);
1249
1726
  if (isLeader(nextPort)) {
1250
- animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx, Number.POSITIVE_INFINITY, !(predictiveRef?.current ?? predictive));
1727
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx, Number.POSITIVE_INFINITY, !(predictiveRef?.current ?? predictive), void 0, 0, !hostOwnsScroll);
1251
1728
  if (predictive && root.querySelector("[data-variant=\"think\"]") === null && runwayOffsetOf(nextPort) > 0) {
1252
1729
  const floor = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight);
1253
1730
  const requestedMinLagPx = Math.max(20, runwayOffsetOf(nextPort) - 32);
@@ -1299,20 +1776,27 @@ window.__ModuleLoader__.load({
1299
1776
  reportFollow(nextPort, activeRef.current);
1300
1777
  return;
1301
1778
  }
1779
+ detectHostScroll(nextPort, floor);
1780
+ if (hostOwnsScroll) {
1781
+ followScrollLedgers.set(nextPort, nextPort.scrollTop);
1782
+ reportFollow(nextPort, false);
1783
+ return;
1784
+ }
1302
1785
  hold(nextPort);
1303
1786
  if (!isLeader(nextPort)) {
1304
1787
  finishEntrance();
1305
1788
  return;
1306
1789
  }
1307
1790
  const predictGrowth = predictiveRef?.current ?? predictive;
1308
- const hasStatus = turnStatusOf(nextPort) !== null;
1791
+ const statusElement = turnStatusOf(nextPort);
1792
+ const hasStatus = statusElement !== null;
1793
+ if (statusElement !== null) lastStatusHeightPx = statusElement.offsetHeight;
1309
1794
  const statusJustRemoved = predictGrowth && statusWasPresent === true && !hasStatus;
1310
- if (statusJustRemoved) reservePx = tuning.runwayPx;
1795
+ if (statusJustRemoved) reservePx = Math.max(reservePx, tuning.runwayPx) + lastStatusHeightPx;
1311
1796
  statusWasPresent = hasStatus;
1312
1797
  const reserveEnabled = hasStatus || statusJustRemoved || reservePx > .25 || speedCpsRef.current > 90;
1313
1798
  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;
1799
+ const effectiveReserveTarget = Math.max(reservePx, pressureReserveTarget);
1316
1800
  const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs);
1317
1801
  reservePx += (effectiveReserveTarget - reservePx) * reserveStep;
1318
1802
  if (predictGrowth || runwayOffsetOf(nextPort) > .5) ensureRunway(nextPort, shiftSurfacesOf(nextPort), reservePx);
@@ -1386,23 +1870,45 @@ window.__ModuleLoader__.load({
1386
1870
  animatedH = contentHeight - runwayOffset;
1387
1871
  velocityPxPerSec = 0;
1388
1872
  } else {
1389
- const minimumLag = predictGrowth ? 0 : Math.max(0, reservePx);
1390
- animatedH = Math.min(contentHeight - runwayOffset - minimumLag, animatedH + step.advancePx);
1873
+ animatedH = Math.min(contentHeight - runwayOffset, animatedH + step.advancePx);
1391
1874
  velocityPxPerSec = step.velocityPxPerSec;
1392
1875
  }
1393
1876
  }
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);
1877
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx, Number.POSITIVE_INFINITY, !predictGrowth, trajectoryShift, elapsedMs, !hostOwnsScroll);
1878
+ if (trajectoryActive) trajectoryPositionPx = floorNow - (followLastShiftPx.get(nextPort) ?? trajectoryShift ?? 0);
1396
1879
  updateRevealScale(nextPort, elapsedMs);
1397
1880
  reportFollow(nextPort, true);
1881
+ if (isLeader(nextPort)) {
1882
+ if (!activeRef.current) enforceReadingAnchor(nextPort);
1883
+ else measureReadingAnchor(nextPort);
1884
+ }
1398
1885
  if (Math.max(0, nextPort.scrollHeight - animatedH - runwayOffsetOf(nextPort)) <= .25) finishEntrance();
1399
1886
  };
1400
1887
  frame(performance.now());
1401
1888
  return () => {
1889
+ if (!controlScrollRef.current) {
1890
+ cancelAnimationFrame(rafId);
1891
+ if (port !== null) followActivePorts.delete(port);
1892
+ unsubscribeCommit?.();
1893
+ resize?.disconnect();
1894
+ mutations?.disconnect();
1895
+ if (port !== null) for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture);
1896
+ if (interactTimer !== null) clearTimeout(interactTimer);
1897
+ const disabledHost = rootRef.current?.closest("[data-conversation-scroll]") ?? port;
1898
+ if (disabledHost !== null) {
1899
+ clearVisual(disabledHost);
1900
+ followLeaders.delete(disabledHost);
1901
+ debugRuntime.reportFollow(disabledHost, null);
1902
+ }
1903
+ releaseRevealScale();
1904
+ return;
1905
+ }
1402
1906
  cancelAnimationFrame(rafId);
1907
+ if (port !== null) followActivePorts.delete(port);
1403
1908
  unsubscribeCommit?.();
1404
1909
  if (interactTimer !== null) clearTimeout(interactTimer);
1405
1910
  resize?.disconnect();
1911
+ mutations?.disconnect();
1406
1912
  if (port !== null) for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture);
1407
1913
  const host = rootRef.current?.closest("[data-conversation-scroll]") ?? port;
1408
1914
  if (host === null) return;
@@ -1426,8 +1932,18 @@ window.__ModuleLoader__.load({
1426
1932
  debugRuntime.reportFollow(host, null);
1427
1933
  return;
1428
1934
  }
1935
+ let settleQuietMs = 0;
1936
+ let settleSig = "";
1429
1937
  const lagBeforeCompletionPaint = Math.max(0, host.scrollHeight - animatedH - runwayOffsetOf(host));
1430
- if (!activeRef.current && lagBeforeCompletionPaint <= 25) {
1938
+ const currentRunway = runwayOffsetOf(host);
1939
+ const currentShift = Math.abs(currentShiftOf(shiftSurfacesOf(host).at(-1) ?? host));
1940
+ if (!activeRef.current && lagBeforeCompletionPaint <= 25 && currentRunway <= .25 && currentShift <= .25 && flowPadOf(host) <= .25) {
1941
+ followTraceUntilMs = Math.max(followTraceUntilMs, performance.now() + 1e4);
1942
+ followTrace("fast-gate", {
1943
+ sh: host.scrollHeight,
1944
+ st: Math.round(host.scrollTop),
1945
+ pad: Math.round(flowPadOf(host))
1946
+ });
1431
1947
  finishAtNaturalFloor(host, !startedAsEntrance);
1432
1948
  followLeaders.delete(host);
1433
1949
  releaseRevealScale();
@@ -1435,19 +1951,37 @@ window.__ModuleLoader__.load({
1435
1951
  return;
1436
1952
  }
1437
1953
  const completionShift = currentShiftOf(shiftSurfacesOf(host).at(-1) ?? host);
1438
- const completionShiftCeiling = completionShift > .25 ? completionShift : Number.POSITIVE_INFINITY;
1954
+ const completionShiftCeiling = startedAsEntrance && completionShift <= .25 ? Number.POSITIVE_INFINITY : completionShift;
1955
+ if (hostOwnsScroll) {
1956
+ clearVisual(host);
1957
+ setFollowScrollTop(host, Math.max(0, host.scrollHeight - host.clientHeight));
1958
+ host.removeAttribute(FOLLOW_OWNED_ATTR);
1959
+ followLeaders.delete(host);
1960
+ releaseRevealScale();
1961
+ debugRuntime.reportFollow(host, null);
1962
+ return;
1963
+ }
1964
+ followTraceUntilMs = performance.now() + 15e3;
1965
+ if (pruneDeadRunway(host)) {
1966
+ followTrace("cleanup-dead-margin", {
1967
+ sh: host.scrollHeight,
1968
+ st: Math.round(host.scrollTop),
1969
+ pad: Math.round(flowPadOf(host))
1970
+ });
1971
+ reservePx = 0;
1972
+ }
1439
1973
  const completionTuning = debugRuntime.activeTuning();
1440
1974
  const previousCompletionRunway = runwayOffsetOf(host);
1441
- ensureRunway(host, shiftSurfacesOf(host), Math.max(reservePx, completionTuning.runwayPx));
1975
+ ensureRunway(host, shiftSurfacesOf(host), Math.max(reservePx, Math.max(0, completionTuning.runwayPx - flowPadOf(host))));
1442
1976
  const completionRunway = runwayOffsetOf(host);
1977
+ measureReadingAnchor(host);
1443
1978
  animatedH = Math.max(0, animatedH - (completionRunway - previousCompletionRunway));
1444
- settleAtFloor(host);
1445
- animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, completionRunway, completionShiftCeiling);
1979
+ if (!hostOwnsScroll) settleAtFloor(host);
1980
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, completionRunway, completionShiftCeiling, false, void 0, 0, !hostOwnsScroll);
1446
1981
  reportFollow(host, false);
1447
1982
  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);
1983
+ if (Math.max(0, host.scrollHeight - animatedH - runwayOffset) <= .25 && runwayOffset <= .25 && reservePx <= .25) {
1984
+ finishAtNaturalFloor(host, !startedAsEntrance, !hostOwnsScroll);
1451
1985
  followLeaders.delete(host);
1452
1986
  releaseRevealScale();
1453
1987
  debugRuntime.reportFollow(host, null);
@@ -1456,14 +1990,35 @@ window.__ModuleLoader__.load({
1456
1990
  for (const name of GESTURE_EVENTS) host.addEventListener(name, markGesture, { passive: true });
1457
1991
  const stopSettleListeners = () => {
1458
1992
  for (const name of GESTURE_EVENTS) host.removeEventListener(name, markGesture);
1993
+ resize?.disconnect();
1994
+ mutations?.disconnect();
1459
1995
  if (interactTimer !== null) {
1460
1996
  clearTimeout(interactTimer);
1461
1997
  interactTimer = null;
1462
1998
  }
1463
1999
  };
2000
+ if (typeof ResizeObserver !== "undefined") {
2001
+ resize = new ResizeObserver(() => restoreBeforePaint());
2002
+ resize.observe(host);
2003
+ const proxy = resizeProxyOf(host);
2004
+ if (proxy !== null) resize.observe(proxy);
2005
+ }
2006
+ if (typeof MutationObserver !== "undefined") {
2007
+ const flow = flowElementOf(host);
2008
+ if (flow !== null) {
2009
+ mutations = new MutationObserver(() => {
2010
+ restoreBeforePaint();
2011
+ });
2012
+ mutations.observe(flow, {
2013
+ childList: true,
2014
+ subtree: true
2015
+ });
2016
+ }
2017
+ }
2018
+ followCompletionSettle.add(host);
2019
+ followCompletionSettleRows.set(host, countUserRows(host));
2020
+ handedOff = true;
1464
2021
  let settleLast = performance.now();
1465
- let settleMarginPx = Math.max(runwayOffsetOf(host), reservePx);
1466
- let settleMarginRate = settleMarginPx / 160;
1467
2022
  const settleFrame = (now) => {
1468
2023
  if (!isLeader(host)) {
1469
2024
  stopSettleListeners();
@@ -1473,6 +2028,7 @@ window.__ModuleLoader__.load({
1473
2028
  readerGestureIntent = false;
1474
2029
  handBackVisual(host);
1475
2030
  clearVisual(host);
2031
+ followCompletionSettle.delete(host);
1476
2032
  followLeaders.delete(host);
1477
2033
  releaseRevealScale();
1478
2034
  debugRuntime.reportFollow(host, null);
@@ -1482,19 +2038,56 @@ window.__ModuleLoader__.load({
1482
2038
  const dt = Math.min(32, Math.max(0, now - settleLast));
1483
2039
  const tuning = debugRuntime.activeTuning();
1484
2040
  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);
2041
+ detectHostScroll(host, Math.max(0, host.scrollHeight - host.clientHeight));
2042
+ if (hostOwnsScroll) {
2043
+ clearVisual(host);
2044
+ setFollowScrollTop(host, Math.max(0, host.scrollHeight - host.clientHeight));
2045
+ host.removeAttribute(FOLLOW_OWNED_ATTR);
2046
+ followCompletionSettle.delete(host);
2047
+ followLeaders.delete(host);
2048
+ releaseRevealScale();
2049
+ debugRuntime.reportFollow(host, null);
2050
+ stopSettleListeners();
2051
+ return;
2052
+ }
2053
+ const guardDelta = !settleRetiring && !followActivePorts.has(host) ? enforceReadingAnchor(host, true) : null;
2054
+ settleQuietMs = !settleRetiring && (guardDelta === null || Math.abs(guardDelta) <= .5) ? settleQuietMs + dt : 0;
2055
+ const settleStatus = turnStatusOf(host);
2056
+ const ownedRunwayPx = runwayOffsetOf(host);
2057
+ if (ownedRunwayPx > .25) {
2058
+ const requestedTransferPx = settleStatus === null ? ownedRunwayPx : Math.min(reservePx, (tuning.runwayPx || 72) / FOLLOW_RUNWAY_RETIRE_MS * dt);
2059
+ const transferredPx = transferRunwayToFlowPad(host, requestedTransferPx);
2060
+ if (transferredPx > 0) setFlowPad(host, Math.max(0, flowPadOf(host) - transferredPx));
2061
+ reservePx = Math.max(0, reservePx - transferredPx);
2062
+ animatedH += transferredPx;
1489
2063
  }
1490
2064
  const runwayOffset = runwayOffsetOf(host);
1491
2065
  const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset);
1492
- if (lag <= .25 && settleMarginPx <= .25) {
2066
+ if (!settleRetiring && lag <= .25 && reservePx <= .25 && settleQuietMs >= 240 && flowPadOf(host) > .25) {
2067
+ followTrace("retire-start", {
2068
+ pad: Math.round(flowPadOf(host)),
2069
+ sh: host.scrollHeight,
2070
+ st: Math.round(host.scrollTop)
2071
+ });
2072
+ settleRetiring = true;
2073
+ }
2074
+ if (settleRetiring) {
2075
+ const padPx = flowPadOf(host);
2076
+ const retirePx = Math.min(padPx, (tuning.runwayPx || 72) / FOLLOW_RUNWAY_RETIRE_MS * dt);
2077
+ if (padPx - retirePx <= .25) {
2078
+ setFlowPad(host, 0);
2079
+ settleRetiring = false;
2080
+ } else setFlowPad(host, padPx - retirePx);
2081
+ }
2082
+ if (lag <= .25 && (reservePx <= .25 || settleStatus === null) && flowPadOf(host) <= .25 && settleQuietMs >= 240) {
2083
+ followTrace("finish", {
2084
+ st: Math.round(host.scrollTop),
2085
+ sh: host.scrollHeight,
2086
+ pad: Math.round(flowPadOf(host))
2087
+ });
1493
2088
  animatedH = host.scrollHeight;
1494
- reservePx = 0;
1495
2089
  velocityPxPerSec = 0;
1496
- followRunways.delete(host);
1497
- finishAtNaturalFloor(host, !startedAsEntrance);
2090
+ finishAtNaturalFloor(host, !startedAsEntrance, !hostOwnsScroll);
1498
2091
  followLeaders.delete(host);
1499
2092
  releaseRevealScale();
1500
2093
  debugRuntime.reportFollow(host, null);
@@ -1508,8 +2101,22 @@ window.__ModuleLoader__.load({
1508
2101
  }, tuning);
1509
2102
  animatedH = Math.min(host.scrollHeight - runwayOffset, animatedH + step.advancePx);
1510
2103
  velocityPxPerSec = step.velocityPxPerSec;
1511
- settleAtFloor(host);
1512
- animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, Math.max(settleMarginPx, runwayOffset));
2104
+ if (!hostOwnsScroll) settleAtFloor(host);
2105
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, runwayOffset, Number.POSITIVE_INFINITY, false, void 0, 0, !hostOwnsScroll);
2106
+ if (traceActive()) {
2107
+ const sig = `${Math.round(host.scrollTop)}|${host.scrollHeight}|${Math.round(flowPadOf(host))}|${Math.round(reservePx)}|${settleRetiring}`;
2108
+ if (sig !== settleSig) {
2109
+ followTrace("settle", {
2110
+ st: Math.round(host.scrollTop),
2111
+ sh: host.scrollHeight,
2112
+ pad: Math.round(flowPadOf(host)),
2113
+ reserve: Math.round(reservePx),
2114
+ lag: Math.round(lag * 10) / 10,
2115
+ retiring: settleRetiring
2116
+ });
2117
+ settleSig = sig;
2118
+ }
2119
+ }
1513
2120
  reportFollow(host, false);
1514
2121
  requestAnimationFrame(settleFrame);
1515
2122
  };
@@ -1521,7 +2128,8 @@ window.__ModuleLoader__.load({
1521
2128
  speedCpsRef,
1522
2129
  revealScaleRef,
1523
2130
  predictive,
1524
- predictiveRef
2131
+ predictiveRef,
2132
+ controlScroll
1525
2133
  ]);
1526
2134
  (0, react.useLayoutEffect)(() => {
1527
2135
  const host = rootRef.current?.closest("[data-conversation-scroll]") ?? null;
@@ -2003,10 +2611,10 @@ window.__ModuleLoader__.load({
2003
2611
  * generic wrapped renderers re-arm one glide when their DOM grows without
2004
2612
  * requiring a business-kind-specific lifecycle predicate.
2005
2613
  */
2006
- function FollowHost({ active, entrance = false, onEntranceSettled, onGrowth, entranceExtentRef, speedCpsRef, revealedCharsRef, revealScaleRef, predictive = true, predictiveRef, hostRef, children }) {
2614
+ function FollowHost({ active, entrance = false, onEntranceSettled, onGrowth, entranceExtentRef, speedCpsRef, revealedCharsRef, revealScaleRef, predictive = true, predictiveRef, controlScroll = true, hostRef, className, entranceActive, children }) {
2007
2615
  const localRootRef = (0, react.useRef)(null);
2008
2616
  const rootRef = hostRef ?? localRootRef;
2009
- useConversationFollow(rootRef, active || entrance, speedCpsRef, revealScaleRef, predictive, entrance, onEntranceSettled, predictiveRef, entranceExtentRef, revealedCharsRef);
2617
+ useConversationFollow(rootRef, active || entrance, speedCpsRef, revealScaleRef, predictive, entrance, onEntranceSettled, predictiveRef, entranceExtentRef, revealedCharsRef, controlScroll);
2010
2618
  (0, react.useEffect)(() => {
2011
2619
  if (onGrowth === void 0 || typeof ResizeObserver === "undefined") return;
2012
2620
  const root = rootRef.current;
@@ -2039,7 +2647,8 @@ window.__ModuleLoader__.load({
2039
2647
  }, [onGrowth]);
2040
2648
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2041
2649
  ref: rootRef,
2042
- className: TypewriterAssistantNodeView_module_css_default.follow,
2650
+ className: className === void 0 ? TypewriterAssistantNodeView_module_css_default.follow : `${TypewriterAssistantNodeView_module_css_default.follow} ${className}`,
2651
+ "data-entrance": entranceActive === void 0 ? void 0 : entranceActive ? "active" : "idle",
2043
2652
  children
2044
2653
  });
2045
2654
  }
@@ -2277,7 +2886,7 @@ window.__ModuleLoader__.load({
2277
2886
  * drains, the settled full parse (KaTeX math, fence highlighting, file
2278
2887
  * mentions) swaps in exactly once.
2279
2888
  */
2280
- function AnimatedMarkdownText({ text, codeLabels, fileMentions, streaming, ownFollow, followSpeedCpsRef, followRevealedCharsRef, followRevealScaleRef, onPredictiveChange, preset, shouldHoldBack }) {
2889
+ function AnimatedMarkdownText({ text, labels, fileMentions, streaming, ownFollow, followSpeedCpsRef, followRevealedCharsRef, followRevealScaleRef, onPredictiveChange, preset, shouldHoldBack, controlScroll = true }) {
2281
2890
  const reduced = usePrefersReducedMotion();
2282
2891
  const [typing, setTyping] = (0, react.useState)(streaming);
2283
2892
  const localSpeedCpsRef = (0, react.useRef)(35);
@@ -2340,11 +2949,12 @@ window.__ModuleLoader__.load({
2340
2949
  revealedCharsRef: followRevealedCharsRef,
2341
2950
  revealScaleRef: followRevealScaleRef,
2342
2951
  predictive: streaming,
2952
+ controlScroll,
2343
2953
  hostRef: followRootRef,
2344
2954
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MarkdownText, {
2345
2955
  text: live ? shown : text,
2346
2956
  streaming: live,
2347
- codeLabels,
2957
+ labels,
2348
2958
  fileMentions: live ? void 0 : fileMentions
2349
2959
  })
2350
2960
  });
@@ -2460,7 +3070,7 @@ window.__ModuleLoader__.load({
2460
3070
  * holds offscreen reveals when the frame rate is degraded. Settled text
2461
3071
  * renders with the full Markdown pipeline.
2462
3072
  */
2463
- const TypewriterAssistantNodeView = (0, react.memo)(function TypewriterAssistantNodeView({ mode: _mode = DEFAULT_STREAM_CONFIG.mode, preset = DEFAULT_STREAM_CONFIG.preset, revealCharsPerSec: _revealCharsPerSec = DEFAULT_STREAM_CONFIG.revealCharsPerSec, scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec, maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec, thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand, node, useTurnData, openFile, loadImage, fileMentions, t }) {
3073
+ const TypewriterAssistantNodeView = (0, react.memo)(function TypewriterAssistantNodeView({ mode: _mode = DEFAULT_STREAM_CONFIG.mode, preset = DEFAULT_STREAM_CONFIG.preset, revealCharsPerSec: _revealCharsPerSec = DEFAULT_STREAM_CONFIG.revealCharsPerSec, scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec, maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec, thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand, controlScroll = true, node, useTurnData, openFile, loadImage, fileMentions, t }) {
2464
3074
  const data = node.data;
2465
3075
  const streaming = data.status === "running";
2466
3076
  const reduced = usePrefersReducedMotion();
@@ -2497,9 +3107,12 @@ window.__ModuleLoader__.load({
2497
3107
  turn
2498
3108
  ]);
2499
3109
  const mentions = (0, react.useMemo)(() => owner === void 0 ? void 0 : fileMentions(owner), [fileMentions, owner]);
2500
- const codeLabels = (0, react.useMemo)(() => ({
2501
- copyLabel: t("copy"),
2502
- copiedLabel: t("copied")
3110
+ const markdownLabels = (0, react.useMemo)(() => ({
3111
+ code: {
3112
+ copyLabel: t("copy"),
3113
+ copiedLabel: t("copied")
3114
+ },
3115
+ footnotes: t("markdown.footnotes")
2503
3116
  }), [t]);
2504
3117
  const imageLoader = loadImage ?? (async () => {
2505
3118
  throw new Error(t("image.serviceUnavailable"));
@@ -2520,7 +3133,7 @@ window.__ModuleLoader__.load({
2520
3133
  case "text":
2521
3134
  rendered.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnimatedMarkdownText, {
2522
3135
  text: block.text,
2523
- codeLabels,
3136
+ labels: markdownLabels,
2524
3137
  fileMentions: mentions,
2525
3138
  streaming,
2526
3139
  ownFollow: !streaming && index === lastFollow,
@@ -2529,7 +3142,8 @@ window.__ModuleLoader__.load({
2529
3142
  followRevealScaleRef: index === lastFollow ? rootRevealScaleRef : void 0,
2530
3143
  onPredictiveChange: index === lastFollow ? updateTextPrediction : void 0,
2531
3144
  preset,
2532
- shouldHoldBack
3145
+ shouldHoldBack,
3146
+ controlScroll
2533
3147
  }, index));
2534
3148
  break;
2535
3149
  case "reasoning":
@@ -2582,6 +3196,7 @@ window.__ModuleLoader__.load({
2582
3196
  revealedCharsRef: rootRevealedCharsRef,
2583
3197
  revealScaleRef: rootRevealScaleRef,
2584
3198
  predictiveRef: rootPredictiveRef,
3199
+ controlScroll,
2585
3200
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2586
3201
  className: TypewriterAssistantNodeView_module_css_default.body,
2587
3202
  children: [rendered, data.status === "interrupted" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -2593,867 +3208,6 @@ window.__ModuleLoader__.load({
2593
3208
  });
2594
3209
  });
2595
3210
  //#endregion
2596
- //#region src/client/auto-collapse-controller.ts
2597
- /**
2598
- * Auto-collapse coordinator: folds the work process of finished turns behind
2599
- * one summary row so only the model's final answer stays visible.
2600
- *
2601
- * The Harness ChatView lays a conversation out as a flat list of flow items
2602
- * (`[data-chat-flow] > [data-chat-flow-kind=…]`). This controller watches that
2603
- * list, splits it into turn segments at `user`/`steering` boundaries closed by
2604
- * `turn-tail`, and — once a segment has settled — hides its thinking rows,
2605
- * tool/command/context seats and intermediate replies, leaving one clickable
2606
- * `已处理 {时长}` row plus the final answer. Clicking the row expands the full
2607
- * process again; the choice survives later renders because it lives in the
2608
- * controller, not the DOM.
2609
- *
2610
- * Adapted from the standalone `dsh-auto-collapse` plugin (level-1 folding
2611
- * only): no second-level chips, no merged thinking rows, no status-text
2612
- * rewrite, no transition animation — the collapse decision itself stays
2613
- * instantaneous and reversible.
2614
- *
2615
- * React coexistence follows the upstream design: injected rows are plain
2616
- * siblings/prepend children the vdom diff never claims, native rows are only
2617
- * ever touched through an inline-`display` ledger that records the precise
2618
- * original value and restores it on stop, session switch, or foreign takeover
2619
- * (a written-value comparison plus an ownership sentinel catches another
2620
- * script rewriting styles behind our back). A MutationObserver batches real
2621
- * structure changes into rAF passes; a low-frequency self-rearming audit pass
2622
- * reconciles unobservable style writes; a setTimeout fallback keeps passes
2623
- * flowing when background tabs suspend rAF.
2624
- */
2625
- const STYLE_ID = "dshss-auto-collapse-style";
2626
- /** Class of the injected per-turn summary row; the follow engine's generalized
2627
- * surface walk picks it up as an ordinary foreign flow sibling. */
2628
- const PROCESSED_CLASS = "dshss-processed";
2629
- /** Inline custom property marking elements whose display this plugin owns. */
2630
- const DISPLAY_OWNED_PROP = "--dshss-display-owned";
2631
- /** Unobservable external style writes are reconciled by this audit cadence. */
2632
- const AUDIT_TICK_MS = 1e3;
2633
- /** Background-tab fallback that flushes a scheduled pass when rAF is frozen. */
2634
- const PASS_FALLBACK_MS = 60;
2635
- /**
2636
- * Fold motion, deliberately minimal: layout changes land synchronously (the
2637
- * display toggle happens in the same tick as the decision) and nothing
2638
- * animates geometry at all — the earlier container-height squeeze was removed
2639
- * because real sessions showed residual drift after the pin released (other
2640
- * layout participants keep settling around the fold). What remains is one
2641
- * paint-only flourish: the summary row fades its opacity in. A fold is
2642
- * therefore a single reflow followed by a fade — the most stable shape
2643
- * possible.
2644
- */
2645
- const FOLD_ROW_FADE_MS = 160;
2646
- const FOLD_EASE_OUT = "cubic-bezier(0.22, 0.61, 0.36, 1)";
2647
- const ZH_COPY = {
2648
- processedLabel: "已处理",
2649
- expandTitle: "展开工作过程",
2650
- collapseTitle: "收起工作过程",
2651
- formatDuration: formatZhDuration
2652
- };
2653
- const EN_COPY = {
2654
- processedLabel: "Processed",
2655
- expandTitle: "Show the work process",
2656
- collapseTitle: "Hide the work process",
2657
- formatDuration: formatEnDuration
2658
- };
2659
- function resolveFoldCopy() {
2660
- let lang = "";
2661
- try {
2662
- lang = document.documentElement.lang || navigator.language || "";
2663
- } catch {
2664
- lang = "";
2665
- }
2666
- return lang.toLowerCase().startsWith("zh") ? ZH_COPY : EN_COPY;
2667
- }
2668
- const FOLD_CSS = `
2669
- .${PROCESSED_CLASS} {
2670
- box-sizing: border-box;
2671
- display: inline-flex;
2672
- align-self: flex-start;
2673
- width: fit-content;
2674
- max-width: 100%;
2675
- align-items: center;
2676
- gap: 6px;
2677
- padding: 2px 0;
2678
- border: none;
2679
- background: none;
2680
- font: 400 14px/24px system-ui, -apple-system, "Segoe UI", sans-serif;
2681
- color: var(--dsw-alias-label-tertiary, rgba(127, 127, 127, 0.9));
2682
- cursor: pointer;
2683
- user-select: none;
2684
- border-radius: 4px;
2685
- transition: color 0.15s ease;
2686
- }
2687
- .${PROCESSED_CLASS}:hover {
2688
- color: var(--dsw-alias-label-primary, inherit);
2689
- background: transparent;
2690
- }
2691
- .${PROCESSED_CLASS}:focus-visible {
2692
- outline: 2px solid var(--dsw-alias-state-focus-ring, rgba(77, 107, 254, 0.8));
2693
- outline-offset: 2px;
2694
- }
2695
- .${PROCESSED_CLASS}-chevron {
2696
- display: inline-flex;
2697
- flex: none;
2698
- width: 14px;
2699
- height: 14px;
2700
- opacity: 0.55;
2701
- transform: rotate(-90deg);
2702
- transition: transform 0.12s ease, opacity 0.1s ease;
2703
- }
2704
- .${PROCESSED_CLASS}:hover .${PROCESSED_CLASS}-chevron {
2705
- opacity: 0.9;
2706
- }
2707
- .${PROCESSED_CLASS}[aria-expanded="true"] .${PROCESSED_CLASS}-chevron {
2708
- transform: rotate(0deg);
2709
- }
2710
- @media (prefers-reduced-motion: reduce) {
2711
- .${PROCESSED_CLASS},
2712
- .${PROCESSED_CLASS}-chevron {
2713
- transition: none;
2714
- }
2715
- }
2716
- `;
2717
- var AutoCollapseController = class {
2718
- observer = null;
2719
- raf = 0;
2720
- timer = 0;
2721
- auditTimer = 0;
2722
- /** True between start() and stop(); the preference toggle re-enters both. */
2723
- running = false;
2724
- lastPassError = "";
2725
- followDeferredAt = null;
2726
- flow = null;
2727
- /** Stable segment key → summary row + gesture state. */
2728
- segmentStates = /* @__PURE__ */ new Map();
2729
- /** First sighting of a running segment, for turns without a host duration. */
2730
- runningSince = /* @__PURE__ */ new Map();
2731
- /** Segments that settled once: a resumed run restarts local timing. */
2732
- completedOnce = /* @__PURE__ */ new Set();
2733
- /** Precise pre-plugin display values, restored on stop/switch/takeover. */
2734
- originalDisplay = /* @__PURE__ */ new WeakMap();
2735
- writtenDisplay = /* @__PURE__ */ new WeakMap();
2736
- controlledDisplay = /* @__PURE__ */ new Set();
2737
- /** Reply-presence verdicts cached per message until a mutation hits it. */
2738
- bodyTextCache = /* @__PURE__ */ new WeakMap();
2739
- dirtyMessages = /* @__PURE__ */ new Set();
2740
- onVisibilityChange = () => {
2741
- if (typeof document === "undefined" || document.hidden !== true) this.schedule();
2742
- };
2743
- /** Re-run a pass immediately (settings changes, diagnostics, tests).
2744
- * Reply-presence verdicts are dropped wholesale: manual refreshes are rare,
2745
- * and a conservative rebuild beats a stale classification. */
2746
- refresh() {
2747
- this.bodyTextCache = /* @__PURE__ */ new WeakMap();
2748
- this.dirtyMessages.clear();
2749
- this.schedule();
2750
- }
2751
- start() {
2752
- if (this.running) return;
2753
- this.running = true;
2754
- injectStyle();
2755
- try {
2756
- this.observer = new MutationObserver((records) => {
2757
- if (this.shouldSchedule(records)) {
2758
- this.markDirty(records);
2759
- this.schedule();
2760
- }
2761
- });
2762
- this.observer.observe(document.body, {
2763
- childList: true,
2764
- subtree: true,
2765
- attributes: true,
2766
- attributeFilter: ["data-state"],
2767
- characterData: true
2768
- });
2769
- this.armAuditLoop();
2770
- this.schedule();
2771
- } catch (error) {
2772
- this.reportError(error);
2773
- throw error;
2774
- }
2775
- }
2776
- stop() {
2777
- this.running = false;
2778
- this.followDeferredAt = null;
2779
- if (this.raf !== 0) cancelAnimationFrame(this.raf);
2780
- this.raf = 0;
2781
- if (this.timer !== 0) clearTimeout(this.timer);
2782
- this.timer = 0;
2783
- if (this.auditTimer !== 0) clearTimeout(this.auditTimer);
2784
- this.auditTimer = 0;
2785
- if (typeof document !== "undefined" && typeof document.removeEventListener === "function") document.removeEventListener("visibilitychange", this.onVisibilityChange);
2786
- this.observer?.disconnect();
2787
- this.observer = null;
2788
- this.switchFlow(null);
2789
- removeStyle();
2790
- }
2791
- /** Low-frequency reconciliation for style writes no observer can see. */
2792
- armAuditLoop() {
2793
- if (typeof document !== "undefined" && typeof document.addEventListener === "function") document.addEventListener("visibilitychange", this.onVisibilityChange);
2794
- this.rearmAudit();
2795
- }
2796
- rearmAudit() {
2797
- if (!this.running || this.auditTimer !== 0) return;
2798
- this.auditTimer = setTimeout(() => {
2799
- this.auditTimer = 0;
2800
- if (!this.running) return;
2801
- if (typeof document !== "undefined" && document.hidden === true) {
2802
- this.rearmAudit();
2803
- return;
2804
- }
2805
- this.schedule();
2806
- this.rearmAudit();
2807
- }, AUDIT_TICK_MS);
2808
- }
2809
- /** Only mutations inside the active flow can affect collapse geometry. */
2810
- shouldSchedule(records) {
2811
- if (records.length === 0 || this.flow === null || !this.flow.isConnected) return true;
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
- });
2819
- }
2820
- /** Walk each record to its flow child so reply-cache invalidation stays
2821
- * targeted; anything unattributable invalidates conservatively. */
2822
- markDirty(records) {
2823
- const flow = this.flow;
2824
- if (flow === null || !flow.isConnected) return;
2825
- if (records.length === 0) {
2826
- this.bodyTextCache = /* @__PURE__ */ new WeakMap();
2827
- this.dirtyMessages.clear();
2828
- return;
2829
- }
2830
- for (const record of records) {
2831
- let current = record.target;
2832
- while (current !== null && current.parentNode !== flow) current = current.parentNode;
2833
- if (!(current instanceof HTMLElement)) {
2834
- this.bodyTextCache = /* @__PURE__ */ new WeakMap();
2835
- this.dirtyMessages.clear();
2836
- return;
2837
- }
2838
- this.dirtyMessages.add(current);
2839
- }
2840
- }
2841
- schedule() {
2842
- if (!this.running || this.raf !== 0) return;
2843
- this.raf = requestAnimationFrame(() => {
2844
- this.raf = 0;
2845
- if (this.timer !== 0) {
2846
- clearTimeout(this.timer);
2847
- this.timer = 0;
2848
- }
2849
- this.runPass();
2850
- });
2851
- if (this.timer !== 0) clearTimeout(this.timer);
2852
- this.timer = setTimeout(() => {
2853
- this.timer = 0;
2854
- if (this.raf !== 0) {
2855
- cancelAnimationFrame(this.raf);
2856
- this.raf = 0;
2857
- this.runPass();
2858
- }
2859
- }, PASS_FALLBACK_MS);
2860
- }
2861
- runPass() {
2862
- try {
2863
- this.pass();
2864
- this.lastPassError = "";
2865
- } catch (error) {
2866
- this.reportError(error);
2867
- }
2868
- }
2869
- reportError(error) {
2870
- const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
2871
- if (message === this.lastPassError) return;
2872
- this.lastPassError = message;
2873
- console.error("[dsh-smooth-stream] auto-collapse pass failed", error);
2874
- }
2875
- pass() {
2876
- if (!this.running) return;
2877
- const nextFlow = findFlow();
2878
- if (nextFlow !== this.flow) this.switchFlow(nextFlow);
2879
- const flow = this.flow;
2880
- if (flow === null) return;
2881
- for (const el of this.dirtyMessages) this.bodyTextCache.delete(el);
2882
- this.dirtyMessages.clear();
2883
- const segments = buildSegments(flow, (el) => this.hasBodyCached(el));
2884
- const liveSegmentKeys = new Set(segments.map((segment) => segment.key));
2885
- for (const segment of segments) {
2886
- if (!segment.running) continue;
2887
- if (this.completedOnce.has(segment.key)) {
2888
- this.completedOnce.delete(segment.key);
2889
- this.runningSince.delete(segment.key);
2890
- }
2891
- if (!this.runningSince.has(segment.key)) this.runningSince.set(segment.key, Date.now());
2892
- }
2893
- const completedKeys = /* @__PURE__ */ new Set();
2894
- for (const segment of segments) {
2895
- if (!segment.closed || segment.running || !segment.hasWork) continue;
2896
- completedKeys.add(segment.key);
2897
- this.completedOnce.add(segment.key);
2898
- let state = this.segmentStates.get(segment.key);
2899
- if (state === void 0) {
2900
- state = {
2901
- key: segment.key,
2902
- row: null,
2903
- expanded: false,
2904
- snapshot: segment
2905
- };
2906
- this.segmentStates.set(segment.key, state);
2907
- } else state.snapshot = segment;
2908
- const started = this.runningSince.get(segment.key);
2909
- const parsed = segment.boundary === null ? void 0 : parseTurnDuration(segment.boundary);
2910
- if (parsed !== void 0) state.duration = parsed;
2911
- else if (state.duration === void 0 && started !== void 0) state.duration = Date.now() - started;
2912
- if (state.row === null || !state.row.isConnected) state.row = this.createProcessedRow(state);
2913
- this.syncProcessedRow(state);
2914
- }
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;
2922
- }
2923
- }
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) {
2937
- const state = this.segmentStates.get(segment.key);
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
- }
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;
2969
- }
2970
- }
2971
- for (const key of [...this.runningSince.keys()]) if (!liveSegmentKeys.has(key)) this.runningSince.delete(key);
2972
- for (const key of [...this.completedOnce]) if (!liveSegmentKeys.has(key)) this.completedOnce.delete(key);
2973
- }
2974
- /** Flow swap means a session switch: fully restore the old tree, rebuild. */
2975
- switchFlow(next) {
2976
- if (next === this.flow) return;
2977
- for (const state of this.segmentStates.values()) state.row?.remove();
2978
- this.segmentStates.clear();
2979
- this.runningSince.clear();
2980
- this.completedOnce.clear();
2981
- this.bodyTextCache = /* @__PURE__ */ new WeakMap();
2982
- this.dirtyMessages.clear();
2983
- this.restoreAllDisplays();
2984
- this.flow = next;
2985
- }
2986
- /** Keep already-controlled elements in the desired-hidden set so the sweep
2987
- * does not flip them back mid-flight of an external change. */
2988
- retainDisplayControl(el, desiredHidden) {
2989
- if (this.controlledDisplay.has(el)) desiredHidden.add(el);
2990
- }
2991
- /** Restore every element the ledger controls that this pass no longer wants
2992
- * hidden; elements absent from `desired` come back to their true display. */
2993
- restoreUnusedDisplays(desired) {
2994
- for (const el of [...this.controlledDisplay]) if (!desired.has(el)) this.restoreElement(el);
2995
- }
2996
- /** Restore every element still under the display ledger, then retire the
2997
- * ledger itself (stop / session switch). */
2998
- restoreAllDisplays() {
2999
- for (const el of [...this.controlledDisplay]) this.restoreElement(el);
3000
- this.controlledDisplay.clear();
3001
- this.originalDisplay = /* @__PURE__ */ new WeakMap();
3002
- this.writtenDisplay = /* @__PURE__ */ new WeakMap();
3003
- }
3004
- createProcessedRow(state) {
3005
- const row = createProcessedRowElement(state.duration);
3006
- row.addEventListener("click", () => {
3007
- state.expanded = !state.expanded;
3008
- this.syncProcessedRow(state);
3009
- this.schedule();
3010
- });
3011
- return row;
3012
- }
3013
- syncProcessedRow(state) {
3014
- const row = state.row;
3015
- if (row === null) return;
3016
- const copy = resolveFoldCopy();
3017
- const text = row.firstElementChild;
3018
- const label = state.duration === void 0 ? copy.processedLabel : `${copy.processedLabel} ${copy.formatDuration(state.duration)}`;
3019
- if (text !== null && text.textContent !== label) text.textContent = label;
3020
- const expanded = String(state.expanded);
3021
- if (row.getAttribute("aria-expanded") !== expanded) row.setAttribute("aria-expanded", expanded);
3022
- const title = state.expanded ? copy.collapseTitle : copy.expandTitle;
3023
- if (row.title !== title) row.title = title;
3024
- }
3025
- placeProcessedRow(flow, state) {
3026
- const row = state.row;
3027
- if (row === null) return;
3028
- if (!state.snapshot.hasWork || !hasVisibleSegmentWork(state.snapshot)) {
3029
- row.remove();
3030
- state.row = null;
3031
- return;
3032
- }
3033
- let target = state.snapshot.firstWork ?? state.snapshot.finalStep ?? state.snapshot.boundary;
3034
- if (target === null || target.parentElement !== flow) {
3035
- row.remove();
3036
- state.row = null;
3037
- return;
3038
- }
3039
- if (row.parentElement !== flow || row.nextElementSibling !== target) {
3040
- target.before(row);
3041
- this.revealVisualOnce(row);
3042
- }
3043
- }
3044
- hasBodyCached(el) {
3045
- const cached = this.bodyTextCache.get(el);
3046
- if (cached !== void 0) return cached;
3047
- const value = hasBodyContent(el);
3048
- this.bodyTextCache.set(el, value);
3049
- return value;
3050
- }
3051
- /** True when the recorded display was changed (or the ownership sentinel
3052
- * wiped) behind the ledger's back; callers hand control back to reality. */
3053
- displayForeign(el) {
3054
- const written = this.writtenDisplay.get(el);
3055
- if (written === void 0) return false;
3056
- return el.style.getPropertyValue(DISPLAY_OWNED_PROP) === "" || el.style.display !== written;
3057
- }
3058
- releaseDisplayLedger(el) {
3059
- this.originalDisplay.delete(el);
3060
- this.writtenDisplay.delete(el);
3061
- this.controlledDisplay.delete(el);
3062
- el.style.removeProperty(DISPLAY_OWNED_PROP);
3063
- }
3064
- hideElement(el, desired) {
3065
- desired.add(el);
3066
- if (!this.originalDisplay.has(el) && !isDisplayed(el)) return;
3067
- if (!this.originalDisplay.has(el) || this.displayForeign(el)) {
3068
- this.originalDisplay.set(el, el.style.display);
3069
- this.writtenDisplay.set(el, el.style.display);
3070
- el.style.setProperty(DISPLAY_OWNED_PROP, "1");
3071
- }
3072
- this.controlledDisplay.add(el);
3073
- if (el.style.display === "none") return;
3074
- el.style.display = "none";
3075
- this.writtenDisplay.set(el, "none");
3076
- }
3077
- restoreElement(el) {
3078
- if (!this.originalDisplay.has(el)) return;
3079
- if (this.displayForeign(el)) {
3080
- this.releaseDisplayLedger(el);
3081
- return;
3082
- }
3083
- const original = this.originalDisplay.get(el);
3084
- if (el.style.display !== original) el.style.display = original;
3085
- this.releaseDisplayLedger(el);
3086
- }
3087
- /** Whether WAAPI exists and the user has not asked for reduced motion. */
3088
- canAnimate(el) {
3089
- if (typeof el.animate !== "function") return false;
3090
- try {
3091
- if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches) return false;
3092
- } catch {
3093
- return false;
3094
- }
3095
- return true;
3096
- }
3097
- /** First-appearance flourish for the plugin-owned summary row: a pure
3098
- * opacity fade. Paint-only, so it cannot shift anyone's layout. */
3099
- revealVisualOnce(row) {
3100
- if (row.dataset.dshssShown === "1") return;
3101
- row.dataset.dshssShown = "1";
3102
- if (!this.canAnimate(row)) return;
3103
- row.animate([{ opacity: "0" }, { opacity: "1" }], {
3104
- duration: FOLD_ROW_FADE_MS,
3105
- easing: FOLD_EASE_OUT
3106
- });
3107
- }
3108
- };
3109
- /** Visible chat flow container, falling back to the first one rendered. */
3110
- function findFlow() {
3111
- const flows = document.querySelectorAll("[data-chat-flow]");
3112
- for (const flow of flows) if (flow.offsetParent !== null || flow.getBoundingClientRect().width > 0) return flow;
3113
- return flows[0] ?? null;
3114
- }
3115
- /** parentNode walk that also accepts Text mutation targets. */
3116
- function nodeWithin(node, ancestor) {
3117
- for (let current = node; current !== null; current = current.parentNode) if (current === ancestor) return true;
3118
- return false;
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
- }
3137
- /** Top-level message order, excluding this plugin's own summary rows. */
3138
- function flowItems(flow) {
3139
- return [...flow.children].filter((el) => el instanceof HTMLElement && !el.classList.contains(PROCESSED_CLASS));
3140
- }
3141
- function isDisplayed(el) {
3142
- if (typeof getComputedStyle === "function") return getComputedStyle(el).display !== "none";
3143
- return el.style.display !== "none";
3144
- }
3145
- function stableElementKey(el, fallbackIndex) {
3146
- const kind = el.getAttribute("data-chat-flow-kind") ?? "node";
3147
- return `${kind}:${el.getAttribute("data-chat-flow-key") ?? el.getAttribute("data-chat-anchor-key") ?? `${kind}:${fallbackIndex}`}`;
3148
- }
3149
- /** Kinds that can open a history stretch lacking an explicit user marker. */
3150
- function opensTurnWork(el) {
3151
- const kind = el.getAttribute("data-chat-flow-kind");
3152
- return kind === "assistant-step" || kind === "assistant" || kind === "tool-call" || kind === "command" || kind === "manual-compaction";
3153
- }
3154
- /**
3155
- * Split the flow into turn segments. `user`/`steering` rows close the previous
3156
- * segment and open the next; `turn-tail` closes and resets the opener. Leading
3157
- * context before the first user row joins that user's segment, matching how
3158
- * the host presents context injection.
3159
- */
3160
- function buildSegments(flow, hasBody) {
3161
- const items = flowItems(flow);
3162
- const itemIndex = new Map(items.map((el, index) => [el, index]));
3163
- const snapshots = [];
3164
- let contentStart = 0;
3165
- let startMarker = null;
3166
- const append = (end, boundary, closed) => {
3167
- if (end < contentStart) return;
3168
- const range = items.slice(contentStart, end);
3169
- const hideSeats = /* @__PURE__ */ new Set();
3170
- const bodySteps = [];
3171
- let running = false;
3172
- for (const el of range) {
3173
- const classified = classifyItem(el, hasBody);
3174
- if (classified.fold === "body") bodySteps.push(el);
3175
- else if (classified.fold === "seat") hideSeats.add(el);
3176
- if (classified.running) running = true;
3177
- }
3178
- const finalStep = bodySteps.at(-1) ?? null;
3179
- const middleSteps = new Set(bodySteps.slice(0, -1));
3180
- for (const step of middleSteps) hideSeats.add(step);
3181
- const finalThinkRows = finalStep === null ? [] : thinkRowsIn(finalStep);
3182
- const firstWork = range.find((el) => hideSeats.has(el)) ?? finalStep;
3183
- const identity = startMarker ?? range.find((el) => opensTurnWork(el)) ?? boundary;
3184
- const identityIndex = identity === null ? contentStart : itemIndex.get(identity) ?? contentStart;
3185
- const key = `${startMarker === null ? "leading" : "segment"}:${identity === null ? `open:${contentStart}` : stableElementKey(identity, identityIndex)}`;
3186
- snapshots.push({
3187
- key,
3188
- boundary,
3189
- startMarker,
3190
- hideSeats,
3191
- middleSteps,
3192
- finalStep,
3193
- finalThinkRows,
3194
- firstWork,
3195
- closed,
3196
- running,
3197
- hasWork: hideSeats.size > 0 || finalThinkRows.length > 0
3198
- });
3199
- };
3200
- items.forEach((el, index) => {
3201
- const kind = el.getAttribute("data-chat-flow-kind");
3202
- if (kind === "user" || kind === "steering") {
3203
- if (startMarker !== null) {
3204
- append(index, el, true);
3205
- contentStart = index + 1;
3206
- } else if (items.slice(contentStart, index).some((el) => opensTurnWork(el))) {
3207
- append(index, el, true);
3208
- contentStart = index + 1;
3209
- }
3210
- startMarker = el;
3211
- return;
3212
- }
3213
- if (kind === "turn-tail") {
3214
- append(index, el, true);
3215
- contentStart = index + 1;
3216
- startMarker = null;
3217
- }
3218
- });
3219
- if (contentStart < items.length) append(items.length, null, false);
3220
- return snapshots;
3221
- }
3222
- /**
3223
- * Classify one top-level flow item against the Harness's own node vocabulary.
3224
- * Reply-bearing assistant messages become `body` candidates (last one per
3225
- * segment wins as the final answer); core work process — tool-call seats,
3226
- * command/compaction cards, context injection, thinking-only messages —
3227
- * becomes a hideable `seat`. Everything else is skipped untouched: boundaries,
3228
- * empty decorations, and crucially any kind this module does not own, so rows
3229
- * contributed by other plugins' slots are never folded or hidden.
3230
- */
3231
- function classifyItem(el, hasBody) {
3232
- const kind = el.getAttribute("data-chat-flow-kind");
3233
- if (kind === "user" || kind === "steering" || kind === "turn-tail") return {
3234
- fold: "skip",
3235
- running: false
3236
- };
3237
- if (kind === "assistant-step" || kind === "assistant") {
3238
- if (hasBody(el)) return {
3239
- fold: "body",
3240
- running: subtreeRunning(el)
3241
- };
3242
- if (thinkRowsIn(el).length > 0) return {
3243
- fold: "seat",
3244
- running: subtreeRunning(el)
3245
- };
3246
- return {
3247
- fold: "skip",
3248
- running: false
3249
- };
3250
- }
3251
- if (kind === "tool-call") {
3252
- const rows = topCallRowsIn(el);
3253
- if (rows.length === 0 || !rows.every((row) => hasNativeCardChrome(row))) return {
3254
- fold: "skip",
3255
- running: subtreeRunning(el)
3256
- };
3257
- return {
3258
- fold: "seat",
3259
- running: subtreeRunning(el)
3260
- };
3261
- }
3262
- if (kind === "context" || kind === "command" || kind === "manual-compaction") return {
3263
- fold: "seat",
3264
- running: subtreeRunning(el)
3265
- };
3266
- return {
3267
- fold: "skip",
3268
- running: false
3269
- };
3270
- }
3271
- /** Native work-card shape: every core card family stamps `data-variant`
3272
- * together with `data-state`, whether composed through ToolRow or hand-rolled
3273
- * (Bash). Custom toolviews use their own namespaced attributes instead. */
3274
- function hasNativeCardChrome(row) {
3275
- return row.querySelector("[data-variant][data-state]") !== null;
3276
- }
3277
- /** Whether anything inside the item reports a running lifecycle. Deliberately
3278
- * broad: staying "running" too long only delays a collapse, collapsing early
3279
- * would cut off live output. Native cards stamp `data-state` next to
3280
- * `data-tool`/`data-variant`; custom views use their own vocabulary and are
3281
- * intentionally invisible here. */
3282
- function subtreeRunning(el) {
3283
- if (el.getAttribute("data-state") === "running") return true;
3284
- for (const root of el.querySelectorAll("[data-tool][data-state], [data-variant][data-state]")) if (root.getAttribute("data-state") === "running") return true;
3285
- return false;
3286
- }
3287
- function hasVisibleSegmentWork(segment) {
3288
- const candidates = [...segment.hideSeats, ...segment.middleSteps];
3289
- if (segment.startMarker !== null) candidates.push(segment.startMarker);
3290
- if (segment.finalStep !== null) candidates.push(segment.finalStep);
3291
- return candidates.some(isDisplayed);
3292
- }
3293
- /** Top-level tool card rows of a seat (excluding sub-dispatch nests). */
3294
- function topCallRowsIn(el) {
3295
- const rows = [];
3296
- for (const row of el.querySelectorAll("[data-chat-call-id]")) {
3297
- if (row.closest("[data-subcalls]") !== null) continue;
3298
- if (row.closest("[data-chat-call-id]") !== row) continue;
3299
- rows.push(row);
3300
- }
3301
- return rows;
3302
- }
3303
- /** Reasoning rows of a message: native think variants outside tool cards. */
3304
- function thinkRowsIn(el) {
3305
- const rows = [];
3306
- for (const row of el.querySelectorAll("[data-variant=\"think\"]:not([data-tool])")) {
3307
- if (row.closest("[data-chat-call-id]") !== null) continue;
3308
- if (row.closest("[data-subcalls]") !== null) continue;
3309
- rows.push(row);
3310
- }
3311
- return rows;
3312
- }
3313
- /**
3314
- * Reply detection beyond reasoning rows and tool cards: any non-empty text or
3315
- * media counts, because Markdown paragraphs necessarily carry text nodes.
3316
- * Rendered markdown class names are build-time hashes, so structure-based
3317
- * detection is the only stable contract.
3318
- */
3319
- function hasBodyContent(el) {
3320
- const kind = el.getAttribute("data-chat-flow-kind");
3321
- if (kind === "command" || kind === "manual-compaction") return false;
3322
- if (hasBodyText(el)) return true;
3323
- const excluded = "[data-variant=\"think\"], [data-chat-call-id]";
3324
- for (const media of el.querySelectorAll("img, video, audio, canvas")) if (media.closest(excluded) === null) return true;
3325
- return false;
3326
- }
3327
- function hasBodyText(el) {
3328
- const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
3329
- let node;
3330
- while ((node = walker.nextNode()) !== null) {
3331
- if (node.data.trim() === "") continue;
3332
- const parent = node.parentElement;
3333
- if (parent !== null && parent.closest("[data-variant=\"think\"], [data-chat-call-id], [data-variant=\"others\"][data-state]") !== null) continue;
3334
- return true;
3335
- }
3336
- return false;
3337
- }
3338
- /**
3339
- * Official turn duration from the closing element: localized tails read
3340
- * `用时 33秒` / `用时 2分05秒` or `Ran for 33s` / `Ran for 2m 05s`; newer
3341
- * tails only carry an end timestamp, which is diffed against the opening user
3342
- * row's timestamp.
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
- }
3357
- function parseTurnDuration(boundary) {
3358
- const text = boundary.textContent ?? "";
3359
- const zh = text.match(/用时\s*(\d+)分(\d+)秒|用时\s*(\d+)秒/);
3360
- if (zh !== null) {
3361
- if (zh[1] !== void 0 && zh[2] !== void 0) return Number(zh[1]) * 6e4 + Number(zh[2]) * 1e3;
3362
- if (zh[3] !== void 0) return Number(zh[3]) * 1e3;
3363
- return;
3364
- }
3365
- const en = text.match(/Ran for\s*(?:(\d+)m\s*)?(\d+)s/);
3366
- if (en !== null) return ((en[1] === void 0 ? 0 : Number(en[1])) * 60 + Number(en[2])) * 1e3;
3367
- const end = parseTimeText(text);
3368
- const start = findTurnStart(boundary);
3369
- if (end !== void 0 && start !== void 0 && end > start) return end - start;
3370
- }
3371
- /** DSH timestamp text (`8月14日 21:56` / `2026年8月14日 22:11`) → epoch ms. */
3372
- function parseTimeText(text) {
3373
- const m = text.match(/(?:(\d{4})年)?(\d{1,2})月(\d{1,2})日\s*(\d{1,2}):(\d{2})/);
3374
- if (m === null) return void 0;
3375
- const year = m[1] !== void 0 ? Number(m[1]) : (/* @__PURE__ */ new Date()).getFullYear();
3376
- const t = new Date(year, Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5])).getTime();
3377
- return Number.isNaN(t) ? void 0 : t;
3378
- }
3379
- /** Latest turn-opening timestamp at or before the boundary element. */
3380
- function findTurnStart(boundary) {
3381
- const flow = boundary.parentElement;
3382
- if (flow === null) return void 0;
3383
- let best = null;
3384
- for (const s of flow.querySelectorAll("[class*=\"timeStart\"]")) {
3385
- const pos = s.compareDocumentPosition(boundary);
3386
- if ((pos & Node.DOCUMENT_POSITION_FOLLOWING) !== 0 || (pos & Node.DOCUMENT_POSITION_CONTAINED_BY) !== 0 || s === boundary) best = s;
3387
- else break;
3388
- }
3389
- if (best === null) return void 0;
3390
- return parseTimeText(best.textContent ?? "");
3391
- }
3392
- /** Compact duration pieces shared by both locales: seconds under a minute,
3393
- * whole minutes drop the seconds, hours drop to minute granularity. */
3394
- function durationParts(ms) {
3395
- const s = Math.round(ms / 1e3);
3396
- return {
3397
- h: Math.floor(s / 3600),
3398
- m: Math.floor(s % 3600 / 60),
3399
- s: s % 60
3400
- };
3401
- }
3402
- /** 毫秒 → 中文紧凑时长(14秒 / 2分05秒 / 15分 / 3小时2分)。 */
3403
- function formatZhDuration(ms) {
3404
- const { h, m, s } = durationParts(ms);
3405
- if (h > 0) return m > 0 ? `${h}小时${m}分` : `${h}小时`;
3406
- if (s < 60 && m === 0 && h === 0) return `${s}秒`;
3407
- if (s === 0) return `${m}分`;
3408
- return `${m}分${String(s).padStart(2, "0")}秒`;
3409
- }
3410
- /** Milliseconds → compact English duration (14s / 2m05s / 15m / 3h2m). */
3411
- function formatEnDuration(ms) {
3412
- const { h, m, s } = durationParts(ms);
3413
- if (h > 0) return m > 0 ? `${h}h${m}m` : `${h}h`;
3414
- if (m === 0 && h === 0) return `${s}s`;
3415
- if (s === 0) return `${m}m`;
3416
- return `${m}m${String(s).padStart(2, "0")}s`;
3417
- }
3418
- /** Native disclosure chevron (IconChevronDownOutline14, 14x14). */
3419
- const CHEVRON_PATH = "M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z";
3420
- function createChevronIcon(className) {
3421
- const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
3422
- svg.setAttribute("width", "14");
3423
- svg.setAttribute("height", "14");
3424
- svg.setAttribute("class", className);
3425
- svg.setAttribute("viewBox", "0 0 14 14");
3426
- svg.setAttribute("fill", "none");
3427
- svg.setAttribute("aria-hidden", "true");
3428
- const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
3429
- path.setAttribute("d", CHEVRON_PATH);
3430
- path.setAttribute("fill", "currentColor");
3431
- svg.appendChild(path);
3432
- return svg;
3433
- }
3434
- function createProcessedRowElement(duration) {
3435
- const copy = resolveFoldCopy();
3436
- const btn = document.createElement("button");
3437
- btn.type = "button";
3438
- btn.className = PROCESSED_CLASS;
3439
- btn.setAttribute("aria-expanded", "false");
3440
- const text = document.createElement("span");
3441
- text.textContent = duration === void 0 ? copy.processedLabel : `${copy.processedLabel} ${copy.formatDuration(duration)}`;
3442
- btn.append(text, createChevronIcon(`${PROCESSED_CLASS}-chevron`));
3443
- btn.title = copy.expandTitle;
3444
- return btn;
3445
- }
3446
- function injectStyle() {
3447
- if (document.getElementById(STYLE_ID) !== null) return;
3448
- const style = document.createElement("style");
3449
- style.id = STYLE_ID;
3450
- style.textContent = FOLD_CSS;
3451
- document.head.appendChild(style);
3452
- }
3453
- function removeStyle() {
3454
- document.getElementById(STYLE_ID)?.remove();
3455
- }
3456
- //#endregion
3457
3211
  //#region src/client/useProgressiveDomText.ts
3458
3212
  /**
3459
3213
  * Progressive text reveal for opaque Agent renderers.
@@ -3714,6 +3468,24 @@ window.__ModuleLoader__.load({
3714
3468
  ]);
3715
3469
  }
3716
3470
  //#endregion
3471
+ //#region \0dsh-css:/Users/dzlin/work/project/dsh-smooth-stream/src/client/AgentRowEntrance.module.css.mjs
3472
+ const css$2 = "._7P9_Ta_surface{min-width:0}._7P9_Ta_surface[data-entrance=active]{will-change:opacity, transform;animation:.22s cubic-bezier(.2,.8,.2,1) both _7P9_Ta_dsh-smooth-stream-agent-row-in}@keyframes _7P9_Ta_dsh-smooth-stream-agent-row-in{0%{opacity:0;clip-path:inset(0 100% 0 0);transform:translateY(4px)}55%{opacity:1;clip-path:inset(0 18% 0 0)}to{opacity:1;clip-path:inset(0);transform:translate(0,0)}}@media (prefers-reduced-motion:reduce){._7P9_Ta_surface[data-entrance=active]{opacity:1;clip-path:none;will-change:auto;animation:none;transform:none}}";
3473
+ const tagId$2 = "dsh-smooth-stream/AgentRowEntrance.module.css";
3474
+ if (typeof document !== "undefined") {
3475
+ let tag = document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$2) + "]");
3476
+ if (tag === null) {
3477
+ tag = document.createElement("style");
3478
+ tag.dataset.plugin = "dsh-smooth-stream";
3479
+ tag.dataset.pluginCss = tagId$2;
3480
+ document.head.appendChild(tag);
3481
+ }
3482
+ tag.textContent = css$2;
3483
+ }
3484
+ var AgentRowEntrance_module_css_default = {
3485
+ "surface": "_7P9_Ta_surface",
3486
+ "dsh-smooth-stream-agent-row-in": "_7P9_Ta_dsh-smooth-stream-agent-row-in"
3487
+ };
3488
+ //#endregion
3717
3489
  //#region src/client/TypewriterToolNodeView.tsx
3718
3490
  function openAgentLocation(node) {
3719
3491
  if (node === null || typeof node !== "object" || !("location" in node)) return false;
@@ -3797,8 +3569,9 @@ window.__ModuleLoader__.load({
3797
3569
  * @param Inner - The already-registered row component.
3798
3570
  * @returns A follow-hosted row.
3799
3571
  */
3800
- function wrapFollowNodeView(Inner) {
3572
+ function wrapFollowNodeView(Inner, useControlScroll) {
3801
3573
  return function TypewriterFollowNodeView(props) {
3574
+ const controlScroll = useControlScroll?.() ?? true;
3802
3575
  const speedCpsRef = (0, react.useRef)(35);
3803
3576
  const hostRef = (0, react.useRef)(null);
3804
3577
  const growing = isGrowingChatNode(props.node);
@@ -3878,8 +3651,11 @@ window.__ModuleLoader__.load({
3878
3651
  onGrowth: followable ? onGrowth : void 0,
3879
3652
  entranceExtentRef: growthExtentRef,
3880
3653
  speedCpsRef,
3654
+ controlScroll,
3881
3655
  predictive: false,
3882
3656
  hostRef,
3657
+ className: AgentRowEntrance_module_css_default.surface,
3658
+ entranceActive: entering || growthPulse,
3883
3659
  children: (0, react.createElement)(Inner, props)
3884
3660
  });
3885
3661
  };
@@ -3899,32 +3675,32 @@ window.__ModuleLoader__.load({
3899
3675
  tag.textContent = css$1;
3900
3676
  }
3901
3677
  var SmoothStreamCard_module_css_default = {
3902
- "failure": "XNGeRG_failure",
3903
- "updateRow": "XNGeRG_updateRow",
3678
+ "updateCopy": "XNGeRG_updateCopy",
3904
3679
  "footer": "XNGeRG_footer",
3680
+ "name": "XNGeRG_name",
3905
3681
  "update": "XNGeRG_update",
3682
+ "updateRow": "XNGeRG_updateRow",
3683
+ "headText": "XNGeRG_headText",
3684
+ "label": "XNGeRG_label",
3685
+ "description": "XNGeRG_description",
3686
+ "discard": "XNGeRG_discard",
3687
+ "save": "XNGeRG_save",
3906
3688
  "cardOpen": "XNGeRG_cardOpen",
3907
- "fieldDisabled": "XNGeRG_fieldDisabled",
3908
- "header": "XNGeRG_header",
3909
- "chevronOpen": "XNGeRG_chevronOpen",
3910
- "field": "XNGeRG_field",
3911
- "version": "XNGeRG_version",
3912
- "pending": "XNGeRG_pending",
3913
- "chevron": "XNGeRG_chevron",
3914
- "hint": "XNGeRG_hint",
3915
3689
  "failed": "XNGeRG_failed",
3916
- "description": "XNGeRG_description",
3917
- "label": "XNGeRG_label",
3918
3690
  "fieldHead": "XNGeRG_fieldHead",
3919
- "updateCopy": "XNGeRG_updateCopy",
3920
- "save": "XNGeRG_save",
3921
- "discard": "XNGeRG_discard",
3922
- "body": "XNGeRG_body",
3923
- "name": "XNGeRG_name",
3691
+ "toggle": "XNGeRG_toggle",
3692
+ "chevron": "XNGeRG_chevron",
3693
+ "hint": "XNGeRG_hint",
3924
3694
  "card": "XNGeRG_card",
3695
+ "header": "XNGeRG_header",
3696
+ "pending": "XNGeRG_pending",
3697
+ "chevronOpen": "XNGeRG_chevronOpen",
3925
3698
  "readOnly": "XNGeRG_readOnly",
3926
- "headText": "XNGeRG_headText",
3927
- "toggle": "XNGeRG_toggle"
3699
+ "field": "XNGeRG_field",
3700
+ "version": "XNGeRG_version",
3701
+ "failure": "XNGeRG_failure",
3702
+ "fieldDisabled": "XNGeRG_fieldDisabled",
3703
+ "body": "XNGeRG_body"
3928
3704
  };
3929
3705
  //#endregion
3930
3706
  //#region src/client/SmoothStreamCard.tsx
@@ -4025,40 +3801,40 @@ window.__ModuleLoader__.load({
4025
3801
  className: SmoothStreamCard_module_css_default.fieldHead,
4026
3802
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4027
3803
  className: SmoothStreamCard_module_css_default.label,
4028
- children: t("thinkAutoExpand")
3804
+ children: t("controlScroll")
4029
3805
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4030
3806
  type: "checkbox",
4031
3807
  className: SmoothStreamCard_module_css_default.toggle,
4032
- checked: state.thinkAutoExpand,
3808
+ checked: state.controlScroll,
4033
3809
  disabled: !state.writable || state.saving || !state.enabled,
4034
3810
  onChange: (event) => {
4035
- props.edit({ thinkAutoExpand: event.target.checked });
3811
+ props.edit({ controlScroll: event.target.checked });
4036
3812
  }
4037
3813
  })]
4038
3814
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4039
3815
  className: SmoothStreamCard_module_css_default.hint,
4040
- children: t("thinkAutoExpandHint")
3816
+ children: t("controlScrollHint")
4041
3817
  })]
4042
3818
  }),
4043
3819
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4044
- className: SmoothStreamCard_module_css_default.field,
3820
+ className: state.enabled ? SmoothStreamCard_module_css_default.field : `${SmoothStreamCard_module_css_default.field} ${SmoothStreamCard_module_css_default.fieldDisabled}`,
4045
3821
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4046
3822
  className: SmoothStreamCard_module_css_default.fieldHead,
4047
3823
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4048
3824
  className: SmoothStreamCard_module_css_default.label,
4049
- children: t("autoCollapse")
3825
+ children: t("thinkAutoExpand")
4050
3826
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4051
3827
  type: "checkbox",
4052
3828
  className: SmoothStreamCard_module_css_default.toggle,
4053
- checked: state.autoCollapse,
4054
- disabled: !state.writable || state.saving,
3829
+ checked: state.thinkAutoExpand,
3830
+ disabled: !state.writable || state.saving || !state.enabled,
4055
3831
  onChange: (event) => {
4056
- props.edit({ autoCollapse: event.target.checked });
3832
+ props.edit({ thinkAutoExpand: event.target.checked });
4057
3833
  }
4058
3834
  })]
4059
3835
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4060
3836
  className: SmoothStreamCard_module_css_default.hint,
4061
- children: t("autoCollapseHint")
3837
+ children: t("thinkAutoExpandHint")
4062
3838
  })]
4063
3839
  }),
4064
3840
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
@@ -4182,11 +3958,11 @@ window.__ModuleLoader__.load({
4182
3958
  hooks: { smoothStreamCard: this.store },
4183
3959
  edit: (patch) => {
4184
3960
  if (this.saving) return;
4185
- if (patch.enabled !== void 0 || patch.thinkAutoExpand !== void 0 || patch.autoCollapse !== void 0) this.stagedBase = {
3961
+ if (patch.enabled !== void 0 || patch.controlScroll !== void 0 || patch.thinkAutoExpand !== void 0) this.stagedBase = {
4186
3962
  ...this.baseValues(),
4187
3963
  ...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
4188
- ...patch.thinkAutoExpand === void 0 ? {} : { thinkAutoExpand: patch.thinkAutoExpand },
4189
- ...patch.autoCollapse === void 0 ? {} : { autoCollapse: patch.autoCollapse }
3964
+ ...patch.controlScroll === void 0 ? {} : { controlScroll: patch.controlScroll },
3965
+ ...patch.thinkAutoExpand === void 0 ? {} : { thinkAutoExpand: patch.thinkAutoExpand }
4190
3966
  };
4191
3967
  if (this.loadedDebug !== void 0 && (patch.debugEnabled !== void 0 || patch.debugTuning !== void 0)) this.stagedDebug = {
4192
3968
  ...this.debugValues(),
@@ -4238,8 +4014,8 @@ window.__ModuleLoader__.load({
4238
4014
  baseValues() {
4239
4015
  return this.stagedBase ?? {
4240
4016
  enabled: this.loaded?.enabled ?? DEFAULT_STREAM_SETTINGS.enabled,
4241
- thinkAutoExpand: this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
4242
- autoCollapse: this.loaded?.autoCollapse ?? DEFAULT_STREAM_SETTINGS.autoCollapse
4017
+ controlScroll: this.loaded?.controlScroll ?? DEFAULT_STREAM_SETTINGS.controlScroll,
4018
+ thinkAutoExpand: this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand
4243
4019
  };
4244
4020
  }
4245
4021
  debugValues() {
@@ -4359,7 +4135,7 @@ window.__ModuleLoader__.load({
4359
4135
  "npm",
4360
4136
  "development",
4361
4137
  "unmanaged"
4362
- ].includes(data.installation) || typeof data.writable !== "boolean" || typeof data.enabled !== "boolean" || typeof data.thinkAutoExpand !== "boolean" || typeof data.autoCollapse !== "boolean" || typeof data.canUpgrade !== "boolean") throw new Error("dsh-smooth-stream: malformed settings response");
4138
+ ].includes(data.installation) || typeof data.writable !== "boolean" || typeof data.enabled !== "boolean" || typeof data.controlScroll !== "boolean" || typeof data.thinkAutoExpand !== "boolean" || typeof data.canUpgrade !== "boolean") throw new Error("dsh-smooth-stream: malformed settings response");
4363
4139
  return data;
4364
4140
  }
4365
4141
  function upgradeView(value) {
@@ -4388,8 +4164,8 @@ window.__ModuleLoader__.load({
4388
4164
  async write(settings) {
4389
4165
  return settingsView(accepted(await connection.rpc.call(STREAM_SETTINGS_RPC_CHANNEL, STREAM_SETTINGS_RPC.write, {
4390
4166
  enabled: settings.enabled,
4167
+ controlScroll: settings.controlScroll,
4391
4168
  thinkAutoExpand: settings.thinkAutoExpand,
4392
- autoCollapse: settings.autoCollapse,
4393
4169
  ...settings.debugEnabled === void 0 || settings.debugTuning === void 0 ? {} : {
4394
4170
  debugEnabled: settings.debugEnabled,
4395
4171
  debugTuning: settings.debugTuning
@@ -4425,33 +4201,33 @@ window.__ModuleLoader__.load({
4425
4201
  tag.textContent = css;
4426
4202
  }
4427
4203
  var DebugPanel_module_css_default = {
4428
- "section": "m4_tdG_section",
4429
- "panelHeader": "m4_tdG_panelHeader",
4430
- "statusDot": "m4_tdG_statusDot",
4431
- "iconButton": "m4_tdG_iconButton",
4432
- "unsaved": "m4_tdG_unsaved",
4433
- "infoButton": "m4_tdG_infoButton",
4434
- "triggerActive": "m4_tdG_triggerActive",
4435
- "number": "m4_tdG_number",
4436
- "primaryButton": "m4_tdG_primaryButton",
4437
- "statusLive": "m4_tdG_statusLive",
4438
4204
  "metric": "m4_tdG_metric",
4439
- "control": "m4_tdG_control",
4440
- "secondaryButton": "m4_tdG_secondaryButton",
4205
+ "trigger": "m4_tdG_trigger",
4441
4206
  "panel": "m4_tdG_panel",
4442
- "controlHead": "m4_tdG_controlHead",
4207
+ "statusLive": "m4_tdG_statusLive",
4443
4208
  "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",
4209
+ "triggerActive": "m4_tdG_triggerActive",
4210
+ "secondaryButton": "m4_tdG_secondaryButton",
4211
+ "number": "m4_tdG_number",
4449
4212
  "title": "m4_tdG_title",
4450
- "metrics": "m4_tdG_metrics",
4213
+ "controlHead": "m4_tdG_controlHead",
4451
4214
  "unit": "m4_tdG_unit",
4452
- "footerSpacer": "m4_tdG_footerSpacer",
4215
+ "visuallyHidden": "m4_tdG_visuallyHidden",
4216
+ "panelHeader": "m4_tdG_panelHeader",
4217
+ "section": "m4_tdG_section",
4453
4218
  "range": "m4_tdG_range",
4219
+ "unsaved": "m4_tdG_unsaved",
4454
4220
  "state": "m4_tdG_state",
4221
+ "control": "m4_tdG_control",
4222
+ "numberWrap": "m4_tdG_numberWrap",
4223
+ "metrics": "m4_tdG_metrics",
4224
+ "footerSpacer": "m4_tdG_footerSpacer",
4225
+ "statusDot": "m4_tdG_statusDot",
4226
+ "controlLabel": "m4_tdG_controlLabel",
4227
+ "footer": "m4_tdG_footer",
4228
+ "iconButton": "m4_tdG_iconButton",
4229
+ "infoButton": "m4_tdG_infoButton",
4230
+ "primaryButton": "m4_tdG_primaryButton",
4455
4231
  "scrollArea": "m4_tdG_scrollArea"
4456
4232
  };
4457
4233
  //#endregion
@@ -4848,10 +4624,10 @@ window.__ModuleLoader__.load({
4848
4624
  description: "How replies are revealed while they stream.",
4849
4625
  enabled: "Enable smooth streaming",
4850
4626
  enabledHint: "Let this plugin render and follow streaming replies. Turn off to use the built-in Harness renderer.",
4627
+ controlScroll: "Take over scrolling",
4628
+ controlScrollHint: "On by default; smooth-stream writes the conversation scroll position. Turn off to leave bottom-follow to Harness.",
4851
4629
  thinkAutoExpand: "Auto-expand thinking",
4852
4630
  thinkAutoExpandHint: "Open the thinking block while it streams. Turn off to keep it collapsed.",
4853
- autoCollapse: "Collapse finished work",
4854
- autoCollapseHint: "When a reply finishes, fold its thinking, tools, and intermediate output behind one “Processed” summary so only the final answer shows. Click the summary to expand again; works with streaming on or off.",
4855
4631
  debugEnabled: "Show render diagnostics",
4856
4632
  debugEnabledHint: "Show live streaming and scroll metrics on the right side of the chat. Tune values there, then save them here.",
4857
4633
  debugUnavailable: "Live diagnostics require a newer plugin Host.",
@@ -4927,10 +4703,10 @@ window.__ModuleLoader__.load({
4927
4703
  description: "回复在流式输出时如何逐字展现。",
4928
4704
  enabled: "启用丝滑流式渲染",
4929
4705
  enabledHint: "由本插件渲染并跟随流式回复;关闭后使用 Harness 内置渲染。",
4706
+ controlScroll: "接管滚动",
4707
+ controlScrollHint: "默认开启,由丝滑流式写入会话滚动位置;关闭后贴底滚动交给 Harness。",
4930
4708
  thinkAutoExpand: "自动展开思考",
4931
4709
  thinkAutoExpandHint: "思考块在流式时自动展开;关闭后保持折叠,可手动展开。",
4932
- autoCollapse: "完成后自动折叠",
4933
- autoCollapseHint: "回复处理完成后,把思考、工具与过程输出折叠为一行“已处理”摘要,只展示最终回复;点击摘要可再次展开。开启或关闭流式渲染均可用,默认开启。",
4934
4710
  debugEnabled: "显示渲染调试面板",
4935
4711
  debugEnabledHint: "在聊天右侧显示流式渲染和滚动的实时参数,可在面板中调节并在这里保存。",
4936
4712
  debugUnavailable: "当前 Host 版本不支持实时调试,请先更新插件 Host。",
@@ -5064,7 +4840,7 @@ window.__ModuleLoader__.load({
5064
4840
  * @param ctx - Browser context carrying the slot registry.
5065
4841
  * @returns Restorer that puts the original components back.
5066
4842
  */
5067
- function wrapAgentChatRows(ctx) {
4843
+ function wrapAgentChatRows(ctx, useControlScroll) {
5068
4844
  const restores = [];
5069
4845
  const wrapped = /* @__PURE__ */ new WeakSet();
5070
4846
  const wrapAll = () => {
@@ -5074,7 +4850,7 @@ window.__ModuleLoader__.load({
5074
4850
  const current = entry.component;
5075
4851
  if (!isWrappableComponent(current) || wrapped.has(current)) continue;
5076
4852
  const inner = current;
5077
- const next = wrapFollowNodeView(inner);
4853
+ const next = wrapFollowNodeView(inner, useControlScroll);
5078
4854
  wrapped.add(next);
5079
4855
  entry.component = next;
5080
4856
  restores.push(() => {
@@ -5123,7 +4899,7 @@ window.__ModuleLoader__.load({
5123
4899
  refresh() {
5124
4900
  const next = this.read();
5125
4901
  const pending = this.card?.getSnapshot().status === "loading";
5126
- if (pending === this.pending && next.enabled === this.value.enabled && next.thinkAutoExpand === this.value.thinkAutoExpand && next.autoCollapse === this.value.autoCollapse && next.debugEnabled === this.value.debugEnabled && next.debugTuning === this.value.debugTuning) return;
4902
+ if (pending === this.pending && next.enabled === this.value.enabled && next.controlScroll === this.value.controlScroll && next.thinkAutoExpand === this.value.thinkAutoExpand && next.debugEnabled === this.value.debugEnabled && next.debugTuning === this.value.debugTuning) return;
5127
4903
  this.pending = pending;
5128
4904
  this.value = next;
5129
4905
  for (const listener of this.listeners) listener();
@@ -5132,10 +4908,6 @@ window.__ModuleLoader__.load({
5132
4908
  takeoverEnabled() {
5133
4909
  return !this.pending && this.value.enabled;
5134
4910
  }
5135
- /** Whether finished turns should fold behind a summary row right now. */
5136
- autoCollapseActive() {
5137
- return !this.pending && this.value.autoCollapse;
5138
- }
5139
4911
  getSnapshot = () => this.value;
5140
4912
  subscribe = (listener) => {
5141
4913
  this.listeners.add(listener);
@@ -5158,19 +4930,7 @@ window.__ModuleLoader__.load({
5158
4930
  function apply(ctx) {
5159
4931
  const config = readBootConfig();
5160
4932
  const settings = new SettingsCell();
5161
- ctx.inject(["slots"], () => {
5162
- const autoCollapse = new AutoCollapseController();
5163
- const syncAutoCollapse = () => {
5164
- if (settings.autoCollapseActive()) autoCollapse.start();
5165
- else autoCollapse.stop();
5166
- };
5167
- const unsubscribe = settings.subscribe(syncAutoCollapse);
5168
- syncAutoCollapse();
5169
- return () => {
5170
- unsubscribe();
5171
- autoCollapse.stop();
5172
- };
5173
- });
4933
+ const useControlScroll = () => (0, react.useSyncExternalStore)(settings.subscribe, () => settings.getSnapshot().controlScroll, () => settings.getSnapshot().controlScroll);
5174
4934
  ctx.inject([
5175
4935
  "slots",
5176
4936
  "locale",
@@ -5238,7 +4998,8 @@ window.__ModuleLoader__.load({
5238
4998
  revealCharsPerSec: config.revealCharsPerSec,
5239
4999
  scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
5240
5000
  maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
5241
- thinkAutoExpand: preferences.thinkAutoExpand
5001
+ thinkAutoExpand: preferences.thinkAutoExpand,
5002
+ controlScroll: preferences.controlScroll
5242
5003
  });
5243
5004
  };
5244
5005
  ctx.slots.inject("conversation.chat.node", () => {
@@ -5250,7 +5011,7 @@ window.__ModuleLoader__.load({
5250
5011
  return;
5251
5012
  }
5252
5013
  if (releaseTakeover !== void 0) return;
5253
- const unwrap = wrapAgentChatRows(ctx);
5014
+ const unwrap = wrapAgentChatRows(ctx, useControlScroll);
5254
5015
  const unshadow = ctx.slots.register({
5255
5016
  name: "conversation.chat.node",
5256
5017
  key: "assistant-step",