diff --git a/src/components/chat/Messages.tsx b/src/components/chat/Messages.tsx index df9d1a25..f44549fe 100644 --- a/src/components/chat/Messages.tsx +++ b/src/components/chat/Messages.tsx @@ -131,6 +131,34 @@ const MessagesImpl = () => { const vlistRef = useRef(null); const initialTurnPinnedRef = useRef(false); + // When a thread is loaded/switched, drop the user at the bottom of the + // scroll container so they land on the latest exchange (typically the + // most recent assistant response). This relies on `` being + // keyed by `threadId` in `` so React remounts it on every + // thread switch — a fresh mount is the one-shot signal, no ref guard + // or dep tracking needed. (Without the key the future + // `ChatRuntimesProvider` runtime cache would let React reconcile this + // tree in place across threads and the effect would stop firing.) + // + // We bail when `isStreaming` is true on mount so we don't fight the + // pin-to-top effect below in the brand-new-thread case (where + // `` mounts mid-turn). Empty deps mean the effect won't + // re-fire when streaming ends, avoiding a post-turn scroll jolt. + useLayoutEffect(() => { + if (isStreaming) return; + if (messages.length === 0) return; + const handle = vlistRef.current; + if (!handle) return; + const lastIndex = messages.length - 1; + requestAnimationFrame(() => { + handle.scrollToIndex(lastIndex, { align: "end" }); + }); + // Intentional: fire once per mount. `messages` and + // `isStreaming` are read from the first-render closure, which is + // the correct moment. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Pin the newest message just below the top of the viewport on every new // turn. Most turns arrive as a `ready → submitted` transition, but the // very first turn mounts `` after we already left the welcome diff --git a/src/components/chat/ThreadBody.tsx b/src/components/chat/ThreadBody.tsx index a81e4b4e..47166758 100644 --- a/src/components/chat/ThreadBody.tsx +++ b/src/components/chat/ThreadBody.tsx @@ -19,7 +19,7 @@ interface ThreadBodyProps { * `` can rely on their DOM being present on first mount. */ export function ThreadBody({ empty }: ThreadBodyProps) { - const { messages, isHistoryLoading } = useChatContext(); + const { threadId, messages, isHistoryLoading } = useChatContext(); if (messages.length === 0 && isHistoryLoading) { return ( @@ -45,5 +45,14 @@ export function ThreadBody({ empty }: ThreadBodyProps) { ); } - return ; + // Key by threadId so `` remounts on every thread switch. + // Today, ChatProvider clears messages on switch and we route through + // the skeleton above, so a remount happens naturally. Once the + // ChatRuntimesProvider lands and BoundChatProvider rebinds + // useChat({ chat }) to a cached runtime, that gap disappears — React + // would otherwise reconcile in place and stale per-thread state + // (initial scroll position, lastMeasuredUser, prevStatusRef, the + // initialTurnPinnedRef guard, virtua's internal cache) would carry + // across threads. The key forces a fresh mount instead. + return ; }