From 1a36d15f5256f9bdbbdb3cac6fcebf562a8f6e9c Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:46:23 +0000 Subject: [PATCH 1/3] Scroll to bottom when thread loads so latest messages are visible Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/components/chat/Messages.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/components/chat/Messages.tsx b/src/components/chat/Messages.tsx index df9d1a25..7b887e93 100644 --- a/src/components/chat/Messages.tsx +++ b/src/components/chat/Messages.tsx @@ -131,6 +131,28 @@ 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). `` unmounts whenever the + // active thread changes — `ChatProvider.selectThread` clears messages + // and `` swaps in the loading skeleton — so this ref is + // fresh on every mount and naturally fires once per thread load. + const initialScrollToBottomRef = useRef(false); + useLayoutEffect(() => { + if (initialScrollToBottomRef.current) return; + if (messages.length === 0) return; + // Active turns are handled by the pin-to-top effect below; don't + // fight it. + if (isStreaming) return; + const handle = vlistRef.current; + if (!handle) return; + initialScrollToBottomRef.current = true; + const lastIndex = messages.length - 1; + requestAnimationFrame(() => { + handle.scrollToIndex(lastIndex, { align: "end" }); + }); + }, [messages.length, isStreaming]); + // 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 From 83ca5c8e4b99d9ab0eeabca0c8800fd081e04a80 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:53:00 +0000 Subject: [PATCH 2/3] Simplify thread-load scroll-to-bottom: empty-deps useLayoutEffect on mount `` already remounts on every thread load (ChatProvider clears messages, ThreadBody routes through the skeleton during hydration), so React's mount lifecycle is the natural one-shot signal. Drops the ref guard and the messages.length/isStreaming deps that were causing a post-turn scroll jolt in brand-new threads. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/components/chat/Messages.tsx | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/components/chat/Messages.tsx b/src/components/chat/Messages.tsx index 7b887e93..c6a8408a 100644 --- a/src/components/chat/Messages.tsx +++ b/src/components/chat/Messages.tsx @@ -133,25 +133,30 @@ const MessagesImpl = () => { // 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). `` unmounts whenever the - // active thread changes — `ChatProvider.selectThread` clears messages - // and `` swaps in the loading skeleton — so this ref is - // fresh on every mount and naturally fires once per thread load. - const initialScrollToBottomRef = useRef(false); + // most recent assistant response). `` remounts on every + // thread load — `ChatProvider.selectThread` clears messages and + // `` swaps in the loading skeleton until hydration + // completes — so a single mount-time effect is enough; no ref guard + // or dep tracking needed. + // + // 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 (initialScrollToBottomRef.current) return; - if (messages.length === 0) return; - // Active turns are handled by the pin-to-top effect below; don't - // fight it. if (isStreaming) return; + if (messages.length === 0) return; const handle = vlistRef.current; if (!handle) return; - initialScrollToBottomRef.current = true; const lastIndex = messages.length - 1; requestAnimationFrame(() => { handle.scrollToIndex(lastIndex, { align: "end" }); }); - }, [messages.length, isStreaming]); + // 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 From 0a879b0027856237a8c6f3bb54e9efaca503d73f Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:57:09 +0000 Subject: [PATCH 3/3] Key Messages by threadId for v6 ChatRuntimesProvider compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today Messages naturally remounts on thread switch because ChatProvider.selectThread calls setMessages([]) and ThreadBody routes through the loading skeleton during hydration. With the planned ChatRuntimesProvider + BoundChatProvider rebind pattern, useChat will swap to a cached Chat instance that already holds messages — there's no length-zero gap, so React would reconcile in place and the mount-once scroll effect would stop firing per thread. Keying by threadId guarantees a fresh mount per thread regardless of how the runtime is wired upstream. Costs nothing today and resets all per-thread state (lastMeasuredUser, prevStatusRef, initialTurnPinnedRef, virtua cache) cleanly across switches. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/components/chat/Messages.tsx | 11 ++++++----- src/components/chat/ThreadBody.tsx | 13 +++++++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/components/chat/Messages.tsx b/src/components/chat/Messages.tsx index c6a8408a..f44549fe 100644 --- a/src/components/chat/Messages.tsx +++ b/src/components/chat/Messages.tsx @@ -133,11 +133,12 @@ const MessagesImpl = () => { // 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). `` remounts on every - // thread load — `ChatProvider.selectThread` clears messages and - // `` swaps in the loading skeleton until hydration - // completes — so a single mount-time effect is enough; no ref guard - // or dep tracking needed. + // 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 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 ; }