Skip to content

Commit 7b32529

Browse files
authored
fix(thread): stop ChatThread snapping to top on prompt submit (#3083)
1 parent 670c1ac commit 7b32529

1 file changed

Lines changed: 84 additions & 3 deletions

File tree

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

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

Lines changed: 84 additions & 3 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";
@@ -134,7 +135,7 @@ function groupToolRuns(items: ConversationItem[]): ThreadItem[] {
134135
const flush = () => {
135136
if (toolCount >= 2) {
136137
const tools = buffer.filter(isToolCallItem);
137-
out.push({ type: "tool_group", id: `tool-group-${tools[0].id}`, tools });
138+
out.push({ type: "tool_group", id: tools[0].id, tools });
138139
} else {
139140
out.push(...buffer);
140141
}
@@ -512,6 +513,72 @@ const ThreadRow = memo(function ThreadRow({
512513
);
513514
});
514515

516+
/**
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.
530+
*/
531+
function ThreadAutoFollow({ items }: { items: ConversationItem[] }) {
532+
const { scrollToEnd } = useChatMessageScroller();
533+
const { end } = useChatMessageScrollerScrollable();
534+
const lastItem = items.at(-1);
535+
const userMessageCount = useMemo(
536+
() =>
537+
items.reduce((n, item) => (item.type === "user_message" ? n + 1 : n), 0),
538+
[items],
539+
);
540+
const prevCountRef = useRef(userMessageCount);
541+
const armedRef = useRef(false);
542+
const probeRef = useRef<HTMLSpanElement>(null);
543+
544+
useLayoutEffect(() => {
545+
const previous = prevCountRef.current;
546+
prevCountRef.current = userMessageCount;
547+
if (previous === 0 || userMessageCount <= previous) return;
548+
if (lastItem?.type !== "user_message") return;
549+
armedRef.current = true;
550+
scrollToEnd({ behavior: "auto" });
551+
}, [userMessageCount, lastItem, scrollToEnd]);
552+
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" />;
580+
}
581+
515582
/** The scroll body, under the Provider so the overlay + scroll-button hooks can read engine state. */
516583
function ThreadScrollBody({
517584
items,
@@ -525,15 +592,24 @@ function ThreadScrollBody({
525592
/** Status row (duration / context usage) pinned as the last item in the thread. */
526593
footer?: ReactNode;
527594
}) {
595+
const keyedRows = useMemo(() => {
596+
let userTurn = 0;
597+
return rows.map((item) => ({
598+
item,
599+
key: item.type === "user_message" ? `user-turn-${userTurn++}` : item.id,
600+
}));
601+
}, [rows]);
602+
528603
// `group/thread` so the footer's hover-reveal (opacity-50 → 100 on group-hover) tracks the thread,
529604
// mirroring the legacy ConversationView container.
530605
return (
531606
<ChatMessageScroller className="group/thread">
532607
<StickyHeaderOverlay items={items} />
608+
<ThreadAutoFollow items={items} />
533609
<ChatMessageScrollerViewport>
534610
<ChatMessageScrollerContent className="py-4 pb-8" density="default">
535-
{rows.map((item) => (
536-
<ThreadRow key={item.id} item={item} renderItem={renderItem} />
611+
{keyedRows.map(({ item, key }) => (
612+
<ThreadRow key={key} item={item} renderItem={renderItem} />
537613
))}
538614
{footer && (
539615
<div
@@ -692,6 +768,11 @@ export function ChatThread({
692768
<ChatMessageScrollerProvider
693769
autoScroll
694770
defaultScrollPosition="end"
771+
// Default is 8px: with the thread's bottom padding you're rarely that close, so
772+
// auto-follow ("following-bottom") would disengage on any stray trackpad wheel and
773+
// never re-engage. Within this band the engine recaptures follow on the next content
774+
// change; deliberate upward flicks travel past it and stay free-scrolling.
775+
scrollEdgeThreshold={100}
695776
scrollPreviousItemPeek={64}
696777
>
697778
<ThreadScrollBody

0 commit comments

Comments
 (0)