diff --git a/CHANGELOG.md b/CHANGELOG.md index d446e043..9abf67f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### The transcript stays with the question when an answer starts arriving + +Sending a message carried it up to the top of the view, correctly, and then the Bot's first token +threw the conversation back to its very first message, with the answer being written several +screens below the fold. It happened on every turn, from wherever the reader happened to be +scrolled, so every answer began with a scroll back down to find it. The transcript now holds the +question in place for the whole turn, and the scroll that puts it there is animated rather than a +jump — unless the reader has asked their system for reduced motion. + ### Coworkers are made in a wizard and managed in a dialog Creating a coworker is now a three-step wizard — who it is, who may see it, then where it runs, diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index 2a6a1fdd..45b69a38 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -5,7 +5,7 @@ import { } from "@copilotkit/react-core/v2"; import { IconBox } from "@tabler/icons-react"; import { motion, useReducedMotion } from "motion/react"; -import { memo, useEffect, useMemo, useRef } from "react"; +import { memo, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import { Streamdown } from "streamdown"; import { Bubble, BubbleContent } from "@/components/ui/bubble"; import { @@ -618,6 +618,42 @@ function ServerToolLine({ name, result }: { name: string; result?: string }) { ); } +const SEND_SCROLL_MS = 700; + +function useSmoothSendScroll( + viewport: React.RefObject, + newestUserMessageId: string | null, +) { + const reducedMotion = useReducedMotion(); + const seenRef = useRef(null); + + useLayoutEffect(() => { + const element = viewport.current; + const previous = seenRef.current; + seenRef.current = newestUserMessageId; + + if ( + element === null || + newestUserMessageId === null || + previous === null || + previous === newestUserMessageId || + reducedMotion + ) { + return; + } + + element.style.scrollBehavior = "smooth"; + const timer = window.setTimeout(() => { + element.style.scrollBehavior = ""; + }, SEND_SCROLL_MS); + + return () => { + window.clearTimeout(timer); + element.style.scrollBehavior = ""; + }; + }, [newestUserMessageId, reducedMotion, viewport]); +} + export function ChatTranscript({ busy = false, commandNames = "", @@ -651,6 +687,12 @@ export function ChatTranscript({ const waitingOnFirstToken = busy && lastItem?.kind === "text" && lastItem.role === "user"; + const viewportRef = useRef(null); + const newestUserMessageId = + items.findLast((item) => item.kind === "text" && item.role === "user") + ?.id ?? null; + useSmoothSendScroll(viewportRef, newestUserMessageId); + /* * One decider per mounted transcript, so opening a different channel starts the cascade over and * a message never inherits a delay from a conversation it was not in. @@ -678,18 +720,30 @@ export function ChatTranscript({ return ( - + - {/* - * The memo boundary is INSIDE the scroller item, not around it. `MessageScrollerItem` - * reads the scroller's context, so it re-renders whenever the scroll state moves and - * memoising it would achieve nothing. Its child is what costs — markdown parsing and - * chart SVGs — and that is what is skipped. - */} - {/* Instead of the rows, never alongside them: one real message and this is a lie. */} +
+ {stopped ? ( + + ) : waitingOnFirstToken ? ( + + ) : null} + {queued.map((message) => ( + onRemoveQueued(message.id) + : undefined + } + text={message.text} + /> + ))} +
{items.length === 0 && restoring ? : null} {items.map((item, index) => item.kind === "tool" ? ( @@ -724,35 +778,6 @@ export function ChatTranscript({ ), )} - {/* - * Outside the item list, so neither of these is a message. Each has no id, is never - * anchored, and is gone by the next turn — giving one a `MessageScrollerItem` would ask - * the scroller to measure and anchor something that exists for a second and a half. - * - * One or the other, never both: a turn that ended has stopped being in flight, and a - * shimmering "Thinking" under a line saying the Bot stopped would contradict it. - */} - {stopped ? ( - - ) : waitingOnFirstToken ? ( - - ) : null} - {/* - * Below the thinking line, and outside the item list for the same reason it is: these - * are not yet turns. They have ids of their own, but they are this tab's ids and not the - * thread's, so handing them to the scroller would ask it to anchor on something that is - * about to be replaced by a message with a different id — and the replacement is the - * one worth scrolling to. - */} - {queued.map((message) => ( - onRemoveQueued(message.id) : undefined - } - text={message.text} - /> - ))}