From 5dc350034e803732d9406506bbd2970039205b3f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:05:12 +0000 Subject: [PATCH] Keep large Import Session lists responsive on Web (#7872) * Virtualize long Import Session Web lists while preserving native and short-list scrolling * fix(app): measure import list headers through the existing layout event Keep the virtualized import list usable in the existing component harness without introducing an observer mock. Import React for the unit JSX transform. Co-authored-by: royenheart --------- Co-authored-by: royenheart Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../e2e/browser/import-session-flow.spec.ts | 137 ++++++++ .../app/e2e/support/helpers/import-session.ts | 34 +- .../src/components/import-session-list.tsx | 4 + .../components/import-session-list.types.ts | 13 + .../components/import-session-list.web.tsx | 141 ++++++++ .../src/components/import-session-sheet.tsx | 320 +++++++++++++----- 6 files changed, 555 insertions(+), 94 deletions(-) create mode 100644 packages/app/src/components/import-session-list.tsx create mode 100644 packages/app/src/components/import-session-list.types.ts create mode 100644 packages/app/src/components/import-session-list.web.tsx diff --git a/packages/app/e2e/browser/import-session-flow.spec.ts b/packages/app/e2e/browser/import-session-flow.spec.ts index c6d34f38f2..fe2f57c67a 100644 --- a/packages/app/e2e/browser/import-session-flow.spec.ts +++ b/packages/app/e2e/browser/import-session-flow.spec.ts @@ -229,6 +229,143 @@ test("captures the desktop import sheet and command-center entry", async ({ page }); }); +test("large import lists keep mounted rows bounded and remain scrollable", async ({ + page, +}, testInfo) => { + const fixtureDirectory = path.join(claudeConfigDirectory, "projects", "virtualization-fixture"); + await mkdir(fixtureDirectory, { recursive: true }); + try { + const newest = Date.now(); + for (let index = 0; index < 200; index += 1) { + const id = `virtualized-${String(index).padStart(3, "0")}`; + const file = path.join(fixtureDirectory, `${id}.jsonl`); + await writeFile( + file, + `${JSON.stringify({ + type: "user", + sessionId: id, + cwd: index % 2 === 0 ? scenario.projectRoot : scenario.worktreeDirectory, + message: { + role: "user", + content: `Virtualized session ${index}${index % 3 === 0 ? " with a longer preview that wraps onto a second line in a compact sheet" : ""}`, + }, + })}\n`, + ); + const timestamp = new Date(newest - index * 10); + await utimes(file, timestamp, timestamp); + } + for (const width of [1280, 390]) { + const flow = new ImportSessionFlow(page); + await flow.openWorkspace(scenario.project.workspaceId, { width, height: 844 }); + await flow.openGlobally(); + const rows = page.locator('[data-testid^="import-session-session-"]'); + const scrollToEnd = async (end = true) => { + // Scroll the body's real overflow element. Virtual rows need not exist yet. + await rows.first().evaluate((row, atEnd) => { + let element = row.parentElement; + while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY)) { + element = element.parentElement; + } + if (!element) throw new Error("Import list has no scroll owner"); + element.scrollTop = atEnd ? element.scrollHeight : 0; + element.dispatchEvent(new Event("scroll")); + }, end); + }; + for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) { + const loadMore = page.getByTestId("import-session-load-more"); + await expect(async () => { + await scrollToEnd(); + await expect(loadMore).toBeInViewport({ timeout: 500 }); + }).toPass({ timeout: 10_000 }); + await loadMore.click(); + if (pageIndex < 2) await expect(loadMore).toBeEnabled(); + } + await expect(async () => { + await scrollToEnd(); + await expect( + page.getByTestId("import-session-session-claude-virtualized-199"), + ).toBeInViewport({ timeout: 500 }); + }).toPass({ timeout: 10_000 }); + await expect.poll(() => rows.count()).toBeLessThan(100); + await page.screenshot({ path: testInfo.outputPath(`virtualized-${width}.png`) }); + if (width === 1280) { + const row = (index: number) => + page.getByTestId( + `import-session-session-claude-virtualized-${String(index).padStart(3, "0")}`, + ); + await expect(async () => { + await scrollToEnd(false); + await expect(row(0)).toBeInViewport({ timeout: 500 }); + }).toPass({ timeout: 10_000 }); + await row(0).focus(); + for (let index = 1; index <= 30; index += 1) { + await page.keyboard.press("Tab"); + await expect(row(index)).toBeFocused(); + } + for (let index = 29; index >= 15; index -= 1) { + await page.keyboard.press("Shift+Tab"); + await expect(row(index)).toBeFocused(); + } + const list = page.getByTestId("import-session-list"); + const scrollWithKey = async (event: "scroll" | "scrollend", key: string) => { + const listener = await list.evaluateHandle((node, eventName) => { + const promise = new Promise((resolve) => { + node.addEventListener(eventName, () => resolve(), { once: true }); + }); + return { promise }; + }, event); + try { + await page.keyboard.press(key); + await listener.evaluate((value) => value.promise); + } finally { + await listener.dispose(); + } + }; + const beforePageDown = await list.evaluate((node) => node.scrollTop); + await scrollWithKey("scroll", "PageDown"); + await expect + .poll(() => list.evaluate((node) => node.scrollTop)) + .toBeGreaterThan(beforePageDown); + await expect(row(15)).toBeFocused(); + await page.keyboard.press("End"); + await expect(row(15)).toBeFocused(); + await expect(row(199)).toBeInViewport(); + // Home must remain at the first row after the native page scroll ends. + await scrollWithKey("scrollend", "Home"); + await expect(row(0)).toBeInViewport(); + await expect(row(15)).toBeFocused(); + + await page.keyboard.press("End"); + await expect(row(199)).toBeInViewport(); + await row(199).focus(); + await scrollWithKey("scroll", "PageUp"); + await page.keyboard.press("Home"); + await expect(row(0)).toBeInViewport(); + await scrollWithKey("scrollend", "End"); + await expect(row(199)).toBeInViewport(); + await expect(row(199)).toBeFocused(); + await testInfo.attach("keyboard-scroll-owner", { + body: JSON.stringify( + await list.evaluate((node) => ({ + focusedRow: document.activeElement?.getAttribute("data-testid"), + scrollTop: node.scrollTop, + scrollHeight: node.scrollHeight, + clientHeight: node.clientHeight, + })), + ), + contentType: "application/json", + }); + await page.screenshot({ path: testInfo.outputPath("virtualized-keyboard-end.png") }); + await page.keyboard.press("Home"); + await expect(row(0)).toBeInViewport(); + } + await flow.close(); + } + } finally { + await rm(fixtureDirectory, { recursive: true, force: true }); + } +}); + async function capture(page: Page, testInfo: TestInfo, name: string): Promise { const outputPath = testInfo.outputPath(name); await page.screenshot({ path: outputPath, fullPage: true }); diff --git a/packages/app/e2e/support/helpers/import-session.ts b/packages/app/e2e/support/helpers/import-session.ts index 718b0d5965..9caabc5edd 100644 --- a/packages/app/e2e/support/helpers/import-session.ts +++ b/packages/app/e2e/support/helpers/import-session.ts @@ -27,7 +27,7 @@ export class ImportSessionFlow { await expectMobileAgentSidebarVisible(this.page); } await openGlobalNewWorkspaceComposer(this.page); - const button = this.page.getByTestId("new-workspace-import-session"); + const button = this.page.getByTestId("new-workspace-import-session").filter({ visible: true }); await expect(button).toHaveAccessibleName("Import session"); await expect(button).toBeInViewport(); } @@ -36,7 +36,7 @@ export class ImportSessionFlow { await this.openFromNewWorkspace(); } async openFromNewWorkspace() { - await this.page.getByTestId("new-workspace-import-session").click(); + await this.page.getByTestId("new-workspace-import-session").filter({ visible: true }).click(); await this.expectSheetReady(); } async openFromWorkspaceHeader() { @@ -86,7 +86,29 @@ export class ImportSessionFlow { expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width); } async revealSession(id: string) { - await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded(); + const target = this.page.getByTestId(rowTestId(id)); + if ((await target.count()) === 0) { + await this.scrollList(false); + await expect(async () => { + if ((await target.count()) === 0) await this.scrollList(true); + await expect(target).toHaveCount(1, { timeout: 100 }); + }).toPass({ timeout: 10_000 }); + } + await target.scrollIntoViewIfNeeded(); + await expect(target).toBeInViewport(); + } + private async scrollList(forward: boolean) { + await this.page + .locator('[data-testid^="import-session-session-"]') + .first() + .evaluate((row, advance) => { + let element = row.parentElement; + while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY)) + element = element.parentElement; + if (!element) throw new Error("Import list has no scroll owner"); + element.scrollTop = advance ? element.scrollTop + element.clientHeight * 0.8 : 0; + element.dispatchEvent(new Event("scroll")); + }, forward); } async search(query: string) { await this.page.getByTestId("import-session-search").fill(query); @@ -100,7 +122,8 @@ export class ImportSessionFlow { } async loadMore() { await this.page.getByTestId("import-session-load-more").click(); - await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible(); + await this.revealSession("fixture-root-20"); + await expect(this.page.getByText("Root session 20", { exact: true })).toBeInViewport(); await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0); } async retryProvider(provider: string, label: string) { @@ -111,6 +134,7 @@ export class ImportSessionFlow { await expect(this.page.getByRole("progressbar")).toHaveCount(0); } async importSession(id: string) { + await this.revealSession(id); await this.page.getByTestId(rowTestId(id)).click(); await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, { timeout: 30_000, @@ -138,7 +162,7 @@ export class ImportSessionFlow { ); } async close() { - await this.page.keyboard.press("Escape"); + await this.page.getByRole("button", { name: "Close", exact: true }).last().click(); await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0); } async expectCommandCenterMatch() { diff --git a/packages/app/src/components/import-session-list.tsx b/packages/app/src/components/import-session-list.tsx new file mode 100644 index 0000000000..d2ff037651 --- /dev/null +++ b/packages/app/src/components/import-session-list.tsx @@ -0,0 +1,4 @@ +import type { ImportSessionListComponent } from "./import-session-list.types"; + +// Native keeps the sheet-owned ScrollView until its gestures and keyboard are verified. +export const ImportSessionList: ImportSessionListComponent | null = null; diff --git a/packages/app/src/components/import-session-list.types.ts b/packages/app/src/components/import-session-list.types.ts new file mode 100644 index 0000000000..e62b31bccb --- /dev/null +++ b/packages/app/src/components/import-session-list.types.ts @@ -0,0 +1,13 @@ +import type { ReactElement, ReactNode } from "react"; + +export interface ImportSessionListProps { + data: ReadonlyArray; + keyExtractor: (item: Item) => string; + renderItem: (item: Item) => ReactNode; + header: ReactElement | null; + footer: ReactElement | null; +} + +export type ImportSessionListComponent = ( + props: ImportSessionListProps, +) => ReactElement; diff --git a/packages/app/src/components/import-session-list.web.tsx b/packages/app/src/components/import-session-list.web.tsx new file mode 100644 index 0000000000..aa8f74cd7c --- /dev/null +++ b/packages/app/src/components/import-session-list.web.tsx @@ -0,0 +1,141 @@ +import React, { + useCallback, + useMemo, + useRef, + useState, + type CSSProperties, + type FocusEvent, + type KeyboardEvent, +} from "react"; +import { View, type LayoutChangeEvent } from "react-native"; +import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/react-virtual"; +import type { ImportSessionListProps } from "./import-session-list.types"; + +const SCROLLER_STYLE: CSSProperties = { + flex: 1, + minHeight: 0, + overflowY: "auto", + overflowX: "hidden", + overscrollBehavior: "contain", +}; +const CONTENT_STYLE: CSSProperties = { width: "100%", position: "relative" }; + +export function ImportSessionList({ + data, + keyExtractor, + renderItem, + header, + footer, +}: ImportSessionListProps) { + const scrollRef = useRef(null); + const [headerHeight, setHeaderHeight] = useState(0); + const [focusedKey, setFocusedKey] = useState(null); + const indexByKey = useMemo( + () => new Map(data.map((item, index) => [keyExtractor(item), index])), + [data, keyExtractor], + ); + const focusedIndex = focusedKey === null ? undefined : indexByKey.get(focusedKey); + const rangeExtractor = useCallback( + (range: Range) => { + const visible = defaultRangeExtractor(range); + if (focusedIndex === undefined) return visible; + // Keep focus and its Tab neighbours mounted when PageDown/End moves the window. + const focused = [focusedIndex - 1, focusedIndex, focusedIndex + 1].filter( + (index) => index >= 0 && index < data.length, + ); + return [...new Set([...visible, ...focused])].sort((left, right) => left - right); + }, + [data.length, focusedIndex], + ); + const handleFocus = useCallback((event: FocusEvent) => { + const row = event.target.closest("[data-session-key]"); + setFocusedKey(row?.dataset.sessionKey ?? null); + }, []); + const handleBlur = useCallback((event: FocusEvent) => { + if (!event.currentTarget.contains(event.relatedTarget)) setFocusedKey(null); + }, []); + const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => { + setHeaderHeight(event.nativeEvent.layout.height); + }, []); + const getScrollElement = useCallback(() => scrollRef.current, []); + const getItemKey = useCallback( + (index: number) => keyExtractor(data[index]!), + [data, keyExtractor], + ); + const estimateSize = useCallback(() => 88, []); + const virtualizer = useVirtualizer({ + count: data.length, + getScrollElement, + getItemKey, + estimateSize, + scrollMargin: headerHeight, + overscan: 5, + rangeExtractor, + }); + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + const target = event.target; + if ( + event.defaultPrevented || + !(target instanceof HTMLElement) || + !event.currentTarget.contains(target) || + target.isContentEditable || + target.closest("input, textarea, select") + ) + return; + if (event.key === "End") { + event.preventDefault(); + // Reconcile the landing position as variable-height rows are measured. + virtualizer.scrollToIndex(data.length - 1, { align: "end" }); + } else if (event.key === "Home") { + event.preventDefault(); + virtualizer.scrollToOffset(0); + } else if (event.key === "PageDown" || event.key === "PageUp") { + event.preventDefault(); + // Native page-scroll animations can keep moving after Home or End. + const direction = event.key === "PageDown" ? 1 : -1; + virtualizer.scrollBy(direction * event.currentTarget.clientHeight); + } + }, + [data.length, virtualizer], + ); + const virtualRows = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + const contentStyle = useMemo(() => ({ ...CONTENT_STYLE, height: totalSize }), [totalSize]); + const rowStyle = useCallback( + (offset: number): CSSProperties => ({ + ...CONTENT_STYLE, + position: "absolute", + top: 0, + transform: `translateY(${offset}px)`, + }), + [], + ); + + return ( +
+ {header} +
+ {virtualRows.map((row) => ( +
+ {renderItem(data[row.index]!)} +
+ ))} +
+
{footer}
+
+ ); +} diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index 3be15c0892..21296e54e0 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Pressable, type PressableStateCallbackType, Text, View } from "react-native"; import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; @@ -11,6 +11,7 @@ import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; +import { ImportSessionList } from "@/components/import-session-list"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { useProviderIcon, useProviderIcons } from "@/components/provider-icons"; @@ -371,7 +372,33 @@ function ImportSessionActivityTime({ date }: { date: string }) { return {label}; } +function getSessionRowKey(entry: FetchRecentProviderSessionEntry): string { + return `${entry.providerId}:${entry.providerHandleId}`; +} + +function hasListHeader( + input: SheetStatusMessagesProps & { + showFilter: boolean; + hasProviderErrors: boolean; + }, +): boolean { + return ( + input.showFilter || + !input.isClientReady || + input.isSnapshotUnsupported || + input.hasNoImportableProviders || + (input.isLoadingSessions && !input.hasRows) || + input.importErrored || + input.hasProviderErrors + ); +} + function SessionRows({ + visible, + onClose, + header, + listHeader, + listFooter, serverId, entries, disabled, @@ -379,6 +406,11 @@ function SessionRows({ resolveFolder, onImportSession, }: { + visible: boolean; + onClose: () => void; + header: SheetHeader; + listHeader: ReactNode; + listFooter: ReactNode; serverId: string | null; entries: ReadonlyArray; disabled: boolean; @@ -386,20 +418,69 @@ function SessionRows({ resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null; onImportSession: (entry: FetchRecentProviderSessionEntry) => void; }) { + // Keep short results content-sized; only long lists need a bounded viewport. + const virtualized = ImportSessionList !== null && entries.length > 8; + const renderSessionRow = useCallback( + (entry: FetchRecentProviderSessionEntry) => ( + + ), + [serverId, disabled, importingSessionKey, resolveFolder, onImportSession], + ); + const renderItem = useCallback( + (item: FetchRecentProviderSessionEntry) => ( + {renderSessionRow(item)} + ), + [renderSessionRow], + ); + const virtualHeader = useMemo( + () => (listHeader ? {listHeader} : null), + [listHeader], + ); + const virtualFooter = useMemo( + () => {listFooter}, + [listFooter], + ); + return ( - - {entries.map((entry) => ( - - ))} - + + {ImportSessionList && virtualized ? ( + + + + ) : ( + <> + {listHeader} + {entries.length > 0 ? ( + {entries.map(renderSessionRow)} + ) : null} + {listFooter} + + )} + ); } @@ -726,85 +807,124 @@ export function ImportSessionSheet({ }); const showFilter = filterProviders.length > 1; const showLoadMore = hasMoreSessions(queries, pageLimit); - + const showListHeader = hasListHeader({ + showFilter, + isClientReady: Boolean(client), + isSnapshotUnsupported, + hasNoImportableProviders, + isLoadingSessions, + hasRows: visibleEntries.length > 0, + importErrored: importMutation.isError, + hasProviderErrors: providerErrorRows.length > 0, + }); + const listHeader = useMemo( + () => + showListHeader ? ( + + {showFilter ? ( + + + {selectedProvider === ALL_FILTER_VALUE ? ( + + ) : ( + + )} + + {selectedProviderLabel} + + + + + + ) : null} + 0} + importErrored={importMutation.isError} + /> + {providerErrorRows.length > 0 ? ( + + ) : null} + + ) : null, + [ + showListHeader, + showFilter, + filterTriggerStyle, + handleFilterOpen, + selectedProvider, + theme.colors.foregroundMuted, + SelectedProviderIcon, + selectedProviderLabel, + filterComboboxOptions, + handleFilterSelect, + renderFilterOption, + isFilterOpen, + client, + isSnapshotUnsupported, + hasNoImportableProviders, + isLoadingSessions, + visibleEntries.length, + importMutation.isError, + providerErrorRows, + handleRetryProvider, + ], + ); + const listFooter = useMemo( + () => ( + <> + {showLoadMore ? ( + + + + ) : null} + {showEmptyState ? : null} + + ), + [showLoadMore, handleLoadMore, isRefreshing, t, showEmptyState, emptyStateTitle], + ); return ( - - {showFilter ? ( - - - {selectedProvider === ALL_FILTER_VALUE ? ( - - ) : ( - - )} - - {selectedProviderLabel} - - - - - - ) : null} - 0} - importErrored={importMutation.isError} - /> - {providerErrorRows.length > 0 ? ( - - ) : null} - {visibleEntries.length > 0 ? ( - - ) : null} - {showLoadMore ? ( - - - - ) : null} - {showEmptyState ? : null} - + listHeader={listHeader} + listFooter={listFooter} + serverId={serverId} + entries={visibleEntries} + disabled={importMutation.isPending} + importingSessionKey={importingSessionKey} + resolveFolder={resolveFolder} + onImportSession={handleImportSession} + /> ); } @@ -853,9 +973,31 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, }, + listBody: { + gap: 0, + }, list: { + flex: 1, + minHeight: 0, + marginHorizontal: -theme.spacing[2], + }, + listHeader: { + marginHorizontal: theme.spacing[2], + paddingBottom: theme.spacing[4], + }, + listHeaderContent: { + gap: theme.spacing[4], + }, + smallList: { gap: theme.spacing[1], }, + listFooter: { + marginHorizontal: theme.spacing[2], + }, + listRow: { + marginHorizontal: theme.spacing[2], + paddingBottom: theme.spacing[1], + }, footer: { paddingTop: theme.spacing[2], alignItems: "center",