diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index dda77aaff..f5949620a 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1200,8 +1200,17 @@ export default function Page() { ), ) + // Only follow the bottom while the model is actually streaming. This was + // previously hard-coded to `true`, so the auto-scroller's ResizeObserver + // force-scrolled on *any* reflow (image load, tool accordion expand, font + // swap, layout settle) — which read as the chat "jumping" on its own. Gating + // it on the session's real working state also stops it from competing with + // the timeline's own bottom-lock outside of streaming. const autoScroll = createAutoScroll({ - working: () => true, + working: () => { + const id = params.id + return id ? sync.data.session_working(id) : false + }, overflowAnchor: "dynamic", }) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index c6329b39c..fa2325a93 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -568,12 +568,21 @@ export function MessageTimeline(props: { createEffect( on( () => [timelineRowKeys(), activeAssistantContentVersion(), sessionStatus().type] as const, - () => { + (curr, prev) => { if (!virtualizer) return if (!props.shouldAnchorBottom() && !measuredBottomAnchored) return - const keys = timelineRowKeys() + const keys = curr[0] if (keys.length === 0) return - virtualizer.scrollToIndex(keys.length - 1, { align: "end" }) + // Only realign via virtua (an estimate-based scroll that can visibly + // pre-jump before item heights are measured) when the *set of rows* + // changes or the session status flips. Pure intra-row growth while a + // token streams is left to the measured-bottom rAF lock below, which + // pins against the real DOM height — so the two mechanisms stop landing + // on slightly different scroll positions frame-to-frame. `timelineRowKeys` + // is memoized with `equals: sameKeys`, so its reference only changes when + // the row set actually changes, making this comparison cheap and exact. + const rowsChanged = !prev || prev[0] !== keys || prev[2] !== curr[2] + if (rowsChanged) virtualizer.scrollToIndex(keys.length - 1, { align: "end" }) scheduleMeasuredBottomAnchor() }, { defer: true }, diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 71b1fa807..cd4d42705 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -60,6 +60,9 @@ import { useLocation } from "@solidjs/router" import { attached, inline, kind } from "./message-file" import { readPartText } from "./message-part-text" +const reducedMotion = () => + typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches + async function writeClipboard(text: string): Promise { const body = typeof document === "undefined" ? undefined : document.body if (body) { @@ -90,6 +93,17 @@ function ShellSubmessage(props: { text: string; animate?: boolean }) { onMount(() => { if (!props.animate) return + // The initial render collapses width to 0 and hides the value behind a blur; + // if the user prefers reduced motion, snap straight to the resting state + // instead of animating (and instead of leaving it stuck collapsed/hidden). + if (reducedMotion()) { + if (widthRef) widthRef.style.width = "auto" + if (valueRef) { + valueRef.style.opacity = "1" + valueRef.style.filter = "blur(0px)" + } + return + } requestAnimationFrame(() => { if (widthRef) { animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 })