Skip to content
22 changes: 11 additions & 11 deletions apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@ import { FlatList, Pressable, TextInput, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { repositoryKey, repositoryLabel } from '@/components/agents/new-session-repository-state';
import { EmptyState } from '@/components/empty-state';
import { PickerSheet } from '@/components/picker-sheet';
import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { REPO_PLATFORM_LABEL_KEYS, type RepoOption } from '@/lib/picker-bridge';
import { REPO_PLATFORM_LABEL_KEYS, type RepoPickerBridge } from '@/lib/picker-bridge';
import { repoPickerSlot, UNFENCED_ROUTE_KEY, useRouteRegistry } from '@/lib/route-registry';
import { filterRepoPickerOptions } from '@/lib/repo-picker-filter';

type PickerListItem =
| { key: string; kind: 'header'; titleKey: string }
| { key: string; kind: 'repo'; repo: RepoOption };
| { key: string; kind: 'repo'; repo: RepoPickerBridge['repositories'][number] };

export default function RepoPickerScreen() {
const router = useRouter();
Expand Down Expand Up @@ -58,7 +59,7 @@ export default function RepoPickerScreen() {
const listItems = useMemo<PickerListItem[]>(() => {
if (search.trim()) {
return filtered.map(repo => ({
key: `${repo.platform}:${repo.fullName}`,
key: repositoryKey(repo),
kind: 'repo',
repo,
}));
Expand All @@ -69,7 +70,7 @@ export default function RepoPickerScreen() {
if (section.repos.length > 0) {
items.push({ key: `header:${section.key}`, kind: 'header', titleKey: section.titleKey });
for (const repo of section.repos) {
items.push({ key: `${repo.platform}:${repo.fullName}`, kind: 'repo', repo });
items.push({ key: repositoryKey(repo), kind: 'repo', repo });
}
}
}
Expand Down Expand Up @@ -150,15 +151,16 @@ export default function RepoPickerScreen() {
}
const repo = item.repo;
const platformName = t(REPO_PLATFORM_LABEL_KEYS[repo.platform]);
const rowLabel = `${platformName} ${repo.fullName}`;
const rowLabel = `${platformName} · ${repositoryLabel(repo)}`;
return (
<Pressable
className="flex-row items-center gap-3 border-b border-border px-4 py-3 active:bg-secondary will-change-pressable"
className="min-h-12 flex-row items-center gap-3 border-b border-border px-4 py-3 active:bg-secondary will-change-pressable"
onPress={() => {
handleSelect(`${repo.platform}:${repo.fullName}`);
handleSelect(repositoryKey(repo));
}}
accessibilityRole="button"
accessibilityLabel={rowLabel}
accessibilityState={{ selected: bridge.currentValue === repositoryKey(repo) }}
>
{repo.isPrivate ? (
<Lock size={14} color={colors.mutedForeground} />
Expand All @@ -171,10 +173,8 @@ export default function RepoPickerScreen() {
>
{platformName}
</Text>
<Text className="flex-1 text-base text-foreground" numberOfLines={1}>
{repo.fullName}
</Text>
{bridge.currentValue === `${repo.platform}:${repo.fullName}` ? (
<Text className="flex-1 text-base text-foreground">{repositoryLabel(repo)}</Text>
{bridge.currentValue === repositoryKey(repo) ? (
<Check size={18} color={colors.primary} />
) : null}
</Pressable>
Expand Down
191 changes: 144 additions & 47 deletions apps/mobile/src/components/agents/new-session-prefill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import { describe, expect, it, vi } from 'vitest';

import { i18n } from '@/i18n';
import { type LaunchRepositoryReference } from '@kilocode/app-shared/code-review/repository-identity';
import { normalizeSessionRepository } from './new-session-repository-state';

import {
appendNewSessionPrefill,
Expand Down Expand Up @@ -42,10 +44,6 @@ describe('buildContinuePrefillParams', () => {
it.each([
{ gitUrl: null as string | null, desc: 'null' },
{ gitUrl: 'https://github.com/group/sub/repo', desc: 'too many segments' },
{ gitUrl: 'https://gitlab.com/owner/repo.git', desc: 'non-GitHub HTTPS' },
{ gitUrl: 'git@gitlab.com:owner/repo.git', desc: 'non-GitHub scp-style' },
{ gitUrl: 'https://git.example.com/owner/repo.git', desc: 'self-hosted HTTPS' },
{ gitUrl: 'git@git.example.com:owner/repo.git', desc: 'self-hosted scp-style' },
])('omits repo when gitUrl is $desc', ({ gitUrl }) => {
const params = buildContinuePrefillParams({ gitUrl, mode: 'code', model: '', variant: '' });
expect(params.repo).toBeUndefined();
Expand Down Expand Up @@ -259,50 +257,146 @@ describe('resolvePrefillRepo', () => {
// ════════════════════════════════════════════════════════════════

describe('resolvePrefillRepoSelection', () => {
const repos = [
{ platform: 'gitlab', fullName: 'kilo-org/cloud' },
{ platform: 'bitbucket', fullName: 'kilo-org/cloud' },
{ platform: 'github', fullName: 'Kilo-Org/cloud' },
];

it('selects the GitHub row and never a same-named GitLab/Bitbucket row', () => {
const result = resolvePrefillRepoSelection(repos, { mode: 'code', repo: 'kilo-org/cloud' });
expect(result).toBe('github:Kilo-Org/cloud');
});

it('returns the GitHub row when only the GitLab row shares the name', () => {
const result = resolvePrefillRepoSelection(
[
{ platform: 'gitlab', fullName: 'owner/repo' },
{ platform: 'github', fullName: 'owner/repo' },
],
{ mode: 'code', repo: 'owner/repo' }
);
expect(result).toBe('github:owner/repo');
});

it('returns null when no GitHub row matches, even if a GitLab row does', () => {
const result = resolvePrefillRepoSelection([{ platform: 'gitlab', fullName: 'owner/repo' }], {
mode: 'code',
repo: 'owner/repo',
function repositories() {
return (['github', 'gitlab', 'bitbucket'] as const).flatMap(provider => {
const row = normalizeSessionRepository(
{
private: true,
repositoryReference: {
...reference,
repository:
provider === 'bitbucket'
? {
provider,
fullName: 'Kilo-Org/cloud',
repositoryId: '7',
instanceUrl: 'https://bitbucket.org',
defaultBranch: 'develop',
workspaceUuid: 'workspace-uuid',
}
: {
provider,
fullName: 'Kilo-Org/cloud',
repositoryId: '7',
instanceUrl: `https://${provider}.com`,
defaultBranch: 'develop',
},
},
},
'user-1',
'org-1'
);
return row ? [row] : [];
});
expect(result).toBeNull();
});
}

it.each(['github', 'gitlab', 'bitbucket'] as const)(
'selects only the exact %s identity among same-named provider rows',
platform => {
const rows = repositories();
const requested = rows.find(row => row.platform === platform);
if (!requested) {
throw new Error('Missing provider fixture');
}
expect(resolvePrefillRepoSelection(rows, { mode: 'code', repo: requested.key })).toBe(
requested.key
);
expect(
resolvePrefillRepoSelection(
rows.filter(row => row !== requested),
{ mode: 'code', repo: requested.key }
)
).toBeNull();
}
);

it('matches case-insensitively and returns the canonical GitHub casing', () => {
const result = resolvePrefillRepoSelection(repos, { mode: 'code', repo: 'kilo-Org/Cloud' });
expect(result).toBe('github:Kilo-Org/cloud');
});
it.each(['Kilo-Org/cloud', 'kilo-org/CLOUD', 'https://github.com/Kilo-Org/cloud.git'])(
'does not infer legacy uniqueness for %s from one visible GitHub integration',
repo => {
const rows = repositories().filter(row => row.platform === 'github');
expect(rows).toHaveLength(1);
expect(resolvePrefillRepoSelection(rows, { mode: 'code', repo })).toBeNull();
}
);

it.each([
{ repo: 'gh/other', desc: 'no match' },
{ repo: undefined, desc: 'absent' },
{ repo: '', desc: 'empty string' },
])('returns null when repo is $desc', ({ repo }) => {
expect(resolvePrefillRepoSelection(repos, { mode: 'code', repo })).toBeNull();
expect(resolvePrefillRepoSelection(repositories(), { mode: 'code', repo })).toBeNull();
});
});

const reference: LaunchRepositoryReference = {
repository: {
provider: 'gitlab',
instanceUrl: 'https://git.example.com/base',
repositoryId: '7',
fullName: 'group/nested/Repo',
defaultBranch: 'develop',
},
authorization: {
kind: 'ownerIntegration',
owner: { type: 'org', id: 'org-1' },
integrationId: 'integration-1',
},
};

it.each([
'https://git.example.com/base/group/nested/Repo.git',
'git@git.example.com:base/group/nested/Repo.git',
])('preserves the self-managed URL %s but requires an exact identity for selection', gitUrl => {
const params = buildContinuePrefillParams({ gitUrl, mode: 'code', model: '', variant: '' });
expect(params.repo).toBe(gitUrl);
const row = normalizeSessionRepository(
{ private: true, repositoryReference: reference },
'user-1',
'org-1'
);
if (!row) {
throw new Error('Invalid fixture');
}
const prefill = readNewSessionPrefill({ prefillRepo: params.repo });
expect(resolvePrefillRepoSelection([row], prefill)).toBeNull();
expect(resolvePrefillRepoSelection([row], { mode: 'code', repo: row.key })).toBe(row.key);
expect(
resolvePrefillRepoSelection([row], { mode: 'code', repo: gitUrl.replace('/Repo', '/repo') })
).toBeNull();
expect(
resolvePrefillRepoSelection([row], { mode: 'code', repo: gitUrl.replace('base/', 'other/') })
).toBeNull();
});

it('quarantines a legacy name or URL shared by multiple integrations instead of picking the first', () => {
const rows = ['integration-1', 'integration-2'].flatMap(integrationId => {
const row = normalizeSessionRepository(
{
private: true,
repositoryReference: {
...reference,
authorization: { ...reference.authorization, integrationId },
},
},
'user-1',
'org-1'
);
return row ? [row] : [];
});
expect(
resolvePrefillRepoSelection(rows, {
mode: 'code',
repo: 'https://git.example.com/base/group/nested/Repo.git',
})
).toBeNull();
for (const row of rows) {
expect(resolvePrefillRepoSelection(rows, { mode: 'code', repo: row.key })).toBe(row.key);
}
expect(
resolvePrefillRepoSelection(rows.slice(1), { mode: 'code', repo: rows[0]?.key })
).toBeNull();
});

// ════════════════════════════════════════════════════════════════
// describePrefillFallback
// ════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -338,6 +432,19 @@ describe('describePrefillFallback', () => {
model: 'anthropic/claude-sonnet-4',
}),
},
{
desc: 'model unmatched, repository identity unresolved',
prefill: {
mode: 'code',
repo: 'owner/repo',
model: 'anthropic/claude-sonnet-4',
} satisfies NewSessionPrefill,
repos: unsettled,
models: settled,
expected: i18n.t('agentChat.newSession.prefillModelUnavailable', {
model: 'anthropic/claude-sonnet-4',
}),
},
])('returns per-field message when $desc', ({ prefill, repos, models, expected }) => {
expect(describePrefillFallback({ prefill, repos, models })).toBe(expected);
});
Expand All @@ -353,16 +460,6 @@ describe('describePrefillFallback', () => {
repos: settled,
models: unsettled,
},
{
desc: 'both requested, only models settled',
prefill: {
mode: 'code',
repo: 'owner/repo',
model: 'anthropic/claude-sonnet-4',
} satisfies NewSessionPrefill,
repos: unsettled,
models: settled,
},
{
desc: 'both matched',
prefill: {
Expand Down
Loading
Loading