diff options
| author | Adam Malczewski <[email protected]> | 2026-08-12 22:01:59 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-08-12 22:01:59 +0900 |
| commit | 5f10a40b26dbfdc10b2c02cdf23ec3a7edfa9443 (patch) | |
| tree | 2f1d5ee2674d1dcc9909e89f06a82a7b10b4191a /src | |
| parent | 774519e052fd869952cc5e05aca159e4bf978fa1 (diff) | |
| download | dispatch-web-5f10a40b26dbfdc10b2c02cdf23ec3a7edfa9443.tar.gz dispatch-web-5f10a40b26dbfdc10b2c02cdf23ec3a7edfa9443.zip | |
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-<n> keys (#2 before #10); friendly
OpenCode #N labels (raw opencode-N/<model> value still selected/persisted);
migrateLegacyModelName rewrites opencode/<model> -> opencode-1/<model>.
- 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/<model> tab models idempotently.
- concurrency: providerLabel renders opencode-<n> as OpenCode #<n> 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).
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/App.test.ts | 4 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 33 | ||||
| -rw-r--r-- | src/app/store.test.ts | 73 | ||||
| -rw-r--r-- | src/features/chat/index.ts | 8 | ||||
| -rw-r--r-- | src/features/chat/model-select.test.ts | 56 | ||||
| -rw-r--r-- | src/features/chat/model-select.ts | 58 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 37 | ||||
| -rw-r--r-- | src/features/chat/ui/ModelSelector.svelte | 31 | ||||
| -rw-r--r-- | src/features/concurrency/logic/view-model.test.ts | 28 | ||||
| -rw-r--r-- | src/features/concurrency/logic/view-model.ts | 12 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyLimitRow.svelte | 5 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.svelte | 3 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.test.ts | 4 |
13 files changed, 309 insertions, 43 deletions
diff --git a/src/app/App.test.ts b/src/app/App.test.ts index f43a6b8..06e562f 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -58,7 +58,7 @@ function fakeFetchImpl(): typeof fetch { return async (input: string | URL | Request): Promise<Response> => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { status: 200, }); } @@ -492,7 +492,7 @@ describe("App component interaction tests", () => { const failingFetch = async (input: string | URL | Request): Promise<Response> => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { status: 200, }); } diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 8997ce0..e11a445 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -53,7 +53,7 @@ import { unsubscribe as protocolUnsubscribe, } from "../core/protocol"; import type { ChatStore, HistorySync, MetricsSync } from "../features/chat"; -import { createChatStore } from "../features/chat"; +import { createChatStore, migrateLegacyModelName } from "../features/chat"; import type { SetThinkingRequest, ThinkingResponse } from "../features/chat/reasoning-effort"; import type { ConcurrencyCooldownResult, @@ -91,7 +91,7 @@ import { resolveHttpUrl } from "./resolve-http-url"; import { resolveWsUrl } from "./resolve-ws-url"; import { randomId } from "./uuid"; -const DEFAULT_MODEL = "opencode/deepseek-v4-flash"; +const DEFAULT_MODEL = "opencode-1/deepseek-v4-flash"; /** Outcome of a manual `POST /chat/warm` (the "warm now" affordance). */ export type WarmResult = @@ -1112,7 +1112,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // authoritative conversation record is the source of truth, so a // differing tab workspace is corrected here. tabsStore.setTitle(conv.id, conv.title); - if (tabsStore.tabs.find((t) => t.conversationId === conv.id)?.workspaceId !== conv.workspaceId) { + if ( + tabsStore.tabs.find((t) => t.conversationId === conv.id)?.workspaceId !== + conv.workspaceId + ) { tabsStore.setWorkspaceId(conv.id, conv.workspaceId); } } @@ -1231,12 +1234,13 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { if (data === undefined) return; models = data.models; modelInfo = data.modelInfo ?? {}; - if (data.models.length > 0 && !data.models.includes(activeModel)) { - const first = data.models[0]; - if (first !== undefined) { - activeModel = first; - draftStore.setModel(first); - } + // Do NOT silently switch the active model when the current selection is + // missing from the catalog (e.g. a saved numbered OpenCode credential was + // removed). The model selector surfaces it as unavailable; the user picks + // explicitly. activeModel is still normalized through the legacy-migration + // helper so an old `opencode/...` value is rewritten once. + if (migrateLegacyModelName(activeModel) !== activeModel) { + activeModel = migrateLegacyModelName(activeModel); } }) .catch((err) => { @@ -1262,7 +1266,14 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const persistedState = storageAdapter.load(); if (persistedState !== null && persistedState.tabs.length > 0) { for (const tab of persistedState.tabs) { - const store = createChatFor(tab.conversationId, tab.model, tab.workspaceId); + // One-time persisted-state migration: rewrite a legacy `opencode/<model>` + // tab model to the numbered `opencode-1/<model>` namespace, and persist the + // migrated value back so a re-load is a no-op (idempotent). + const model = migrateLegacyModelName(tab.model); + if (model !== tab.model) { + tabsStore.setModel(tab.conversationId, model); + } + const store = createChatFor(tab.conversationId, model, tab.workspaceId); chatStores.set(tab.conversationId, store); void store.load(); // Watch each restored conversation's live turns: after a reload mid-turn the @@ -1275,7 +1286,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { (t) => t.conversationId === persistedState.activeConversationId, ); if (activeTab !== undefined) { - activeModel = activeTab.model; + activeModel = migrateLegacyModelName(activeTab.model); } } } diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 47c3977..16d1760 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -107,7 +107,7 @@ interface FakeFetchOptions { } function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch { - const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"]; + const models = opts?.models ?? ["opencode-1/deepseek-v4-flash", "openai/gpt-4o"]; const history = opts?.history ?? {}; return async (input: string | URL | Request): Promise<Response> => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; @@ -524,7 +524,7 @@ describe("createAppStore", () => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; fetchedUrls.push(url); if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { status: 200, }); } @@ -573,7 +573,7 @@ describe("createAppStore", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl({ - models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"], + models: ["opencode-1/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"], }), localStorage: createFakeStorage(), }); @@ -582,7 +582,7 @@ describe("createAppStore", () => { await new Promise((r) => setTimeout(r, 50)); expect(store.models).toEqual([ - "opencode/deepseek-v4-flash", + "opencode-1/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3", ]); @@ -599,11 +599,62 @@ describe("createAppStore", () => { }); ws.resolveOpen(); - expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); store.dispose(); }); + it("does not silently switch the active model when the selected one is absent from the catalog", async () => { + // The catalog only has umans; the active (default/selected) OpenCode model is + // NOT available. The FE must NOT auto-fall back to the first catalog model — + // it keeps the selection and lets the model selector surface it as unavailable. + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ models: ["umans/umans-glm-5.2"] }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + await new Promise((r) => setTimeout(r, 50)); + + expect(store.models).toEqual(["umans/umans-glm-5.2"]); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); + + store.dispose(); + }); + + it("migrates a legacy persisted opencode/<model> tab model to opencode-1/<model>", async () => { + const ws = fakeSocket(); + // Seed localStorage with a persisted tab whose model is the OBSOLETE + // `opencode/deepseek-v4-flash` namespace. + const storage = createFakeStorage(); + storage.setItem( + "dispatch.tabs", + JSON.stringify({ + tabs: [ + { + conversationId: "c-legacy", + title: "Legacy", + model: "opencode/deepseek-v4-flash", + workspaceId: "default", + }, + ], + activeConversationId: null, + }), + ); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws.resolveOpen(); + // After restore, the restored tab's model + the persisted store are migrated. + const restored = store.tabs.find((t) => t.conversationId === "c-legacy"); + expect(restored?.model).toBe("opencode-1/deepseek-v4-flash"); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); + store.dispose(); + }); + it("draft: sending the first message creates a tab titled from the message", () => { const ws = fakeSocket(); const store = createAppStore({ @@ -679,7 +730,7 @@ describe("createAppStore", () => { store.send("first message"); // New tab opens with the default model until the persisted-model fetch resolves. - expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); // Wait for the persisted model fetch to resolve. await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o")); @@ -1212,7 +1263,7 @@ describe("createAppStore", () => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; fetchedUrls.push(url); if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { status: 200, }); } @@ -1518,7 +1569,7 @@ describe("createAppStore", () => { const limits = opts?.limits ?? { limits: [ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ], }; const limit = opts?.limit ?? { providerId: "umans", limit: 4 }; @@ -1526,7 +1577,7 @@ describe("createAppStore", () => { providers: [ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, { - providerId: "openai-compat", + providerId: "opencode-1", limit: 5, inFlight: 5, queued: 3, @@ -1586,7 +1637,7 @@ describe("createAppStore", () => { if (!result.ok) throw new Error("unreachable"); expect(result.limits).toEqual([ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ]); store.dispose(); }); @@ -1758,7 +1809,7 @@ describe("createAppStore", () => { autoReduced: false, }); expect(result.providers[1]).toMatchObject({ - providerId: "openai-compat", + providerId: "opencode-1", paused: true, pausedUntil: 1_719_408_000_000, cooldownMs: 350, diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index 2f98a0e..8cf776b 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -6,7 +6,13 @@ export type { } from "../../core/chunks"; export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks"; export type { TurnMetricsEntry } from "../../core/metrics"; -export { isVisionModel } from "./model-select"; +export { + isNumberedOpenCode, + isVisionModel, + migrateLegacyModelName, + modelKeyLabel, + openCodeNumber, +} from "./model-select"; export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; export type { EffortOption, diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts index 6d3081d..33c1a16 100644 --- a/src/features/chat/model-select.test.ts +++ b/src/features/chat/model-select.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest"; import { isVisionModel, joinModelName, + migrateLegacyModelName, + modelKeyLabel, modelKeys, modelsForKey, + openCodeNumber, splitModelName, } from "./model-select"; @@ -47,11 +50,64 @@ describe("modelKeys", () => { ).toEqual(["openai", "anthropic", "google"]); }); + it("sorts numbered OpenCode keys numerically (#2 before #10)", () => { + expect( + modelKeys(["opencode-10/models", "opencode-2/models", "opencode-1/models", "openai/gpt-4"]), + ).toEqual(["opencode-1", "opencode-2", "opencode-10", "openai"]); + }); + it("is empty for no models", () => { expect(modelKeys([])).toEqual([]); }); }); +describe("openCodeNumber", () => { + it("parses a numbered OpenCode key", () => { + expect(openCodeNumber("opencode-1")).toBe(1); + expect(openCodeNumber("opencode-10")).toBe(10); + }); + + it("returns null for non-openCode / malformed keys", () => { + expect(openCodeNumber("umans")).toBeNull(); + expect(openCodeNumber("opencode")).toBeNull(); + expect(openCodeNumber("opencode-0")).toBeNull(); + expect(openCodeNumber("opencode-01")).toBeNull(); + }); +}); + +describe("modelKeyLabel", () => { + it("labels a numbered OpenCode key as OpenCode #N", () => { + expect(modelKeyLabel("opencode-2")).toBe("OpenCode #2"); + expect(modelKeyLabel("opencode-10")).toBe("OpenCode #10"); + }); + + it("leaves non-OpenCode keys unchanged", () => { + expect(modelKeyLabel("umans")).toBe("umans"); + }); +}); + +describe("migrateLegacyModelName", () => { + it("rewrites opencode/<model> to opencode-1/<model>", () => { + expect(migrateLegacyModelName("opencode/deepseek-v4-flash")).toBe( + "opencode-1/deepseek-v4-flash", + ); + expect(migrateLegacyModelName("opencode/nested/model")).toBe("opencode-1/nested/model"); + }); + + it("leaves already-numbered and other-provider names unchanged", () => { + expect(migrateLegacyModelName("opencode-1/deepseek-v4-flash")).toBe( + "opencode-1/deepseek-v4-flash", + ); + expect(migrateLegacyModelName("umans/umans-glm-5.2")).toBe("umans/umans-glm-5.2"); + expect(migrateLegacyModelName("opencode-2/x")).toBe("opencode-2/x"); + }); + + it("is idempotent", () => { + const once = migrateLegacyModelName("opencode/deepseek-v4-flash"); + expect(migrateLegacyModelName(once)).toBe(once); + }); +}); + describe("modelsForKey", () => { it("returns the model suffixes under a key, in order", () => { const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"]; diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts index 602e0ef..5da6e7b 100644 --- a/src/features/chat/model-select.ts +++ b/src/features/chat/model-select.ts @@ -29,15 +29,61 @@ export function joinModelName(key: string, model: string): string { /** Distinct keys across all models, in first-seen order. */ export function modelKeys(models: readonly string[]): string[] { const seen = new Set<string>(); - const out: string[] = []; + const openCodeKeys: Array<{ key: string; n: number }> = []; + const otherKeys: string[] = []; for (const full of models) { const { key } = splitModelName(full); - if (!seen.has(key)) { - seen.add(key); - out.push(key); - } + if (seen.has(key)) continue; + seen.add(key); + const n = openCodeNumber(key); + if (n !== null) openCodeKeys.push({ key, n }); + else otherKeys.push(key); } - return out; + // Numbered OpenCode credentials sort NUMERICALLY (#2 before #10), placed + // before other providers (the primary credential in this app). Others keep + // first-seen order. + openCodeKeys.sort((a, b) => a.n - b.n); + return [...openCodeKeys.map((e) => e.key), ...otherKeys]; +} + +/** + * The stable numeric identity of a numbered OpenCode provider id/key + * (`opencode-1` → 1, `opencode-10` → 10), or `null` when the id is not a + * numbered OpenCode key. Numbers are canonical positive integers with no + * leading zeros. + */ +export function openCodeNumber(key: string): number | null { + const m = /^opencode-([1-9][0-9]*)$/.exec(key); + return m === null ? null : Number(m[1]); +} + +/** True iff `id` is a numbered OpenCode provider/key (`opencode-<n>`). */ +export function isNumberedOpenCode(id: string): boolean { + return openCodeNumber(id) !== null; +} + +/** + * A friendly human label for a model key/provider id: `opencode-<n>` → + * "OpenCode #<n>"; any other id is returned unchanged. The RAW id is still what + * gets selected/persisted — this only affects display. + */ +export function modelKeyLabel(key: string): string { + const n = openCodeNumber(key); + return n === null ? key : `OpenCode #${n}`; +} + +/** + * Migration helper: rewrite a legacy `opencode/<model>` full model name to the + * numbered `opencode-1/<model>` namespace. Returns the input unchanged for + * anything that is not `opencode/`-prefixed (already-numbered names, other + * providers). Idempotent. This is the FE-side persisted-state migration; the + * backend performs its own equivalent on its own store. + */ +export function migrateLegacyModelName(full: string): string { + const legacyPrefix = "opencode/"; + if (!full.startsWith(legacyPrefix)) return full; + const model = full.slice(legacyPrefix.length); + return model.length === 0 ? full : `opencode-1/${model}`; } /** The model suffixes available under a given key, in order. */ 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", () => { diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte index 11b9feb..1d06848 100644 --- a/src/features/chat/ui/ModelSelector.svelte +++ b/src/features/chat/ui/ModelSelector.svelte @@ -1,6 +1,13 @@ <script lang="ts"> import type { ModelMetadata } from "@dispatch/transport-contract"; - import { isVisionModel, joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select"; + import { + isVisionModel, + joinModelName, + modelKeyLabel, + modelKeys, + modelsForKey, + splitModelName, + } from "../model-select"; let { models, @@ -27,8 +34,17 @@ // Whether the currently-selected full model name is vision-capable. const selectedVision = $derived(isVisionModel(modelInfo, selected)); + // Whether the currently-selected full name is PRESENT in the catalog. When a + // saved credential/model disappears, we show it as unavailable rather than + // silently switching to something else. + const selectedUnavailable = $derived(selected !== "" && !models.includes(selected)); + // The key part is itself missing from the catalog (e.g. a whole numbered + // OpenCode credential was removed). + const keyUnavailable = $derived(selected !== "" && !keys.includes(current.key)); + // Switching key jumps to the first model available under it. function selectKey(key: string): void { + if (key === current.key) return; const first = modelsForKey(models, key)[0] ?? ""; onSelect(joinModelName(key, first)); } @@ -50,8 +66,11 @@ onchange={(e) => selectKey(e.currentTarget.value)} aria-label="Key selector" > + {#if keyUnavailable} + <option value={current.key} disabled>{modelKeyLabel(current.key)} (unavailable)</option> + {/if} {#each keys as key (key)} - <option value={key}>{key}</option> + <option value={key}>{modelKeyLabel(key)}</option> {/each} </select> <select @@ -65,7 +84,15 @@ {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if} </option> {/each} + {#if selectedUnavailable && !keyUnavailable} + <option value={current.model} disabled>{current.model} (unavailable)</option> + {/if} </select> + {#if selectedUnavailable} + <div class="text-xs text-warning" role="status"> + Selected model “{selected}” is unavailable — choose another credential. + </div> + {/if} {#if selectedVision} <div class="flex items-center gap-1 text-xs text-base-content/60"> <svg diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts index 6e6b770..9c557f5 100644 --- a/src/features/concurrency/logic/view-model.test.ts +++ b/src/features/concurrency/logic/view-model.test.ts @@ -13,6 +13,7 @@ import { parseLimitInput, pauseLabel, providerFromModel, + providerLabel, providerOptions, statusLabel, summarizeLimits, @@ -95,7 +96,7 @@ describe("cooldownLabel", () => { describe("providerFromModel", () => { it("takes the part before the first slash", () => { expect(providerFromModel("openai/gpt-4o")).toBe("openai"); - expect(providerFromModel("openai-compat/gpt-4o-mini")).toBe("openai-compat"); + expect(providerFromModel("opencode-1/gpt-4o-mini")).toBe("opencode-1"); }); it("returns the whole string when there is no slash", () => { expect(providerFromModel("umans")).toBe("umans"); @@ -127,6 +128,23 @@ describe("providerOptions", () => { }); }); +describe("providerLabel", () => { + it("labels a numbered OpenCode provider as OpenCode #N", () => { + expect(providerLabel("opencode-2")).toBe("OpenCode #2"); + expect(providerLabel("opencode-10")).toBe("OpenCode #10"); + }); + + it("leaves non-OpenCode provider ids unchanged", () => { + expect(providerLabel("umans")).toBe("umans"); + expect(providerLabel("anthropic")).toBe("anthropic"); + }); + + it("rejects noncanonical numbered ids (label passthrough)", () => { + expect(providerLabel("opencode")).toBe("opencode"); + expect(providerLabel("opencode-0")).toBe("opencode-0"); + }); +}); + // ── pauseLabel + formatPauseDuration ─────────────────────────────────────────── describe("formatPauseDuration", () => { @@ -450,12 +468,12 @@ describe("normalizeConcurrencyLimits", () => { const limits = normalizeConcurrencyLimits({ limits: [ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ], }); expect(limits).toEqual([ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ]); }); @@ -510,7 +528,7 @@ describe("normalizeConcurrencyStatus", () => { providers: [ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, { - providerId: "openai-compat", + providerId: "opencode-1", limit: 5, inFlight: 5, queued: 3, @@ -532,7 +550,7 @@ describe("normalizeConcurrencyStatus", () => { }); expect(first !== undefined && !("pausedUntil" in first)).toBe(true); expect(second).toEqual({ - providerId: "openai-compat", + providerId: "opencode-1", limit: 5, inFlight: 5, queued: 3, diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts index 8a37198..80c155c 100644 --- a/src/features/concurrency/logic/view-model.ts +++ b/src/features/concurrency/logic/view-model.ts @@ -170,6 +170,18 @@ export function providerOptions( return out; } +/** + * Friendly display label for a concurrency provider id. A numbered OpenCode + * provider (`opencode-<n>`) renders as "OpenCode #<n>" (so "opencode-2" reads + * as a coherent credential identity, matching the model picker); any other id is + * shown unchanged. The RAW id is still what is sent to / displayed-with the + * backend — this only affects presentation. + */ +export function providerLabel(providerId: string): string { + const m = /^opencode-([1-9][0-9]*)$/.exec(providerId); + return m === null ? providerId : `OpenCode #${m[1]}`; +} + // ── Status → display view ────────────────────────────────────────────────────── const NO_LIMITS = "No limits configured"; diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte index bf06ac0..c8f6c8f 100644 --- a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte +++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte @@ -4,6 +4,7 @@ DEFAULT_COOLDOWN_MS, parseCooldownInput, parseLimitInput, + providerLabel, statusLabel, type Badge, type ConcurrencyLimitView, @@ -132,8 +133,8 @@ the buttons never wrap; tight gap + narrow inputs keep the provider name visible; the provider shrinks via flex-1 + min-w-0). --> <div class="flex flex-nowrap items-center gap-1"> - <span class="min-w-0 flex-1 truncate font-medium font-mono" title={limit.providerId} - >{limit.providerId}</span + <span class="min-w-0 flex-1 truncate font-medium" title={limit.providerId} + >{providerLabel(limit.providerId)}</span > <input type="text" diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte index aadb8d1..2e23f5c 100644 --- a/src/features/concurrency/ui/ConcurrencyView.svelte +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -6,6 +6,7 @@ DEFAULT_COOLDOWN_MS, parseCooldownInput, parseLimitInput, + providerLabel, providerOptions, summarizeLimits, viewConcurrencyLimits, @@ -376,7 +377,7 @@ <option value="" disabled>No providers available</option> {:else} {#each providerOpts as provider (provider)} - <option value={provider}>{provider}</option> + <option value={provider}>{providerLabel(provider)}</option> {/each} {/if} </select> diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts index a8163c2..e214692 100644 --- a/src/features/concurrency/ui/ConcurrencyView.test.ts +++ b/src/features/concurrency/ui/ConcurrencyView.test.ts @@ -11,8 +11,8 @@ import type { } from "../logic/types"; import ConcurrencyView from "./ConcurrencyView.svelte"; -// Available models → provider ids are "umans", "anthropic", "openai-compat". -const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "openai-compat/gpt-4o"] as const; +// Available models → provider ids are "umans", "anthropic", "opencode-1". +const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "opencode-1/gpt-4o"] as const; // A status entry factory (defaults to a healthy limited provider). The new // concurrency-fixes fields (`cooldownMs`, `autoReduced`) are always present. |
