feat(x): one model-selection experience — unified settings, provider lifecycle, split-view picker

Task slots (config + runtime):
- taskModels gains backgroundTask and subagent; getBackgroundTaskAgentModel
  resolves its own override instead of mirroring live-notes (migration
  materializes the old mirror rule so effective models are unchanged)
- spawn-agent precedence: explicit per-spawn args > configured subagent
  override > parent turn's model
- repo.setProvider rejects auth-derived flavors (rowboat/codex) and merges
  over existing entries so key rotation keeps contextLength/reasoningEffort

Models settings, rebuilt:
- ModelSelectionSection: ONE Assistant model picker (with an Unavailable
  badge when the saved model drops off its provider's live list) + all
  seven tasks defaulting to "Same as Assistant" with resolve subtext and a
  clear-override link. Replaces both the signed-in and BYOK screens; the
  "Auto (recommended)" sentinel and both hardcoded preferredDefaults maps
  are gone — connect-time resolution uses backend recommendations
- ProvidersSection replaces the old ModelSettings card grid: status cards
  (Connected · N models / error + Retry), an add-provider dialog
  (choose → creds → loading → first-vs-more result states → error with
  manual-model fallback), and a manage dialog (masked key + Replace,
  endpoint, Refresh models, Used-by list, disconnect with consequences
  spelled out). Rowboat and ChatGPT are cards in the same list
- connecting a provider seeds the assistant ONLY when none is configured —
  never replaces a saved choice
- models:getConfig IPC serves selections + credential-free provider
  metadata; the probe machinery (use-provider-models, liveCredentials)
  is deleted — the add flow validates credentials click-driven

Onboarding:
- screen 2 is the same ProvidersSection (onboarding variant) for BOTH
  paths: "Add more providers" after sign-in, "Connect a model provider"
  otherwise; Continue gates on an assistant model existing. The tile grid,
  per-provider key forms, and ~350 lines of duplicate state are deleted;
  one step sequence replaces the two path-dependent indicators

Picker:
- split-view browse (Popover + cmdk Command): providers left with counts
  and error dots, selected provider's models right, ←/→ switches provider,
  ↑/↓ navigates, typing collapses to a flat cross-provider list that
  matches provider NAMES as well as model ids (searching "rowboat" now
  works). Scoped/static pickers stay flat; public API unchanged

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-24 16:16:21 +05:30
parent f39daa9f5d
commit c695ddf1c1
24 changed files with 1599 additions and 1917 deletions

View file

@ -29,7 +29,7 @@ export interface CatalogModelEntry {
export interface CatalogProviderEntry {
/**
* Provider INSTANCE identifier what ModelRef.provider, defaultSelection,
* Provider INSTANCE identifier what ModelRef.provider, assistantModel,
* task overrides, and refreshProvider all reference. Today one instance
* exists per flavor, so id always equals the flavor key ("openai",
* "ollama", "rowboat", ); a future multi-key setup ("openai-work" /

View file

@ -107,12 +107,18 @@ export async function getChatTitleModel(): Promise<ModelSelection> {
return getCategoryModel("chatTitle");
}
/**
* Model used by the background-task agent + routing classifier. Currently
* mirrors `getLiveNoteAgentModel()` both surfaces want a fast, reliable
* agent model. Split into its own getter so a future per-feature override
* doesn't require touching all call sites.
*/
/** Model used by the background-task agent + routing classifier. */
export async function getBackgroundTaskAgentModel(): Promise<ModelSelection> {
return getLiveNoteAgentModel();
return getCategoryModel("backgroundTask");
}
/**
* Explicit subagent model override, or null to inherit the PARENT turn's
* model (spawn-agent's default which is the assistant for a top-level
* chat). Not getCategoryModel: the no-override fallback is the parent, not
* the assistant, and the caller owns that resolution.
*/
export async function getSubagentModelOverride(): Promise<ModelSelection | null> {
const cfg = await readConfig();
return cfg?.taskModels?.subagent ?? null;
}

View file

@ -38,6 +38,8 @@ describe('migrateModelsConfig', () => {
taskModels: {
knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
// v1 background tasks mirrored the live-note model.
backgroundTask: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
// chat titles used flash-lite because the assistant routes
// through the gateway; meeting notes used the curated default
@ -59,6 +61,7 @@ describe('migrateModelsConfig', () => {
expect(v2?.taskModels).toEqual({
knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
backgroundTask: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
// Meeting notes used the curated gateway default, which now
// differs from the (BYOK) assistant — preserved explicitly.
@ -83,6 +86,18 @@ describe('migrateModelsConfig', () => {
});
});
it('a v1 live-note override propagates to backgroundTask (v1 bg tasks mirrored live-note)', () => {
const v1 = {
provider: { flavor: 'openai', apiKey: 'sk-a' },
model: 'gpt-5.4',
liveNoteAgentModel: { provider: 'ollama', model: 'qwen3' },
};
expect(migrateModelsConfig(v1, false)?.taskModels).toEqual({
liveNoteAgent: { provider: 'ollama', model: 'qwen3' },
backgroundTask: { provider: 'ollama', model: 'qwen3' },
});
});
it('an override equal to the assistant is dropped (inherit produces the same model)', () => {
const v1 = {
provider: { flavor: 'openai', apiKey: 'sk-a' },

View file

@ -131,11 +131,15 @@ export function migrateModelsConfig(rawInput: unknown, signedIn: boolean): V2 |
const assistantModel = v1EffectiveAssistant(raw, signedIn);
// Old effective model per task, via the deleted v1 rules.
const liveNoteEffective = v1Override(raw, "liveNoteAgentModel", signedIn)
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel);
const oldTaskModels: Record<string, Ref | undefined> = {
knowledgeGraph: v1Override(raw, "knowledgeGraphModel", signedIn)
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
liveNoteAgent: v1Override(raw, "liveNoteAgentModel", signedIn)
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
liveNoteAgent: liveNoteEffective,
// v1 had no backgroundTask key — getBackgroundTaskAgentModel mirrored
// the live-note model (override included). v2 gives it its own slot.
backgroundTask: liveNoteEffective,
autoPermissionDecision: v1Override(raw, "autoPermissionDecisionModel", signedIn)
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
meetingNotes: v1Override(raw, "meetingNotesModel", signedIn)

View file

@ -90,8 +90,20 @@ export class FSModelConfigRepo implements IModelConfigRepo {
}
async setProvider(id: string, provider: z.infer<typeof LlmProvider>): Promise<void> {
// The credential-less flavors are never stored: their connection IS
// their auth token store (oauth.json / chatgpt-auth.json), and the
// catalog derives their presence from auth state — a providers-map
// entry would double-list them.
if (provider.flavor === "rowboat" || provider.flavor === "codex") {
throw new Error(`Provider flavor '${provider.flavor}' is auth-derived and cannot be stored in models.json`);
}
const config = await this.read();
config.providers[id] = LlmProvider.parse(provider);
// Merge over an existing entry: replacing a key must not wipe
// hand-tuned connection prefs (contextLength, reasoningEffort).
config.providers[id] = LlmProvider.parse({
...config.providers[id],
...provider,
});
await this.write(config);
}

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { z } from "zod";
import type { TurnEvent, TurnState } from "@x/shared/dist/turns.js";
import type { ITurnRuntime } from "../turns/api.js";
@ -10,6 +10,16 @@ import type {
} from "./headless.js";
import { runSpawnedAgent } from "./spawn-agent.js";
// spawn-agent reads the configured subagent override lazily from
// models/defaults.js; stub it so tests control the whole precedence chain
// (explicit args > configured override > parent model).
const subagentOverride = vi.hoisted(() => ({
value: null as { provider: string; model: string } | null,
}));
vi.mock("../../models/defaults.js", () => ({
getSubagentModelOverride: async () => subagentOverride.value,
}));
const TS = "2026-07-07T10:00:00Z";
function parentCreated(
@ -95,6 +105,34 @@ function fakeServices(opts: {
const signal = new AbortController().signal;
describe("runSpawnedAgent", () => {
beforeEach(() => {
subagentOverride.value = null;
});
it("uses the configured subagent override when no explicit model is passed", async () => {
subagentOverride.value = { provider: "ollama", model: "qwen3" };
const { services, started } = fakeServices({});
await runSpawnedAgent(
{ task: "t", instructions: "x" },
{ parentTurnId: "parent-1", signal, services },
);
expect(started[0].agent).toMatchObject({
inline: { model: { provider: "ollama", model: "qwen3" } },
});
});
it("explicit per-spawn model args beat the configured override", async () => {
subagentOverride.value = { provider: "ollama", model: "qwen3" };
const { services, started } = fakeServices({});
await runSpawnedAgent(
{ task: "t", instructions: "x", model: "gpt-5.4", provider: "openai" },
{ parentTurnId: "parent-1", signal, services },
);
expect(started[0].agent).toMatchObject({
inline: { model: { provider: "openai", model: "gpt-5.4" } },
});
});
it("runs an inline child on the parent's model and returns the result envelope", async () => {
const { services, started } = fakeServices({});
const progress: unknown[] = [];

View file

@ -135,12 +135,15 @@ export async function runSpawnedAgent(
parentModel = undefined;
}
// Model precedence: explicit per-spawn args > the user's configured
// subagent override (taskModels.subagent) > the parent turn's model.
const subagentOverride = input.model ? null : await readSubagentOverride();
const model: z.infer<typeof ModelDescriptor> | undefined = input.model
? {
provider: input.provider ?? parentModel?.provider ?? "",
model: input.model,
}
: parentModel;
: subagentOverride ?? parentModel;
if (model && !model.provider) {
return spawnError(
"`model` was set but no provider could be determined; pass `provider` too",
@ -232,6 +235,21 @@ export async function runSpawnedAgent(
};
}
// Lazy for the same import-cycle reason as resolveServices; best-effort —
// an unreadable config means "no override", never a failed spawn.
async function readSubagentOverride(): Promise<
z.infer<typeof ModelDescriptor> | null
> {
try {
const { getSubagentModelOverride } = await import(
"../../models/defaults.js"
);
return await getSubagentModelOverride();
} catch {
return null;
}
}
async function resolveServices(): Promise<
NonNullable<SpawnedAgentCallbacks["services"]>
> {

View file

@ -656,7 +656,7 @@ const ipcSchemas = {
}).nullable(),
res: z.object({
providers: z.array(z.object({
// Provider INSTANCE id — what ModelRef.provider / defaultSelection /
// Provider INSTANCE id — what ModelRef.provider / assistantModel /
// refreshProvider reference. One instance per flavor today, so it
// always equals the flavor key; kept distinct so a future
// multi-instance setup ("openai-work") is additive.
@ -748,6 +748,33 @@ const ipcSchemas = {
success: z.literal(true),
}),
},
// Current model selections plus credential-FREE provider metadata (the
// renderer never needs keys to render pickers or provider cards). Null
// assistantModel = not configured yet.
'models:getConfig': {
req: z.null(),
res: z.object({
// Configured BYOK provider entries, secrets stripped: enough to
// render manage/edit surfaces (masked key indicator, endpoint).
providers: z.array(z.object({
id: z.string(),
flavor: z.string(),
baseURL: z.string().optional(),
hasApiKey: z.boolean(),
})),
assistantModel: ModelRef.nullable(),
taskModels: z.object({
knowledgeGraph: ModelRef.nullable(),
meetingNotes: ModelRef.nullable(),
liveNoteAgent: ModelRef.nullable(),
autoPermissionDecision: ModelRef.nullable(),
chatTitle: ModelRef.nullable(),
backgroundTask: ModelRef.nullable(),
subagent: ModelRef.nullable(),
}),
deferBackgroundTasks: z.boolean(),
}),
},
// Partial merge of model selections into models.json. Omitted keys are
// untouched; null clears a key (a cleared task override inherits the
// assistant model again). taskModels merges per-key.
@ -760,6 +787,8 @@ const ipcSchemas = {
liveNoteAgent: ModelRef.nullable().optional(),
autoPermissionDecision: ModelRef.nullable().optional(),
chatTitle: ModelRef.nullable().optional(),
backgroundTask: ModelRef.nullable().optional(),
subagent: ModelRef.nullable().optional(),
}).optional(),
deferBackgroundTasks: z.boolean().nullable().optional(),
}),

View file

@ -41,13 +41,17 @@ export const ModelRef = z.object({
model: z.string(),
});
// The per-task model override slots. Absence = inherit the assistant model.
// The per-task model override slots. Absence = inherit the assistant model
// (except `subagent`, whose default is the PARENT turn's model — which is
// the assistant for a top-level chat).
export const TaskModels = z.object({
knowledgeGraph: ModelRef.optional(),
meetingNotes: ModelRef.optional(),
liveNoteAgent: ModelRef.optional(),
autoPermissionDecision: ModelRef.optional(),
chatTitle: ModelRef.optional(),
backgroundTask: ModelRef.optional(),
subagent: ModelRef.optional(),
});
export type TaskModelKey = keyof z.infer<typeof TaskModels>;