Skip to content
Merged
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
20 changes: 13 additions & 7 deletions apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,13 @@ export default function RepoPickerScreen() {
{/* The placeholder is a single-line Text overlay, not the input's own
placeholder: Android lays the native hint out at the field's width
with no line cap, so copy wider than a narrow field wraps onto a
second line. A tail-ellipsized Text truncates the copy at any width
instead. The shared box draws the value on one line box and centres
it, and both texts share `px-0` so the overlay sits exactly where
the typed text will. */}
second line that the field's fixed height clips against its
border. A tail-ellipsized Text truncates the copy at any width
instead. Both texts share leading-[normal] and a centred text rect
so the overlay sits exactly where the typed text will. The
overlay box is a row, so the copy starts at the field's start
edge — the physical right in RTL — and the hugging Text cannot
drift to the other side of the field. */}
<View className="relative flex-1">
<Input
accessibilityLabel={t('agentChat.repoPicker.searchLabel')}
Expand All @@ -123,17 +126,20 @@ export default function RepoPickerScreen() {
clearButtonMode="while-editing"
returnKeyType="search"
textAlignVertical="center"
className="px-0 text-base text-foreground"
className="h-8 p-0 text-base leading-[normal] text-foreground"
style={{ color: colors.foreground }}
onChangeText={setSearch}
/>
{search.length === 0 ? (
<View className="absolute inset-0 justify-center" pointerEvents="none">
<View
className="absolute inset-0 flex-row items-center justify-start"
pointerEvents="none"
>
<Text
accessible={false}
numberOfLines={1}
ellipsizeMode="tail"
className="text-base leading-[normal] font-normal text-muted-foreground"
className="shrink max-w-full text-base leading-[normal] font-normal text-muted-foreground"
>
{t('agentChat.repoPicker.searchPlaceholder')}
</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ vi.mock('@/components/ui/refresh-control', () => ({ RefreshControl: 'RefreshCont
// the minimal `react-native` mock below.
vi.mock('@shopify/flash-list', () => ({ FlashList: 'FlashList' }));
vi.mock('react-native', () => ({
// The live Agents header reads the interface direction for its action
// label's LTR-only letterspacing (session-list-screen.tsx), so the mock
// needs the export to exist.
I18nManager: { isRTL: false },
Platform: { OS: 'ios' },
AppState: { addEventListener: () => ({ remove: () => undefined }) },
Keyboard: { addListener: () => ({ remove: () => undefined }) },
Expand All @@ -86,7 +90,13 @@ vi.mock('@/components/ui/icons', () => ({
UserRound: 'UserRound',
}));
vi.mock('@/components/ui/blur-bar', () => ({ BlurBar: 'BlurBar' }));
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
// The screen imports the eyebrow's LTR display class constant
// (`@/components/ui/text`); this suite asserts header/count props, not
// class strings, so the mock only needs the export to exist.
vi.mock('@/components/ui/text', () => ({
EYEBROW_LATIN_DISPLAY: 'uppercase tracking-[1.5px]',
Text: 'Text',
}));
vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' }));
vi.mock('@/components/ui/button', () => ({ Button: 'Button' }));
vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,11 @@ vi.mock('@shopify/flash-list', async () => {
});

vi.mock('react-native', () => ({
// `withRtlInputAlignment` reads I18nManager on every render.
I18nManager: { isRTL: false },
Pressable: 'Pressable',
TextInput: 'TextInput',
View: 'View',
// `@/components/ui/input` reads `I18nManager.isRTL` through
// `withRtlInputAlignment` on every render.
I18nManager: { isRTL: false },
}));
vi.mock('expo-router', () => ({
useRouter: () => ({ back: routerBack, push: vi.fn() }),
Expand Down Expand Up @@ -388,14 +387,22 @@ describe('ModelPickerContent deferred search', () => {
// same way.
const clearActions = findByType(renderer.root, 'Button');
expect(clearActions).toHaveLength(1);
/* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */
expect(clearActions[0]?.props.children?.props.children).toBe('Clear search');
/* eslint-enable typescript-eslint/no-unsafe-member-access */
const clearAction = clearActions[0];
if (!clearAction) {
throw new Error('clear search action not found');
}
const buttonProps = clearAction.props as unknown as {
children?: { props?: { children?: ReactNode } };
onPress?: unknown;
};
expect(buttonProps.children?.props?.children).toBe('Clear search');
const onPress = buttonProps.onPress;
if (typeof onPress !== 'function') {
throw new TypeError('clear search action is not pressable');
}

await act(async () => {
/* eslint-disable typescript-eslint/no-unsafe-call, typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */
clearActions[0]?.props.onPress();
/* eslint-enable typescript-eslint/no-unsafe-call, typescript-eslint/no-unsafe-member-access */
(onPress as () => void)();
await Promise.resolve();
});

Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/components/agents/model-picker-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ export function ModelPickerContent() {
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
className="flex-1 px-0 text-base text-foreground"
className="h-8 flex-1 p-0 text-base leading-[normal] text-foreground"
onChangeText={setSearch}
/>
{/* In-field clear, on every platform: `clearButtonMode` is iOS
Expand Down
80 changes: 80 additions & 0 deletions apps/mobile/src/components/agents/picker-search.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,77 @@ describe('repository picker search placeholder', () => {
});
});

// RN 0.86 does not resolve `textAlign: 'auto'` from the layout direction, so
// without a named alignment both search queries stay on the physical left of a
// row that mirrors (the finding). `lib/rtl-text.ts` owns the rule; these tests
// pin that each field follows it, and that the overlay box — not the Text's own
// alignment — places the repo placeholder at the start edge.
const PHYSICAL_ALIGNMENT_CLASSES = new Set([
'text-left',
'text-right',
'text-center',
'text-justify',
]);

describe('picker search alignment follows the interface direction', () => {
const repositoryPlaceholder = 'Search repositories...';
const cases = [
{ name: 'model', Component: ModelPickerContent },
{ name: 'repository', Component: RepoPickerScreen },
];

it.each(cases)(
'right-aligns the $name search input content in a right-to-left interface',
async ({ Component }) => {
i18nManager.isRTL = true;
const renderer = await mount(Component);
const input = hosts(renderer, 'TextInput')[0];
if (!input) {
throw new Error('Picker search input did not mount');
}
expect(inputStyle(input).textAlign).toBe('right');
}
);

it.each(cases)(
'leaves the $name search input content unaligned in a left-to-right interface',
async ({ Component }) => {
i18nManager.isRTL = false;
const renderer = await mount(Component);
const input = hosts(renderer, 'TextInput')[0];
if (!input) {
throw new Error('Picker search input did not mount');
}
expect(inputStyle(input).textAlign).toBeUndefined();
}
);

it.each([false, true])(
'places the repository placeholder at the field start edge as a row, never a physical text alignment (RTL=%s)',
async isRTL => {
i18nManager.isRTL = isRTL;
const renderer = await mount(RepoPickerScreen);
const placeholder = hosts(renderer, 'Text').find(
node => node.props.children === repositoryPlaceholder
);
if (!placeholder) {
throw new Error('Search placeholder overlay did not mount');
}
const wrapper = placeholder.parent;
if (!wrapper) {
throw new Error('Search placeholder overlay has no box');
}
// A `flex-row`'s start edge is the physical right in RTL and the physical
// left in LTR, and the hugging Text cannot float away from it.
const wrapperClasses = (wrapper.props.className as string).split(' ');
expect(wrapperClasses).toEqual(expect.arrayContaining(['flex-row', 'justify-start']));
const textClasses = (placeholder.props.className as string).split(' ');
expect(textClasses).toEqual(expect.arrayContaining(['shrink', 'max-w-full']));
expect(textClasses.filter(name => PHYSICAL_ALIGNMENT_CLASSES.has(name))).toEqual([]);
}
);
});

describe('repository picker query alignment', () => {
function searchInput(renderer: Awaited<ReturnType<typeof mount>>) {
const input = hosts(renderer, 'TextInput')[0];
Expand Down Expand Up @@ -281,6 +352,15 @@ function hosts(renderer: Awaited<ReturnType<typeof mount>>, type: string) {
return renderer.root.findAll(node => node.type === type);
}

/** The style RN flattens an input's array to, or nothing when it carries none. */
function inputStyle(input: ReturnType<typeof hosts>[number]): Record<string, unknown> {
const style = input.props.style;
if (style === undefined) {
return {};
}
return Object.assign({}, ...([style] as Record<string, unknown>[]).flat());
}

// The model picker hosts its rows in a FlashList 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { i18n } from '@/i18n';
import type * as MotionContextModule from '@/lib/a11y/motion-context';
import type * as PlatformFilterModule from './platform-filter-modal';
import type * as TextModule from '@/components/ui/text';
import { AgentSessionListScreen } from './session-list-screen';
import { RowsRefreshControl } from './rows-refresh-control';
import { FAB_MARGIN, FAB_SIZE } from './session-list-content';
Expand Down Expand Up @@ -64,6 +65,10 @@ const state = vi.hoisted(() => ({
liveQuery: vi.fn<(options: Parameters<typeof useLiveAgentSessions>[0]) => void>(),
}));
const readFilterRecord = vi.hoisted(() => vi.fn<(storageKey: string) => Promise<string | null>>());
// Mutable so a case can put the tree in an RTL interface: the header action's
// letterspaced capitals are LTR-only and must drop under RTL, keeping the
// Arabic label's joins.
const i18nManager = vi.hoisted(() => ({ isRTL: false }));
vi.mock('expo-secure-store', () => ({
getItemAsync: readFilterRecord,
}));
Expand Down Expand Up @@ -100,7 +105,7 @@ vi.mock('@shopify/flash-list', () => ({
),
}));
vi.mock('react-native', () => ({
I18nManager: { isRTL: false },
I18nManager: i18nManager,
Platform: state.platform,
Modal: 'Modal',
Pressable: 'Pressable',
Expand Down Expand Up @@ -227,9 +232,13 @@ vi.mock('@/components/agents/use-agent-session-navigator', () => ({
}));
vi.mock('@/components/home/section-header', () => ({ SectionHeader: 'SectionHeader' }));
vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));
vi.mock('@/components/ui/text', async () => {
vi.mock('@rn-primitives/slot', () => ({ Text: 'Slot.Text' }));
vi.mock('@/components/ui/text', async importOriginal => {
const { createContext } = await import('react');
return { Text: 'Text', TextClassContext: createContext('') };
// The real constant, so the header action's LTR letterspacing is asserted
// against the same class string the eyebrow variant uses (it cannot drift).
const { EYEBROW_LATIN_DISPLAY } = await importOriginal<typeof TextModule>();
return { EYEBROW_LATIN_DISPLAY, Text: 'Text', TextClassContext: createContext('') };
});
vi.mock('@/lib/auth/auth-context', () => ({ useAuth: () => state.auth }));
vi.mock('@/lib/organization-context', () => ({
Expand Down Expand Up @@ -492,6 +501,7 @@ beforeEach(() => {
state.fontScale = 1;
state.platform.OS = 'ios';
state.reducedMotion = false;
i18nManager.isRTL = false;
state.topInset = 0;
state.leftInset = 0;
state.rightInset = 0;
Expand Down Expand Up @@ -637,7 +647,7 @@ describe('AgentSessionListScreen live presentation', () => {
expect(text()).not.toContain(i18n.t('home.agentSessions'));
expect(header().props.inlineActions).toBeUndefined();
expect(header().props.eyebrow).toBe('0 LIVE');
// The body carries exactly one history control — the See-all that opens the
// The body carries exactly one history control — the one that opens the
// stored history, which has no other entry point in the app (review
// finding) — so the header cannot quietly grow a second one.
expect(
Expand All @@ -655,15 +665,20 @@ describe('AgentSessionListScreen live presentation', () => {
// The accepted-empty state withholds the header row, so the body's history
// control is the app's only route to stored sessions. A bare See-all does
// not say where it opens — the explorer's objectless See-all, now
// context-free — so the control names its destination instead of reusing
// the header's `home.seeAll` copy.
// context-free — so the control names its destination instead.
const history = action('Session history');
expect(history.props.testID).toBe('agents-view-history');
expect(history.findByType(Text).children).toEqual(['Session history']);
// The non-empty live list keeps the header's shared See-all copy.
// The non-empty live list keeps the history route in the header, under its
// own destination copy instead of this body label.
state.live.activeSessions = [row];
await renderScreen();
expect(action(i18n.t('home.seeAll')).props.testID).toBe('agents-view-history');
expect(action(i18n.t('agents.sessionList.pastSessions')).props.testID).toBe(
'agents-view-history'
);
expect(
nodes('Pressable').filter(node => node.props.accessibilityLabel === 'Session history')
).toHaveLength(0);
});

it('uses shared scrolling and refresh while preserving the large-text creation action', async () => {
Expand Down Expand Up @@ -1253,7 +1268,7 @@ describe('AgentSessionListScreen live presentation', () => {
expect(nodes(type)).toHaveLength(0);
}
expect(headerAction().type).toBe('Pressable');
// The trailing See-all label is an eyebrow-scale label, the same element the
// The trailing history label is an eyebrow-scale label, the same element the
// row's section label uses.
expect(headerAction().props.children.type).toBe(Eyebrow);
});
Expand All @@ -1275,7 +1290,7 @@ describe('AgentSessionListScreen header and admission', () => {
{ ...row, gitUrl: test.filterable ? 'https://github.com/kilo/cloud.git' : undefined },
];
await renderScreen();
const history = action('Összes megtekintése');
const history = action(i18n.t('agents.sessionList.pastSessions'));
const label = history.findByType(Text);
const actionsRow = history.parent;
const actionsWrapper = actionsRow?.parent;
Expand Down Expand Up @@ -1316,10 +1331,47 @@ describe('AgentSessionListScreen header and admission', () => {
expect(
nodes('Pressable').filter(node => node.props.testID === 'agents-open-filters')
).toHaveLength(test.filterable ? 1 : 0);
press('Összes megtekintése');
press(i18n.t('agents.sessionList.pastSessions'));
expect(state.destination).toBe('/(app)/(tabs)/(2_agents)/history');
});

// Finding live-now-all: the header link opened the stored-session list while
// reading "See all", so it named a longer live list that is not on screen. The
// label names the surface it opens instead. The accepted-empty state carries
// its own destination copy in the body, because that state withholds the
// header row (see the accepted-empty case above).
it('names the stored-session list it opens, with the LTR-only display treatment', async () => {
state.live.activeSessions = [row];
await renderScreen();
const historyLabel = i18n.t('agents.sessionList.pastSessions');
const history = action(historyLabel);
// The live list's only history control is the header's: the body's copy
// belongs to the accepted-empty state.
expect(
nodes('Pressable').filter(node => node.props.testID === 'agents-view-history')
).toHaveLength(1);
expect(history.props.testID).toBe('agents-view-history');
expect(history.props.accessibilityLabel).toBe(historyLabel);
expect(history.props.accessibilityLabel).not.toBe(i18n.t('home.seeAll'));
const label = history.findByType(Text);
expect(label.children).toEqual([historyLabel]);
expect(label.props.className).toContain('text-primary');
expect(label.props.className).toContain('uppercase');
expect(label.props.className).toContain('tracking-[1.5px]');
press(historyLabel);
expect(state.destination).toBe('/(app)/(tabs)/(2_agents)/history');

// The letterspaced capitals are LTR-only: an RTL label drops them (the
// gaps break a cursive script's joins) and keeps the mono family, size and
// color, exactly as the eyebrow variant and SectionHeader do.
i18nManager.isRTL = true;
await renderScreen();
const rtlClasses = (action(historyLabel).findByType(Text).props.className as string).split(' ');
expect(rtlClasses).not.toContain('uppercase');
expect(rtlClasses.some(name => name.startsWith('tracking'))).toBe(false);
expect(rtlClasses).toContain('text-primary');
});

it('withholds cached rows and the live count until membership resolves', async () => {
state.organization.organizationId = 'org-1';
state.boundary.orgs = [{ organizationId: 'org-1', organizationName: 'Engineering' }];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,13 @@ vi.mock('@/components/ui/refresh-control', () => ({ RefreshControl: 'RefreshCont
vi.mock('@/components/ui/refresh-progress', () => ({ RefreshProgress: 'RefreshProgress' }));
vi.mock('@/components/ui/text', async () => {
const { createContext } = await import('react');
return { Text: 'Text', TextClassContext: createContext('') };
// The screen imports the eyebrow's LTR display class constant; the mock only
// needs the export to exist (this suite asserts pull-failure states).
return {
EYEBROW_LATIN_DISPLAY: 'uppercase tracking-[1.5px]',
Text: 'Text',
TextClassContext: createContext(''),
};
});
vi.mock('@/lib/auth/auth-context', () => ({
useAuth: () => ({ token: 'account', isLoading: false, isSigningOut: false, authEpoch: 0 }),
Expand Down
Loading
Loading