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
97 changes: 71 additions & 26 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ import {
ThreadId,
} from "@t3tools/contracts";
import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
import {
buildProviderOptionSelectionsFromDescriptors,
createModelSelection,
getProviderOptionDescriptors,
resolveConfiguredProviderOptionDefaults,
resolveConfiguredRuntimeMode,
} from "@t3tools/shared/model";
import {
isDefaultThreadEnvModeSettled,
resolveDefaultThreadEnvMode,
Expand All @@ -32,7 +39,8 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import {
buildModelOptions,
groupByProvider,
resolveDefaultableModelSelection,
resolveNewTaskModelSelection,
resolveNewTaskRuntimeMode,
resolveSelectableModelSelection,
} from "../../lib/modelOptions";
import { scopedProjectKey } from "../../lib/scopedEntities";
Expand Down Expand Up @@ -400,39 +408,32 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
draftStartFromOrigin ??
selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ??
true;
const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
const interactionMode = planModeEnabled
? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE)
: DEFAULT_PROVIDER_INTERACTION_MODE;

// Stored selections only count while their provider is usable on the
// server; otherwise the server's default model wins instead of silently
// targeting a disabled provider. The draft selection is an explicit pick
// and passes through as-is; the project default (last used, possibly from
// desktop) is implicit and additionally never resolves to a legacy model.
const draftModelSelection = resolveSelectableModelSelection(
selectedEnvironmentServerConfig,
selectedProjectDraft.modelSelection ?? null,
);
const projectDefaultModelSelection = resolveDefaultableModelSelection(
selectedEnvironmentServerConfig,
selectedProject?.defaultModelSelection ?? null,
// and passes through as-is.
const draftModelSelection = selectedProjectDraft.modelSelection ?? null;
const selectedModel = useMemo(
() =>
resolveNewTaskModelSelection({
config: selectedEnvironmentServerConfig,
draftSelection: draftModelSelection,
}),
[selectedEnvironmentServerConfig, draftModelSelection],
);
const modelOptions = useMemo(
() =>
buildModelOptions(
selectedEnvironmentServerConfig,
draftModelSelection ?? projectDefaultModelSelection,
),
[selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection],
() => buildModelOptions(selectedEnvironmentServerConfig, selectedModel),
[selectedEnvironmentServerConfig, selectedModel],
);

const selectedModel =
draftModelSelection ??
projectDefaultModelSelection ??
modelOptions.find((option) => option.isDefault)?.selection ??
modelOptions[0]?.selection ??
null;
const runtimeMode = resolveNewTaskRuntimeMode({
config: selectedEnvironmentServerConfig,
selectedModel,
draftRuntimeMode: selectedProjectDraft.runtimeMode,
});
const selectedModelKey = selectedModel
? `${selectedModel.instanceId}:${selectedModel.model}`
: null;
Expand Down Expand Up @@ -462,11 +463,55 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
if (!option) {
return;
}
const provider = selectedEnvironmentServerConfig?.providers.find(
(candidate) => candidate.instanceId === option.selection.instanceId,
);
const model = provider?.models.find((candidate) => candidate.slug === option.selection.model);
const existingOptions =
options ??
(selectedProjectDraft.modelSelection?.instanceId === option.selection.instanceId
? selectedProjectDraft.modelSelection.options
: undefined);
const descriptors = model?.capabilities?.optionDescriptors ?? [];
const seededOptions =
existingOptions !== undefined
? buildProviderOptionSelectionsFromDescriptors(
getProviderOptionDescriptors({
caps: { optionDescriptors: descriptors },
selections: existingOptions,
}),
)
: resolveConfiguredProviderOptionDefaults({
settings: selectedEnvironmentServerConfig?.settings ?? {
providerNewThreadDefaults: {},
},
instanceId: option.selection.instanceId,
descriptors,
});
const configuredRuntimeMode =
selectedProjectDraft.modelSelection?.instanceId !== option.selection.instanceId &&
selectedEnvironmentServerConfig
? resolveConfiguredRuntimeMode(
selectedEnvironmentServerConfig.settings,
option.selection.instanceId,
)
: null;
updateComposerDraftSettings(selectedProjectDraftKey, {
modelSelection: options ? { ...option.selection, options } : option.selection,
modelSelection: createModelSelection(
option.selection.instanceId,
option.selection.model,
seededOptions,
),
...(configuredRuntimeMode !== null ? { runtimeMode: configuredRuntimeMode } : {}),
});
},
[modelOptions, selectedProjectDraftKey],
[
modelOptions,
selectedEnvironmentServerConfig,
selectedProjectDraft.modelSelection,
selectedProjectDraft.runtimeMode,
selectedProjectDraftKey,
],
);
const setSelectedModelOptions = useCallback(
(options: ReadonlyArray<ProviderOptionSelection> | undefined) => {
Expand Down
89 changes: 88 additions & 1 deletion apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,102 @@
import { describe, expect, it } from "vite-plus/test";

import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";
import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";

import {
buildModelOptions,
groupByProvider,
resolveDefaultableModelSelection,
resolveNewTaskModelSelection,
resolveNewTaskRuntimeMode,
resolveSelectableModelSelection,
} from "./modelOptions";

describe("mobile model options", () => {
it("resolves configured new-thread defaults with configured options", () => {
const codexId = ProviderInstanceId.make("codex");
const claudeId = ProviderInstanceId.make("claudeAgent");
const config = {
settings: {
...DEFAULT_SERVER_SETTINGS,
newThreadModel: { instanceId: claudeId, model: "claude" },
providerNewThreadDefaults: {
[claudeId]: {
modelOptions: [{ id: "effort", value: "high" }],
runtimeMode: "approval-required",
},
},
},
providers: [
{
instanceId: codexId,
driver: "codex",
enabled: true,
installed: true,
auth: { status: "authenticated" },
models: [
{
slug: "gpt",
name: "GPT",
isCustom: false,
isDefault: true,
capabilities: null,
},
],
},
{
instanceId: claudeId,
driver: "claudeAgent",
enabled: true,
installed: true,
auth: { status: "authenticated" },
models: [
{
slug: "claude",
name: "Claude",
isCustom: false,
capabilities: {
optionDescriptors: [
{
id: "effort",
label: "Effort",
type: "select",
options: [
{ id: "low", label: "Low", isDefault: true },
{ id: "high", label: "High" },
],
},
],
},
},
],
},
],
} as unknown as ServerConfig;

const configured = resolveNewTaskModelSelection({
config,
draftSelection: null,
});
expect(configured).toEqual({
instanceId: claudeId,
model: "claude",
options: [{ id: "effort", value: "high" }],
});
expect(
resolveNewTaskRuntimeMode({ config, selectedModel: configured, draftRuntimeMode: undefined }),
).toBe("approval-required");
expect(
resolveNewTaskModelSelection({
config,
draftSelection: {
instanceId: claudeId,
model: "claude",
options: [{ id: "effort", value: "low" }],
},
})?.options,
).toEqual([{ id: "effort", value: "low" }]);
});

it("groups models by provider and flags legacy entries", () => {
const config = {
providers: [
Expand Down
51 changes: 51 additions & 0 deletions apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import type {
ModelCapabilities,
ModelSelection,
RuntimeMode,
ServerConfig as T3ServerConfig,
} from "@t3tools/contracts";
import { DEFAULT_RUNTIME_MODE } from "@t3tools/contracts";
import {
buildProviderOptionSelectionsFromDescriptors,
createModelSelection,
getProviderOptionDescriptors,
resolveConfiguredProviderOptionDefaults,
resolveConfiguredRuntimeMode,
} from "@t3tools/shared/model";

export type ModelOption = {
Expand Down Expand Up @@ -167,6 +172,52 @@ export function buildModelOptions(
return [...options.values()];
}

export function resolveNewTaskModelSelection(input: {
readonly config: T3ServerConfig | null | undefined;
readonly draftSelection: ModelSelection | null;
}): ModelSelection | null {
const draftSelection = resolveSelectableModelSelection(input.config, input.draftSelection);
const configuredSelection = resolveDefaultableModelSelection(
input.config,
input.config?.settings.newThreadModel ?? null,
);
const modelOptions = buildModelOptions(input.config, draftSelection ?? configuredSelection);
const base =
draftSelection ??
configuredSelection ??
modelOptions.find((option) => option.isDefault)?.selection ??
modelOptions[0]?.selection ??
null;
if (!base) return null;
const provider = input.config?.providers.find(
(candidate) => candidate.instanceId === base.instanceId,
);
const model = provider?.models.find((candidate) => candidate.slug === base.model);
if (draftSelection?.options && draftSelection.options.length > 0) {
return normalizeSelectionOptions(draftSelection, model?.capabilities ?? null);
}
const options = resolveConfiguredProviderOptionDefaults({
settings: input.config?.settings ?? { providerNewThreadDefaults: {} },
instanceId: base.instanceId,
descriptors: model?.capabilities?.optionDescriptors ?? [],
});
return createModelSelection(base.instanceId, base.model, options);
}

export function resolveNewTaskRuntimeMode(input: {
readonly config: T3ServerConfig | null | undefined;
readonly selectedModel: ModelSelection | null;
readonly draftRuntimeMode: RuntimeMode | undefined;
}): RuntimeMode {
return (
input.draftRuntimeMode ??
(input.config && input.selectedModel
? resolveConfiguredRuntimeMode(input.config.settings, input.selectedModel.instanceId)
: null) ??
DEFAULT_RUNTIME_MODE
);
}

export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyArray<ProviderGroup> {
const groups = new Map<string, { providerLabel: string; models: ModelOption[] }>();
for (const option of options) {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/AppRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { QuitHoldOverlay } from "./components/QuitHoldOverlay";
import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
import type { AppRouter } from "./router";
import { ServerBootReload } from "./components/ServerBootReload";
import { DraftDefaultsSync } from "./draftDefaultsSync";

/**
* Owns renderer-wide providers. The Electron browser host intentionally sits
Expand All @@ -16,6 +17,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) {
return (
<AppAtomRegistryProvider>
<ServerBootReload />
<DraftDefaultsSync />
<RouterProvider router={router} />
<PreviewAutomationHosts />
<ElectronBrowserHost />
Expand Down
Loading
Loading