Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5433,8 +5433,10 @@ function remarkThoughtPreview(fallback: string) {
function ReasoningTraceContent({ entries }: { entries: ReadonlyArray<TimelineWorkEntry> }) {
const ctx = use(TimelineRowCtx);
const { isWorking, latestRunId } = use(TimelineRowActivityCtx);
const isStreaming = (entry: TimelineWorkEntry) =>
isWorking && entry.runId === latestRunId && entry.toolLifecycleStatus === "inProgress";
return (
<WorkLogDetails>
<WorkLogDetails followLive={entries.some(isStreaming)}>
{entries.map((entry) => (
<ChatMarkdown
key={entry.id}
Expand All @@ -5443,9 +5445,7 @@ function ReasoningTraceContent({ entries }: { entries: ReadonlyArray<TimelineWor
cwd={ctx.markdownCwd}
threadRef={ctx.threadRef ?? undefined}
skills={ctx.skills}
isStreaming={
isWorking && entry.runId === latestRunId && entry.toolLifecycleStatus === "inProgress"
}
isStreaming={isStreaming(entry)}
headingLevelOffset={MESSAGE_HEADING_LEVEL}
onUseArtifactTemplate={ctx.onUseArtifactTemplate}
onImageExpand={ctx.onImageExpand}
Expand Down
103 changes: 103 additions & 0 deletions apps/web/src/components/chat/WorkLog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// @vitest-environment jsdom

import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { WorkLogDetails } from "./WorkLog";

// jsdom has no layout, so the scroller's geometry is simulated: a 384px
// viewport over content whose height the test grows like a streamed trace.
const VIEWPORT = 384;
let contentHeight = 0;
let scrollTop = 0;

let root: Root;
let host: HTMLDivElement;

function render(followLive: boolean, text: string) {
act(() => {
root.render(
<WorkLogDetails followLive={followLive}>
<p>{text}</p>
</WorkLogDetails>,
);
});
}

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);
});
});
30 changes: 29 additions & 1 deletion apps/web/src/components/chat/WorkLog.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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<HTMLDivElement>(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 (
<div
ref={scrollerRef}
onScroll={(event) => {
const { scrollHeight, scrollTop, clientHeight } = event.currentTarget;
atLiveEdgeRef.current = scrollHeight - scrollTop - clientHeight <= LIVE_EDGE_PX;
}}
className={cn(
"cursor-auto",
kind === "text"
Expand Down
Loading