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 | f79e3fcd846f24582ea8c536cf55f1f72d75d967 (patch) | |
| tree | b3190926745adf3527de7e3cee1de497529a122c /src | |
| parent | 4cfdf106cae5d1c19191258e05a64235985178d1 (diff) | |
| download | dispatch-web-f79e3fcd846f24582ea8c536cf55f1f72d75d967.tar.gz dispatch-web-f79e3fcd846f24582ea8c536cf55f1f72d75d967.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')
| -rw-r--r-- | src/app/App.svelte | 1 | ||||
| -rw-r--r-- | src/features/concurrency/index.ts | 2 | ||||
| -rw-r--r-- | src/features/concurrency/logic/view-model.test.ts | 39 | ||||
| -rw-r--r-- | src/features/concurrency/logic/view-model.ts | 33 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.svelte | 55 | ||||
| -rw-r--r-- | src/features/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 @@ <!-- Per-provider concurrency limits + live status. GLOBAL (not workspace- or conversation-scoped), so the panel stays mounted across tab switches. --> <ConcurrencyView + models={store.models} loadLimits={loadConcurrencyLimits} saveLimit={saveConcurrencyLimit} deleteLimit={deleteConcurrencyLimit} diff --git a/src/features/concurrency/index.ts b/src/features/concurrency/index.ts index 0b1892b..151acb8 100644 --- a/src/features/concurrency/index.ts +++ b/src/features/concurrency/index.ts @@ -25,6 +25,8 @@ export { normalizeLimit, parseLimitInput, pauseLabel, + providerFromModel, + providerOptions, summarizeLimits, summarizeStatus, viewConcurrencyLimit, 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"; 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 (`<provider>/<model>`) — 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<string | null>(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<string | null>(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<void> { limitsLoading = true; limitsError = null; @@ -74,13 +94,12 @@ } async function handleAdd(): Promise<void> { - 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 @@ }} > <label class="flex flex-col gap-1"> - <span class="text-[10px] uppercase opacity-60">Provider id</span> - <input - class="input input-bordered input-xs w-40 font-mono" - placeholder="umans" - autocomplete="off" - spellcheck="false" + <span class="text-[10px] uppercase opacity-60">Provider</span> + <select + class="select select-bordered select-xs w-40 font-mono" + aria-label="Provider" bind:value={newProviderId} - disabled={adding} - /> + disabled={adding || providerOpts.length === 0} + > + {#if providerOpts.length === 0} + <option value="" disabled>No providers available</option> + {:else} + {#each providerOpts as provider (provider)} + <option value={provider}>{provider}</option> + {/each} + {/if} + </select> </label> <label class="flex flex-col gap-1"> <span class="text-[10px] uppercase opacity-60">Limit</span> diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts index ba458a5..14882f1 100644 --- a/src/features/concurrency/ui/ConcurrencyView.test.ts +++ b/src/features/concurrency/ui/ConcurrencyView.test.ts @@ -10,6 +10,9 @@ 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; + // Fakes for the four injected ports. Each resolves immediately so the mount // effect's initial load settles in a microtask (assertions await via findBy*). @@ -58,6 +61,7 @@ describe("ConcurrencyView", () => { const fakes = makeFakes(); render(ConcurrencyView, { props: { + models: MODELS, loadLimits: fakes.loadLimits, saveLimit: fakes.saveLimit, deleteLimit: fakes.deleteLimit, @@ -65,6 +69,9 @@ describe("ConcurrencyView", () => { }, }); + // The provider dropdown is populated from the available models' providers. + const providerSelect = await screen.findByLabelText("Provider"); + expect(providerSelect).toBeVisible(); // Limits summary (unique to the limits section) + the row's remove control. expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument(); expect(await screen.findByLabelText("Remove concurrency limit for umans")).toBeVisible(); @@ -74,11 +81,12 @@ describe("ConcurrencyView", () => { expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1); }); - it("adds a provider limit via the form (calls saveLimit + reloads)", async () => { + it("adds a provider limit via the dropdown form (calls saveLimit + reloads)", async () => { const user = userEvent.setup(); const fakes = makeFakes({ limits: [], status: [] }); render(ConcurrencyView, { props: { + models: MODELS, loadLimits: fakes.loadLimits, saveLimit: fakes.saveLimit, deleteLimit: fakes.deleteLimit, @@ -86,9 +94,9 @@ describe("ConcurrencyView", () => { }, }); - await screen.findByPlaceholderText("umans"); - - await user.type(screen.getByPlaceholderText("umans"), "anthropic"); + const providerSelect = await screen.findByLabelText("Provider"); + // Choose "anthropic" from the dropdown (the list is auto-selected first). + await user.selectOptions(providerSelect, "anthropic"); await user.type(screen.getByPlaceholderText("4"), "8"); await user.click(screen.getByRole("button", { name: "Add" })); @@ -99,11 +107,12 @@ describe("ConcurrencyView", () => { expect(await screen.findByLabelText("Remove concurrency limit for anthropic")).toBeVisible(); }); - it("disables Add when the provider id is empty or the limit is invalid", async () => { + it("disables Add when the limit is empty/invalid (provider is auto-selected)", async () => { const user = userEvent.setup(); const fakes = makeFakes({ limits: [], status: [] }); render(ConcurrencyView, { props: { + models: MODELS, loadLimits: fakes.loadLimits, saveLimit: fakes.saveLimit, deleteLimit: fakes.deleteLimit, @@ -111,24 +120,46 @@ describe("ConcurrencyView", () => { }, }); - await screen.findByPlaceholderText("umans"); + const providerSelect = await screen.findByLabelText("Provider"); + // A provider is auto-selected from the dropdown. + expect((providerSelect as HTMLSelectElement).value).not.toBe(""); const addBtn = screen.getByRole("button", { name: "Add" }); - expect(addBtn).toBeDisabled(); + expect(addBtn).toBeDisabled(); // no limit entered yet - // Provider id set but invalid limit → still disabled. - await user.type(screen.getByPlaceholderText("umans"), "openai-compat"); + // An invalid (non-numeric) limit keeps Add disabled. + await user.type(screen.getByPlaceholderText("4"), "abc"); expect(addBtn).toBeDisabled(); - // Now a valid limit → enabled. - await user.type(screen.getByPlaceholderText("4"), "5"); + // A valid positive-integer limit enables Add. + const limitInput = screen.getByPlaceholderText("4"); + await user.clear(limitInput); + await user.type(limitInput, "5"); expect(addBtn).toBeEnabled(); }); + it("shows no-providers + disables the dropdown when there are no models", async () => { + const fakes = makeFakes({ limits: [], status: [] }); + render(ConcurrencyView, { + props: { + models: [], + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + const providerSelect = await screen.findByLabelText("Provider"); + expect(providerSelect).toBeDisabled(); + expect(screen.getByRole("button", { name: "Add" })).toBeDisabled(); + }); + it("removes a provider limit via the row ✕ (calls deleteLimit + reloads)", async () => { const user = userEvent.setup(); const fakes = makeFakes(); render(ConcurrencyView, { props: { + models: MODELS, loadLimits: fakes.loadLimits, saveLimit: fakes.saveLimit, deleteLimit: fakes.deleteLimit, @@ -146,6 +177,7 @@ describe("ConcurrencyView", () => { it("surfaces a load error from the limits endpoint", async () => { const failing = { + models: MODELS, loadLimits: async (): Promise<ConcurrencyLimitsResult> => ({ ok: false, error: "Concurrency service not available", |
