authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-13 13:51:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-13 13:51:12-07:00
log33dadb5aeca1ac5201682daed6e1412d57e14287
treead4f865d5757b923a3589b7d2c4f00e52a67a8ca
parentd18e43b6747887a5c4bdf55a04f0b3f2f71ead83
signature Signed by PGP key B5690EEEBB952194

Fix thread timeline autoscroll and simplify branch state (#2002)


3 files changed, 179 insertions(+), 4 deletions(-)

apps/web/src/components/ChatView.tsx-1
......@@ -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}
apps/web/src/components/chat/MessagesTimeline.browser.tsx created+160
......@@ -0,0 +1,160 @@
1import "../../index.css";
2
3import { EnvironmentId } from "@t3tools/contracts";
4import { createRef } from "react";
5import type { LegendListRef } from "@legendapp/list/react";
6import { page } from "vitest/browser";
7import { afterEach, describe, expect, it, vi } from "vitest";
8import { render } from "vitest-browser-react";
9
10const scrollToEndSpy = vi.fn();
11const getStateSpy = vi.fn(() => ({ isAtEnd: true }));
12
13vi.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
49import { MessagesTimeline } from "./MessagesTimeline";
50
51function 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
76describe("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+19-3
......@@ -92,7 +92,6 @@ const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
9292// ---------------------------------------------------------------------------
9393
9494interface MessagesTimelineProps {
95 hasMessages: boolean;
9695 isWorking: boolean;
9796 activeTurnInProgress: boolean;
9897 activeTurnId?: TurnId | null;
......@@ -121,7 +120,6 @@ interface MessagesTimelineProps {
121120// ---------------------------------------------------------------------------
122121
123122export 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">