summaryrefslogtreecommitdiffhomepage
path: root/src/features/concurrency/logic
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
commitf4e6fb12c1ed446bc7b088a7d07b51c6de991af3 (patch)
treeaeb738337ccafc6a910559b3f0845592f2b93654 /src/features/concurrency/logic
parent5b9f48f48053e5e2d8e456efd12a461562b6e593 (diff)
downloaddispatch-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.ts39
-rw-r--r--src/features/concurrency/logic/view-model.ts33
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";