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
68 changes: 60 additions & 8 deletions apps/mobile/src/app/(app)/agent-chat/new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { View } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { useActionSheet } from '@expo/react-native-action-sheet';
import { useQuery } from '@tanstack/react-query';
import { type RemoteModelOverride } from '@kilocode/cloud-agent-sdk';

import { NewSessionConfigureForm } from '@/components/agents/new-session-configure-form';
import { resolveNewSessionModelView } from '@/components/agents/new-session-model-view';
import { useNewSessionCreator } from '@/components/agents/use-new-session-creator';
import {
NewSessionModelProvider,
Expand All @@ -16,10 +18,12 @@ import { ScreenHeader } from '@/components/screen-header';
import { AGENT_ATTACHMENT_MAX_FILES } from '@/lib/agent-attachments/constants';
import { useAgentAttachmentUpload } from '@/lib/agent-attachments/use-agent-attachment-upload';
import { useAvailableModels } from '@/lib/hooks/use-available-models';
import { useInstanceModelCatalog } from '@/lib/hooks/use-instance-model-catalog';
import { useModelPreferences } from '@/lib/hooks/use-model-preferences';
import { usePersistedAgentModel } from '@/lib/hooks/use-persisted-agent-model';
import { createRemoteModelOverride } from '@/lib/hooks/use-session-model-options';
import { resolveNewSessionSubmitDisabled } from '@/lib/new-session-submit';
import { type InstancePickerInstance } from '@/lib/picker-bridge';
import { type InstancePickerInstance, type ModelPickerSelection } from '@/lib/picker-bridge';
import { shouldShowRunOnSelector } from '@/lib/should-show-run-on-selector';
import { useNewSessionShareRemote } from '@/lib/use-new-session-share-remote';
import { useNewSessionRepos } from '@/lib/use-new-session-repos';
Expand All @@ -46,6 +50,7 @@ function NewSessionScreenBody() {
const shareId: string | undefined = Array.isArray(shareIdParam) ? shareIdParam[0] : shareIdParam;

const [runOnInstance, setRunOnInstance] = useState<InstancePickerInstance | null>(null);
const [remoteOverride, setRemoteOverride] = useState<RemoteModelOverride | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasPrompt, setHasPrompt] = useState(false);
Expand All @@ -60,6 +65,30 @@ function NewSessionScreenBody() {
isError: isModelsError,
refetch: refetchModels,
} = useAvailableModels(organizationId);
const instanceCatalog = useInstanceModelCatalog(runOnInstance?.connectionId ?? null);
const modelView = useMemo(
() =>
resolveNewSessionModelView({
isRemoteTarget: runOnInstance !== null,
catalog: instanceCatalog.catalog,
catalogLoading: instanceCatalog.isLoading,
gatewayModels: models,
gatewayModelsLoading: isLoadingModels,
gatewayModel: model,
gatewayVariant: variant,
remoteOverride,
}),
[
runOnInstance,
instanceCatalog.catalog,
instanceCatalog.isLoading,
models,
isLoadingModels,
model,
variant,
remoteOverride,
]
);
const { setLastSelected: persistServerLastSelected } = useModelPreferences(organizationId);
const { saveModel } = usePersistedAgentModel();
const attachments = useAgentAttachmentUpload({ organizationId });
Expand Down Expand Up @@ -109,16 +138,27 @@ function NewSessionScreenBody() {

const { remoteSpawn, handleRunOnInstanceChange } = useNewSessionShareRemote({
organizationId,
mode,
runOnInstance,
setRunOnInstance,
refetchInstances,
instanceList,
promptRef,
attachments: attachments.attachments,
selection: modelView.spawnSelection,
});

const handleModelSelect = useCallback(
(modelId: string, newVariant: string) => {
(modelId: string, newVariant: string, pickerSelection?: ModelPickerSelection) => {
setRemoteOverride(
pickerSelection ? createRemoteModelOverride(pickerSelection.option, newVariant) : null
);
// A CLI-catalog option id is the opaque `remote-model-N`; its model may
// not exist on the gateway. Never persist either to the gateway
// preference.
if (pickerSelection?.option.overrideSource === 'cli-catalog') {
return;
}
setModel(modelId);
setVariant(newVariant);
saveModel(organizationId, { model: modelId, variant: newVariant });
Expand All @@ -127,6 +167,14 @@ function NewSessionScreenBody() {
[organizationId, saveModel, persistServerLastSelected, setModel, setVariant]
);

const handleRunOnChange = useCallback(
(next: InstancePickerInstance | null) => {
setRemoteOverride(null);
handleRunOnInstanceChange(next);
},
[handleRunOnInstanceChange]
);

function handlePromptChange(text: string) {
promptRef.current = text;
const nextHasPrompt = text.trim().length > 0;
Expand Down Expand Up @@ -170,7 +218,11 @@ function NewSessionScreenBody() {

const isRemoteTargetSelected = runOnInstance !== null;
const isStartDisabled = isRemoteTargetSelected
? remoteSpawn.isSpawningRemote || isSubmitting || attachments.hasFailedAttachments
? remoteSpawn.isSpawningRemote ||
isSubmitting ||
attachments.hasFailedAttachments ||
modelView.isSelectionUnavailable ||
instanceCatalog.isLoading
: resolveNewSessionSubmitDisabled({
attachmentsHasFailed: attachments.hasFailedAttachments,
attachmentsIsUploading: attachments.isUploading,
Expand Down Expand Up @@ -201,11 +253,11 @@ function NewSessionScreenBody() {
attachmentMax={AGENT_ATTACHMENT_MAX_FILES}
isCreating={isCreating}
isModelsError={isModelsError}
isLoadingModels={isLoadingModels}
isLoadingModels={isLoadingModels || (isRemoteTargetSelected && instanceCatalog.isLoading)}
mode={mode}
model={model}
variant={variant}
modelOptions={models}
model={modelView.selectedValue}
variant={modelView.selectedVariant}
modelOptions={modelView.options}
initialPrompt={promptRef.current}
onChangeText={handlePromptChange}
onModeChange={setMode}
Expand All @@ -221,7 +273,7 @@ function NewSessionScreenBody() {
runOnInstance={runOnInstance}
instanceList={instanceList}
isLoadingInstances={isLoadingInstances}
onChangeRunOnInstance={handleRunOnInstanceChange}
onChangeRunOnInstance={handleRunOnChange}
showInstanceDisconnectedNote={remoteSpawn.showInstanceDisconnectedNote}
view={view}
isRetrying={isRetrying}
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/src/components/agents/chat-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { ComposerPasteButton } from '@/components/agents/composer-paste-button';
import { type AgentMode, ModeSelector } from '@/components/agents/mode-selector';
import { ModelSelector } from '@/components/agents/model-selector';
import { type ModelOption } from '@/lib/hooks/use-available-models';
import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';
import { type ModelPickerSelection } from '@/lib/picker-bridge';
import { cn } from '@/lib/utils';

type ChatToolbarOrder = 'mode-first' | 'model-first';
Expand All @@ -13,8 +15,8 @@ type ChatToolbarProps = {
onModeChange: (mode: AgentMode) => void;
model: string;
variant: string;
modelOptions: ModelOption[];
onModelSelect: (modelId: string, variant: string) => void;
modelOptions: (ModelOption | SessionModelOption)[];
onModelSelect: (modelId: string, variant: string, pickerSelection?: ModelPickerSelection) => void;
disabled?: boolean;
isLoadingModels?: boolean;
order?: ChatToolbarOrder;
Expand Down
110 changes: 93 additions & 17 deletions apps/mobile/src/components/agents/continuation-seed.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import { type Part, type StoredMessage, type TextPart } from '@kilocode/cloud-agent-sdk';
import {
type ModelSelection,
type Part,
type StoredMessage,
type TextPart,
} from '@kilocode/cloud-agent-sdk';
import {
type InstanceModelCatalogResult,
type RemoteModelCatalogV1,
} from '@kilocode/cloud-agent-sdk/instance-model-catalog';
import { normalizeAgentMode } from '@/components/agents/mode-options';
import {
buildContinuePrefillParams,
type NewSessionPrefill,
resolvePrefillModel,
resolvePrefillRepo,
} from '@/components/agents/new-session-prefill';
import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';
import {
buildCreateRemoteSessionInput,
type CreateRemoteSessionInput,
} from '@/lib/hooks/remote-instance-spawn-classifier';
import { type InstancePickerInstance } from '@/lib/picker-bridge';

export const CONTINUATION_SEED_MAX_CHARS = 3800;
Expand Down Expand Up @@ -111,26 +125,88 @@ export type ContinuationDestination =
| { kind: 'remote'; instance: InstancePickerInstance };

/**
* Validate a stored model against the current gateway catalog.
* Resolve the stored model + variant of a continued session against the
* target instance's model catalog.
*
* Returns a `ModelSelection` only when the selection is valid on the target:
*
* - The stored model must exist in `options`, the source session's picker
* options. A plain gateway option has no `modelRef`; its `id` is the
* gateway model id, so the selection defaults to the `kilo` provider.
* - A non-empty variant must be offered by the source option; otherwise the
* whole selection is dropped, keeping today's "never silently change a
* variant" behavior.
* - With a catalog, the provider and model must exist in it, and a set
* variant must be offered by that catalog model.
* - Without a catalog (an old CLI, or a CLI whose catalog could not be
* read), only a `kilo` selection is sent; an unvalidated non-Kilo provider
* is omitted rather than guessed.
*
* Returns the original model + variant when present and valid. Returns empty
* strings when the model is absent or the variant is not in its variant list,
* so the caller can omit the model override and let the remote CLI use its
* default.
* Returns `undefined` when the selection must be omitted so the CLI uses its
* own default model.
*/
export function resolveContinueRemoteModel(
model: string,
variant: string,
catalog: { id: string; variants: string[] }[]
): { model: string; variant: string } {
const found = catalog.find(m => m.id === model);
if (!found) {
return { model: '', variant: '' };
export function resolveContinueRemoteSelection(input: {
model: string;
variant: string;
options: SessionModelOption[];
catalog: RemoteModelCatalogV1 | null;
}): ModelSelection | undefined {
const { model, variant, options, catalog } = input;
const option = options.find(o => o.id === model);
if (!option) {
return undefined;
}
if (variant && !found.variants.includes(variant)) {
return { model: '', variant: '' };
if (variant && !option.variants.includes(variant)) {
return undefined;
}
return { model, variant };
const ref = option.modelRef ?? { providerID: 'kilo', modelID: option.id };
if (catalog !== null) {
const catalogModel = catalog.providers
.find(provider => provider.id === ref.providerID)
?.models.find(m => m.id === ref.modelID);
if (!catalogModel || (variant && !catalogModel.variants.includes(variant))) {
return undefined;
}
} else if (ref.providerID !== 'kilo') {
return undefined;
}
return { model: ref, ...(variant ? { variant } : {}) };
}

/**
* Assemble the `create_session` wire input for a continued remote session.
*
* Normalizes the catalog result with the same model-count rule as the
* new-session hook: a parsed catalog counts only when it carries at least one
* model; a catalog with no models is treated as "no catalog". Then resolves
* the stored selection against it and delegates to
* `buildCreateRemoteSessionInput`. Pure so the continue hook keeps no
* catalog logic and the behavior is testable without mounting the hook.
*/
export function buildContinueRemoteSpawnInput(input: {
mode: string;
model: string;
variant: string;
options: SessionModelOption[];
catalogResult: InstanceModelCatalogResult;
organizationId: string | undefined;
}): CreateRemoteSessionInput | undefined {
const catalog =
input.catalogResult.ok &&
input.catalogResult.catalog.providers.some(provider => provider.models.length > 0)
? input.catalogResult.catalog
: null;
const selection = resolveContinueRemoteSelection({
model: input.model,
variant: input.variant,
options: input.options,
catalog,
});
return buildCreateRemoteSessionInput({
mode: input.mode,
selection,
organizationId: input.organizationId,
});
}

export function resolveContinuationDestinations(args: {
Expand Down
Loading