dsh-smooth-stream 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -96,11 +96,14 @@ export const FOLLOW_PAINT_GUARD_PX = 1
96
96
  export const FOLLOW_STATUS_RUNWAY_PX = 72
97
97
 
98
98
  /**
99
- * Duration of the lockstep runway retirement at stream end. The margin and
100
- * its canceling transform shrink together, so this duration is invisible;
101
- * it only bounds how long the owned margin lingers.
99
+ * Duration of completion runway retirement. The final pad is visible motion:
100
+ * its shrinking floor brings the transcript down to its natural resting
101
+ * position. 1.5s keeps the default 72px runway at 48px/s (0.8px per 60Hz
102
+ * frame), matching the configured default reading-follow velocity instead of
103
+ * the old 160ms / 450px/s staircase that looked like repeated completion
104
+ * jumps.
102
105
  */
103
- export const FOLLOW_RUNWAY_RETIRE_MS = 160
106
+ export const FOLLOW_RUNWAY_RETIRE_MS = 1500
104
107
 
105
108
  /** How long a gesture keeps `isUserInteracting` so the next scroll can unpin. */
106
109
  export const FOLLOW_GESTURE_MS = 800
@@ -108,6 +111,23 @@ export const FOLLOW_GESTURE_MS = 800
108
111
  /** Sub-pixel settle threshold; clearing below this cannot produce a visible rebound. */
109
112
  export const FOLLOW_SETTLE_EPSILON_PX = 0.25
110
113
 
114
+ /**
115
+ * How long the completion settle waits for the HOST's completion cascade —
116
+ * status-row swap, think auto-collapse, metrics-tail mount — to stop moving
117
+ * the scroll extent before the follower releases the port. Releasing earlier
118
+ * hands a still-changing layout to the host's hard floor-snap, which paints
119
+ * each host action as a single-frame slam of the whole transcript.
120
+ */
121
+ export const FOLLOW_SETTLE_QUIET_MS = 240
122
+
123
+ /**
124
+ * Ceiling for the flow's completion pad. The pad backs the floor so the
125
+ * pinned viewport can follow the reading anchor through host completion
126
+ * commits (row swaps/insertions); capping it bounds the below-fold blank
127
+ * space a long conversation accumulates.
128
+ */
129
+ export const FOLLOW_SETTLE_PAD_CAP_PX = 2 * FOLLOW_STATUS_RUNWAY_PX
130
+
111
131
  /** Retained visual-motion budget for compatibility with diagnostics/tests. */
112
132
  export const FOLLOW_CATCHUP_MAX_STEP_PX = 8
113
133
 
@@ -119,9 +139,11 @@ export const FOLLOW_CATCHUP_MAX_STEP_PX = 8
119
139
  * line glides in (slow start) instead of snapping.
120
140
  */
121
141
  /**
122
- * Per-frame bound on the DECAY side of the painted shift only (runway
123
- * retirement and settle). The growth side is wrap compensation and must stay
124
- * unlimited — see the clamp site in `applyVisual`.
142
+ * Per-frame bound on the DECAY side of the painted shift (runway retirement
143
+ * and settle), widened by any same-frame floor drop so extent collapses are
144
+ * always fully repainted — see the clamp site in `applyVisual`. The growth
145
+ * side stays unlimited: it is wrap compensation locked to the same-frame
146
+ * floor step.
125
147
  */
126
148
  export const FOLLOW_PAINT_SHIFT_MAX_STEP_PX = 8
127
149
 
@@ -393,7 +415,7 @@ function resizeProxyOf(port: HTMLElement): HTMLElement | null {
393
415
  * Outermost message surfaces; nested tool rows ride their parent.
394
416
  *
395
417
  * Another plugin may insert its own element as a flow sibling of the Chat rows
396
- * (meow-memory's fold bar is one; so is this plugin's own fold summary row).
418
+ * (meow-memory's fold bar is one).
397
419
  * Such a row carries no `data-chat-anchor-key`, so selecting only anchored
398
420
  * rows would shift the conversation while leaving the foreign row at its
399
421
  * natural offset, letting the shifted rows paint over it. Every direct flow
@@ -612,6 +634,189 @@ const followPaintLimits = new WeakMap<HTMLElement, FollowPaintLimit>()
612
634
  const followHadChrome = new WeakSet<HTMLElement>()
613
635
  /** Last painted shift per port, to spread a wrap's one-line step over frames. */
614
636
  const followLastShiftPx = new WeakMap<HTMLElement, number>()
637
+ /** Last settled floor per port, to size the shift decay bound against extent collapse. */
638
+ const followLastFloorPx = new WeakMap<HTMLElement, number>()
639
+ /**
640
+ * Screen-space completion anchor per port, SHARED by every follower arm and
641
+ * the settle loop. Per-arm closures made the guard incoherent across the
642
+ * completion cascade: block arms hand off leadership mid-turn, each new
643
+ * settle loop re-seeded its own baseline at the already-jumped position, and
644
+ * the cascade's one-frame clamp jump painted uncompensated. One baseline per
645
+ * port also makes concurrent guards idempotent — whoever compensates first
646
+ * restores the anchor, so the second measure reads ~0 and grants nothing.
647
+ *
648
+ * The anchor is the READING surface (the current turn's assistant row), not
649
+ * the bottom-most shift surface: the cascade mounts/unmounts rows at the
650
+ * column's bottom (process/tail mount, status swap), which switches
651
+ * `at(-1)`'s identity mid-guard. Measuring across that switch reads a newly
652
+ * mounted tail row as a >1000px "content moved down" push and floods the
653
+ * pad, while the reading surface's real clamp jump goes uncompensated.
654
+ */
655
+ interface FollowGuardAnchor {
656
+ readonly element: HTMLElement
657
+ /** Held viewport top — the position the reader should keep seeing. */
658
+ readonly top: number
659
+ /** Flow-child index, to tell an in-place replacement from a new turn. */
660
+ readonly index: number
661
+ /** Scroll geometry at store time, to identify an extent-neutral scroll. */
662
+ readonly scrollTop: number
663
+ readonly scrollHeight: number
664
+ /**
665
+ * Compositor shift and owned pad at store time. The engine moves the
666
+ * rendered anchor between guard passes through TWO channels — the shift
667
+ * glide (reveal decay: renderedΔ = −shiftΔ) and the pad retirement (the
668
+ * floor sinks with the pad and the pin follows: renderedΔ = −padΔ). Only
669
+ * the delta beyond BOTH is host motion worth compensating.
670
+ */
671
+ readonly shift: number
672
+ readonly pad: number
673
+ }
674
+ const followGuardAnchors = new WeakMap<HTMLElement, FollowGuardAnchor>()
675
+ /**
676
+ * Ports whose completion settle loop owns the follow. The settle drains the
677
+ * reveal, retires the pad and guards the cascade; the swap that ends the
678
+ * turn REMOUNTS the follower arms in the same frame cluster, and a freshly
679
+ * mounted arm primes with a higher generation and would otherwise steal the
680
+ * port mid-drain — its observers miss the cascade mutations (armed after
681
+ * the fact) and its state initialization re-materializes engine space.
682
+ * Ownership is released only when the settle finishes, the reader gestures,
683
+ * or a genuinely NEW turn arrives (a user row joined since the handoff).
684
+ */
685
+ const followCompletionSettle = new WeakSet<HTMLElement>()
686
+ /** User-row count at handoff, for the new-turn check above. */
687
+ const followCompletionSettleRows = new WeakMap<HTMLElement, number>()
688
+
689
+ /** User rows below the flow, or -1 when the host does not label rows with
690
+ * `data-chat-flow-kind` (the engine's own audit benches): the new-turn
691
+ * check is unsupported there and ownership guarding must stay OFF. */
692
+ function countUserRows(port: HTMLElement): number {
693
+ const flow = flowElementOf(port)
694
+ if (flow === null) return -1
695
+ let count = 0
696
+ let sawKind = false
697
+ for (const child of flow.children) {
698
+ if (!(child instanceof HTMLElement)) continue
699
+ const kind = child.getAttribute('data-chat-flow-kind')
700
+ if (kind !== null) sawKind = true
701
+ if (kind === 'user') count++
702
+ }
703
+ return sawKind ? count : -1
704
+ }
705
+
706
+ /** True while this port's completion settle owns the follow and no new turn
707
+ * has arrived since the handoff. */
708
+ function completionSettleGuardsPort(port: HTMLElement): boolean {
709
+ if (!followCompletionSettle.has(port)) return false
710
+ const baseline = followCompletionSettleRows.get(port) ?? -1
711
+ const current = countUserRows(port)
712
+ return baseline >= 0 && current >= 0 && current <= baseline
713
+ }
714
+ /**
715
+ * Ports whose completion settle loop owns the follow. A settle loop drains,
716
+ * retires the pad and guards the cascade; an arm that primes while this is
717
+ * set with `active === false` (the settled-side static arm mounting in the
718
+ * very swap frame) must NOT seize leadership — the swap re-renders the node
719
+ * view, a fresh arm would otherwise steal the port mid-drain with
720
+ * `reservePx = ownedBottomSpace` (the pad!), re-materialize an equal runway
721
+ * through applyVisual, double-count the extent and fight the settle's own
722
+ * guard for the rest of the window. A streaming arm (active === true, the
723
+ * next turn) still takes over normally and the settle yields.
724
+ */
725
+
726
+ function readingAnchorOf(port: HTMLElement): HTMLElement | null {
727
+ const flow = flowElementOf(port)
728
+ if (flow === null) return null
729
+ let anchor: HTMLElement | null = null
730
+ for (const child of flow.children) {
731
+ if (!(child instanceof HTMLElement)) continue
732
+ if (child.getAttribute('data-chat-flow-kind') === 'assistant' || child.querySelector('[data-variant="think"]') !== null) {
733
+ anchor = child
734
+ }
735
+ }
736
+ return anchor ?? shiftSurfacesOf(port).at(-1) ?? null
737
+ }
738
+
739
+ /**
740
+ * Measure the reading surface's screen delta since the last guard pass.
741
+ * Returns null when there is nothing comparable yet (first observation), or
742
+ * when the anchor changed identity in a way that is NOT the in-place
743
+ * live→settled replacement (a new turn's row joined — nothing jumped,
744
+ * re-seed). Compensation sites must store the POST-compensation held
745
+ * position via `holdGuardAnchor`, or the guard would read its own
746
+ * correction as a fresh jump and oscillate.
747
+ */
748
+ function measureReadingAnchor(port: HTMLElement): { anchor: HTMLElement; index: number; top: number; delta: number } | null {
749
+ const anchor = readingAnchorOf(port)
750
+ if (anchor === null) {
751
+ followGuardAnchors.delete(port)
752
+ return null
753
+ }
754
+ const rect = anchor.getBoundingClientRect()
755
+ // Unmeasurable geometry (jsdom's zero rects, a detached row): screen-space
756
+ // comparison is meaningless — never seed or compensate from it.
757
+ if (!(rect.width > 0 || rect.height > 0)) return null
758
+ const top = rect.top
759
+ const shift = currentShiftOf(anchor)
760
+ const pad = flowPadOf(port)
761
+ const scrollTop = port.scrollTop
762
+ const scrollHeight = port.scrollHeight
763
+ const flow = flowElementOf(port)
764
+ const index = flow === null ? -1 : [...flow.children].indexOf(anchor)
765
+ const stored = followGuardAnchors.get(port)
766
+ if (stored === undefined) {
767
+ followGuardAnchors.set(port, { element: anchor, top, index, shift, pad, scrollTop, scrollHeight })
768
+ return null
769
+ }
770
+ // A host bottom-follow write is NOT a layout push. With extent unchanged, a
771
+ // scrollTop change only moves the whole viewport; granting pad for it would
772
+ // manufacture blank space and then require a second "retirement" motion.
773
+ // Rebase both ledgers and let the writer keep its scroll position.
774
+ if (
775
+ Math.abs(scrollHeight - stored.scrollHeight) <= 0.5
776
+ && Math.abs(scrollTop - stored.scrollTop) > 0.5
777
+ ) {
778
+ followScrollLedgers.set(port, scrollTop)
779
+ followGuardAnchors.set(port, { element: anchor, top, index, shift, pad, scrollTop, scrollHeight })
780
+ return null
781
+ }
782
+ // Host-caused motion only: the engine's own shift glide and floor changes
783
+ // cancel out (renderedΔ = topΔ − shiftΔ + floorΔ).
784
+ const delta = (top - stored.top) - (shift - stored.shift) + (pad - stored.pad)
785
+ if (stored.element === anchor) {
786
+ // Refresh the baseline to the current rendered position on every
787
+ // measurement: between guard passes the viewport legitimately moves
788
+ // (streaming pins, reveal glide), and a stale baseline would read the
789
+ // accumulated motion as one giant host jump at the next commit.
790
+ followGuardAnchors.set(port, { element: anchor, top, index, shift, pad, scrollTop, scrollHeight })
791
+ return { anchor, index, top, delta }
792
+ }
793
+ // Identity changed. An in-place replacement (same slot, old node detached)
794
+ // is the live→settled swap: bridge it by holding the reading surface at
795
+ // its pre-swap viewport position. Anything else (the next turn's row
796
+ // joining below, a session swap) is new layout — re-seed, never hold.
797
+ if (!stored.element.isConnected && stored.index === index) {
798
+ return { anchor, index, top, delta }
799
+ }
800
+ followGuardAnchors.set(port, { element: anchor, top, index, shift, pad, scrollTop, scrollHeight })
801
+ return null
802
+ }
803
+
804
+ /** Record the position the reader should keep seeing after a correction. */
805
+ function holdGuardAnchor(
806
+ port: HTMLElement,
807
+ measured: { anchor: HTMLElement; index: number },
808
+ heldTop: number,
809
+ ): void {
810
+ followGuardAnchors.set(port, {
811
+ element: measured.anchor,
812
+ index: measured.index,
813
+ top: heldTop,
814
+ shift: currentShiftOf(measured.anchor),
815
+ pad: flowPadOf(port),
816
+ scrollTop: port.scrollTop,
817
+ scrollHeight: port.scrollHeight,
818
+ })
819
+ }
615
820
  /** Last runway offset seen per port, to rebase the extent when the margin size changes. */
616
821
  const followRunwayOffsetHistory = new WeakMap<HTMLElement, number>()
617
822
  /** Last observed scroll floor per port, for the slack→overflow runway re-measure. */
@@ -619,6 +824,93 @@ const followFloorHistory = new WeakMap<HTMLElement, number>()
619
824
  /** One-shot flag: the transition frame must paint the full runway as baseline. */
620
825
  const followSlackTransition = new WeakSet<HTMLElement>()
621
826
 
827
+ /**
828
+ * Bottom padding a completed settle left BELOW the status row. The settle
829
+ * retires the owned runway by moving it from the status's top margin (the
830
+ * visible reserve gap) to the flow's bottom padding (space under the chrome):
831
+ * scroll extent stays constant, so the floor — and therefore the pinned
832
+ * scrollTop and every content pixel — never moves, while the status glides up
833
+ * to meet the final line. The pad is reclaimed back into the next stream's
834
+ * runway by `ensureRunway`, so completions never accumulate dead space.
835
+ */
836
+ interface FollowSettlePad {
837
+ readonly element: HTMLElement
838
+ readonly original: string
839
+ readonly px: number
840
+ }
841
+ const followSettlePads = new WeakMap<HTMLElement, FollowSettlePad>()
842
+
843
+ /**
844
+ * Retired follower space lives as `padding-bottom` on the FLOW element — the
845
+ * one node the engine already owns styles on (flow-fill min-height) and the
846
+ * host never rewrites. Host completion commits routinely REPLACE row elements
847
+ * (live→settled swap re-keys the assistant row, the status row unmounts), and
848
+ * any engine space written on those rows dies with them, sinking the floor
849
+ * and slamming the pinned transcript for a frame. The flow survives.
850
+ */
851
+ function flowPadOf(port: HTMLElement): number {
852
+ return followSettlePads.get(port)?.px ?? 0
853
+ }
854
+ /**
855
+ * After a loss is re-opened as pad, a registry entry whose element is no
856
+ * longer connected claims extent that no longer exists; drop it so the next
857
+ * `ensureRunway` re-measures fresh instead of double-counting.
858
+ */
859
+ function pruneDeadRunway(port: HTMLElement): boolean {
860
+ const runway = followRunways.get(port)
861
+ if (runway !== undefined && !runway.element.isConnected) {
862
+ restoreRunway(port)
863
+ return true
864
+ }
865
+ return false
866
+ }
867
+
868
+ function setFlowPad(port: HTMLElement, px: number): void {
869
+ const flow = flowElementOf(port)
870
+ if (flow === null) return
871
+ const existing = followSettlePads.get(port)
872
+ const original = existing?.original ?? flow.style.paddingBottom
873
+ if (px <= FOLLOW_SETTLE_EPSILON_PX) {
874
+ if (existing !== undefined) {
875
+ flow.style.paddingBottom = existing.original
876
+ followSettlePads.delete(port)
877
+ }
878
+ return
879
+ }
880
+ flow.style.paddingBottom = original === ''
881
+ ? `${px}px`
882
+ : `calc(${original} + ${px}px)`
883
+ followSettlePads.set(port, { element: flow, original, px })
884
+ }
885
+
886
+ /** Extent the follower owns below the content: the live runway margin plus
887
+ * the retired completion pad. At adopt the pad is reclaimed into the fresh
888
+ * reservation (same frame, pre-paint), so the floor never steps. */
889
+ function ownedBottomSpaceOf(port: HTMLElement): number {
890
+ return runwayOffsetOf(port) + flowPadOf(port)
891
+ }
892
+
893
+ /**
894
+ * Completion-window diagnostics. Armed when a follower hands off to its
895
+ * settle loop; every engine decision and every externally-written scroll
896
+ * position inside the window prints one compact console line, so a live
897
+ * host session can be diffed against the lab without a debugger.
898
+ */
899
+ let followTraceUntilMs = 0
900
+ function traceActive(): boolean {
901
+ // Completion/finalize paths arm short trace windows automatically. Keep the
902
+ // code path in published bundles, but do not emit console noise unless the
903
+ // user has explicitly turned on render diagnostics.
904
+ return debugRuntime.isEnabled() && performance.now() < followTraceUntilMs
905
+ }
906
+ function followTrace(event: string, detail: Record<string, number | string | boolean>): void {
907
+ if (!traceActive()) return
908
+ console.log(`[dsh-follow] ${event}`, JSON.stringify(detail))
909
+ }
910
+ function hostShOf(port: HTMLElement): number {
911
+ return port.scrollHeight
912
+ }
913
+
622
914
  function invalidatePaintLimit(port: HTMLElement): void {
623
915
  followPaintLimits.delete(port)
624
916
  }
@@ -797,6 +1089,39 @@ function runwayOffsetOf(port: HTMLElement): number {
797
1089
  return followRunways.get(port)?.offset ?? 0
798
1090
  }
799
1091
 
1092
+ /**
1093
+ * Move owned runway margin into the persistent flow pad without changing the
1094
+ * scroll extent. Returns the actual layout pixels removed from the margin.
1095
+ */
1096
+ function transferRunwayToFlowPad(port: HTMLElement, requestedPx: number): number {
1097
+ const runway = followRunways.get(port)
1098
+ if (runway === undefined || requestedPx <= 0 || !runway.element.isConnected) return 0
1099
+ const nextRequestedPx = Math.max(0, runway.requestedPx - requestedPx)
1100
+ const beforeOffset = runway.offset
1101
+ const beforeHeight = port.scrollHeight
1102
+ runway.element.style[runway.property] = nextRequestedPx <= FOLLOW_SETTLE_EPSILON_PX
1103
+ ? runway.original
1104
+ : runway.original === ''
1105
+ ? `${nextRequestedPx}px`
1106
+ : `calc(${runway.original} + ${nextRequestedPx}px)`
1107
+ const nextOffset = Math.max(0, beforeOffset + port.scrollHeight - beforeHeight)
1108
+ const transferredPx = Math.max(0, beforeOffset - nextOffset)
1109
+ if (nextRequestedPx <= FOLLOW_SETTLE_EPSILON_PX || nextOffset <= FOLLOW_SETTLE_EPSILON_PX) {
1110
+ followRunways.delete(port)
1111
+ } else {
1112
+ followRunways.set(port, {
1113
+ ...runway,
1114
+ offset: nextOffset,
1115
+ requestedPx: nextRequestedPx,
1116
+ })
1117
+ }
1118
+ if (transferredPx > 0) {
1119
+ setFlowPad(port, flowPadOf(port) + transferredPx)
1120
+ invalidatePaintLimit(port)
1121
+ }
1122
+ return transferredPx
1123
+ }
1124
+
800
1125
  /** Available paint room below the last message before fixed conversation chrome. */
801
1126
  function safeShiftLimit(
802
1127
  port: HTMLElement,
@@ -850,6 +1175,18 @@ function safeShiftLimit(
850
1175
  }
851
1176
 
852
1177
  function setFollowScrollTop(port: HTMLElement, nextTop: number): void {
1178
+ const ledger = followScrollLedgers.get(port)
1179
+ // Claim before the write so a Host ResizeObserver that runs in the same task
1180
+ // sees the owner instead of racing in with its own bottom-follow.
1181
+ if (port.getAttribute(FOLLOW_OWNED_ATTR) === null) {
1182
+ port.setAttribute(FOLLOW_OWNED_ATTR, 'active')
1183
+ }
1184
+ // Someone else wrote scrollTop since our last write (host floor-snap,
1185
+ // browser clamp, reader): surface it — a fight between the host's own
1186
+ // follow controller and this engine is the completion-jitter suspect.
1187
+ if (ledger !== undefined && traceActive() && Math.abs(port.scrollTop - ledger) > 1) {
1188
+ followTrace('external-scroll', { from: Math.round(port.scrollTop), to: Math.round(nextTop), ledger: Math.round(ledger) })
1189
+ }
853
1190
  if (Math.abs(port.scrollTop - nextTop) > 0.01) port.scrollTop = nextTop
854
1191
  followScrollLedgers.set(port, port.scrollTop)
855
1192
  const ownedTop = String(port.scrollTop)
@@ -866,6 +1203,24 @@ function setFollowScrollTop(port: HTMLElement, nextTop: number): void {
866
1203
  */
867
1204
  const followScrollLedgers = new WeakMap<HTMLElement, number>()
868
1205
  const followActivityAt = new WeakMap<HTMLElement, number>()
1206
+ interface FollowScrollOwnership {
1207
+ /** User-row count at handoff; a larger count means a genuinely new turn. */
1208
+ readonly userRows: number
1209
+ }
1210
+ /**
1211
+ * Scroll ownership must survive follower-arm remounts. Per-closure state let a
1212
+ * new text/tool arm reset the strike counter, so the same host write kept
1213
+ * triggering another engine write and repainting the visible up/down fight.
1214
+ */
1215
+ const followHostScrollPorts = new WeakMap<HTMLElement, FollowScrollOwnership>()
1216
+
1217
+ /** Clear host ownership only when a new user turn actually starts. */
1218
+ function resetHostScrollOwnershipForNewTurn(port: HTMLElement): void {
1219
+ const ownership = followHostScrollPorts.get(port)
1220
+ if (ownership === undefined) return
1221
+ const userRows = countUserRows(port)
1222
+ if (userRows >= 0 && userRows > ownership.userRows) followHostScrollPorts.delete(port)
1223
+ }
869
1224
 
870
1225
  /** Whether this port was owned recently enough to identify a closing tail row. */
871
1226
  export function hasRecentConversationFollow(port: HTMLElement, windowMs = 250): boolean {
@@ -894,6 +1249,8 @@ function applyVisual(
894
1249
  shiftCeilingPx = Number.POSITIVE_INFINITY,
895
1250
  promoteAtRest = false,
896
1251
  trajectoryShiftPx?: number,
1252
+ dtMs = 16.7,
1253
+ writeScrollTop = true,
897
1254
  ): number {
898
1255
  const surfaces = shiftSurfacesOf(port)
899
1256
  ensureFlowFillsPort(port)
@@ -917,10 +1274,6 @@ function applyVisual(
917
1274
  followSlackTransition.add(port)
918
1275
  }
919
1276
  if (preFloor !== lastFloorSeen) {
920
- // A changed floor means the natural tail geometry may have changed. This
921
- // is the infrequent layout-growth boundary; ordinary same-floor reveal
922
- // commits continue to use the cached chrome clearance.
923
- invalidatePaintLimit(port)
924
1277
  followFloorHistory.set(port, preFloor)
925
1278
  }
926
1279
  }
@@ -947,7 +1300,7 @@ function applyVisual(
947
1300
  if (port.style.scrollBehavior !== 'auto') port.style.scrollBehavior = 'auto'
948
1301
  if (floor <= 0) {
949
1302
  followRunwayOffsetHistory.set(port, 0)
950
- setFollowScrollTop(port, 0)
1303
+ if (writeScrollTop) setFollowScrollTop(port, 0)
951
1304
  followMotionStates.set(port, {
952
1305
  capacityPx: Number.POSITIVE_INFINITY,
953
1306
  constrained: false,
@@ -997,23 +1350,60 @@ function applyVisual(
997
1350
  const idlePromotion = promoteAtRest && motionShift <= 0.01 && availableShift > 0 ? 0.1 : 0
998
1351
  let shift = Math.max(motionShift, idlePromotion)
999
1352
  const previousShift = followLastShiftPx.get(port)
1000
- if (previousShift !== undefined && shift < previousShift - FOLLOW_PAINT_SHIFT_MAX_STEP_PX) {
1353
+ // The per-frame decay bound paces the engine's OWN glide (the margin
1354
+ // transfer retires at well under 8px/frame, so the limiter never binds for
1355
+ // it). It must NOT cap the compensation for a floor the engine did not
1356
+ // choose: the host completion cascade (think auto-collapse, status swap)
1357
+ // collapses the extent tens of px in one frame, scrollTop follows the
1358
+ // floor 1:1, and a decay capped at 8px/frame leaves the unpainted
1359
+ // remainder on screen — the completion 回弹 (visible 14-26px/frame sink).
1360
+ // Widen the bound by the floor's drop so the shift can always repaint what
1361
+ // the extent took away; the retire glide is unaffected because its
1362
+ // motionShift never moves.
1363
+ const previousFloor = followLastFloorPx.get(port)
1364
+ const floorDropPx = Math.max(0, (previousFloor ?? floor) - floor)
1365
+ const maxDecayPx = Math.max(
1366
+ dtMs <= 0
1367
+ ? FOLLOW_PAINT_SHIFT_MAX_STEP_PX
1368
+ : Math.max(1, (FOLLOW_PAINT_SHIFT_MAX_STEP_PX / 16.67) * dtMs),
1369
+ floorDropPx,
1370
+ )
1371
+ followLastFloorPx.set(port, floor)
1372
+ if (previousShift !== undefined && shift < previousShift - maxDecayPx) {
1001
1373
  // Decay-only rate limit. Growth is a WRAP COMPENSATION: the floor already
1002
1374
  // jumped one line in the same layout pass and `scrollTop` followed it, so
1003
1375
  // the matching shift increase cancels that step exactly. Rate-limiting it
1004
1376
  // paints the uncovered remainder as a visible one-frame jump (the
1005
1377
  // "换行 18px 跳变"). Only the decay side — runway retirement and settle —
1006
1378
  // is a real animation and keeps its per-frame bound.
1007
- shift = previousShift - FOLLOW_PAINT_SHIFT_MAX_STEP_PX
1379
+ shift = previousShift - maxDecayPx
1380
+ }
1381
+ // COMPLETION RISE FUNDING. In the completion window a shift rise is only
1382
+ // invisible when it cancels floor growth the reader actually gets. The
1383
+ // handoff re-opens the runway while the think disclosure is still
1384
+ // collapsing: a same-task paint funded on the measured extent growth
1385
+ // over-covers by whatever the transition retracts before the paint lands —
1386
+ // the drain-onset 回弹. Same-task painters are therefore ceiling-frozen
1387
+ // (see the observer and the handoff below) and this clamp funds the
1388
+ // settle's rAF-frame rise on the growth CONFIRMED since the previous
1389
+ // paint; a completion without a collapse covers the runway in full on the
1390
+ // first frame (the drain contract). The streaming path never enters this
1391
+ // branch — wrap compensation stays unlimited there.
1392
+ if (followCompletionSettle.has(port) && previousShift !== undefined && previousFloor !== undefined) {
1393
+ const confirmedGrowthPx = Math.max(0, floor - previousFloor)
1394
+ if (shift > previousShift + confirmedGrowthPx) shift = previousShift + confirmedGrowthPx
1008
1395
  }
1009
1396
  followLastShiftPx.set(port, shift)
1010
- const effectiveLag = Math.max(0, motionShift - baselineShift)
1397
+ const requestedShift = trajectoryShiftPx ?? (baselineShift + requestedLag)
1398
+ const effectiveLag = Math.max(0, shift - baselineShift)
1011
1399
  const capacityPx = Math.max(0, limit - baselineShift)
1012
1400
  const effectiveExtent = targetHeight - effectiveLag
1013
- setFollowScrollTop(port, floor)
1401
+ const isConstrained = requestedShift > availableShift + FOLLOW_SETTLE_EPSILON_PX
1402
+ || (limit <= 0 && requestedShift > baselineShift)
1403
+ if (writeScrollTop) setFollowScrollTop(port, floor)
1014
1404
  followMotionStates.set(port, {
1015
1405
  capacityPx,
1016
- constrained: requestedLag > effectiveLag + FOLLOW_SETTLE_EPSILON_PX,
1406
+ constrained: isConstrained,
1017
1407
  extent: effectiveExtent,
1018
1408
  lagPx: effectiveLag,
1019
1409
  reservePx: visibleReserve,
@@ -1039,6 +1429,7 @@ function clearVisual(port: HTMLElement): void {
1039
1429
  restoreRunway(port)
1040
1430
  followMotionStates.delete(port)
1041
1431
  followLastShiftPx.delete(port)
1432
+ followLastFloorPx.delete(port)
1042
1433
  invalidatePaintLimit(port)
1043
1434
  }
1044
1435
 
@@ -1050,12 +1441,19 @@ function holdCompositorAtRest(element: HTMLElement): void {
1050
1441
  }
1051
1442
 
1052
1443
  /** Remove equal offsets, land on the floor, then retire the compositor quietly. */
1053
- function finishAtNaturalFloor(port: HTMLElement, retainCompositor = true): void {
1444
+ function finishAtNaturalFloor(
1445
+ port: HTMLElement,
1446
+ retainCompositor = true,
1447
+ writeScrollTop = true,
1448
+ ): void {
1449
+ followCompletionSettle.delete(port)
1450
+ followTraceUntilMs = Math.max(followTraceUntilMs, performance.now() + 10000)
1451
+ followTrace('finish-enter', { sh: hostShOf(port), st: Math.round(port.scrollTop), pad: Math.round(flowPadOf(port)), retain: retainCompositor })
1054
1452
  const surfaces = shiftSurfacesOf(port)
1055
1453
  const status = turnStatusOf(port)
1056
1454
  if (!retainCompositor) {
1057
1455
  restoreRunway(port)
1058
- settleAtFloor(port)
1456
+ if (writeScrollTop) settleAtFloor(port)
1059
1457
  clearMotion(port)
1060
1458
  followMotionStates.delete(port)
1061
1459
  return
@@ -1063,8 +1461,7 @@ function finishAtNaturalFloor(port: HTMLElement, retainCompositor = true): void
1063
1461
  const promoted = [...surfaces, ...(status === null ? [] : [status])]
1064
1462
  .filter(element => element.style.transform !== '' || element.style.willChange === 'transform')
1065
1463
  const promotedSet = new Set(promoted)
1066
- restoreRunway(port)
1067
- settleAtFloor(port)
1464
+ if (writeScrollTop) settleAtFloor(port)
1068
1465
  port.removeAttribute(FOLLOW_OWNED_ATTR)
1069
1466
  port.style.overflowAnchor = ''
1070
1467
  port.style.scrollBehavior = ''
@@ -1101,6 +1498,8 @@ interface FollowLeader {
1101
1498
  /** Only the newest active follower may write one port's shared visual state. */
1102
1499
  const followLeaders = new WeakMap<HTMLElement, FollowLeader>()
1103
1500
  let followGeneration = 0
1501
+ /** Ports with a live streaming arm; completion guards must not fight them. */
1502
+ const followActivePorts = new WeakSet<HTMLElement>()
1104
1503
 
1105
1504
  /**
1106
1505
  * Own the conversation scrollport's bottom-follow while `active` is true.
@@ -1115,6 +1514,7 @@ let followGeneration = 0
1115
1514
  * @param predictiveRef - Optional live visibility gate for predictive runway.
1116
1515
  * @param entranceExtentRef - Optional measured growth delta for a generic row.
1117
1516
  * @param revealedCharsRef - Committed code-point count for feed-forward phase.
1517
+ * @param controlScroll - When false, leave the scrollport entirely to the Host.
1118
1518
  */
1119
1519
  export function useConversationFollow(
1120
1520
  rootRef: RefObject<HTMLElement | null>,
@@ -1127,6 +1527,7 @@ export function useConversationFollow(
1127
1527
  predictiveRef?: { current: boolean },
1128
1528
  entranceExtentRef?: { current: number | null },
1129
1529
  revealedCharsRef?: { current: number },
1530
+ controlScroll = true,
1130
1531
  ): void {
1131
1532
  const activeRef = useRef(active)
1132
1533
  const entranceRef = useRef(entrance)
@@ -1134,8 +1535,11 @@ export function useConversationFollow(
1134
1535
  entranceRef.current = entrance
1135
1536
  onEntranceSettledRef.current = onEntranceSettled
1136
1537
  activeRef.current = active
1538
+ const controlScrollRef = useRef(controlScroll)
1539
+ controlScrollRef.current = controlScroll
1137
1540
 
1138
1541
  useLayoutEffect(() => {
1542
+ if (!controlScroll) return
1139
1543
  if (!active) return
1140
1544
  const startedAsEntrance = entrance
1141
1545
  const owner = {}
@@ -1154,8 +1558,10 @@ export function useConversationFollow(
1154
1558
  let interactTimer: ReturnType<typeof setTimeout> | null = null
1155
1559
  let port: HTMLElement | null = null
1156
1560
  let resize: ResizeObserver | null = null
1561
+ let mutations: MutationObserver | null = null
1157
1562
  let observedTail: HTMLElement | null = null
1158
1563
  let statusWasPresent: boolean | null = null
1564
+ let lastStatusHeightPx = 0
1159
1565
  let trajectoryPositionPx: number | null = null
1160
1566
  let trajectoryVelocityPxPerMs = 0
1161
1567
  let trajectoryTargetVelocityPxPerMs = 0
@@ -1223,11 +1629,52 @@ export function useConversationFollow(
1223
1629
  if (holding === next && isLeader(next)) return
1224
1630
  holding = next
1225
1631
  const leader = followLeaders.get(next)
1226
- if (leader === undefined || generation > leader.generation) {
1632
+ if ((leader === undefined || generation > leader.generation) && !completionSettleGuardsPort(next)) {
1633
+ followCompletionSettle.delete(next)
1227
1634
  followLeaders.set(next, { generation, owner })
1228
1635
  }
1229
1636
  }
1230
1637
 
1638
+ const yieldScrollOwnership = (next: HTMLElement, floor: number): void => {
1639
+ if (hostOwnsScroll) return
1640
+ hostOwnsScroll = true
1641
+ followHostScrollPorts.set(next, { userRows: countUserRows(next) })
1642
+ followTrace('yield-scroll', {
1643
+ from: Math.round(next.scrollTop),
1644
+ to: Math.round(floor),
1645
+ })
1646
+ // A host-owned port must be a clean host-owned render. Keeping the
1647
+ // engine's transform/reserve alive lets its decay fight the host's hard
1648
+ // bottom-follow; that is the visible up/down jitter after handoff.
1649
+ clearVisual(next)
1650
+ const naturalFloor = Math.max(0, next.scrollHeight - next.clientHeight)
1651
+ setFollowScrollTop(next, naturalFloor)
1652
+ next.removeAttribute(FOLLOW_OWNED_ATTR)
1653
+ followLeaders.delete(next)
1654
+ releaseRevealScale()
1655
+ debugRuntime.reportFollow(next, null)
1656
+ }
1657
+
1658
+ const detectHostScroll = (next: HTMLElement, floor: number): void => {
1659
+ if (followHostScrollPorts.has(next)) {
1660
+ hostOwnsScroll = true
1661
+ return
1662
+ }
1663
+ const ledger = followScrollLedgers.get(next)
1664
+ if (ledger === undefined || Math.abs(next.scrollTop - ledger) <= 1) return
1665
+ // A write that lands on the current floor has the same semantics as our
1666
+ // own bottom-follow (including a manual jump-to-bottom or a shrink
1667
+ // clamp). Rebase and keep smoothing; treating it as a second writer
1668
+ // would permanently retire the effect for the rest of the turn.
1669
+ if (Math.abs(next.scrollTop - floor) <= 1) {
1670
+ followScrollLedgers.set(next, next.scrollTop)
1671
+ externalScrollStrikes = 0
1672
+ return
1673
+ }
1674
+ externalScrollStrikes += 1
1675
+ if (externalScrollStrikes >= 2) yieldScrollOwnership(next, floor)
1676
+ }
1677
+
1231
1678
  const drop = (next: HTMLElement): void => {
1232
1679
  if (holding === next) holding = null
1233
1680
  if (isLeader(next)) {
@@ -1282,8 +1729,186 @@ export function useConversationFollow(
1282
1729
  }, FOLLOW_GESTURE_MS)
1283
1730
  }
1284
1731
 
1732
+ /**
1733
+ * Last observed scroll extent, shared by the pre-paint correction and the
1734
+ * settle loop so a host layout shrink is re-opened exactly once no matter
1735
+ * which observer sees it first. `-1` until the first owned observation.
1736
+ */
1737
+ let settleRetiring = false
1738
+ // The engine may lead streaming writes, but the host also owns a
1739
+ // bottom-follow controller. Ledger disagreement is harmless once; a
1740
+ // repeated disagreement is proof of a second writer. Hand the scrollport
1741
+ // to that writer immediately: the engine keeps compositor compensation but
1742
+ // never writes scrollTop again. This is the only way to avoid a ping-pong
1743
+ // fight when the host bundle does not consume `data-follow-owned`.
1744
+ let hostOwnsScroll = false
1745
+ let externalScrollStrikes = 0
1746
+ let lastReadingAnchorPullAtMs = Number.NEGATIVE_INFINITY
1747
+ let readingAnchorPullBudgetPx = 0
1748
+ // Set once this closure's completion handoff has armed its settle loop.
1749
+ // Only a handed-off arm's observers may guard a LEADERLESS port: mid-turn
1750
+ // arms that never handed off must keep standing down (entrance/steaming
1751
+ // successors own the port), while a handed-off arm whose settle loop was
1752
+ // killed by leadership churn is the only pre-paint guard left when the
1753
+ // completion cascade lands in the leaderless window.
1754
+ let handedOff = false
1755
+ /**
1756
+ * Bring the reading surface back down toward its held position after an
1757
+ * upward host push (clamp jump, live→settled swap): spend persistent pad
1758
+ * first — lowering the floor lets the pin carry the text back down —
1759
+ * then raise the compositor shift for whatever pad cannot cover, and
1760
+ * rebase the spring extent so the raise survives the next applyVisual
1761
+ * (which otherwise recomputes the shift from lag and undoes it).
1762
+ */
1763
+ const pullReadingAnchorBack = (
1764
+ host: HTMLElement,
1765
+ measured: { anchor: HTMLElement; index: number; top: number; delta: number },
1766
+ trace = false,
1767
+ ): void => {
1768
+ // Experimental no-rebound gate: an upward correction here has repeatedly
1769
+ // over/under-compensated against the settle loop's floor writes. Hold the
1770
+ // new position instead of issuing a reverse correction; the monotone
1771
+ // final retirement remains the only release path.
1772
+ holdGuardAnchor(host, measured, measured.top)
1773
+ return
1774
+ const need = -measured.delta
1775
+ // A large host shrink must not spend its whole pad in one paint. Keep
1776
+ // the screen anchored with an immediate shift, then hand the floor back
1777
+ // at the same bounded rate as the final retirement.
1778
+ const now = performance.now()
1779
+ const elapsedPx = lastReadingAnchorPullAtMs === Number.NEGATIVE_INFINITY
1780
+ ? FOLLOW_PAINT_SHIFT_MAX_STEP_PX
1781
+ : Math.min(24, Math.max(0, now - lastReadingAnchorPullAtMs) * (FOLLOW_PAINT_SHIFT_MAX_STEP_PX / 16.7))
1782
+ readingAnchorPullBudgetPx = Math.min(need, readingAnchorPullBudgetPx + elapsedPx)
1783
+ lastReadingAnchorPullAtMs = now
1784
+ const released = Math.min(flowPadOf(host), readingAnchorPullBudgetPx)
1785
+ readingAnchorPullBudgetPx -= released
1786
+ if (released > FOLLOW_SETTLE_EPSILON_PX) {
1787
+ if (trace) {
1788
+ followTrace('anchor-pull', { screenDelta: Math.round(measured.delta), released: Math.round(released), st: Math.round(host.scrollTop) })
1789
+ }
1790
+ setFlowPad(host, flowPadOf(host) - released)
1791
+ animatedH = Math.max(0, animatedH - released)
1792
+ }
1793
+ const remainder = need - released
1794
+ let raise = 0
1795
+ if (remainder > 0.5) {
1796
+ const surfaces = shiftSurfacesOf(host)
1797
+ const limit = safeShiftLimit(host, surfaces)
1798
+ const current = currentShiftOf(surfaces.at(-1) ?? host)
1799
+ const target = Math.min(current + remainder, Math.max(0, limit - FOLLOW_PAINT_GUARD_PX))
1800
+ raise = target - current
1801
+ if (raise > 0) {
1802
+ for (const surface of surfaces) setShift(surface, current + raise)
1803
+ followLastShiftPx.set(host, current + raise)
1804
+ animatedH = Math.max(0, animatedH - raise)
1805
+ }
1806
+ }
1807
+ holdGuardAnchor(host, measured, measured.top + released + raise)
1808
+ }
1809
+ /**
1810
+ * THE screen-space anchor hold, shared by every entry point: the
1811
+ * structural-observer path (pre-paint cascade correction), the settle
1812
+ * loop's per-frame poll, and the ACTIVE loop's per-frame poll. The
1813
+ * shift-excluded delta filters the engine's own motion (reveal glide,
1814
+ * wrap lockstep) to ~0, so a live poll may compensate safely — which is
1815
+ * what saves the completion swap: the settled-side arm primes in its
1816
+ * layout effect and steals leadership IN the cascade frame, its own
1817
+ * observers miss the mutations (armed after the fact), and only this
1818
+ * poll sees the jump while it can still be corrected before paint.
1819
+ * Returns the measured delta (null when nothing was comparable).
1820
+ */
1821
+ const enforceReadingAnchor = (host: HTMLElement, trace = false): number | null => {
1822
+ const measured = measureReadingAnchor(host)
1823
+ if (measured === null) return null
1824
+ if (measured.delta > 0.5) {
1825
+ if (trace) {
1826
+ followTrace('anchor-hold', { screenDelta: Math.round(measured.delta), st: Math.round(host.scrollTop) })
1827
+ }
1828
+ if (pruneDeadRunway(host)) reservePx = 0
1829
+ holdGuardAnchor(host, measured, measured.top)
1830
+ } else if (measured.delta < -0.5) {
1831
+ if (pruneDeadRunway(host)) reservePx = 0
1832
+ pullReadingAnchorBack(host, measured, trace)
1833
+ } else {
1834
+ holdGuardAnchor(host, measured, measured.top)
1835
+ }
1836
+ return measured.delta
1837
+ }
1838
+ /** Pre-paint correction (observers, commit subscription). */
1285
1839
  const restoreBeforePaint = (): void => {
1286
- if (!following || port === null || !isLeader(port)) return
1840
+ if (!following || port === null) return
1841
+ if (hostOwnsScroll || followHostScrollPorts.has(port)) {
1842
+ hostOwnsScroll = true
1843
+ followScrollLedgers.set(port, port.scrollTop)
1844
+ return
1845
+ }
1846
+ if (!activeRef.current) {
1847
+ detectHostScroll(port, Math.max(0, port.scrollHeight - port.clientHeight))
1848
+ }
1849
+ // Yield only to a LIVE owner. Block arms hand off leadership mid-turn
1850
+ // and each handoff kills the previous settle loop; the completion
1851
+ // cascade routinely lands in the leaderless window between that death
1852
+ // and the successor's first frame. Standing down whenever leadership is
1853
+ // merely absent left the cascade's clamp jump uncompensated. While
1854
+ // SOMEONE holds the port, the dead observers must not fight the live
1855
+ // guard; while NOBODY does, a handed-off arm's armed observer is the
1856
+ // only guard left, and the shared followGuardAnchors baseline keeps it
1857
+ // idempotent.
1858
+ const leaderless = !isLeader(port)
1859
+ if (!activeRef.current && followActivePorts.has(port)) return
1860
+ if (leaderless && (followLeaders.has(port) || !handedOff)) return
1861
+ // Leaderless window: this arm's closure state froze when its loop died.
1862
+ // Sync to the shared per-port motion ledger the last live loop wrote,
1863
+ // or the applyVisual below would replay stale extent/reserve into the
1864
+ // runway and pad ledgers while compensating the cascade.
1865
+ if (leaderless) {
1866
+ const sharedMotion = followMotionStates.get(port)
1867
+ if (sharedMotion !== undefined) {
1868
+ animatedH = Math.min(port.scrollHeight, Math.max(0, sharedMotion.extent))
1869
+ reservePx = sharedMotion.reservePx
1870
+ velocityPxPerSec = sharedMotion.velocityPxPerSec
1871
+ }
1872
+ }
1873
+ // A host keyed replacement can disconnect the element carrying our
1874
+ // margin before MutationObserver runs. Drop the stale registry entry;
1875
+ // banking it as pad preserves an extent that immediately becomes the
1876
+ // slow retirement rebound. The same-task ensureRunway / applyVisual path
1877
+ // reopens only the runway that is still needed.
1878
+ pruneDeadRunway(port)
1879
+ // HOST COMPLETION COMMITS under the pin must be corrected in THIS
1880
+ // pre-paint task: the settle loop's rAF guard runs after the host's own
1881
+ // follow effects, so a cascade commit (status swap, tail mount) would
1882
+ // otherwise paint one frame pinned to the sunk floor. The screen-space
1883
+ // anchor hold reads the shared per-port baseline (see
1884
+ // followGuardAnchors); it runs for structural commits only and stands
1885
+ // down while this settle's retirement glide runs (that motion is ours).
1886
+ if (!settleRetiring) {
1887
+ const measured = measureReadingAnchor(port)
1888
+ if (measured !== null && measured.delta > 0.5) {
1889
+ if (pruneDeadRunway(port)) reservePx = 0
1890
+ holdGuardAnchor(port, measured, measured.top)
1891
+ } else if (measured !== null && measured.delta < -0.5) {
1892
+ if (pruneDeadRunway(port)) reservePx = 0
1893
+ if (!activeRef.current) {
1894
+ // Completion window: pad first, then the shift raise absorbs
1895
+ // whatever pad cannot cover (the swap jump rides this path).
1896
+ pullReadingAnchorBack(port, measured)
1897
+ } else {
1898
+ // Live streaming: the original semantics — release only what the
1899
+ // pad holds and leave the remainder to the frame loop's own
1900
+ // lockstep. A shift raise here would cancel the reveal glide.
1901
+ const released = Math.min(flowPadOf(port), -measured.delta)
1902
+ if (released > FOLLOW_SETTLE_EPSILON_PX) {
1903
+ setFlowPad(port, flowPadOf(port) - released)
1904
+ animatedH = Math.max(0, animatedH - released)
1905
+ }
1906
+ holdGuardAnchor(port, measured, measured.top + released)
1907
+ }
1908
+ } else if (measured !== null) {
1909
+ holdGuardAnchor(port, measured, measured.top)
1910
+ }
1911
+ }
1287
1912
  // A reveal commit changes the measured tail, not the fixed chrome. Keep
1288
1913
  // the paint-limit TTL intact here; ResizeObserver and viewport/chrome
1289
1914
  // changes invalidate it when the cached geometry is no longer valid.
@@ -1309,12 +1934,23 @@ export function useConversationFollow(
1309
1934
  reservePx,
1310
1935
  velocityPxPerSec,
1311
1936
  tuning.runwayPx,
1312
- Number.POSITIVE_INFINITY,
1937
+ // COMPLETION RISE FREEZE: this observer runs in the mutation's
1938
+ // microtask, but the paint lands one vsync later — extent a CSS
1939
+ // transition retracts in between makes any rise funded here
1940
+ // over-cover by exactly that retraction (the drain-onset 回弹).
1941
+ // Freeze rises same-task; the settle's rAF frame re-reads the floor
1942
+ // within its own paint tick and funds the confirmed growth there.
1943
+ activeRef.current
1944
+ ? Number.POSITIVE_INFINITY
1945
+ : (followLastShiftPx.get(port) ?? Number.POSITIVE_INFINITY),
1313
1946
  !predictGrowth,
1314
1947
  trajectoryShift,
1948
+ 0,
1949
+ activeRef.current && !hostOwnsScroll,
1315
1950
  )
1316
1951
  if (trajectoryShift !== undefined) {
1317
- trajectoryPositionPx = floor - currentShiftOf(shiftSurfacesOf(port).at(-1) ?? port)
1952
+ const currentShift = followLastShiftPx.get(port) ?? (floor - (trajectoryPositionPx ?? floor))
1953
+ trajectoryPositionPx = floor - currentShift
1318
1954
  }
1319
1955
  updateRevealScale(port, 0, true)
1320
1956
  reportFollow(port, activeRef.current)
@@ -1333,6 +1969,7 @@ export function useConversationFollow(
1333
1969
  if (port !== null) {
1334
1970
  for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture)
1335
1971
  resize?.disconnect()
1972
+ mutations?.disconnect()
1336
1973
  }
1337
1974
  unsubscribeCommit?.()
1338
1975
  port = next
@@ -1342,11 +1979,23 @@ export function useConversationFollow(
1342
1979
  port.addEventListener(name, markGesture, { passive: true })
1343
1980
  }
1344
1981
  if (typeof ResizeObserver !== 'undefined') {
1345
- resize = new ResizeObserver(restoreBeforePaint)
1982
+ resize = new ResizeObserver(() => restoreBeforePaint())
1346
1983
  resize.observe(port)
1347
1984
  const proxy = resizeProxyOf(port)
1348
1985
  if (proxy !== null) resize.observe(proxy)
1349
1986
  }
1987
+ // Completion is chiefly a child-list transaction (status removal,
1988
+ // live-to-settled replacement, tail insertion). Because following
1989
+ // gives the flow a viewport min-height, those mutations can leave the
1990
+ // flow's border box unchanged and never notify ResizeObserver. Observe
1991
+ // the transaction itself so the anchor hold still runs before paint.
1992
+ if (typeof MutationObserver !== 'undefined') {
1993
+ const flow = flowElementOf(port)
1994
+ if (flow !== null) {
1995
+ mutations = new MutationObserver(() => { restoreBeforePaint() })
1996
+ mutations.observe(flow, { childList: true, subtree: true })
1997
+ }
1998
+ }
1350
1999
  }
1351
2000
 
1352
2001
  /**
@@ -1379,6 +2028,10 @@ export function useConversationFollow(
1379
2028
  if (nextPort === null) return
1380
2029
  bindPort(nextPort)
1381
2030
  observeTailSurface()
2031
+ resetHostScrollOwnershipForNewTurn(nextPort)
2032
+ hostOwnsScroll = followHostScrollPorts.has(nextPort)
2033
+ if (activeRef.current) followActivePorts.add(nextPort)
2034
+ else followActivePorts.delete(nextPort)
1382
2035
  // A hidden/unmeasured port has no meaningful floor yet. Keep this owner
1383
2036
  // unprimed and let the already-scheduled RAF initialize it after layout.
1384
2037
  if (nextPort.clientHeight <= 0) return
@@ -1391,6 +2044,15 @@ export function useConversationFollow(
1391
2044
  )
1392
2045
 
1393
2046
  if (!primed) {
2047
+ // A completion settle owns this port (the swap remounted this arm in
2048
+ // the same frame cluster): stay a passive watcher. Taking over here
2049
+ // would zero the pad mid-retirement and re-inherit it as reserve —
2050
+ // double-counting the extent for one giant jump.
2051
+ if (completionSettleGuardsPort(nextPort)) {
2052
+ primed = true
2053
+ following = false
2054
+ return
2055
+ }
1394
2056
  const inherited = nextPort.hasAttribute(FOLLOW_OWNED_ATTR)
1395
2057
  ? followMotionStates.get(nextPort)
1396
2058
  : undefined
@@ -1418,16 +2080,31 @@ export function useConversationFollow(
1418
2080
  // written in the same commit, so this held-and-canceled space
1419
2081
  // never moves a pixel.
1420
2082
  const hasStatus = turnStatusOf(nextPort) !== null
1421
- reservePx = predictGrowth && (hasStatus || speedCpsRef.current > FOLLOW_RESERVE_MIN_CPS)
1422
- ? computeFollowReserve(speedCpsRef.current, tuning.runwayPx)
1423
- : 0
2083
+ // ZERO-DOWNWARD-REBOUND: the reservation must never land below the
2084
+ // margin this port already owns. base = margin − reservation is the
2085
+ // painted shift; a reservation smaller than the owned margin would
2086
+ // repaint the difference as an instant downward step on this arm's
2087
+ // first frame. The margin stays owned (see the growth-only rule in
2088
+ // the frame loop), so the reservation opens at least to match it.
2089
+ reservePx = Math.max(
2090
+ ownedBottomSpaceOf(nextPort),
2091
+ predictGrowth && (hasStatus || speedCpsRef.current > FOLLOW_RESERVE_MIN_CPS)
2092
+ ? computeFollowReserve(speedCpsRef.current, tuning.runwayPx)
2093
+ : 0,
2094
+ )
2095
+ // Adopt-time reclaim, exactly once and in this same pre-paint task:
2096
+ // the previous completion's pad becomes this stream's reserve gap,
2097
+ // so the extent — and the pinned floor — never steps between turns.
2098
+ // A completion settle still owning the port keeps its ledgers: the
2099
+ // swap's remounted arms must not zero the pad mid-retirement.
2100
+ if (!completionSettleGuardsPort(nextPort)) setFlowPad(nextPort, 0)
1424
2101
  statusWasPresent = hasStatus
1425
2102
  velocityPxPerSec = 0
1426
2103
  // The committed row/growth delta has already moved the new floor.
1427
2104
  // Decide ownership from READER INTENT EVIDENCE, not raw lag: an
1428
2105
  // upward move recorded past our own ledger is a pull-up; anything
1429
2106
  // else (content that mounted while the host had not re-pinned yet,
1430
- // a post-fold clamp, first-frame geometry) must keep following,
2107
+ // a completion clamp, first-frame geometry) must keep following,
1431
2108
  // otherwise the whole stream falls back to the host's hard snap.
1432
2109
  void reportedLag
1433
2110
  // A prior follower's reader-release record outranks ledger
@@ -1453,6 +2130,9 @@ export function useConversationFollow(
1453
2130
  tuning.runwayPx,
1454
2131
  Number.POSITIVE_INFINITY,
1455
2132
  !(predictiveRef?.current ?? predictive),
2133
+ undefined,
2134
+ 0,
2135
+ !hostOwnsScroll,
1456
2136
  )
1457
2137
  if (
1458
2138
  predictive
@@ -1525,12 +2205,17 @@ export function useConversationFollow(
1525
2205
  reportFollow(nextPort, activeRef.current)
1526
2206
  return
1527
2207
  }
2208
+ detectHostScroll(nextPort, floor)
2209
+ if (hostOwnsScroll) {
2210
+ followScrollLedgers.set(nextPort, nextPort.scrollTop)
2211
+ reportFollow(nextPort, false)
2212
+ return
2213
+ }
1528
2214
  hold(nextPort)
1529
2215
  if (!isLeader(nextPort)) {
1530
2216
  finishEntrance()
1531
2217
  return
1532
2218
  }
1533
-
1534
2219
  // Runway and an equal transform cancel visually. It is the zero point,
1535
2220
  // not residual motion: decaying below it would scroll past the final
1536
2221
  // resting position and rebound when runway is removed.
@@ -1542,9 +2227,18 @@ export function useConversationFollow(
1542
2227
  // constant. Growing the reservation without the margin would glide the
1543
2228
  // whole column; growing both together is invisible.
1544
2229
  const predictGrowth = predictiveRef?.current ?? predictive
1545
- const hasStatus = turnStatusOf(nextPort) !== null
2230
+ const statusElement = turnStatusOf(nextPort)
2231
+ const hasStatus = statusElement !== null
2232
+ if (statusElement !== null) lastStatusHeightPx = statusElement.offsetHeight
1546
2233
  const statusJustRemoved = predictGrowth && statusWasPresent === true && !hasStatus
1547
- if (statusJustRemoved) reservePx = tuning.runwayPx
2234
+ if (statusJustRemoved) {
2235
+ // The dying status row takes its own layout height AND the margin
2236
+ // riding on it out of the scroll extent in one commit. Re-open the
2237
+ // margin on the next surface in the same frame, sized to cover both,
2238
+ // or the floor sinks by that height and the transcript slides down
2239
+ // under the pin before the replacement margin can land.
2240
+ reservePx = Math.max(reservePx, tuning.runwayPx) + lastStatusHeightPx
2241
+ }
1548
2242
  statusWasPresent = hasStatus
1549
2243
  const reserveEnabled = hasStatus
1550
2244
  || statusJustRemoved
@@ -1553,18 +2247,18 @@ export function useConversationFollow(
1553
2247
  const pressureReserveTarget = predictGrowth && reserveEnabled
1554
2248
  ? computeFollowReserve(speedCpsRef.current, tuning.runwayPx)
1555
2249
  : 0
1556
- // Reveal pressure may open more runway, but a burst gap must not retire
1557
- // it mid-stream: shrinking the owned margin moves the real floor and
1558
- // creates the exact 1px back-and-forth motion this module prevents.
1559
- // Prediction shutdown and an explicit lower debug cap still retire it.
1560
- const heldReserveTarget = tuning.runwayPx < reservePx
1561
- ? tuning.runwayPx
1562
- : Math.max(reservePx, pressureReserveTarget)
1563
- const effectiveReserveTarget = !predictGrowth
1564
- ? 0
1565
- : statusJustRemoved
1566
- ? tuning.runwayPx
1567
- : heldReserveTarget
2250
+ // ZERO-DOWNWARD-REBOUND (root cause): while this follower owns the port
2251
+ // it pins scrollTop to the floor, and the floor rides the owned bottom
2252
+ // margin 1:1. Shrinking that margin under the pin clamps scrollTop
2253
+ // downward and slides the whole transcript down — the one motion this
2254
+ // module must never paint; no shift remains to release it because the
2255
+ // reservation already covers the margin (base = margin − reservation
2256
+ // stays flat). An owned margin therefore only GROWS here: prediction
2257
+ // shutdown and reveal-pressure drops freeze it at its current size, and
2258
+ // retirement is deferred to reader handback (a user-driven scroll) or
2259
+ // the next ownership. Growth stays invisible because the reservation
2260
+ // grows in the same frame and the baseline difference never moves.
2261
+ const effectiveReserveTarget = Math.max(reservePx, pressureReserveTarget)
1568
2262
  const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs)
1569
2263
  reservePx += (effectiveReserveTarget - reservePx) * reserveStep
1570
2264
  if (predictGrowth || runwayOffsetOf(nextPort) > 0.5) {
@@ -1659,9 +2353,16 @@ export function useConversationFollow(
1659
2353
  animatedH = contentHeight - runwayOffset
1660
2354
  velocityPxPerSec = 0
1661
2355
  } else {
1662
- const minimumLag = predictGrowth ? 0 : Math.max(0, reservePx)
2356
+ // ZERO-DOWNWARD-REBOUND: the reservation is NOT real lag. With the
2357
+ // steady-state tail pin it already rides as baseline-canceled gap
2358
+ // (shift = margin − reservation + reveal lag), so forcing the spring
2359
+ // to stop `reservePx` short of the natural floor — the pre-tail-pin
2360
+ // "hold the reserve as lag" semantic — would double-count it and
2361
+ // repaint the difference as an instant downward step the moment
2362
+ // prediction shuts off. The spring always drains to the natural
2363
+ // floor; the painted shift decays through the rate-limited release.
1663
2364
  animatedH = Math.min(
1664
- contentHeight - runwayOffset - minimumLag,
2365
+ contentHeight - runwayOffset,
1665
2366
  animatedH + step.advancePx,
1666
2367
  )
1667
2368
  velocityPxPerSec = step.velocityPxPerSec
@@ -1676,12 +2377,38 @@ export function useConversationFollow(
1676
2377
  Number.POSITIVE_INFINITY,
1677
2378
  !predictGrowth,
1678
2379
  trajectoryShift,
2380
+ elapsedMs,
2381
+ !hostOwnsScroll,
1679
2382
  )
1680
2383
  if (trajectoryActive) {
1681
- trajectoryPositionPx = floorNow - currentShiftOf(shiftSurfacesOf(nextPort).at(-1) ?? nextPort)
2384
+ const currentShift = followLastShiftPx.get(nextPort) ?? (trajectoryShift ?? 0)
2385
+ trajectoryPositionPx = floorNow - currentShift
1682
2386
  }
1683
2387
  updateRevealScale(nextPort, elapsedMs)
1684
2388
  reportFollow(nextPort, true)
2389
+ // Keep the previous painted tail as the completion handoff baseline.
2390
+ // The active loop is the only place that sees the old surface before a
2391
+ // host live->settled replacement disconnects it.
2392
+ // Per-frame anchor hold — completion window only. During live
2393
+ // streaming the lockstep in this very frame's applyVisual already
2394
+ // cancels wraps; a guard running here fights it (its pad/raise writes
2395
+ // shift the next frame's lag bookkeeping) and visibly degrades the
2396
+ // reveal. The completion cascade is different: the settled-side arm
2397
+ // that primes IN the swap frame has active=false from mount, so ITS
2398
+ // first poll runs before paint and compensates the swap jump that the
2399
+ // older arms' observers were too late (or too unprivileged) to fix.
2400
+ // Track the reading surface's rendered position so the completion
2401
+ // guard inherits the exact last-painted baseline (never a stale or
2402
+ // already-jumped one) when the stream hands off. Compensation is NOT
2403
+ // done from this poll (a guard beside the active loop's own lockstep
2404
+ // fights it and degrades the reveal), and a NON-leader arm must not
2405
+ // even refresh the baseline: its measurement would store the very
2406
+ // jump the owning guard is about to correct and neutralize its next
2407
+ // pass.
2408
+ if (isLeader(nextPort)) {
2409
+ if (!activeRef.current) enforceReadingAnchor(nextPort)
2410
+ else measureReadingAnchor(nextPort)
2411
+ }
1685
2412
  const remainingEntranceLag = Math.max(
1686
2413
  0,
1687
2414
  nextPort.scrollHeight - animatedH - runwayOffsetOf(nextPort),
@@ -1695,10 +2422,31 @@ export function useConversationFollow(
1695
2422
  // the previous owner, leaving a large final append at the old scrollTop.
1696
2423
  frame(performance.now())
1697
2424
  return () => {
2425
+ if (!controlScrollRef.current) {
2426
+ cancelAnimationFrame(rafId)
2427
+ if (port !== null) followActivePorts.delete(port)
2428
+ unsubscribeCommit?.()
2429
+ resize?.disconnect()
2430
+ mutations?.disconnect()
2431
+ if (port !== null) {
2432
+ for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture)
2433
+ }
2434
+ if (interactTimer !== null) clearTimeout(interactTimer)
2435
+ const disabledHost = rootRef.current?.closest<HTMLElement>('[data-conversation-scroll]') ?? port
2436
+ if (disabledHost !== null) {
2437
+ clearVisual(disabledHost)
2438
+ followLeaders.delete(disabledHost)
2439
+ debugRuntime.reportFollow(disabledHost, null)
2440
+ }
2441
+ releaseRevealScale()
2442
+ return
2443
+ }
1698
2444
  cancelAnimationFrame(rafId)
2445
+ if (port !== null) followActivePorts.delete(port)
1699
2446
  unsubscribeCommit?.()
1700
2447
  if (interactTimer !== null) clearTimeout(interactTimer)
1701
2448
  resize?.disconnect()
2449
+ mutations?.disconnect()
1702
2450
  if (port !== null) {
1703
2451
  for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture)
1704
2452
  }
@@ -1740,11 +2488,27 @@ export function useConversationFollow(
1740
2488
  // so treating it as real lag paints a whole runway-height step at the
1741
2489
  // exact moment leadership hands to the draining arm. The settle loop
1742
2490
  // caps animatedH against the shrinking extent directly.
2491
+ // Settle state (declared before the handoff so the completion paths can
2492
+ // measure the extent against it):
2493
+ let settleQuietMs = 0
2494
+ let settleSig = ''
1743
2495
  const lagBeforeCompletionPaint = Math.max(
1744
2496
  0,
1745
2497
  host.scrollHeight - animatedH - runwayOffsetOf(host),
1746
2498
  )
1747
- if (!activeRef.current && lagBeforeCompletionPaint <= FOLLOW_SLACK_PX) {
2499
+ const currentRunway = runwayOffsetOf(host)
2500
+ const currentShift = Math.abs(currentShiftOf(shiftSurfacesOf(host).at(-1) ?? host))
2501
+ if (
2502
+ !activeRef.current
2503
+ && lagBeforeCompletionPaint <= FOLLOW_SLACK_PX
2504
+ && currentRunway <= FOLLOW_SETTLE_EPSILON_PX
2505
+ && currentShift <= FOLLOW_SETTLE_EPSILON_PX
2506
+ // A live completion pad must retire through the settle's glide first;
2507
+ // fast-finishing here would freeze it in place as visible bottom gap.
2508
+ && flowPadOf(host) <= FOLLOW_SETTLE_EPSILON_PX
2509
+ ) {
2510
+ followTraceUntilMs = Math.max(followTraceUntilMs, performance.now() + 10000)
2511
+ followTrace('fast-gate', { sh: host.scrollHeight, st: Math.round(host.scrollTop), pad: Math.round(flowPadOf(host)) })
1748
2512
  finishAtNaturalFloor(host, !startedAsEntrance)
1749
2513
  followLeaders.delete(host)
1750
2514
  releaseRevealScale()
@@ -1752,9 +2516,33 @@ export function useConversationFollow(
1752
2516
  return
1753
2517
  }
1754
2518
  const completionShift = currentShiftOf(shiftSurfacesOf(host).at(-1) ?? host)
1755
- const completionShiftCeiling = completionShift > FOLLOW_SETTLE_EPSILON_PX
1756
- ? completionShift
1757
- : Number.POSITIVE_INFINITY
2519
+ // Freeze, never raise, in this same-task paint — same race as the
2520
+ // observer above; the settle's first rAF frame funds the re-open.
2521
+ // Entrance handoffs are mount transients with no collapse in flight:
2522
+ // their first cover must paint immediately (the soften contract).
2523
+ const completionShiftCeiling = startedAsEntrance && completionShift <= FOLLOW_SETTLE_EPSILON_PX
2524
+ ? Number.POSITIVE_INFINITY
2525
+ : completionShift
2526
+ if (hostOwnsScroll) {
2527
+ clearVisual(host)
2528
+ setFollowScrollTop(host, Math.max(0, host.scrollHeight - host.clientHeight))
2529
+ host.removeAttribute(FOLLOW_OWNED_ATTR)
2530
+ followLeaders.delete(host)
2531
+ releaseRevealScale()
2532
+ debugRuntime.reportFollow(host, null)
2533
+ return
2534
+ }
2535
+ // The completion commit often REPLACES the last surface (live→settled
2536
+ // swap re-keys the row), and the owned margin written on the old element
2537
+ // dies with it in the same layout pass. Do NOT bank that loss as a flow
2538
+ // pad: the next ensureRunway opens the completion runway in this same
2539
+ // pre-paint task. Converting the dead margin to pad creates a second
2540
+ // owned extent that must later retire as the visible "slow rebound".
2541
+ followTraceUntilMs = performance.now() + 15000
2542
+ if (pruneDeadRunway(host)) {
2543
+ followTrace('cleanup-dead-margin', { sh: host.scrollHeight, st: Math.round(host.scrollTop), pad: Math.round(flowPadOf(host)) })
2544
+ reservePx = 0
2545
+ }
1758
2546
  // The completion handoff keeps at least one real runway open so the
1759
2547
  // final height has reserved paint room to drain through: with zero
1760
2548
  // margin the whole final height lands as shift in ONE paint (offset
@@ -1762,13 +2550,26 @@ export function useConversationFollow(
1762
2550
  // exists to prevent. The draining arm ramps its reserve from here.
1763
2551
  const completionTuning = debugRuntime.activeTuning()
1764
2552
  const previousCompletionRunway = runwayOffsetOf(host)
1765
- ensureRunway(host, shiftSurfacesOf(host), Math.max(reservePx, completionTuning.runwayPx))
2553
+ // The completion pad already holds retired extent below the fold; the
2554
+ // handoff only opens the runway room the pad does not cover, or the
2555
+ // same pixels are added twice and the restored floor overshoots.
2556
+ ensureRunway(
2557
+ host,
2558
+ shiftSurfacesOf(host),
2559
+ Math.max(reservePx, Math.max(0, completionTuning.runwayPx - flowPadOf(host))),
2560
+ )
1766
2561
  const completionRunway = runwayOffsetOf(host)
2562
+ // The reading anchor's baseline is already live (the active loop
2563
+ // refreshed it every frame, and the guard observers keep it current);
2564
+ // only seed it here when this port somehow has none, so the first
2565
+ // completion commit has a real screen-space baseline to compare
2566
+ // against instead of silently adopting the jumped position.
2567
+ measureReadingAnchor(host)
1767
2568
  // Rebase the spring extent onto the new offset domain before the paint:
1768
2569
  // adding the margin drops targetHeight by the same amount, so animatedH
1769
2570
  // must drop in lockstep or the margin's px release as an instant shift.
1770
2571
  animatedH = Math.max(0, animatedH - (completionRunway - previousCompletionRunway))
1771
- settleAtFloor(host)
2572
+ if (!hostOwnsScroll) settleAtFloor(host)
1772
2573
  animatedH = applyVisual(
1773
2574
  host,
1774
2575
  animatedH,
@@ -1776,15 +2577,20 @@ export function useConversationFollow(
1776
2577
  velocityPxPerSec,
1777
2578
  completionRunway,
1778
2579
  completionShiftCeiling,
2580
+ false,
2581
+ undefined,
2582
+ 0,
2583
+ !hostOwnsScroll,
1779
2584
  )
1780
2585
  reportFollow(host, false)
1781
2586
  const runwayOffset = runwayOffsetOf(host)
1782
2587
  const remainingLag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
1783
2588
  if (
1784
2589
  remainingLag <= FOLLOW_SETTLE_EPSILON_PX
1785
- || (!activeRef.current && remainingLag <= FOLLOW_SLACK_PX)
2590
+ && runwayOffset <= FOLLOW_SETTLE_EPSILON_PX
2591
+ && reservePx <= FOLLOW_SETTLE_EPSILON_PX
1786
2592
  ) {
1787
- finishAtNaturalFloor(host, !startedAsEntrance)
2593
+ finishAtNaturalFloor(host, !startedAsEntrance, !hostOwnsScroll)
1788
2594
  followLeaders.delete(host)
1789
2595
  releaseRevealScale()
1790
2596
  debugRuntime.reportFollow(host, null)
@@ -1796,14 +2602,42 @@ export function useConversationFollow(
1796
2602
  }
1797
2603
  const stopSettleListeners = (): void => {
1798
2604
  for (const name of GESTURE_EVENTS) host.removeEventListener(name, markGesture)
2605
+ resize?.disconnect()
2606
+ mutations?.disconnect()
1799
2607
  if (interactTimer !== null) {
1800
2608
  clearTimeout(interactTimer)
1801
2609
  interactTimer = null
1802
2610
  }
1803
2611
  }
2612
+ // Re-arm pre-paint observation for the whole completion cascade. The
2613
+ // active effect's observers were disconnected above, but status/tail
2614
+ // commits continue while the detached settle loop owns the port.
2615
+ if (typeof ResizeObserver !== 'undefined') {
2616
+ resize = new ResizeObserver(() => restoreBeforePaint())
2617
+ resize.observe(host)
2618
+ const proxy = resizeProxyOf(host)
2619
+ if (proxy !== null) resize.observe(proxy)
2620
+ }
2621
+ if (typeof MutationObserver !== 'undefined') {
2622
+ const flow = flowElementOf(host)
2623
+ if (flow !== null) {
2624
+ mutations = new MutationObserver(() => { restoreBeforePaint() })
2625
+ mutations.observe(flow, { childList: true, subtree: true })
2626
+ }
2627
+ }
2628
+ // From here this closure's observers are the completion guard of last
2629
+ // resort (see `handedOff` above), and this settle OWNS the port until
2630
+ // it finishes.
2631
+ followCompletionSettle.add(host)
2632
+ followCompletionSettleRows.set(host, countUserRows(host))
2633
+ handedOff = true
1804
2634
  let settleLast = performance.now()
1805
- let settleMarginPx = Math.max(runwayOffsetOf(host), reservePx)
1806
- let settleMarginRate = settleMarginPx / FOLLOW_RUNWAY_RETIRE_MS
2635
+ // Settle-pad transfer state lives above the completion handoff; the
2636
+ // loop below only drives it frame by frame. `settleRetiring` marks the
2637
+ // final glide: the cascade has quieted, so the pad that kept every host
2638
+ // commit pixel-stable is handed back to the layout at the bounded
2639
+ // settle rate and the pinned viewport glides down to the natural
2640
+ // resting position against the composer.
1807
2641
  const settleFrame = (now: number): void => {
1808
2642
  if (!isLeader(host)) {
1809
2643
  stopSettleListeners()
@@ -1813,6 +2647,7 @@ export function useConversationFollow(
1813
2647
  readerGestureIntent = false
1814
2648
  handBackVisual(host)
1815
2649
  clearVisual(host)
2650
+ followCompletionSettle.delete(host)
1816
2651
  followLeaders.delete(host)
1817
2652
  releaseRevealScale()
1818
2653
  debugRuntime.reportFollow(host, null)
@@ -1822,24 +2657,111 @@ export function useConversationFollow(
1822
2657
  const dt = Math.min(FOLLOW_MAX_FRAME_MS, Math.max(0, now - settleLast))
1823
2658
  const tuning = debugRuntime.activeTuning()
1824
2659
  settleLast = now
1825
- // Retire the runway in LOCKSTEP with its canceling reserve: the
1826
- // margin shrinks by exactly the amount the reservation shrinks, so
1827
- // their baseline difference — and therefore every painted pixel —
1828
- // stays put while the reserved space closes. Decaying only the
1829
- // reservation would slide the whole column by the runway height.
1830
- if (settleMarginPx > 0 && settleMarginRate > 0) {
1831
- settleMarginPx = Math.max(0, settleMarginPx - settleMarginRate * dt)
1832
- ensureRunway(host, shiftSurfacesOf(host), settleMarginPx)
1833
- reservePx = Math.min(reservePx, settleMarginPx)
2660
+ detectHostScroll(host, Math.max(0, host.scrollHeight - host.clientHeight))
2661
+ if (hostOwnsScroll) {
2662
+ clearVisual(host)
2663
+ setFollowScrollTop(host, Math.max(0, host.scrollHeight - host.clientHeight))
2664
+ host.removeAttribute(FOLLOW_OWNED_ATTR)
2665
+ followCompletionSettle.delete(host)
2666
+ followLeaders.delete(host)
2667
+ releaseRevealScale()
2668
+ debugRuntime.reportFollow(host, null)
2669
+ stopSettleListeners()
2670
+ return
2671
+ }
2672
+ // HOST COMPLETION CASCADE: around completion the host swaps the status
2673
+ // row for its process/tail rows, auto-collapses the think disclosure
2674
+ // and mounts the metrics tail — each a same-frame layout shrink under
2675
+ // the pinned floor. Re-open every lost pixel below the last surface
2676
+ // (out of sight; extent, floor and pinned scrollTop restored) and hold
2677
+ // ownership until the cascade has been quiet for
2678
+ // FOLLOW_SETTLE_QUIET_MS, so the host's own floor-snap never paints a
2679
+ // host action as a single-frame slam of the whole transcript.
2680
+ // ANCHOR SCREEN HOLD: the completion cascade moves the reading
2681
+ // anchor's viewport position (status swap, live→settled replacement,
2682
+ // clamp jumps). The reading-surface baseline lives in the shared
2683
+ // followGuardAnchors ledger; the shift-excluded delta filters the
2684
+ // engine's own glide, so the per-frame poll only answers real host
2685
+ // motion. While the pad retires, extent motion is OUR OWN glide —
2686
+ // the hold stands down.
2687
+ const guardDelta = !settleRetiring && !followActivePorts.has(host)
2688
+ ? enforceReadingAnchor(host, true)
2689
+ : null
2690
+ settleQuietMs = !settleRetiring && (guardDelta === null || Math.abs(guardDelta) <= 0.5) ? settleQuietMs + dt : 0
2691
+ // ZERO-DOWNWARD-REBOUND (root cause): scrollTop is pinned to the floor
2692
+ // and the floor rides the owned margin 1:1, so the settle must NOT
2693
+ // shrink the margin — that clamps scrollTop downward with no shift
2694
+ // left to release (the reservation covers the margin, so base = margin
2695
+ // − reservation never moves). Instead the margin MOVES below the
2696
+ // status row: marginTop −δ and marginBottom +δ in the same layout
2697
+ // pass keep the scroll extent constant, so the floor, the pinned
2698
+ // scrollTop and every content pixel stay put while the status glides
2699
+ // up to close the reserve gap — the completion 归位, with zero
2700
+ // downward motion.
2701
+ const settleStatus = turnStatusOf(host)
2702
+ const ownedRunwayPx = runwayOffsetOf(host)
2703
+ if (ownedRunwayPx > FOLLOW_SETTLE_EPSILON_PX) {
2704
+ const requestedTransferPx = settleStatus === null
2705
+ ? ownedRunwayPx
2706
+ : Math.min(
2707
+ reservePx,
2708
+ ((tuning.runwayPx || FOLLOW_STATUS_RUNWAY_PX) / FOLLOW_RUNWAY_RETIRE_MS) * dt,
2709
+ )
2710
+ const transferredPx = transferRunwayToFlowPad(host, requestedTransferPx)
2711
+ // Do not bank a completion pad. The margin release and pad removal
2712
+ // happen in one layout pass, so there is no extra extent to retire
2713
+ // later and no second slow rebound after the cascade quiets.
2714
+ if (transferredPx > 0) setFlowPad(host, Math.max(0, flowPadOf(host) - transferredPx))
2715
+ reservePx = Math.max(0, reservePx - transferredPx)
2716
+ // targetHeight = scrollHeight - runway. The equal margin-to-pad
2717
+ // transfer keeps scrollHeight fixed and lowers runway by δ, so the
2718
+ // logical extent rises by exactly δ (not 2δ). Both this rebase and
2719
+ // applyVisual's offset-history rebase are required: dropping either
2720
+ // one stalls the spring or leaks residual motion into the quiet
2721
+ // window (audit burst-gap/ramp quiescence-move).
2722
+ animatedH += transferredPx
1834
2723
  }
1835
2724
  const runwayOffset = runwayOffsetOf(host)
1836
2725
  const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
1837
- if (lag <= FOLLOW_SETTLE_EPSILON_PX && settleMarginPx <= FOLLOW_SETTLE_EPSILON_PX) {
2726
+ // PAD RETIREMENT (the final glide of 收尾归位): cascade quiet, drain
2727
+ // closed — the pad that kept every host commit pixel-stable is handed
2728
+ // back to the layout at the bounded settle rate. The floor sinks with
2729
+ // it and the pinned viewport glides down to the natural resting
2730
+ // position against the composer; smooth and rate-limited, never the
2731
+ // single-frame slam the raw host snap paints.
2732
+ if (
2733
+ !settleRetiring
2734
+ && lag <= FOLLOW_SETTLE_EPSILON_PX
2735
+ && reservePx <= FOLLOW_SETTLE_EPSILON_PX
2736
+ && settleQuietMs >= FOLLOW_SETTLE_QUIET_MS
2737
+ && flowPadOf(host) > FOLLOW_SETTLE_EPSILON_PX
2738
+ ) {
2739
+ followTrace('retire-start', { pad: Math.round(flowPadOf(host)), sh: host.scrollHeight, st: Math.round(host.scrollTop) })
2740
+ settleRetiring = true
2741
+ }
2742
+ if (settleRetiring) {
2743
+ const padPx = flowPadOf(host)
2744
+ const retirePx = Math.min(
2745
+ padPx,
2746
+ ((tuning.runwayPx || FOLLOW_STATUS_RUNWAY_PX) / FOLLOW_RUNWAY_RETIRE_MS) * dt,
2747
+ )
2748
+ if (padPx - retirePx <= FOLLOW_SETTLE_EPSILON_PX) {
2749
+ setFlowPad(host, 0)
2750
+ settleRetiring = false
2751
+ } else {
2752
+ setFlowPad(host, padPx - retirePx)
2753
+ }
2754
+ }
2755
+ if (
2756
+ lag <= FOLLOW_SETTLE_EPSILON_PX
2757
+ && (reservePx <= FOLLOW_SETTLE_EPSILON_PX || settleStatus === null)
2758
+ && flowPadOf(host) <= FOLLOW_SETTLE_EPSILON_PX
2759
+ && settleQuietMs >= FOLLOW_SETTLE_QUIET_MS
2760
+ ) {
2761
+ followTrace('finish', { st: Math.round(host.scrollTop), sh: host.scrollHeight, pad: Math.round(flowPadOf(host)) })
1838
2762
  animatedH = host.scrollHeight
1839
- reservePx = 0
1840
2763
  velocityPxPerSec = 0
1841
- followRunways.delete(host)
1842
- finishAtNaturalFloor(host, !startedAsEntrance)
2764
+ finishAtNaturalFloor(host, !startedAsEntrance, !hostOwnsScroll)
1843
2765
  followLeaders.delete(host)
1844
2766
  releaseRevealScale()
1845
2767
  debugRuntime.reportFollow(host, null)
@@ -1856,14 +2778,38 @@ export function useConversationFollow(
1856
2778
  animatedH + step.advancePx,
1857
2779
  )
1858
2780
  velocityPxPerSec = step.velocityPxPerSec
1859
- settleAtFloor(host)
1860
- animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, Math.max(settleMarginPx, runwayOffset))
2781
+ if (!hostOwnsScroll) settleAtFloor(host)
2782
+ // Once the host has written, never write scrollTop again. The settle
2783
+ // still computes compositor shifts and rebases the spring; the host
2784
+ // owns the scrollport. A completion commit that GROWS the extent
2785
+ // changes the host floor; the host snaps while applyVisual answers
2786
+ // with the matching shift raise — the same lockstep the active loop
2787
+ // paints for a wrap.
2788
+ animatedH = applyVisual(
2789
+ host,
2790
+ animatedH,
2791
+ reservePx,
2792
+ velocityPxPerSec,
2793
+ runwayOffset,
2794
+ Number.POSITIVE_INFINITY,
2795
+ false,
2796
+ undefined,
2797
+ 0,
2798
+ !hostOwnsScroll,
2799
+ )
2800
+ if (traceActive()) {
2801
+ const sig = `${Math.round(host.scrollTop)}|${host.scrollHeight}|${Math.round(flowPadOf(host))}|${Math.round(reservePx)}|${settleRetiring}`
2802
+ if (sig !== settleSig) {
2803
+ followTrace('settle', { st: Math.round(host.scrollTop), sh: host.scrollHeight, pad: Math.round(flowPadOf(host)), reserve: Math.round(reservePx), lag: Math.round(lag * 10) / 10, retiring: settleRetiring })
2804
+ settleSig = sig
2805
+ }
2806
+ }
1861
2807
  reportFollow(host, false)
1862
2808
  requestAnimationFrame(settleFrame)
1863
2809
  }
1864
2810
  requestAnimationFrame(settleFrame)
1865
2811
  }
1866
- }, [active, rootRef, speedCpsRef, revealScaleRef, predictive, predictiveRef])
2812
+ }, [active, rootRef, speedCpsRef, revealScaleRef, predictive, predictiveRef, controlScroll])
1867
2813
 
1868
2814
  useLayoutEffect(() => {
1869
2815
  const host = rootRef.current?.closest<HTMLElement>('[data-conversation-scroll]') ?? null