diff --git a/apps/web/package.json b/apps/web/package.json index a447b3e0ef..362eeecc02 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,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", diff --git a/apps/web/src/chat-scroll.test.ts b/apps/web/src/chat-scroll.test.ts deleted file mode 100644 index 5311fb40ae..0000000000 --- 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 35190ab1b9..0000000000 --- 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 76f64d93fe..5d5a7b34ea 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,49 +425,22 @@ 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) { return; } - branchListScrollElementRef.current?.scrollTo({ top: 0 }); - }, [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; @@ -487,8 +460,9 @@ export function BranchToolbarBranchSelector({ }, [isBranchMenuOpen, maybeFetchNextBranchPage]); useEffect(() => { + if (shouldVirtualizeBranchList) return; maybeFetchNextBranchPage(); - }, [branches.length, maybeFetchNextBranchPage]); + }, [branches.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); const triggerLabel = getBranchTriggerLabel({ activeWorktreePath, @@ -496,7 +470,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 +502,6 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - style={style} onClick={() => createBranch(trimmedBranchQuery)} > Create new branch "{trimmedBranchQuery}" @@ -557,7 +529,6 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - style={style} onClick={() => selectBranch(branch)} >
@@ -575,8 +546,13 @@ export function BranchToolbarBranchSelector({ autoHighlight virtualized={shouldVirtualizeBranchList} onItemHighlighted={(_value, eventDetails) => { - if (!isBranchMenuOpen || eventDetails.index < 0) return; - branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); + if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { + return; + } + branchListRef.current?.scrollIndexIntoView?.({ + index: eventDetails.index, + animated: false, + }); }} onOpenChange={handleOpenChange} open={isBranchMenuOpen} @@ -604,30 +580,30 @@ 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.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index a65f5755f9..48c6284917 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, @@ -2771,6 +2492,108 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("keeps the new worktree branch picker anchored at the top when opening with a preselected branch", async () => { + const draftId = DraftId.make("draft-branch-picker-scroll-regression"); + const branches = [ + { + name: "feature/current", + current: true, + isDefault: false, + worktreePath: null, + }, + { + name: "main", + current: false, + isDefault: true, + worktreePath: null, + }, + ...Array.from({ length: 48 }, (_, index) => ({ + name: `feature/${String(index).padStart(2, "0")}`, + current: false, + isDefault: false, + worktreePath: null, + })), + { + name: "feature/selected", + current: false, + isDefault: false, + worktreePath: null, + }, + ]; + + useComposerDraftStore.setState({ + draftThreadsByThreadKey: { + [draftId]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/selected", + worktreePath: null, + envMode: "worktree", + }, + }, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: draftId, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + initialPath: `/draft/${draftId}`, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.gitListBranches) { + return { + isRepo: true, + hasOriginRemote: true, + nextCursor: null, + totalCount: branches.length, + branches, + }; + } + return undefined; + }, + }); + + try { + const branchButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "From feature/selected", + ) as HTMLButtonElement | null, + 'Unable to find branch selector button with "From feature/selected".', + ); + branchButton.click(); + + await waitForElement( + () => document.querySelector('input[placeholder="Search branches..."]'), + "Unable to find branch search input.", + ); + + const popup = await waitForElement( + () => document.querySelector('[data-slot="combobox-popup"]'), + "Unable to find the branch picker popup.", + ); + + await vi.waitFor( + () => { + const popupSpans = Array.from(popup.querySelectorAll("span")); + expect( + popupSpans.some((element) => element.textContent?.trim() === "feature/current"), + ).toBe(true); + expect(popupSpans.some((element) => element.textContent?.trim() === "main")).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7a5a2875cc..bc65fa7319 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -28,7 +28,8 @@ 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 { Debouncer } from "@tanstack/react-pacer"; +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"; @@ -57,7 +58,7 @@ import { isLatestTurnSettled, formatElapsed, } from "../session-logic"; -import { isScrollContainerNearBottom } from "../chat-scroll"; +import { type LegendListRef } from "@legendapp/list/react"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -174,7 +175,6 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; -const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID: Record = {}; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; type ThreadPlanCatalogEntry = Pick; @@ -590,18 +590,9 @@ export default function ChatView(props: ChatViewProps) { ); const setStoreThreadError = useStore((store) => store.setError); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const setThreadChangedFilesExpanded = useUiStateStore( - (store) => store.setThreadChangedFilesExpanded, - ); const activeThreadLastVisitedAt = useUiStateStore((store) => routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, ); - const changedFilesExpandedByTurnId = useUiStateStore((store) => - routeKind === "server" - ? (store.threadChangedFilesExpandedById[routeThreadKey] ?? - EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID) - : EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID, - ); const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, @@ -673,14 +664,12 @@ export default function ChatView(props: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. const planSidebarOpenOnNextThreadRef = useRef(false); - const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); @@ -695,27 +684,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), @@ -995,16 +969,6 @@ export default function ChatView(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); - const handleSetChangedFilesExpanded = useCallback( - (turnId: TurnId, expanded: boolean) => { - if (routeKind !== "server") { - return; - } - setThreadChangedFilesExpanded(routeThreadKey, turnId, expanded); - }, - [routeKind, routeThreadKey, setThreadChangedFilesExpanded], - ); - useEffect(() => { if (!serverThread?.id) return; if (!latestTurnSettled) return; @@ -1144,7 +1108,6 @@ export default function ChatView(props: ChatViewProps) { threadError: activeThread?.error, }); const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; - const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, @@ -1970,169 +1933,33 @@ 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); + // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd. + const scrollToEnd = useCallback((animated = false) => { + legendListRef.current?.scrollToEnd?.({ animated }); }, []); - 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; - }, []); - const onMessagesPointerUp = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, []); - 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; + // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during + // thread switches. LegendList fires scroll events with isAtEnd=false while + // initialScrollAtEnd is settling; hiding is always immediate. + const showScrollDebouncer = useRef( + new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), + ); + const onIsAtEndChange = useCallback((isAtEnd: boolean) => { + if (isAtEndRef.current === isAtEnd) return; + isAtEndRef.current = isAtEnd; + if (isAtEnd) { + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + } else { + showScrollDebouncer.current.maybeExecute(); } - 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(); - const timeout = window.setTimeout(() => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - if (isScrollContainerNearBottom(scrollContainer)) return; - scheduleStickToBottom(); - }, 96); - 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]); useEffect(() => { - setExpandedWorkGroups({}); setPullRequestDialogState(null); + isAtEndRef.current = true; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(true); @@ -2290,16 +2117,6 @@ export default function ChatView(props: ChatViewProps) { terminalState.terminalOpen, ]); - useEffect(() => { - if (phase !== "running") return; - const timer = window.setInterval(() => { - setNowTick(Date.now()); - }, 1000); - return () => { - window.clearInterval(timer); - }; - }, [phase]); - useEffect(() => { if (!activeThreadKey) return; const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; @@ -2575,6 +2392,14 @@ export default function ChatView(props: ChatViewProps) { sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); + // Scroll to the current end *before* adding the optimistic message. + // This sets LegendList's internal isAtEnd=true so maintainScrollAtEnd + // automatically pins to the new item when the data changes. + isAtEndRef.current = true; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + await legendListRef.current?.scrollToEnd?.({ animated: false }); + setOptimisticUserMessages((existing) => [ ...existing, { @@ -2586,9 +2411,6 @@ export default function ChatView(props: ChatViewProps) { streaming: false, }, ]); - // Sending a message should always bring the latest user turn into view. - shouldAutoScrollRef.current = true; - forceStickToBottom(); setThreadError(threadIdForSend, null); if (expiredTerminalContextCount > 0) { @@ -2969,6 +2791,13 @@ export default function ChatView(props: ChatViewProps) { sendInFlightRef.current = true; beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); + + // Scroll to the current end *before* adding the optimistic message. + isAtEndRef.current = true; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + await legendListRef.current?.scrollToEnd?.({ animated: false }); + setOptimisticUserMessages((existing) => [ ...existing, { @@ -2979,8 +2808,6 @@ export default function ChatView(props: ChatViewProps) { streaming: false, }, ]); - shouldAutoScrollRef.current = true; - forceStickToBottom(); try { await persistThreadSettingsForNextTurn({ @@ -3046,7 +2873,6 @@ export default function ChatView(props: ChatViewProps) { activeThread, activeProposedPlan, beginLocalDispatch, - forceStickToBottom, isConnecting, isSendBusy, isServerThread, @@ -3241,12 +3067,6 @@ export default function ChatView(props: ChatViewProps) { ], ); - const onToggleWorkGroup = useCallback((groupId: string) => { - setExpandedWorkGroups((existing) => ({ - ...existing, - [groupId]: !existing[groupId], - })); - }, []); const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); @@ -3272,16 +3092,19 @@ export default function ChatView(props: ChatViewProps) { }, [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], ); - const onRevertUserMessage = useCallback( - (messageId: MessageId) => { - const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); - if (typeof targetTurnCount !== "number") { - return; - } - void onRevertToTurnCount(targetTurnCount); - }, - [onRevertToTurnCount, revertTurnCountByUserMessageId], - ); + // Both the Map and the revert handler are read from refs at call-time so + // the callback reference is fully stable and never busts context identity. + const revertTurnCountRef = useRef(revertTurnCountByUserMessageId); + revertTurnCountRef.current = revertTurnCountByUserMessageId; + const onRevertToTurnCountRef = useRef(onRevertToTurnCount); + onRevertToTurnCountRef.current = onRevertToTurnCount; + const onRevertUserMessage = useCallback((messageId: MessageId) => { + const targetTurnCount = revertTurnCountRef.current.get(messageId); + if (typeof targetTurnCount !== "number") { + return; + } + void onRevertToTurnCountRef.current(targetTurnCount); + }, []); // Empty state: no active thread if (!activeThread) { @@ -3338,57 +3161,39 @@ 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} + activeThreadEnvironmentId={activeThread.environmentId} + routeThreadKey={routeThreadKey} + 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 && (
- )} -
- )} -
- {visibleEntries.map((workEntry) => ( - - ))} -
-
- ); - })()} + {row.kind === "work" && } {row.kind === "message" && row.message.role === "user" && @@ -364,7 +286,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const userImages = row.message.attachments ?? []; const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); const terminalContexts = displayedUserMessage.contexts; - const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); + const canRevertAgentWork = typeof row.revertTurnCount === "number"; return (
@@ -384,15 +306,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onClick={() => { const preview = buildExpandedImagePreview(userImages, image.id); if (!preview) return; - onImageExpand(preview); + ctx.onImageExpand(preview); }} > {image.name} ) : ( @@ -422,8 +342,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ type="button" size="xs" variant="outline" - disabled={isRevertingCheckpoint || isWorking} - onClick={() => onRevertUserMessage(row.message.id)} + disabled={ctx.isRevertingCheckpoint || ctx.isWorking} + onClick={() => ctx.onRevertUserMessage(row.message.id)} title="Revert to this message" > @@ -431,7 +351,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}

- {formatTimestamp(row.message.createdAt, timestampFormat)} + {formatTimestamp(row.message.createdAt, ctx.timestampFormat)}

@@ -444,10 +364,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ (() => { const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); const assistantTurnStillInProgress = - activeTurnInProgress && - activeTurnId !== null && - activeTurnId !== undefined && - row.message.turnId === activeTurnId; + ctx.activeTurnInProgress && + ctx.activeTurnId !== null && + ctx.activeTurnId !== undefined && + row.message.turnId === ctx.activeTurnId; const assistantCopyState = resolveAssistantMessageCopyState({ text: row.message.text ?? null, showCopyButton: row.showAssistantCopyButton, @@ -459,7 +379,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
- {completionSummary ? `Response • ${completionSummary}` : "Response"} + {ctx.completionSummary ? `Response • ${ctx.completionSummary}` : "Response"}
@@ -467,76 +387,29 @@ export const MessagesTimeline = memo(function MessagesTimeline({
- {(() => { - const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); - if (!turnSummary) return null; - const checkpointFiles = turnSummary.files; - if (checkpointFiles.length === 0) return null; - const summaryStat = summarizeTurnDiffStats(checkpointFiles); - const changedFileCountLabel = String(checkpointFiles.length); - const allDirectoriesExpanded = - changedFilesExpandedByTurnId[turnSummary.turnId] ?? true; - return ( -
-
-

- Changed files ({changedFileCountLabel}) - {hasNonZeroStat(summaryStat) && ( - <> - - - - )} -

-
- - -
-
- -
- ); - })()} +

- {formatMessageMeta( - row.message.createdAt, - row.message.streaming - ? formatElapsed(row.durationStart, nowIso) - : formatElapsed(row.durationStart, row.message.completedAt), - timestampFormat, + {row.message.streaming ? ( + + ) : ( + formatMessageMeta( + row.message.createdAt, + formatElapsed(row.durationStart, row.message.completedAt), + ctx.timestampFormat, + ) )}

{assistantCopyState.visible ? ( @@ -559,9 +432,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
)} @@ -575,100 +448,205 @@ export const MessagesTimeline = memo(function MessagesTimeline({ - {row.createdAt - ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` - : "Working..."} + {row.createdAt ? ( + <> + Working for + + ) : ( + "Working..." + )}
)} ); +} - if (!hasMessages && !isWorking) { - return ( -
-

- Send a message to start the conversation. -

-
- ); - } +// --------------------------------------------------------------------------- +// Self-ticking components — bypass LegendList memoisation entirely. +// Each owns a `nowMs` state value consumed in the render output so the +// React Compiler cannot elide the re-render as a no-op. +// --------------------------------------------------------------------------- + +/** Live "Working for Xs" label. */ +function WorkingTimer({ createdAt }: { createdAt: string }) { + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNowMs(Date.now()), 1000); + return () => clearInterval(id); + }, [createdAt]); + return <>{formatWorkingTimer(createdAt, new Date(nowMs).toISOString()) ?? "0s"}; +} + +/** Live timestamp + elapsed duration for a streaming assistant message. */ +function LiveMessageMeta({ + createdAt, + durationStart, + timestampFormat, +}: { + createdAt: string; + durationStart: string | null | undefined; + timestampFormat: TimestampFormat; +}) { + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNowMs(Date.now()), 1000); + return () => clearInterval(id); + }, [durationStart]); + const elapsed = durationStart + ? formatElapsed(durationStart, new Date(nowMs).toISOString()) + : null; + return <>{formatMessageMeta(createdAt, elapsed, timestampFormat)}; +} + +// --------------------------------------------------------------------------- +// Extracted row sections — own their state / store subscriptions so changes +// re-render only the affected row, not the entire list. +// --------------------------------------------------------------------------- + +/** Owns its own expand/collapse state so toggling re-renders only this row. + * State resets on unmount which is fine — work groups start collapsed. */ +const WorkGroupSection = memo(function WorkGroupSection({ + groupedEntries, +}: { + groupedEntries: Extract["groupedEntries"]; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const visibleEntries = + hasOverflow && !isExpanded + ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) + : groupedEntries; + const hiddenCount = groupedEntries.length - visibleEntries.length; + const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); + const showHeader = hasOverflow || !onlyToolEntries; + const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; return ( -
- {virtualizedRowCount > 0 && ( -
- {virtualRows.map((virtualRow: VirtualItem) => { - const row = rows[virtualRow.index]; - if (!row) return null; - - return ( -
- {renderRowContent(row)} -
- ); - })} +
+ {showHeader && ( +
+

+ {groupLabel} ({groupedEntries.length}) +

+ {hasOverflow && ( + + )}
)} - - {nonVirtualizedRows.map((row) => ( -
{renderRowContent(row)}
- ))} +
+ {visibleEntries.map((workEntry) => ( + + ))} +
); }); -type TimelineEntry = ReturnType[number]; -type TimelineMessage = Extract["message"]; -type TimelineWorkEntry = Extract["groupedEntries"][number]; -type TimelineRow = MessagesTimelineRow; - -function formatWorkingTimer(startIso: string, endIso: string): string | null { - const startedAtMs = Date.parse(startIso); - const endedAtMs = Date.parse(endIso); - if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { - return null; - } - - const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); - if (elapsedSeconds < 60) { - return `${elapsedSeconds}s`; - } +/** Subscribes directly to the UI state store for expand/collapse state, + * so toggling re-renders only this component — not the entire list. */ +const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection({ + turnSummary, + routeThreadKey, + resolvedTheme, + onOpenTurnDiff, +}: { + turnSummary: TurnDiffSummary | undefined; + routeThreadKey: string; + resolvedTheme: "light" | "dark"; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; +}) { + if (!turnSummary) return null; + const checkpointFiles = turnSummary.files; + if (checkpointFiles.length === 0) return null; - const hours = Math.floor(elapsedSeconds / 3600); - const minutes = Math.floor((elapsedSeconds % 3600) / 60); - const seconds = elapsedSeconds % 60; + return ( + + ); +}); - if (hours > 0) { - return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; - } +/** Inner component that only mounts when there are actual changed files, + * so the store subscription is unconditional (no hooks after early return). */ +function AssistantChangedFilesSectionInner({ + turnSummary, + checkpointFiles, + routeThreadKey, + resolvedTheme, + onOpenTurnDiff, +}: { + turnSummary: TurnDiffSummary; + checkpointFiles: TurnDiffSummary["files"]; + routeThreadKey: string; + resolvedTheme: "light" | "dark"; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; +}) { + const allDirectoriesExpanded = useUiStateStore( + (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true, + ); + const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); + const summaryStat = summarizeTurnDiffStats(checkpointFiles); + const changedFileCountLabel = String(checkpointFiles.length); - return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + return ( +
+
+

+ Changed files ({changedFileCountLabel}) + {hasNonZeroStat(summaryStat) && ( + <> + + + + )} +

+
+ + +
+
+ +
+ ); } -function formatMessageMeta( - createdAt: string, - duration: string | null, - timestampFormat: TimestampFormat, -): string { - if (!duration) return formatTimestamp(createdAt, timestampFormat); - return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; -} +// --------------------------------------------------------------------------- +// Leaf components +// --------------------------------------------------------------------------- const UserMessageTerminalContextInlineLabel = memo( function UserMessageTerminalContextInlineLabel(props: { context: ParsedTerminalContextEntry }) { @@ -774,6 +752,62 @@ const UserMessageBody = memo(function UserMessageBody(props: { ); }); +// --------------------------------------------------------------------------- +// Structural sharing — reuse old row references when data hasn't changed +// so LegendList (and React) can skip re-rendering unchanged items. +// --------------------------------------------------------------------------- + +/** Returns a structurally-shared copy of `rows`: for each row whose content + * hasn't changed since last call, the previous object reference is reused. */ +function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { + const prevState = useRef({ + byId: new Map(), + result: [], + }); + + return useMemo(() => { + const nextState = computeStableMessagesTimelineRows(rows, prevState.current); + prevState.current = nextState; + return nextState.result; + }, [rows]); +} + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +function formatWorkingTimer(startIso: string, endIso: string): string | null { + const startedAtMs = Date.parse(startIso); + const endedAtMs = Date.parse(endIso); + if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { + return null; + } + + const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); + if (elapsedSeconds < 60) { + return `${elapsedSeconds}s`; + } + + const hours = Math.floor(elapsedSeconds / 3600); + const minutes = Math.floor((elapsedSeconds % 3600) / 60); + const seconds = elapsedSeconds % 60; + + if (hours > 0) { + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } + + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; +} + +function formatMessageMeta( + createdAt: string, + duration: string | null, + timestampFormat: TimestampFormat, +): string { + if (!duration) return formatTimestamp(createdAt, timestampFormat); + return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; +} + function workToneIcon(tone: TimelineWorkEntry["tone"]): { icon: LucideIcon; className: string; 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 be3cf5c67a..0000000000 --- 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/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts deleted file mode 100644 index 5317a5fc68..0000000000 --- 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 3cb2aebb88..0000000000 --- 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; -} diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 8348d4c2fc..52dd996ec5 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 0c95792b69..2322e31267 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=="],