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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { TestRenderer } from '@/test/renderer';

Expand All @@ -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 {
Expand Down Expand Up @@ -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> = {}): 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.
Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions apps/mobile/src/components/agents/model-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -311,9 +317,9 @@ export function ModelPickerOptionRow({
{t('agentChat.modelSelector.unavailable')}
</Text>
) : null}
{free || byok || collectsData ? (
{showFreeBadge || byok || collectsData ? (
<View className="mt-1 flex-row items-center gap-1 self-start">
{free && !byok ? (
{showFreeBadge ? (
<View className="rounded-full bg-good px-2 py-0.5">
<Text className="text-[11px] font-medium text-good-foreground">
{freeModelFreeLabel()}
Expand Down
71 changes: 67 additions & 4 deletions apps/mobile/src/components/agents/picker-search.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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.
Expand All @@ -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() }));
Expand All @@ -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() }),
}));
Expand All @@ -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: '',
Expand Down Expand Up @@ -208,6 +217,60 @@ describe('repository picker search placeholder', () => {
});
});

describe('repository picker query alignment', () => {
function searchInput(renderer: Awaited<ReturnType<typeof mount>>) {
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<ReturnType<typeof mount>>) {
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);
Expand Down
19 changes: 15 additions & 4 deletions apps/mobile/src/components/language-picker-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
Expand All @@ -57,8 +66,10 @@ export function LanguagePickerRow({
}}
>
<View className={textClass}>
<Text className="text-sm font-medium">{t('language.deviceLanguage')}</Text>
<Text variant="muted" className="mt-0.5 text-xs">
<Text className={cn('text-sm font-medium', alignClass)}>
{t('language.deviceLanguage')}
</Text>
<Text variant="muted" className={cn('mt-0.5 text-xs', alignClass)}>
{deviceEndonym}
</Text>
</View>
Expand All @@ -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. */}
<Text className="text-sm font-medium">{item.row.endonym}</Text>
<Text variant="muted" className="mt-0.5 text-xs">
<Text className={cn('text-sm font-medium', alignClass)}>{item.row.endonym}</Text>
<Text variant="muted" className={cn('mt-0.5 text-xs', alignClass)}>
{item.row.englishName}
</Text>
</View>
Expand Down
43 changes: 43 additions & 0 deletions apps/mobile/src/components/language-picker-sheet.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/src/lib/free-model-data-disclosure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
hasUserByokAvailable,
isFreeModelOption,
mayTrainOnYourPrompts,
modelNameStatesFree,
} from './free-model-data-disclosure';

describe('free model data disclosure', () => {
Expand Down Expand Up @@ -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');
});
Expand Down
Loading
Loading