dsh-smooth-stream 0.4.3 → 0.6.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
+ "stopped": "I17U7q_stopped",
27
+ "thinkChevron": "I17U7q_thinkChevron",
26
28
  "thinkTitle": "I17U7q_thinkTitle",
27
29
  "thinkLeading": "I17U7q_thinkLeading",
28
30
  "thinkSeparator": "I17U7q_thinkSeparator",
29
- "disclosureRoot": "I17U7q_disclosureRoot",
30
- "dsh-smooth-stream-think-sweep": "I17U7q_dsh-smooth-stream-think-sweep",
31
- "root": "I17U7q_root",
32
- "disclosureTitle": "I17U7q_disclosureTitle",
31
+ "body": "I17U7q_body",
32
+ "thinkBody": "I17U7q_thinkBody",
33
+ "disclosureContent": "I17U7q_disclosureContent",
33
34
  "visuallyHidden": "I17U7q_visuallyHidden",
35
+ "disclosureTitle": "I17U7q_disclosureTitle",
36
+ "dsh-smooth-stream-think-sweep": "I17U7q_dsh-smooth-stream-think-sweep",
37
+ "disclosureIconIdle": "I17U7q_disclosureIconIdle",
34
38
  "follow": "I17U7q_follow",
35
- "disclosureContent": "I17U7q_disclosureContent",
36
- "stopped": "I17U7q_stopped",
39
+ "think": "I17U7q_think",
37
40
  "thinkSummary": "I17U7q_thinkSummary",
38
41
  "disclosureRow": "I17U7q_disclosureRow",
39
- "disclosureIconIdle": "I17U7q_disclosureIconIdle",
40
- "thinkRow": "I17U7q_thinkRow",
41
- "thinkChevron": "I17U7q_thinkChevron",
42
- "think": "I17U7q_think",
43
- "body": "I17U7q_body",
42
+ "root": "I17U7q_root",
44
43
  "disclosureLeading": "I17U7q_disclosureLeading",
44
+ "thinkRow": "I17U7q_thinkRow",
45
45
  "disclosureChevronHover": "I17U7q_disclosureChevronHover",
46
- "thinkBody": "I17U7q_thinkBody"
46
+ "disclosureRoot": "I17U7q_disclosureRoot"
47
47
  };
48
48
  //#endregion
49
49
  //#region src/client/AnimatedDisclosure.tsx
@@ -125,33 +125,68 @@ 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,
129
+ motionPreference: "auto",
128
130
  thinkAutoExpand: true,
129
- autoCollapse: true,
130
131
  debugEnabled: false,
131
132
  debugTuning: DEFAULT_STREAM_DEBUG_TUNING
132
133
  };
133
134
  //#endregion
134
135
  //#region src/client/clientStore.ts
136
+ function fallbackSnapshotStore(init) {
137
+ let state = init;
138
+ const listeners = /* @__PURE__ */ new Set();
139
+ return {
140
+ getSnapshot: () => state,
141
+ subscribe: (listener) => {
142
+ listeners.add(listener);
143
+ return () => {
144
+ listeners.delete(listener);
145
+ };
146
+ },
147
+ update: (mutator) => {
148
+ const draft = Object.create(Object.getPrototypeOf(state), Object.getOwnPropertyDescriptors(state));
149
+ mutator(draft);
150
+ state = draft;
151
+ for (const listener of [...listeners]) listener(state);
152
+ },
153
+ set: (next) => {
154
+ state = next;
155
+ for (const listener of [...listeners]) listener(state);
156
+ }
157
+ };
158
+ }
135
159
  /**
136
160
  * Runtime require: the module-loader factory scopes its own resolver over the
137
161
  * booting kernel's table; under Node (vitest) there is no global require, so
138
162
  * fall back to `createRequire`.
139
163
  */
140
164
  function pickRequire() {
141
- if (typeof require === "function") return require;
142
- if (typeof process !== "undefined" && typeof process.getBuiltinModule === "function") {
165
+ if (typeof require === "function") try {
166
+ return require;
167
+ } catch {
168
+ return null;
169
+ }
170
+ if (typeof process !== "undefined" && typeof process.getBuiltinModule === "function") try {
143
171
  const { createRequire } = process.getBuiltinModule("node:module");
144
172
  return createRequire(process.cwd() + "/package.json");
173
+ } catch {
174
+ return null;
145
175
  }
146
- throw new TypeError("dsh-smooth-stream: no client module resolver available");
176
+ return null;
147
177
  }
148
178
  let clientStore;
149
179
  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;
180
+ const req = pickRequire();
181
+ if (req !== null) try {
182
+ clientStore = req("@deepseek-ai/dsh-client-store");
183
+ } catch {
184
+ try {
185
+ clientStore = req("@deepseek-ai/dsh-client-runtime/client");
186
+ } catch {}
187
+ }
188
+ } catch {}
189
+ const createSnapshotStore = clientStore?.createSnapshotStore ?? fallbackSnapshotStore;
155
190
  //#endregion
156
191
  //#region src/client/debugRuntime.ts
157
192
  /**
@@ -433,8 +468,15 @@ window.__ModuleLoader__.load({
433
468
  * scroll ownership. Current Harness also sees the write land at the floor.
434
469
  */
435
470
  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;
471
+ /**
472
+ * Duration of completion runway retirement. The final pad is visible motion:
473
+ * its shrinking floor brings the transcript down to its natural resting
474
+ * position. 1.5s keeps the default 72px runway at 48px/s (0.8px per 60Hz
475
+ * frame), matching the configured default reading-follow velocity instead of
476
+ * the old 160ms / 450px/s staircase that looked like repeated completion
477
+ * jumps.
478
+ */
479
+ const FOLLOW_RUNWAY_RETIRE_MS = 1500;
438
480
  /** Runway size emitted by bundles before the 72px predictive runway. */
439
481
  const LEGACY_RUNWAY_PX = 48;
440
482
  /** Safe-lag occupancy band over which reveal pressure is progressively reduced. */
@@ -591,7 +633,7 @@ window.__ModuleLoader__.load({
591
633
  * Outermost message surfaces; nested tool rows ride their parent.
592
634
  *
593
635
  * 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).
636
+ * (meow-memory's fold bar is one).
595
637
  * Such a row carries no `data-chat-anchor-key`, so selecting only anchored
596
638
  * rows would shift the conversation while leaving the foreign row at its
597
639
  * natural offset, letting the shifted rows paint over it. Every direct flow
@@ -705,12 +747,237 @@ window.__ModuleLoader__.load({
705
747
  const followHadChrome = /* @__PURE__ */ new WeakSet();
706
748
  /** Last painted shift per port, to spread a wrap's one-line step over frames. */
707
749
  const followLastShiftPx = /* @__PURE__ */ new WeakMap();
750
+ /** Last settled floor per port, to size the shift decay bound against extent collapse. */
751
+ const followLastFloorPx = /* @__PURE__ */ new WeakMap();
752
+ const followGuardAnchors = /* @__PURE__ */ new WeakMap();
753
+ /**
754
+ * Ports whose completion settle loop owns the follow. The settle drains the
755
+ * reveal, retires the pad and guards the cascade; the swap that ends the
756
+ * turn REMOUNTS the follower arms in the same frame cluster, and a freshly
757
+ * mounted arm primes with a higher generation and would otherwise steal the
758
+ * port mid-drain — its observers miss the cascade mutations (armed after
759
+ * the fact) and its state initialization re-materializes engine space.
760
+ * Ownership is released only when the settle finishes, the reader gestures,
761
+ * or a genuinely NEW turn arrives (a user row joined since the handoff).
762
+ */
763
+ const followCompletionSettle = /* @__PURE__ */ new WeakSet();
764
+ /** User-row count at handoff, for the new-turn check above. */
765
+ const followCompletionSettleRows = /* @__PURE__ */ new WeakMap();
766
+ /** User rows below the flow, or -1 when the host does not label rows with
767
+ * `data-chat-flow-kind` (the engine's own audit benches): the new-turn
768
+ * check is unsupported there and ownership guarding must stay OFF. */
769
+ function countUserRows(port) {
770
+ const flow = flowElementOf(port);
771
+ if (flow === null) return -1;
772
+ let count = 0;
773
+ let sawKind = false;
774
+ for (const child of flow.children) {
775
+ if (!(child instanceof HTMLElement)) continue;
776
+ const kind = child.getAttribute("data-chat-flow-kind");
777
+ if (kind !== null) sawKind = true;
778
+ if (kind === "user") count++;
779
+ }
780
+ return sawKind ? count : -1;
781
+ }
782
+ /** True while this port's completion settle owns the follow and no new turn
783
+ * has arrived since the handoff. */
784
+ function completionSettleGuardsPort(port) {
785
+ if (!followCompletionSettle.has(port)) return false;
786
+ const baseline = followCompletionSettleRows.get(port) ?? -1;
787
+ const current = countUserRows(port);
788
+ return baseline >= 0 && current >= 0 && current <= baseline;
789
+ }
790
+ /**
791
+ * Ports whose completion settle loop owns the follow. A settle loop drains,
792
+ * retires the pad and guards the cascade; an arm that primes while this is
793
+ * set with `active === false` (the settled-side static arm mounting in the
794
+ * very swap frame) must NOT seize leadership — the swap re-renders the node
795
+ * view, a fresh arm would otherwise steal the port mid-drain with
796
+ * `reservePx = ownedBottomSpace` (the pad!), re-materialize an equal runway
797
+ * through applyVisual, double-count the extent and fight the settle's own
798
+ * guard for the rest of the window. A streaming arm (active === true, the
799
+ * next turn) still takes over normally and the settle yields.
800
+ */
801
+ function readingAnchorOf(port) {
802
+ const flow = flowElementOf(port);
803
+ if (flow === null) return null;
804
+ let anchor = null;
805
+ for (const child of flow.children) {
806
+ if (!(child instanceof HTMLElement)) continue;
807
+ if (child.getAttribute("data-chat-flow-kind") === "assistant" || child.querySelector("[data-variant=\"think\"]") !== null) anchor = child;
808
+ }
809
+ return anchor ?? shiftSurfacesOf(port).at(-1) ?? null;
810
+ }
811
+ /**
812
+ * Measure the reading surface's screen delta since the last guard pass.
813
+ * Returns null when there is nothing comparable yet (first observation), or
814
+ * when the anchor changed identity in a way that is NOT the in-place
815
+ * live→settled replacement (a new turn's row joined — nothing jumped,
816
+ * re-seed). Compensation sites must store the POST-compensation held
817
+ * position via `holdGuardAnchor`, or the guard would read its own
818
+ * correction as a fresh jump and oscillate.
819
+ */
820
+ function measureReadingAnchor(port) {
821
+ const anchor = readingAnchorOf(port);
822
+ if (anchor === null) {
823
+ followGuardAnchors.delete(port);
824
+ return null;
825
+ }
826
+ const rect = anchor.getBoundingClientRect();
827
+ if (!(rect.width > 0 || rect.height > 0)) return null;
828
+ const top = rect.top;
829
+ const shift = currentShiftOf(anchor);
830
+ const pad = flowPadOf(port);
831
+ const scrollTop = port.scrollTop;
832
+ const scrollHeight = port.scrollHeight;
833
+ const flow = flowElementOf(port);
834
+ const index = flow === null ? -1 : [...flow.children].indexOf(anchor);
835
+ const stored = followGuardAnchors.get(port);
836
+ if (stored === void 0) {
837
+ followGuardAnchors.set(port, {
838
+ element: anchor,
839
+ top,
840
+ index,
841
+ shift,
842
+ pad,
843
+ scrollTop,
844
+ scrollHeight
845
+ });
846
+ return null;
847
+ }
848
+ if (Math.abs(scrollHeight - stored.scrollHeight) <= .5 && Math.abs(scrollTop - stored.scrollTop) > .5) {
849
+ followScrollLedgers.set(port, scrollTop);
850
+ followGuardAnchors.set(port, {
851
+ element: anchor,
852
+ top,
853
+ index,
854
+ shift,
855
+ pad,
856
+ scrollTop,
857
+ scrollHeight
858
+ });
859
+ return null;
860
+ }
861
+ const delta = top - stored.top - (shift - stored.shift) + (pad - stored.pad);
862
+ if (stored.element === anchor) {
863
+ followGuardAnchors.set(port, {
864
+ element: anchor,
865
+ top,
866
+ index,
867
+ shift,
868
+ pad,
869
+ scrollTop,
870
+ scrollHeight
871
+ });
872
+ return {
873
+ anchor,
874
+ index,
875
+ top,
876
+ delta
877
+ };
878
+ }
879
+ if (!stored.element.isConnected && stored.index === index) return {
880
+ anchor,
881
+ index,
882
+ top,
883
+ delta
884
+ };
885
+ followGuardAnchors.set(port, {
886
+ element: anchor,
887
+ top,
888
+ index,
889
+ shift,
890
+ pad,
891
+ scrollTop,
892
+ scrollHeight
893
+ });
894
+ return null;
895
+ }
896
+ /** Record the position the reader should keep seeing after a correction. */
897
+ function holdGuardAnchor(port, measured, heldTop) {
898
+ followGuardAnchors.set(port, {
899
+ element: measured.anchor,
900
+ index: measured.index,
901
+ top: heldTop,
902
+ shift: currentShiftOf(measured.anchor),
903
+ pad: flowPadOf(port),
904
+ scrollTop: port.scrollTop,
905
+ scrollHeight: port.scrollHeight
906
+ });
907
+ }
708
908
  /** Last runway offset seen per port, to rebase the extent when the margin size changes. */
709
909
  const followRunwayOffsetHistory = /* @__PURE__ */ new WeakMap();
710
910
  /** Last observed scroll floor per port, for the slack→overflow runway re-measure. */
711
911
  const followFloorHistory = /* @__PURE__ */ new WeakMap();
712
912
  /** One-shot flag: the transition frame must paint the full runway as baseline. */
713
913
  const followSlackTransition = /* @__PURE__ */ new WeakSet();
914
+ const followSettlePads = /* @__PURE__ */ new WeakMap();
915
+ /**
916
+ * Retired follower space lives as `padding-bottom` on the FLOW element — the
917
+ * one node the engine already owns styles on (flow-fill min-height) and the
918
+ * host never rewrites. Host completion commits routinely REPLACE row elements
919
+ * (live→settled swap re-keys the assistant row, the status row unmounts), and
920
+ * any engine space written on those rows dies with them, sinking the floor
921
+ * and slamming the pinned transcript for a frame. The flow survives.
922
+ */
923
+ function flowPadOf(port) {
924
+ return followSettlePads.get(port)?.px ?? 0;
925
+ }
926
+ /**
927
+ * After a loss is re-opened as pad, a registry entry whose element is no
928
+ * longer connected claims extent that no longer exists; drop it so the next
929
+ * `ensureRunway` re-measures fresh instead of double-counting.
930
+ */
931
+ function pruneDeadRunway(port) {
932
+ const runway = followRunways.get(port);
933
+ if (runway !== void 0 && !runway.element.isConnected) {
934
+ restoreRunway(port);
935
+ return true;
936
+ }
937
+ return false;
938
+ }
939
+ function setFlowPad(port, px) {
940
+ const flow = flowElementOf(port);
941
+ if (flow === null) return;
942
+ const existing = followSettlePads.get(port);
943
+ const original = existing?.original ?? flow.style.paddingBottom;
944
+ if (px <= .25) {
945
+ if (existing !== void 0) {
946
+ flow.style.paddingBottom = existing.original;
947
+ followSettlePads.delete(port);
948
+ }
949
+ return;
950
+ }
951
+ flow.style.paddingBottom = original === "" ? `${px}px` : `calc(${original} + ${px}px)`;
952
+ followSettlePads.set(port, {
953
+ element: flow,
954
+ original,
955
+ px
956
+ });
957
+ }
958
+ /** Extent the follower owns below the content: the live runway margin plus
959
+ * the retired completion pad. At adopt the pad is reclaimed into the fresh
960
+ * reservation (same frame, pre-paint), so the floor never steps. */
961
+ function ownedBottomSpaceOf(port) {
962
+ return runwayOffsetOf(port) + flowPadOf(port);
963
+ }
964
+ /**
965
+ * Completion-window diagnostics. Armed when a follower hands off to its
966
+ * settle loop; every engine decision and every externally-written scroll
967
+ * position inside the window prints one compact console line, so a live
968
+ * host session can be diffed against the lab without a debugger.
969
+ */
970
+ let followTraceUntilMs = 0;
971
+ function traceActive() {
972
+ return debugRuntime.isEnabled() && performance.now() < followTraceUntilMs;
973
+ }
974
+ function followTrace(event, detail) {
975
+ if (!traceActive()) return;
976
+ console.log(`[dsh-follow] ${event}`, JSON.stringify(detail));
977
+ }
978
+ function hostShOf(port) {
979
+ return port.scrollHeight;
980
+ }
714
981
  function invalidatePaintLimit(port) {
715
982
  followPaintLimits.delete(port);
716
983
  }
@@ -831,6 +1098,31 @@ window.__ModuleLoader__.load({
831
1098
  function runwayOffsetOf(port) {
832
1099
  return followRunways.get(port)?.offset ?? 0;
833
1100
  }
1101
+ /**
1102
+ * Move owned runway margin into the persistent flow pad without changing the
1103
+ * scroll extent. Returns the actual layout pixels removed from the margin.
1104
+ */
1105
+ function transferRunwayToFlowPad(port, requestedPx) {
1106
+ const runway = followRunways.get(port);
1107
+ if (runway === void 0 || requestedPx <= 0 || !runway.element.isConnected) return 0;
1108
+ const nextRequestedPx = Math.max(0, runway.requestedPx - requestedPx);
1109
+ const beforeOffset = runway.offset;
1110
+ const beforeHeight = port.scrollHeight;
1111
+ runway.element.style[runway.property] = nextRequestedPx <= .25 ? runway.original : runway.original === "" ? `${nextRequestedPx}px` : `calc(${runway.original} + ${nextRequestedPx}px)`;
1112
+ const nextOffset = Math.max(0, beforeOffset + port.scrollHeight - beforeHeight);
1113
+ const transferredPx = Math.max(0, beforeOffset - nextOffset);
1114
+ if (nextRequestedPx <= .25 || nextOffset <= .25) followRunways.delete(port);
1115
+ else followRunways.set(port, {
1116
+ ...runway,
1117
+ offset: nextOffset,
1118
+ requestedPx: nextRequestedPx
1119
+ });
1120
+ if (transferredPx > 0) {
1121
+ setFlowPad(port, flowPadOf(port) + transferredPx);
1122
+ invalidatePaintLimit(port);
1123
+ }
1124
+ return transferredPx;
1125
+ }
834
1126
  /** Available paint room below the last message before fixed conversation chrome. */
835
1127
  function safeShiftLimit(port, surfaces) {
836
1128
  const last = surfaces.at(-1);
@@ -859,6 +1151,13 @@ window.__ModuleLoader__.load({
859
1151
  return limit;
860
1152
  }
861
1153
  function setFollowScrollTop(port, nextTop) {
1154
+ const ledger = followScrollLedgers.get(port);
1155
+ if (port.getAttribute(FOLLOW_OWNED_ATTR) === null) port.setAttribute(FOLLOW_OWNED_ATTR, "active");
1156
+ if (ledger !== void 0 && traceActive() && Math.abs(port.scrollTop - ledger) > 1) followTrace("external-scroll", {
1157
+ from: Math.round(port.scrollTop),
1158
+ to: Math.round(nextTop),
1159
+ ledger: Math.round(ledger)
1160
+ });
862
1161
  if (Math.abs(port.scrollTop - nextTop) > .01) port.scrollTop = nextTop;
863
1162
  followScrollLedgers.set(port, port.scrollTop);
864
1163
  const ownedTop = String(port.scrollTop);
@@ -872,6 +1171,19 @@ window.__ModuleLoader__.load({
872
1171
  */
873
1172
  const followScrollLedgers = /* @__PURE__ */ new WeakMap();
874
1173
  const followActivityAt = /* @__PURE__ */ new WeakMap();
1174
+ /**
1175
+ * Scroll ownership must survive follower-arm remounts. Per-closure state let a
1176
+ * new text/tool arm reset the strike counter, so the same host write kept
1177
+ * triggering another engine write and repainting the visible up/down fight.
1178
+ */
1179
+ const followHostScrollPorts = /* @__PURE__ */ new WeakMap();
1180
+ /** Clear host ownership only when a new user turn actually starts. */
1181
+ function resetHostScrollOwnershipForNewTurn(port) {
1182
+ const ownership = followHostScrollPorts.get(port);
1183
+ if (ownership === void 0) return;
1184
+ const userRows = countUserRows(port);
1185
+ if (userRows >= 0 && userRows > ownership.userRows) followHostScrollPorts.delete(port);
1186
+ }
875
1187
  /** Whether this port was owned recently enough to identify a closing tail row. */
876
1188
  function hasRecentConversationFollow(port, windowMs = 250) {
877
1189
  const last = followActivityAt.get(port);
@@ -888,7 +1200,7 @@ window.__ModuleLoader__.load({
888
1200
  * same frame. Carrying that excess in `scrollTop` would move the transcript
889
1201
  * toward fixed chrome and also make the host expose jump-to-bottom.
890
1202
  */
891
- function applyVisual(port, animatedH, reservePx, velocityPxPerSec = 0, runwayPx = 72, shiftCeilingPx = Number.POSITIVE_INFINITY, promoteAtRest = false, trajectoryShiftPx) {
1203
+ function applyVisual(port, animatedH, reservePx, velocityPxPerSec = 0, runwayPx = 72, shiftCeilingPx = Number.POSITIVE_INFINITY, promoteAtRest = false, trajectoryShiftPx, dtMs = 16.7, writeScrollTop = true) {
892
1204
  const surfaces = shiftSurfacesOf(port);
893
1205
  ensureFlowFillsPort(port);
894
1206
  {
@@ -900,10 +1212,7 @@ window.__ModuleLoader__.load({
900
1212
  ensureRunway(port, surfaces, owned?.requestedPx ?? runwayPx);
901
1213
  followSlackTransition.add(port);
902
1214
  }
903
- if (preFloor !== lastFloorSeen) {
904
- invalidatePaintLimit(port);
905
- followFloorHistory.set(port, preFloor);
906
- }
1215
+ if (preFloor !== lastFloorSeen) followFloorHistory.set(port, preFloor);
907
1216
  }
908
1217
  ensureRunway(port, surfaces, reservePx);
909
1218
  const contentHeight2 = Math.max(0, port.scrollHeight);
@@ -920,7 +1229,7 @@ window.__ModuleLoader__.load({
920
1229
  if (port.style.scrollBehavior !== "auto") port.style.scrollBehavior = "auto";
921
1230
  if (floor <= 0) {
922
1231
  followRunwayOffsetHistory.set(port, 0);
923
- setFollowScrollTop(port, 0);
1232
+ if (writeScrollTop) setFollowScrollTop(port, 0);
924
1233
  followMotionStates.set(port, {
925
1234
  capacityPx: Number.POSITIVE_INFINITY,
926
1235
  constrained: false,
@@ -944,15 +1253,25 @@ window.__ModuleLoader__.load({
944
1253
  const motionShift = Math.min(trajectoryShiftPx ?? baselineShift + requestedLag, availableShift);
945
1254
  let shift = Math.max(motionShift, promoteAtRest && motionShift <= .01 && availableShift > 0 ? .1 : 0);
946
1255
  const previousShift = followLastShiftPx.get(port);
947
- if (previousShift !== void 0 && shift < previousShift - 8) shift = previousShift - 8;
1256
+ const previousFloor = followLastFloorPx.get(port);
1257
+ const floorDropPx = Math.max(0, (previousFloor ?? floor) - floor);
1258
+ const maxDecayPx = Math.max(dtMs <= 0 ? 8 : Math.max(1, 8 / 16.67 * dtMs), floorDropPx);
1259
+ followLastFloorPx.set(port, floor);
1260
+ if (previousShift !== void 0 && shift < previousShift - maxDecayPx) shift = previousShift - maxDecayPx;
1261
+ if (followCompletionSettle.has(port) && previousShift !== void 0 && previousFloor !== void 0) {
1262
+ const confirmedGrowthPx = Math.max(0, floor - previousFloor);
1263
+ if (shift > previousShift + confirmedGrowthPx) shift = previousShift + confirmedGrowthPx;
1264
+ }
948
1265
  followLastShiftPx.set(port, shift);
949
- const effectiveLag = Math.max(0, motionShift - baselineShift);
1266
+ const requestedShift = trajectoryShiftPx ?? baselineShift + requestedLag;
1267
+ const effectiveLag = Math.max(0, shift - baselineShift);
950
1268
  const capacityPx = Math.max(0, limit - baselineShift);
951
1269
  const effectiveExtent = targetHeight - effectiveLag;
952
- setFollowScrollTop(port, floor);
1270
+ const isConstrained = requestedShift > availableShift + .25 || limit <= 0 && requestedShift > baselineShift;
1271
+ if (writeScrollTop) setFollowScrollTop(port, floor);
953
1272
  followMotionStates.set(port, {
954
1273
  capacityPx,
955
- constrained: requestedLag > effectiveLag + FOLLOW_SETTLE_EPSILON_PX,
1274
+ constrained: isConstrained,
956
1275
  extent: effectiveExtent,
957
1276
  lagPx: effectiveLag,
958
1277
  reservePx: visibleReserve,
@@ -976,6 +1295,7 @@ window.__ModuleLoader__.load({
976
1295
  restoreRunway(port);
977
1296
  followMotionStates.delete(port);
978
1297
  followLastShiftPx.delete(port);
1298
+ followLastFloorPx.delete(port);
979
1299
  invalidatePaintLimit(port);
980
1300
  }
981
1301
  /** Keep an already-promoted surface at zero until one stable final paint lands. */
@@ -985,20 +1305,27 @@ window.__ModuleLoader__.load({
985
1305
  element.style.clipPath = "";
986
1306
  }
987
1307
  /** Remove equal offsets, land on the floor, then retire the compositor quietly. */
988
- function finishAtNaturalFloor(port, retainCompositor = true) {
1308
+ function finishAtNaturalFloor(port, retainCompositor = true, writeScrollTop = true) {
1309
+ followCompletionSettle.delete(port);
1310
+ followTraceUntilMs = Math.max(followTraceUntilMs, performance.now() + 1e4);
1311
+ followTrace("finish-enter", {
1312
+ sh: hostShOf(port),
1313
+ st: Math.round(port.scrollTop),
1314
+ pad: Math.round(flowPadOf(port)),
1315
+ retain: retainCompositor
1316
+ });
989
1317
  const surfaces = shiftSurfacesOf(port);
990
1318
  const status = turnStatusOf(port);
991
1319
  if (!retainCompositor) {
992
1320
  restoreRunway(port);
993
- settleAtFloor(port);
1321
+ if (writeScrollTop) settleAtFloor(port);
994
1322
  clearMotion(port);
995
1323
  followMotionStates.delete(port);
996
1324
  return;
997
1325
  }
998
1326
  const promoted = [...surfaces, ...status === null ? [] : [status]].filter((element) => element.style.transform !== "" || element.style.willChange === "transform");
999
1327
  const promotedSet = new Set(promoted);
1000
- restoreRunway(port);
1001
- settleAtFloor(port);
1328
+ if (writeScrollTop) settleAtFloor(port);
1002
1329
  port.removeAttribute(FOLLOW_OWNED_ATTR);
1003
1330
  port.style.overflowAnchor = "";
1004
1331
  port.style.scrollBehavior = "";
@@ -1023,6 +1350,8 @@ window.__ModuleLoader__.load({
1023
1350
  /** Only the newest active follower may write one port's shared visual state. */
1024
1351
  const followLeaders = /* @__PURE__ */ new WeakMap();
1025
1352
  let followGeneration = 0;
1353
+ /** Ports with a live streaming arm; completion guards must not fight them. */
1354
+ const followActivePorts = /* @__PURE__ */ new WeakSet();
1026
1355
  /**
1027
1356
  * Own the conversation scrollport's bottom-follow while `active` is true.
1028
1357
  *
@@ -1036,15 +1365,19 @@ window.__ModuleLoader__.load({
1036
1365
  * @param predictiveRef - Optional live visibility gate for predictive runway.
1037
1366
  * @param entranceExtentRef - Optional measured growth delta for a generic row.
1038
1367
  * @param revealedCharsRef - Committed code-point count for feed-forward phase.
1368
+ * @param controlScroll - When false, leave the scrollport entirely to the Host.
1039
1369
  */
1040
- function useConversationFollow(rootRef, active, speedCpsRef, revealScaleRef, predictive = true, entrance = false, onEntranceSettled, predictiveRef, entranceExtentRef, revealedCharsRef) {
1370
+ function useConversationFollow(rootRef, active, speedCpsRef, revealScaleRef, predictive = true, entrance = false, onEntranceSettled, predictiveRef, entranceExtentRef, revealedCharsRef, controlScroll = true) {
1041
1371
  const activeRef = (0, react.useRef)(active);
1042
1372
  const entranceRef = (0, react.useRef)(entrance);
1043
1373
  const onEntranceSettledRef = (0, react.useRef)(onEntranceSettled);
1044
1374
  entranceRef.current = entrance;
1045
1375
  onEntranceSettledRef.current = onEntranceSettled;
1046
1376
  activeRef.current = active;
1377
+ const controlScrollRef = (0, react.useRef)(controlScroll);
1378
+ controlScrollRef.current = controlScroll;
1047
1379
  (0, react.useLayoutEffect)(() => {
1380
+ if (!controlScroll) return;
1048
1381
  if (!active) return;
1049
1382
  const startedAsEntrance = entrance;
1050
1383
  const owner = {};
@@ -1063,8 +1396,10 @@ window.__ModuleLoader__.load({
1063
1396
  let interactTimer = null;
1064
1397
  let port = null;
1065
1398
  let resize = null;
1399
+ let mutations = null;
1066
1400
  let observedTail = null;
1067
1401
  let statusWasPresent = null;
1402
+ let lastStatusHeightPx = 0;
1068
1403
  let trajectoryPositionPx = null;
1069
1404
  let trajectoryVelocityPxPerMs = 0;
1070
1405
  let trajectoryTargetVelocityPxPerMs = 0;
@@ -1120,10 +1455,43 @@ window.__ModuleLoader__.load({
1120
1455
  if (holding === next && isLeader(next)) return;
1121
1456
  holding = next;
1122
1457
  const leader = followLeaders.get(next);
1123
- if (leader === void 0 || generation > leader.generation) followLeaders.set(next, {
1124
- generation,
1125
- owner
1458
+ if ((leader === void 0 || generation > leader.generation) && !completionSettleGuardsPort(next)) {
1459
+ followCompletionSettle.delete(next);
1460
+ followLeaders.set(next, {
1461
+ generation,
1462
+ owner
1463
+ });
1464
+ }
1465
+ };
1466
+ const yieldScrollOwnership = (next, floor) => {
1467
+ if (hostOwnsScroll) return;
1468
+ hostOwnsScroll = true;
1469
+ followHostScrollPorts.set(next, { userRows: countUserRows(next) });
1470
+ followTrace("yield-scroll", {
1471
+ from: Math.round(next.scrollTop),
1472
+ to: Math.round(floor)
1126
1473
  });
1474
+ clearVisual(next);
1475
+ setFollowScrollTop(next, Math.max(0, next.scrollHeight - next.clientHeight));
1476
+ next.removeAttribute(FOLLOW_OWNED_ATTR);
1477
+ followLeaders.delete(next);
1478
+ releaseRevealScale();
1479
+ debugRuntime.reportFollow(next, null);
1480
+ };
1481
+ const detectHostScroll = (next, floor) => {
1482
+ if (followHostScrollPorts.has(next)) {
1483
+ hostOwnsScroll = true;
1484
+ return;
1485
+ }
1486
+ const ledger = followScrollLedgers.get(next);
1487
+ if (ledger === void 0 || Math.abs(next.scrollTop - ledger) <= 1) return;
1488
+ if (Math.abs(next.scrollTop - floor) <= 1) {
1489
+ followScrollLedgers.set(next, next.scrollTop);
1490
+ externalScrollStrikes = 0;
1491
+ return;
1492
+ }
1493
+ externalScrollStrikes += 1;
1494
+ if (externalScrollStrikes >= 2) yieldScrollOwnership(next, floor);
1127
1495
  };
1128
1496
  const drop = (next) => {
1129
1497
  if (holding === next) holding = null;
@@ -1164,16 +1532,102 @@ window.__ModuleLoader__.load({
1164
1532
  interactTimer = null;
1165
1533
  }, 800);
1166
1534
  };
1535
+ /**
1536
+ * Last observed scroll extent, shared by the pre-paint correction and the
1537
+ * settle loop so a host layout shrink is re-opened exactly once no matter
1538
+ * which observer sees it first. `-1` until the first owned observation.
1539
+ */
1540
+ let settleRetiring = false;
1541
+ let hostOwnsScroll = false;
1542
+ let externalScrollStrikes = 0;
1543
+ Number.NEGATIVE_INFINITY;
1544
+ let handedOff = false;
1545
+ /**
1546
+ * Bring the reading surface back down toward its held position after an
1547
+ * upward host push (clamp jump, live→settled swap): spend persistent pad
1548
+ * first — lowering the floor lets the pin carry the text back down —
1549
+ * then raise the compositor shift for whatever pad cannot cover, and
1550
+ * rebase the spring extent so the raise survives the next applyVisual
1551
+ * (which otherwise recomputes the shift from lag and undoes it).
1552
+ */
1553
+ const pullReadingAnchorBack = (host, measured, trace = false) => {
1554
+ holdGuardAnchor(host, measured, measured.top);
1555
+ };
1556
+ /**
1557
+ * THE screen-space anchor hold, shared by every entry point: the
1558
+ * structural-observer path (pre-paint cascade correction), the settle
1559
+ * loop's per-frame poll, and the ACTIVE loop's per-frame poll. The
1560
+ * shift-excluded delta filters the engine's own motion (reveal glide,
1561
+ * wrap lockstep) to ~0, so a live poll may compensate safely — which is
1562
+ * what saves the completion swap: the settled-side arm primes in its
1563
+ * layout effect and steals leadership IN the cascade frame, its own
1564
+ * observers miss the mutations (armed after the fact), and only this
1565
+ * poll sees the jump while it can still be corrected before paint.
1566
+ * Returns the measured delta (null when nothing was comparable).
1567
+ */
1568
+ const enforceReadingAnchor = (host, trace = false) => {
1569
+ const measured = measureReadingAnchor(host);
1570
+ if (measured === null) return null;
1571
+ if (measured.delta > .5) {
1572
+ if (trace) followTrace("anchor-hold", {
1573
+ screenDelta: Math.round(measured.delta),
1574
+ st: Math.round(host.scrollTop)
1575
+ });
1576
+ if (pruneDeadRunway(host)) reservePx = 0;
1577
+ holdGuardAnchor(host, measured, measured.top);
1578
+ } else if (measured.delta < -.5) {
1579
+ if (pruneDeadRunway(host)) reservePx = 0;
1580
+ pullReadingAnchorBack(host, measured, trace);
1581
+ } else holdGuardAnchor(host, measured, measured.top);
1582
+ return measured.delta;
1583
+ };
1584
+ /** Pre-paint correction (observers, commit subscription). */
1167
1585
  const restoreBeforePaint = () => {
1168
- if (!following || port === null || !isLeader(port)) return;
1586
+ if (!following || port === null) return;
1587
+ if (hostOwnsScroll || followHostScrollPorts.has(port)) {
1588
+ hostOwnsScroll = true;
1589
+ followScrollLedgers.set(port, port.scrollTop);
1590
+ return;
1591
+ }
1592
+ if (!activeRef.current) detectHostScroll(port, Math.max(0, port.scrollHeight - port.clientHeight));
1593
+ const leaderless = !isLeader(port);
1594
+ if (!activeRef.current && followActivePorts.has(port)) return;
1595
+ if (leaderless && (followLeaders.has(port) || !handedOff)) return;
1596
+ if (leaderless) {
1597
+ const sharedMotion = followMotionStates.get(port);
1598
+ if (sharedMotion !== void 0) {
1599
+ animatedH = Math.min(port.scrollHeight, Math.max(0, sharedMotion.extent));
1600
+ reservePx = sharedMotion.reservePx;
1601
+ velocityPxPerSec = sharedMotion.velocityPxPerSec;
1602
+ }
1603
+ }
1604
+ pruneDeadRunway(port);
1605
+ if (!settleRetiring) {
1606
+ const measured = measureReadingAnchor(port);
1607
+ if (measured !== null && measured.delta > .5) {
1608
+ if (pruneDeadRunway(port)) reservePx = 0;
1609
+ holdGuardAnchor(port, measured, measured.top);
1610
+ } else if (measured !== null && measured.delta < -.5) {
1611
+ if (pruneDeadRunway(port)) reservePx = 0;
1612
+ if (!activeRef.current) pullReadingAnchorBack(port, measured);
1613
+ else {
1614
+ const released = Math.min(flowPadOf(port), -measured.delta);
1615
+ if (released > .25) {
1616
+ setFlowPad(port, flowPadOf(port) - released);
1617
+ animatedH = Math.max(0, animatedH - released);
1618
+ }
1619
+ holdGuardAnchor(port, measured, measured.top + released);
1620
+ }
1621
+ } else if (measured !== null) holdGuardAnchor(port, measured, measured.top);
1622
+ }
1169
1623
  const tuning = debugRuntime.activeTuning();
1170
1624
  const predictGrowth = predictiveRef?.current ?? predictive;
1171
1625
  const floor = Math.max(0, port.scrollHeight - port.clientHeight);
1172
1626
  if (followFloorHistory.get(port) !== floor) invalidatePaintLimit(port);
1173
1627
  const isReasoningSurface = rootRef.current?.querySelector("[data-variant=\"think\"]") !== null;
1174
1628
  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);
1629
+ 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);
1630
+ if (trajectoryShift !== void 0) trajectoryPositionPx = floor - (followLastShiftPx.get(port) ?? floor - (trajectoryPositionPx ?? floor));
1177
1631
  updateRevealScale(port, 0, true);
1178
1632
  reportFollow(port, activeRef.current);
1179
1633
  };
@@ -1183,6 +1637,7 @@ window.__ModuleLoader__.load({
1183
1637
  if (port !== null) {
1184
1638
  for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture);
1185
1639
  resize?.disconnect();
1640
+ mutations?.disconnect();
1186
1641
  }
1187
1642
  unsubscribeCommit?.();
1188
1643
  port = next;
@@ -1192,11 +1647,23 @@ window.__ModuleLoader__.load({
1192
1647
  });
1193
1648
  for (const name of GESTURE_EVENTS) port.addEventListener(name, markGesture, { passive: true });
1194
1649
  if (typeof ResizeObserver !== "undefined") {
1195
- resize = new ResizeObserver(restoreBeforePaint);
1650
+ resize = new ResizeObserver(() => restoreBeforePaint());
1196
1651
  resize.observe(port);
1197
1652
  const proxy = resizeProxyOf(port);
1198
1653
  if (proxy !== null) resize.observe(proxy);
1199
1654
  }
1655
+ if (typeof MutationObserver !== "undefined") {
1656
+ const flow = flowElementOf(port);
1657
+ if (flow !== null) {
1658
+ mutations = new MutationObserver(() => {
1659
+ restoreBeforePaint();
1660
+ });
1661
+ mutations.observe(flow, {
1662
+ childList: true,
1663
+ subtree: true
1664
+ });
1665
+ }
1666
+ }
1200
1667
  };
1201
1668
  /**
1202
1669
  * Keep the observer on the flow's TAIL surface. A flow locked to the
@@ -1224,17 +1691,28 @@ window.__ModuleLoader__.load({
1224
1691
  if (nextPort === null) return;
1225
1692
  bindPort(nextPort);
1226
1693
  observeTailSurface();
1694
+ resetHostScrollOwnershipForNewTurn(nextPort);
1695
+ hostOwnsScroll = followHostScrollPorts.has(nextPort);
1696
+ if (activeRef.current) followActivePorts.add(nextPort);
1697
+ else followActivePorts.delete(nextPort);
1227
1698
  if (nextPort.clientHeight <= 0) return;
1228
- const reportedLag = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight) - nextPort.scrollTop;
1699
+ const floor = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight);
1700
+ const reportedLag = floor - nextPort.scrollTop;
1229
1701
  const extent = Math.min(nextPort.scrollHeight, Math.max(0, nextPort.scrollHeight - reportedLag));
1230
1702
  if (!primed) {
1703
+ if (completionSettleGuardsPort(nextPort)) {
1704
+ primed = true;
1705
+ following = false;
1706
+ return;
1707
+ }
1231
1708
  const inherited = nextPort.hasAttribute(FOLLOW_OWNED_ATTR) ? followMotionStates.get(nextPort) : void 0;
1232
1709
  if (inherited === void 0) {
1233
1710
  const entranceExtent = entrancePending ? entranceExtentRef?.current ?? entranceExtentOf(root) : 0;
1234
1711
  const predictGrowth = predictiveRef?.current ?? predictive;
1235
1712
  animatedH = entrancePending ? Math.max(0, nextPort.scrollHeight - entranceExtent) : nextPort.scrollHeight;
1236
1713
  const hasStatus = turnStatusOf(nextPort) !== null;
1237
- reservePx = predictGrowth && (hasStatus || speedCpsRef.current > 90) ? computeFollowReserve(speedCpsRef.current, tuning.runwayPx) : 0;
1714
+ reservePx = Math.max(ownedBottomSpaceOf(nextPort), predictGrowth && (hasStatus || speedCpsRef.current > 90) ? computeFollowReserve(speedCpsRef.current, tuning.runwayPx) : 0);
1715
+ if (!completionSettleGuardsPort(nextPort)) setFlowPad(nextPort, 0);
1238
1716
  statusWasPresent = hasStatus;
1239
1717
  velocityPxPerSec = 0;
1240
1718
  following = !readerScrolledUp(nextPort) && !followReaderHolds.has(nextPort);
@@ -1247,7 +1725,7 @@ window.__ModuleLoader__.load({
1247
1725
  if (following) {
1248
1726
  hold(nextPort);
1249
1727
  if (isLeader(nextPort)) {
1250
- animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx, Number.POSITIVE_INFINITY, !(predictiveRef?.current ?? predictive));
1728
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx, Number.POSITIVE_INFINITY, !(predictiveRef?.current ?? predictive), void 0, 0, !hostOwnsScroll);
1251
1729
  if (predictive && root.querySelector("[data-variant=\"think\"]") === null && runwayOffsetOf(nextPort) > 0) {
1252
1730
  const floor = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight);
1253
1731
  const requestedMinLagPx = Math.max(20, runwayOffsetOf(nextPort) - 32);
@@ -1299,20 +1777,27 @@ window.__ModuleLoader__.load({
1299
1777
  reportFollow(nextPort, activeRef.current);
1300
1778
  return;
1301
1779
  }
1780
+ detectHostScroll(nextPort, floor);
1781
+ if (hostOwnsScroll) {
1782
+ followScrollLedgers.set(nextPort, nextPort.scrollTop);
1783
+ reportFollow(nextPort, false);
1784
+ return;
1785
+ }
1302
1786
  hold(nextPort);
1303
1787
  if (!isLeader(nextPort)) {
1304
1788
  finishEntrance();
1305
1789
  return;
1306
1790
  }
1307
1791
  const predictGrowth = predictiveRef?.current ?? predictive;
1308
- const hasStatus = turnStatusOf(nextPort) !== null;
1792
+ const statusElement = turnStatusOf(nextPort);
1793
+ const hasStatus = statusElement !== null;
1794
+ if (statusElement !== null) lastStatusHeightPx = statusElement.offsetHeight;
1309
1795
  const statusJustRemoved = predictGrowth && statusWasPresent === true && !hasStatus;
1310
- if (statusJustRemoved) reservePx = tuning.runwayPx;
1796
+ if (statusJustRemoved) reservePx = Math.max(reservePx, tuning.runwayPx) + lastStatusHeightPx;
1311
1797
  statusWasPresent = hasStatus;
1312
1798
  const reserveEnabled = hasStatus || statusJustRemoved || reservePx > .25 || speedCpsRef.current > 90;
1313
1799
  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;
1800
+ const effectiveReserveTarget = Math.max(reservePx, pressureReserveTarget);
1316
1801
  const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs);
1317
1802
  reservePx += (effectiveReserveTarget - reservePx) * reserveStep;
1318
1803
  if (predictGrowth || runwayOffsetOf(nextPort) > .5) ensureRunway(nextPort, shiftSurfacesOf(nextPort), reservePx);
@@ -1386,23 +1871,45 @@ window.__ModuleLoader__.load({
1386
1871
  animatedH = contentHeight - runwayOffset;
1387
1872
  velocityPxPerSec = 0;
1388
1873
  } else {
1389
- const minimumLag = predictGrowth ? 0 : Math.max(0, reservePx);
1390
- animatedH = Math.min(contentHeight - runwayOffset - minimumLag, animatedH + step.advancePx);
1874
+ animatedH = Math.min(contentHeight - runwayOffset, animatedH + step.advancePx);
1391
1875
  velocityPxPerSec = step.velocityPxPerSec;
1392
1876
  }
1393
1877
  }
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);
1878
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx, Number.POSITIVE_INFINITY, !predictGrowth, trajectoryShift, elapsedMs, !hostOwnsScroll);
1879
+ if (trajectoryActive) trajectoryPositionPx = floorNow - (followLastShiftPx.get(nextPort) ?? trajectoryShift ?? 0);
1396
1880
  updateRevealScale(nextPort, elapsedMs);
1397
1881
  reportFollow(nextPort, true);
1882
+ if (isLeader(nextPort)) {
1883
+ if (!activeRef.current) enforceReadingAnchor(nextPort);
1884
+ else measureReadingAnchor(nextPort);
1885
+ }
1398
1886
  if (Math.max(0, nextPort.scrollHeight - animatedH - runwayOffsetOf(nextPort)) <= .25) finishEntrance();
1399
1887
  };
1400
1888
  frame(performance.now());
1401
1889
  return () => {
1890
+ if (!controlScrollRef.current) {
1891
+ cancelAnimationFrame(rafId);
1892
+ if (port !== null) followActivePorts.delete(port);
1893
+ unsubscribeCommit?.();
1894
+ resize?.disconnect();
1895
+ mutations?.disconnect();
1896
+ if (port !== null) for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture);
1897
+ if (interactTimer !== null) clearTimeout(interactTimer);
1898
+ const disabledHost = rootRef.current?.closest("[data-conversation-scroll]") ?? port;
1899
+ if (disabledHost !== null) {
1900
+ clearVisual(disabledHost);
1901
+ followLeaders.delete(disabledHost);
1902
+ debugRuntime.reportFollow(disabledHost, null);
1903
+ }
1904
+ releaseRevealScale();
1905
+ return;
1906
+ }
1402
1907
  cancelAnimationFrame(rafId);
1908
+ if (port !== null) followActivePorts.delete(port);
1403
1909
  unsubscribeCommit?.();
1404
1910
  if (interactTimer !== null) clearTimeout(interactTimer);
1405
1911
  resize?.disconnect();
1912
+ mutations?.disconnect();
1406
1913
  if (port !== null) for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture);
1407
1914
  const host = rootRef.current?.closest("[data-conversation-scroll]") ?? port;
1408
1915
  if (host === null) return;
@@ -1426,8 +1933,18 @@ window.__ModuleLoader__.load({
1426
1933
  debugRuntime.reportFollow(host, null);
1427
1934
  return;
1428
1935
  }
1936
+ let settleQuietMs = 0;
1937
+ let settleSig = "";
1429
1938
  const lagBeforeCompletionPaint = Math.max(0, host.scrollHeight - animatedH - runwayOffsetOf(host));
1430
- if (!activeRef.current && lagBeforeCompletionPaint <= 25) {
1939
+ const currentRunway = runwayOffsetOf(host);
1940
+ const currentShift = Math.abs(currentShiftOf(shiftSurfacesOf(host).at(-1) ?? host));
1941
+ if (!activeRef.current && lagBeforeCompletionPaint <= 25 && currentRunway <= .25 && currentShift <= .25 && flowPadOf(host) <= .25) {
1942
+ followTraceUntilMs = Math.max(followTraceUntilMs, performance.now() + 1e4);
1943
+ followTrace("fast-gate", {
1944
+ sh: host.scrollHeight,
1945
+ st: Math.round(host.scrollTop),
1946
+ pad: Math.round(flowPadOf(host))
1947
+ });
1431
1948
  finishAtNaturalFloor(host, !startedAsEntrance);
1432
1949
  followLeaders.delete(host);
1433
1950
  releaseRevealScale();
@@ -1435,19 +1952,37 @@ window.__ModuleLoader__.load({
1435
1952
  return;
1436
1953
  }
1437
1954
  const completionShift = currentShiftOf(shiftSurfacesOf(host).at(-1) ?? host);
1438
- const completionShiftCeiling = completionShift > .25 ? completionShift : Number.POSITIVE_INFINITY;
1955
+ const completionShiftCeiling = startedAsEntrance && completionShift <= .25 ? Number.POSITIVE_INFINITY : completionShift;
1956
+ if (hostOwnsScroll) {
1957
+ clearVisual(host);
1958
+ setFollowScrollTop(host, Math.max(0, host.scrollHeight - host.clientHeight));
1959
+ host.removeAttribute(FOLLOW_OWNED_ATTR);
1960
+ followLeaders.delete(host);
1961
+ releaseRevealScale();
1962
+ debugRuntime.reportFollow(host, null);
1963
+ return;
1964
+ }
1965
+ followTraceUntilMs = performance.now() + 15e3;
1966
+ if (pruneDeadRunway(host)) {
1967
+ followTrace("cleanup-dead-margin", {
1968
+ sh: host.scrollHeight,
1969
+ st: Math.round(host.scrollTop),
1970
+ pad: Math.round(flowPadOf(host))
1971
+ });
1972
+ reservePx = 0;
1973
+ }
1439
1974
  const completionTuning = debugRuntime.activeTuning();
1440
1975
  const previousCompletionRunway = runwayOffsetOf(host);
1441
- ensureRunway(host, shiftSurfacesOf(host), Math.max(reservePx, completionTuning.runwayPx));
1976
+ ensureRunway(host, shiftSurfacesOf(host), Math.max(reservePx, Math.max(0, completionTuning.runwayPx - flowPadOf(host))));
1442
1977
  const completionRunway = runwayOffsetOf(host);
1978
+ measureReadingAnchor(host);
1443
1979
  animatedH = Math.max(0, animatedH - (completionRunway - previousCompletionRunway));
1444
- settleAtFloor(host);
1445
- animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, completionRunway, completionShiftCeiling);
1980
+ if (!hostOwnsScroll) settleAtFloor(host);
1981
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, completionRunway, completionShiftCeiling, false, void 0, 0, !hostOwnsScroll);
1446
1982
  reportFollow(host, false);
1447
1983
  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);
1984
+ if (Math.max(0, host.scrollHeight - animatedH - runwayOffset) <= .25 && runwayOffset <= .25 && reservePx <= .25) {
1985
+ finishAtNaturalFloor(host, !startedAsEntrance, !hostOwnsScroll);
1451
1986
  followLeaders.delete(host);
1452
1987
  releaseRevealScale();
1453
1988
  debugRuntime.reportFollow(host, null);
@@ -1456,14 +1991,35 @@ window.__ModuleLoader__.load({
1456
1991
  for (const name of GESTURE_EVENTS) host.addEventListener(name, markGesture, { passive: true });
1457
1992
  const stopSettleListeners = () => {
1458
1993
  for (const name of GESTURE_EVENTS) host.removeEventListener(name, markGesture);
1994
+ resize?.disconnect();
1995
+ mutations?.disconnect();
1459
1996
  if (interactTimer !== null) {
1460
1997
  clearTimeout(interactTimer);
1461
1998
  interactTimer = null;
1462
1999
  }
1463
2000
  };
2001
+ if (typeof ResizeObserver !== "undefined") {
2002
+ resize = new ResizeObserver(() => restoreBeforePaint());
2003
+ resize.observe(host);
2004
+ const proxy = resizeProxyOf(host);
2005
+ if (proxy !== null) resize.observe(proxy);
2006
+ }
2007
+ if (typeof MutationObserver !== "undefined") {
2008
+ const flow = flowElementOf(host);
2009
+ if (flow !== null) {
2010
+ mutations = new MutationObserver(() => {
2011
+ restoreBeforePaint();
2012
+ });
2013
+ mutations.observe(flow, {
2014
+ childList: true,
2015
+ subtree: true
2016
+ });
2017
+ }
2018
+ }
2019
+ followCompletionSettle.add(host);
2020
+ followCompletionSettleRows.set(host, countUserRows(host));
2021
+ handedOff = true;
1464
2022
  let settleLast = performance.now();
1465
- let settleMarginPx = Math.max(runwayOffsetOf(host), reservePx);
1466
- let settleMarginRate = settleMarginPx / 160;
1467
2023
  const settleFrame = (now) => {
1468
2024
  if (!isLeader(host)) {
1469
2025
  stopSettleListeners();
@@ -1473,6 +2029,7 @@ window.__ModuleLoader__.load({
1473
2029
  readerGestureIntent = false;
1474
2030
  handBackVisual(host);
1475
2031
  clearVisual(host);
2032
+ followCompletionSettle.delete(host);
1476
2033
  followLeaders.delete(host);
1477
2034
  releaseRevealScale();
1478
2035
  debugRuntime.reportFollow(host, null);
@@ -1482,19 +2039,56 @@ window.__ModuleLoader__.load({
1482
2039
  const dt = Math.min(32, Math.max(0, now - settleLast));
1483
2040
  const tuning = debugRuntime.activeTuning();
1484
2041
  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);
2042
+ detectHostScroll(host, Math.max(0, host.scrollHeight - host.clientHeight));
2043
+ if (hostOwnsScroll) {
2044
+ clearVisual(host);
2045
+ setFollowScrollTop(host, Math.max(0, host.scrollHeight - host.clientHeight));
2046
+ host.removeAttribute(FOLLOW_OWNED_ATTR);
2047
+ followCompletionSettle.delete(host);
2048
+ followLeaders.delete(host);
2049
+ releaseRevealScale();
2050
+ debugRuntime.reportFollow(host, null);
2051
+ stopSettleListeners();
2052
+ return;
2053
+ }
2054
+ const guardDelta = !settleRetiring && !followActivePorts.has(host) ? enforceReadingAnchor(host, true) : null;
2055
+ settleQuietMs = !settleRetiring && (guardDelta === null || Math.abs(guardDelta) <= .5) ? settleQuietMs + dt : 0;
2056
+ const settleStatus = turnStatusOf(host);
2057
+ const ownedRunwayPx = runwayOffsetOf(host);
2058
+ if (ownedRunwayPx > .25) {
2059
+ const requestedTransferPx = settleStatus === null ? ownedRunwayPx : Math.min(reservePx, (tuning.runwayPx || 72) / FOLLOW_RUNWAY_RETIRE_MS * dt);
2060
+ const transferredPx = transferRunwayToFlowPad(host, requestedTransferPx);
2061
+ if (transferredPx > 0) setFlowPad(host, Math.max(0, flowPadOf(host) - transferredPx));
2062
+ reservePx = Math.max(0, reservePx - transferredPx);
2063
+ animatedH += transferredPx;
1489
2064
  }
1490
2065
  const runwayOffset = runwayOffsetOf(host);
1491
2066
  const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset);
1492
- if (lag <= .25 && settleMarginPx <= .25) {
2067
+ if (!settleRetiring && lag <= .25 && reservePx <= .25 && settleQuietMs >= 240 && flowPadOf(host) > .25) {
2068
+ followTrace("retire-start", {
2069
+ pad: Math.round(flowPadOf(host)),
2070
+ sh: host.scrollHeight,
2071
+ st: Math.round(host.scrollTop)
2072
+ });
2073
+ settleRetiring = true;
2074
+ }
2075
+ if (settleRetiring) {
2076
+ const padPx = flowPadOf(host);
2077
+ const retirePx = Math.min(padPx, (tuning.runwayPx || 72) / FOLLOW_RUNWAY_RETIRE_MS * dt);
2078
+ if (padPx - retirePx <= .25) {
2079
+ setFlowPad(host, 0);
2080
+ settleRetiring = false;
2081
+ } else setFlowPad(host, padPx - retirePx);
2082
+ }
2083
+ if (lag <= .25 && (reservePx <= .25 || settleStatus === null) && flowPadOf(host) <= .25 && settleQuietMs >= 240) {
2084
+ followTrace("finish", {
2085
+ st: Math.round(host.scrollTop),
2086
+ sh: host.scrollHeight,
2087
+ pad: Math.round(flowPadOf(host))
2088
+ });
1493
2089
  animatedH = host.scrollHeight;
1494
- reservePx = 0;
1495
2090
  velocityPxPerSec = 0;
1496
- followRunways.delete(host);
1497
- finishAtNaturalFloor(host, !startedAsEntrance);
2091
+ finishAtNaturalFloor(host, !startedAsEntrance, !hostOwnsScroll);
1498
2092
  followLeaders.delete(host);
1499
2093
  releaseRevealScale();
1500
2094
  debugRuntime.reportFollow(host, null);
@@ -1508,8 +2102,22 @@ window.__ModuleLoader__.load({
1508
2102
  }, tuning);
1509
2103
  animatedH = Math.min(host.scrollHeight - runwayOffset, animatedH + step.advancePx);
1510
2104
  velocityPxPerSec = step.velocityPxPerSec;
1511
- settleAtFloor(host);
1512
- animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, Math.max(settleMarginPx, runwayOffset));
2105
+ if (!hostOwnsScroll) settleAtFloor(host);
2106
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, runwayOffset, Number.POSITIVE_INFINITY, false, void 0, 0, !hostOwnsScroll);
2107
+ if (traceActive()) {
2108
+ const sig = `${Math.round(host.scrollTop)}|${host.scrollHeight}|${Math.round(flowPadOf(host))}|${Math.round(reservePx)}|${settleRetiring}`;
2109
+ if (sig !== settleSig) {
2110
+ followTrace("settle", {
2111
+ st: Math.round(host.scrollTop),
2112
+ sh: host.scrollHeight,
2113
+ pad: Math.round(flowPadOf(host)),
2114
+ reserve: Math.round(reservePx),
2115
+ lag: Math.round(lag * 10) / 10,
2116
+ retiring: settleRetiring
2117
+ });
2118
+ settleSig = sig;
2119
+ }
2120
+ }
1513
2121
  reportFollow(host, false);
1514
2122
  requestAnimationFrame(settleFrame);
1515
2123
  };
@@ -1521,7 +2129,8 @@ window.__ModuleLoader__.load({
1521
2129
  speedCpsRef,
1522
2130
  revealScaleRef,
1523
2131
  predictive,
1524
- predictiveRef
2132
+ predictiveRef,
2133
+ controlScroll
1525
2134
  ]);
1526
2135
  (0, react.useLayoutEffect)(() => {
1527
2136
  const host = rootRef.current?.closest("[data-conversation-scroll]") ?? null;
@@ -2003,10 +2612,10 @@ window.__ModuleLoader__.load({
2003
2612
  * generic wrapped renderers re-arm one glide when their DOM grows without
2004
2613
  * requiring a business-kind-specific lifecycle predicate.
2005
2614
  */
2006
- function FollowHost({ active, entrance = false, onEntranceSettled, onGrowth, entranceExtentRef, speedCpsRef, revealedCharsRef, revealScaleRef, predictive = true, predictiveRef, hostRef, children }) {
2615
+ function FollowHost({ active, entrance = false, onEntranceSettled, onGrowth, entranceExtentRef, speedCpsRef, revealedCharsRef, revealScaleRef, predictive = true, predictiveRef, controlScroll = true, hostRef, className, entranceActive, children }) {
2007
2616
  const localRootRef = (0, react.useRef)(null);
2008
2617
  const rootRef = hostRef ?? localRootRef;
2009
- useConversationFollow(rootRef, active || entrance, speedCpsRef, revealScaleRef, predictive, entrance, onEntranceSettled, predictiveRef, entranceExtentRef, revealedCharsRef);
2618
+ useConversationFollow(rootRef, active || entrance, speedCpsRef, revealScaleRef, predictive, entrance, onEntranceSettled, predictiveRef, entranceExtentRef, revealedCharsRef, controlScroll);
2010
2619
  (0, react.useEffect)(() => {
2011
2620
  if (onGrowth === void 0 || typeof ResizeObserver === "undefined") return;
2012
2621
  const root = rootRef.current;
@@ -2039,7 +2648,8 @@ window.__ModuleLoader__.load({
2039
2648
  }, [onGrowth]);
2040
2649
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2041
2650
  ref: rootRef,
2042
- className: TypewriterAssistantNodeView_module_css_default.follow,
2651
+ className: className === void 0 ? TypewriterAssistantNodeView_module_css_default.follow : `${TypewriterAssistantNodeView_module_css_default.follow} ${className}`,
2652
+ "data-entrance": entranceActive === void 0 ? void 0 : entranceActive ? "active" : "idle",
2043
2653
  children
2044
2654
  });
2045
2655
  }
@@ -2072,6 +2682,20 @@ window.__ModuleLoader__.load({
2072
2682
  }, []);
2073
2683
  return reduced;
2074
2684
  }
2685
+ /**
2686
+ * Resolve whether the reveal engine should stay off for this view. The OS
2687
+ * preference wins only in `auto` mode: `force-smooth` keeps the engine on
2688
+ * machines where a system-wide reduce-motion switch (or a forced browser
2689
+ * flag) would otherwise silently bypass smoothing, and `force-reduced`
2690
+ * disables it even when the OS asks for motion. Credit: three-state design
2691
+ * proposed by @Zn-Dk in #21/#22.
2692
+ */
2693
+ function useMotionReduced(preference) {
2694
+ const system = usePrefersReducedMotion();
2695
+ if (preference === "force-smooth") return false;
2696
+ if (preference === "force-reduced") return true;
2697
+ return system;
2698
+ }
2075
2699
  /** Conservative fallback before the streaming Markdown tail has geometry. */
2076
2700
  const PREDICTIVE_WRAP_FALLBACK_CHARS = 32;
2077
2701
  const STREAM_ANNOUNCEMENT_INTERVAL_MS = 800;
@@ -2277,8 +2901,8 @@ window.__ModuleLoader__.load({
2277
2901
  * drains, the settled full parse (KaTeX math, fence highlighting, file
2278
2902
  * mentions) swaps in exactly once.
2279
2903
  */
2280
- function AnimatedMarkdownText({ text, codeLabels, fileMentions, streaming, ownFollow, followSpeedCpsRef, followRevealedCharsRef, followRevealScaleRef, onPredictiveChange, preset, shouldHoldBack }) {
2281
- const reduced = usePrefersReducedMotion();
2904
+ function AnimatedMarkdownText({ text, labels, fileMentions, streaming, motionReduced, ownFollow, followSpeedCpsRef, followRevealedCharsRef, followRevealScaleRef, onPredictiveChange, preset, shouldHoldBack, controlScroll = true }) {
2905
+ const reduced = motionReduced;
2282
2906
  const [typing, setTyping] = (0, react.useState)(streaming);
2283
2907
  const localSpeedCpsRef = (0, react.useRef)(35);
2284
2908
  const followRootRef = (0, react.useRef)(null);
@@ -2334,17 +2958,21 @@ window.__ModuleLoader__.load({
2334
2958
  text,
2335
2959
  typing
2336
2960
  ]);
2961
+ (0, react.useEffect)(() => {
2962
+ if (streaming) setTyping(true);
2963
+ }, [streaming]);
2337
2964
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FollowHost, {
2338
2965
  active: live && ownFollow,
2339
2966
  speedCpsRef,
2340
2967
  revealedCharsRef: followRevealedCharsRef,
2341
2968
  revealScaleRef: followRevealScaleRef,
2342
2969
  predictive: streaming,
2970
+ controlScroll,
2343
2971
  hostRef: followRootRef,
2344
2972
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MarkdownText, {
2345
2973
  text: live ? shown : text,
2346
2974
  streaming: live,
2347
- codeLabels,
2975
+ labels,
2348
2976
  fileMentions: live ? void 0 : fileMentions
2349
2977
  })
2350
2978
  });
@@ -2362,6 +2990,50 @@ window.__ModuleLoader__.load({
2362
2990
  }
2363
2991
  };
2364
2992
  }
2993
+ /**
2994
+ * Apply searchable hidden state without unmounting a stable subtree — the
2995
+ * Host's completion fold hides the answer-inline reasoning this way, so the
2996
+ * takeover renderer must reproduce it to stay inside the contract: the row
2997
+ * disappears into the Host's process summary and comes back on find-in-page.
2998
+ * (Mirrors the Harness chat kit's `useSearchableHidden`.)
2999
+ */
3000
+ function useSearchableHidden(hidden, reveal) {
3001
+ const ref = (0, react.useRef)(null);
3002
+ (0, react.useLayoutEffect)(() => {
3003
+ const element = ref.current;
3004
+ if (element === null) return;
3005
+ if (hidden && element.contains(element.ownerDocument.activeElement)) {
3006
+ reveal();
3007
+ return;
3008
+ }
3009
+ if (hidden) element.setAttribute("hidden", "until-found");
3010
+ else element.removeAttribute("hidden");
3011
+ }, [hidden, reveal]);
3012
+ (0, react.useEffect)(() => {
3013
+ const element = ref.current;
3014
+ if (element === null) return;
3015
+ element.addEventListener("beforematch", reveal);
3016
+ return () => {
3017
+ element.removeEventListener("beforematch", reveal);
3018
+ };
3019
+ }, [reveal]);
3020
+ return ref;
3021
+ }
3022
+ /**
3023
+ * One answer-inline reasoning block wrapped in the Host's fold contract. The
3024
+ * Host computes the fold decision (turn closed, compact transcript, this node
3025
+ * is the answer) and delivers it through the `turnProcess` owner prop; when
3026
+ * folded the block hides into the Host's "thought" summary row instead of
3027
+ * staying mounted above the answer.
3028
+ */
3029
+ function FoldableReasoning({ hidden, reveal, children }) {
3030
+ const ref = useSearchableHidden(hidden, reveal);
3031
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3032
+ ref,
3033
+ "data-turn-process-inline": hidden || void 0,
3034
+ children
3035
+ });
3036
+ }
2365
3037
  function firstLine(text) {
2366
3038
  const newline = text.indexOf("\n");
2367
3039
  return newline === -1 ? text : text.slice(0, newline);
@@ -2380,8 +3052,8 @@ window.__ModuleLoader__.load({
2380
3052
  * or the assistant node settling — not when the rest of the reply is
2381
3053
  * still streaming.
2382
3054
  */
2383
- function AnimatedReasoning({ text, running, preset, thinkAutoExpand, shouldHoldBack, followSpeedCpsRef, followRevealScaleRef, t }) {
2384
- const reduced = usePrefersReducedMotion();
3055
+ function AnimatedReasoning({ text, running, preset, thinkAutoExpand, motionReduced, shouldHoldBack, followSpeedCpsRef, followRevealScaleRef, t }) {
3056
+ const reduced = motionReduced;
2385
3057
  const [expanded, setExpanded] = (0, react.useState)(running && thinkAutoExpand);
2386
3058
  const [autoClosed, setAutoClosed] = (0, react.useState)(false);
2387
3059
  const summaryRef = (0, react.useRef)(null);
@@ -2460,10 +3132,14 @@ window.__ModuleLoader__.load({
2460
3132
  * holds offscreen reveals when the frame rate is degraded. Settled text
2461
3133
  * renders with the full Markdown pipeline.
2462
3134
  */
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 }) {
3135
+ 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, motionPreference = DEFAULT_STREAM_SETTINGS.motionPreference, node, useTurnData, openFile, loadImage, fileMentions, turnProcess, t }) {
2464
3136
  const data = node.data;
2465
3137
  const streaming = data.status === "running";
2466
- const reduced = usePrefersReducedMotion();
3138
+ const reduced = useMotionReduced(motionPreference);
3139
+ const reasoningHidden = turnProcess !== void 0 && turnProcess.foldable && turnProcess.spec.answerStep === data.step && turnProcess.spec.inlineReasoning && !turnProcess.open;
3140
+ const revealProcess = (0, react.useCallback)(() => {
3141
+ turnProcess?.setOpen(true);
3142
+ }, [turnProcess]);
2467
3143
  const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming);
2468
3144
  const rootSpeedRef = (0, react.useRef)(35);
2469
3145
  const rootRevealedCharsRef = (0, react.useRef)(0);
@@ -2497,9 +3173,12 @@ window.__ModuleLoader__.load({
2497
3173
  turn
2498
3174
  ]);
2499
3175
  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")
3176
+ const markdownLabels = (0, react.useMemo)(() => ({
3177
+ code: {
3178
+ copyLabel: t("copy"),
3179
+ copiedLabel: t("copied")
3180
+ },
3181
+ footnotes: t("markdown.footnotes")
2503
3182
  }), [t]);
2504
3183
  const imageLoader = loadImage ?? (async () => {
2505
3184
  throw new Error(t("image.serviceUnavailable"));
@@ -2520,28 +3199,35 @@ window.__ModuleLoader__.load({
2520
3199
  case "text":
2521
3200
  rendered.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnimatedMarkdownText, {
2522
3201
  text: block.text,
2523
- codeLabels,
3202
+ labels: markdownLabels,
2524
3203
  fileMentions: mentions,
2525
3204
  streaming,
3205
+ motionReduced: reduced,
2526
3206
  ownFollow: !streaming && index === lastFollow,
2527
3207
  followSpeedCpsRef: index === lastFollow ? rootSpeedRef : void 0,
2528
3208
  followRevealedCharsRef: index === lastFollow ? rootRevealedCharsRef : void 0,
2529
3209
  followRevealScaleRef: index === lastFollow ? rootRevealScaleRef : void 0,
2530
3210
  onPredictiveChange: index === lastFollow ? updateTextPrediction : void 0,
2531
3211
  preset,
2532
- shouldHoldBack
3212
+ shouldHoldBack,
3213
+ controlScroll
2533
3214
  }, index));
2534
3215
  break;
2535
3216
  case "reasoning":
2536
- rendered.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnimatedReasoning, {
2537
- text: block.text,
2538
- running: streaming && index === last,
2539
- preset,
2540
- thinkAutoExpand,
2541
- shouldHoldBack,
2542
- followSpeedCpsRef: reasoningOwnsSpeed && index === last ? rootSpeedRef : void 0,
2543
- followRevealScaleRef: reasoningOwnsSpeed && index === last ? rootRevealScaleRef : void 0,
2544
- t
3217
+ rendered.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FoldableReasoning, {
3218
+ hidden: reasoningHidden,
3219
+ reveal: revealProcess,
3220
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnimatedReasoning, {
3221
+ text: block.text,
3222
+ running: streaming && index === last,
3223
+ preset,
3224
+ thinkAutoExpand,
3225
+ motionReduced: reduced,
3226
+ shouldHoldBack,
3227
+ followSpeedCpsRef: reasoningOwnsSpeed && index === last ? rootSpeedRef : void 0,
3228
+ followRevealScaleRef: reasoningOwnsSpeed && index === last ? rootRevealScaleRef : void 0,
3229
+ t
3230
+ })
2545
3231
  }, index));
2546
3232
  break;
2547
3233
  case "image": {
@@ -2582,6 +3268,7 @@ window.__ModuleLoader__.load({
2582
3268
  revealedCharsRef: rootRevealedCharsRef,
2583
3269
  revealScaleRef: rootRevealScaleRef,
2584
3270
  predictiveRef: rootPredictiveRef,
3271
+ controlScroll,
2585
3272
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2586
3273
  className: TypewriterAssistantNodeView_module_css_default.body,
2587
3274
  children: [rendered, data.status === "interrupted" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -2593,867 +3280,6 @@ window.__ModuleLoader__.load({
2593
3280
  });
2594
3281
  });
2595
3282
  //#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
3283
  //#region src/client/useProgressiveDomText.ts
3458
3284
  /**
3459
3285
  * Progressive text reveal for opaque Agent renderers.
@@ -3714,6 +3540,24 @@ window.__ModuleLoader__.load({
3714
3540
  ]);
3715
3541
  }
3716
3542
  //#endregion
3543
+ //#region \0dsh-css:/Users/dzlin/work/project/dsh-smooth-stream/src/client/AgentRowEntrance.module.css.mjs
3544
+ 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}}";
3545
+ const tagId$2 = "dsh-smooth-stream/AgentRowEntrance.module.css";
3546
+ if (typeof document !== "undefined") {
3547
+ let tag = document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$2) + "]");
3548
+ if (tag === null) {
3549
+ tag = document.createElement("style");
3550
+ tag.dataset.plugin = "dsh-smooth-stream";
3551
+ tag.dataset.pluginCss = tagId$2;
3552
+ document.head.appendChild(tag);
3553
+ }
3554
+ tag.textContent = css$2;
3555
+ }
3556
+ var AgentRowEntrance_module_css_default = {
3557
+ "dsh-smooth-stream-agent-row-in": "_7P9_Ta_dsh-smooth-stream-agent-row-in",
3558
+ "surface": "_7P9_Ta_surface"
3559
+ };
3560
+ //#endregion
3717
3561
  //#region src/client/TypewriterToolNodeView.tsx
3718
3562
  function openAgentLocation(node) {
3719
3563
  if (node === null || typeof node !== "object" || !("location" in node)) return false;
@@ -3797,8 +3641,9 @@ window.__ModuleLoader__.load({
3797
3641
  * @param Inner - The already-registered row component.
3798
3642
  * @returns A follow-hosted row.
3799
3643
  */
3800
- function wrapFollowNodeView(Inner) {
3644
+ function wrapFollowNodeView(Inner, useControlScroll) {
3801
3645
  return function TypewriterFollowNodeView(props) {
3646
+ const controlScroll = useControlScroll?.() ?? true;
3802
3647
  const speedCpsRef = (0, react.useRef)(35);
3803
3648
  const hostRef = (0, react.useRef)(null);
3804
3649
  const growing = isGrowingChatNode(props.node);
@@ -3878,15 +3723,18 @@ window.__ModuleLoader__.load({
3878
3723
  onGrowth: followable ? onGrowth : void 0,
3879
3724
  entranceExtentRef: growthExtentRef,
3880
3725
  speedCpsRef,
3726
+ controlScroll,
3881
3727
  predictive: false,
3882
3728
  hostRef,
3729
+ className: AgentRowEntrance_module_css_default.surface,
3730
+ entranceActive: entering || growthPulse,
3883
3731
  children: (0, react.createElement)(Inner, props)
3884
3732
  });
3885
3733
  };
3886
3734
  }
3887
3735
  //#endregion
3888
3736
  //#region \0dsh-css:/Users/dzlin/work/project/dsh-smooth-stream/src/client/SmoothStreamCard.module.css.mjs
3889
- const css$1 = ".XNGeRG_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;flex-direction:column;list-style:none;transition:border-color .16s,background .16s;display:flex}.XNGeRG_card:hover{border-color:var(--dsw-alias-label-dimmed)}.XNGeRG_cardOpen{border-color:var(--dsw-alias-label-dimmed);background:var(--dsw-alias-bg-layer-2)}.XNGeRG_header{appearance:none;cursor:pointer;text-align:left;width:100%;font:inherit;color:inherit;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.XNGeRG_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.XNGeRG_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.XNGeRG_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.XNGeRG_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.XNGeRG_version{text-overflow:ellipsis;white-space:nowrap;max-width:16rem;color:var(--dsw-alias-label-tertiary);flex:none;font-size:12px;line-height:1.5;overflow:hidden}.XNGeRG_pending{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);white-space:nowrap;border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.XNGeRG_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.XNGeRG_chevronOpen{transform:rotate(180deg)}.XNGeRG_body{border-top:1px solid var(--dsw-alias-border-l2);flex-direction:column;margin:0 16px;padding-bottom:8px;display:flex}.XNGeRG_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.XNGeRG_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.XNGeRG_field+.XNGeRG_field{border-top:1px solid var(--dsw-alias-border-l2)}.XNGeRG_fieldDisabled{opacity:.5}.XNGeRG_fieldHead{justify-content:space-between;align-items:center;gap:8px;display:flex}.XNGeRG_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.XNGeRG_toggle{width:16px;height:16px;accent-color:var(--dsw-alias-brand-primary);cursor:pointer}.XNGeRG_toggle:disabled{cursor:default}.XNGeRG_toggle:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.XNGeRG_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}.XNGeRG_failure{justify-content:space-between;align-items:center;gap:12px;padding:12px 0;display:flex}.XNGeRG_updateRow{border-top:1px solid var(--dsw-alias-border-l2);justify-content:space-between;align-items:center;gap:12px;padding:12px 0;display:flex}.XNGeRG_updateCopy{flex-direction:column;gap:4px;min-width:0;display:flex}.XNGeRG_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.XNGeRG_failed{min-width:0;color:var(--dsw-alias-label-error);flex:1;margin:0;font-size:12px;line-height:1.5}.XNGeRG_save,.XNGeRG_discard,.XNGeRG_update{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.XNGeRG_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.XNGeRG_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.XNGeRG_discard:hover:not(:disabled),.XNGeRG_update:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed);color:var(--dsw-alias-label-primary)}.XNGeRG_update{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0;flex:none;align-items:center;gap:6px;display:inline-flex}.XNGeRG_discard:disabled,.XNGeRG_save:disabled,.XNGeRG_update:disabled{opacity:.4;cursor:default}.XNGeRG_discard:focus-visible,.XNGeRG_save:focus-visible,.XNGeRG_update:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}";
3737
+ const css$1 = ".XNGeRG_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;flex-direction:column;list-style:none;transition:border-color .16s,background .16s;display:flex}.XNGeRG_card:hover{border-color:var(--dsw-alias-label-dimmed)}.XNGeRG_cardOpen{border-color:var(--dsw-alias-label-dimmed);background:var(--dsw-alias-bg-layer-2)}.XNGeRG_header{appearance:none;cursor:pointer;text-align:left;width:100%;font:inherit;color:inherit;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.XNGeRG_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.XNGeRG_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.XNGeRG_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.XNGeRG_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.XNGeRG_version{text-overflow:ellipsis;white-space:nowrap;max-width:16rem;color:var(--dsw-alias-label-tertiary);flex:none;font-size:12px;line-height:1.5;overflow:hidden}.XNGeRG_pending{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);white-space:nowrap;border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.XNGeRG_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.XNGeRG_chevronOpen{transform:rotate(180deg)}.XNGeRG_body{border-top:1px solid var(--dsw-alias-border-l2);flex-direction:column;margin:0 16px;padding-bottom:8px;display:flex}.XNGeRG_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.XNGeRG_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.XNGeRG_field+.XNGeRG_field{border-top:1px solid var(--dsw-alias-border-l2)}.XNGeRG_fieldDisabled{opacity:.5}.XNGeRG_fieldHead{justify-content:space-between;align-items:center;gap:8px;display:flex}.XNGeRG_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.XNGeRG_toggle{width:16px;height:16px;accent-color:var(--dsw-alias-brand-primary);cursor:pointer}.XNGeRG_toggle:disabled{cursor:default}.XNGeRG_toggle:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.XNGeRG_choiceRow{flex-wrap:wrap;gap:6px 14px;margin:2px 0 0;padding:0;display:flex}.XNGeRG_choice{color:var(--dsw-alias-label-secondary);cursor:pointer;align-items:center;gap:5px;font-size:12px;line-height:1.5;display:flex}.XNGeRG_choiceInput{width:14px;height:14px;accent-color:var(--dsw-alias-brand-primary);cursor:pointer}.XNGeRG_choice:has(.XNGeRG_choiceInput:disabled){cursor:default;opacity:.55}.XNGeRG_choiceInput:disabled{cursor:default}.XNGeRG_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}.XNGeRG_failure{justify-content:space-between;align-items:center;gap:12px;padding:12px 0;display:flex}.XNGeRG_updateRow{border-top:1px solid var(--dsw-alias-border-l2);justify-content:space-between;align-items:center;gap:12px;padding:12px 0;display:flex}.XNGeRG_updateCopy{flex-direction:column;gap:4px;min-width:0;display:flex}.XNGeRG_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.XNGeRG_failed{min-width:0;color:var(--dsw-alias-label-error);flex:1;margin:0;font-size:12px;line-height:1.5}.XNGeRG_save,.XNGeRG_discard,.XNGeRG_update{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.XNGeRG_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.XNGeRG_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.XNGeRG_discard:hover:not(:disabled),.XNGeRG_update:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed);color:var(--dsw-alias-label-primary)}.XNGeRG_update{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0;flex:none;align-items:center;gap:6px;display:inline-flex}.XNGeRG_discard:disabled,.XNGeRG_save:disabled,.XNGeRG_update:disabled{opacity:.4;cursor:default}.XNGeRG_discard:focus-visible,.XNGeRG_save:focus-visible,.XNGeRG_update:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}";
3890
3738
  const tagId$1 = "dsh-smooth-stream/SmoothStreamCard.module.css";
3891
3739
  if (typeof document !== "undefined") {
3892
3740
  let tag = document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]");
@@ -3899,32 +3747,35 @@ window.__ModuleLoader__.load({
3899
3747
  tag.textContent = css$1;
3900
3748
  }
3901
3749
  var SmoothStreamCard_module_css_default = {
3750
+ "body": "XNGeRG_body",
3751
+ "fieldDisabled": "XNGeRG_fieldDisabled",
3752
+ "readOnly": "XNGeRG_readOnly",
3753
+ "field": "XNGeRG_field",
3754
+ "choiceInput": "XNGeRG_choiceInput",
3755
+ "headText": "XNGeRG_headText",
3756
+ "version": "XNGeRG_version",
3757
+ "choice": "XNGeRG_choice",
3758
+ "description": "XNGeRG_description",
3759
+ "pending": "XNGeRG_pending",
3902
3760
  "fieldHead": "XNGeRG_fieldHead",
3903
3761
  "label": "XNGeRG_label",
3904
3762
  "toggle": "XNGeRG_toggle",
3905
- "cardOpen": "XNGeRG_cardOpen",
3763
+ "hint": "XNGeRG_hint",
3764
+ "save": "XNGeRG_save",
3765
+ "updateRow": "XNGeRG_updateRow",
3906
3766
  "chevron": "XNGeRG_chevron",
3767
+ "cardOpen": "XNGeRG_cardOpen",
3768
+ "card": "XNGeRG_card",
3907
3769
  "header": "XNGeRG_header",
3908
- "field": "XNGeRG_field",
3909
3770
  "failure": "XNGeRG_failure",
3910
- "discard": "XNGeRG_discard",
3911
- "headText": "XNGeRG_headText",
3912
- "updateRow": "XNGeRG_updateRow",
3913
- "footer": "XNGeRG_footer",
3914
- "fieldDisabled": "XNGeRG_fieldDisabled",
3915
- "pending": "XNGeRG_pending",
3916
- "body": "XNGeRG_body",
3917
- "save": "XNGeRG_save",
3918
- "update": "XNGeRG_update",
3919
- "chevronOpen": "XNGeRG_chevronOpen",
3920
- "hint": "XNGeRG_hint",
3921
3771
  "failed": "XNGeRG_failed",
3772
+ "chevronOpen": "XNGeRG_chevronOpen",
3773
+ "discard": "XNGeRG_discard",
3922
3774
  "updateCopy": "XNGeRG_updateCopy",
3923
- "description": "XNGeRG_description",
3924
- "card": "XNGeRG_card",
3925
- "version": "XNGeRG_version",
3926
- "readOnly": "XNGeRG_readOnly",
3927
- "name": "XNGeRG_name"
3775
+ "update": "XNGeRG_update",
3776
+ "footer": "XNGeRG_footer",
3777
+ "name": "XNGeRG_name",
3778
+ "choiceRow": "XNGeRG_choiceRow"
3928
3779
  };
3929
3780
  //#endregion
3930
3781
  //#region src/client/SmoothStreamCard.tsx
@@ -4025,40 +3876,91 @@ window.__ModuleLoader__.load({
4025
3876
  className: SmoothStreamCard_module_css_default.fieldHead,
4026
3877
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4027
3878
  className: SmoothStreamCard_module_css_default.label,
4028
- children: t("thinkAutoExpand")
3879
+ children: t("controlScroll")
4029
3880
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4030
3881
  type: "checkbox",
4031
3882
  className: SmoothStreamCard_module_css_default.toggle,
4032
- checked: state.thinkAutoExpand,
3883
+ checked: state.controlScroll,
4033
3884
  disabled: !state.writable || state.saving || !state.enabled,
4034
3885
  onChange: (event) => {
4035
- props.edit({ thinkAutoExpand: event.target.checked });
3886
+ props.edit({ controlScroll: event.target.checked });
4036
3887
  }
4037
3888
  })]
4038
3889
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4039
3890
  className: SmoothStreamCard_module_css_default.hint,
4040
- children: t("thinkAutoExpandHint")
3891
+ children: t("controlScrollHint")
4041
3892
  })]
4042
3893
  }),
3894
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3895
+ className: state.enabled ? SmoothStreamCard_module_css_default.field : `${SmoothStreamCard_module_css_default.field} ${SmoothStreamCard_module_css_default.fieldDisabled}`,
3896
+ children: [
3897
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3898
+ className: SmoothStreamCard_module_css_default.fieldHead,
3899
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3900
+ className: SmoothStreamCard_module_css_default.label,
3901
+ children: t("motionPreference")
3902
+ })
3903
+ }),
3904
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3905
+ className: SmoothStreamCard_module_css_default.hint,
3906
+ children: t("motionPreferenceHint")
3907
+ }),
3908
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3909
+ className: SmoothStreamCard_module_css_default.choiceRow,
3910
+ role: "radiogroup",
3911
+ "aria-label": t("motionPreference"),
3912
+ children: [
3913
+ [
3914
+ "auto",
3915
+ "motionAuto",
3916
+ "motionAutoHint"
3917
+ ],
3918
+ [
3919
+ "force-smooth",
3920
+ "motionForceSmooth",
3921
+ "motionForceSmoothHint"
3922
+ ],
3923
+ [
3924
+ "force-reduced",
3925
+ "motionForceReduced",
3926
+ "motionForceReducedHint"
3927
+ ]
3928
+ ].map(([value, label, hint]) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3929
+ className: SmoothStreamCard_module_css_default.choice,
3930
+ title: t(hint),
3931
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3932
+ type: "radio",
3933
+ className: SmoothStreamCard_module_css_default.choiceInput,
3934
+ name: "smooth-stream-motion",
3935
+ checked: state.motionPreference === value,
3936
+ disabled: !state.writable || state.saving || !state.enabled,
3937
+ onChange: () => {
3938
+ props.edit({ motionPreference: value });
3939
+ }
3940
+ }), t(label)]
3941
+ }, value))
3942
+ })
3943
+ ]
3944
+ }),
4043
3945
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4044
- className: SmoothStreamCard_module_css_default.field,
3946
+ className: state.enabled ? SmoothStreamCard_module_css_default.field : `${SmoothStreamCard_module_css_default.field} ${SmoothStreamCard_module_css_default.fieldDisabled}`,
4045
3947
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4046
3948
  className: SmoothStreamCard_module_css_default.fieldHead,
4047
3949
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4048
3950
  className: SmoothStreamCard_module_css_default.label,
4049
- children: t("autoCollapse")
3951
+ children: t("thinkAutoExpand")
4050
3952
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4051
3953
  type: "checkbox",
4052
3954
  className: SmoothStreamCard_module_css_default.toggle,
4053
- checked: state.autoCollapse,
4054
- disabled: !state.writable || state.saving,
3955
+ checked: state.thinkAutoExpand,
3956
+ disabled: !state.writable || state.saving || !state.enabled,
4055
3957
  onChange: (event) => {
4056
- props.edit({ autoCollapse: event.target.checked });
3958
+ props.edit({ thinkAutoExpand: event.target.checked });
4057
3959
  }
4058
3960
  })]
4059
3961
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4060
3962
  className: SmoothStreamCard_module_css_default.hint,
4061
- children: t("autoCollapseHint")
3963
+ children: t("thinkAutoExpandHint")
4062
3964
  })]
4063
3965
  }),
4064
3966
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
@@ -4182,11 +4084,12 @@ window.__ModuleLoader__.load({
4182
4084
  hooks: { smoothStreamCard: this.store },
4183
4085
  edit: (patch) => {
4184
4086
  if (this.saving) return;
4185
- if (patch.enabled !== void 0 || patch.thinkAutoExpand !== void 0 || patch.autoCollapse !== void 0) this.stagedBase = {
4087
+ if (patch.enabled !== void 0 || patch.controlScroll !== void 0 || patch.motionPreference !== void 0 || patch.thinkAutoExpand !== void 0) this.stagedBase = {
4186
4088
  ...this.baseValues(),
4187
4089
  ...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
4188
- ...patch.thinkAutoExpand === void 0 ? {} : { thinkAutoExpand: patch.thinkAutoExpand },
4189
- ...patch.autoCollapse === void 0 ? {} : { autoCollapse: patch.autoCollapse }
4090
+ ...patch.controlScroll === void 0 ? {} : { controlScroll: patch.controlScroll },
4091
+ ...patch.motionPreference === void 0 ? {} : { motionPreference: patch.motionPreference },
4092
+ ...patch.thinkAutoExpand === void 0 ? {} : { thinkAutoExpand: patch.thinkAutoExpand }
4190
4093
  };
4191
4094
  if (this.loadedDebug !== void 0 && (patch.debugEnabled !== void 0 || patch.debugTuning !== void 0)) this.stagedDebug = {
4192
4095
  ...this.debugValues(),
@@ -4238,8 +4141,9 @@ window.__ModuleLoader__.load({
4238
4141
  baseValues() {
4239
4142
  return this.stagedBase ?? {
4240
4143
  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
4144
+ controlScroll: this.loaded?.controlScroll ?? DEFAULT_STREAM_SETTINGS.controlScroll,
4145
+ motionPreference: this.loaded?.motionPreference ?? DEFAULT_STREAM_SETTINGS.motionPreference,
4146
+ thinkAutoExpand: this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand
4243
4147
  };
4244
4148
  }
4245
4149
  debugValues() {
@@ -4359,7 +4263,11 @@ window.__ModuleLoader__.load({
4359
4263
  "npm",
4360
4264
  "development",
4361
4265
  "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");
4266
+ ].includes(data.installation) || typeof data.writable !== "boolean" || typeof data.enabled !== "boolean" || typeof data.controlScroll !== "boolean" || ![
4267
+ "auto",
4268
+ "force-smooth",
4269
+ "force-reduced"
4270
+ ].includes(data.motionPreference) || typeof data.thinkAutoExpand !== "boolean" || typeof data.canUpgrade !== "boolean") throw new Error("dsh-smooth-stream: malformed settings response");
4363
4271
  return data;
4364
4272
  }
4365
4273
  function upgradeView(value) {
@@ -4388,8 +4296,9 @@ window.__ModuleLoader__.load({
4388
4296
  async write(settings) {
4389
4297
  return settingsView(accepted(await connection.rpc.call(STREAM_SETTINGS_RPC_CHANNEL, STREAM_SETTINGS_RPC.write, {
4390
4298
  enabled: settings.enabled,
4299
+ controlScroll: settings.controlScroll,
4300
+ motionPreference: settings.motionPreference,
4391
4301
  thinkAutoExpand: settings.thinkAutoExpand,
4392
- autoCollapse: settings.autoCollapse,
4393
4302
  ...settings.debugEnabled === void 0 || settings.debugTuning === void 0 ? {} : {
4394
4303
  debugEnabled: settings.debugEnabled,
4395
4304
  debugTuning: settings.debugTuning
@@ -4425,34 +4334,34 @@ window.__ModuleLoader__.load({
4425
4334
  tag.textContent = css;
4426
4335
  }
4427
4336
  var DebugPanel_module_css_default = {
4337
+ "iconButton": "m4_tdG_iconButton",
4428
4338
  "triggerActive": "m4_tdG_triggerActive",
4429
- "guide": "m4_tdG_guide",
4430
- "metric": "m4_tdG_metric",
4339
+ "controlHead": "m4_tdG_controlHead",
4340
+ "number": "m4_tdG_number",
4431
4341
  "range": "m4_tdG_range",
4432
- "iconButton": "m4_tdG_iconButton",
4433
- "unsaved": "m4_tdG_unsaved",
4434
- "section": "m4_tdG_section",
4435
- "metrics": "m4_tdG_metrics",
4436
- "panel": "m4_tdG_panel",
4437
- "unit": "m4_tdG_unit",
4438
- "trigger": "m4_tdG_trigger",
4439
- "state": "m4_tdG_state",
4440
- "secondaryButton": "m4_tdG_secondaryButton",
4342
+ "statusLive": "m4_tdG_statusLive",
4441
4343
  "title": "m4_tdG_title",
4442
- "controlHead": "m4_tdG_controlHead",
4443
4344
  "controlLabel": "m4_tdG_controlLabel",
4345
+ "guide": "m4_tdG_guide",
4346
+ "infoButton": "m4_tdG_infoButton",
4444
4347
  "numberWrap": "m4_tdG_numberWrap",
4445
- "primaryButton": "m4_tdG_primaryButton",
4446
- "panelHeader": "m4_tdG_panelHeader",
4447
- "statusLive": "m4_tdG_statusLive",
4348
+ "footer": "m4_tdG_footer",
4349
+ "control": "m4_tdG_control",
4350
+ "secondaryButton": "m4_tdG_secondaryButton",
4351
+ "unsaved": "m4_tdG_unsaved",
4352
+ "metrics": "m4_tdG_metrics",
4448
4353
  "scrollArea": "m4_tdG_scrollArea",
4354
+ "state": "m4_tdG_state",
4355
+ "metric": "m4_tdG_metric",
4356
+ "panel": "m4_tdG_panel",
4357
+ "trigger": "m4_tdG_trigger",
4358
+ "statusDot": "m4_tdG_statusDot",
4359
+ "section": "m4_tdG_section",
4360
+ "unit": "m4_tdG_unit",
4449
4361
  "footerSpacer": "m4_tdG_footerSpacer",
4450
4362
  "visuallyHidden": "m4_tdG_visuallyHidden",
4451
- "statusDot": "m4_tdG_statusDot",
4452
- "infoButton": "m4_tdG_infoButton",
4453
- "footer": "m4_tdG_footer",
4454
- "number": "m4_tdG_number",
4455
- "control": "m4_tdG_control"
4363
+ "primaryButton": "m4_tdG_primaryButton",
4364
+ "panelHeader": "m4_tdG_panelHeader"
4456
4365
  };
4457
4366
  //#endregion
4458
4367
  //#region src/client/DebugPanel.tsx
@@ -4848,10 +4757,18 @@ window.__ModuleLoader__.load({
4848
4757
  description: "How replies are revealed while they stream.",
4849
4758
  enabled: "Enable smooth streaming",
4850
4759
  enabledHint: "Let this plugin render and follow streaming replies. Turn off to use the built-in Harness renderer.",
4760
+ controlScroll: "Take over scrolling",
4761
+ controlScrollHint: "On by default; smooth-stream writes the conversation scroll position. Turn off to leave bottom-follow to Harness.",
4762
+ motionPreference: "Motion",
4763
+ motionPreferenceHint: "How streaming responds to the system reduce-motion setting.",
4764
+ motionAuto: "Follow system",
4765
+ motionAutoHint: "Reduced-motion systems get raw text (accessibility first).",
4766
+ motionForceSmooth: "Always smooth",
4767
+ motionForceSmoothHint: "Keep the smoothing engine even when the system asks for reduced motion.",
4768
+ motionForceReduced: "Always raw",
4769
+ motionForceReducedHint: "Render raw text even when the system allows motion.",
4851
4770
  thinkAutoExpand: "Auto-expand thinking",
4852
4771
  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
4772
  debugEnabled: "Show render diagnostics",
4856
4773
  debugEnabledHint: "Show live streaming and scroll metrics on the right side of the chat. Tune values there, then save them here.",
4857
4774
  debugUnavailable: "Live diagnostics require a newer plugin Host.",
@@ -4927,10 +4844,18 @@ window.__ModuleLoader__.load({
4927
4844
  description: "回复在流式输出时如何逐字展现。",
4928
4845
  enabled: "启用丝滑流式渲染",
4929
4846
  enabledHint: "由本插件渲染并跟随流式回复;关闭后使用 Harness 内置渲染。",
4847
+ controlScroll: "接管滚动",
4848
+ controlScrollHint: "默认开启,由丝滑流式写入会话滚动位置;关闭后贴底滚动交给 Harness。",
4849
+ motionPreference: "动效偏好",
4850
+ motionPreferenceHint: "流式渲染如何响应系统的「减少动态效果」设置。",
4851
+ motionAuto: "跟随系统",
4852
+ motionAutoHint: "系统开启减少动态效果时直接呈现原始文本(优先无障碍)。",
4853
+ motionForceSmooth: "始终平滑",
4854
+ motionForceSmoothHint: "即使系统要求减少动态效果,仍保持平滑流式渲染。",
4855
+ motionForceReduced: "始终原始",
4856
+ motionForceReducedHint: "即使系统允许动效,也直接呈现原始文本。",
4930
4857
  thinkAutoExpand: "自动展开思考",
4931
4858
  thinkAutoExpandHint: "思考块在流式时自动展开;关闭后保持折叠,可手动展开。",
4932
- autoCollapse: "完成后自动折叠",
4933
- autoCollapseHint: "回复处理完成后,把思考、工具与过程输出折叠为一行“已处理”摘要,只展示最终回复;点击摘要可再次展开。开启或关闭流式渲染均可用,默认开启。",
4934
4859
  debugEnabled: "显示渲染调试面板",
4935
4860
  debugEnabledHint: "在聊天右侧显示流式渲染和滚动的实时参数,可在面板中调节并在这里保存。",
4936
4861
  debugUnavailable: "当前 Host 版本不支持实时调试,请先更新插件 Host。",
@@ -5064,7 +4989,7 @@ window.__ModuleLoader__.load({
5064
4989
  * @param ctx - Browser context carrying the slot registry.
5065
4990
  * @returns Restorer that puts the original components back.
5066
4991
  */
5067
- function wrapAgentChatRows(ctx) {
4992
+ function wrapAgentChatRows(ctx, useControlScroll) {
5068
4993
  const restores = [];
5069
4994
  const wrapped = /* @__PURE__ */ new WeakSet();
5070
4995
  const wrapAll = () => {
@@ -5074,7 +4999,7 @@ window.__ModuleLoader__.load({
5074
4999
  const current = entry.component;
5075
5000
  if (!isWrappableComponent(current) || wrapped.has(current)) continue;
5076
5001
  const inner = current;
5077
- const next = wrapFollowNodeView(inner);
5002
+ const next = wrapFollowNodeView(inner, useControlScroll);
5078
5003
  wrapped.add(next);
5079
5004
  entry.component = next;
5080
5005
  restores.push(() => {
@@ -5123,7 +5048,7 @@ window.__ModuleLoader__.load({
5123
5048
  refresh() {
5124
5049
  const next = this.read();
5125
5050
  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;
5051
+ if (pending === this.pending && next.enabled === this.value.enabled && next.controlScroll === this.value.controlScroll && next.motionPreference === this.value.motionPreference && next.thinkAutoExpand === this.value.thinkAutoExpand && next.debugEnabled === this.value.debugEnabled && next.debugTuning === this.value.debugTuning) return;
5127
5052
  this.pending = pending;
5128
5053
  this.value = next;
5129
5054
  for (const listener of this.listeners) listener();
@@ -5132,10 +5057,6 @@ window.__ModuleLoader__.load({
5132
5057
  takeoverEnabled() {
5133
5058
  return !this.pending && this.value.enabled;
5134
5059
  }
5135
- /** Whether finished turns should fold behind a summary row right now. */
5136
- autoCollapseActive() {
5137
- return !this.pending && this.value.autoCollapse;
5138
- }
5139
5060
  getSnapshot = () => this.value;
5140
5061
  subscribe = (listener) => {
5141
5062
  this.listeners.add(listener);
@@ -5158,19 +5079,7 @@ window.__ModuleLoader__.load({
5158
5079
  function apply(ctx) {
5159
5080
  const config = readBootConfig();
5160
5081
  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
- });
5082
+ const useControlScroll = () => (0, react.useSyncExternalStore)(settings.subscribe, () => settings.getSnapshot().controlScroll, () => settings.getSnapshot().controlScroll);
5174
5083
  ctx.inject([
5175
5084
  "slots",
5176
5085
  "locale",
@@ -5238,7 +5147,9 @@ window.__ModuleLoader__.load({
5238
5147
  revealCharsPerSec: config.revealCharsPerSec,
5239
5148
  scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
5240
5149
  maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
5241
- thinkAutoExpand: preferences.thinkAutoExpand
5150
+ thinkAutoExpand: preferences.thinkAutoExpand,
5151
+ controlScroll: preferences.controlScroll,
5152
+ motionPreference: preferences.motionPreference
5242
5153
  });
5243
5154
  };
5244
5155
  ctx.slots.inject("conversation.chat.node", () => {
@@ -5250,7 +5161,7 @@ window.__ModuleLoader__.load({
5250
5161
  return;
5251
5162
  }
5252
5163
  if (releaseTakeover !== void 0) return;
5253
- const unwrap = wrapAgentChatRows(ctx);
5164
+ const unwrap = wrapAgentChatRows(ctx, useControlScroll);
5254
5165
  const unshadow = ctx.slots.register({
5255
5166
  name: "conversation.chat.node",
5256
5167
  key: "assistant-step",