Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
15 changes: 8 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 @@ -2,11 +2,12 @@ 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, useDeferredValue, useMemo, useRef, useState } from 'react';
import { Pressable, TextInput, View } from 'react-native';
import { Pressable, View } from 'react-native';
import { useTranslation } from 'react-i18next';

import { EmptyState } from '@/components/empty-state';
import { PickerSheet } from '@/components/picker-sheet';
import { Input } from '@/components/ui/input';
import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { REPO_PLATFORM_LABEL_KEYS, type RepoOption } from '@/lib/picker-bridge';
Expand Down Expand Up @@ -110,19 +111,19 @@ 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 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. */}
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. */}
<View className="relative flex-1">
<TextInput
<Input
accessibilityLabel={t('agentChat.repoPicker.searchLabel')}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
returnKeyType="search"
textAlignVertical="center"
className="h-8 p-0 text-base leading-[normal] text-foreground"
className="px-0 text-base text-foreground"
style={{ color: colors.foreground }}
onChangeText={setSearch}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ vi.mock('react-native', () => ({
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
7 changes: 4 additions & 3 deletions apps/mobile/src/components/agents/model-picker-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import * as Haptics from 'expo-haptics';
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
import { AlertCircle, Info, Search, SearchX, X } from '@/components/ui/icons';
import { useCallback, useDeferredValue, useMemo, useRef, useState } from 'react';
import { FlatList, Pressable, TextInput, View } from 'react-native';
import { FlatList, Pressable, type TextInput, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';

import { ModelPickerOptionRow } from '@/components/agents/model-selector';
import { EmptyState } from '@/components/empty-state';
import { PickerSheet } from '@/components/picker-sheet';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Text } from '@/components/ui/text';
import { useModelPreferences } from '@/lib/hooks/use-model-preferences';
import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';
Expand Down Expand Up @@ -181,14 +182,14 @@ export function ModelPickerContent() {
<View>
<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} />
<TextInput
<Input
ref={searchInputRef}
placeholder={t('common.searchModels')}
placeholderTextColor={colors.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
className="h-8 flex-1 p-0 text-base leading-[normal] text-foreground"
className="flex-1 px-0 text-base text-foreground"
onChangeText={setSearch}
/>
{/* In-field clear, on every platform: `clearButtonMode` is iOS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ vi.mock('react-native', () => ({
ScrollView: 'ScrollView',
TextInput: 'TextInput',
View: 'View',
// `@/components/ui/input` reads `I18nManager.isRTL` through
// `withRtlInputAlignment` on every render.
I18nManager: { isRTL: false },
}));
vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) }));
vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn() }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { QuestionCard } from './question-card';

vi.mock('react-native', () => ({
Alert: { alert: vi.fn() },
// `@/components/ui/input` reads `I18nManager.isRTL` through
// `@/lib/rtl-text`; the mock must expose it or the shared box throws.
I18nManager: { isRTL: false },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
TextInput: MockTextInput,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'Activ
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Alert: { alert: vi.fn() },
// `@/components/ui/input` reads `I18nManager.isRTL` through
// `@/lib/rtl-text`; the mock must expose it or the shared box throws.
I18nManager: { isRTL: false },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
TextInput: MockTextInput,
Expand Down
18 changes: 15 additions & 3 deletions apps/mobile/src/components/agents/question-card.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
/* eslint-disable max-lines -- The blocking question card keeps its selection, custom-answer, and CTA presentation together. */
import { useEffect, useRef, useState } from 'react';
import { Alert, Pressable, type Text as RNText, ScrollView, TextInput, View } from 'react-native';
import {
Alert,
Pressable,
type Text as RNText,
ScrollView,
type TextInput,
View,
} from 'react-native';
import { ActivityIndicator } from '@/components/ui/activity-indicator';
import * as Haptics from 'expo-haptics';
import { useTranslation } from 'react-i18next';

import { Button } from '@/components/ui/button';
import { Check } from '@/components/ui/icons';
import { Input } from '@/components/ui/input';
import { Text } from '@/components/ui/text';
import {
applyBlockingCardAppearance,
Expand Down Expand Up @@ -329,7 +337,7 @@ export function QuestionCard({
<Check size={16} color={colors.primaryForeground} />
) : null}
</Pressable>
<TextInput
<Input
ref={node => {
customInputRefs.current[qIndex] = node;
}}
Expand All @@ -350,7 +358,11 @@ export function QuestionCard({
accessibilityLabel={t('agentChat.questionCard.typeYourOwnAnswer')}
accessibilityState={{ disabled: interactionDisabled }}
className={cn(
'rounded-md border px-3 py-2.5 text-sm shadow-sm shadow-[#0000000D]',
// The shared single-line box supplies the touch
// floor (`min-h-[44px]`, never a fixed height);
// dropping `py-2.5` lets the box own the vertical
// geometry, and every other token stays.
'rounded-md border px-3 text-sm shadow-sm shadow-[#0000000D]',
isCustomActive
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background text-foreground dark:border-neutral-700 dark:bg-secondary',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,10 @@ describe('SessionListSearchHeader typed query alignment', () => {
it('aligns the typed query to the field start edge in RTL', async () => {
i18nManager.isRTL = true;
const renderer = await mount(<SessionListSearchHeader {...baseProps} />);
expect(searchInput(renderer).props.style).toEqual({ textAlign: 'right' });
// The shared box applies the RTL content alignment in front of the
// caller's own style (this field passes none), so the field's query follows
// the interface direction.
expect(searchInput(renderer).props.style).toEqual([{ textAlign: 'right' }, undefined]);
});
});

Expand Down Expand Up @@ -235,4 +238,28 @@ describe('SessionListSearchHeader clear control', () => {
box.height + rowVerticalPaddingDp + rowVerticalBorderDp
);
});

it('takes the row height from the shared field floor, not the row floor', async () => {
const renderer = await mount(<SessionListSearchHeader {...baseProps} hasText />);
const rowClassName = fieldRow(renderer).props.className as string;
const rowDeclarations = (await compiledDimensions(rowClassName)) as { minHeight?: number }[];
const row = Object.assign({}, ...rowDeclarations) as { minHeight?: number };
const inputDeclarations = (await compiledDimensions(
searchInput(renderer).props.className as string
)) as { minHeight?: number }[];
const input = Object.assign({}, ...inputDeclarations) as { minHeight?: number };

const rowVerticalPaddingDp = 2 * (await compiledLengthDp(rowClassName, 'paddingBlock'));
const rowVerticalBorderDp = 2 * (await compiledLengthDp(rowClassName, 'borderWidth'));

// The shared field's 44pt floor is taller than the 38pt clear control, so
// the field's floor lays the row out: 44 + 10.5 + 2 = 56.5pt. The row's own
// `min-h-[51px]` is a lower bound the field dominates, not the height the
// row takes.
const fieldFloorDp = input.minHeight ?? 0;
expect(fieldFloorDp).toBe(44);
const laidOutHeightDp = fieldFloorDp + rowVerticalPaddingDp + rowVerticalBorderDp;
expect(laidOutHeightDp).toBe(56.5);
expect(row.minHeight).toBeLessThan(laidOutHeightDp);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ mutedForeground: '#6F6A61' }),
}));
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
// `@/components/ui/input` merges its box with `cn`, which imports `@/i18n`;
// this pure suite does not mount the app's i18n instance.
vi.mock('@/lib/utils', () => ({
cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
}));

function mount(hasText: boolean): TestRenderer.ReactTestRenderer {
let renderer: TestRenderer.ReactTestRenderer | null = null;
Expand Down
41 changes: 20 additions & 21 deletions apps/mobile/src/components/agents/session-list-search-header.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import { Search, X } from '@/components/ui/icons';
import { type RefObject, useMemo } from 'react';
import { I18nManager, Pressable, TextInput, View } from 'react-native';
import { Pressable, type TextInput, View } from 'react-native';
import { ActivityIndicator } from '@/components/ui/activity-indicator';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';

import { Input } from '@/components/ui/input';
import { COMPACT_CONTROL_HIT_SLOP_DP } from '@/lib/a11y/tap-target';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';

// In RTL the field's start edge is its physical right; TextInput maps
// textAlign to a physical gravity (unlike Text), so it must be set explicitly.
const SEARCH_RTL = { textAlign: 'right' } as const;

// The row's `gap-2` compiles to 7pt, not 8pt: NativeWind v5 fixes 1rem at
// 14pt. The clear control's left slop is capped at that gap, so its touch
// region meets the input's instead of covering it — the same cap
Expand Down Expand Up @@ -50,7 +47,6 @@ export function SessionListSearchHeader({
}: Readonly<SessionListSearchHeaderProps>) {
const colors = useThemeColors();
const { t } = useTranslation();
const isRtl = I18nManager.isRTL;
// The landscape side insets keep the field's rounded border and left tap
// area clear of the sensor housing; portrait insets are 0, keeping the
// fixed 22px margin unchanged.
Expand All @@ -61,14 +57,18 @@ export function SessionListSearchHeader({
);
return (
<View>
{/* One `min-height` floor, not two: `min-h-[51px]` is the height the row
must already hold for the clear control's 38pt box plus the field's
own 10.5pt vertical padding (`py-1.5` is 0.375rem at the app's 14pt
rem) and its 2pt vertical `border` (1pt a side, inside the border box
React Native lays out), so the first keystroke cannot grow the row and
shift the list below. A second `min-h-*` class would set the same
property, leaving which floor wins to the order Tailwind emits its
rules rather than to this intent. */}
{/* The row's height comes from the shared field box, not from this row:
the field's 44pt floor plus this row's 10.5pt vertical padding
(`py-1.5` is 0.375rem at the app's 14pt rem) and its 2pt vertical
`border` (1pt a side, inside the border box React Native lays out) is
56.5pt, the same whether the clear control is showing or not, so the
first keystroke cannot grow the row and shift the list below. The
row's own `min-h-[51px]` (the clear control's 38pt box plus that same
padding and border, 50.5pt) stays as a lower bound the field's floor
dominates, so the row never shrinks below the control's own box. A
second `min-h-*` class would set the same property, leaving which
floor wins to the order Tailwind emits its rules rather than to this
intent. */}
<View
style={fieldMargins}
className="my-2 min-h-[51px] flex-row items-center gap-2 rounded-[10px] border border-border bg-card px-4 py-1.5"
Expand All @@ -85,16 +85,15 @@ export function SessionListSearchHeader({
<Search size={18} color={colors.mutedForeground} />
)}
</View>
<TextInput
<Input
key={inputKey}
ref={inputRef}
accessibilityLabel={t('agents.search.searchSessions')}
// Height comes from `min-h`, never `py`: iOS insets the already-centered
// text rect by the padding and draws the placeholder low.
className="min-h-[26px] flex-1 text-[15px] leading-[normal] text-foreground"
// textAlign is applied inline, not via a class: NativeWind maps it
// to a native prop for TextInput and crashes on it in this version.
style={isRtl ? SEARCH_RTL : undefined}
// The shared box supplies the height (`min-h-[44px]`, never `py`: iOS
// insets the already-centered text rect by padding and draws the
// placeholder low) and the RTL content alignment, so this field keeps
// only its own size and text size.
className="flex-1 text-[15px] text-foreground"
// One line, always: at a narrow width with a large font scale the
// placeholder wrapped inside the field and the field grew with it.
numberOfLines={1}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, View } from 'react-native';
import { View } from 'react-native';
import { ActivityIndicator } from '@/components/ui/activity-indicator';
import { toast } from 'sonner-native';

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Text } from '@/components/ui/text';
import { useConnectBitbucket } from '@/lib/hooks/use-code-reviewer';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
Expand Down Expand Up @@ -40,8 +41,11 @@ export function BitbucketConnectForm({ scope }: Readonly<{ scope: string }>) {
<Text className="text-center text-xs text-muted-foreground">
{t('codeReviewer.bitbucketConnect.scopes')}
</Text>
<TextInput
className="h-12 rounded-md border border-input bg-background px-3 text-sm leading-[normal] text-foreground"
<Input
// The shared single-line box supplies the touch floor
// (`min-h-[44px]`, never a fixed height or `py-*`); the field keeps
// its own chrome, horizontal inset and line box.
className="rounded-md border border-input bg-background px-3 text-sm leading-[normal] text-foreground"
placeholder={t('codeReviewer.bitbucketConnect.tokenPlaceholder')}
placeholderTextColor={colors.mutedForeground}
autoCapitalize="none"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ vi.mock('react-native', () => ({
},
},
Dimensions: { get: () => ({ height: 900 }) },
// `@/components/ui/input` reads `I18nManager.isRTL` through
// `@/lib/rtl-text`; the mock must expose it or the shared box throws.
I18nManager: { isRTL: false },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
TextInput: 'TextInput',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { Button } from '@/components/ui/button';
import { formFieldA11y } from '@/components/ui/form-field-a11y';
import { Input } from '@/components/ui/input';
import { RadioGroup, radioItemA11y } from '@/components/ui/radio-group';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
Expand Down Expand Up @@ -251,9 +252,12 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) {
<Text variant="small" className="uppercase tracking-wide text-muted-foreground">
{t('codeReviewer.manualReview.pullRequestUrl')}
</Text>
<TextInput
<Input
key={platform}
className="h-12 rounded-md border border-input bg-background px-3 text-sm leading-[normal] text-foreground"
// The shared single-line box supplies the touch floor
// (`min-h-[44px]`, never a fixed height); the field keeps its
// own chrome, horizontal inset and line box.
className="rounded-md border border-input bg-background px-3 text-sm leading-[normal] text-foreground"
placeholder={URL_PLACEHOLDER[platform]}
placeholderTextColor={colors.mutedForeground}
autoCapitalize="none"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,10 @@ describe('LanguagePickerSheet search field', () => {
if (!input) {
throw new Error('language search input not found');
}
// The field is the shared single-line `Input`, which renders the TextInput
// itself, so the pill is the input's wrapper row: walk up to it rather than
// stopping at the input's own node. The shared box is what keeps the
// placeholder and the value in one box.
const field = findFieldContainer(input);

expect((field.props.className as string).split(/\s+/)).toEqual(
Expand Down
Loading
Loading