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
69 changes: 69 additions & 0 deletions apps/mobile/src/components/agents/model-selector-badges.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';

import { modelSelectorBadges } from './model-selector-badges';

function cliCatalogOption(overrides: { hasUserByokAvailable?: boolean } = {}) {
return { id: 'remote-model-0', showGatewayMetadata: false, ...overrides };
}

function gatewayOption(overrides: { hasUserByokAvailable?: boolean; isFree?: boolean } = {}) {
return { id: 'anthropic/claude', showGatewayMetadata: true, ...overrides };
}

describe('modelSelectorBadges', () => {
it('shows BYOK for a CLI-catalog option with user BYOK available', () => {
const badges = modelSelectorBadges(cliCatalogOption({ hasUserByokAvailable: true }));
expect(badges.byok).toBe(true);
expect(badges.free).toBe(false);
expect(badges.collectsData).toBe(false);
});

it('hides BYOK for a CLI-catalog option without the flag', () => {
const badges = modelSelectorBadges(cliCatalogOption());
expect(badges.byok).toBe(false);
expect(badges.free).toBe(false);
expect(badges.collectsData).toBe(false);
});

it('keeps free and data-collection suppressed for CLI-catalog options with the flag', () => {
const badges = modelSelectorBadges({
id: 'remote-model-0',
showGatewayMetadata: false,
isFree: true,
mayTrainOnYourPrompts: true,
hasUserByokAvailable: true,
});
expect(badges.byok).toBe(true);
expect(badges.free).toBe(false);
expect(badges.collectsData).toBe(false);
});

it('keeps BYOK gating for gateway options with the flag', () => {
const badges = modelSelectorBadges(gatewayOption({ hasUserByokAvailable: true }));
expect(badges.byok).toBe(true);
expect(badges.free).toBe(false);
});

it('keeps free gating for gateway options without the BYOK flag', () => {
const badges = modelSelectorBadges(gatewayOption({ isFree: true }));
expect(badges.free).toBe(true);
expect(badges.byok).toBe(false);
});

it('shows no badges for an unavailable option without the flag', () => {
const badges = modelSelectorBadges({
id: 'remote-unavailable-model',
showGatewayMetadata: false,
});
expect(badges.byok).toBe(false);
expect(badges.free).toBe(false);
expect(badges.collectsData).toBe(false);
});

it('shows no badges for an undefined option', () => {
const badges = modelSelectorBadges(undefined);
expect(badges.byok).toBe(false);
expect(badges.free).toBe(false);
expect(badges.collectsData).toBe(false);
});
});
30 changes: 30 additions & 0 deletions apps/mobile/src/components/agents/model-selector-badges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {
hasUserByokAvailable,
isFreeModelOption,
mayTrainOnYourPrompts,
type ModelDataDisclosure,
} from '@/lib/free-model-data-disclosure';

type ModelBadgeOption = ModelDataDisclosure & {
showGatewayMetadata: boolean;
};

/**
* Badge predicates for the model selector pill and picker rows.
* Input contract: a post-normalization SessionModelOption (both call sites
* pass one: ModelSelector maps options through toSessionModelOption in
* model-selector.tsx, and the picker bridge carries SessionModelOption).
* BYOK is per-user account state, not gateway metadata: the CLI passes the
* backend's hasUserByokAvailable through the v1 wire catalog, so the badge
* must render for CLI-catalog options too. Free/data-collection stay gated
* on showGatewayMetadata because CLI-catalog options do not carry Kilo
* gateway pricing or data-policy semantics.
*/
export function modelSelectorBadges(option: ModelBadgeOption | undefined) {
const showGatewayMetadata = option?.showGatewayMetadata === true;
return {
byok: hasUserByokAvailable(option),
free: showGatewayMetadata && isFreeModelOption(option),
collectsData: showGatewayMetadata && mayTrainOnYourPrompts(option),
};
}
118 changes: 118 additions & 0 deletions apps/mobile/src/components/agents/model-selector.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/components/consent/consent-card.mounted.test.tsx) */
import { createElement } from 'react';
import TestRenderer from 'react-test-renderer';
import { describe, expect, it, vi } from 'vitest';

import {
BYOK_MODEL_LABEL,
FREE_MODEL_DATA_LABEL,
FREE_MODEL_FREE_LABEL,
} from '@/lib/free-model-data-disclosure';
import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';

import { ModelPickerOptionRow } from './model-selector';

vi.mock('react-native', () => ({
Pressable: 'Pressable',
ScrollView: 'ScrollView',
View: 'View',
}));
vi.mock('expo-haptics', () => ({
selectionAsync: vi.fn(),
}));
vi.mock('expo-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock('lucide-react-native', () => ({
BookOpenCheck: 'BookOpenCheck',
Brain: 'Brain',
Check: 'Check',
ChevronDown: 'ChevronDown',
Star: 'Star',
}));
vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({
warn: '#9F6612',
mutedForeground: '#6F6A61',
primary: '#4F5A10',
}),
}));
vi.mock('@/lib/hooks/use-available-models', () => ({
thinkingEffortLabel: (variant: string) => variant,
}));
vi.mock('@/lib/picker-bridge', () => ({
setModelPickerBridge: vi.fn(),
}));
vi.mock('@/lib/utils', () => ({
cn: (...parts: unknown[]) => parts.filter(Boolean).join(' '),
}));

function cliCatalogOption(overrides: Partial<SessionModelOption> = {}): SessionModelOption {
return {
id: 'remote-model-0',
name: 'Minimax M2.5',
displayId: 'minimax/minimax-m2.5',
variants: [],
isPreferred: false,
showGatewayMetadata: false,
...overrides,
};
}

function renderRow(option: SessionModelOption): TestRenderer.ReactTestRenderer {
const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
TestRenderer.act(() => {
ref.current = TestRenderer.create(
createElement(ModelPickerOptionRow, {
option,
selected: false,
selectedVariant: '',
isFavorite: false,
onSelectModel: vi.fn<(option: SessionModelOption) => void>(),
onSelectVariant: vi.fn<(variant: string) => void>(),
onToggleFavorite: vi.fn<(option: SessionModelOption) => void>(),
})
);
});
const renderer = ref.current;
if (!renderer) {
throw new Error('renderer was not created');
}
return renderer;
}

function textStrings(root: TestRenderer.ReactTestInstance): string[] {
return root
.findAll(
node =>
typeof node.type === 'string' &&
(node.type as string) === 'Text' &&
typeof node.props.children === 'string'
)
.map(node => node.props.children as string);
}

function countWithAccessibilityLabel(root: TestRenderer.ReactTestInstance, label: string): number {
return root.findAll(node => (node.props.accessibilityLabel as string | undefined) === label)
.length;
}

describe('ModelPickerOptionRow BYOK badge', () => {
it('renders the BYOK badge for a CLI-catalog option with user BYOK available', () => {
const renderer = renderRow(cliCatalogOption({ hasUserByokAvailable: true }));
expect(textStrings(renderer.root)).toContain(BYOK_MODEL_LABEL);
});

it('renders no BYOK badge for a CLI-catalog option without the flag', () => {
const renderer = renderRow(cliCatalogOption());
expect(textStrings(renderer.root)).not.toContain(BYOK_MODEL_LABEL);
});

it('renders no Free or data-collection indicators for a CLI-catalog option', () => {
const renderer = renderRow(cliCatalogOption({ isFree: true, mayTrainOnYourPrompts: true }));
expect(textStrings(renderer.root)).not.toContain(FREE_MODEL_FREE_LABEL);
expect(countWithAccessibilityLabel(renderer.root, FREE_MODEL_DATA_LABEL)).toBe(0);
});
});
13 changes: 4 additions & 9 deletions apps/mobile/src/components/agents/model-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ import {
FREE_MODEL_DATA_LABEL,
FREE_MODEL_FREE_LABEL,
getFreeModelDataAccessibilityLabel,
hasUserByokAvailable,
isFreeModelOption,
mayTrainOnYourPrompts,
} 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 All @@ -27,6 +24,8 @@ import {
} from '@/lib/picker-bridge';
import { cn } from '@/lib/utils';

import { modelSelectorBadges } from './model-selector-badges';

type ModelSelectorProps = {
value: string;
variant: string;
Expand Down Expand Up @@ -143,10 +142,8 @@ export function ModelSelector({
const providerAware = pickerOptions.some(
option => option.modelRef !== undefined || !option.showGatewayMetadata
);
const showGatewayMetadata = selectedModel?.showGatewayMetadata ?? false;
const label = selectedModel?.name ?? (!providerAware && value ? value : 'Model');
const byok = showGatewayMetadata && hasUserByokAvailable(selectedModel);
const collectsData = showGatewayMetadata && mayTrainOnYourPrompts(selectedModel);
const { byok, collectsData } = modelSelectorBadges(selectedModel);
const hasVariants = selectedModel ? selectedModel.variants.length > 1 : false;
const variantLabel = variant ? thinkingEffortLabel(variant) : '';
const compactVariantLabel = variant ? compactThinkingEffortLabel(variant) : '';
Expand Down Expand Up @@ -227,9 +224,7 @@ export function ModelPickerOptionRow({
onToggleFavorite: (option: SessionModelOption) => void;
}>) {
const colors = useThemeColors();
const free = option.showGatewayMetadata && isFreeModelOption(option);
const byok = option.showGatewayMetadata && hasUserByokAvailable(option);
const collectsData = option.showGatewayMetadata && mayTrainOnYourPrompts(option);
const { free, byok, collectsData } = modelSelectorBadges(option);
const costLabel = modelPickerCostLabel(option);
const accessibilityLabel = [
option.provider?.name,
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/free-model-data-disclosure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export const BYOK_MODEL_LABEL = 'BYOK';
export const FREE_MODEL_DATA_LABEL = 'Data collected';
export const FREE_MODEL_FREE_LABEL = 'Free';

type ModelDataDisclosure = {
export type ModelDataDisclosure = {
id: string;
isFree?: boolean;
mayTrainOnYourPrompts?: boolean;
Expand Down
Loading
Loading