diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 18:15:37 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 18:15:37 +0900 |
| commit | f4e6fb12c1ed446bc7b088a7d07b51c6de991af3 (patch) | |
| tree | aeb738337ccafc6a910559b3f0845592f2b93654 /src/features/concurrency/logic | |
| parent | 5b9f48f48053e5e2d8e456efd12a461562b6e593 (diff) | |
| download | dispatch-web-f4e6fb12c1ed446bc7b088a7d07b51c6de991af3.tar.gz dispatch-web-f4e6fb12c1ed446bc7b088a7d07b51c6de991af3.zip | |
feat(concurrency): provider dropdown instead of free-text input
Replace the Add-form provider text input with a <select> dropdown populated
from the available models' provider prefixes (<provider>/<model>), unioned
with providers already carrying a configured limit (so a limit set out-of-band
but whose model list is empty still appears). The selection auto-defaults to
the first option and falls back when an option disappears.
- logic/view-model.ts: pure providerFromModel + providerOptions(models, limits)
(distinct provider ids, first-seen order) + 7 tests.
- ConcurrencyView.svelte: models prop + <select> 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.
Diffstat (limited to 'src/features/concurrency/logic')
| -rw-r--r-- | src/features/concurrency/logic/view-model.test.ts | 39 | ||||
| -rw-r--r-- | src/features/concurrency/logic/view-model.ts | 33 |
2 files changed, 72 insertions, 0 deletions
diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts index 336a6eb..82b72b0 100644 --- a/src/features/concurrency/logic/view-model.test.ts +++ b/src/features/concurrency/logic/view-model.test.ts @@ -7,6 +7,8 @@ import { normalizeConcurrencyStatus, parseLimitInput, pauseLabel, + providerFromModel, + providerOptions, summarizeLimits, summarizeStatus, viewConcurrencyLimit, @@ -44,6 +46,43 @@ describe("parseLimitInput", () => { }); }); +// ── 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 +// (`<provider>/<model>` — 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 `<provider>/<model>` 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<string>(); + 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"; |
