Skip to content

Commit 33dadb5

Browse files
Fix thread timeline autoscroll and simplify branch state (#2002)
1 parent d18e43b commit 33dadb5

3 files changed

Lines changed: 179 additions & 4 deletions

File tree

apps/web/src/components/ChatView.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3229,7 +3229,6 @@ export default function ChatView(props: ChatViewProps) {
32293229
{/* Messages — LegendList handles virtualization and scrolling internally */}
32303230
<MessagesTimeline
32313231
key={activeThread.id}
3232-
hasMessages={timelineEntries.length > 0}
32333232
isWorking={isWorking}
32343233
activeTurnInProgress={isWorking || !latestTurnSettled}
32353234
activeTurnId={activeLatestTurn?.turnId ?? null}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import "../../index.css";
2+
3+
import { EnvironmentId } from "@t3tools/contracts";
4+
import { createRef } from "react";
5+
import type { LegendListRef } from "@legendapp/list/react";
6+
import { page } from "vitest/browser";
7+
import { afterEach, describe, expect, it, vi } from "vitest";
8+
import { render } from "vitest-browser-react";
9+
10+
const scrollToEndSpy = vi.fn();
11+
const getStateSpy = vi.fn(() => ({ isAtEnd: true }));
12+
13+
vi.mock("@legendapp/list/react", async () => {
14+
const React = await import("react");
15+
16+
const LegendList = React.forwardRef(function MockLegendList(
17+
props: {
18+
data: Array<{ id: string }>;
19+
keyExtractor: (item: { id: string }) => string;
20+
renderItem: (args: { item: { id: string } }) => React.ReactNode;
21+
ListHeaderComponent?: React.ReactNode;
22+
ListFooterComponent?: React.ReactNode;
23+
},
24+
ref: React.ForwardedRef<LegendListRef>,
25+
) {
26+
React.useImperativeHandle(
27+
ref,
28+
() =>
29+
({
30+
scrollToEnd: scrollToEndSpy,
31+
getState: getStateSpy,
32+
}) as unknown as LegendListRef,
33+
);
34+
35+
return (
36+
<div data-testid="legend-list">
37+
{props.ListHeaderComponent}
38+
{props.data.map((item) => (
39+
<div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div>
40+
))}
41+
{props.ListFooterComponent}
42+
</div>
43+
);
44+
});
45+
46+
return { LegendList };
47+
});
48+
49+
import { MessagesTimeline } from "./MessagesTimeline";
50+
51+
function buildProps() {
52+
return {
53+
isWorking: false,
54+
activeTurnInProgress: false,
55+
activeTurnId: null,
56+
activeTurnStartedAt: null,
57+
listRef: createRef<LegendListRef | null>(),
58+
completionDividerBeforeEntryId: null,
59+
completionSummary: null,
60+
turnDiffSummaryByAssistantMessageId: new Map(),
61+
routeThreadKey: "environment-local:thread-1",
62+
onOpenTurnDiff: vi.fn(),
63+
revertTurnCountByUserMessageId: new Map(),
64+
onRevertUserMessage: vi.fn(),
65+
isRevertingCheckpoint: false,
66+
onImageExpand: vi.fn(),
67+
activeThreadEnvironmentId: EnvironmentId.make("environment-local"),
68+
markdownCwd: undefined,
69+
resolvedTheme: "dark" as const,
70+
timestampFormat: "24-hour" as const,
71+
workspaceRoot: undefined,
72+
onIsAtEndChange: vi.fn(),
73+
};
74+
}
75+
76+
describe("MessagesTimeline", () => {
77+
afterEach(() => {
78+
scrollToEndSpy.mockReset();
79+
getStateSpy.mockClear();
80+
vi.restoreAllMocks();
81+
document.body.innerHTML = "";
82+
});
83+
84+
it("renders activity rows instead of the empty placeholder when a thread has non-message timeline data", async () => {
85+
const screen = await render(
86+
<MessagesTimeline
87+
{...buildProps()}
88+
timelineEntries={[
89+
{
90+
id: "work-1",
91+
kind: "work",
92+
createdAt: "2026-04-13T12:00:00.000Z",
93+
entry: {
94+
id: "work-1",
95+
createdAt: "2026-04-13T12:00:00.000Z",
96+
label: "thinking",
97+
detail: "Inspecting repository state",
98+
tone: "thinking",
99+
},
100+
},
101+
]}
102+
/>,
103+
);
104+
105+
try {
106+
await expect
107+
.element(page.getByText("Send a message to start the conversation."))
108+
.not.toBeInTheDocument();
109+
await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible();
110+
} finally {
111+
await screen.unmount();
112+
}
113+
});
114+
115+
it("snaps to the bottom when timeline rows appear after an initially empty render", async () => {
116+
const requestAnimationFrameSpy = vi
117+
.spyOn(window, "requestAnimationFrame")
118+
.mockImplementation((callback: FrameRequestCallback) => {
119+
callback(0);
120+
return 1;
121+
});
122+
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
123+
124+
const props = buildProps();
125+
const screen = await render(<MessagesTimeline {...props} timelineEntries={[]} />);
126+
127+
try {
128+
await expect
129+
.element(page.getByText("Send a message to start the conversation."))
130+
.toBeVisible();
131+
132+
await screen.rerender(
133+
<MessagesTimeline
134+
{...props}
135+
timelineEntries={[
136+
{
137+
id: "work-1",
138+
kind: "work",
139+
createdAt: "2026-04-13T12:00:00.000Z",
140+
entry: {
141+
id: "work-1",
142+
createdAt: "2026-04-13T12:00:00.000Z",
143+
label: "thinking",
144+
detail: "Inspecting repository state",
145+
tone: "thinking",
146+
},
147+
},
148+
]}
149+
/>,
150+
);
151+
152+
await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible();
153+
expect(props.onIsAtEndChange).toHaveBeenCalledWith(true);
154+
expect(scrollToEndSpy).toHaveBeenCalledWith({ animated: false });
155+
expect(requestAnimationFrameSpy).toHaveBeenCalled();
156+
} finally {
157+
await screen.unmount();
158+
}
159+
});
160+
});

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@ const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
9292
// ---------------------------------------------------------------------------
9393

9494
interface MessagesTimelineProps {
95-
hasMessages: boolean;
9695
isWorking: boolean;
9796
activeTurnInProgress: boolean;
9897
activeTurnId?: TurnId | null;
@@ -121,7 +120,6 @@ interface MessagesTimelineProps {
121120
// ---------------------------------------------------------------------------
122121

123122
export const MessagesTimeline = memo(function MessagesTimeline({
124-
hasMessages,
125123
isWorking,
126124
activeTurnInProgress,
127125
activeTurnId,
@@ -172,6 +170,24 @@ export const MessagesTimeline = memo(function MessagesTimeline({
172170
}
173171
}, [listRef, onIsAtEndChange]);
174172

173+
const previousRowCountRef = useRef(rows.length);
174+
useEffect(() => {
175+
const previousRowCount = previousRowCountRef.current;
176+
previousRowCountRef.current = rows.length;
177+
178+
if (previousRowCount > 0 || rows.length === 0) {
179+
return;
180+
}
181+
182+
onIsAtEndChange(true);
183+
const frameId = window.requestAnimationFrame(() => {
184+
void listRef.current?.scrollToEnd?.({ animated: false });
185+
});
186+
return () => {
187+
window.cancelAnimationFrame(frameId);
188+
};
189+
}, [listRef, onIsAtEndChange, rows.length]);
190+
175191
// Memoised context value — only changes on state transitions, NOT on
176192
// every streaming chunk. Callbacks from ChatView are useCallback-stable.
177193
const sharedState = useMemo<TimelineRowSharedState>(
@@ -220,7 +236,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
220236
[],
221237
);
222238

223-
if (!hasMessages && !isWorking) {
239+
if (rows.length === 0 && !isWorking) {
224240
return (
225241
<div className="flex h-full items-center justify-center">
226242
<p className="text-sm text-muted-foreground/30">

0 commit comments

Comments
 (0)