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",