From 9df7c0d4f5de1799ddb55e2ad722f3e94d06814b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 3 Jul 2026 13:46:21 +0200 Subject: [PATCH 1/2] fix(mobile): paginate agent sessions list and clear the tab bar overlay The sessions SectionList had no bottom padding for the absolutely-positioned tab bar, so the last rows were stuck underneath it and the list bounced back when scrolling down. Content now pads by the tab bar overlay height. Stored sessions were also fetched with a hardcoded 5-day updatedSince window, so anything older was never loaded. The list now uses cursor-based infinite pagination (the cliSessionsV2.list procedure already supported it), loading 30 sessions per page via onEndReached with a footer spinner. --- .../agents/session-list-content.tsx | 29 +++++++++-- .../components/agents/session-list-screen.tsx | 11 +++++ .../src/lib/hooks/use-agent-sessions.ts | 48 +++++++++++-------- 3 files changed, 66 insertions(+), 22 deletions(-) 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..4630d413a1 100644 --- a/apps/mobile/src/components/agents/session-list-screen.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.tsx @@ -80,6 +80,9 @@ export function AgentSessionListScreen() { activeSessionIds, isLoading, isError, + hasNextPage, + isFetchingNextPage, + fetchNextPage, refetch, } = useAgentSessions({ createdOnPlatform, @@ -190,6 +193,12 @@ export function AgentSessionListScreen() { [router] ); + const handleEndReached = useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + const hasActiveFilter = platformFilter.length > 0 || projectFilter.length > 0; return ( @@ -246,7 +255,9 @@ export function AgentSessionListScreen() { hasAnySessions={storedSessions.length > 0 || activeSessions.length > 0} isLoading={isLoading || !ready} isError={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..12744d5415 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 { 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, + } ) ); } @@ -170,33 +175,38 @@ 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()]); }, From 145cb78fe6312e8a402395de296a004df4a3344b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 3 Jul 2026 14:02:36 +0200 Subject: [PATCH 2/2] feat(mobile): search agent sessions server-side Client-side search only matched sessions from already-loaded pages now that the list is cursor-paginated. Wire the debounced search query to the cliSessionsV2.search endpoint instead, so search covers the full history. Active (remote) sessions keep the client-side match since they're a small in-memory list not covered by the search endpoint. --- .../components/agents/session-list-screen.tsx | 46 +++++++++++++------ .../src/lib/hooks/use-agent-sessions.ts | 41 ++++++++++++++++- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx index 4630d413a1..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'; @@ -90,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, @@ -161,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, @@ -181,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) => { @@ -194,10 +214,10 @@ export function AgentSessionListScreen() { ); const handleEndReached = useCallback(() => { - if (hasNextPage && !isFetchingNextPage) { + if (!isSearching && hasNextPage && !isFetchingNextPage) { void fetchNextPage(); } - }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + }, [fetchNextPage, hasNextPage, isFetchingNextPage, isSearching]); const hasActiveFilter = platformFilter.length > 0 || projectFilter.length > 0; @@ -253,8 +273,8 @@ 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} diff --git a/apps/mobile/src/lib/hooks/use-agent-sessions.ts b/apps/mobile/src/lib/hooks/use-agent-sessions.ts index 12744d5415..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 { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; import { useTRPC } from '@/lib/trpc'; @@ -169,6 +169,45 @@ 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) {