diff --git a/apps/mobile/src/components/agents/model-selector.mounted.test.tsx b/apps/mobile/src/components/agents/model-selector.mounted.test.tsx index 82d6333cc8..ad1b2b044e 100644 --- a/apps/mobile/src/components/agents/model-selector.mounted.test.tsx +++ b/apps/mobile/src/components/agents/model-selector.mounted.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { TestRenderer } from '@/test/renderer'; @@ -7,6 +7,7 @@ import { freeModelDataLabel, freeModelFreeLabel, } from '@/lib/free-model-data-disclosure'; +import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; import { i18n } from '@/i18n'; import { @@ -62,6 +63,27 @@ vi.mock('@/lib/utils', () => ({ cn: (...parts: unknown[]) => parts.filter(Boolean).join(' '), })); +// Locale cases switch the shared instance; put English back so the remaining +// rows keep rendering the English catalog. +afterEach(async () => { + if (i18n.language !== 'en') { + await i18n.changeLanguage('en'); + } +}); + +function gatewayCatalogOption(overrides: Partial = {}): SessionModelOption { + return { + id: 'gateway-model-0', + name: 'Laguna S 2.1 (free)', + displayId: 'laguna/s-2.1', + variants: [], + isPreferred: false, + isFree: true, + showGatewayMetadata: true, + ...overrides, + }; +} + // The trailing icons of a row, in render order. The favorite star must be the // last one so every row's star shares one right-alignment column, and the // selected check sits in the reserved column to its left. @@ -89,6 +111,44 @@ describe('ModelPickerOptionRow BYOK badge', () => { }); }); +describe('ModelPickerOptionRow free badge', () => { + // The gateway catalogue names free models "… (free)" and Kilo's own Auto Free + // model is named for it in every catalog, so the green free badge would print + // a fact the row title already carries. + it('renders no free badge when the displayed name already states it', () => { + const renderer = renderRow(gatewayCatalogOption({ name: 'Laguna S 2.1 (free)' })); + + expect(textStrings(renderer.root)).not.toContain(freeModelFreeLabel()); + }); + + it('still renders the free badge when the name does not state it', () => { + const renderer = renderRow(gatewayCatalogOption({ name: 'Laguna S 2.1' })); + + expect(textStrings(renderer.root)).toContain(freeModelFreeLabel()); + }); + + // The badge must be suppressed from the model's identity, not from matching + // copy: these nine catalogs name the Auto Free model with a free word the + // badge label does not literally contain (ru "Авто Бесплатный" vs + // "Бесплатно"), which was the localized half of the duplication. + it.each(['be', 'bg', 'bs', 'hr', 'mk', 'ru', 'sr', 'ta', 'uk'])( + 'renders no free badge for the localized Auto Free name (%s)', + async locale => { + await i18n.changeLanguage(locale); + const renderer = renderRow( + gatewayCatalogOption({ + id: 'kilo-auto/free', + displayId: 'kilo-auto/free', + name: 'backend name', + }) + ); + + expect(textStrings(renderer.root)).toContain(i18n.t('models.auto.free')); + expect(textStrings(renderer.root)).not.toContain(freeModelFreeLabel()); + } + ); +}); + describe('Auto model labels', () => { // The backend names Kilo's own Auto models in English ("Auto Efficient"), // which no catalog translates; the chip and the picker row must show the diff --git a/apps/mobile/src/components/agents/model-selector.tsx b/apps/mobile/src/components/agents/model-selector.tsx index b3d5d27eb0..350eafd034 100644 --- a/apps/mobile/src/components/agents/model-selector.tsx +++ b/apps/mobile/src/components/agents/model-selector.tsx @@ -15,6 +15,7 @@ import { freeModelDataLabel, freeModelFreeLabel, getFreeModelDataAccessibilityLabel, + modelNameStatesFree, } from '@/lib/free-model-data-disclosure'; import { type ModelOption, thinkingEffortLabel } from '@/lib/hooks/use-available-models'; import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; @@ -254,13 +255,18 @@ export function ModelPickerOptionRow({ const { free, byok, collectsData } = modelSelectorBadges(option); const costLabel = modelPickerCostLabel(option); const name = autoModelLabel(option.displayId, option.name); + // The name may already state the fact ("… (free)", "Auto Free" in every + // locale), so the badge and its accessibility phrase only render when it + // does not. The free Auto model is decided by id too: nine catalogs name it + // with a free word the badge label does not contain. + const showFreeBadge = free && !byok && !modelNameStatesFree(name, option.displayId); const accessibilityLabel = formatList( [ option.provider?.name, name, option.displayId, byok ? BYOK_MODEL_LABEL : undefined, - free && !byok ? freeModelFreeLabel() : undefined, + showFreeBadge ? freeModelFreeLabel() : undefined, collectsData ? freeModelDataLabel() : undefined, costLabel ?? undefined, option.unavailable ? t('agentChat.modelSelector.unavailableState') : undefined, @@ -311,9 +317,9 @@ export function ModelPickerOptionRow({ {t('agentChat.modelSelector.unavailable')} ) : null} - {free || byok || collectsData ? ( + {showFreeBadge || byok || collectsData ? ( - {free && !byok ? ( + {showFreeBadge ? ( {freeModelFreeLabel()} 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 da4e25ca80..c9de951f40 100644 --- a/apps/mobile/src/components/agents/picker-search.mounted.test.tsx +++ b/apps/mobile/src/components/agents/picker-search.mounted.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- The repository and model pickers' search, alignment and centering contracts share one mount harness. */ import { act, createElement, @@ -16,6 +17,13 @@ import { type RepoOption } from '@/lib/picker-bridge'; import { modelPickerSlot, repoPickerSlot, UNFENCED_ROUTE_KEY } from '@/lib/route-registry'; import '@/i18n'; +// Live so a test can flip the interface direction before it mounts; the input +// alignment helper reads `I18nManager.isRTL` when it composes the style. +const i18nManager = vi.hoisted(() => ({ isRTL: false })); +// The one token the picker's search input passes inline; the assertions read +// the same value the mock hands the component. +const theme = vi.hoisted(() => ({ foreground: '#111111' })); + vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); // The model picker renders its rows through FlashList v2; this stub renders // each row through the real `renderItem` so the suite sees the row hosts. @@ -39,13 +47,11 @@ vi.mock('@shopify/flash-list', () => ({ })); vi.mock('react-native', () => ({ FlatList: 'FlatList', + I18nManager: i18nManager, Pressable: 'Pressable', 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() })); @@ -72,7 +78,9 @@ vi.mock('@/components/ui/icons', () => ({ vi.mock('@/components/agents/model-selector', () => ({ ModelPickerOptionRow: 'ModelPickerOptionRow', })); -vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({}) })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ foreground: theme.foreground }), +})); vi.mock('@/lib/hooks/use-model-preferences', () => ({ useModelPreferences: () => ({ favorites: [], addFavorite: vi.fn(), removeFavorite: vi.fn() }), })); @@ -89,6 +97,7 @@ const repo: RepoOption = { platform: 'github', fullName: 'org/repo', isPrivate: beforeEach(() => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + i18nManager.isRTL = false; modelPickerSlot.set(UNFENCED_ROUTE_KEY, { options: [model], currentValue: '', @@ -208,6 +217,60 @@ describe('repository picker search placeholder', () => { }); }); +describe('repository picker query alignment', () => { + function searchInput(renderer: Awaited>) { + const input = hosts(renderer, 'TextInput')[0]; + if (!input) { + throw new Error('Picker search input did not mount'); + } + return input; + } + + it('aligns the typed query to the field start edge in RTL', async () => { + // `textAlign: 'auto'` resolves against the first strong character, so a + // Latin query stays at the left edge while the clear and search controls + // sit at the right, leaving a dead gap between them. + i18nManager.isRTL = true; + const renderer = await mount(RepoPickerScreen); + expect(searchInput(renderer).props.style).toEqual([ + { textAlign: 'right' }, + { color: theme.foreground }, + ]); + }); + + it('leaves the input style to the caller in LTR so English is unchanged', async () => { + i18nManager.isRTL = false; + const renderer = await mount(RepoPickerScreen); + expect(searchInput(renderer).props.style).toEqual({ color: theme.foreground }); + }); +}); + +describe('model picker query alignment', () => { + function searchInput(renderer: Awaited>) { + const input = hosts(renderer, 'TextInput')[0]; + if (!input) { + throw new Error('Picker search input did not mount'); + } + return input; + } + + it('aligns the query and its native placeholder to the field start edge in RTL', async () => { + // `textAlign: 'auto'` resolves against the first strong character, so a + // Latin query and the Arabic placeholder stay at the left edge while the + // clear and search controls sit at the right, leaving a dead gap between + // them. The model picker had no alignment at all before this. + i18nManager.isRTL = true; + const renderer = await mount(ModelPickerContent); + expect(searchInput(renderer).props.style).toEqual([{ textAlign: 'right' }, undefined]); + }); + + it('leaves the input style to the caller in LTR so English is unchanged', async () => { + i18nManager.isRTL = false; + const renderer = await mount(ModelPickerContent); + expect(searchInput(renderer).props.style).toBeUndefined(); + }); +}); + async function mount(Component: () => ReactNode) { const mounted = await renderWithProviders(createElement(Component)); onTestFinished(mounted.unmount); diff --git a/apps/mobile/src/components/language-picker-row.tsx b/apps/mobile/src/components/language-picker-row.tsx index a159b06111..8bded52d68 100644 --- a/apps/mobile/src/components/language-picker-row.tsx +++ b/apps/mobile/src/components/language-picker-row.tsx @@ -5,6 +5,7 @@ import { ChoiceRow } from '@/components/ui/choice-row'; import { Text } from '@/components/ui/text'; import { type LanguagePickerItem } from '@/i18n/language-rows'; import { type LanguagePreference } from '@/lib/hooks/use-language-preference'; +import { cn } from '@/lib/utils'; type LanguagePickerRowProps = Readonly<{ item: LanguagePickerItem; @@ -34,6 +35,14 @@ export function LanguagePickerRow({ const { t } = useTranslation(); const dividerClass = showDivider ? 'border-b-[0.5px] border-hair-soft' : undefined; const textClass = `flex-1 ${isRtl ? 'pl-3' : 'pr-3'}`; + // iOS resolves `textAlign: 'auto'` from the paragraph's first strong + // character, so an Arabic endonym right-aligns itself inside an LTR + // interface while its Latin subtitle stays left — the two lines of one row + // then do not share an edge. Naming the physical start edge in LTR keeps + // them together. RTL needs no counterpart: `@/components/ui/text` names the + // paragraph direction there, and React Native swaps a physical `text-left` + // under an RTL layout, which would float it to the wrong edge. + const alignClass = isRtl ? undefined : 'text-left'; if (item.kind === 'section') { return ( @@ -57,8 +66,10 @@ export function LanguagePickerRow({ }} > - {t('language.deviceLanguage')} - + + {t('language.deviceLanguage')} + + {deviceEndonym} @@ -80,8 +91,8 @@ export function LanguagePickerRow({ a Latin name under a right-aligned row must not jump to the left edge. Unicode bidi already renders each script correctly inside the line. */} - {item.row.endonym} - + {item.row.endonym} + {item.row.englishName} diff --git a/apps/mobile/src/components/language-picker-sheet.mounted.test.tsx b/apps/mobile/src/components/language-picker-sheet.mounted.test.tsx index 2e6c2d17fd..1dfc75ee01 100644 --- a/apps/mobile/src/components/language-picker-sheet.mounted.test.tsx +++ b/apps/mobile/src/components/language-picker-sheet.mounted.test.tsx @@ -540,6 +540,49 @@ describe('LanguagePickerSheet apply', () => { }); }); +describe('LanguagePickerSheet row alignment', () => { + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + i18nManager.isRTL = false; + }); + + function rowLineClassNames(row: TestRenderer.ReactTestInstance): string[] { + return findByType(row, 'Text').map(line => line.props.className ?? ''); + } + + it('pins both lines of a row to the interface start edge in an LTR interface', async () => { + i18nManager.isRTL = false; + const renderer = await mountSheet(vi.fn<() => void>()); + + // `العربية` is the row whose endonym right-aligned itself under LTR; the + // device row carries the same two-line shape. + for (const label of ['العربية', 'Device language']) { + const lines = rowLineClassNames(findChoiceRow(renderer.root, label)); + expect(lines).toHaveLength(2); + for (const className of lines) { + expect(className).toContain('text-left'); + } + } + + renderer.unmount(); + }); + + it('leaves an RTL row to the paragraph direction, never a physical edge', async () => { + i18nManager.isRTL = true; + const renderer = await mountSheet(vi.fn<() => void>()); + + for (const label of ['العربية', 'Device language']) { + const lines = rowLineClassNames(findChoiceRow(renderer.root, label)); + expect(lines).toHaveLength(2); + for (const className of lines) { + expect(className).not.toContain('text-left'); + } + } + + renderer.unmount(); + }); +}); + describe('LanguagePickerSheet search field', () => { beforeEach(() => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/apps/mobile/src/lib/free-model-data-disclosure.test.ts b/apps/mobile/src/lib/free-model-data-disclosure.test.ts index 26a5561384..fb36f33f88 100644 --- a/apps/mobile/src/lib/free-model-data-disclosure.test.ts +++ b/apps/mobile/src/lib/free-model-data-disclosure.test.ts @@ -7,6 +7,7 @@ import { hasUserByokAvailable, isFreeModelOption, mayTrainOnYourPrompts, + modelNameStatesFree, } from './free-model-data-disclosure'; describe('free model data disclosure', () => { @@ -58,6 +59,23 @@ describe('free model data disclosure', () => { expect(hasUserByokAvailable({ id: 'anthropic/claude' })).toBe(false); }); + it('detects a displayed name that already states the model is free', () => { + expect(modelNameStatesFree('Laguna S 2.1 (free)')).toBe(true); + expect(modelNameStatesFree('Nemotron 3 Ultra (free)')).toBe(true); + expect(modelNameStatesFree('Auto Free')).toBe(true); + expect(modelNameStatesFree('Laguna S 2.1')).toBe(false); + expect(modelNameStatesFree('Auto Efficient')).toBe(false); + }); + + it('states free from the free Auto model identity even when the name does not', () => { + // Nine catalogs name the Auto Free model with a free word the free badge's + // own label does not literally contain (ru "Авто Бесплатный" vs + // "Бесплатно"), so the model's identity decides, not a substring of copy. + expect(modelNameStatesFree('Авто Бесплатный', 'kilo-auto/free')).toBe(true); + expect(modelNameStatesFree('Авто Бесплатный', 'kilocode/kilo-auto/free')).toBe(true); + expect(modelNameStatesFree('Auto Efficient', 'kilo-auto/efficient')).toBe(false); + }); + it('adds a data collection phrase to accessibility labels', () => { expect(getFreeModelDataAccessibilityLabel('Kilo Auto')).toBe('Kilo Auto, Data collected'); }); diff --git a/apps/mobile/src/lib/free-model-data-disclosure.ts b/apps/mobile/src/lib/free-model-data-disclosure.ts index 0802a7c18d..cebf7671c6 100644 --- a/apps/mobile/src/lib/free-model-data-disclosure.ts +++ b/apps/mobile/src/lib/free-model-data-disclosure.ts @@ -1,5 +1,7 @@ import { i18n } from '@/i18n'; +import { stripModelPrefix } from './model-id'; + /** An acronym the product keeps in English in every locale. */ export const BYOK_MODEL_LABEL = 'BYOK'; @@ -11,6 +13,36 @@ export function freeModelFreeLabel(): string { return i18n.t('models.free'); } +/** The gateway catalogue's own marker for a free model: `Laguna S 2.1 (free)`. */ +const CATALOG_FREE_MARKER = /\(free\)/i; + +/** Kilo's own Auto Free model: its localized name is a free name in every catalog. */ +const FREE_AUTO_MODEL_ID = 'kilo-auto/free'; + +/** + * Whether the displayed model name already states that the model is free. + * The gateway catalogue names free models "… (free)", and Kilo's own Auto Free + * model is named for it in every catalog, so a free badge beside such a name + * would print the same fact twice on one row. + * + * Nine catalogs name Auto Free with a free word the badge's own label does not + * literally contain (ru "Авто Бесплатный" vs "Бесплатно"), so the model's + * identity decides as well: `displayId` is the model the picker resolved the + * label from, and the free Auto model always states free. + */ +export function modelNameStatesFree(name: string, displayId?: string): boolean { + if (displayId && stripModelPrefix(displayId) === FREE_AUTO_MODEL_ID) { + return true; + } + const freeLabel = freeModelFreeLabel(); + const locale = i18n.language; + return ( + CATALOG_FREE_MARKER.test(name) || + (freeLabel.length > 0 && + name.toLocaleLowerCase(locale).includes(freeLabel.toLocaleLowerCase(locale))) + ); +} + export type ModelDataDisclosure = { id: string; isFree?: boolean;