summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 18:15:37 +0900
committerAdam Malczewski <[email protected]>2026-06-27 18:15:37 +0900
commitf79e3fcd846f24582ea8c536cf55f1f72d75d967 (patch)
treeb3190926745adf3527de7e3cee1de497529a122c /src
parent4cfdf106cae5d1c19191258e05a64235985178d1 (diff)
downloaddispatch-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.svelte1
-rw-r--r--src/features/concurrency/index.ts2
-rw-r--r--src/features/concurrency/logic/view-model.test.ts39
-rw-r--r--src/features/concurrency/logic/view-model.ts33
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.svelte55
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.test.ts54
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",