From 5f10a40b26dbfdc10b2c02cdf23ec3a7edfa9443 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 12 Aug 2026 22:01:59 +0900 Subject: feat(models): numbered OpenCode credential selection, labels, migration Adopt the numbered OPENCODE_API_KEY_N scheme in the model picker and concurrency surfaces. - model-select: numeric sort of opencode- keys (#2 before #10); friendly OpenCode #N labels (raw opencode-N/ value still selected/persisted); migrateLegacyModelName rewrites opencode/ -> opencode-1/. - ModelSelector: renders friendly key labels; surfaces a disappeared saved credential as UNAVAILABLE (disabled option + notice) without silently switching to another model. - app store: DEFAULT_MODEL -> opencode-1/...; catalogs no longer auto-fallback the active model when the selected credential is absent; boot restore migrates persisted legacy opencode/ tab models idempotently. - concurrency: providerLabel renders opencode- as OpenCode # in the limit rows and Add dropdown (raw provider id preserved on the wire). - Tests: parsing/sorting/labels/migration, unavailable-state selector behavior, no-silent-fallback, and concurrency provider label; fixtures updated to the numbered scheme. Verification: bun install, bun run typecheck (0 errors), bun run test (1179 passed), bun run check (clean, exit 0), bun run build (vite OK). --- src/features/chat/ui.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) (limited to 'src/features/chat/ui.test.ts') diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index f4006f7..aa43e04 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -1089,6 +1089,43 @@ describe("ModelSelector", () => { }); expect(screen.getByText(/auto-described/i)).toBeInTheDocument(); }); + + it("labels numbered OpenCode keys as OpenCode #N and sorts them numerically", () => { + const models = ["opencode-10/m1", "opencode-2/m2", "opencode-1/m3"]; + const onSelect = vi.fn(); + render(ModelSelector, { + props: { models, selected: "opencode-2/m2", onSelect }, + }); + + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + const options = within(keySelect).getAllByRole("option"); + // Numeric order: #1 < #2 < #10, and values stay RAW (opencode-N). + expect(options.map((o) => (o as HTMLOptionElement).value)).toEqual([ + "opencode-1", + "opencode-2", + "opencode-10", + ]); + expect(options[0]?.textContent).toContain("OpenCode #1"); + expect(options[1]?.textContent).toContain("OpenCode #2"); + expect(options[2]?.textContent).toContain("OpenCode #10"); + }); + + it("shows a disappeared saved credential as unavailable without silently falling back", () => { + // Saved selection references opencode-5, but the catalog no longer has it. + const models = ["opencode-1/deepseek-v4-flash"]; + const onSelect = vi.fn(); + render(ModelSelector, { + props: { models, selected: "opencode-5/deepseek-v4-flash", onSelect }, + }); + + // The unavailable key is surfaced as an explicit (disabled) option + notice. + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + const unavailable = within(keySelect).getByText(/OpenCode #5 \(unavailable\)/i); + expect(unavailable).toBeInTheDocument(); + expect(screen.getByText(/Selected model .* unavailable/)).toBeInTheDocument(); + // No silent switch is attempted. + expect(onSelect).not.toHaveBeenCalled(); + }); }); describe("ReasoningEffortSelector", () => { -- cgit v1.2.3