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
6 changes: 5 additions & 1 deletion apps/mobile/src/app/(app)/agent-chat/model-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { BookOpenCheck, Check, Search } from 'lucide-react-native';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FlatList, Pressable, ScrollView, TextInput, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { CLI_MODEL_ID } from 'cloud-agent-sdk/cli-model';

import { Text } from '@/components/ui/text';
import {
Expand Down Expand Up @@ -207,6 +208,7 @@ export default function ModelPickerScreen() {
const byok = hasUserByokAvailable(modelOption);
const collectsData = mayTrainOnYourPrompts(modelOption);
const hasVariants = modelOption.variants.length > 1;
const isCliModel = modelOption.id === CLI_MODEL_ID;
const accessibilityLabel = [
modelOption.name,
byok ? BYOK_MODEL_LABEL : undefined,
Expand All @@ -229,7 +231,9 @@ export default function ModelPickerScreen() {
>
<View className="flex-1">
<Text className="text-base text-foreground">{modelOption.name}</Text>
<Text className="text-xs text-muted-foreground">{modelOption.id}</Text>
{!isCliModel && (
<Text className="text-xs text-muted-foreground">{modelOption.id}</Text>
)}
{free || byok || collectsData ? (
<View className="mt-1 flex-row items-center gap-1 self-start">
{free && !byok ? (
Expand Down
33 changes: 29 additions & 4 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/* eslint-disable max-lines */
import { type CloudStatus, type KiloSessionId, type StoredMessage } from 'cloud-agent-sdk';
import { CLI_MODEL_ID, cliModelLabel } from 'cloud-agent-sdk/cli-model';
import { useAtomValue } from 'jotai';
import { useCallback, useEffect, useMemo } from 'react';
import { ActivityIndicator, FlatList, KeyboardAvoidingView, Platform, View } from 'react-native';
Expand Down Expand Up @@ -61,6 +63,7 @@ export function SessionDetailContent({ sessionId }: Readonly<SessionDetailConten
const totalCost = useAtomValue(manager.atoms.totalCost);
const getChildMessages = useAtomValue(manager.atoms.childMessages);
const pendingMessages = useAtomValue(manager.atoms.pendingMessages);
const sessionType = useAtomValue(manager.atoms.sessionType);

const { isConnected } = useAppLifecycle();
const { bottom } = useSafeAreaInsets();
Expand All @@ -76,6 +79,22 @@ export function SessionDetailContent({ sessionId }: Readonly<SessionDetailConten
const organizationId = fetchedData?.organizationId ?? undefined;

const { models: modelOptions } = useAvailableModels(organizationId);
const isRemote = sessionType === 'remote';
const composerModelOptions = useMemo(
() =>
isRemote
? [
{
id: CLI_MODEL_ID,
name: cliModelLabel(sessionConfig),
variants: [],
isPreferred: false,
},
...modelOptions,
]
: modelOptions,
[isRemote, modelOptions, sessionConfig]
);

const {
currentMode,
Expand All @@ -84,7 +103,12 @@ export function SessionDetailContent({ sessionId }: Readonly<SessionDetailConten
setCurrentMode,
setCurrentModel,
setCurrentVariant,
} = useSessionConfigSync({ fetchedData, sessionConfig, modelOptions });
} = useSessionConfigSync({
fetchedData,
sessionConfig,
modelOptions: composerModelOptions,
isRemote,
});

const {
flatListRef,
Expand Down Expand Up @@ -168,13 +192,14 @@ export function SessionDetailContent({ sessionId }: Readonly<SessionDetailConten
return;
}
try {
const isCliModel = currentModel === CLI_MODEL_ID;
await manager.send({
payload: {
type: 'prompt',
prompt: text,
mode: currentMode,
model: currentModel,
variant: currentVariant || undefined,
model: isCliModel ? '' : currentModel,
variant: isCliModel ? undefined : currentVariant || undefined,
},
});
} catch {
Expand Down Expand Up @@ -259,7 +284,7 @@ export function SessionDetailContent({ sessionId }: Readonly<SessionDetailConten
onModeChange={setCurrentMode}
model={currentModel}
variant={currentVariant}
modelOptions={modelOptions}
modelOptions={composerModelOptions}
onModelSelect={(modelId, newVariant) => {
setCurrentModel(modelId);
setCurrentVariant(newVariant);
Expand Down
32 changes: 23 additions & 9 deletions apps/mobile/src/components/agents/use-session-config-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { normalizeAgentMode } from '@/components/agents/mode-options';
import { type AgentMode } from '@/components/agents/mode-selector';
import { type ModelOption } from '@/lib/hooks/use-available-models';
import { CLI_MODEL_ID } from 'cloud-agent-sdk/cli-model';

type SessionConfigSnapshot = {
mode?: string | null;
Expand All @@ -14,6 +15,7 @@ type UseSessionConfigSyncOptions = {
fetchedData: SessionConfigSnapshot | null;
sessionConfig: SessionConfigSnapshot | null | undefined;
modelOptions: ModelOption[];
isRemote: boolean;
};

type UseSessionConfigSyncResult = {
Expand All @@ -33,6 +35,7 @@ export function useSessionConfigSync({
fetchedData,
sessionConfig,
modelOptions,
isRemote,
}: UseSessionConfigSyncOptions): UseSessionConfigSyncResult {
const [currentMode, setCurrentMode] = useState<AgentMode>(() =>
normalizeAgentMode(fetchedData?.mode)
Expand All @@ -46,16 +49,19 @@ export function useSessionConfigSync({
setCurrentMode(normalizeAgentMode(mode));
}

const model = sessionConfig?.model ?? fetchedData?.model;
if (model) {
setCurrentModel(model);
}
if (!isRemote) {
const model = sessionConfig?.model ?? fetchedData?.model;
if (model) {
setCurrentModel(model);
}

const variant = sessionConfig?.variant ?? fetchedData?.variant;
if (variant) {
setCurrentVariant(variant);
const variant = sessionConfig?.variant ?? fetchedData?.variant;
if (variant) {
setCurrentVariant(variant);
}
}
}, [
isRemote,
sessionConfig?.mode,
sessionConfig?.model,
sessionConfig?.variant,
Expand All @@ -65,15 +71,23 @@ export function useSessionConfigSync({
]);

useEffect(() => {
if (currentModel || modelOptions.length === 0 || fetchedData === null) {
if (isRemote || currentModel || modelOptions.length === 0 || fetchedData === null) {
return;
}
const firstModel = modelOptions[0];
if (firstModel) {
setCurrentModel(firstModel.id);
setCurrentVariant(firstModel.variants[0] ?? '');
}
}, [currentModel, modelOptions, fetchedData]);
}, [isRemote, currentModel, modelOptions, fetchedData]);

useEffect(() => {
if (!isRemote) {
return;
}
setCurrentModel(CLI_MODEL_ID);
setCurrentVariant('');
}, [isRemote]);

return {
currentMode,
Expand Down
24 changes: 24 additions & 0 deletions apps/mobile/src/lib/model-picker-rows.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';

import { type ModelOption } from '@/lib/hooks/use-available-models';
import { CLI_MODEL_ID } from 'cloud-agent-sdk/cli-model';

import { buildModelPickerRows } from './model-picker-rows';

Expand All @@ -19,6 +20,13 @@ const models: ModelOption[] = [
},
];

const cliModel: ModelOption = {
id: CLI_MODEL_ID,
name: 'CLI model — anthropic/claude-sonnet-4',
variants: [],
isPreferred: false,
};

describe('buildModelPickerRows', () => {
it('groups preferred models before all other models', () => {
expect(buildModelPickerRows({ models, search: '' })).toEqual([
Expand All @@ -42,4 +50,20 @@ describe('buildModelPickerRows', () => {
{ key: 'model:openai/gpt-5', model: models[1], type: 'model' },
]);
});

it('keeps the CLI model row first before section headers', () => {
expect(buildModelPickerRows({ models: [cliModel, ...models], search: '' })).toEqual([
{ key: `model:${CLI_MODEL_ID}`, model: cliModel, type: 'model' },
{ key: 'recommended', title: 'RECOMMENDED', type: 'header' },
{ key: 'model:anthropic/claude-sonnet-4', model: models[0], type: 'model' },
{ key: 'all', title: 'ALL MODELS', type: 'header' },
{ key: 'model:openai/gpt-5', model: models[1], type: 'model' },
]);
});

it('filters the CLI model row by name', () => {
expect(buildModelPickerRows({ models: [cliModel, ...models], search: 'CLI model' })).toEqual([
{ key: `model:${CLI_MODEL_ID}`, model: cliModel, type: 'model' },
]);
});
});
8 changes: 7 additions & 1 deletion apps/mobile/src/lib/model-picker-rows.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type ModelOption } from '@/lib/hooks/use-available-models';
import { CLI_MODEL_ID } from 'cloud-agent-sdk/cli-model';

export type ModelPickerRow =
| { key: string; title: string; type: 'header' }
Expand All @@ -17,9 +18,14 @@ export function buildModelPickerRows({
);

const recommended = filtered.filter(m => m.isPreferred);
const all = filtered.filter(m => !m.isPreferred);
const cliModel = filtered.find(m => m.id === CLI_MODEL_ID);
const all = filtered.filter(m => !m.isPreferred && m.id !== CLI_MODEL_ID);
const result: ModelPickerRow[] = [];

if (cliModel) {
result.push({ key: `model:${cliModel.id}`, model: cliModel, type: 'model' });
}

if (recommended.length > 0) {
result.push({ key: 'recommended', title: 'RECOMMENDED', type: 'header' });
for (const modelOption of recommended) {
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'cloud-agent-sdk/message-id': fileURLToPath(
new URL('../../apps/web/src/lib/cloud-agent-sdk/message-id.ts', import.meta.url)
),
'cloud-agent-sdk/cli-model': fileURLToPath(
new URL('../../apps/web/src/lib/cloud-agent-sdk/cli-model.ts', import.meta.url)
),
'cloud-agent-sdk': fileURLToPath(
new URL('../../apps/web/src/lib/cloud-agent-sdk/index.ts', import.meta.url)
),
},
},
test: {
Expand Down
12 changes: 9 additions & 3 deletions apps/web/src/components/cloud-agent-next/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ type ChatInputProps = {
model?: string;
/** Available model options for the toolbar */
modelOptions?: ModelOption[];
/** Optional model option pinned above regular gateway models. */
pinnedModelOption?: ModelOption;
/** Whether models are loading */
isLoadingModels?: boolean;
/** Callback when mode changes */
Expand Down Expand Up @@ -103,6 +105,7 @@ export function ChatInput({
mode,
model,
modelOptions = [],
pinnedModelOption,
isLoadingModels = false,
onModeChange,
onModelChange,
Expand Down Expand Up @@ -157,8 +160,8 @@ export function ChatInput({
// back to the raw id when the model isn't in the org's allowed list (e.g. an
// agent pinned a model that was later restricted).
const lockedModelOption = useMemo(
() => modelOptions.find(m => m.id === model),
[modelOptions, model]
() => [pinnedModelOption, ...modelOptions].find(m => m?.id === model),
[pinnedModelOption, modelOptions, model]
);
const lockedModelLabel = lockedModelOption
? formatShortModelDisplayName(lockedModelOption.name)
Expand Down Expand Up @@ -361,7 +364,8 @@ export function ChatInput({
: undefined;

// Check if toolbar should be rendered (has callbacks and options)
const hasToolbar = showToolbar && onModeChange && onModelChange && modelOptions.length > 0;
const hasToolbar =
showToolbar && onModeChange && onModelChange && (modelOptions.length > 0 || pinnedModelOption);

return (
<div className="px-[max(1rem,calc(50%_-_27rem))] py-3 md:py-4">
Expand Down Expand Up @@ -503,6 +507,7 @@ export function ChatInput({
onModeChange={onModeChange}
model={model}
modelOptions={modelOptions}
pinnedModelOption={pinnedModelOption}
onModelChange={onModelChange}
isLoadingModels={isLoadingModels}
variant={variant}
Expand Down Expand Up @@ -545,6 +550,7 @@ export function ChatInput({
) : (
<ModelCombobox
models={modelOptions}
pinnedModel={pinnedModelOption}
value={model}
onValueChange={onModelChange}
variant="compact"
Expand Down
Loading