diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8d3f88ea97b229151ccebf9d970ea3149d8beb93..d490b980e5f0005155e137228f15bb367d36722b 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 378387c4261ecea2b11f818ed0f25eaa73956f06..63d9fbd6b95a2c406a3d2e6cc265029a4c803f1d 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"