Skip to content

Commit fb41df9

Browse files
committed
feat(timeline): auto-follow bottom when content grows
- Observes content resize via ResizeObserver to detect size changes - Auto-scrolls to bottom when already pinned and content grows - Respects manual scroll position when scrolled up - Enables smooth follow during streaming output and card expansion
1 parent 3668af9 commit fb41df9

2 files changed

Lines changed: 284 additions & 10 deletions

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import "../../index.css";
2+
3+
import { MessageId } from "@marcode/contracts";
4+
import { page } from "vitest/browser";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
6+
import { render } from "vitest-browser-react";
7+
import { useRef } from "react";
8+
import type { LegendListRef } from "@legendapp/list/react";
9+
10+
import { type TimelineEntry } from "../../session-logic";
11+
import { useRuntimeToolOutputStore } from "../../runtimeToolOutputStore";
12+
import { MessagesTimeline } from "./MessagesTimeline";
13+
14+
const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never;
15+
const THREAD_ID = "thread-messages-timeline-browser";
16+
const COMMAND_ITEM_ID = "command-item-live";
17+
18+
function isoAt(offsetSeconds: number): string {
19+
return new Date(Date.parse("2026-03-17T19:12:28.000Z") + offsetSeconds * 1_000).toISOString();
20+
}
21+
22+
function createTimelineEntries(commandDetail?: string): TimelineEntry[] {
23+
const entries: TimelineEntry[] = [];
24+
25+
for (let index = 0; index < 12; index += 1) {
26+
const userCreatedAt = isoAt(index * 4);
27+
const assistantCreatedAt = isoAt(index * 4 + 2);
28+
entries.push({
29+
id: `entry-user-${index}`,
30+
kind: "message",
31+
createdAt: userCreatedAt,
32+
message: {
33+
id: MessageId.make(`message-user-${index}`),
34+
role: "user",
35+
text: `User message ${index}`,
36+
createdAt: userCreatedAt,
37+
turnId: null,
38+
streaming: false,
39+
},
40+
});
41+
entries.push({
42+
id: `entry-assistant-${index}`,
43+
kind: "message",
44+
createdAt: assistantCreatedAt,
45+
message: {
46+
id: MessageId.make(`message-assistant-${index}`),
47+
role: "assistant",
48+
text: `Assistant message ${index}\nAssistant detail ${index}`,
49+
createdAt: assistantCreatedAt,
50+
turnId: null,
51+
streaming: false,
52+
},
53+
});
54+
}
55+
56+
entries.push({
57+
id: "entry-command-live",
58+
kind: "work",
59+
createdAt: isoAt(80),
60+
entry: {
61+
id: "work-command-live",
62+
createdAt: isoAt(80),
63+
label: "Run tests",
64+
command: "bun run test",
65+
tone: "tool",
66+
itemType: "command_execution",
67+
itemId: COMMAND_ITEM_ID,
68+
...(commandDetail ? { detail: commandDetail } : {}),
69+
},
70+
});
71+
72+
return entries;
73+
}
74+
75+
async function waitForLayout(frames = 2): Promise<void> {
76+
for (let index = 0; index < frames; index += 1) {
77+
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
78+
}
79+
}
80+
81+
async function waitForScrollContainer(host: HTMLElement): Promise<HTMLDivElement> {
82+
return vi.waitFor(
83+
() => {
84+
const container = host.querySelector<HTMLDivElement>("div.overscroll-y-contain");
85+
expect(container).toBeTruthy();
86+
return container!;
87+
},
88+
{ timeout: 8_000, interval: 16 },
89+
);
90+
}
91+
92+
function bottomGap(scrollContainer: HTMLElement): number {
93+
return scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop;
94+
}
95+
96+
async function pinToBottom(scrollContainer: HTMLDivElement): Promise<void> {
97+
scrollContainer.scrollTop = scrollContainer.scrollHeight - scrollContainer.clientHeight;
98+
scrollContainer.dispatchEvent(new Event("scroll"));
99+
await waitForLayout();
100+
}
101+
102+
async function mountTimeline(entries: TimelineEntry[]) {
103+
function Harness(props: { timelineEntries: TimelineEntry[] }) {
104+
const listRef = useRef<LegendListRef | null>(null);
105+
106+
return (
107+
<div style={{ height: "420px", width: "720px", overflow: "hidden" }}>
108+
<MessagesTimeline
109+
threadId={THREAD_ID}
110+
hasMessages
111+
isHydrating={false}
112+
isWorking={false}
113+
activeTurnInProgress={false}
114+
activeTurnStartedAt={null}
115+
listRef={listRef}
116+
onIsAtEndChange={() => {}}
117+
timelineEntries={props.timelineEntries}
118+
completionDividerBeforeEntryId={null}
119+
completionSummary={null}
120+
turnDiffSummaryByAssistantMessageId={new Map()}
121+
changedFilesExpandedByTurnId={{}}
122+
onSetChangedFilesExpanded={() => {}}
123+
onOpenTurnDiff={() => {}}
124+
revertTurnCountByUserMessageId={new Map()}
125+
onRevertUserMessage={() => {}}
126+
isRevertingCheckpoint={false}
127+
onImageExpand={() => {}}
128+
activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID}
129+
markdownCwd={undefined}
130+
resolvedTheme="light"
131+
timestampFormat="locale"
132+
workspaceRoot="/repo/project"
133+
isSendBusy={false}
134+
isSessionStarting={false}
135+
hasPendingAssistantResponse={false}
136+
isPreparingWorktree={false}
137+
isCompacting={false}
138+
onSubagentSelect={() => {}}
139+
editingUserMessageId={null}
140+
editingUserMessageText=""
141+
editingUserMessageImages={[]}
142+
onStartEditUserMessage={() => {}}
143+
onChangeEditingUserMessageText={() => {}}
144+
onAddEditingUserMessageImages={() => {}}
145+
onRemoveEditingUserMessageImage={() => {}}
146+
onCancelEditUserMessage={() => {}}
147+
onSubmitEditUserMessage={() => {}}
148+
onReplyToSelection={() => {}}
149+
/>
150+
</div>
151+
);
152+
}
153+
154+
const host = document.createElement("div");
155+
document.body.append(host);
156+
const screen = await render(<Harness timelineEntries={entries} />, { container: host });
157+
const scrollContainer = await waitForScrollContainer(host);
158+
await waitForLayout();
159+
160+
const cleanup = async () => {
161+
await screen.unmount();
162+
host.remove();
163+
};
164+
165+
return {
166+
[Symbol.asyncDispose]: cleanup,
167+
host,
168+
scrollContainer,
169+
cleanup,
170+
};
171+
}
172+
173+
describe("MessagesTimeline auto-follow", () => {
174+
afterEach(() => {
175+
document.body.innerHTML = "";
176+
useRuntimeToolOutputStore.getState().clearAll();
177+
});
178+
179+
it("stays pinned to the bottom while live command output grows", async () => {
180+
useRuntimeToolOutputStore.getState().appendOutput(THREAD_ID, COMMAND_ITEM_ID, "boot\nready\n");
181+
182+
await using mounted = await mountTimeline(createTimelineEntries());
183+
await pinToBottom(mounted.scrollContainer);
184+
185+
const initialScrollHeight = mounted.scrollContainer.scrollHeight;
186+
187+
for (let index = 0; index < 5; index += 1) {
188+
useRuntimeToolOutputStore
189+
.getState()
190+
.appendOutput(
191+
THREAD_ID,
192+
COMMAND_ITEM_ID,
193+
`${Array.from({ length: 4 }, (_, lineIndex) => `line ${index}-${lineIndex}`).join("\n")}\n`,
194+
);
195+
await waitForLayout(3);
196+
}
197+
198+
await vi.waitFor(
199+
() => {
200+
expect(mounted.scrollContainer.scrollHeight).toBeGreaterThan(initialScrollHeight + 24);
201+
expect(Math.abs(bottomGap(mounted.scrollContainer))).toBeLessThanOrEqual(4);
202+
},
203+
{ timeout: 8_000, interval: 16 },
204+
);
205+
});
206+
207+
it("stays pinned to the bottom when an overflowed command card expands", async () => {
208+
const output = Array.from({ length: 40 }, (_, index) => `expanded line ${index}`).join("\n");
209+
210+
await using mounted = await mountTimeline(createTimelineEntries(output));
211+
await pinToBottom(mounted.scrollContainer);
212+
213+
const initialScrollHeight = mounted.scrollContainer.scrollHeight;
214+
215+
await vi.waitFor(
216+
() => {
217+
expect(page.getByRole("button", { name: "Show full output" }).query()).toBeTruthy();
218+
},
219+
{ timeout: 8_000, interval: 16 },
220+
);
221+
222+
await page.getByRole("button", { name: "Show full output" }).click();
223+
await waitForLayout(3);
224+
225+
await vi.waitFor(
226+
() => {
227+
expect(mounted.scrollContainer.scrollHeight).toBeGreaterThan(initialScrollHeight + 24);
228+
expect(Math.abs(bottomGap(mounted.scrollContainer))).toBeLessThanOrEqual(4);
229+
},
230+
{ timeout: 8_000, interval: 16 },
231+
);
232+
});
233+
});

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -335,21 +335,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({
335335
}, [rows, threadId, isHydrating]);
336336

337337
const hasFinishedInitialScrollRef = useRef(false);
338-
const handleScroll = useCallback(() => {
339-
if (!hasFinishedInitialScrollRef.current) return;
338+
const isAtEndRef = useRef(true);
339+
const updateIsAtEnd = useCallback(
340+
(isAtEnd: boolean) => {
341+
isAtEndRef.current = isAtEnd;
342+
onIsAtEndChange(isAtEnd);
343+
},
344+
[onIsAtEndChange],
345+
);
346+
const syncIsAtEnd = useCallback(() => {
340347
const state = listRef.current?.getState?.();
341348
if (state) {
342-
onIsAtEndChange(state.isAtEnd);
349+
updateIsAtEnd(state.isAtEnd);
343350
}
344-
}, [listRef, onIsAtEndChange]);
351+
}, [listRef, updateIsAtEnd]);
352+
const handleScroll = useCallback(() => {
353+
if (!hasFinishedInitialScrollRef.current) return;
354+
syncIsAtEnd();
355+
}, [syncIsAtEnd]);
345356

346357
const hasAutoScrolledOnMountRef = useRef(false);
347358
useEffect(() => {
348359
if (rows.length === 0) return;
349360
if (hasAutoScrolledOnMountRef.current) return;
350361
hasAutoScrolledOnMountRef.current = true;
351362

352-
onIsAtEndChange(true);
363+
updateIsAtEnd(true);
353364

354365
let cancelled = false;
355366
let completed = false;
@@ -365,10 +376,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
365376
} else {
366377
completed = true;
367378
hasFinishedInitialScrollRef.current = true;
368-
const state = listRef.current?.getState?.();
369-
if (state) {
370-
onIsAtEndChange(state.isAtEnd);
371-
}
379+
syncIsAtEnd();
372380
}
373381
});
374382
};
@@ -385,7 +393,40 @@ export const MessagesTimeline = memo(function MessagesTimeline({
385393
hasAutoScrolledOnMountRef.current = false;
386394
}
387395
};
388-
}, [listRef, onIsAtEndChange, rows.length]);
396+
}, [listRef, rows.length, syncIsAtEnd, updateIsAtEnd]);
397+
398+
useEffect(() => {
399+
if (rows.length === 0) return;
400+
if (typeof ResizeObserver === "undefined") return;
401+
402+
const scrollContainer = listRef.current?.getScrollableNode?.();
403+
const contentNode = scrollContainer?.firstElementChild;
404+
if (!(contentNode instanceof HTMLElement)) return;
405+
406+
let rafId: number | null = null;
407+
const scheduleFollow = () => {
408+
if (rafId !== null) return;
409+
rafId = window.requestAnimationFrame(() => {
410+
rafId = null;
411+
if (!hasFinishedInitialScrollRef.current) return;
412+
if (isAtEndRef.current) {
413+
void listRef.current?.scrollToEnd?.({ animated: false });
414+
return;
415+
}
416+
syncIsAtEnd();
417+
});
418+
};
419+
420+
const observer = new ResizeObserver(scheduleFollow);
421+
observer.observe(contentNode);
422+
423+
return () => {
424+
observer.disconnect();
425+
if (rafId !== null) {
426+
window.cancelAnimationFrame(rafId);
427+
}
428+
};
429+
}, [listRef, rows.length, syncIsAtEnd]);
389430

390431
const sharedState = useMemo<TimelineRowSharedState>(
391432
() => ({

0 commit comments

Comments
 (0)