authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 02:13:31-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 02:13:31-07:00
log9a5dc75648de725c737069379600570e391ec8b5
tree07635e94cae42f7a9499ae10f85fa9520a9d413d
parentd10621de5f8f7e3af4831be04c0564a4ae65eade
signature Signed by PGP key B5690EEEBB952194

fix(web): live reasoning stays scrolled to the newest text while it streams (#19334)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

3 files changed, 136 insertions(+), 5 deletions(-)

apps/web/src/components/chat/MessagesTimeline.tsx+4-4
...@@ -5433,8 +5433,10 @@ function remarkThoughtPreview(fallback: string) {...@@ -5433,8 +5433,10 @@ function remarkThoughtPreview(fallback: string) {
5433function ReasoningTraceContent({ entries }: { entries: ReadonlyArray<TimelineWorkEntry> }) {5433function ReasoningTraceContent({ entries }: { entries: ReadonlyArray<TimelineWorkEntry> }) {
5434 const ctx = use(TimelineRowCtx);5434 const ctx = use(TimelineRowCtx);
5435 const { isWorking, latestRunId } = use(TimelineRowActivityCtx);5435 const { isWorking, latestRunId } = use(TimelineRowActivityCtx);
5436 const isStreaming = (entry: TimelineWorkEntry) =>
5437 isWorking && entry.runId === latestRunId && entry.toolLifecycleStatus === "inProgress";
5436 return (5438 return (
5437 <WorkLogDetails>5439 <WorkLogDetails followLive={entries.some(isStreaming)}>
5438 {entries.map((entry) => (5440 {entries.map((entry) => (
5439 <ChatMarkdown5441 <ChatMarkdown
5440 key={entry.id}5442 key={entry.id}
...@@ -5443,9 +5445,7 @@ function ReasoningTraceContent({ entries }: { entries: ReadonlyArray<TimelineWor...@@ -5443,9 +5445,7 @@ function ReasoningTraceContent({ entries }: { entries: ReadonlyArray<TimelineWor
5443 cwd={ctx.markdownCwd}5445 cwd={ctx.markdownCwd}
5444 threadRef={ctx.threadRef ?? undefined}5446 threadRef={ctx.threadRef ?? undefined}
5445 skills={ctx.skills}5447 skills={ctx.skills}
5446 isStreaming={5448 isStreaming={isStreaming(entry)}
5447 isWorking && entry.runId === latestRunId && entry.toolLifecycleStatus === "inProgress"
5448 }
5449 headingLevelOffset={MESSAGE_HEADING_LEVEL}5449 headingLevelOffset={MESSAGE_HEADING_LEVEL}
5450 onUseArtifactTemplate={ctx.onUseArtifactTemplate}5450 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
5451 onImageExpand={ctx.onImageExpand}5451 onImageExpand={ctx.onImageExpand}
apps/web/src/components/chat/WorkLog.test.tsx created+103
...@@ -0,0 +1,103 @@
1// @vitest-environment jsdom
2
3import { act } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6import { WorkLogDetails } from "./WorkLog";
7
8// jsdom has no layout, so the scroller's geometry is simulated: a 384px
9// viewport over content whose height the test grows like a streamed trace.
10const VIEWPORT = 384;
11let contentHeight = 0;
12let scrollTop = 0;
13
14let root: Root;
15let host: HTMLDivElement;
16
17function render(followLive: boolean, text: string) {
18 act(() => {
19 root.render(
20 <WorkLogDetails followLive={followLive}>
21 <p>{text}</p>
22 </WorkLogDetails>,
23 );
24 });
25}
26
27function scroller() {
28 const element = host.firstElementChild;
29 if (!(element instanceof HTMLDivElement)) throw new Error("missing scroller");
30 return element;
31}
32
33function stream(followLive: boolean, text: string) {
34 contentHeight += 200;
35 render(followLive, text);
36}
37
38function readerScrollsTo(top: number) {
39 scroller().scrollTop = top;
40 act(() => {
41 scroller().dispatchEvent(new Event("scroll"));
42 });
43}
44
45const maxScrollTop = () => contentHeight - VIEWPORT;
46
47beforeEach(() => {
48 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
49 contentHeight = 1000;
50 scrollTop = 0;
51 Object.defineProperties(HTMLDivElement.prototype, {
52 clientHeight: { configurable: true, get: () => VIEWPORT },
53 scrollHeight: { configurable: true, get: () => contentHeight },
54 scrollTop: {
55 configurable: true,
56 get: () => scrollTop,
57 set: (value: number) => {
58 scrollTop = Math.max(0, Math.min(value, maxScrollTop()));
59 },
60 },
61 });
62 host = document.createElement("div");
63 document.body.append(host);
64 root = createRoot(host);
65});
66
67afterEach(() => {
68 act(() => root.unmount());
69 host.remove();
70 for (const key of ["clientHeight", "scrollHeight", "scrollTop"]) {
71 Reflect.deleteProperty(HTMLDivElement.prototype, key);
72 }
73 vi.unstubAllGlobals();
74});
75
76describe("WorkLogDetails followLive", () => {
77 it("keeps the newest streamed text in view", () => {
78 render(true, "a");
79 expect(scrollTop).toBe(maxScrollTop());
80
81 stream(true, "ab");
82 stream(true, "abc");
83 expect(scrollTop).toBe(maxScrollTop());
84 });
85
86 it("stops following when the reader scrolls up and resumes at the end", () => {
87 render(true, "a");
88 readerScrollsTo(100);
89
90 stream(true, "ab");
91 expect(scrollTop).toBe(100);
92
93 readerScrollsTo(maxScrollTop());
94 stream(true, "abc");
95 expect(scrollTop).toBe(maxScrollTop());
96 });
97
98 it("does not move a trace that is not streaming", () => {
99 render(false, "a");
100 stream(false, "ab");
101 expect(scrollTop).toBe(0);
102 });
103});
apps/web/src/components/chat/WorkLog.tsx+29-1
...@@ -1,4 +1,11 @@...@@ -1,4 +1,11 @@
1import { createContext, use, type ComponentProps, type ReactNode } from "react";1import {
2 createContext,
3 use,
4 useLayoutEffect,
5 useRef,
6 type ComponentProps,
7 type ReactNode,
8} from "react";
2import { cn } from "../../lib/utils";9import { cn } from "../../lib/utils";
310
4const GroupedRows = createContext(false);11const GroupedRows = createContext(false);
...@@ -122,15 +129,36 @@ export function WorkLogRow({...@@ -122,15 +129,36 @@ export function WorkLogRow({
122 );129 );
123}130}
124131
132/** Distance from the end, in px, that still counts as reading the newest text. */
133const LIVE_EDGE_PX = 4;
134
125export function WorkLogDetails({135export function WorkLogDetails({
126 children,136 children,
127 kind = "text",137 kind = "text",
138 followLive = false,
128}: {139}: {
129 children: ReactNode;140 children: ReactNode;
130 kind?: "text" | "panel" | "media";141 kind?: "text" | "panel" | "media";
142 /** Keep streamed text in view. Scrolling up pauses this until the reader returns to the end. */
143 followLive?: boolean;
131}) {144}) {
145 const scrollerRef = useRef<HTMLDivElement>(null);
146 const atLiveEdgeRef = useRef(true);
147 // No deps: each streamed chunk re-renders this, and pinning before paint
148 // shows the new text without a visible jump.
149 useLayoutEffect(() => {
150 const scroller = scrollerRef.current;
151 if (followLive && atLiveEdgeRef.current && scroller) {
152 scroller.scrollTop = scroller.scrollHeight;
153 }
154 });
132 return (155 return (
133 <div156 <div
157 ref={scrollerRef}
158 onScroll={(event) => {
159 const { scrollHeight, scrollTop, clientHeight } = event.currentTarget;
160 atLiveEdgeRef.current = scrollHeight - scrollTop - clientHeight <= LIVE_EDGE_PX;
161 }}
134 className={cn(162 className={cn(
135 "cursor-auto",163 "cursor-auto",
136 kind === "text"164 kind === "text"