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