Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions apps/mobile/src/components/agents/session-list-content.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -26,7 +33,9 @@ type AgentSessionListContentProps = {
hasAnySessions: boolean;
isLoading: boolean;
isError: boolean;
isFetchingNextPage: boolean;
refetch: () => Promise<void>;
onEndReached: () => void;
onSessionPress: (sessionId: string, organizationId?: string | null) => void;
onSearchChange: (text: string) => void;
onCreateSession: () => void;
Expand All @@ -38,15 +47,19 @@ export function AgentSessionListContent({
hasAnySessions,
isLoading,
isError,
isFetchingNextPage,
refetch,
onEndReached,
onSessionPress,
onSearchChange,
onCreateSession,
}: Readonly<AgentSessionListContentProps>) {
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]
);
Expand Down Expand Up @@ -178,7 +191,7 @@ export function AgentSessionListContent({
<Animated.View
entering={FadeIn.duration(200)}
className="flex-1 items-center justify-center"
style={emptyStateContainerStyle}
style={tabBarClearanceStyle}
>
<EmptyState
icon={Bot}
Expand All @@ -199,8 +212,18 @@ export function AgentSessionListContent({
keyExtractor={keyExtractor}
ListHeaderComponent={listHeader}
ListEmptyComponent={listEmptyComponent}
ListFooterComponent={
isFetchingNextPage ? (
<View className="py-4">
<ActivityIndicator color={colors.mutedForeground} />
</View>
) : null
}
contentContainerStyle={tabBarClearanceStyle}
contentOffset={{ x: 0, y: SEARCH_BAR_HEIGHT }}
keyboardDismissMode="on-drag"
onEndReached={onEndReached}
onEndReachedThreshold={0.5}
refreshControl={<RefreshControl refreshing={false} onRefresh={handleRefresh} />}
/>
</Animated.View>
Expand Down
53 changes: 42 additions & 11 deletions apps/mobile/src/components/agents/session-list-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -80,13 +84,24 @@ export function AgentSessionListScreen() {
activeSessionIds,
isLoading,
isError,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
refetch,
} = useAgentSessions({
createdOnPlatform,
gitUrl,
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,
Expand Down Expand Up @@ -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,
Expand All @@ -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) => {
Expand All @@ -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 (
Expand Down Expand Up @@ -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={() => {
Expand Down
87 changes: 68 additions & 19 deletions apps/mobile/src/lib/hooks/use-agent-sessions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(
Comment thread
iscekic marked this conversation as resolved.
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,
}
)
);
}
Expand Down Expand Up @@ -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<string>();
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()]);
},
Expand Down