diff --git a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx index ea1126da52..70f53ff8d2 100644 --- a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx @@ -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. */} {search.length === 0 ? ( - + {t('agentChat.repoPicker.searchPlaceholder')} diff --git a/apps/mobile/src/components/agents/agents-tab-badge.test-helpers.ts b/apps/mobile/src/components/agents/agents-tab-badge.test-helpers.ts index ed60ddf841..e77b5bb028 100644 --- a/apps/mobile/src/components/agents/agents-tab-badge.test-helpers.ts +++ b/apps/mobile/src/components/agents/agents-tab-badge.test-helpers.ts @@ -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 }) }, @@ -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' })); diff --git a/apps/mobile/src/components/agents/model-picker-content.mounted.test.tsx b/apps/mobile/src/components/agents/model-picker-content.mounted.test.tsx index 1fb797300a..8c7bd68d55 100644 --- a/apps/mobile/src/components/agents/model-picker-content.mounted.test.tsx +++ b/apps/mobile/src/components/agents/model-picker-content.mounted.test.tsx @@ -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() }), @@ -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(); }); diff --git a/apps/mobile/src/components/agents/model-picker-content.tsx b/apps/mobile/src/components/agents/model-picker-content.tsx index dba89e0944..ad0d738e75 100644 --- a/apps/mobile/src/components/agents/model-picker-content.tsx +++ b/apps/mobile/src/components/agents/model-picker-content.tsx @@ -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 diff --git a/apps/mobile/src/components/agents/picker-search.mounted.test.tsx b/apps/mobile/src/components/agents/picker-search.mounted.test.tsx index c9de951f40..75bac37763 100644 --- a/apps/mobile/src/components/agents/picker-search.mounted.test.tsx +++ b/apps/mobile/src/components/agents/picker-search.mounted.test.tsx @@ -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>) { const input = hosts(renderer, 'TextInput')[0]; @@ -281,6 +352,15 @@ function hosts(renderer: Awaited>, 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[number]): Record { + const style = input.props.style; + if (style === undefined) { + return {}; + } + return Object.assign({}, ...([style] as Record[]).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. diff --git a/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx b/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx index 8e31fe9d90..cf6b36088c 100644 --- a/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx @@ -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'; @@ -64,6 +65,10 @@ const state = vi.hoisted(() => ({ liveQuery: vi.fn<(options: Parameters[0]) => void>(), })); const readFilterRecord = vi.hoisted(() => vi.fn<(storageKey: string) => Promise>()); +// 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, })); @@ -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', @@ -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(); + return { EYEBROW_LATIN_DISPLAY, Text: 'Text', TextClassContext: createContext('') }; }); vi.mock('@/lib/auth/auth-context', () => ({ useAuth: () => state.auth })); vi.mock('@/lib/organization-context', () => ({ @@ -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; @@ -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( @@ -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 () => { @@ -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); }); @@ -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; @@ -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' }]; diff --git a/apps/mobile/src/components/agents/session-list-screen.pull-failure.mounted.test.tsx b/apps/mobile/src/components/agents/session-list-screen.pull-failure.mounted.test.tsx index dbcd80dd29..bed9f36afb 100644 --- a/apps/mobile/src/components/agents/session-list-screen.pull-failure.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.pull-failure.mounted.test.tsx @@ -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 }), diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx index 1f1d9c390d..ca0f2d9a8e 100644 --- a/apps/mobile/src/components/agents/session-list-screen.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.tsx @@ -3,6 +3,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro import { FlashList, type FlashListRef } from '@shopify/flash-list'; import { AppState, + I18nManager, KeyboardAvoidingView, Platform, Pressable, @@ -38,12 +39,13 @@ import { useAgentSessionNavigator } from '@/components/agents/use-agent-session- import { useAgentsListChrome } from '@/components/agents/use-agents-list-chrome'; import { Button } from '@/components/ui/button'; import { Eyebrow } from '@/components/ui/eyebrow'; -import { Text } from '@/components/ui/text'; +import { EYEBROW_LATIN_DISPLAY, Text } from '@/components/ui/text'; import { ScreenHeader } from '@/components/screen-header'; import { AppAwareKeyboardPaddingView } from '@/components/kilo-chat/app-aware-keyboard-padding'; import { getRevisionSnapshot } from '@/lib/session-attention'; import { useEffectiveTabBarHeight } from '@/lib/tab-bar-clearance'; import { type ActiveSession, useLiveAgentSessions } from '@/lib/hooks/use-agent-sessions'; +import { cn } from '@/lib/utils'; import { type Href, useFocusEffect, useNavigation, useRouter, useScrollToTop } from 'expo-router'; @@ -203,11 +205,13 @@ export function AgentSessionListScreen() { const navigateToSession = useAgentSessionNavigator(); - const seeAllLabel = t('home.seeAll'); - // The accepted-empty body names where its history route goes instead of - // reusing the header's `home.seeAll`: a bare See-all on an empty live list - // does not say what it opens, and this is the only route to stored sessions - // there (UX repair, agents-empty). + // The header link opens the stored-session list, so it names that surface: a + // bare `home.seeAll` here claimed a longer live list that is not on screen + // (finding live-now-all). + const historyLabel = t('agents.sessionList.pastSessions'); + // The accepted-empty body names where its history route goes for the same + // reason: a bare See-all on an empty live list does not say what it opens, and + // this is the only route to stored sessions there (UX repair, agents-empty). const viewHistoryLabel = t('agents.sessionList.viewHistory'); // The list controls share the title's row through the header's `inlineActions` // slot, trailing the eyebrow + title heading. The heading keeps @@ -228,8 +232,9 @@ export function AgentSessionListScreen() { // The history route is the app's only route to the stored-session history, // which exists independently of the live list, so it outlives the live // section: the accepted-empty state carries the same control in the body, - // labeled by its destination (`viewHistoryAction`) while the header keeps the - // `See all` copy over a non-empty live list. + // labeled by its destination (`viewHistoryAction`), while the header links to + // the same route with its own destination copy (`historyLabel`) over a + // non-empty live list. const historyControl = (label: string) => ( { @@ -242,17 +247,26 @@ export function AgentSessionListScreen() { testID="agents-view-history" className="min-w-0 shrink justify-center active:opacity-70" > - + {/* An eyebrow-scale label, with the same LTR-only treatment: the + letterspaced capitals break a cursive script's joins, so an RTL + label drops them (home-ar-loading) while the variant and + `SectionHeader` keep the identical class string. */} + {label} ); - const seeAllAction = historyControl(seeAllLabel); const viewHistoryAction = historyControl(viewHistoryLabel); const headerActions = ( {t('home.agentSessions')} - {seeAllAction} + {historyControl(historyLabel)} {query.canFilter ? (