diff --git a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx index 0d2e3dd80e..cb7eab7803 100644 --- a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx @@ -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(); @@ -58,7 +59,7 @@ export default function RepoPickerScreen() { const listItems = useMemo(() => { if (search.trim()) { return filtered.map(repo => ({ - key: `${repo.platform}:${repo.fullName}`, + key: repositoryKey(repo), kind: 'repo', repo, })); @@ -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 }); } } } @@ -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 ( { - handleSelect(`${repo.platform}:${repo.fullName}`); + handleSelect(repositoryKey(repo)); }} accessibilityRole="button" accessibilityLabel={rowLabel} + accessibilityState={{ selected: bridge.currentValue === repositoryKey(repo) }} > {repo.isPrivate ? ( @@ -171,10 +173,8 @@ export default function RepoPickerScreen() { > {platformName} - - {repo.fullName} - - {bridge.currentValue === `${repo.platform}:${repo.fullName}` ? ( + {repositoryLabel(repo)} + {bridge.currentValue === repositoryKey(repo) ? ( ) : null} diff --git a/apps/mobile/src/components/agents/new-session-prefill.test.ts b/apps/mobile/src/components/agents/new-session-prefill.test.ts index b11a1e9dd0..f323ffa3bc 100644 --- a/apps/mobile/src/components/agents/new-session-prefill.test.ts +++ b/apps/mobile/src/components/agents/new-session-prefill.test.ts @@ -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, @@ -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(); @@ -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 // ════════════════════════════════════════════════════════════════ @@ -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); }); @@ -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: { diff --git a/apps/mobile/src/components/agents/new-session-prefill.ts b/apps/mobile/src/components/agents/new-session-prefill.ts index 15bca6ea0b..ee0cbb59f2 100644 --- a/apps/mobile/src/components/agents/new-session-prefill.ts +++ b/apps/mobile/src/components/agents/new-session-prefill.ts @@ -1,3 +1,4 @@ +import { type ResolvedNewSessionRepository } from '@/components/agents/new-session-repository-state'; import { type AgentMode, normalizeAgentMode } from '@/components/agents/mode-normalize'; import { formatGitUrlProject } from '@/components/agents/session-list-helpers'; import { i18n } from '@/i18n'; @@ -38,8 +39,8 @@ function isGitHubUrl(gitUrl: string): boolean { /** * Build query-param prefill values from a session's displayed targets. - * Each field is included only when non-empty; repo is included only when - * it reduces to exactly two non-empty `/` segments. + * GitHub keeps its legacy owner/repo prefill. Other providers retain the full + * clone URL for exact instance/path resolution against authorized discovery. */ export function buildContinuePrefillParams(input: { gitUrl: string | null | undefined; @@ -51,8 +52,13 @@ export function buildContinuePrefillParams(input: { if (input.gitUrl) { const project = formatGitUrlProject(input.gitUrl); - if (isGitHubUrl(input.gitUrl) && isValidOwnerRepo(project)) { - params.repo = project; + if (isGitHubUrl(input.gitUrl)) { + if (isValidOwnerRepo(project)) { + params.repo = project; + } + } else { + // Preserve the full instance/subpath until authorized discovery resolves it. + params.repo = input.gitUrl; } } if (input.mode) { @@ -222,35 +228,22 @@ export function resolvePrefillRepo( return match?.fullName ?? null; } -/** - * Resolve a prefill repository to a platform-qualified picker key - * `platform:fullName`. Continuation prefill is GitHub-only (see - * `buildContinuePrefillParams` + `isGitHubUrl`), so only a GitHub row may - * satisfy it: a same-named GitLab/Bitbucket row must never be selected. - * Kept separate from `resolvePrefillRepo`, which returns the bare matched - * `fullName` and is used where a bare fullName is needed. - */ +/** Resolve an exact authorized key, never uniqueness inferred from partial browsing. */ export function resolvePrefillRepoSelection( - repositories: { platform: string; fullName: string }[], + repositories: readonly ResolvedNewSessionRepository[], prefill: NewSessionPrefill ): string | null { - if (!prefill.repo) { - return null; - } - - const lower = prefill.repo.toLowerCase(); - const match = repositories.find( - repository => repository.platform === 'github' && repository.fullName.toLowerCase() === lower - ); - return match ? `github:${match.fullName}` : null; + // Old name/clone-URL prefills omit integration identity. Browsing can omit other + // authorized integrations, so these require explicit selection, even with one match. + // Remove only after old clients/records disappear and the 30-day ledger window expires. + return repositories.find(repository => repository.key === prefill.repo)?.key ?? null; } /** * Describe what could not be carried over, if anything. * - * `settled` means "the list finished loading, without error, and is - * **non-empty**". An account with no GitHub integration leaves - * `repos.settled === false` forever (empty list ≠ settled). + * An unmatched repository can settle only with complete authorized evidence. + * A known exact match can settle while other providers still load. * * Per-field gating: a field that was **not** requested never blocks * and never contributes, whatever its `settled` value is. @@ -265,15 +258,12 @@ export function describePrefillFallback(input: { const repoRequested = Boolean(prefill.repo); const modelRequested = Boolean(prefill.model); - // Wait only on the fields that were actually requested. - if (repoRequested && !repos.settled) { - return null; - } if (modelRequested && !models.settled) { return null; } - const repoDropped = repoRequested && !repos.matched; + // Unresolved repository identity cannot block an independently confirmed model fallback. + const repoDropped = repoRequested && repos.settled && !repos.matched; const modelDropped = modelRequested && !models.matched; if (repoDropped && modelDropped) { diff --git a/apps/mobile/src/components/agents/new-session-repository-section.mounted.test.tsx b/apps/mobile/src/components/agents/new-session-repository-section.mounted.test.tsx new file mode 100644 index 0000000000..1160e6316e --- /dev/null +++ b/apps/mobile/src/components/agents/new-session-repository-section.mounted.test.tsx @@ -0,0 +1,307 @@ +/* eslint-disable typescript-eslint/no-deprecated -- native selection is mounted with the DOM-free renderer */ +import { createElement, Fragment, type ReactNode, useEffect, useState } from 'react'; +import { Pressable } from 'react-native'; +import TestRenderer, { act } from 'react-test-renderer'; +import { afterEach, assert, beforeEach, expect, it, vi } from 'vitest'; +import { type LaunchRepositoryReference } from '@kilocode/app-shared/code-review/repository-identity'; +import { type RepoPickerBridge } from '@/lib/picker-bridge'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import RepoPickerScreen from '@/app/(app)/agent-chat/repo-picker'; +import { NewSessionRepositorySection } from './new-session-repository-section'; +import { normalizeSessionRepository, type RepositoryGroup } from './new-session-repository-state'; + +const native = vi.hoisted(() => ({ + bridge: undefined as RepoPickerBridge | undefined, + setOpen: undefined as ((open: boolean) => void) | undefined, +})); +vi.mock('react-native', () => ({ + View: 'View', + Pressable: 'Pressable', + TextInput: 'TextInput', + ActivityIndicator: 'ActivityIndicator', + FlatList: ({ + data, + renderItem, + }: { + data: { key: string }[]; + renderItem: (input: { item: { key: string } }) => ReactNode; + }) => data.map(item => createElement(Fragment, { key: item.key }, renderItem({ item }))), +})); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/icons', () => ({ + ChevronDown: 'icon', + ExternalLink: 'icon', + RefreshCw: 'icon', + Check: 'icon', + Info: 'icon', + Lock: 'icon', + Search: 'icon', + SearchX: 'icon', + Unlock: 'icon', +})); +vi.mock('@/components/ui/accessible-status', () => ({ + AccessibleStatus: ({ message }: { message: string | null }) => createElement('Text', {}, message), +})); +vi.mock('@/components/query-error', () => ({ + QueryError: (props: { title: string; message: string; onRetry?: () => void }) => ( + <> + + {props.title} + {props.message} + + {props.onRetry ? ( + + ) : null} + + ), +})); +vi.mock('@/components/picker-sheet', () => ({ + PickerSheet: ({ children }: { children: ReactNode }): ReactNode => children, +})); +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({}) })); +vi.mock('@/lib/hooks/use-current-user-id', () => ({ useCurrentUserId: vi.fn() })); +vi.mock('@/lib/config', () => ({ WEB_BASE_URL: 'https://web.test' })); +vi.mock('sonner-native', () => ({ toast: { error: vi.fn() } })); +vi.mock('@/lib/pr-review/connect-gate-platform', () => ({ + openAuthorizationAndWaitForReturn: vi.fn(), +})); +vi.mock('@/i18n', () => ({ i18n: { t: (key: string) => key } })); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: { provider?: string; label?: string }) => + [key, params?.provider, params?.label].filter(Boolean).join(' '), + }), +})); +vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); +vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn() })); +vi.mock('@/lib/trpc', () => ({ useTRPC: vi.fn() })); +vi.mock('@expo/react-native-action-sheet', () => ({ + useActionSheet: () => ({ showActionSheetWithOptions: vi.fn() }), +})); +vi.mock('expo-router', () => ({ + useFocusEffect: (effect: () => void) => { + useEffect(effect, [effect]); + }, + useRouter: () => ({ + push: () => { + native.setOpen?.(true); + }, + back: () => { + native.setOpen?.(false); + }, + }), +})); +vi.mock('@/lib/route-registry', () => ({ + UNFENCED_ROUTE_KEY: '', + useRouteRegistry: vi.fn(), + repoPickerSlot: { + get: () => native.bridge, + set: (_key: string, bridge: RepoPickerBridge) => { + native.bridge = bridge; + }, + clear: () => { + native.bridge = undefined; + }, + }, +})); + +function repo(integrationId: string, owner = 'org-1') { + const reference: LaunchRepositoryReference = { + repository: { + provider: 'gitlab', + instanceUrl: `https://${integrationId}.example.com/base`, + repositoryId: '7', + fullName: 'group/nested/repo', + defaultBranch: 'develop', + }, + authorization: { kind: 'ownerIntegration', owner: { type: 'org', id: owner }, integrationId }, + }; + const result = normalizeSessionRepository( + { private: true, repositoryReference: reference }, + 'user-1', + owner + ); + assert(result, 'Invalid fixture'); + return result; +} +const rows = [repo('integration-1'), repo('integration-2')]; +function Harness({ + groups, + repositories = rows, +}: { + groups: RepositoryGroup[]; + repositories?: typeof rows; +}) { + const [value, setValue] = useState(''); + const [isOpen, setOpen] = useState(false); + const [recovery, setRecovery] = useState(''); + native.setOpen = setOpen; + return ( + <> + { + setRecovery('refresh'); + }} + repositories={repositories} + recents={repositories.slice(0, 1)} + groups={groups} + value={value} + /> + {isOpen ? : null} + {createElement('output', { value, recovery })} + + ); +} +let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; +async function render(groups: RepositoryGroup[], repositories = rows) { + await act(() => { + const tree = ; + if (renderer) { + renderer.update(tree); + } else { + renderer = TestRenderer.create(tree); + } + }); +} +function output() { + return renderer?.root.findByType('output').props as { value: string; recovery: string }; +} +function text() { + return JSON.stringify(renderer?.toJSON()); +} +type Control = { + onPress: () => void; + disabled?: boolean; + accessibilityLabel?: string; + accessibilityState?: { selected?: boolean }; +}; +function controls() { + return renderer?.root.findAllByType(Pressable).map(node => node.props as Control) ?? []; +} +function trigger() { + return controls().find(props => + props.accessibilityLabel?.startsWith('agentChat.repoPicker.accessibility') + ); +} +async function open() { + await act(() => { + trigger()?.onPress(); + }); +} +async function press(label: string) { + const button = renderer?.root + .findAllByType(Button) + .find(node => node.findAllByType(Text).some(child => child.children.includes(label))); + if (!button) { + throw new Error(`Missing button: ${label}`); + } + await act(() => { + (button.props as Control).onPress(); + }); +} +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + native.bridge = undefined; +}); +afterEach(async () => { + await act(() => { + renderer?.unmount(); + }); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +it('keeps loaded rows selectable and labels both picker states with exact identity', async () => { + await render([ + { key: 'gitlab', status: 'repos', repositories: rows }, + { key: 'github', status: 'loading', repositories: [] }, + { key: 'bitbucket', status: 'loading', repositories: [] }, + ]); + expect(text()).toContain( + 'agentChat.newSession.loadingRepositories agentChat.repoPicker.platformGithub' + ); + expect(text()).toContain( + 'agentChat.newSession.loadingRepositories agentChat.repoPicker.platformBitbucket' + ); + expect(trigger()?.disabled).toBe(false); + await open(); + expect(native.bridge?.repositories.map(row => row.reference)).toEqual( + rows.map(row => row.reference) + ); + const options = controls().filter(props => + props.accessibilityLabel?.includes('group/nested/repo') + ); + expect(options).toHaveLength(2); + expect(options[1]?.accessibilityLabel).toContain('org:org-1'); + await act(() => { + options[1]?.onPress(); + }); + expect(output().value).toBe(rows[1]?.key); + expect(trigger()?.accessibilityLabel).toContain('integration-2.example.com/base'); + await open(); + const selected = controls().filter(props => props.accessibilityState?.selected); + expect(selected).toHaveLength(1); + expect(selected[0]?.accessibilityLabel).toContain('integration-2.example.com/base'); +}); + +it.each([ + ['error', 'couldNotLoadGithubRepositories', 'refresh'], + ['identity-unavailable', 'repositoryIdentityUnavailable', 'refresh'], + ['access-denied', 'repositoryAccessDenied', 'github'], + ['connected-empty', 'noRepositoriesVisible', 'github'], + ['connect', 'connectGithubDescription', 'github'], +] as const)( + 'keeps loaded rows available beside %s and exposes its recovery', + async (status, message, recovery) => { + await render([ + { key: 'github', status, repositories: [] }, + { key: 'bitbucket', status: 'repos', repositories: [] }, + ]); + expect(trigger()?.disabled).toBe(false); + expect(text()).toContain(message); + if (status === 'access-denied') { + expect(text()).not.toContain('refreshRepositories'); + } + await press(recovery === 'refresh' ? 'retry' : 'agentChat.newSession.openGithub'); + expect(output().recovery).toBe(recovery); + } +); + +it('rejects a stale picker callback after the owner changes without choosing a same-name repository', async () => { + const groups: RepositoryGroup[] = [{ key: 'bitbucket', status: 'repos', repositories: [] }]; + await render(groups); + await open(); + const previous = native.bridge; + await render(groups, [repo('integration-1', 'org-2')]); + await act(() => { + previous?.onSelect(rows[0].key); + }); + expect(output().value).toBe(''); +}); + +it('keeps first-use empty selection normal and explains a removed selection', async () => { + const groups: RepositoryGroup[] = [ + { key: 'gitlab', status: 'connected-empty', repositories: [] }, + { key: 'bitbucket', status: 'repos', repositories: [] }, + ]; + await render(groups, []); + expect(text()).toContain('noRepositoriesVisibleGitlab'); + expect(text()).not.toContain('repositoryUnavailable'); + expect(trigger()).toBeUndefined(); + await render(groups); + await open(); + await act(() => { + native.bridge?.onSelect(rows[0].key); + }); + await render(groups, []); + expect(text()).toContain('repositoryUnavailable'); +}); diff --git a/apps/mobile/src/components/agents/new-session-repository-section.tsx b/apps/mobile/src/components/agents/new-session-repository-section.tsx index 00867d28ff..e2415449b9 100644 --- a/apps/mobile/src/components/agents/new-session-repository-section.tsx +++ b/apps/mobile/src/components/agents/new-session-repository-section.tsx @@ -1,13 +1,22 @@ -import { Fragment, type ReactElement } from 'react'; +import { Fragment, type ReactElement, useEffect, useRef } from 'react'; import { ActivityIndicator, View } from 'react-native'; import { useTranslation } from 'react-i18next'; +import { useActionSheet } from '@expo/react-native-action-sheet'; +import { useRouter } from 'expo-router'; +import { useQuery } from '@tanstack/react-query'; import { ExternalLink, RefreshCw } from '@/components/ui/icons'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import { AccessibleStatus } from '@/components/ui/accessible-status'; import { QueryError } from '@/components/query-error'; import { RepoSelector } from '@/components/agents/repo-selector'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; +import { useTRPC } from '@/lib/trpc'; +import { withRepositoryAccount } from '@/lib/use-github-repos-refresh'; +import { REPO_PLATFORM_LABEL_KEYS } from '@/lib/picker-bridge'; +import { RepositoryBranchSelector } from './repository-branch-selector'; import { type NewSessionRepository, type RepositoryGroup, @@ -17,11 +26,10 @@ import { type NewSessionRepositorySectionProps = { disabled: boolean; isRetrying: boolean; - onChange: (fullName: string) => void; + onChange: (key: string) => void; onConnect: (platform: RepositoryPlatform) => void; onRefreshRepos: () => void; repositories: NewSessionRepository[]; - /** Recently used rows for the picker's "Recently used" section. */ recents: NewSessionRepository[]; groups: RepositoryGroup[]; value: string; @@ -64,11 +72,6 @@ const PROVIDER_COPY = { } >; -/** - * Provider-aware repository section. One group per provider renders its own - * connect/empty/error state independently, and the picker trigger lists every - * repository plus the Recently used rows when any provider has rows. - */ export function NewSessionRepositorySection({ disabled, isRetrying, @@ -82,16 +85,14 @@ export function NewSessionRepositorySection({ }: Readonly) { const colors = useThemeColors(); const { t } = useTranslation(); - const hasRepos = repositories.length > 0; const anyLoading = groups.some(group => group.status === 'loading'); - + const selected = repositories.find(repo => repo.key === value); return ( {t('agentChat.newSession.repository')} - {(hasRepos || anyLoading) && ( )} - + + {selected ? ( + { + onConnect(selected.platform); + }} + connectLabel={t(PROVIDER_COPY[selected.platform].openLabel)} + /> + ) : null} {groups.map(group => ( {renderGroupCard(group.key, group.status)} ))} + {!groups.some(group => group.key === 'bitbucket') ? ( + + ) : null} ); @@ -113,51 +128,64 @@ export function NewSessionRepositorySection({ platform: RepositoryPlatform, status: RepositoryGroup['status'] ): ReactElement | null { - switch (status) { - case 'connect': { - return renderConnectCard(platform); - } - case 'connected-empty': { - return renderConnectedEmptyCard(platform); - } - case 'error': { - return ( - - - - ); - } - case 'loading': { - return null; - } - case 'repos': { - return null; - } - default: { - return null; - } - } - } - - function renderConnectCard(platform: RepositoryPlatform): ReactElement | null { const copy = PROVIDER_COPY[platform]; + if (status === 'repos') { + return null; + } + if (status === 'loading') { + return ( + + + + + ); + } + if (status === 'error' || status === 'identity-unavailable') { + return ( + + + + ); + } + const connectedEmpty = status === 'connected-empty'; + const description = connectedEmpty ? copy.emptyDescription : copy.connectDescription; return ( - {t(copy.connectTitle)} - {t(copy.connectDescription)} + + {t(connectedEmpty ? copy.connectedTitle : copy.connectTitle)} + + - + {status !== 'access-denied' ? ( + + ) : null} ); } +} - function renderConnectedEmptyCard(platform: RepositoryPlatform): ReactElement | null { - const copy = PROVIDER_COPY[platform]; - return ( - - - {t(copy.connectedTitle)} - {t(copy.emptyDescription)} - - - - - - ); - } +function PersonalBitbucketNotice({ disabled }: { disabled: boolean }) { + const { t } = useTranslation(); + const trpc = useTRPC(); + const router = useRouter(); + const { userId } = useCurrentUserId(); + const currentAccount = useRef(userId); + currentAccount.current = userId; + useEffect(() => { + currentAccount.current = userId; + return () => { + currentAccount.current = undefined; + }; + }, [userId]); + const { showActionSheetWithOptions } = useActionSheet(); + const query = useQuery({ + ...withRepositoryAccount(trpc.organizations.list.queryOptions(), userId), + enabled: Boolean(userId), + }); + const organizations = query.data ?? []; + return ( + + + {t('agentChat.newSession.personalBitbucket')} + + {query.isError ? ( + { + void query.refetch(); + }} + isRetrying={query.isFetching} + /> + ) : null} + {organizations.length > 0 ? ( + + ) : null} + + ); } diff --git a/apps/mobile/src/components/agents/new-session-repository-state.test.ts b/apps/mobile/src/components/agents/new-session-repository-state.test.ts index 29a64838a6..402aa524c6 100644 --- a/apps/mobile/src/components/agents/new-session-repository-state.test.ts +++ b/apps/mobile/src/components/agents/new-session-repository-state.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; - +import { type LaunchRepositoryReference } from '@kilocode/app-shared/code-review/repository-identity'; import { dedupeRepositoriesByPlatformAndFullName, - type NewSessionRepository, + normalizeSessionRepository, type RepositoryGroup, resolveBitbucketStatus, resolveProviderStatus, @@ -10,140 +10,91 @@ import { } from './new-session-repository-state'; describe('resolveProviderStatus', () => { - it('returns loading while the provider query is loading', () => { - expect( - resolveProviderStatus({ - isLoading: true, - isError: false, - integrationInstalled: true, - repositoryCount: 5, - }) - ).toBe('loading'); - }); - - it('returns error when the query failed with no cached repos', () => { - expect( - resolveProviderStatus({ - isLoading: false, - isError: true, - integrationInstalled: undefined, - repositoryCount: 0, - }) - ).toBe('error'); - }); - - it('keeps cached repos visible after a background refetch error', () => { - expect( - resolveProviderStatus({ - isLoading: false, - isError: true, - integrationInstalled: undefined, - repositoryCount: 3, - }) - ).toBe('repos'); - }); - - it('returns connect when the provider is not installed', () => { - expect( - resolveProviderStatus({ - isLoading: false, - isError: false, - integrationInstalled: false, - repositoryCount: 0, - }) - ).toBe('connect'); - }); - - it('returns connected-empty when installed but no repos are visible', () => { + it.each<[Partial[0]>, string]>([ + [{ isLoading: true, repositoryCount: 5 }, 'loading'], + [{ isError: true, integrationInstalled: undefined }, 'error'], + [{ isError: true, integrationInstalled: undefined, repositoryCount: 3 }, 'error'], + [{ integrationInstalled: false }, 'connect'], + [{ integrationInstalled: false, isError: true }, 'connect'], + [{ integrationInstalled: false, isError: true, isLoading: true }, 'connect'], + [{}, 'connected-empty'], + [{ repositoryCount: 3 }, 'repos'], + [{ errorCode: 'FORBIDDEN' }, 'access-denied'], + [{ errorCode: 'BAD_REQUEST' }, 'access-denied'], + [{ errorCode: 'UNAUTHORIZED' }, 'connect'], + [{ errorCode: 'PRECONDITION_FAILED' }, 'connect'], + [{ hasUnresolved: true }, 'identity-unavailable'], + ])('maps %j to the distinct recovery state %s', (input, expected) => { expect( resolveProviderStatus({ isLoading: false, isError: false, integrationInstalled: true, repositoryCount: 0, + ...input, }) - ).toBe('connected-empty'); - }); - - it('returns repos when installed with repos visible', () => { - expect( - resolveProviderStatus({ - isLoading: false, - isError: false, - integrationInstalled: true, - repositoryCount: 3, - }) - ).toBe('repos'); + ).toBe(expected); }); }); describe('resolveBitbucketStatus', () => { - it('returns loading while the query is loading', () => { - expect( - resolveBitbucketStatus({ - isLoading: true, - isError: false, - status: undefined, - repositoryCount: 0, - }) - ).toBe('loading'); - }); - - it('returns connect for a not_connected status', () => { - expect( - resolveBitbucketStatus({ - isLoading: false, - isError: false, - status: 'not_connected', - repositoryCount: 0, - }) - ).toBe('connect'); - }); - - it('returns error for temporarily_unavailable', () => { - expect( - resolveBitbucketStatus({ - isLoading: false, - isError: false, - status: 'temporarily_unavailable', - repositoryCount: 0, - }) - ).toBe('error'); - }); - - it('returns connected-empty when available with no repos', () => { + it.each<[Partial[0]>, string]>([ + [{ isLoading: true, status: undefined }, 'loading'], + [{ status: undefined }, 'loading'], + [{ status: 'not_connected' }, 'connect'], + [{ status: 'workspace_selection_required' }, 'connect'], + [{ status: 'reconnect_required' }, 'connect'], + [{ status: 'temporarily_unavailable' }, 'error'], + [{ isError: true, repositoryCount: 3 }, 'error'], + [{}, 'connected-empty'], + [{ repositoryCount: 2 }, 'repos'], + [{ status: 'insufficient_permissions' }, 'access-denied'], + [{ status: 'invalid_request' }, 'access-denied'], + [{ errorCode: 'FORBIDDEN' }, 'access-denied'], + [{ errorCode: 'UNAUTHORIZED' }, 'connect'], + [{ hasUnresolved: true }, 'identity-unavailable'], + ])('maps %j to the distinct recovery state %s', (input, expected) => { expect( resolveBitbucketStatus({ isLoading: false, isError: false, status: 'available', repositoryCount: 0, + ...input, }) - ).toBe('connected-empty'); - }); - - it('returns repos when available with repos', () => { - expect( - resolveBitbucketStatus({ - isLoading: false, - isError: false, - status: 'available', - repositoryCount: 2, - }) - ).toBe('repos'); + ).toBe(expected); }); }); -const github = (fullName: string): NewSessionRepository => ({ - platform: 'github', - fullName, - isPrivate: false, -}); -const gitlab = (fullName: string): NewSessionRepository => ({ - platform: 'gitlab', - fullName, - isPrivate: false, -}); +function repository(platform: 'github' | 'gitlab', fullName: string) { + const row = normalizeSessionRepository( + { + private: false, + repositoryReference: { + repository: { + provider: platform, + fullName, + instanceUrl: `https://${platform}.com`, + repositoryId: '1', + defaultBranch: null, + }, + authorization: { + kind: 'ownerIntegration', + owner: { type: 'org', id: 'org-1' }, + integrationId: 'integration-1', + }, + }, + }, + 'user-1', + 'org-1' + ); + if (!row) { + throw new Error('Invalid repository fixture'); + } + return row; +} +const github = (fullName: string) => repository('github', fullName); +const gitlab = (fullName: string) => repository('gitlab', fullName); describe('dedupeRepositoriesByPlatformAndFullName', () => { it('keeps the same fullName on two platforms as two rows', () => { @@ -175,11 +126,7 @@ const group = ( }); describe('resolveRepositoryGroups', () => { - const githubRow: NewSessionRepository = { - platform: 'github', - fullName: 'owner/repo', - isPrivate: false, - }; + const githubRow = github('owner/repo'); it('hides the Bitbucket group when no organization is set', () => { const { groups } = resolveRepositoryGroups({ @@ -206,4 +153,145 @@ describe('resolveRepositoryGroups', () => { expect(githubGroup?.repositories).toEqual([githubRow]); expect(gitlabGroup?.status).toBe('error'); }); + + it.each<[RepositoryGroup['status'], boolean]>([ + ['loading', true], + ['error', true], + ['identity-unavailable', true], + ['connect', false], + ['access-denied', false], + ])('keeps cached rows and recents usable only when %s permits it', (status, retain) => { + const result = resolveRepositoryGroups({ + organizationId: 'org-1', + github: group('github', { status, repositories: [githubRow] }), + gitlab: group('gitlab', { status: 'connected-empty' }), + bitbucket: group('bitbucket', { status: 'connect' }), + recents: [githubRow], + }); + expect(result.groups[0]?.repositories).toEqual(retain ? [githubRow] : []); + expect(result.recents).toEqual(retain ? [githubRow] : []); + }); +}); + +const reference: LaunchRepositoryReference = { + repository: { + provider: 'gitlab', + instanceUrl: 'https://git.example.com/base', + repositoryId: '42', + fullName: 'group/nested/Repo', + defaultBranch: 'develop', + }, + authorization: { + kind: 'ownerIntegration', + owner: { type: 'org', id: 'org-1' }, + integrationId: 'integration-1', + }, +}; + +it('keeps every identity component distinct through normalization and deduplication', () => { + const variants: LaunchRepositoryReference[] = [ + reference, + { + ...reference, + authorization: { ...reference.authorization, owner: { type: 'user', id: 'user-1' } }, + }, + { + ...reference, + authorization: { ...reference.authorization, owner: { type: 'org', id: 'org-2' } }, + }, + { ...reference, authorization: { ...reference.authorization, integrationId: 'integration-2' } }, + { + ...reference, + repository: { ...reference.repository, instanceUrl: 'https://git.example.com/other' }, + }, + { ...reference, repository: { ...reference.repository, repositoryId: '43' } }, + { ...reference, repository: { ...reference.repository, fullName: 'group/nested/repo' } }, + { + ...reference, + repository: { + provider: 'github', + instanceUrl: reference.repository.instanceUrl, + repositoryId: '42', + fullName: reference.repository.fullName, + defaultBranch: null, + }, + }, + { + ...reference, + repository: { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + repositoryId: 'repo-uuid', + workspaceUuid: 'workspace-1', + fullName: 'team/repo', + defaultBranch: 'release', + }, + }, + { + ...reference, + repository: { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + repositoryId: 'repo-uuid', + workspaceUuid: 'workspace-2', + fullName: 'team/repo', + defaultBranch: 'release', + }, + }, + ]; + const rows = variants.map(ref => + normalizeSessionRepository( + { private: true, repositoryReference: ref }, + 'user-1', + ref.authorization.owner.type === 'org' ? ref.authorization.owner.id : undefined + ) + ); + const account = normalizeSessionRepository( + { private: true, repositoryReference: reference }, + 'user-2', + 'org-1' + ); + const resolved = [...rows, account].filter(row => row !== null); + expect(resolved).toHaveLength(variants.length + 1); + expect(dedupeRepositoriesByPlatformAndFullName([...resolved, ...resolved])).toEqual(resolved); + expect(resolved[0]?.reference.repository.defaultBranch).toBe('develop'); + expect(resolved[8]).toMatchObject({ workspaceUuid: 'workspace-1', repositoryUuid: 'repo-uuid' }); +}); + +it('quarantines missing identity, wrong owners, and Personal Bitbucket without inventing a reference', () => { + expect(normalizeSessionRepository({ private: true }, 'user-1', 'org-1')).toBeNull(); + expect( + normalizeSessionRepository({ private: true, repositoryReference: reference }, 'user-1', 'org-2') + ).toBeNull(); + expect( + normalizeSessionRepository( + { private: true, repositoryReference: reference }, + undefined, + 'org-1' + ) + ).toBeNull(); + expect( + normalizeSessionRepository( + { + private: true, + repositoryReference: { + repository: { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + repositoryId: 'repo', + workspaceUuid: 'workspace', + fullName: 'team/repo', + defaultBranch: null, + }, + authorization: { + kind: 'ownerIntegration', + owner: { type: 'user', id: 'user-1' }, + integrationId: 'integration-1', + }, + }, + }, + 'user-1', + undefined + ) + ).toBeNull(); }); diff --git a/apps/mobile/src/components/agents/new-session-repository-state.ts b/apps/mobile/src/components/agents/new-session-repository-state.ts index e79d58a603..9fd67f5d6e 100644 --- a/apps/mobile/src/components/agents/new-session-repository-state.ts +++ b/apps/mobile/src/components/agents/new-session-repository-state.ts @@ -1,24 +1,96 @@ -import { type RepoPlatform } from '@/lib/picker-bridge'; +import { type CodeReviewPlatform } from '@kilocode/app-shared/code-review'; +import { + type LaunchRepositoryReference, + repositoryResourceKey, +} from '@kilocode/app-shared/code-review/repository-identity'; +import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; -export type RepositoryPlatform = RepoPlatform; +export type RepositoryPlatform = CodeReviewPlatform; -/** - * One selectable repository row. `platform` is required so two rows with the - * same `fullName` on different providers stay distinct in the picker and in - * the create payload. `workspaceUuid`/`repositoryUuid` are only present on - * Bitbucket rows (`repositoryUuid` = the Bitbucket repository `id`). - */ export type NewSessionRepository = { platform: RepositoryPlatform; fullName: string; isPrivate: boolean; workspaceUuid?: string; repositoryUuid?: string; + // Old creator callers omit identity. Remove this input form only after old + // clients/records disappear and the 30-day ledger window expires. + reference?: LaunchRepositoryReference; + key?: string; + accountId?: string; + accountLogin?: string; +}; + +export type ResolvedNewSessionRepository = NewSessionRepository & { + reference: LaunchRepositoryReference; + key: string; + accountId: string; }; +type RepositoryWire = Pick< + inferRouterOutputs['cloudAgentNext']['listGitHubRepositories']['repositories'][number], + 'private' | 'repositoryReference' | 'platformAccountLogin' +>; + +export function normalizeSessionRepository( + row: RepositoryWire, + accountId: string | undefined, + organizationId: string | undefined +): ResolvedNewSessionRepository | null { + const reference = row.repositoryReference; + // Old discovery responses without identity remain quarantined until refreshed. + // Remove after old clients/records disappear and the 30-day ledger window expires. + if (!reference || !accountId) { + return null; + } + const { repository, authorization } = reference; + if ( + authorization.owner.type !== (organizationId ? 'org' : 'user') || + authorization.owner.id !== (organizationId ?? accountId) || + (repository.provider === 'bitbucket' && !organizationId) + ) { + return null; + } + return { + platform: repository.provider, + fullName: repository.fullName, + isPrivate: row.private, + reference, + accountId, + key: repositoryResourceKey(accountId, reference), + accountLogin: row.platformAccountLogin, + ...(repository.provider === 'bitbucket' + ? { workspaceUuid: repository.workspaceUuid, repositoryUuid: repository.repositoryId } + : {}), + }; +} + +export function repositoryKey(repository: ResolvedNewSessionRepository): string { + return repository.key; +} + +export function repositoryLabel(repository: NewSessionRepository): string { + const reference = repository.reference; + if (!reference) { + return repository.fullName; + } + const { owner, integrationId } = reference.authorization; + return [ + repository.fullName, + repository.accountLogin, + reference.repository.instanceUrl, + `${owner.type}:${owner.id}`, + integrationId, + ] + .filter(Boolean) + .join(' · '); +} + export type RepositoryProviderStatus = | 'loading' | 'error' + | 'access-denied' + | 'identity-unavailable' | 'connect' | 'connected-empty' | 'repos'; @@ -26,12 +98,12 @@ export type RepositoryProviderStatus = export type RepositoryGroup = { key: RepositoryPlatform; status: RepositoryProviderStatus; - repositories: NewSessionRepository[]; + repositories: ResolvedNewSessionRepository[]; }; export type RepositoryGroups = { /** Recently used rows, resolved against connected providers. */ - recents: NewSessionRepository[]; + recents: ResolvedNewSessionRepository[]; /** Ordered groups: GitHub, GitLab, then Bitbucket (only when an organization is set). */ groups: RepositoryGroup[]; }; @@ -47,20 +119,34 @@ export function resolveProviderStatus({ isError, integrationInstalled, repositoryCount, + errorCode, + hasUnresolved = false, }: { isLoading: boolean; isError: boolean; integrationInstalled: boolean | undefined; repositoryCount: number; + errorCode?: string; + hasUnresolved?: boolean; }): RepositoryProviderStatus { + if (errorCode === 'FORBIDDEN' || errorCode === 'BAD_REQUEST') { + return 'access-denied'; + } + if ( + errorCode === 'UNAUTHORIZED' || + errorCode === 'PRECONDITION_FAILED' || + integrationInstalled === false + ) { + return 'connect'; + } if (isLoading) { return 'loading'; } - if (isError && repositoryCount === 0) { + if (isError) { return 'error'; } - if (integrationInstalled === false) { - return 'connect'; + if (hasUnresolved) { + return 'identity-unavailable'; } if (integrationInstalled === true && repositoryCount === 0) { return 'connected-empty'; @@ -75,53 +161,67 @@ export function resolveProviderStatus({ * * - `available` -> repos / connected-empty * - connect-shaped statuses -> connect (open the Bitbucket settings page) - * - transient/invalid -> error (retry) + * - transient failure -> error (retry) + * - invalid/denied -> access-denied (correct access or selection) */ export function resolveBitbucketStatus({ isLoading, isError, status, repositoryCount, + errorCode, + hasUnresolved = false, }: { isLoading: boolean; isError: boolean; status: string | undefined; repositoryCount: number; + errorCode?: string; + hasUnresolved?: boolean; }): RepositoryProviderStatus { + if ( + errorCode === 'FORBIDDEN' || + status === 'insufficient_permissions' || + status === 'invalid_request' + ) { + return 'access-denied'; + } + if ( + errorCode === 'UNAUTHORIZED' || + status === 'not_connected' || + status === 'workspace_selection_required' || + status === 'reconnect_required' + ) { + return 'connect'; + } if (isLoading) { return 'loading'; } - if (isError && repositoryCount === 0) { + if (isError || status === 'temporarily_unavailable') { return 'error'; } + if (hasUnresolved) { + return 'identity-unavailable'; + } if (status === undefined) { return 'loading'; } if (status === 'available') { return repositoryCount === 0 ? 'connected-empty' : 'repos'; } - if (status === 'temporarily_unavailable' || status === 'invalid_request') { - return 'error'; - } - // not_connected, workspace_selection_required, reconnect_required, - // insufficient_permissions -> the user must (re)establish the connection. + // not_connected, workspace_selection_required, reconnect_required: + // the user must establish the connection. return 'connect'; } // ── Dedup and grouping ─────────────────────────────────────────────── -const repositoryKey = (repository: NewSessionRepository): string => - `${repository.platform}/${repository.fullName}`; - -/** - * Deduplicate repository rows by `platform + fullName`, so the same - * `fullName` on two platforms stays two rows. - */ -export function dedupeRepositoriesByPlatformAndFullName( - repositories: readonly NewSessionRepository[] -): NewSessionRepository[] { +/** Keep the old export name; normalized discovery deduplicates by the complete identity. */ +export function dedupeRepositoriesByPlatformAndFullName( + repositories: readonly T[] +): T[] { const seen = new Set(); - const result: NewSessionRepository[] = []; + const result: T[] = []; for (const repository of repositories) { const key = repositoryKey(repository); if (!seen.has(key)) { @@ -143,20 +243,20 @@ export function resolveRepositoryGroups(input: { github: RepositoryGroup; gitlab: RepositoryGroup; bitbucket: RepositoryGroup; - recents: NewSessionRepository[]; + recents: ResolvedNewSessionRepository[]; }): RepositoryGroups { - const groups: RepositoryGroup[] = [ - { key: 'github', status: input.github.status, repositories: input.github.repositories }, - { key: 'gitlab', status: input.gitlab.status, repositories: input.gitlab.repositories }, - ]; - if (input.organizationId) { - groups.push({ - key: 'bitbucket', - status: input.bitbucket.status, - repositories: input.bitbucket.repositories, - }); - } - return { recents: input.recents, groups }; + const groups = [ + input.github, + input.gitlab, + ...(input.organizationId ? [input.bitbucket] : []), + ].map(group => ({ + key: group.key, + status: group.status, + repositories: + group.status === 'connect' || group.status === 'access-denied' ? [] : group.repositories, + })); + const usableKeys = new Set(groups.flatMap(group => group.repositories.map(repo => repo.key))); + return { recents: input.recents.filter(repo => usableKeys.has(repo.key)), groups }; } /** diff --git a/apps/mobile/src/components/agents/new-session-screen-body.mounted.test.tsx b/apps/mobile/src/components/agents/new-session-screen-body.mounted.test.tsx index 33f2c46df7..cf812906c6 100644 --- a/apps/mobile/src/components/agents/new-session-screen-body.mounted.test.tsx +++ b/apps/mobile/src/components/agents/new-session-screen-body.mounted.test.tsx @@ -1,10 +1,16 @@ /* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer mounts the actual screen and both creators without a DOM */ /* eslint-disable require-await, @typescript-eslint/require-await -- native storage and transport doubles return promises */ /* eslint-disable max-lines -- the screen dependencies and overlapping launch regressions share one mounted harness */ -import { type ComponentProps, createElement } from 'react'; +import { type ComponentProps, createElement, useState } from 'react'; +import { type CodeReviewPlatform } from '@kilocode/app-shared/code-review'; +import { type LaunchRepositoryReference } from '@kilocode/app-shared/code-review/repository-identity'; +import { normalizeSessionRepository } from './new-session-repository-state'; +import { type ProviderPrepareInput } from './provider-launch-input'; +import { NewSessionRepositorySection } from './new-session-repository-section'; import TestRenderer, { act } from 'react-test-renderer'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { Button } from '@/components/ui/button'; import { listOutboxRows } from '@/lib/persist/mutation-outbox'; import { NewSessionConfigureForm } from './new-session-configure-form'; import { NewSessionScreenBody } from './new-session-screen-body'; @@ -13,35 +19,104 @@ type FormProps = ComponentProps; type RemoveEvent = { data: { action: { type: string } } }; const native = vi.hoisted(() => ({ userId: 'user-1', - organizationId: 'org-1', - selectedRepo: 'github:owner/repo', + organizationId: 'org-1' as string | undefined, + selectedRepo: '', + platform: 'github' as CodeReviewPlatform, + integrationId: 'integration-1', + repositoryId: '42', + gitlabInstanceUrl: 'https://git.example.com/base', + cloneFromKiloSessionId: 'ses_source', + choose: undefined as ((index?: number) => void) | undefined, + branchOptions: [] as string[], + branches: ['develop', 'feature/b4', 'feature/next'], + defaultBranch: 'develop' as string | null, + organizations: [] as { organizationId: string; organizationName: string }[], + organizationDestination: '', path: '/continue', nextKey: 0, errors: [] as string[], + draft: null as string | null, + branchError: null as Error | null, + alert: null as { + title: string; + message: string; + buttons: { text: string; onPress: () => void }[]; + } | null, leaveLocked: false, beforeRemove: undefined as ((event: RemoveEvent) => void) | undefined, rows: new Map(), })); -vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('react-native', () => ({ + View: 'View', + Pressable: 'Pressable', + ActivityIndicator: 'ActivityIndicator', + Alert: { + alert: (title: string, message: string, buttons: { text: string; onPress: () => void }[]) => { + native.alert = { title, message, buttons }; + }, + }, +})); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/accessible-status', () => ({ AccessibleStatus: 'AccessibleStatus' })); +vi.mock('@/components/ui/icons', () => ({ + ChevronDown: 'icon', + ExternalLink: 'icon', + RefreshCw: 'icon', +})); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({}) })); +vi.mock('@/lib/route-registry', () => ({ + UNFENCED_ROUTE_KEY: '', + repoPickerSlot: { set: vi.fn() }, +})); vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); vi.mock('./new-session-configure-form', () => ({ - NewSessionConfigureForm: (props: FormProps) => createElement('configure-form', props), + NewSessionConfigureForm: (props: FormProps) => + createElement( + 'configure-form', + props, + createElement(NewSessionRepositorySection, { + disabled: props.isCreating, + isRetrying: props.isRetrying, + onChange: props.onChangeRepo, + onConnect: props.onConnectProvider, + onRefreshRepos: props.onRefreshRepos, + repositories: props.repositories, + recents: props.recents, + groups: props.groups, + value: props.selectedRepo, + }) + ), +})); +vi.mock('@/lib/pr-review/connect-gate-platform', () => ({ + openAuthorizationAndWaitForReturn: vi.fn(), })); vi.mock('@/i18n', () => ({ i18n: { t: (key: string) => key } })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); vi.mock('@expo/react-native-action-sheet', () => ({ - useActionSheet: () => ({ showActionSheetWithOptions: vi.fn() }), + useActionSheet: () => ({ + showActionSheetWithOptions: ( + options: { options: string[] }, + choose: (index?: number) => void + ) => { + native.branchOptions = options.options; + native.choose = choose; + }, + }), })); vi.mock('expo-router', () => ({ useLocalSearchParams: () => ({ organizationId: native.organizationId, - cloneFromKiloSessionId: 'ses_source', + cloneFromKiloSessionId: native.cloneFromKiloSessionId, }), useRouter: () => ({ replace: (path: string) => { native.path = path; }, + setParams: ({ organizationId }: { organizationId: string }) => { + native.organizationDestination = organizationId; + }, }), useNavigation: () => ({ dispatch: () => { @@ -62,8 +137,33 @@ vi.mock('@/lib/hooks/use-current-user-id', () => ({ useCurrentUserId: () => ({ userId: native.userId, isLoading: false }), })); vi.mock('@tanstack/react-query', () => ({ + skipToken: Symbol('skipToken'), useQueryClient: () => ({}), - useQuery: () => ({ data: { instances: [] }, isLoading: false, refetch: vi.fn() }), + useQuery: (options: { queryKey?: readonly [readonly string[], ...unknown[]] }) => ({ + data: options.queryKey?.[0][0] === 'organizations' ? native.organizations : { instances: [] }, + isLoading: false, + refetch: vi.fn(), + }), + useInfiniteQuery: () => ({ + data: { + pages: [ + { + branches: native.branches.map(name => ({ + name, + isDefault: name === native.defaultBranch, + })), + defaultBranch: native.defaultBranch, + nextCursor: null, + }, + ], + }, + isPending: false, + isError: native.branchError !== null, + isFetching: false, + // The missing-default fixture still has another branch page. + hasNextPage: native.defaultBranch === null, + error: native.branchError, + }), })); vi.mock('@/components/agents/new-session-model-provider', () => ({ useNewSessionModelState: () => ({ @@ -105,16 +205,16 @@ vi.mock('@/components/agents/use-effective-agent-profile', () => ({ }), })); vi.mock('@/components/agents/use-new-session-prefill', () => ({ - useNewSessionPrefillTargets: () => ({ - selectedRepo: native.selectedRepo, - setSelectedRepo: vi.fn(), - }), + useNewSessionPrefillTargets: () => { + const [selectedRepo, setSelectedRepo] = useState(native.selectedRepo); + return { selectedRepo, setSelectedRepo }; + }, })); vi.mock('@/lib/use-new-session-repos', () => ({ useNewSessionRepos: () => ({ - repositories: [{ platform: 'github', fullName: 'owner/repo', isPrivate: true }], + repositories: [currentRepository()], recents: [], - groups: [], + groups: native.organizationId ? [{ key: 'bitbucket', status: 'repos', repositories: [] }] : [], reposSettled: true, isRetrying: false, openIntegration: vi.fn(), @@ -129,13 +229,18 @@ vi.mock('@/lib/use-new-session-share-remote', () => ({ })); vi.mock('@/lib/persist/drafts', () => ({ NEW_SESSION_DRAFT_KEY: 'new-session', - clearDraft: vi.fn(), - saveDraft: vi.fn(), - resolvePrefillOverDraft: vi.fn(), + clearDraft: async () => { + native.draft = null; + return true; + }, + saveDraft: (_userId: string, _key: string, value: string) => { + native.draft = value; + }, + resolvePrefillOverDraft: (prefill: string | null, draft: string | null) => prefill ?? draft, })); vi.mock('@/lib/persist/use-draft-flush', () => ({ useDraftFlushOnBackground: vi.fn() })); vi.mock('@/lib/persist/use-draft-load', () => ({ - useFencedDraftLoad: () => ({ settled: true, value: null }), + useFencedDraftLoad: () => ({ settled: true, value: native.draft }), useRemoteSpawnDraftCleanup: () => ({ markRemoteSpawnAttempted: vi.fn() }), })); vi.mock('@/lib/share-payload', () => ({ peekSharePayload: vi.fn() })); @@ -216,21 +321,87 @@ vi.mock('@/components/agents/mobile-session-page-adapter', () => ({ vi.mock('@/components/agents/tool-card-image-cache', () => ({ cacheToolAttachment: vi.fn() })); vi.mock('@/components/agents/file-part-cache', () => ({ cacheFilePart: vi.fn() })); vi.mock('@/lib/trpc', () => ({ - useTRPC: () => ({ activeSessions: { listInstances: { queryOptions: () => ({}) } } }), + useTRPC: () => ({ + activeSessions: { listInstances: { queryOptions: () => ({}) } }, + cloudAgentNext: { + listRepositoryBranches: { + infiniteQueryOptions: (input: unknown) => ({ + queryKey: [['personal-branches'], { input, type: 'infinite' }], + }), + }, + }, + organizations: { + list: { queryOptions: () => ({ queryKey: [['organizations', 'list']] }) }, + cloudAgentNext: { + listRepositoryBranches: { + infiniteQueryOptions: (input: unknown) => ({ + queryKey: [['org-branches'], { input, type: 'infinite' }], + }), + }, + }, + }, + }), trpcClient: { cloudAgentNext: { prepareSession: { mutate: prepare } }, organizations: { cloudAgentNext: { prepareSession: { mutate: prepare } } }, }, })); -type Payload = { operationKey: string; organizationId?: string }; +function currentRepository() { + const identity = { + instanceUrl: + native.platform === 'gitlab' + ? native.gitlabInstanceUrl + : `https://${native.platform === 'github' ? 'github.com' : 'bitbucket.org'}`, + repositoryId: native.repositoryId, + fullName: native.platform === 'gitlab' ? 'owner/nested/repo' : 'owner/repo', + defaultBranch: 'develop', + }; + const reference: LaunchRepositoryReference = { + repository: + native.platform === 'bitbucket' + ? { + ...identity, + provider: 'bitbucket', + repositoryId: 'repo-uuid', + workspaceUuid: 'workspace-uuid', + } + : { ...identity, provider: native.platform }, + authorization: { + kind: 'ownerIntegration', + owner: native.organizationId + ? { type: 'org', id: native.organizationId } + : { type: 'user', id: native.userId }, + integrationId: native.integrationId, + }, + }; + const row = normalizeSessionRepository( + { private: true, repositoryReference: reference }, + native.userId, + native.organizationId + ); + if (!row) { + throw new Error('Invalid repository fixture'); + } + return row; +} + +type Payload = ProviderPrepareInput & { + operationKey: string; + organizationId?: string; + prompt?: string; +}; const requests: { input: Payload; response: ReturnType>; }[] = []; +const acceptedSessions = new Map(); async function prepare(input: Payload) { const response = Promise.withResolvers<{ kiloSessionId: string }>(); requests.push({ input, response }); + if (!acceptedSessions.has(input.operationKey)) { + acceptedSessions.set(input.operationKey, `session-${acceptedSessions.size + 1}`); + } return response.promise; } async function storedKeys() { @@ -266,14 +437,27 @@ beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); native.userId = 'user-1'; native.organizationId = 'org-1'; - native.selectedRepo = 'github:owner/repo'; + native.platform = 'github'; + native.integrationId = 'integration-1'; + native.repositoryId = '42'; + native.gitlabInstanceUrl = 'https://git.example.com/base'; + native.branches = ['develop', 'feature/b4', 'feature/next']; + native.defaultBranch = 'develop'; + native.organizations = []; + native.organizationDestination = ''; + native.cloneFromKiloSessionId = 'ses_source'; + native.selectedRepo = currentRepository().key; native.path = '/continue'; native.nextKey = 0; native.errors = []; + native.draft = null; + native.branchError = null; + native.alert = null; native.leaveLocked = false; native.beforeRemove = undefined; native.rows.clear(); requests.length = 0; + acceptedSessions.clear(); }); afterEach(async () => { await act(async () => { @@ -301,6 +485,10 @@ it.each(['success', 'retryable', 'terminal'] as const)( screen?.update(createElement(NewSessionScreenBody)); }); expect(form().isCreating).toBe(false); + expect(form().isStartDisabled).toBe(true); + await act(async () => { + form().onChangeRepo(currentRepository().key); + }); await start(); const second = requests[1]; if (!second) { @@ -392,3 +580,586 @@ it('keeps Continue disabled when the selected repository is absent', async () => expect(native.errors).toEqual([]); expect(requests).toEqual([]); }); + +async function chooseBranch(branch: string) { + const button = screen?.root + .findAllByType(Button) + .map(node => node.props as { accessibilityLabel?: string; onPress: () => void }) + .find(props => props.accessibilityLabel === 'agentChat.newSession.branch'); + if (!button) { + throw new Error('Branch selector is absent'); + } + await act(() => { + button.onPress(); + }); + expect(native.branchOptions).toContain(branch); + await act(() => { + native.choose?.(native.branchOptions.indexOf(branch)); + }); +} + +const launchCases: { + platform: CodeReviewPlatform; + organizationId?: string; + expected: ProviderPrepareInput; +}[] = [ + { + platform: 'github', + expected: { githubRepo: 'owner/repo', githubIntegrationId: 'integration-1' }, + }, + { + platform: 'github', + organizationId: 'org-1', + expected: { githubRepo: 'owner/repo', githubIntegrationId: 'integration-1' }, + }, + { + platform: 'gitlab', + expected: { + gitlabProject: 'owner/nested/repo', + gitlabIntegrationId: 'integration-1', + gitlabInstanceUrl: 'https://git.example.com/base', + }, + }, + { + platform: 'gitlab', + organizationId: 'org-1', + expected: { + gitlabProject: 'owner/nested/repo', + gitlabIntegrationId: 'integration-1', + gitlabInstanceUrl: 'https://git.example.com/base', + }, + }, + { + platform: 'bitbucket', + organizationId: 'org-1', + expected: { + bitbucketRepo: { + fullName: 'owner/repo', + workspaceUuid: 'workspace-uuid', + repositoryUuid: 'repo-uuid', + }, + bitbucketIntegrationId: 'integration-1', + }, + }, +]; +it.each(['owner', 'repository', 'integration', 'instance'])( + 'resets the branch after a changed %s without deleting the prompt', + async change => { + native.platform = 'gitlab'; + native.cloneFromKiloSessionId = ''; + native.selectedRepo = currentRepository().key; + await mount(); + await act(() => { + form().onChangeText('Preserve this prompt'); + }); + await chooseBranch('feature/b4'); + if (change === 'owner') { + native.organizationId = 'org-2'; + } + if (change === 'repository') { + native.repositoryId = '84'; + } + if (change === 'integration') { + native.integrationId = 'integration-2'; + } + if (change === 'instance') { + native.gitlabInstanceUrl = 'https://git.example.com/other'; + } + await act(() => { + screen?.update(createElement(NewSessionScreenBody)); + }); + expect(form().isStartDisabled).toBe(true); + await act(() => { + form().onStartSession(); + }); + expect(requests).toEqual([]); + await act(() => { + form().onChangeRepo(currentRepository().key); + }); + await start(); + expect(requests[0]?.input).toMatchObject({ + prompt: 'Preserve this prompt', + upstreamBranch: 'develop', + gitlabIntegrationId: native.integrationId, + gitlabInstanceUrl: native.gitlabInstanceUrl, + }); + await act(() => { + requests[0]?.response.resolve({ kiloSessionId: 'session-reselected' }); + }); + } +); + +it.each([null, 'develop'])( + 'requires branch recovery without losing the prompt when the initial default is %s', + async defaultBranch => { + native.cloneFromKiloSessionId = ''; + native.branches = ['develop', 'main', 'feature/b4']; + native.defaultBranch = defaultBranch; + await mount(); + await act(() => { + form().onChangeText('Preserve the branch prompt'); + }); + if (defaultBranch === null) { + expect(form().isStartDisabled).toBe(true); + expect(JSON.stringify(screen?.toJSON())).toContain('defaultBranchUnavailable'); + await chooseBranch('feature/b4'); + } + expect(form().isStartDisabled).toBe(false); + native.branches = ['main']; + native.defaultBranch = 'main'; + await act(() => { + screen?.update(createElement(NewSessionScreenBody)); + }); + expect(form().isStartDisabled).toBe(true); + expect(JSON.stringify(screen?.toJSON())).toContain('branchUnavailable'); + expect(requests).toEqual([]); + await chooseBranch('main'); + await start(); + expect(requests[0]?.input).toMatchObject({ + upstreamBranch: 'main', + prompt: 'Preserve the branch prompt', + }); + await act(() => { + requests[0]?.response.resolve({ kiloSessionId: 'session-branch-recovery' }); + }); + } +); + +it('offers Personal Bitbucket organization switching and rejects a previous account choice', async () => { + native.organizationId = undefined; + native.platform = 'gitlab'; + native.selectedRepo = currentRepository().key; + await mount(); + expect(JSON.stringify(screen?.toJSON())).toContain('agentChat.newSession.personalBitbucket'); + expect(JSON.stringify(screen?.toJSON())).not.toContain( + 'providerReview.connection.switchOrganization' + ); + native.organizations = [{ organizationId: 'org-2', organizationName: 'Team Two' }]; + await act(() => { + screen?.update(createElement(NewSessionScreenBody)); + }); + async function openOrganizationChooser() { + const button = screen?.root + .findAllByType(Button) + .find( + node => + node.findAll(child => + child.children.includes('providerReview.connection.switchOrganization') + ).length > 0 + ); + if (!button) { + throw new Error('Organization chooser is absent'); + } + await act(() => { + (button.props as { onPress: () => void }).onPress(); + }); + } + await openOrganizationChooser(); + const previous = native.choose; + native.userId = 'user-2'; + await act(() => { + screen?.update(createElement(NewSessionScreenBody)); + }); + await act(() => { + previous?.(0); + }); + expect(native.organizationDestination).toBe(''); + await openOrganizationChooser(); + await act(() => { + native.choose?.(0); + }); + expect(native.organizationDestination).toBe('org-2'); +}); + +it.each( + (['ordinary', 'continue'] as const).flatMap(entry => + launchCases.map(({ platform, organizationId, expected }) => ({ + platform, + organizationId, + expected, + entry, + owner: organizationId ?? 'Personal', + })) + ) +)( + '$entry sends the exact $platform identity for $owner and changes the branch intent', + async ({ entry, platform, organizationId, expected }) => { + native.platform = platform; + native.organizationId = organizationId; + native.cloneFromKiloSessionId = entry === 'continue' ? 'ses_source' : ''; + native.selectedRepo = currentRepository().key; + await mount(); + if (entry === 'ordinary') { + await act(async () => { + form().onChangeText('Keep this prompt unchanged'); + }); + } + await chooseBranch('feature/b4'); + await start(); + const first = requests[0]; + if (!first) { + throw new Error('The selected launch was not admitted'); + } + expect(first.input).toMatchObject({ ...expected, upstreamBranch: 'feature/b4' }); + expect(first.input.organizationId).toBe(organizationId); + if (platform !== 'github') { + expect(first.input).not.toHaveProperty('githubRepo'); + } + if (entry === 'ordinary') { + expect(first.input.prompt).toBe('Keep this prompt unchanged'); + } else { + expect(first.input).not.toHaveProperty('prompt'); + } + const pendingRows = await listOutboxRows('user-1'); + const firstFingerprint = pendingRows?.[0]?.fingerprint; + expect(firstFingerprint).toContain('feature/b4'); + expect(firstFingerprint).toContain('integration-1'); + await act(() => { + native.choose?.(native.branchOptions.indexOf('feature/next')); + }); + expect(form().isCreating).toBe(true); + expect(JSON.stringify(screen?.toJSON())).toContain('"text":"feature/b4"'); + await act(async () => { + first.response.reject(new Error('Lost response')); + }); + await chooseBranch('feature/next'); + await start(); + const second = requests[1]; + if (!second) { + throw new Error('The changed branch was not admitted'); + } + expect(second.input).toMatchObject({ ...expected, upstreamBranch: 'feature/next' }); + expect(second.input.operationKey).not.toBe(first.input.operationKey); + if (entry === 'ordinary') { + expect(second.input.prompt).toBe('Keep this prompt unchanged'); + } + const stored = await listOutboxRows('user-1'); + const secondFingerprint = stored?.find( + row => row.operationKey === second.input.operationKey + )?.fingerprint; + expect(secondFingerprint).toContain('feature/next'); + expect(secondFingerprint).not.toBe(firstFingerprint); + expect(stored).toHaveLength(2); + await act(async () => { + second.response.resolve({ kiloSessionId: 'session-branch' }); + }); + expect(native.path).toContain('agent-chat/session-branch'); + } +); + +function seedLegacyRecord( + entry: 'bare' | 'ordinary' | 'continue', + invalid = false, + platform: CodeReviewPlatform = 'github' +) { + native.platform = platform; + native.selectedRepo = currentRepository().key; + native.cloneFromKiloSessionId = entry === 'continue' ? 'ses_source' : ''; + native.path = entry === 'continue' ? '/continue' : '/new'; + native.draft = 'Saved prompt'; + // These are deployed fingerprint bytes, not fingerprints from the new mapper. + const repo = { + github: '{"platform":"github","fullName":"owner/repo"}', + gitlab: '{"platform":"gitlab","fullName":"owner/nested/repo"}', + bitbucket: + '{"platform":"bitbucket","fullName":"owner/repo","workspaceUuid":"workspace-uuid","repositoryUuid":"repo-uuid"}', + }[platform]; + const fingerprint = { + continue: `{"cloneFromKiloSessionId":"ses_source","repo":${repo},"model":"model","mode":"code","organizationId":"org-1"}`, + bare: '{"prompt":"Saved prompt","mode":"code","model":"model","repo":"owner/repo","autoCommit":false,"organizationId":"org-1","profileId":null,"attachments":null}', + ordinary: `{"prompt":"Saved prompt","mode":"code","model":"model","repo":${repo},"autoCommit":false,"organizationId":"org-1","profileId":null,"attachments":null}`, + }[entry]; + const repositoryInputs: Record = { + github: { githubRepo: 'owner/repo' }, + gitlab: { gitlabProject: 'owner/nested/repo' }, + bitbucket: { + bitbucketRepo: { + fullName: 'owner/repo', + workspaceUuid: 'workspace-uuid', + repositoryUuid: 'repo-uuid', + }, + }, + }; + const input = { + ...(entry === 'continue' + ? { cloneFromKiloSessionId: 'ses_source' } + : { prompt: 'Saved prompt', initialMessageId: 'msg_before_upgrade' }), + mode: 'code', + model: 'model', + autoCommit: false, + autoInitiate: true, + operationKey: 'before-upgrade', + ...repositoryInputs[platform], + ...(invalid ? { githubIntegrationId: 'unresolved-pin' } : {}), + }; + const row = { taxonomy: 'safe-retry', operationKey: 'before-upgrade', fingerprint, input }; + native.rows.set(`outbox:user-1\0${fingerprint}`, { + scope: 'outbox:user-1', + k: fingerprint, + v: JSON.stringify(row), + }); + acceptedSessions.set('before-upgrade', 'session-before-upgrade'); + return row; +} + +it('quarantines competing legacy keys instead of selecting one admitted operation', async () => { + const bare = seedLegacyRecord('bare'); + const scoped = seedLegacyRecord('ordinary'); + const competing = { + ...scoped, + operationKey: 'another-original-key', + input: { ...scoped.input, operationKey: 'another-original-key' }, + }; + native.rows.set(`outbox:user-1\0${competing.fingerprint}`, { + scope: 'outbox:user-1', + k: competing.fingerprint, + v: JSON.stringify(competing), + }); + acceptedSessions.set(competing.operationKey, 'another-original-session'); + await mount(); + await start(); + expect(native.errors.at(-1)).toBe('agentChat.newSession.legacyLaunchUnavailable'); + expect(native.alert).toBeNull(); + expect(requests).toEqual([]); + expect(acceptedSessions.size).toBe(2); + expect(native.nextKey).toBe(0); + expect(new Set(await storedKeys())).toEqual(new Set([bare.operationKey, competing.operationKey])); + expect(native.draft).toBe('Saved prompt'); + expect(form().isCreating).toBe(false); +}); + +async function answerLegacyAlert(retry = true) { + const alert = native.alert; + expect(alert?.title).toBe('agentChat.newSession.legacyLaunchTitle'); + expect(alert?.message).toBe('agentChat.newSession.legacyLaunchMessage'); + const button = alert?.buttons.find( + action => action.text === (retry ? 'agentChat.newSession.retryLegacyLaunch' : 'common.cancel') + ); + if (!button) { + throw new Error('Legacy recovery action is absent'); + } + native.alert = null; + await act(async () => { + button.onPress(); + }); +} + +it.each([ + ['bare', 'github'], + ['ordinary', 'github'], + ['continue', 'github'], + ['ordinary', 'gitlab'], + ['continue', 'gitlab'], + ['ordinary', 'bitbucket'], + ['continue', 'bitbucket'], +] as const)( + 'recovers the serialized %s %s launch with its admitted input across a lost response and remount', + async (entry, platform) => { + const row = seedLegacyRecord(entry, false, platform); + await mount(); + await chooseBranch('feature/b4'); + await start(); + expect(requests).toEqual([]); + expect(native.nextKey).toBe(0); + expect(await listOutboxRows('user-1')).toEqual([row]); + await answerLegacyAlert(false); + expect(form().isCreating).toBe(false); + expect(native.draft).toBe('Saved prompt'); + expect(requests).toEqual([]); + await start(); + await answerLegacyAlert(); + const first = requests[0]; + expect(first?.input).toEqual({ ...row.input, organizationId: 'org-1' }); + expect(acceptedSessions.size).toBe(1); + await act(async () => { + first?.response.reject(new Error('Lost response')); + }); + expect(await listOutboxRows('user-1')).toEqual([row]); + expect(native.draft).toBe('Saved prompt'); + await act(() => { + screen?.unmount(); + }); + screen = undefined; + await mount(); + await chooseBranch('feature/next'); + await start(); + expect(requests).toHaveLength(1); + await answerLegacyAlert(); + expect(requests[1]?.input).toEqual({ ...row.input, organizationId: 'org-1' }); + expect(acceptedSessions.size).toBe(1); + expect(native.nextKey).toBe(0); + await act(async () => { + requests[1]?.response.resolve({ kiloSessionId: 'session-before-upgrade' }); + }); + expect(native.path).toContain('agent-chat/session-before-upgrade'); + expect(await listOutboxRows('user-1')).toEqual([]); + expect(native.draft).toBe(entry === 'continue' ? 'Saved prompt' : null); + } +); + +it.each(['ordinary', 'continue'] as const)( + 'blocks an unsafe serialized %s record without replacing its key or saved input', + async entry => { + const row = seedLegacyRecord(entry, true); + async function attemptUnsafeRetry() { + await mount(); + await start(); + expect(native.errors.at(-1)).toBe('agentChat.newSession.legacyLaunchUnavailable'); + expect(native.alert).toBeNull(); + expect(requests).toEqual([]); + expect(acceptedSessions.size).toBe(1); + expect(native.nextKey).toBe(0); + expect(await listOutboxRows('user-1')).toEqual([row]); + expect(native.draft).toBe('Saved prompt'); + expect(form().isCreating).toBe(false); + await act(() => { + screen?.unmount(); + }); + screen = undefined; + } + await attemptUnsafeRetry(); + await attemptUnsafeRetry(); + } +); + +it.each(['ordinary', 'continue'] as const)( + 'keeps an unresolved %s legacy operation after the server rejects its original identity', + async entry => { + const row = seedLegacyRecord(entry); + await mount(); + await start(); + await answerLegacyAlert(); + await act(async () => { + requests[0]?.response.reject( + Object.assign(new Error('Ambiguous repository identity'), { data: { code: 'BAD_REQUEST' } }) + ); + }); + expect(native.errors.at(-1)).toBe('Ambiguous repository identity'); + expect(await listOutboxRows('user-1')).toEqual([row]); + expect(native.draft).toBe('Saved prompt'); + await start(); + expect(requests).toHaveLength(1); + expect(native.nextKey).toBe(0); + await answerLegacyAlert(false); + expect(form().isCreating).toBe(false); + expect(acceptedSessions.size).toBe(1); + } +); + +it.each(['repository', 'integration', 'instance', 'branch'])( + 'retires Continue completion after %s invalidation and keeps the replacement form and retry row', + async change => { + native.platform = 'gitlab'; + native.selectedRepo = currentRepository().key; + native.draft = 'An unrelated ordinary draft'; + await mount(); + await chooseBranch('feature/b4'); + await start(); + const first = requests[0]; + if (!first) { + throw new Error('First launch was not admitted'); + } + if (change === 'repository') { + native.repositoryId = '84'; + } else if (change === 'integration') { + native.integrationId = 'integration-2'; + } else if (change === 'instance') { + native.gitlabInstanceUrl = 'https://git.example.com/other'; + } else { + native.branches = ['develop', 'feature/next']; + } + await act(() => { + screen?.update(createElement(NewSessionScreenBody)); + }); + expect(form().isCreating).toBe(false); + expect(form().isStartDisabled).toBe(true); + if (change !== 'branch') { + await act(() => { + form().onChangeRepo(currentRepository().key); + }); + } + await chooseBranch('feature/next'); + await start(); + const second = requests[1]; + if (!second) { + throw new Error('Replacement launch was not admitted'); + } + const replacementKey = form().selectedRepo; + await act(async () => { + first.response.resolve({ kiloSessionId: 'session-retired' }); + }); + expect(native.path).toBe('/continue'); + expect(form().selectedRepo).toBe(replacementKey); + expect(form().isCreating).toBe(true); + expect(form().isStartDisabled).toBe(true); + expect(JSON.stringify(screen?.toJSON())).toContain('"text":"feature/next"'); + expect(native.draft).toBe('An unrelated ordinary draft'); + expect(native.errors).toEqual([]); + expect(new Set(await storedKeys())).toEqual( + new Set([first.input.operationKey, second.input.operationKey]) + ); + goBack(); + expect(native.path).toBe('/continue'); + await act(async () => { + second.response.resolve({ kiloSessionId: 'session-replacement' }); + }); + expect(native.path).toContain('agent-chat/session-replacement'); + expect(await storedKeys()).toEqual([first.input.operationKey]); + expect(native.draft).toBe('An unrelated ordinary draft'); + } +); + +it.each([ + ['BAD_REQUEST', 'invalidRepositorySelection'], + ['NOT_FOUND', 'repositoryUnavailable'], +])( + 'keeps repository reselection available after %s without reconnecting', + async (code, message) => { + native.cloneFromKiloSessionId = ''; + native.draft = 'Keep the resource recovery prompt'; + native.branchError = Object.assign(new Error('Unavailable'), { data: { code } }); + await mount(); + const text = JSON.stringify(screen?.toJSON()); + expect(text).toContain(`agentChat.newSession.${message}`); + expect(text).not.toContain('agentChat.newSession.openGithub'); + expect(text).not.toContain('branchAccessDenied'); + const picker = screen?.root + .findAllByType('Pressable') + .map(node => node.props as { accessibilityLabel?: string; disabled?: boolean }) + .find(props => props.accessibilityLabel?.startsWith('agentChat.repoPicker.accessibility')); + expect(picker?.disabled).toBe(false); + expect(form().isStartDisabled).toBe(true); + native.repositoryId = '84'; + native.branchError = null; + await act(() => { + form().onChangeRepo(currentRepository().key); + }); + await start(); + expect(requests[0]?.input.prompt).toBe('Keep the resource recovery prompt'); + await act(async () => { + requests[0]?.response.resolve({ kiloSessionId: 'session-reselected' }); + }); + expect(native.path).toContain('agent-chat/session-reselected'); + } +); + +it.each(['ordinary', 'continue'] as const)( + 'retires %s legacy consent after discovery invalidates the selection', + async entry => { + const row = seedLegacyRecord(entry); + await mount(); + await start(); + expect(requests).toEqual([]); + native.integrationId = 'replacement-integration'; + await act(() => { + screen?.update(createElement(NewSessionScreenBody)); + }); + await answerLegacyAlert(); + expect(requests).toEqual([]); + expect(native.nextKey).toBe(0); + expect(await listOutboxRows('user-1')).toEqual([row]); + expect(native.draft).toBe('Saved prompt'); + expect(form().isCreating).toBe(false); + expect(form().isStartDisabled).toBe(true); + } +); diff --git a/apps/mobile/src/components/agents/new-session-screen-body.tsx b/apps/mobile/src/components/agents/new-session-screen-body.tsx index cc083c35b2..6f3d46850f 100644 --- a/apps/mobile/src/components/agents/new-session-screen-body.tsx +++ b/apps/mobile/src/components/agents/new-session-screen-body.tsx @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- The screen body wires the form, draft, model, and provider hooks end-to-end from the thin route. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { View } from 'react-native'; +import { Alert, View } from 'react-native'; import { useTranslation } from 'react-i18next'; import { useLocalSearchParams, useNavigation } from 'expo-router'; import { useActionSheet } from '@expo/react-native-action-sheet'; @@ -9,6 +9,11 @@ import { toast } from 'sonner-native'; import { type KiloSessionId, type RemoteModelOverride } from '@kilocode/cloud-agent-sdk'; import { NewSessionConfigureForm } from '@/components/agents/new-session-configure-form'; +import { isProviderLaunchSelectionCurrent } from '@/components/agents/provider-launch-input'; +import { + RepositoryBranchContext, + useRepositoryBranchSelection, +} from '@/components/agents/repository-branch-selector'; import { resolveNewSessionModelView } from '@/components/agents/new-session-model-view'; import { useNewSessionCreator } from '@/components/agents/use-new-session-creator'; import { useEffectiveAgentProfile } from '@/components/agents/use-effective-agent-profile'; @@ -81,6 +86,37 @@ function AndroidPendingPickerRecovery({ return null; } +async function confirmLegacyLaunchRetry(): Promise { + const confirmed = await new Promise(resolve => { + Alert.alert( + i18n.t('agentChat.newSession.legacyLaunchTitle'), + i18n.t('agentChat.newSession.legacyLaunchMessage'), + [ + { + text: i18n.t('common.cancel'), + style: 'cancel', + onPress: () => { + resolve(false); + }, + }, + { + text: i18n.t('agentChat.newSession.retryLegacyLaunch'), + onPress: () => { + resolve(true); + }, + }, + ], + { + cancelable: true, + onDismiss: () => { + resolve(false); + }, + } + ); + }); + return confirmed; +} + export function NewSessionScreenBody() { const { mode, setMode, model, setModel, variant, setVariant } = useNewSessionModelState(); const { t } = useTranslation(); @@ -213,27 +249,34 @@ export function NewSessionScreenBody() { refreshReposForceFresh, } = useNewSessionRepos({ organizationId }); + const prefillOrganizationId = useRef(organizationId); const { selectedRepo, setSelectedRepo } = useNewSessionPrefillTargets({ - repositories, + repositories: prefillOrganizationId.current === organizationId ? repositories : [], reposSettled, models, modelsSettled: !isLoadingModels && !isModelsError && models.length > 0, }); - // The picker reports a `platform:fullName` key; resolve it to the full row so - // the creator can send the platform-specific repository field. The prefill - // seeds the same platform-qualified key, so no bare-fullName fallback is - // needed (and one would bind a same-named GitLab/Bitbucket row). - const selectedRepository = useMemo(() => { - if (!selectedRepo) { - return null; - } - return ( + const selectedRepository = useMemo( + () => repositories.find( - repository => `${repository.platform}:${repository.fullName}` === selectedRepo - ) ?? null - ); - }, [repositories, selectedRepo]); + repository => + repository.key === selectedRepo && + repository.accountId === userId && + isProviderLaunchSelectionCurrent({ + launchSelection: { reference: repository.reference }, + accountId: userId, + organizationId, + }) + ) ?? null, + [repositories, selectedRepo, userId, organizationId] + ); + const branchState = useRepositoryBranchSelection( + runOnInstance ? null : selectedRepository, + userId, + organizationId + ); + const launchSelection = branchState.launchSelection; const { profile, @@ -313,6 +356,8 @@ export function NewSessionScreenBody() { organizationId, onCreated: handleCreated, selectedRepository, + launchSelection, + confirmLegacyRetry: confirmLegacyLaunchRetry, setIsCreating, variant: displayVariant, autoCommit, @@ -389,9 +434,20 @@ export function NewSessionScreenBody() { onSpawnReady: armCloneNavigateBypass, }); - const runCloudCreate = useContinueCloudCreate(organizationId, armCloneNavigateBypass); + const runCloudCreate = useContinueCloudCreate(organizationId, armCloneNavigateBypass, { + launchSelection, + confirmLegacyRetry: confirmLegacyLaunchRetry, + }); // The creator retires its results; its caller must also retire busy/error completion. - const continueScope = useMemo(() => ({ userId, organizationId }), [userId, organizationId]); + const continueScope = useMemo( + () => ({ + userId, + organizationId, + repositoryKey: selectedRepository?.key, + branch: launchSelection?.upstreamBranch, + }), + [userId, organizationId, selectedRepository?.key, launchSelection?.upstreamBranch] + ); const currentContinueScope = useRef(continueScope); currentContinueScope.current = continueScope; useEffect(() => { @@ -564,9 +620,13 @@ export function NewSessionScreenBody() { }); } - const isStartDisabled = resolveStartDisabled(); + const isStartDisabled = + (!isRemoteTargetSelected && launchSelection === null) || resolveStartDisabled(); const handleStartSession = useCallback(() => { + if (runOnInstance === null && launchSelection === null) { + return; + } if (isCloneEntry) { if (runOnInstance !== null) { // Live CLI import: the dispatch carries the clone source id only when @@ -584,7 +644,12 @@ export function NewSessionScreenBody() { try { await runCloudCreate( cloneFromKiloSessionId as KiloSessionId, - { repository: selectedRepository, model: displayModel, variant: displayVariant }, + { + repository: selectedRepository, + launchSelection, + model: displayModel, + variant: displayVariant, + }, mode ); } catch (error) { @@ -617,6 +682,7 @@ export function NewSessionScreenBody() { runOnInstance, cloneFromKiloSessionId, selectedRepository, + launchSelection, displayModel, displayVariant, mode, @@ -628,73 +694,75 @@ export function NewSessionScreenBody() { ]); return ( - - {!isCloneEntry ? : null} - - {isCloneEntry ? ( - - - {t('agentChat.newSession.continueFrom', { - title: cloneSourceTitle || t('agentChat.session.title'), - })} - - - ) : null} - void handleAddAttachment()} - onRemoveAttachment={handleRemoveAttachment} - onRetryAttachment={handleRetryAttachment} - onRefetchModels={() => void refetchModels()} - onPrefillAttachments={addCandidates} - shareId={shareId} - voiceInputSettlerRef={voiceInputSettlerRef} - showRunOnSelector={showRunOnSelector} - runOnInstance={runOnInstance} - instanceList={instanceList} - isLoadingInstances={isLoadingInstances} - onChangeRunOnInstance={handleRunOnChange} - showInstanceDisconnectedNote={remoteSpawn.showInstanceDisconnectedNote} - folderPath={folderPath} - onChangeFolderPath={setFolderPath} - runOnInlineNote={runOnInlineNote} - isCloneEntry={isCloneEntry} - groups={groups} - isRetrying={isRetrying} - onChangeRepo={setSelectedRepo} - onConnectProvider={openIntegration} - onRefreshRepos={() => void refreshReposForceFresh()} - repositories={repositories} - recents={recents} - selectedRepo={selectedRepo} - profile={profile} - isProfileLoading={isProfileLoading} - isProfileError={isProfileError} - onRetryProfile={() => void refetchProfile()} - autoCommit={autoCommit} - onAutoCommitChange={setAutoCommit} - isStartDisabled={isStartDisabled} - isSpawningRemote={remoteSpawn.isSpawningRemote} - onStartSession={handleStartSession} - /> - + + + {!isCloneEntry ? : null} + + {isCloneEntry ? ( + + + {t('agentChat.newSession.continueFrom', { + title: cloneSourceTitle || t('agentChat.session.title'), + })} + + + ) : null} + void handleAddAttachment()} + onRemoveAttachment={handleRemoveAttachment} + onRetryAttachment={handleRetryAttachment} + onRefetchModels={() => void refetchModels()} + onPrefillAttachments={addCandidates} + shareId={shareId} + voiceInputSettlerRef={voiceInputSettlerRef} + showRunOnSelector={showRunOnSelector} + runOnInstance={runOnInstance} + instanceList={instanceList} + isLoadingInstances={isLoadingInstances} + onChangeRunOnInstance={handleRunOnChange} + showInstanceDisconnectedNote={remoteSpawn.showInstanceDisconnectedNote} + folderPath={folderPath} + onChangeFolderPath={setFolderPath} + runOnInlineNote={runOnInlineNote} + isCloneEntry={isCloneEntry} + groups={groups} + isRetrying={isRetrying} + onChangeRepo={setSelectedRepo} + onConnectProvider={openIntegration} + onRefreshRepos={() => void refreshReposForceFresh()} + repositories={repositories} + recents={recents} + selectedRepo={selectedRepo} + profile={profile} + isProfileLoading={isProfileLoading} + isProfileError={isProfileError} + onRetryProfile={() => void refetchProfile()} + autoCommit={autoCommit} + onAutoCommitChange={setAutoCommit} + isStartDisabled={isStartDisabled} + isSpawningRemote={remoteSpawn.isSpawningRemote} + onStartSession={handleStartSession} + /> + + ); } diff --git a/apps/mobile/src/components/agents/provider-launch-input.test.ts b/apps/mobile/src/components/agents/provider-launch-input.test.ts index 87a08bfdb4..37cadef5fa 100644 --- a/apps/mobile/src/components/agents/provider-launch-input.test.ts +++ b/apps/mobile/src/components/agents/provider-launch-input.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { type LaunchRepositoryReference } from '@kilocode/app-shared/code-review/repository-identity'; import { type SessionManagerConfig } from '@kilocode/cloud-agent-sdk'; -import { resolveProviderLaunchInput } from './provider-launch-input'; +import { resolveProviderLaunchInput, restoreLegacyLaunchInput } from './provider-launch-input'; import { type NewSessionRepository } from './new-session-repository-state'; const reference: LaunchRepositoryReference = { @@ -175,6 +175,49 @@ describe('provider launch boundary', () => { }); }); +describe('legacy launch admission', () => { + const input = { + githubRepo: 'owner/repo', + prompt: 'Saved prompt', + initialMessageId: 'original-message', + operationKey: 'original-key', + mode: 'code', + model: 'model', + autoCommit: false, + autoInitiate: true, + }; + const row = { + taxonomy: 'safe-retry' as const, + operationKey: 'original-key', + fingerprint: 'old', + input, + }; + const retry = { + ...input, + initialMessageId: 'replacement-message', + operationKey: 'replacement-key', + }; + + it('restores the admitted key and message without adding current branch or integration pins', () => { + expect(restoreLegacyLaunchInput(row, retry)).toEqual(input); + expect(restoreLegacyLaunchInput(row, { ...retry, upstreamBranch: 'release' })).toBeNull(); + expect(restoreLegacyLaunchInput(row, { ...retry, githubIntegrationId: 'new' })).toBeNull(); + }); + + it.each([ + ['repository mismatch', { githubRepo: 'owner/other' }], + ['prompt mismatch', { prompt: 'Another prompt' }], + ['key mismatch', { operationKey: 'another-key' }], + ['missing message identity', { initialMessageId: undefined }], + ['unrecorded branch', { upstreamBranch: 'release' }], + ['unrecorded integration', { githubIntegrationId: 'unknown' }], + ['unknown intent field', { futureSetting: true }], + ['malformed attachments', { attachments: { path: 'a', files: 'not-an-array' } }], + ] as const)('quarantines %s instead of reinterpreting the operation', (_name, change) => { + expect(restoreLegacyLaunchInput({ ...row, input: { ...input, ...change } }, retry)).toBeNull(); + }); +}); + // Exercise the real mobile adapter with its external transports replaced. const manager = vi.hoisted(() => ({ config: null as SessionManagerConfig | null, query: vi.fn() })); vi.mock('@kilocode/cloud-agent-sdk', () => ({ diff --git a/apps/mobile/src/components/agents/provider-launch-input.ts b/apps/mobile/src/components/agents/provider-launch-input.ts index 98fff97e37..8a8a4f6efb 100644 --- a/apps/mobile/src/components/agents/provider-launch-input.ts +++ b/apps/mobile/src/components/agents/provider-launch-input.ts @@ -4,14 +4,77 @@ import { requireLaunchRepository, } from '@kilocode/app-shared/code-review/repository-identity'; import { type PrepareInput } from '@kilocode/cloud-agent-sdk/session-manager'; +import * as z from 'zod'; +import { type AgentAttachmentWire } from '@/lib/agent-attachments/use-agent-attachment-upload'; +import { type OutboxRow } from '@/lib/persist/mutation-outbox'; +import { type AgentMode } from './mode-normalize'; import { type NewSessionRepository } from './new-session-repository-state'; +const legacyAttachmentsSchema = z.strictObject({ path: z.string(), files: z.array(z.string()) }); +const legacyBitbucketRepositorySchema = z.strictObject({ + fullName: z.string(), + workspaceUuid: z.string(), + repositoryUuid: z.string(), +}); +const legacyLaunchInputSchema = z.strictObject({ + prompt: z.string().optional(), + initialMessageId: z.string().min(1).optional(), + cloneFromKiloSessionId: z.string().optional(), + mode: z.string(), + model: z.string(), + variant: z.string().optional(), + autoCommit: z.boolean(), + autoInitiate: z.literal(true), + operationKey: z.string().min(1), + profileId: z.string().optional(), + attachments: legacyAttachmentsSchema.optional(), + githubRepo: z.string().optional(), + gitlabProject: z.string().optional(), + bitbucketRepo: legacyBitbucketRepositorySchema.optional(), +}); + +// Old unpinned outbox inputs retain their admitted settings, not the current +// selection's pins. Remove after old clients/records disappear and the 30-day +// ledger window expires. +export function restoreLegacyLaunchInput< + T extends { operationKey: string; initialMessageId?: string }, +>(row: OutboxRow, input: T): T | null { + const stored = legacyLaunchInputSchema.safeParse(row.input); + if ( + row.taxonomy !== 'safe-retry' || + !stored.success || + stored.data.operationKey !== row.operationKey + ) { + return null; + } + const restored = { ...input, operationKey: row.operationKey }; + if (input.initialMessageId !== undefined && stored.data.initialMessageId !== undefined) { + restored.initialMessageId = stored.data.initialMessageId; + } + const expected = legacyLaunchInputSchema.safeParse(restored); + // Parsing both sides gives a stable field order and rejects unknown intent fields. + return expected.success && JSON.stringify(expected.data) === JSON.stringify(stored.data) + ? restored + : null; +} + export type ProviderLaunchSelection = { reference: LaunchRepositoryReference; upstreamBranch?: string; }; +export function getProviderLaunchFingerprint( + accountId: string, + selection: ProviderLaunchSelection +) { + return JSON.stringify([ + 'provider-launch:v1', + repositoryResourceKey(accountId, selection.reference), + selection.upstreamBranch ?? null, + ]); +} + export type ProviderPrepareInput = Pick< PrepareInput, | 'githubRepo' @@ -24,6 +87,19 @@ export type ProviderPrepareInput = Pick< | 'upstreamBranch' >; +export type NewSessionPrepareInput = ProviderPrepareInput & { + prompt: string; + initialMessageId: string; + mode: AgentMode; + model: string; + variant: string | undefined; + autoCommit: boolean; + autoInitiate: boolean; + operationKey: string; + profileId?: string; + attachments?: AgentAttachmentWire; +}; + export type ProviderLaunchContext = { launchSelection?: ProviderLaunchSelection | null; accountId?: string; @@ -108,11 +184,10 @@ export function resolveProviderLaunchInput( if (reference && accountId) { return { input, - fingerprint: JSON.stringify([ - 'provider-launch:v1', - repositoryResourceKey(accountId, reference), - input.upstreamBranch ?? null, - ]), + fingerprint: getProviderLaunchFingerprint(accountId, { + reference, + upstreamBranch: input.upstreamBranch, + }), }; } const fingerprint = diff --git a/apps/mobile/src/components/agents/repo-selector.tsx b/apps/mobile/src/components/agents/repo-selector.tsx index 275c5534ff..6c9676df06 100644 --- a/apps/mobile/src/components/agents/repo-selector.tsx +++ b/apps/mobile/src/components/agents/repo-selector.tsx @@ -1,4 +1,5 @@ import { type Href, useRouter } from 'expo-router'; +import { useEffect, useRef } from 'react'; import { ChevronDown } from '@/components/ui/icons'; import { Pressable } from 'react-native'; import { useTranslation } from 'react-i18next'; @@ -6,88 +7,46 @@ import { useTranslation } from 'react-i18next'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { - type RepoOption as BridgeRepoOption, REPO_PLATFORM_LABEL_KEYS, type RepoPickerSection, type RepoPlatform, } from '@/lib/picker-bridge'; import { repoPickerSlot, UNFENCED_ROUTE_KEY } from '@/lib/route-registry'; import { cn } from '@/lib/utils'; - -type RepoOption = { - fullName: string; - isPrivate: boolean; - /** Provider platform; omitted rows are treated as GitHub until d1 fills it. */ - platform?: RepoPlatform; - workspaceUuid?: string; - repositoryUuid?: string; -}; +import { + type NewSessionRepository, + type ResolvedNewSessionRepository as RepoOption, + repositoryKey, + repositoryLabel, +} from './new-session-repository-state'; type RepoSelectorProps = { value: string; - repositories: RepoOption[]; - /** Recently used rows, rendered under a "Recently used" section header in the picker. */ - recents: RepoOption[]; + repositories: NewSessionRepository[]; + recents: NewSessionRepository[]; isLoading: boolean; onChange: (repo: string) => void; disabled?: boolean; }; -function isRepoPlatform(platform: string): platform is RepoPlatform { - return platform === 'github' || platform === 'gitlab' || platform === 'bitbucket'; -} - -/** Constant order for provider sections (Bitbucket only when its rows exist, i.e. an org is set). */ const PROVIDER_SECTION_ORDER: readonly RepoPlatform[] = ['github', 'gitlab', 'bitbucket']; -function toBridgeRepo(repo: RepoOption): BridgeRepoOption { - return { - platform: repo.platform ?? 'github', - fullName: repo.fullName, - isPrivate: repo.isPrivate, - ...(repo.workspaceUuid !== undefined ? { workspaceUuid: repo.workspaceUuid } : {}), - ...(repo.repositoryUuid !== undefined ? { repositoryUuid: repo.repositoryUuid } : {}), - }; -} - -/** - * Assemble the picker's grouped sections: a "Recently used" section over the - * recents, then one provider section per platform (in PROVIDER_SECTION_ORDER) - * over the provider's non-recent rows. Each recent row appears once (under - * recents), and Bitbucket only appears when it has rows. - */ -function buildRepoSections({ - repositories, - recents, -}: { - repositories: RepoOption[]; - recents: RepoOption[]; -}): RepoPickerSection[] { - const recentKeys = new Set( - recents.map(repo => `${repo.platform ?? 'github'}/${repo.fullName.toLowerCase()}`) - ); +function buildRepoSections(repositories: RepoOption[], recents: RepoOption[]): RepoPickerSection[] { + const recentKeys = new Set(recents.map(repo => repositoryKey(repo))); const sections: RepoPickerSection[] = []; if (recents.length > 0) { sections.push({ key: 'recents', titleKey: 'agentChat.newSession.recentlyUsed', - repos: recents.map(repo => toBridgeRepo(repo)), + repos: recents, }); } for (const platform of PROVIDER_SECTION_ORDER) { - const repos = repositories.filter(repo => { - const repoPlatform = repo.platform ?? 'github'; - return ( - repoPlatform === platform && - !recentKeys.has(`${repoPlatform}/${repo.fullName.toLowerCase()}`) - ); - }); + const repos = repositories.filter( + repo => repo.platform === platform && !recentKeys.has(repositoryKey(repo)) + ); if (repos.length > 0) { - sections.push({ - key: platform, - titleKey: REPO_PLATFORM_LABEL_KEYS[platform], - repos: repos.map(repo => toBridgeRepo(repo)), - }); + sections.push({ key: platform, titleKey: REPO_PLATFORM_LABEL_KEYS[platform], repos }); } } return sections; @@ -104,37 +63,46 @@ export function RepoSelector({ const router = useRouter(); const colors = useThemeColors(); const { t } = useTranslation(); - const effectivelyDisabled = disabled || isLoading || repositories.length === 0; - - // The selection value is `platform:fullName`; show the platform name next to - // the fullName so two same-name repos on different providers stay distinct in - // the closed state. A bare fullName (legacy prefill) falls back to the - // matching row's platform. - const colonIndex = value.indexOf(':'); - const rawPlatform = colonIndex !== -1 ? value.slice(0, colonIndex) : ''; - const selectedPlatform: RepoPlatform | undefined = - colonIndex !== -1 && isRepoPlatform(rawPlatform) - ? rawPlatform - : repositories.find(repo => repo.fullName === value)?.platform; - const displayValue = colonIndex !== -1 ? value.slice(colonIndex + 1) : value; - const platformName = selectedPlatform ? t(REPO_PLATFORM_LABEL_KEYS[selectedPlatform]) : undefined; - let label = displayValue; - if (!label) { - label = isLoading ? t('agentChat.repoPicker.loading') : t('agentChat.repoPicker.title'); - } else if (platformName) { - label = `${platformName} · ${displayValue}`; - } + // Old form props permit unresolved rows. Never put them in a normalized bridge. + // Remove after old clients/records disappear and the 30-day ledger window expires. + const available = repositories.filter((repo): repo is RepoOption => + Boolean(repo.reference && repo.key && repo.accountId) + ); + const latest = useRef({ available, onChange, disabled }); + latest.current = { available, onChange, disabled }; + const mounted = useRef(false); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const effectivelyDisabled = disabled || available.length === 0; + const selected = available.find(repo => repo.key === value); + const label = selected + ? `${t(REPO_PLATFORM_LABEL_KEYS[selected.platform])} · ${repositoryLabel(selected)}` + : t(isLoading ? 'agentChat.repoPicker.loading' : 'agentChat.repoPicker.title'); function handlePress() { if (effectivelyDisabled) { return; } - const bridgeRepositories: BridgeRepoOption[] = repositories.map(repo => toBridgeRepo(repo)); repoPickerSlot.set(UNFENCED_ROUTE_KEY, { - repositories: bridgeRepositories, - sections: buildRepoSections({ repositories, recents }), + repositories: available, + sections: buildRepoSections( + available, + recents.flatMap(repo => available.filter(row => row.key === repo.key)) + ), currentValue: value, - onSelect: onChange, + onSelect: key => { + if ( + mounted.current && + !latest.current.disabled && + latest.current.available.some(repo => repo.key === key) + ) { + latest.current.onChange(key); + } + }, }); router.push('/(app)/agent-chat/repo-picker' as Href); } @@ -147,13 +115,12 @@ export function RepoSelector({ accessibilityLabel={t('agentChat.repoPicker.accessibility', { label })} accessibilityState={{ disabled: effectivelyDisabled }} className={cn( - 'flex-row items-center justify-between rounded-lg border border-border bg-secondary px-3 py-3', + 'min-h-12 flex-row items-center justify-between rounded-lg border border-border bg-secondary px-3 py-3 active:opacity-70', effectivelyDisabled && 'opacity-50' )} > {label} diff --git a/apps/mobile/src/components/agents/repository-branch-selector.mounted.test.tsx b/apps/mobile/src/components/agents/repository-branch-selector.mounted.test.tsx new file mode 100644 index 0000000000..e6fba0292b --- /dev/null +++ b/apps/mobile/src/components/agents/repository-branch-selector.mounted.test.tsx @@ -0,0 +1,309 @@ +/* eslint-disable typescript-eslint/no-deprecated -- mounted React Native contract tests use the DOM-free renderer */ +import TestRenderer, { act } from 'react-test-renderer'; +import { notifyManager, QueryClient, QueryClientProvider, skipToken } from '@tanstack/react-query'; +import { type TRPCQueryKey } from '@trpc/tanstack-react-query'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { type LaunchRepositoryReference } from '@kilocode/app-shared/code-review/repository-identity'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { type ProviderLaunchSelection } from './provider-launch-input'; +import { normalizeSessionRepository } from './new-session-repository-state'; +import { + RepositoryBranchContext, + RepositoryBranchSelector, + useRepositoryBranchSelection, +} from './repository-branch-selector'; + +const native = vi.hoisted(() => ({ + choose: undefined as ((index?: number) => void) | undefined, + options: [] as string[], + selection: null as ProviderLaunchSelection | null, + destination: '', +})); +vi.mock('react-native', () => ({ View: 'View', ActivityIndicator: 'ActivityIndicator' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/accessible-status', () => ({ + AccessibleStatus: ({ message }: { message: string | null }) => {message}, +})); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('@/i18n', () => ({ i18n: { t: (key: string) => key } })); +vi.mock('@/lib/config', () => ({ WEB_BASE_URL: 'https://web.test' })); +vi.mock('sonner-native', () => ({ toast: { error: vi.fn() } })); +vi.mock('@/lib/hooks/use-current-user-id', () => ({ useCurrentUserId: vi.fn() })); +vi.mock('@/lib/pr-review/connect-gate-platform', () => ({ + openAuthorizationAndWaitForReturn: vi.fn(), +})); +vi.mock('@expo/react-native-action-sheet', () => ({ + useActionSheet: () => ({ + showActionSheetWithOptions: ( + sheet: { options: string[] }, + onSelect: (index?: number) => void + ) => { + native.options = sheet.options; + native.choose = onSelect; + }, + }), +})); +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + cloudAgentNext: { listRepositoryBranches: { infiniteQueryOptions: options } }, + organizations: { + cloudAgentNext: { listRepositoryBranches: { infiniteQueryOptions: options } }, + }, + }), +})); + +type Page = { + branches: { name: string; isDefault: boolean }[]; + defaultBranch: string | null; + nextCursor: string | null; +}; +type Input = LaunchRepositoryReference & { organizationId?: string; cursor?: string }; +const requests: { input: Input; response: ReturnType> }[] = []; +function options(input: Input | typeof skipToken) { + return { + queryKey: [ + ['branches'], + { input: input === skipToken ? undefined : input, type: 'infinite' }, + ] satisfies TRPCQueryKey, + initialPageParam: undefined, + queryFn: + input === skipToken + ? skipToken + : async ({ pageParam }: { pageParam?: string }) => { + const response = Promise.withResolvers(); + requests.push({ input: { ...input, cursor: pageParam }, response }); + const page = await response.promise; + return page; + }, + }; +} +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', + }, +}; +function Harness({ + reference: ref, + accountId, +}: { + reference: LaunchRepositoryReference | null; + accountId: string; +}) { + const organizationId = + ref?.authorization.owner.type === 'org' ? ref.authorization.owner.id : undefined; + const repository = ref + ? normalizeSessionRepository( + { private: true, repositoryReference: ref }, + accountId, + organizationId + ) + : null; + const state = useRepositoryBranchSelection(repository, accountId, organizationId); + native.selection = state.launchSelection; + return ( + + { + native.destination = state.repository?.reference.authorization.integrationId ?? ''; + }} + /> + + ); +} +let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; +const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); +async function flush(action: () => void) { + await act(async () => { + action(); + // Keep deferred query notifications inside React's asynchronous act scope. + await Promise.resolve(); + }); +} +async function render(ref: LaunchRepositoryReference | null = reference, accountId = 'user-1') { + await flush(() => { + const tree = ( + + + + ); + if (renderer) { + renderer.update(tree); + } else { + renderer = TestRenderer.create(tree); + } + }); +} +function text() { + return JSON.stringify(renderer?.toJSON()); +} +async function respond( + index: number, + names = ['develop', 'feature'], + page: Partial> = {} +) { + await flush(() => { + const defaultBranch = page.defaultBranch === undefined ? 'develop' : page.defaultBranch; + requests[index]?.response.resolve({ + branches: names.map(name => ({ name, isDefault: name === defaultBranch })), + defaultBranch, + nextCursor: null, + ...page, + }); + }); +} +async function press(label: string, branch?: string) { + const button = renderer?.root + .findAllByType(Button) + .find( + node => + (node.props as { accessibilityLabel?: string }).accessibilityLabel === label || + node.findAllByType(Text).some(child => child.children.includes(label)) + ); + if (!button) { + throw new Error(`Missing button: ${label}`); + } + await flush(() => { + (button.props as { onPress: () => void }).onPress(); + if (branch) { + native.choose?.(native.options.indexOf(branch)); + } + }); +} +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + notifyManager.setScheduler(queueMicrotask); + requests.length = 0; + native.destination = ''; +}); +afterEach(async () => { + await flush(() => { + renderer?.unmount(); + }); + renderer = undefined; + client.clear(); + notifyManager.setScheduler(task => { + setTimeout(task, 0); + }); + vi.unstubAllGlobals(); +}); + +it('loads only a selected identity and uses its provider default before an explicit branch', async () => { + await render(null); + expect(requests).toEqual([]); + await render(); + expect(text()).toContain('agentChat.newSession.loadingBranches'); + expect(native.selection).toBeNull(); + await respond(0); + expect(requests[0]?.input).toMatchObject({ ...reference, organizationId: 'org-1' }); + expect(native.selection).toEqual({ reference, upstreamBranch: 'develop' }); + await press('agentChat.newSession.branch', 'feature'); + expect(native.selection).toEqual({ reference, upstreamBranch: 'feature' }); +}); + +it.each(['owner', 'integration', 'instance', 'repository', 'account'])( + 'rejects late pages and native choices after a changed %s', + async change => { + await render(); + await respond(0); + await press('agentChat.newSession.branch', 'feature'); + const oldChoice = native.choose; + await flush(() => { + void client.refetchQueries(); + }); + const next = structuredClone(reference); + if (change === 'owner') { + next.authorization.owner.id = 'org-2'; + } + if (change === 'integration') { + next.authorization.integrationId = 'integration-2'; + } + if (change === 'instance') { + next.repository.instanceUrl = 'https://git.example.com/other'; + } + if (change === 'repository') { + next.repository.repositoryId = '8'; + } + await render(next, change === 'account' ? 'user-2' : 'user-1'); + expect(native.selection).toBeNull(); + await respond(1, ['old-branch'], { defaultBranch: 'old-branch' }); + await flush(() => { + oldChoice?.(1); + }); + expect(native.selection).toBeNull(); + await respond(2, ['release'], { defaultBranch: 'release' }); + expect(native.selection).toEqual({ reference: next, upstreamBranch: 'release' }); + } +); + +it('distinguishes empty branches from a failed refresh and recovers through retry', async () => { + await render(); + await respond(0, [], { defaultBranch: null }); + expect(text()).toContain('agentChat.newSession.noBranches'); + expect(native.selection).toBeNull(); + await press('common.refresh'); + await flush(() => { + requests[1]?.response.reject(new Error('Offline')); + }); + expect(text()).toContain('agentChat.newSession.couldNotLoadBranches'); + expect(native.selection).toBeNull(); + await press('common.retry'); + await respond(2); + expect(native.selection?.upstreamBranch).toBe('develop'); +}); + +it('retains loaded branches and retries only the failed next page', async () => { + await render(); + await respond(0, ['develop'], { nextCursor: 'page-2' }); + await press('agentChat.newSession.loadMoreBranches'); + await flush(() => { + requests[1]?.response.reject(new Error('Offline')); + }); + expect(text()).toContain('agentChat.newSession.couldNotLoadBranches'); + expect(native.selection?.upstreamBranch).toBe('develop'); + await press('common.retry'); + expect(requests.map(request => request.input.cursor)).toEqual([undefined, 'page-2', 'page-2']); + await respond(2, ['feature']); + await press('agentChat.newSession.branch', 'feature'); + expect(native.selection?.upstreamBranch).toBe('feature'); +}); + +it.each([ + ['FORBIDDEN', 'branchAccessDenied', true], + ['UNAUTHORIZED', 'branchAccessDenied', true], + ['PRECONDITION_FAILED', 'branchAccessDenied', true], + ['BAD_REQUEST', 'invalidRepositorySelection', false], + ['NOT_FOUND', 'repositoryUnavailable', false], +] as const)( + 'blocks %s and exposes only applicable recovery', + async (code, message, canReconnect) => { + await render(); + await respond(0); + await flush(() => { + void client.refetchQueries(); + }); + await flush(() => { + requests[1]?.response.reject(Object.assign(new Error('Unavailable'), { data: { code } })); + }); + expect(text()).toContain(`agentChat.newSession.${message}`); + expect(text()).not.toContain('common.retry'); + expect(native.selection).toBeNull(); + expect(text().includes('"connect"')).toBe(canReconnect); + if (canReconnect) { + await press('connect'); + } + expect(native.destination).toBe(canReconnect ? 'integration-1' : ''); + } +); diff --git a/apps/mobile/src/components/agents/repository-branch-selector.tsx b/apps/mobile/src/components/agents/repository-branch-selector.tsx new file mode 100644 index 0000000000..08e17c705e --- /dev/null +++ b/apps/mobile/src/components/agents/repository-branch-selector.tsx @@ -0,0 +1,212 @@ +import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import { skipToken, useInfiniteQuery } from '@tanstack/react-query'; +import { useActionSheet } from '@expo/react-native-action-sheet'; +import { useTranslation } from 'react-i18next'; + +import { AccessibleStatus } from '@/components/ui/accessible-status'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useTRPC } from '@/lib/trpc'; +import { readTrpcErrorField } from '@/lib/trpc-error'; +import { withRepositoryAccount } from '@/lib/use-github-repos-refresh'; +import { type ResolvedNewSessionRepository } from './new-session-repository-state'; +import { + isProviderLaunchSelectionCurrent, + type ProviderLaunchSelection, +} from './provider-launch-input'; + +// The screen owns launch state; the repository section renders it inside the form's scroll view. +export const RepositoryBranchContext = createContext | null>(null); + +export function useRepositoryBranchSelection( + repository: ResolvedNewSessionRepository | null, + accountId: string | undefined, + organizationId: string | undefined +) { + const trpc = useTRPC(); + const reference = + repository?.accountId === accountId && + isProviderLaunchSelectionCurrent({ + launchSelection: repository ? { reference: repository.reference } : null, + accountId, + organizationId, + }) + ? repository?.reference + : undefined; + const options = organizationId + ? trpc.organizations.cloudAgentNext.listRepositoryBranches.infiniteQueryOptions( + reference ? { ...reference, organizationId } : skipToken + ) + : trpc.cloudAgentNext.listRepositoryBranches.infiniteQueryOptions(reference ?? skipToken); + const query = useInfiniteQuery({ + ...withRepositoryAccount(options, accountId), + getNextPageParam: page => page.nextCursor ?? undefined, + retry: false, + }); + const key = reference ? repository?.key : undefined; + const scope = useMemo( + () => ({ key, accountId, organizationId }), + [key, accountId, organizationId] + ); + const current = useRef(scope); + current.current = scope; + const [choice, setChoice] = useState<{ scope: typeof scope; branch: string } | null>(null); + useEffect(() => { + current.current = scope; + setChoice(null); + return () => { + current.current = null; + }; + }, [scope]); + const defaultBranch = query.data?.pages[0]?.defaultBranch; + useEffect(() => { + if (choice?.scope !== scope && defaultBranch) { + setChoice({ scope, branch: defaultBranch }); + } + }, [choice, scope, defaultBranch]); + const branches = [ + ...new Set(query.data?.pages.flatMap(page => page.branches.map(branch => branch.name))), + ]; + const branch = choice?.scope === scope ? choice.branch : defaultBranch; + const code = readTrpcErrorField(query.error, 'code'); + const connectionRecovery = ['FORBIDDEN', 'UNAUTHORIZED', 'PRECONDITION_FAILED'].includes( + code ?? '' + ); + const terminal = connectionRecovery || code === 'NOT_FOUND' || code === 'BAD_REQUEST'; + const valid = Boolean(branch && branches.includes(branch) && !terminal); + const launchSelection = useMemo( + () => (reference && branch && valid ? { reference, upstreamBranch: branch } : null), + [reference, branch, valid] + ); + let message: string | null = null; + if (connectionRecovery) { + message = 'agentChat.newSession.branchAccessDenied'; + } else if (code === 'NOT_FOUND') { + message = 'agentChat.newSession.repositoryUnavailable'; + } else if (code === 'BAD_REQUEST') { + message = 'agentChat.newSession.invalidRepositorySelection'; + } else if (query.isError) { + message = 'agentChat.newSession.couldNotLoadBranches'; + } else if (query.isPending) { + message = 'agentChat.newSession.loadingBranches'; + } else if (branches.length === 0 && !query.hasNextPage) { + message = 'agentChat.newSession.noBranches'; + } else if (!valid && (!branch || !query.hasNextPage)) { + message = + choice?.scope === scope + ? 'agentChat.newSession.branchUnavailable' + : 'agentChat.newSession.defaultBranchUnavailable'; + } else if (query.isFetching) { + message = 'agentChat.newSession.loadingBranches'; + } + return { + repository: reference ? repository : null, + query, + branches, + branch, + message, + terminal, + connectionRecovery, + launchSelection, + select: (selected: string) => { + if (current.current === scope && branches.includes(selected) && !terminal) { + setChoice({ scope, branch: selected }); + } + }, + }; +} + +export function RepositoryBranchSelector({ + disabled, + onConnect, + connectLabel, +}: { + disabled: boolean; + onConnect: () => void; + connectLabel: string; +}) { + const state = useContext(RepositoryBranchContext); + const { t } = useTranslation(); + const { showActionSheetWithOptions } = useActionSheet(); + const latest = useRef({ state, disabled }); + latest.current = { state, disabled }; + if (!state?.repository) { + return null; + } + const { query, branches, branch, message, terminal, connectionRecovery } = state; + const busy = query.isFetching; + const canRefresh = !terminal && (query.isError || (!query.isPending && branches.length === 0)); + return ( + + + {t('agentChat.newSession.branch')} + + + {busy ? : null} + + {connectionRecovery ? ( + + ) : null} + {canRefresh ? ( + + ) : null} + {query.hasNextPage && !query.isError ? ( + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/agents/use-continue-cloud-create.test.ts b/apps/mobile/src/components/agents/use-continue-cloud-create.test.ts index 3d2ccc53c8..38382b8bac 100644 --- a/apps/mobile/src/components/agents/use-continue-cloud-create.test.ts +++ b/apps/mobile/src/components/agents/use-continue-cloud-create.test.ts @@ -24,6 +24,7 @@ const operationKeyMock = vi.hoisted(() => ({ })); const outboxMock = vi.hoisted(() => ({ + getStoredSafeRetry: vi.fn(() => null), getStoredOperationKey: vi.fn((_fingerprint: string): string | null => null), writeSafeRetry: vi.fn(async (): Promise => undefined), remove: vi.fn(async (): Promise => undefined), diff --git a/apps/mobile/src/components/agents/use-continue-cloud-create.ts b/apps/mobile/src/components/agents/use-continue-cloud-create.ts index e1b25e39d8..a435c68c49 100644 --- a/apps/mobile/src/components/agents/use-continue-cloud-create.ts +++ b/apps/mobile/src/components/agents/use-continue-cloud-create.ts @@ -9,9 +9,11 @@ import * as Haptics from 'expo-haptics'; import { type AgentMode, normalizeAgentMode } from '@/components/agents/mode-normalize'; import { type NewSessionRepository } from '@/components/agents/new-session-repository-state'; import { + getProviderLaunchFingerprint, type ProviderLaunchSelection, type ProviderPrepareInput, resolveProviderLaunchInput, + restoreLegacyLaunchInput, } from '@/components/agents/provider-launch-input'; import { isCloudPrepareRetryableError } from '@/components/agents/mobile-session-manager'; import { replaceWithAgentSession } from '@/components/agents/session-detail-routes'; @@ -33,13 +35,23 @@ type ContinueDestination = { export function useContinueCloudCreate( organizationId: string | undefined, /** Invoked once the clone settled, right before the success navigation. */ - onCreated?: () => void + onCreated?: () => void, + current?: { + launchSelection: ProviderLaunchSelection | null; + confirmLegacyRetry?: () => Promise; + } ): (sessionId: KiloSessionId, dest: ContinueDestination, mode: string) => Promise { const router = useRouter(); const queryClient = useQueryClient(); const trpc = useTRPC(); const { userId } = useCurrentUserId(); - const scopeKey = JSON.stringify([userId, organizationId]); + const { launchSelection: currentLaunchSelection, confirmLegacyRetry } = current ?? {}; + const selectionIsTracked = currentLaunchSelection !== undefined; + const selectedFingerprint = + currentLaunchSelection && userId + ? getProviderLaunchFingerprint(userId, currentLaunchSelection) + : null; + const scopeKey = JSON.stringify([userId, organizationId, selectedFingerprint]); const scope = useMemo(() => ({ key: scopeKey }), [scopeKey]); const currentScope = useRef(scope); currentScope.current = scope; @@ -56,6 +68,7 @@ export function useContinueCloudCreate( // reuses the same key instead of minting a duplicate session. const { getStoredOperationKey, + getStoredSafeRetry, writeSafeRetry, remove: removeOutboxRow, whenLoaded, @@ -81,21 +94,19 @@ export function useContinueCloudCreate( { data: { code: 'BAD_REQUEST' } } ); } - const intentFingerprint = JSON.stringify({ + if (selectionIsTracked && launch.fingerprint !== selectedFingerprint) { + return; + } + const intent = { cloneFromKiloSessionId: sessionId, repo: launch.fingerprint, model: dest.model, variant: dest.variant || undefined, mode, organizationId: organizationId ?? null, - }); - // Reuse a stored safe-retry key for this fingerprint on relaunch; mint a - // fresh key only when no stored row exists. A stored row must never be - // replaced by a new in-memory key. Gate on the outbox load first: a - // continue that races the launch load would read empty rows and mint a - // duplicate. - // A failed outbox read reads as no stored rows, so refuse instead of - // minting a fresh key over a row whose POST the server may have accepted. + }; + let intentFingerprint = JSON.stringify(intent); + // Gate key lookup on the load; unread stored rows must never mint a replacement. const outboxLoaded = await whenLoaded(); if (currentScope.current !== scope) { return; @@ -103,11 +114,20 @@ export function useContinueCloudCreate( if (!outboxLoaded) { throw new Error(i18n.t('agentChat.newSession.couldNotReadPendingSessions')); } + const storedOperationKey = getStoredOperationKey(intentFingerprint); + const unpinnedLaunch = resolveProviderLaunchInput(dest.repository, {}); + // Retain old unpinned Continue records until old clients/records and the + // 30-day ledger window expire. Replay the old intent, never newly selected pins. + const legacyRow = + storedOperationKey === null && dest.launchSelection && unpinnedLaunch + ? getStoredSafeRetry(JSON.stringify({ ...intent, repo: unpinnedLaunch.fingerprint })) + : null; const operationKey = - getStoredOperationKey(intentFingerprint) ?? cloudOperationKey.getKey(intentFingerprint); - // The clone-only prepare schema forbids `prompt` and `initialMessageId`; - // the clone carries no synthetic turn. - const baseInput: ContinuePrepareInput = { + legacyRow?.operationKey ?? + storedOperationKey ?? + cloudOperationKey.getKey(intentFingerprint); + // The clone-only prepare carries no synthetic turn. + let baseInput: ContinuePrepareInput = { mode: normalizeAgentMode(mode), model: dest.model, variant: dest.variant || undefined, @@ -115,16 +135,25 @@ export function useContinueCloudCreate( autoInitiate: true, operationKey, cloneFromKiloSessionId: sessionId, - ...launch.input, + ...(legacyRow && unpinnedLaunch ? unpinnedLaunch.input : launch.input), }; try { - // Persist the safe-retry row BEFORE the mutate so a crash mid-flight - // reuses the same key on relaunch instead of minting a duplicate. - await writeSafeRetry({ - operationKey, - fingerprint: intentFingerprint, - input: baseInput, - }); + if (legacyRow) { + const restored = restoreLegacyLaunchInput(legacyRow, baseInput); + if (!restored || !confirmLegacyRetry) { + throw Object.assign(new Error(i18n.t('agentChat.newSession.legacyLaunchUnavailable')), { + data: { code: 'BAD_REQUEST' }, + }); + } + if (!(await confirmLegacyRetry()) || currentScope.current !== scope) { + return; + } + baseInput = restored; + intentFingerprint = legacyRow.fingerprint; + } else { + // Persist before dispatch. An existing legacy row stays unchanged until success. + await writeSafeRetry({ operationKey, fingerprint: intentFingerprint, input: baseInput }); + } if (currentScope.current !== scope) { return; } @@ -192,9 +221,8 @@ export function useContinueCloudCreate( if (currentScope.current !== scope) { return; } - // Only `prepareSession` errors reach here; UI failures are contained - // above. A typed terminal rejection ends the intent. - if (!isCloudPrepareRetryableError(error)) { + // An unresolved legacy identity must keep its original key, even after rejection. + if (!legacyRow && !isCloudPrepareRetryableError(error)) { cloudOperationKey.rotateKey(); await removeOutboxRow(intentFingerprint); } @@ -207,15 +235,19 @@ export function useContinueCloudCreate( organizationId, userId, scope, + selectionIsTracked, + selectedFingerprint, queryClient, router, trpc, cloudOperationKey, getStoredOperationKey, + getStoredSafeRetry, writeSafeRetry, removeOutboxRow, whenLoaded, onCreated, + confirmLegacyRetry, ] ); } diff --git a/apps/mobile/src/components/agents/use-new-session-creator.test.ts b/apps/mobile/src/components/agents/use-new-session-creator.test.ts index 2de4b78afd..637902bdb9 100644 --- a/apps/mobile/src/components/agents/use-new-session-creator.test.ts +++ b/apps/mobile/src/components/agents/use-new-session-creator.test.ts @@ -122,6 +122,7 @@ vi.mock('expo-crypto', () => { }); const outboxMock = vi.hoisted(() => ({ + getStoredSafeRetry: vi.fn(() => null), getStoredOperationKey: vi.fn((_fingerprint: string): string | null => null), writeSafeRetry: vi.fn( async (_row: { operationKey: string; fingerprint: string }): Promise => undefined diff --git a/apps/mobile/src/components/agents/use-new-session-creator.ts b/apps/mobile/src/components/agents/use-new-session-creator.ts index cee66a92d6..421a19a780 100644 --- a/apps/mobile/src/components/agents/use-new-session-creator.ts +++ b/apps/mobile/src/components/agents/use-new-session-creator.ts @@ -9,9 +9,10 @@ import { i18n } from '@/i18n'; import { type AgentMode } from '@/components/agents/mode-selector'; import { type NewSessionRepository } from '@/components/agents/new-session-repository-state'; import { + type NewSessionPrepareInput as PrepareSessionInput, type ProviderLaunchSelection, - type ProviderPrepareInput, resolveProviderLaunchInput, + restoreLegacyLaunchInput, } from '@/components/agents/provider-launch-input'; import { resolveNewSessionPromptForCreate } from '@/components/agents/new-session-prompt-state'; import { isCloudPrepareRetryableError } from '@/components/agents/mobile-session-manager'; @@ -21,10 +22,7 @@ import { captureEvent, SESSION_CREATED_EVENT } from '@/lib/analytics/posthog'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; import { useHoistedOperationKey } from '@/lib/operation-key'; import { useMutationOutbox } from '@/lib/persist/use-mutation-outbox'; -import { - type AgentAttachmentWire, - type useAgentAttachmentUpload, -} from '@/lib/agent-attachments/use-agent-attachment-upload'; +import { type useAgentAttachmentUpload } from '@/lib/agent-attachments/use-agent-attachment-upload'; import { trpcClient, useTRPC } from '@/lib/trpc'; type UseNewSessionCreatorInput = { @@ -42,19 +40,7 @@ type UseNewSessionCreatorInput = { autoCommit: boolean; /** Effective environment profile id; omitted from the create body when unset. */ profileId?: string | null; -}; - -type PrepareSessionInput = ProviderPrepareInput & { - prompt: string; - initialMessageId: string; - mode: AgentMode; - model: string; - variant: string | undefined; - autoCommit: boolean; - autoInitiate: boolean; - operationKey: string; - profileId?: string; - attachments?: AgentAttachmentWire; + confirmLegacyRetry?: () => Promise; }; type UseNewSessionCreatorResult = { @@ -81,6 +67,7 @@ export function useNewSessionCreator({ variant, autoCommit, profileId, + confirmLegacyRetry, }: UseNewSessionCreatorInput): UseNewSessionCreatorResult { const router = useRouter(); const queryClient = useQueryClient(); @@ -113,6 +100,7 @@ export function useNewSessionCreator({ // reuses the same key instead of minting a duplicate session. const { getStoredOperationKey, + getStoredSafeRetry, writeSafeRetry, remove: removeOutboxRow, whenLoaded, @@ -167,7 +155,7 @@ export function useNewSessionCreator({ // Computed once and reused for both the fingerprint and the create body, so // the two cannot disagree and a swapped attachment set is a fresh intent. const attachmentWire = uploaded.wire; - const intentFingerprint = JSON.stringify({ + const intent = { prompt, mode, model, @@ -177,23 +165,15 @@ export function useNewSessionCreator({ organizationId: organizationId ?? null, profileId: profileId ?? null, attachments: attachmentWire ?? null, - }); - // Old GitHub safe-retry rows used the bare name. Never apply that lookup to - // a pinned selection. Remove only after old clients/records disappear and - // the 30-day ledger window expires; preserve the serialized field order. + }; + let intentFingerprint = JSON.stringify(intent); + const unpinnedLaunch = resolveProviderLaunchInput(selectedRepository, {}); + // Retain both deployed fingerprint forms until old clients/records disappear + // and the 30-day ledger window expires. A match identifies an old operation, + // not an exact repository. const legacyIntentFingerprint = - launchSelection === undefined && selectedRepository?.platform === 'github' - ? JSON.stringify({ - prompt, - mode, - model, - variant: variant || undefined, - repo: selectedRepository.fullName, - autoCommit, - organizationId: organizationId ?? null, - profileId: profileId ?? null, - attachments: attachmentWire ?? null, - }) + selectedRepository?.platform === 'github' + ? JSON.stringify({ ...intent, repo: selectedRepository.fullName }) : null; // Reuse a stored safe-retry key for this fingerprint on relaunch; mint a // fresh key only when no stored row exists. A stored row must never be @@ -211,12 +191,23 @@ export function useNewSessionCreator({ return; } let operationKey = getStoredOperationKey(intentFingerprint); - // The consumed legacy row migrates to the scoped fingerprint so the normal - // success/failure cleanup only ever touches the scoped row. Delete it only - // after the scoped row exists: a crash between the two writes would - // otherwise lose the key and mint a duplicate session on relaunch. + const legacyRows = + operationKey === null && launchSelection && unpinnedLaunch + ? [JSON.stringify({ ...intent, repo: unpinnedLaunch.fingerprint }), legacyIntentFingerprint] + .filter(fingerprint => fingerprint !== null) + .map(fingerprint => getStoredSafeRetry(fingerprint)) + .filter(row => row !== null) + : []; + const legacyRow = legacyRows[0]; + operationKey ??= legacyRow?.operationKey ?? null; + // Old unnormalized callers still migrate the bare GitHub fingerprint only + // after persistence. Normalized callers never migrate old intent into new pins. let legacyRowToDrop: string | null = null; - if (operationKey === null && legacyIntentFingerprint !== null) { + if ( + operationKey === null && + launchSelection === undefined && + legacyIntentFingerprint !== null + ) { operationKey = getStoredOperationKey(legacyIntentFingerprint); if (operationKey !== null) { legacyRowToDrop = legacyIntentFingerprint; @@ -225,17 +216,16 @@ export function useNewSessionCreator({ operationKey ??= getKey(intentFingerprint); try { - const initialMessageId = generateMessageId(); - const baseInput: PrepareSessionInput = { + let baseInput: PrepareSessionInput = { prompt, - initialMessageId, + initialMessageId: generateMessageId(), mode, model, variant: variant || undefined, autoCommit, autoInitiate: true, operationKey, - ...launch.input, + ...(legacyRow && unpinnedLaunch ? unpinnedLaunch.input : launch.input), }; if (profileId) { baseInput.profileId = profileId; @@ -243,14 +233,21 @@ export function useNewSessionCreator({ if (attachmentWire) { baseInput.attachments = attachmentWire; } - - // Persist the safe-retry row BEFORE the mutate so a crash mid-flight - // reuses the same key on relaunch instead of minting a duplicate. - await writeSafeRetry({ - operationKey, - fingerprint: intentFingerprint, - input: baseInput, - }); + if (legacyRow) { + const restored = + legacyRows.length === 1 ? restoreLegacyLaunchInput(legacyRow, baseInput) : null; + if (!restored || !confirmLegacyRetry) { + throw new Error(i18n.t('agentChat.newSession.legacyLaunchUnavailable')); + } + if (!(await confirmLegacyRetry()) || currentScope.current !== scope) { + return; + } + baseInput = restored; + intentFingerprint = legacyRow.fingerprint; + } else { + // Persist before dispatch. An existing legacy row stays unchanged until success. + await writeSafeRetry({ operationKey, fingerprint: intentFingerprint, input: baseInput }); + } if (currentScope.current !== scope) { return; } @@ -330,12 +327,12 @@ export function useNewSessionCreator({ if (currentScope.current !== scope) { return; } - // Only `prepareSession` errors reach here; UI failures are swallowed. + // Report recovery and prepare failures; post-success UI failures stay contained. const message = error instanceof Error ? error.message : i18n.t('agentChat.newSession.failedToCreate'); toast.error(message); - // A typed terminal rejection ends the intent; a retryable one keeps the key. - if (!isCloudPrepareRetryableError(error)) { + // An unresolved legacy identity must keep its original key, even after rejection. + if (!legacyRow && !isCloudPrepareRetryableError(error)) { rotateKey(); await removeOutboxRow(intentFingerprint); } @@ -363,10 +360,12 @@ export function useNewSessionCreator({ getKey, rotateKey, getStoredOperationKey, + getStoredSafeRetry, writeSafeRetry, removeOutboxRow, whenLoaded, onCreated, + confirmLegacyRetry, ]); return { createSessionFromDraft, promptRef }; diff --git a/apps/mobile/src/components/agents/use-new-session-prefill.test.ts b/apps/mobile/src/components/agents/use-new-session-prefill.test.ts new file mode 100644 index 0000000000..27cf223046 --- /dev/null +++ b/apps/mobile/src/components/agents/use-new-session-prefill.test.ts @@ -0,0 +1,134 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the existing DOM-free hook harness. */ +import * as React from 'react'; +import TestRenderer, { act, type ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, assert, beforeEach, expect, it, vi } from 'vitest'; +import { i18n } from '@/i18n'; +import { normalizeSessionRepository } from './new-session-repository-state'; +import { + useNewSessionPrefillTargets, + type UseNewSessionPrefillTargetsInput, +} from './use-new-session-prefill'; + +const mocks = vi.hoisted(() => { + const params: { prefillRepo?: string; prefillModel?: string } = {}; + return { params, notes: [] as string[] }; +}); +vi.mock('expo-router', () => ({ useLocalSearchParams: () => mocks.params })); +vi.mock('sonner-native', () => ({ + toast: { info: (message: string) => mocks.notes.push(message) }, +})); +vi.mock('@/components/ui/icons', () => ({ + Bug: 'Bug', + Code: 'Code', + HelpCircle: 'HelpCircle', + NotebookPen: 'NotebookPen', + Workflow: 'Workflow', +})); + +let renderer: ReactTestRenderer | undefined = undefined; +let latest: ReturnType | undefined = undefined; +function result() { + assert(latest, 'Prefill hook did not render'); + return latest; +} +function Harness(props: UseNewSessionPrefillTargetsInput) { + latest = useNewSessionPrefillTargets(props); + return null; +} +function mountPrefill(input: UseNewSessionPrefillTargetsInput) { + act(() => { + renderer = TestRenderer.create(React.createElement(Harness, input)); + }); +} +function updatePrefill(input: UseNewSessionPrefillTargetsInput) { + act(() => renderer?.update(React.createElement(Harness, input))); +} +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + mocks.params = {}; + mocks.notes.length = 0; +}); +afterEach(() => { + act(() => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +it.each([ + { provider: 'github', repo: 'owner/repo' }, + { provider: 'gitlab', repo: 'https://gitlab.com/owner/repo.git' }, +] as const)( + 'announces the unavailable model once while legacy $provider prefill stays unresolved', + ({ provider, repo }) => { + mocks.params = { prefillRepo: repo, prefillModel: 'unavailable/model' }; + const repository = normalizeSessionRepository( + { + private: true, + repositoryReference: { + repository: { + provider, + instanceUrl: `https://${provider}.com`, + repositoryId: '7', + fullName: 'owner/repo', + defaultBranch: 'main', + }, + authorization: { + kind: 'ownerIntegration', + owner: { type: 'org', id: 'org-1' }, + integrationId: 'integration-1', + }, + }, + }, + 'user-1', + 'org-1' + ); + assert(repository, 'Authorized repository fixture is missing'); + const input: UseNewSessionPrefillTargetsInput = { + repositories: [repository], + reposSettled: true, + models: [], + modelsSettled: false, + }; + mountPrefill(input); + expect(result().selectedRepo).toBe(''); + expect(mocks.notes).toEqual([]); + + // A loading or failed model request cannot yet prove that the model is missing. + const models = [{ id: 'available/model', variants: [] }]; + updatePrefill({ ...input, models }); + expect(mocks.notes).toEqual([]); + updatePrefill({ ...input, models, modelsSettled: true }); + const expectedNotice = i18n.t('agentChat.newSession.prefillModelUnavailable', { + model: 'unavailable/model', + }); + expect(mocks.notes).toEqual([expectedNotice]); + expect(result().selectedRepo).toBe(''); + + act(() => { + result().setSelectedRepo(repository.key); + }); + updatePrefill({ ...input, models: [...models], modelsSettled: true }); + expect(result().selectedRepo).toBe(repository.key); + expect(mocks.notes).toEqual([expectedNotice]); + } +); + +it.each(['available/model', undefined])( + 'keeps unresolved repository prefill silent when requested model is %s', + prefillModel => { + mocks.params = { prefillRepo: 'owner/repo', prefillModel }; + mountPrefill({ + repositories: [], + reposSettled: true, + models: [{ id: 'available/model', variants: [] }], + modelsSettled: true, + }); + expect(result().selectedRepo).toBe(''); + expect(mocks.notes).toEqual([]); + } +); + +it('keeps empty prefill silent while discovery is empty', () => { + mountPrefill({ repositories: [], reposSettled: true, models: [], modelsSettled: false }); + expect(result().selectedRepo).toBe(''); + expect(mocks.notes).toEqual([]); +}); diff --git a/apps/mobile/src/components/agents/use-new-session-prefill.ts b/apps/mobile/src/components/agents/use-new-session-prefill.ts index c120125dc5..7c8cdbf12c 100644 --- a/apps/mobile/src/components/agents/use-new-session-prefill.ts +++ b/apps/mobile/src/components/agents/use-new-session-prefill.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocalSearchParams } from 'expo-router'; import { toast } from 'sonner-native'; +import { type ResolvedNewSessionRepository } from '@/components/agents/new-session-repository-state'; import { describePrefillFallback, @@ -33,8 +34,8 @@ export function useNewSessionPrefill(): NewSessionPrefill { } export type UseNewSessionPrefillTargetsInput = { - repositories: { platform: string; fullName: string }[]; - // !isLoadingRepos && !isReposError && repositories.length > 0 + repositories: ResolvedNewSessionRepository[]; + /** Request settlement does not prove complete authorized discovery. */ reposSettled: boolean; models: { id: string; variants: string[] }[]; // !isLoadingModels && !isModelsError && models.length > 0 @@ -42,27 +43,25 @@ export type UseNewSessionPrefillTargetsInput = { }; /** - * Owns the `selectedRepo` state and applies the repo prefill exactly once - * when the repository list settles. Also fires the fallback info toast at - * most once per mount. + * Owns the `selectedRepo` state and applies an exact authorized prefill once. + * Partial browsing can establish an exact match, but never a legacy match or absence. + * Fires the fallback info toast at most once per mount. * * The repo prefill apply mirrors the existing `hasAppliedAutoSelection` * pattern in `agent-chat/new.tsx` — a same-component render-phase update * guarded by a ref. */ export function useNewSessionPrefillTargets(input: UseNewSessionPrefillTargetsInput) { - const { repositories, reposSettled, models, modelsSettled } = input; + const { repositories, models, modelsSettled } = input; const prefill = useNewSessionPrefill(); const [selectedRepo, setSelectedRepo] = useState(''); const hasAppliedRepo = useRef(false); const hasFiredToast = useRef(false); + const resolvedRepo = resolvePrefillRepoSelection(repositories, prefill); - if (!hasAppliedRepo.current && reposSettled && !selectedRepo) { + if (!hasAppliedRepo.current && resolvedRepo && !selectedRepo) { hasAppliedRepo.current = true; - const resolved = resolvePrefillRepoSelection(repositories, prefill); - if (resolved) { - setSelectedRepo(resolved); - } + setSelectedRepo(resolvedRepo); } useEffect(() => { @@ -73,8 +72,9 @@ export function useNewSessionPrefillTargets(input: UseNewSessionPrefillTargetsIn const note = describePrefillFallback({ prefill, repos: { - settled: reposSettled, - matched: resolvePrefillRepoSelection(repositories, prefill) !== null, + // Browsing cannot prove absence. Do not announce that an unresolved prefill is unavailable. + settled: !prefill.repo || resolvedRepo !== null, + matched: resolvedRepo !== null, }, models: { settled: modelsSettled, @@ -86,7 +86,7 @@ export function useNewSessionPrefillTargets(input: UseNewSessionPrefillTargetsIn hasFiredToast.current = true; toast.info(note); } - }, [prefill, reposSettled, modelsSettled, repositories, models]); + }, [prefill, resolvedRepo, modelsSettled, models]); return { selectedRepo, setSelectedRepo }; } diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index b8fb24cefd..a5138b4adf 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -2306,6 +2306,25 @@ "newSession": { "title": "New session", "repository": "Repository", + "branch": "Branch", + "selectBranch": "Select branch", + "loadingBranches": "Loading branches…", + "loadMoreBranches": "Load more branches", + "couldNotLoadBranches": "Could not load branches. Retry without changing your repository or prompt.", + "branchUnavailable": "The selected branch is no longer available. Select another branch.", + "noBranches": "This repository has no branches. Refresh after a branch is created, or select another repository.", + "defaultBranchUnavailable": "The default branch is unavailable. Select a branch.", + "branchAccessDenied": "Branches are unavailable for this connection. Reconnect or select another repository.", + "loadingRepositories": "Loading {{provider}} repositories…", + "repositoryUnavailable": "This repository is no longer available for the selected owner. Select a repository again.", + "repositoryAccessDenied": "Repository access is denied. Check the integration permissions or select another repository.", + "repositoryIdentityUnavailable": "Repository identity is unavailable. Refresh the connection before selecting this repository.", + "invalidRepositorySelection": "This repository selection is invalid. Select a repository again.", + "legacyLaunchTitle": "Recover a previous launch?", + "legacyLaunchMessage": "This launch was saved before exact repository and branch selection. Retry its original settings. Your current repository connection and branch selection will not apply.", + "retryLegacyLaunch": "Retry original launch", + "legacyLaunchUnavailable": "The saved launch cannot be recovered safely. Its retry key is preserved. Check your sessions before starting a replacement.", + "personalBitbucket": "Bitbucket sessions require an organization. Switch to an organization to select a Bitbucket repository.", "recentlyUsed": "Recently used", "couldNotLoadGithubRepositories": "Couldn't load GitHub repositories", "couldNotLoadGitlabRepositories": "Couldn't load GitLab repositories", diff --git a/apps/mobile/src/lib/integration-urls.test.ts b/apps/mobile/src/lib/integration-urls.test.ts index ed551a3d6a..df255342ca 100644 --- a/apps/mobile/src/lib/integration-urls.test.ts +++ b/apps/mobile/src/lib/integration-urls.test.ts @@ -14,12 +14,15 @@ describe('getGitLabIntegrationUrl', () => { }); describe('getBitbucketIntegrationUrl', () => { - it('links to the org code-reviews page with the Bitbucket tab selected', () => { + it('opens the actual organization integration setup', () => { expect(getBitbucketIntegrationUrl('https://app.kilo.ai', 'org_123')).toBe( - 'https://app.kilo.ai/organizations/org_123/code-reviews?platform=bitbucket' + 'https://app.kilo.ai/organizations/org_123/integrations/bitbucket' ); expect(getBitbucketIntegrationUrl('https://app.kilo.ai/', 'org_123')).toBe( - 'https://app.kilo.ai/organizations/org_123/code-reviews?platform=bitbucket' + 'https://app.kilo.ai/organizations/org_123/integrations/bitbucket' + ); + expect(getBitbucketIntegrationUrl('https://app.kilo.ai', 'org/a')).toBe( + 'https://app.kilo.ai/organizations/org%2Fa/integrations/bitbucket' ); }); }); diff --git a/apps/mobile/src/lib/integration-urls.ts b/apps/mobile/src/lib/integration-urls.ts index cabfe28c6d..e25d70603c 100644 --- a/apps/mobile/src/lib/integration-urls.ts +++ b/apps/mobile/src/lib/integration-urls.ts @@ -6,11 +6,8 @@ export function getGitLabIntegrationUrl(webBaseUrl: string, organizationId?: str return `${baseUrl}/organizations/${encodeURIComponent(organizationId)}/integrations/gitlab`; } -// Bitbucket is org-only (see PLATFORM_CAPABILITIES), so unlike the GitHub/ -// GitLab helpers above there is no personal variant — it links straight to -// the org's Code Reviewer settings page (apps/web's -// organizations/[id]/code-reviews), pre-selecting the Bitbucket tab. +// Bitbucket setup is organization-only; interactive launch does not use Code Reviewer. export function getBitbucketIntegrationUrl(webBaseUrl: string, organizationId: string): string { const baseUrl = webBaseUrl.endsWith('/') ? webBaseUrl.slice(0, -1) : webBaseUrl; - return `${baseUrl}/organizations/${encodeURIComponent(organizationId)}/code-reviews?platform=bitbucket`; + return `${baseUrl}/organizations/${encodeURIComponent(organizationId)}/integrations/bitbucket`; } diff --git a/apps/mobile/src/lib/persist/use-mutation-outbox.test.ts b/apps/mobile/src/lib/persist/use-mutation-outbox.test.ts index 898e2e4b5e..807be6ab2f 100644 --- a/apps/mobile/src/lib/persist/use-mutation-outbox.test.ts +++ b/apps/mobile/src/lib/persist/use-mutation-outbox.test.ts @@ -107,6 +107,26 @@ afterEach(() => { }); describe('useMutationOutbox key reuse', () => { + it('returns the admitted input after loading and excludes other accounts and taxonomies', async () => { + const load = deferred(); + listOutboxRowsMock.mockReturnValueOnce(load.promise); + const { resultRef, rerender } = mountOutbox(); + const snapshot = requireResult(resultRef); + const row = safeRetryRow({ input: { githubRepo: 'owner/repo', initialMessageId: 'original' } }); + expect(snapshot.getStoredSafeRetry('fp-1')).toBeNull(); + const loaded = snapshot.whenLoaded(); + await act(async () => { + load.resolve([row, safeRetryRow({ fingerprint: 'reconcile', taxonomy: 'reconcile-first' })]); + await loaded; + }); + expect(snapshot.getStoredSafeRetry('fp-1')).toEqual(row); + expect(snapshot.getStoredSafeRetry('reconcile')).toBeNull(); + identityMock.value = { userId: 'u2', isLoading: false }; + rerender(); + await flushMicrotasks(); + expect(requireResult(resultRef).getStoredSafeRetry('fp-1')).toBeNull(); + }); + it('reuses a stored safe-retry key for a matching fingerprint on launch', async () => { listOutboxRowsMock.mockResolvedValue([safeRetryRow({ fingerprint: 'fp-1' })]); const { resultRef } = mountOutbox(); diff --git a/apps/mobile/src/lib/persist/use-mutation-outbox.ts b/apps/mobile/src/lib/persist/use-mutation-outbox.ts index 1432d1febb..8a46e72a36 100644 --- a/apps/mobile/src/lib/persist/use-mutation-outbox.ts +++ b/apps/mobile/src/lib/persist/use-mutation-outbox.ts @@ -29,7 +29,8 @@ export type OutboxRowInput = { * `safe-retry` fingerprint, or null. A caller must check this BEFORE minting * a new key, so a relaunch reuses the stored key instead of minting a new * UUID. `reconcile-first` rows never contribute a key: they are never - * auto-replayed. + * auto-replayed. `getStoredSafeRetry` also returns the admitted input for + * compatibility recovery; consumers must validate that unknown input. * - `loaded` is false until the launch load settles (or the identity resolves * to no user). `whenLoaded` resolves at that point, so a submit can gate on * the load and read the freshly-loaded rows instead of minting a key over @@ -139,12 +140,16 @@ export function useMutationOutbox() { }; }, [isLoading, runLoad, resetLoaded]); - const getStoredOperationKey = useCallback((fingerprint: string): string | null => { - const row = rowsRef.current.find( - r => r.fingerprint === fingerprint && r.taxonomy === 'safe-retry' - ); - return row?.operationKey ?? null; - }, []); + const getStoredSafeRetry = useCallback( + (fingerprint: string): OutboxRow | null => + rowsRef.current.find(r => r.fingerprint === fingerprint && r.taxonomy === 'safe-retry') ?? + null, + [] + ); + const getStoredOperationKey = useCallback( + (fingerprint: string): string | null => getStoredSafeRetry(fingerprint)?.operationKey ?? null, + [getStoredSafeRetry] + ); const writeSafeRetry = useCallback( async (row: OutboxRowInput): Promise => { @@ -222,6 +227,7 @@ export function useMutationOutbox() { return { getStoredOperationKey, + getStoredSafeRetry, writeSafeRetry, writeReconcileFirst, remove, diff --git a/apps/mobile/src/lib/picker-bridge.ts b/apps/mobile/src/lib/picker-bridge.ts index 96d1dcf0a6..4c5d020b8a 100644 --- a/apps/mobile/src/lib/picker-bridge.ts +++ b/apps/mobile/src/lib/picker-bridge.ts @@ -1,3 +1,8 @@ +import { + type NewSessionRepository, + type RepositoryPlatform, + type ResolvedNewSessionRepository, +} from '@/components/agents/new-session-repository-state'; import { type AgentMode } from '@/components/agents/mode-selector'; import { type ModeOption } from '@/components/agents/mode-normalize'; import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; @@ -41,7 +46,7 @@ export type ModePickerBridge = { customOptions?: ModeOption[]; }; -export type RepoPlatform = 'github' | 'gitlab' | 'bitbucket'; +export type RepoPlatform = RepositoryPlatform; /** i18n key for each repository provider's display name (rows, closed selector, and group headers). */ export const REPO_PLATFORM_LABEL_KEYS = { @@ -50,23 +55,19 @@ export const REPO_PLATFORM_LABEL_KEYS = { bitbucket: 'agentChat.repoPicker.platformBitbucket', } satisfies Record; -export type RepoOption = { - platform: RepoPlatform; - fullName: string; - isPrivate: boolean; - workspaceUuid?: string; - repositoryUuid?: string; -}; +// Old search callers can supply unqualified rows, but selection requires resolved identity. +// Remove after old clients/records disappear and the 30-day ledger window expires. +export type RepoOption = NewSessionRepository; export type RepoPickerSection = { key: 'recents' | RepoPlatform; /** i18n key the picker resolves for the section header. */ titleKey: string; - repos: RepoOption[]; + repos: ResolvedNewSessionRepository[]; }; export type RepoPickerBridge = { - repositories: RepoOption[]; + repositories: ResolvedNewSessionRepository[]; /** Grouped sections (recents, then providers) shown when the search box is empty. */ sections: RepoPickerSection[]; currentValue: string; diff --git a/apps/mobile/src/lib/repo-picker-filter.test.ts b/apps/mobile/src/lib/repo-picker-filter.test.ts index 393b866cfc..b313bb70dd 100644 --- a/apps/mobile/src/lib/repo-picker-filter.test.ts +++ b/apps/mobile/src/lib/repo-picker-filter.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { type RepoOption } from './picker-bridge'; +import { + type NewSessionRepository, + normalizeSessionRepository, + repositoryKey, + type ResolvedNewSessionRepository, +} from '@/components/agents/new-session-repository-state'; import { filterRepoPickerOptions } from './repo-picker-filter'; -const repositories: RepoOption[] = [ +const repositories: NewSessionRepository[] = [ { fullName: 'Kilo-Org/cloud', isPrivate: true, platform: 'github' }, { fullName: 'octocat/Hello-World', isPrivate: false, platform: 'github' }, { fullName: 'acme/widgets', isPrivate: true, platform: 'github' }, @@ -17,4 +22,65 @@ describe('filterRepoPickerOptions', () => { it('filters repositories by full name case-insensitively', () => { expect(filterRepoPickerOptions({ repositories, search: 'hello' })).toEqual([repositories[1]]); }); + + it.each(['git.example.com/base', 'integration-2', 'org:team-2'])( + 'finds a same-named repository by its exact identity label %s', + search => { + const qualified: NewSessionRepository = { + platform: 'gitlab', + fullName: 'Kilo-Org/cloud', + isPrivate: true, + reference: { + repository: { + provider: 'gitlab', + instanceUrl: 'https://git.example.com/base', + repositoryId: '7', + fullName: 'Kilo-Org/cloud', + defaultBranch: null, + }, + authorization: { + kind: 'ownerIntegration', + owner: { type: 'org', id: 'team-2' }, + integrationId: 'integration-2', + }, + }, + }; + expect( + filterRepoPickerOptions({ repositories: [...repositories, qualified], search }) + ).toEqual([qualified]); + } + ); + + it('keeps normalized identity when filtering same-named integrations for selection', () => { + const rows = ['integration-1', 'integration-2'].flatMap(integrationId => { + const row = normalizeSessionRepository( + { + private: true, + repositoryReference: { + repository: { + provider: 'gitlab', + instanceUrl: 'https://git.example.com/base', + repositoryId: '7', + fullName: 'Kilo-Org/cloud', + defaultBranch: 'develop', + }, + authorization: { + kind: 'ownerIntegration', + owner: { type: 'org', id: 'team-2' }, + integrationId, + }, + }, + }, + 'user-1', + 'team-2' + ); + return row ? [row] : []; + }); + const filtered: ResolvedNewSessionRepository[] = filterRepoPickerOptions({ + repositories: rows, + search: 'integration-2', + }); + expect(filtered).toEqual([rows[1]]); + expect(filtered.map(row => repositoryKey(row))).toEqual([rows[1]?.key]); + }); }); diff --git a/apps/mobile/src/lib/repo-picker-filter.ts b/apps/mobile/src/lib/repo-picker-filter.ts index 119b14761b..aa70621173 100644 --- a/apps/mobile/src/lib/repo-picker-filter.ts +++ b/apps/mobile/src/lib/repo-picker-filter.ts @@ -1,15 +1,16 @@ +import { repositoryLabel } from '@/components/agents/new-session-repository-state'; import { type RepoOption } from '@/lib/picker-bridge'; -export function filterRepoPickerOptions({ +export function filterRepoPickerOptions({ repositories, search, }: { - repositories: RepoOption[]; + repositories: T[]; search: string; }) { const query = search.toLowerCase().trim(); if (!query) { return repositories; } - return repositories.filter(repo => repo.fullName.toLowerCase().includes(query)); + return repositories.filter(repo => repositoryLabel(repo).toLowerCase().includes(query)); } diff --git a/apps/mobile/src/lib/use-github-repos-refresh-cache.test.ts b/apps/mobile/src/lib/use-github-repos-refresh-cache.test.ts new file mode 100644 index 0000000000..c15b06c0be --- /dev/null +++ b/apps/mobile/src/lib/use-github-repos-refresh-cache.test.ts @@ -0,0 +1,297 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the existing DOM-free hook harness. */ +import * as React from 'react'; +import TestRenderer, { act, type ReactTestRenderer } from 'react-test-renderer'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { type TRPCQueryKey } from '@trpc/tanstack-react-query'; +import { afterEach, assert, beforeEach, expect, it, vi } from 'vitest'; +import { type RepositoryPlatform } from '@/components/agents/new-session-repository-state'; +import { createKiloAppQueryClient } from './query-client'; +import { setRepositoryDiscoveryError } from './use-github-repos-refresh'; +import { useNewSessionRepos } from './use-new-session-repos'; + +const mocks = vi.hoisted(() => ({ + fetch: vi.fn<(platform: RepositoryPlatform, forceRefresh: boolean) => Promise>(), + browser: vi.fn<() => Promise>(), + mint: vi.fn<() => Promise<{ token: string }>>(), + normalRequests: [] as RepositoryPlatform[], + recents: [] as unknown[], +})); +vi.mock('react-native', () => ({ + AppState: { addEventListener: () => ({ remove: vi.fn() }) }, + Platform: { OS: 'ios' }, +})); +vi.mock('sonner-native', () => ({ toast: { error: vi.fn() } })); +vi.mock('@/lib/hooks/use-current-user-id', () => ({ + useCurrentUserId: () => ({ userId: 'user-1' }), +})); +vi.mock('@/lib/hooks/use-agent-sessions', () => ({ + useRecentAgentRepositories: () => ({ data: { repositories: mocks.recents } }), +})); +vi.mock('@/lib/config', () => ({ WEB_BASE_URL: 'https://app.example.com' })); +vi.mock('@/lib/auth/trpc-unauthorized', () => ({ handleTrpcQueryError: vi.fn() })); +vi.mock('@/lib/force-update-signal', () => ({ reportTrpcError: vi.fn() })); +vi.mock('@/lib/pr-review/connect-gate-platform', () => ({ + openAuthorizationAndWaitForReturn: mocks.browser, +})); +vi.mock('@/lib/trpc', () => { + const procedure = (platform: RepositoryPlatform) => ({ + queryOptions: (input: { organizationId?: string; forceRefresh: boolean }) => ({ + queryKey: [[platform], { input, type: 'query' }], + queryFn: async () => { + if (!input.forceRefresh) { + mocks.normalRequests.push(platform); + } + const data = await mocks.fetch(platform, input.forceRefresh); + return data; + }, + }), + }); + const cloudAgentNext = { + listGitHubRepositories: procedure('github'), + listGitLabRepositories: procedure('gitlab'), + listBitbucketRepositories: procedure('bitbucket'), + listRepositoryBranches: { pathFilter: () => ({ queryKey: [['branches']] }) }, + }; + const trpc = { cloudAgentNext, organizations: { cloudAgentNext } }; + return { + useTRPC: () => trpc, + trpcClient: { githubApps: { mintInstallState: { mutate: mocks.mint } } }, + }; +}); + +const client = createKiloAppQueryClient(); +const empty = { integrationInstalled: true, status: 'available', repositories: [] }; +let renderer: ReactTestRenderer | undefined = undefined; +let latest: ReturnType | undefined = undefined; +function result() { + assert(latest, 'Discovery hook did not render'); + return latest; +} +function Harness() { + latest = useNewSessionRepos({ organizationId: 'org-1' }); + return null; +} +function tree() { + return React.createElement(QueryClientProvider, { client }, React.createElement(Harness)); +} +function mountRepos() { + act(() => { + renderer = TestRenderer.create(tree()); + }); +} +async function flushQueries() { + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); +} +function repositoryData(platform: RepositoryPlatform, integrationId: string) { + return { + ...empty, + repositories: [ + { + private: true, + repositoryReference: { + repository: { + provider: platform, + instanceUrl: + platform === 'bitbucket' ? 'https://bitbucket.org' : `https://${platform}.com`, + repositoryId: platform === 'bitbucket' ? '{repository-uuid}' : '7', + fullName: 'owner/repo', + defaultBranch: 'main', + ...(platform === 'bitbucket' ? { workspaceUuid: '{workspace-uuid}' } : {}), + }, + authorization: { + kind: 'ownerIntegration', + owner: { type: 'org', id: 'org-1' }, + integrationId, + }, + }, + }, + ], + }; +} +beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + mocks.normalRequests.length = 0; + mocks.recents.length = 0; + mocks.browser.mockResolvedValue('sheet-close'); + mocks.mint.mockResolvedValue({ token: 'install-state' }); +}); +afterEach(() => { + act(() => renderer?.unmount()); + client.clear(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it.each([ + ['FORBIDDEN', 'access-denied'], + ['UNAUTHORIZED', 'connect'], +])('retains %s recovery after the production cache removes both variants', async (code, status) => { + const permissionClient = createKiloAppQueryClient(); + const normalKey: TRPCQueryKey = [ + ['github'], + { input: { organizationId: 'org-1', forceRefresh: false }, type: 'query', accountId: 'user-1' }, + ]; + const forcedKey: TRPCQueryKey = [ + ['github'], + { input: { organizationId: 'org-1', forceRefresh: true }, type: 'query', accountId: 'user-1' }, + ]; + const error = Object.assign(new Error('Access revoked'), { data: { code } }); + permissionClient.setQueryData(normalKey, { + integrationInstalled: true, + repositories: ['revoked rows'], + }); + try { + await expect( + permissionClient.fetchQuery({ + queryKey: forcedKey, + queryFn: async () => { + await Promise.resolve(); + throw error; + }, + }) + ).rejects.toBe(error); + expect( + permissionClient.getQueryCache().find({ queryKey: normalKey, exact: true }) + ).toBeUndefined(); + expect( + permissionClient.getQueryCache().find({ queryKey: forcedKey, exact: true }) + ).toBeUndefined(); + + expect(setRepositoryDiscoveryError(permissionClient, normalKey, error)).toBe(status); + expect(permissionClient.getQueryState(normalKey)).toMatchObject({ + data: undefined, + error, + status: 'error', + }); + expect(permissionClient.getQueryData(normalKey)).toBeUndefined(); + } finally { + permissionClient.clear(); + } +}); + +it.each( + (['github', 'gitlab', 'bitbucket'] as const).flatMap(platform => + [ + { code: 'FORBIDDEN', status: 'access-denied' }, + { code: 'UNAUTHORIZED', status: 'connect' }, + ].flatMap(({ code, status }) => + ['retry', 'authorization return'].map(recovery => ({ platform, code, status, recovery })) + ) + ) +)( + 'keeps mounted $platform $code recovery until $recovery', + async ({ platform, code, status, recovery }) => { + const cached = repositoryData(platform, 'revoked-integration'); + mocks.recents.push( + ...cached.repositories.map(row => ({ + identity: { kind: 'resolved', accountId: 'user-1', reference: row.repositoryReference }, + })) + ); + mocks.fetch.mockImplementation(async provider => { + await Promise.resolve(); + return provider === platform ? cached : empty; + }); + mountRepos(); + await flushQueries(); + expect(result().repositories).toHaveLength(1); + expect(result().recents).toHaveLength(1); + + const pendingNormal = Promise.withResolvers(); + const denied = Object.assign(new Error('Repository access denied'), { data: { code } }); + mocks.fetch.mockImplementation(async (provider, forceRefresh) => { + await Promise.resolve(); + if (provider !== platform) { + return empty; + } + if (forceRefresh) { + throw denied; + } + return pendingNormal.promise; + }); + await act(async () => { + await result().refreshReposForceFresh(); + }); + await flushQueries(); + expect(result().groups.find(group => group.key === platform)).toEqual({ + key: platform, + status, + repositories: [], + }); + expect(result().repositories).toEqual([]); + expect(result().recents).toEqual([]); + expect(result().isRetrying).toBe(false); + + act(() => renderer?.update(tree())); + await flushQueries(); + expect(result().groups.find(group => group.key === platform)?.status).toBe(status); + act(() => renderer?.unmount()); + mountRepos(); + await flushQueries(); + expect(result().groups.find(group => group.key === platform)?.status).toBe(status); + expect(result().repositories).toEqual([]); + expect(result().recents).toEqual([]); + expect(mocks.normalRequests.filter(provider => provider === platform)).toEqual([platform]); + + const restored = repositoryData(platform, 'restored-integration'); + mocks.fetch.mockImplementation(async (provider, forceRefresh) => { + await Promise.resolve(); + if (provider !== platform) { + return empty; + } + return forceRefresh ? restored : pendingNormal.promise; + }); + await act(async () => { + if (recovery === 'retry') { + await result().refreshReposForceFresh(); + } else { + result().openIntegration(platform); + } + }); + await flushQueries(); + expect(result().groups.find(group => group.key === platform)?.status).toBe('repos'); + expect(result().repositories.map(row => row.reference)).toEqual( + restored.repositories.map(row => row.repositoryReference) + ); + expect(result().isRetrying).toBe(false); + expect(mocks.normalRequests.filter(provider => provider === platform)).toEqual([platform]); + } +); + +it.each( + (['github', 'gitlab', 'bitbucket'] as const).flatMap(platform => + ['FORBIDDEN', 'UNAUTHORIZED'].map(code => ({ platform, code })) + ) +)( + 'retains a directly observed $platform $code failure without retrying on remount', + async ({ platform, code }) => { + const denied = Object.assign(new Error('Repository access denied'), { data: { code } }); + const pending = Promise.withResolvers(); + let firstAttempt = true; + mocks.fetch.mockImplementation(async provider => { + await Promise.resolve(); + if (provider !== platform) { + return empty; + } + if (firstAttempt) { + firstAttempt = false; + throw denied; + } + return pending.promise; + }); + mountRepos(); + await flushQueries(); + const status = code === 'FORBIDDEN' ? 'access-denied' : 'connect'; + expect(result().groups.find(group => group.key === platform)?.status).toBe(status); + act(() => renderer?.unmount()); + mountRepos(); + await flushQueries(); + expect(result().groups.find(group => group.key === platform)?.status).toBe(status); + expect(result().repositories).toEqual([]); + expect(result().recents).toEqual([]); + expect(mocks.normalRequests.filter(provider => provider === platform)).toEqual([platform]); + } +); diff --git a/apps/mobile/src/lib/use-github-repos-refresh.test.ts b/apps/mobile/src/lib/use-github-repos-refresh.test.ts index 64e0545b24..b638e4434d 100644 --- a/apps/mobile/src/lib/use-github-repos-refresh.test.ts +++ b/apps/mobile/src/lib/use-github-repos-refresh.test.ts @@ -1,86 +1,292 @@ -import { describe, expect, it } from 'vitest'; - +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the existing DOM-free hook harness. */ +import * as React from 'react'; +import TestRenderer, { act, type ReactTestRenderer } from 'react-test-renderer'; +import { QueryClient } from '@tanstack/react-query'; +import { type TRPCQueryKey } from '@trpc/tanstack-react-query'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useGitHubReposRefresh } from './use-github-repos-refresh'; import { resolveRefreshTrigger, shouldClearConnectCheckFailed, shouldSetConnectCheckFailed, } from './use-github-repos-refresh-helpers'; -describe('resolveRefreshTrigger', () => { - it('returns sheet-close for iOS', () => { - expect(resolveRefreshTrigger('ios')).toBe('sheet-close'); - }); - - it('returns app-foreground for Android', () => { - expect(resolveRefreshTrigger('android')).toBe('app-foreground'); - }); - - it('falls back to app-foreground for unknown platforms', () => { - expect(resolveRefreshTrigger('web')).toBe('app-foreground'); - expect(resolveRefreshTrigger('')).toBe('app-foreground'); +const mocks = vi.hoisted(() => ({ + userId: 'user-1', + platform: 'ios', + fetch: vi.fn<() => Promise>(), + mint: vi.fn<() => Promise>(), + browser: vi.fn<(os: string, url: string) => Promise>(), + messages: [] as string[], + destinations: [] as string[], + listeners: new Set<(state: string) => void>(), +})); +const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); +const roots: ReactTestRenderer[] = []; +vi.mock('react-native', () => ({ + Platform: { + get OS() { + return mocks.platform; + }, + }, + AppState: { + addEventListener: (_event: string, listener: (state: string) => void) => { + mocks.listeners.add(listener); + return { remove: () => mocks.listeners.delete(listener) }; + }, + }, +})); +vi.mock('sonner-native', () => ({ + toast: { error: (message: string) => mocks.messages.push(message) }, +})); +vi.mock(import('@tanstack/react-query'), async importOriginal => ({ + ...(await importOriginal()), + useQueryClient: () => client, +})); +vi.mock('@/lib/hooks/use-current-user-id', () => ({ + useCurrentUserId: () => ({ userId: mocks.userId }), +})); +vi.mock('@/lib/config', () => ({ WEB_BASE_URL: 'https://app.example.com' })); +vi.mock('@/lib/pr-review/connect-gate-platform', () => ({ + openAuthorizationAndWaitForReturn: async (os: string, url: string) => { + mocks.destinations.push(url); + const trigger = await mocks.browser(os, url); + return trigger ?? (mocks.platform === 'ios' ? 'sheet-close' : 'app-foreground'); + }, +})); +vi.mock('@/lib/trpc', () => { + const cloudAgentNext = { + listGitHubRepositories: { + queryOptions: (input: unknown) => ({ + queryKey: [['github'], { input, type: 'query' }], + queryFn: mocks.fetch, + }), + queryKey: (input: unknown) => [['github'], { input, type: 'query' }], + }, + }; + const trpc = { cloudAgentNext, organizations: { cloudAgentNext } }; + return { + useTRPC: () => trpc, + trpcClient: { githubApps: { mintInstallState: { mutate: mocks.mint } } }, + }; +}); +const available = { integrationInstalled: true, repositories: [] }; +function discoveryKey( + organizationId: string, + forceRefresh = false, + accountId = mocks.userId +): TRPCQueryKey { + return [['github'], { input: { organizationId, forceRefresh }, type: 'query', accountId }]; +} +beforeEach(() => { + vi.resetAllMocks(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + mocks.userId = 'user-1'; + mocks.platform = 'ios'; + mocks.messages.length = 0; + mocks.destinations.length = 0; + mocks.fetch.mockResolvedValue(available); + mocks.mint.mockResolvedValue({ token: 'install-state' }); + mocks.browser.mockResolvedValue(undefined); +}); +afterEach(() => { + act(() => { + for (const root of roots.splice(0)) { + root.unmount(); + } }); + client.clear(); + vi.unstubAllGlobals(); }); - -describe('shouldSetConnectCheckFailed', () => { - it('sets when return-triggered AND integration not installed', () => { - expect( - shouldSetConnectCheckFailed({ - isReturnTriggered: true, - integrationInstalled: false, - }) - ).toBe(true); +async function foreground() { + await act(() => { + for (const listener of mocks.listeners) { + listener('active'); + } }); - - it('does NOT set when return-triggered but integration IS installed', () => { - expect( - shouldSetConnectCheckFailed({ - isReturnTriggered: true, - integrationInstalled: true, - }) - ).toBe(false); +} +function mountRefresh(organizationId: string | undefined = 'org-1') { + let result: ReturnType | undefined = undefined; + function Harness({ org, installed }: { org: string | undefined; installed?: boolean }) { + result = useGitHubReposRefresh({ organizationId: org, integrationInstalled: installed }); + return null; + } + let root: ReactTestRenderer | undefined = undefined; + act(() => { + root = TestRenderer.create(React.createElement(Harness, { org: organizationId })); + roots.push(root); }); + return { + get current() { + if (!result) { + throw new Error('Hook did not render'); + } + return result; + }, + update: (org: string | undefined, installed?: boolean) => + act(() => { + root?.update(React.createElement(Harness, { org, installed })); + }), + unmount: () => + act(() => { + root?.unmount(); + }), + }; +} - it('does NOT set when NOT return-triggered (manual Refresh / Check again)', () => { - expect( - shouldSetConnectCheckFailed({ - isReturnTriggered: false, - integrationInstalled: false, - }) - ).toBe(false); +describe('legacy refresh decisions', () => { + it.each([ + ['ios', 'sheet-close'], + ['android', 'app-foreground'], + ['web', 'app-foreground'], + ['', 'app-foreground'], + ])('uses %s return behavior', (platform, trigger) => { + expect(resolveRefreshTrigger(platform)).toBe(trigger); }); - - it('does NOT set when integrationInstalled is undefined', () => { - expect( - shouldSetConnectCheckFailed({ - isReturnTriggered: true, - integrationInstalled: undefined, - }) - ).toBe(false); + it.each<[boolean, boolean | undefined, [boolean, boolean]]>([ + [true, false, [true, false]], + [true, true, [false, true]], + [true, undefined, [false, false]], + [false, false, [false, false]], + [false, true, [false, true]], + [false, undefined, [false, false]], + ])('resolves return=%s installed=%s', (isReturnTriggered, integrationInstalled, [set, clear]) => { + expect(shouldSetConnectCheckFailed({ isReturnTriggered, integrationInstalled })).toBe(set); + expect(shouldClearConnectCheckFailed({ integrationInstalled })).toBe(clear); }); }); -describe('shouldClearConnectCheckFailed', () => { - it('clears when integration is installed', () => { - expect( - shouldClearConnectCheckFailed({ - integrationInstalled: true, - }) - ).toBe(true); - }); +it.each( + ['owner', 'account', 'unmount'].flatMap(change => + ['manual', 'connect'].flatMap(mode => + ['success', 'failure'].map(outcome => ({ change, mode, outcome })) + ) + ) +)( + 'isolates late $mode refresh $outcome after $change replacement', + async ({ change, mode, outcome }) => { + const old = Promise.withResolvers(); + mocks.fetch.mockReturnValue(old.promise); + const hook = mountRefresh(); + const oldRefresh = hook.current.refreshReposForceFresh; + const requests: Partial>> = {}; + await act(() => { + if (mode === 'manual') { + requests.old = oldRefresh(); + } else { + hook.current.openGitHubIntegration(); + } + }); + expect(hook.current.isRefreshingRepos).toBe(true); + const org = change === 'owner' ? 'org-2' : 'org-1'; + if (change === 'account') { + mocks.userId = 'user-2'; + } + if (change === 'unmount') { + hook.unmount(); + } else { + hook.update(org); + } + const replacement = Promise.withResolvers(); + mocks.fetch.mockReturnValue(replacement.promise); + if (change !== 'unmount') { + expect(hook.current.isRefreshingRepos).toBe(false); + act(() => { + requests.next = hook.current.refreshReposForceFresh(); + }); + } + await act(async () => { + if (outcome === 'success') { + old.resolve({ integrationInstalled: false, repositories: ['old'] }); + } else { + old.reject(new Error('Retired request failed')); + } + await requests.old; + await oldRefresh(); + }); + expect(client.getQueryData(discoveryKey('org-1', false, 'user-1'))).toBeUndefined(); + expect(client.getQueryData(discoveryKey('org-1', true, 'user-1'))).toBeUndefined(); + expect(mocks.messages).toEqual([]); + if (change !== 'unmount') { + expect(hook.current.isRefreshingRepos).toBe(true); + expect(hook.current.connectCheckFailed).toBe(false); + await act(async () => { + replacement.resolve(available); + await requests.next; + }); + expect(client.getQueryData(discoveryKey(org))).toEqual(available); + expect(hook.current.isRefreshingRepos).toBe(false); + } + } +); - it('does NOT clear when integration is not installed', () => { - expect( - shouldClearConnectCheckFailed({ - integrationInstalled: false, - }) - ).toBe(false); - }); +it.each( + ['owner', 'account', 'unmount'].flatMap(change => + ['mint', 'browser'].flatMap(stage => + ['success', 'failure'].flatMap(outcome => + ['ios', 'android'].map(os => ({ change, stage, outcome, os })) + ) + ) + ) +)( + 'ignores late $stage $outcome after $change replacement on $os', + async ({ change, stage, outcome, os }) => { + mocks.platform = os; + const old = Promise.withResolvers(); + if (stage === 'mint') { + mocks.mint.mockReturnValue(old.promise); + } else { + mocks.browser.mockReturnValue(old.promise); + } + const hook = mountRefresh(); + await act(() => { + hook.current.openGitHubIntegration(); + }); + if (change === 'account') { + mocks.userId = 'user-2'; + } + if (change === 'unmount') { + hook.unmount(); + } else { + hook.update(change === 'owner' ? 'org-2' : 'org-1'); + } + await act(() => { + if (outcome === 'failure') { + old.reject(new Error('Retired browser failed')); + } else { + old.resolve(stage === 'mint' ? { token: 'old-state' } : undefined); + } + }); + await foreground(); + expect(mocks.destinations).toHaveLength(stage === 'mint' ? 0 : 1); + expect(client.getQueryCache().getAll()).toEqual([]); + expect(mocks.messages).toEqual([]); + } +); - it('does NOT clear when integration is undefined', () => { - expect( - shouldClearConnectCheckFailed({ - integrationInstalled: undefined, - }) - ).toBe(false); - }); -}); +it.each(['ios', 'android'])( + 'preserves the %s connection check and consumes each return once', + async platform => { + mocks.platform = platform; + mocks.fetch.mockResolvedValue({ integrationInstalled: false, repositories: [] }); + const hook = mountRefresh(); + await act(() => { + hook.current.openGitHubIntegration(); + }); + await foreground(); + expect(hook.current.connectCheckFailed).toBe(true); + const key = discoveryKey('org-1'); + client.setQueryData(key, { ...available, repositories: ['newer rows'] }); + await foreground(); + expect(client.getQueryData(key)).toMatchObject({ repositories: ['newer rows'] }); + hook.update('org-1', true); + expect(hook.current.connectCheckFailed).toBe(false); + mocks.fetch.mockRejectedValue(new Error('Offline')); + await act(async () => { + await hook.current.refreshReposForceFresh(); + }); + expect(mocks.messages).toEqual(['Could not refresh repositories. Please try again.']); + expect(client.getQueryData(key)).toMatchObject({ repositories: ['newer rows'] }); + expect(hook.current.isRefreshingRepos).toBe(false); + } +); diff --git a/apps/mobile/src/lib/use-github-repos-refresh.ts b/apps/mobile/src/lib/use-github-repos-refresh.ts index fdf89408ca..1a852c4427 100644 --- a/apps/mobile/src/lib/use-github-repos-refresh.ts +++ b/apps/mobile/src/lib/use-github-repos-refresh.ts @@ -1,19 +1,55 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppState, type AppStateStatus, Platform } from 'react-native'; -import { useQueryClient } from '@tanstack/react-query'; +import { type QueryClient, useQueryClient } from '@tanstack/react-query'; +import { type TRPCQueryKey } from '@trpc/tanstack-react-query'; import { toast } from 'sonner-native'; import { i18n } from '@/i18n'; +import { resolveProviderStatus } from '@/components/agents/new-session-repository-state'; import { getGitHubIntegrationUrl } from '@/lib/agent-github-integration'; import { WEB_BASE_URL } from '@/lib/config'; +import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; import { openAuthorizationAndWaitForReturn } from '@/lib/pr-review/connect-gate-platform'; import { trpcClient, useTRPC } from '@/lib/trpc'; +import { readTrpcErrorField } from '@/lib/trpc-error'; import { shouldClearConnectCheckFailed, shouldSetConnectCheckFailed, } from './use-github-repos-refresh-helpers'; -// ── Hook ──────────────────────────────────────────────────────────── +/** Add cache identity outside the tRPC path and input, preserving its public tuple. */ +export function withRepositoryAccount( + options: T, + accountId: string | undefined +): Omit & { queryKey: TRPCQueryKey } { + const details = { ...options.queryKey[1], accountId }; + return { ...options, queryKey: [options.queryKey[0], details] }; +} + +/** Keep the failure in the normal cache so remounting cannot revive revoked rows. */ +export function setRepositoryDiscoveryError( + queryClient: QueryClient, + queryKey: TRPCQueryKey, + error: unknown +) { + const status = resolveProviderStatus({ + isLoading: false, + isError: true, + integrationInstalled: undefined, + repositoryCount: 0, + errorCode: readTrpcErrorField(error, 'code'), + }); + // The permission handler can remove both cache variants before this catch runs. + const query = queryClient.getQueryCache().build(queryClient, { queryKey }); + query.setState({ + error, + errorUpdatedAt: Date.now(), + errorUpdateCount: query.state.errorUpdateCount + 1, + status: 'error', + ...(status === 'connect' || status === 'access-denied' ? { data: undefined } : {}), + }); + return status; +} type UseGitHubReposRefreshParams = { organizationId: string | undefined; @@ -33,49 +69,74 @@ export function useGitHubReposRefresh({ }: UseGitHubReposRefreshParams): UseGitHubReposRefreshResult { const trpc = useTRPC(); const queryClient = useQueryClient(); - const [isRefreshingRepos, setIsRefreshingRepos] = useState(false); + const { userId } = useCurrentUserId(); + const [refreshCount, setRefreshCount] = useState(0); const [connectCheckFailed, setConnectCheckFailed] = useState(false); + const scope = useMemo( + () => ({ + userId, + queryClient, + options: withRepositoryAccount( + organizationId + ? trpc.organizations.cloudAgentNext.listGitHubRepositories.queryOptions({ + organizationId, + forceRefresh: true, + }) + : trpc.cloudAgentNext.listGitHubRepositories.queryOptions({ forceRefresh: true }), + userId + ), + normal: withRepositoryAccount( + organizationId + ? trpc.organizations.cloudAgentNext.listGitHubRepositories.queryOptions({ + organizationId, + forceRefresh: false, + }) + : trpc.cloudAgentNext.listGitHubRepositories.queryOptions({ forceRefresh: false }), + userId + ), + }), + [userId, organizationId, queryClient, trpc] + ); + const currentScope = useRef(scope); + currentScope.current = scope; - // Sentinel: set before browser launch on Android, cleared on - // consume or error. Prevents stale AppState from triggering refetch. + // Android consumes this sentinel only after a browser launch in the current scope. const launchedAt = useRef(null); + useEffect(() => { + currentScope.current = scope; + setRefreshCount(0); + setConnectCheckFailed(false); + return () => { + currentScope.current = null; + launchedAt.current = null; + void scope.queryClient.cancelQueries({ queryKey: scope.options.queryKey, exact: true }); + void scope.queryClient.cancelQueries({ queryKey: scope.normal.queryKey, exact: true }); + }; + }, [scope]); - // ── connectCheckFailed clear-on-input effect ────────────────────── useEffect(() => { if (integrationInstalled === true && connectCheckFailed) { setConnectCheckFailed(false); } }, [integrationInstalled, connectCheckFailed]); - // ── Force-fresh refetch ────────────────────────────────────────── const performForceFresh = useCallback( async (isReturnTriggered: boolean) => { - setIsRefreshingRepos(true); + if (!scope.userId || currentScope.current !== scope) { + return; + } + setRefreshCount(count => count + 1); try { - const fresh = await queryClient.fetchQuery({ - ...(organizationId - ? trpc.organizations.cloudAgentNext.listGitHubRepositories.queryOptions({ - organizationId, - forceRefresh: true, - }) - : trpc.cloudAgentNext.listGitHubRepositories.queryOptions({ - forceRefresh: true, - })), - staleTime: 0, - }); - queryClient.setQueryData( - organizationId - ? trpc.organizations.cloudAgentNext.listGitHubRepositories.queryKey({ - organizationId, - forceRefresh: false, - }) - : trpc.cloudAgentNext.listGitHubRepositories.queryKey({ - forceRefresh: false, - }), - fresh - ); - - // Connect check failed flag management + const fresh = await queryClient.fetchQuery({ ...scope.options, staleTime: 0 }); + if (currentScope.current !== scope) { + return; + } + // A pending normal response cannot overwrite the confirmed refresh result. + await queryClient.cancelQueries({ queryKey: scope.normal.queryKey, exact: true }); + if (currentScope.current !== scope) { + return; + } + queryClient.setQueryData(scope.normal.queryKey, fresh); const installed = fresh.integrationInstalled; if ( shouldSetConnectCheckFailed({ @@ -87,30 +148,34 @@ export function useGitHubReposRefresh({ } else if (shouldClearConnectCheckFailed({ integrationInstalled: installed })) { setConnectCheckFailed(false); } - } catch { - toast.error(i18n.t('agentChat.newSession.couldNotRefreshRepositories')); + } catch (error) { + if (currentScope.current !== scope) { + return; + } + await queryClient.cancelQueries({ queryKey: scope.normal.queryKey, exact: true }); + if ( + currentScope.current === scope && + setRepositoryDiscoveryError(queryClient, scope.normal.queryKey, error) === 'error' + ) { + toast.error(i18n.t('agentChat.newSession.couldNotRefreshRepositories')); + } } finally { - setIsRefreshingRepos(false); + if (currentScope.current === scope) { + setRefreshCount(count => count - 1); + } } }, - [organizationId, trpc, queryClient] + [scope, queryClient] ); - // Always-correct ref so Android AppState listener calls the latest - // performForceFresh after organization/context changes. const performForceFreshRef = useRef(performForceFresh); performForceFreshRef.current = performForceFresh; - - // ── Android foreground listener ─────────────────────────────────── useEffect(() => { if (Platform.OS !== 'android') { return undefined; } const handleChange = (nextState: AppStateStatus) => { - if (nextState !== 'active') { - return; - } - if (launchedAt.current === null) { + if (nextState !== 'active' || launchedAt.current === null) { return; } launchedAt.current = null; @@ -122,8 +187,10 @@ export function useGitHubReposRefresh({ }; }, []); - // ── Open GitHub integration ────────────────────────────────────── const openGitHubIntegration = useCallback(() => { + if (!scope.userId || currentScope.current !== scope) { + return; + } void (async () => { try { launchedAt.current = Date.now(); @@ -131,27 +198,29 @@ export function useGitHubReposRefresh({ organizationId: organizationId ?? undefined, returnTo: '/cloud/sessions', }); + if (currentScope.current !== scope) { + return; + } const trigger = await openAuthorizationAndWaitForReturn( Platform.OS, getGitHubIntegrationUrl(WEB_BASE_URL, organizationId, token) ); + if (currentScope.current !== scope) { + return; + } if (trigger === 'sheet-close') { - // iOS: refetch immediately. Clear the sentinel so the AppState - // handler (if it ever fires on iOS) doesn't double-refetch. + // iOS refreshes on sheet close; Android consumes the foreground sentinel. launchedAt.current = null; await performForceFresh(true); } - // Android: refetch is handled by the AppState listener when the - // app returns to foreground. Do NOT clear the sentinel here — the - // foreground handler clears it when consumed. } catch { - // Browser failed to open — clear the sentinel so a later - // unrelated foreground doesn't trigger a stray refetch. - launchedAt.current = null; - toast.error(i18n.t('codeReviewer.providerConnect.githubError')); + if (currentScope.current === scope) { + launchedAt.current = null; + toast.error(i18n.t('codeReviewer.providerConnect.githubError')); + } } })(); - }, [organizationId, performForceFresh]); + }, [scope, organizationId, performForceFresh]); const refreshReposForceFresh = useCallback(async () => { await performForceFresh(false); @@ -160,7 +229,7 @@ export function useGitHubReposRefresh({ return { openGitHubIntegration, refreshReposForceFresh, - isRefreshingRepos, + isRefreshingRepos: refreshCount > 0, connectCheckFailed, }; } diff --git a/apps/mobile/src/lib/use-new-session-repos.test.ts b/apps/mobile/src/lib/use-new-session-repos.test.ts index 91174ec326..ea647f8571 100644 --- a/apps/mobile/src/lib/use-new-session-repos.test.ts +++ b/apps/mobile/src/lib/use-new-session-repos.test.ts @@ -1,174 +1,883 @@ -/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom); see src/components/agents/use-new-session-creator.test.ts */ -/* eslint-disable require-await, @typescript-eslint/require-await -- the fake query factories settle without await because they resolve immediately */ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the existing DOM-free hook harness. */ +/* eslint-disable max-lines -- Provider/account/cache and native return matrices share one discovery hook harness. */ import * as React from 'react'; -import TestRenderer, { act } from 'react-test-renderer'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - +import TestRenderer, { act, type ReactTestRenderer } from 'react-test-renderer'; +import * as ReactQuery from '@tanstack/react-query'; +import { type MobileRouter } from '@kilocode/trpc/mobile'; +import { + type LaunchRepositoryReference, + repositoryResourceKey, +} from '@kilocode/app-shared/code-review/repository-identity'; +import { afterEach, assert, beforeEach, expect, it, vi } from 'vitest'; +import { resolvePrefillRepoSelection } from '@/components/agents/new-session-prefill'; +import { useNewSessionPrefillTargets } from '@/components/agents/use-new-session-prefill'; +import { type RepositoryPlatform } from '@/components/agents/new-session-repository-state'; import { useNewSessionRepos } from './use-new-session-repos'; -const mocks = vi.hoisted(() => ({ - fetchQuery: vi.fn(async (_opts: unknown): Promise => ({})), - setQueryData: vi.fn(() => undefined), - toastError: vi.fn(), - refreshGitHubForceFresh: vi.fn(async () => undefined), +type DiscoveryRequest = { + path: string; + input: { organizationId?: string; forceRefresh: boolean }; + accountId: string; +}; +const mocks = vi.hoisted(() => { + const prefill: { prefillRepo?: string } = {}; + return { + fetch: vi.fn<(request: DiscoveryRequest) => unknown>(), + normalFetch: vi.fn<(request: DiscoveryRequest) => unknown>(), + browser: vi.fn<(os: string, url: string) => Promise>(), + queries: new Map(), + recents: undefined as unknown, + prefill, + userId: 'user-1', + platform: { OS: 'ios' }, + messages: [] as string[], + notes: [] as string[], + destinations: [] as string[], + requests: [] as DiscoveryRequest[], + listeners: new Set<(state: string) => void>(), + }; +}); +const client = new ReactQuery.QueryClient({ defaultOptions: { queries: { retry: false } } }); +let renderer: ReactTestRenderer | undefined = undefined; +let latest: ReturnType | undefined = undefined; +let prefillTargets: ReturnType | undefined = undefined; +function result() { + assert(latest, 'Hook did not render'); + return latest; +} +vi.mock('react-native', () => ({ + Platform: mocks.platform, + AppState: { + addEventListener: (_event: string, listener: (state: string) => void) => { + mocks.listeners.add(listener); + return { remove: () => mocks.listeners.delete(listener) }; + }, + }, })); - -vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })); - -vi.mock('sonner-native', () => ({ toast: { error: mocks.toastError } })); - -vi.mock('@tanstack/react-query', () => ({ - useQuery: () => ({ data: undefined, isLoading: false, isError: false, isRefetching: false }), - useQueryClient: () => ({ fetchQuery: mocks.fetchQuery, setQueryData: mocks.setQueryData }), +vi.mock('expo-router', () => ({ useLocalSearchParams: () => mocks.prefill })); +vi.mock('@/components/ui/icons', () => ({ + Bug: 'Bug', + Code: 'Code', + HelpCircle: 'HelpCircle', + NotebookPen: 'NotebookPen', + Workflow: 'Workflow', })); - -vi.mock('@/lib/config', () => ({ WEB_BASE_URL: 'https://app.example.com' })); - -vi.mock('@/lib/hooks/use-agent-sessions', () => ({ - useRecentAgentRepositories: () => ({ data: undefined }), +vi.mock('sonner-native', () => ({ + toast: { + error: (message: string) => mocks.messages.push(message), + info: (message: string) => mocks.notes.push(message), + }, })); - -vi.mock('@/lib/integration-urls', () => ({ - getGitLabIntegrationUrl: vi.fn(() => ''), - getBitbucketIntegrationUrl: vi.fn(() => ''), +vi.mock('@tanstack/react-query', async importOriginal => ({ + ...(await importOriginal()), + // Keep the earlier lifecycle fixtures; cache regressions replace this with the real useQuery. + useQuery: vi.fn(({ queryKey }: { queryKey: [string[], unknown] }) => ({ + isLoading: false, + isError: false, + isRefetching: false, + ...mocks.queries.get( + (queryKey[0].at(-1) ?? '').replace('list', '').replace('Repositories', '').toLowerCase() + ), + })), + useQueryClient: () => client, })); - -vi.mock('@/lib/pr-review/connect-gate-platform', () => ({ - openAuthorizationAndWaitForReturn: vi.fn(async () => 'sheet-close'), +vi.mock('@/lib/hooks/use-current-user-id', () => ({ + useCurrentUserId: () => ({ userId: mocks.userId }), })); - -vi.mock('@/lib/external-auth/use-external-auth-return', () => ({ - useExternalAuthReturn: () => ({ markLaunched: vi.fn(), clearLaunch: vi.fn() }), +vi.mock('@/lib/config', () => ({ WEB_BASE_URL: 'https://app.example.com' })); +vi.mock('@/lib/hooks/use-agent-sessions', () => ({ + useRecentAgentRepositories: () => ({ data: mocks.recents }), })); - -vi.mock('@/lib/use-github-repos-refresh', () => ({ - useGitHubReposRefresh: () => ({ - openGitHubIntegration: vi.fn(), - refreshReposForceFresh: mocks.refreshGitHubForceFresh, - isRefreshingRepos: false, - connectCheckFailed: false, - }), +vi.mock('@/lib/pr-review/connect-gate-platform', () => ({ + openAuthorizationAndWaitForReturn: async (os: string, url: string) => { + mocks.destinations.push(url); + const trigger = await mocks.browser(os, url); + return trigger ?? (mocks.platform.OS === 'ios' ? 'sheet-close' : 'app-foreground'); + }, })); - -vi.mock('@/lib/trpc', () => ({ - useTRPC: () => ({ - cloudAgentNext: { - listGitHubRepositories: { - queryOptions: () => ({ queryKey: ['github'] }), - queryKey: () => ['github'], - }, - listGitLabRepositories: { - queryOptions: ({ forceRefresh }: { forceRefresh: boolean }) => ({ - queryKey: ['gitlab', forceRefresh], - }), - queryKey: ({ forceRefresh }: { forceRefresh: boolean }) => ['gitlab', forceRefresh], - }, - }, - organizations: { - cloudAgentNext: { - listGitHubRepositories: { - queryOptions: () => ({ queryKey: ['github'] }), - queryKey: () => ['github'], - }, - listGitLabRepositories: { - queryOptions: ({ forceRefresh }: { forceRefresh: boolean }) => ({ - queryKey: ['gitlab', forceRefresh], - }), - queryKey: ({ forceRefresh }: { forceRefresh: boolean }) => ['gitlab', forceRefresh], - }, - listBitbucketRepositories: { - queryOptions: ({ forceRefresh }: { forceRefresh: boolean }) => ({ - queryKey: ['bitbucket', forceRefresh], - }), - queryKey: ({ forceRefresh }: { forceRefresh: boolean }) => ['bitbucket', forceRefresh], +vi.mock('@/lib/trpc', async () => { + const { createTRPCClient, httpLink } = await import('@trpc/client'); + const { createTRPCOptionsProxy } = await import('@trpc/tanstack-react-query'); + const trpcClient = createTRPCClient({ + links: [ + httpLink({ + url: 'https://app.example.com/trpc', + fetch: async url => { + const address = new URL(url instanceof Request ? url.url : url); + const request: DiscoveryRequest = { + path: address.pathname.slice('/trpc/'.length), + input: JSON.parse(address.searchParams.get('input') ?? '{}'), + accountId: mocks.userId, + }; + mocks.requests.push(request); + const data = await (request.input.forceRefresh + ? mocks.fetch(request) + : mocks.normalFetch(request)); + return Response.json({ result: { data } }); }, - }, + }), + ], + }); + const trpc = createTRPCOptionsProxy({ + queryClient: () => client, + client: trpcClient, + }); + return { useTRPC: () => trpc, trpcClient }; +}); +const available = { status: 'available', integrationInstalled: true, repositories: [] }; +const procedures = { + github: 'listGitHubRepositories', + gitlab: 'listGitLabRepositories', + bitbucket: 'listBitbucketRepositories', +}; +function discoveryKey( + platform: RepositoryPlatform, + organizationId: string | undefined, + { + forceRefresh = false, + accountId = 'user-1', + }: { forceRefresh?: boolean; accountId?: string } = {} +) { + return [ + [...(organizationId ? ['organizations'] : []), 'cloudAgentNext', procedures[platform]], + { + input: { ...(organizationId ? { organizationId } : {}), forceRefresh }, + type: 'query', + accountId, }, - }), -})); - -type ReposResult = ReturnType; - -function Harness({ - organizationId, - resultRef, -}: { - organizationId: string | undefined; - resultRef: { current: ReposResult | null }; -}) { - const result = useNewSessionRepos({ organizationId }); - resultRef.current = result; + ]; +} +beforeEach(() => { + vi.resetAllMocks(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + mocks.queries.clear(); + mocks.recents = undefined; + mocks.prefill = {}; + mocks.userId = 'user-1'; + mocks.platform.OS = 'ios'; + mocks.messages.length = 0; + mocks.notes.length = 0; + mocks.destinations.length = 0; + mocks.requests.length = 0; + mocks.fetch.mockResolvedValue(available); + mocks.normalFetch.mockResolvedValue(available); + mocks.browser.mockResolvedValue(undefined); +}); +afterEach(() => { + act(() => { + renderer?.unmount(); + }); + client.clear(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); +async function foreground() { + await act(() => { + for (const listener of mocks.listeners) { + listener('active'); + } + }); +} +function Harness({ org }: { org: string | undefined }) { + latest = useNewSessionRepos({ organizationId: org }); + prefillTargets = useNewSessionPrefillTargets({ ...latest, models: [], modelsSettled: true }); return null; } - +function tree(org: string | undefined) { + return React.createElement( + ReactQuery.QueryClientProvider, + { client }, + React.createElement(Harness, { org }) + ); +} function mountRepos(organizationId: string | undefined) { - const resultRef: { current: ReposResult | null } = { current: null }; act(() => { - TestRenderer.create(React.createElement(Harness, { organizationId, resultRef })); + renderer = TestRenderer.create(tree(organizationId)); }); - return resultRef; + return { + update: (org: string | undefined) => + act(() => { + renderer?.update(tree(org)); + }), + unmount: () => + act(() => { + renderer?.unmount(); + }), + }; } - -function requireResult(resultRef: { current: ReposResult | null }): ReposResult { - const result = resultRef.current; - if (result === null) { - throw new Error('useNewSessionRepos did not run'); +async function useRealQueries() { + const actual = await vi.importActual('@tanstack/react-query'); + vi.mocked(ReactQuery.useQuery).mockImplementation(actual.useQuery); + vi.useFakeTimers(); +} +async function flushQueries() { + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); +} +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', + }, +}; +function providerReference( + platform: RepositoryPlatform, + organizationId: string | undefined, + integrationId = 'integration-1' +): LaunchRepositoryReference { + return { + repository: + platform === 'bitbucket' + ? { + provider: platform, + instanceUrl: 'https://bitbucket.org', + repositoryId: '{repository-uuid}', + workspaceUuid: '{workspace-uuid}', + fullName: 'owner/repo', + defaultBranch: 'release', + } + : { + provider: platform, + instanceUrl: `https://${platform}.com`, + repositoryId: '7', + fullName: 'owner/repo', + defaultBranch: 'develop', + }, + authorization: { + kind: 'ownerIntegration', + owner: organizationId + ? { type: 'org', id: organizationId } + : { type: 'user', id: mocks.userId }, + integrationId, + }, + }; +} +function providerData(platform: RepositoryPlatform, refs: LaunchRepositoryReference[] = []) { + const repositories = refs.map(repositoryReference => { + const { repository, authorization } = repositoryReference; + return { + id: + repository.provider === 'bitbucket' + ? repository.repositoryId + : Number(repository.repositoryId), + ...(repository.provider === 'bitbucket' ? { workspaceUuid: repository.workspaceUuid } : {}), + name: 'repo', + fullName: repository.fullName, + private: true, + defaultBranch: repository.defaultBranch ?? undefined, + platformIntegrationId: authorization.integrationId, + instanceUrl: repository.instanceUrl, + repositoryReference, + }; + }); + return platform === 'bitbucket' + ? { status: 'available', repositories, syncedAt: '2026-08-29T16:00:00Z' } + : { integrationInstalled: true, repositories, syncedAt: '2026-08-29T16:00:00Z' }; +} +function requestProvider(request: DiscoveryRequest): RepositoryPlatform { + if (request.path.endsWith(procedures.github)) { + return 'github'; } - return result; + if (request.path.endsWith(procedures.gitlab)) { + return 'gitlab'; + } + return 'bitbucket'; } -// Every provider's force-fresh reads `fetchQuery` with a `queryKey` whose first -// element names the provider, so the fake can answer Bitbucket and GitLab -// differently from one call site. -function mockFetchQuery(resultForBitbucket: unknown, gitlabAndGithub: unknown) { - mocks.fetchQuery.mockImplementation(async (opts: unknown) => { - const queryKey = (opts as { queryKey?: unknown[] }).queryKey; - return Array.isArray(queryKey) && queryKey[0] === 'bitbucket' - ? resultForBitbucket - : gitlabAndGithub; +it.each(['available', 'temporarily_unavailable'])( + 'handles a %s Bitbucket refresh without losing cached rows', + async status => { + const key = discoveryKey('bitbucket', 'org-1'); + const cached = { ...available, repositories: [{ fullName: 'workspace/repo' }] }; + client.setQueryData(key, cached); + mocks.fetch.mockResolvedValue({ ...available, status }); + mountRepos('org-1'); + await act(async () => { + await result().refreshReposForceFresh(); + }); + expect(client.getQueryData(key)).toEqual(status === 'available' ? available : cached); + expect(mocks.messages).toEqual( + status === 'available' ? [] : ['Could not refresh repositories. Please try again.'] + ); + } +); + +it('keeps exact discovery and recents selectable while another provider loads', () => { + const other = { + ...reference, + authorization: { ...reference.authorization, integrationId: 'integration-2' }, + }; + mocks.queries.set('gitlab', { + data: { + ...available, + repositories: [reference, other].map(repositoryReference => ({ + private: true, + repositoryReference, + })), + }, }); -} + mocks.queries.set('github', { isLoading: true }); + mocks.queries.set('bitbucket', { data: available }); + mocks.recents = { + repositories: [ + { identity: { kind: 'resolved', accountId: 'user-1', reference: other } }, + { identity: { kind: 'legacy-unresolved', accountId: 'user-1', reason: 'ambiguous' } }, + { identity: { kind: 'resolved', accountId: 'other-user', reference } }, + ], + }; + const hook = mountRepos('org-1'); + expect(result().repositories.map(repo => repo.reference)).toEqual([other, reference]); + expect(result().recents.map(repo => repo.reference)).toEqual([other]); + expect(result().groups.map(group => [group.key, group.status])).toEqual([ + ['github', 'loading'], + ['gitlab', 'repos'], + ['bitbucket', 'connected-empty'], + ]); + expect(result().reposSettled).toBe(false); + hook.update('org-2'); + expect(result().repositories).toEqual([]); + expect(result().recents).toEqual([]); +}); -beforeEach(() => { - vi.clearAllMocks(); - mocks.fetchQuery.mockImplementation(async (_opts: unknown) => ({ repositories: [] })); +it('quarantines incomplete discovery and settles an empty Personal browsing response', () => { + mocks.queries.set('github', { + data: { ...available, repositories: [{ fullName: 'owner/repo', private: true }] }, + }); + mocks.queries.set('gitlab', { data: { integrationInstalled: false, repositories: [] } }); + const hook = mountRepos(undefined); + expect(result().repositories).toEqual([]); + expect(result().groups[0]?.status).toBe('identity-unavailable'); + expect(result().reposSettled).toBe(false); + mocks.queries.set('github', { data: available }); + hook.update(undefined); + expect(result().groups.map(group => group.status)).toEqual(['connected-empty', 'connect']); + expect(result().recents).toEqual([]); + expect(result().reposSettled).toBe(true); }); -describe('useNewSessionRepos force-fresh Bitbucket cache write', () => { - it('does not overwrite the normal cache and toasts when a force-fresh is temporarily unavailable', async () => { - mockFetchQuery( - { status: 'temporarily_unavailable', repositories: [] }, - { repositories: [], integrationInstalled: true } - ); - const resultRef = mountRepos('org-1'); +it.each( + ['owner', 'account', 'unmount'].flatMap(change => + (['refresh', 'gitlab', 'bitbucket'] as const).flatMap(action => + ['success', 'failure'].flatMap(outcome => + ['ios', 'android'].map(os => ({ change, action, outcome, os })) + ) + ) + ) +)( + 'isolates late $action $outcome after $change replacement on $os', + async ({ change, action, outcome, os }) => { + mocks.platform.OS = os; + const old = Promise.withResolvers(); + mocks.fetch.mockReturnValue(old.promise); + mocks.browser.mockReturnValue(old.promise); + const hook = mountRepos('org-1'); + await act(() => { + if (action === 'refresh') { + void result().refreshReposForceFresh(); + } else { + result().openIntegration(action); + } + }); + const organizationId = change === 'owner' ? 'org-2' : 'org-1'; + if (change === 'account') { + mocks.userId = 'user-2'; + } + if (change === 'unmount') { + hook.unmount(); + } else { + hook.update(organizationId); + } + const replacement = Promise.withResolvers(); + mocks.fetch.mockResolvedValue(available); + if (action === 'refresh') { + mocks.fetch.mockReturnValue(replacement.promise); + } + if (change !== 'unmount' && action === 'refresh') { + expect(result().isRetrying).toBe(false); + act(() => { + void result().refreshReposForceFresh(); + }); + expect(result().groups.map(group => group.status)).toEqual(['loading', 'loading', 'loading']); + } + await act(() => { + if (outcome === 'failure') { + old.reject(new Error('Retired request failed')); + } else { + old.resolve(action === 'refresh' ? available : undefined); + } + }); + await foreground(); + for (const name of ['github', 'gitlab', 'bitbucket'] as const) { + for (const forceRefresh of [false, true]) { + const key = discoveryKey(name, 'org-1', { forceRefresh }); + expect(client.getQueryData(key)).toBeUndefined(); + expect(client.getQueryState(key)?.error ?? null).toBeNull(); + } + } + expect(mocks.messages).toEqual([]); + if (change !== 'unmount') { + expect(result().isRetrying).toBe(action === 'refresh'); + await act(() => { + replacement.resolve(available); + }); + expect(result().isRetrying).toBe(false); + } + } +); - await act(async () => { - await requireResult(resultRef).refreshReposForceFresh(); +it.each(['gitlab', 'bitbucket'] as const)( + 'refreshes %s once on Android return and handles a failed return', + async platform => { + mocks.platform.OS = 'android'; + mountRepos('org-1'); + await act(() => { + result().openIntegration(platform); }); + expect(mocks.destinations).toEqual([ + `https://app.example.com/organizations/org-1/integrations/${platform}`, + ]); + await foreground(); + const key = discoveryKey(platform, 'org-1'); + expect(client.getQueryData(key)).toEqual(available); + client.setQueryData(key, { ...available, repositories: ['newer rows'] }); + await foreground(); + expect(client.getQueryData(key)).toMatchObject({ repositories: ['newer rows'] }); + mocks.fetch.mockRejectedValue(new Error('Offline')); + await act(() => { + result().openIntegration(platform); + }); + await foreground(); + expect(mocks.messages).toHaveLength(1); + expect(client.getQueryData(key)).toMatchObject({ repositories: ['newer rows'] }); + expect(result().isRetrying).toBe(false); + } +); - // The Bitbucket forceRefresh:false key must stay untouched so an existing - // `available` cache survives a transient outage. - expect(mocks.setQueryData).not.toHaveBeenCalledWith(['bitbucket', false], expect.anything()); - expect(mocks.toastError).toHaveBeenCalledWith( - 'Could not refresh repositories. Please try again.' +it.each( + (['github', 'gitlab', 'bitbucket'] as const).flatMap(platform => + ['owner', 'account', 'unmount'].flatMap(change => + ['success', 'failure'].map(outcome => ({ platform, change, outcome })) + ) + ) +)( + 'isolates cached $platform rows and late normal $outcome after $change replacement', + async ({ platform, change, outcome }) => { + await useRealQueries(); + const old = Promise.withResolvers(); + const next = Promise.withResolvers(); + const cachedReference = providerReference(platform, 'org-1'); + const cached = providerData(platform, [cachedReference]); + const key = discoveryKey(platform, 'org-1'); + client.setQueryData(key, cached); + mocks.normalFetch.mockImplementation(async request => { + const response = + requestProvider(request) === platform + ? await old.promise + : providerData(requestProvider(request)); + return response; + }); + const hook = mountRepos('org-1'); + await flushQueries(); + expect(result().repositories.map(repo => repo.reference)).toEqual([cachedReference]); + const organizationId = change === 'owner' ? 'org-2' : 'org-1'; + if (change === 'account') { + mocks.userId = 'user-2'; + } + mocks.recents = { + repositories: [ + { identity: { kind: 'resolved', accountId: mocks.userId, reference: cachedReference } }, + ], + }; + mocks.normalFetch.mockImplementation(async request => { + const response = + requestProvider(request) === platform + ? await next.promise + : providerData(requestProvider(request)); + return response; + }); + if (change === 'unmount') { + hook.unmount(); + } else { + hook.update(organizationId); + await flushQueries(); + expect(result().repositories).toEqual([]); + expect(result().recents).toEqual([]); + expect(result().groups.find(group => group.key === platform)?.status).toBe('loading'); + } + await act(() => { + if (outcome === 'success') { + old.resolve(providerData(platform, [providerReference(platform, 'org-1', 'late-old')])); + } else { + old.reject(new Error('Retired normal discovery failed')); + } + }); + await flushQueries(); + expect(client.getQueryData(key)).toEqual(cached); + expect(client.getQueryState(key)?.error).toBeNull(); + expect(mocks.messages).toEqual([]); + if (change !== 'unmount') { + const nextKey = discoveryKey(platform, organizationId, { accountId: mocks.userId }); + expect(client.getQueryData(nextKey)).toBeUndefined(); + expect(client.getQueryState(nextKey)?.error).toBeNull(); + expect(result().repositories).toEqual([]); + const authorized = providerReference(platform, organizationId, 'replacement-integration'); + next.resolve(providerData(platform, [authorized])); + await flushQueries(); + expect(result().repositories.map(repo => [repo.accountId, repo.reference])).toEqual([ + [mocks.userId, authorized], + ]); + expect(result().groups.find(group => group.key === platform)?.status).toBe('repos'); + } + } +); + +it.each(['org-1', undefined])( + 'keeps original tRPC request paths and inputs for owner %s in both discovery caches', + async organizationId => { + await useRealQueries(); + const response = (request: DiscoveryRequest) => { + const platform = requestProvider(request); + return providerData(platform, [providerReference(platform, organizationId)]); + }; + mocks.normalFetch.mockImplementation(response); + mocks.fetch.mockImplementation(response); + mountRepos(organizationId); + await flushQueries(); + await act(async () => { + await result().refreshReposForceFresh(); + }); + await flushQueries(); + const platforms: RepositoryPlatform[] = organizationId + ? ['github', 'gitlab', 'bitbucket'] + : ['github', 'gitlab']; + expect(result().repositories.map(repo => repo.platform)).toEqual(platforms); + expect(mocks.requests).toEqual( + [false, true].flatMap(forceRefresh => + platforms.map(platform => ({ + path: `${organizationId ? 'organizations.' : ''}cloudAgentNext.${procedures[platform]}`, + input: { ...(organizationId ? { organizationId } : {}), forceRefresh }, + accountId: 'user-1', + })) + ) ); - }); + } +); + +it.each( + (['github', 'gitlab'] as const).flatMap(platform => + ['org-1', undefined].flatMap(organizationId => + ['missing', 'suspended'].map(connection => ({ platform, organizationId, connection })) + ) + ) +)( + 'exposes connection recovery for $platform $connection with owner $organizationId', + async ({ platform, organizationId, connection }) => { + await useRealQueries(); + const name = platform === 'github' ? 'GitHub' : 'GitLab'; + const missing = { + integrationInstalled: false, + repositories: [], + syncedAt: null, + errorMessage: + connection === 'suspended' + ? `${name} integration is suspended` + : `No ${name} integration found for this ${organizationId ? 'organization' : 'user'}`, + }; + const response = (request: DiscoveryRequest) => + requestProvider(request) === platform ? missing : providerData(requestProvider(request)); + mocks.normalFetch.mockImplementation(response); + mocks.fetch.mockImplementation(response); + mountRepos(organizationId); + await flushQueries(); + expect(result().groups.find(group => group.key === platform)?.status).toBe('connect'); + await act(async () => { + await result().refreshReposForceFresh(); + }); + await flushQueries(); + expect(result().groups.find(group => group.key === platform)?.status).toBe('connect'); + expect(result().repositories).toEqual([]); + expect(client.getQueryData(discoveryKey(platform, organizationId))).toEqual(missing); + expect(mocks.messages).toEqual([]); + } +); - it('writes the normal cache and stays silent when a force-fresh is available', async () => { - const available = { - status: 'available', +it.each( + [ + ['not_connected', 'connect'], + ['reconnect_required', 'connect'], + ['workspace_selection_required', 'connect'], + ['insufficient_permissions', 'access-denied'], + ['invalid_request', 'access-denied'], + ].flatMap(([status, expected]) => + ['manual', 'ios', 'android'].map(mode => ({ status, expected, mode })) + ) +)( + 'publishes Bitbucket $status after $mode refresh and removes revoked rows and recents', + async ({ status, expected, mode }) => { + await useRealQueries(); + mocks.platform.OS = mode === 'android' ? 'android' : 'ios'; + const authorized = providerReference('bitbucket', 'org-1'); + mocks.recents = { repositories: [ - { fullName: 'workspace/repo', private: false, workspaceUuid: 'ws-1', id: 'id-1' }, + { identity: { kind: 'resolved', accountId: 'user-1', reference: authorized } }, ], }; - mockFetchQuery(available, { repositories: [], integrationInstalled: true }); - const resultRef = mountRepos('org-1'); + mocks.normalFetch.mockImplementation(request => + providerData( + requestProvider(request), + requestProvider(request) === 'bitbucket' ? [authorized] : [] + ) + ); + mocks.fetch.mockImplementation(request => + requestProvider(request) === 'bitbucket' ? { status } : providerData(requestProvider(request)) + ); + mountRepos('org-1'); + await flushQueries(); + expect(result().recents.map(repo => repo.reference)).toEqual([authorized]); + await act(async () => { + if (mode === 'manual') { + await result().refreshReposForceFresh(); + } else { + result().openIntegration('bitbucket'); + } + }); + if (mode === 'android') { + await foreground(); + } + await flushQueries(); + expect(client.getQueryData(discoveryKey('bitbucket', 'org-1'))).toEqual({ status }); + expect(result().groups.find(group => group.key === 'bitbucket')).toEqual({ + key: 'bitbucket', + status: expected, + repositories: [], + }); + expect(result().repositories).toEqual([]); + expect(result().recents).toEqual([]); + expect(mocks.messages).toEqual([]); + } +); +it.each(['github', 'gitlab', 'bitbucket'] as const)( + 'retains authorized %s rows after transient refresh failure and recovers on retry', + async platform => { + await useRealQueries(); + const authorized = providerReference(platform, 'org-1'); + const cached = providerData(platform, [authorized]); + mocks.normalFetch.mockImplementation(request => + requestProvider(request) === platform ? cached : providerData(requestProvider(request)) + ); + mocks.fetch.mockImplementation(request => { + if (requestProvider(request) !== platform) { + return providerData(requestProvider(request)); + } + if (platform === 'bitbucket') { + return { status: 'temporarily_unavailable' }; + } + throw new Error('Provider offline'); + }); + mountRepos('org-1'); + await flushQueries(); + await act(async () => { + await result().refreshReposForceFresh(); + }); + await flushQueries(); + expect(result().groups.find(group => group.key === platform)?.status).toBe('error'); + expect(result().repositories.map(repo => repo.reference)).toEqual([authorized]); + expect(client.getQueryData(discoveryKey(platform, 'org-1'))).toEqual(cached); + expect(result().reposSettled).toBe(false); + expect(mocks.messages).toEqual(['Could not refresh repositories. Please try again.']); + mocks.fetch.mockImplementation(request => + requestProvider(request) === platform ? cached : providerData(requestProvider(request)) + ); await act(async () => { - await requireResult(resultRef).refreshReposForceFresh(); + await result().refreshReposForceFresh(); }); + await flushQueries(); + expect(result().groups.find(group => group.key === platform)?.status).toBe('repos'); + } +); - expect(mocks.setQueryData).toHaveBeenCalledWith(['bitbucket', false], available); - expect(mocks.toastError).not.toHaveBeenCalled(); +it.each(['hidden Personal integration', 'failed organization integration'])( + 'keeps legacy prefill unresolved beside a %s while permitting exact selection', + async scenario => { + await useRealQueries(); + const organizationId = scenario === 'hidden Personal integration' ? undefined : 'org-1'; + const visible = providerReference('github', organizationId); + mocks.prefill = { prefillRepo: 'owner/repo' }; + // Both producers return only the visible integration, with no completeness field or error. + mocks.normalFetch.mockImplementation(request => + providerData(requestProvider(request), requestProvider(request) === 'github' ? [visible] : []) + ); + mountRepos(organizationId); + await flushQueries(); + const row = result().repositories[0]; + assert(row, 'Visible authorized repository is missing'); + expect(row.reference).toEqual(visible); + expect(prefillTargets?.selectedRepo).toBe(''); + expect(mocks.notes).toEqual([]); + expect( + resolvePrefillRepoSelection(result().repositories, { mode: 'code', repo: row.key }) + ).toBe(row.key); + act(() => prefillTargets?.setSelectedRepo(row.key)); + expect(prefillTargets?.selectedRepo).toBe(row.key); + } +); + +it('applies exact prefill during partial loading without erasing normalized identity', async () => { + await useRealQueries(); + const authorized = providerReference('github', 'org-1'); + mocks.prefill = { prefillRepo: repositoryResourceKey('user-1', authorized) }; + const pending = Promise.withResolvers(); + mocks.normalFetch.mockImplementation(async request => { + const response = + requestProvider(request) === 'gitlab' + ? await pending.promise + : providerData( + requestProvider(request), + requestProvider(request) === 'github' ? [authorized] : [] + ); + return response; }); + mountRepos('org-1'); + await flushQueries(); + expect(result().reposSettled).toBe(false); + expect( + result().repositories.find(repo => repo.key === prefillTargets?.selectedRepo)?.reference + ).toEqual(authorized); + expect(mocks.notes).toEqual([]); +}); + +it('distinguishes a confirmed empty provider from incomplete identity through real discovery', async () => { + await useRealQueries(); + mocks.normalFetch.mockImplementation(request => + requestProvider(request) === 'github' + ? { + integrationInstalled: true, + repositories: [{ id: 1, name: 'repo', fullName: 'owner/repo', private: true }], + } + : providerData(requestProvider(request)) + ); + mountRepos('org-1'); + await flushQueries(); + expect(result().groups.map(group => group.status)).toEqual([ + 'identity-unavailable', + 'connected-empty', + 'connected-empty', + ]); + expect(result().repositories).toEqual([]); + expect(result().recents).toEqual([]); + expect(result().reposSettled).toBe(false); }); + +it.each( + (['github', 'gitlab', 'bitbucket'] as const).flatMap(platform => + [ + { code: 'FORBIDDEN', rpcCode: -32_003, status: 'access-denied' }, + { code: 'UNAUTHORIZED', rpcCode: -32_001, status: 'connect' }, + ].map(({ code, rpcCode, status }) => ({ platform, code, rpcCode, status })) + ) +)( + 'does not revive $platform rows after $code refresh failure and remount', + async ({ platform, code, rpcCode, status }) => { + await useRealQueries(); + const { TRPCClientError } = await import('@trpc/client'); + const denied = TRPCClientError.from({ + error: { code: rpcCode, message: 'Repository access denied', data: { code } }, + }); + const authorized = providerReference(platform, 'org-1'); + const cached = providerData(platform, [authorized]); + mocks.recents = { + repositories: [ + { identity: { kind: 'resolved', accountId: 'user-1', reference: authorized } }, + ], + }; + mocks.normalFetch.mockImplementation(request => + requestProvider(request) === platform ? cached : providerData(requestProvider(request)) + ); + mocks.fetch.mockImplementation(request => { + if (requestProvider(request) === platform) { + throw denied; + } + return providerData(requestProvider(request)); + }); + const hook = mountRepos('org-1'); + await flushQueries(); + expect(result().repositories.map(repo => repo.reference)).toEqual([authorized]); + await act(async () => { + await result().refreshReposForceFresh(); + }); + await flushQueries(); + expect(result().groups.find(group => group.key === platform)?.status).toBe(status); + expect(result().repositories).toEqual([]); + hook.unmount(); + const pending = Promise.withResolvers(); + mocks.normalFetch.mockReturnValue(pending.promise); + mountRepos('org-1'); + await flushQueries(); + expect(result().repositories).toEqual([]); + expect(result().recents).toEqual([]); + } +); + +it.each( + (['github', 'gitlab', 'bitbucket'] as const).flatMap(platform => + ['success', 'failure'].map(outcome => ({ platform, outcome })) + ) +)( + 'keeps confirmed $platform disconnection after a late normal $outcome', + async ({ platform, outcome }) => { + await useRealQueries(); + const normal = Promise.withResolvers(); + const cached = providerData(platform, [providerReference(platform, 'org-1')]); + const key = discoveryKey(platform, 'org-1'); + const disconnected = + platform === 'bitbucket' + ? { status: 'reconnect_required' } + : { + integrationInstalled: false, + repositories: [], + syncedAt: null, + errorMessage: `${platform === 'github' ? 'GitHub' : 'GitLab'} integration is suspended`, + }; + client.setQueryData(key, cached); + mocks.normalFetch.mockImplementation(async request => { + const response = + requestProvider(request) === platform + ? await normal.promise + : providerData(requestProvider(request)); + return response; + }); + mocks.fetch.mockImplementation(request => + requestProvider(request) === platform ? disconnected : providerData(requestProvider(request)) + ); + mountRepos('org-1'); + await flushQueries(); + expect(result().repositories).toHaveLength(1); + await act(async () => { + await result().refreshReposForceFresh(); + }); + await act(() => { + if (outcome === 'success') { + normal.resolve(cached); + } else { + normal.reject(new Error('Old normal request failed')); + } + }); + await flushQueries(); + expect(client.getQueryData(key)).toEqual(disconnected); + expect(client.getQueryState(key)?.error).toBeNull(); + expect(result().groups.find(group => group.key === platform)?.status).toBe('connect'); + expect(result().repositories).toEqual([]); + expect(result().recents).toEqual([]); + expect(mocks.messages).toEqual([]); + } +); diff --git a/apps/mobile/src/lib/use-new-session-repos.ts b/apps/mobile/src/lib/use-new-session-repos.ts index a5e77c9eea..7868b83c93 100644 --- a/apps/mobile/src/lib/use-new-session-repos.ts +++ b/apps/mobile/src/lib/use-new-session-repos.ts @@ -1,28 +1,34 @@ /* eslint-disable max-lines -- One hook wires the GitHub, GitLab, and Bitbucket provider queries, recents resolution, and connect/refresh flows end-to-end. */ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Platform } from 'react-native'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner-native'; +import { repositoryResourceKey } from '@kilocode/app-shared/code-review/repository-identity'; import { dedupeRepositoriesByPlatformAndFullName, - detectRepositoryPlatform, - type NewSessionRepository, + normalizeSessionRepository, type RepositoryGroup, type RepositoryGroups, type RepositoryPlatform, resolveBitbucketStatus, + type ResolvedNewSessionRepository, resolveProviderStatus, resolveRepositoryGroups, } from '@/components/agents/new-session-repository-state'; -import { formatGitUrlProject } from '@/components/agents/session-list-helpers'; +import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; +import { readTrpcErrorField } from '@/lib/trpc-error'; import { i18n } from '@/i18n'; import { WEB_BASE_URL } from '@/lib/config'; import { useRecentAgentRepositories } from '@/lib/hooks/use-agent-sessions'; import { getBitbucketIntegrationUrl, getGitLabIntegrationUrl } from '@/lib/integration-urls'; import { openAuthorizationAndWaitForReturn } from '@/lib/pr-review/connect-gate-platform'; import { useExternalAuthReturn } from '@/lib/external-auth/use-external-auth-return'; -import { useGitHubReposRefresh } from '@/lib/use-github-repos-refresh'; +import { + setRepositoryDiscoveryError, + useGitHubReposRefresh, + withRepositoryAccount, +} from '@/lib/use-github-repos-refresh'; import { useTRPC } from '@/lib/trpc'; type UseNewSessionReposArgs = { @@ -30,55 +36,129 @@ type UseNewSessionReposArgs = { }; type UseNewSessionReposResult = { - /** Merged + deduped rows (recents first) for the picker and prefill. */ - repositories: NewSessionRepository[]; + /** Merged + deduped authorized rows (recents first) for the picker and prefill. */ + repositories: ResolvedNewSessionRepository[]; /** Recently used rows resolved against connected providers ("Recently used" picker section). */ - recents: NewSessionRepository[]; + recents: ResolvedNewSessionRepository[]; groups: RepositoryGroup[]; isRetrying: boolean; - /** True once every provider query has settled and at least one repo is visible. */ + /** Visible requests have settled; browsing does not prove complete authorized discovery. */ reposSettled: boolean; openIntegration: (platform: RepositoryPlatform) => void; refreshReposForceFresh: () => Promise; }; +function canAutomaticallyDiscoverRepositories(error: unknown): boolean { + const code = readTrpcErrorField(error, 'code'); + return code !== 'FORBIDDEN' && code !== 'UNAUTHORIZED'; +} + export function useNewSessionRepos({ organizationId, }: UseNewSessionReposArgs): UseNewSessionReposResult { const trpc = useTRPC(); const queryClient = useQueryClient(); - - const githubQuery = useQuery( - organizationId - ? trpc.organizations.cloudAgentNext.listGitHubRepositories.queryOptions({ - organizationId, - forceRefresh: false, - }) - : trpc.cloudAgentNext.listGitHubRepositories.queryOptions({ + const { userId } = useCurrentUserId(); + const scope = useMemo( + () => ({ + userId, + queryClient, + github: withRepositoryAccount( + organizationId + ? trpc.organizations.cloudAgentNext.listGitHubRepositories.queryOptions({ + organizationId, + forceRefresh: false, + }) + : trpc.cloudAgentNext.listGitHubRepositories.queryOptions({ forceRefresh: false }), + userId + ), + gitlab: withRepositoryAccount( + organizationId + ? trpc.organizations.cloudAgentNext.listGitLabRepositories.queryOptions({ + organizationId, + forceRefresh: false, + }) + : trpc.cloudAgentNext.listGitLabRepositories.queryOptions({ forceRefresh: false }), + userId + ), + bitbucket: withRepositoryAccount( + trpc.organizations.cloudAgentNext.listBitbucketRepositories.queryOptions({ + organizationId: organizationId ?? '', forceRefresh: false, - }) + }), + userId + ), + gitlabFresh: withRepositoryAccount( + organizationId + ? trpc.organizations.cloudAgentNext.listGitLabRepositories.queryOptions({ + organizationId, + forceRefresh: true, + }) + : trpc.cloudAgentNext.listGitLabRepositories.queryOptions({ forceRefresh: true }), + userId + ), + bitbucketFresh: organizationId + ? withRepositoryAccount( + trpc.organizations.cloudAgentNext.listBitbucketRepositories.queryOptions({ + organizationId, + forceRefresh: true, + }), + userId + ) + : null, + }), + [userId, organizationId, queryClient, trpc] ); + const currentScope = useRef(scope); + currentScope.current = scope; - const gitlabQuery = useQuery( - organizationId - ? trpc.organizations.cloudAgentNext.listGitLabRepositories.queryOptions({ - organizationId, - forceRefresh: false, - }) - : trpc.cloudAgentNext.listGitLabRepositories.queryOptions({ - forceRefresh: false, - }) + // Keep normal observers active until their own error reaches the permission cache handler. + // A denial already present at render blocks automatic discovery, including rebuilt queries. + const githubDiscoveryAllowed = canAutomaticallyDiscoverRepositories( + queryClient.getQueryState(scope.github.queryKey)?.error ); - + const gitlabDiscoveryAllowed = canAutomaticallyDiscoverRepositories( + queryClient.getQueryState(scope.gitlab.queryKey)?.error + ); + const bitbucketDiscoveryAllowed = canAutomaticallyDiscoverRepositories( + queryClient.getQueryState(scope.bitbucket.queryKey)?.error + ); + // A forced success clears the current error without starting another normal fetch. + const githubQuery = useQuery({ + ...scope.github, + enabled: query => + Boolean(userId) && + (githubDiscoveryAllowed || canAutomaticallyDiscoverRepositories(query.state.error)), + }); + const gitlabQuery = useQuery({ + ...scope.gitlab, + enabled: query => + Boolean(userId) && + (gitlabDiscoveryAllowed || canAutomaticallyDiscoverRepositories(query.state.error)), + }); // Bitbucket is organization-only: the query is disabled without an org. const bitbucketQuery = useQuery({ - ...trpc.organizations.cloudAgentNext.listBitbucketRepositories.queryOptions({ - organizationId: organizationId ?? '', - forceRefresh: false, - }), - enabled: Boolean(organizationId), + ...scope.bitbucket, + enabled: query => + Boolean(userId && organizationId) && + (bitbucketDiscoveryAllowed || canAutomaticallyDiscoverRepositories(query.state.error)), }); + // Reconnect/refresh must also release a stale branch error, even when discovery rows are unchanged. + useEffect(() => { + const branches = organizationId + ? trpc.organizations.cloudAgentNext.listRepositoryBranches + : trpc.cloudAgentNext.listRepositoryBranches; + void queryClient.invalidateQueries(branches.pathFilter()); + }, [ + githubQuery.dataUpdatedAt, + gitlabQuery.dataUpdatedAt, + bitbucketQuery.dataUpdatedAt, + organizationId, + queryClient, + trpc, + ]); + const { data: recentRepoData } = useRecentAgentRepositories({ organizationId }); const { @@ -90,95 +170,86 @@ export function useNewSessionRepos({ integrationInstalled: githubQuery.data?.integrationInstalled, }); - const githubRepositories = useMemo( - () => - (githubQuery.data?.repositories ?? []).map(repo => ({ - platform: 'github', - fullName: repo.fullName, - isPrivate: repo.private, - })), - [githubQuery.data] - ); + const [providerRefreshCounts, setProviderRefreshCounts] = useState({ gitlab: 0, bitbucket: 0 }); - const gitlabRepositories = useMemo( + const normalize = useCallback( + (row: Parameters[0]) => { + const repository = normalizeSessionRepository(row, userId, organizationId); + return repository ? [repository] : []; + }, + [userId, organizationId] + ); + const githubRepositories = useMemo( + () => (githubQuery.data?.repositories ?? []).flatMap(row => normalize(row)), + [githubQuery.data, normalize] + ); + const gitlabRepositories = useMemo( + () => (gitlabQuery.data?.repositories ?? []).flatMap(row => normalize(row)), + [gitlabQuery.data, normalize] + ); + const bitbucketRepositories = useMemo( () => - (gitlabQuery.data?.repositories ?? []).map(repo => ({ - platform: 'gitlab', - fullName: repo.fullName, - isPrivate: repo.private, - })), - [gitlabQuery.data] + bitbucketQuery.data?.status === 'available' + ? bitbucketQuery.data.repositories.flatMap(row => normalize(row)) + : [], + [bitbucketQuery.data, normalize] ); - const bitbucketRepositories = useMemo(() => { - const data = bitbucketQuery.data; - if (data?.status !== 'available') { - return []; - } - return data.repositories.map(repo => ({ - platform: 'bitbucket', - fullName: repo.fullName, - isPrivate: repo.private, - workspaceUuid: repo.workspaceUuid, - repositoryUuid: repo.id, - })); - }, [bitbucketQuery.data]); - - // Recently used rows: only recents that resolve to a connected repository - // appear, and they are deduped by platform + fullName. - const recentlyUsed = useMemo(() => { - const recentList = recentRepoData?.repositories; - if (!recentList?.length) { - return []; - } - const unified = [...githubRepositories, ...gitlabRepositories, ...bitbucketRepositories]; - const byKey = new Map(unified.map(repo => [repoKey(repo), repo])); - const seen = new Set(); - const result: NewSessionRepository[] = []; - for (const recent of recentList) { - const platform = detectRepositoryPlatform(recent.gitUrl); - const fullName = formatGitUrlProject(recent.gitUrl); - const match = - platform && fullName ? byKey.get(`${platform}/${fullName.toLowerCase()}`) : undefined; - if (match) { - const key = repoKey(match); - if (!seen.has(key)) { - seen.add(key); - result.push(match); + const recentlyUsed = useMemo(() => { + const byKey = new Map( + [...githubRepositories, ...gitlabRepositories, ...bitbucketRepositories].map(repo => [ + repo.key, + repo, + ]) + ); + // Old URL-only or unresolved recents stay in history, not in another identity's picker. + // Remove after old clients/records disappear and the 30-day ledger window expires. + return dedupeRepositoriesByPlatformAndFullName( + (recentRepoData?.repositories ?? []).flatMap(recent => { + if (recent.identity?.kind !== 'resolved' || recent.identity.accountId !== userId) { + return []; } - } - } - return result; - }, [recentRepoData, githubRepositories, gitlabRepositories, bitbucketRepositories]); - - const repositories = useMemo( - () => - dedupeRepositoriesByPlatformAndFullName([ - ...recentlyUsed, - ...githubRepositories, - ...gitlabRepositories, - ...bitbucketRepositories, - ]), - [recentlyUsed, githubRepositories, gitlabRepositories, bitbucketRepositories] - ); + const match = byKey.get( + repositoryResourceKey(recent.identity.accountId, recent.identity.reference) + ); + return match ? [match] : []; + }) + ); + }, [recentRepoData, userId, githubRepositories, gitlabRepositories, bitbucketRepositories]); const githubStatus = resolveProviderStatus({ - isLoading: githubQuery.isLoading, - isError: githubQuery.isError, + isLoading: !userId || githubQuery.isLoading || githubQuery.isRefetching || isRefreshingGitHub, + isError: githubQuery.isError || Boolean(githubQuery.data?.errorMessage), + errorCode: readTrpcErrorField(githubQuery.error, 'code'), integrationInstalled: githubQuery.data?.integrationInstalled, repositoryCount: githubRepositories.length, + hasUnresolved: (githubQuery.data?.repositories.length ?? 0) > githubRepositories.length, }); const gitlabStatus = resolveProviderStatus({ - isLoading: gitlabQuery.isLoading, - isError: gitlabQuery.isError, + isLoading: + !userId || + gitlabQuery.isLoading || + gitlabQuery.isRefetching || + providerRefreshCounts.gitlab > 0, + isError: gitlabQuery.isError || Boolean(gitlabQuery.data?.errorMessage), + errorCode: readTrpcErrorField(gitlabQuery.error, 'code'), integrationInstalled: gitlabQuery.data?.integrationInstalled, repositoryCount: gitlabRepositories.length, + hasUnresolved: (gitlabQuery.data?.repositories.length ?? 0) > gitlabRepositories.length, }); const bitbucketStatus = resolveBitbucketStatus({ - isLoading: bitbucketQuery.isLoading, + isLoading: + !userId || + bitbucketQuery.isLoading || + bitbucketQuery.isRefetching || + providerRefreshCounts.bitbucket > 0, isError: bitbucketQuery.isError, status: bitbucketQuery.data?.status, repositoryCount: bitbucketRepositories.length, + errorCode: readTrpcErrorField(bitbucketQuery.error, 'code'), + hasUnresolved: + bitbucketQuery.data?.status === 'available' && + bitbucketQuery.data.repositories.length > bitbucketRepositories.length, }); const { groups, recents } = useMemo( @@ -205,145 +276,189 @@ export function useNewSessionRepos({ recentlyUsed, ] ); + const repositories = useMemo( + () => + dedupeRepositoriesByPlatformAndFullName([ + ...recents, + ...groups.flatMap(group => group.repositories), + ]), + [recents, groups] + ); - // ── Force-fresh per-provider refresh ────────────────────────────── - const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); - - const forceFreshGitLab = useCallback(async () => { - const fresh = await queryClient.fetchQuery({ - ...(organizationId - ? trpc.organizations.cloudAgentNext.listGitLabRepositories.queryOptions({ - organizationId, - forceRefresh: true, - }) - : trpc.cloudAgentNext.listGitLabRepositories.queryOptions({ - forceRefresh: true, - })), - staleTime: 0, - }); - queryClient.setQueryData( - organizationId - ? trpc.organizations.cloudAgentNext.listGitLabRepositories.queryKey({ - organizationId, - forceRefresh: false, - }) - : trpc.cloudAgentNext.listGitLabRepositories.queryKey({ - forceRefresh: false, - }), - fresh - ); - }, [organizationId, trpc, queryClient]); + const forceFreshProvider = useCallback( + async (platform: 'gitlab' | 'bitbucket') => { + if ( + !scope.userId || + currentScope.current !== scope || + (platform === 'bitbucket' && !scope.bitbucketFresh) + ) { + return; + } + setProviderRefreshCounts(counts => ({ ...counts, [platform]: counts[platform] + 1 })); + try { + if (platform === 'gitlab') { + const fresh = await queryClient.fetchQuery({ ...scope.gitlabFresh, staleTime: 0 }); + if (currentScope.current !== scope) { + return; + } + await queryClient.cancelQueries({ queryKey: scope.gitlab.queryKey, exact: true }); + if (currentScope.current !== scope) { + return; + } + queryClient.setQueryData(scope.gitlab.queryKey, fresh); + } else if (scope.bitbucketFresh) { + const fresh = await queryClient.fetchQuery({ ...scope.bitbucketFresh, staleTime: 0 }); + if (currentScope.current !== scope) { + return; + } + // Only a transient outage permits the previous authorized snapshot to remain usable. + if (fresh.status === 'temporarily_unavailable') { + throw new Error('Bitbucket repositories are temporarily unavailable'); + } + await queryClient.cancelQueries({ queryKey: scope.bitbucket.queryKey, exact: true }); + if (currentScope.current !== scope) { + return; + } + queryClient.setQueryData(scope.bitbucket.queryKey, fresh); + } + } catch (error) { + if (currentScope.current !== scope) { + return; + } + await queryClient.cancelQueries({ queryKey: scope[platform].queryKey, exact: true }); + if (currentScope.current !== scope) { + return; + } + if (setRepositoryDiscoveryError(queryClient, scope[platform].queryKey, error) === 'error') { + throw error; + } + } finally { + if (currentScope.current === scope) { + setProviderRefreshCounts(counts => ({ ...counts, [platform]: counts[platform] - 1 })); + } + } + }, + [scope, queryClient] + ); - const forceFreshBitbucket = useCallback(async () => { - if (!organizationId) { + const refreshReposForceFresh = useCallback(async () => { + if (currentScope.current !== scope) { return; } - const fresh = await queryClient.fetchQuery({ - ...trpc.organizations.cloudAgentNext.listBitbucketRepositories.queryOptions({ - organizationId, - forceRefresh: true, - }), - staleTime: 0, - }); - // A non-`available` force-fresh result is a refresh failure, not a - // cacheable snapshot: writing it over an existing `available` cache would - // clear good rows on a transient Bitbucket outage. Throw so - // `refreshReposForceFresh` toasts `couldNotRefreshRepositories`. - if (fresh.status !== 'available') { - throw new Error('Bitbucket repositories are not available'); + const results = await Promise.allSettled([ + refreshGitHubForceFresh(), + forceFreshProvider('gitlab'), + forceFreshProvider('bitbucket'), + ]); + // GitHub handles its own errors. Report current GitLab/Bitbucket failures once. + if ( + currentScope.current === scope && + (results[1].status === 'rejected' || results[2].status === 'rejected') + ) { + toast.error(i18n.t('agentChat.newSession.couldNotRefreshRepositories')); } - queryClient.setQueryData( - trpc.organizations.cloudAgentNext.listBitbucketRepositories.queryKey({ - organizationId, - forceRefresh: false, - }), - fresh - ); - }, [organizationId, trpc, queryClient]); + }, [scope, refreshGitHubForceFresh, forceFreshProvider]); - const refreshReposForceFresh = useCallback(async () => { - setIsRefreshingProviders(true); - try { - const results = await Promise.allSettled([ - refreshGitHubForceFresh(), - forceFreshGitLab(), - forceFreshBitbucket(), - ]); - // GitHub resolves always (it catches its own errors and toasts), so a - // rejection here is a GitLab or Bitbucket force-fresh failure. Toast once. - if (results[1].status === 'rejected' || results[2].status === 'rejected') { - toast.error(i18n.t('agentChat.newSession.couldNotRefreshRepositories')); + const refreshAfterReturn = useCallback( + async (platform: 'gitlab' | 'bitbucket') => { + try { + await forceFreshProvider(platform); + } catch { + if (currentScope.current === scope) { + toast.error( + i18n.t( + platform === 'gitlab' + ? 'codeReviewer.providerConnect.gitlabError' + : 'codeReviewer.providerConnect.bitbucketError' + ) + ); + } } - } finally { - setIsRefreshingProviders(false); - } - }, [refreshGitHubForceFresh, forceFreshGitLab, forceFreshBitbucket]); - - // ── Per-provider connect ────────────────────────────────────────── - // Android: `openAuthorizationAndWaitForReturn` returns `'app-foreground'` - // (the browser launch is fire-and-forget), so each provider's refresh runs - // from a shared foreground listener when the app returns. + }, + [scope, forceFreshProvider] + ); const { markLaunched: markGitLabLaunched, clearLaunch: clearGitLabLaunch } = useExternalAuthReturn(() => { - void forceFreshGitLab(); + void refreshAfterReturn('gitlab'); }); const { markLaunched: markBitbucketLaunched, clearLaunch: clearBitbucketLaunch } = useExternalAuthReturn(() => { - void forceFreshBitbucket(); + void refreshAfterReturn('bitbucket'); }); - - const openGitLabIntegration = useCallback(() => { - void (async () => { - try { - markGitLabLaunched(); - const trigger = await openAuthorizationAndWaitForReturn( - Platform.OS, - getGitLabIntegrationUrl(WEB_BASE_URL, organizationId) - ); - if (trigger === 'sheet-close') { - clearGitLabLaunch(); - await forceFreshGitLab(); - } - } catch { - clearGitLabLaunch(); - toast.error(i18n.t('codeReviewer.providerConnect.gitlabError')); - } - })(); - }, [organizationId, forceFreshGitLab, markGitLabLaunched, clearGitLabLaunch]); - - const openBitbucketIntegration = useCallback(() => { - if (!organizationId) { - return; - } - void (async () => { - try { - markBitbucketLaunched(); - const trigger = await openAuthorizationAndWaitForReturn( - Platform.OS, - getBitbucketIntegrationUrl(WEB_BASE_URL, organizationId) - ); - if (trigger === 'sheet-close') { - clearBitbucketLaunch(); - await forceFreshBitbucket(); + useEffect(() => { + currentScope.current = scope; + setProviderRefreshCounts({ gitlab: 0, bitbucket: 0 }); + return () => { + currentScope.current = null; + clearGitLabLaunch(); + clearBitbucketLaunch(); + for (const options of [ + scope.github, + scope.gitlab, + scope.bitbucket, + scope.gitlabFresh, + scope.bitbucketFresh, + ]) { + if (options) { + void scope.queryClient.cancelQueries({ queryKey: options.queryKey, exact: true }); } - } catch { - clearBitbucketLaunch(); - toast.error(i18n.t('codeReviewer.providerConnect.bitbucketError')); } - })(); - }, [organizationId, forceFreshBitbucket, markBitbucketLaunched, clearBitbucketLaunch]); + }; + }, [scope, clearGitLabLaunch, clearBitbucketLaunch]); const openIntegration = useCallback( (platform: RepositoryPlatform) => { + if (!scope.userId || currentScope.current !== scope) { + return; + } if (platform === 'github') { openGitHubIntegration(); - } else if (platform === 'gitlab') { - openGitLabIntegration(); - } else { - openBitbucketIntegration(); + return; } + if (platform === 'bitbucket' && !organizationId) { + return; + } + const markLaunched = platform === 'gitlab' ? markGitLabLaunched : markBitbucketLaunched; + const clearLaunch = platform === 'gitlab' ? clearGitLabLaunch : clearBitbucketLaunch; + void (async () => { + try { + markLaunched(); + const url = + platform === 'bitbucket' && organizationId + ? getBitbucketIntegrationUrl(WEB_BASE_URL, organizationId) + : getGitLabIntegrationUrl(WEB_BASE_URL, organizationId); + const trigger = await openAuthorizationAndWaitForReturn(Platform.OS, url); + if (currentScope.current !== scope) { + return; + } + if (trigger === 'sheet-close') { + clearLaunch(); + await refreshAfterReturn(platform); + } + } catch { + if (currentScope.current === scope) { + clearLaunch(); + toast.error( + i18n.t( + platform === 'gitlab' + ? 'codeReviewer.providerConnect.gitlabError' + : 'codeReviewer.providerConnect.bitbucketError' + ) + ); + } + } + })(); }, - [openGitHubIntegration, openGitLabIntegration, openBitbucketIntegration] + [ + scope, + organizationId, + openGitHubIntegration, + markGitLabLaunched, + markBitbucketLaunched, + clearGitLabLaunch, + clearBitbucketLaunch, + refreshAfterReturn, + ] ); const isRetrying = @@ -351,13 +466,17 @@ export function useNewSessionRepos({ gitlabQuery.isRefetching || bitbucketQuery.isRefetching || isRefreshingGitHub || - isRefreshingProviders; + providerRefreshCounts.gitlab > 0 || + providerRefreshCounts.bitbucket > 0; const reposSettled = - !githubQuery.isLoading && - !gitlabQuery.isLoading && - !bitbucketQuery.isLoading && - repositories.length > 0; + Boolean(userId) && + groups.every( + group => + group.status !== 'loading' && + group.status !== 'error' && + group.status !== 'identity-unavailable' + ); return { repositories, @@ -369,7 +488,3 @@ export function useNewSessionRepos({ refreshReposForceFresh, }; } - -function repoKey(repository: NewSessionRepository): string { - return `${repository.platform}/${repository.fullName.toLowerCase()}`; -}