diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index afb801b36f..42e2880536 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -1,6 +1,13 @@ import { Bot, Plus, Search } from 'lucide-react-native'; import { useCallback, useMemo } from 'react'; -import { Platform, RefreshControl, SectionList, TextInput, View } from 'react-native'; +import { + ActivityIndicator, + Platform, + RefreshControl, + SectionList, + TextInput, + View, +} from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -26,7 +33,9 @@ type AgentSessionListContentProps = { hasAnySessions: boolean; isLoading: boolean; isError: boolean; + isFetchingNextPage: boolean; refetch: () => Promise; + onEndReached: () => void; onSessionPress: (sessionId: string, organizationId?: string | null) => void; onSearchChange: (text: string) => void; onCreateSession: () => void; @@ -38,7 +47,9 @@ export function AgentSessionListContent({ hasAnySessions, isLoading, isError, + isFetchingNextPage, refetch, + onEndReached, onSessionPress, onSearchChange, onCreateSession, @@ -46,7 +57,9 @@ export function AgentSessionListContent({ const colors = useThemeColors(); const { bottom } = useSafeAreaInsets(); const { deleteSession, renameSession } = useSessionMutations(); - const emptyStateContainerStyle = useMemo( + // The tab bar is an absolutely-positioned overlay, so scrollable content + // must clear it or the last rows are stuck underneath it. + const tabBarClearanceStyle = useMemo( () => ({ paddingBottom: getTabBarOverlayHeight(bottom, Platform.OS) }), [bottom] ); @@ -178,7 +191,7 @@ export function AgentSessionListContent({ + + + ) : null + } + contentContainerStyle={tabBarClearanceStyle} contentOffset={{ x: 0, y: SEARCH_BAR_HEIGHT }} keyboardDismissMode="on-drag" + onEndReached={onEndReached} + onEndReachedThreshold={0.5} refreshControl={} /> diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx index eddcf9d88d..626fb720f0 100644 --- a/apps/mobile/src/components/agents/session-list-screen.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.tsx @@ -20,7 +20,11 @@ import { } from '@/components/agents/session-list-helpers'; import { ProfileAvatarButton } from '@/components/profile-avatar-button'; import { ScreenHeader } from '@/components/screen-header'; -import { useAgentSessions, useRecentAgentRepositories } from '@/lib/hooks/use-agent-sessions'; +import { + useAgentSessions, + useAgentSessionSearch, + useRecentAgentRepositories, +} from '@/lib/hooks/use-agent-sessions'; import { usePersistedAgentSessionFilters } from '@/lib/hooks/use-persisted-agent-session-filters'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { useOrganization } from '@/lib/organization-context'; @@ -80,6 +84,9 @@ export function AgentSessionListScreen() { activeSessionIds, isLoading, isError, + hasNextPage, + isFetchingNextPage, + fetchNextPage, refetch, } = useAgentSessions({ createdOnPlatform, @@ -87,6 +94,14 @@ export function AgentSessionListScreen() { organizationId, enabled: ready, }); + const isSearching = searchQuery.length > 0; + const search = useAgentSessionSearch({ + searchQuery, + createdOnPlatform, + gitUrl, + organizationId, + enabled: ready && isSearching, + }); const { data: recentRepositories } = useRecentAgentRepositories({ organizationId, enabled: ready, @@ -158,15 +173,15 @@ export function AgentSessionListScreen() { }); } - for (const group of dateGroups) { - const filteredSessions = searchQuery - ? group.sessions.filter(s => matchesSearch(searchQuery, s.title, s.git_url)) - : group.sessions; - - if (filteredSessions.length > 0) { + // Stored sessions are cursor-paginated, so a client-side filter would only + // see the loaded pages. When a query is active, use the server search + // results (which cover the full history) instead. + const storedGroups = searchQuery ? search.dateGroups : dateGroups; + for (const group of storedGroups) { + if (group.sessions.length > 0) { result.push({ title: group.label, - data: filteredSessions.map( + data: group.sessions.map( (session): StoredSessionItem => ({ kind: 'stored', session, @@ -178,7 +193,15 @@ export function AgentSessionListScreen() { } return result; - }, [activeSessionIds, activeSessions, dateGroups, projectFilter, searchQuery, storedSessions]); + }, [ + activeSessionIds, + activeSessions, + dateGroups, + projectFilter, + search.dateGroups, + searchQuery, + storedSessions, + ]); const navigateToSession = useCallback( (sessionId: string, sessionOrgId?: string | null) => { @@ -190,6 +213,12 @@ export function AgentSessionListScreen() { [router] ); + const handleEndReached = useCallback(() => { + if (!isSearching && hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage, isSearching]); + const hasActiveFilter = platformFilter.length > 0 || projectFilter.length > 0; return ( @@ -244,9 +273,11 @@ export function AgentSessionListScreen() { sections={sections} storedSessions={storedSessions} hasAnySessions={storedSessions.length > 0 || activeSessions.length > 0} - isLoading={isLoading || !ready} - isError={isError} + isLoading={isLoading || !ready || (isSearching && search.isPending)} + isError={isError || (isSearching && search.isError)} + isFetchingNextPage={isFetchingNextPage} refetch={refetch} + onEndReached={handleEndReached} onSessionPress={navigateToSession} onSearchChange={handleSearchChange} onCreateSession={() => { diff --git a/apps/mobile/src/lib/hooks/use-agent-sessions.ts b/apps/mobile/src/lib/hooks/use-agent-sessions.ts index e2b141f1f0..3413c9c07b 100644 --- a/apps/mobile/src/lib/hooks/use-agent-sessions.ts +++ b/apps/mobile/src/lib/hooks/use-agent-sessions.ts @@ -1,5 +1,5 @@ import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc'; -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; import { useTRPC } from '@/lib/trpc'; @@ -50,21 +50,26 @@ function getUpdatedSince(days: number): string { // ── Queries ────────────────────────────────────────────────────────── +const SESSIONS_PAGE_SIZE = 30; + function useStoredSessions(options?: UseAgentSessionsOptions) { const trpc = useTRPC(); - const updatedSince = useMemo(() => getUpdatedSince(5), []); - return useQuery( - trpc.cliSessionsV2.list.queryOptions( + return useInfiniteQuery( + trpc.cliSessionsV2.list.infiniteQueryOptions( { - updatedSince, + limit: SESSIONS_PAGE_SIZE, orderBy: 'updated_at', includeChildren: false, createdOnPlatform: options?.createdOnPlatform, gitUrl: options?.gitUrl, organizationId: options?.organizationId, }, - { staleTime: 30_000, enabled: options?.enabled } + { + staleTime: 30_000, + enabled: options?.enabled, + getNextPageParam: lastPage => lastPage.nextCursor, + } ) ); } @@ -164,39 +169,83 @@ function groupSessionsByDate(sessions: StoredSession[]): DateGroup[] { })); } +// ── Search ─────────────────────────────────────────────────────────── + +type UseAgentSessionSearchOptions = UseAgentSessionsOptions & { + searchQuery: string; +}; + +/** + * Server-side session search. The list itself is cursor-paginated, so + * client-side filtering would only see the pages loaded so far — this + * searches the user's full history instead. + */ +export function useAgentSessionSearch(options: UseAgentSessionSearchOptions) { + const trpc = useTRPC(); + + const query = useQuery( + trpc.cliSessionsV2.search.queryOptions( + { + search_string: options.searchQuery, + // Endpoint max; no offset paging — past 50 matches, refining the query is the answer. + limit: 50, + includeChildren: false, + createdOnPlatform: options.createdOnPlatform, + gitUrl: options.gitUrl, + organizationId: options.organizationId, + }, + { + staleTime: 30_000, + enabled: (options.enabled ?? true) && options.searchQuery.length > 0, + placeholderData: keepPreviousData, + } + ) + ); + + const sessions = useMemo(() => query.data?.results ?? [], [query.data]); + const dateGroups = useMemo(() => groupSessionsByDate(sessions), [sessions]); + + return { dateGroups, isPending: query.isPending, isError: query.isError }; +} + // ── Main hook ──────────────────────────────────────────────────────── export function useAgentSessions(options?: UseAgentSessionsOptions) { const stored = useStoredSessions(options); const active = useActiveSessions(options); - const storedSessions = useMemo(() => stored.data?.cliSessions ?? [], [stored.data]); + // A session can repeat across pages when it is updated while older pages + // load (the cursor is its updated_at), so dedupe by session_id. + const storedSessions = useMemo(() => { + const seen = new Set(); + const sessions: StoredSession[] = []; + for (const page of stored.data?.pages ?? []) { + for (const session of page.cliSessions) { + if (!seen.has(session.session_id)) { + seen.add(session.session_id); + sessions.push(session); + } + } + } + return sessions; + }, [stored.data]); const activeSessions = useMemo(() => active.data?.sessions ?? [], [active.data]); const activeSessionIds = useMemo(() => new Set(activeSessions.map(s => s.id)), [activeSessions]); - const liveStoredSessions = useMemo( - () => storedSessions.filter(s => activeSessionIds.has(s.session_id)), - [storedSessions, activeSessionIds] - ); - - const offlineSessions = useMemo( - () => storedSessions.filter(s => !activeSessionIds.has(s.session_id)), - [storedSessions, activeSessionIds] - ); - const dateGroups = useMemo(() => groupSessionsByDate(storedSessions), [storedSessions]); return { storedSessions, activeSessions, activeSessionIds, - liveStoredSessions, - offlineSessions, dateGroups, isLoading: stored.isLoading || active.isLoading, isError: stored.isError || active.isError, + hasNextPage: stored.hasNextPage, + isFetchingNextPage: stored.isFetchingNextPage, + fetchNextPage: stored.fetchNextPage, refetch: async () => { await Promise.all([stored.refetch(), active.refetch()]); },