summaryrefslogtreecommitdiffhomepage
path: root/src/features/concurrency/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 03:38:09 +0900
committerAdam Malczewski <[email protected]>2026-06-27 03:38:09 +0900
commit486fd488432569afe6c823e975690ddadcb688ed (patch)
tree8b34d16bc0b96d4e6e2f12215de09e771cd45e33 /src/features/concurrency/ui
parent0a271e3e8aecb1ec9db3eea873b3df1ecaab8b99 (diff)
downloaddispatch-web-486fd488432569afe6c823e975690ddadcb688ed.tar.gz
dispatch-web-486fd488432569afe6c823e975690ddadcb688ed.zip
feat(concurrency): add provider concurrency limits UI — settings + live status
Diffstat (limited to 'src/features/concurrency/ui')
-rw-r--r--src/features/concurrency/ui/ConcurrencyLimitRow.svelte106
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.svelte311
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.test.ts160
3 files changed, 577 insertions, 0 deletions
diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
new file mode 100644
index 0000000..411a3cd
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
@@ -0,0 +1,106 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import { parseLimitInput, type ConcurrencyLimitView } from "../logic/view-model";
+ import type { DeleteConcurrencyLimit, SaveConcurrencyLimit } from "../logic/types";
+
+ let {
+ limit,
+ save,
+ remove,
+ }: {
+ /** The configured limit row (providerId + current limit). */
+ limit: ConcurrencyLimitView;
+ save: SaveConcurrencyLimit;
+ remove: DeleteConcurrencyLimit;
+ } = $props();
+
+ // Inline-edit state: the raw text bound to the limit input. Seeded from the
+ // row's canonical limit, but only while the field is untouched — so a save
+ // echo / list refresh re-syncs it without clobbering an in-flight edit. Mirrors
+ // the ChatLimitField seed pattern (avoids reading the prop in the $state init).
+ let draft = $state("");
+ let lastSeed = $state("");
+ let saving = $state(false);
+ let removing = $state(false);
+ let error = $state<string | null>(null);
+
+ $effect(() => {
+ const incoming = String(limit.limit);
+ untrack(() => {
+ if (draft === lastSeed) draft = incoming;
+ lastSeed = incoming;
+ });
+ });
+
+ const parsed = $derived(parseLimitInput(draft));
+ const dirty = $derived(parsed !== null && parsed !== limit.limit);
+
+ async function handleSave(): Promise<void> {
+ if (parsed === null || parsed === limit.limit) return;
+ saving = true;
+ error = null;
+ const result = await save(limit.providerId, parsed);
+ saving = false;
+ if (result.ok) {
+ // Reflect the echoed limit back into the field immediately (the prop will
+ // also re-assert it via the seed effect above once the parent reloads).
+ draft = String(result.limit);
+ lastSeed = draft;
+ } else {
+ error = result.error;
+ }
+ }
+
+ async function handleRemove(): Promise<void> {
+ removing = true;
+ error = null;
+ const result = await remove(limit.providerId);
+ removing = false;
+ if (!result.ok) {
+ error = result.error;
+ }
+ // On success the parent drops this row (re-loaded limits list).
+ }
+</script>
+
+<div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <div class="flex items-center gap-2">
+ <span class="flex-1 truncate font-medium font-mono" title={limit.providerId}>{limit.providerId}</span>
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-20 font-mono"
+ aria-label={`Concurrency limit for ${limit.providerId}`}
+ bind:value={draft}
+ disabled={saving || removing}
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-xs"
+ disabled={!dirty || saving || removing}
+ onclick={handleSave}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Save
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs text-error"
+ aria-label={`Remove concurrency limit for ${limit.providerId}`}
+ disabled={saving || removing}
+ onclick={handleRemove}
+ >
+ {#if removing}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ ✕
+ {/if}
+ </button>
+ </div>
+ {#if error}
+ <span class="font-mono text-xs text-error">{error}</span>
+ {/if}
+</div>
diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte
new file mode 100644
index 0000000..078c003
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyView.svelte
@@ -0,0 +1,311 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+ import {
+ type Badge,
+ type ConcurrencyLimitView,
+ parseLimitInput,
+ summarizeLimits,
+ summarizeStatus,
+ viewConcurrencyLimits,
+ viewConcurrencyStatuses,
+ } from "../logic/view-model";
+ import type {
+ ConcurrencyLimitEntry,
+ DeleteConcurrencyLimit,
+ LoadConcurrencyLimits,
+ LoadConcurrencyStatus,
+ SaveConcurrencyLimit,
+ } from "../logic/types";
+ import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte";
+
+ let {
+ loadLimits,
+ saveLimit,
+ deleteLimit,
+ loadStatus,
+ }: {
+ loadLimits: LoadConcurrencyLimits;
+ saveLimit: SaveConcurrencyLimit;
+ deleteLimit: DeleteConcurrencyLimit;
+ loadStatus: LoadConcurrencyStatus;
+ } = $props();
+
+ const badgeClass: Record<Badge, string> = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ neutral: "badge-ghost",
+ };
+
+ // ── Limits (config: list / add / update / remove) ────────────────────────────
+ let limits = $state<readonly ConcurrencyLimitEntry[]>([]);
+ let limitsLoading = $state(false);
+ let limitsError = $state<string | null>(null);
+ let hasLoadedLimits = $state(false);
+
+ // Add-form state.
+ let newProviderId = $state("");
+ let newLimitInput = $state("");
+ let adding = $state(false);
+ let addError = $state<string | null>(null);
+
+ const limitViews = $derived(viewConcurrencyLimits(limits));
+ const limitsSummary = $derived(summarizeLimits(limits));
+ const parsedNewLimit = $derived(parseLimitInput(newLimitInput));
+ const trimmedProviderId = $derived(newProviderId.trim());
+ const canAdd = $derived(
+ trimmedProviderId !== "" &&
+ parsedNewLimit !== null &&
+ !limits.some((l) => l.providerId === trimmedProviderId) &&
+ !adding,
+ );
+
+ async function refreshLimits(): Promise<void> {
+ limitsLoading = true;
+ limitsError = null;
+ const result = await loadLimits();
+ limitsLoading = false;
+ hasLoadedLimits = true;
+ if (result.ok) {
+ limits = result.limits;
+ } else {
+ limitsError = result.error;
+ }
+ }
+
+ async function handleAdd(): Promise<void> {
+ if (parsedNewLimit === null || trimmedProviderId === "") return;
+ adding = true;
+ addError = null;
+ const result = await saveLimit(trimmedProviderId, parsedNewLimit);
+ adding = false;
+ if (result.ok) {
+ newProviderId = "";
+ newLimitInput = "";
+ void refreshLimits();
+ void refreshStatus();
+ } else {
+ addError = result.error;
+ }
+ }
+
+ // Wrap the ports so a row's save/remove reloads the authoritative list + status
+ // on success (the row still gets the result to drive its own UI).
+ async function rowSave(providerId: string, limit: number) {
+ const result = await saveLimit(providerId, limit);
+ if (result.ok) {
+ void refreshLimits();
+ void refreshStatus();
+ }
+ return result;
+ }
+
+ async function rowRemove(providerId: string) {
+ const result = await deleteLimit(providerId);
+ if (result.ok) {
+ void refreshLimits();
+ void refreshStatus();
+ }
+ return result;
+ }
+
+ // ── Live status (polls while mounted) ───────────────────────────────────────
+ let statusEntries = $state<readonly ConcurrencyStatusEntry[]>([]);
+ let statusLoading = $state(false);
+ let statusError = $state<string | null>(null);
+ let hasLoadedStatus = $state(false);
+ let now = $state(Date.now());
+
+ // A 1s clock so a `paused — resumes in Ns` countdown ticks live between polls.
+ $effect(() => {
+ const h = setInterval(() => {
+ now = Date.now();
+ }, 1000);
+ return () => clearInterval(h);
+ });
+
+ const statusViews = $derived(viewConcurrencyStatuses(statusEntries, now));
+ const statusSummary = $derived(summarizeStatus(statusEntries, now));
+
+ async function refreshStatus(): Promise<void> {
+ statusLoading = true;
+ statusError = null;
+ const result = await loadStatus();
+ statusLoading = false;
+ hasLoadedStatus = true;
+ if (result.ok) {
+ statusEntries = result.providers;
+ } else {
+ statusError = result.error;
+ }
+ }
+
+ const STATUS_POLL_MS = 2000;
+
+ // Load limits + status on mount, and poll the live status while the view is
+ // alive (a running provider's in-flight/queued/paused transitions stay fresh
+ // without a manual refresh). Runs once — no reactive deps read inside.
+ $effect(() => {
+ untrack(() => {
+ void refreshLimits();
+ void refreshStatus();
+ });
+ const h = setInterval(() => {
+ void refreshStatus();
+ }, STATUS_POLL_MS);
+ return () => clearInterval(h);
+ });
+</script>
+
+<div class="flex flex-col gap-4">
+ <!-- Limits (config) -->
+ <section class="flex flex-col gap-2">
+ <div class="flex items-center justify-between gap-2">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Concurrency limits</h3>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={limitsLoading}
+ onclick={() => refreshLimits()}
+ aria-label="Refresh concurrency limits"
+ >
+ {#if limitsLoading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Refresh
+ {/if}
+ </button>
+ </div>
+ <p class="text-xs opacity-60">
+ Cap how many concurrent token-generating requests a provider may run. At the cap, further
+ requests queue (oldest-agent-first); remove a limit to make a provider unlimited. Limits are
+ in-memory only.
+ </p>
+
+ <!-- Add form -->
+ <form
+ class="flex flex-wrap items-end gap-2"
+ onsubmit={(e) => {
+ e.preventDefault();
+ void handleAdd();
+ }}
+ >
+ <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"
+ bind:value={newProviderId}
+ disabled={adding}
+ />
+ </label>
+ <label class="flex flex-col gap-1">
+ <span class="text-[10px] uppercase opacity-60">Limit</span>
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-20 font-mono"
+ placeholder="4"
+ bind:value={newLimitInput}
+ disabled={adding}
+ />
+ </label>
+ <button
+ type="submit"
+ class="btn btn-primary btn-xs"
+ disabled={!canAdd}
+ >
+ {#if adding}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Add
+ {/if}
+ </button>
+ </form>
+ {#if addError}
+ <p class="font-mono text-xs text-error">{addError}</p>
+ {/if}
+
+ <span class="text-xs opacity-70">{limitsSummary}</span>
+
+ {#if limitsError}
+ <p class="text-xs text-error">{limitsError}</p>
+ {:else if hasLoadedLimits && limitViews.length === 0 && !limitsLoading}
+ <p class="text-xs opacity-60">No limits configured — providers run unlimited.</p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each limitViews as limit (limit.providerId)}
+ <li>
+ <ConcurrencyLimitRow {limit} save={rowSave} remove={rowRemove} />
+ </li>
+ {/each}
+ </ul>
+ {/if}
+ </section>
+
+ <!-- Live status -->
+ <section class="flex flex-col gap-2">
+ <div class="flex items-center justify-between gap-2">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Live status</h3>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={statusLoading}
+ onclick={() => refreshStatus()}
+ aria-label="Refresh concurrency status"
+ >
+ {#if statusLoading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Refresh
+ {/if}
+ </button>
+ </div>
+ <p class="text-xs opacity-60">
+ In-flight slots held vs the cap (e.g. 2/4), agents queued waiting, and a paused state when a
+ provider backs off after a 429. Polls every 2s.
+ </p>
+
+ <span class="text-xs opacity-70">{statusSummary}</span>
+
+ {#if statusError}
+ <p class="text-xs text-error">{statusError}</p>
+ {:else if hasLoadedStatus && statusViews.length === 0 && !statusLoading}
+ <p class="text-xs opacity-60">No limits configured — nothing to report.</p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each statusViews as s (s.providerId)}
+ <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <div class="flex items-center justify-between gap-2">
+ <span class="font-medium font-mono" title={s.providerId}>{s.providerId}</span>
+ <span class="badge badge-sm {badgeClass[s.badge]} gap-1">
+ {#if s.busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {#if s.paused}
+ Paused
+ {:else if s.inFlight >= s.limit && s.queued > 0}
+ At capacity
+ {:else if s.inFlight > 0}
+ Active
+ {:else}
+ Idle
+ {/if}
+ </span>
+ </div>
+ <div class="flex flex-wrap items-center justify-between gap-2 text-xs opacity-70">
+ <span title="In-flight slots held vs cap">{s.inFlightLabel} in flight</span>
+ <span>{s.queuedLabel}</span>
+ </div>
+ {#if s.pausedLabel}
+ <span class="text-xs text-warning">{s.pausedLabel}</span>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+ {/if}
+ </section>
+</div>
diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts
new file mode 100644
index 0000000..ba458a5
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyView.test.ts
@@ -0,0 +1,160 @@
+import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import type {
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusResult,
+} from "../logic/types";
+import ConcurrencyView from "./ConcurrencyView.svelte";
+
+// Fakes for the four injected ports. Each resolves immediately so the mount
+// effect's initial load settles in a microtask (assertions await via findBy*).
+
+function makeFakes(opts?: {
+ limits?: readonly { providerId: string; limit: number }[];
+ status?: readonly ConcurrencyStatusEntry[];
+}) {
+ let limits = opts?.limits ?? [{ providerId: "umans", limit: 4 }];
+ const status = opts?.status ?? [
+ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
+ ];
+
+ const calls = {
+ loadLimits: 0,
+ loadStatus: 0,
+ saves: [] as { providerId: string; limit: number }[],
+ deletes: [] as string[],
+ };
+
+ return {
+ calls,
+ loadLimits: async (): Promise<ConcurrencyLimitsResult> => {
+ calls.loadLimits++;
+ return { ok: true, limits };
+ },
+ saveLimit: async (providerId: string, limit: number): Promise<ConcurrencyLimitResult> => {
+ calls.saves.push({ providerId, limit });
+ // Reflect the new limit into the list the next load returns.
+ limits = [...limits.filter((l) => l.providerId !== providerId), { providerId, limit }];
+ return { ok: true, providerId, limit };
+ },
+ deleteLimit: async (providerId: string): Promise<ConcurrencyDeleteResult> => {
+ calls.deletes.push(providerId);
+ limits = limits.filter((l) => l.providerId !== providerId);
+ return { ok: true, providerId };
+ },
+ loadStatus: async (): Promise<ConcurrencyStatusResult> => {
+ calls.loadStatus++;
+ return { ok: true, providers: status };
+ },
+ };
+}
+
+describe("ConcurrencyView", () => {
+ it("loads + renders the configured limits and live status on mount", async () => {
+ const fakes = makeFakes();
+ render(ConcurrencyView, {
+ props: {
+ loadLimits: fakes.loadLimits,
+ saveLimit: fakes.saveLimit,
+ deleteLimit: fakes.deleteLimit,
+ loadStatus: fakes.loadStatus,
+ },
+ });
+
+ // 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();
+ // Status summary (unique to the status section).
+ expect(await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/)).toBeInTheDocument();
+ expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(1);
+ expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1);
+ });
+
+ it("adds a provider limit via the form (calls saveLimit + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, {
+ props: {
+ loadLimits: fakes.loadLimits,
+ saveLimit: fakes.saveLimit,
+ deleteLimit: fakes.deleteLimit,
+ loadStatus: fakes.loadStatus,
+ },
+ });
+
+ await screen.findByPlaceholderText("umans");
+
+ await user.type(screen.getByPlaceholderText("umans"), "anthropic");
+ await user.type(screen.getByPlaceholderText("4"), "8");
+ await user.click(screen.getByRole("button", { name: "Add" }));
+
+ expect(fakes.calls.saves).toEqual([{ providerId: "anthropic", limit: 8 }]);
+ // After save the component reloads the limits list (now showing the row).
+ expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2);
+ expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument();
+ 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 () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, {
+ props: {
+ loadLimits: fakes.loadLimits,
+ saveLimit: fakes.saveLimit,
+ deleteLimit: fakes.deleteLimit,
+ loadStatus: fakes.loadStatus,
+ },
+ });
+
+ await screen.findByPlaceholderText("umans");
+ const addBtn = screen.getByRole("button", { name: "Add" });
+ expect(addBtn).toBeDisabled();
+
+ // Provider id set but invalid limit → still disabled.
+ await user.type(screen.getByPlaceholderText("umans"), "openai-compat");
+ expect(addBtn).toBeDisabled();
+
+ // Now a valid limit → enabled.
+ await user.type(screen.getByPlaceholderText("4"), "5");
+ expect(addBtn).toBeEnabled();
+ });
+
+ it("removes a provider limit via the row ✕ (calls deleteLimit + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes();
+ render(ConcurrencyView, {
+ props: {
+ loadLimits: fakes.loadLimits,
+ saveLimit: fakes.saveLimit,
+ deleteLimit: fakes.deleteLimit,
+ loadStatus: fakes.loadStatus,
+ },
+ });
+
+ // Wait for the limits to load (unique summary) before interacting.
+ await screen.findByText(/1 limit configured/);
+ await user.click(screen.getByLabelText("Remove concurrency limit for umans"));
+
+ expect(fakes.calls.deletes).toEqual(["umans"]);
+ expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2);
+ });
+
+ it("surfaces a load error from the limits endpoint", async () => {
+ const failing = {
+ loadLimits: async (): Promise<ConcurrencyLimitsResult> => ({
+ ok: false,
+ error: "Concurrency service not available",
+ }),
+ saveLimit: async (): Promise<ConcurrencyLimitResult> => ({ ok: false, error: "noop" }),
+ deleteLimit: async (): Promise<ConcurrencyDeleteResult> => ({ ok: false, error: "noop" }),
+ loadStatus: async (): Promise<ConcurrencyStatusResult> => ({ ok: true, providers: [] }),
+ };
+ render(ConcurrencyView, { props: failing });
+ expect(await screen.findByText("Concurrency service not available")).toBeVisible();
+ });
+});