From ed8f41b78db1ccadb50b5a7947704cf28833eeee Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 02:05:36 -0700 Subject: [PATCH] fix(web): live reasoning trace follows the stream to the bottom The expanded reasoning trace is capped at max-h-96 and nothing pinned it to the end, so streamed text grew below the visible area. While any entry streams, WorkLogDetails now pins its scroller to the end before paint. Scrolling up pauses that until the reader returns to the end. Fixes #16201 Co-Authored-By: Claude Opus 5.5 --- .../src/components/chat/MessagesTimeline.tsx | 8 +- apps/web/src/components/chat/WorkLog.test.tsx | 103 ++++++++++++++++++ apps/web/src/components/chat/WorkLog.tsx | 30 ++++- 3 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/chat/WorkLog.test.tsx diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8d3f88ea97b2..d490b980e5f0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -5433,8 +5433,10 @@ function remarkThoughtPreview(fallback: string) { function ReasoningTraceContent({ entries }: { entries: ReadonlyArray }) { const ctx = use(TimelineRowCtx); const { isWorking, latestRunId } = use(TimelineRowActivityCtx); + const isStreaming = (entry: TimelineWorkEntry) => + isWorking && entry.runId === latestRunId && entry.toolLifecycleStatus === "inProgress"; return ( - + {entries.map((entry) => ( { + root.render( + +

{text}

+
, + ); + }); +} + +function scroller() { + const element = host.firstElementChild; + if (!(element instanceof HTMLDivElement)) throw new Error("missing scroller"); + return element; +} + +function stream(followLive: boolean, text: string) { + contentHeight += 200; + render(followLive, text); +} + +function readerScrollsTo(top: number) { + scroller().scrollTop = top; + act(() => { + scroller().dispatchEvent(new Event("scroll")); + }); +} + +const maxScrollTop = () => contentHeight - VIEWPORT; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + contentHeight = 1000; + scrollTop = 0; + Object.defineProperties(HTMLDivElement.prototype, { + clientHeight: { configurable: true, get: () => VIEWPORT }, + scrollHeight: { configurable: true, get: () => contentHeight }, + scrollTop: { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = Math.max(0, Math.min(value, maxScrollTop())); + }, + }, + }); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); + for (const key of ["clientHeight", "scrollHeight", "scrollTop"]) { + Reflect.deleteProperty(HTMLDivElement.prototype, key); + } + vi.unstubAllGlobals(); +}); + +describe("WorkLogDetails followLive", () => { + it("keeps the newest streamed text in view", () => { + render(true, "a"); + expect(scrollTop).toBe(maxScrollTop()); + + stream(true, "ab"); + stream(true, "abc"); + expect(scrollTop).toBe(maxScrollTop()); + }); + + it("stops following when the reader scrolls up and resumes at the end", () => { + render(true, "a"); + readerScrollsTo(100); + + stream(true, "ab"); + expect(scrollTop).toBe(100); + + readerScrollsTo(maxScrollTop()); + stream(true, "abc"); + expect(scrollTop).toBe(maxScrollTop()); + }); + + it("does not move a trace that is not streaming", () => { + render(false, "a"); + stream(false, "ab"); + expect(scrollTop).toBe(0); + }); +}); diff --git a/apps/web/src/components/chat/WorkLog.tsx b/apps/web/src/components/chat/WorkLog.tsx index 378387c4261e..63d9fbd6b95a 100644 --- a/apps/web/src/components/chat/WorkLog.tsx +++ b/apps/web/src/components/chat/WorkLog.tsx @@ -1,4 +1,11 @@ -import { createContext, use, type ComponentProps, type ReactNode } from "react"; +import { + createContext, + use, + useLayoutEffect, + useRef, + type ComponentProps, + type ReactNode, +} from "react"; import { cn } from "../../lib/utils"; const GroupedRows = createContext(false); @@ -122,15 +129,36 @@ export function WorkLogRow({ ); } +/** Distance from the end, in px, that still counts as reading the newest text. */ +const LIVE_EDGE_PX = 4; + export function WorkLogDetails({ children, kind = "text", + followLive = false, }: { children: ReactNode; kind?: "text" | "panel" | "media"; + /** Keep streamed text in view. Scrolling up pauses this until the reader returns to the end. */ + followLive?: boolean; }) { + const scrollerRef = useRef(null); + const atLiveEdgeRef = useRef(true); + // No deps: each streamed chunk re-renders this, and pinning before paint + // shows the new text without a visible jump. + useLayoutEffect(() => { + const scroller = scrollerRef.current; + if (followLive && atLiveEdgeRef.current && scroller) { + scroller.scrollTop = scroller.scrollHeight; + } + }); return (
{ + const { scrollHeight, scrollTop, clientHeight } = event.currentTarget; + atLiveEdgeRef.current = scrollHeight - scrollTop - clientHeight <= LIVE_EDGE_PX; + }} className={cn( "cursor-auto", kind === "text"