From 6e53e780b37821a5094256658a557dc8c8eba2ca Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 19:22:35 -0800 Subject: [PATCH 1/2] fix(web): cycle model profiles on the home prompt When several profiles can run and none is automatic, the home prompt still shows the profile control and selects a runnable profile. --- apps/web/src/pages/profile-select.test.tsx | 98 ++++++++++++++++++++++ apps/web/src/pages/profile-select.tsx | 14 ++-- 2 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/pages/profile-select.test.tsx diff --git a/apps/web/src/pages/profile-select.test.tsx b/apps/web/src/pages/profile-select.test.tsx new file mode 100644 index 00000000..9e95e02e --- /dev/null +++ b/apps/web/src/pages/profile-select.test.tsx @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ModelCatalog, Tiers } from "@corbits-builder/schemas/models"; +import { keys } from "../queries/keys.ts"; +import { NewProjectProfile } from "./profile-select.tsx"; + +const SETTINGS_DETOUR = + "Choose a model profile in Settings, Profiles, and the coordinator can start."; + +function tiers(provider: string): Tiers { + const binding = { provider, model: "m", effort: "medium" as const }; + return { low: binding, medium: binding, high: binding, max: binding }; +} + +/** Two providers connected, so the catalog has no automatic profile. */ +function severalProviders(): ModelCatalog { + return { + providers: [], + roles: [], + tiers: [], + efforts: [], + customApis: [], + unresolved: { + code: "model_profile_required", + message: `More than one model provider is connected. ${SETTINGS_DETOUR}`, + }, + profiles: [ + { + id: "xai_default", + label: "Grok", + hint: "Not connected", + tiers: tiers("xai"), + available: false, + }, + { + id: "anthropic_default", + label: "Claude", + hint: "Anthropic", + tiers: tiers("anthropic"), + available: true, + }, + { + id: "openai_default", + label: "GPT", + hint: "OpenAI", + tiers: tiers("openai"), + available: true, + }, + ], + }; +} + +function render(value: string | undefined, catalog = severalProviders()) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(keys.modelCatalog, catalog); + client.setQueryData(keys.modelChoice, null); + return renderToStaticMarkup( + + {}} /> + , + ); +} + +describe("NewProjectProfile", () => { + test("renders and can show each runnable profile when none is automatic", () => { + const initial = render(undefined); + expect(initial).toContain('data-slot="prompt-input-model-select"'); + expect(initial).toContain('role="combobox"'); + expect(initial).toContain('aria-haspopup="listbox"'); + expect(initial).toContain("Model profile, Claude"); + expect(initial).not.toContain("Grok"); + expect(initial).not.toContain(SETTINGS_DETOUR); + + const cycled = render("openai_default"); + expect(cycled).toContain("Model profile, GPT"); + expect(cycled).not.toContain("Model profile, Claude"); + expect(cycled).not.toContain(SETTINGS_DETOUR); + }); + + test("keeps the automatic profile when one is set", () => { + const catalog = severalProviders(); + catalog.automaticProfile = "openai_default"; + delete catalog.unresolved; + expect(render(undefined, catalog)).toContain("Model profile, GPT"); + }); + + test("renders nothing when no profile can run", () => { + const catalog = severalProviders(); + catalog.profiles = catalog.profiles.map((profile) => ({ + ...profile, + available: false, + })); + expect(render(undefined, catalog)).toBe(""); + }); +}); diff --git a/apps/web/src/pages/profile-select.tsx b/apps/web/src/pages/profile-select.tsx index a95ecbaa..a3fad74d 100644 --- a/apps/web/src/pages/profile-select.tsx +++ b/apps/web/src/pages/profile-select.tsx @@ -1,7 +1,8 @@ /** - * The model profile in the new-project prompt box. A new project starts on the one picked here - * (the person's active profile, else the only connected provider's, until they pick another); its - * bindings never change once it starts. With neither, nothing is picked and the hub says why. + * The model profile in the new-project prompt box. A new project starts on the one picked here: + * the person's active profile, else the only connected provider's, else the first profile that can + * run, until they pick another. Its bindings never change once it starts. With no runnable + * profile, nothing is picked and the hub says why. */ import { PromptInputModelSelect, @@ -74,9 +75,10 @@ export function NewProjectProfile({ const models = catalog && choice !== undefined ? profileOptions(catalog, choice) : []; const active = choice?.profile ?? catalog?.automaticProfile; - const shown = models.some((model) => model.id === active) - ? active - : undefined; + // No active or automatic profile (several providers connected) still has to + // offer every runnable profile, with one selected so a start carries an id. + const shown = + models.find((model) => model.id === active)?.id ?? models[0]?.id; useEffect(() => { if (value === undefined && shown !== undefined) onValueChange(shown); }, [value, shown, onValueChange]); From f90076815634ba6690b96cbadde66b924cf11645 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 19:33:39 -0800 Subject: [PATCH 2/2] test(web): drop the profile picker narration --- apps/web/src/pages/profile-select.test.tsx | 40 +++++++--------------- apps/web/src/pages/profile-select.tsx | 2 -- 2 files changed, 13 insertions(+), 29 deletions(-) diff --git a/apps/web/src/pages/profile-select.test.tsx b/apps/web/src/pages/profile-select.test.tsx index 9e95e02e..228f7750 100644 --- a/apps/web/src/pages/profile-select.test.tsx +++ b/apps/web/src/pages/profile-select.test.tsx @@ -5,26 +5,18 @@ import type { ModelCatalog, Tiers } from "@corbits-builder/schemas/models"; import { keys } from "../queries/keys.ts"; import { NewProjectProfile } from "./profile-select.tsx"; -const SETTINGS_DETOUR = - "Choose a model profile in Settings, Profiles, and the coordinator can start."; - function tiers(provider: string): Tiers { const binding = { provider, model: "m", effort: "medium" as const }; return { low: binding, medium: binding, high: binding, max: binding }; } -/** Two providers connected, so the catalog has no automatic profile. */ -function severalProviders(): ModelCatalog { +function catalog(): ModelCatalog { return { providers: [], roles: [], tiers: [], efforts: [], customApis: [], - unresolved: { - code: "model_profile_required", - message: `More than one model provider is connected. ${SETTINGS_DETOUR}`, - }, profiles: [ { id: "xai_default", @@ -51,11 +43,11 @@ function severalProviders(): ModelCatalog { }; } -function render(value: string | undefined, catalog = severalProviders()) { +function render(value: string | undefined, models = catalog()) { const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); - client.setQueryData(keys.modelCatalog, catalog); + client.setQueryData(keys.modelCatalog, models); client.setQueryData(keys.modelChoice, null); return renderToStaticMarkup( @@ -65,34 +57,28 @@ function render(value: string | undefined, catalog = severalProviders()) { } describe("NewProjectProfile", () => { - test("renders and can show each runnable profile when none is automatic", () => { + test("selects the first runnable profile when none is automatic", () => { const initial = render(undefined); - expect(initial).toContain('data-slot="prompt-input-model-select"'); - expect(initial).toContain('role="combobox"'); - expect(initial).toContain('aria-haspopup="listbox"'); expect(initial).toContain("Model profile, Claude"); expect(initial).not.toContain("Grok"); - expect(initial).not.toContain(SETTINGS_DETOUR); - const cycled = render("openai_default"); - expect(cycled).toContain("Model profile, GPT"); - expect(cycled).not.toContain("Model profile, Claude"); - expect(cycled).not.toContain(SETTINGS_DETOUR); + const picked = render("openai_default"); + expect(picked).toContain("Model profile, GPT"); + expect(picked).not.toContain("Model profile, Claude"); }); test("keeps the automatic profile when one is set", () => { - const catalog = severalProviders(); - catalog.automaticProfile = "openai_default"; - delete catalog.unresolved; - expect(render(undefined, catalog)).toContain("Model profile, GPT"); + const models = catalog(); + models.automaticProfile = "openai_default"; + expect(render(undefined, models)).toContain("Model profile, GPT"); }); test("renders nothing when no profile can run", () => { - const catalog = severalProviders(); - catalog.profiles = catalog.profiles.map((profile) => ({ + const models = catalog(); + models.profiles = models.profiles.map((profile) => ({ ...profile, available: false, })); - expect(render(undefined, catalog)).toBe(""); + expect(render(undefined, models)).toBe(""); }); }); diff --git a/apps/web/src/pages/profile-select.tsx b/apps/web/src/pages/profile-select.tsx index a3fad74d..cb34aed0 100644 --- a/apps/web/src/pages/profile-select.tsx +++ b/apps/web/src/pages/profile-select.tsx @@ -75,8 +75,6 @@ export function NewProjectProfile({ const models = catalog && choice !== undefined ? profileOptions(catalog, choice) : []; const active = choice?.profile ?? catalog?.automaticProfile; - // No active or automatic profile (several providers connected) still has to - // offer every runnable profile, with one selected so a start carries an id. const shown = models.find((model) => model.id === active)?.id ?? models[0]?.id; useEffect(() => {