History

Migrate chat scrolling and branch lists to LegendList (#1953)

Julius Marminge committed 7 months ago

Commit 96c93061 parent 94d13a215 files · +895 −2672Browse files

Unified · Split

15 files
  1. apps/web/package.json +1 −1
  2. apps/web/src/chat-scroll.test.ts +0 −62
  3. apps/web/src/chat-scroll.ts +0 −24
  4. apps/web/src/components/BranchToolbarBranchSelector.tsx +46 −70
  5. apps/web/src/components/ChatView.browser.tsx +103 −280
  6. apps/web/src/components/ChatView.tsx +82 −277
  7. apps/web/src/components/chat/MessagesTimeline.logic.test.ts +168 −0
  8. apps/web/src/components/chat/MessagesTimeline.logic.ts +55 −61
  9. apps/web/src/components/chat/MessagesTimeline.test.tsx +0 −151
  10. apps/web/src/components/chat/MessagesTimeline.tsx +422 −388
  11. apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +0 −1071
  12. apps/web/src/components/timelineHeight.test.ts +0 −151
  13. apps/web/src/components/timelineHeight.ts +0 −131
  14. apps/web/src/components/ui/combobox.tsx +15 −0
  15. bun.lock +3 −5
apps/web/package.json+1−1
@@ -21,6 +21,7 @@ · 20 unmodified lines
2121 "@dnd-kit/utilities": "^3.2.2",
2222 "@effect/atom-react": "catalog:",
2323 "@formkit/auto-animate": "^0.9.0",
24+ "@legendapp/list": "3.0.0-beta.44",
2425 "@lexical/react": "^0.41.0",
2526 "@pierre/diffs": "^1.1.0-beta.16",
2627 "@t3tools/client-runtime": "workspace:*",
@@ -29,7 +30,6 @@ · 2 unmodified lines
2930 "@tanstack/react-pacer": "^0.19.4",
3031 "@tanstack/react-query": "^5.90.0",
3132 "@tanstack/react-router": "^1.160.2",
32− "@tanstack/react-virtual": "^3.13.18",
3333 "@xterm/addon-fit": "^0.11.0",
3434 "@xterm/xterm": "^6.0.0",
3535 "class-variance-authority": "^0.7.1",
apps/web/src/chat-scroll.test.ts+0−62
@@ -1,62 +0,0 @@
1−import { describe, expect, it } from "vitest";
2−
3−import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "./chat-scroll";
4−
5−describe("isScrollContainerNearBottom", () => {
6− it("returns true when already at bottom", () => {
7− expect(
8− isScrollContainerNearBottom({
9− scrollTop: 600,
10− clientHeight: 400,
11− scrollHeight: 1_000,
12− }),
13− ).toBe(true);
14− });
15−
16− it("returns true when within the auto-scroll threshold", () => {
17− expect(
18− isScrollContainerNearBottom({
19− scrollTop: 540,
20− clientHeight: 400,
21− scrollHeight: 1_000,
22− }),
23− ).toBe(true);
24− });
25−
26− it("returns false when the user is meaningfully above the bottom", () => {
27− expect(
28− isScrollContainerNearBottom({
29− scrollTop: 520,
30− clientHeight: 400,
31− scrollHeight: 1_000,
32− }),
33− ).toBe(false);
34− });
35−
36− it("clamps negative thresholds to zero", () => {
37− expect(
38− isScrollContainerNearBottom(
39− {
40− scrollTop: 539,
41− clientHeight: 400,
42− scrollHeight: 1_000,
43− },
44− -1,
45− ),
46− ).toBe(false);
47− });
48−
49− it("falls back to the default threshold for non-finite values", () => {
50− expect(
51− isScrollContainerNearBottom(
52− {
53− scrollTop: 540,
54− clientHeight: 400,
55− scrollHeight: 1_000,
56− },
57− Number.NaN,
58− ),
59− ).toBe(true);
60− expect(AUTO_SCROLL_BOTTOM_THRESHOLD_PX).toBe(64);
61− });
62−});
apps/web/src/chat-scroll.ts+0−24
@@ -1,24 +0,0 @@
1−export const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
2−
3−interface ScrollPosition {
4− scrollTop: number;
5− clientHeight: number;
6− scrollHeight: number;
7−}
8−
9−export function isScrollContainerNearBottom(
10− position: ScrollPosition,
11− thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
12−): boolean {
13− const threshold = Number.isFinite(thresholdPx)
14− ? Math.max(0, thresholdPx)
15− : AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
16−
17− const { scrollTop, clientHeight, scrollHeight } = position;
18− if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) {
19− return true;
20− }
21−
22− const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
23− return distanceFromBottom <= threshold;
24−}
apps/web/src/components/BranchToolbarBranchSelector.tsx+46−70
@@ -1,10 +1,9 @@
11 import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
22 import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts";
33 import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
4−import { useVirtualizer } from "@tanstack/react-virtual";
4+import { LegendList, type LegendListRef } from "@legendapp/list/react";
55 import { ChevronDownIcon } from "lucide-react";
66 import {
7− type CSSProperties,
87 useCallback,
98 useDeferredValue,
109 useEffect,
@@ -38,6 +37,7 @@ import { · 27 unmodified lines
3837 ComboboxInput,
3938 ComboboxItem,
4039 ComboboxList,
40+ ComboboxListVirtualized,
4141 ComboboxPopup,
4242 ComboboxStatus,
4343 ComboboxTrigger,
@@ -390,7 +390,7 @@ export function BranchToolbarBranchSelector({ · 346 unmodified lines
390390 }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]);
391391
392392 // ---------------------------------------------------------------------------
393− // Combobox / virtualizer plumbing
393+ // Combobox / list plumbing
394394 // ---------------------------------------------------------------------------
395395 const handleOpenChange = useCallback(
396396 (open: boolean) => {
@@ -425,49 +425,22 @@ export function BranchToolbarBranchSelector({ · 28 unmodified lines
425425
426426 void fetchNextPage().catch(() => undefined);
427427 }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]);
428− const branchListVirtualizer = useVirtualizer({
429− count: filteredBranchPickerItems.length,
430− estimateSize: (index) =>
431− filteredBranchPickerItems[index] === checkoutPullRequestItemValue ? 44 : 28,
432− getScrollElement: () => branchListScrollElementRef.current,
433− overscan: 12,
434− enabled: isBranchMenuOpen && shouldVirtualizeBranchList,
435− initialRect: {
436− height: 224,
437− width: 0,
438− },
439− });
440− const virtualBranchRows = branchListVirtualizer.getVirtualItems();
441− const setBranchListRef = useCallback(
442− (element: HTMLDivElement | null) => {
443− branchListScrollElementRef.current =
444− (element?.parentElement as HTMLDivElement | null) ?? null;
445− if (element) {
446− branchListVirtualizer.measure();
447− }
448− },
449− [branchListVirtualizer],
450− );
451−
452− useEffect(() => {
453− if (!isBranchMenuOpen || !shouldVirtualizeBranchList) return;
454− queueMicrotask(() => {
455− branchListVirtualizer.measure();
456− });
457− }, [
458− branchListVirtualizer,
459− filteredBranchPickerItems.length,
460− isBranchMenuOpen,
461− shouldVirtualizeBranchList,
462− ]);
428+ const branchListRef = useRef<LegendListRef | null>(null);
429+ const setBranchListRef = useCallback((element: HTMLDivElement | null) => {
430+ branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null;
431+ }, []);
463432
464433 useEffect(() => {
465434 if (!isBranchMenuOpen) {
466435 return;
467436 }
468437
469− branchListScrollElementRef.current?.scrollTo({ top: 0 });
470− }, [deferredTrimmedBranchQuery, isBranchMenuOpen]);
438+ if (shouldVirtualizeBranchList) {
439+ branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false });
440+ } else {
441+ branchListScrollElementRef.current?.scrollTo({ top: 0 });
442+ }
443+ }, [deferredTrimmedBranchQuery, isBranchMenuOpen, shouldVirtualizeBranchList]);
471444
472445 useEffect(() => {
473446 const scrollElement = branchListScrollElementRef.current;
@@ -487,8 +460,9 @@ export function BranchToolbarBranchSelector({ · 13 unmodified lines
487460 }, [isBranchMenuOpen, maybeFetchNextBranchPage]);
488461
489462 useEffect(() => {
463+ if (shouldVirtualizeBranchList) return;
490464 maybeFetchNextBranchPage();
491− }, [branches.length, maybeFetchNextBranchPage]);
465+ }, [branches.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]);
492466
493467 const triggerLabel = getBranchTriggerLabel({
494468 activeWorktreePath,
@@ -496,7 +470,7 @@ export function BranchToolbarBranchSelector({ · 1 unmodified line
496470 resolvedActiveBranch,
497471 });
498472
499− function renderPickerItem(itemValue: string, index: number, style?: CSSProperties) {
473+ function renderPickerItem(itemValue: string, index: number) {
500474 if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) {
501475 return (
502476 <ComboboxItem
@@ -504,7 +478,6 @@ export function BranchToolbarBranchSelector({ · 1 unmodified line
504478 key={itemValue}
505479 index={index}
506480 value={itemValue}
507− style={style}
508481 onClick={() => {
509482 if (!prReference || !onCheckoutPullRequestRequest) {
510483 return;
@@ -529,7 +502,6 @@ export function BranchToolbarBranchSelector({ · 18 unmodified lines
529502 key={itemValue}
530503 index={index}
531504 value={itemValue}
532− style={style}
533505 onClick={() => createBranch(trimmedBranchQuery)}
534506 >
535507 <span className="truncate">Create new branch &quot;{trimmedBranchQuery}&quot;</span>
@@ -557,7 +529,6 @@ export function BranchToolbarBranchSelector({ · 21 unmodified lines
557529 key={itemValue}
558530 index={index}
559531 value={itemValue}
560− style={style}
561532 onClick={() => selectBranch(branch)}
562533 >
563534 <div className="flex w-full items-center justify-between gap-2">
@@ -575,8 +546,13 @@ export function BranchToolbarBranchSelector({ · 11 unmodified lines
575546 autoHighlight
576547 virtualized={shouldVirtualizeBranchList}
577548 onItemHighlighted={(_value, eventDetails) => {
578− if (!isBranchMenuOpen || eventDetails.index < 0) return;
579− branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" });
549+ if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") {
550+ return;
551+ }
552+ branchListRef.current?.scrollIndexIntoView?.({
553+ index: eventDetails.index,
554+ animated: false,
555+ });
580556 }}
581557 onOpenChange={handleOpenChange}
582558 open={isBranchMenuOpen}
@@ -604,30 +580,30 @@ export function BranchToolbarBranchSelector({ · 21 unmodified lines
604580 </div>
605581 <ComboboxEmpty>No branches found.</ComboboxEmpty>
606582
607− <ComboboxList ref={setBranchListRef} className="max-h-56">
608− {shouldVirtualizeBranchList ? (
609− <div
610− className="relative"
611− style={{
612− height: `${branchListVirtualizer.getTotalSize()}px`,
583+ {shouldVirtualizeBranchList ? (
584+ <ComboboxListVirtualized>
585+ <LegendList<string>
586+ ref={branchListRef}
587+ data={filteredBranchPickerItems}
588+ keyExtractor={(item) => item}
589+ renderItem={({ item, index }) => renderPickerItem(item, index)}
590+ estimatedItemSize={28}
591+ drawDistance={336}
592+ onEndReached={() => {
593+ if (hasNextPage && !isFetchingNextPage) {
594+ void fetchNextPage().catch(() => undefined);
595+ }
613596 }}
614− >
615− {virtualBranchRows.map((virtualRow) => {
616− const itemValue = filteredBranchPickerItems[virtualRow.index];
617− if (!itemValue) return null;
618− return renderPickerItem(itemValue, virtualRow.index, {
619− position: "absolute",
620− top: 0,
621− left: 0,
622− width: "100%",
623− transform: `translateY(${virtualRow.start}px)`,
624− });
625− })}
626− </div>
627− ) : (
628− filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index))
629− )}
630− </ComboboxList>
597+ style={{ maxHeight: "14rem" }}
598+ />
599+ </ComboboxListVirtualized>
600+ ) : (
601+ <ComboboxList ref={setBranchListRef} className="max-h-56">
602+ {filteredBranchPickerItems.map((itemValue, index) =>
603+ renderPickerItem(itemValue, index),
604+ )}
605+ </ComboboxList>
606+ )}
631607 {branchStatusText ? <ComboboxStatus>{branchStatusText}</ComboboxStatus> : null}
632608 </ComboboxPopup>
633609 </Combobox>
apps/web/src/components/ChatView.browser.tsx+103−280
@@ -47,7 +47,7 @@ import { useTerminalStateStore } from "../terminalStateStore"; · 46 unmodified lines
4747 import { useUiStateStore } from "../uiStateStore";
4848 import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers";
4949 import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";
50−import { estimateTimelineMessageHeight } from "./timelineHeight";
50+
5151 import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
5252
5353 vi.mock("../lib/gitStatusState", () => ({
@@ -112,28 +112,9 @@ const COMPACT_FOOTER_VIEWPORT: ViewportSpec = { · 58 unmodified lines
112112 textTolerancePx: 56,
113113 attachmentTolerancePx: 56,
114114 };
115−const TEXT_VIEWPORT_MATRIX = [
116− DEFAULT_VIEWPORT,
117− { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 },
118− { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 },
119− { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 },
120−] as const satisfies readonly ViewportSpec[];
121−const ATTACHMENT_VIEWPORT_MATRIX = [
122− { ...DEFAULT_VIEWPORT, attachmentTolerancePx: 120 },
123− { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 120 },
124− { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 120 },
125−] as const satisfies readonly ViewportSpec[];
126−
127−interface UserRowMeasurement {
128− measuredRowHeightPx: number;
129− timelineWidthMeasuredPx: number;
130− renderedInVirtualizedRegion: boolean;
131−}
132−
133115 interface MountedChatView {
134116 [Symbol.asyncDispose]: () => Promise<void>;
135117 cleanup: () => Promise<void>;
136− measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>;
137118 setViewport: (viewport: ViewportSpec) => Promise<void>;
138119 setContainerSize: (viewport: Pick<ViewportSpec, "width" | "height">) => Promise<void>;
139120 router: ReturnType<typeof getRouter>;
@@ -1378,91 +1359,6 @@ async function waitForCommandPaletteShortcutLabel(): Promise<void> { · 1238 unmodified lines
13781359 );
13791360 }
13801361
1381−async function waitForImagesToLoad(scope: ParentNode): Promise<void> {
1382− const images = Array.from(scope.querySelectorAll("img"));
1383− if (images.length === 0) {
1384− return;
1385− }
1386− await Promise.all(
1387− images.map(
1388− (image) =>
1389− new Promise<void>((resolve) => {
1390− if (image.complete) {
1391− resolve();
1392− return;
1393− }
1394− image.addEventListener("load", () => resolve(), { once: true });
1395− image.addEventListener("error", () => resolve(), { once: true });
1396− }),
1397− ),
1398− );
1399− await waitForLayout();
1400−}
1401−
1402−async function measureUserRow(options: {
1403− host: HTMLElement;
1404− targetMessageId: MessageId;
1405−}): Promise<UserRowMeasurement> {
1406− const { host, targetMessageId } = options;
1407− const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="user"]`;
1408−
1409− const scrollContainer = await waitForElement(
1410− () => host.querySelector<HTMLDivElement>("div.overflow-y-auto.overscroll-y-contain"),
1411− "Unable to find ChatView message scroll container.",
1412− );
1413−
1414− let row: HTMLElement | null = null;
1415− await vi.waitFor(
1416− async () => {
1417− scrollContainer.scrollTop = 0;
1418− scrollContainer.dispatchEvent(new Event("scroll"));
1419− await waitForLayout();
1420− row = host.querySelector<HTMLElement>(rowSelector);
1421− expect(row, "Unable to locate targeted user message row.").toBeTruthy();
1422− },
1423− {
1424− timeout: 8_000,
1425− interval: 16,
1426− },
1427− );
1428−
1429− await waitForImagesToLoad(row!);
1430− scrollContainer.scrollTop = 0;
1431− scrollContainer.dispatchEvent(new Event("scroll"));
1432− await nextFrame();
1433−
1434− const timelineRoot =
1435− row!.closest<HTMLElement>('[data-timeline-root="true"]') ??
1436− host.querySelector<HTMLElement>('[data-timeline-root="true"]');
1437− if (!(timelineRoot instanceof HTMLElement)) {
1438− throw new Error("Unable to locate timeline root container.");
1439− }
1440−
1441− let timelineWidthMeasuredPx = 0;
1442− let measuredRowHeightPx = 0;
1443− let renderedInVirtualizedRegion = false;
1444− await vi.waitFor(
1445− async () => {
1446− scrollContainer.scrollTop = 0;
1447− scrollContainer.dispatchEvent(new Event("scroll"));
1448− await nextFrame();
1449− const measuredRow = host.querySelector<HTMLElement>(rowSelector);
1450− expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy();
1451− timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width;
1452− measuredRowHeightPx = measuredRow!.getBoundingClientRect().height;
1453− renderedInVirtualizedRegion = measuredRow!.closest("[data-index]") instanceof HTMLElement;
1454− expect(timelineWidthMeasuredPx, "Unable to measure timeline width.").toBeGreaterThan(0);
1455− expect(measuredRowHeightPx, "Unable to measure targeted user row height.").toBeGreaterThan(0);
1456− },
1457− {
1458− timeout: 4_000,
1459− interval: 16,
1460− },
1461− );
1462−
1463− return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion };
1464−}
1465−
14661362 async function mountChatView(options: {
14671363 viewport: ViewportSpec;
14681364 snapshot: OrchestrationReadModel;
@@ -1515,7 +1411,6 @@ async function mountChatView(options: { · 46 unmodified lines
15151411 return {
15161412 [Symbol.asyncDispose]: cleanup,
15171413 cleanup,
1518− measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }),
15191414 setViewport: async (viewport: ViewportSpec) => {
15201415 await setViewport(viewport);
15211416 await waitForProductionStyles();
@@ -1529,23 +1424,6 @@ async function mountChatView(options: { · 7 unmodified lines
15291424 };
15301425 }
15311426
1532−async function measureUserRowAtViewport(options: {
1533− snapshot: OrchestrationReadModel;
1534− targetMessageId: MessageId;
1535− viewport: ViewportSpec;
1536−}): Promise<UserRowMeasurement> {
1537− const mounted = await mountChatView({
1538− viewport: options.viewport,
1539− snapshot: options.snapshot,
1540− });
1541−
1542− try {
1543− return await mounted.measureUserRow(options.targetMessageId);
1544− } finally {
1545− await mounted.cleanup();
1546− }
1547−}
1548−
15491427 describe("ChatView timeline estimator parity (full app)", () => {
15501428 beforeAll(async () => {
15511429 fixture = buildFixture(
@@ -1633,39 +1511,6 @@ describe("ChatView timeline estimator parity (full app)", () => { · 81 unmodified lines
16331511 document.body.innerHTML = "";
16341512 });
16351513
1636− it.each(TEXT_VIEWPORT_MATRIX)(
1637− "keeps long user message estimate close at the $name viewport",
1638− async (viewport) => {
1639− const userText = "x".repeat(3_200);
1640− const targetMessageId = `msg-user-target-long-${viewport.name}` as MessageId;
1641− const mounted = await mountChatView({
1642− viewport,
1643− snapshot: createSnapshotForTargetUser({
1644− targetMessageId,
1645− targetText: userText,
1646− }),
1647− });
1648−
1649− try {
1650− const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } =
1651− await mounted.measureUserRow(targetMessageId);
1652−
1653− expect(renderedInVirtualizedRegion).toBe(true);
1654−
1655− const estimatedHeightPx = estimateTimelineMessageHeight(
1656− { role: "user", text: userText, attachments: [] },
1657− { timelineWidthPx: timelineWidthMeasuredPx },
1658− );
1659−
1660− expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual(
1661− viewport.textTolerancePx,
1662− );
1663− } finally {
1664− await mounted.cleanup();
1665− }
1666− },
1667− );
1668−
16691514 it("re-expands the bootstrap project using its scoped key", async () => {
16701515 useUiStateStore.setState({
16711516 projectExpandedById: {
@@ -1695,130 +1540,6 @@ describe("ChatView timeline estimator parity (full app)", () => { · 23 unmodified lines
16951540 }
16961541 });
16971542
1698− it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => {
1699− const userText = "x".repeat(3_200);
1700− const targetMessageId = "msg-user-target-resize" as MessageId;
1701− const mounted = await mountChatView({
1702− viewport: TEXT_VIEWPORT_MATRIX[0],
1703− snapshot: createSnapshotForTargetUser({
1704− targetMessageId,
1705− targetText: userText,
1706− }),
1707− });
1708−
1709− try {
1710− const measurements: Array<
1711− UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number }
1712− > = [];
1713−
1714− for (const viewport of TEXT_VIEWPORT_MATRIX) {
1715− await mounted.setViewport(viewport);
1716− const measurement = await mounted.measureUserRow(targetMessageId);
1717− const estimatedHeightPx = estimateTimelineMessageHeight(
1718− { role: "user", text: userText, attachments: [] },
1719− { timelineWidthPx: measurement.timelineWidthMeasuredPx },
1720− );
1721−
1722− expect(measurement.renderedInVirtualizedRegion).toBe(true);
1723− expect(Math.abs(measurement.measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual(
1724− viewport.textTolerancePx,
1725− );
1726− measurements.push({ ...measurement, viewport, estimatedHeightPx });
1727− }
1728−
1729− expect(
1730− new Set(measurements.map((measurement) => Math.round(measurement.timelineWidthMeasuredPx)))
1731− .size,
1732− ).toBeGreaterThanOrEqual(3);
1733−
1734− const byMeasuredWidth = measurements.toSorted(
1735− (left, right) => left.timelineWidthMeasuredPx - right.timelineWidthMeasuredPx,
1736− );
1737− const narrowest = byMeasuredWidth[0]!;
1738− const widest = byMeasuredWidth.at(-1)!;
1739− expect(narrowest.timelineWidthMeasuredPx).toBeLessThan(widest.timelineWidthMeasuredPx);
1740− expect(narrowest.measuredRowHeightPx).toBeGreaterThan(widest.measuredRowHeightPx);
1741− expect(narrowest.estimatedHeightPx).toBeGreaterThan(widest.estimatedHeightPx);
1742− } finally {
1743− await mounted.cleanup();
1744− }
1745− });
1746−
1747− it("tracks additional rendered wrapping when ChatView width narrows between desktop and mobile viewports", async () => {
1748− const userText = "x".repeat(2_400);
1749− const targetMessageId = "msg-user-target-wrap" as MessageId;
1750− const snapshot = createSnapshotForTargetUser({
1751− targetMessageId,
1752− targetText: userText,
1753− });
1754− const desktopMeasurement = await measureUserRowAtViewport({
1755− viewport: TEXT_VIEWPORT_MATRIX[0],
1756− snapshot,
1757− targetMessageId,
1758− });
1759− const mobileMeasurement = await measureUserRowAtViewport({
1760− viewport: TEXT_VIEWPORT_MATRIX[2],
1761− snapshot,
1762− targetMessageId,
1763− });
1764−
1765− const estimatedDesktopPx = estimateTimelineMessageHeight(
1766− { role: "user", text: userText, attachments: [] },
1767− { timelineWidthPx: desktopMeasurement.timelineWidthMeasuredPx },
1768− );
1769− const estimatedMobilePx = estimateTimelineMessageHeight(
1770− { role: "user", text: userText, attachments: [] },
1771− { timelineWidthPx: mobileMeasurement.timelineWidthMeasuredPx },
1772− );
1773−
1774− const measuredDeltaPx =
1775− mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx;
1776− const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx;
1777− expect(measuredDeltaPx).toBeGreaterThan(0);
1778− expect(estimatedDeltaPx).toBeGreaterThan(0);
1779− const ratio = estimatedDeltaPx / measuredDeltaPx;
1780− expect(ratio).toBeGreaterThan(0.65);
1781− expect(ratio).toBeLessThan(1.35);
1782− });
1783−
1784− it.each(ATTACHMENT_VIEWPORT_MATRIX)(
1785− "keeps user attachment estimate close at the $name viewport",
1786− async (viewport) => {
1787− const targetMessageId = `msg-user-target-attachments-${viewport.name}` as MessageId;
1788− const userText = "message with image attachments";
1789− const mounted = await mountChatView({
1790− viewport,
1791− snapshot: createSnapshotForTargetUser({
1792− targetMessageId,
1793− targetText: userText,
1794− targetAttachmentCount: 2,
1795− }),
1796− });
1797−
1798− try {
1799− const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } =
1800− await mounted.measureUserRow(targetMessageId);
1801−
1802− expect(renderedInVirtualizedRegion).toBe(true);
1803−
1804− const estimatedHeightPx = estimateTimelineMessageHeight(
1805− {
1806− role: "user",
1807− text: userText,
1808− attachments: [{ id: "attachment-1" }, { id: "attachment-2" }],
1809− },
1810− { timelineWidthPx: timelineWidthMeasuredPx },
1811− );
1812−
1813− expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual(
1814− viewport.attachmentTolerancePx,
1815− );
1816− } finally {
1817− await mounted.cleanup();
1818− }
1819− },
1820− );
1821−
18221543 it("shows an explicit empty state for projects without threads in the sidebar", async () => {
18231544 const mounted = await mountChatView({
18241545 viewport: DEFAULT_VIEWPORT,
@@ -2771,6 +2492,108 @@ describe("ChatView timeline estimator parity (full app)", () => { · 946 unmodified lines
27712492 }
27722493 });
27732494
2495+ it("keeps the new worktree branch picker anchored at the top when opening with a preselected branch", async () => {
2496+ const draftId = DraftId.make("draft-branch-picker-scroll-regression");
2497+ const branches = [
2498+ {
2499+ name: "feature/current",
2500+ current: true,
2501+ isDefault: false,
2502+ worktreePath: null,
2503+ },
2504+ {
2505+ name: "main",
2506+ current: false,
2507+ isDefault: true,
2508+ worktreePath: null,
2509+ },
2510+ ...Array.from({ length: 48 }, (_, index) => ({
2511+ name: `feature/${String(index).padStart(2, "0")}`,
2512+ current: false,
2513+ isDefault: false,
2514+ worktreePath: null,
2515+ })),
2516+ {
2517+ name: "feature/selected",
2518+ current: false,
2519+ isDefault: false,
2520+ worktreePath: null,
2521+ },
2522+ ];
2523+
2524+ useComposerDraftStore.setState({
2525+ draftThreadsByThreadKey: {
2526+ [draftId]: {
2527+ threadId: THREAD_ID,
2528+ environmentId: LOCAL_ENVIRONMENT_ID,
2529+ projectId: PROJECT_ID,
2530+ logicalProjectKey: PROJECT_DRAFT_KEY,
2531+ createdAt: NOW_ISO,
2532+ runtimeMode: "full-access",
2533+ interactionMode: "default",
2534+ branch: "feature/selected",
2535+ worktreePath: null,
2536+ envMode: "worktree",
2537+ },
2538+ },
2539+ logicalProjectDraftThreadKeyByLogicalProjectKey: {
2540+ [PROJECT_DRAFT_KEY]: draftId,
2541+ },
2542+ });
2543+
2544+ const mounted = await mountChatView({
2545+ viewport: DEFAULT_VIEWPORT,
2546+ snapshot: createDraftOnlySnapshot(),
2547+ initialPath: `/draft/${draftId}`,
2548+ resolveRpc: (body) => {
2549+ if (body._tag === WS_METHODS.gitListBranches) {
2550+ return {
2551+ isRepo: true,
2552+ hasOriginRemote: true,
2553+ nextCursor: null,
2554+ totalCount: branches.length,
2555+ branches,
2556+ };
2557+ }
2558+ return undefined;
2559+ },
2560+ });
2561+
2562+ try {
2563+ const branchButton = await waitForElement(
2564+ () =>
2565+ Array.from(document.querySelectorAll("button")).find(
2566+ (button) => button.textContent?.trim() === "From feature/selected",
2567+ ) as HTMLButtonElement | null,
2568+ 'Unable to find branch selector button with "From feature/selected".',
2569+ );
2570+ branchButton.click();
2571+
2572+ await waitForElement(
2573+ () => document.querySelector<HTMLInputElement>('input[placeholder="Search branches..."]'),
2574+ "Unable to find branch search input.",
2575+ );
2576+
2577+ const popup = await waitForElement(
2578+ () => document.querySelector<HTMLElement>('[data-slot="combobox-popup"]'),
2579+ "Unable to find the branch picker popup.",
2580+ );
2581+
2582+ await vi.waitFor(
2583+ () => {
2584+ const popupSpans = Array.from(popup.querySelectorAll("span"));
2585+ expect(
2586+ popupSpans.some((element) => element.textContent?.trim() === "feature/current"),
2587+ ).toBe(true);
2588+ expect(popupSpans.some((element) => element.textContent?.trim() === "main")).toBe(true);
2589+ },
2590+ { timeout: 8_000, interval: 16 },
2591+ );
2592+ } finally {
2593+ await mounted.cleanup();
2594+ }
2595+ });
2596+
27742597 it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => {
27752598 const mounted = await mountChatView({
27762599 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/ChatView.tsx+82−277
@@ -28,7 +28,8 @@ import { · 27 unmodified lines
2828 import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model";
2929 import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
3030 import { truncate } from "@t3tools/shared/String";
31−import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
31+import { Debouncer } from "@tanstack/react-pacer";
32+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
3233 import { useNavigate, useSearch } from "@tanstack/react-router";
3334 import { useShallow } from "zustand/react/shallow";
3435 import { useGitStatus } from "~/lib/gitStatusState";
@@ -57,7 +58,7 @@ import { · 22 unmodified lines
5758 isLatestTurnSettled,
5859 formatElapsed,
5960 } from "../session-logic";
60−import { isScrollContainerNearBottom } from "../chat-scroll";
61+import { type LegendListRef } from "@legendapp/list/react";
6162 import {
6263 buildPendingUserInputAnswers,
6364 derivePendingUserInputProgress,
@@ -174,7 +175,6 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = · 110 unmodified lines
174175 const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
175176 const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
176177 const EMPTY_PROVIDERS: ServerProvider[] = [];
177−const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID: Record<string, boolean> = {};
178178 const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
179179
180180 type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
@@ -590,18 +590,9 @@ export default function ChatView(props: ChatViewProps) { · 409 unmodified lines
590590 );
591591 const setStoreThreadError = useStore((store) => store.setError);
592592 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
593− const setThreadChangedFilesExpanded = useUiStateStore(
594− (store) => store.setThreadChangedFilesExpanded,
595− );
596593 const activeThreadLastVisitedAt = useUiStateStore((store) =>
597594 routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined,
598595 );
599− const changedFilesExpandedByTurnId = useUiStateStore((store) =>
600− routeKind === "server"
601− ? (store.threadChangedFilesExpandedById[routeThreadKey] ??
602− EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID)
603− : EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID,
604− );
605596 const settings = useSettings();
606597 const setStickyComposerModelSelection = useComposerDraftStore(
607598 (store) => store.setStickyModelSelection,
@@ -673,14 +664,12 @@ export default function ChatView(props: ChatViewProps) { · 65 unmodified lines
673664 >({});
674665 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
675666 useState<Record<string, number>>({});
676− const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
677667 const [planSidebarOpen, setPlanSidebarOpen] = useState(false);
678668 // Tracks whether the user explicitly dismissed the sidebar for the active turn.
679669 const planSidebarDismissedForTurnRef = useRef<string | null>(null);
680670 // When set, the thread-change reset effect will open the sidebar instead of closing it.
681671 // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation.
682672 const planSidebarOpenOnNextThreadRef = useRef(false);
683− const [nowTick, setNowTick] = useState(() => Date.now());
684673 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
685674 const [pullRequestDialogState, setPullRequestDialogState] =
686675 useState<PullRequestDialogState | null>(null);
@@ -695,27 +684,12 @@ export default function ChatView(props: ChatViewProps) { · 8 unmodified lines
695684 {},
696685 LastInvokedScriptByProjectSchema,
697686 );
698− const messagesScrollRef = useRef<HTMLDivElement>(null);
699− const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
700− const shouldAutoScrollRef = useRef(true);
701− const lastKnownScrollTopRef = useRef(0);
702− const isPointerScrollActiveRef = useRef(false);
703− const lastTouchClientYRef = useRef<number | null>(null);
704− const pendingUserScrollUpIntentRef = useRef(false);
705− const pendingAutoScrollFrameRef = useRef<number | null>(null);
706− const pendingInteractionAnchorRef = useRef<{
707− element: HTMLElement;
708− top: number;
709− } | null>(null);
710− const pendingInteractionAnchorFrameRef = useRef<number | null>(null);
687+ const legendListRef = useRef<LegendListRef | null>(null);
688+ const isAtEndRef = useRef(true);
711689 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
712690 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
713691 const sendInFlightRef = useRef(false);
714692 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
715− const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => {
716− messagesScrollRef.current = element;
717− setMessagesScrollElement(element);
718− }, []);
719693
720694 const terminalState = useTerminalStateStore((state) =>
721695 selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef),
@@ -995,16 +969,6 @@ export default function ChatView(props: ChatViewProps) { · 273 unmodified lines
995969 [openOrReuseProjectDraftThread],
996970 );
997971
998− const handleSetChangedFilesExpanded = useCallback(
999− (turnId: TurnId, expanded: boolean) => {
1000− if (routeKind !== "server") {
1001− return;
1002− }
1003− setThreadChangedFilesExpanded(routeThreadKey, turnId, expanded);
1004− },
1005− [routeKind, routeThreadKey, setThreadChangedFilesExpanded],
1006− );
1007−
1008972 useEffect(() => {
1009973 if (!serverThread?.id) return;
1010974 if (!latestTurnSettled) return;
@@ -1144,7 +1108,6 @@ export default function ChatView(props: ChatViewProps) { · 133 unmodified lines
11441108 threadError: activeThread?.error,
11451109 });
11461110 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
1147− const nowIso = new Date(nowTick).toISOString();
11481111 const activeWorkStartedAt = deriveActiveWorkStartedAt(
11491112 activeLatestTurn,
11501113 activeThread?.session ?? null,
@@ -1970,169 +1933,33 @@ export default function ChatView(props: ChatViewProps) { · 819 unmodified lines
19701933 [environmentId, serverThread],
19711934 );
19721935
1973− // Auto-scroll on new messages
1974− const messageCount = timelineMessages.length;
1975− const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
1976− const scrollContainer = messagesScrollRef.current;
1977− if (!scrollContainer) return;
1978− scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
1979− lastKnownScrollTopRef.current = scrollContainer.scrollTop;
1980− shouldAutoScrollRef.current = true;
1981− }, []);
1982− const cancelPendingStickToBottom = useCallback(() => {
1983− const pendingFrame = pendingAutoScrollFrameRef.current;
1984− if (pendingFrame === null) return;
1985− pendingAutoScrollFrameRef.current = null;
1986− window.cancelAnimationFrame(pendingFrame);
1987− }, []);
1988− const cancelPendingInteractionAnchorAdjustment = useCallback(() => {
1989− const pendingFrame = pendingInteractionAnchorFrameRef.current;
1990− if (pendingFrame === null) return;
1991− pendingInteractionAnchorFrameRef.current = null;
1992− window.cancelAnimationFrame(pendingFrame);
1936+ // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd.
1937+ const scrollToEnd = useCallback((animated = false) => {
1938+ legendListRef.current?.scrollToEnd?.({ animated });
19931939 }, []);
1994− const scheduleStickToBottom = useCallback(() => {
1995− if (pendingAutoScrollFrameRef.current !== null) return;
1996− pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
1997− pendingAutoScrollFrameRef.current = null;
1998− scrollMessagesToBottom();
1999− });
2000− }, [scrollMessagesToBottom]);
2001− const onMessagesClickCapture = useCallback(
2002− (event: React.MouseEvent<HTMLDivElement>) => {
2003− const scrollContainer = messagesScrollRef.current;
2004− if (!scrollContainer || !(event.target instanceof Element)) return;
2005−
2006− const trigger = event.target.closest<HTMLElement>(
2007− "button, summary, [role='button'], [data-scroll-anchor-target]",
2008− );
2009− if (!trigger || !scrollContainer.contains(trigger)) return;
2010− if (trigger.closest("[data-scroll-anchor-ignore]")) return;
2011−
2012− pendingInteractionAnchorRef.current = {
2013− element: trigger,
2014− top: trigger.getBoundingClientRect().top,
2015− };
20161940
2017− cancelPendingInteractionAnchorAdjustment();
2018− pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame(() => {
2019− pendingInteractionAnchorFrameRef.current = null;
2020− const anchor = pendingInteractionAnchorRef.current;
2021− pendingInteractionAnchorRef.current = null;
2022− const activeScrollContainer = messagesScrollRef.current;
2023− if (!anchor || !activeScrollContainer) return;
2024− if (!anchor.element.isConnected || !activeScrollContainer.contains(anchor.element)) return;
2025−
2026− const nextTop = anchor.element.getBoundingClientRect().top;
2027− const delta = nextTop - anchor.top;
2028− if (Math.abs(delta) < 0.5) return;
2029−
2030− activeScrollContainer.scrollTop += delta;
2031− lastKnownScrollTopRef.current = activeScrollContainer.scrollTop;
2032− });
2033− },
2034− [cancelPendingInteractionAnchorAdjustment],
2035− );
2036− const forceStickToBottom = useCallback(() => {
2037− cancelPendingStickToBottom();
2038− scrollMessagesToBottom();
2039− scheduleStickToBottom();
2040− }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]);
2041− const onMessagesScroll = useCallback(() => {
2042− const scrollContainer = messagesScrollRef.current;
2043− if (!scrollContainer) return;
2044− const currentScrollTop = scrollContainer.scrollTop;
2045− const isNearBottom = isScrollContainerNearBottom(scrollContainer);
2046−
2047− if (!shouldAutoScrollRef.current && isNearBottom) {
2048− shouldAutoScrollRef.current = true;
2049− pendingUserScrollUpIntentRef.current = false;
2050− } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) {
2051− const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
2052− if (scrolledUp && !isNearBottom) {
2053− shouldAutoScrollRef.current = false;
2054− }
2055− pendingUserScrollUpIntentRef.current = false;
2056− } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) {
2057− const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
2058− if (scrolledUp && !isNearBottom) {
2059− shouldAutoScrollRef.current = false;
2060− }
2061− } else if (shouldAutoScrollRef.current && !isNearBottom) {
2062− // Catch-all for keyboard/assistive scroll interactions.
2063− const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
2064− if (scrolledUp) {
2065− shouldAutoScrollRef.current = false;
2066− }
2067− }
2068−
2069− setShowScrollToBottom(!shouldAutoScrollRef.current);
2070− lastKnownScrollTopRef.current = currentScrollTop;
2071− }, []);
2072− const onMessagesWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
2073− if (event.deltaY < 0) {
2074− pendingUserScrollUpIntentRef.current = true;
2075− }
2076− }, []);
2077− const onMessagesPointerDown = useCallback((_event: React.PointerEvent<HTMLDivElement>) => {
2078− isPointerScrollActiveRef.current = true;
2079− }, []);
2080− const onMessagesPointerUp = useCallback((_event: React.PointerEvent<HTMLDivElement>) => {
2081− isPointerScrollActiveRef.current = false;
2082− }, []);
2083− const onMessagesPointerCancel = useCallback((_event: React.PointerEvent<HTMLDivElement>) => {
2084− isPointerScrollActiveRef.current = false;
2085− }, []);
2086− const onMessagesTouchStart = useCallback((event: React.TouchEvent<HTMLDivElement>) => {
2087− const touch = event.touches[0];
2088− if (!touch) return;
2089− lastTouchClientYRef.current = touch.clientY;
2090− }, []);
2091− const onMessagesTouchMove = useCallback((event: React.TouchEvent<HTMLDivElement>) => {
2092− const touch = event.touches[0];
2093− if (!touch) return;
2094− const previousTouchY = lastTouchClientYRef.current;
2095− if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
2096− pendingUserScrollUpIntentRef.current = true;
1941+ // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during
1942+ // thread switches. LegendList fires scroll events with isAtEnd=false while
1943+ // initialScrollAtEnd is settling; hiding is always immediate.
1944+ const showScrollDebouncer = useRef(
1945+ new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }),
1946+ );
1947+ const onIsAtEndChange = useCallback((isAtEnd: boolean) => {
1948+ if (isAtEndRef.current === isAtEnd) return;
1949+ isAtEndRef.current = isAtEnd;
1950+ if (isAtEnd) {
1951+ showScrollDebouncer.current.cancel();
1952+ setShowScrollToBottom(false);
1953+ } else {
1954+ showScrollDebouncer.current.maybeExecute();
20971955 }
2098− lastTouchClientYRef.current = touch.clientY;
20991956 }, []);
2100− const onMessagesTouchEnd = useCallback((_event: React.TouchEvent<HTMLDivElement>) => {
2101− lastTouchClientYRef.current = null;
2102− }, []);
2103− useEffect(() => {
2104− return () => {
2105− cancelPendingStickToBottom();
2106− cancelPendingInteractionAnchorAdjustment();
2107− };
2108− }, [cancelPendingInteractionAnchorAdjustment, cancelPendingStickToBottom]);
2109− useLayoutEffect(() => {
2110− if (!activeThread?.id) return;
2111− shouldAutoScrollRef.current = true;
2112− scheduleStickToBottom();
2113− const timeout = window.setTimeout(() => {
2114− const scrollContainer = messagesScrollRef.current;
2115− if (!scrollContainer) return;
2116− if (isScrollContainerNearBottom(scrollContainer)) return;
2117− scheduleStickToBottom();
2118− }, 96);
2119− return () => {
2120− window.clearTimeout(timeout);
2121− };
2122− }, [activeThread?.id, scheduleStickToBottom]);
2123− useEffect(() => {
2124− if (!shouldAutoScrollRef.current) return;
2125− scheduleStickToBottom();
2126− }, [messageCount, scheduleStickToBottom]);
2127− useEffect(() => {
2128− if (phase !== "running") return;
2129− if (!shouldAutoScrollRef.current) return;
2130− scheduleStickToBottom();
2131− }, [phase, scheduleStickToBottom, timelineEntries]);
21321957
21331958 useEffect(() => {
2134− setExpandedWorkGroups({});
21351959 setPullRequestDialogState(null);
1960+ isAtEndRef.current = true;
1961+ showScrollDebouncer.current.cancel();
1962+ setShowScrollToBottom(false);
21361963 if (planSidebarOpenOnNextThreadRef.current) {
21371964 planSidebarOpenOnNextThreadRef.current = false;
21381965 setPlanSidebarOpen(true);
@@ -2290,16 +2117,6 @@ export default function ChatView(props: ChatViewProps) { · 151 unmodified lines
22902117 terminalState.terminalOpen,
22912118 ]);
22922119
2293− useEffect(() => {
2294− if (phase !== "running") return;
2295− const timer = window.setInterval(() => {
2296− setNowTick(Date.now());
2297− }, 1000);
2298− return () => {
2299− window.clearInterval(timer);
2300− };
2301− }, [phase]);
2302−
23032120 useEffect(() => {
23042121 if (!activeThreadKey) return;
23052122 const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false;
@@ -2575,6 +2392,14 @@ export default function ChatView(props: ChatViewProps) { · 269 unmodified lines
25752392 sizeBytes: image.sizeBytes,
25762393 previewUrl: image.previewUrl,
25772394 }));
2395+ // Scroll to the current end *before* adding the optimistic message.
2396+ // This sets LegendList's internal isAtEnd=true so maintainScrollAtEnd
2397+ // automatically pins to the new item when the data changes.
2398+ isAtEndRef.current = true;
2399+ showScrollDebouncer.current.cancel();
2400+ setShowScrollToBottom(false);
2401+ await legendListRef.current?.scrollToEnd?.({ animated: false });
2402+
25782403 setOptimisticUserMessages((existing) => [
25792404 ...existing,
25802405 {
@@ -2586,9 +2411,6 @@ export default function ChatView(props: ChatViewProps) { · 5 unmodified lines
25862411 streaming: false,
25872412 },
25882413 ]);
2589− // Sending a message should always bring the latest user turn into view.
2590− shouldAutoScrollRef.current = true;
2591− forceStickToBottom();
25922414
25932415 setThreadError(threadIdForSend, null);
25942416 if (expiredTerminalContextCount > 0) {
@@ -2969,6 +2791,13 @@ export default function ChatView(props: ChatViewProps) { · 374 unmodified lines
29692791 sendInFlightRef.current = true;
29702792 beginLocalDispatch({ preparingWorktree: false });
29712793 setThreadError(threadIdForSend, null);
2794+
2795+ // Scroll to the current end *before* adding the optimistic message.
2796+ isAtEndRef.current = true;
2797+ showScrollDebouncer.current.cancel();
2798+ setShowScrollToBottom(false);
2799+ await legendListRef.current?.scrollToEnd?.({ animated: false });
2800+
29722801 setOptimisticUserMessages((existing) => [
29732802 ...existing,
29742803 {
@@ -2979,8 +2808,6 @@ export default function ChatView(props: ChatViewProps) { · 4 unmodified lines
29792808 streaming: false,
29802809 },
29812810 ]);
2982− shouldAutoScrollRef.current = true;
2983− forceStickToBottom();
29842811
29852812 try {
29862813 await persistThreadSettingsForNextTurn({
@@ -3046,7 +2873,6 @@ export default function ChatView(props: ChatViewProps) { · 59 unmodified lines
30462873 activeThread,
30472874 activeProposedPlan,
30482875 beginLocalDispatch,
3049− forceStickToBottom,
30502876 isConnecting,
30512877 isSendBusy,
30522878 isServerThread,
@@ -3241,12 +3067,6 @@ export default function ChatView(props: ChatViewProps) { · 188 unmodified lines
32413067 ],
32423068 );
32433069
3244− const onToggleWorkGroup = useCallback((groupId: string) => {
3245− setExpandedWorkGroups((existing) => ({
3246− ...existing,
3247− [groupId]: !existing[groupId],
3248− }));
3249− }, []);
32503070 const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {
32513071 setExpandedImage(preview);
32523072 }, []);
@@ -3272,16 +3092,19 @@ export default function ChatView(props: ChatViewProps) { · 19 unmodified lines
32723092 },
32733093 [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId],
32743094 );
3275− const onRevertUserMessage = useCallback(
3276− (messageId: MessageId) => {
3277− const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);
3278− if (typeof targetTurnCount !== "number") {
3279− return;
3280− }
3281− void onRevertToTurnCount(targetTurnCount);
3282− },
3283− [onRevertToTurnCount, revertTurnCountByUserMessageId],
3284− );
3095+ // Both the Map and the revert handler are read from refs at call-time so
3096+ // the callback reference is fully stable and never busts context identity.
3097+ const revertTurnCountRef = useRef(revertTurnCountByUserMessageId);
3098+ revertTurnCountRef.current = revertTurnCountByUserMessageId;
3099+ const onRevertToTurnCountRef = useRef(onRevertToTurnCount);
3100+ onRevertToTurnCountRef.current = onRevertToTurnCount;
3101+ const onRevertUserMessage = useCallback((messageId: MessageId) => {
3102+ const targetTurnCount = revertTurnCountRef.current.get(messageId);
3103+ if (typeof targetTurnCount !== "number") {
3104+ return;
3105+ }
3106+ void onRevertToTurnCountRef.current(targetTurnCount);
3107+ }, []);
32853108
32863109 // Empty state: no active thread
32873110 if (!activeThread) {
@@ -3338,57 +3161,39 @@ export default function ChatView(props: ChatViewProps) { · 50 unmodified lines
33383161 <div className="flex min-h-0 min-w-0 flex-1 flex-col">
33393162 {/* Messages Wrapper */}
33403163 <div className="relative flex min-h-0 flex-1 flex-col">
3341− {/* Messages */}
3342− <div
3343− ref={setMessagesScrollContainerRef}
3344− className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 py-3 sm:px-5 sm:py-4"
3345− onScroll={onMessagesScroll}
3346− onClickCapture={onMessagesClickCapture}
3347− onWheel={onMessagesWheel}
3348− onPointerDown={onMessagesPointerDown}
3349− onPointerUp={onMessagesPointerUp}
3350− onPointerCancel={onMessagesPointerCancel}
3351− onTouchStart={onMessagesTouchStart}
3352− onTouchMove={onMessagesTouchMove}
3353− onTouchEnd={onMessagesTouchEnd}
3354− onTouchCancel={onMessagesTouchEnd}
3355− >
3356− <MessagesTimeline
3357− key={activeThread.id}
3358− hasMessages={timelineEntries.length > 0}
3359− isWorking={isWorking}
3360− activeTurnInProgress={isWorking || !latestTurnSettled}
3361− activeTurnId={activeLatestTurn?.turnId ?? null}
3362− activeTurnStartedAt={activeWorkStartedAt}
3363− scrollContainer={messagesScrollElement}
3364− timelineEntries={timelineEntries}
3365− completionDividerBeforeEntryId={completionDividerBeforeEntryId}
3366− completionSummary={completionSummary}
3367− turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
3368− nowIso={nowIso}
3369− activeThreadEnvironmentId={activeThread.environmentId}
3370− expandedWorkGroups={expandedWorkGroups}
3371− onToggleWorkGroup={onToggleWorkGroup}
3372− changedFilesExpandedByTurnId={changedFilesExpandedByTurnId}
3373− onSetChangedFilesExpanded={handleSetChangedFilesExpanded}
3374− onOpenTurnDiff={onOpenTurnDiff}
3375− revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}
3376− onRevertUserMessage={onRevertUserMessage}
3377− isRevertingCheckpoint={isRevertingCheckpoint}
3378− onImageExpand={onExpandTimelineImage}
3379− markdownCwd={gitCwd ?? undefined}
3380− resolvedTheme={resolvedTheme}
3381− timestampFormat={timestampFormat}
3382− workspaceRoot={activeWorkspaceRoot}
3383− />
3384− </div>
3164+ {/* Messages — LegendList handles virtualization and scrolling internally */}
3165+ <MessagesTimeline
3166+ key={activeThread.id}
3167+ hasMessages={timelineEntries.length > 0}
3168+ isWorking={isWorking}
3169+ activeTurnInProgress={isWorking || !latestTurnSettled}
3170+ activeTurnId={activeLatestTurn?.turnId ?? null}
3171+ activeTurnStartedAt={activeWorkStartedAt}
3172+ listRef={legendListRef}
3173+ timelineEntries={timelineEntries}
3174+ completionDividerBeforeEntryId={completionDividerBeforeEntryId}
3175+ completionSummary={completionSummary}
3176+ turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
3177+ activeThreadEnvironmentId={activeThread.environmentId}
3178+ routeThreadKey={routeThreadKey}
3179+ onOpenTurnDiff={onOpenTurnDiff}
3180+ revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}
3181+ onRevertUserMessage={onRevertUserMessage}
3182+ isRevertingCheckpoint={isRevertingCheckpoint}
3183+ onImageExpand={onExpandTimelineImage}
3184+ markdownCwd={gitCwd ?? undefined}
3185+ resolvedTheme={resolvedTheme}
3186+ timestampFormat={timestampFormat}
3187+ workspaceRoot={activeWorkspaceRoot}
3188+ onIsAtEndChange={onIsAtEndChange}
3189+ />
33853190
33863191 {/* scroll to bottom pill — shown when user has scrolled away from the bottom */}
33873192 {showScrollToBottom && (
33883193 <div className="pointer-events-none absolute bottom-1 left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5">
33893194 <button
33903195 type="button"
3391− onClick={() => scrollMessagesToBottom("smooth")}
3196+ onClick={() => scrollToEnd(true)}
33923197 className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 bg-card px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer"
33933198 >
33943199 <ChevronDownIcon className="size-3.5" />
@@ -3443,8 +3248,8 @@ export default function ChatView(props: ChatViewProps) { · 48 unmodified lines
34433248 promptRef={promptRef}
34443249 composerImagesRef={composerImagesRef}
34453250 composerTerminalContextsRef={composerTerminalContextsRef}
3446− shouldAutoScrollRef={shouldAutoScrollRef}
3447− scheduleStickToBottom={scheduleStickToBottom}
3251+ shouldAutoScrollRef={isAtEndRef}
3252+ scheduleStickToBottom={scrollToEnd}
34483253 onSend={onSend}
34493254 onInterrupt={onInterrupt}
34503255 onImplementPlanInNewThread={onImplementPlanInNewThread}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+168−0
@@ -1,5 +1,6 @@
11 import { describe, expect, it } from "vitest";
22 import {
3+ computeStableMessagesTimelineRows,
34 computeMessageDurationStart,
45 deriveMessagesTimelineRows,
56 normalizeCompactToolLabel,
@@ -252,6 +253,8 @@ describe("deriveMessagesTimelineRows", () => { · 246 unmodified lines
252253 completionDividerBeforeEntryId: "assistant-final-entry",
253254 isWorking: false,
254255 activeTurnStartedAt: null,
256+ turnDiffSummaryByAssistantMessageId: new Map(),
257+ revertTurnCountByUserMessageId: new Map(),
255258 });
256259
257260 const assistantRows = rows.filter(
@@ -264,4 +267,169 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines
264267 expect(assistantRows[1]?.showAssistantCopyButton).toBe(true);
265268 expect(assistantRows[1]?.showCompletionDivider).toBe(true);
266269 });
270+
271+ it("projects assistant diff summaries and user revert counts onto the affected rows", () => {
272+ const assistantTurnDiffSummary = {
273+ turnId: "turn-1" as never,
274+ completedAt: "2026-01-01T00:00:30Z",
275+ assistantMessageId: "assistant-1" as never,
276+ checkpointTurnCount: 2,
277+ files: [{ path: "src/index.ts", additions: 3, deletions: 1 }],
278+ };
279+
280+ const rows = deriveMessagesTimelineRows({
281+ timelineEntries: [
282+ {
283+ id: "user-entry",
284+ kind: "message",
285+ createdAt: "2026-01-01T00:00:00Z",
286+ message: {
287+ id: "user-1" as never,
288+ role: "user",
289+ text: "Do the thing",
290+ turnId: null,
291+ createdAt: "2026-01-01T00:00:00Z",
292+ streaming: false,
293+ },
294+ },
295+ {
296+ id: "assistant-entry",
297+ kind: "message",
298+ createdAt: "2026-01-01T00:00:20Z",
299+ message: {
300+ id: "assistant-1" as never,
301+ role: "assistant",
302+ text: "Done",
303+ turnId: "turn-1" as never,
304+ createdAt: "2026-01-01T00:00:20Z",
305+ completedAt: "2026-01-01T00:00:30Z",
306+ streaming: false,
307+ },
308+ },
309+ ],
310+ completionDividerBeforeEntryId: null,
311+ isWorking: false,
312+ activeTurnStartedAt: null,
313+ turnDiffSummaryByAssistantMessageId: new Map([
314+ ["assistant-1" as never, assistantTurnDiffSummary],
315+ ]),
316+ revertTurnCountByUserMessageId: new Map([["user-1" as never, 1]]),
317+ });
318+
319+ const userRow = rows.find(
320+ (row): row is Extract<(typeof rows)[number], { kind: "message" }> =>
321+ row.kind === "message" && row.message.role === "user",
322+ );
323+ const assistantRow = rows.find(
324+ (row): row is Extract<(typeof rows)[number], { kind: "message" }> =>
325+ row.kind === "message" && row.message.role === "assistant",
326+ );
327+
328+ expect(userRow?.revertTurnCount).toBe(1);
329+ expect(assistantRow?.assistantTurnDiffSummary).toBe(assistantTurnDiffSummary);
330+ });
331+});
332+
333+describe("computeStableMessagesTimelineRows", () => {
334+ it("returns the previous result when row order and content are unchanged", () => {
335+ const firstUserMessage = {
336+ id: "user-1" as never,
337+ role: "user" as const,
338+ text: "First",
339+ turnId: null,
340+ createdAt: "2026-01-01T00:00:00Z",
341+ streaming: false,
342+ };
343+ const secondUserMessage = {
344+ id: "user-2" as never,
345+ role: "user" as const,
346+ text: "Second",
347+ turnId: null,
348+ createdAt: "2026-01-01T00:00:10Z",
349+ streaming: false,
350+ };
351+
352+ const rows = deriveMessagesTimelineRows({
353+ timelineEntries: [
354+ {
355+ id: "entry-user-1",
356+ kind: "message",
357+ createdAt: firstUserMessage.createdAt,
358+ message: firstUserMessage,
359+ },
360+ {
361+ id: "entry-user-2",
362+ kind: "message",
363+ createdAt: secondUserMessage.createdAt,
364+ message: secondUserMessage,
365+ },
366+ ],
367+ completionDividerBeforeEntryId: null,
368+ isWorking: false,
369+ activeTurnStartedAt: null,
370+ turnDiffSummaryByAssistantMessageId: new Map(),
371+ revertTurnCountByUserMessageId: new Map(),
372+ });
373+
374+ const initial = computeStableMessagesTimelineRows(rows, {
375+ byId: new Map(),
376+ result: [],
377+ });
378+
379+ const repeated = computeStableMessagesTimelineRows(rows, initial);
380+
381+ expect(repeated).toBe(initial);
382+ expect(repeated.result).toBe(initial.result);
383+ });
384+
385+ it("returns a new result when row order changes without content changes", () => {
386+ const firstUserMessage = {
387+ id: "user-1" as never,
388+ role: "user" as const,
389+ text: "First",
390+ turnId: null,
391+ createdAt: "2026-01-01T00:00:00Z",
392+ streaming: false,
393+ };
394+ const secondUserMessage = {
395+ id: "user-2" as never,
396+ role: "user" as const,
397+ text: "Second",
398+ turnId: null,
399+ createdAt: "2026-01-01T00:00:10Z",
400+ streaming: false,
401+ };
402+
403+ const firstRows = deriveMessagesTimelineRows({
404+ timelineEntries: [
405+ {
406+ id: "entry-user-1",
407+ kind: "message",
408+ createdAt: firstUserMessage.createdAt,
409+ message: firstUserMessage,
410+ },
411+ {
412+ id: "entry-user-2",
413+ kind: "message",
414+ createdAt: secondUserMessage.createdAt,
415+ message: secondUserMessage,
416+ },
417+ ],
418+ completionDividerBeforeEntryId: null,
419+ isWorking: false,
420+ activeTurnStartedAt: null,
421+ turnDiffSummaryByAssistantMessageId: new Map(),
422+ revertTurnCountByUserMessageId: new Map(),
423+ });
424+
425+ const initial = computeStableMessagesTimelineRows(firstRows, {
426+ byId: new Map(),
427+ result: [],
428+ });
429+
430+ const reordered = computeStableMessagesTimelineRows([firstRows[1]!, firstRows[0]!], initial);
431+
432+ expect(reordered).not.toBe(initial);
433+ expect(reordered.result).toEqual([initial.result[1], initial.result[0]]);
434+ });
267435 });
apps/web/src/components/chat/MessagesTimeline.logic.ts+55−61
@@ -1,8 +1,6 @@
1−import { type MessageId } from "@t3tools/contracts";
21 import { type TimelineEntry, type WorkLogEntry } from "../../session-logic";
3−import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree";
42 import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
5−import { estimateTimelineMessageHeight } from "../timelineHeight";
3+import { type MessageId } from "@t3tools/contracts";
64
75 export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
86
@@ -28,6 +26,8 @@ export type MessagesTimelineRow = · 19 unmodified lines
2826 durationStart: string;
2927 showCompletionDivider: boolean;
3028 showAssistantCopyButton: boolean;
29+ assistantTurnDiffSummary?: TurnDiffSummary | undefined;
30+ revertTurnCount?: number | undefined;
3131 }
3232 | {
3333 kind: "proposed-plan";
@@ -37,6 +37,11 @@ export type MessagesTimelineRow = · 3 unmodified lines
3737 }
3838 | { kind: "working"; id: string; createdAt: string | null };
3939
40+export interface StableMessagesTimelineRowsState {
41+ byId: Map<string, MessagesTimelineRow>;
42+ result: MessagesTimelineRow[];
43+}
44+
4045 export function computeMessageDurationStart(
4146 messages: ReadonlyArray<TimelineDurationMessage>,
4247 ): Map<string, string> {
@@ -107,6 +112,8 @@ export function deriveMessagesTimelineRows(input: { · 64 unmodified lines
107112 completionDividerBeforeEntryId: string | null;
108113 isWorking: boolean;
109114 activeTurnStartedAt: string | null;
115+ turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
116+ revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;
110117 }): MessagesTimelineRow[] {
111118 const nextRows: MessagesTimelineRow[] = [];
112119 const durationStartByMessageId = computeMessageDurationStart(
@@ -162,6 +169,14 @@ export function deriveMessagesTimelineRows(input: { · 49 unmodified lines
162169 showAssistantCopyButton:
163170 timelineEntry.message.role === "assistant" &&
164171 terminalAssistantMessageIds.has(timelineEntry.message.id),
172+ assistantTurnDiffSummary:
173+ timelineEntry.message.role === "assistant"
174+ ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)
175+ : undefined,
176+ revertTurnCount:
177+ timelineEntry.message.role === "user"
178+ ? input.revertTurnCountByUserMessageId.get(timelineEntry.message.id)
179+ : undefined,
165180 });
166181 }
167182
@@ -176,71 +191,50 @@ export function deriveMessagesTimelineRows(input: { · 8 unmodified lines
176191 return nextRows;
177192 }
178193
179−export function estimateMessagesTimelineRowHeight(
180− row: MessagesTimelineRow,
181− input: {
182− timelineWidthPx: number | null;
183− expandedWorkGroups?: Readonly<Record<string, boolean>>;
184− turnDiffSummaryByAssistantMessageId?: ReadonlyMap<MessageId, TurnDiffSummary>;
185− },
186−): number {
187− switch (row.kind) {
188− case "work":
189− return estimateWorkRowHeight(row, input);
190− case "proposed-plan":
191− return estimateTimelineProposedPlanHeight(row.proposedPlan);
192− case "working":
193− return 40;
194− case "message": {
195− let estimate = estimateTimelineMessageHeight(row.message, {
196− timelineWidthPx: input.timelineWidthPx,
197− });
198− const turnDiffSummary = input.turnDiffSummaryByAssistantMessageId?.get(row.message.id);
199− if (turnDiffSummary && turnDiffSummary.files.length > 0) {
200− estimate += estimateChangedFilesCardHeight(turnDiffSummary);
201− }
202− return estimate;
194+export function computeStableMessagesTimelineRows(
195+ rows: MessagesTimelineRow[],
196+ previous: StableMessagesTimelineRowsState,
197+): StableMessagesTimelineRowsState {
198+ const next = new Map<string, MessagesTimelineRow>();
199+ let anyChanged = rows.length !== previous.byId.size;
200+
201+ const result = rows.map((row, index) => {
202+ const prevRow = previous.byId.get(row.id);
203+ const nextRow = prevRow && isRowUnchanged(prevRow, row) ? prevRow : row;
204+ next.set(row.id, nextRow);
205+ if (!anyChanged && previous.result[index] !== nextRow) {
206+ anyChanged = true;
203207 }
204− }
205−}
208+ return nextRow;
209+ });
206210
207−function estimateWorkRowHeight(
208− row: Extract<MessagesTimelineRow, { kind: "work" }>,
209− input: {
210− expandedWorkGroups?: Readonly<Record<string, boolean>>;
211− },
212−): number {
213− const isExpanded = input.expandedWorkGroups?.[row.id] ?? false;
214− const hasOverflow = row.groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
215− const visibleEntries =
216− hasOverflow && !isExpanded ? MAX_VISIBLE_WORK_LOG_ENTRIES : row.groupedEntries.length;
217− const onlyToolEntries = row.groupedEntries.every((entry) => entry.tone === "tool");
218− const showHeader = hasOverflow || !onlyToolEntries;
219−
220− // Card chrome, optional header, and one compact work-entry row per visible entry.
221− return 28 + (showHeader ? 26 : 0) + visibleEntries * 32;
211+ return anyChanged ? { byId: next, result } : previous;
222212 }
223213
224−function estimateTimelineProposedPlanHeight(proposedPlan: ProposedPlan): number {
225− const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72));
226− return 120 + Math.min(estimatedLines * 22, 880);
227−}
214+/** Shallow field comparison per row variant — avoids deep equality cost. */
215+function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean {
216+ if (a.kind !== b.kind || a.id !== b.id) return false;
228217
229−function estimateChangedFilesCardHeight(turnDiffSummary: TurnDiffSummary): number {
230− const treeNodes = buildTurnDiffTree(turnDiffSummary.files);
231− const visibleNodeCount = countTurnDiffTreeNodes(treeNodes);
218+ switch (a.kind) {
219+ case "working":
220+ return a.createdAt === (b as typeof a).createdAt;
232221
233− // Card chrome: top/bottom padding, header row, and tree spacing.
234− return 60 + visibleNodeCount * 25;
235−}
222+ case "proposed-plan":
223+ return a.proposedPlan === (b as typeof a).proposedPlan;
224+
225+ case "work":
226+ return a.groupedEntries === (b as typeof a).groupedEntries;
236227
237−function countTurnDiffTreeNodes(nodes: ReadonlyArray<TurnDiffTreeNode>): number {
238− let count = 0;
239− for (const node of nodes) {
240− count += 1;
241− if (node.kind === "directory") {
242− count += countTurnDiffTreeNodes(node.children);
228+ case "message": {
229+ const bm = b as typeof a;
230+ return (
231+ a.message === bm.message &&
232+ a.durationStart === bm.durationStart &&
233+ a.showCompletionDivider === bm.showCompletionDivider &&
234+ a.showAssistantCopyButton === bm.showAssistantCopyButton &&
235+ a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary &&
236+ a.revertTurnCount === bm.revertTurnCount
237+ );
243238 }
244239 }
245− return count;
246240 }
apps/web/src/components/chat/MessagesTimeline.test.tsx+0−151
@@ -1,151 +0,0 @@
1−import { MessageId } from "@t3tools/contracts";
2−import { renderToStaticMarkup } from "react-dom/server";
3−import { beforeAll, describe, expect, it, vi } from "vitest";
4−
5−function matchMedia() {
6− return {
7− matches: false,
8− addEventListener: () => {},
9− removeEventListener: () => {},
10− };
11−}
12−
13−beforeAll(() => {
14− const classList = {
15− add: () => {},
16− remove: () => {},
17− toggle: () => {},
18− contains: () => false,
19− };
20−
21− vi.stubGlobal("localStorage", {
22− getItem: () => null,
23− setItem: () => {},
24− removeItem: () => {},
25− clear: () => {},
26− });
27− vi.stubGlobal("window", {
28− matchMedia,
29− addEventListener: () => {},
30− removeEventListener: () => {},
31− desktopBridge: undefined,
32− });
33− vi.stubGlobal("document", {
34− documentElement: {
35− classList,
36− offsetHeight: 0,
37− },
38− });
39− vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
40− callback(0);
41− return 0;
42− });
43−});
44−
45−const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never;
46−
47−describe("MessagesTimeline", () => {
48− it("renders inline terminal labels with the composer chip UI", async () => {
49− const { MessagesTimeline } = await import("./MessagesTimeline");
50− const markup = renderToStaticMarkup(
51− <MessagesTimeline
52− hasMessages
53− isWorking={false}
54− activeTurnInProgress={false}
55− activeTurnStartedAt={null}
56− scrollContainer={null}
57− timelineEntries={[
58− {
59− id: "entry-1",
60− kind: "message",
61− createdAt: "2026-03-17T19:12:28.000Z",
62− message: {
63− id: MessageId.make("message-2"),
64− role: "user",
65− text: [
66− "yoo what's @terminal-1:1-5 mean",
67− "",
68− "<terminal_context>",
69− "- Terminal 1 lines 1-5:",
70− " 1 | julius@mac effect-http-ws-cli % bun i",
71− " 2 | bun install v1.3.9 (cf6cdbbb)",
72− "</terminal_context>",
73− ].join("\n"),
74− createdAt: "2026-03-17T19:12:28.000Z",
75− streaming: false,
76− },
77− },
78− ]}
79− completionDividerBeforeEntryId={null}
80− completionSummary={null}
81− turnDiffSummaryByAssistantMessageId={new Map()}
82− nowIso="2026-03-17T19:12:30.000Z"
83− expandedWorkGroups={{}}
84− onToggleWorkGroup={() => {}}
85− changedFilesExpandedByTurnId={{}}
86− onSetChangedFilesExpanded={() => {}}
87− onOpenTurnDiff={() => {}}
88− revertTurnCountByUserMessageId={new Map()}
89− onRevertUserMessage={() => {}}
90− isRevertingCheckpoint={false}
91− onImageExpand={() => {}}
92− activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID}
93− markdownCwd={undefined}
94− resolvedTheme="light"
95− timestampFormat="locale"
96− workspaceRoot={undefined}
97− />,
98− );
99−
100− expect(markup).toContain("Terminal 1 lines 1-5");
101− expect(markup).toContain("lucide-terminal");
102− expect(markup).toContain("yoo what&#x27;s ");
103− }, 10_000);
104−
105− it("renders context compaction entries in the normal work log", async () => {
106− const { MessagesTimeline } = await import("./MessagesTimeline");
107− const markup = renderToStaticMarkup(
108− <MessagesTimeline
109− hasMessages
110− isWorking={false}
111− activeTurnInProgress={false}
112− activeTurnStartedAt={null}
113− scrollContainer={null}
114− timelineEntries={[
115− {
116− id: "entry-1",
117− kind: "work",
118− createdAt: "2026-03-17T19:12:28.000Z",
119− entry: {
120− id: "work-1",
121− createdAt: "2026-03-17T19:12:28.000Z",
122− label: "Context compacted",
123− tone: "info",
124− },
125− },
126− ]}
127− completionDividerBeforeEntryId={null}
128− completionSummary={null}
129− turnDiffSummaryByAssistantMessageId={new Map()}
130− nowIso="2026-03-17T19:12:30.000Z"
131− expandedWorkGroups={{}}
132− onToggleWorkGroup={() => {}}
133− changedFilesExpandedByTurnId={{}}
134− onSetChangedFilesExpanded={() => {}}
135− onOpenTurnDiff={() => {}}
136− revertTurnCountByUserMessageId={new Map()}
137− onRevertUserMessage={() => {}}
138− isRevertingCheckpoint={false}
139− onImageExpand={() => {}}
140− activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID}
141− markdownCwd={undefined}
142− resolvedTheme="light"
143− timestampFormat="locale"
144− workspaceRoot={undefined}
145− />,
146− );
147−
148− expect(markup).toContain("Context compacted");
149− expect(markup).toContain("Work log");
150− });
151−});
apps/web/src/components/chat/MessagesTimeline.tsx+422−388
@@ -1,21 +1,17 @@
11 import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts";
22 import {
3+ createContext,
34 memo,
5+ use,
46 useCallback,
57 useEffect,
6− useLayoutEffect,
78 useMemo,
89 useRef,
910 useState,
1011 type ReactNode,
1112 } from "react";
12−import {
13− measureElement as measureVirtualElement,
14− type VirtualItem,
15− useVirtualizer,
16−} from "@tanstack/react-virtual";
13+import { LegendList, type LegendListRef } from "@legendapp/list/react";
1714 import { deriveTimelineEntries, formatElapsed } from "../../session-logic";
18−import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";
1915 import { type TurnDiffSummary } from "../../types";
2016 import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
2117 import ChatMarkdown from "../ChatMarkdown";
@@ -34,18 +30,18 @@ import { · 12 unmodified lines
3430 ZapIcon,
3531 } from "lucide-react";
3632 import { Button } from "../ui/button";
37−import { clamp } from "effect/Number";
3833 import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
3934 import { ProposedPlanCard } from "./ProposedPlanCard";
4035 import { ChangedFilesTree } from "./ChangedFilesTree";
4136 import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
4237 import { MessageCopyButton } from "./MessageCopyButton";
4338 import {
39+ computeStableMessagesTimelineRows,
4440 MAX_VISIBLE_WORK_LOG_ENTRIES,
4541 deriveMessagesTimelineRows,
46− estimateMessagesTimelineRowHeight,
4742 normalizeCompactToolLabel,
4843 resolveAssistantMessageCopyState,
44+ type StableMessagesTimelineRowsState,
4945 type MessagesTimelineRow,
5046 } from "./MessagesTimeline.logic";
5147 import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
@@ -55,6 +51,7 @@ import { · 3 unmodified lines
5551 type ParsedTerminalContextEntry,
5652 } from "~/lib/terminalContext";
5753 import { cn } from "~/lib/utils";
54+import { useUiStateStore } from "~/uiStateStore";
5855 import { type TimestampFormat } from "@t3tools/contracts/settings";
5956 import { formatTimestamp } from "../../timestampFormat";
6057
@@ -64,7 +61,35 @@ import { · 3 unmodified lines
6461 textContainsInlineTerminalContextLabels,
6562 } from "./userMessageTerminalContexts";
6663
67−const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
64+// ---------------------------------------------------------------------------
65+// Context — shared state consumed by every row component via useContext.
66+// Propagates through LegendList's memo boundaries for shared callbacks and
67+// non-row-scoped state. `nowIso` is intentionally excluded — self-ticking
68+// components (WorkingTimer, LiveElapsed) handle it.
69+// ---------------------------------------------------------------------------
70+
71+interface TimelineRowSharedState {
72+ activeTurnInProgress: boolean;
73+ activeTurnId: TurnId | null | undefined;
74+ isWorking: boolean;
75+ isRevertingCheckpoint: boolean;
76+ completionSummary: string | null;
77+ timestampFormat: TimestampFormat;
78+ routeThreadKey: string;
79+ markdownCwd: string | undefined;
80+ resolvedTheme: "light" | "dark";
81+ workspaceRoot: string | undefined;
82+ activeThreadEnvironmentId: EnvironmentId;
83+ onRevertUserMessage: (messageId: MessageId) => void;
84+ onImageExpand: (preview: ExpandedImagePreview) => void;
85+ onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
86+}
87+
88+const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
89+
90+// ---------------------------------------------------------------------------
91+// Props (public API)
92+// ---------------------------------------------------------------------------
6893
6994 interface MessagesTimelineProps {
7095 hasMessages: boolean;
@@ -72,16 +97,12 @@ interface MessagesTimelineProps { · 1 unmodified line
7297 activeTurnInProgress: boolean;
7398 activeTurnId?: TurnId | null;
7499 activeTurnStartedAt: string | null;
75− scrollContainer: HTMLDivElement | null;
100+ listRef: React.RefObject<LegendListRef | null>;
76101 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
77102 completionDividerBeforeEntryId: string | null;
78103 completionSummary: string | null;
79104 turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;
80− nowIso: string;
81− expandedWorkGroups: Record<string, boolean>;
82− onToggleWorkGroup: (groupId: string) => void;
83− changedFilesExpandedByTurnId: Record<string, boolean>;
84− onSetChangedFilesExpanded: (turnId: TurnId, expanded: boolean) => void;
105+ routeThreadKey: string;
85106 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
86107 revertTurnCountByUserMessageId: Map<MessageId, number>;
87108 onRevertUserMessage: (messageId: MessageId) => void;
@@ -92,35 +113,25 @@ interface MessagesTimelineProps { · 4 unmodified lines
92113 resolvedTheme: "light" | "dark";
93114 timestampFormat: TimestampFormat;
94115 workspaceRoot: string | undefined;
95− onVirtualizerSnapshot?: (snapshot: {
96− totalSize: number;
97− measurements: ReadonlyArray<{
98− id: string;
99− kind: MessagesTimelineRow["kind"];
100− index: number;
101− size: number;
102− start: number;
103− end: number;
104− }>;
105− }) => void;
116+ onIsAtEndChange: (isAtEnd: boolean) => void;
106117 }
107118
119+// ---------------------------------------------------------------------------
120+// MessagesTimeline — list owner
121+// ---------------------------------------------------------------------------
122+
108123 export const MessagesTimeline = memo(function MessagesTimeline({
109124 hasMessages,
110125 isWorking,
111126 activeTurnInProgress,
112127 activeTurnId,
113128 activeTurnStartedAt,
114− scrollContainer,
129+ listRef,
115130 timelineEntries,
116131 completionDividerBeforeEntryId,
117132 completionSummary,
118133 turnDiffSummaryByAssistantMessageId,
119− nowIso,
120− expandedWorkGroups,
121− onToggleWorkGroup,
122− changedFilesExpandedByTurnId,
123− onSetChangedFilesExpanded,
134+ routeThreadKey,
124135 onOpenTurnDiff,
125136 revertTurnCountByUserMessageId,
126137 onRevertUserMessage,
@@ -131,181 +142,132 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 4 unmodified lines
131142 resolvedTheme,
132143 timestampFormat,
133144 workspaceRoot,
134− onVirtualizerSnapshot,
145+ onIsAtEndChange,
135146 }: MessagesTimelineProps) {
136− const timelineRootRef = useRef<HTMLDivElement | null>(null);
137− const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);
138−
139− useLayoutEffect(() => {
140− const timelineRoot = timelineRootRef.current;
141− if (!timelineRoot) return;
142−
143− const updateWidth = (nextWidth: number) => {
144− setTimelineWidthPx((previousWidth) => {
145− if (previousWidth !== null && Math.abs(previousWidth - nextWidth) < 0.5) {
146− return previousWidth;
147− }
148− return nextWidth;
149− });
150− };
151−
152− updateWidth(timelineRoot.getBoundingClientRect().width);
153−
154− if (typeof ResizeObserver === "undefined") return;
155− const observer = new ResizeObserver(() => {
156− updateWidth(timelineRoot.getBoundingClientRect().width);
157− });
158− observer.observe(timelineRoot);
159− return () => {
160− observer.disconnect();
161− };
162− }, [hasMessages, isWorking]);
163−
164− const rows = useMemo(
147+ const rawRows = useMemo(
165148 () =>
166149 deriveMessagesTimelineRows({
167150 timelineEntries,
168151 completionDividerBeforeEntryId,
169152 isWorking,
170153 activeTurnStartedAt,
154+ turnDiffSummaryByAssistantMessageId,
155+ revertTurnCountByUserMessageId,
171156 }),
172− [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt],
157+ [
158+ timelineEntries,
159+ completionDividerBeforeEntryId,
160+ isWorking,
161+ activeTurnStartedAt,
162+ turnDiffSummaryByAssistantMessageId,
163+ revertTurnCountByUserMessageId,
164+ ],
173165 );
166+ const rows = useStableRows(rawRows);
174167
175− const firstUnvirtualizedRowIndex = useMemo(() => {
176− const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0);
177− if (!activeTurnInProgress) return firstTailRowIndex;
178−
179− const turnStartedAtMs =
180− typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN;
181− let firstCurrentTurnRowIndex = -1;
182− if (!Number.isNaN(turnStartedAtMs)) {
183− firstCurrentTurnRowIndex = rows.findIndex((row) => {
184− if (row.kind === "working") return true;
185− if (!row.createdAt) return false;
186− const rowCreatedAtMs = Date.parse(row.createdAt);
187− return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs;
188− });
168+ const handleScroll = useCallback(() => {
169+ const state = listRef.current?.getState?.();
170+ if (state) {
171+ onIsAtEndChange(state.isAtEnd);
189172 }
173+ }, [listRef, onIsAtEndChange]);
174+
175+ // Memoised context value — only changes on state transitions, NOT on
176+ // every streaming chunk. Callbacks from ChatView are useCallback-stable.
177+ const sharedState = useMemo<TimelineRowSharedState>(
178+ () => ({
179+ activeTurnInProgress,
180+ activeTurnId: activeTurnId ?? null,
181+ isWorking,
182+ isRevertingCheckpoint,
183+ completionSummary,
184+ timestampFormat,
185+ routeThreadKey,
186+ markdownCwd,
187+ resolvedTheme,
188+ workspaceRoot,
189+ activeThreadEnvironmentId,
190+ onRevertUserMessage,
191+ onImageExpand,
192+ onOpenTurnDiff,
193+ }),
194+ [
195+ activeTurnInProgress,
196+ activeTurnId,
197+ isWorking,
198+ isRevertingCheckpoint,
199+ completionSummary,
200+ timestampFormat,
201+ routeThreadKey,
202+ markdownCwd,
203+ resolvedTheme,
204+ workspaceRoot,
205+ activeThreadEnvironmentId,
206+ onRevertUserMessage,
207+ onImageExpand,
208+ onOpenTurnDiff,
209+ ],
210+ );
190211
191− if (firstCurrentTurnRowIndex < 0) {
192− firstCurrentTurnRowIndex = rows.findIndex(
193− (row) => row.kind === "message" && row.message.streaming,
194− );
195− }
212+ // Stable renderItem — no closure deps. Row components read shared state
213+ // from TimelineRowCtx, which propagates through LegendList's memo.
214+ const renderItem = useCallback(
215+ ({ item }: { item: MessagesTimelineRow }) => (
216+ <div className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden" data-timeline-root="true">
217+ <TimelineRowContent row={item} />
218+ </div>
219+ ),
220+ [],
221+ );
196222
197− if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex;
223+ if (!hasMessages && !isWorking) {
224+ return (
225+ <div className="flex h-full items-center justify-center">
226+ <p className="text-sm text-muted-foreground/30">
227+ Send a message to start the conversation.
228+ </p>
229+ </div>
230+ );
231+ }
198232
199− for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) {
200− const previousRow = rows[index];
201− if (!previousRow || previousRow.kind !== "message") continue;
202− if (previousRow.message.role === "user") {
203− return Math.min(index, firstTailRowIndex);
204− }
205− if (previousRow.message.role === "assistant" && !previousRow.message.streaming) {
206− break;
207− }
208− }
233+ return (
234+ <TimelineRowCtx.Provider value={sharedState}>
235+ <LegendList<MessagesTimelineRow>
236+ ref={listRef}
237+ data={rows}
238+ keyExtractor={keyExtractor}
239+ renderItem={renderItem}
240+ estimatedItemSize={90}
241+ initialScrollAtEnd
242+ maintainScrollAtEnd
243+ maintainScrollAtEndThreshold={0.1}
244+ maintainVisibleContentPosition
245+ onScroll={handleScroll}
246+ className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5"
247+ ListHeaderComponent={<div className="h-3 sm:h-4" />}
248+ ListFooterComponent={<div className="h-3 sm:h-4" />}
249+ />
250+ </TimelineRowCtx.Provider>
251+ );
252+});
209253
210− return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex);
211− }, [activeTurnInProgress, activeTurnStartedAt, rows]);
254+function keyExtractor(item: MessagesTimelineRow) {
255+ return item.id;
256+}
212257
213− const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, {
214− minimum: 0,
215− maximum: rows.length,
216− });
217− const virtualMeasurementScopeKey =
218− timelineWidthPx === null ? "width:unknown" : `width:${Math.round(timelineWidthPx)}`;
219−
220− const rowVirtualizer = useVirtualizer({
221− count: virtualizedRowCount,
222− getScrollElement: () => scrollContainer,
223− // Scope cached row measurements to the current timeline width so offscreen
224− // rows do not keep stale heights after wrapping changes.
225− getItemKey: (index: number) => {
226− const rowId = rows[index]?.id ?? String(index);
227− return `${virtualMeasurementScopeKey}:${rowId}`;
228− },
229− estimateSize: (index: number) => {
230− const row = rows[index];
231− if (!row) return 96;
232− return estimateMessagesTimelineRowHeight(row, {
233− expandedWorkGroups,
234− timelineWidthPx,
235− turnDiffSummaryByAssistantMessageId,
236− });
237− },
238− measureElement: measureVirtualElement,
239− useAnimationFrameWithResizeObserver: true,
240− overscan: 8,
241− });
242− useEffect(() => {
243− if (timelineWidthPx === null) return;
244− rowVirtualizer.measure();
245− }, [rowVirtualizer, timelineWidthPx]);
246− useEffect(() => {
247− rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
248− const viewportHeight = instance.scrollRect?.height ?? 0;
249− const scrollOffset = instance.scrollOffset ?? 0;
250− const itemIntersectsViewport =
251− item.end > scrollOffset && item.start < scrollOffset + viewportHeight;
252− if (itemIntersectsViewport) {
253− return false;
254− }
255− const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
256− return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
257− };
258− return () => {
259− rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
260− };
261− }, [rowVirtualizer]);
262− const pendingMeasureFrameRef = useRef<number | null>(null);
263− const onTimelineImageLoad = useCallback(() => {
264− if (pendingMeasureFrameRef.current !== null) return;
265− pendingMeasureFrameRef.current = window.requestAnimationFrame(() => {
266− pendingMeasureFrameRef.current = null;
267− rowVirtualizer.measure();
268− });
269− }, [rowVirtualizer]);
270− useEffect(() => {
271− return () => {
272− const frame = pendingMeasureFrameRef.current;
273− if (frame !== null) {
274− window.cancelAnimationFrame(frame);
275− }
276− };
277− }, []);
278− useLayoutEffect(() => {
279− if (!onVirtualizerSnapshot) {
280− return;
281− }
282− onVirtualizerSnapshot({
283− totalSize: rowVirtualizer.getTotalSize(),
284− measurements: rowVirtualizer.measurementsCache
285− .slice(0, virtualizedRowCount)
286− .flatMap((measurement) => {
287− const row = rows[measurement.index];
288− if (!row) {
289− return [];
290− }
291− return [
292− {
293− id: row.id,
294− kind: row.kind,
295− index: measurement.index,
296− size: measurement.size,
297− start: measurement.start,
298− end: measurement.end,
299− },
300− ];
301− }),
302− });
303− }, [onVirtualizerSnapshot, rowVirtualizer, rows, virtualizedRowCount]);
304−
305− const virtualRows = rowVirtualizer.getVirtualItems();
306− const nonVirtualizedRows = rows.slice(virtualizedRowCount);
307−
308− const renderRowContent = (row: TimelineRow) => (
258+// ---------------------------------------------------------------------------
259+// TimelineRowContent — the actual row component
260+// ---------------------------------------------------------------------------
261+
262+type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
263+type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
264+type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];
265+type TimelineRow = MessagesTimelineRow;
266+
267+function TimelineRowContent({ row }: { row: TimelineRow }) {
268+ const ctx = use(TimelineRowCtx);
269+
270+ return (
309271 <div
310272 className={cn(
311273 "pb-4",
@@ -316,47 +278,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 4 unmodified lines
316278 data-message-id={row.kind === "message" ? row.message.id : undefined}
317279 data-message-role={row.kind === "message" ? row.message.role : undefined}
318280 >
319− {row.kind === "work" &&
320− (() => {
321− const groupId = row.id;
322− const groupedEntries = row.groupedEntries;
323− const isExpanded = expandedWorkGroups[groupId] ?? false;
324− const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
325− const visibleEntries =
326− hasOverflow && !isExpanded
327− ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
328− : groupedEntries;
329− const hiddenCount = groupedEntries.length - visibleEntries.length;
330− const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
331− const showHeader = hasOverflow || !onlyToolEntries;
332− const groupLabel = onlyToolEntries ? "Tool calls" : "Work log";
333−
334− return (
335− <div className="rounded-xl border border-border/45 bg-card/25 px-2 py-1.5">
336− {showHeader && (
337− <div className="mb-1.5 flex items-center justify-between gap-2 px-0.5">
338− <p className="text-[9px] uppercase tracking-[0.16em] text-muted-foreground/55">
339− {groupLabel} ({groupedEntries.length})
340− </p>
341− {hasOverflow && (
342− <button
343− type="button"
344− className="text-[9px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-foreground/75"
345− onClick={() => onToggleWorkGroup(groupId)}
346− >
347− {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
348− </button>
349− )}
350− </div>
351− )}
352− <div className="space-y-0.5">
353− {visibleEntries.map((workEntry) => (
354− <SimpleWorkEntryRow key={`work-row:${workEntry.id}`} workEntry={workEntry} />
355− ))}
356− </div>
357− </div>
358− );
359− })()}
281+ {row.kind === "work" && <WorkGroupSection groupedEntries={row.groupedEntries} />}
360282
361283 {row.kind === "message" &&
362284 row.message.role === "user" &&
@@ -364,7 +286,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line
364286 const userImages = row.message.attachments ?? [];
365287 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);
366288 const terminalContexts = displayedUserMessage.contexts;
367− const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);
289+ const canRevertAgentWork = typeof row.revertTurnCount === "number";
368290 return (
369291 <div className="flex justify-end">
370292 <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
@@ -384,15 +306,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 13 unmodified lines
384306 onClick={() => {
385307 const preview = buildExpandedImagePreview(userImages, image.id);
386308 if (!preview) return;
387− onImageExpand(preview);
309+ ctx.onImageExpand(preview);
388310 }}
389311 >
390312 <img
391313 src={image.previewUrl}
392314 alt={image.name}
393315 className="block h-auto max-h-[220px] w-full object-cover"
394− onLoad={onTimelineImageLoad}
395− onError={onTimelineImageLoad}
396316 />
397317 </button>
398318 ) : (
@@ -422,8 +342,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 23 unmodified lines
422342 type="button"
423343 size="xs"
424344 variant="outline"
425− disabled={isRevertingCheckpoint || isWorking}
426− onClick={() => onRevertUserMessage(row.message.id)}
345+ disabled={ctx.isRevertingCheckpoint || ctx.isWorking}
346+ onClick={() => ctx.onRevertUserMessage(row.message.id)}
427347 title="Revert to this message"
428348 >
429349 <Undo2Icon className="size-3" />
@@ -431,7 +351,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line
431351 )}
432352 </div>
433353 <p className="text-right text-xs text-muted-foreground/50">
434− {formatTimestamp(row.message.createdAt, timestampFormat)}
354+ {formatTimestamp(row.message.createdAt, ctx.timestampFormat)}
435355 </p>
436356 </div>
437357 </div>
@@ -444,10 +364,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 6 unmodified lines
444364 (() => {
445365 const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
446366 const assistantTurnStillInProgress =
447− activeTurnInProgress &&
448− activeTurnId !== null &&
449− activeTurnId !== undefined &&
450− row.message.turnId === activeTurnId;
367+ ctx.activeTurnInProgress &&
368+ ctx.activeTurnId !== null &&
369+ ctx.activeTurnId !== undefined &&
370+ row.message.turnId === ctx.activeTurnId;
451371 const assistantCopyState = resolveAssistantMessageCopyState({
452372 text: row.message.text ?? null,
453373 showCopyButton: row.showAssistantCopyButton,
@@ -459,7 +379,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 5 unmodified lines
459379 <div className="my-3 flex items-center gap-3">
460380 <span className="h-px flex-1 bg-border" />
461381 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
462− {completionSummary ? `Response • ${completionSummary}` : "Response"}
382+ {ctx.completionSummary ? `Response • ${ctx.completionSummary}` : "Response"}
463383 </span>
464384 <span className="h-px flex-1 bg-border" />
465385 </div>
@@ -467,76 +387,29 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line
467387 <div className="min-w-0 px-1 py-0.5">
468388 <ChatMarkdown
469389 text={messageText}
470− cwd={markdownCwd}
390+ cwd={ctx.markdownCwd}
471391 isStreaming={Boolean(row.message.streaming)}
472392 />
473− {(() => {
474− const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id);
475− if (!turnSummary) return null;
476− const checkpointFiles = turnSummary.files;
477− if (checkpointFiles.length === 0) return null;
478− const summaryStat = summarizeTurnDiffStats(checkpointFiles);
479− const changedFileCountLabel = String(checkpointFiles.length);
480− const allDirectoriesExpanded =
481− changedFilesExpandedByTurnId[turnSummary.turnId] ?? true;
482− return (
483− <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
484− <div className="mb-1.5 flex items-center justify-between gap-2">
485− <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
486− <span>Changed files ({changedFileCountLabel})</span>
487− {hasNonZeroStat(summaryStat) && (
488− <>
489− <span className="mx-1">•</span>
490− <DiffStatLabel
491− additions={summaryStat.additions}
492− deletions={summaryStat.deletions}
493− />
494− </>
495− )}
496− </p>
497− <div className="flex items-center gap-1.5">
498− <Button
499− type="button"
500− size="xs"
501− variant="outline"
502− data-scroll-anchor-ignore
503− onClick={() =>
504− onSetChangedFilesExpanded(turnSummary.turnId, !allDirectoriesExpanded)
505− }
506− >
507− {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
508− </Button>
509− <Button
510− type="button"
511− size="xs"
512− variant="outline"
513− onClick={() =>
514− onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)
515− }
516− >
517− View diff
518− </Button>
519− </div>
520− </div>
521− <ChangedFilesTree
522− key={`changed-files-tree:${turnSummary.turnId}`}
523− turnId={turnSummary.turnId}
524− files={checkpointFiles}
525− allDirectoriesExpanded={allDirectoriesExpanded}
526− resolvedTheme={resolvedTheme}
527− onOpenTurnDiff={onOpenTurnDiff}
528− />
529− </div>
530− );
531− })()}
393+ <AssistantChangedFilesSection
394+ turnSummary={row.assistantTurnDiffSummary}
395+ routeThreadKey={ctx.routeThreadKey}
396+ resolvedTheme={ctx.resolvedTheme}
397+ onOpenTurnDiff={ctx.onOpenTurnDiff}
398+ />
532399 <div className="mt-1.5 flex items-center gap-2">
533400 <p className="text-[10px] text-muted-foreground/30">
534− {formatMessageMeta(
535− row.message.createdAt,
536− row.message.streaming
537− ? formatElapsed(row.durationStart, nowIso)
538− : formatElapsed(row.durationStart, row.message.completedAt),
539− timestampFormat,
401+ {row.message.streaming ? (
402+ <LiveMessageMeta
403+ createdAt={row.message.createdAt}
404+ durationStart={row.durationStart}
405+ timestampFormat={ctx.timestampFormat}
406+ />
407+ ) : (
408+ formatMessageMeta(
409+ row.message.createdAt,
410+ formatElapsed(row.durationStart, row.message.completedAt),
411+ ctx.timestampFormat,
412+ )
540413 )}
541414 </p>
542415 {assistantCopyState.visible ? (
@@ -559,9 +432,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 16 unmodified lines
559432 <div className="min-w-0 px-1 py-0.5">
560433 <ProposedPlanCard
561434 planMarkdown={row.proposedPlan.planMarkdown}
562− environmentId={activeThreadEnvironmentId}
563− cwd={markdownCwd}
564− workspaceRoot={workspaceRoot}
435+ environmentId={ctx.activeThreadEnvironmentId}
436+ cwd={ctx.markdownCwd}
437+ workspaceRoot={ctx.workspaceRoot}
565438 />
566439 </div>
567440 )}
@@ -575,100 +448,205 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 7 unmodified lines
575448 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
576449 </span>
577450 <span>
578− {row.createdAt
579− ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}`
580− : "Working..."}
451+ {row.createdAt ? (
452+ <>
453+ Working for <WorkingTimer createdAt={row.createdAt} />
454+ </>
455+ ) : (
456+ "Working..."
457+ )}
581458 </span>
582459 </div>
583460 </div>
584461 )}
585462 </div>
586463 );
464+}
587465
588− if (!hasMessages && !isWorking) {
589− return (
590− <div className="flex h-full items-center justify-center">
591− <p className="text-sm text-muted-foreground/30">
592− Send a message to start the conversation.
593− </p>
594− </div>
595− );
596− }
466+// ---------------------------------------------------------------------------
467+// Self-ticking components — bypass LegendList memoisation entirely.
468+// Each owns a `nowMs` state value consumed in the render output so the
469+// React Compiler cannot elide the re-render as a no-op.
470+// ---------------------------------------------------------------------------
471+
472+/** Live "Working for Xs" label. */
473+function WorkingTimer({ createdAt }: { createdAt: string }) {
474+ const [nowMs, setNowMs] = useState(() => Date.now());
475+ useEffect(() => {
476+ const id = setInterval(() => setNowMs(Date.now()), 1000);
477+ return () => clearInterval(id);
478+ }, [createdAt]);
479+ return <>{formatWorkingTimer(createdAt, new Date(nowMs).toISOString()) ?? "0s"}</>;
480+}
481+
482+/** Live timestamp + elapsed duration for a streaming assistant message. */
483+function LiveMessageMeta({
484+ createdAt,
485+ durationStart,
486+ timestampFormat,
487+}: {
488+ createdAt: string;
489+ durationStart: string | null | undefined;
490+ timestampFormat: TimestampFormat;
491+}) {
492+ const [nowMs, setNowMs] = useState(() => Date.now());
493+ useEffect(() => {
494+ const id = setInterval(() => setNowMs(Date.now()), 1000);
495+ return () => clearInterval(id);
496+ }, [durationStart]);
497+ const elapsed = durationStart
498+ ? formatElapsed(durationStart, new Date(nowMs).toISOString())
499+ : null;
500+ return <>{formatMessageMeta(createdAt, elapsed, timestampFormat)}</>;
501+}
502+
503+// ---------------------------------------------------------------------------
504+// Extracted row sections — own their state / store subscriptions so changes
505+// re-render only the affected row, not the entire list.
506+// ---------------------------------------------------------------------------
507+
508+/** Owns its own expand/collapse state so toggling re-renders only this row.
509+ * State resets on unmount which is fine — work groups start collapsed. */
510+const WorkGroupSection = memo(function WorkGroupSection({
511+ groupedEntries,
512+}: {
513+ groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
514+}) {
515+ const [isExpanded, setIsExpanded] = useState(false);
516+ const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
517+ const visibleEntries =
518+ hasOverflow && !isExpanded
519+ ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
520+ : groupedEntries;
521+ const hiddenCount = groupedEntries.length - visibleEntries.length;
522+ const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
523+ const showHeader = hasOverflow || !onlyToolEntries;
524+ const groupLabel = onlyToolEntries ? "Tool calls" : "Work log";
597525
598526 return (
599− <div
600− ref={timelineRootRef}
601− data-timeline-root="true"
602− className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden"
603− >
604− {virtualizedRowCount > 0 && (
605− <div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
606− {virtualRows.map((virtualRow: VirtualItem) => {
607− const row = rows[virtualRow.index];
608− if (!row) return null;
609−
610− return (
611− <div
612− key={`virtual-row:${row.id}`}
613− data-index={virtualRow.index}
614− data-virtual-row-id={row.id}
615− data-virtual-row-kind={row.kind}
616− data-virtual-row-size={virtualRow.size}
617− data-virtual-row-start={virtualRow.start}
618− ref={rowVirtualizer.measureElement}
619− className="absolute left-0 top-0 w-full"
620− style={{ transform: `translateY(${virtualRow.start}px)` }}
621− >
622− {renderRowContent(row)}
623− </div>
624− );
625− })}
527+ <div className="rounded-xl border border-border/45 bg-card/25 px-2 py-1.5">
528+ {showHeader && (
529+ <div className="mb-1.5 flex items-center justify-between gap-2 px-0.5">
530+ <p className="text-[9px] uppercase tracking-[0.16em] text-muted-foreground/55">
531+ {groupLabel} ({groupedEntries.length})
532+ </p>
533+ {hasOverflow && (
534+ <button
535+ type="button"
536+ className="text-[9px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-foreground/75"
537+ onClick={() => setIsExpanded((v) => !v)}
538+ >
539+ {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
540+ </button>
541+ )}
626542 </div>
627543 )}
628−
629− {nonVirtualizedRows.map((row) => (
630− <div key={`non-virtual-row:${row.id}`}>{renderRowContent(row)}</div>
631− ))}
544+ <div className="space-y-0.5">
545+ {visibleEntries.map((workEntry) => (
546+ <SimpleWorkEntryRow key={`work-row:${workEntry.id}`} workEntry={workEntry} />
547+ ))}
548+ </div>
632549 </div>
633550 );
634551 });
635552
636−type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
637−type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
638−type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];
639−type TimelineRow = MessagesTimelineRow;
640−
641−function formatWorkingTimer(startIso: string, endIso: string): string | null {
642− const startedAtMs = Date.parse(startIso);
643− const endedAtMs = Date.parse(endIso);
644− if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) {
645− return null;
646− }
647−
648− const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000));
649− if (elapsedSeconds < 60) {
650− return `${elapsedSeconds}s`;
651− }
553+/** Subscribes directly to the UI state store for expand/collapse state,
554+ * so toggling re-renders only this component — not the entire list. */
555+const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection({
556+ turnSummary,
557+ routeThreadKey,
558+ resolvedTheme,
559+ onOpenTurnDiff,
560+}: {
561+ turnSummary: TurnDiffSummary | undefined;
562+ routeThreadKey: string;
563+ resolvedTheme: "light" | "dark";
564+ onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
565+}) {
566+ if (!turnSummary) return null;
567+ const checkpointFiles = turnSummary.files;
568+ if (checkpointFiles.length === 0) return null;
652569
653− const hours = Math.floor(elapsedSeconds / 3600);
654− const minutes = Math.floor((elapsedSeconds % 3600) / 60);
655− const seconds = elapsedSeconds % 60;
570+ return (
571+ <AssistantChangedFilesSectionInner
572+ turnSummary={turnSummary}
573+ checkpointFiles={checkpointFiles}
574+ routeThreadKey={routeThreadKey}
575+ resolvedTheme={resolvedTheme}
576+ onOpenTurnDiff={onOpenTurnDiff}
577+ />
578+ );
579+});
656580
657− if (hours > 0) {
658− return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
659− }
581+/** Inner component that only mounts when there are actual changed files,
582+ * so the store subscription is unconditional (no hooks after early return). */
583+function AssistantChangedFilesSectionInner({
584+ turnSummary,
585+ checkpointFiles,
586+ routeThreadKey,
587+ resolvedTheme,
588+ onOpenTurnDiff,
589+}: {
590+ turnSummary: TurnDiffSummary;
591+ checkpointFiles: TurnDiffSummary["files"];
592+ routeThreadKey: string;
593+ resolvedTheme: "light" | "dark";
594+ onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
595+}) {
596+ const allDirectoriesExpanded = useUiStateStore(
597+ (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true,
598+ );
599+ const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
600+ const summaryStat = summarizeTurnDiffStats(checkpointFiles);
601+ const changedFileCountLabel = String(checkpointFiles.length);
660602
661− return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
603+ return (
604+ <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
605+ <div className="mb-1.5 flex items-center justify-between gap-2">
606+ <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
607+ <span>Changed files ({changedFileCountLabel})</span>
608+ {hasNonZeroStat(summaryStat) && (
609+ <>
610+ <span className="mx-1">•</span>
611+ <DiffStatLabel additions={summaryStat.additions} deletions={summaryStat.deletions} />
612+ </>
613+ )}
614+ </p>
615+ <div className="flex items-center gap-1.5">
616+ <Button
617+ type="button"
618+ size="xs"
619+ variant="outline"
620+ data-scroll-anchor-ignore
621+ onClick={() => setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)}
622+ >
623+ {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
624+ </Button>
625+ <Button
626+ type="button"
627+ size="xs"
628+ variant="outline"
629+ onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)}
630+ >
631+ View diff
632+ </Button>
633+ </div>
634+ </div>
635+ <ChangedFilesTree
636+ key={`changed-files-tree:${turnSummary.turnId}`}
637+ turnId={turnSummary.turnId}
638+ files={checkpointFiles}
639+ allDirectoriesExpanded={allDirectoriesExpanded}
640+ resolvedTheme={resolvedTheme}
641+ onOpenTurnDiff={onOpenTurnDiff}
642+ />
643+ </div>
644+ );
662645 }
663646
664−function formatMessageMeta(
665− createdAt: string,
666− duration: string | null,
667− timestampFormat: TimestampFormat,
668−): string {
669− if (!duration) return formatTimestamp(createdAt, timestampFormat);
670− return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`;
671−}
647+// ---------------------------------------------------------------------------
648+// Leaf components
649+// ---------------------------------------------------------------------------
672650
673651 const UserMessageTerminalContextInlineLabel = memo(
674652 function UserMessageTerminalContextInlineLabel(props: { context: ParsedTerminalContextEntry }) {
@@ -774,6 +752,62 @@ const UserMessageBody = memo(function UserMessageBody(props: { · 99 unmodified lines
774752 );
775753 });
776754
755+// ---------------------------------------------------------------------------
756+// Structural sharing — reuse old row references when data hasn't changed
757+// so LegendList (and React) can skip re-rendering unchanged items.
758+// ---------------------------------------------------------------------------
759+
760+/** Returns a structurally-shared copy of `rows`: for each row whose content
761+ * hasn't changed since last call, the previous object reference is reused. */
762+function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] {
763+ const prevState = useRef<StableMessagesTimelineRowsState>({
764+ byId: new Map<string, MessagesTimelineRow>(),
765+ result: [],
766+ });
767+
768+ return useMemo(() => {
769+ const nextState = computeStableMessagesTimelineRows(rows, prevState.current);
770+ prevState.current = nextState;
771+ return nextState.result;
772+ }, [rows]);
773+}
774+
775+// ---------------------------------------------------------------------------
776+// Pure helpers
777+// ---------------------------------------------------------------------------
778+
779+function formatWorkingTimer(startIso: string, endIso: string): string | null {
780+ const startedAtMs = Date.parse(startIso);
781+ const endedAtMs = Date.parse(endIso);
782+ if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) {
783+ return null;
784+ }
785+
786+ const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000));
787+ if (elapsedSeconds < 60) {
788+ return `${elapsedSeconds}s`;
789+ }
790+
791+ const hours = Math.floor(elapsedSeconds / 3600);
792+ const minutes = Math.floor((elapsedSeconds % 3600) / 60);
793+ const seconds = elapsedSeconds % 60;
794+
795+ if (hours > 0) {
796+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
797+ }
798+
799+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
800+}
801+
802+function formatMessageMeta(
803+ createdAt: string,
804+ duration: string | null,
805+ timestampFormat: TimestampFormat,
806+): string {
807+ if (!duration) return formatTimestamp(createdAt, timestampFormat);
808+ return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`;
809+}
810+
777811 function workToneIcon(tone: TimelineWorkEntry["tone"]): {
778812 icon: LucideIcon;
779813 className: string;
apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx+0−1071
@@ -1,1071 +0,0 @@
1−import "../../index.css";
2−
3−import { MessageId, type TurnId } from "@t3tools/contracts";
4−import { page } from "vitest/browser";
5−import { useCallback, useState, type ComponentProps } from "react";
6−import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7−import { render } from "vitest-browser-react";
8−
9−import { deriveTimelineEntries, type WorkLogEntry } from "../../session-logic";
10−import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
11−import { MessagesTimeline } from "./MessagesTimeline";
12−import {
13− deriveMessagesTimelineRows,
14− estimateMessagesTimelineRowHeight,
15−} from "./MessagesTimeline.logic";
16−
17−const DEFAULT_VIEWPORT = {
18− width: 960,
19− height: 1_100,
20−};
21−const MARKDOWN_CWD = "/repo/project";
22−const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never;
23−
24−interface RowMeasurement {
25− actualHeightPx: number;
26− estimatedHeightPx: number;
27− timelineWidthPx: number;
28− virtualizerSizePx: number;
29− renderedInVirtualizedRegion: boolean;
30−}
31−
32−interface VirtualizationScenario {
33− name: string;
34− targetRowId: string;
35− props: Omit<
36− ComponentProps<typeof MessagesTimeline>,
37− "scrollContainer" | "activeThreadEnvironmentId"
38− >;
39− maxEstimateDeltaPx: number;
40−}
41−
42−interface VirtualizerSnapshot {
43− totalSize: number;
44− measurements: ReadonlyArray<{
45− id: string;
46− kind: string;
47− index: number;
48− size: number;
49− start: number;
50− end: number;
51− }>;
52−}
53−
54−function MessagesTimelineBrowserHarness(
55− props: Omit<
56− ComponentProps<typeof MessagesTimeline>,
57− "scrollContainer" | "activeThreadEnvironmentId"
58− >,
59−) {
60− const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null);
61− const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>(
62− () => props.expandedWorkGroups,
63− );
64− const [changedFilesExpandedByTurnId, setChangedFilesExpandedByTurnId] = useState<
65− Record<string, boolean>
66− >(() => props.changedFilesExpandedByTurnId);
67− const handleToggleWorkGroup = useCallback(
68− (groupId: string) => {
69− setExpandedWorkGroups((current) => ({
70− ...current,
71− [groupId]: !(current[groupId] ?? false),
72− }));
73− props.onToggleWorkGroup(groupId);
74− },
75− [props],
76− );
77− const handleSetChangedFilesExpanded = useCallback(
78− (turnId: TurnId, expanded: boolean) => {
79− setChangedFilesExpandedByTurnId((current) => ({
80− ...current,
81− [turnId]: expanded,
82− }));
83− props.onSetChangedFilesExpanded(turnId, expanded);
84− },
85− [props],
86− );
87−
88− return (
89− <div
90− ref={setScrollContainer}
91− data-testid="messages-timeline-scroll-container"
92− className="h-full overflow-y-auto overscroll-y-contain"
93− >
94− <MessagesTimeline
95− {...props}
96− activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID}
97− scrollContainer={scrollContainer}
98− expandedWorkGroups={expandedWorkGroups}
99− onToggleWorkGroup={handleToggleWorkGroup}
100− changedFilesExpandedByTurnId={changedFilesExpandedByTurnId}
101− onSetChangedFilesExpanded={handleSetChangedFilesExpanded}
102− />
103− </div>
104− );
105−}
106−
107−function isoAt(offsetSeconds: number): string {
108− return new Date(Date.UTC(2026, 2, 17, 19, 12, 28) + offsetSeconds * 1_000).toISOString();
109−}
110−
111−function createMessage(input: {
112− id: string;
113− role: ChatMessage["role"];
114− text: string;
115− offsetSeconds: number;
116− attachments?: ChatMessage["attachments"];
117−}): ChatMessage {
118− return {
119− id: MessageId.make(input.id),
120− role: input.role,
121− text: input.text,
122− ...(input.attachments ? { attachments: input.attachments } : {}),
123− createdAt: isoAt(input.offsetSeconds),
124− ...(input.role === "assistant" ? { completedAt: isoAt(input.offsetSeconds + 1) } : {}),
125− streaming: false,
126− };
127−}
128−
129−function createToolWorkEntry(input: {
130− id: string;
131− offsetSeconds: number;
132− label?: string;
133− detail?: string;
134−}): WorkLogEntry {
135− return {
136− id: input.id,
137− createdAt: isoAt(input.offsetSeconds),
138− label: input.label ?? "exec_command completed",
139− ...(input.detail ? { detail: input.detail } : {}),
140− tone: "tool",
141− toolTitle: "exec_command",
142− };
143−}
144−
145−function createPlan(input: {
146− id: string;
147− offsetSeconds: number;
148− planMarkdown: string;
149−}): ProposedPlan {
150− return {
151− id: input.id as ProposedPlan["id"],
152− turnId: null,
153− planMarkdown: input.planMarkdown,
154− implementedAt: null,
155− implementationThreadId: null,
156− createdAt: isoAt(input.offsetSeconds),
157− updatedAt: isoAt(input.offsetSeconds + 1),
158− };
159−}
160−
161−function createBaseTimelineProps(input: {
162− messages?: ChatMessage[];
163− proposedPlans?: ProposedPlan[];
164− workEntries?: WorkLogEntry[];
165− expandedWorkGroups?: Record<string, boolean>;
166− completionDividerBeforeEntryId?: string | null;
167− turnDiffSummaryByAssistantMessageId?: Map<MessageId, TurnDiffSummary>;
168− onVirtualizerSnapshot?: ComponentProps<typeof MessagesTimeline>["onVirtualizerSnapshot"];
169−}): Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer" | "activeThreadEnvironmentId"> {
170− return {
171− hasMessages: true,
172− isWorking: false,
173− activeTurnInProgress: false,
174− activeTurnStartedAt: null,
175− timelineEntries: deriveTimelineEntries(
176− input.messages ?? [],
177− input.proposedPlans ?? [],
178− input.workEntries ?? [],
179− ),
180− completionDividerBeforeEntryId: input.completionDividerBeforeEntryId ?? null,
181− completionSummary: null,
182− turnDiffSummaryByAssistantMessageId: input.turnDiffSummaryByAssistantMessageId ?? new Map(),
183− nowIso: isoAt(10_000),
184− expandedWorkGroups: input.expandedWorkGroups ?? {},
185− onToggleWorkGroup: () => {},
186− changedFilesExpandedByTurnId: {},
187− onSetChangedFilesExpanded: () => {},
188− onOpenTurnDiff: () => {},
189− revertTurnCountByUserMessageId: new Map(),
190− onRevertUserMessage: () => {},
191− isRevertingCheckpoint: false,
192− onImageExpand: () => {},
193− markdownCwd: MARKDOWN_CWD,
194− resolvedTheme: "light",
195− timestampFormat: "locale",
196− workspaceRoot: MARKDOWN_CWD,
197− ...(input.onVirtualizerSnapshot ? { onVirtualizerSnapshot: input.onVirtualizerSnapshot } : {}),
198− };
199−}
200−
201−function createFillerMessages(input: {
202− prefix: string;
203− startOffsetSeconds: number;
204− pairCount: number;
205−}): ChatMessage[] {
206− const messages: ChatMessage[] = [];
207− for (let index = 0; index < input.pairCount; index += 1) {
208− const baseOffset = input.startOffsetSeconds + index * 4;
209− messages.push(
210− createMessage({
211− id: `${input.prefix}-user-${index}`,
212− role: "user",
213− text: `filler user message ${index}`,
214− offsetSeconds: baseOffset,
215− }),
216− );
217− messages.push(
218− createMessage({
219− id: `${input.prefix}-assistant-${index}`,
220− role: "assistant",
221− text: `filler assistant message ${index}`,
222− offsetSeconds: baseOffset + 1,
223− }),
224− );
225− }
226− return messages;
227−}
228−
229−function createChangedFilesSummary(
230− targetMessageId: MessageId,
231− files: TurnDiffSummary["files"],
232−): Map<MessageId, TurnDiffSummary> {
233− return new Map([
234− [
235− targetMessageId,
236− {
237− turnId: "turn-changed-files" as TurnId,
238− completedAt: isoAt(10),
239− assistantMessageId: targetMessageId,
240− files,
241− },
242− ],
243− ]);
244−}
245−
246−function createChangedFilesScenario(input: {
247− name: string;
248− rowId: string;
249− files: TurnDiffSummary["files"];
250− maxEstimateDeltaPx?: number;
251−}): VirtualizationScenario {
252− const beforeMessages = createFillerMessages({
253− prefix: `${input.rowId}-before`,
254− startOffsetSeconds: 0,
255− pairCount: 2,
256− });
257− const afterMessages = createFillerMessages({
258− prefix: `${input.rowId}-after`,
259− startOffsetSeconds: 40,
260− pairCount: 8,
261− });
262− const changedFilesMessage = createMessage({
263− id: input.rowId,
264− role: "assistant",
265− text: "Validation passed on the merged tree.",
266− offsetSeconds: 12,
267− });
268−
269− return {
270− name: input.name,
271− targetRowId: changedFilesMessage.id,
272− props: createBaseTimelineProps({
273− messages: [...beforeMessages, changedFilesMessage, ...afterMessages],
274− turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(
275− changedFilesMessage.id,
276− input.files,
277− ),
278− }),
279− maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 72,
280− };
281−}
282−
283−function createAssistantMessageScenario(input: {
284− name: string;
285− rowId: string;
286− text: string;
287− maxEstimateDeltaPx?: number;
288−}): VirtualizationScenario {
289− const beforeMessages = createFillerMessages({
290− prefix: `${input.rowId}-before`,
291− startOffsetSeconds: 0,
292− pairCount: 2,
293− });
294− const afterMessages = createFillerMessages({
295− prefix: `${input.rowId}-after`,
296− startOffsetSeconds: 40,
297− pairCount: 8,
298− });
299− const assistantMessage = createMessage({
300− id: input.rowId,
301− role: "assistant",
302− text: input.text,
303− offsetSeconds: 12,
304− });
305−
306− return {
307− name: input.name,
308− targetRowId: assistantMessage.id,
309− props: createBaseTimelineProps({
310− messages: [...beforeMessages, assistantMessage, ...afterMessages],
311− }),
312− maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 16,
313− };
314−}
315−
316−function buildStaticScenarios(): VirtualizationScenario[] {
317− const beforeMessages = createFillerMessages({
318− prefix: "before",
319− startOffsetSeconds: 0,
320− pairCount: 2,
321− });
322− const afterMessages = createFillerMessages({
323− prefix: "after",
324− startOffsetSeconds: 40,
325− pairCount: 8,
326− });
327−
328− const longUserMessage = createMessage({
329− id: "target-user-long",
330− role: "user",
331− text: "x".repeat(3_200),
332− offsetSeconds: 12,
333− });
334− const workEntries = Array.from({ length: 4 }, (_, index) =>
335− createToolWorkEntry({
336− id: `target-work-${index}`,
337− offsetSeconds: 12 + index,
338− detail: `tool output line ${index + 1}`,
339− }),
340− );
341− const moderatePlan = createPlan({
342− id: "target-plan",
343− offsetSeconds: 12,
344− planMarkdown: [
345− "# Stabilize virtualization",
346− "",
347− "- Gather baseline measurements",
348− "- Add browser harness coverage",
349− "- Compare estimated and rendered heights",
350− "- Fix the broken rows without broad refactors",
351− "- Re-run lint and typecheck",
352− ].join("\n"),
353− });
354− return [
355− {
356− name: "long user message",
357− targetRowId: longUserMessage.id,
358− props: createBaseTimelineProps({
359− messages: [...beforeMessages, longUserMessage, ...afterMessages],
360− }),
361− maxEstimateDeltaPx: 56,
362− },
363− {
364− name: "grouped work log row",
365− targetRowId: workEntries[0]!.id,
366− props: createBaseTimelineProps({
367− messages: [...beforeMessages, ...afterMessages],
368− workEntries,
369− }),
370− maxEstimateDeltaPx: 56,
371− },
372− {
373− name: "expanded grouped work log row with show more enabled",
374− targetRowId: "target-work-expanded-0",
375− props: createBaseTimelineProps({
376− messages: [...beforeMessages, ...afterMessages],
377− workEntries: Array.from({ length: 10 }, (_, index) =>
378− createToolWorkEntry({
379− id: `target-work-expanded-${index}`,
380− offsetSeconds: 12 + index,
381− detail: `tool output line ${index + 1}`,
382− }),
383− ),
384− expandedWorkGroups: {
385− "target-work-expanded-0": true,
386− },
387− }),
388− maxEstimateDeltaPx: 72,
389− },
390− {
391− name: "proposed plan row",
392− targetRowId: moderatePlan.id,
393− props: createBaseTimelineProps({
394− messages: [...beforeMessages, ...afterMessages],
395− proposedPlans: [moderatePlan],
396− }),
397− maxEstimateDeltaPx: 96,
398− },
399− createAssistantMessageScenario({
400− name: "assistant single-paragraph row with plain prose",
401− rowId: "target-assistant-plain-prose",
402− text: [
403− "The host is still expanding to content somewhere in the grid layout.",
404− "I'm stripping it back further to a plain block container so the test width",
405− "is actually the timeline width.",
406− ].join(" "),
407− }),
408− createAssistantMessageScenario({
409− name: "assistant single-paragraph row with inline code",
410− rowId: "target-assistant-inline-code",
411− text: [
412− "Typecheck found one exact-optional-property issue in the browser harness:",
413− "I was always passing `onVirtualizerSnapshot`, including `undefined`.",
414− "I'm tightening that object construction and rerunning the checks.",
415− ].join(" "),
416− maxEstimateDeltaPx: 28,
417− }),
418− createChangedFilesScenario({
419− name: "assistant changed-files row with a compacted single-chain directory",
420− rowId: "target-assistant-changed-files-single-chain",
421− files: [
422− { path: "apps/web/src/components/chat/ChangedFilesTree.tsx", additions: 37, deletions: 45 },
423− {
424− path: "apps/web/src/components/chat/ChangedFilesTree.test.tsx",
425− additions: 0,
426− deletions: 26,
427− },
428− ],
429− }),
430− createChangedFilesScenario({
431− name: "assistant changed-files row with a branch after compaction",
432− rowId: "target-assistant-changed-files-branch-point",
433− files: [
434− { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 },
435− { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 },
436− {
437− path: "apps/server/src/provider/Layers/CodexAdapter.ts",
438− additions: 27,
439− deletions: 8,
440− },
441− {
442− path: "apps/server/src/provider/Layers/CodexAdapter.test.ts",
443− additions: 36,
444− deletions: 0,
445− },
446− ],
447− }),
448− createChangedFilesScenario({
449− name: "assistant changed-files row with mixed root and nested entries",
450− rowId: "target-assistant-changed-files-mixed-root",
451− files: [
452− { path: "README.md", additions: 5, deletions: 1 },
453− { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 },
454− { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 },
455− ],
456− }),
457− ];
458−}
459−
460−async function nextFrame(): Promise<void> {
461− await new Promise<void>((resolve) => {
462− window.requestAnimationFrame(() => resolve());
463− });
464−}
465−
466−async function waitForLayout(): Promise<void> {
467− await nextFrame();
468− await nextFrame();
469− await nextFrame();
470−}
471−
472−async function setViewport(viewport: { width: number; height: number }): Promise<void> {
473− await page.viewport(viewport.width, viewport.height);
474− await waitForLayout();
475−}
476−
477−async function waitForProductionStyles(): Promise<void> {
478− await vi.waitFor(
479− () => {
480− expect(
481− getComputedStyle(document.documentElement).getPropertyValue("--background").trim(),
482− ).not.toBe("");
483− expect(getComputedStyle(document.body).marginTop).toBe("0px");
484− },
485− { timeout: 4_000, interval: 16 },
486− );
487−}
488−
489−async function waitForElement<T extends Element>(
490− query: () => T | null,
491− errorMessage: string,
492−): Promise<T> {
493− let element: T | null = null;
494− await vi.waitFor(
495− () => {
496− element = query();
497− expect(element, errorMessage).toBeTruthy();
498− },
499− { timeout: 8_000, interval: 16 },
500− );
501− if (!element) {
502− throw new Error(errorMessage);
503− }
504− return element;
505−}
506−
507−async function measureTimelineRow(input: {
508− host: HTMLElement;
509− props: Omit<
510− ComponentProps<typeof MessagesTimeline>,
511− "scrollContainer" | "activeThreadEnvironmentId"
512− >;
513− targetRowId: string;
514−}): Promise<RowMeasurement> {
515− const scrollContainer = await waitForElement(
516− () =>
517− input.host.querySelector<HTMLDivElement>(
518− '[data-testid="messages-timeline-scroll-container"]',
519− ),
520− "Unable to find MessagesTimeline scroll container.",
521− );
522−
523− const rowSelector = `[data-timeline-row-id="${input.targetRowId}"]`;
524− const virtualRowSelector = `[data-virtual-row-id="${input.targetRowId}"]`;
525−
526− let timelineWidthPx = 0;
527− let actualHeightPx = 0;
528− let virtualizerSizePx = 0;
529− let renderedInVirtualizedRegion = false;
530−
531− await vi.waitFor(
532− async () => {
533− scrollContainer.scrollTop = 0;
534− scrollContainer.dispatchEvent(new Event("scroll"));
535− await waitForLayout();
536−
537− const rowElement = input.host.querySelector<HTMLElement>(rowSelector);
538− const virtualRowElement = input.host.querySelector<HTMLElement>(virtualRowSelector);
539− const timelineRoot = input.host.querySelector<HTMLElement>('[data-timeline-root="true"]');
540−
541− expect(rowElement, "Unable to locate target timeline row.").toBeTruthy();
542− expect(virtualRowElement, "Unable to locate target virtualized wrapper.").toBeTruthy();
543− expect(timelineRoot, "Unable to locate MessagesTimeline root.").toBeTruthy();
544−
545− timelineWidthPx = timelineRoot!.getBoundingClientRect().width;
546− actualHeightPx = rowElement!.getBoundingClientRect().height;
547− virtualizerSizePx = Number.parseFloat(virtualRowElement!.dataset.virtualRowSize ?? "0");
548− renderedInVirtualizedRegion = virtualRowElement!.hasAttribute("data-index");
549−
550− expect(timelineWidthPx).toBeGreaterThan(0);
551− expect(actualHeightPx).toBeGreaterThan(0);
552− expect(virtualizerSizePx).toBeGreaterThan(0);
553− expect(renderedInVirtualizedRegion).toBe(true);
554− },
555− { timeout: 8_000, interval: 16 },
556− );
557−
558− const rows = deriveMessagesTimelineRows({
559− timelineEntries: input.props.timelineEntries,
560− completionDividerBeforeEntryId: input.props.completionDividerBeforeEntryId,
561− isWorking: input.props.isWorking,
562− activeTurnStartedAt: input.props.activeTurnStartedAt,
563− });
564− const targetRow = rows.find((row) => row.id === input.targetRowId);
565− expect(targetRow, `Unable to derive target row ${input.targetRowId}.`).toBeTruthy();
566−
567− return {
568− actualHeightPx,
569− estimatedHeightPx: estimateMessagesTimelineRowHeight(targetRow!, {
570− expandedWorkGroups: input.props.expandedWorkGroups,
571− timelineWidthPx,
572− turnDiffSummaryByAssistantMessageId: input.props.turnDiffSummaryByAssistantMessageId,
573− }),
574− timelineWidthPx,
575− virtualizerSizePx,
576− renderedInVirtualizedRegion,
577− };
578−}
579−
580−async function mountMessagesTimeline(input: {
581− props: Omit<
582− ComponentProps<typeof MessagesTimeline>,
583− "scrollContainer" | "activeThreadEnvironmentId"
584− >;
585− viewport?: { width: number; height: number };
586−}) {
587− const viewport = input.viewport ?? DEFAULT_VIEWPORT;
588− await setViewport(viewport);
589− await waitForProductionStyles();
590−
591− const host = document.createElement("div");
592− host.style.width = `${viewport.width}px`;
593− host.style.minWidth = `${viewport.width}px`;
594− host.style.maxWidth = `${viewport.width}px`;
595− host.style.height = `${viewport.height}px`;
596− host.style.minHeight = `${viewport.height}px`;
597− host.style.maxHeight = `${viewport.height}px`;
598− host.style.display = "block";
599− host.style.overflow = "hidden";
600− document.body.append(host);
601−
602− const screen = await render(<MessagesTimelineBrowserHarness {...input.props} />, {
603− container: host,
604− });
605− await waitForLayout();
606−
607− return {
608− host,
609− rerender: async (
610− nextProps: Omit<
611− ComponentProps<typeof MessagesTimeline>,
612− "scrollContainer" | "activeThreadEnvironmentId"
613− >,
614− ) => {
615− await screen.rerender(<MessagesTimelineBrowserHarness {...nextProps} />);
616− await waitForLayout();
617− },
618− setContainerSize: async (nextViewport: { width: number; height: number }) => {
619− await setViewport(nextViewport);
620− host.style.width = `${nextViewport.width}px`;
621− host.style.minWidth = `${nextViewport.width}px`;
622− host.style.maxWidth = `${nextViewport.width}px`;
623− host.style.height = `${nextViewport.height}px`;
624− host.style.minHeight = `${nextViewport.height}px`;
625− host.style.maxHeight = `${nextViewport.height}px`;
626− await waitForLayout();
627− },
628− cleanup: async () => {
629− await screen.unmount();
630− host.remove();
631− },
632− };
633−}
634−
635−async function measureRenderedRowActualHeight(input: {
636− host: HTMLElement;
637− targetRowId: string;
638−}): Promise<number> {
639− const rowElement = await waitForElement(
640− () => input.host.querySelector<HTMLElement>(`[data-timeline-row-id="${input.targetRowId}"]`),
641− `Unable to locate rendered row ${input.targetRowId}.`,
642− );
643− return rowElement.getBoundingClientRect().height;
644−}
645−
646−describe("MessagesTimeline virtualization harness", () => {
647− beforeEach(async () => {
648− document.body.innerHTML = "";
649− await setViewport(DEFAULT_VIEWPORT);
650− });
651−
652− afterEach(() => {
653− document.body.innerHTML = "";
654− });
655−
656− it.each(buildStaticScenarios())("keeps the $name estimate within tolerance", async (scenario) => {
657− const mounted = await mountMessagesTimeline({ props: scenario.props });
658−
659− try {
660− const measurement = await measureTimelineRow({
661− host: mounted.host,
662− props: scenario.props,
663− targetRowId: scenario.targetRowId,
664− });
665−
666− expect(
667− Math.abs(measurement.actualHeightPx - measurement.estimatedHeightPx),
668− `estimate delta for ${scenario.name}`,
669− ).toBeLessThanOrEqual(scenario.maxEstimateDeltaPx);
670− } finally {
671− await mounted.cleanup();
672− }
673− });
674−
675− it("keeps the changed-files row virtualizer size in sync after collapsing directories", async () => {
676− const beforeMessages = createFillerMessages({
677− prefix: "before-collapse",
678− startOffsetSeconds: 0,
679− pairCount: 2,
680− });
681− const afterMessages = createFillerMessages({
682− prefix: "after-collapse",
683− startOffsetSeconds: 40,
684− pairCount: 8,
685− });
686− const targetMessage = createMessage({
687− id: "target-assistant-collapse",
688− role: "assistant",
689− text: "Validation passed on the merged tree.",
690− offsetSeconds: 12,
691− });
692− const props = createBaseTimelineProps({
693− messages: [...beforeMessages, targetMessage, ...afterMessages],
694− turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [
695− { path: ".plans/effect-atom.md", additions: 89, deletions: 0 },
696− {
697− path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts",
698− additions: 4,
699− deletions: 3,
700− },
701− {
702− path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts",
703− additions: 131,
704− deletions: 128,
705− },
706− {
707− path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts",
708− additions: 1,
709− deletions: 1,
710− },
711− { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 },
712− {
713− path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts",
714− additions: 106,
715− deletions: 112,
716− },
717− { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 },
718− { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 },
719− {
720− path: "apps/web/src/components/chat/MessagesTimeline.tsx",
721− additions: 52,
722− deletions: 7,
723− },
724− {
725− path: "apps/web/src/components/chat/ChangedFilesTree.tsx",
726− additions: 32,
727− deletions: 4,
728− },
729− { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 },
730− { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 },
731− ]),
732− });
733− const mounted = await mountMessagesTimeline({
734− props,
735− viewport: { width: 320, height: 700 },
736− });
737−
738− try {
739− const beforeCollapse = await measureTimelineRow({
740− host: mounted.host,
741− props,
742− targetRowId: targetMessage.id,
743− });
744− const targetRowElement = mounted.host.querySelector<HTMLElement>(
745− `[data-timeline-row-id="${targetMessage.id}"]`,
746− );
747− expect(targetRowElement, "Unable to locate target changed-files row.").toBeTruthy();
748−
749− const collapseAllButton =
750− Array.from(targetRowElement!.querySelectorAll<HTMLButtonElement>("button")).find(
751− (button) => button.textContent?.trim() === "Collapse all",
752− ) ?? null;
753− expect(collapseAllButton, 'Unable to find "Collapse all" button.').toBeTruthy();
754−
755− collapseAllButton!.click();
756−
757− await vi.waitFor(
758− async () => {
759− const afterCollapse = await measureTimelineRow({
760− host: mounted.host,
761− props,
762− targetRowId: targetMessage.id,
763− });
764− expect(afterCollapse.actualHeightPx).toBeLessThan(beforeCollapse.actualHeightPx - 24);
765− },
766− { timeout: 8_000, interval: 16 },
767− );
768−
769− const afterCollapse = await measureTimelineRow({
770− host: mounted.host,
771− props,
772− targetRowId: targetMessage.id,
773− });
774− expect(
775− Math.abs(afterCollapse.actualHeightPx - afterCollapse.virtualizerSizePx),
776− ).toBeLessThanOrEqual(8);
777− } finally {
778− await mounted.cleanup();
779− }
780− });
781−
782− it("keeps the work-log row virtualizer size in sync after show more expands the group", async () => {
783− const beforeMessages = createFillerMessages({
784− prefix: "before-worklog-expand",
785− startOffsetSeconds: 0,
786− pairCount: 2,
787− });
788− const afterMessages = createFillerMessages({
789− prefix: "after-worklog-expand",
790− startOffsetSeconds: 40,
791− pairCount: 8,
792− });
793− const workEntries = Array.from({ length: 10 }, (_, index) =>
794− createToolWorkEntry({
795− id: `target-work-toggle-${index}`,
796− offsetSeconds: 12 + index,
797− detail: `tool output line ${index + 1}`,
798− }),
799− );
800− const props = createBaseTimelineProps({
801− messages: [...beforeMessages, ...afterMessages],
802− workEntries,
803− });
804− const mounted = await mountMessagesTimeline({ props });
805−
806− try {
807− const beforeExpand = await measureTimelineRow({
808− host: mounted.host,
809− props,
810− targetRowId: workEntries[0]!.id,
811− });
812− const targetRowElement = mounted.host.querySelector<HTMLElement>(
813− `[data-timeline-row-id="${workEntries[0]!.id}"]`,
814− );
815− expect(targetRowElement, "Unable to locate target work-log row.").toBeTruthy();
816−
817− const showMoreButton =
818− Array.from(targetRowElement!.querySelectorAll<HTMLButtonElement>("button")).find((button) =>
819− button.textContent?.includes("Show 4 more"),
820− ) ?? null;
821− expect(showMoreButton, 'Unable to find "Show more" button.').toBeTruthy();
822−
823− showMoreButton!.click();
824−
825− await vi.waitFor(
826− async () => {
827− const afterExpand = await measureTimelineRow({
828− host: mounted.host,
829− props,
830− targetRowId: workEntries[0]!.id,
831− });
832− expect(afterExpand.actualHeightPx).toBeGreaterThan(beforeExpand.actualHeightPx + 72);
833− },
834− { timeout: 8_000, interval: 16 },
835− );
836−
837− const afterExpand = await measureTimelineRow({
838− host: mounted.host,
839− props,
840− targetRowId: workEntries[0]!.id,
841− });
842− expect(
843− Math.abs(afterExpand.actualHeightPx - afterExpand.virtualizerSizePx),
844− ).toBeLessThanOrEqual(8);
845− } finally {
846− await mounted.cleanup();
847− }
848− });
849−
850− it("preserves measured tail row heights when rows transition into virtualization", async () => {
851− const beforeMessages = createFillerMessages({
852− prefix: "tail-transition-before",
853− startOffsetSeconds: 0,
854− pairCount: 1,
855− });
856− const afterMessages = createFillerMessages({
857− prefix: "tail-transition-after",
858− startOffsetSeconds: 40,
859− pairCount: 3,
860− });
861− const targetMessage = createMessage({
862− id: "target-tail-transition",
863− role: "assistant",
864− text: "Validation passed on the merged tree.",
865− offsetSeconds: 12,
866− });
867− let latestSnapshot: VirtualizerSnapshot | null = null;
868− const initialProps = createBaseTimelineProps({
869− messages: [...beforeMessages, targetMessage, ...afterMessages],
870− turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [
871− { path: ".plans/effect-atom.md", additions: 89, deletions: 0 },
872− {
873− path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts",
874− additions: 4,
875− deletions: 3,
876− },
877− {
878− path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts",
879− additions: 131,
880− deletions: 128,
881− },
882− {
883− path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts",
884− additions: 1,
885− deletions: 1,
886− },
887− { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 },
888− {
889− path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts",
890− additions: 106,
891− deletions: 112,
892− },
893− { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 },
894− { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 },
895− {
896− path: "apps/web/src/components/chat/MessagesTimeline.tsx",
897− additions: 52,
898− deletions: 7,
899− },
900− {
901− path: "apps/web/src/components/chat/ChangedFilesTree.tsx",
902− additions: 32,
903− deletions: 4,
904− },
905− { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 },
906− { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 },
907− ]),
908− onVirtualizerSnapshot: (snapshot) => {
909− latestSnapshot = {
910− totalSize: snapshot.totalSize,
911− measurements: snapshot.measurements,
912− };
913− },
914− });
915−
916− const mounted = await mountMessagesTimeline({ props: initialProps });
917−
918− try {
919− const initiallyRenderedHeight = await measureRenderedRowActualHeight({
920− host: mounted.host,
921− targetRowId: targetMessage.id,
922− });
923−
924− const appendedProps = createBaseTimelineProps({
925− messages: [
926− ...beforeMessages,
927− targetMessage,
928− ...afterMessages,
929− ...createFillerMessages({
930− prefix: "tail-transition-extra",
931− startOffsetSeconds: 120,
932− pairCount: 8,
933− }),
934− ],
935− turnDiffSummaryByAssistantMessageId: initialProps.turnDiffSummaryByAssistantMessageId,
936− onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot,
937− });
938− await mounted.rerender(appendedProps);
939−
940− const scrollContainer = await waitForElement(
941− () =>
942− mounted.host.querySelector<HTMLDivElement>(
943− '[data-testid="messages-timeline-scroll-container"]',
944− ),
945− "Unable to find MessagesTimeline scroll container.",
946− );
947− scrollContainer.scrollTop = scrollContainer.scrollHeight;
948− scrollContainer.dispatchEvent(new Event("scroll"));
949− await waitForLayout();
950−
951− await vi.waitFor(
952− () => {
953− const measurement = latestSnapshot?.measurements.find(
954− (entry) => entry.id === targetMessage.id,
955− );
956− expect(
957− measurement,
958− "Expected target row to transition into virtualizer cache.",
959− ).toBeTruthy();
960− expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual(
961− 8,
962− );
963− },
964− { timeout: 8_000, interval: 16 },
965− );
966− } finally {
967− await mounted.cleanup();
968− }
969− });
970−
971− it("preserves measured tail image row heights when rows transition into virtualization", async () => {
972− const beforeMessages = createFillerMessages({
973− prefix: "tail-image-before",
974− startOffsetSeconds: 0,
975− pairCount: 1,
976− });
977− const afterMessages = createFillerMessages({
978− prefix: "tail-image-after",
979− startOffsetSeconds: 40,
980− pairCount: 3,
981− });
982− const targetMessage = createMessage({
983− id: "target-tail-image-transition",
984− role: "user",
985− text: "Here is a narrow screenshot.",
986− offsetSeconds: 12,
987− attachments: [
988− {
989− type: "image",
990− id: "target-tail-image",
991− name: "narrow.svg",
992− mimeType: "image/svg+xml",
993− sizeBytes: 512,
994− previewUrl:
995− "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",
996− },
997− ],
998− });
999− let latestSnapshot: VirtualizerSnapshot | null = null;
1000− const initialProps = createBaseTimelineProps({
1001− messages: [...beforeMessages, targetMessage, ...afterMessages],
1002− onVirtualizerSnapshot: (snapshot) => {
1003− latestSnapshot = {
1004− totalSize: snapshot.totalSize,
1005− measurements: snapshot.measurements,
1006− };
1007− },
1008− });
1009− const mounted = await mountMessagesTimeline({ props: initialProps });
1010−
1011− try {
1012− await vi.waitFor(
1013− () => {
1014− const image = mounted.host.querySelector<HTMLImageElement>(
1015− `[data-timeline-row-id="${targetMessage.id}"] img`,
1016− );
1017− expect(image?.naturalHeight ?? 0).toBeGreaterThan(0);
1018− },
1019− { timeout: 8_000, interval: 16 },
1020− );
1021−
1022− const initiallyRenderedHeight = await measureRenderedRowActualHeight({
1023− host: mounted.host,
1024− targetRowId: targetMessage.id,
1025− });
1026− const appendedProps = createBaseTimelineProps({
1027− messages: [
1028− ...beforeMessages,
1029− targetMessage,
1030− ...afterMessages,
1031− ...createFillerMessages({
1032− prefix: "tail-image-extra",
1033− startOffsetSeconds: 120,
1034− pairCount: 8,
1035− }),
1036− ],
1037− onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot,
1038− });
1039− await mounted.rerender(appendedProps);
1040−
1041− const scrollContainer = await waitForElement(
1042− () =>
1043− mounted.host.querySelector<HTMLDivElement>(
1044− '[data-testid="messages-timeline-scroll-container"]',
1045− ),
1046− "Unable to find MessagesTimeline scroll container.",
1047− );
1048− scrollContainer.scrollTop = scrollContainer.scrollHeight;
1049− scrollContainer.dispatchEvent(new Event("scroll"));
1050− await waitForLayout();
1051−
1052− await vi.waitFor(
1053− () => {
1054− const measurement = latestSnapshot?.measurements.find(
1055− (entry) => entry.id === targetMessage.id,
1056− );
1057− expect(
1058− measurement,
1059− "Expected target image row to transition into virtualizer cache.",
1060− ).toBeTruthy();
1061− expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual(
1062− 8,
1063− );
1064− },
1065− { timeout: 8_000, interval: 16 },
1066− );
1067− } finally {
1068− await mounted.cleanup();
1069− }
1070− });
1071−});
apps/web/src/components/timelineHeight.test.ts+0−151
@@ -1,151 +0,0 @@
1−import { describe, expect, it } from "vitest";
2−
3−import { appendTerminalContextsToPrompt } from "../lib/terminalContext";
4−import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts";
5−import { estimateTimelineMessageHeight } from "./timelineHeight";
6−
7−describe("estimateTimelineMessageHeight", () => {
8− it("uses assistant sizing rules for assistant messages", () => {
9− expect(
10− estimateTimelineMessageHeight({
11− role: "assistant",
12− text: "a".repeat(144),
13− }),
14− ).toBe(86.5);
15− });
16−
17− it("uses assistant sizing rules for system messages", () => {
18− expect(
19− estimateTimelineMessageHeight({
20− role: "system",
21− text: "a".repeat(144),
22− }),
23− ).toBe(86.5);
24− });
25−
26− it("adds one attachment row for one or two user attachments", () => {
27− expect(
28− estimateTimelineMessageHeight({
29− role: "user",
30− text: "hello",
31− attachments: [{ id: "1" }],
32− }),
33− ).toBe(234);
34−
35− expect(
36− estimateTimelineMessageHeight({
37− role: "user",
38− text: "hello",
39− attachments: [{ id: "1" }, { id: "2" }],
40− }),
41− ).toBe(234);
42− });
43−
44− it("adds a second attachment row for three or four user attachments", () => {
45− expect(
46− estimateTimelineMessageHeight({
47− role: "user",
48− text: "hello",
49− attachments: [{ id: "1" }, { id: "2" }, { id: "3" }],
50− }),
51− ).toBe(350);
52−
53− expect(
54− estimateTimelineMessageHeight({
55− role: "user",
56− text: "hello",
57− attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }],
58− }),
59− ).toBe(350);
60− });
61−
62− it("does not cap long user message estimates", () => {
63− expect(
64− estimateTimelineMessageHeight({
65− role: "user",
66− text: "a".repeat(56 * 120),
67− }),
68− ).toBe(2736);
69− });
70−
71− it("counts explicit newlines for user message estimates", () => {
72− expect(
73− estimateTimelineMessageHeight({
74− role: "user",
75− text: "first\nsecond\nthird",
76− }),
77− ).toBe(162);
78− });
79−
80− it("adds terminal context chrome without counting the hidden block as message text", () => {
81− const prompt = appendTerminalContextsToPrompt("Investigate this", [
82− {
83− terminalId: "default",
84− terminalLabel: "Terminal 1",
85− lineStart: 40,
86− lineEnd: 43,
87− text: [
88− "git status",
89− "M apps/web/src/components/chat/MessagesTimeline.tsx",
90− "?? tmp",
91− "",
92− ].join("\n"),
93− },
94− ]);
95−
96− expect(
97− estimateTimelineMessageHeight({
98− role: "user",
99− text: prompt,
100− }),
101− ).toBe(
102− estimateTimelineMessageHeight({
103− role: "user",
104− text: `${buildInlineTerminalContextText([{ header: "Terminal 1 lines 40-43" }])} Investigate this`,
105− }),
106− );
107− });
108−
109− it("uses narrower width to increase user line wrapping", () => {
110− const message = {
111− role: "user" as const,
112− text: "a".repeat(52),
113− };
114−
115− expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(140);
116− expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(118);
117− });
118−
119− it("does not clamp user wrapping too aggressively on very narrow layouts", () => {
120− const message = {
121− role: "user" as const,
122− text: "a".repeat(20),
123− };
124−
125− expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(184);
126− expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(118);
127− });
128−
129− it("uses narrower width to increase assistant line wrapping", () => {
130− const message = {
131− role: "assistant" as const,
132− text: "a".repeat(200),
133− };
134−
135− expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(154.75);
136− expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(86.5);
137− });
138−
139− it("treats inline code as wider when estimating assistant markdown wrapping", () => {
140− const message = {
141− role: "assistant" as const,
142− text: [
143− "Typecheck found one exact-optional-property issue in the browser harness:",
144− "I was always passing `onVirtualizerSnapshot`, including `undefined`.",
145− "I'm tightening that object construction and rerunning the checks.",
146− ].join(" "),
147− };
148−
149− expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(109.25);
150− });
151−});
apps/web/src/components/timelineHeight.ts+0−131
@@ -1,131 +0,0 @@
1−import { deriveDisplayedUserMessageState } from "../lib/terminalContext";
2−import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts";
3−
4−const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72;
5−const USER_CHARS_PER_LINE_FALLBACK = 56;
6−const USER_LINE_HEIGHT_PX = 22;
7−const 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.
11−const ASSISTANT_BASE_HEIGHT_PX = 41;
12−const USER_BASE_HEIGHT_PX = 96;
13−const ATTACHMENTS_PER_ROW = 2;
14−// Full-app browser measurements land closer to a ~116px attachment row once
15−// the bubble shrinks to content width, so calibrate the estimate to that DOM.
16−const USER_ATTACHMENT_ROW_HEIGHT_PX = 116;
17−const USER_BUBBLE_WIDTH_RATIO = 0.8;
18−const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32;
19−const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8;
20−const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4;
21−const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2;
22−const MIN_USER_CHARS_PER_LINE = 4;
23−const MIN_ASSISTANT_CHARS_PER_LINE = 20;
24−const ASSISTANT_INLINE_CODE_WIDTH_MULTIPLIER = 1.2;
25−const ASSISTANT_INLINE_CODE_WRAP_OVERHEAD_CHARS = 2;
26−const INLINE_CODE_SPAN_REGEX = /`([^`\n]+)`/g;
27−
28−interface TimelineMessageHeightInput {
29− role: "user" | "assistant" | "system";
30− text: string;
31− attachments?: ReadonlyArray<{ id: string }>;
32−}
33−
34−interface TimelineHeightEstimateLayout {
35− timelineWidthPx: number | null;
36−}
37−
38−function estimateWrappedLineCount(text: string, charsPerLine: number): number {
39− if (text.length === 0) return 1;
40−
41− // Avoid allocating via split for long logs; iterate once and count wrapped lines.
42− let lines = 0;
43− let currentLineLength = 0;
44− for (let index = 0; index < text.length; index += 1) {
45− if (text.charCodeAt(index) === 10) {
46− lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine));
47− currentLineLength = 0;
48− continue;
49− }
50− currentLineLength += 1;
51− }
52−
53− lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine));
54− return lines;
55−}
56−
57−function isFinitePositiveNumber(value: number | null | undefined): value is number {
58− return typeof value === "number" && Number.isFinite(value) && value > 0;
59−}
60−
61−function estimateCharsPerLineForUser(timelineWidthPx: number | null): number {
62− if (!isFinitePositiveNumber(timelineWidthPx)) return USER_CHARS_PER_LINE_FALLBACK;
63− const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO;
64− const textWidthPx = Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0);
65− return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_MONO_AVG_CHAR_WIDTH_PX));
66−}
67−
68−function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): number {
69− if (!isFinitePositiveNumber(timelineWidthPx)) return ASSISTANT_CHARS_PER_LINE_FALLBACK;
70− const textWidthPx = Math.max(timelineWidthPx - ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX, 0);
71− return Math.max(
72− MIN_ASSISTANT_CHARS_PER_LINE,
73− Math.floor(textWidthPx / ASSISTANT_AVG_CHAR_WIDTH_PX),
74− );
75−}
76−
77−function expandAssistantInlineCodeForEstimate(text: string) {
78− return text.replace(INLINE_CODE_SPAN_REGEX, (_match, code: string) =>
79− "x".repeat(
80− Math.max(
81− code.length + 2,
82− Math.ceil(
83− code.length * ASSISTANT_INLINE_CODE_WIDTH_MULTIPLIER +
84− ASSISTANT_INLINE_CODE_WRAP_OVERHEAD_CHARS,
85− ),
86− ),
87− ),
88− );
89−}
90−
91−export function estimateTimelineMessageHeight(
92− message: TimelineMessageHeightInput,
93− layout: TimelineHeightEstimateLayout = { timelineWidthPx: null },
94−): number {
95− if (message.role === "assistant") {
96− const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx);
97− const estimatedLines = estimateWrappedLineCount(
98− expandAssistantInlineCodeForEstimate(message.text),
99− charsPerLine,
100− );
101− return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX;
102− }
103−
104− if (message.role === "user") {
105− const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx);
106− const displayedUserMessage = deriveDisplayedUserMessageState(message.text);
107− const renderedText =
108− displayedUserMessage.contexts.length > 0
109− ? [
110− buildInlineTerminalContextText(displayedUserMessage.contexts),
111− displayedUserMessage.visibleText,
112− ]
113− .filter((part) => part.length > 0)
114− .join(" ")
115− : displayedUserMessage.visibleText;
116− const estimatedLines = estimateWrappedLineCount(renderedText, charsPerLine);
117− const attachmentCount = message.attachments?.length ?? 0;
118− const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW);
119− const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX;
120− return USER_BASE_HEIGHT_PX + estimatedLines * USER_LINE_HEIGHT_PX + attachmentHeight;
121− }
122−
123− // `system` messages are not rendered in the chat timeline, but keep a stable
124− // explicit branch in case they are present in timeline data.
125− const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx);
126− const estimatedLines = estimateWrappedLineCount(
127− expandAssistantInlineCodeForEstimate(message.text),
128− charsPerLine,
129− );
130− return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX;
131−}
apps/web/src/components/ui/combobox.tsx+15−0
@@ -275,6 +275,20 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { · 274 unmodified lines
275275 );
276276 }
277277
278+/**
279+ * A variant of `ComboboxList` without `ScrollArea`, for use when
280+ * an external virtualizer (e.g. LegendList) owns the scroll container.
281+ */
282+function ComboboxListVirtualized({ className, ...props }: ComboboxPrimitive.List.Props) {
283+ return (
284+ <ComboboxPrimitive.List
285+ className={cn("not-empty:px-1 not-empty:py-1", className)}
286+ data-slot="combobox-list"
287+ {...props}
288+ />
289+ );
290+}
291+
278292 function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
279293 return <ComboboxPrimitive.Clear className={className} data-slot="combobox-clear" {...props} />;
280294 }
@@ -371,6 +385,7 @@ export { · 90 unmodified lines
371385 ComboboxEmpty,
372386 ComboboxValue,
373387 ComboboxList,
388+ ComboboxListVirtualized,
374389 ComboboxClear,
375390 ComboboxStatus,
376391 ComboboxRow,
bun.lock+3−5
@@ -80,6 +80,7 @@ · 79 unmodified lines
8080 "@dnd-kit/utilities": "^3.2.2",
8181 "@effect/atom-react": "catalog:",
8282 "@formkit/auto-animate": "^0.9.0",
83+ "@legendapp/list": "3.0.0-beta.44",
8384 "@lexical/react": "^0.41.0",
8485 "@pierre/diffs": "^1.1.0-beta.16",
8586 "@t3tools/client-runtime": "workspace:*",
@@ -88,7 +89,6 @@ · 2 unmodified lines
8889 "@tanstack/react-pacer": "^0.19.4",
8990 "@tanstack/react-query": "^5.90.0",
9091 "@tanstack/react-router": "^1.160.2",
91− "@tanstack/react-virtual": "^3.13.18",
9292 "@xterm/addon-fit": "^0.11.0",
9393 "@xterm/xterm": "^6.0.0",
9494 "class-variance-authority": "^0.7.1",
@@ -462,6 +462,8 @@ · 367 unmodified lines
462462
463463 "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
464464
465+ "@legendapp/list": ["@legendapp/list@3.0.0-beta.44", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*" } }, "sha512-loGRve78NuZ5k8Z54ZSDNOtv3dVBM1SeBCRtm1EYtZiDIZ8SyMVcYpUGgFpGuNKk71+9/NuM9hvScrgf7+4E+A=="],
466+
465467 "@lexical/clipboard": ["@lexical/clipboard@0.41.0", "", { "dependencies": { "@lexical/html": "0.41.0", "@lexical/list": "0.41.0", "@lexical/selection": "0.41.0", "@lexical/utils": "0.41.0", "lexical": "0.41.0" } }, "sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA=="],
466468
467469 "@lexical/code": ["@lexical/code@0.41.0", "", { "dependencies": { "@lexical/utils": "0.41.0", "lexical": "0.41.0", "prismjs": "^1.30.0" } }, "sha512-0hoNi1KC9/N3SBOGcOcFqnT0OpwmcRRAhfxTKMGqfCtCvAMzULVwZ8RWc9/NV9bKYESgBTW5D9xkDANP2mspHg=="],
@@ -740,8 +742,6 @@ · 272 unmodified lines
740742
741743 "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="],
742744
743− "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.23", "", { "dependencies": { "@tanstack/virtual-core": "3.13.23" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ=="],
744−
745745 "@tanstack/router-core": ["@tanstack/router-core@1.167.3", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-M/CxrTGKk1fsySJjd+Pzpbi3YLDz+cJSutDjSTMy12owWlOgHV/I6kzR0UxyaBlHraM6XgMHNA0XdgsS1fa4Nw=="],
746746
747747 "@tanstack/router-generator": ["@tanstack/router-generator@1.166.11", "", { "dependencies": { "@tanstack/router-core": "1.167.3", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.6", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-Q/49wxURbft1oNOvo/eVAWZq/lNLK3nBGlavqhLToAYXY6LCzfMtRlE/y3XPHzYC9pZc09u5jvBR1k1E4hyGDQ=="],
@@ -752,8 +752,6 @@ · 4 unmodified lines
752752
753753 "@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="],
754754
755− "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.23", "", {}, "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="],
756−
757755 "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.6", "", {}, "sha512-EGWs9yvJA821pUkwkiZLQW89CzUumHyJy8NKq229BubyoWXfDw1oWnTJYSS/hhbLiwP9+KpopjeF5wWwnCCyeQ=="],
758756
759757 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],