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/README.en.md +13 -0
- package/README.md +13 -0
- package/lib/client.js +962 -1051
- package/lib/client.js.map +1 -1
- package/lib/index.js +22 -6
- package/package.json +11 -5
- package/src/client/AgentRowEntrance.module.css +43 -0
- package/src/client/FollowHost.tsx +18 -1
- package/src/client/SmoothStreamCard.module.css +34 -0
- package/src/client/SmoothStreamCard.tsx +39 -10
- package/src/client/TypewriterAssistantNodeView.tsx +130 -21
- package/src/client/TypewriterToolNodeView.tsx +9 -1
- package/src/client/clientStore.ts +71 -12
- package/src/client/index.ts +15 -29
- package/src/client/locales.ts +25 -5
- package/src/client/smooth-stream-card-controller.ts +10 -6
- package/src/client/smooth-stream-settings-api.ts +6 -4
- package/src/client/teleprompterGlide.ts +1021 -75
- package/src/client/turn-process-face.d.ts +27 -0
- package/src/plugin.ts +30 -6
- package/src/settings-api.ts +4 -2
- package/src/settings.ts +26 -8
- package/src/client/auto-collapse-controller.ts +0 -1072
|
@@ -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
|
|
100
|
-
* its
|
|
101
|
-
*
|
|
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 =
|
|
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
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
|
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
|
-
|
|
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 -
|
|
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
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
//
|
|
1557
|
-
// it
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1806
|
-
|
|
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
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|