Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/components/chat/Messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,34 @@ const MessagesImpl = () => {
const vlistRef = useRef<VListHandle>(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 `<Messages>` being
// keyed by `threadId` in `<ThreadBody>` 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
// `<Messages>` 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;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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 <Messages> 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 `<Messages>` after we already left the welcome
Expand Down
13 changes: 11 additions & 2 deletions src/components/chat/ThreadBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ interface ThreadBodyProps {
* `<Messages>` 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 (
Expand All @@ -45,5 +45,14 @@ export function ThreadBody({ empty }: ThreadBodyProps) {
);
}

return <Messages />;
// Key by threadId so `<Messages>` 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 <Messages key={threadId} />;
}
Loading