* Effect-only child that tracks the last user-prompt scrolled above the * viewport top and fires onChange when it changes. * * Rendered as a separate component (not a hook in VirtualMessageList) so it * can subscribe to scroll at FINER granularity than SCROLL_QUANTUM=40. The * list needs the co
({
messages,
start,
end,
offsets,
getItemTop,
getItemElement,
scrollRef
}: {
messages: RenderableMessage[];
start: number;
end: number;
offsets: ArrayLike<number>;
getItemTop: (index: number) => number;
getItemElement: (index: number) => DOMElement | null;
scrollRef: RefObject<ScrollBoxHandle | null>;
})
| 890 | * from the mount-range end; break when an item's top is above target. |
| 891 | */ |
| 892 | function StickyTracker({ |
| 893 | messages, |
| 894 | start, |
| 895 | end, |
| 896 | offsets, |
| 897 | getItemTop, |
| 898 | getItemElement, |
| 899 | scrollRef |
| 900 | }: { |
| 901 | messages: RenderableMessage[]; |
| 902 | start: number; |
| 903 | end: number; |
| 904 | offsets: ArrayLike<number>; |
| 905 | getItemTop: (index: number) => number; |
| 906 | getItemElement: (index: number) => DOMElement | null; |
| 907 | scrollRef: RefObject<ScrollBoxHandle | null>; |
| 908 | }): null { |
| 909 | const { |
| 910 | setStickyPrompt |
| 911 | } = useContext(ScrollChromeContext); |
| 912 | // Fine-grained subscription — snapshot is unquantized scrollTop+delta so |
| 913 | // every scroll action (wheel tick, PgUp, drag) triggers a re-render of |
| 914 | // THIS component only. Sticky bit folded into the sign so sticky→broken |
| 915 | // also triggers (scrollToBottom sets sticky without moving scrollTop). |
| 916 | const subscribe = useCallback((listener: () => void) => scrollRef.current?.subscribe(listener) ?? NOOP_UNSUB, [scrollRef]); |
| 917 | useSyncExternalStore(subscribe, () => { |
| 918 | const s = scrollRef.current; |
| 919 | if (!s) return NaN; |
| 920 | const t = s.getScrollTop() + s.getPendingDelta(); |
| 921 | return s.isSticky() ? -1 - t : t; |
| 922 | }); |
| 923 | |
| 924 | // Read live scroll state on every render. |
| 925 | const isSticky = scrollRef.current?.isSticky() ?? true; |
| 926 | const target = Math.max(0, (scrollRef.current?.getScrollTop() ?? 0) + (scrollRef.current?.getPendingDelta() ?? 0)); |
| 927 | |
| 928 | // Walk the mounted range to find the first item at-or-below the viewport |
| 929 | // top. `range` is from the parent's coarse-quantum render (may be slightly |
| 930 | // stale) but overscan guarantees it spans well past the viewport in both |
| 931 | // directions. Items without a Yoga layout yet (newly mounted this frame) |
| 932 | // are treated as at-or-below — they're somewhere in view, and assuming |
| 933 | // otherwise would show a sticky for a prompt that's actually on screen. |
| 934 | let firstVisible = start; |
| 935 | let firstVisibleTop = -1; |
| 936 | for (let i = end - 1; i >= start; i--) { |
| 937 | const top = getItemTop(i); |
| 938 | if (top >= 0) { |
| 939 | if (top < target) break; |
| 940 | firstVisibleTop = top; |
| 941 | } |
| 942 | firstVisible = i; |
| 943 | } |
| 944 | let idx = -1; |
| 945 | let text: string | null = null; |
| 946 | if (firstVisible > 0 && !isSticky) { |
| 947 | for (let i = firstVisible - 1; i >= 0; i--) { |
| 948 | const t = stickyPromptText(messages[i]!); |
| 949 | if (t === null) continue; |
nothing calls this directly
no test coverage detected