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
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
180 changes: 180 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,180 @@
/* 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');
});
});
81 changes: 81 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,81 @@
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>
);
}
Loading
Loading