Skip to content
Draft
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
9 changes: 9 additions & 0 deletions apps/mobile/src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,15 @@ export default function AppLayout() {
headerShown: false,
}}
/>
<Stack.Screen
name="agent-chat/branch-picker"
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5, fullSheetDetent],
sheetGrabberVisible: true,
headerShown: false,
}}
/>
<Stack.Screen
name="agent-chat/mode-picker"
options={{
Expand Down
182 changes: 182 additions & 0 deletions apps/mobile/src/app/(app)/agent-chat/branch-picker.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/components/agents/attachment-preview-strip.mounted.test.tsx) */
import { createElement } from 'react';
import TestRenderer, { act } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { i18n } from '@/i18n';
import BranchPickerScreen from './branch-picker';
import { type BranchPickerBridge } from '@/lib/picker-bridge';

const router = vi.hoisted(() => ({ back: vi.fn() }));
const slot = vi.hoisted(() => ({ bridge: undefined as BranchPickerBridge | undefined }));

vi.mock('expo-router', () => ({
useRouter: () => router,
}));
vi.mock('react-native', () => ({
Pressable: 'Pressable',
ScrollView: 'ScrollView',
View: 'View',
}));
vi.mock('@/components/picker-sheet', () => ({
// The fake shell renders the header contract (title + both dismiss
// controls) and the rows below it, so a test can assert the header
// controls and the rows in one tree.
PickerSheet: (props: {
title: string;
onDone: () => void;
onCancel?: () => void;
expired?: boolean;
children?: React.ReactNode;
}) =>
createElement(
'PickerSheet',
{
title: props.title,
expired: props.expired === true,
onCancel: props.onCancel,
onDone: props.onDone,
},
props.children
),
}));
vi.mock('@/components/ui/text', async () => {
const React = await import('react');
return { Text: 'Text', TextClassContext: React.createContext<string | undefined>(undefined) };
});
vi.mock('@/components/ui/icons', () => ({ Check: 'Check' }));
vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ primary: '#0a84ff' }),
}));
vi.mock('@/lib/route-registry', () => ({
UNFENCED_ROUTE_KEY: 'unscoped',
useRouteRegistry: vi.fn(),
branchPickerSlot: {
get: () => slot.bridge,
clear: vi.fn(),
},
}));

function texts(renderer: TestRenderer.ReactTestRenderer): string[] {
return renderer.root
.findAllByType('Text' as never)
.flatMap(node => node.children)
.filter((child): child is string => typeof child === 'string');
}

function branchLabel(branch: string): string {
return i18n.t('agentChat.newSession.branchAccessibility', { label: branch });
}

function branchRow(renderer: TestRenderer.ReactTestRenderer, branch: string) {
return renderer.root.findAll(
node =>
node.props.accessibilityLabel === branchLabel(branch) &&
typeof node.props.onPress === 'function'
)[0];
}

/** Fire a node's `onPress`, the way a tap would. */
function press(node: TestRenderer.ReactTestInstance | undefined) {
act(() => {
(node?.props.onPress as (() => void) | undefined)?.();
});
}

/** Mount the screen inside act, so i18n's subscription settles inside it. */
function mount(): TestRenderer.ReactTestRenderer {
const ref: { current: TestRenderer.ReactTestRenderer | null } = { current: null };
act(() => {
ref.current = TestRenderer.create(createElement(BranchPickerScreen));
});
const created = ref.current;
if (created === null) {
throw new Error('the branch picker route did not render');
}
return created;
}

function setBridge(overrides: Partial<BranchPickerBridge> = {}) {
slot.bridge = {
branches: ['main', 'release/2.0'],
defaultBranch: 'main',
selectedBranch: 'main',
onSelect: vi.fn(() => undefined),
...overrides,
};
}

beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
slot.bridge = undefined;
router.back.mockClear();
});

describe('BranchPickerScreen', () => {
it('renders the header shell with both dismiss controls and one row per branch', () => {
setBridge();
const renderer = mount();

const shell = renderer.root.findByType('PickerSheet' as never);
expect(shell.props.title).toBe(i18n.t('agentChat.newSession.branchPickerTitle'));
expect(typeof shell.props.onCancel).toBe('function');
expect(typeof shell.props.onDone).toBe('function');

expect(branchRow(renderer, 'main')).toBeDefined();
expect(branchRow(renderer, 'release/2.0')).toBeDefined();
});

it('marks the provider default row and the selected row', () => {
setBridge({ selectedBranch: 'release/2.0' });
const renderer = mount();

expect(texts(renderer)).toContain(i18n.t('agentChat.newSession.branchDefault'));
expect(branchRow(renderer, 'release/2.0')?.props.accessibilityState).toEqual({
selected: true,
});
expect(branchRow(renderer, 'main')?.props.accessibilityState).toEqual({ selected: false });
});

it('hands the picked branch name back and dismisses', () => {
const onSelect = vi.fn(() => undefined);
setBridge({ onSelect });
const renderer = mount();

press(branchRow(renderer, 'release/2.0'));

expect(onSelect).toHaveBeenCalledWith('release/2.0');
expect(router.back).toHaveBeenCalledTimes(1);
});

it('hands the default branch name back too — the trigger owns the override decision', () => {
const onSelect = vi.fn(() => undefined);
setBridge({ onSelect });
const renderer = mount();

press(branchRow(renderer, 'main'));

expect(onSelect).toHaveBeenCalledWith('main');
});

it('dismisses from the header Cancel without reporting a pick', () => {
const onSelect = vi.fn(() => undefined);
setBridge({ onSelect });
const renderer = mount();

const shell = renderer.root.findByType('PickerSheet' as never);
act(() => {
(shell.props.onCancel as () => void)();
});

expect(router.back).toHaveBeenCalledTimes(1);
expect(onSelect).not.toHaveBeenCalled();
});

it('renders the standard expired shell when the slot is gone', () => {
const renderer = mount();

const shell = renderer.root.findByType('PickerSheet' as never);
expect(shell.props.expired).toBe(true);
expect(texts(renderer)).not.toContain('main');
});
});
85 changes: 85 additions & 0 deletions apps/mobile/src/app/(app)/agent-chat/branch-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useRouter } from 'expo-router';
import { Check } from '@/components/ui/icons';
import { useState } from 'react';
import { Pressable, View } from 'react-native';
import { useTranslation } from 'react-i18next';

import { PickerSheet } from '@/components/picker-sheet';
import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { type BranchPickerBridge } from '@/lib/picker-bridge';
import { branchPickerSlot, UNFENCED_ROUTE_KEY, useRouteRegistry } from '@/lib/route-registry';

/**
* The new-session branch picker, presented as the standard formSheet (same
* shell as the repo/mode/model pickers). The shell's header carries the
* dismiss controls and the rows render below it, so a Cancel control can
* never float over — or drift away from — the branch rows.
*/
export default function BranchPickerScreen() {
const router = useRouter();
const colors = useThemeColors();
const { t } = useTranslation();
useRouteRegistry(UNFENCED_ROUTE_KEY);
// Lazy init reads the slot synchronously on first render — no effect, no
// "Options expired" flash before a later effect populates state.
const [bridge] = useState(() => branchPickerSlot.get(UNFENCED_ROUTE_KEY));

function close() {
router.back();
}

function handleSelect(picker: BranchPickerBridge, branch: string) {
picker.onSelect(branch);
branchPickerSlot.clear(UNFENCED_ROUTE_KEY);
router.back();
}

if (!bridge) {
return (
<PickerSheet
title={t('agentChat.newSession.branchPickerTitle')}
onDone={close}
scrollable={false}
expired
/>
);
}

return (
<PickerSheet
title={t('agentChat.newSession.branchPickerTitle')}
onDone={close}
onCancel={close}
>
<View>
{bridge.branches.map(branch => {
const isSelected = branch === bridge.selectedBranch;
const isDefault = branch === bridge.defaultBranch;
return (
<Pressable
key={branch}
className="flex-row items-center gap-3 border-b border-hair-soft px-4 py-3 active:bg-secondary"
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
accessibilityLabel={t('agentChat.newSession.branchAccessibility', { label: branch })}
onPress={() => {
handleSelect(bridge, branch);
}}
>
<Text className="flex-1 text-base text-foreground" numberOfLines={1}>
{branch}
</Text>
{isDefault ? (
<Text className="text-xs text-muted-foreground">
{t('agentChat.newSession.branchDefault')}
</Text>
) : null}
{isSelected ? <Check size={18} color={colors.primary} /> : null}
</Pressable>
);
})}
</View>
</PickerSheet>
);
}
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')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Org-only Bitbucket note is keyed on a provider section, so recents-only Bitbucket still shows it

buildRepoSections only emits key: 'bitbucket' for non-recent Bitbucket rows. An org whose Bitbucket repos all sit under Recently used has no bitbucket section, so this still renders "Bitbucket is available for organizations only" under real Bitbucket recents. The same predicate fires in an org with zero Bitbucket rows.

Suppress the note when any listed row (including recents) is Bitbucket, or when the session already has an organization.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

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>
);
}
}
Loading