From 6163716a70443406102517296c63627c7be53f21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 3 Jul 2026 14:46:17 +0200 Subject: [PATCH] fix(remote): preserve CLI model for remote sessions --- .../src/app/(app)/agent-chat/model-picker.tsx | 6 +- .../agents/session-detail-content.tsx | 33 +++++++++-- .../agents/use-session-config-sync.ts | 32 +++++++--- apps/mobile/src/lib/model-picker-rows.test.ts | 24 ++++++++ apps/mobile/src/lib/model-picker-rows.ts | 8 ++- apps/mobile/vitest.config.ts | 9 +++ .../components/cloud-agent-next/ChatInput.tsx | 12 +++- .../cloud-agent-next/CloudChatPage.tsx | 51 ++++++++++++---- .../cloud-agent-next/MobileToolbarPopover.tsx | 5 +- .../src/components/shared/ModelCombobox.tsx | 59 ++++++++++++++++++- .../cli-live-transport.test.ts | 16 +++++ apps/web/src/lib/cloud-agent-sdk/cli-model.ts | 8 +++ apps/web/src/lib/cloud-agent-sdk/index.ts | 5 +- .../cloud-agent-sdk/session-manager.test.ts | 17 ++++++ .../lib/cloud-agent-sdk/session-manager.ts | 13 +++- 15 files changed, 265 insertions(+), 33 deletions(-) create mode 100644 apps/web/src/lib/cloud-agent-sdk/cli-model.ts diff --git a/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx index 3ac89e5ff1..fbe26ec861 100644 --- a/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx @@ -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 { @@ -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, @@ -229,7 +231,9 @@ export default function ModelPickerScreen() { > {modelOption.name} - {modelOption.id} + {!isCliModel && ( + {modelOption.id} + )} {free || byok || collectsData ? ( {free && !byok ? ( diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 57d49e7066..0bedbd9e9c 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -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'; @@ -61,6 +63,7 @@ export function SessionDetailContent({ sessionId }: Readonly + isRemote + ? [ + { + id: CLI_MODEL_ID, + name: cliModelLabel(sessionConfig), + variants: [], + isPreferred: false, + }, + ...modelOptions, + ] + : modelOptions, + [isRemote, modelOptions, sessionConfig] + ); const { currentMode, @@ -84,7 +103,12 @@ export function SessionDetailContent({ sessionId }: Readonly { setCurrentModel(modelId); setCurrentVariant(newVariant); diff --git a/apps/mobile/src/components/agents/use-session-config-sync.ts b/apps/mobile/src/components/agents/use-session-config-sync.ts index cc8618babd..2151e66497 100644 --- a/apps/mobile/src/components/agents/use-session-config-sync.ts +++ b/apps/mobile/src/components/agents/use-session-config-sync.ts @@ -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; @@ -14,6 +15,7 @@ type UseSessionConfigSyncOptions = { fetchedData: SessionConfigSnapshot | null; sessionConfig: SessionConfigSnapshot | null | undefined; modelOptions: ModelOption[]; + isRemote: boolean; }; type UseSessionConfigSyncResult = { @@ -33,6 +35,7 @@ export function useSessionConfigSync({ fetchedData, sessionConfig, modelOptions, + isRemote, }: UseSessionConfigSyncOptions): UseSessionConfigSyncResult { const [currentMode, setCurrentMode] = useState(() => normalizeAgentMode(fetchedData?.mode) @@ -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, @@ -65,7 +71,7 @@ export function useSessionConfigSync({ ]); useEffect(() => { - if (currentModel || modelOptions.length === 0 || fetchedData === null) { + if (isRemote || currentModel || modelOptions.length === 0 || fetchedData === null) { return; } const firstModel = modelOptions[0]; @@ -73,7 +79,15 @@ export function useSessionConfigSync({ 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, diff --git a/apps/mobile/src/lib/model-picker-rows.test.ts b/apps/mobile/src/lib/model-picker-rows.test.ts index fc1f5dd9cf..2aef4e5cfd 100644 --- a/apps/mobile/src/lib/model-picker-rows.test.ts +++ b/apps/mobile/src/lib/model-picker-rows.test.ts @@ -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'; @@ -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([ @@ -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' }, + ]); + }); }); diff --git a/apps/mobile/src/lib/model-picker-rows.ts b/apps/mobile/src/lib/model-picker-rows.ts index 863a1d6ef4..4090b41322 100644 --- a/apps/mobile/src/lib/model-picker-rows.ts +++ b/apps/mobile/src/lib/model-picker-rows.ts @@ -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' } @@ -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) { diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index 4f0e22d42d..331062a1e0 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -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: { diff --git a/apps/web/src/components/cloud-agent-next/ChatInput.tsx b/apps/web/src/components/cloud-agent-next/ChatInput.tsx index 7ded90c003..106190a588 100644 --- a/apps/web/src/components/cloud-agent-next/ChatInput.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatInput.tsx @@ -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 */ @@ -103,6 +105,7 @@ export function ChatInput({ mode, model, modelOptions = [], + pinnedModelOption, isLoadingModels = false, onModeChange, onModelChange, @@ -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) @@ -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 (
@@ -503,6 +507,7 @@ export function ChatInput({ onModeChange={onModeChange} model={model} modelOptions={modelOptions} + pinnedModelOption={pinnedModelOption} onModelChange={onModelChange} isLoadingModels={isLoadingModels} variant={variant} @@ -545,6 +550,7 @@ export function ChatInput({ ) : ( uuidv4()); const [workspaceTabs, setWorkspaceTabs] = useState(createWorkspaceTabsState); @@ -240,9 +242,19 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { // -- Organization models -------------------------------------------------- const { modelOptions, isLoadingModels, contextLengthByModelId } = useOrganizationModels(organizationId); + const isRemote = sessionType === 'remote'; + const pinnedModelOption = useMemo( + () => + isRemote ? { id: CLI_MODEL_ID, name: cliModelLabel(sessionConfig), variants: [] } : undefined, + [isRemote, sessionConfig] + ); const contextWindow = resolveContextWindow(contextUsage, contextLengthByModelId); const { availableCommands } = useSlashCommandSets(); + useEffect(() => { + setUseCliModel(sessionType === 'remote'); + }, [sessionType, sessionIdFromParams]); + // -- Sound effects -------------------------------------------------------- const { play: playCelebrationSound, soundEnabled, setSoundEnabled } = useCelebrationSound(); @@ -406,10 +418,16 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { type: 'prompt', prompt, mode: sessionConfig?.mode ?? 'code', - model: agentModelOverrideForSend ?? sessionConfig?.model ?? '', - variant: agentModelOverrideForSend - ? agentVariantOverrideForSend - : (sessionConfig?.variant ?? undefined), + model: + useCliModel && !agentModelOverrideForSend + ? '' + : (agentModelOverrideForSend ?? sessionConfig?.model ?? ''), + variant: + useCliModel && !agentModelOverrideForSend + ? undefined + : agentModelOverrideForSend + ? agentVariantOverrideForSend + : (sessionConfig?.variant ?? undefined), }, attachments: supportsAttachments ? attachments : undefined, }); @@ -421,7 +439,7 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { } return accepted; }, - [manager, scheduleScrollToBottom, sessionConfig, setChatUI, supportsAttachments] + [manager, scheduleScrollToBottom, sessionConfig, setChatUI, supportsAttachments, useCliModel] ); const handleSendSlashCommand = useCallback( @@ -581,6 +599,11 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { const handleModelChange = useCallback( (model: string) => { if (!sessionConfig) return; + if (model === CLI_MODEL_ID) { + setUseCliModel(true); + return; + } + setUseCliModel(false); // Reset variant to first available (typically "none") when switching models if current is invalid const newModelVariants = modelOptions.find(m => m.id === model)?.variants ?? []; const validVariant = @@ -625,6 +648,13 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { ? agentVariantOverride : (sessionConfig?.variant ?? undefined); const displayAvailableVariants = modelPickerLocked ? [] : availableVariants; + const inputModel = modelPickerLocked ? displayModel : useCliModel ? CLI_MODEL_ID : displayModel; + const inputVariant = modelPickerLocked + ? displayVariant + : useCliModel + ? undefined + : displayVariant; + const inputAvailableVariants = modelPickerLocked || useCliModel ? [] : displayAvailableVariants; const placeholder = isLoading ? 'Loading session…' @@ -807,14 +837,15 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { placeholder={placeholder} slashCommands={availableCommands} mode={sessionConfig?.mode as AgentMode | undefined} - model={displayModel} + model={inputModel} modelOptions={modelOptions} + pinnedModelOption={pinnedModelOption} isLoadingModels={isLoadingModels} onModeChange={handleModeChange} onModelChange={handleModelChange} - variant={displayVariant} + variant={inputVariant} onVariantChange={handleVariantChange} - availableVariants={displayAvailableVariants} + availableVariants={inputAvailableVariants} showToolbar={Boolean(sessionIdFromParams)} initialValue={failedPrompt ?? undefined} customModeOptions={customModeOptions} diff --git a/apps/web/src/components/cloud-agent-next/MobileToolbarPopover.tsx b/apps/web/src/components/cloud-agent-next/MobileToolbarPopover.tsx index c4d13ff03f..b4b4dea07e 100644 --- a/apps/web/src/components/cloud-agent-next/MobileToolbarPopover.tsx +++ b/apps/web/src/components/cloud-agent-next/MobileToolbarPopover.tsx @@ -17,6 +17,7 @@ type MobileToolbarPopoverProps = { onModeChange?: (mode: AgentMode) => void; model?: string; modelOptions: ModelOption[]; + pinnedModelOption?: ModelOption; onModelChange?: (model: string) => void; isLoadingModels?: boolean; variant?: string; @@ -38,6 +39,7 @@ export function MobileToolbarPopover({ onModeChange, model, modelOptions, + pinnedModelOption, onModelChange, isLoadingModels, variant, @@ -53,7 +55,7 @@ export function MobileToolbarPopover({ }: MobileToolbarPopoverProps) { const [open, setOpen] = useState(false); - const selectedModel = modelOptions.find(m => m.id === model); + const selectedModel = [pinnedModelOption, ...modelOptions].find(m => m?.id === model); const displayName = selectedModel ? formatShortModelDisplayName(selectedModel.name) : 'Select model'; @@ -99,6 +101,7 @@ export function MobileToolbarPopover({ ) : ( model.id === value); + const selectedModel = [pinnedModel, ...models].find(model => model?.id === value); const isCompact = variant === 'compact'; const showLabel = !isCompact && label; const selectedCollectsData = mayTrainOnYourPrompts(selectedModel); @@ -175,7 +178,7 @@ export function ModelCombobox({ ); } - if (!models || models.length === 0) { + if ((!models || models.length === 0) && !pinnedModel) { if (isCompact) { return (