From f79e3fcd846f24582ea8c536cf55f1f72d75d967 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 27 Jun 2026 18:15:37 +0900 Subject: feat(concurrency): provider dropdown instead of free-text input Replace the Add-form provider text input with a bound to newProviderId; disabled + 'No providers available' when there are no models. - App.svelte: pass models={store.models}; component test updated to the dropdown (selectOptions) + a no-models case (6 component tests). Verified: typecheck 0/0, 922 tests green, biome clean, build OK. --- src/app/App.svelte | 1 + src/features/concurrency/index.ts | 2 + src/features/concurrency/logic/view-model.test.ts | 39 +++++++++++++++ src/features/concurrency/logic/view-model.ts | 33 +++++++++++++ src/features/concurrency/ui/ConcurrencyView.svelte | 55 ++++++++++++++++------ .../concurrency/ui/ConcurrencyView.test.ts | 54 ++++++++++++++++----- 6 files changed, 158 insertions(+), 26 deletions(-) diff --git a/src/app/App.svelte b/src/app/App.svelte index b40ed09..e0f64eb 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -716,6 +716,7 @@ { }); }); +// ── providerFromModel / providerOptions ─────────────────────────────────────── + +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"); + }); + it("returns the whole string when there is no slash", () => { + expect(providerFromModel("umans")).toBe("umans"); + }); +}); + +describe("providerOptions", () => { + it("derives distinct provider ids from models, first-seen order", () => { + expect( + providerOptions(["openai/gpt-4o", "umans/umans-glm-5.2", "openai/gpt-4o-mini"], []), + ).toEqual(["openai", "umans"]); + }); + it("unions with providers already carrying a configured limit", () => { + expect(providerOptions(["openai/gpt-4o"], [{ providerId: "anthropic", limit: 4 }])).toEqual([ + "openai", + "anthropic", + ]); + }); + it("does not duplicate a provider present in both models and limits", () => { + expect(providerOptions(["openai/gpt-4o"], [{ providerId: "openai", limit: 4 }])).toEqual([ + "openai", + ]); + }); + it("ignores models whose provider prefix is empty", () => { + expect(providerOptions(["/model-only", "umans/x"], [])).toEqual(["umans"]); + }); + it("returns [] when there are no models and no limits", () => { + expect(providerOptions([], [])).toEqual([]); + }); +}); + // ── pauseLabel + formatPauseDuration ─────────────────────────────────────────── describe("formatPauseDuration", () => { diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts index 0a4a7a9..7a4fe0e 100644 --- a/src/features/concurrency/logic/view-model.ts +++ b/src/features/concurrency/logic/view-model.ts @@ -63,6 +63,39 @@ export function normalizeLimit(value: unknown): number { return int >= 1 ? int : 1; } +// ── Provider options (the Add-form dropdown) ─────────────────────────────────── +// +// A concurrency `providerId` is the credential name that prefixes a model name +// (`/` — the same key the model picker groups by). The dropdown +// is the UNION of providers discoverable from the available models AND providers +// already carrying a configured limit (so a limit set out-of-band but whose +// model list is empty still appears), in first-seen order. Models are the +// authority; a provider with models but no limit is still selectable (Add sets it). + +/** The provider id prefix of a `/` name (the part before the first `/`, or the whole string). */ +export function providerFromModel(full: string): string { + const i = full.indexOf("/"); + return i === -1 ? full : full.slice(0, i); +} + +/** Distinct provider ids to offer in the Add dropdown, first-seen order. */ +export function providerOptions( + models: readonly string[], + limits: readonly ConcurrencyLimitEntry[], +): string[] { + const seen = new Set(); + const out: string[] = []; + const add = (p: string): void => { + if (p !== "" && !seen.has(p)) { + seen.add(p); + out.push(p); + } + }; + for (const m of models) add(providerFromModel(m)); + for (const l of limits) add(l.providerId); + return out; +} + // ── Status → display view ────────────────────────────────────────────────────── const NO_LIMITS = "No limits configured"; diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte index e52e184..1b52f55 100644 --- a/src/features/concurrency/ui/ConcurrencyView.svelte +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -4,6 +4,7 @@ import { type Badge, parseLimitInput, + providerOptions, summarizeLimits, summarizeStatus, viewConcurrencyLimits, @@ -19,11 +20,14 @@ import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte"; let { + models, loadLimits, saveLimit, deleteLimit, loadStatus, }: { + /** Available models (`/`) — the source of provider ids for the Add dropdown. */ + models: readonly string[]; loadLimits: LoadConcurrencyLimits; saveLimit: SaveConcurrencyLimit; deleteLimit: DeleteConcurrencyLimit; @@ -43,23 +47,39 @@ let limitsError = $state(null); let hasLoadedLimits = $state(false); - // Add-form state. + // Add-form state. The provider id is chosen from a dropdown of known providers + // (derived from the available models + any already-configured limit providers). let newProviderId = $state(""); let newLimitInput = $state(""); let adding = $state(false); let addError = $state(null); + const providerOpts = $derived(providerOptions(models, limits)); const limitViews = $derived(viewConcurrencyLimits(limits)); const limitsSummary = $derived(summarizeLimits(limits)); const parsedNewLimit = $derived(parseLimitInput(newLimitInput)); - const trimmedProviderId = $derived(newProviderId.trim()); const canAdd = $derived( - trimmedProviderId !== "" && + newProviderId !== "" && parsedNewLimit !== null && - !limits.some((l) => l.providerId === trimmedProviderId) && + !limits.some((l) => l.providerId === newProviderId) && !adding, ); + // Keep the dropdown selection valid: default to the first option, and if the + // selected provider is removed from the options (e.g. its limit was deleted and + // it has no models), fall back to the first remaining option. Runs untracked so + // it doesn't loop on its own assignment. + $effect(() => { + const opts = providerOpts; + untrack(() => { + if (opts.length === 0) { + if (newProviderId !== "") newProviderId = ""; + return; + } + if (!opts.includes(newProviderId)) newProviderId = opts[0] ?? ""; + }); + }); + async function refreshLimits(): Promise { limitsLoading = true; limitsError = null; @@ -74,13 +94,12 @@ } async function handleAdd(): Promise { - if (parsedNewLimit === null || trimmedProviderId === "") return; + if (parsedNewLimit === null || newProviderId === "") return; adding = true; addError = null; - const result = await saveLimit(trimmedProviderId, parsedNewLimit); + const result = await saveLimit(newProviderId, parsedNewLimit); adding = false; if (result.ok) { - newProviderId = ""; newLimitInput = ""; void refreshLimits(); void refreshStatus(); @@ -191,15 +210,21 @@ }} >