Skip to content

Commit 8867aec

Browse files
committed
fix(thread): recapture bottom-follow when large streamed blocks outrun it
The engine guards each autoscroll with a 180ms grace window; a big streamed block can jank past it, so the engine sees content below the fold while not autoscrolling and demotes itself to free-scrolling mid-reply. Arm a pin on submit that re-issues scrollToEnd whenever content slips below the fold, disarmed by user scroll intent. Generated-By: PostHog Code Task-Id: c30ae5a8-6f18-4a44-882e-a1b29ea84154
1 parent ab7c047 commit 8867aec

1 file changed

Lines changed: 47 additions & 8 deletions

File tree

  • packages/ui/src/features/sessions/components/chat-thread

packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
ChatMessageScrollerViewport,
2020
cn,
2121
useChatMessageScroller,
22+
useChatMessageScrollerScrollable,
2223
useChatMessageScrollerVisibility,
2324
} from "@posthog/quill";
2425
import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared";
@@ -513,31 +514,69 @@ const ThreadRow = memo(function ThreadRow({
513514
});
514515

515516
/**
516-
* Scrolls to the end when the user sends a prompt, regardless of current position. The engine only
517-
* auto-follows in `following-bottom` mode, which it re-enters solely within 8px of the exact bottom
518-
* — with the thread's bottom padding you're rarely that close, so without this a submit can leave
519-
* the new prompt (and the streaming response) below the fold. `scrollToEnd` also puts the engine
520-
* back into `following-bottom`, so the response streams with the view following.
517+
* Keeps the view pinned to the bottom from prompt submit until the user scrolls away.
518+
*
519+
* The engine's own follow mode isn't enough on its own:
520+
* - It only re-engages within `scrollEdgeThreshold` of the exact bottom, so a submit from anywhere
521+
* higher would leave the new prompt (and the reply) below the fold. Scrolling to the end on
522+
* submit also flips the engine back into `following-bottom`.
523+
* - Each engine autoscroll is guarded by a 180ms grace window; a large streamed block (heavy
524+
* markdown render) can jank past it, making the engine observe "content below the fold while not
525+
* autoscrolling" and silently demote itself to `free-scrolling` mid-reply. While armed, any
526+
* commit that leaves content below the fold re-issues `scrollToEnd` to recapture follow.
527+
*
528+
* User scroll intent (wheel, touch, pointer, keys — same signals the engine listens to) disarms
529+
* the pin; the next submit or the scroll-to-bottom button re-engages following.
521530
*/
522-
function ScrollToEndOnSubmit({ items }: { items: ConversationItem[] }) {
531+
function ThreadAutoFollow({ items }: { items: ConversationItem[] }) {
523532
const { scrollToEnd } = useChatMessageScroller();
533+
const { end } = useChatMessageScrollerScrollable();
524534
const lastItem = items.at(-1);
525535
const userMessageCount = useMemo(
526536
() =>
527537
items.reduce((n, item) => (item.type === "user_message" ? n + 1 : n), 0),
528538
[items],
529539
);
530540
const prevCountRef = useRef(userMessageCount);
541+
const armedRef = useRef(false);
542+
const probeRef = useRef<HTMLSpanElement>(null);
531543

532544
useLayoutEffect(() => {
533545
const previous = prevCountRef.current;
534546
prevCountRef.current = userMessageCount;
535547
if (previous === 0 || userMessageCount <= previous) return;
536548
if (lastItem?.type !== "user_message") return;
549+
armedRef.current = true;
537550
scrollToEnd({ behavior: "auto" });
538551
}, [userMessageCount, lastItem, scrollToEnd]);
539552

540-
return null;
553+
useEffect(() => {
554+
const viewport = probeRef.current
555+
?.closest('[data-slot="chat-message-scroller"]')
556+
?.querySelector('[data-slot="chat-message-scroller-viewport"]');
557+
if (!viewport) return;
558+
const disarm = () => {
559+
armedRef.current = false;
560+
};
561+
const events = ["wheel", "touchmove", "pointerdown", "keydown"] as const;
562+
for (const event of events) {
563+
viewport.addEventListener(event, disarm, { passive: true });
564+
}
565+
return () => {
566+
for (const event of events) {
567+
viewport.removeEventListener(event, disarm);
568+
}
569+
};
570+
}, []);
571+
572+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-check on every streamed change — `end` alone doesn't re-notify while it stays true across commits.
573+
useEffect(() => {
574+
if (armedRef.current && end) {
575+
scrollToEnd({ behavior: "auto" });
576+
}
577+
}, [items, end, scrollToEnd]);
578+
579+
return <span ref={probeRef} className="hidden" aria-hidden="true" />;
541580
}
542581

543582
/** The scroll body, under the Provider so the overlay + scroll-button hooks can read engine state. */
@@ -566,7 +605,7 @@ function ThreadScrollBody({
566605
return (
567606
<ChatMessageScroller className="group/thread">
568607
<StickyHeaderOverlay items={items} />
569-
<ScrollToEndOnSubmit items={items} />
608+
<ThreadAutoFollow items={items} />
570609
<ChatMessageScrollerViewport>
571610
<ChatMessageScrollerContent className="py-4 pb-8" density="default">
572611
{keyedRows.map(({ item, key }) => (

0 commit comments

Comments
 (0)