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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 38 additions & 15 deletions apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ import { useFocusEffect, useRouter } from 'expo-router';
import * as Haptics from 'expo-haptics';
import { Check, Info, Lock, Search, SearchX, Unlock } from '@/components/ui/icons';
import { useCallback, useMemo, useRef, useState } from 'react';
import { FlatList, Pressable, TextInput, View } from 'react-native';
import { Pressable, TextInput, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { EmptyState } from '@/components/empty-state';
import { PickerSheet } from '@/components/picker-sheet';
Expand All @@ -21,7 +20,6 @@ type PickerListItem =
export default function RepoPickerScreen() {
const router = useRouter();
const colors = useThemeColors();
const { bottom } = useSafeAreaInsets();
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [bridge, setBridge] = useState(() => repoPickerSlot.get(UNFENCED_ROUTE_KEY));
Expand Down Expand Up @@ -102,7 +100,6 @@ export default function RepoPickerScreen() {
<PickerSheet
title={t('agentChat.repoPicker.title')}
onDone={closePicker}
scrollable={false}
headerContent={
<View className="flex-row items-center gap-2 rounded-full bg-secondary px-3 py-2 mx-4 mb-3 mt-3">
<Search size={18} color={colors.mutedForeground} />
Expand Down Expand Up @@ -136,17 +133,18 @@ export default function RepoPickerScreen() {
}
/>
) : (
<FlatList
className="flex-1 bg-background"
data={listItems}
keyExtractor={item => item.key}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
contentContainerStyle={{ paddingBottom: bottom }}
renderItem={({ item }) => {
// Mapped rows inside the shell ScrollView instead of a FlatList: the
// FlatList stretches into the space the formSheet offers and its rows
// painted over the pinned search header while scrolling. The shell
// scroll view starts below the header, so a row can never overlap it.
<View>
{listItems.map(item => {
if (item.kind === 'header') {
return (
<Text className="px-4 pt-4 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<Text
key={item.key}
className="px-4 pt-4 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground"
>
{t(item.titleKey)}
</Text>
);
Expand All @@ -156,6 +154,7 @@ export default function RepoPickerScreen() {
const rowLabel = `${platformName} ${repo.fullName}`;
return (
<Pressable
key={item.key}
className="flex-row items-center gap-3 border-b border-border px-4 py-3 active:bg-secondary will-change-pressable"
onPress={() => {
handleSelect(`${repo.platform}:${repo.fullName}`);
Expand All @@ -182,9 +181,33 @@ export default function RepoPickerScreen() {
) : null}
</Pressable>
);
}}
/>
})}
{renderBitbucketNote()}
</View>
)}
</PickerSheet>
);

/**
* Personal Bitbucket never lists repositories (organization-only), so the
* grouped list would end at GitLab with nothing explaining the gap. The
* note renders once, after the provider sections, whenever the picker has
* rows but no Bitbucket section; a connected org's rows suppress it.
*/
function renderBitbucketNote() {
if (search.trim() || !bridge) {
return null;
}
if (bridge.sections.some(section => section.key === 'bitbucket')) {
return null;
}
return (
<View className="mx-4 mt-3 gap-1 rounded-lg border border-border bg-card p-3">
<Text className="text-sm font-semibold text-foreground">
{t('agentChat.repoPicker.platformBitbucket')}
</Text>
<Text variant="muted">{t('agentChat.newSession.bitbucketOrganizationsOnly')}</Text>
</View>
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export function NewSessionConfigureForm({
contentContainerClassName="flex-grow px-4 pb-8 pt-4"
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets
keyboardDismissMode="on-drag"
>
<NewSessionPrompt
attachments={attachments}
Expand Down
28 changes: 20 additions & 8 deletions apps/mobile/src/components/agents/picker-search.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,18 @@ function hosts(renderer: Awaited<ReturnType<typeof mount>>, type: string) {
return renderer.root.findAll(node => node.type === type);
}

// The model picker hosts its rows in a FlatList and manages its own scrolling
// (PickerSheet scrollable=false); the repository picker renders mapped rows
// inside the shell ScrollView and so always keeps that ScrollView mounted.
describe.each([
{ name: 'model', Component: ModelPickerContent },
{ name: 'repository', Component: RepoPickerScreen },
])('$name picker centering', ({ Component }) => {
{ name: 'model', Component: ModelPickerContent, rowHost: 'FlatList', hasShellScrollView: false },
{
name: 'repository',
Component: RepoPickerScreen,
rowHost: 'Pressable',
hasShellScrollView: true,
},
])('$name picker centering', ({ Component, rowHost, hasShellScrollView }) => {
it('keeps the search input and native header mounted when replacing the list', async () => {
const renderer = await mount(Component);
const input = hosts(renderer, 'TextInput')[0];
Expand All @@ -102,15 +110,17 @@ describe.each([
const group = header.parent;
expect(group?.props.collapsable).toBe(false);
expect(group?.findAll(node => node === input)).toHaveLength(1);
expect(hosts(renderer, 'FlatList')).toHaveLength(1);
expect(hosts(renderer, rowHost).length).toBeGreaterThan(0);
expect(hosts(renderer, 'CenteredState')).toHaveLength(0);

const changeSearch = input.props.onChangeText as (text: string) => void;
act(() => {
changeSearch('no matching choice');
});
expect(hosts(renderer, 'FlatList')).toHaveLength(0);
expect(hosts(renderer, 'ScrollView')).toHaveLength(0);
expect(hosts(renderer, rowHost)).toHaveLength(0);
if (!hasShellScrollView) {
expect(hosts(renderer, 'ScrollView')).toHaveLength(0);
}
expect(hosts(renderer, 'CenteredState')).toHaveLength(1);
expect(hosts(renderer, 'TextInput')[0]).toBe(input);
expect(hosts(renderer, 'SheetHeader')[0]).toBe(header);
Expand All @@ -119,7 +129,7 @@ describe.each([
act(() => {
changeSearch('');
});
expect(hosts(renderer, 'FlatList')).toHaveLength(1);
expect(hosts(renderer, rowHost).length).toBeGreaterThan(0);
expect(hosts(renderer, 'CenteredState')).toHaveLength(0);
expect(hosts(renderer, 'TextInput')[0]).toBe(input);
expect(header.parent).toBe(group);
Expand All @@ -136,7 +146,9 @@ describe.each([
const renderer = await mount(Component);
expect(hosts(renderer, 'CenteredState')).toHaveLength(1);
expect(hosts(renderer, 'FlatList')).toHaveLength(0);
expect(hosts(renderer, 'ScrollView')).toHaveLength(0);
if (!hasShellScrollView) {
expect(hosts(renderer, 'ScrollView')).toHaveLength(0);
}
expect(hosts(renderer, 'TextInput')).toHaveLength(1);
});
});
18 changes: 11 additions & 7 deletions apps/mobile/src/components/agents/repository-branch-selector.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useSyncExternalStore } from 'react';
import { FlatList, Modal, Pressable, View } from 'react-native';
import { Modal, Pressable, ScrollView, View } from 'react-native';
import { useTranslation } from 'react-i18next';

import { Check, ChevronDown } from '@/components/ui/icons';
Expand Down Expand Up @@ -198,12 +198,13 @@
<Text accessibilityRole="header" className="text-center text-base font-semibold">
{t('agentChat.newSession.branchPickerTitle')}
</Text>
<FlatList
data={branches.branches}
keyExtractor={branch => branch}
renderItem={({ item }) => renderBranchRow(item, close)}
showsVerticalScrollIndicator={false}
/>
{/* ScrollView, not FlatList: a FlatList stretches to the space
its container offers, so two branch rows rendered as a mostly
empty sheet. A ScrollView hugs its rows and only scrolls once
the card's max height is reached. */}
<ScrollView showsVerticalScrollIndicator={false}>

Check failure on line 205 in apps/mobile/src/components/agents/repository-branch-selector.tsx

View workflow job for this annotation

GitHub Actions / test

[mobile-mounted] src/components/agents/repository-branch-selector.mounted.test.tsx > RepositoryBranchSelector > invites a choice when the provider names no default branch

Error: [vitest] No "ScrollView" export is defined on the "react-native" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("react-native"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ renderPicker src/components/agents/repository-branch-selector.tsx:205:14 ❯ RepositoryBranchSelector src/components/agents/repository-branch-selector.tsx:71:23 ❯ Object.react_stack_bottom_frame ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:15670:20 ❯ renderWithHooks ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:4863:22 ❯ updateFunctionComponent ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:7018:19 ❯ beginWork ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:8499:18 ❯ runWithFiberInDEV ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:2315:13 ❯ performUnitOfWork ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:13224:22

Check failure on line 205 in apps/mobile/src/components/agents/repository-branch-selector.tsx

View workflow job for this annotation

GitHub Actions / test

[mobile-mounted] src/components/agents/repository-branch-selector.mounted.test.tsx > RepositoryBranchSelector > drops the override when the default branch is picked again

Error: [vitest] No "ScrollView" export is defined on the "react-native" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("react-native"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ renderPicker src/components/agents/repository-branch-selector.tsx:205:14 ❯ RepositoryBranchSelector src/components/agents/repository-branch-selector.tsx:71:23 ❯ Object.react_stack_bottom_frame ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:15670:20 ❯ renderWithHooks ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:4863:22 ❯ updateFunctionComponent ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:7018:19 ❯ beginWork ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:8499:18 ❯ runWithFiberInDEV ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:2315:13 ❯ performUnitOfWork ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:13224:22

Check failure on line 205 in apps/mobile/src/components/agents/repository-branch-selector.tsx

View workflow job for this annotation

GitHub Actions / test

[mobile-mounted] src/components/agents/repository-branch-selector.mounted.test.tsx > RepositoryBranchSelector > records a non-default choice for exactly this repository

Error: [vitest] No "ScrollView" export is defined on the "react-native" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("react-native"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ renderPicker src/components/agents/repository-branch-selector.tsx:205:14 ❯ RepositoryBranchSelector src/components/agents/repository-branch-selector.tsx:71:23 ❯ Object.react_stack_bottom_frame ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:15670:20 ❯ renderWithHooks ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:4863:22 ❯ updateFunctionComponent ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:7018:19 ❯ beginWork ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:8499:18 ❯ runWithFiberInDEV ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:2315:13 ❯ performUnitOfWork ../../node_modules/.pnpm/react-test-renderer@19.2.8_react@19.2.3/node_modules/react-test-renderer/cjs/react-test-renderer.development.js:13224:22
{branches.branches.map(branch => renderBranchRow(branch, close))}
</ScrollView>
<View className="flex-row justify-end">
<Button variant="outline" onPress={close}>
<Text>{t('common.cancel')}</Text>
Expand All @@ -223,6 +224,9 @@
const isDefault = branch === branches.defaultBranch;
return (
<Pressable
// Keyed map rows: the picker lists branches inside a ScrollView, and
// a mapped child without a key makes React warn on every open.
key={branch}
className="flex-row items-center gap-3 rounded-lg px-3 py-2.5 active:bg-secondary"
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,8 @@ export function SessionDetailContent({
if (kept.length === 0) {
return base;
}
return [...base, ...kept].toSorted((a, b) => {
// eslint-disable-next-line unicorn/no-array-sort -- Hermes does not implement Array.prototype.toSorted; the spread already copies so nothing shared is mutated
return [...base, ...kept].sort((a, b) => {
if (a.info.id < b.info.id) {
return -1;
}
Expand Down
9 changes: 8 additions & 1 deletion apps/mobile/src/components/picker-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,14 @@ export function PickerSheet({
{headerContent}
</View>
{scrollable && !expired ? (
<ScrollView contentContainerStyle={{ paddingBottom: bottom + 16 }}>{body}</ScrollView>
// keyboardShouldPersistTaps keeps a first tap on a row working while
// a picker's search field holds the keyboard open.
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingBottom: bottom + 16 }}
>
{body}
</ScrollView>
) : (
body
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,22 @@ vi.mock('@tanstack/react-query', async importOriginal => ({
...(await importOriginal<typeof ReactQuery>()),
useQuery: () => query,
}));
vi.mock('expo-router', () => ({
useRouter: () => ({ back: vi.fn(), push: vi.fn() }),
useLocalSearchParams: () => ({
// Mutable so one suite can hand the composer route a malformed param set
// without re-mocking `expo-router` per test.
const routeParams = vi.hoisted(() => {
const current: Record<string, string> = {
owner: 'org',
repo: 'repo',
number: '1',
path: 'src/a.ts',
line: '1',
side: 'RIGHT',
}),
};
return { current };
});
vi.mock('expo-router', () => ({
useRouter: () => ({ back: vi.fn(), push: vi.fn(), replace: vi.fn() }),
useLocalSearchParams: () => routeParams.current,
}));
vi.mock('react-native', () => ({
View: 'View',
Expand Down Expand Up @@ -116,6 +122,14 @@ beforeEach(() => {
query.isError = true;
query.isLoading = false;
query.error.data.code = 'INTERNAL_SERVER_ERROR';
routeParams.current = {
owner: 'org',
repo: 'repo',
number: '1',
path: 'src/a.ts',
line: '1',
side: 'RIGHT',
};
vi.clearAllMocks();
});

Expand Down Expand Up @@ -205,3 +219,22 @@ describe.each([
unmount();
});
});

describe('composer malformed route', () => {
it('renders the terminal invalid state without the Add-comment chrome', async () => {
// A hand-built or restored composer link with no valid comment target
// failed at the ROUTE, not at the comment: the sheet must not announce
// "Add comment" over a "Page not found" body.
routeParams.current = { owner: 'org', repo: 'repo', number: '1' };
const { renderer, unmount } = await renderWithProviders(
createElement(PrReviewCommentComposerScreen)
);
expect(renderer.root.findAll(node => String(node.type) === 'InvalidRouteState')).toHaveLength(
1
);
expect(renderer.root.findAll(node => String(node.type) === 'PrFormSheetHeader')).toHaveLength(
0
);
unmount();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@ export function useFormSheetKeyboardVisible(): boolean {
export function PrFormSheetHeader(props: { title: string; eyebrow: string; onBack: () => void }) {
return (
<View collapsable={false} className="border-b border-border bg-background">
{/* Left-aligned heading on the back row: `centerTitle` would split the
header into a centered title row and a second row holding a lone
dismiss chevron, which read as a stray control under the title. */}
<ScreenHeader
title={props.title}
eyebrow={props.eyebrow}
centerTitle
onBack={props.onBack}
backIcon="close"
className="pt-3"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,17 @@ export function PrReviewCommentComposerScreen() {
);
}

let body: ReactNode = null;
// A route with no valid comment target is not a broken comment flow: the
// route failed, nothing the composer could post. The "Add comment" chrome
// over a "Page not found" body read as a comment sheet that cannot save, so
// the terminal invalid state renders alone — no misleading title, no lone
// dismiss chevron — and carries its own Go back to the shared inbox.
if (!parsed) {
body = <InvalidRouteState backTo="/(app)/pr-review" />;
} else if (isEdit) {
return <InvalidRouteState backTo="/(app)/pr-review" />;
}

let body: ReactNode = null;
if (isEdit) {
body = null;
} else if (pr.isLoading) {
body = (
Expand Down
Loading
Loading