| author | |
| committer | |
| log | 96c9306dbd9fac8dff76133da0a19ab44de7caec |
| tree | 5ba7d4f1e73f37f6399dc3f4a33febf2f1611238 |
| parent | 94d13a2b754f78b57b3a936bc77494a06a88a504 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 21 | 21 | "@dnd-kit/utilities": "^3.2.2", |
| 22 | 22 | "@effect/atom-react": "catalog:", |
| 23 | 23 | "@formkit/auto-animate": "^0.9.0", |
| 24 | "@legendapp/list": "3.0.0-beta.44", | |
| 24 | 25 | "@lexical/react": "^0.41.0", |
| 25 | 26 | "@pierre/diffs": "^1.1.0-beta.16", |
| 26 | 27 | "@t3tools/client-runtime": "workspace:*", |
| ... | ... | @@ -29,7 +30,6 @@ |
| 29 | 30 | "@tanstack/react-pacer": "^0.19.4", |
| 30 | 31 | "@tanstack/react-query": "^5.90.0", |
| 31 | 32 | "@tanstack/react-router": "^1.160.2", |
| 32 | "@tanstack/react-virtual": "^3.13.18", | |
| 33 | 33 | "@xterm/addon-fit": "^0.11.0", |
| 34 | 34 | "@xterm/xterm": "^6.0.0", |
| 35 | 35 | "class-variance-authority": "^0.7.1", |
apps/web/src/chat-scroll.test.ts deleted-62| ... | ... | @@ -1,62 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "./chat-scroll"; | |
| 4 | ||
| 5 | describe("isScrollContainerNearBottom", () => { | |
| 6 | it("returns true when already at bottom", () => { | |
| 7 | expect( | |
| 8 | isScrollContainerNearBottom({ | |
| 9 | scrollTop: 600, | |
| 10 | clientHeight: 400, | |
| 11 | scrollHeight: 1_000, | |
| 12 | }), | |
| 13 | ).toBe(true); | |
| 14 | }); | |
| 15 | ||
| 16 | it("returns true when within the auto-scroll threshold", () => { | |
| 17 | expect( | |
| 18 | isScrollContainerNearBottom({ | |
| 19 | scrollTop: 540, | |
| 20 | clientHeight: 400, | |
| 21 | scrollHeight: 1_000, | |
| 22 | }), | |
| 23 | ).toBe(true); | |
| 24 | }); | |
| 25 | ||
| 26 | it("returns false when the user is meaningfully above the bottom", () => { | |
| 27 | expect( | |
| 28 | isScrollContainerNearBottom({ | |
| 29 | scrollTop: 520, | |
| 30 | clientHeight: 400, | |
| 31 | scrollHeight: 1_000, | |
| 32 | }), | |
| 33 | ).toBe(false); | |
| 34 | }); | |
| 35 | ||
| 36 | it("clamps negative thresholds to zero", () => { | |
| 37 | expect( | |
| 38 | isScrollContainerNearBottom( | |
| 39 | { | |
| 40 | scrollTop: 539, | |
| 41 | clientHeight: 400, | |
| 42 | scrollHeight: 1_000, | |
| 43 | }, | |
| 44 | -1, | |
| 45 | ), | |
| 46 | ).toBe(false); | |
| 47 | }); | |
| 48 | ||
| 49 | it("falls back to the default threshold for non-finite values", () => { | |
| 50 | expect( | |
| 51 | isScrollContainerNearBottom( | |
| 52 | { | |
| 53 | scrollTop: 540, | |
| 54 | clientHeight: 400, | |
| 55 | scrollHeight: 1_000, | |
| 56 | }, | |
| 57 | Number.NaN, | |
| 58 | ), | |
| 59 | ).toBe(true); | |
| 60 | expect(AUTO_SCROLL_BOTTOM_THRESHOLD_PX).toBe(64); | |
| 61 | }); | |
| 62 | }); |
apps/web/src/chat-scroll.ts deleted-24| ... | ... | @@ -1,24 +0,0 @@ |
| 1 | export const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; | |
| 2 | ||
| 3 | interface ScrollPosition { | |
| 4 | scrollTop: number; | |
| 5 | clientHeight: number; | |
| 6 | scrollHeight: number; | |
| 7 | } | |
| 8 | ||
| 9 | export function isScrollContainerNearBottom( | |
| 10 | position: ScrollPosition, | |
| 11 | thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX, | |
| 12 | ): boolean { | |
| 13 | const threshold = Number.isFinite(thresholdPx) | |
| 14 | ? Math.max(0, thresholdPx) | |
| 15 | : AUTO_SCROLL_BOTTOM_THRESHOLD_PX; | |
| 16 | ||
| 17 | const { scrollTop, clientHeight, scrollHeight } = position; | |
| 18 | if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) { | |
| 19 | return true; | |
| 20 | } | |
| 21 | ||
| 22 | const distanceFromBottom = scrollHeight - clientHeight - scrollTop; | |
| 23 | return distanceFromBottom <= threshold; | |
| 24 | } |
apps/web/src/components/BranchToolbarBranchSelector.tsx+46-70| ... | ... | @@ -1,10 +1,9 @@ |
| 1 | 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; |
| 2 | 2 | import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts"; |
| 3 | 3 | import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; |
| 4 | import { useVirtualizer } from "@tanstack/react-virtual"; | |
| 4 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; | |
| 5 | 5 | import { ChevronDownIcon } from "lucide-react"; |
| 6 | 6 | import { |
| 7 | type CSSProperties, | |
| 8 | 7 | useCallback, |
| 9 | 8 | useDeferredValue, |
| 10 | 9 | useEffect, |
| ... | ... | @@ -38,6 +37,7 @@ import { |
| 38 | 37 | ComboboxInput, |
| 39 | 38 | ComboboxItem, |
| 40 | 39 | ComboboxList, |
| 40 | ComboboxListVirtualized, | |
| 41 | 41 | ComboboxPopup, |
| 42 | 42 | ComboboxStatus, |
| 43 | 43 | ComboboxTrigger, |
| ... | ... | @@ -390,7 +390,7 @@ export function BranchToolbarBranchSelector({ |
| 390 | 390 | }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); |
| 391 | 391 | |
| 392 | 392 | // --------------------------------------------------------------------------- |
| 393 | // Combobox / virtualizer plumbing | |
| 393 | // Combobox / list plumbing | |
| 394 | 394 | // --------------------------------------------------------------------------- |
| 395 | 395 | const handleOpenChange = useCallback( |
| 396 | 396 | (open: boolean) => { |
| ... | ... | @@ -425,49 +425,22 @@ export function BranchToolbarBranchSelector({ |
| 425 | 425 | |
| 426 | 426 | void fetchNextPage().catch(() => undefined); |
| 427 | 427 | }, [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 | }, []); | |
| 463 | 432 | |
| 464 | 433 | useEffect(() => { |
| 465 | 434 | if (!isBranchMenuOpen) { |
| 466 | 435 | return; |
| 467 | 436 | } |
| 468 | 437 | |
| 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]); | |
| 471 | 444 | |
| 472 | 445 | useEffect(() => { |
| 473 | 446 | const scrollElement = branchListScrollElementRef.current; |
| ... | ... | @@ -487,8 +460,9 @@ export function BranchToolbarBranchSelector({ |
| 487 | 460 | }, [isBranchMenuOpen, maybeFetchNextBranchPage]); |
| 488 | 461 | |
| 489 | 462 | useEffect(() => { |
| 463 | if (shouldVirtualizeBranchList) return; | |
| 490 | 464 | maybeFetchNextBranchPage(); |
| 491 | }, [branches.length, maybeFetchNextBranchPage]); | |
| 465 | }, [branches.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); | |
| 492 | 466 | |
| 493 | 467 | const triggerLabel = getBranchTriggerLabel({ |
| 494 | 468 | activeWorktreePath, |
| ... | ... | @@ -496,7 +470,7 @@ export function BranchToolbarBranchSelector({ |
| 496 | 470 | resolvedActiveBranch, |
| 497 | 471 | }); |
| 498 | 472 | |
| 499 | function renderPickerItem(itemValue: string, index: number, style?: CSSProperties) { | |
| 473 | function renderPickerItem(itemValue: string, index: number) { | |
| 500 | 474 | if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { |
| 501 | 475 | return ( |
| 502 | 476 | <ComboboxItem |
| ... | ... | @@ -504,7 +478,6 @@ export function BranchToolbarBranchSelector({ |
| 504 | 478 | key={itemValue} |
| 505 | 479 | index={index} |
| 506 | 480 | value={itemValue} |
| 507 | style={style} | |
| 508 | 481 | onClick={() => { |
| 509 | 482 | if (!prReference || !onCheckoutPullRequestRequest) { |
| 510 | 483 | return; |
| ... | ... | @@ -529,7 +502,6 @@ export function BranchToolbarBranchSelector({ |
| 529 | 502 | key={itemValue} |
| 530 | 503 | index={index} |
| 531 | 504 | value={itemValue} |
| 532 | style={style} | |
| 533 | 505 | onClick={() => createBranch(trimmedBranchQuery)} |
| 534 | 506 | > |
| 535 | 507 | <span className="truncate">Create new branch &quot;{trimmedBranchQuery}&quot;</span> |
| ... | ... | @@ -557,7 +529,6 @@ export function BranchToolbarBranchSelector({ |
| 557 | 529 | key={itemValue} |
| 558 | 530 | index={index} |
| 559 | 531 | value={itemValue} |
| 560 | style={style} | |
| 561 | 532 | onClick={() => selectBranch(branch)} |
| 562 | 533 | > |
| 563 | 534 | <div className="flex w-full items-center justify-between gap-2"> |
| ... | ... | @@ -575,8 +546,13 @@ export function BranchToolbarBranchSelector({ |
| 575 | 546 | autoHighlight |
| 576 | 547 | virtualized={shouldVirtualizeBranchList} |
| 577 | 548 | 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 | }); | |
| 580 | 556 | }} |
| 581 | 557 | onOpenChange={handleOpenChange} |
| 582 | 558 | open={isBranchMenuOpen} |
| ... | ... | @@ -604,30 +580,30 @@ export function BranchToolbarBranchSelector({ |
| 604 | 580 | </div> |
| 605 | 581 | <ComboboxEmpty>No branches found.</ComboboxEmpty> |
| 606 | 582 | |
| 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 | } | |
| 613 | 596 | }} |
| 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 | )} | |
| 631 | 607 | {branchStatusText ? <ComboboxStatus>{branchStatusText}</ComboboxStatus> : null} |
| 632 | 608 | </ComboboxPopup> |
| 633 | 609 | </Combobox> |
apps/web/src/components/ChatView.browser.tsx+103-280| ... | ... | @@ -47,7 +47,7 @@ import { useTerminalStateStore } from "../terminalStateStore"; |
| 47 | 47 | import { useUiStateStore } from "../uiStateStore"; |
| 48 | 48 | import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; |
| 49 | 49 | import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; |
| 50 | import { estimateTimelineMessageHeight } from "./timelineHeight"; | |
| 50 | ||
| 51 | 51 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; |
| 52 | 52 | |
| 53 | 53 | vi.mock("../lib/gitStatusState", () => ({ |
| ... | ... | @@ -112,28 +112,9 @@ const COMPACT_FOOTER_VIEWPORT: ViewportSpec = { |
| 112 | 112 | textTolerancePx: 56, |
| 113 | 113 | attachmentTolerancePx: 56, |
| 114 | 114 | }; |
| 115 | const TEXT_VIEWPORT_MATRIX = [ | |
| 116 | DEFAULT_VIEWPORT, | |
| 117 | { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 }, | |
| 118 | { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, | |
| 119 | { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, | |
| 120 | ] as const satisfies readonly ViewportSpec[]; | |
| 121 | const ATTACHMENT_VIEWPORT_MATRIX = [ | |
| 122 | { ...DEFAULT_VIEWPORT, attachmentTolerancePx: 120 }, | |
| 123 | { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 120 }, | |
| 124 | { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 120 }, | |
| 125 | ] as const satisfies readonly ViewportSpec[]; | |
| 126 | ||
| 127 | interface UserRowMeasurement { | |
| 128 | measuredRowHeightPx: number; | |
| 129 | timelineWidthMeasuredPx: number; | |
| 130 | renderedInVirtualizedRegion: boolean; | |
| 131 | } | |
| 132 | ||
| 133 | 115 | interface MountedChatView { |
| 134 | 116 | [Symbol.asyncDispose]: () => Promise<void>; |
| 135 | 117 | cleanup: () => Promise<void>; |
| 136 | measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>; | |
| 137 | 118 | setViewport: (viewport: ViewportSpec) => Promise<void>; |
| 138 | 119 | setContainerSize: (viewport: Pick<ViewportSpec, "width" | "height">) => Promise<void>; |
| 139 | 120 | router: ReturnType<typeof getRouter>; |
| ... | ... | @@ -1378,91 +1359,6 @@ async function waitForCommandPaletteShortcutLabel(): Promise<void> { |
| 1378 | 1359 | ); |
| 1379 | 1360 | } |
| 1380 | 1361 | |
| 1381 | async function waitForImagesToLoad(scope: ParentNode): Promise<void> { | |
| 1382 | const images = Array.from(scope.querySelectorAll("img")); | |
| 1383 | if (images.length === 0) { | |
| 1384 | return; | |
| 1385 | } | |
| 1386 | await Promise.all( | |
| 1387 | images.map( | |
| 1388 | (image) => | |
| 1389 | new Promise<void>((resolve) => { | |
| 1390 | if (image.complete) { | |
| 1391 | resolve(); | |
| 1392 | return; | |
| 1393 | } | |
| 1394 | image.addEventListener("load", () => resolve(), { once: true }); | |
| 1395 | image.addEventListener("error", () => resolve(), { once: true }); | |
| 1396 | }), | |
| 1397 | ), | |
| 1398 | ); | |
| 1399 | await waitForLayout(); | |
| 1400 | } | |
| 1401 | ||
| 1402 | async function measureUserRow(options: { | |
| 1403 | host: HTMLElement; | |
| 1404 | targetMessageId: MessageId; | |
| 1405 | }): Promise<UserRowMeasurement> { | |
| 1406 | const { host, targetMessageId } = options; | |
| 1407 | const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="user"]`; | |
| 1408 | ||
| 1409 | const scrollContainer = await waitForElement( | |
| 1410 | () => host.querySelector<HTMLDivElement>("div.overflow-y-auto.overscroll-y-contain"), | |
| 1411 | "Unable to find ChatView message scroll container.", | |
| 1412 | ); | |
| 1413 | ||
| 1414 | let row: HTMLElement | null = null; | |
| 1415 | await vi.waitFor( | |
| 1416 | async () => { | |
| 1417 | scrollContainer.scrollTop = 0; | |
| 1418 | scrollContainer.dispatchEvent(new Event("scroll")); | |
| 1419 | await waitForLayout(); | |
| 1420 | row = host.querySelector<HTMLElement>(rowSelector); | |
| 1421 | expect(row, "Unable to locate targeted user message row.").toBeTruthy(); | |
| 1422 | }, | |
| 1423 | { | |
| 1424 | timeout: 8_000, | |
| 1425 | interval: 16, | |
| 1426 | }, | |
| 1427 | ); | |
| 1428 | ||
| 1429 | await waitForImagesToLoad(row!); | |
| 1430 | scrollContainer.scrollTop = 0; | |
| 1431 | scrollContainer.dispatchEvent(new Event("scroll")); | |
| 1432 | await nextFrame(); | |
| 1433 | ||
| 1434 | const timelineRoot = | |
| 1435 | row!.closest<HTMLElement>('[data-timeline-root="true"]') ?? | |
| 1436 | host.querySelector<HTMLElement>('[data-timeline-root="true"]'); | |
| 1437 | if (!(timelineRoot instanceof HTMLElement)) { | |
| 1438 | throw new Error("Unable to locate timeline root container."); | |
| 1439 | } | |
| 1440 | ||
| 1441 | let timelineWidthMeasuredPx = 0; | |
| 1442 | let measuredRowHeightPx = 0; | |
| 1443 | let renderedInVirtualizedRegion = false; | |
| 1444 | await vi.waitFor( | |
| 1445 | async () => { | |
| 1446 | scrollContainer.scrollTop = 0; | |
| 1447 | scrollContainer.dispatchEvent(new Event("scroll")); | |
| 1448 | await nextFrame(); | |
| 1449 | const measuredRow = host.querySelector<HTMLElement>(rowSelector); | |
| 1450 | expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy(); | |
| 1451 | timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; | |
| 1452 | measuredRowHeightPx = measuredRow!.getBoundingClientRect().height; | |
| 1453 | renderedInVirtualizedRegion = measuredRow!.closest("[data-index]") instanceof HTMLElement; | |
| 1454 | expect(timelineWidthMeasuredPx, "Unable to measure timeline width.").toBeGreaterThan(0); | |
| 1455 | expect(measuredRowHeightPx, "Unable to measure targeted user row height.").toBeGreaterThan(0); | |
| 1456 | }, | |
| 1457 | { | |
| 1458 | timeout: 4_000, | |
| 1459 | interval: 16, | |
| 1460 | }, | |
| 1461 | ); | |
| 1462 | ||
| 1463 | return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; | |
| 1464 | } | |
| 1465 | ||
| 1466 | 1362 | async function mountChatView(options: { |
| 1467 | 1363 | viewport: ViewportSpec; |
| 1468 | 1364 | snapshot: OrchestrationReadModel; |
| ... | ... | @@ -1515,7 +1411,6 @@ async function mountChatView(options: { |
| 1515 | 1411 | return { |
| 1516 | 1412 | [Symbol.asyncDispose]: cleanup, |
| 1517 | 1413 | cleanup, |
| 1518 | measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }), | |
| 1519 | 1414 | setViewport: async (viewport: ViewportSpec) => { |
| 1520 | 1415 | await setViewport(viewport); |
| 1521 | 1416 | await waitForProductionStyles(); |
| ... | ... | @@ -1529,23 +1424,6 @@ async function mountChatView(options: { |
| 1529 | 1424 | }; |
| 1530 | 1425 | } |
| 1531 | 1426 | |
| 1532 | async function measureUserRowAtViewport(options: { | |
| 1533 | snapshot: OrchestrationReadModel; | |
| 1534 | targetMessageId: MessageId; | |
| 1535 | viewport: ViewportSpec; | |
| 1536 | }): Promise<UserRowMeasurement> { | |
| 1537 | const mounted = await mountChatView({ | |
| 1538 | viewport: options.viewport, | |
| 1539 | snapshot: options.snapshot, | |
| 1540 | }); | |
| 1541 | ||
| 1542 | try { | |
| 1543 | return await mounted.measureUserRow(options.targetMessageId); | |
| 1544 | } finally { | |
| 1545 | await mounted.cleanup(); | |
| 1546 | } | |
| 1547 | } | |
| 1548 | ||
| 1549 | 1427 | describe("ChatView timeline estimator parity (full app)", () => { |
| 1550 | 1428 | beforeAll(async () => { |
| 1551 | 1429 | fixture = buildFixture( |
| ... | ... | @@ -1633,39 +1511,6 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1633 | 1511 | document.body.innerHTML = ""; |
| 1634 | 1512 | }); |
| 1635 | 1513 | |
| 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 | ||
| 1669 | 1514 | it("re-expands the bootstrap project using its scoped key", async () => { |
| 1670 | 1515 | useUiStateStore.setState({ |
| 1671 | 1516 | projectExpandedById: { |
| ... | ... | @@ -1695,130 +1540,6 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1695 | 1540 | } |
| 1696 | 1541 | }); |
| 1697 | 1542 | |
| 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 | ||
| 1822 | 1543 | it("shows an explicit empty state for projects without threads in the sidebar", async () => { |
| 1823 | 1544 | const mounted = await mountChatView({ |
| 1824 | 1545 | viewport: DEFAULT_VIEWPORT, |
| ... | ... | @@ -2771,6 +2492,108 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2771 | 2492 | } |
| 2772 | 2493 | }); |
| 2773 | 2494 | |
| 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 | ||
| 2774 | 2597 | it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => { |
| 2775 | 2598 | const mounted = await mountChatView({ |
| 2776 | 2599 | viewport: DEFAULT_VIEWPORT, |
apps/web/src/components/ChatView.tsx+82-277| ... | ... | @@ -28,7 +28,8 @@ import { |
| 28 | 28 | import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; |
| 29 | 29 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 30 | 30 | import { truncate } from "@t3tools/shared/String"; |
| 31 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 31 | import { Debouncer } from "@tanstack/react-pacer"; | |
| 32 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 32 | 33 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 33 | 34 | import { useShallow } from "zustand/react/shallow"; |
| 34 | 35 | import { useGitStatus } from "~/lib/gitStatusState"; |
| ... | ... | @@ -57,7 +58,7 @@ import { |
| 57 | 58 | isLatestTurnSettled, |
| 58 | 59 | formatElapsed, |
| 59 | 60 | } from "../session-logic"; |
| 60 | import { isScrollContainerNearBottom } from "../chat-scroll"; | |
| 61 | import { type LegendListRef } from "@legendapp/list/react"; | |
| 61 | 62 | import { |
| 62 | 63 | buildPendingUserInputAnswers, |
| 63 | 64 | derivePendingUserInputProgress, |
| ... | ... | @@ -174,7 +175,6 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 174 | 175 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 175 | 176 | const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; |
| 176 | 177 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 177 | const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID: Record<string, boolean> = {}; | |
| 178 | 178 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| 179 | 179 | |
| 180 | 180 | type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">; |
| ... | ... | @@ -590,18 +590,9 @@ export default function ChatView(props: ChatViewProps) { |
| 590 | 590 | ); |
| 591 | 591 | const setStoreThreadError = useStore((store) => store.setError); |
| 592 | 592 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); |
| 593 | const setThreadChangedFilesExpanded = useUiStateStore( | |
| 594 | (store) => store.setThreadChangedFilesExpanded, | |
| 595 | ); | |
| 596 | 593 | const activeThreadLastVisitedAt = useUiStateStore((store) => |
| 597 | 594 | routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, |
| 598 | 595 | ); |
| 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 | ); | |
| 605 | 596 | const settings = useSettings(); |
| 606 | 597 | const setStickyComposerModelSelection = useComposerDraftStore( |
| 607 | 598 | (store) => store.setStickyModelSelection, |
| ... | ... | @@ -673,14 +664,12 @@ export default function ChatView(props: ChatViewProps) { |
| 673 | 664 | >({}); |
| 674 | 665 | const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = |
| 675 | 666 | useState<Record<string, number>>({}); |
| 676 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); | |
| 677 | 667 | const [planSidebarOpen, setPlanSidebarOpen] = useState(false); |
| 678 | 668 | // Tracks whether the user explicitly dismissed the sidebar for the active turn. |
| 679 | 669 | const planSidebarDismissedForTurnRef = useRef<string | null>(null); |
| 680 | 670 | // When set, the thread-change reset effect will open the sidebar instead of closing it. |
| 681 | 671 | // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. |
| 682 | 672 | const planSidebarOpenOnNextThreadRef = useRef(false); |
| 683 | const [nowTick, setNowTick] = useState(() => Date.now()); | |
| 684 | 673 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 685 | 674 | const [pullRequestDialogState, setPullRequestDialogState] = |
| 686 | 675 | useState<PullRequestDialogState | null>(null); |
| ... | ... | @@ -695,27 +684,12 @@ export default function ChatView(props: ChatViewProps) { |
| 695 | 684 | {}, |
| 696 | 685 | LastInvokedScriptByProjectSchema, |
| 697 | 686 | ); |
| 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); | |
| 711 | 689 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); |
| 712 | 690 | const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({}); |
| 713 | 691 | const sendInFlightRef = useRef(false); |
| 714 | 692 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| 715 | const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { | |
| 716 | messagesScrollRef.current = element; | |
| 717 | setMessagesScrollElement(element); | |
| 718 | }, []); | |
| 719 | 693 | |
| 720 | 694 | const terminalState = useTerminalStateStore((state) => |
| 721 | 695 | selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef), |
| ... | ... | @@ -995,16 +969,6 @@ export default function ChatView(props: ChatViewProps) { |
| 995 | 969 | [openOrReuseProjectDraftThread], |
| 996 | 970 | ); |
| 997 | 971 | |
| 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 | ||
| 1008 | 972 | useEffect(() => { |
| 1009 | 973 | if (!serverThread?.id) return; |
| 1010 | 974 | if (!latestTurnSettled) return; |
| ... | ... | @@ -1144,7 +1108,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1144 | 1108 | threadError: activeThread?.error, |
| 1145 | 1109 | }); |
| 1146 | 1110 | const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; |
| 1147 | const nowIso = new Date(nowTick).toISOString(); | |
| 1148 | 1111 | const activeWorkStartedAt = deriveActiveWorkStartedAt( |
| 1149 | 1112 | activeLatestTurn, |
| 1150 | 1113 | activeThread?.session ?? null, |
| ... | ... | @@ -1970,169 +1933,33 @@ export default function ChatView(props: ChatViewProps) { |
| 1970 | 1933 | [environmentId, serverThread], |
| 1971 | 1934 | ); |
| 1972 | 1935 | |
| 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 }); | |
| 1993 | 1939 | }, []); |
| 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 | }; | |
| 2016 | 1940 | |
| 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(); | |
| 2097 | 1955 | } |
| 2098 | lastTouchClientYRef.current = touch.clientY; | |
| 2099 | 1956 | }, []); |
| 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]); | |
| 2132 | 1957 | |
| 2133 | 1958 | useEffect(() => { |
| 2134 | setExpandedWorkGroups({}); | |
| 2135 | 1959 | setPullRequestDialogState(null); |
| 1960 | isAtEndRef.current = true; | |
| 1961 | showScrollDebouncer.current.cancel(); | |
| 1962 | setShowScrollToBottom(false); | |
| 2136 | 1963 | if (planSidebarOpenOnNextThreadRef.current) { |
| 2137 | 1964 | planSidebarOpenOnNextThreadRef.current = false; |
| 2138 | 1965 | setPlanSidebarOpen(true); |
| ... | ... | @@ -2290,16 +2117,6 @@ export default function ChatView(props: ChatViewProps) { |
| 2290 | 2117 | terminalState.terminalOpen, |
| 2291 | 2118 | ]); |
| 2292 | 2119 | |
| 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 | ||
| 2303 | 2120 | useEffect(() => { |
| 2304 | 2121 | if (!activeThreadKey) return; |
| 2305 | 2122 | const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; |
| ... | ... | @@ -2575,6 +2392,14 @@ export default function ChatView(props: ChatViewProps) { |
| 2575 | 2392 | sizeBytes: image.sizeBytes, |
| 2576 | 2393 | previewUrl: image.previewUrl, |
| 2577 | 2394 | })); |
| 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 | ||
| 2578 | 2403 | setOptimisticUserMessages((existing) => [ |
| 2579 | 2404 | ...existing, |
| 2580 | 2405 | { |
| ... | ... | @@ -2586,9 +2411,6 @@ export default function ChatView(props: ChatViewProps) { |
| 2586 | 2411 | streaming: false, |
| 2587 | 2412 | }, |
| 2588 | 2413 | ]); |
| 2589 | // Sending a message should always bring the latest user turn into view. | |
| 2590 | shouldAutoScrollRef.current = true; | |
| 2591 | forceStickToBottom(); | |
| 2592 | 2414 | |
| 2593 | 2415 | setThreadError(threadIdForSend, null); |
| 2594 | 2416 | if (expiredTerminalContextCount > 0) { |
| ... | ... | @@ -2969,6 +2791,13 @@ export default function ChatView(props: ChatViewProps) { |
| 2969 | 2791 | sendInFlightRef.current = true; |
| 2970 | 2792 | beginLocalDispatch({ preparingWorktree: false }); |
| 2971 | 2793 | 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 | ||
| 2972 | 2801 | setOptimisticUserMessages((existing) => [ |
| 2973 | 2802 | ...existing, |
| 2974 | 2803 | { |
| ... | ... | @@ -2979,8 +2808,6 @@ export default function ChatView(props: ChatViewProps) { |
| 2979 | 2808 | streaming: false, |
| 2980 | 2809 | }, |
| 2981 | 2810 | ]); |
| 2982 | shouldAutoScrollRef.current = true; | |
| 2983 | forceStickToBottom(); | |
| 2984 | 2811 | |
| 2985 | 2812 | try { |
| 2986 | 2813 | await persistThreadSettingsForNextTurn({ |
| ... | ... | @@ -3046,7 +2873,6 @@ export default function ChatView(props: ChatViewProps) { |
| 3046 | 2873 | activeThread, |
| 3047 | 2874 | activeProposedPlan, |
| 3048 | 2875 | beginLocalDispatch, |
| 3049 | forceStickToBottom, | |
| 3050 | 2876 | isConnecting, |
| 3051 | 2877 | isSendBusy, |
| 3052 | 2878 | isServerThread, |
| ... | ... | @@ -3241,12 +3067,6 @@ export default function ChatView(props: ChatViewProps) { |
| 3241 | 3067 | ], |
| 3242 | 3068 | ); |
| 3243 | 3069 | |
| 3244 | const onToggleWorkGroup = useCallback((groupId: string) => { | |
| 3245 | setExpandedWorkGroups((existing) => ({ | |
| 3246 | ...existing, | |
| 3247 | [groupId]: !existing[groupId], | |
| 3248 | })); | |
| 3249 | }, []); | |
| 3250 | 3070 | const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { |
| 3251 | 3071 | setExpandedImage(preview); |
| 3252 | 3072 | }, []); |
| ... | ... | @@ -3272,16 +3092,19 @@ export default function ChatView(props: ChatViewProps) { |
| 3272 | 3092 | }, |
| 3273 | 3093 | [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], |
| 3274 | 3094 | ); |
| 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 | }, []); | |
| 3285 | 3108 | |
| 3286 | 3109 | // Empty state: no active thread |
| 3287 | 3110 | if (!activeThread) { |
| ... | ... | @@ -3338,57 +3161,39 @@ export default function ChatView(props: ChatViewProps) { |
| 3338 | 3161 | <div className="flex min-h-0 min-w-0 flex-1 flex-col"> |
| 3339 | 3162 | {/* Messages Wrapper */} |
| 3340 | 3163 | <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 | /> | |
| 3385 | 3190 | |
| 3386 | 3191 | {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} |
| 3387 | 3192 | {showScrollToBottom && ( |
| 3388 | 3193 | <div className="pointer-events-none absolute bottom-1 left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5"> |
| 3389 | 3194 | <button |
| 3390 | 3195 | type="button" |
| 3391 | onClick={() => scrollMessagesToBottom("smooth")} | |
| 3196 | onClick={() => scrollToEnd(true)} | |
| 3392 | 3197 | 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" |
| 3393 | 3198 | > |
| 3394 | 3199 | <ChevronDownIcon className="size-3.5" /> |
| ... | ... | @@ -3443,8 +3248,8 @@ export default function ChatView(props: ChatViewProps) { |
| 3443 | 3248 | promptRef={promptRef} |
| 3444 | 3249 | composerImagesRef={composerImagesRef} |
| 3445 | 3250 | composerTerminalContextsRef={composerTerminalContextsRef} |
| 3446 | shouldAutoScrollRef={shouldAutoScrollRef} | |
| 3447 | scheduleStickToBottom={scheduleStickToBottom} | |
| 3251 | shouldAutoScrollRef={isAtEndRef} | |
| 3252 | scheduleStickToBottom={scrollToEnd} | |
| 3448 | 3253 | onSend={onSend} |
| 3449 | 3254 | onInterrupt={onInterrupt} |
| 3450 | 3255 | onImplementPlanInNewThread={onImplementPlanInNewThread} |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+168| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | import { |
| 3 | computeStableMessagesTimelineRows, | |
| 3 | 4 | computeMessageDurationStart, |
| 4 | 5 | deriveMessagesTimelineRows, |
| 5 | 6 | normalizeCompactToolLabel, |
| ... | ... | @@ -252,6 +253,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 252 | 253 | completionDividerBeforeEntryId: "assistant-final-entry", |
| 253 | 254 | isWorking: false, |
| 254 | 255 | activeTurnStartedAt: null, |
| 256 | turnDiffSummaryByAssistantMessageId: new Map(), | |
| 257 | revertTurnCountByUserMessageId: new Map(), | |
| 255 | 258 | }); |
| 256 | 259 | |
| 257 | 260 | const assistantRows = rows.filter( |
| ... | ... | @@ -264,4 +267,169 @@ describe("deriveMessagesTimelineRows", () => { |
| 264 | 267 | expect(assistantRows[1]?.showAssistantCopyButton).toBe(true); |
| 265 | 268 | expect(assistantRows[1]?.showCompletionDivider).toBe(true); |
| 266 | 269 | }); |
| 270 | ||
| 271 | it("projects assistant diff summaries and user revert counts onto the affected rows", () => { | |
| 272 | const assistantTurnDiffSummary = { | |
| 273 | turnId: "turn-1" as never, | |
| 274 | completedAt: "2026-01-01T00:00:30Z", | |
| 275 | assistantMessageId: "assistant-1" as never, | |
| 276 | checkpointTurnCount: 2, | |
| 277 | files: [{ path: "src/index.ts", additions: 3, deletions: 1 }], | |
| 278 | }; | |
| 279 | ||
| 280 | const rows = deriveMessagesTimelineRows({ | |
| 281 | timelineEntries: [ | |
| 282 | { | |
| 283 | id: "user-entry", | |
| 284 | kind: "message", | |
| 285 | createdAt: "2026-01-01T00:00:00Z", | |
| 286 | message: { | |
| 287 | id: "user-1" as never, | |
| 288 | role: "user", | |
| 289 | text: "Do the thing", | |
| 290 | turnId: null, | |
| 291 | createdAt: "2026-01-01T00:00:00Z", | |
| 292 | streaming: false, | |
| 293 | }, | |
| 294 | }, | |
| 295 | { | |
| 296 | id: "assistant-entry", | |
| 297 | kind: "message", | |
| 298 | createdAt: "2026-01-01T00:00:20Z", | |
| 299 | message: { | |
| 300 | id: "assistant-1" as never, | |
| 301 | role: "assistant", | |
| 302 | text: "Done", | |
| 303 | turnId: "turn-1" as never, | |
| 304 | createdAt: "2026-01-01T00:00:20Z", | |
| 305 | completedAt: "2026-01-01T00:00:30Z", | |
| 306 | streaming: false, | |
| 307 | }, | |
| 308 | }, | |
| 309 | ], | |
| 310 | completionDividerBeforeEntryId: null, | |
| 311 | isWorking: false, | |
| 312 | activeTurnStartedAt: null, | |
| 313 | turnDiffSummaryByAssistantMessageId: new Map([ | |
| 314 | ["assistant-1" as never, assistantTurnDiffSummary], | |
| 315 | ]), | |
| 316 | revertTurnCountByUserMessageId: new Map([["user-1" as never, 1]]), | |
| 317 | }); | |
| 318 | ||
| 319 | const userRow = rows.find( | |
| 320 | (row): row is Extract<(typeof rows)[number], { kind: "message" }> => | |
| 321 | row.kind === "message" && row.message.role === "user", | |
| 322 | ); | |
| 323 | const assistantRow = rows.find( | |
| 324 | (row): row is Extract<(typeof rows)[number], { kind: "message" }> => | |
| 325 | row.kind === "message" && row.message.role === "assistant", | |
| 326 | ); | |
| 327 | ||
| 328 | expect(userRow?.revertTurnCount).toBe(1); | |
| 329 | expect(assistantRow?.assistantTurnDiffSummary).toBe(assistantTurnDiffSummary); | |
| 330 | }); | |
| 331 | }); | |
| 332 | ||
| 333 | describe("computeStableMessagesTimelineRows", () => { | |
| 334 | it("returns the previous result when row order and content are unchanged", () => { | |
| 335 | const firstUserMessage = { | |
| 336 | id: "user-1" as never, | |
| 337 | role: "user" as const, | |
| 338 | text: "First", | |
| 339 | turnId: null, | |
| 340 | createdAt: "2026-01-01T00:00:00Z", | |
| 341 | streaming: false, | |
| 342 | }; | |
| 343 | const secondUserMessage = { | |
| 344 | id: "user-2" as never, | |
| 345 | role: "user" as const, | |
| 346 | text: "Second", | |
| 347 | turnId: null, | |
| 348 | createdAt: "2026-01-01T00:00:10Z", | |
| 349 | streaming: false, | |
| 350 | }; | |
| 351 | ||
| 352 | const rows = deriveMessagesTimelineRows({ | |
| 353 | timelineEntries: [ | |
| 354 | { | |
| 355 | id: "entry-user-1", | |
| 356 | kind: "message", | |
| 357 | createdAt: firstUserMessage.createdAt, | |
| 358 | message: firstUserMessage, | |
| 359 | }, | |
| 360 | { | |
| 361 | id: "entry-user-2", | |
| 362 | kind: "message", | |
| 363 | createdAt: secondUserMessage.createdAt, | |
| 364 | message: secondUserMessage, | |
| 365 | }, | |
| 366 | ], | |
| 367 | completionDividerBeforeEntryId: null, | |
| 368 | isWorking: false, | |
| 369 | activeTurnStartedAt: null, | |
| 370 | turnDiffSummaryByAssistantMessageId: new Map(), | |
| 371 | revertTurnCountByUserMessageId: new Map(), | |
| 372 | }); | |
| 373 | ||
| 374 | const initial = computeStableMessagesTimelineRows(rows, { | |
| 375 | byId: new Map(), | |
| 376 | result: [], | |
| 377 | }); | |
| 378 | ||
| 379 | const repeated = computeStableMessagesTimelineRows(rows, initial); | |
| 380 | ||
| 381 | expect(repeated).toBe(initial); | |
| 382 | expect(repeated.result).toBe(initial.result); | |
| 383 | }); | |
| 384 | ||
| 385 | it("returns a new result when row order changes without content changes", () => { | |
| 386 | const firstUserMessage = { | |
| 387 | id: "user-1" as never, | |
| 388 | role: "user" as const, | |
| 389 | text: "First", | |
| 390 | turnId: null, | |
| 391 | createdAt: "2026-01-01T00:00:00Z", | |
| 392 | streaming: false, | |
| 393 | }; | |
| 394 | const secondUserMessage = { | |
| 395 | id: "user-2" as never, | |
| 396 | role: "user" as const, | |
| 397 | text: "Second", | |
| 398 | turnId: null, | |
| 399 | createdAt: "2026-01-01T00:00:10Z", | |
| 400 | streaming: false, | |
| 401 | }; | |
| 402 | ||
| 403 | const firstRows = deriveMessagesTimelineRows({ | |
| 404 | timelineEntries: [ | |
| 405 | { | |
| 406 | id: "entry-user-1", | |
| 407 | kind: "message", | |
| 408 | createdAt: firstUserMessage.createdAt, | |
| 409 | message: firstUserMessage, | |
| 410 | }, | |
| 411 | { | |
| 412 | id: "entry-user-2", | |
| 413 | kind: "message", | |
| 414 | createdAt: secondUserMessage.createdAt, | |
| 415 | message: secondUserMessage, | |
| 416 | }, | |
| 417 | ], | |
| 418 | completionDividerBeforeEntryId: null, | |
| 419 | isWorking: false, | |
| 420 | activeTurnStartedAt: null, | |
| 421 | turnDiffSummaryByAssistantMessageId: new Map(), | |
| 422 | revertTurnCountByUserMessageId: new Map(), | |
| 423 | }); | |
| 424 | ||
| 425 | const initial = computeStableMessagesTimelineRows(firstRows, { | |
| 426 | byId: new Map(), | |
| 427 | result: [], | |
| 428 | }); | |
| 429 | ||
| 430 | const reordered = computeStableMessagesTimelineRows([firstRows[1]!, firstRows[0]!], initial); | |
| 431 | ||
| 432 | expect(reordered).not.toBe(initial); | |
| 433 | expect(reordered.result).toEqual([initial.result[1], initial.result[0]]); | |
| 434 | }); | |
| 267 | 435 | }); |
apps/web/src/components/chat/MessagesTimeline.logic.ts+55-61| ... | ... | @@ -1,8 +1,6 @@ |
| 1 | import { type MessageId } from "@t3tools/contracts"; | |
| 2 | 1 | import { type TimelineEntry, type WorkLogEntry } from "../../session-logic"; |
| 3 | import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; | |
| 4 | 2 | import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; |
| 5 | import { estimateTimelineMessageHeight } from "../timelineHeight"; | |
| 3 | import { type MessageId } from "@t3tools/contracts"; | |
| 6 | 4 | |
| 7 | 5 | export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 8 | 6 | |
| ... | ... | @@ -28,6 +26,8 @@ export type MessagesTimelineRow = |
| 28 | 26 | durationStart: string; |
| 29 | 27 | showCompletionDivider: boolean; |
| 30 | 28 | showAssistantCopyButton: boolean; |
| 29 | assistantTurnDiffSummary?: TurnDiffSummary | undefined; | |
| 30 | revertTurnCount?: number | undefined; | |
| 31 | 31 | } |
| 32 | 32 | | { |
| 33 | 33 | kind: "proposed-plan"; |
| ... | ... | @@ -37,6 +37,11 @@ export type MessagesTimelineRow = |
| 37 | 37 | } |
| 38 | 38 | | { kind: "working"; id: string; createdAt: string | null }; |
| 39 | 39 | |
| 40 | export interface StableMessagesTimelineRowsState { | |
| 41 | byId: Map<string, MessagesTimelineRow>; | |
| 42 | result: MessagesTimelineRow[]; | |
| 43 | } | |
| 44 | ||
| 40 | 45 | export function computeMessageDurationStart( |
| 41 | 46 | messages: ReadonlyArray<TimelineDurationMessage>, |
| 42 | 47 | ): Map<string, string> { |
| ... | ... | @@ -107,6 +112,8 @@ export function deriveMessagesTimelineRows(input: { |
| 107 | 112 | completionDividerBeforeEntryId: string | null; |
| 108 | 113 | isWorking: boolean; |
| 109 | 114 | activeTurnStartedAt: string | null; |
| 115 | turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>; | |
| 116 | revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>; | |
| 110 | 117 | }): MessagesTimelineRow[] { |
| 111 | 118 | const nextRows: MessagesTimelineRow[] = []; |
| 112 | 119 | const durationStartByMessageId = computeMessageDurationStart( |
| ... | ... | @@ -162,6 +169,14 @@ export function deriveMessagesTimelineRows(input: { |
| 162 | 169 | showAssistantCopyButton: |
| 163 | 170 | timelineEntry.message.role === "assistant" && |
| 164 | 171 | 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, | |
| 165 | 180 | }); |
| 166 | 181 | } |
| 167 | 182 | |
| ... | ... | @@ -176,71 +191,50 @@ export function deriveMessagesTimelineRows(input: { |
| 176 | 191 | return nextRows; |
| 177 | 192 | } |
| 178 | 193 | |
| 179 | export function estimateMessagesTimelineRowHeight( | |
| 180 | row: MessagesTimelineRow, | |
| 181 | input: { | |
| 182 | timelineWidthPx: number | null; | |
| 183 | expandedWorkGroups?: Readonly<Record<string, boolean>>; | |
| 184 | turnDiffSummaryByAssistantMessageId?: ReadonlyMap<MessageId, TurnDiffSummary>; | |
| 185 | }, | |
| 186 | ): number { | |
| 187 | switch (row.kind) { | |
| 188 | case "work": | |
| 189 | return estimateWorkRowHeight(row, input); | |
| 190 | case "proposed-plan": | |
| 191 | return estimateTimelineProposedPlanHeight(row.proposedPlan); | |
| 192 | case "working": | |
| 193 | return 40; | |
| 194 | case "message": { | |
| 195 | let estimate = estimateTimelineMessageHeight(row.message, { | |
| 196 | timelineWidthPx: input.timelineWidthPx, | |
| 197 | }); | |
| 198 | const turnDiffSummary = input.turnDiffSummaryByAssistantMessageId?.get(row.message.id); | |
| 199 | if (turnDiffSummary && turnDiffSummary.files.length > 0) { | |
| 200 | estimate += estimateChangedFilesCardHeight(turnDiffSummary); | |
| 201 | } | |
| 202 | return estimate; | |
| 194 | export function computeStableMessagesTimelineRows( | |
| 195 | rows: MessagesTimelineRow[], | |
| 196 | previous: StableMessagesTimelineRowsState, | |
| 197 | ): StableMessagesTimelineRowsState { | |
| 198 | const next = new Map<string, MessagesTimelineRow>(); | |
| 199 | let anyChanged = rows.length !== previous.byId.size; | |
| 200 | ||
| 201 | const result = rows.map((row, index) => { | |
| 202 | const prevRow = previous.byId.get(row.id); | |
| 203 | const nextRow = prevRow && isRowUnchanged(prevRow, row) ? prevRow : row; | |
| 204 | next.set(row.id, nextRow); | |
| 205 | if (!anyChanged && previous.result[index] !== nextRow) { | |
| 206 | anyChanged = true; | |
| 203 | 207 | } |
| 204 | } | |
| 205 | } | |
| 208 | return nextRow; | |
| 209 | }); | |
| 206 | 210 | |
| 207 | function estimateWorkRowHeight( | |
| 208 | row: Extract<MessagesTimelineRow, { kind: "work" }>, | |
| 209 | input: { | |
| 210 | expandedWorkGroups?: Readonly<Record<string, boolean>>; | |
| 211 | }, | |
| 212 | ): number { | |
| 213 | const isExpanded = input.expandedWorkGroups?.[row.id] ?? false; | |
| 214 | const hasOverflow = row.groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | |
| 215 | const visibleEntries = | |
| 216 | hasOverflow && !isExpanded ? MAX_VISIBLE_WORK_LOG_ENTRIES : row.groupedEntries.length; | |
| 217 | const onlyToolEntries = row.groupedEntries.every((entry) => entry.tone === "tool"); | |
| 218 | const showHeader = hasOverflow || !onlyToolEntries; | |
| 219 | ||
| 220 | // Card chrome, optional header, and one compact work-entry row per visible entry. | |
| 221 | return 28 + (showHeader ? 26 : 0) + visibleEntries * 32; | |
| 211 | return anyChanged ? { byId: next, result } : previous; | |
| 222 | 212 | } |
| 223 | 213 | |
| 224 | function estimateTimelineProposedPlanHeight(proposedPlan: ProposedPlan): number { | |
| 225 | const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); | |
| 226 | return 120 + Math.min(estimatedLines * 22, 880); | |
| 227 | } | |
| 214 | /** Shallow field comparison per row variant — avoids deep equality cost. */ | |
| 215 | function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean { | |
| 216 | if (a.kind !== b.kind || a.id !== b.id) return false; | |
| 228 | 217 | |
| 229 | function estimateChangedFilesCardHeight(turnDiffSummary: TurnDiffSummary): number { | |
| 230 | const treeNodes = buildTurnDiffTree(turnDiffSummary.files); | |
| 231 | const visibleNodeCount = countTurnDiffTreeNodes(treeNodes); | |
| 218 | switch (a.kind) { | |
| 219 | case "working": | |
| 220 | return a.createdAt === (b as typeof a).createdAt; | |
| 232 | 221 | |
| 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; | |
| 236 | 227 | |
| 237 | function countTurnDiffTreeNodes(nodes: ReadonlyArray<TurnDiffTreeNode>): number { | |
| 238 | let count = 0; | |
| 239 | for (const node of nodes) { | |
| 240 | count += 1; | |
| 241 | if (node.kind === "directory") { | |
| 242 | count += countTurnDiffTreeNodes(node.children); | |
| 228 | case "message": { | |
| 229 | const bm = b as typeof a; | |
| 230 | return ( | |
| 231 | a.message === bm.message && | |
| 232 | a.durationStart === bm.durationStart && | |
| 233 | a.showCompletionDivider === bm.showCompletionDivider && | |
| 234 | a.showAssistantCopyButton === bm.showAssistantCopyButton && | |
| 235 | a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary && | |
| 236 | a.revertTurnCount === bm.revertTurnCount | |
| 237 | ); | |
| 243 | 238 | } |
| 244 | 239 | } |
| 245 | return count; | |
| 246 | 240 | } |
apps/web/src/components/chat/MessagesTimeline.test.tsx deleted-151| ... | ... | @@ -1,151 +0,0 @@ |
| 1 | import { MessageId } from "@t3tools/contracts"; | |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 3 | import { beforeAll, describe, expect, it, vi } from "vitest"; | |
| 4 | ||
| 5 | function matchMedia() { | |
| 6 | return { | |
| 7 | matches: false, | |
| 8 | addEventListener: () => {}, | |
| 9 | removeEventListener: () => {}, | |
| 10 | }; | |
| 11 | } | |
| 12 | ||
| 13 | beforeAll(() => { | |
| 14 | const classList = { | |
| 15 | add: () => {}, | |
| 16 | remove: () => {}, | |
| 17 | toggle: () => {}, | |
| 18 | contains: () => false, | |
| 19 | }; | |
| 20 | ||
| 21 | vi.stubGlobal("localStorage", { | |
| 22 | getItem: () => null, | |
| 23 | setItem: () => {}, | |
| 24 | removeItem: () => {}, | |
| 25 | clear: () => {}, | |
| 26 | }); | |
| 27 | vi.stubGlobal("window", { | |
| 28 | matchMedia, | |
| 29 | addEventListener: () => {}, | |
| 30 | removeEventListener: () => {}, | |
| 31 | desktopBridge: undefined, | |
| 32 | }); | |
| 33 | vi.stubGlobal("document", { | |
| 34 | documentElement: { | |
| 35 | classList, | |
| 36 | offsetHeight: 0, | |
| 37 | }, | |
| 38 | }); | |
| 39 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { | |
| 40 | callback(0); | |
| 41 | return 0; | |
| 42 | }); | |
| 43 | }); | |
| 44 | ||
| 45 | const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never; | |
| 46 | ||
| 47 | describe("MessagesTimeline", () => { | |
| 48 | it("renders inline terminal labels with the composer chip UI", async () => { | |
| 49 | const { MessagesTimeline } = await import("./MessagesTimeline"); | |
| 50 | const markup = renderToStaticMarkup( | |
| 51 | <MessagesTimeline | |
| 52 | hasMessages | |
| 53 | isWorking={false} | |
| 54 | activeTurnInProgress={false} | |
| 55 | activeTurnStartedAt={null} | |
| 56 | scrollContainer={null} | |
| 57 | timelineEntries={[ | |
| 58 | { | |
| 59 | id: "entry-1", | |
| 60 | kind: "message", | |
| 61 | createdAt: "2026-03-17T19:12:28.000Z", | |
| 62 | message: { | |
| 63 | id: MessageId.make("message-2"), | |
| 64 | role: "user", | |
| 65 | text: [ | |
| 66 | "yoo what's @terminal-1:1-5 mean", | |
| 67 | "", | |
| 68 | "<terminal_context>", | |
| 69 | "- Terminal 1 lines 1-5:", | |
| 70 | " 1 | julius@mac effect-http-ws-cli % bun i", | |
| 71 | " 2 | bun install v1.3.9 (cf6cdbbb)", | |
| 72 | "</terminal_context>", | |
| 73 | ].join("\n"), | |
| 74 | createdAt: "2026-03-17T19:12:28.000Z", | |
| 75 | streaming: false, | |
| 76 | }, | |
| 77 | }, | |
| 78 | ]} | |
| 79 | completionDividerBeforeEntryId={null} | |
| 80 | completionSummary={null} | |
| 81 | turnDiffSummaryByAssistantMessageId={new Map()} | |
| 82 | nowIso="2026-03-17T19:12:30.000Z" | |
| 83 | expandedWorkGroups={{}} | |
| 84 | onToggleWorkGroup={() => {}} | |
| 85 | changedFilesExpandedByTurnId={{}} | |
| 86 | onSetChangedFilesExpanded={() => {}} | |
| 87 | onOpenTurnDiff={() => {}} | |
| 88 | revertTurnCountByUserMessageId={new Map()} | |
| 89 | onRevertUserMessage={() => {}} | |
| 90 | isRevertingCheckpoint={false} | |
| 91 | onImageExpand={() => {}} | |
| 92 | activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} | |
| 93 | markdownCwd={undefined} | |
| 94 | resolvedTheme="light" | |
| 95 | timestampFormat="locale" | |
| 96 | workspaceRoot={undefined} | |
| 97 | />, | |
| 98 | ); | |
| 99 | ||
| 100 | expect(markup).toContain("Terminal 1 lines 1-5"); | |
| 101 | expect(markup).toContain("lucide-terminal"); | |
| 102 | expect(markup).toContain("yoo what&#x27;s "); | |
| 103 | }, 10_000); | |
| 104 | ||
| 105 | it("renders context compaction entries in the normal work log", async () => { | |
| 106 | const { MessagesTimeline } = await import("./MessagesTimeline"); | |
| 107 | const markup = renderToStaticMarkup( | |
| 108 | <MessagesTimeline | |
| 109 | hasMessages | |
| 110 | isWorking={false} | |
| 111 | activeTurnInProgress={false} | |
| 112 | activeTurnStartedAt={null} | |
| 113 | scrollContainer={null} | |
| 114 | timelineEntries={[ | |
| 115 | { | |
| 116 | id: "entry-1", | |
| 117 | kind: "work", | |
| 118 | createdAt: "2026-03-17T19:12:28.000Z", | |
| 119 | entry: { | |
| 120 | id: "work-1", | |
| 121 | createdAt: "2026-03-17T19:12:28.000Z", | |
| 122 | label: "Context compacted", | |
| 123 | tone: "info", | |
| 124 | }, | |
| 125 | }, | |
| 126 | ]} | |
| 127 | completionDividerBeforeEntryId={null} | |
| 128 | completionSummary={null} | |
| 129 | turnDiffSummaryByAssistantMessageId={new Map()} | |
| 130 | nowIso="2026-03-17T19:12:30.000Z" | |
| 131 | expandedWorkGroups={{}} | |
| 132 | onToggleWorkGroup={() => {}} | |
| 133 | changedFilesExpandedByTurnId={{}} | |
| 134 | onSetChangedFilesExpanded={() => {}} | |
| 135 | onOpenTurnDiff={() => {}} | |
| 136 | revertTurnCountByUserMessageId={new Map()} | |
| 137 | onRevertUserMessage={() => {}} | |
| 138 | isRevertingCheckpoint={false} | |
| 139 | onImageExpand={() => {}} | |
| 140 | activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} | |
| 141 | markdownCwd={undefined} | |
| 142 | resolvedTheme="light" | |
| 143 | timestampFormat="locale" | |
| 144 | workspaceRoot={undefined} | |
| 145 | />, | |
| 146 | ); | |
| 147 | ||
| 148 | expect(markup).toContain("Context compacted"); | |
| 149 | expect(markup).toContain("Work log"); | |
| 150 | }); | |
| 151 | }); |
apps/web/src/components/chat/MessagesTimeline.tsx+422-388| ... | ... | @@ -1,21 +1,17 @@ |
| 1 | 1 | import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts"; |
| 2 | 2 | import { |
| 3 | createContext, | |
| 3 | 4 | memo, |
| 5 | use, | |
| 4 | 6 | useCallback, |
| 5 | 7 | useEffect, |
| 6 | useLayoutEffect, | |
| 7 | 8 | useMemo, |
| 8 | 9 | useRef, |
| 9 | 10 | useState, |
| 10 | 11 | type ReactNode, |
| 11 | 12 | } from "react"; |
| 12 | import { | |
| 13 | measureElement as measureVirtualElement, | |
| 14 | type VirtualItem, | |
| 15 | useVirtualizer, | |
| 16 | } from "@tanstack/react-virtual"; | |
| 13 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; | |
| 17 | 14 | import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; |
| 18 | import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; | |
| 19 | 15 | import { type TurnDiffSummary } from "../../types"; |
| 20 | 16 | import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; |
| 21 | 17 | import ChatMarkdown from "../ChatMarkdown"; |
| ... | ... | @@ -34,18 +30,18 @@ import { |
| 34 | 30 | ZapIcon, |
| 35 | 31 | } from "lucide-react"; |
| 36 | 32 | import { Button } from "../ui/button"; |
| 37 | import { clamp } from "effect/Number"; | |
| 38 | 33 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; |
| 39 | 34 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 40 | 35 | import { ChangedFilesTree } from "./ChangedFilesTree"; |
| 41 | 36 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 42 | 37 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 43 | 38 | import { |
| 39 | computeStableMessagesTimelineRows, | |
| 44 | 40 | MAX_VISIBLE_WORK_LOG_ENTRIES, |
| 45 | 41 | deriveMessagesTimelineRows, |
| 46 | estimateMessagesTimelineRowHeight, | |
| 47 | 42 | normalizeCompactToolLabel, |
| 48 | 43 | resolveAssistantMessageCopyState, |
| 44 | type StableMessagesTimelineRowsState, | |
| 49 | 45 | type MessagesTimelineRow, |
| 50 | 46 | } from "./MessagesTimeline.logic"; |
| 51 | 47 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; |
| ... | ... | @@ -55,6 +51,7 @@ import { |
| 55 | 51 | type ParsedTerminalContextEntry, |
| 56 | 52 | } from "~/lib/terminalContext"; |
| 57 | 53 | import { cn } from "~/lib/utils"; |
| 54 | import { useUiStateStore } from "~/uiStateStore"; | |
| 58 | 55 | import { type TimestampFormat } from "@t3tools/contracts/settings"; |
| 59 | 56 | import { formatTimestamp } from "../../timestampFormat"; |
| 60 | 57 | |
| ... | ... | @@ -64,7 +61,35 @@ import { |
| 64 | 61 | textContainsInlineTerminalContextLabels, |
| 65 | 62 | } from "./userMessageTerminalContexts"; |
| 66 | 63 | |
| 67 | const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; | |
| 64 | // --------------------------------------------------------------------------- | |
| 65 | // Context — shared state consumed by every row component via useContext. | |
| 66 | // Propagates through LegendList's memo boundaries for shared callbacks and | |
| 67 | // non-row-scoped state. `nowIso` is intentionally excluded — self-ticking | |
| 68 | // components (WorkingTimer, LiveElapsed) handle it. | |
| 69 | // --------------------------------------------------------------------------- | |
| 70 | ||
| 71 | interface TimelineRowSharedState { | |
| 72 | activeTurnInProgress: boolean; | |
| 73 | activeTurnId: TurnId | null | undefined; | |
| 74 | isWorking: boolean; | |
| 75 | isRevertingCheckpoint: boolean; | |
| 76 | completionSummary: string | null; | |
| 77 | timestampFormat: TimestampFormat; | |
| 78 | routeThreadKey: string; | |
| 79 | markdownCwd: string | undefined; | |
| 80 | resolvedTheme: "light" | "dark"; | |
| 81 | workspaceRoot: string | undefined; | |
| 82 | activeThreadEnvironmentId: EnvironmentId; | |
| 83 | onRevertUserMessage: (messageId: MessageId) => void; | |
| 84 | onImageExpand: (preview: ExpandedImagePreview) => void; | |
| 85 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | |
| 86 | } | |
| 87 | ||
| 88 | const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); | |
| 89 | ||
| 90 | // --------------------------------------------------------------------------- | |
| 91 | // Props (public API) | |
| 92 | // --------------------------------------------------------------------------- | |
| 68 | 93 | |
| 69 | 94 | interface MessagesTimelineProps { |
| 70 | 95 | hasMessages: boolean; |
| ... | ... | @@ -72,16 +97,12 @@ interface MessagesTimelineProps { |
| 72 | 97 | activeTurnInProgress: boolean; |
| 73 | 98 | activeTurnId?: TurnId | null; |
| 74 | 99 | activeTurnStartedAt: string | null; |
| 75 | scrollContainer: HTMLDivElement | null; | |
| 100 | listRef: React.RefObject<LegendListRef | null>; | |
| 76 | 101 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| 77 | 102 | completionDividerBeforeEntryId: string | null; |
| 78 | 103 | completionSummary: string | null; |
| 79 | 104 | 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; | |
| 85 | 106 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 86 | 107 | revertTurnCountByUserMessageId: Map<MessageId, number>; |
| 87 | 108 | onRevertUserMessage: (messageId: MessageId) => void; |
| ... | ... | @@ -92,35 +113,25 @@ interface MessagesTimelineProps { |
| 92 | 113 | resolvedTheme: "light" | "dark"; |
| 93 | 114 | timestampFormat: TimestampFormat; |
| 94 | 115 | 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; | |
| 106 | 117 | } |
| 107 | 118 | |
| 119 | // --------------------------------------------------------------------------- | |
| 120 | // MessagesTimeline — list owner | |
| 121 | // --------------------------------------------------------------------------- | |
| 122 | ||
| 108 | 123 | export const MessagesTimeline = memo(function MessagesTimeline({ |
| 109 | 124 | hasMessages, |
| 110 | 125 | isWorking, |
| 111 | 126 | activeTurnInProgress, |
| 112 | 127 | activeTurnId, |
| 113 | 128 | activeTurnStartedAt, |
| 114 | scrollContainer, | |
| 129 | listRef, | |
| 115 | 130 | timelineEntries, |
| 116 | 131 | completionDividerBeforeEntryId, |
| 117 | 132 | completionSummary, |
| 118 | 133 | turnDiffSummaryByAssistantMessageId, |
| 119 | nowIso, | |
| 120 | expandedWorkGroups, | |
| 121 | onToggleWorkGroup, | |
| 122 | changedFilesExpandedByTurnId, | |
| 123 | onSetChangedFilesExpanded, | |
| 134 | routeThreadKey, | |
| 124 | 135 | onOpenTurnDiff, |
| 125 | 136 | revertTurnCountByUserMessageId, |
| 126 | 137 | onRevertUserMessage, |
| ... | ... | @@ -131,181 +142,132 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 131 | 142 | resolvedTheme, |
| 132 | 143 | timestampFormat, |
| 133 | 144 | workspaceRoot, |
| 134 | onVirtualizerSnapshot, | |
| 145 | onIsAtEndChange, | |
| 135 | 146 | }: 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( | |
| 165 | 148 | () => |
| 166 | 149 | deriveMessagesTimelineRows({ |
| 167 | 150 | timelineEntries, |
| 168 | 151 | completionDividerBeforeEntryId, |
| 169 | 152 | isWorking, |
| 170 | 153 | activeTurnStartedAt, |
| 154 | turnDiffSummaryByAssistantMessageId, | |
| 155 | revertTurnCountByUserMessageId, | |
| 171 | 156 | }), |
| 172 | [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt], | |
| 157 | [ | |
| 158 | timelineEntries, | |
| 159 | completionDividerBeforeEntryId, | |
| 160 | isWorking, | |
| 161 | activeTurnStartedAt, | |
| 162 | turnDiffSummaryByAssistantMessageId, | |
| 163 | revertTurnCountByUserMessageId, | |
| 164 | ], | |
| 173 | 165 | ); |
| 166 | const rows = useStableRows(rawRows); | |
| 174 | 167 | |
| 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); | |
| 189 | 172 | } |
| 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 | ); | |
| 190 | 211 | |
| 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 | ); | |
| 196 | 222 | |
| 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 | } | |
| 198 | 232 | |
| 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 | }); | |
| 209 | 253 | |
| 210 | return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex); | |
| 211 | }, [activeTurnInProgress, activeTurnStartedAt, rows]); | |
| 254 | function keyExtractor(item: MessagesTimelineRow) { | |
| 255 | return item.id; | |
| 256 | } | |
| 212 | 257 | |
| 213 | const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, { | |
| 214 | minimum: 0, | |
| 215 | maximum: rows.length, | |
| 216 | }); | |
| 217 | const virtualMeasurementScopeKey = | |
| 218 | timelineWidthPx === null ? "width:unknown" : `width:${Math.round(timelineWidthPx)}`; | |
| 219 | ||
| 220 | const rowVirtualizer = useVirtualizer({ | |
| 221 | count: virtualizedRowCount, | |
| 222 | getScrollElement: () => scrollContainer, | |
| 223 | // Scope cached row measurements to the current timeline width so offscreen | |
| 224 | // rows do not keep stale heights after wrapping changes. | |
| 225 | getItemKey: (index: number) => { | |
| 226 | const rowId = rows[index]?.id ?? String(index); | |
| 227 | return `${virtualMeasurementScopeKey}:${rowId}`; | |
| 228 | }, | |
| 229 | estimateSize: (index: number) => { | |
| 230 | const row = rows[index]; | |
| 231 | if (!row) return 96; | |
| 232 | return estimateMessagesTimelineRowHeight(row, { | |
| 233 | expandedWorkGroups, | |
| 234 | timelineWidthPx, | |
| 235 | turnDiffSummaryByAssistantMessageId, | |
| 236 | }); | |
| 237 | }, | |
| 238 | measureElement: measureVirtualElement, | |
| 239 | useAnimationFrameWithResizeObserver: true, | |
| 240 | overscan: 8, | |
| 241 | }); | |
| 242 | useEffect(() => { | |
| 243 | if (timelineWidthPx === null) return; | |
| 244 | rowVirtualizer.measure(); | |
| 245 | }, [rowVirtualizer, timelineWidthPx]); | |
| 246 | useEffect(() => { | |
| 247 | rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { | |
| 248 | const viewportHeight = instance.scrollRect?.height ?? 0; | |
| 249 | const scrollOffset = instance.scrollOffset ?? 0; | |
| 250 | const itemIntersectsViewport = | |
| 251 | item.end > scrollOffset && item.start < scrollOffset + viewportHeight; | |
| 252 | if (itemIntersectsViewport) { | |
| 253 | return false; | |
| 254 | } | |
| 255 | const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); | |
| 256 | return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; | |
| 257 | }; | |
| 258 | return () => { | |
| 259 | rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; | |
| 260 | }; | |
| 261 | }, [rowVirtualizer]); | |
| 262 | const pendingMeasureFrameRef = useRef<number | null>(null); | |
| 263 | const onTimelineImageLoad = useCallback(() => { | |
| 264 | if (pendingMeasureFrameRef.current !== null) return; | |
| 265 | pendingMeasureFrameRef.current = window.requestAnimationFrame(() => { | |
| 266 | pendingMeasureFrameRef.current = null; | |
| 267 | rowVirtualizer.measure(); | |
| 268 | }); | |
| 269 | }, [rowVirtualizer]); | |
| 270 | useEffect(() => { | |
| 271 | return () => { | |
| 272 | const frame = pendingMeasureFrameRef.current; | |
| 273 | if (frame !== null) { | |
| 274 | window.cancelAnimationFrame(frame); | |
| 275 | } | |
| 276 | }; | |
| 277 | }, []); | |
| 278 | useLayoutEffect(() => { | |
| 279 | if (!onVirtualizerSnapshot) { | |
| 280 | return; | |
| 281 | } | |
| 282 | onVirtualizerSnapshot({ | |
| 283 | totalSize: rowVirtualizer.getTotalSize(), | |
| 284 | measurements: rowVirtualizer.measurementsCache | |
| 285 | .slice(0, virtualizedRowCount) | |
| 286 | .flatMap((measurement) => { | |
| 287 | const row = rows[measurement.index]; | |
| 288 | if (!row) { | |
| 289 | return []; | |
| 290 | } | |
| 291 | return [ | |
| 292 | { | |
| 293 | id: row.id, | |
| 294 | kind: row.kind, | |
| 295 | index: measurement.index, | |
| 296 | size: measurement.size, | |
| 297 | start: measurement.start, | |
| 298 | end: measurement.end, | |
| 299 | }, | |
| 300 | ]; | |
| 301 | }), | |
| 302 | }); | |
| 303 | }, [onVirtualizerSnapshot, rowVirtualizer, rows, virtualizedRowCount]); | |
| 304 | ||
| 305 | const virtualRows = rowVirtualizer.getVirtualItems(); | |
| 306 | const nonVirtualizedRows = rows.slice(virtualizedRowCount); | |
| 307 | ||
| 308 | const renderRowContent = (row: TimelineRow) => ( | |
| 258 | // --------------------------------------------------------------------------- | |
| 259 | // TimelineRowContent — the actual row component | |
| 260 | // --------------------------------------------------------------------------- | |
| 261 | ||
| 262 | type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; | |
| 263 | type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"]; | |
| 264 | type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number]; | |
| 265 | type TimelineRow = MessagesTimelineRow; | |
| 266 | ||
| 267 | function TimelineRowContent({ row }: { row: TimelineRow }) { | |
| 268 | const ctx = use(TimelineRowCtx); | |
| 269 | ||
| 270 | return ( | |
| 309 | 271 | <div |
| 310 | 272 | className={cn( |
| 311 | 273 | "pb-4", |
| ... | ... | @@ -316,47 +278,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 316 | 278 | data-message-id={row.kind === "message" ? row.message.id : undefined} |
| 317 | 279 | data-message-role={row.kind === "message" ? row.message.role : undefined} |
| 318 | 280 | > |
| 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} />} | |
| 360 | 282 | |
| 361 | 283 | {row.kind === "message" && |
| 362 | 284 | row.message.role === "user" && |
| ... | ... | @@ -364,7 +286,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 364 | 286 | const userImages = row.message.attachments ?? []; |
| 365 | 287 | const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); |
| 366 | 288 | const terminalContexts = displayedUserMessage.contexts; |
| 367 | const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); | |
| 289 | const canRevertAgentWork = typeof row.revertTurnCount === "number"; | |
| 368 | 290 | return ( |
| 369 | 291 | <div className="flex justify-end"> |
| 370 | 292 | <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({ |
| 384 | 306 | onClick={() => { |
| 385 | 307 | const preview = buildExpandedImagePreview(userImages, image.id); |
| 386 | 308 | if (!preview) return; |
| 387 | onImageExpand(preview); | |
| 309 | ctx.onImageExpand(preview); | |
| 388 | 310 | }} |
| 389 | 311 | > |
| 390 | 312 | <img |
| 391 | 313 | src={image.previewUrl} |
| 392 | 314 | alt={image.name} |
| 393 | 315 | className="block h-auto max-h-[220px] w-full object-cover" |
| 394 | onLoad={onTimelineImageLoad} | |
| 395 | onError={onTimelineImageLoad} | |
| 396 | 316 | /> |
| 397 | 317 | </button> |
| 398 | 318 | ) : ( |
| ... | ... | @@ -422,8 +342,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 422 | 342 | type="button" |
| 423 | 343 | size="xs" |
| 424 | 344 | 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)} | |
| 427 | 347 | title="Revert to this message" |
| 428 | 348 | > |
| 429 | 349 | <Undo2Icon className="size-3" /> |
| ... | ... | @@ -431,7 +351,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 431 | 351 | )} |
| 432 | 352 | </div> |
| 433 | 353 | <p className="text-right text-xs text-muted-foreground/50"> |
| 434 | {formatTimestamp(row.message.createdAt, timestampFormat)} | |
| 354 | {formatTimestamp(row.message.createdAt, ctx.timestampFormat)} | |
| 435 | 355 | </p> |
| 436 | 356 | </div> |
| 437 | 357 | </div> |
| ... | ... | @@ -444,10 +364,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 444 | 364 | (() => { |
| 445 | 365 | const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); |
| 446 | 366 | 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; | |
| 451 | 371 | const assistantCopyState = resolveAssistantMessageCopyState({ |
| 452 | 372 | text: row.message.text ?? null, |
| 453 | 373 | showCopyButton: row.showAssistantCopyButton, |
| ... | ... | @@ -459,7 +379,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 459 | 379 | <div className="my-3 flex items-center gap-3"> |
| 460 | 380 | <span className="h-px flex-1 bg-border" /> |
| 461 | 381 | <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"} | |
| 463 | 383 | </span> |
| 464 | 384 | <span className="h-px flex-1 bg-border" /> |
| 465 | 385 | </div> |
| ... | ... | @@ -467,76 +387,29 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 467 | 387 | <div className="min-w-0 px-1 py-0.5"> |
| 468 | 388 | <ChatMarkdown |
| 469 | 389 | text={messageText} |
| 470 | cwd={markdownCwd} | |
| 390 | cwd={ctx.markdownCwd} | |
| 471 | 391 | isStreaming={Boolean(row.message.streaming)} |
| 472 | 392 | /> |
| 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 | /> | |
| 532 | 399 | <div className="mt-1.5 flex items-center gap-2"> |
| 533 | 400 | <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 | ) | |
| 540 | 413 | )} |
| 541 | 414 | </p> |
| 542 | 415 | {assistantCopyState.visible ? ( |
| ... | ... | @@ -559,9 +432,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 559 | 432 | <div className="min-w-0 px-1 py-0.5"> |
| 560 | 433 | <ProposedPlanCard |
| 561 | 434 | 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} | |
| 565 | 438 | /> |
| 566 | 439 | </div> |
| 567 | 440 | )} |
| ... | ... | @@ -575,100 +448,205 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 575 | 448 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> |
| 576 | 449 | </span> |
| 577 | 450 | <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 | )} | |
| 581 | 458 | </span> |
| 582 | 459 | </div> |
| 583 | 460 | </div> |
| 584 | 461 | )} |
| 585 | 462 | </div> |
| 586 | 463 | ); |
| 464 | } | |
| 587 | 465 | |
| 588 | if (!hasMessages && !isWorking) { | |
| 589 | return ( | |
| 590 | <div className="flex h-full items-center justify-center"> | |
| 591 | <p className="text-sm text-muted-foreground/30"> | |
| 592 | Send a message to start the conversation. | |
| 593 | </p> | |
| 594 | </div> | |
| 595 | ); | |
| 596 | } | |
| 466 | // --------------------------------------------------------------------------- | |
| 467 | // Self-ticking components — bypass LegendList memoisation entirely. | |
| 468 | // Each owns a `nowMs` state value consumed in the render output so the | |
| 469 | // React Compiler cannot elide the re-render as a no-op. | |
| 470 | // --------------------------------------------------------------------------- | |
| 471 | ||
| 472 | /** Live "Working for Xs" label. */ | |
| 473 | function WorkingTimer({ createdAt }: { createdAt: string }) { | |
| 474 | const [nowMs, setNowMs] = useState(() => Date.now()); | |
| 475 | useEffect(() => { | |
| 476 | const id = setInterval(() => setNowMs(Date.now()), 1000); | |
| 477 | return () => clearInterval(id); | |
| 478 | }, [createdAt]); | |
| 479 | return <>{formatWorkingTimer(createdAt, new Date(nowMs).toISOString()) ?? "0s"}</>; | |
| 480 | } | |
| 481 | ||
| 482 | /** Live timestamp + elapsed duration for a streaming assistant message. */ | |
| 483 | function LiveMessageMeta({ | |
| 484 | createdAt, | |
| 485 | durationStart, | |
| 486 | timestampFormat, | |
| 487 | }: { | |
| 488 | createdAt: string; | |
| 489 | durationStart: string | null | undefined; | |
| 490 | timestampFormat: TimestampFormat; | |
| 491 | }) { | |
| 492 | const [nowMs, setNowMs] = useState(() => Date.now()); | |
| 493 | useEffect(() => { | |
| 494 | const id = setInterval(() => setNowMs(Date.now()), 1000); | |
| 495 | return () => clearInterval(id); | |
| 496 | }, [durationStart]); | |
| 497 | const elapsed = durationStart | |
| 498 | ? formatElapsed(durationStart, new Date(nowMs).toISOString()) | |
| 499 | : null; | |
| 500 | return <>{formatMessageMeta(createdAt, elapsed, timestampFormat)}</>; | |
| 501 | } | |
| 502 | ||
| 503 | // --------------------------------------------------------------------------- | |
| 504 | // Extracted row sections — own their state / store subscriptions so changes | |
| 505 | // re-render only the affected row, not the entire list. | |
| 506 | // --------------------------------------------------------------------------- | |
| 507 | ||
| 508 | /** Owns its own expand/collapse state so toggling re-renders only this row. | |
| 509 | * State resets on unmount which is fine — work groups start collapsed. */ | |
| 510 | const WorkGroupSection = memo(function WorkGroupSection({ | |
| 511 | groupedEntries, | |
| 512 | }: { | |
| 513 | groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"]; | |
| 514 | }) { | |
| 515 | const [isExpanded, setIsExpanded] = useState(false); | |
| 516 | const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | |
| 517 | const visibleEntries = | |
| 518 | hasOverflow && !isExpanded | |
| 519 | ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) | |
| 520 | : groupedEntries; | |
| 521 | const hiddenCount = groupedEntries.length - visibleEntries.length; | |
| 522 | const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); | |
| 523 | const showHeader = hasOverflow || !onlyToolEntries; | |
| 524 | const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; | |
| 597 | 525 | |
| 598 | 526 | 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 | )} | |
| 626 | 542 | </div> |
| 627 | 543 | )} |
| 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> | |
| 632 | 549 | </div> |
| 633 | 550 | ); |
| 634 | 551 | }); |
| 635 | 552 | |
| 636 | type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; | |
| 637 | type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"]; | |
| 638 | type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number]; | |
| 639 | type TimelineRow = MessagesTimelineRow; | |
| 640 | ||
| 641 | function formatWorkingTimer(startIso: string, endIso: string): string | null { | |
| 642 | const startedAtMs = Date.parse(startIso); | |
| 643 | const endedAtMs = Date.parse(endIso); | |
| 644 | if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { | |
| 645 | return null; | |
| 646 | } | |
| 647 | ||
| 648 | const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); | |
| 649 | if (elapsedSeconds < 60) { | |
| 650 | return `${elapsedSeconds}s`; | |
| 651 | } | |
| 553 | /** Subscribes directly to the UI state store for expand/collapse state, | |
| 554 | * so toggling re-renders only this component — not the entire list. */ | |
| 555 | const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection({ | |
| 556 | turnSummary, | |
| 557 | routeThreadKey, | |
| 558 | resolvedTheme, | |
| 559 | onOpenTurnDiff, | |
| 560 | }: { | |
| 561 | turnSummary: TurnDiffSummary | undefined; | |
| 562 | routeThreadKey: string; | |
| 563 | resolvedTheme: "light" | "dark"; | |
| 564 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | |
| 565 | }) { | |
| 566 | if (!turnSummary) return null; | |
| 567 | const checkpointFiles = turnSummary.files; | |
| 568 | if (checkpointFiles.length === 0) return null; | |
| 652 | 569 | |
| 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 | }); | |
| 656 | 580 | |
| 657 | if (hours > 0) { | |
| 658 | return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; | |
| 659 | } | |
| 581 | /** Inner component that only mounts when there are actual changed files, | |
| 582 | * so the store subscription is unconditional (no hooks after early return). */ | |
| 583 | function AssistantChangedFilesSectionInner({ | |
| 584 | turnSummary, | |
| 585 | checkpointFiles, | |
| 586 | routeThreadKey, | |
| 587 | resolvedTheme, | |
| 588 | onOpenTurnDiff, | |
| 589 | }: { | |
| 590 | turnSummary: TurnDiffSummary; | |
| 591 | checkpointFiles: TurnDiffSummary["files"]; | |
| 592 | routeThreadKey: string; | |
| 593 | resolvedTheme: "light" | "dark"; | |
| 594 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | |
| 595 | }) { | |
| 596 | const allDirectoriesExpanded = useUiStateStore( | |
| 597 | (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true, | |
| 598 | ); | |
| 599 | const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); | |
| 600 | const summaryStat = summarizeTurnDiffStats(checkpointFiles); | |
| 601 | const changedFileCountLabel = String(checkpointFiles.length); | |
| 660 | 602 | |
| 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 | ); | |
| 662 | 645 | } |
| 663 | 646 | |
| 664 | function formatMessageMeta( | |
| 665 | createdAt: string, | |
| 666 | duration: string | null, | |
| 667 | timestampFormat: TimestampFormat, | |
| 668 | ): string { | |
| 669 | if (!duration) return formatTimestamp(createdAt, timestampFormat); | |
| 670 | return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; | |
| 671 | } | |
| 647 | // --------------------------------------------------------------------------- | |
| 648 | // Leaf components | |
| 649 | // --------------------------------------------------------------------------- | |
| 672 | 650 | |
| 673 | 651 | const UserMessageTerminalContextInlineLabel = memo( |
| 674 | 652 | function UserMessageTerminalContextInlineLabel(props: { context: ParsedTerminalContextEntry }) { |
| ... | ... | @@ -774,6 +752,62 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 774 | 752 | ); |
| 775 | 753 | }); |
| 776 | 754 | |
| 755 | // --------------------------------------------------------------------------- | |
| 756 | // Structural sharing — reuse old row references when data hasn't changed | |
| 757 | // so LegendList (and React) can skip re-rendering unchanged items. | |
| 758 | // --------------------------------------------------------------------------- | |
| 759 | ||
| 760 | /** Returns a structurally-shared copy of `rows`: for each row whose content | |
| 761 | * hasn't changed since last call, the previous object reference is reused. */ | |
| 762 | function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { | |
| 763 | const prevState = useRef<StableMessagesTimelineRowsState>({ | |
| 764 | byId: new Map<string, MessagesTimelineRow>(), | |
| 765 | result: [], | |
| 766 | }); | |
| 767 | ||
| 768 | return useMemo(() => { | |
| 769 | const nextState = computeStableMessagesTimelineRows(rows, prevState.current); | |
| 770 | prevState.current = nextState; | |
| 771 | return nextState.result; | |
| 772 | }, [rows]); | |
| 773 | } | |
| 774 | ||
| 775 | // --------------------------------------------------------------------------- | |
| 776 | // Pure helpers | |
| 777 | // --------------------------------------------------------------------------- | |
| 778 | ||
| 779 | function formatWorkingTimer(startIso: string, endIso: string): string | null { | |
| 780 | const startedAtMs = Date.parse(startIso); | |
| 781 | const endedAtMs = Date.parse(endIso); | |
| 782 | if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { | |
| 783 | return null; | |
| 784 | } | |
| 785 | ||
| 786 | const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); | |
| 787 | if (elapsedSeconds < 60) { | |
| 788 | return `${elapsedSeconds}s`; | |
| 789 | } | |
| 790 | ||
| 791 | const hours = Math.floor(elapsedSeconds / 3600); | |
| 792 | const minutes = Math.floor((elapsedSeconds % 3600) / 60); | |
| 793 | const seconds = elapsedSeconds % 60; | |
| 794 | ||
| 795 | if (hours > 0) { | |
| 796 | return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; | |
| 797 | } | |
| 798 | ||
| 799 | return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; | |
| 800 | } | |
| 801 | ||
| 802 | function formatMessageMeta( | |
| 803 | createdAt: string, | |
| 804 | duration: string | null, | |
| 805 | timestampFormat: TimestampFormat, | |
| 806 | ): string { | |
| 807 | if (!duration) return formatTimestamp(createdAt, timestampFormat); | |
| 808 | return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; | |
| 809 | } | |
| 810 | ||
| 777 | 811 | function workToneIcon(tone: TimelineWorkEntry["tone"]): { |
| 778 | 812 | icon: LucideIcon; |
| 779 | 813 | className: string; |
apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx deleted-1071| ... | ... | @@ -1,1071 +0,0 @@ |
| 1 | import "../../index.css"; | |
| 2 | ||
| 3 | import { MessageId, type TurnId } from "@t3tools/contracts"; | |
| 4 | import { page } from "vitest/browser"; | |
| 5 | import { useCallback, useState, type ComponentProps } from "react"; | |
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 7 | import { render } from "vitest-browser-react"; | |
| 8 | ||
| 9 | import { deriveTimelineEntries, type WorkLogEntry } from "../../session-logic"; | |
| 10 | import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; | |
| 11 | import { MessagesTimeline } from "./MessagesTimeline"; | |
| 12 | import { | |
| 13 | deriveMessagesTimelineRows, | |
| 14 | estimateMessagesTimelineRowHeight, | |
| 15 | } from "./MessagesTimeline.logic"; | |
| 16 | ||
| 17 | const DEFAULT_VIEWPORT = { | |
| 18 | width: 960, | |
| 19 | height: 1_100, | |
| 20 | }; | |
| 21 | const MARKDOWN_CWD = "/repo/project"; | |
| 22 | const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never; | |
| 23 | ||
| 24 | interface RowMeasurement { | |
| 25 | actualHeightPx: number; | |
| 26 | estimatedHeightPx: number; | |
| 27 | timelineWidthPx: number; | |
| 28 | virtualizerSizePx: number; | |
| 29 | renderedInVirtualizedRegion: boolean; | |
| 30 | } | |
| 31 | ||
| 32 | interface VirtualizationScenario { | |
| 33 | name: string; | |
| 34 | targetRowId: string; | |
| 35 | props: Omit< | |
| 36 | ComponentProps<typeof MessagesTimeline>, | |
| 37 | "scrollContainer" | "activeThreadEnvironmentId" | |
| 38 | >; | |
| 39 | maxEstimateDeltaPx: number; | |
| 40 | } | |
| 41 | ||
| 42 | interface VirtualizerSnapshot { | |
| 43 | totalSize: number; | |
| 44 | measurements: ReadonlyArray<{ | |
| 45 | id: string; | |
| 46 | kind: string; | |
| 47 | index: number; | |
| 48 | size: number; | |
| 49 | start: number; | |
| 50 | end: number; | |
| 51 | }>; | |
| 52 | } | |
| 53 | ||
| 54 | function MessagesTimelineBrowserHarness( | |
| 55 | props: Omit< | |
| 56 | ComponentProps<typeof MessagesTimeline>, | |
| 57 | "scrollContainer" | "activeThreadEnvironmentId" | |
| 58 | >, | |
| 59 | ) { | |
| 60 | const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null); | |
| 61 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>( | |
| 62 | () => props.expandedWorkGroups, | |
| 63 | ); | |
| 64 | const [changedFilesExpandedByTurnId, setChangedFilesExpandedByTurnId] = useState< | |
| 65 | Record<string, boolean> | |
| 66 | >(() => props.changedFilesExpandedByTurnId); | |
| 67 | const handleToggleWorkGroup = useCallback( | |
| 68 | (groupId: string) => { | |
| 69 | setExpandedWorkGroups((current) => ({ | |
| 70 | ...current, | |
| 71 | [groupId]: !(current[groupId] ?? false), | |
| 72 | })); | |
| 73 | props.onToggleWorkGroup(groupId); | |
| 74 | }, | |
| 75 | [props], | |
| 76 | ); | |
| 77 | const handleSetChangedFilesExpanded = useCallback( | |
| 78 | (turnId: TurnId, expanded: boolean) => { | |
| 79 | setChangedFilesExpandedByTurnId((current) => ({ | |
| 80 | ...current, | |
| 81 | [turnId]: expanded, | |
| 82 | })); | |
| 83 | props.onSetChangedFilesExpanded(turnId, expanded); | |
| 84 | }, | |
| 85 | [props], | |
| 86 | ); | |
| 87 | ||
| 88 | return ( | |
| 89 | <div | |
| 90 | ref={setScrollContainer} | |
| 91 | data-testid="messages-timeline-scroll-container" | |
| 92 | className="h-full overflow-y-auto overscroll-y-contain" | |
| 93 | > | |
| 94 | <MessagesTimeline | |
| 95 | {...props} | |
| 96 | activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} | |
| 97 | scrollContainer={scrollContainer} | |
| 98 | expandedWorkGroups={expandedWorkGroups} | |
| 99 | onToggleWorkGroup={handleToggleWorkGroup} | |
| 100 | changedFilesExpandedByTurnId={changedFilesExpandedByTurnId} | |
| 101 | onSetChangedFilesExpanded={handleSetChangedFilesExpanded} | |
| 102 | /> | |
| 103 | </div> | |
| 104 | ); | |
| 105 | } | |
| 106 | ||
| 107 | function isoAt(offsetSeconds: number): string { | |
| 108 | return new Date(Date.UTC(2026, 2, 17, 19, 12, 28) + offsetSeconds * 1_000).toISOString(); | |
| 109 | } | |
| 110 | ||
| 111 | function createMessage(input: { | |
| 112 | id: string; | |
| 113 | role: ChatMessage["role"]; | |
| 114 | text: string; | |
| 115 | offsetSeconds: number; | |
| 116 | attachments?: ChatMessage["attachments"]; | |
| 117 | }): ChatMessage { | |
| 118 | return { | |
| 119 | id: MessageId.make(input.id), | |
| 120 | role: input.role, | |
| 121 | text: input.text, | |
| 122 | ...(input.attachments ? { attachments: input.attachments } : {}), | |
| 123 | createdAt: isoAt(input.offsetSeconds), | |
| 124 | ...(input.role === "assistant" ? { completedAt: isoAt(input.offsetSeconds + 1) } : {}), | |
| 125 | streaming: false, | |
| 126 | }; | |
| 127 | } | |
| 128 | ||
| 129 | function createToolWorkEntry(input: { | |
| 130 | id: string; | |
| 131 | offsetSeconds: number; | |
| 132 | label?: string; | |
| 133 | detail?: string; | |
| 134 | }): WorkLogEntry { | |
| 135 | return { | |
| 136 | id: input.id, | |
| 137 | createdAt: isoAt(input.offsetSeconds), | |
| 138 | label: input.label ?? "exec_command completed", | |
| 139 | ...(input.detail ? { detail: input.detail } : {}), | |
| 140 | tone: "tool", | |
| 141 | toolTitle: "exec_command", | |
| 142 | }; | |
| 143 | } | |
| 144 | ||
| 145 | function createPlan(input: { | |
| 146 | id: string; | |
| 147 | offsetSeconds: number; | |
| 148 | planMarkdown: string; | |
| 149 | }): ProposedPlan { | |
| 150 | return { | |
| 151 | id: input.id as ProposedPlan["id"], | |
| 152 | turnId: null, | |
| 153 | planMarkdown: input.planMarkdown, | |
| 154 | implementedAt: null, | |
| 155 | implementationThreadId: null, | |
| 156 | createdAt: isoAt(input.offsetSeconds), | |
| 157 | updatedAt: isoAt(input.offsetSeconds + 1), | |
| 158 | }; | |
| 159 | } | |
| 160 | ||
| 161 | function createBaseTimelineProps(input: { | |
| 162 | messages?: ChatMessage[]; | |
| 163 | proposedPlans?: ProposedPlan[]; | |
| 164 | workEntries?: WorkLogEntry[]; | |
| 165 | expandedWorkGroups?: Record<string, boolean>; | |
| 166 | completionDividerBeforeEntryId?: string | null; | |
| 167 | turnDiffSummaryByAssistantMessageId?: Map<MessageId, TurnDiffSummary>; | |
| 168 | onVirtualizerSnapshot?: ComponentProps<typeof MessagesTimeline>["onVirtualizerSnapshot"]; | |
| 169 | }): Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer" | "activeThreadEnvironmentId"> { | |
| 170 | return { | |
| 171 | hasMessages: true, | |
| 172 | isWorking: false, | |
| 173 | activeTurnInProgress: false, | |
| 174 | activeTurnStartedAt: null, | |
| 175 | timelineEntries: deriveTimelineEntries( | |
| 176 | input.messages ?? [], | |
| 177 | input.proposedPlans ?? [], | |
| 178 | input.workEntries ?? [], | |
| 179 | ), | |
| 180 | completionDividerBeforeEntryId: input.completionDividerBeforeEntryId ?? null, | |
| 181 | completionSummary: null, | |
| 182 | turnDiffSummaryByAssistantMessageId: input.turnDiffSummaryByAssistantMessageId ?? new Map(), | |
| 183 | nowIso: isoAt(10_000), | |
| 184 | expandedWorkGroups: input.expandedWorkGroups ?? {}, | |
| 185 | onToggleWorkGroup: () => {}, | |
| 186 | changedFilesExpandedByTurnId: {}, | |
| 187 | onSetChangedFilesExpanded: () => {}, | |
| 188 | onOpenTurnDiff: () => {}, | |
| 189 | revertTurnCountByUserMessageId: new Map(), | |
| 190 | onRevertUserMessage: () => {}, | |
| 191 | isRevertingCheckpoint: false, | |
| 192 | onImageExpand: () => {}, | |
| 193 | markdownCwd: MARKDOWN_CWD, | |
| 194 | resolvedTheme: "light", | |
| 195 | timestampFormat: "locale", | |
| 196 | workspaceRoot: MARKDOWN_CWD, | |
| 197 | ...(input.onVirtualizerSnapshot ? { onVirtualizerSnapshot: input.onVirtualizerSnapshot } : {}), | |
| 198 | }; | |
| 199 | } | |
| 200 | ||
| 201 | function createFillerMessages(input: { | |
| 202 | prefix: string; | |
| 203 | startOffsetSeconds: number; | |
| 204 | pairCount: number; | |
| 205 | }): ChatMessage[] { | |
| 206 | const messages: ChatMessage[] = []; | |
| 207 | for (let index = 0; index < input.pairCount; index += 1) { | |
| 208 | const baseOffset = input.startOffsetSeconds + index * 4; | |
| 209 | messages.push( | |
| 210 | createMessage({ | |
| 211 | id: `${input.prefix}-user-${index}`, | |
| 212 | role: "user", | |
| 213 | text: `filler user message ${index}`, | |
| 214 | offsetSeconds: baseOffset, | |
| 215 | }), | |
| 216 | ); | |
| 217 | messages.push( | |
| 218 | createMessage({ | |
| 219 | id: `${input.prefix}-assistant-${index}`, | |
| 220 | role: "assistant", | |
| 221 | text: `filler assistant message ${index}`, | |
| 222 | offsetSeconds: baseOffset + 1, | |
| 223 | }), | |
| 224 | ); | |
| 225 | } | |
| 226 | return messages; | |
| 227 | } | |
| 228 | ||
| 229 | function createChangedFilesSummary( | |
| 230 | targetMessageId: MessageId, | |
| 231 | files: TurnDiffSummary["files"], | |
| 232 | ): Map<MessageId, TurnDiffSummary> { | |
| 233 | return new Map([ | |
| 234 | [ | |
| 235 | targetMessageId, | |
| 236 | { | |
| 237 | turnId: "turn-changed-files" as TurnId, | |
| 238 | completedAt: isoAt(10), | |
| 239 | assistantMessageId: targetMessageId, | |
| 240 | files, | |
| 241 | }, | |
| 242 | ], | |
| 243 | ]); | |
| 244 | } | |
| 245 | ||
| 246 | function createChangedFilesScenario(input: { | |
| 247 | name: string; | |
| 248 | rowId: string; | |
| 249 | files: TurnDiffSummary["files"]; | |
| 250 | maxEstimateDeltaPx?: number; | |
| 251 | }): VirtualizationScenario { | |
| 252 | const beforeMessages = createFillerMessages({ | |
| 253 | prefix: `${input.rowId}-before`, | |
| 254 | startOffsetSeconds: 0, | |
| 255 | pairCount: 2, | |
| 256 | }); | |
| 257 | const afterMessages = createFillerMessages({ | |
| 258 | prefix: `${input.rowId}-after`, | |
| 259 | startOffsetSeconds: 40, | |
| 260 | pairCount: 8, | |
| 261 | }); | |
| 262 | const changedFilesMessage = createMessage({ | |
| 263 | id: input.rowId, | |
| 264 | role: "assistant", | |
| 265 | text: "Validation passed on the merged tree.", | |
| 266 | offsetSeconds: 12, | |
| 267 | }); | |
| 268 | ||
| 269 | return { | |
| 270 | name: input.name, | |
| 271 | targetRowId: changedFilesMessage.id, | |
| 272 | props: createBaseTimelineProps({ | |
| 273 | messages: [...beforeMessages, changedFilesMessage, ...afterMessages], | |
| 274 | turnDiffSummaryByAssistantMessageId: createChangedFilesSummary( | |
| 275 | changedFilesMessage.id, | |
| 276 | input.files, | |
| 277 | ), | |
| 278 | }), | |
| 279 | maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 72, | |
| 280 | }; | |
| 281 | } | |
| 282 | ||
| 283 | function createAssistantMessageScenario(input: { | |
| 284 | name: string; | |
| 285 | rowId: string; | |
| 286 | text: string; | |
| 287 | maxEstimateDeltaPx?: number; | |
| 288 | }): VirtualizationScenario { | |
| 289 | const beforeMessages = createFillerMessages({ | |
| 290 | prefix: `${input.rowId}-before`, | |
| 291 | startOffsetSeconds: 0, | |
| 292 | pairCount: 2, | |
| 293 | }); | |
| 294 | const afterMessages = createFillerMessages({ | |
| 295 | prefix: `${input.rowId}-after`, | |
| 296 | startOffsetSeconds: 40, | |
| 297 | pairCount: 8, | |
| 298 | }); | |
| 299 | const assistantMessage = createMessage({ | |
| 300 | id: input.rowId, | |
| 301 | role: "assistant", | |
| 302 | text: input.text, | |
| 303 | offsetSeconds: 12, | |
| 304 | }); | |
| 305 | ||
| 306 | return { | |
| 307 | name: input.name, | |
| 308 | targetRowId: assistantMessage.id, | |
| 309 | props: createBaseTimelineProps({ | |
| 310 | messages: [...beforeMessages, assistantMessage, ...afterMessages], | |
| 311 | }), | |
| 312 | maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 16, | |
| 313 | }; | |
| 314 | } | |
| 315 | ||
| 316 | function buildStaticScenarios(): VirtualizationScenario[] { | |
| 317 | const beforeMessages = createFillerMessages({ | |
| 318 | prefix: "before", | |
| 319 | startOffsetSeconds: 0, | |
| 320 | pairCount: 2, | |
| 321 | }); | |
| 322 | const afterMessages = createFillerMessages({ | |
| 323 | prefix: "after", | |
| 324 | startOffsetSeconds: 40, | |
| 325 | pairCount: 8, | |
| 326 | }); | |
| 327 | ||
| 328 | const longUserMessage = createMessage({ | |
| 329 | id: "target-user-long", | |
| 330 | role: "user", | |
| 331 | text: "x".repeat(3_200), | |
| 332 | offsetSeconds: 12, | |
| 333 | }); | |
| 334 | const workEntries = Array.from({ length: 4 }, (_, index) => | |
| 335 | createToolWorkEntry({ | |
| 336 | id: `target-work-${index}`, | |
| 337 | offsetSeconds: 12 + index, | |
| 338 | detail: `tool output line ${index + 1}`, | |
| 339 | }), | |
| 340 | ); | |
| 341 | const moderatePlan = createPlan({ | |
| 342 | id: "target-plan", | |
| 343 | offsetSeconds: 12, | |
| 344 | planMarkdown: [ | |
| 345 | "# Stabilize virtualization", | |
| 346 | "", | |
| 347 | "- Gather baseline measurements", | |
| 348 | "- Add browser harness coverage", | |
| 349 | "- Compare estimated and rendered heights", | |
| 350 | "- Fix the broken rows without broad refactors", | |
| 351 | "- Re-run lint and typecheck", | |
| 352 | ].join("\n"), | |
| 353 | }); | |
| 354 | return [ | |
| 355 | { | |
| 356 | name: "long user message", | |
| 357 | targetRowId: longUserMessage.id, | |
| 358 | props: createBaseTimelineProps({ | |
| 359 | messages: [...beforeMessages, longUserMessage, ...afterMessages], | |
| 360 | }), | |
| 361 | maxEstimateDeltaPx: 56, | |
| 362 | }, | |
| 363 | { | |
| 364 | name: "grouped work log row", | |
| 365 | targetRowId: workEntries[0]!.id, | |
| 366 | props: createBaseTimelineProps({ | |
| 367 | messages: [...beforeMessages, ...afterMessages], | |
| 368 | workEntries, | |
| 369 | }), | |
| 370 | maxEstimateDeltaPx: 56, | |
| 371 | }, | |
| 372 | { | |
| 373 | name: "expanded grouped work log row with show more enabled", | |
| 374 | targetRowId: "target-work-expanded-0", | |
| 375 | props: createBaseTimelineProps({ | |
| 376 | messages: [...beforeMessages, ...afterMessages], | |
| 377 | workEntries: Array.from({ length: 10 }, (_, index) => | |
| 378 | createToolWorkEntry({ | |
| 379 | id: `target-work-expanded-${index}`, | |
| 380 | offsetSeconds: 12 + index, | |
| 381 | detail: `tool output line ${index + 1}`, | |
| 382 | }), | |
| 383 | ), | |
| 384 | expandedWorkGroups: { | |
| 385 | "target-work-expanded-0": true, | |
| 386 | }, | |
| 387 | }), | |
| 388 | maxEstimateDeltaPx: 72, | |
| 389 | }, | |
| 390 | { | |
| 391 | name: "proposed plan row", | |
| 392 | targetRowId: moderatePlan.id, | |
| 393 | props: createBaseTimelineProps({ | |
| 394 | messages: [...beforeMessages, ...afterMessages], | |
| 395 | proposedPlans: [moderatePlan], | |
| 396 | }), | |
| 397 | maxEstimateDeltaPx: 96, | |
| 398 | }, | |
| 399 | createAssistantMessageScenario({ | |
| 400 | name: "assistant single-paragraph row with plain prose", | |
| 401 | rowId: "target-assistant-plain-prose", | |
| 402 | text: [ | |
| 403 | "The host is still expanding to content somewhere in the grid layout.", | |
| 404 | "I'm stripping it back further to a plain block container so the test width", | |
| 405 | "is actually the timeline width.", | |
| 406 | ].join(" "), | |
| 407 | }), | |
| 408 | createAssistantMessageScenario({ | |
| 409 | name: "assistant single-paragraph row with inline code", | |
| 410 | rowId: "target-assistant-inline-code", | |
| 411 | text: [ | |
| 412 | "Typecheck found one exact-optional-property issue in the browser harness:", | |
| 413 | "I was always passing `onVirtualizerSnapshot`, including `undefined`.", | |
| 414 | "I'm tightening that object construction and rerunning the checks.", | |
| 415 | ].join(" "), | |
| 416 | maxEstimateDeltaPx: 28, | |
| 417 | }), | |
| 418 | createChangedFilesScenario({ | |
| 419 | name: "assistant changed-files row with a compacted single-chain directory", | |
| 420 | rowId: "target-assistant-changed-files-single-chain", | |
| 421 | files: [ | |
| 422 | { path: "apps/web/src/components/chat/ChangedFilesTree.tsx", additions: 37, deletions: 45 }, | |
| 423 | { | |
| 424 | path: "apps/web/src/components/chat/ChangedFilesTree.test.tsx", | |
| 425 | additions: 0, | |
| 426 | deletions: 26, | |
| 427 | }, | |
| 428 | ], | |
| 429 | }), | |
| 430 | createChangedFilesScenario({ | |
| 431 | name: "assistant changed-files row with a branch after compaction", | |
| 432 | rowId: "target-assistant-changed-files-branch-point", | |
| 433 | files: [ | |
| 434 | { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 }, | |
| 435 | { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 }, | |
| 436 | { | |
| 437 | path: "apps/server/src/provider/Layers/CodexAdapter.ts", | |
| 438 | additions: 27, | |
| 439 | deletions: 8, | |
| 440 | }, | |
| 441 | { | |
| 442 | path: "apps/server/src/provider/Layers/CodexAdapter.test.ts", | |
| 443 | additions: 36, | |
| 444 | deletions: 0, | |
| 445 | }, | |
| 446 | ], | |
| 447 | }), | |
| 448 | createChangedFilesScenario({ | |
| 449 | name: "assistant changed-files row with mixed root and nested entries", | |
| 450 | rowId: "target-assistant-changed-files-mixed-root", | |
| 451 | files: [ | |
| 452 | { path: "README.md", additions: 5, deletions: 1 }, | |
| 453 | { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, | |
| 454 | { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, | |
| 455 | ], | |
| 456 | }), | |
| 457 | ]; | |
| 458 | } | |
| 459 | ||
| 460 | async function nextFrame(): Promise<void> { | |
| 461 | await new Promise<void>((resolve) => { | |
| 462 | window.requestAnimationFrame(() => resolve()); | |
| 463 | }); | |
| 464 | } | |
| 465 | ||
| 466 | async function waitForLayout(): Promise<void> { | |
| 467 | await nextFrame(); | |
| 468 | await nextFrame(); | |
| 469 | await nextFrame(); | |
| 470 | } | |
| 471 | ||
| 472 | async function setViewport(viewport: { width: number; height: number }): Promise<void> { | |
| 473 | await page.viewport(viewport.width, viewport.height); | |
| 474 | await waitForLayout(); | |
| 475 | } | |
| 476 | ||
| 477 | async function waitForProductionStyles(): Promise<void> { | |
| 478 | await vi.waitFor( | |
| 479 | () => { | |
| 480 | expect( | |
| 481 | getComputedStyle(document.documentElement).getPropertyValue("--background").trim(), | |
| 482 | ).not.toBe(""); | |
| 483 | expect(getComputedStyle(document.body).marginTop).toBe("0px"); | |
| 484 | }, | |
| 485 | { timeout: 4_000, interval: 16 }, | |
| 486 | ); | |
| 487 | } | |
| 488 | ||
| 489 | async function waitForElement<T extends Element>( | |
| 490 | query: () => T | null, | |
| 491 | errorMessage: string, | |
| 492 | ): Promise<T> { | |
| 493 | let element: T | null = null; | |
| 494 | await vi.waitFor( | |
| 495 | () => { | |
| 496 | element = query(); | |
| 497 | expect(element, errorMessage).toBeTruthy(); | |
| 498 | }, | |
| 499 | { timeout: 8_000, interval: 16 }, | |
| 500 | ); | |
| 501 | if (!element) { | |
| 502 | throw new Error(errorMessage); | |
| 503 | } | |
| 504 | return element; | |
| 505 | } | |
| 506 | ||
| 507 | async function measureTimelineRow(input: { | |
| 508 | host: HTMLElement; | |
| 509 | props: Omit< | |
| 510 | ComponentProps<typeof MessagesTimeline>, | |
| 511 | "scrollContainer" | "activeThreadEnvironmentId" | |
| 512 | >; | |
| 513 | targetRowId: string; | |
| 514 | }): Promise<RowMeasurement> { | |
| 515 | const scrollContainer = await waitForElement( | |
| 516 | () => | |
| 517 | input.host.querySelector<HTMLDivElement>( | |
| 518 | '[data-testid="messages-timeline-scroll-container"]', | |
| 519 | ), | |
| 520 | "Unable to find MessagesTimeline scroll container.", | |
| 521 | ); | |
| 522 | ||
| 523 | const rowSelector = `[data-timeline-row-id="${input.targetRowId}"]`; | |
| 524 | const virtualRowSelector = `[data-virtual-row-id="${input.targetRowId}"]`; | |
| 525 | ||
| 526 | let timelineWidthPx = 0; | |
| 527 | let actualHeightPx = 0; | |
| 528 | let virtualizerSizePx = 0; | |
| 529 | let renderedInVirtualizedRegion = false; | |
| 530 | ||
| 531 | await vi.waitFor( | |
| 532 | async () => { | |
| 533 | scrollContainer.scrollTop = 0; | |
| 534 | scrollContainer.dispatchEvent(new Event("scroll")); | |
| 535 | await waitForLayout(); | |
| 536 | ||
| 537 | const rowElement = input.host.querySelector<HTMLElement>(rowSelector); | |
| 538 | const virtualRowElement = input.host.querySelector<HTMLElement>(virtualRowSelector); | |
| 539 | const timelineRoot = input.host.querySelector<HTMLElement>('[data-timeline-root="true"]'); | |
| 540 | ||
| 541 | expect(rowElement, "Unable to locate target timeline row.").toBeTruthy(); | |
| 542 | expect(virtualRowElement, "Unable to locate target virtualized wrapper.").toBeTruthy(); | |
| 543 | expect(timelineRoot, "Unable to locate MessagesTimeline root.").toBeTruthy(); | |
| 544 | ||
| 545 | timelineWidthPx = timelineRoot!.getBoundingClientRect().width; | |
| 546 | actualHeightPx = rowElement!.getBoundingClientRect().height; | |
| 547 | virtualizerSizePx = Number.parseFloat(virtualRowElement!.dataset.virtualRowSize ?? "0"); | |
| 548 | renderedInVirtualizedRegion = virtualRowElement!.hasAttribute("data-index"); | |
| 549 | ||
| 550 | expect(timelineWidthPx).toBeGreaterThan(0); | |
| 551 | expect(actualHeightPx).toBeGreaterThan(0); | |
| 552 | expect(virtualizerSizePx).toBeGreaterThan(0); | |
| 553 | expect(renderedInVirtualizedRegion).toBe(true); | |
| 554 | }, | |
| 555 | { timeout: 8_000, interval: 16 }, | |
| 556 | ); | |
| 557 | ||
| 558 | const rows = deriveMessagesTimelineRows({ | |
| 559 | timelineEntries: input.props.timelineEntries, | |
| 560 | completionDividerBeforeEntryId: input.props.completionDividerBeforeEntryId, | |
| 561 | isWorking: input.props.isWorking, | |
| 562 | activeTurnStartedAt: input.props.activeTurnStartedAt, | |
| 563 | }); | |
| 564 | const targetRow = rows.find((row) => row.id === input.targetRowId); | |
| 565 | expect(targetRow, `Unable to derive target row ${input.targetRowId}.`).toBeTruthy(); | |
| 566 | ||
| 567 | return { | |
| 568 | actualHeightPx, | |
| 569 | estimatedHeightPx: estimateMessagesTimelineRowHeight(targetRow!, { | |
| 570 | expandedWorkGroups: input.props.expandedWorkGroups, | |
| 571 | timelineWidthPx, | |
| 572 | turnDiffSummaryByAssistantMessageId: input.props.turnDiffSummaryByAssistantMessageId, | |
| 573 | }), | |
| 574 | timelineWidthPx, | |
| 575 | virtualizerSizePx, | |
| 576 | renderedInVirtualizedRegion, | |
| 577 | }; | |
| 578 | } | |
| 579 | ||
| 580 | async function mountMessagesTimeline(input: { | |
| 581 | props: Omit< | |
| 582 | ComponentProps<typeof MessagesTimeline>, | |
| 583 | "scrollContainer" | "activeThreadEnvironmentId" | |
| 584 | >; | |
| 585 | viewport?: { width: number; height: number }; | |
| 586 | }) { | |
| 587 | const viewport = input.viewport ?? DEFAULT_VIEWPORT; | |
| 588 | await setViewport(viewport); | |
| 589 | await waitForProductionStyles(); | |
| 590 | ||
| 591 | const host = document.createElement("div"); | |
| 592 | host.style.width = `${viewport.width}px`; | |
| 593 | host.style.minWidth = `${viewport.width}px`; | |
| 594 | host.style.maxWidth = `${viewport.width}px`; | |
| 595 | host.style.height = `${viewport.height}px`; | |
| 596 | host.style.minHeight = `${viewport.height}px`; | |
| 597 | host.style.maxHeight = `${viewport.height}px`; | |
| 598 | host.style.display = "block"; | |
| 599 | host.style.overflow = "hidden"; | |
| 600 | document.body.append(host); | |
| 601 | ||
| 602 | const screen = await render(<MessagesTimelineBrowserHarness {...input.props} />, { | |
| 603 | container: host, | |
| 604 | }); | |
| 605 | await waitForLayout(); | |
| 606 | ||
| 607 | return { | |
| 608 | host, | |
| 609 | rerender: async ( | |
| 610 | nextProps: Omit< | |
| 611 | ComponentProps<typeof MessagesTimeline>, | |
| 612 | "scrollContainer" | "activeThreadEnvironmentId" | |
| 613 | >, | |
| 614 | ) => { | |
| 615 | await screen.rerender(<MessagesTimelineBrowserHarness {...nextProps} />); | |
| 616 | await waitForLayout(); | |
| 617 | }, | |
| 618 | setContainerSize: async (nextViewport: { width: number; height: number }) => { | |
| 619 | await setViewport(nextViewport); | |
| 620 | host.style.width = `${nextViewport.width}px`; | |
| 621 | host.style.minWidth = `${nextViewport.width}px`; | |
| 622 | host.style.maxWidth = `${nextViewport.width}px`; | |
| 623 | host.style.height = `${nextViewport.height}px`; | |
| 624 | host.style.minHeight = `${nextViewport.height}px`; | |
| 625 | host.style.maxHeight = `${nextViewport.height}px`; | |
| 626 | await waitForLayout(); | |
| 627 | }, | |
| 628 | cleanup: async () => { | |
| 629 | await screen.unmount(); | |
| 630 | host.remove(); | |
| 631 | }, | |
| 632 | }; | |
| 633 | } | |
| 634 | ||
| 635 | async function measureRenderedRowActualHeight(input: { | |
| 636 | host: HTMLElement; | |
| 637 | targetRowId: string; | |
| 638 | }): Promise<number> { | |
| 639 | const rowElement = await waitForElement( | |
| 640 | () => input.host.querySelector<HTMLElement>(`[data-timeline-row-id="${input.targetRowId}"]`), | |
| 641 | `Unable to locate rendered row ${input.targetRowId}.`, | |
| 642 | ); | |
| 643 | return rowElement.getBoundingClientRect().height; | |
| 644 | } | |
| 645 | ||
| 646 | describe("MessagesTimeline virtualization harness", () => { | |
| 647 | beforeEach(async () => { | |
| 648 | document.body.innerHTML = ""; | |
| 649 | await setViewport(DEFAULT_VIEWPORT); | |
| 650 | }); | |
| 651 | ||
| 652 | afterEach(() => { | |
| 653 | document.body.innerHTML = ""; | |
| 654 | }); | |
| 655 | ||
| 656 | it.each(buildStaticScenarios())("keeps the $name estimate within tolerance", async (scenario) => { | |
| 657 | const mounted = await mountMessagesTimeline({ props: scenario.props }); | |
| 658 | ||
| 659 | try { | |
| 660 | const measurement = await measureTimelineRow({ | |
| 661 | host: mounted.host, | |
| 662 | props: scenario.props, | |
| 663 | targetRowId: scenario.targetRowId, | |
| 664 | }); | |
| 665 | ||
| 666 | expect( | |
| 667 | Math.abs(measurement.actualHeightPx - measurement.estimatedHeightPx), | |
| 668 | `estimate delta for ${scenario.name}`, | |
| 669 | ).toBeLessThanOrEqual(scenario.maxEstimateDeltaPx); | |
| 670 | } finally { | |
| 671 | await mounted.cleanup(); | |
| 672 | } | |
| 673 | }); | |
| 674 | ||
| 675 | it("keeps the changed-files row virtualizer size in sync after collapsing directories", async () => { | |
| 676 | const beforeMessages = createFillerMessages({ | |
| 677 | prefix: "before-collapse", | |
| 678 | startOffsetSeconds: 0, | |
| 679 | pairCount: 2, | |
| 680 | }); | |
| 681 | const afterMessages = createFillerMessages({ | |
| 682 | prefix: "after-collapse", | |
| 683 | startOffsetSeconds: 40, | |
| 684 | pairCount: 8, | |
| 685 | }); | |
| 686 | const targetMessage = createMessage({ | |
| 687 | id: "target-assistant-collapse", | |
| 688 | role: "assistant", | |
| 689 | text: "Validation passed on the merged tree.", | |
| 690 | offsetSeconds: 12, | |
| 691 | }); | |
| 692 | const props = createBaseTimelineProps({ | |
| 693 | messages: [...beforeMessages, targetMessage, ...afterMessages], | |
| 694 | turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [ | |
| 695 | { path: ".plans/effect-atom.md", additions: 89, deletions: 0 }, | |
| 696 | { | |
| 697 | path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts", | |
| 698 | additions: 4, | |
| 699 | deletions: 3, | |
| 700 | }, | |
| 701 | { | |
| 702 | path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts", | |
| 703 | additions: 131, | |
| 704 | deletions: 128, | |
| 705 | }, | |
| 706 | { | |
| 707 | path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts", | |
| 708 | additions: 1, | |
| 709 | deletions: 1, | |
| 710 | }, | |
| 711 | { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 }, | |
| 712 | { | |
| 713 | path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts", | |
| 714 | additions: 106, | |
| 715 | deletions: 112, | |
| 716 | }, | |
| 717 | { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 }, | |
| 718 | { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 }, | |
| 719 | { | |
| 720 | path: "apps/web/src/components/chat/MessagesTimeline.tsx", | |
| 721 | additions: 52, | |
| 722 | deletions: 7, | |
| 723 | }, | |
| 724 | { | |
| 725 | path: "apps/web/src/components/chat/ChangedFilesTree.tsx", | |
| 726 | additions: 32, | |
| 727 | deletions: 4, | |
| 728 | }, | |
| 729 | { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, | |
| 730 | { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, | |
| 731 | ]), | |
| 732 | }); | |
| 733 | const mounted = await mountMessagesTimeline({ | |
| 734 | props, | |
| 735 | viewport: { width: 320, height: 700 }, | |
| 736 | }); | |
| 737 | ||
| 738 | try { | |
| 739 | const beforeCollapse = await measureTimelineRow({ | |
| 740 | host: mounted.host, | |
| 741 | props, | |
| 742 | targetRowId: targetMessage.id, | |
| 743 | }); | |
| 744 | const targetRowElement = mounted.host.querySelector<HTMLElement>( | |
| 745 | `[data-timeline-row-id="${targetMessage.id}"]`, | |
| 746 | ); | |
| 747 | expect(targetRowElement, "Unable to locate target changed-files row.").toBeTruthy(); | |
| 748 | ||
| 749 | const collapseAllButton = | |
| 750 | Array.from(targetRowElement!.querySelectorAll<HTMLButtonElement>("button")).find( | |
| 751 | (button) => button.textContent?.trim() === "Collapse all", | |
| 752 | ) ?? null; | |
| 753 | expect(collapseAllButton, 'Unable to find "Collapse all" button.').toBeTruthy(); | |
| 754 | ||
| 755 | collapseAllButton!.click(); | |
| 756 | ||
| 757 | await vi.waitFor( | |
| 758 | async () => { | |
| 759 | const afterCollapse = await measureTimelineRow({ | |
| 760 | host: mounted.host, | |
| 761 | props, | |
| 762 | targetRowId: targetMessage.id, | |
| 763 | }); | |
| 764 | expect(afterCollapse.actualHeightPx).toBeLessThan(beforeCollapse.actualHeightPx - 24); | |
| 765 | }, | |
| 766 | { timeout: 8_000, interval: 16 }, | |
| 767 | ); | |
| 768 | ||
| 769 | const afterCollapse = await measureTimelineRow({ | |
| 770 | host: mounted.host, | |
| 771 | props, | |
| 772 | targetRowId: targetMessage.id, | |
| 773 | }); | |
| 774 | expect( | |
| 775 | Math.abs(afterCollapse.actualHeightPx - afterCollapse.virtualizerSizePx), | |
| 776 | ).toBeLessThanOrEqual(8); | |
| 777 | } finally { | |
| 778 | await mounted.cleanup(); | |
| 779 | } | |
| 780 | }); | |
| 781 | ||
| 782 | it("keeps the work-log row virtualizer size in sync after show more expands the group", async () => { | |
| 783 | const beforeMessages = createFillerMessages({ | |
| 784 | prefix: "before-worklog-expand", | |
| 785 | startOffsetSeconds: 0, | |
| 786 | pairCount: 2, | |
| 787 | }); | |
| 788 | const afterMessages = createFillerMessages({ | |
| 789 | prefix: "after-worklog-expand", | |
| 790 | startOffsetSeconds: 40, | |
| 791 | pairCount: 8, | |
| 792 | }); | |
| 793 | const workEntries = Array.from({ length: 10 }, (_, index) => | |
| 794 | createToolWorkEntry({ | |
| 795 | id: `target-work-toggle-${index}`, | |
| 796 | offsetSeconds: 12 + index, | |
| 797 | detail: `tool output line ${index + 1}`, | |
| 798 | }), | |
| 799 | ); | |
| 800 | const props = createBaseTimelineProps({ | |
| 801 | messages: [...beforeMessages, ...afterMessages], | |
| 802 | workEntries, | |
| 803 | }); | |
| 804 | const mounted = await mountMessagesTimeline({ props }); | |
| 805 | ||
| 806 | try { | |
| 807 | const beforeExpand = await measureTimelineRow({ | |
| 808 | host: mounted.host, | |
| 809 | props, | |
| 810 | targetRowId: workEntries[0]!.id, | |
| 811 | }); | |
| 812 | const targetRowElement = mounted.host.querySelector<HTMLElement>( | |
| 813 | `[data-timeline-row-id="${workEntries[0]!.id}"]`, | |
| 814 | ); | |
| 815 | expect(targetRowElement, "Unable to locate target work-log row.").toBeTruthy(); | |
| 816 | ||
| 817 | const showMoreButton = | |
| 818 | Array.from(targetRowElement!.querySelectorAll<HTMLButtonElement>("button")).find((button) => | |
| 819 | button.textContent?.includes("Show 4 more"), | |
| 820 | ) ?? null; | |
| 821 | expect(showMoreButton, 'Unable to find "Show more" button.').toBeTruthy(); | |
| 822 | ||
| 823 | showMoreButton!.click(); | |
| 824 | ||
| 825 | await vi.waitFor( | |
| 826 | async () => { | |
| 827 | const afterExpand = await measureTimelineRow({ | |
| 828 | host: mounted.host, | |
| 829 | props, | |
| 830 | targetRowId: workEntries[0]!.id, | |
| 831 | }); | |
| 832 | expect(afterExpand.actualHeightPx).toBeGreaterThan(beforeExpand.actualHeightPx + 72); | |
| 833 | }, | |
| 834 | { timeout: 8_000, interval: 16 }, | |
| 835 | ); | |
| 836 | ||
| 837 | const afterExpand = await measureTimelineRow({ | |
| 838 | host: mounted.host, | |
| 839 | props, | |
| 840 | targetRowId: workEntries[0]!.id, | |
| 841 | }); | |
| 842 | expect( | |
| 843 | Math.abs(afterExpand.actualHeightPx - afterExpand.virtualizerSizePx), | |
| 844 | ).toBeLessThanOrEqual(8); | |
| 845 | } finally { | |
| 846 | await mounted.cleanup(); | |
| 847 | } | |
| 848 | }); | |
| 849 | ||
| 850 | it("preserves measured tail row heights when rows transition into virtualization", async () => { | |
| 851 | const beforeMessages = createFillerMessages({ | |
| 852 | prefix: "tail-transition-before", | |
| 853 | startOffsetSeconds: 0, | |
| 854 | pairCount: 1, | |
| 855 | }); | |
| 856 | const afterMessages = createFillerMessages({ | |
| 857 | prefix: "tail-transition-after", | |
| 858 | startOffsetSeconds: 40, | |
| 859 | pairCount: 3, | |
| 860 | }); | |
| 861 | const targetMessage = createMessage({ | |
| 862 | id: "target-tail-transition", | |
| 863 | role: "assistant", | |
| 864 | text: "Validation passed on the merged tree.", | |
| 865 | offsetSeconds: 12, | |
| 866 | }); | |
| 867 | let latestSnapshot: VirtualizerSnapshot | null = null; | |
| 868 | const initialProps = createBaseTimelineProps({ | |
| 869 | messages: [...beforeMessages, targetMessage, ...afterMessages], | |
| 870 | turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [ | |
| 871 | { path: ".plans/effect-atom.md", additions: 89, deletions: 0 }, | |
| 872 | { | |
| 873 | path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts", | |
| 874 | additions: 4, | |
| 875 | deletions: 3, | |
| 876 | }, | |
| 877 | { | |
| 878 | path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts", | |
| 879 | additions: 131, | |
| 880 | deletions: 128, | |
| 881 | }, | |
| 882 | { | |
| 883 | path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts", | |
| 884 | additions: 1, | |
| 885 | deletions: 1, | |
| 886 | }, | |
| 887 | { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 }, | |
| 888 | { | |
| 889 | path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts", | |
| 890 | additions: 106, | |
| 891 | deletions: 112, | |
| 892 | }, | |
| 893 | { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 }, | |
| 894 | { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 }, | |
| 895 | { | |
| 896 | path: "apps/web/src/components/chat/MessagesTimeline.tsx", | |
| 897 | additions: 52, | |
| 898 | deletions: 7, | |
| 899 | }, | |
| 900 | { | |
| 901 | path: "apps/web/src/components/chat/ChangedFilesTree.tsx", | |
| 902 | additions: 32, | |
| 903 | deletions: 4, | |
| 904 | }, | |
| 905 | { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, | |
| 906 | { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, | |
| 907 | ]), | |
| 908 | onVirtualizerSnapshot: (snapshot) => { | |
| 909 | latestSnapshot = { | |
| 910 | totalSize: snapshot.totalSize, | |
| 911 | measurements: snapshot.measurements, | |
| 912 | }; | |
| 913 | }, | |
| 914 | }); | |
| 915 | ||
| 916 | const mounted = await mountMessagesTimeline({ props: initialProps }); | |
| 917 | ||
| 918 | try { | |
| 919 | const initiallyRenderedHeight = await measureRenderedRowActualHeight({ | |
| 920 | host: mounted.host, | |
| 921 | targetRowId: targetMessage.id, | |
| 922 | }); | |
| 923 | ||
| 924 | const appendedProps = createBaseTimelineProps({ | |
| 925 | messages: [ | |
| 926 | ...beforeMessages, | |
| 927 | targetMessage, | |
| 928 | ...afterMessages, | |
| 929 | ...createFillerMessages({ | |
| 930 | prefix: "tail-transition-extra", | |
| 931 | startOffsetSeconds: 120, | |
| 932 | pairCount: 8, | |
| 933 | }), | |
| 934 | ], | |
| 935 | turnDiffSummaryByAssistantMessageId: initialProps.turnDiffSummaryByAssistantMessageId, | |
| 936 | onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot, | |
| 937 | }); | |
| 938 | await mounted.rerender(appendedProps); | |
| 939 | ||
| 940 | const scrollContainer = await waitForElement( | |
| 941 | () => | |
| 942 | mounted.host.querySelector<HTMLDivElement>( | |
| 943 | '[data-testid="messages-timeline-scroll-container"]', | |
| 944 | ), | |
| 945 | "Unable to find MessagesTimeline scroll container.", | |
| 946 | ); | |
| 947 | scrollContainer.scrollTop = scrollContainer.scrollHeight; | |
| 948 | scrollContainer.dispatchEvent(new Event("scroll")); | |
| 949 | await waitForLayout(); | |
| 950 | ||
| 951 | await vi.waitFor( | |
| 952 | () => { | |
| 953 | const measurement = latestSnapshot?.measurements.find( | |
| 954 | (entry) => entry.id === targetMessage.id, | |
| 955 | ); | |
| 956 | expect( | |
| 957 | measurement, | |
| 958 | "Expected target row to transition into virtualizer cache.", | |
| 959 | ).toBeTruthy(); | |
| 960 | expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual( | |
| 961 | 8, | |
| 962 | ); | |
| 963 | }, | |
| 964 | { timeout: 8_000, interval: 16 }, | |
| 965 | ); | |
| 966 | } finally { | |
| 967 | await mounted.cleanup(); | |
| 968 | } | |
| 969 | }); | |
| 970 | ||
| 971 | it("preserves measured tail image row heights when rows transition into virtualization", async () => { | |
| 972 | const beforeMessages = createFillerMessages({ | |
| 973 | prefix: "tail-image-before", | |
| 974 | startOffsetSeconds: 0, | |
| 975 | pairCount: 1, | |
| 976 | }); | |
| 977 | const afterMessages = createFillerMessages({ | |
| 978 | prefix: "tail-image-after", | |
| 979 | startOffsetSeconds: 40, | |
| 980 | pairCount: 3, | |
| 981 | }); | |
| 982 | const targetMessage = createMessage({ | |
| 983 | id: "target-tail-image-transition", | |
| 984 | role: "user", | |
| 985 | text: "Here is a narrow screenshot.", | |
| 986 | offsetSeconds: 12, | |
| 987 | attachments: [ | |
| 988 | { | |
| 989 | type: "image", | |
| 990 | id: "target-tail-image", | |
| 991 | name: "narrow.svg", | |
| 992 | mimeType: "image/svg+xml", | |
| 993 | sizeBytes: 512, | |
| 994 | previewUrl: | |
| 995 | "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='72'%3E%3Crect width='240' height='72' fill='%23dbeafe'/%3E%3C/svg%3E", | |
| 996 | }, | |
| 997 | ], | |
| 998 | }); | |
| 999 | let latestSnapshot: VirtualizerSnapshot | null = null; | |
| 1000 | const initialProps = createBaseTimelineProps({ | |
| 1001 | messages: [...beforeMessages, targetMessage, ...afterMessages], | |
| 1002 | onVirtualizerSnapshot: (snapshot) => { | |
| 1003 | latestSnapshot = { | |
| 1004 | totalSize: snapshot.totalSize, | |
| 1005 | measurements: snapshot.measurements, | |
| 1006 | }; | |
| 1007 | }, | |
| 1008 | }); | |
| 1009 | const mounted = await mountMessagesTimeline({ props: initialProps }); | |
| 1010 | ||
| 1011 | try { | |
| 1012 | await vi.waitFor( | |
| 1013 | () => { | |
| 1014 | const image = mounted.host.querySelector<HTMLImageElement>( | |
| 1015 | `[data-timeline-row-id="${targetMessage.id}"] img`, | |
| 1016 | ); | |
| 1017 | expect(image?.naturalHeight ?? 0).toBeGreaterThan(0); | |
| 1018 | }, | |
| 1019 | { timeout: 8_000, interval: 16 }, | |
| 1020 | ); | |
| 1021 | ||
| 1022 | const initiallyRenderedHeight = await measureRenderedRowActualHeight({ | |
| 1023 | host: mounted.host, | |
| 1024 | targetRowId: targetMessage.id, | |
| 1025 | }); | |
| 1026 | const appendedProps = createBaseTimelineProps({ | |
| 1027 | messages: [ | |
| 1028 | ...beforeMessages, | |
| 1029 | targetMessage, | |
| 1030 | ...afterMessages, | |
| 1031 | ...createFillerMessages({ | |
| 1032 | prefix: "tail-image-extra", | |
| 1033 | startOffsetSeconds: 120, | |
| 1034 | pairCount: 8, | |
| 1035 | }), | |
| 1036 | ], | |
| 1037 | onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot, | |
| 1038 | }); | |
| 1039 | await mounted.rerender(appendedProps); | |
| 1040 | ||
| 1041 | const scrollContainer = await waitForElement( | |
| 1042 | () => | |
| 1043 | mounted.host.querySelector<HTMLDivElement>( | |
| 1044 | '[data-testid="messages-timeline-scroll-container"]', | |
| 1045 | ), | |
| 1046 | "Unable to find MessagesTimeline scroll container.", | |
| 1047 | ); | |
| 1048 | scrollContainer.scrollTop = scrollContainer.scrollHeight; | |
| 1049 | scrollContainer.dispatchEvent(new Event("scroll")); | |
| 1050 | await waitForLayout(); | |
| 1051 | ||
| 1052 | await vi.waitFor( | |
| 1053 | () => { | |
| 1054 | const measurement = latestSnapshot?.measurements.find( | |
| 1055 | (entry) => entry.id === targetMessage.id, | |
| 1056 | ); | |
| 1057 | expect( | |
| 1058 | measurement, | |
| 1059 | "Expected target image row to transition into virtualizer cache.", | |
| 1060 | ).toBeTruthy(); | |
| 1061 | expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual( | |
| 1062 | 8, | |
| 1063 | ); | |
| 1064 | }, | |
| 1065 | { timeout: 8_000, interval: 16 }, | |
| 1066 | ); | |
| 1067 | } finally { | |
| 1068 | await mounted.cleanup(); | |
| 1069 | } | |
| 1070 | }); | |
| 1071 | }); |
apps/web/src/components/timelineHeight.test.ts deleted-151| ... | ... | @@ -1,151 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { appendTerminalContextsToPrompt } from "../lib/terminalContext"; | |
| 4 | import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; | |
| 5 | import { estimateTimelineMessageHeight } from "./timelineHeight"; | |
| 6 | ||
| 7 | describe("estimateTimelineMessageHeight", () => { | |
| 8 | it("uses assistant sizing rules for assistant messages", () => { | |
| 9 | expect( | |
| 10 | estimateTimelineMessageHeight({ | |
| 11 | role: "assistant", | |
| 12 | text: "a".repeat(144), | |
| 13 | }), | |
| 14 | ).toBe(86.5); | |
| 15 | }); | |
| 16 | ||
| 17 | it("uses assistant sizing rules for system messages", () => { | |
| 18 | expect( | |
| 19 | estimateTimelineMessageHeight({ | |
| 20 | role: "system", | |
| 21 | text: "a".repeat(144), | |
| 22 | }), | |
| 23 | ).toBe(86.5); | |
| 24 | }); | |
| 25 | ||
| 26 | it("adds one attachment row for one or two user attachments", () => { | |
| 27 | expect( | |
| 28 | estimateTimelineMessageHeight({ | |
| 29 | role: "user", | |
| 30 | text: "hello", | |
| 31 | attachments: [{ id: "1" }], | |
| 32 | }), | |
| 33 | ).toBe(234); | |
| 34 | ||
| 35 | expect( | |
| 36 | estimateTimelineMessageHeight({ | |
| 37 | role: "user", | |
| 38 | text: "hello", | |
| 39 | attachments: [{ id: "1" }, { id: "2" }], | |
| 40 | }), | |
| 41 | ).toBe(234); | |
| 42 | }); | |
| 43 | ||
| 44 | it("adds a second attachment row for three or four user attachments", () => { | |
| 45 | expect( | |
| 46 | estimateTimelineMessageHeight({ | |
| 47 | role: "user", | |
| 48 | text: "hello", | |
| 49 | attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], | |
| 50 | }), | |
| 51 | ).toBe(350); | |
| 52 | ||
| 53 | expect( | |
| 54 | estimateTimelineMessageHeight({ | |
| 55 | role: "user", | |
| 56 | text: "hello", | |
| 57 | attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], | |
| 58 | }), | |
| 59 | ).toBe(350); | |
| 60 | }); | |
| 61 | ||
| 62 | it("does not cap long user message estimates", () => { | |
| 63 | expect( | |
| 64 | estimateTimelineMessageHeight({ | |
| 65 | role: "user", | |
| 66 | text: "a".repeat(56 * 120), | |
| 67 | }), | |
| 68 | ).toBe(2736); | |
| 69 | }); | |
| 70 | ||
| 71 | it("counts explicit newlines for user message estimates", () => { | |
| 72 | expect( | |
| 73 | estimateTimelineMessageHeight({ | |
| 74 | role: "user", | |
| 75 | text: "first\nsecond\nthird", | |
| 76 | }), | |
| 77 | ).toBe(162); | |
| 78 | }); | |
| 79 | ||
| 80 | it("adds terminal context chrome without counting the hidden block as message text", () => { | |
| 81 | const prompt = appendTerminalContextsToPrompt("Investigate this", [ | |
| 82 | { | |
| 83 | terminalId: "default", | |
| 84 | terminalLabel: "Terminal 1", | |
| 85 | lineStart: 40, | |
| 86 | lineEnd: 43, | |
| 87 | text: [ | |
| 88 | "git status", | |
| 89 | "M apps/web/src/components/chat/MessagesTimeline.tsx", | |
| 90 | "?? tmp", | |
| 91 | "", | |
| 92 | ].join("\n"), | |
| 93 | }, | |
| 94 | ]); | |
| 95 | ||
| 96 | expect( | |
| 97 | estimateTimelineMessageHeight({ | |
| 98 | role: "user", | |
| 99 | text: prompt, | |
| 100 | }), | |
| 101 | ).toBe( | |
| 102 | estimateTimelineMessageHeight({ | |
| 103 | role: "user", | |
| 104 | text: `${buildInlineTerminalContextText([{ header: "Terminal 1 lines 40-43" }])} Investigate this`, | |
| 105 | }), | |
| 106 | ); | |
| 107 | }); | |
| 108 | ||
| 109 | it("uses narrower width to increase user line wrapping", () => { | |
| 110 | const message = { | |
| 111 | role: "user" as const, | |
| 112 | text: "a".repeat(52), | |
| 113 | }; | |
| 114 | ||
| 115 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(140); | |
| 116 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(118); | |
| 117 | }); | |
| 118 | ||
| 119 | it("does not clamp user wrapping too aggressively on very narrow layouts", () => { | |
| 120 | const message = { | |
| 121 | role: "user" as const, | |
| 122 | text: "a".repeat(20), | |
| 123 | }; | |
| 124 | ||
| 125 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(184); | |
| 126 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(118); | |
| 127 | }); | |
| 128 | ||
| 129 | it("uses narrower width to increase assistant line wrapping", () => { | |
| 130 | const message = { | |
| 131 | role: "assistant" as const, | |
| 132 | text: "a".repeat(200), | |
| 133 | }; | |
| 134 | ||
| 135 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(154.75); | |
| 136 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(86.5); | |
| 137 | }); | |
| 138 | ||
| 139 | it("treats inline code as wider when estimating assistant markdown wrapping", () => { | |
| 140 | const message = { | |
| 141 | role: "assistant" as const, | |
| 142 | text: [ | |
| 143 | "Typecheck found one exact-optional-property issue in the browser harness:", | |
| 144 | "I was always passing `onVirtualizerSnapshot`, including `undefined`.", | |
| 145 | "I'm tightening that object construction and rerunning the checks.", | |
| 146 | ].join(" "), | |
| 147 | }; | |
| 148 | ||
| 149 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(109.25); | |
| 150 | }); | |
| 151 | }); |
apps/web/src/components/timelineHeight.ts deleted-131| ... | ... | @@ -1,131 +0,0 @@ |
| 1 | import { deriveDisplayedUserMessageState } from "../lib/terminalContext"; | |
| 2 | import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; | |
| 3 | ||
| 4 | const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; | |
| 5 | const USER_CHARS_PER_LINE_FALLBACK = 56; | |
| 6 | const USER_LINE_HEIGHT_PX = 22; | |
| 7 | const ASSISTANT_LINE_HEIGHT_PX = 22.75; | |
| 8 | // Assistant rows render as markdown content plus a compact timestamp meta line. | |
| 9 | // The DOM baseline is much smaller than the user bubble chrome, so model it | |
| 10 | // separately instead of reusing the old shared constant. | |
| 11 | const ASSISTANT_BASE_HEIGHT_PX = 41; | |
| 12 | const USER_BASE_HEIGHT_PX = 96; | |
| 13 | const ATTACHMENTS_PER_ROW = 2; | |
| 14 | // Full-app browser measurements land closer to a ~116px attachment row once | |
| 15 | // the bubble shrinks to content width, so calibrate the estimate to that DOM. | |
| 16 | const USER_ATTACHMENT_ROW_HEIGHT_PX = 116; | |
| 17 | const USER_BUBBLE_WIDTH_RATIO = 0.8; | |
| 18 | const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; | |
| 19 | const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; | |
| 20 | const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; | |
| 21 | const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; | |
| 22 | const MIN_USER_CHARS_PER_LINE = 4; | |
| 23 | const MIN_ASSISTANT_CHARS_PER_LINE = 20; | |
| 24 | const ASSISTANT_INLINE_CODE_WIDTH_MULTIPLIER = 1.2; | |
| 25 | const ASSISTANT_INLINE_CODE_WRAP_OVERHEAD_CHARS = 2; | |
| 26 | const INLINE_CODE_SPAN_REGEX = /`([^`\n]+)`/g; | |
| 27 | ||
| 28 | interface TimelineMessageHeightInput { | |
| 29 | role: "user" | "assistant" | "system"; | |
| 30 | text: string; | |
| 31 | attachments?: ReadonlyArray<{ id: string }>; | |
| 32 | } | |
| 33 | ||
| 34 | interface TimelineHeightEstimateLayout { | |
| 35 | timelineWidthPx: number | null; | |
| 36 | } | |
| 37 | ||
| 38 | function estimateWrappedLineCount(text: string, charsPerLine: number): number { | |
| 39 | if (text.length === 0) return 1; | |
| 40 | ||
| 41 | // Avoid allocating via split for long logs; iterate once and count wrapped lines. | |
| 42 | let lines = 0; | |
| 43 | let currentLineLength = 0; | |
| 44 | for (let index = 0; index < text.length; index += 1) { | |
| 45 | if (text.charCodeAt(index) === 10) { | |
| 46 | lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine)); | |
| 47 | currentLineLength = 0; | |
| 48 | continue; | |
| 49 | } | |
| 50 | currentLineLength += 1; | |
| 51 | } | |
| 52 | ||
| 53 | lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine)); | |
| 54 | return lines; | |
| 55 | } | |
| 56 | ||
| 57 | function isFinitePositiveNumber(value: number | null | undefined): value is number { | |
| 58 | return typeof value === "number" && Number.isFinite(value) && value > 0; | |
| 59 | } | |
| 60 | ||
| 61 | function estimateCharsPerLineForUser(timelineWidthPx: number | null): number { | |
| 62 | if (!isFinitePositiveNumber(timelineWidthPx)) return USER_CHARS_PER_LINE_FALLBACK; | |
| 63 | const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO; | |
| 64 | const textWidthPx = Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); | |
| 65 | return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_MONO_AVG_CHAR_WIDTH_PX)); | |
| 66 | } | |
| 67 | ||
| 68 | function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): number { | |
| 69 | if (!isFinitePositiveNumber(timelineWidthPx)) return ASSISTANT_CHARS_PER_LINE_FALLBACK; | |
| 70 | const textWidthPx = Math.max(timelineWidthPx - ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX, 0); | |
| 71 | return Math.max( | |
| 72 | MIN_ASSISTANT_CHARS_PER_LINE, | |
| 73 | Math.floor(textWidthPx / ASSISTANT_AVG_CHAR_WIDTH_PX), | |
| 74 | ); | |
| 75 | } | |
| 76 | ||
| 77 | function expandAssistantInlineCodeForEstimate(text: string) { | |
| 78 | return text.replace(INLINE_CODE_SPAN_REGEX, (_match, code: string) => | |
| 79 | "x".repeat( | |
| 80 | Math.max( | |
| 81 | code.length + 2, | |
| 82 | Math.ceil( | |
| 83 | code.length * ASSISTANT_INLINE_CODE_WIDTH_MULTIPLIER + | |
| 84 | ASSISTANT_INLINE_CODE_WRAP_OVERHEAD_CHARS, | |
| 85 | ), | |
| 86 | ), | |
| 87 | ), | |
| 88 | ); | |
| 89 | } | |
| 90 | ||
| 91 | export function estimateTimelineMessageHeight( | |
| 92 | message: TimelineMessageHeightInput, | |
| 93 | layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, | |
| 94 | ): number { | |
| 95 | if (message.role === "assistant") { | |
| 96 | const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); | |
| 97 | const estimatedLines = estimateWrappedLineCount( | |
| 98 | expandAssistantInlineCodeForEstimate(message.text), | |
| 99 | charsPerLine, | |
| 100 | ); | |
| 101 | return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX; | |
| 102 | } | |
| 103 | ||
| 104 | if (message.role === "user") { | |
| 105 | const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); | |
| 106 | const displayedUserMessage = deriveDisplayedUserMessageState(message.text); | |
| 107 | const renderedText = | |
| 108 | displayedUserMessage.contexts.length > 0 | |
| 109 | ? [ | |
| 110 | buildInlineTerminalContextText(displayedUserMessage.contexts), | |
| 111 | displayedUserMessage.visibleText, | |
| 112 | ] | |
| 113 | .filter((part) => part.length > 0) | |
| 114 | .join(" ") | |
| 115 | : displayedUserMessage.visibleText; | |
| 116 | const estimatedLines = estimateWrappedLineCount(renderedText, charsPerLine); | |
| 117 | const attachmentCount = message.attachments?.length ?? 0; | |
| 118 | const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); | |
| 119 | const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; | |
| 120 | return USER_BASE_HEIGHT_PX + estimatedLines * USER_LINE_HEIGHT_PX + attachmentHeight; | |
| 121 | } | |
| 122 | ||
| 123 | // `system` messages are not rendered in the chat timeline, but keep a stable | |
| 124 | // explicit branch in case they are present in timeline data. | |
| 125 | const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); | |
| 126 | const estimatedLines = estimateWrappedLineCount( | |
| 127 | expandAssistantInlineCodeForEstimate(message.text), | |
| 128 | charsPerLine, | |
| 129 | ); | |
| 130 | return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX; | |
| 131 | } |
apps/web/src/components/ui/combobox.tsx+15| ... | ... | @@ -275,6 +275,20 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { |
| 275 | 275 | ); |
| 276 | 276 | } |
| 277 | 277 | |
| 278 | /** | |
| 279 | * A variant of `ComboboxList` without `ScrollArea`, for use when | |
| 280 | * an external virtualizer (e.g. LegendList) owns the scroll container. | |
| 281 | */ | |
| 282 | function ComboboxListVirtualized({ className, ...props }: ComboboxPrimitive.List.Props) { | |
| 283 | return ( | |
| 284 | <ComboboxPrimitive.List | |
| 285 | className={cn("not-empty:px-1 not-empty:py-1", className)} | |
| 286 | data-slot="combobox-list" | |
| 287 | {...props} | |
| 288 | /> | |
| 289 | ); | |
| 290 | } | |
| 291 | ||
| 278 | 292 | function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { |
| 279 | 293 | return <ComboboxPrimitive.Clear className={className} data-slot="combobox-clear" {...props} />; |
| 280 | 294 | } |
| ... | ... | @@ -371,6 +385,7 @@ export { |
| 371 | 385 | ComboboxEmpty, |
| 372 | 386 | ComboboxValue, |
| 373 | 387 | ComboboxList, |
| 388 | ComboboxListVirtualized, | |
| 374 | 389 | ComboboxClear, |
| 375 | 390 | ComboboxStatus, |
| 376 | 391 | ComboboxRow, |
bun.lock+3-5| ... | ... | @@ -80,6 +80,7 @@ |
| 80 | 80 | "@dnd-kit/utilities": "^3.2.2", |
| 81 | 81 | "@effect/atom-react": "catalog:", |
| 82 | 82 | "@formkit/auto-animate": "^0.9.0", |
| 83 | "@legendapp/list": "3.0.0-beta.44", | |
| 83 | 84 | "@lexical/react": "^0.41.0", |
| 84 | 85 | "@pierre/diffs": "^1.1.0-beta.16", |
| 85 | 86 | "@t3tools/client-runtime": "workspace:*", |
| ... | ... | @@ -88,7 +89,6 @@ |
| 88 | 89 | "@tanstack/react-pacer": "^0.19.4", |
| 89 | 90 | "@tanstack/react-query": "^5.90.0", |
| 90 | 91 | "@tanstack/react-router": "^1.160.2", |
| 91 | "@tanstack/react-virtual": "^3.13.18", | |
| 92 | 92 | "@xterm/addon-fit": "^0.11.0", |
| 93 | 93 | "@xterm/xterm": "^6.0.0", |
| 94 | 94 | "class-variance-authority": "^0.7.1", |
| ... | ... | @@ -462,6 +462,8 @@ |
| 462 | 462 | |
| 463 | 463 | "@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=="], |
| 464 | 464 | |
| 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 | ||
| 465 | 467 | "@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=="], |
| 466 | 468 | |
| 467 | 469 | "@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 @@ |
| 740 | 742 | |
| 741 | 743 | "@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=="], |
| 742 | 744 | |
| 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 | ||
| 745 | 745 | "@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=="], |
| 746 | 746 | |
| 747 | 747 | "@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 @@ |
| 752 | 752 | |
| 753 | 753 | "@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="], |
| 754 | 754 | |
| 755 | "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.23", "", {}, "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="], | |
| 756 | ||
| 757 | 755 | "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.6", "", {}, "sha512-EGWs9yvJA821pUkwkiZLQW89CzUumHyJy8NKq229BubyoWXfDw1oWnTJYSS/hhbLiwP9+KpopjeF5wWwnCCyeQ=="], |
| 758 | 756 | |
| 759 | 757 | "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], |