From 85020e3f25185d2be81c4b16412d8292e4cc6bab Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 12 Apr 2026 00:04:40 -0700 Subject: [PATCH 01/18] Migrate chat scrolling to LegendList - Replace custom chat auto-scroll and branch virtualization with LegendList - Remove deprecated chat scroll helpers and related tests - Add LegendList dependency for list rendering --- apps/web/package.json | 3 +- apps/web/src/chat-scroll.test.ts | 62 - apps/web/src/chat-scroll.ts | 24 - .../BranchToolbarBranchSelector.tsx | 102 +- apps/web/src/components/ChatView.tsx | 280 +---- .../components/chat/MessagesTimeline.logic.ts | 73 +- .../components/chat/MessagesTimeline.test.tsx | 151 --- .../src/components/chat/MessagesTimeline.tsx | 259 +--- ...essagesTimeline.virtualization.browser.tsx | 1071 ----------------- apps/web/src/components/ui/combobox.tsx | 15 + bun.lock | 8 +- 11 files changed, 148 insertions(+), 1900 deletions(-) delete mode 100644 apps/web/src/chat-scroll.test.ts delete mode 100644 apps/web/src/chat-scroll.ts delete mode 100644 apps/web/src/components/chat/MessagesTimeline.test.tsx delete mode 100644 apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx diff --git a/apps/web/package.json b/apps/web/package.json index a447b3e0efa..7304ffedde7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@formkit/auto-animate": "^0.9.0", + "@legendapp/list": "3.0.0-beta.44", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", "@t3tools/client-runtime": "workspace:*", @@ -29,7 +30,7 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", "@tanstack/react-router": "^1.160.2", - "@tanstack/react-virtual": "^3.13.18", + "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/chat-scroll.test.ts b/apps/web/src/chat-scroll.test.ts deleted file mode 100644 index 5311fb40aed..00000000000 --- a/apps/web/src/chat-scroll.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "./chat-scroll"; - -describe("isScrollContainerNearBottom", () => { - it("returns true when already at bottom", () => { - expect( - isScrollContainerNearBottom({ - scrollTop: 600, - clientHeight: 400, - scrollHeight: 1_000, - }), - ).toBe(true); - }); - - it("returns true when within the auto-scroll threshold", () => { - expect( - isScrollContainerNearBottom({ - scrollTop: 540, - clientHeight: 400, - scrollHeight: 1_000, - }), - ).toBe(true); - }); - - it("returns false when the user is meaningfully above the bottom", () => { - expect( - isScrollContainerNearBottom({ - scrollTop: 520, - clientHeight: 400, - scrollHeight: 1_000, - }), - ).toBe(false); - }); - - it("clamps negative thresholds to zero", () => { - expect( - isScrollContainerNearBottom( - { - scrollTop: 539, - clientHeight: 400, - scrollHeight: 1_000, - }, - -1, - ), - ).toBe(false); - }); - - it("falls back to the default threshold for non-finite values", () => { - expect( - isScrollContainerNearBottom( - { - scrollTop: 540, - clientHeight: 400, - scrollHeight: 1_000, - }, - Number.NaN, - ), - ).toBe(true); - expect(AUTO_SCROLL_BOTTOM_THRESHOLD_PX).toBe(64); - }); -}); diff --git a/apps/web/src/chat-scroll.ts b/apps/web/src/chat-scroll.ts deleted file mode 100644 index 35190ab1b94..00000000000 --- a/apps/web/src/chat-scroll.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; - -interface ScrollPosition { - scrollTop: number; - clientHeight: number; - scrollHeight: number; -} - -export function isScrollContainerNearBottom( - position: ScrollPosition, - thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX, -): boolean { - const threshold = Number.isFinite(thresholdPx) - ? Math.max(0, thresholdPx) - : AUTO_SCROLL_BOTTOM_THRESHOLD_PX; - - const { scrollTop, clientHeight, scrollHeight } = position; - if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) { - return true; - } - - const distanceFromBottom = scrollHeight - clientHeight - scrollTop; - return distanceFromBottom <= threshold; -} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 76f64d93fe1..07d18600d2e 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,10 +1,9 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; -import { useVirtualizer } from "@tanstack/react-virtual"; +import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon } from "lucide-react"; import { - type CSSProperties, useCallback, useDeferredValue, useEffect, @@ -38,6 +37,7 @@ import { ComboboxInput, ComboboxItem, ComboboxList, + ComboboxListVirtualized, ComboboxPopup, ComboboxStatus, ComboboxTrigger, @@ -390,7 +390,7 @@ export function BranchToolbarBranchSelector({ }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); // --------------------------------------------------------------------------- - // Combobox / virtualizer plumbing + // Combobox / list plumbing // --------------------------------------------------------------------------- const handleOpenChange = useCallback( (open: boolean) => { @@ -425,41 +425,11 @@ export function BranchToolbarBranchSelector({ void fetchNextPage().catch(() => undefined); }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); - const branchListVirtualizer = useVirtualizer({ - count: filteredBranchPickerItems.length, - estimateSize: (index) => - filteredBranchPickerItems[index] === checkoutPullRequestItemValue ? 44 : 28, - getScrollElement: () => branchListScrollElementRef.current, - overscan: 12, - enabled: isBranchMenuOpen && shouldVirtualizeBranchList, - initialRect: { - height: 224, - width: 0, - }, - }); - const virtualBranchRows = branchListVirtualizer.getVirtualItems(); - const setBranchListRef = useCallback( - (element: HTMLDivElement | null) => { - branchListScrollElementRef.current = - (element?.parentElement as HTMLDivElement | null) ?? null; - if (element) { - branchListVirtualizer.measure(); - } - }, - [branchListVirtualizer], - ); - - useEffect(() => { - if (!isBranchMenuOpen || !shouldVirtualizeBranchList) return; - queueMicrotask(() => { - branchListVirtualizer.measure(); - }); - }, [ - branchListVirtualizer, - filteredBranchPickerItems.length, - isBranchMenuOpen, - shouldVirtualizeBranchList, - ]); + const branchListRef = useRef(null); + const setBranchListRef = useCallback((element: HTMLDivElement | null) => { + branchListScrollElementRef.current = + (element?.parentElement as HTMLDivElement | null) ?? null; + }, []); useEffect(() => { if (!isBranchMenuOpen) { @@ -467,6 +437,7 @@ export function BranchToolbarBranchSelector({ } branchListScrollElementRef.current?.scrollTo({ top: 0 }); + branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); useEffect(() => { @@ -496,7 +467,7 @@ export function BranchToolbarBranchSelector({ resolvedActiveBranch, }); - function renderPickerItem(itemValue: string, index: number, style?: CSSProperties) { + function renderPickerItem(itemValue: string, index: number) { if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { return ( { if (!prReference || !onCheckoutPullRequestRequest) { return; @@ -529,7 +499,6 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - style={style} onClick={() => createBranch(trimmedBranchQuery)} > Create new branch "{trimmedBranchQuery}" @@ -557,7 +526,6 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - style={style} onClick={() => selectBranch(branch)} >
@@ -576,7 +544,10 @@ export function BranchToolbarBranchSelector({ virtualized={shouldVirtualizeBranchList} onItemHighlighted={(_value, eventDetails) => { if (!isBranchMenuOpen || eventDetails.index < 0) return; - branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); + branchListRef.current?.scrollIndexIntoView?.({ + index: eventDetails.index, + animated: false, + }); }} onOpenChange={handleOpenChange} open={isBranchMenuOpen} @@ -604,30 +575,29 @@ export function BranchToolbarBranchSelector({
No branches found. - - {shouldVirtualizeBranchList ? ( -
+ + ref={branchListRef} + data={filteredBranchPickerItems} + keyExtractor={(item) => item} + renderItem={({ item, index }) => renderPickerItem(item, index)} + estimatedItemSize={28} + drawDistance={336} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage().catch(() => undefined); + } }} - > - {virtualBranchRows.map((virtualRow) => { - const itemValue = filteredBranchPickerItems[virtualRow.index]; - if (!itemValue) return null; - return renderPickerItem(itemValue, virtualRow.index, { - position: "absolute", - top: 0, - left: 0, - width: "100%", - transform: `translateY(${virtualRow.start}px)`, - }); - })} -
- ) : ( - filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index)) - )} -
+ style={{ maxHeight: "14rem" }} + /> + + ) : ( + + {filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index)) + } + + )} {branchStatusText ? {branchStatusText} : null} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7a5a2875ccc..a5ca6c15e0d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -57,7 +57,7 @@ import { isLatestTurnSettled, formatElapsed, } from "../session-logic"; -import { isScrollContainerNearBottom } from "../chat-scroll"; +import { type LegendListRef } from "@legendapp/list/react"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -695,27 +695,12 @@ export default function ChatView(props: ChatViewProps) { {}, LastInvokedScriptByProjectSchema, ); - const messagesScrollRef = useRef(null); - const [messagesScrollElement, setMessagesScrollElement] = useState(null); - const shouldAutoScrollRef = useRef(true); - const lastKnownScrollTopRef = useRef(0); - const isPointerScrollActiveRef = useRef(false); - const lastTouchClientYRef = useRef(null); - const pendingUserScrollUpIntentRef = useRef(false); - const pendingAutoScrollFrameRef = useRef(null); - const pendingInteractionAnchorRef = useRef<{ - element: HTMLElement; - top: number; - } | null>(null); - const pendingInteractionAnchorFrameRef = useRef(null); + const legendListRef = useRef(null); + const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const terminalOpenByThreadRef = useRef>({}); - const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { - messagesScrollRef.current = element; - setMessagesScrollElement(element); - }, []); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef), @@ -1970,165 +1955,26 @@ export default function ChatView(props: ChatViewProps) { [environmentId, serverThread], ); - // Auto-scroll on new messages - const messageCount = timelineMessages.length; - const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); - lastKnownScrollTopRef.current = scrollContainer.scrollTop; - shouldAutoScrollRef.current = true; - }, []); - const cancelPendingStickToBottom = useCallback(() => { - const pendingFrame = pendingAutoScrollFrameRef.current; - if (pendingFrame === null) return; - pendingAutoScrollFrameRef.current = null; - window.cancelAnimationFrame(pendingFrame); - }, []); - const cancelPendingInteractionAnchorAdjustment = useCallback(() => { - const pendingFrame = pendingInteractionAnchorFrameRef.current; - if (pendingFrame === null) return; - pendingInteractionAnchorFrameRef.current = null; - window.cancelAnimationFrame(pendingFrame); - }, []); - const scheduleStickToBottom = useCallback(() => { - if (pendingAutoScrollFrameRef.current !== null) return; - pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { - pendingAutoScrollFrameRef.current = null; - scrollMessagesToBottom(); - }); - }, [scrollMessagesToBottom]); - const onMessagesClickCapture = useCallback( - (event: React.MouseEvent) => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer || !(event.target instanceof Element)) return; - - const trigger = event.target.closest( - "button, summary, [role='button'], [data-scroll-anchor-target]", - ); - if (!trigger || !scrollContainer.contains(trigger)) return; - if (trigger.closest("[data-scroll-anchor-ignore]")) return; - - pendingInteractionAnchorRef.current = { - element: trigger, - top: trigger.getBoundingClientRect().top, - }; - - cancelPendingInteractionAnchorAdjustment(); - pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame(() => { - pendingInteractionAnchorFrameRef.current = null; - const anchor = pendingInteractionAnchorRef.current; - pendingInteractionAnchorRef.current = null; - const activeScrollContainer = messagesScrollRef.current; - if (!anchor || !activeScrollContainer) return; - if (!anchor.element.isConnected || !activeScrollContainer.contains(anchor.element)) return; - - const nextTop = anchor.element.getBoundingClientRect().top; - const delta = nextTop - anchor.top; - if (Math.abs(delta) < 0.5) return; - - activeScrollContainer.scrollTop += delta; - lastKnownScrollTopRef.current = activeScrollContainer.scrollTop; - }); - }, - [cancelPendingInteractionAnchorAdjustment], - ); - const forceStickToBottom = useCallback(() => { - cancelPendingStickToBottom(); - scrollMessagesToBottom(); - scheduleStickToBottom(); - }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); - const onMessagesScroll = useCallback(() => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - const currentScrollTop = scrollContainer.scrollTop; - const isNearBottom = isScrollContainerNearBottom(scrollContainer); - - if (!shouldAutoScrollRef.current && isNearBottom) { - shouldAutoScrollRef.current = true; - pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp && !isNearBottom) { - shouldAutoScrollRef.current = false; - } - pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp && !isNearBottom) { - shouldAutoScrollRef.current = false; - } - } else if (shouldAutoScrollRef.current && !isNearBottom) { - // Catch-all for keyboard/assistive scroll interactions. - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { - shouldAutoScrollRef.current = false; - } - } - - setShowScrollToBottom(!shouldAutoScrollRef.current); - lastKnownScrollTopRef.current = currentScrollTop; - }, []); - const onMessagesWheel = useCallback((event: React.WheelEvent) => { - if (event.deltaY < 0) { - pendingUserScrollUpIntentRef.current = true; - } - }, []); - const onMessagesPointerDown = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = true; + // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd. + // These are used for imperative scroll-to-end when sending messages or changing threads. + const scrollToEnd = useCallback((animated = false) => { + legendListRef.current?.scrollToEnd?.({ animated }); }, []); - const onMessagesPointerUp = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; + const onIsAtEndChange = useCallback((isAtEnd: boolean) => { + isAtEndRef.current = isAtEnd; + setShowScrollToBottom(!isAtEnd); }, []); - const onMessagesPointerCancel = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, []); - const onMessagesTouchStart = useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - lastTouchClientYRef.current = touch.clientY; - }, []); - const onMessagesTouchMove = useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - const previousTouchY = lastTouchClientYRef.current; - if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { - pendingUserScrollUpIntentRef.current = true; - } - lastTouchClientYRef.current = touch.clientY; - }, []); - const onMessagesTouchEnd = useCallback((_event: React.TouchEvent) => { - lastTouchClientYRef.current = null; - }, []); - useEffect(() => { - return () => { - cancelPendingStickToBottom(); - cancelPendingInteractionAnchorAdjustment(); - }; - }, [cancelPendingInteractionAnchorAdjustment, cancelPendingStickToBottom]); useLayoutEffect(() => { if (!activeThread?.id) return; - shouldAutoScrollRef.current = true; - scheduleStickToBottom(); + isAtEndRef.current = true; + // Give LegendList a frame to mount/layout before scrolling. const timeout = window.setTimeout(() => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - if (isScrollContainerNearBottom(scrollContainer)) return; - scheduleStickToBottom(); - }, 96); + legendListRef.current?.scrollToEnd?.({ animated: false }); + }, 50); return () => { window.clearTimeout(timeout); }; - }, [activeThread?.id, scheduleStickToBottom]); - useEffect(() => { - if (!shouldAutoScrollRef.current) return; - scheduleStickToBottom(); - }, [messageCount, scheduleStickToBottom]); - useEffect(() => { - if (phase !== "running") return; - if (!shouldAutoScrollRef.current) return; - scheduleStickToBottom(); - }, [phase, scheduleStickToBottom, timelineEntries]); + }, [activeThread?.id]); useEffect(() => { setExpandedWorkGroups({}); @@ -2587,8 +2433,11 @@ export default function ChatView(props: ChatViewProps) { }, ]); // Sending a message should always bring the latest user turn into view. - shouldAutoScrollRef.current = true; - forceStickToBottom(); + isAtEndRef.current = true; + requestAnimationFrame(() => { + legendListRef.current?.scrollToEnd?.({ animated: true }); + setShowScrollToBottom(false); + }); setThreadError(threadIdForSend, null); if (expiredTerminalContextCount > 0) { @@ -2979,8 +2828,11 @@ export default function ChatView(props: ChatViewProps) { streaming: false, }, ]); - shouldAutoScrollRef.current = true; - forceStickToBottom(); + isAtEndRef.current = true; + requestAnimationFrame(() => { + legendListRef.current?.scrollToEnd?.({ animated: true }); + setShowScrollToBottom(false); + }); try { await persistThreadSettingsForNextTurn({ @@ -3046,7 +2898,7 @@ export default function ChatView(props: ChatViewProps) { activeThread, activeProposedPlan, beginLocalDispatch, - forceStickToBottom, + scrollToEnd, isConnecting, isSendBusy, isServerThread, @@ -3338,57 +3190,43 @@ export default function ChatView(props: ChatViewProps) {
{/* Messages Wrapper */}
- {/* Messages */} -
- 0} - isWorking={isWorking} - activeTurnInProgress={isWorking || !latestTurnSettled} - activeTurnId={activeLatestTurn?.turnId ?? null} - activeTurnStartedAt={activeWorkStartedAt} - scrollContainer={messagesScrollElement} - timelineEntries={timelineEntries} - completionDividerBeforeEntryId={completionDividerBeforeEntryId} - completionSummary={completionSummary} - turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} - nowIso={nowIso} - activeThreadEnvironmentId={activeThread.environmentId} - expandedWorkGroups={expandedWorkGroups} - onToggleWorkGroup={onToggleWorkGroup} - changedFilesExpandedByTurnId={changedFilesExpandedByTurnId} - onSetChangedFilesExpanded={handleSetChangedFilesExpanded} - onOpenTurnDiff={onOpenTurnDiff} - revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} - onRevertUserMessage={onRevertUserMessage} - isRevertingCheckpoint={isRevertingCheckpoint} - onImageExpand={onExpandTimelineImage} - markdownCwd={gitCwd ?? undefined} - resolvedTheme={resolvedTheme} - timestampFormat={timestampFormat} - workspaceRoot={activeWorkspaceRoot} - /> -
+ {/* Messages — LegendList handles virtualization and scrolling internally */} + 0} + isWorking={isWorking} + activeTurnInProgress={isWorking || !latestTurnSettled} + activeTurnId={activeLatestTurn?.turnId ?? null} + activeTurnStartedAt={activeWorkStartedAt} + listRef={legendListRef} + timelineEntries={timelineEntries} + completionDividerBeforeEntryId={completionDividerBeforeEntryId} + completionSummary={completionSummary} + turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} + nowIso={nowIso} + activeThreadEnvironmentId={activeThread.environmentId} + expandedWorkGroups={expandedWorkGroups} + onToggleWorkGroup={onToggleWorkGroup} + changedFilesExpandedByTurnId={changedFilesExpandedByTurnId} + onSetChangedFilesExpanded={handleSetChangedFilesExpanded} + onOpenTurnDiff={onOpenTurnDiff} + revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} + onRevertUserMessage={onRevertUserMessage} + isRevertingCheckpoint={isRevertingCheckpoint} + onImageExpand={onExpandTimelineImage} + markdownCwd={gitCwd ?? undefined} + resolvedTheme={resolvedTheme} + timestampFormat={timestampFormat} + workspaceRoot={activeWorkspaceRoot} + onIsAtEndChange={onIsAtEndChange} + /> {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} {showScrollToBottom && (
) : ( @@ -596,40 +412,29 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } return ( -
- {virtualizedRowCount > 0 && ( -
- {virtualRows.map((virtualRow: VirtualItem) => { - const row = rows[virtualRow.index]; - if (!row) return null; - - return ( -
- {renderRowContent(row)} -
- ); - })} + + ref={listRef} + data={rows} + keyExtractor={(item) => item.id} + renderItem={({ item }) => ( +
+ {renderRowContent(item)}
)} - - {nonVirtualizedRows.map((row) => ( -
{renderRowContent(row)}
- ))} -
+ estimatedItemSize={90} + alignItemsAtEnd + initialScrollAtEnd + maintainScrollAtEnd + maintainScrollAtEndThreshold={0.1} + maintainVisibleContentPosition + onScroll={handleScroll} + className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" + ListHeaderComponent={
} + ListFooterComponent={
} + /> ); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx deleted file mode 100644 index be3cf5c67a8..00000000000 --- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +++ /dev/null @@ -1,1071 +0,0 @@ -import "../../index.css"; - -import { MessageId, type TurnId } from "@t3tools/contracts"; -import { page } from "vitest/browser"; -import { useCallback, useState, type ComponentProps } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render } from "vitest-browser-react"; - -import { deriveTimelineEntries, type WorkLogEntry } from "../../session-logic"; -import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; -import { MessagesTimeline } from "./MessagesTimeline"; -import { - deriveMessagesTimelineRows, - estimateMessagesTimelineRowHeight, -} from "./MessagesTimeline.logic"; - -const DEFAULT_VIEWPORT = { - width: 960, - height: 1_100, -}; -const MARKDOWN_CWD = "/repo/project"; -const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never; - -interface RowMeasurement { - actualHeightPx: number; - estimatedHeightPx: number; - timelineWidthPx: number; - virtualizerSizePx: number; - renderedInVirtualizedRegion: boolean; -} - -interface VirtualizationScenario { - name: string; - targetRowId: string; - props: Omit< - ComponentProps, - "scrollContainer" | "activeThreadEnvironmentId" - >; - maxEstimateDeltaPx: number; -} - -interface VirtualizerSnapshot { - totalSize: number; - measurements: ReadonlyArray<{ - id: string; - kind: string; - index: number; - size: number; - start: number; - end: number; - }>; -} - -function MessagesTimelineBrowserHarness( - props: Omit< - ComponentProps, - "scrollContainer" | "activeThreadEnvironmentId" - >, -) { - const [scrollContainer, setScrollContainer] = useState(null); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>( - () => props.expandedWorkGroups, - ); - const [changedFilesExpandedByTurnId, setChangedFilesExpandedByTurnId] = useState< - Record - >(() => props.changedFilesExpandedByTurnId); - const handleToggleWorkGroup = useCallback( - (groupId: string) => { - setExpandedWorkGroups((current) => ({ - ...current, - [groupId]: !(current[groupId] ?? false), - })); - props.onToggleWorkGroup(groupId); - }, - [props], - ); - const handleSetChangedFilesExpanded = useCallback( - (turnId: TurnId, expanded: boolean) => { - setChangedFilesExpandedByTurnId((current) => ({ - ...current, - [turnId]: expanded, - })); - props.onSetChangedFilesExpanded(turnId, expanded); - }, - [props], - ); - - return ( -
- -
- ); -} - -function isoAt(offsetSeconds: number): string { - return new Date(Date.UTC(2026, 2, 17, 19, 12, 28) + offsetSeconds * 1_000).toISOString(); -} - -function createMessage(input: { - id: string; - role: ChatMessage["role"]; - text: string; - offsetSeconds: number; - attachments?: ChatMessage["attachments"]; -}): ChatMessage { - return { - id: MessageId.make(input.id), - role: input.role, - text: input.text, - ...(input.attachments ? { attachments: input.attachments } : {}), - createdAt: isoAt(input.offsetSeconds), - ...(input.role === "assistant" ? { completedAt: isoAt(input.offsetSeconds + 1) } : {}), - streaming: false, - }; -} - -function createToolWorkEntry(input: { - id: string; - offsetSeconds: number; - label?: string; - detail?: string; -}): WorkLogEntry { - return { - id: input.id, - createdAt: isoAt(input.offsetSeconds), - label: input.label ?? "exec_command completed", - ...(input.detail ? { detail: input.detail } : {}), - tone: "tool", - toolTitle: "exec_command", - }; -} - -function createPlan(input: { - id: string; - offsetSeconds: number; - planMarkdown: string; -}): ProposedPlan { - return { - id: input.id as ProposedPlan["id"], - turnId: null, - planMarkdown: input.planMarkdown, - implementedAt: null, - implementationThreadId: null, - createdAt: isoAt(input.offsetSeconds), - updatedAt: isoAt(input.offsetSeconds + 1), - }; -} - -function createBaseTimelineProps(input: { - messages?: ChatMessage[]; - proposedPlans?: ProposedPlan[]; - workEntries?: WorkLogEntry[]; - expandedWorkGroups?: Record; - completionDividerBeforeEntryId?: string | null; - turnDiffSummaryByAssistantMessageId?: Map; - onVirtualizerSnapshot?: ComponentProps["onVirtualizerSnapshot"]; -}): Omit, "scrollContainer" | "activeThreadEnvironmentId"> { - return { - hasMessages: true, - isWorking: false, - activeTurnInProgress: false, - activeTurnStartedAt: null, - timelineEntries: deriveTimelineEntries( - input.messages ?? [], - input.proposedPlans ?? [], - input.workEntries ?? [], - ), - completionDividerBeforeEntryId: input.completionDividerBeforeEntryId ?? null, - completionSummary: null, - turnDiffSummaryByAssistantMessageId: input.turnDiffSummaryByAssistantMessageId ?? new Map(), - nowIso: isoAt(10_000), - expandedWorkGroups: input.expandedWorkGroups ?? {}, - onToggleWorkGroup: () => {}, - changedFilesExpandedByTurnId: {}, - onSetChangedFilesExpanded: () => {}, - onOpenTurnDiff: () => {}, - revertTurnCountByUserMessageId: new Map(), - onRevertUserMessage: () => {}, - isRevertingCheckpoint: false, - onImageExpand: () => {}, - markdownCwd: MARKDOWN_CWD, - resolvedTheme: "light", - timestampFormat: "locale", - workspaceRoot: MARKDOWN_CWD, - ...(input.onVirtualizerSnapshot ? { onVirtualizerSnapshot: input.onVirtualizerSnapshot } : {}), - }; -} - -function createFillerMessages(input: { - prefix: string; - startOffsetSeconds: number; - pairCount: number; -}): ChatMessage[] { - const messages: ChatMessage[] = []; - for (let index = 0; index < input.pairCount; index += 1) { - const baseOffset = input.startOffsetSeconds + index * 4; - messages.push( - createMessage({ - id: `${input.prefix}-user-${index}`, - role: "user", - text: `filler user message ${index}`, - offsetSeconds: baseOffset, - }), - ); - messages.push( - createMessage({ - id: `${input.prefix}-assistant-${index}`, - role: "assistant", - text: `filler assistant message ${index}`, - offsetSeconds: baseOffset + 1, - }), - ); - } - return messages; -} - -function createChangedFilesSummary( - targetMessageId: MessageId, - files: TurnDiffSummary["files"], -): Map { - return new Map([ - [ - targetMessageId, - { - turnId: "turn-changed-files" as TurnId, - completedAt: isoAt(10), - assistantMessageId: targetMessageId, - files, - }, - ], - ]); -} - -function createChangedFilesScenario(input: { - name: string; - rowId: string; - files: TurnDiffSummary["files"]; - maxEstimateDeltaPx?: number; -}): VirtualizationScenario { - const beforeMessages = createFillerMessages({ - prefix: `${input.rowId}-before`, - startOffsetSeconds: 0, - pairCount: 2, - }); - const afterMessages = createFillerMessages({ - prefix: `${input.rowId}-after`, - startOffsetSeconds: 40, - pairCount: 8, - }); - const changedFilesMessage = createMessage({ - id: input.rowId, - role: "assistant", - text: "Validation passed on the merged tree.", - offsetSeconds: 12, - }); - - return { - name: input.name, - targetRowId: changedFilesMessage.id, - props: createBaseTimelineProps({ - messages: [...beforeMessages, changedFilesMessage, ...afterMessages], - turnDiffSummaryByAssistantMessageId: createChangedFilesSummary( - changedFilesMessage.id, - input.files, - ), - }), - maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 72, - }; -} - -function createAssistantMessageScenario(input: { - name: string; - rowId: string; - text: string; - maxEstimateDeltaPx?: number; -}): VirtualizationScenario { - const beforeMessages = createFillerMessages({ - prefix: `${input.rowId}-before`, - startOffsetSeconds: 0, - pairCount: 2, - }); - const afterMessages = createFillerMessages({ - prefix: `${input.rowId}-after`, - startOffsetSeconds: 40, - pairCount: 8, - }); - const assistantMessage = createMessage({ - id: input.rowId, - role: "assistant", - text: input.text, - offsetSeconds: 12, - }); - - return { - name: input.name, - targetRowId: assistantMessage.id, - props: createBaseTimelineProps({ - messages: [...beforeMessages, assistantMessage, ...afterMessages], - }), - maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 16, - }; -} - -function buildStaticScenarios(): VirtualizationScenario[] { - const beforeMessages = createFillerMessages({ - prefix: "before", - startOffsetSeconds: 0, - pairCount: 2, - }); - const afterMessages = createFillerMessages({ - prefix: "after", - startOffsetSeconds: 40, - pairCount: 8, - }); - - const longUserMessage = createMessage({ - id: "target-user-long", - role: "user", - text: "x".repeat(3_200), - offsetSeconds: 12, - }); - const workEntries = Array.from({ length: 4 }, (_, index) => - createToolWorkEntry({ - id: `target-work-${index}`, - offsetSeconds: 12 + index, - detail: `tool output line ${index + 1}`, - }), - ); - const moderatePlan = createPlan({ - id: "target-plan", - offsetSeconds: 12, - planMarkdown: [ - "# Stabilize virtualization", - "", - "- Gather baseline measurements", - "- Add browser harness coverage", - "- Compare estimated and rendered heights", - "- Fix the broken rows without broad refactors", - "- Re-run lint and typecheck", - ].join("\n"), - }); - return [ - { - name: "long user message", - targetRowId: longUserMessage.id, - props: createBaseTimelineProps({ - messages: [...beforeMessages, longUserMessage, ...afterMessages], - }), - maxEstimateDeltaPx: 56, - }, - { - name: "grouped work log row", - targetRowId: workEntries[0]!.id, - props: createBaseTimelineProps({ - messages: [...beforeMessages, ...afterMessages], - workEntries, - }), - maxEstimateDeltaPx: 56, - }, - { - name: "expanded grouped work log row with show more enabled", - targetRowId: "target-work-expanded-0", - props: createBaseTimelineProps({ - messages: [...beforeMessages, ...afterMessages], - workEntries: Array.from({ length: 10 }, (_, index) => - createToolWorkEntry({ - id: `target-work-expanded-${index}`, - offsetSeconds: 12 + index, - detail: `tool output line ${index + 1}`, - }), - ), - expandedWorkGroups: { - "target-work-expanded-0": true, - }, - }), - maxEstimateDeltaPx: 72, - }, - { - name: "proposed plan row", - targetRowId: moderatePlan.id, - props: createBaseTimelineProps({ - messages: [...beforeMessages, ...afterMessages], - proposedPlans: [moderatePlan], - }), - maxEstimateDeltaPx: 96, - }, - createAssistantMessageScenario({ - name: "assistant single-paragraph row with plain prose", - rowId: "target-assistant-plain-prose", - text: [ - "The host is still expanding to content somewhere in the grid layout.", - "I'm stripping it back further to a plain block container so the test width", - "is actually the timeline width.", - ].join(" "), - }), - createAssistantMessageScenario({ - name: "assistant single-paragraph row with inline code", - rowId: "target-assistant-inline-code", - text: [ - "Typecheck found one exact-optional-property issue in the browser harness:", - "I was always passing `onVirtualizerSnapshot`, including `undefined`.", - "I'm tightening that object construction and rerunning the checks.", - ].join(" "), - maxEstimateDeltaPx: 28, - }), - createChangedFilesScenario({ - name: "assistant changed-files row with a compacted single-chain directory", - rowId: "target-assistant-changed-files-single-chain", - files: [ - { path: "apps/web/src/components/chat/ChangedFilesTree.tsx", additions: 37, deletions: 45 }, - { - path: "apps/web/src/components/chat/ChangedFilesTree.test.tsx", - additions: 0, - deletions: 26, - }, - ], - }), - createChangedFilesScenario({ - name: "assistant changed-files row with a branch after compaction", - rowId: "target-assistant-changed-files-branch-point", - files: [ - { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 }, - { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 }, - { - path: "apps/server/src/provider/Layers/CodexAdapter.ts", - additions: 27, - deletions: 8, - }, - { - path: "apps/server/src/provider/Layers/CodexAdapter.test.ts", - additions: 36, - deletions: 0, - }, - ], - }), - createChangedFilesScenario({ - name: "assistant changed-files row with mixed root and nested entries", - rowId: "target-assistant-changed-files-mixed-root", - files: [ - { path: "README.md", additions: 5, deletions: 1 }, - { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, - { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, - ], - }), - ]; -} - -async function nextFrame(): Promise { - await new Promise((resolve) => { - window.requestAnimationFrame(() => resolve()); - }); -} - -async function waitForLayout(): Promise { - await nextFrame(); - await nextFrame(); - await nextFrame(); -} - -async function setViewport(viewport: { width: number; height: number }): Promise { - await page.viewport(viewport.width, viewport.height); - await waitForLayout(); -} - -async function waitForProductionStyles(): Promise { - await vi.waitFor( - () => { - expect( - getComputedStyle(document.documentElement).getPropertyValue("--background").trim(), - ).not.toBe(""); - expect(getComputedStyle(document.body).marginTop).toBe("0px"); - }, - { timeout: 4_000, interval: 16 }, - ); -} - -async function waitForElement( - query: () => T | null, - errorMessage: string, -): Promise { - let element: T | null = null; - await vi.waitFor( - () => { - element = query(); - expect(element, errorMessage).toBeTruthy(); - }, - { timeout: 8_000, interval: 16 }, - ); - if (!element) { - throw new Error(errorMessage); - } - return element; -} - -async function measureTimelineRow(input: { - host: HTMLElement; - props: Omit< - ComponentProps, - "scrollContainer" | "activeThreadEnvironmentId" - >; - targetRowId: string; -}): Promise { - const scrollContainer = await waitForElement( - () => - input.host.querySelector( - '[data-testid="messages-timeline-scroll-container"]', - ), - "Unable to find MessagesTimeline scroll container.", - ); - - const rowSelector = `[data-timeline-row-id="${input.targetRowId}"]`; - const virtualRowSelector = `[data-virtual-row-id="${input.targetRowId}"]`; - - let timelineWidthPx = 0; - let actualHeightPx = 0; - let virtualizerSizePx = 0; - let renderedInVirtualizedRegion = false; - - await vi.waitFor( - async () => { - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); - - const rowElement = input.host.querySelector(rowSelector); - const virtualRowElement = input.host.querySelector(virtualRowSelector); - const timelineRoot = input.host.querySelector('[data-timeline-root="true"]'); - - expect(rowElement, "Unable to locate target timeline row.").toBeTruthy(); - expect(virtualRowElement, "Unable to locate target virtualized wrapper.").toBeTruthy(); - expect(timelineRoot, "Unable to locate MessagesTimeline root.").toBeTruthy(); - - timelineWidthPx = timelineRoot!.getBoundingClientRect().width; - actualHeightPx = rowElement!.getBoundingClientRect().height; - virtualizerSizePx = Number.parseFloat(virtualRowElement!.dataset.virtualRowSize ?? "0"); - renderedInVirtualizedRegion = virtualRowElement!.hasAttribute("data-index"); - - expect(timelineWidthPx).toBeGreaterThan(0); - expect(actualHeightPx).toBeGreaterThan(0); - expect(virtualizerSizePx).toBeGreaterThan(0); - expect(renderedInVirtualizedRegion).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - - const rows = deriveMessagesTimelineRows({ - timelineEntries: input.props.timelineEntries, - completionDividerBeforeEntryId: input.props.completionDividerBeforeEntryId, - isWorking: input.props.isWorking, - activeTurnStartedAt: input.props.activeTurnStartedAt, - }); - const targetRow = rows.find((row) => row.id === input.targetRowId); - expect(targetRow, `Unable to derive target row ${input.targetRowId}.`).toBeTruthy(); - - return { - actualHeightPx, - estimatedHeightPx: estimateMessagesTimelineRowHeight(targetRow!, { - expandedWorkGroups: input.props.expandedWorkGroups, - timelineWidthPx, - turnDiffSummaryByAssistantMessageId: input.props.turnDiffSummaryByAssistantMessageId, - }), - timelineWidthPx, - virtualizerSizePx, - renderedInVirtualizedRegion, - }; -} - -async function mountMessagesTimeline(input: { - props: Omit< - ComponentProps, - "scrollContainer" | "activeThreadEnvironmentId" - >; - viewport?: { width: number; height: number }; -}) { - const viewport = input.viewport ?? DEFAULT_VIEWPORT; - await setViewport(viewport); - await waitForProductionStyles(); - - const host = document.createElement("div"); - host.style.width = `${viewport.width}px`; - host.style.minWidth = `${viewport.width}px`; - host.style.maxWidth = `${viewport.width}px`; - host.style.height = `${viewport.height}px`; - host.style.minHeight = `${viewport.height}px`; - host.style.maxHeight = `${viewport.height}px`; - host.style.display = "block"; - host.style.overflow = "hidden"; - document.body.append(host); - - const screen = await render(, { - container: host, - }); - await waitForLayout(); - - return { - host, - rerender: async ( - nextProps: Omit< - ComponentProps, - "scrollContainer" | "activeThreadEnvironmentId" - >, - ) => { - await screen.rerender(); - await waitForLayout(); - }, - setContainerSize: async (nextViewport: { width: number; height: number }) => { - await setViewport(nextViewport); - host.style.width = `${nextViewport.width}px`; - host.style.minWidth = `${nextViewport.width}px`; - host.style.maxWidth = `${nextViewport.width}px`; - host.style.height = `${nextViewport.height}px`; - host.style.minHeight = `${nextViewport.height}px`; - host.style.maxHeight = `${nextViewport.height}px`; - await waitForLayout(); - }, - cleanup: async () => { - await screen.unmount(); - host.remove(); - }, - }; -} - -async function measureRenderedRowActualHeight(input: { - host: HTMLElement; - targetRowId: string; -}): Promise { - const rowElement = await waitForElement( - () => input.host.querySelector(`[data-timeline-row-id="${input.targetRowId}"]`), - `Unable to locate rendered row ${input.targetRowId}.`, - ); - return rowElement.getBoundingClientRect().height; -} - -describe("MessagesTimeline virtualization harness", () => { - beforeEach(async () => { - document.body.innerHTML = ""; - await setViewport(DEFAULT_VIEWPORT); - }); - - afterEach(() => { - document.body.innerHTML = ""; - }); - - it.each(buildStaticScenarios())("keeps the $name estimate within tolerance", async (scenario) => { - const mounted = await mountMessagesTimeline({ props: scenario.props }); - - try { - const measurement = await measureTimelineRow({ - host: mounted.host, - props: scenario.props, - targetRowId: scenario.targetRowId, - }); - - expect( - Math.abs(measurement.actualHeightPx - measurement.estimatedHeightPx), - `estimate delta for ${scenario.name}`, - ).toBeLessThanOrEqual(scenario.maxEstimateDeltaPx); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the changed-files row virtualizer size in sync after collapsing directories", async () => { - const beforeMessages = createFillerMessages({ - prefix: "before-collapse", - startOffsetSeconds: 0, - pairCount: 2, - }); - const afterMessages = createFillerMessages({ - prefix: "after-collapse", - startOffsetSeconds: 40, - pairCount: 8, - }); - const targetMessage = createMessage({ - id: "target-assistant-collapse", - role: "assistant", - text: "Validation passed on the merged tree.", - offsetSeconds: 12, - }); - const props = createBaseTimelineProps({ - messages: [...beforeMessages, targetMessage, ...afterMessages], - turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [ - { path: ".plans/effect-atom.md", additions: 89, deletions: 0 }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts", - additions: 4, - deletions: 3, - }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts", - additions: 131, - deletions: 128, - }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts", - additions: 1, - deletions: 1, - }, - { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 }, - { - path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts", - additions: 106, - deletions: 112, - }, - { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 }, - { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 }, - { - path: "apps/web/src/components/chat/MessagesTimeline.tsx", - additions: 52, - deletions: 7, - }, - { - path: "apps/web/src/components/chat/ChangedFilesTree.tsx", - additions: 32, - deletions: 4, - }, - { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, - { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, - ]), - }); - const mounted = await mountMessagesTimeline({ - props, - viewport: { width: 320, height: 700 }, - }); - - try { - const beforeCollapse = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: targetMessage.id, - }); - const targetRowElement = mounted.host.querySelector( - `[data-timeline-row-id="${targetMessage.id}"]`, - ); - expect(targetRowElement, "Unable to locate target changed-files row.").toBeTruthy(); - - const collapseAllButton = - Array.from(targetRowElement!.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Collapse all", - ) ?? null; - expect(collapseAllButton, 'Unable to find "Collapse all" button.').toBeTruthy(); - - collapseAllButton!.click(); - - await vi.waitFor( - async () => { - const afterCollapse = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: targetMessage.id, - }); - expect(afterCollapse.actualHeightPx).toBeLessThan(beforeCollapse.actualHeightPx - 24); - }, - { timeout: 8_000, interval: 16 }, - ); - - const afterCollapse = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: targetMessage.id, - }); - expect( - Math.abs(afterCollapse.actualHeightPx - afterCollapse.virtualizerSizePx), - ).toBeLessThanOrEqual(8); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the work-log row virtualizer size in sync after show more expands the group", async () => { - const beforeMessages = createFillerMessages({ - prefix: "before-worklog-expand", - startOffsetSeconds: 0, - pairCount: 2, - }); - const afterMessages = createFillerMessages({ - prefix: "after-worklog-expand", - startOffsetSeconds: 40, - pairCount: 8, - }); - const workEntries = Array.from({ length: 10 }, (_, index) => - createToolWorkEntry({ - id: `target-work-toggle-${index}`, - offsetSeconds: 12 + index, - detail: `tool output line ${index + 1}`, - }), - ); - const props = createBaseTimelineProps({ - messages: [...beforeMessages, ...afterMessages], - workEntries, - }); - const mounted = await mountMessagesTimeline({ props }); - - try { - const beforeExpand = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: workEntries[0]!.id, - }); - const targetRowElement = mounted.host.querySelector( - `[data-timeline-row-id="${workEntries[0]!.id}"]`, - ); - expect(targetRowElement, "Unable to locate target work-log row.").toBeTruthy(); - - const showMoreButton = - Array.from(targetRowElement!.querySelectorAll("button")).find((button) => - button.textContent?.includes("Show 4 more"), - ) ?? null; - expect(showMoreButton, 'Unable to find "Show more" button.').toBeTruthy(); - - showMoreButton!.click(); - - await vi.waitFor( - async () => { - const afterExpand = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: workEntries[0]!.id, - }); - expect(afterExpand.actualHeightPx).toBeGreaterThan(beforeExpand.actualHeightPx + 72); - }, - { timeout: 8_000, interval: 16 }, - ); - - const afterExpand = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: workEntries[0]!.id, - }); - expect( - Math.abs(afterExpand.actualHeightPx - afterExpand.virtualizerSizePx), - ).toBeLessThanOrEqual(8); - } finally { - await mounted.cleanup(); - } - }); - - it("preserves measured tail row heights when rows transition into virtualization", async () => { - const beforeMessages = createFillerMessages({ - prefix: "tail-transition-before", - startOffsetSeconds: 0, - pairCount: 1, - }); - const afterMessages = createFillerMessages({ - prefix: "tail-transition-after", - startOffsetSeconds: 40, - pairCount: 3, - }); - const targetMessage = createMessage({ - id: "target-tail-transition", - role: "assistant", - text: "Validation passed on the merged tree.", - offsetSeconds: 12, - }); - let latestSnapshot: VirtualizerSnapshot | null = null; - const initialProps = createBaseTimelineProps({ - messages: [...beforeMessages, targetMessage, ...afterMessages], - turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [ - { path: ".plans/effect-atom.md", additions: 89, deletions: 0 }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts", - additions: 4, - deletions: 3, - }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts", - additions: 131, - deletions: 128, - }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts", - additions: 1, - deletions: 1, - }, - { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 }, - { - path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts", - additions: 106, - deletions: 112, - }, - { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 }, - { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 }, - { - path: "apps/web/src/components/chat/MessagesTimeline.tsx", - additions: 52, - deletions: 7, - }, - { - path: "apps/web/src/components/chat/ChangedFilesTree.tsx", - additions: 32, - deletions: 4, - }, - { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, - { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, - ]), - onVirtualizerSnapshot: (snapshot) => { - latestSnapshot = { - totalSize: snapshot.totalSize, - measurements: snapshot.measurements, - }; - }, - }); - - const mounted = await mountMessagesTimeline({ props: initialProps }); - - try { - const initiallyRenderedHeight = await measureRenderedRowActualHeight({ - host: mounted.host, - targetRowId: targetMessage.id, - }); - - const appendedProps = createBaseTimelineProps({ - messages: [ - ...beforeMessages, - targetMessage, - ...afterMessages, - ...createFillerMessages({ - prefix: "tail-transition-extra", - startOffsetSeconds: 120, - pairCount: 8, - }), - ], - turnDiffSummaryByAssistantMessageId: initialProps.turnDiffSummaryByAssistantMessageId, - onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot, - }); - await mounted.rerender(appendedProps); - - const scrollContainer = await waitForElement( - () => - mounted.host.querySelector( - '[data-testid="messages-timeline-scroll-container"]', - ), - "Unable to find MessagesTimeline scroll container.", - ); - scrollContainer.scrollTop = scrollContainer.scrollHeight; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); - - await vi.waitFor( - () => { - const measurement = latestSnapshot?.measurements.find( - (entry) => entry.id === targetMessage.id, - ); - expect( - measurement, - "Expected target row to transition into virtualizer cache.", - ).toBeTruthy(); - expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual( - 8, - ); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("preserves measured tail image row heights when rows transition into virtualization", async () => { - const beforeMessages = createFillerMessages({ - prefix: "tail-image-before", - startOffsetSeconds: 0, - pairCount: 1, - }); - const afterMessages = createFillerMessages({ - prefix: "tail-image-after", - startOffsetSeconds: 40, - pairCount: 3, - }); - const targetMessage = createMessage({ - id: "target-tail-image-transition", - role: "user", - text: "Here is a narrow screenshot.", - offsetSeconds: 12, - attachments: [ - { - type: "image", - id: "target-tail-image", - name: "narrow.svg", - mimeType: "image/svg+xml", - sizeBytes: 512, - previewUrl: - "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", - }, - ], - }); - let latestSnapshot: VirtualizerSnapshot | null = null; - const initialProps = createBaseTimelineProps({ - messages: [...beforeMessages, targetMessage, ...afterMessages], - onVirtualizerSnapshot: (snapshot) => { - latestSnapshot = { - totalSize: snapshot.totalSize, - measurements: snapshot.measurements, - }; - }, - }); - const mounted = await mountMessagesTimeline({ props: initialProps }); - - try { - await vi.waitFor( - () => { - const image = mounted.host.querySelector( - `[data-timeline-row-id="${targetMessage.id}"] img`, - ); - expect(image?.naturalHeight ?? 0).toBeGreaterThan(0); - }, - { timeout: 8_000, interval: 16 }, - ); - - const initiallyRenderedHeight = await measureRenderedRowActualHeight({ - host: mounted.host, - targetRowId: targetMessage.id, - }); - const appendedProps = createBaseTimelineProps({ - messages: [ - ...beforeMessages, - targetMessage, - ...afterMessages, - ...createFillerMessages({ - prefix: "tail-image-extra", - startOffsetSeconds: 120, - pairCount: 8, - }), - ], - onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot, - }); - await mounted.rerender(appendedProps); - - const scrollContainer = await waitForElement( - () => - mounted.host.querySelector( - '[data-testid="messages-timeline-scroll-container"]', - ), - "Unable to find MessagesTimeline scroll container.", - ); - scrollContainer.scrollTop = scrollContainer.scrollHeight; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); - - await vi.waitFor( - () => { - const measurement = latestSnapshot?.measurements.find( - (entry) => entry.id === targetMessage.id, - ); - expect( - measurement, - "Expected target image row to transition into virtualizer cache.", - ).toBeTruthy(); - expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual( - 8, - ); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); -}); diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 8348d4c2fcf..52dd996ec56 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -275,6 +275,20 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { ); } +/** + * A variant of `ComboboxList` without `ScrollArea`, for use when + * an external virtualizer (e.g. LegendList) owns the scroll container. + */ +function ComboboxListVirtualized({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + ); +} + function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { return ; } @@ -371,6 +385,7 @@ export { ComboboxEmpty, ComboboxValue, ComboboxList, + ComboboxListVirtualized, ComboboxClear, ComboboxStatus, ComboboxRow, diff --git a/bun.lock b/bun.lock index 0c95792b694..2322e312675 100644 --- a/bun.lock +++ b/bun.lock @@ -80,6 +80,7 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@formkit/auto-animate": "^0.9.0", + "@legendapp/list": "3.0.0-beta.44", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", "@t3tools/client-runtime": "workspace:*", @@ -88,7 +89,6 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", "@tanstack/react-router": "^1.160.2", - "@tanstack/react-virtual": "^3.13.18", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", @@ -462,6 +462,8 @@ "@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=="], + "@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=="], + "@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=="], "@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 @@ "@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=="], - "@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=="], - "@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=="], "@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 @@ "@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.23", "", {}, "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="], - "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.6", "", {}, "sha512-EGWs9yvJA821pUkwkiZLQW89CzUumHyJy8NKq229BubyoWXfDw1oWnTJYSS/hhbLiwP9+KpopjeF5wWwnCCyeQ=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], From 175e3a8f40ca7877434ce7928d65388eba04cf18 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 12 Apr 2026 00:10:22 -0700 Subject: [PATCH 02/18] Fix branch selector scroll reset - Reset the branch list correctly for virtualized and non-virtualized views - Preserve formatting cleanup in web package and timeline logic --- apps/web/package.json | 1 - .../components/BranchToolbarBranchSelector.tsx | 17 ++++++++++------- .../components/chat/MessagesTimeline.logic.ts | 1 - 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 7304ffedde7..362eeecc023 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,7 +30,6 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", "@tanstack/react-router": "^1.160.2", - "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 07d18600d2e..c28a16d69a7 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -427,8 +427,7 @@ export function BranchToolbarBranchSelector({ }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); const branchListRef = useRef(null); const setBranchListRef = useCallback((element: HTMLDivElement | null) => { - branchListScrollElementRef.current = - (element?.parentElement as HTMLDivElement | null) ?? null; + branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null; }, []); useEffect(() => { @@ -436,9 +435,12 @@ export function BranchToolbarBranchSelector({ return; } - branchListScrollElementRef.current?.scrollTo({ top: 0 }); - branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); - }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); + if (shouldVirtualizeBranchList) { + branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); + } else { + branchListScrollElementRef.current?.scrollTo({ top: 0 }); + } + }, [deferredTrimmedBranchQuery, isBranchMenuOpen, shouldVirtualizeBranchList]); useEffect(() => { const scrollElement = branchListScrollElementRef.current; @@ -594,8 +596,9 @@ export function BranchToolbarBranchSelector({ ) : ( - {filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index)) - } + {filteredBranchPickerItems.map((itemValue, index) => + renderPickerItem(itemValue, index), + )} )} {branchStatusText ? {branchStatusText} : null} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 751fbdb53b7..b34964a8f69 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -172,4 +172,3 @@ export function deriveMessagesTimelineRows(input: { return nextRows; } - From 083a86a168061594d0da5c04a66b4e3f4a8f3147 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 12 Apr 2026 00:27:25 -0700 Subject: [PATCH 03/18] Remove stale timeline height parity tests - Drop browser harness measurements for timeline height parity - Delete the unused timelineHeight helper and its unit tests --- apps/web/src/components/ChatView.browser.tsx | 281 +----------------- .../web/src/components/timelineHeight.test.ts | 151 ---------- apps/web/src/components/timelineHeight.ts | 131 -------- 3 files changed, 1 insertion(+), 562 deletions(-) delete mode 100644 apps/web/src/components/timelineHeight.test.ts delete mode 100644 apps/web/src/components/timelineHeight.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index a65f5755f9a..ebf031575e5 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -47,7 +47,7 @@ import { useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; -import { estimateTimelineMessageHeight } from "./timelineHeight"; + import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; vi.mock("../lib/gitStatusState", () => ({ @@ -112,28 +112,9 @@ const COMPACT_FOOTER_VIEWPORT: ViewportSpec = { textTolerancePx: 56, attachmentTolerancePx: 56, }; -const TEXT_VIEWPORT_MATRIX = [ - DEFAULT_VIEWPORT, - { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 }, - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, - { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, -] as const satisfies readonly ViewportSpec[]; -const ATTACHMENT_VIEWPORT_MATRIX = [ - { ...DEFAULT_VIEWPORT, attachmentTolerancePx: 120 }, - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 120 }, - { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 120 }, -] as const satisfies readonly ViewportSpec[]; - -interface UserRowMeasurement { - measuredRowHeightPx: number; - timelineWidthMeasuredPx: number; - renderedInVirtualizedRegion: boolean; -} - interface MountedChatView { [Symbol.asyncDispose]: () => Promise; cleanup: () => Promise; - measureUserRow: (targetMessageId: MessageId) => Promise; setViewport: (viewport: ViewportSpec) => Promise; setContainerSize: (viewport: Pick) => Promise; router: ReturnType; @@ -1378,91 +1359,6 @@ async function waitForCommandPaletteShortcutLabel(): Promise { ); } -async function waitForImagesToLoad(scope: ParentNode): Promise { - const images = Array.from(scope.querySelectorAll("img")); - if (images.length === 0) { - return; - } - await Promise.all( - images.map( - (image) => - new Promise((resolve) => { - if (image.complete) { - resolve(); - return; - } - image.addEventListener("load", () => resolve(), { once: true }); - image.addEventListener("error", () => resolve(), { once: true }); - }), - ), - ); - await waitForLayout(); -} - -async function measureUserRow(options: { - host: HTMLElement; - targetMessageId: MessageId; -}): Promise { - const { host, targetMessageId } = options; - const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="user"]`; - - const scrollContainer = await waitForElement( - () => host.querySelector("div.overflow-y-auto.overscroll-y-contain"), - "Unable to find ChatView message scroll container.", - ); - - let row: HTMLElement | null = null; - await vi.waitFor( - async () => { - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); - row = host.querySelector(rowSelector); - expect(row, "Unable to locate targeted user message row.").toBeTruthy(); - }, - { - timeout: 8_000, - interval: 16, - }, - ); - - await waitForImagesToLoad(row!); - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await nextFrame(); - - const timelineRoot = - row!.closest('[data-timeline-root="true"]') ?? - host.querySelector('[data-timeline-root="true"]'); - if (!(timelineRoot instanceof HTMLElement)) { - throw new Error("Unable to locate timeline root container."); - } - - let timelineWidthMeasuredPx = 0; - let measuredRowHeightPx = 0; - let renderedInVirtualizedRegion = false; - await vi.waitFor( - async () => { - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await nextFrame(); - const measuredRow = host.querySelector(rowSelector); - expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy(); - timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; - measuredRowHeightPx = measuredRow!.getBoundingClientRect().height; - renderedInVirtualizedRegion = measuredRow!.closest("[data-index]") instanceof HTMLElement; - expect(timelineWidthMeasuredPx, "Unable to measure timeline width.").toBeGreaterThan(0); - expect(measuredRowHeightPx, "Unable to measure targeted user row height.").toBeGreaterThan(0); - }, - { - timeout: 4_000, - interval: 16, - }, - ); - - return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; -} - async function mountChatView(options: { viewport: ViewportSpec; snapshot: OrchestrationReadModel; @@ -1515,7 +1411,6 @@ async function mountChatView(options: { return { [Symbol.asyncDispose]: cleanup, cleanup, - measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }), setViewport: async (viewport: ViewportSpec) => { await setViewport(viewport); await waitForProductionStyles(); @@ -1529,23 +1424,6 @@ async function mountChatView(options: { }; } -async function measureUserRowAtViewport(options: { - snapshot: OrchestrationReadModel; - targetMessageId: MessageId; - viewport: ViewportSpec; -}): Promise { - const mounted = await mountChatView({ - viewport: options.viewport, - snapshot: options.snapshot, - }); - - try { - return await mounted.measureUserRow(options.targetMessageId); - } finally { - await mounted.cleanup(); - } -} - describe("ChatView timeline estimator parity (full app)", () => { beforeAll(async () => { fixture = buildFixture( @@ -1633,39 +1511,6 @@ describe("ChatView timeline estimator parity (full app)", () => { document.body.innerHTML = ""; }); - it.each(TEXT_VIEWPORT_MATRIX)( - "keeps long user message estimate close at the $name viewport", - async (viewport) => { - const userText = "x".repeat(3_200); - const targetMessageId = `msg-user-target-long-${viewport.name}` as MessageId; - const mounted = await mountChatView({ - viewport, - snapshot: createSnapshotForTargetUser({ - targetMessageId, - targetText: userText, - }), - }); - - try { - const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = - await mounted.measureUserRow(targetMessageId); - - expect(renderedInVirtualizedRegion).toBe(true); - - const estimatedHeightPx = estimateTimelineMessageHeight( - { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: timelineWidthMeasuredPx }, - ); - - expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( - viewport.textTolerancePx, - ); - } finally { - await mounted.cleanup(); - } - }, - ); - it("re-expands the bootstrap project using its scoped key", async () => { useUiStateStore.setState({ projectExpandedById: { @@ -1695,130 +1540,6 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => { - const userText = "x".repeat(3_200); - const targetMessageId = "msg-user-target-resize" as MessageId; - const mounted = await mountChatView({ - viewport: TEXT_VIEWPORT_MATRIX[0], - snapshot: createSnapshotForTargetUser({ - targetMessageId, - targetText: userText, - }), - }); - - try { - const measurements: Array< - UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number } - > = []; - - for (const viewport of TEXT_VIEWPORT_MATRIX) { - await mounted.setViewport(viewport); - const measurement = await mounted.measureUserRow(targetMessageId); - const estimatedHeightPx = estimateTimelineMessageHeight( - { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: measurement.timelineWidthMeasuredPx }, - ); - - expect(measurement.renderedInVirtualizedRegion).toBe(true); - expect(Math.abs(measurement.measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( - viewport.textTolerancePx, - ); - measurements.push({ ...measurement, viewport, estimatedHeightPx }); - } - - expect( - new Set(measurements.map((measurement) => Math.round(measurement.timelineWidthMeasuredPx))) - .size, - ).toBeGreaterThanOrEqual(3); - - const byMeasuredWidth = measurements.toSorted( - (left, right) => left.timelineWidthMeasuredPx - right.timelineWidthMeasuredPx, - ); - const narrowest = byMeasuredWidth[0]!; - const widest = byMeasuredWidth.at(-1)!; - expect(narrowest.timelineWidthMeasuredPx).toBeLessThan(widest.timelineWidthMeasuredPx); - expect(narrowest.measuredRowHeightPx).toBeGreaterThan(widest.measuredRowHeightPx); - expect(narrowest.estimatedHeightPx).toBeGreaterThan(widest.estimatedHeightPx); - } finally { - await mounted.cleanup(); - } - }); - - it("tracks additional rendered wrapping when ChatView width narrows between desktop and mobile viewports", async () => { - const userText = "x".repeat(2_400); - const targetMessageId = "msg-user-target-wrap" as MessageId; - const snapshot = createSnapshotForTargetUser({ - targetMessageId, - targetText: userText, - }); - const desktopMeasurement = await measureUserRowAtViewport({ - viewport: TEXT_VIEWPORT_MATRIX[0], - snapshot, - targetMessageId, - }); - const mobileMeasurement = await measureUserRowAtViewport({ - viewport: TEXT_VIEWPORT_MATRIX[2], - snapshot, - targetMessageId, - }); - - const estimatedDesktopPx = estimateTimelineMessageHeight( - { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: desktopMeasurement.timelineWidthMeasuredPx }, - ); - const estimatedMobilePx = estimateTimelineMessageHeight( - { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: mobileMeasurement.timelineWidthMeasuredPx }, - ); - - const measuredDeltaPx = - mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx; - const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx; - expect(measuredDeltaPx).toBeGreaterThan(0); - expect(estimatedDeltaPx).toBeGreaterThan(0); - const ratio = estimatedDeltaPx / measuredDeltaPx; - expect(ratio).toBeGreaterThan(0.65); - expect(ratio).toBeLessThan(1.35); - }); - - it.each(ATTACHMENT_VIEWPORT_MATRIX)( - "keeps user attachment estimate close at the $name viewport", - async (viewport) => { - const targetMessageId = `msg-user-target-attachments-${viewport.name}` as MessageId; - const userText = "message with image attachments"; - const mounted = await mountChatView({ - viewport, - snapshot: createSnapshotForTargetUser({ - targetMessageId, - targetText: userText, - targetAttachmentCount: 2, - }), - }); - - try { - const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = - await mounted.measureUserRow(targetMessageId); - - expect(renderedInVirtualizedRegion).toBe(true); - - const estimatedHeightPx = estimateTimelineMessageHeight( - { - role: "user", - text: userText, - attachments: [{ id: "attachment-1" }, { id: "attachment-2" }], - }, - { timelineWidthPx: timelineWidthMeasuredPx }, - ); - - expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( - viewport.attachmentTolerancePx, - ); - } finally { - await mounted.cleanup(); - } - }, - ); - it("shows an explicit empty state for projects without threads in the sidebar", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts deleted file mode 100644 index 5317a5fc683..00000000000 --- a/apps/web/src/components/timelineHeight.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { appendTerminalContextsToPrompt } from "../lib/terminalContext"; -import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; -import { estimateTimelineMessageHeight } from "./timelineHeight"; - -describe("estimateTimelineMessageHeight", () => { - it("uses assistant sizing rules for assistant messages", () => { - expect( - estimateTimelineMessageHeight({ - role: "assistant", - text: "a".repeat(144), - }), - ).toBe(86.5); - }); - - it("uses assistant sizing rules for system messages", () => { - expect( - estimateTimelineMessageHeight({ - role: "system", - text: "a".repeat(144), - }), - ).toBe(86.5); - }); - - it("adds one attachment row for one or two user attachments", () => { - expect( - estimateTimelineMessageHeight({ - role: "user", - text: "hello", - attachments: [{ id: "1" }], - }), - ).toBe(234); - - expect( - estimateTimelineMessageHeight({ - role: "user", - text: "hello", - attachments: [{ id: "1" }, { id: "2" }], - }), - ).toBe(234); - }); - - it("adds a second attachment row for three or four user attachments", () => { - expect( - estimateTimelineMessageHeight({ - role: "user", - text: "hello", - attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], - }), - ).toBe(350); - - expect( - estimateTimelineMessageHeight({ - role: "user", - text: "hello", - attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], - }), - ).toBe(350); - }); - - it("does not cap long user message estimates", () => { - expect( - estimateTimelineMessageHeight({ - role: "user", - text: "a".repeat(56 * 120), - }), - ).toBe(2736); - }); - - it("counts explicit newlines for user message estimates", () => { - expect( - estimateTimelineMessageHeight({ - role: "user", - text: "first\nsecond\nthird", - }), - ).toBe(162); - }); - - it("adds terminal context chrome without counting the hidden block as message text", () => { - const prompt = appendTerminalContextsToPrompt("Investigate this", [ - { - terminalId: "default", - terminalLabel: "Terminal 1", - lineStart: 40, - lineEnd: 43, - text: [ - "git status", - "M apps/web/src/components/chat/MessagesTimeline.tsx", - "?? tmp", - "", - ].join("\n"), - }, - ]); - - expect( - estimateTimelineMessageHeight({ - role: "user", - text: prompt, - }), - ).toBe( - estimateTimelineMessageHeight({ - role: "user", - text: `${buildInlineTerminalContextText([{ header: "Terminal 1 lines 40-43" }])} Investigate this`, - }), - ); - }); - - it("uses narrower width to increase user line wrapping", () => { - const message = { - role: "user" as const, - text: "a".repeat(52), - }; - - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(140); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(118); - }); - - it("does not clamp user wrapping too aggressively on very narrow layouts", () => { - const message = { - role: "user" as const, - text: "a".repeat(20), - }; - - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(184); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(118); - }); - - it("uses narrower width to increase assistant line wrapping", () => { - const message = { - role: "assistant" as const, - text: "a".repeat(200), - }; - - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(154.75); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(86.5); - }); - - it("treats inline code as wider when estimating assistant markdown wrapping", () => { - const message = { - role: "assistant" as const, - text: [ - "Typecheck found one exact-optional-property issue in the browser harness:", - "I was always passing `onVirtualizerSnapshot`, including `undefined`.", - "I'm tightening that object construction and rerunning the checks.", - ].join(" "), - }; - - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(109.25); - }); -}); diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts deleted file mode 100644 index 3cb2aebb889..00000000000 --- a/apps/web/src/components/timelineHeight.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { deriveDisplayedUserMessageState } from "../lib/terminalContext"; -import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; - -const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; -const USER_CHARS_PER_LINE_FALLBACK = 56; -const USER_LINE_HEIGHT_PX = 22; -const ASSISTANT_LINE_HEIGHT_PX = 22.75; -// Assistant rows render as markdown content plus a compact timestamp meta line. -// The DOM baseline is much smaller than the user bubble chrome, so model it -// separately instead of reusing the old shared constant. -const ASSISTANT_BASE_HEIGHT_PX = 41; -const USER_BASE_HEIGHT_PX = 96; -const ATTACHMENTS_PER_ROW = 2; -// Full-app browser measurements land closer to a ~116px attachment row once -// the bubble shrinks to content width, so calibrate the estimate to that DOM. -const USER_ATTACHMENT_ROW_HEIGHT_PX = 116; -const USER_BUBBLE_WIDTH_RATIO = 0.8; -const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; -const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; -const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; -const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; -const MIN_USER_CHARS_PER_LINE = 4; -const MIN_ASSISTANT_CHARS_PER_LINE = 20; -const ASSISTANT_INLINE_CODE_WIDTH_MULTIPLIER = 1.2; -const ASSISTANT_INLINE_CODE_WRAP_OVERHEAD_CHARS = 2; -const INLINE_CODE_SPAN_REGEX = /`([^`\n]+)`/g; - -interface TimelineMessageHeightInput { - role: "user" | "assistant" | "system"; - text: string; - attachments?: ReadonlyArray<{ id: string }>; -} - -interface TimelineHeightEstimateLayout { - timelineWidthPx: number | null; -} - -function estimateWrappedLineCount(text: string, charsPerLine: number): number { - if (text.length === 0) return 1; - - // Avoid allocating via split for long logs; iterate once and count wrapped lines. - let lines = 0; - let currentLineLength = 0; - for (let index = 0; index < text.length; index += 1) { - if (text.charCodeAt(index) === 10) { - lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine)); - currentLineLength = 0; - continue; - } - currentLineLength += 1; - } - - lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine)); - return lines; -} - -function isFinitePositiveNumber(value: number | null | undefined): value is number { - return typeof value === "number" && Number.isFinite(value) && value > 0; -} - -function estimateCharsPerLineForUser(timelineWidthPx: number | null): number { - if (!isFinitePositiveNumber(timelineWidthPx)) return USER_CHARS_PER_LINE_FALLBACK; - const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO; - const textWidthPx = Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); - return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_MONO_AVG_CHAR_WIDTH_PX)); -} - -function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): number { - if (!isFinitePositiveNumber(timelineWidthPx)) return ASSISTANT_CHARS_PER_LINE_FALLBACK; - const textWidthPx = Math.max(timelineWidthPx - ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX, 0); - return Math.max( - MIN_ASSISTANT_CHARS_PER_LINE, - Math.floor(textWidthPx / ASSISTANT_AVG_CHAR_WIDTH_PX), - ); -} - -function expandAssistantInlineCodeForEstimate(text: string) { - return text.replace(INLINE_CODE_SPAN_REGEX, (_match, code: string) => - "x".repeat( - Math.max( - code.length + 2, - Math.ceil( - code.length * ASSISTANT_INLINE_CODE_WIDTH_MULTIPLIER + - ASSISTANT_INLINE_CODE_WRAP_OVERHEAD_CHARS, - ), - ), - ), - ); -} - -export function estimateTimelineMessageHeight( - message: TimelineMessageHeightInput, - layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, -): number { - if (message.role === "assistant") { - const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); - const estimatedLines = estimateWrappedLineCount( - expandAssistantInlineCodeForEstimate(message.text), - charsPerLine, - ); - return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX; - } - - if (message.role === "user") { - const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); - const displayedUserMessage = deriveDisplayedUserMessageState(message.text); - const renderedText = - displayedUserMessage.contexts.length > 0 - ? [ - buildInlineTerminalContextText(displayedUserMessage.contexts), - displayedUserMessage.visibleText, - ] - .filter((part) => part.length > 0) - .join(" ") - : displayedUserMessage.visibleText; - const estimatedLines = estimateWrappedLineCount(renderedText, charsPerLine); - const attachmentCount = message.attachments?.length ?? 0; - const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); - const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; - return USER_BASE_HEIGHT_PX + estimatedLines * USER_LINE_HEIGHT_PX + attachmentHeight; - } - - // `system` messages are not rendered in the chat timeline, but keep a stable - // explicit branch in case they are present in timeline data. - const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); - const estimatedLines = estimateWrappedLineCount( - expandAssistantInlineCodeForEstimate(message.text), - charsPerLine, - ); - return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX; -} From f34a717f2e95d583e33150c6fecbbca4087da687 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 12 Apr 2026 00:31:09 -0700 Subject: [PATCH 04/18] Remove thread mount auto-scroll from ChatView - Stop forcing LegendList to scroll to the end on thread mount - Rely on existing scroll state handling instead --- apps/web/src/components/ChatView.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a5ca6c15e0d..d4050a54698 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1964,17 +1964,6 @@ export default function ChatView(props: ChatViewProps) { isAtEndRef.current = isAtEnd; setShowScrollToBottom(!isAtEnd); }, []); - useLayoutEffect(() => { - if (!activeThread?.id) return; - isAtEndRef.current = true; - // Give LegendList a frame to mount/layout before scrolling. - const timeout = window.setTimeout(() => { - legendListRef.current?.scrollToEnd?.({ animated: false }); - }, 50); - return () => { - window.clearTimeout(timeout); - }; - }, [activeThread?.id]); useEffect(() => { setExpandedWorkGroups({}); @@ -2898,7 +2887,6 @@ export default function ChatView(props: ChatViewProps) { activeThread, activeProposedPlan, beginLocalDispatch, - scrollToEnd, isConnecting, isSendBusy, isServerThread, From 1620842be52c96b6bc9ba1ec7e9780e037f8e819 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 12 Apr 2026 16:48:16 +0000 Subject: [PATCH 05/18] fix: skip scroll-based fetch effect in virtualized branch list path When shouldVirtualizeBranchList is true, branchListScrollElementRef stays null because setBranchListRef is only attached to the non-virtualized ComboboxList. The branches.length effect would call maybeFetchNextBranchPage which bails out immediately due to the null scroll element. Guard the effect to skip when virtualized, since the LegendList onEndReached callback already handles infinite loading in that path, and with 40+ items the visible area is always filled. Applied via @cursor push command --- apps/web/src/components/BranchToolbarBranchSelector.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index c28a16d69a7..202e3730f94 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -460,8 +460,9 @@ export function BranchToolbarBranchSelector({ }, [isBranchMenuOpen, maybeFetchNextBranchPage]); useEffect(() => { + if (shouldVirtualizeBranchList) return; maybeFetchNextBranchPage(); - }, [branches.length, maybeFetchNextBranchPage]); + }, [branches.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); const triggerLabel = getBranchTriggerLabel({ activeWorktreePath, From 34c8c65b1ec307f81465db781a43777bfac6d1f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 12 Apr 2026 17:35:43 +0000 Subject: [PATCH 06/18] Fix scroll-to-bottom button not reset on thread switch Reset isAtEndRef and showScrollToBottom when activeThread changes so stale scroll state from a previous thread does not persist into the new one. Applied via @cursor push command --- apps/web/src/components/ChatView.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d4050a54698..e7b40522a44 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1968,6 +1968,8 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { setExpandedWorkGroups({}); setPullRequestDialogState(null); + isAtEndRef.current = true; + setShowScrollToBottom(false); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(true); From 85616f1a99b106ee1148827a1892f09871f4703d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 12 Apr 2026 10:55:02 -0700 Subject: [PATCH 07/18] Disable animated chat autoscroll - Scroll LegendList to the end without animation when sending messages - Avoid mid-flight layout changes from landing the view at the wrong position --- apps/web/src/components/ChatView.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e7b40522a44..5688a1058e3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -28,7 +28,7 @@ import { import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; @@ -2424,9 +2424,12 @@ export default function ChatView(props: ChatViewProps) { }, ]); // Sending a message should always bring the latest user turn into view. + // Use animated: false — an animated scroll can be interrupted by mid-flight + // data changes (server response, composer resize) causing the scroll to + // land at the wrong position. isAtEndRef.current = true; requestAnimationFrame(() => { - legendListRef.current?.scrollToEnd?.({ animated: true }); + legendListRef.current?.scrollToEnd?.({ animated: false }); setShowScrollToBottom(false); }); @@ -2821,7 +2824,7 @@ export default function ChatView(props: ChatViewProps) { ]); isAtEndRef.current = true; requestAnimationFrame(() => { - legendListRef.current?.scrollToEnd?.({ animated: true }); + legendListRef.current?.scrollToEnd?.({ animated: false }); setShowScrollToBottom(false); }); From 81d09011fc05231a8d59c1e69607f82d58c40fff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 12 Apr 2026 15:17:01 -0700 Subject: [PATCH 08/18] Stabilize chat timeline scrolling and row updates - Move timeline row state into local/context-driven components to reduce list-wide rerenders - Fix scroll-to-end behavior when sending messages so LegendList stays pinned correctly - Add live ticking timers for working and streaming message metadata --- apps/web/index.html | 1 + apps/web/src/components/ChatView.tsx | 92 +-- .../src/components/chat/MessagesTimeline.tsx | 690 +++++++++++++----- 3 files changed, 519 insertions(+), 264 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index 9f0329b6020..a0149eba6d8 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -8,6 +8,7 @@ +