diff --git a/apps/mobile/src/components/agents/new-session-repository-section.test-helpers.ts b/apps/mobile/src/components/agents/new-session-repository-section.test-helpers.ts new file mode 100644 index 0000000000..3dae1ab4dc --- /dev/null +++ b/apps/mobile/src/components/agents/new-session-repository-section.test-helpers.ts @@ -0,0 +1,69 @@ +import { type ComponentProps, createElement } from 'react'; +import { act, TestRenderer } from '@/test/renderer'; +import { vi } from 'vitest'; + +import { NewSessionRepositorySection } from './new-session-repository-section'; +import { type NewSessionRepository, type RepositoryGroup } from './new-session-repository-state'; + +export const githubRow: NewSessionRepository = { + platform: 'github', + fullName: 'owner/repo', + isPrivate: false, +}; +export const gitlabRow: NewSessionRepository = { + platform: 'gitlab', + fullName: 'owner/repo', + isPrivate: false, +}; + +export const group = ( + key: RepositoryGroup['key'], + status: RepositoryGroup['status'], + repositories: NewSessionRepository[] = [] +): RepositoryGroup => ({ key, status, repositories }); + +export function mountSection( + overrides: Partial> +) { + const renderer: { current: TestRenderer.ReactTestRenderer | null } = { current: null }; + act(() => { + renderer.current = TestRenderer.create( + createElement(NewSessionRepositorySection, { + disabled: false, + isRetrying: false, + onChange: vi.fn(() => undefined), + onConnect: vi.fn(() => undefined), + onRefreshRepos: vi.fn(() => undefined), + repositories: [githubRow, gitlabRow], + recents: [], + groups: [group('github', 'repos'), group('gitlab', 'repos')], + value: '', + organizationId: undefined, + isCloneEntry: false, + ...overrides, + }) + ); + }); + const created = renderer.current; + if (created === null) { + throw new Error('the section did not render'); + } + return created; +} + +export function branchSelectorProps(renderer: TestRenderer.ReactTestRenderer) { + return renderer.root.findAllByType('RepositoryBranchSelector' as never)[0]?.props as + | { + repository: NewSessionRepository | null; + organizationId: string | undefined; + disabled: boolean; + } + | undefined; +} + +export function renderedText(renderer: TestRenderer.ReactTestRenderer): string[] { + return renderer.root + .findAllByType('Text' as never) + .flatMap(node => node.children) + .filter((child): child is string => typeof child === 'string'); +} diff --git a/apps/mobile/src/components/agents/new-session-repository-section.test.ts b/apps/mobile/src/components/agents/new-session-repository-section.test.ts index 5fbc7c08dc..00eb44e6b2 100644 --- a/apps/mobile/src/components/agents/new-session-repository-section.test.ts +++ b/apps/mobile/src/components/agents/new-session-repository-section.test.ts @@ -1,13 +1,17 @@ -import { createElement } from 'react'; -import { act, TestRenderer } from '@/test/renderer'; +import { act, type TestRenderer } from '@/test/renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { i18n } from '@/i18n'; -import { NewSessionRepositorySection } from './new-session-repository-section'; +import { + branchSelectorProps, + githubRow, + gitlabRow, + group, + mountSection, + renderedText, +} from './new-session-repository-section.test-helpers'; import { getSelectedBranchOverride, - type NewSessionRepository, - type RepositoryGroup, resetSelectedBranchOverrides, setSelectedBranchOverride, } from './new-session-repository-state'; @@ -63,72 +67,6 @@ vi.mock('@/lib/hooks/use-collapsed-connect-ctas-preference', () => ({ setConnectCtaCollapsed: collapseState.setConnectCtaCollapsed, })); -const githubRow: NewSessionRepository = { - platform: 'github', - fullName: 'owner/repo', - isPrivate: false, -}; -const gitlabRow: NewSessionRepository = { - platform: 'gitlab', - fullName: 'owner/repo', - isPrivate: false, -}; - -const group = ( - key: RepositoryGroup['key'], - status: RepositoryGroup['status'], - repositories: NewSessionRepository[] = [] -): RepositoryGroup => ({ key, status, repositories }); - -function mountSection(overrides: { - value?: string; - repositories?: NewSessionRepository[]; - groups?: RepositoryGroup[]; - organizationId?: string | undefined; - isCloneEntry?: boolean; -}) { - const renderer: { current: TestRenderer.ReactTestRenderer | null } = { current: null }; - act(() => { - renderer.current = TestRenderer.create( - createElement(NewSessionRepositorySection, { - disabled: false, - isRetrying: false, - onChange: vi.fn(() => undefined), - onConnect: vi.fn(() => undefined), - onRefreshRepos: vi.fn(() => undefined), - repositories: overrides.repositories ?? [githubRow, gitlabRow], - recents: [], - groups: overrides.groups ?? [group('github', 'repos'), group('gitlab', 'repos')], - value: overrides.value ?? '', - organizationId: overrides.organizationId, - isCloneEntry: overrides.isCloneEntry ?? false, - }) - ); - }); - const created = renderer.current; - if (created === null) { - throw new Error('the section did not render'); - } - return created; -} - -function branchSelectorProps(renderer: TestRenderer.ReactTestRenderer) { - return renderer.root.findAllByType('RepositoryBranchSelector' as never)[0]?.props as - | { - repository: NewSessionRepository | null; - organizationId: string | undefined; - disabled: boolean; - } - | undefined; -} - -function renderedText(renderer: TestRenderer.ReactTestRenderer): string[] { - return renderer.root - .findAllByType('Text' as never) - .flatMap(node => node.children) - .filter((child): child is string => typeof child === 'string'); -} - /** The headers of the connect cards; the section renders no other pressable. */ function pressables(renderer: TestRenderer.ReactTestRenderer) { return renderer.root.findAllByType('Pressable' as never); @@ -157,22 +95,14 @@ beforeEach(() => { }); describe('NewSessionRepositorySection branch row', () => { - it('hands the branch selector the resolved repository row', () => { - const renderer = mountSection({ value: 'github:owner/repo' }); - - expect(branchSelectorProps(renderer)?.repository).toEqual(githubRow); - }); - - it('keeps same-named rows on two providers distinct', () => { - const renderer = mountSection({ value: 'gitlab:owner/repo' }); - - expect(branchSelectorProps(renderer)?.repository).toEqual(gitlabRow); - }); - - it('offers no branch row until a repository is selected', () => { - const renderer = mountSection({ value: '' }); - - expect(branchSelectorProps(renderer)?.repository).toBeNull(); + it.each([ + ['github:owner/repo', githubRow], + ['gitlab:owner/repo', gitlabRow], + ['', null], + ] as const)('resolves "%s" to its provider-specific branch row', (value, repository) => { + const renderer = mountSection({ value }); + + expect(branchSelectorProps(renderer)?.repository).toEqual(repository); }); it('hands the branch selector the route organization scope', () => { @@ -205,15 +135,19 @@ describe('NewSessionRepositorySection branch row', () => { }); describe('NewSessionRepositorySection Bitbucket connect card', () => { - it('states outright that Bitbucket is organizations-only', () => { - const renderer = mountSection({ - groups: [group('github', 'repos'), group('gitlab', 'repos'), group('bitbucket', 'connect')], - }); - - expect(renderedText(renderer)).toContain( - i18n.t('agentChat.newSession.bitbucketOrganizationsOnly') - ); - }); + it.each(['', 'github:owner/repo', 'gitlab:owner/repo'])( + 'states outright that Bitbucket is organizations-only with selection "%s"', + value => { + const renderer = mountSection({ + value, + groups: [group('github', 'repos'), group('gitlab', 'repos'), group('bitbucket', 'connect')], + }); + + expect(renderedText(renderer)).toContain( + i18n.t('agentChat.newSession.bitbucketOrganizationsOnly') + ); + } + ); it('leaves the GitHub connect card free of the Bitbucket restriction', () => { const renderer = mountSection({ @@ -276,21 +210,13 @@ describe('NewSessionRepositorySection connect card collapse', () => { ).toEqual({ expanded: true }); }); - it('requests a collapse when an expanded header is pressed', () => { + it.each([false, true])('toggles the persisted collapse state from %s', collapsed => { + collapseState.collapsedCtas = collapsed ? ['github'] : []; const renderer = mountSection({ groups: [group('github', 'connect')] }); pressHeader(renderer, i18n.t('common.connectGithub')); - expect(collapseState.setConnectCtaCollapsed).toHaveBeenCalledWith('github', true); - }); - - it('requests an expand when a collapsed header is pressed', () => { - collapseState.collapsedCtas = ['github']; - const renderer = mountSection({ groups: [group('github', 'connect')] }); - - pressHeader(renderer, i18n.t('common.connectGithub')); - - expect(collapseState.setConnectCtaCollapsed).toHaveBeenCalledWith('github', false); + expect(collapseState.setConnectCtaCollapsed).toHaveBeenCalledWith('github', !collapsed); }); it('renders no connect card until the persisted state has loaded', () => { @@ -303,18 +229,119 @@ describe('NewSessionRepositorySection connect card collapse', () => { expect(pressables(renderer)).toHaveLength(0); }); - it('renders no connect card when every provider has repositories', () => { + it.each([ + { groups: [group('github', 'repos')] }, + { groups: [group('github', 'repos'), group('gitlab', 'repos')] }, + ])('renders no connect card when every provider has repositories: $groups', options => { + const renderer = mountSection(options); + + expect(renderedText(renderer)).not.toContain(i18n.t('common.connectGithub')); + expect(pressables(renderer)).toHaveLength(0); + }); +}); + +describe('NewSessionRepositorySection connect cards after selection', () => { + it.each([ + ['github', 'Github', gitlabRow], + ['gitlab', 'Gitlab', githubRow], + ['bitbucket', 'Bitbucket', githubRow], + ] as const)('keeps compact %s actions without expanded instructions', (platform, copy, row) => { + const onConnect = vi.fn(() => undefined); + const onRefreshRepos = vi.fn(() => undefined); + collapseState.collapsedCtas = [platform]; + collapseState.hasLoaded = false; + setSelectedBranchOverride(row, 'release/2.0'); const renderer = mountSection({ - groups: [group('github', 'repos'), group('gitlab', 'repos')], + value: `${row.platform}:${row.fullName}`, + groups: [group(row.platform, 'repos'), group(platform, 'connect')], + organizationId: 'org-1', + onConnect, + onRefreshRepos, }); + const text = renderedText(renderer); + expect(text).toContain(i18n.t(`common.connect${copy}`)); + expect(text).not.toContain(i18n.t(`agentChat.newSession.connect${copy}Description`)); + expect(text.includes(i18n.t('agentChat.newSession.bitbucketOrganizationsOnly'))).toBe( + platform === 'bitbucket' + ); expect(pressables(renderer)).toHaveLength(0); + const connect = renderer.root.findAllByType('Button' as never)[0]; + if (!connect) { + throw new Error('no connect action'); + } + const refresh = renderer.root.findByProps({ + accessibilityLabel: i18n.t('agentChat.newSession.refreshRepositories'), + }); + expect(refresh.props.disabled).toBe(false); + act(() => { + (connect.props.onPress as () => void)(); + (refresh.props.onPress as () => void)(); + }); + expect(onConnect).toHaveBeenCalledWith(platform); + expect(onRefreshRepos).toHaveBeenCalledOnce(); + expect(branchSelectorProps(renderer)?.repository).toEqual(row); + expect(getSelectedBranchOverride(row)).toBe('release/2.0'); }); - it('renders no connect card for a github group that only has repositories', () => { - const renderer = mountSection({ groups: [group('github', 'repos')] }); + it('keeps one inline wait in the disabled refresh action', () => { + const renderer = mountSection({ + value: 'github:owner/repo', + groups: [group('github', 'repos'), group('gitlab', 'connect')], + isRetrying: true, + }); + expect( + renderer.root.findByProps({ + accessibilityLabel: i18n.t('agentChat.newSession.refreshRepositories'), + }).props.disabled + ).toBe(true); + expect(renderer.root.findAllByType('ActivityIndicator' as never)).toHaveLength(1); + expect(branchSelectorProps(renderer)?.repository).toEqual(githubRow); + }); - expect(renderedText(renderer)).not.toContain(i18n.t('common.connectGithub')); - expect(pressables(renderer)).toHaveLength(0); + it('keeps a retryable provider error recoverable after selection', () => { + const onRefreshRepos = vi.fn(() => undefined); + const renderer = mountSection({ + value: 'github:owner/repo', + groups: [group('github', 'repos'), group('gitlab', 'error')], + onRefreshRepos, + }); + const error = renderer.root.findByType('QueryError' as never); + expect(error.props.title).toBe(i18n.t('agentChat.newSession.couldNotLoadGitlabRepositories')); + act(() => { + (error.props.onRetry as () => void)(); + }); + expect(onRefreshRepos).toHaveBeenCalledOnce(); + expect(branchSelectorProps(renderer)?.repository).toEqual(githubRow); + }); + + it('keeps connected-empty guidance and refresh after selection', () => { + const onRefreshRepos = vi.fn(() => undefined); + const renderer = mountSection({ + value: 'github:owner/repo', + groups: [group('github', 'repos'), group('gitlab', 'connected-empty')], + onRefreshRepos, + }); + expect(renderedText(renderer)).toContain( + i18n.t('agentChat.newSession.noRepositoriesVisibleGitlab') + ); + const refresh = renderer.root.findByProps({ + accessibilityLabel: i18n.t('agentChat.newSession.refreshRepositories'), + }); + act(() => { + (refresh.props.onPress as () => void)(); + }); + expect(onRefreshRepos).toHaveBeenCalledOnce(); + }); + + it('keeps the connect prompt while no repository is selected', () => { + const renderer = mountSection({ + value: '', + groups: [group('github', 'repos'), group('gitlab', 'connect')], + }); + + expect(renderedText(renderer)).toContain( + i18n.t('agentChat.newSession.connectGitlabDescription') + ); }); }); 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 4a75271c29..866b00ee32 100644 --- a/apps/mobile/src/components/agents/new-session-repository-section.tsx +++ b/apps/mobile/src/components/agents/new-session-repository-section.tsx @@ -95,8 +95,11 @@ function connectNoteKey(platform: RepositoryPlatform): string | undefined { /** * 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. + * empty/error state independently, and the picker trigger lists every + * repository plus the Recently used rows when any provider has rows. A + * provider's expanded connect prompt renders only before a repository is + * selected. Afterwards, compact actions keep other providers reachable without + * contradicting the completed selection or requiring it to be cleared. */ export function NewSessionRepositorySection({ disabled, @@ -164,7 +167,15 @@ export function NewSessionRepositorySection({ ): ReactElement | null { switch (status) { case 'connect': { - return renderConnectCard(platform); + const noteKey = connectNoteKey(platform); + return selectedRepository === null ? ( + renderConnectCard(platform) + ) : ( + + {noteKey ? {t(noteKey)} : null} + {renderConnectActions(platform)} + + ); } case 'connected-empty': { return renderConnectedEmptyCard(platform); @@ -220,35 +231,42 @@ export function NewSessionRepositorySection({ {t(copy.connectDescription)} {noteKey ? {t(noteKey)} : null} - - - - + {renderConnectActions(platform)} ); } + function renderConnectActions(platform: RepositoryPlatform): ReactElement { + const copy = PROVIDER_COPY[platform]; + return ( + + + + + ); + } + function renderConnectedEmptyCard(platform: RepositoryPlatform): ReactElement | null { const copy = PROVIDER_COPY[platform]; return ( diff --git a/apps/mobile/src/lib/case-guard.test.ts b/apps/mobile/src/lib/case-guard.test.ts index 5f44d134b1..fe6fc846c1 100644 --- a/apps/mobile/src/lib/case-guard.test.ts +++ b/apps/mobile/src/lib/case-guard.test.ts @@ -40,6 +40,7 @@ const ALLOWED_NON_DISPLAY: Readonly> = { 'lib/use-new-session-repos.ts': 'repo key normalization', 'lib/organization-invoice-download.ts': 'filename comparison', 'lib/agent-attachments/validate.ts': 'file-extension normalization', + 'lib/auth/passkey-client.ts': 'native passkey error-identifier comparison', 'lib/auth/passkey-client.ts': 'credential-error classification key', 'lib/auth/use-native-auth.ts': 'email normalization', 'lib/telemetry/install-error-reporting.ts': 'hostname comparison',