authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-02 00:10:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-02 00:10:34-07:00
log867cf4cf091b2626930e58d46c9fee9f009b6916
treeeebb6eb5859b73a7b9bc744faa7e5553dc56e603
parentae6f9715c6472670c0fae3b9cd0f44484f8e31d4
signature Signed by PGP key B5690EEEBB952194

fix: stabilize messages timeline virtualization (#1664)

Co-authored-by: codex <codex@users.noreply.github.com>

9 files changed, 1465 insertions(+), 150 deletions(-)

apps/web/src/components/chat/ChangedFilesTree.test.tsx created+116
......@@ -0,0 +1,116 @@
1import { TurnId } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vitest";
4
5import { ChangedFilesTree } from "./ChangedFilesTree";
6
7describe("ChangedFilesTree", () => {
8 it.each([
9 {
10 name: "a compacted single-chain directory",
11 files: [
12 { path: "apps/web/src/index.ts", additions: 2, deletions: 1 },
13 { path: "apps/web/src/main.ts", additions: 3, deletions: 0 },
14 ],
15 visibleLabels: ["apps/web/src"],
16 hiddenLabels: ["index.ts", "main.ts"],
17 },
18 {
19 name: "a branch point after a compacted prefix",
20 files: [
21 { path: "apps/server/src/git/Layers/GitCore.ts", additions: 4, deletions: 3 },
22 { path: "apps/server/src/provider/Layers/CodexAdapter.ts", additions: 7, deletions: 2 },
23 ],
24 visibleLabels: ["apps/server/src"],
25 hiddenLabels: ["git", "provider", "GitCore.ts", "CodexAdapter.ts"],
26 },
27 {
28 name: "mixed root files and nested compacted directories",
29 files: [
30 { path: "README.md", additions: 1, deletions: 0 },
31 { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 },
32 { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 },
33 ],
34 visibleLabels: ["README.md", "packages"],
35 hiddenLabels: ["shared/src", "contracts/src", "git.ts", "orchestration.ts"],
36 },
37 ])(
38 "renders $name collapsed on the first render when collapse-all is active",
39 ({ files, visibleLabels, hiddenLabels }) => {
40 const markup = renderToStaticMarkup(
41 <ChangedFilesTree
42 turnId={TurnId.makeUnsafe("turn-1")}
43 files={files}
44 allDirectoriesExpanded={false}
45 resolvedTheme="light"
46 onOpenTurnDiff={() => {}}
47 />,
48 );
49
50 for (const label of visibleLabels) {
51 expect(markup).toContain(label);
52 }
53 for (const label of hiddenLabels) {
54 expect(markup).not.toContain(label);
55 }
56 },
57 );
58
59 it.each([
60 {
61 name: "a compacted single-chain directory",
62 files: [
63 { path: "apps/web/src/index.ts", additions: 2, deletions: 1 },
64 { path: "apps/web/src/main.ts", additions: 3, deletions: 0 },
65 ],
66 visibleLabels: ["apps/web/src", "index.ts", "main.ts"],
67 },
68 {
69 name: "a branch point after a compacted prefix",
70 files: [
71 { path: "apps/server/src/git/Layers/GitCore.ts", additions: 4, deletions: 3 },
72 { path: "apps/server/src/provider/Layers/CodexAdapter.ts", additions: 7, deletions: 2 },
73 ],
74 visibleLabels: [
75 "apps/server/src",
76 "git/Layers",
77 "provider/Layers",
78 "GitCore.ts",
79 "CodexAdapter.ts",
80 ],
81 },
82 {
83 name: "mixed root files and nested compacted directories",
84 files: [
85 { path: "README.md", additions: 1, deletions: 0 },
86 { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 },
87 { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 },
88 ],
89 visibleLabels: [
90 "README.md",
91 "packages",
92 "shared/src",
93 "contracts/src",
94 "git.ts",
95 "orchestration.ts",
96 ],
97 },
98 ])(
99 "renders $name expanded on the first render when expand-all is active",
100 ({ files, visibleLabels }) => {
101 const markup = renderToStaticMarkup(
102 <ChangedFilesTree
103 turnId={TurnId.makeUnsafe("turn-1")}
104 files={files}
105 allDirectoriesExpanded
106 resolvedTheme="light"
107 onOpenTurnDiff={() => {}}
108 />,
109 );
110
111 for (const label of visibleLabels) {
112 expect(markup).toContain(label);
113 }
114 },
115 );
116});
apps/web/src/components/chat/ChangedFilesTree.tsx+32-34
......@@ -1,5 +1,5 @@
11import { type TurnId } from "@t3tools/contracts";
2import { memo, useCallback, useEffect, useMemo, useState } from "react";
2import { memo, useCallback, useMemo, useState } from "react";
33import { type TurnDiffFileChange } from "../../types";
44import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree";
55import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react";
......@@ -7,6 +7,8 @@ import { cn } from "~/lib/utils";
77import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
88import { VscodeEntryIcon } from "./VscodeEntryIcon";
99
10const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
11
1012export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
1113 turnId: TurnId;
1214 files: ReadonlyArray<TurnDiffFileChange>;
......@@ -20,32 +22,39 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
2022 () => collectDirectoryPaths(treeNodes).join("\u0000"),
2123 [treeNodes],
2224 );
23 const allDirectoryExpansionState = useMemo(
24 () =>
25 buildDirectoryExpansionState(
26 directoryPathsKey ? directoryPathsKey.split("\u0000") : [],
27 allDirectoriesExpanded,
28 ),
29 [allDirectoriesExpanded, directoryPathsKey],
30 );
31 const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>(() =>
32 buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true),
33 );
34 useEffect(() => {
35 setExpandedDirectories(allDirectoryExpansionState);
36 }, [allDirectoryExpansionState]);
25 const expansionStateKey = `${allDirectoriesExpanded ? "expanded" : "collapsed"}\u0000${directoryPathsKey}`;
26 const [directoryExpansionState, setDirectoryExpansionState] = useState<{
27 key: string;
28 overrides: Record<string, boolean>;
29 }>(() => ({
30 key: expansionStateKey,
31 overrides: {},
32 }));
33 const expandedDirectories =
34 directoryExpansionState.key === expansionStateKey
35 ? directoryExpansionState.overrides
36 : EMPTY_DIRECTORY_OVERRIDES;
3737
38 const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => {
39 setExpandedDirectories((current) => ({
40 ...current,
41 [pathValue]: !(current[pathValue] ?? fallbackExpanded),
42 }));
43 }, []);
38 const toggleDirectory = useCallback(
39 (pathValue: string) => {
40 setDirectoryExpansionState((current) => {
41 const nextOverrides = current.key === expansionStateKey ? current.overrides : {};
42 return {
43 key: expansionStateKey,
44 overrides: {
45 ...nextOverrides,
46 [pathValue]: !(nextOverrides[pathValue] ?? allDirectoriesExpanded),
47 },
48 };
49 });
50 },
51 [allDirectoriesExpanded, expansionStateKey],
52 );
4453
4554 const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => {
4655 const leftPadding = 8 + depth * 14;
4756 if (node.kind === "directory") {
48 const isExpanded = expandedDirectories[node.path] ?? depth === 0;
57 const isExpanded = expandedDirectories[node.path] ?? allDirectoriesExpanded;
4958 return (
5059 <div key={`dir:${node.path}`}>
5160 <button
......@@ -53,7 +62,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
5362 data-scroll-anchor-ignore
5463 className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80"
5564 style={{ paddingLeft: `${leftPadding}px` }}
56 onClick={() => toggleDirectory(node.path, depth === 0)}
65 onClick={() => toggleDirectory(node.path)}
5766 >
5867 <ChevronRightIcon
5968 aria-hidden="true"
......@@ -124,14 +133,3 @@ function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[]
124133 }
125134 return paths;
126135}
127
128function buildDirectoryExpansionState(
129 directoryPaths: ReadonlyArray<string>,
130 expanded: boolean,
131): Record<string, boolean> {
132 const expandedState: Record<string, boolean> = {};
133 for (const directoryPath of directoryPaths) {
134 expandedState[directoryPath] = expanded;
135 }
136 return expandedState;
137}
apps/web/src/components/chat/MessagesTimeline.logic.ts+170
......@@ -1,3 +1,11 @@
1import { type MessageId } from "@t3tools/contracts";
2import { type TimelineEntry, type WorkLogEntry } from "../../session-logic";
3import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree";
4import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
5import { estimateTimelineMessageHeight } from "../timelineHeight";
6
7export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
8
19export interface TimelineDurationMessage {
210 id: string;
311 role: "user" | "assistant" | "system";
......@@ -5,6 +13,29 @@ export interface TimelineDurationMessage {
513 completedAt?: string | undefined;
614}
715
16export type MessagesTimelineRow =
17 | {
18 kind: "work";
19 id: string;
20 createdAt: string;
21 groupedEntries: WorkLogEntry[];
22 }
23 | {
24 kind: "message";
25 id: string;
26 createdAt: string;
27 message: ChatMessage;
28 durationStart: string;
29 showCompletionDivider: boolean;
30 }
31 | {
32 kind: "proposed-plan";
33 id: string;
34 createdAt: string;
35 proposedPlan: ProposedPlan;
36 }
37 | { kind: "working"; id: string; createdAt: string | null };
38
839export function computeMessageDurationStart(
940 messages: ReadonlyArray<TimelineDurationMessage>,
1041): Map<string, string> {
......@@ -27,3 +58,142 @@ export function computeMessageDurationStart(
2758export function normalizeCompactToolLabel(value: string): string {
2859 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
2960}
61
62export function deriveMessagesTimelineRows(input: {
63 timelineEntries: ReadonlyArray<TimelineEntry>;
64 completionDividerBeforeEntryId: string | null;
65 isWorking: boolean;
66 activeTurnStartedAt: string | null;
67}): MessagesTimelineRow[] {
68 const nextRows: MessagesTimelineRow[] = [];
69 const durationStartByMessageId = computeMessageDurationStart(
70 input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
71 );
72
73 for (let index = 0; index < input.timelineEntries.length; index += 1) {
74 const timelineEntry = input.timelineEntries[index];
75 if (!timelineEntry) {
76 continue;
77 }
78
79 if (timelineEntry.kind === "work") {
80 const groupedEntries = [timelineEntry.entry];
81 let cursor = index + 1;
82 while (cursor < input.timelineEntries.length) {
83 const nextEntry = input.timelineEntries[cursor];
84 if (!nextEntry || nextEntry.kind !== "work") break;
85 groupedEntries.push(nextEntry.entry);
86 cursor += 1;
87 }
88 nextRows.push({
89 kind: "work",
90 id: timelineEntry.id,
91 createdAt: timelineEntry.createdAt,
92 groupedEntries,
93 });
94 index = cursor - 1;
95 continue;
96 }
97
98 if (timelineEntry.kind === "proposed-plan") {
99 nextRows.push({
100 kind: "proposed-plan",
101 id: timelineEntry.id,
102 createdAt: timelineEntry.createdAt,
103 proposedPlan: timelineEntry.proposedPlan,
104 });
105 continue;
106 }
107
108 nextRows.push({
109 kind: "message",
110 id: timelineEntry.id,
111 createdAt: timelineEntry.createdAt,
112 message: timelineEntry.message,
113 durationStart:
114 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt,
115 showCompletionDivider:
116 timelineEntry.message.role === "assistant" &&
117 input.completionDividerBeforeEntryId === timelineEntry.id,
118 });
119 }
120
121 if (input.isWorking) {
122 nextRows.push({
123 kind: "working",
124 id: "working-indicator-row",
125 createdAt: input.activeTurnStartedAt,
126 });
127 }
128
129 return nextRows;
130}
131
132export function estimateMessagesTimelineRowHeight(
133 row: MessagesTimelineRow,
134 input: {
135 timelineWidthPx: number | null;
136 expandedWorkGroups?: Readonly<Record<string, boolean>>;
137 turnDiffSummaryByAssistantMessageId?: ReadonlyMap<MessageId, TurnDiffSummary>;
138 },
139): number {
140 switch (row.kind) {
141 case "work":
142 return estimateWorkRowHeight(row, input);
143 case "proposed-plan":
144 return estimateTimelineProposedPlanHeight(row.proposedPlan);
145 case "working":
146 return 40;
147 case "message": {
148 let estimate = estimateTimelineMessageHeight(row.message, {
149 timelineWidthPx: input.timelineWidthPx,
150 });
151 const turnDiffSummary = input.turnDiffSummaryByAssistantMessageId?.get(row.message.id);
152 if (turnDiffSummary && turnDiffSummary.files.length > 0) {
153 estimate += estimateChangedFilesCardHeight(turnDiffSummary);
154 }
155 return estimate;
156 }
157 }
158}
159
160function estimateWorkRowHeight(
161 row: Extract<MessagesTimelineRow, { kind: "work" }>,
162 input: {
163 expandedWorkGroups?: Readonly<Record<string, boolean>>;
164 },
165): number {
166 const isExpanded = input.expandedWorkGroups?.[row.id] ?? false;
167 const hasOverflow = row.groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
168 const visibleEntries =
169 hasOverflow && !isExpanded ? MAX_VISIBLE_WORK_LOG_ENTRIES : row.groupedEntries.length;
170 const onlyToolEntries = row.groupedEntries.every((entry) => entry.tone === "tool");
171 const showHeader = hasOverflow || !onlyToolEntries;
172
173 // Card chrome, optional header, and one compact work-entry row per visible entry.
174 return 28 + (showHeader ? 26 : 0) + visibleEntries * 32;
175}
176
177function estimateTimelineProposedPlanHeight(proposedPlan: ProposedPlan): number {
178 const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72));
179 return 120 + Math.min(estimatedLines * 22, 880);
180}
181
182function estimateChangedFilesCardHeight(turnDiffSummary: TurnDiffSummary): number {
183 const treeNodes = buildTurnDiffTree(turnDiffSummary.files);
184 const visibleNodeCount = countTurnDiffTreeNodes(treeNodes);
185
186 // Card chrome: top/bottom padding, header row, and tree spacing.
187 return 60 + visibleNodeCount * 25;
188}
189
190function countTurnDiffTreeNodes(nodes: ReadonlyArray<TurnDiffTreeNode>): number {
191 let count = 0;
192 for (const node of nodes) {
193 count += 1;
194 if (node.kind === "directory") {
195 count += countTurnDiffTreeNodes(node.children);
196 }
197 }
198 return count;
199}
apps/web/src/components/chat/MessagesTimeline.tsx+75-102
......@@ -35,13 +35,18 @@ import {
3535} from "lucide-react";
3636import { Button } from "../ui/button";
3737import { clamp } from "effect/Number";
38import { estimateTimelineMessageHeight } from "../timelineHeight";
3938import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
4039import { ProposedPlanCard } from "./ProposedPlanCard";
4140import { ChangedFilesTree } from "./ChangedFilesTree";
4241import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
4342import { MessageCopyButton } from "./MessageCopyButton";
44import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";
43import {
44 MAX_VISIBLE_WORK_LOG_ENTRIES,
45 deriveMessagesTimelineRows,
46 estimateMessagesTimelineRowHeight,
47 normalizeCompactToolLabel,
48 type MessagesTimelineRow,
49} from "./MessagesTimeline.logic";
4550import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
4651import {
4752 deriveDisplayedUserMessageState,
......@@ -56,7 +61,6 @@ import {
5661 textContainsInlineTerminalContextLabels,
5762} from "./userMessageTerminalContexts";
5863
59const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
6064const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
6165
6266interface MessagesTimelineProps {
......@@ -81,6 +85,17 @@ interface MessagesTimelineProps {
8185 resolvedTheme: "light" | "dark";
8286 timestampFormat: TimestampFormat;
8387 workspaceRoot: string | undefined;
88 onVirtualizerSnapshot?: (snapshot: {
89 totalSize: number;
90 measurements: ReadonlyArray<{
91 id: string;
92 kind: MessagesTimelineRow["kind"];
93 index: number;
94 size: number;
95 start: number;
96 end: number;
97 }>;
98 }) => void;
8499}
85100
86101export const MessagesTimeline = memo(function MessagesTimeline({
......@@ -105,6 +120,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
105120 resolvedTheme,
106121 timestampFormat,
107122 workspaceRoot,
123 onVirtualizerSnapshot,
108124}: MessagesTimelineProps) {
109125 const timelineRootRef = useRef<HTMLDivElement | null>(null);
110126 const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);
......@@ -134,70 +150,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({
134150 };
135151 }, [hasMessages, isWorking]);
136152
137 const rows = useMemo<TimelineRow[]>(() => {
138 const nextRows: TimelineRow[] = [];
139 const durationStartByMessageId = computeMessageDurationStart(
140 timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
141 );
142
143 for (let index = 0; index < timelineEntries.length; index += 1) {
144 const timelineEntry = timelineEntries[index];
145 if (!timelineEntry) {
146 continue;
147 }
148
149 if (timelineEntry.kind === "work") {
150 const groupedEntries = [timelineEntry.entry];
151 let cursor = index + 1;
152 while (cursor < timelineEntries.length) {
153 const nextEntry = timelineEntries[cursor];
154 if (!nextEntry || nextEntry.kind !== "work") break;
155 groupedEntries.push(nextEntry.entry);
156 cursor += 1;
157 }
158 nextRows.push({
159 kind: "work",
160 id: timelineEntry.id,
161 createdAt: timelineEntry.createdAt,
162 groupedEntries,
163 });
164 index = cursor - 1;
165 continue;
166 }
167
168 if (timelineEntry.kind === "proposed-plan") {
169 nextRows.push({
170 kind: "proposed-plan",
171 id: timelineEntry.id,
172 createdAt: timelineEntry.createdAt,
173 proposedPlan: timelineEntry.proposedPlan,
174 });
175 continue;
176 }
177
178 nextRows.push({
179 kind: "message",
180 id: timelineEntry.id,
181 createdAt: timelineEntry.createdAt,
182 message: timelineEntry.message,
183 durationStart:
184 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt,
185 showCompletionDivider:
186 timelineEntry.message.role === "assistant" &&
187 completionDividerBeforeEntryId === timelineEntry.id,
188 });
189 }
190
191 if (isWorking) {
192 nextRows.push({
193 kind: "working",
194 id: "working-indicator-row",
195 createdAt: activeTurnStartedAt,
196 });
197 }
198
199 return nextRows;
200 }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]);
153 const rows = useMemo(
154 () =>
155 deriveMessagesTimelineRows({
156 timelineEntries,
157 completionDividerBeforeEntryId,
158 isWorking,
159 activeTurnStartedAt,
160 }),
161 [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt],
162 );
201163
202164 const firstUnvirtualizedRowIndex = useMemo(() => {
203165 const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0);
......@@ -241,19 +203,26 @@ export const MessagesTimeline = memo(function MessagesTimeline({
241203 minimum: 0,
242204 maximum: rows.length,
243205 });
206 const virtualMeasurementScopeKey =
207 timelineWidthPx === null ? "width:unknown" : `width:${Math.round(timelineWidthPx)}`;
244208
245209 const rowVirtualizer = useVirtualizer({
246210 count: virtualizedRowCount,
247211 getScrollElement: () => scrollContainer,
248 // Use stable row ids so virtual measurements do not leak across thread switches.
249 getItemKey: (index: number) => rows[index]?.id ?? index,
212 // Scope cached row measurements to the current timeline width so offscreen
213 // rows do not keep stale heights after wrapping changes.
214 getItemKey: (index: number) => {
215 const rowId = rows[index]?.id ?? String(index);
216 return `${virtualMeasurementScopeKey}:${rowId}`;
217 },
250218 estimateSize: (index: number) => {
251219 const row = rows[index];
252220 if (!row) return 96;
253 if (row.kind === "work") return 112;
254 if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan);
255 if (row.kind === "working") return 40;
256 return estimateTimelineMessageHeight(row.message, { timelineWidthPx });
221 return estimateMessagesTimelineRowHeight(row, {
222 expandedWorkGroups,
223 timelineWidthPx,
224 turnDiffSummaryByAssistantMessageId,
225 });
257226 },
258227 measureElement: measureVirtualElement,
259228 useAnimationFrameWithResizeObserver: true,
......@@ -295,6 +264,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({
295264 }
296265 };
297266 }, []);
267 useLayoutEffect(() => {
268 if (!onVirtualizerSnapshot) {
269 return;
270 }
271 onVirtualizerSnapshot({
272 totalSize: rowVirtualizer.getTotalSize(),
273 measurements: rowVirtualizer.measurementsCache
274 .slice(0, virtualizedRowCount)
275 .flatMap((measurement) => {
276 const row = rows[measurement.index];
277 if (!row) {
278 return [];
279 }
280 return [
281 {
282 id: row.id,
283 kind: row.kind,
284 index: measurement.index,
285 size: measurement.size,
286 start: measurement.start,
287 end: measurement.end,
288 },
289 ];
290 }),
291 });
292 }, [onVirtualizerSnapshot, rowVirtualizer, rows, virtualizedRowCount]);
298293
299294 const virtualRows = rowVirtualizer.getVirtualItems();
300295 const nonVirtualizedRows = rows.slice(virtualizedRowCount);
......@@ -311,6 +306,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
311306 const renderRowContent = (row: TimelineRow) => (
312307 <div
313308 className="pb-4"
309 data-timeline-row-id={row.id}
314310 data-timeline-row-kind={row.kind}
315311 data-message-id={row.kind === "message" ? row.message.id : undefined}
316312 data-message-role={row.kind === "message" ? row.message.role : undefined}
......@@ -585,6 +581,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
585581 <div
586582 key={`virtual-row:${row.id}`}
587583 data-index={virtualRow.index}
584 data-virtual-row-id={row.id}
585 data-virtual-row-kind={row.kind}
586 data-virtual-row-size={virtualRow.size}
587 data-virtual-row-start={virtualRow.start}
588588 ref={rowVirtualizer.measureElement}
589589 className="absolute left-0 top-0 w-full"
590590 style={{ transform: `translateY(${virtualRow.start}px)` }}
......@@ -605,35 +605,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
605605
606606type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
607607type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
608type TimelineProposedPlan = Extract<TimelineEntry, { kind: "proposed-plan" }>["proposedPlan"];
609type TimelineWorkEntry = Extract<TimelineEntry, { kind: "work" }>["entry"];
610type TimelineRow =
611 | {
612 kind: "work";
613 id: string;
614 createdAt: string;
615 groupedEntries: TimelineWorkEntry[];
616 }
617 | {
618 kind: "message";
619 id: string;
620 createdAt: string;
621 message: TimelineMessage;
622 durationStart: string;
623 showCompletionDivider: boolean;
624 }
625 | {
626 kind: "proposed-plan";
627 id: string;
628 createdAt: string;
629 proposedPlan: TimelineProposedPlan;
630 }
631 | { kind: "working"; id: string; createdAt: string | null };
632
633function estimateTimelineProposedPlanHeight(proposedPlan: TimelineProposedPlan): number {
634 const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72));
635 return 120 + Math.min(estimatedLines * 22, 880);
636}
608type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];
609type TimelineRow = MessagesTimelineRow;
637610
638611function formatWorkingTimer(startIso: string, endIso: string): string | null {
639612 const startedAtMs = Date.parse(startIso);
apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx created+1037
......@@ -0,0 +1,1037 @@
1import "../../index.css";
2
3import { MessageId, type TurnId } from "@t3tools/contracts";
4import { page } from "vitest/browser";
5import { useCallback, useState, type ComponentProps } from "react";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7import { render } from "vitest-browser-react";
8
9import { deriveTimelineEntries, type WorkLogEntry } from "../../session-logic";
10import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
11import { MessagesTimeline } from "./MessagesTimeline";
12import {
13 deriveMessagesTimelineRows,
14 estimateMessagesTimelineRowHeight,
15} from "./MessagesTimeline.logic";
16
17const DEFAULT_VIEWPORT = {
18 width: 960,
19 height: 1_100,
20};
21const MARKDOWN_CWD = "/repo/project";
22
23interface RowMeasurement {
24 actualHeightPx: number;
25 estimatedHeightPx: number;
26 timelineWidthPx: number;
27 virtualizerSizePx: number;
28 renderedInVirtualizedRegion: boolean;
29}
30
31interface VirtualizationScenario {
32 name: string;
33 targetRowId: string;
34 props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">;
35 maxEstimateDeltaPx: number;
36}
37
38interface VirtualizerSnapshot {
39 totalSize: number;
40 measurements: ReadonlyArray<{
41 id: string;
42 kind: string;
43 index: number;
44 size: number;
45 start: number;
46 end: number;
47 }>;
48}
49
50function MessagesTimelineBrowserHarness(
51 props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">,
52) {
53 const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null);
54 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>(
55 () => props.expandedWorkGroups,
56 );
57 const handleToggleWorkGroup = useCallback(
58 (groupId: string) => {
59 setExpandedWorkGroups((current) => ({
60 ...current,
61 [groupId]: !(current[groupId] ?? false),
62 }));
63 props.onToggleWorkGroup(groupId);
64 },
65 [props],
66 );
67
68 return (
69 <div
70 ref={setScrollContainer}
71 data-testid="messages-timeline-scroll-container"
72 className="h-full overflow-y-auto overscroll-y-contain"
73 >
74 <MessagesTimeline
75 {...props}
76 scrollContainer={scrollContainer}
77 expandedWorkGroups={expandedWorkGroups}
78 onToggleWorkGroup={handleToggleWorkGroup}
79 />
80 </div>
81 );
82}
83
84function isoAt(offsetSeconds: number): string {
85 return new Date(Date.UTC(2026, 2, 17, 19, 12, 28) + offsetSeconds * 1_000).toISOString();
86}
87
88function createMessage(input: {
89 id: string;
90 role: ChatMessage["role"];
91 text: string;
92 offsetSeconds: number;
93 attachments?: ChatMessage["attachments"];
94}): ChatMessage {
95 return {
96 id: MessageId.makeUnsafe(input.id),
97 role: input.role,
98 text: input.text,
99 ...(input.attachments ? { attachments: input.attachments } : {}),
100 createdAt: isoAt(input.offsetSeconds),
101 ...(input.role === "assistant" ? { completedAt: isoAt(input.offsetSeconds + 1) } : {}),
102 streaming: false,
103 };
104}
105
106function createToolWorkEntry(input: {
107 id: string;
108 offsetSeconds: number;
109 label?: string;
110 detail?: string;
111}): WorkLogEntry {
112 return {
113 id: input.id,
114 createdAt: isoAt(input.offsetSeconds),
115 label: input.label ?? "exec_command completed",
116 ...(input.detail ? { detail: input.detail } : {}),
117 tone: "tool",
118 toolTitle: "exec_command",
119 };
120}
121
122function createPlan(input: {
123 id: string;
124 offsetSeconds: number;
125 planMarkdown: string;
126}): ProposedPlan {
127 return {
128 id: input.id as ProposedPlan["id"],
129 turnId: null,
130 planMarkdown: input.planMarkdown,
131 implementedAt: null,
132 implementationThreadId: null,
133 createdAt: isoAt(input.offsetSeconds),
134 updatedAt: isoAt(input.offsetSeconds + 1),
135 };
136}
137
138function createBaseTimelineProps(input: {
139 messages?: ChatMessage[];
140 proposedPlans?: ProposedPlan[];
141 workEntries?: WorkLogEntry[];
142 expandedWorkGroups?: Record<string, boolean>;
143 completionDividerBeforeEntryId?: string | null;
144 turnDiffSummaryByAssistantMessageId?: Map<MessageId, TurnDiffSummary>;
145 onVirtualizerSnapshot?: ComponentProps<typeof MessagesTimeline>["onVirtualizerSnapshot"];
146}): Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer"> {
147 return {
148 hasMessages: true,
149 isWorking: false,
150 activeTurnInProgress: false,
151 activeTurnStartedAt: null,
152 timelineEntries: deriveTimelineEntries(
153 input.messages ?? [],
154 input.proposedPlans ?? [],
155 input.workEntries ?? [],
156 ),
157 completionDividerBeforeEntryId: input.completionDividerBeforeEntryId ?? null,
158 completionSummary: null,
159 turnDiffSummaryByAssistantMessageId: input.turnDiffSummaryByAssistantMessageId ?? new Map(),
160 nowIso: isoAt(10_000),
161 expandedWorkGroups: input.expandedWorkGroups ?? {},
162 onToggleWorkGroup: () => {},
163 onOpenTurnDiff: () => {},
164 revertTurnCountByUserMessageId: new Map(),
165 onRevertUserMessage: () => {},
166 isRevertingCheckpoint: false,
167 onImageExpand: () => {},
168 markdownCwd: MARKDOWN_CWD,
169 resolvedTheme: "light",
170 timestampFormat: "locale",
171 workspaceRoot: MARKDOWN_CWD,
172 ...(input.onVirtualizerSnapshot ? { onVirtualizerSnapshot: input.onVirtualizerSnapshot } : {}),
173 };
174}
175
176function createFillerMessages(input: {
177 prefix: string;
178 startOffsetSeconds: number;
179 pairCount: number;
180}): ChatMessage[] {
181 const messages: ChatMessage[] = [];
182 for (let index = 0; index < input.pairCount; index += 1) {
183 const baseOffset = input.startOffsetSeconds + index * 4;
184 messages.push(
185 createMessage({
186 id: `${input.prefix}-user-${index}`,
187 role: "user",
188 text: `filler user message ${index}`,
189 offsetSeconds: baseOffset,
190 }),
191 );
192 messages.push(
193 createMessage({
194 id: `${input.prefix}-assistant-${index}`,
195 role: "assistant",
196 text: `filler assistant message ${index}`,
197 offsetSeconds: baseOffset + 1,
198 }),
199 );
200 }
201 return messages;
202}
203
204function createChangedFilesSummary(
205 targetMessageId: MessageId,
206 files: TurnDiffSummary["files"],
207): Map<MessageId, TurnDiffSummary> {
208 return new Map([
209 [
210 targetMessageId,
211 {
212 turnId: "turn-changed-files" as TurnId,
213 completedAt: isoAt(10),
214 assistantMessageId: targetMessageId,
215 files,
216 },
217 ],
218 ]);
219}
220
221function createChangedFilesScenario(input: {
222 name: string;
223 rowId: string;
224 files: TurnDiffSummary["files"];
225 maxEstimateDeltaPx?: number;
226}): VirtualizationScenario {
227 const beforeMessages = createFillerMessages({
228 prefix: `${input.rowId}-before`,
229 startOffsetSeconds: 0,
230 pairCount: 2,
231 });
232 const afterMessages = createFillerMessages({
233 prefix: `${input.rowId}-after`,
234 startOffsetSeconds: 40,
235 pairCount: 8,
236 });
237 const changedFilesMessage = createMessage({
238 id: input.rowId,
239 role: "assistant",
240 text: "Validation passed on the merged tree.",
241 offsetSeconds: 12,
242 });
243
244 return {
245 name: input.name,
246 targetRowId: changedFilesMessage.id,
247 props: createBaseTimelineProps({
248 messages: [...beforeMessages, changedFilesMessage, ...afterMessages],
249 turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(
250 changedFilesMessage.id,
251 input.files,
252 ),
253 }),
254 maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 72,
255 };
256}
257
258function createAssistantMessageScenario(input: {
259 name: string;
260 rowId: string;
261 text: string;
262 maxEstimateDeltaPx?: number;
263}): VirtualizationScenario {
264 const beforeMessages = createFillerMessages({
265 prefix: `${input.rowId}-before`,
266 startOffsetSeconds: 0,
267 pairCount: 2,
268 });
269 const afterMessages = createFillerMessages({
270 prefix: `${input.rowId}-after`,
271 startOffsetSeconds: 40,
272 pairCount: 8,
273 });
274 const assistantMessage = createMessage({
275 id: input.rowId,
276 role: "assistant",
277 text: input.text,
278 offsetSeconds: 12,
279 });
280
281 return {
282 name: input.name,
283 targetRowId: assistantMessage.id,
284 props: createBaseTimelineProps({
285 messages: [...beforeMessages, assistantMessage, ...afterMessages],
286 }),
287 maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 16,
288 };
289}
290
291function buildStaticScenarios(): VirtualizationScenario[] {
292 const beforeMessages = createFillerMessages({
293 prefix: "before",
294 startOffsetSeconds: 0,
295 pairCount: 2,
296 });
297 const afterMessages = createFillerMessages({
298 prefix: "after",
299 startOffsetSeconds: 40,
300 pairCount: 8,
301 });
302
303 const longUserMessage = createMessage({
304 id: "target-user-long",
305 role: "user",
306 text: "x".repeat(3_200),
307 offsetSeconds: 12,
308 });
309 const workEntries = Array.from({ length: 4 }, (_, index) =>
310 createToolWorkEntry({
311 id: `target-work-${index}`,
312 offsetSeconds: 12 + index,
313 detail: `tool output line ${index + 1}`,
314 }),
315 );
316 const moderatePlan = createPlan({
317 id: "target-plan",
318 offsetSeconds: 12,
319 planMarkdown: [
320 "# Stabilize virtualization",
321 "",
322 "- Gather baseline measurements",
323 "- Add browser harness coverage",
324 "- Compare estimated and rendered heights",
325 "- Fix the broken rows without broad refactors",
326 "- Re-run lint and typecheck",
327 ].join("\n"),
328 });
329 return [
330 {
331 name: "long user message",
332 targetRowId: longUserMessage.id,
333 props: createBaseTimelineProps({
334 messages: [...beforeMessages, longUserMessage, ...afterMessages],
335 }),
336 maxEstimateDeltaPx: 56,
337 },
338 {
339 name: "grouped work log row",
340 targetRowId: workEntries[0]!.id,
341 props: createBaseTimelineProps({
342 messages: [...beforeMessages, ...afterMessages],
343 workEntries,
344 }),
345 maxEstimateDeltaPx: 56,
346 },
347 {
348 name: "expanded grouped work log row with show more enabled",
349 targetRowId: "target-work-expanded-0",
350 props: createBaseTimelineProps({
351 messages: [...beforeMessages, ...afterMessages],
352 workEntries: Array.from({ length: 10 }, (_, index) =>
353 createToolWorkEntry({
354 id: `target-work-expanded-${index}`,
355 offsetSeconds: 12 + index,
356 detail: `tool output line ${index + 1}`,
357 }),
358 ),
359 expandedWorkGroups: {
360 "target-work-expanded-0": true,
361 },
362 }),
363 maxEstimateDeltaPx: 72,
364 },
365 {
366 name: "proposed plan row",
367 targetRowId: moderatePlan.id,
368 props: createBaseTimelineProps({
369 messages: [...beforeMessages, ...afterMessages],
370 proposedPlans: [moderatePlan],
371 }),
372 maxEstimateDeltaPx: 96,
373 },
374 createAssistantMessageScenario({
375 name: "assistant single-paragraph row with plain prose",
376 rowId: "target-assistant-plain-prose",
377 text: [
378 "The host is still expanding to content somewhere in the grid layout.",
379 "I'm stripping it back further to a plain block container so the test width",
380 "is actually the timeline width.",
381 ].join(" "),
382 }),
383 createAssistantMessageScenario({
384 name: "assistant single-paragraph row with inline code",
385 rowId: "target-assistant-inline-code",
386 text: [
387 "Typecheck found one exact-optional-property issue in the browser harness:",
388 "I was always passing `onVirtualizerSnapshot`, including `undefined`.",
389 "I'm tightening that object construction and rerunning the checks.",
390 ].join(" "),
391 maxEstimateDeltaPx: 28,
392 }),
393 createChangedFilesScenario({
394 name: "assistant changed-files row with a compacted single-chain directory",
395 rowId: "target-assistant-changed-files-single-chain",
396 files: [
397 { path: "apps/web/src/components/chat/ChangedFilesTree.tsx", additions: 37, deletions: 45 },
398 {
399 path: "apps/web/src/components/chat/ChangedFilesTree.test.tsx",
400 additions: 0,
401 deletions: 26,
402 },
403 ],
404 }),
405 createChangedFilesScenario({
406 name: "assistant changed-files row with a branch after compaction",
407 rowId: "target-assistant-changed-files-branch-point",
408 files: [
409 { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 },
410 { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 },
411 {
412 path: "apps/server/src/provider/Layers/CodexAdapter.ts",
413 additions: 27,
414 deletions: 8,
415 },
416 {
417 path: "apps/server/src/provider/Layers/CodexAdapter.test.ts",
418 additions: 36,
419 deletions: 0,
420 },
421 ],
422 }),
423 createChangedFilesScenario({
424 name: "assistant changed-files row with mixed root and nested entries",
425 rowId: "target-assistant-changed-files-mixed-root",
426 files: [
427 { path: "README.md", additions: 5, deletions: 1 },
428 { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 },
429 { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 },
430 ],
431 }),
432 ];
433}
434
435async function nextFrame(): Promise<void> {
436 await new Promise<void>((resolve) => {
437 window.requestAnimationFrame(() => resolve());
438 });
439}
440
441async function waitForLayout(): Promise<void> {
442 await nextFrame();
443 await nextFrame();
444 await nextFrame();
445}
446
447async function setViewport(viewport: { width: number; height: number }): Promise<void> {
448 await page.viewport(viewport.width, viewport.height);
449 await waitForLayout();
450}
451
452async function waitForProductionStyles(): Promise<void> {
453 await vi.waitFor(
454 () => {
455 expect(
456 getComputedStyle(document.documentElement).getPropertyValue("--background").trim(),
457 ).not.toBe("");
458 expect(getComputedStyle(document.body).marginTop).toBe("0px");
459 },
460 { timeout: 4_000, interval: 16 },
461 );
462}
463
464async function waitForElement<T extends Element>(
465 query: () => T | null,
466 errorMessage: string,
467): Promise<T> {
468 let element: T | null = null;
469 await vi.waitFor(
470 () => {
471 element = query();
472 expect(element, errorMessage).toBeTruthy();
473 },
474 { timeout: 8_000, interval: 16 },
475 );
476 if (!element) {
477 throw new Error(errorMessage);
478 }
479 return element;
480}
481
482async function measureTimelineRow(input: {
483 host: HTMLElement;
484 props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">;
485 targetRowId: string;
486}): Promise<RowMeasurement> {
487 const scrollContainer = await waitForElement(
488 () =>
489 input.host.querySelector<HTMLDivElement>(
490 '[data-testid="messages-timeline-scroll-container"]',
491 ),
492 "Unable to find MessagesTimeline scroll container.",
493 );
494
495 const rowSelector = `[data-timeline-row-id="${input.targetRowId}"]`;
496 const virtualRowSelector = `[data-virtual-row-id="${input.targetRowId}"]`;
497
498 let timelineWidthPx = 0;
499 let actualHeightPx = 0;
500 let virtualizerSizePx = 0;
501 let renderedInVirtualizedRegion = false;
502
503 await vi.waitFor(
504 async () => {
505 scrollContainer.scrollTop = 0;
506 scrollContainer.dispatchEvent(new Event("scroll"));
507 await waitForLayout();
508
509 const rowElement = input.host.querySelector<HTMLElement>(rowSelector);
510 const virtualRowElement = input.host.querySelector<HTMLElement>(virtualRowSelector);
511 const timelineRoot = input.host.querySelector<HTMLElement>('[data-timeline-root="true"]');
512
513 expect(rowElement, "Unable to locate target timeline row.").toBeTruthy();
514 expect(virtualRowElement, "Unable to locate target virtualized wrapper.").toBeTruthy();
515 expect(timelineRoot, "Unable to locate MessagesTimeline root.").toBeTruthy();
516
517 timelineWidthPx = timelineRoot!.getBoundingClientRect().width;
518 actualHeightPx = rowElement!.getBoundingClientRect().height;
519 virtualizerSizePx = Number.parseFloat(virtualRowElement!.dataset.virtualRowSize ?? "0");
520 renderedInVirtualizedRegion = virtualRowElement!.hasAttribute("data-index");
521
522 expect(timelineWidthPx).toBeGreaterThan(0);
523 expect(actualHeightPx).toBeGreaterThan(0);
524 expect(virtualizerSizePx).toBeGreaterThan(0);
525 expect(renderedInVirtualizedRegion).toBe(true);
526 },
527 { timeout: 8_000, interval: 16 },
528 );
529
530 const rows = deriveMessagesTimelineRows({
531 timelineEntries: input.props.timelineEntries,
532 completionDividerBeforeEntryId: input.props.completionDividerBeforeEntryId,
533 isWorking: input.props.isWorking,
534 activeTurnStartedAt: input.props.activeTurnStartedAt,
535 });
536 const targetRow = rows.find((row) => row.id === input.targetRowId);
537 expect(targetRow, `Unable to derive target row ${input.targetRowId}.`).toBeTruthy();
538
539 return {
540 actualHeightPx,
541 estimatedHeightPx: estimateMessagesTimelineRowHeight(targetRow!, {
542 expandedWorkGroups: input.props.expandedWorkGroups,
543 timelineWidthPx,
544 turnDiffSummaryByAssistantMessageId: input.props.turnDiffSummaryByAssistantMessageId,
545 }),
546 timelineWidthPx,
547 virtualizerSizePx,
548 renderedInVirtualizedRegion,
549 };
550}
551
552async function mountMessagesTimeline(input: {
553 props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">;
554 viewport?: { width: number; height: number };
555}) {
556 const viewport = input.viewport ?? DEFAULT_VIEWPORT;
557 await setViewport(viewport);
558 await waitForProductionStyles();
559
560 const host = document.createElement("div");
561 host.style.width = `${viewport.width}px`;
562 host.style.minWidth = `${viewport.width}px`;
563 host.style.maxWidth = `${viewport.width}px`;
564 host.style.height = `${viewport.height}px`;
565 host.style.minHeight = `${viewport.height}px`;
566 host.style.maxHeight = `${viewport.height}px`;
567 host.style.display = "block";
568 host.style.overflow = "hidden";
569 document.body.append(host);
570
571 const screen = await render(<MessagesTimelineBrowserHarness {...input.props} />, {
572 container: host,
573 });
574 await waitForLayout();
575
576 return {
577 host,
578 rerender: async (
579 nextProps: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">,
580 ) => {
581 await screen.rerender(<MessagesTimelineBrowserHarness {...nextProps} />);
582 await waitForLayout();
583 },
584 setContainerSize: async (nextViewport: { width: number; height: number }) => {
585 await setViewport(nextViewport);
586 host.style.width = `${nextViewport.width}px`;
587 host.style.minWidth = `${nextViewport.width}px`;
588 host.style.maxWidth = `${nextViewport.width}px`;
589 host.style.height = `${nextViewport.height}px`;
590 host.style.minHeight = `${nextViewport.height}px`;
591 host.style.maxHeight = `${nextViewport.height}px`;
592 await waitForLayout();
593 },
594 cleanup: async () => {
595 await screen.unmount();
596 host.remove();
597 },
598 };
599}
600
601async function measureRenderedRowActualHeight(input: {
602 host: HTMLElement;
603 targetRowId: string;
604}): Promise<number> {
605 const rowElement = await waitForElement(
606 () => input.host.querySelector<HTMLElement>(`[data-timeline-row-id="${input.targetRowId}"]`),
607 `Unable to locate rendered row ${input.targetRowId}.`,
608 );
609 return rowElement.getBoundingClientRect().height;
610}
611
612describe("MessagesTimeline virtualization harness", () => {
613 beforeEach(async () => {
614 document.body.innerHTML = "";
615 await setViewport(DEFAULT_VIEWPORT);
616 });
617
618 afterEach(() => {
619 document.body.innerHTML = "";
620 });
621
622 it.each(buildStaticScenarios())("keeps the $name estimate within tolerance", async (scenario) => {
623 const mounted = await mountMessagesTimeline({ props: scenario.props });
624
625 try {
626 const measurement = await measureTimelineRow({
627 host: mounted.host,
628 props: scenario.props,
629 targetRowId: scenario.targetRowId,
630 });
631
632 expect(
633 Math.abs(measurement.actualHeightPx - measurement.estimatedHeightPx),
634 `estimate delta for ${scenario.name}`,
635 ).toBeLessThanOrEqual(scenario.maxEstimateDeltaPx);
636 } finally {
637 await mounted.cleanup();
638 }
639 });
640
641 it("keeps the changed-files row virtualizer size in sync after collapsing directories", async () => {
642 const beforeMessages = createFillerMessages({
643 prefix: "before-collapse",
644 startOffsetSeconds: 0,
645 pairCount: 2,
646 });
647 const afterMessages = createFillerMessages({
648 prefix: "after-collapse",
649 startOffsetSeconds: 40,
650 pairCount: 8,
651 });
652 const targetMessage = createMessage({
653 id: "target-assistant-collapse",
654 role: "assistant",
655 text: "Validation passed on the merged tree.",
656 offsetSeconds: 12,
657 });
658 const props = createBaseTimelineProps({
659 messages: [...beforeMessages, targetMessage, ...afterMessages],
660 turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [
661 { path: ".plans/effect-atom.md", additions: 89, deletions: 0 },
662 {
663 path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts",
664 additions: 4,
665 deletions: 3,
666 },
667 {
668 path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts",
669 additions: 131,
670 deletions: 128,
671 },
672 {
673 path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts",
674 additions: 1,
675 deletions: 1,
676 },
677 { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 },
678 {
679 path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts",
680 additions: 106,
681 deletions: 112,
682 },
683 { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 },
684 { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 },
685 {
686 path: "apps/web/src/components/chat/MessagesTimeline.tsx",
687 additions: 52,
688 deletions: 7,
689 },
690 {
691 path: "apps/web/src/components/chat/ChangedFilesTree.tsx",
692 additions: 32,
693 deletions: 4,
694 },
695 { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 },
696 { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 },
697 ]),
698 });
699 const mounted = await mountMessagesTimeline({
700 props,
701 viewport: { width: 320, height: 700 },
702 });
703
704 try {
705 const beforeCollapse = await measureTimelineRow({
706 host: mounted.host,
707 props,
708 targetRowId: targetMessage.id,
709 });
710 const targetRowElement = mounted.host.querySelector<HTMLElement>(
711 `[data-timeline-row-id="${targetMessage.id}"]`,
712 );
713 expect(targetRowElement, "Unable to locate target changed-files row.").toBeTruthy();
714
715 const collapseAllButton =
716 Array.from(targetRowElement!.querySelectorAll<HTMLButtonElement>("button")).find(
717 (button) => button.textContent?.trim() === "Collapse all",
718 ) ?? null;
719 expect(collapseAllButton, 'Unable to find "Collapse all" button.').toBeTruthy();
720
721 collapseAllButton!.click();
722
723 await vi.waitFor(
724 async () => {
725 const afterCollapse = await measureTimelineRow({
726 host: mounted.host,
727 props,
728 targetRowId: targetMessage.id,
729 });
730 expect(afterCollapse.actualHeightPx).toBeLessThan(beforeCollapse.actualHeightPx - 24);
731 },
732 { timeout: 8_000, interval: 16 },
733 );
734
735 const afterCollapse = await measureTimelineRow({
736 host: mounted.host,
737 props,
738 targetRowId: targetMessage.id,
739 });
740 expect(
741 Math.abs(afterCollapse.actualHeightPx - afterCollapse.virtualizerSizePx),
742 ).toBeLessThanOrEqual(8);
743 } finally {
744 await mounted.cleanup();
745 }
746 });
747
748 it("keeps the work-log row virtualizer size in sync after show more expands the group", async () => {
749 const beforeMessages = createFillerMessages({
750 prefix: "before-worklog-expand",
751 startOffsetSeconds: 0,
752 pairCount: 2,
753 });
754 const afterMessages = createFillerMessages({
755 prefix: "after-worklog-expand",
756 startOffsetSeconds: 40,
757 pairCount: 8,
758 });
759 const workEntries = Array.from({ length: 10 }, (_, index) =>
760 createToolWorkEntry({
761 id: `target-work-toggle-${index}`,
762 offsetSeconds: 12 + index,
763 detail: `tool output line ${index + 1}`,
764 }),
765 );
766 const props = createBaseTimelineProps({
767 messages: [...beforeMessages, ...afterMessages],
768 workEntries,
769 });
770 const mounted = await mountMessagesTimeline({ props });
771
772 try {
773 const beforeExpand = await measureTimelineRow({
774 host: mounted.host,
775 props,
776 targetRowId: workEntries[0]!.id,
777 });
778 const targetRowElement = mounted.host.querySelector<HTMLElement>(
779 `[data-timeline-row-id="${workEntries[0]!.id}"]`,
780 );
781 expect(targetRowElement, "Unable to locate target work-log row.").toBeTruthy();
782
783 const showMoreButton =
784 Array.from(targetRowElement!.querySelectorAll<HTMLButtonElement>("button")).find((button) =>
785 button.textContent?.includes("Show 4 more"),
786 ) ?? null;
787 expect(showMoreButton, 'Unable to find "Show more" button.').toBeTruthy();
788
789 showMoreButton!.click();
790
791 await vi.waitFor(
792 async () => {
793 const afterExpand = await measureTimelineRow({
794 host: mounted.host,
795 props,
796 targetRowId: workEntries[0]!.id,
797 });
798 expect(afterExpand.actualHeightPx).toBeGreaterThan(beforeExpand.actualHeightPx + 72);
799 },
800 { timeout: 8_000, interval: 16 },
801 );
802
803 const afterExpand = await measureTimelineRow({
804 host: mounted.host,
805 props,
806 targetRowId: workEntries[0]!.id,
807 });
808 expect(
809 Math.abs(afterExpand.actualHeightPx - afterExpand.virtualizerSizePx),
810 ).toBeLessThanOrEqual(8);
811 } finally {
812 await mounted.cleanup();
813 }
814 });
815
816 it("preserves measured tail row heights when rows transition into virtualization", async () => {
817 const beforeMessages = createFillerMessages({
818 prefix: "tail-transition-before",
819 startOffsetSeconds: 0,
820 pairCount: 1,
821 });
822 const afterMessages = createFillerMessages({
823 prefix: "tail-transition-after",
824 startOffsetSeconds: 40,
825 pairCount: 3,
826 });
827 const targetMessage = createMessage({
828 id: "target-tail-transition",
829 role: "assistant",
830 text: "Validation passed on the merged tree.",
831 offsetSeconds: 12,
832 });
833 let latestSnapshot: VirtualizerSnapshot | null = null;
834 const initialProps = createBaseTimelineProps({
835 messages: [...beforeMessages, targetMessage, ...afterMessages],
836 turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [
837 { path: ".plans/effect-atom.md", additions: 89, deletions: 0 },
838 {
839 path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts",
840 additions: 4,
841 deletions: 3,
842 },
843 {
844 path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts",
845 additions: 131,
846 deletions: 128,
847 },
848 {
849 path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts",
850 additions: 1,
851 deletions: 1,
852 },
853 { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 },
854 {
855 path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts",
856 additions: 106,
857 deletions: 112,
858 },
859 { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 },
860 { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 },
861 {
862 path: "apps/web/src/components/chat/MessagesTimeline.tsx",
863 additions: 52,
864 deletions: 7,
865 },
866 {
867 path: "apps/web/src/components/chat/ChangedFilesTree.tsx",
868 additions: 32,
869 deletions: 4,
870 },
871 { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 },
872 { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 },
873 ]),
874 onVirtualizerSnapshot: (snapshot) => {
875 latestSnapshot = {
876 totalSize: snapshot.totalSize,
877 measurements: snapshot.measurements,
878 };
879 },
880 });
881
882 const mounted = await mountMessagesTimeline({ props: initialProps });
883
884 try {
885 const initiallyRenderedHeight = await measureRenderedRowActualHeight({
886 host: mounted.host,
887 targetRowId: targetMessage.id,
888 });
889
890 const appendedProps = createBaseTimelineProps({
891 messages: [
892 ...beforeMessages,
893 targetMessage,
894 ...afterMessages,
895 ...createFillerMessages({
896 prefix: "tail-transition-extra",
897 startOffsetSeconds: 120,
898 pairCount: 8,
899 }),
900 ],
901 turnDiffSummaryByAssistantMessageId: initialProps.turnDiffSummaryByAssistantMessageId,
902 onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot,
903 });
904 await mounted.rerender(appendedProps);
905
906 const scrollContainer = await waitForElement(
907 () =>
908 mounted.host.querySelector<HTMLDivElement>(
909 '[data-testid="messages-timeline-scroll-container"]',
910 ),
911 "Unable to find MessagesTimeline scroll container.",
912 );
913 scrollContainer.scrollTop = scrollContainer.scrollHeight;
914 scrollContainer.dispatchEvent(new Event("scroll"));
915 await waitForLayout();
916
917 await vi.waitFor(
918 () => {
919 const measurement = latestSnapshot?.measurements.find(
920 (entry) => entry.id === targetMessage.id,
921 );
922 expect(
923 measurement,
924 "Expected target row to transition into virtualizer cache.",
925 ).toBeTruthy();
926 expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual(
927 8,
928 );
929 },
930 { timeout: 8_000, interval: 16 },
931 );
932 } finally {
933 await mounted.cleanup();
934 }
935 });
936
937 it("preserves measured tail image row heights when rows transition into virtualization", async () => {
938 const beforeMessages = createFillerMessages({
939 prefix: "tail-image-before",
940 startOffsetSeconds: 0,
941 pairCount: 1,
942 });
943 const afterMessages = createFillerMessages({
944 prefix: "tail-image-after",
945 startOffsetSeconds: 40,
946 pairCount: 3,
947 });
948 const targetMessage = createMessage({
949 id: "target-tail-image-transition",
950 role: "user",
951 text: "Here is a narrow screenshot.",
952 offsetSeconds: 12,
953 attachments: [
954 {
955 type: "image",
956 id: "target-tail-image",
957 name: "narrow.svg",
958 mimeType: "image/svg+xml",
959 sizeBytes: 512,
960 previewUrl:
961 "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='72'%3E%3Crect width='240' height='72' fill='%23dbeafe'/%3E%3C/svg%3E",
962 },
963 ],
964 });
965 let latestSnapshot: VirtualizerSnapshot | null = null;
966 const initialProps = createBaseTimelineProps({
967 messages: [...beforeMessages, targetMessage, ...afterMessages],
968 onVirtualizerSnapshot: (snapshot) => {
969 latestSnapshot = {
970 totalSize: snapshot.totalSize,
971 measurements: snapshot.measurements,
972 };
973 },
974 });
975 const mounted = await mountMessagesTimeline({ props: initialProps });
976
977 try {
978 await vi.waitFor(
979 () => {
980 const image = mounted.host.querySelector<HTMLImageElement>(
981 `[data-timeline-row-id="${targetMessage.id}"] img`,
982 );
983 expect(image?.naturalHeight ?? 0).toBeGreaterThan(0);
984 },
985 { timeout: 8_000, interval: 16 },
986 );
987
988 const initiallyRenderedHeight = await measureRenderedRowActualHeight({
989 host: mounted.host,
990 targetRowId: targetMessage.id,
991 });
992 const appendedProps = createBaseTimelineProps({
993 messages: [
994 ...beforeMessages,
995 targetMessage,
996 ...afterMessages,
997 ...createFillerMessages({
998 prefix: "tail-image-extra",
999 startOffsetSeconds: 120,
1000 pairCount: 8,
1001 }),
1002 ],
1003 onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot,
1004 });
1005 await mounted.rerender(appendedProps);
1006
1007 const scrollContainer = await waitForElement(
1008 () =>
1009 mounted.host.querySelector<HTMLDivElement>(
1010 '[data-testid="messages-timeline-scroll-container"]',
1011 ),
1012 "Unable to find MessagesTimeline scroll container.",
1013 );
1014 scrollContainer.scrollTop = scrollContainer.scrollHeight;
1015 scrollContainer.dispatchEvent(new Event("scroll"));
1016 await waitForLayout();
1017
1018 await vi.waitFor(
1019 () => {
1020 const measurement = latestSnapshot?.measurements.find(
1021 (entry) => entry.id === targetMessage.id,
1022 );
1023 expect(
1024 measurement,
1025 "Expected target image row to transition into virtualizer cache.",
1026 ).toBeTruthy();
1027 expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual(
1028 8,
1029 );
1030 },
1031 { timeout: 8_000, interval: 16 },
1032 );
1033 } finally {
1034 await mounted.cleanup();
1035 }
1036 });
1037});
apps/web/src/components/timelineHeight.test.ts+4-4
......@@ -11,7 +11,7 @@ describe("estimateTimelineMessageHeight", () => {
1111 role: "assistant",
1212 text: "a".repeat(144),
1313 }),
14 ).toBe(122);
14 ).toBe(86.5);
1515 });
1616
1717 it("uses assistant sizing rules for system messages", () => {
......@@ -20,7 +20,7 @@ describe("estimateTimelineMessageHeight", () => {
2020 role: "system",
2121 text: "a".repeat(144),
2222 }),
23 ).toBe(122);
23 ).toBe(86.5);
2424 });
2525
2626 it("adds one attachment row for one or two user attachments", () => {
......@@ -132,7 +132,7 @@ describe("estimateTimelineMessageHeight", () => {
132132 text: "a".repeat(200),
133133 };
134134
135 expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(188);
136 expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(122);
135 expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(154.75);
136 expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(86.5);
137137 });
138138});
apps/web/src/components/timelineHeight.ts+9-5
......@@ -3,8 +3,12 @@ import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContex
33
44const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72;
55const USER_CHARS_PER_LINE_FALLBACK = 56;
6const LINE_HEIGHT_PX = 22;
7const ASSISTANT_BASE_HEIGHT_PX = 78;
6const USER_LINE_HEIGHT_PX = 22;
7const ASSISTANT_LINE_HEIGHT_PX = 22.75;
8// Assistant rows render as markdown content plus a compact timestamp meta line.
9// The DOM baseline is much smaller than the user bubble chrome, so model it
10// separately instead of reusing the old shared constant.
11const ASSISTANT_BASE_HEIGHT_PX = 41;
812const USER_BASE_HEIGHT_PX = 96;
913const ATTACHMENTS_PER_ROW = 2;
1014// Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap.
......@@ -73,7 +77,7 @@ export function estimateTimelineMessageHeight(
7377 if (message.role === "assistant") {
7478 const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx);
7579 const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine);
76 return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX;
80 return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX;
7781 }
7882
7983 if (message.role === "user") {
......@@ -92,12 +96,12 @@ export function estimateTimelineMessageHeight(
9296 const attachmentCount = message.attachments?.length ?? 0;
9397 const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW);
9498 const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX;
95 return USER_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX + attachmentHeight;
99 return USER_BASE_HEIGHT_PX + estimatedLines * USER_LINE_HEIGHT_PX + attachmentHeight;
96100 }
97101
98102 // `system` messages are not rendered in the chat timeline, but keep a stable
99103 // explicit branch in case they are present in timeline data.
100104 const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx);
101105 const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine);
102 return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX;
106 return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX;
103107}
apps/web/src/routes/__root.tsx+9-4
......@@ -21,6 +21,7 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component
2121import { resolveAndPersistPreferredEditor } from "../editorPreferences";
2222import { readNativeApi } from "../nativeApi";
2323import {
24 getServerConfigUpdatedNotification,
2425 type ServerConfigUpdateSource,
2526 useServerConfig,
2627 useServerConfigUpdatedSubscription,
......@@ -206,7 +207,7 @@ function EventRouter() {
206207 const pathname = useLocation({ select: (loc) => loc.pathname });
207208 const pathnameRef = useRef(pathname);
208209 const handledBootstrapThreadIdRef = useRef<string | null>(null);
209 const handledConfigReplayRef = useRef(false);
210 const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0);
210211 const disposedRef = useRef(false);
211212 const bootstrapFromSnapshotRef = useRef<() => Promise<void>>(async () => undefined);
212213 const serverConfig = useServerConfig();
......@@ -243,15 +244,19 @@ function EventRouter() {
243244
244245 const handleServerConfigUpdated = useEffectEvent(
245246 ({
247 id,
246248 payload,
247249 source,
248250 }: {
251 readonly id: number;
249252 readonly payload: import("@t3tools/contracts").ServerConfigUpdatedPayload;
250253 readonly source: ServerConfigUpdateSource;
251254 }) => {
252 const isReplay = !handledConfigReplayRef.current;
253 handledConfigReplayRef.current = true;
254 if (isReplay || source !== "keybindingsUpdated") {
255 if (id <= seenServerConfigUpdateIdRef.current) {
256 return;
257 }
258 seenServerConfigUpdateIdRef.current = id;
259 if (source !== "keybindingsUpdated") {
255260 return;
256261 }
257262
apps/web/src/rpc/serverState.ts+13-1
......@@ -18,6 +18,7 @@ import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry";
1818export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"];
1919
2020export interface ServerConfigUpdatedNotification {
21 readonly id: number;
2122 readonly payload: ServerConfigUpdatedPayload;
2223 readonly source: ServerConfigUpdateSource;
2324}
......@@ -71,6 +72,10 @@ export function getServerConfig(): ServerConfig | null {
7172 return appAtomRegistry.get(serverConfigAtom);
7273}
7374
75export function getServerConfigUpdatedNotification(): ServerConfigUpdatedNotification | null {
76 return appAtomRegistry.get(serverConfigUpdatedAtom);
77}
78
7479export function setServerConfigSnapshot(config: ServerConfig): void {
7580 resolveServerConfig(config);
7681 emitProvidersUpdated({ providers: config.providers });
......@@ -194,8 +199,11 @@ export function startServerStateSync(client: ServerStateClient): () => void {
194199
195200export function resetServerStateForTests() {
196201 resetAppAtomRegistryForTests();
202 nextServerConfigUpdatedNotificationId = 1;
197203}
198204
205let nextServerConfigUpdatedNotificationId = 1;
206
199207function resolveServerConfig(config: ServerConfig): void {
200208 appAtomRegistry.set(serverConfigAtom, config);
201209}
......@@ -208,7 +216,11 @@ function emitServerConfigUpdated(
208216 payload: ServerConfigUpdatedPayload,
209217 source: ServerConfigUpdateSource,
210218): void {
211 appAtomRegistry.set(serverConfigUpdatedAtom, { payload, source });
219 appAtomRegistry.set(serverConfigUpdatedAtom, {
220 id: nextServerConfigUpdatedNotificationId++,
221 payload,
222 source,
223 });
212224}
213225
214226function subscribeLatest<A>(