authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-12 23:23:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-12 23:23:14-07:00
log96c9306dbd9fac8dff76133da0a19ab44de7caec
tree5ba7d4f1e73f37f6399dc3f4a33febf2f1611238
parent94d13a2b754f78b57b3a936bc77494a06a88a504
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

15 files changed, 895 insertions(+), 2672 deletions(-)

apps/web/package.json+1-1
......@@ -21,6 +21,7 @@
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 @@
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 deleted-62
......@@ -1,62 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "./chat-scroll";
4
5describe("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 deleted-24
......@@ -1,24 +0,0 @@
1export const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
2
3interface ScrollPosition {
4 scrollTop: number;
5 clientHeight: number;
6 scrollHeight: number;
7}
8
9export 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 @@
11import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
22import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts";
33import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
4import { useVirtualizer } from "@tanstack/react-virtual";
4import { LegendList, type LegendListRef } from "@legendapp/list/react";
55import { ChevronDownIcon } from "lucide-react";
66import {
7 type CSSProperties,
87 useCallback,
98 useDeferredValue,
109 useEffect,
......@@ -38,6 +37,7 @@ import {
3837 ComboboxInput,
3938 ComboboxItem,
4039 ComboboxList,
40 ComboboxListVirtualized,
4141 ComboboxPopup,
4242 ComboboxStatus,
4343 ComboboxTrigger,
......@@ -390,7 +390,7 @@ export function BranchToolbarBranchSelector({
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({
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({
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({
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({
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({
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({
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({
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({
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";
4747import { useUiStateStore } from "../uiStateStore";
4848import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers";
4949import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";
50import { estimateTimelineMessageHeight } from "./timelineHeight";
50
5151import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
5252
5353vi.mock("../lib/gitStatusState", () => ({
......@@ -112,28 +112,9 @@ const COMPACT_FOOTER_VIEWPORT: ViewportSpec = {
112112 textTolerancePx: 56,
113113 attachmentTolerancePx: 56,
114114};
115const 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[];
121const 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
127interface UserRowMeasurement {
128 measuredRowHeightPx: number;
129 timelineWidthMeasuredPx: number;
130 renderedInVirtualizedRegion: boolean;
131}
132
133115interface 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> {
13781359 );
13791360}
13801361
1381async 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
1402async 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
14661362async function mountChatView(options: {
14671363 viewport: ViewportSpec;
14681364 snapshot: OrchestrationReadModel;
......@@ -1515,7 +1411,6 @@ async function mountChatView(options: {
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: {
15291424 };
15301425}
15311426
1532async 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
15491427describe("ChatView timeline estimator parity (full app)", () => {
15501428 beforeAll(async () => {
15511429 fixture = buildFixture(
......@@ -1633,39 +1511,6 @@ describe("ChatView timeline estimator parity (full app)", () => {
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)", () => {
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)", () => {
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 {
2828import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model";
2929import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
3030import { truncate } from "@t3tools/shared/String";
31import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
31import { Debouncer } from "@tanstack/react-pacer";
32import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
3233import { useNavigate, useSearch } from "@tanstack/react-router";
3334import { useShallow } from "zustand/react/shallow";
3435import { useGitStatus } from "~/lib/gitStatusState";
......@@ -57,7 +58,7 @@ import {
5758 isLatestTurnSettled,
5859 formatElapsed,
5960} from "../session-logic";
60import { isScrollContainerNearBottom } from "../chat-scroll";
61import { type LegendListRef } from "@legendapp/list/react";
6162import {
6263 buildPendingUserInputAnswers,
6364 derivePendingUserInputProgress,
......@@ -174,7 +175,6 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT =
174175const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
175176const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
176177const EMPTY_PROVIDERS: ServerProvider[] = [];
177const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID: Record<string, boolean> = {};
178178const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
179179
180180type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
......@@ -590,18 +590,9 @@ export default function ChatView(props: ChatViewProps) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
30462873 activeThread,
30472874 activeProposedPlan,
30482875 beginLocalDispatch,
3049 forceStickToBottom,
30502876 isConnecting,
30512877 isSendBusy,
30522878 isServerThread,
......@@ -3241,12 +3067,6 @@ export default function ChatView(props: ChatViewProps) {
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) {
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) {
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) {
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
......@@ -1,5 +1,6 @@
11import { describe, expect, it } from "vitest";
22import {
3 computeStableMessagesTimelineRows,
34 computeMessageDurationStart,
45 deriveMessagesTimelineRows,
56 normalizeCompactToolLabel,
......@@ -252,6 +253,8 @@ describe("deriveMessagesTimelineRows", () => {
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", () => {
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
333describe("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 @@
1import { type MessageId } from "@t3tools/contracts";
21import { type TimelineEntry, type WorkLogEntry } from "../../session-logic";
3import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree";
42import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
5import { estimateTimelineMessageHeight } from "../timelineHeight";
3import { type MessageId } from "@t3tools/contracts";
64
75export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
86
......@@ -28,6 +26,8 @@ export type MessagesTimelineRow =
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 =
3737 }
3838 | { kind: "working"; id: string; createdAt: string | null };
3939
40export interface StableMessagesTimelineRowsState {
41 byId: Map<string, MessagesTimelineRow>;
42 result: MessagesTimelineRow[];
43}
44
4045export function computeMessageDurationStart(
4146 messages: ReadonlyArray<TimelineDurationMessage>,
4247): Map<string, string> {
......@@ -107,6 +112,8 @@ export function deriveMessagesTimelineRows(input: {
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: {
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: {
176191 return nextRows;
177192}
178193
179export 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;
194export 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
207function 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
224function 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. */
215function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean {
216 if (a.kind !== b.kind || a.id !== b.id) return false;
228217
229function 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
237function 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 deleted-151
......@@ -1,151 +0,0 @@
1import { MessageId } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";
3import { beforeAll, describe, expect, it, vi } from "vitest";
4
5function matchMedia() {
6 return {
7 matches: false,
8 addEventListener: () => {},
9 removeEventListener: () => {},
10 };
11}
12
13beforeAll(() => {
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
45const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never;
46
47describe("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 @@
11import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts";
22import {
3 createContext,
34 memo,
5 use,
46 useCallback,
57 useEffect,
6 useLayoutEffect,
78 useMemo,
89 useRef,
910 useState,
1011 type ReactNode,
1112} from "react";
12import {
13 measureElement as measureVirtualElement,
14 type VirtualItem,
15 useVirtualizer,
16} from "@tanstack/react-virtual";
13import { LegendList, type LegendListRef } from "@legendapp/list/react";
1714import { deriveTimelineEntries, formatElapsed } from "../../session-logic";
18import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";
1915import { type TurnDiffSummary } from "../../types";
2016import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
2117import ChatMarkdown from "../ChatMarkdown";
......@@ -34,18 +30,18 @@ import {
3430 ZapIcon,
3531} from "lucide-react";
3632import { Button } from "../ui/button";
37import { clamp } from "effect/Number";
3833import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
3934import { ProposedPlanCard } from "./ProposedPlanCard";
4035import { ChangedFilesTree } from "./ChangedFilesTree";
4136import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
4237import { MessageCopyButton } from "./MessageCopyButton";
4338import {
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";
5147import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
......@@ -55,6 +51,7 @@ import {
5551 type ParsedTerminalContextEntry,
5652} from "~/lib/terminalContext";
5753import { cn } from "~/lib/utils";
54import { useUiStateStore } from "~/uiStateStore";
5855import { type TimestampFormat } from "@t3tools/contracts/settings";
5956import { formatTimestamp } from "../../timestampFormat";
6057
......@@ -64,7 +61,35 @@ import {
6461 textContainsInlineTerminalContextLabels,
6562} from "./userMessageTerminalContexts";
6663
67const 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
71interface 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
88const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
89
90// ---------------------------------------------------------------------------
91// Props (public API)
92// ---------------------------------------------------------------------------
6893
6994interface MessagesTimelineProps {
7095 hasMessages: boolean;
......@@ -72,16 +97,12 @@ interface MessagesTimelineProps {
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 {
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
108123export 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({
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]);
254function 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
262type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
263type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
264type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];
265type TimelineRow = MessagesTimelineRow;
266
267function 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({
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({
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({
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({
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({
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({
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({
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({
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({
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({
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. */
473function 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. */
483function 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. */
510const 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
636type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
637type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
638type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];
639type TimelineRow = MessagesTimelineRow;
640
641function 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. */
555const 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). */
583function 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
664function 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
673651const UserMessageTerminalContextInlineLabel = memo(
674652 function UserMessageTerminalContextInlineLabel(props: { context: ParsedTerminalContextEntry }) {
......@@ -774,6 +752,62 @@ const UserMessageBody = memo(function UserMessageBody(props: {
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. */
762function 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
779function 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
802function 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
777811function workToneIcon(tone: TimelineWorkEntry["tone"]): {
778812 icon: LucideIcon;
779813 className: string;
apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx deleted-1071
......@@ -1,1071 +0,0 @@
1import "../../index.css";
2
3import { MessageId, type TurnId } from "@t3tools/contracts";
4import { page } from "vitest/browser";
5import { useCallback, useState, type ComponentProps } from "react";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7import { render } from "vitest-browser-react";
8
9import { deriveTimelineEntries, type WorkLogEntry } from "../../session-logic";
10import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
11import { MessagesTimeline } from "./MessagesTimeline";
12import {
13 deriveMessagesTimelineRows,
14 estimateMessagesTimelineRowHeight,
15} from "./MessagesTimeline.logic";
16
17const DEFAULT_VIEWPORT = {
18 width: 960,
19 height: 1_100,
20};
21const MARKDOWN_CWD = "/repo/project";
22const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never;
23
24interface RowMeasurement {
25 actualHeightPx: number;
26 estimatedHeightPx: number;
27 timelineWidthPx: number;
28 virtualizerSizePx: number;
29 renderedInVirtualizedRegion: boolean;
30}
31
32interface VirtualizationScenario {
33 name: string;
34 targetRowId: string;
35 props: Omit<
36 ComponentProps<typeof MessagesTimeline>,
37 "scrollContainer" | "activeThreadEnvironmentId"
38 >;
39 maxEstimateDeltaPx: number;
40}
41
42interface 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
54function 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
107function isoAt(offsetSeconds: number): string {
108 return new Date(Date.UTC(2026, 2, 17, 19, 12, 28) + offsetSeconds * 1_000).toISOString();
109}
110
111function 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
129function 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
145function 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
161function 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
201function 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
229function 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
246function 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
283function 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
316function 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
460async function nextFrame(): Promise<void> {
461 await new Promise<void>((resolve) => {
462 window.requestAnimationFrame(() => resolve());
463 });
464}
465
466async function waitForLayout(): Promise<void> {
467 await nextFrame();
468 await nextFrame();
469 await nextFrame();
470}
471
472async function setViewport(viewport: { width: number; height: number }): Promise<void> {
473 await page.viewport(viewport.width, viewport.height);
474 await waitForLayout();
475}
476
477async 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
489async 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
507async 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
580async 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
635async 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
646describe("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 deleted-151
......@@ -1,151 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import { appendTerminalContextsToPrompt } from "../lib/terminalContext";
4import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts";
5import { estimateTimelineMessageHeight } from "./timelineHeight";
6
7describe("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 deleted-131
......@@ -1,131 +0,0 @@
1import { deriveDisplayedUserMessageState } from "../lib/terminalContext";
2import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts";
3
4const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72;
5const USER_CHARS_PER_LINE_FALLBACK = 56;
6const USER_LINE_HEIGHT_PX = 22;
7const ASSISTANT_LINE_HEIGHT_PX = 22.75;
8// Assistant rows render as markdown content plus a compact timestamp meta line.
9// The DOM baseline is much smaller than the user bubble chrome, so model it
10// separately instead of reusing the old shared constant.
11const ASSISTANT_BASE_HEIGHT_PX = 41;
12const USER_BASE_HEIGHT_PX = 96;
13const 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.
16const USER_ATTACHMENT_ROW_HEIGHT_PX = 116;
17const USER_BUBBLE_WIDTH_RATIO = 0.8;
18const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32;
19const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8;
20const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4;
21const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2;
22const MIN_USER_CHARS_PER_LINE = 4;
23const MIN_ASSISTANT_CHARS_PER_LINE = 20;
24const ASSISTANT_INLINE_CODE_WIDTH_MULTIPLIER = 1.2;
25const ASSISTANT_INLINE_CODE_WRAP_OVERHEAD_CHARS = 2;
26const INLINE_CODE_SPAN_REGEX = /`([^`\n]+)`/g;
27
28interface TimelineMessageHeightInput {
29 role: "user" | "assistant" | "system";
30 text: string;
31 attachments?: ReadonlyArray<{ id: string }>;
32}
33
34interface TimelineHeightEstimateLayout {
35 timelineWidthPx: number | null;
36}
37
38function 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
57function isFinitePositiveNumber(value: number | null | undefined): value is number {
58 return typeof value === "number" && Number.isFinite(value) && value > 0;
59}
60
61function 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
68function 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
77function 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
91export 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
......@@ -275,6 +275,20 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
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 */
282function 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
278292function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
279293 return <ComboboxPrimitive.Clear className={className} data-slot="combobox-clear" {...props} />;
280294}
......@@ -371,6 +385,7 @@ export {
371385 ComboboxEmpty,
372386 ComboboxValue,
373387 ComboboxList,
388 ComboboxListVirtualized,
374389 ComboboxClear,
375390 ComboboxStatus,
376391 ComboboxRow,
bun.lock+3-5
......@@ -80,6 +80,7 @@
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 @@
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 @@
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 @@
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 @@
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=="],