diff --git a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx index 3caa8c6961..ea4ea55f02 100644 --- a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx @@ -1,7 +1,7 @@ import { useFocusEffect, useRouter } from 'expo-router'; import * as Haptics from 'expo-haptics'; import { Check, Info, Lock, Search, SearchX, Unlock } from '@/components/ui/icons'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useDeferredValue, useMemo, useRef, useState } from 'react'; import { FlatList, Pressable, TextInput, View } from 'react-native'; import { useTranslation } from 'react-i18next'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -24,6 +24,9 @@ export default function RepoPickerScreen() { const { bottom } = useSafeAreaInsets(); const { t } = useTranslation(); const [search, setSearch] = useState(''); + // The input stays urgent; the filtered list trails behind so a typing burst + // on a large repository list does not filter/reconcile on every key. + const deferredSearch = useDeferredValue(search); const [bridge, setBridge] = useState(() => repoPickerSlot.get(UNFENCED_ROUTE_KEY)); const bridgeRef = useRef(bridge); @@ -48,15 +51,16 @@ export default function RepoPickerScreen() { ); const filtered = useMemo( - () => filterRepoPickerOptions({ repositories: bridge?.repositories ?? [], search }), - [bridge, search] + () => + filterRepoPickerOptions({ repositories: bridge?.repositories ?? [], search: deferredSearch }), + [bridge, deferredSearch] ); // When the search box is empty, render grouped sections (Recently used, then // per-provider); when it is non-empty, render the flat filtered list exactly // as before. const listItems = useMemo(() => { - if (search.trim()) { + if (deferredSearch.trim()) { return filtered.map(repo => ({ key: `${repo.platform}:${repo.fullName}`, kind: 'repo', @@ -74,7 +78,7 @@ export default function RepoPickerScreen() { } } return items; - }, [bridge, filtered, search]); + }, [bridge, filtered, deferredSearch]); const handleSelect = useCallback( (repo: string) => { @@ -123,14 +127,14 @@ export default function RepoPickerScreen() { > {listItems.length === 0 ? ( ({ values: [] as string[] })); + +// The data length of every FlatList commit, in order. A keystroke burst must +// commit the held (pre-typing) list and then the settled list — never an +// intermediate query's rows. +const listCommitLengths = vi.hoisted(() => ({ values: [] as number[] })); + +// A stable favorites array (its identity is part of the rows memo's deps) so +// the derivation only runs when the deferred query actually changes. +const preferences = vi.hoisted(() => ({ favorites: [] as string[] })); + +const slotState = vi.hoisted(() => ({ bridge: undefined as unknown })); + +const routerBack = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/model-picker-rows', async importOriginal => { + const actual = (await importOriginal()) as Record; + const build = actual.buildModelPickerRows as (input: { + models: SessionModelOption[]; + search: string; + favoriteIds: Set; + }) => ModelPickerRow[]; + return { + ...actual, + buildModelPickerRows: (input: Parameters[0]) => { + buildSearchCalls.values.push(input.search); + return build(input); + }, + }; +}); + +vi.mock('@/lib/route-registry', () => ({ + modelPickerSlot: { + get: () => slotState.bridge, + clear: vi.fn(), + }, + UNFENCED_ROUTE_KEY: 'unscoped', + useRouteRegistry: vi.fn(), +})); + +vi.mock('@/lib/hooks/use-model-preferences', () => ({ + useModelPreferences: () => ({ + favorites: preferences.favorites, + favoritesError: null, + addFavorite: vi.fn(), + removeFavorite: vi.fn(), + }), +})); + +// FlatList renders through a callback; this mock mirrors the real list (rows +// via renderItem, key via keyExtractor) so row assertions see real content, +// and records each commit's data length. +const flatListMock = vi.hoisted( + () => + (props: { + data: readonly ModelPickerRow[]; + renderItem?: (info: { item: ModelPickerRow; index: number }) => ReactNode; + keyExtractor?: (item: ModelPickerRow) => string; + }) => { + listCommitLengths.values.push(props.data.length); + const rows = props.data.map((item, index) => + createElement( + Fragment, + { key: props.keyExtractor ? props.keyExtractor(item) : String(index) }, + props.renderItem ? props.renderItem({ item, index }) : null + ) + ); + return createElement('FlatList', { data: props.data }, ...rows); + } +); + +vi.mock('react-native', () => ({ + FlatList: flatListMock, + TextInput: 'TextInput', + View: 'View', +})); +vi.mock('expo-router', () => ({ + useRouter: () => ({ back: routerBack, push: vi.fn() }), + useLocalSearchParams: () => ({}), + useFocusEffect: vi.fn(), +})); +vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn() })); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); +vi.mock('@/components/picker-sheet', () => ({ + PickerSheet: (props: { children?: ReactNode; headerContent?: ReactNode }) => + createElement('PickerSheet', null, props.headerContent, props.children), +})); +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/icons', () => ({ + AlertCircle: 'AlertCircle', + Info: 'Info', + Search: 'Search', + SearchX: 'SearchX', +})); +vi.mock('@/components/agents/model-selector', () => ({ + ModelPickerOptionRow: 'ModelPickerOptionRow', +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ + mutedForeground: '#6b7280', + foreground: '#111827', + destructive: '#ef4444', + }), +})); + +const TOTAL_OPTIONS = 300; +const FINAL_QUERY = 'model-17'; +// `model-17` is a substring of `model-17` and `model-170`..`model-179`. +const FINAL_MATCH_COUNT = 11; + +function makeOption(index: number): SessionModelOption { + return { + id: `remote-model-${index}`, + name: `Model ${index}`, + displayId: `model-${index}`, + variants: [], + isPreferred: false, + provider: { id: 'test', name: 'Test Provider' }, + showGatewayMetadata: false, + }; +} + +function makeBridge(): ModelPickerBridge { + return { + options: Array.from({ length: TOTAL_OPTIONS }, (_, index) => makeOption(index)), + currentValue: '', + currentVariant: '', + selectionScope: { + sessionId: 'unscoped', + ownerConnectionId: null, + protocol: 'unknown', + catalogGenerationIdentity: null, + }, + isSelectionCurrent: () => true, + onSelect: vi.fn<() => void>(), + }; +} + +function findByType( + root: TestRenderer.ReactTestInstance, + type: string +): TestRenderer.ReactTestInstance[] { + return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type); +} + +function listRows(renderer: TestRenderer.ReactTestRenderer): ModelPickerRow[] { + const list = findByType(renderer.root, 'FlatList')[0]; + if (!list) { + throw new Error('FlatList not found'); + } + /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */ + return list.props.data as ModelPickerRow[]; + /* eslint-enable typescript-eslint/no-unsafe-member-access */ +} + +function listedDisplayIds(renderer: TestRenderer.ReactTestRenderer): string[] { + return listRows(renderer) + .filter((row): row is Extract => row.type === 'model') + .map(row => row.model.displayId); +} + +function searchInput(renderer: TestRenderer.ReactTestRenderer): TestRenderer.ReactTestInstance { + const input = findByType(renderer.root, 'TextInput')[0]; + if (!input) { + throw new Error('search input not found'); + } + return input; +} + +/** Drive the uncontrolled TextInput's handler the way a keystroke would. */ +function typeSearch(renderer: TestRenderer.ReactTestRenderer, next: string): void { + const { onChangeText } = searchInput(renderer).props as { + onChangeText: (text: string) => void; + }; + onChangeText(next); +} + +async function mount(): Promise { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + await act(async () => { + ref.current = TestRenderer.create(createElement(ModelPickerContent)); + await Promise.resolve(); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('model picker did not render'); + } + return renderer; +} + +describe('ModelPickerContent deferred search', () => { + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + buildSearchCalls.values.length = 0; + listCommitLengths.values.length = 0; + slotState.bridge = makeBridge(); + routerBack.mockClear(); + }); + + it('holds the rows while typing and commits only the settled query', async () => { + const renderer = await mount(); + + // The catalog starts fully listed: the empty query matches every option. + expect(listedDisplayIds(renderer)).toHaveLength(TOTAL_OPTIONS); + + const commitsBefore = listCommitLengths.values.length; + const callsBefore = buildSearchCalls.values.length; + const heldRowCount = listRows(renderer).length; + + // One burst: several urgent keystrokes before React runs the deferred + // render. Yielding between them lets React commit the urgent input render + // while the list update stays pending, which is what a fast typist does. + await act(async () => { + typeSearch(renderer, 'm'); + await Promise.resolve(); + typeSearch(renderer, 'mo'); + await Promise.resolve(); + typeSearch(renderer, 'mod'); + await Promise.resolve(); + typeSearch(renderer, 'model-1'); + await Promise.resolve(); + typeSearch(renderer, FINAL_QUERY); + await Promise.resolve(); + }); + + // (a) Once the deferred value flushes, the rows are the final query's. + const displayIds = listedDisplayIds(renderer); + expect(displayIds).toHaveLength(FINAL_MATCH_COUNT); + expect(displayIds.every(id => id.includes(FINAL_QUERY))).toBe(true); + const settledRowCount = listRows(renderer).length; + + // (b) The urgent keystrokes did not commit filtered rows: the first list + // commit of the burst still held the pre-typing catalog, and the only + // other commit is the settled query. No superseded query's rows appear. + const burstCommits = listCommitLengths.values.slice(commitsBefore); + expect(burstCommits[0]).toBe(heldRowCount); + expect(burstCommits.at(-1)).toBe(settledRowCount); + expect( + burstCommits.filter(length => length !== heldRowCount && length !== settledRowCount) + ).toEqual([]); + + // The list derivation never saw a superseded keystroke: the memoized rows + // were reused while the deferred query was still empty. + const burstCalls = buildSearchCalls.values.slice(callsBefore); + expect(burstCalls).not.toContain('m'); + expect(burstCalls).not.toContain('mo'); + expect(burstCalls).not.toContain('mod'); + expect(burstCalls).not.toContain('model-1'); + expect(burstCalls.at(-1)).toBe(FINAL_QUERY); + + act(() => { + renderer.unmount(); + }); + }); + + it('shows the existing no-matches empty state for a non-matching query', async () => { + const renderer = await mount(); + + await act(async () => { + typeSearch(renderer, 'no-such-model-xyz'); + await Promise.resolve(); + }); + + expect(findByType(renderer.root, 'FlatList')).toHaveLength(0); + const emptyState = findByType(renderer.root, 'EmptyState'); + expect(emptyState).toHaveLength(1); + /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */ + expect(emptyState[0]?.props.title).toBe('No matches'); + /* eslint-enable typescript-eslint/no-unsafe-member-access */ + + act(() => { + renderer.unmount(); + }); + }); +}); diff --git a/apps/mobile/src/components/agents/model-picker-content.tsx b/apps/mobile/src/components/agents/model-picker-content.tsx index 4ebd843f69..a90e6e9a27 100644 --- a/apps/mobile/src/components/agents/model-picker-content.tsx +++ b/apps/mobile/src/components/agents/model-picker-content.tsx @@ -1,7 +1,7 @@ import * as Haptics from 'expo-haptics'; import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { AlertCircle, Info, Search, SearchX } from '@/components/ui/icons'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useDeferredValue, useMemo, useRef, useState } from 'react'; import { FlatList, TextInput, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; @@ -33,6 +33,9 @@ export function ModelPickerContent() { const routeKey = parseParam(rawRouteKey) ?? UNFENCED_ROUTE_KEY; useRouteRegistry(routeKey); const [search, setSearch] = useState(''); + // The input stays urgent; the list derivation and its rows trail behind so a + // typing burst on a large catalog does not filter/reconcile on every key. + const deferredSearch = useDeferredValue(search); const [bridge, setBridge] = useState(() => modelPickerSlot.get(routeKey)); const [selectedModel, setSelectedModel] = useState(bridge?.currentValue ?? ''); const [selectedVariant, setSelectedVariant] = useState(bridge?.currentVariant ?? ''); @@ -82,8 +85,9 @@ export function ModelPickerContent() { ); const rows = useMemo( - () => buildModelPickerRows({ models: bridge?.options ?? [], search, favoriteIds }), - [bridge, search, favoriteIds] + () => + buildModelPickerRows({ models: bridge?.options ?? [], search: deferredSearch, favoriteIds }), + [bridge, deferredSearch, favoriteIds] ); // The favorite star button in ModelPickerOptionRow already fires its own @@ -185,12 +189,14 @@ export function ModelPickerContent() { > {rows.length === 0 ? (