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
136 changes: 136 additions & 0 deletions apps/mobile/src/components/agents/live-session-filters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';

import {
buildLiveFilterOptions,
filterLiveSessions,
liveSessionPlatformBucket,
type LiveSessionQuery,
} from './live-session-filters';

const CLOUD = {
id: 'cloud',
title: 'Fix the login redirect',
gitUrl: 'https://github.com/kilo/cloud.git',
createdOnPlatform: 'cloud-agent-web',
};
const CLI = {
id: 'cli',
title: 'Bump deps',
gitUrl: 'git@github.com:kilo/app.git',
createdOnPlatform: 'cli',
};
const VSCODE = {
id: 'vscode',
title: 'Rename the module',
gitUrl: 'https://github.com/kilo/cloud.git',
createdOnPlatform: 'vscode',
};
const BARE = { id: 'bare', title: '', gitUrl: null, createdOnPlatform: 'unknown' };

describe('liveSessionPlatformBucket', () => {
it('folds platform variants into the filter bucket', () => {
expect(liveSessionPlatformBucket('cloud-agent-web')).toBe('cloud-agent');
expect(liveSessionPlatformBucket('agent-manager')).toBe('extension');
expect(liveSessionPlatformBucket('cli')).toBe('cli');
});

it('buckets an unlisted platform as other and an unknown origin as null', () => {
expect(liveSessionPlatformBucket('jetbrains')).toBe('other');
expect(liveSessionPlatformBucket('unknown')).toBeNull();
expect(liveSessionPlatformBucket(undefined)).toBeNull();
});
});

describe('buildLiveFilterOptions', () => {
it('offers each repository once, sorted, and skips rows without one', () => {
const { projectOptions } = buildLiveFilterOptions([CLOUD, VSCODE, CLI, BARE]);

expect(projectOptions).toEqual([
{ gitUrl: 'git@github.com:kilo/app.git', displayName: 'kilo/app' },
{ gitUrl: 'https://github.com/kilo/cloud.git', displayName: 'kilo/cloud' },
]);
});

it('offers only the platform buckets that are live, in canonical order', () => {
expect(buildLiveFilterOptions([CLI, VSCODE, CLOUD, BARE]).platformOptions).toEqual([
'cloud-agent',
'extension',
'cli',
]);
});

it('offers nothing for an empty live list', () => {
expect(buildLiveFilterOptions([])).toEqual({ projectOptions: [], platformOptions: [] });
});
});

const query = (over: Partial<LiveSessionQuery> = {}): LiveSessionQuery => ({
platformFilter: [],
projectFilter: [],
searchQuery: '',
...over,
});

describe('filterLiveSessions', () => {
const sessions = [CLOUD, CLI, VSCODE, BARE];

it('returns the list untouched when nothing narrows it', () => {
expect(filterLiveSessions(sessions, query())).toBe(sessions);
});

it('keeps only the selected repository', () => {
expect(
filterLiveSessions(
sessions,
query({ projectFilter: ['https://github.com/kilo/cloud.git'] })
).map(s => s.id)
).toEqual(['cloud', 'vscode']);
});

it('matches a platform bucket across its variants', () => {
expect(
filterLiveSessions(sessions, query({ platformFilter: ['cloud-agent'] })).map(s => s.id)
).toEqual(['cloud']);
expect(
filterLiveSessions(sessions, query({ platformFilter: ['extension'] })).map(s => s.id)
).toEqual(['vscode']);
});

it('combines every dimension with AND', () => {
expect(
filterLiveSessions(
sessions,
query({ platformFilter: ['cli'], projectFilter: ['https://github.com/kilo/cloud.git'] })
)
).toHaveLength(0);
expect(
filterLiveSessions(
sessions,
query({ platformFilter: ['cli'], projectFilter: ['git@github.com:kilo/app.git'] })
).map(s => s.id)
).toEqual(['cli']);
});

it('searches the title case-insensitively', () => {
expect(
filterLiveSessions(sessions, query({ searchQuery: ' FIX the ' })).map(s => s.id)
).toEqual(['cloud']);
});

it('searches the repository name too', () => {
expect(filterLiveSessions(sessions, query({ searchQuery: 'kilo/app' })).map(s => s.id)).toEqual(
['cli']
);
});

it('treats a whitespace-only query as no search', () => {
expect(filterLiveSessions(sessions, query({ searchQuery: ' ' }))).toBe(sessions);
});

it('never claims a row with an unknown origin or no repository', () => {
expect(filterLiveSessions(sessions, query({ platformFilter: ['other'] }))).toHaveLength(0);
expect(
filterLiveSessions([BARE], query({ projectFilter: ['https://github.com/kilo/cloud.git'] }))
).toHaveLength(0);
});
});
110 changes: 110 additions & 0 deletions apps/mobile/src/components/agents/live-session-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {
expandPlatformFilter,
formatGitUrlProject,
PLATFORM_FILTERS,
type ProjectFilterOption,
} from '@/components/agents/session-list-helpers';

/** The part of an active session the live filters read. */
export type LiveFilterSession = {
title?: string;
gitUrl?: string | null;
createdOnPlatform?: string;
};

export type LiveSessionQuery = {
platformFilter: readonly string[];
projectFilter: readonly string[];
/** Free text, matched against the session title and its repository. */
searchQuery: string;
};

export type LiveFilterOptions = {
projectOptions: ProjectFilterOption[];
platformOptions: string[];
};

// Inverse of the history screen's platform expansion, so the live list speaks
// the same vocabulary ('cloud-agent' covers 'cloud-agent-web', 'extension'
// covers 'vscode' and 'agent-manager'). Built from `expandPlatformFilter` so
// the mapping stays in one place.
const BUCKET_BY_PLATFORM = new Map<string, string>(
PLATFORM_FILTERS.filter(bucket => bucket !== 'other').flatMap(bucket =>
expandPlatformFilter([bucket]).map(platform => [platform, bucket] as const)
)
);

/**
* Filter bucket for one live row's origin. Returns null when the origin is
* missing or still 'unknown' (a CLI row before its first enrichment), so such
* a row offers no option and is never claimed by a platform filter.
*/
export function liveSessionPlatformBucket(createdOnPlatform: string | undefined): string | null {
if (!createdOnPlatform || createdOnPlatform === 'unknown') {
return null;
}
return BUCKET_BY_PLATFORM.get(createdOnPlatform) ?? 'other';
}

/**
* Build the filter options from the live rows themselves, so the picker never
* offers a repository or an origin that has nothing running. Options are
* derived from the unfiltered set, so applying a filter does not shrink them.
*/
export function buildLiveFilterOptions(sessions: readonly LiveFilterSession[]): LiveFilterOptions {
const projects = new Map<string, string>();
const platforms = new Set<string>();
for (const session of sessions) {
if (session.gitUrl) {
projects.set(session.gitUrl, formatGitUrlProject(session.gitUrl));
}
const bucket = liveSessionPlatformBucket(session.createdOnPlatform);
if (bucket) {
platforms.add(bucket);
}
}
return {
projectOptions: [...projects]
.map(([gitUrl, displayName]) => ({ gitUrl, displayName }))
// eslint-disable-next-line unicorn/no-array-sort -- Hermes does not implement Array.prototype.toSorted; map already copies so nothing shared is mutated
.sort((a, b) => a.displayName.localeCompare(b.displayName)),
// Keep the canonical platform order the filter modal uses.
platformOptions: PLATFORM_FILTERS.filter(bucket => platforms.has(bucket)),
};
}

function matchesSearch(session: LiveFilterSession, needle: string): boolean {
if (session.title?.toLowerCase().includes(needle)) {
return true;
}
return session.gitUrl
? formatGitUrlProject(session.gitUrl).toLowerCase().includes(needle)
: false;
}

/**
* Client-side live-list query: repository, origin, and free-text search, all
* combined with AND. An empty selection or an empty query means "no filter".
* The live list is fully loaded in memory, so it filters locally — no refetch,
* and no debounce needed.
*/
export function filterLiveSessions<T extends LiveFilterSession>(
sessions: T[],
query: LiveSessionQuery
): T[] {
const { platformFilter, projectFilter } = query;
const needle = query.searchQuery.trim().toLowerCase();
if (platformFilter.length === 0 && projectFilter.length === 0 && needle.length === 0) {
return sessions;
}
return sessions.filter(session => {
const bucket = liveSessionPlatformBucket(session.createdOnPlatform);
const platformMatches =
platformFilter.length === 0 || (bucket !== null && platformFilter.includes(bucket));
const projectMatches =
projectFilter.length === 0 ||
(session.gitUrl != null && projectFilter.includes(session.gitUrl));
const searchMatches = needle.length === 0 || matchesSearch(session, needle);
return platformMatches && projectMatches && searchMatches;
});
}
68 changes: 15 additions & 53 deletions apps/mobile/src/components/agents/platform-filter-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,23 @@ import { Modal, Pressable, ScrollView, View } from 'react-native';
import { useTranslation } from 'react-i18next';

import { i18n } from '@/i18n';
import {
PLATFORM_FILTERS,
type ProjectFilterOption,
} from '@/components/agents/session-list-helpers';
import { Button } from '@/components/ui/button';
import { ChoiceRow } from '@/components/ui/choice-row';
import { RadioGroup } from '@/components/ui/radio-group';
import { Text } from '@/components/ui/text';
import { type AgentSessionSortBy } from '@/lib/agent-session-sort';
import { type AgentSessionFilters } from '@/lib/agent-session-filters';
import { platformLabel } from '@/lib/platform-label';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { subscribePrivacyCover } from '@/lib/privacy-cover-events';
import { cn } from '@/lib/utils';

const PLATFORM_FILTERS = [
'cloud-agent',
'extension',
'cli',
'slack',
'github',
'linear',
'other',
] as const;
const chipScrollContentStyle = { paddingHorizontal: 22, paddingVertical: 8, gap: 8 };

export type ProjectFilterOption = {
gitUrl: string;
displayName: string;
};
export { type ProjectFilterOption };

type SessionFilters = {
platformFilter: string[];
projectFilter: string[];
sortBy: AgentSessionSortBy;
};
const chipScrollContentStyle = { paddingHorizontal: 22, paddingVertical: 8, gap: 8 };

type SessionFilterChipsProps = Omit<SessionFilters, 'sortBy'> & {
type SessionFilterChipsProps = AgentSessionFilters & {
projectOptions: ProjectFilterOption[];
onRemovePlatform: (platform: string) => void;
onRemoveProject: (gitUrl: string) => void;
Expand All @@ -44,10 +29,11 @@ type SessionFilterChipsProps = Omit<SessionFilters, 'sortBy'> & {
type SessionFilterModalProps = {
selectedPlatforms: string[];
selectedProjects: string[];
selectedSortBy: AgentSessionSortBy;
projectOptions: ProjectFilterOption[];
/** Platform rows to offer. Defaults to every known platform. */
platformOptions?: readonly string[];
onClose: () => void;
onApply: (filters: SessionFilters) => void;
onApply: (filters: AgentSessionFilters) => void;
};

type FilterCheckboxRowProps = {
Expand Down Expand Up @@ -80,7 +66,7 @@ function platformFilterLabel(p: string): string {
return i18n.t('agentChat.sessionFilter.platformOther');
}
default: {
return p;
return platformLabel(p);
}
}
}
Expand Down Expand Up @@ -181,19 +167,14 @@ export function SessionFilterChips({
export function SessionFilterModal({
selectedPlatforms,
selectedProjects,
selectedSortBy,
projectOptions,
platformOptions = PLATFORM_FILTERS,
onClose,
onApply,
}: Readonly<SessionFilterModalProps>) {
const { t } = useTranslation();
const [draftPlatforms, setDraftPlatforms] = useState<string[]>(selectedPlatforms);
const [draftProjects, setDraftProjects] = useState<string[]>(selectedProjects);
const [draftSortBy, setDraftSortBy] = useState<AgentSessionSortBy>(selectedSortBy);
const sortOptions: readonly { value: AgentSessionSortBy; label: string }[] = [
{ value: 'updated_at', label: t('agentChat.sessionFilter.sortLastUpdated') },
{ value: 'created_at', label: t('agentChat.sessionFilter.sortCreated') },
];

const togglePlatform = (platform: string) => {
setDraftPlatforms(prev =>
Expand Down Expand Up @@ -234,29 +215,11 @@ export function SessionFilterModal({
<Text className="text-base font-semibold">{t('agentChat.sessionFilter.title')}</Text>
<ScrollView showsVerticalScrollIndicator={false}>
<View className="gap-4">
<View className="gap-1">
<Text variant="eyebrow" className="px-3">
{t('agentChat.sessionFilter.sortBy')}
</Text>
<RadioGroup label={t('agentChat.sessionFilter.sortBy')}>
{sortOptions.map(option => (
<ChoiceRow
key={option.value}
label={option.label}
selected={draftSortBy === option.value}
onPress={() => {
setDraftSortBy(option.value);
}}
className="rounded-lg px-3"
/>
))}
</RadioGroup>
</View>
<View className="gap-1">
<Text variant="eyebrow" className="px-3">
{t('agentChat.sessionFilter.platform')}
</Text>
{PLATFORM_FILTERS.map(platform => (
{platformOptions.map(platform => (
<FilterCheckboxRow
key={platform}
label={platformFilterLabel(platform)}
Expand Down Expand Up @@ -295,7 +258,6 @@ export function SessionFilterModal({
onApply({
platformFilter: draftPlatforms,
projectFilter: draftProjects,
sortBy: draftSortBy,
});
onClose();
}}
Expand Down
Loading