Unified · Split
| @@ -21,6 +21,7 @@ · 20 unmodified lines | ||
| 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 @@ · 2 unmodified lines | ||
| 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", |
| @@ -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 | −}); | |
| @@ -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 | −} | |
| @@ -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 { · 27 unmodified lines | ||
| 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({ · 346 unmodified lines | ||
| 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({ · 28 unmodified lines | ||
| 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({ · 13 unmodified lines | ||
| 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({ · 1 unmodified line | ||
| 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({ · 1 unmodified line | ||
| 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({ · 18 unmodified lines | ||
| 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 "{trimmedBranchQuery}"</span> |
| @@ -557,7 +529,6 @@ export function BranchToolbarBranchSelector({ · 21 unmodified lines | ||
| 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({ · 11 unmodified lines | ||
| 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({ · 21 unmodified lines | ||
| 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> |
| @@ -47,7 +47,7 @@ import { useTerminalStateStore } from "../terminalStateStore"; · 46 unmodified lines | ||
| 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 = { · 58 unmodified lines | ||
| 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> { · 1238 unmodified lines | ||
| 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: { · 46 unmodified lines | ||
| 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: { · 7 unmodified lines | ||
| 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)", () => { · 81 unmodified lines | ||
| 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)", () => { · 23 unmodified lines | ||
| 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)", () => { · 946 unmodified lines | ||
| 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, |
| @@ -28,7 +28,8 @@ import { · 27 unmodified lines | ||
| 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 { · 22 unmodified lines | ||
| 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 = · 110 unmodified lines | ||
| 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) { · 409 unmodified lines | ||
| 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) { · 65 unmodified lines | ||
| 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) { · 8 unmodified lines | ||
| 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) { · 273 unmodified lines | ||
| 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) { · 133 unmodified lines | ||
| 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) { · 819 unmodified lines | ||
| 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) { · 151 unmodified lines | ||
| 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) { · 269 unmodified lines | ||
| 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) { · 5 unmodified lines | ||
| 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) { · 374 unmodified lines | ||
| 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) { · 4 unmodified lines | ||
| 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) { · 59 unmodified lines | ||
| 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) { · 188 unmodified lines | ||
| 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) { · 19 unmodified lines | ||
| 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) { · 50 unmodified lines | ||
| 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) { · 48 unmodified lines | ||
| 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} |
| @@ -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", () => { · 246 unmodified lines | ||
| 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", () => { · 6 unmodified lines | ||
| 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 | }); |
| @@ -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 = · 19 unmodified lines | ||
| 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 = · 3 unmodified lines | ||
| 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: { · 64 unmodified lines | ||
| 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: { · 49 unmodified lines | ||
| 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: { · 8 unmodified lines | ||
| 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 | } |
| @@ -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'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 | −}); | |
| @@ -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 { · 12 unmodified lines | ||
| 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 { · 3 unmodified lines | ||
| 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 { · 3 unmodified lines | ||
| 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 { · 1 unmodified line | ||
| 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 { · 4 unmodified lines | ||
| 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({ · 4 unmodified lines | ||
| 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({ · 4 unmodified lines | ||
| 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({ · 1 unmodified line | ||
| 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({ · 13 unmodified lines | ||
| 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({ · 23 unmodified lines | ||
| 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({ · 1 unmodified line | ||
| 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({ · 6 unmodified lines | ||
| 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({ · 5 unmodified lines | ||
| 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({ · 1 unmodified line | ||
| 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({ · 16 unmodified lines | ||
| 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({ · 7 unmodified lines | ||
| 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: { · 99 unmodified lines | ||
| 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; |
| @@ -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 | −}); | |
| @@ -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 | −}); | |
| @@ -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 | −} | |
| @@ -275,6 +275,20 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { · 274 unmodified lines | ||
| 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 { · 90 unmodified lines | ||
| 371 | 385 | ComboboxEmpty, |
| 372 | 386 | ComboboxValue, |
| 373 | 387 | ComboboxList, |
| 388 | + ComboboxListVirtualized, | |
| 374 | 389 | ComboboxClear, |
| 375 | 390 | ComboboxStatus, |
| 376 | 391 | ComboboxRow, |
| @@ -80,6 +80,7 @@ · 79 unmodified lines | ||
| 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 @@ · 2 unmodified lines | ||
| 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 @@ · 367 unmodified lines | ||
| 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 @@ · 272 unmodified lines | ||
| 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 @@ · 4 unmodified lines | ||
| 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=="], |