summaryrefslogtreecommitdiffhomepage
path: root/src/features/concurrency/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/concurrency/ui')
-rw-r--r--src/features/concurrency/ui/AutoReduceBanner.svelte81
-rw-r--r--src/features/concurrency/ui/ConcurrencyLimitRow.svelte204
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.svelte433
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.test.ts559
4 files changed, 1277 insertions, 0 deletions
diff --git a/src/features/concurrency/ui/AutoReduceBanner.svelte b/src/features/concurrency/ui/AutoReduceBanner.svelte
new file mode 100644
index 0000000..132ebc7
--- /dev/null
+++ b/src/features/concurrency/ui/AutoReduceBanner.svelte
@@ -0,0 +1,81 @@
+<script lang="ts">
+ import type { AutoReduceNotice } from "../logic/view-model";
+ import type { RestoreOutcome } from "../logic/types";
+
+ let {
+ notice,
+ onRestore,
+ onDismiss,
+ }: {
+ /** The auto-reduce banner view (providerId + message + from/current limit). */
+ notice: AutoReduceNotice;
+ /**
+ * "Restore to N" — PUT the limit back to `fromLimit`. Returns the outcome so
+ * a FAILED restore surfaces an inline error here (the banner owns its error
+ * display; the parent only refreshes on success).
+ */
+ onRestore: (providerId: string, limit: number) => Promise<RestoreOutcome>;
+ /** Hide this banner locally (persists hidden while autoReduced stays true). */
+ onDismiss: (providerId: string) => void;
+ } = $props();
+
+ let restoring = $state(false);
+ /** Inline restore error (e.g. "Concurrency service not available"); cleared on retry. */
+ let error = $state<string | null>(null);
+
+ async function handleRestore(): Promise<void> {
+ restoring = true;
+ error = null;
+ // The parent PUTs the limit + refreshes status on success; the banner clears
+ // once the next poll shows autoReduced===false. On failure the outcome is
+ // bubbled back here so the error shows inline next to the button.
+ const result = await onRestore(notice.providerId, notice.fromLimit);
+ restoring = false;
+ if (!result.ok) {
+ error = result.error;
+ }
+ }
+</script>
+
+<div
+ class="alert alert-warning flex flex-col gap-2 py-2 text-xs"
+ role="status"
+ data-testid={`auto-reduce-banner-${notice.providerId}`}
+>
+ <div class="flex items-start gap-2">
+ <span class="shrink-0">⚠</span>
+ <div class="flex-1">
+ <p>{notice.message}</p>
+ <p class="opacity-70">
+ Was {notice.fromLimit}, now {notice.currentLimit}.
+ </p>
+ </div>
+ <div class="flex shrink-0 items-center gap-1">
+ <!-- The "Restore to N" text stays visible while loading (only the spinner is
+ prepended) so the button keeps its accessible name during the PUT — a
+ spinner-only button loses its name for screen-reader users. -->
+ <button
+ type="button"
+ class="btn btn-warning btn-xs gap-1"
+ disabled={restoring}
+ onclick={handleRestore}
+ >
+ {#if restoring}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ Restore to {notice.fromLimit}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ aria-label={`Dismiss auto-reduce notice for ${notice.providerId}`}
+ onclick={() => onDismiss(notice.providerId)}
+ >
+ ✕
+ </button>
+ </div>
+ </div>
+ {#if error}
+ <p class="font-mono text-error" data-testid={`restore-error-${notice.providerId}`}>{error}</p>
+ {/if}
+</div>
diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
new file mode 100644
index 0000000..bf06ac0
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
@@ -0,0 +1,204 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import {
+ DEFAULT_COOLDOWN_MS,
+ parseCooldownInput,
+ parseLimitInput,
+ statusLabel,
+ type Badge,
+ type ConcurrencyLimitView,
+ type ConcurrencyStatusView,
+ } from "../logic/view-model";
+ import type {
+ DeleteConcurrencyLimit,
+ SaveConcurrencyCooldown,
+ SaveConcurrencyLimit,
+ } from "../logic/types";
+
+ let {
+ limit,
+ status,
+ save,
+ saveCooldown,
+ remove,
+ }: {
+ /** The configured limit row (providerId + current limit). */
+ limit: ConcurrencyLimitView;
+ /** The provider's live status view (in-flight/queue/badge), or null when no
+ * status entry exists yet. Drives the status line + seeds the cooldown input. */
+ status: ConcurrencyStatusView | null;
+ save: SaveConcurrencyLimit;
+ saveCooldown: SaveConcurrencyCooldown;
+ remove: DeleteConcurrencyLimit;
+ } = $props();
+
+ // The badge→color map (presentational). Mirrors the old status-card mapping.
+ const badgeClass: Record<Badge, string> = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ neutral: "badge-ghost",
+ };
+
+ // The cooldown input seed: the live cooldown when a status entry exists, else
+ // the server default (350).
+ const cooldownMs = $derived(status?.cooldownMs ?? DEFAULT_COOLDOWN_MS);
+
+ // Inline-edit state for the limit + cooldown inputs. Each is seeded from its
+ // canonical value, but only while untouched — so a save echo / status-poll
+ // refresh re-syncs without clobbering an in-flight edit. Mirrors the
+ // ChatLimitField seed pattern (avoids reading the prop in the $state init).
+ let limitDraft = $state("");
+ let lastLimitSeed = $state("");
+ let cooldownDraft = $state("");
+ let lastCooldownSeed = $state("");
+ let saving = $state(false);
+ let removing = $state(false);
+ let error = $state<string | null>(null);
+ /** Brief "Saved" confirmation after a successful save; cleared on edit. */
+ let justSaved = $state(false);
+
+ $effect(() => {
+ const incomingLimit = String(limit.limit);
+ const incomingCooldown = String(cooldownMs);
+ untrack(() => {
+ if (limitDraft === lastLimitSeed) limitDraft = incomingLimit;
+ lastLimitSeed = incomingLimit;
+ if (cooldownDraft === lastCooldownSeed) cooldownDraft = incomingCooldown;
+ lastCooldownSeed = incomingCooldown;
+ });
+ });
+
+ const parsedLimit = $derived(parseLimitInput(limitDraft));
+ const parsedCooldown = $derived(parseCooldownInput(cooldownDraft));
+ const dirtyLimit = $derived(parsedLimit !== null && parsedLimit !== limit.limit);
+ const dirtyCooldown = $derived(parsedCooldown !== null && parsedCooldown !== cooldownMs);
+ const dirty = $derived(dirtyLimit || dirtyCooldown);
+
+ // Clear the "Saved" hint + any error as soon as the user edits either field.
+ function onInput(): void {
+ justSaved = false;
+ error = null;
+ }
+
+ // "Set" saves whichever field is dirty: the limit first (PUT
+ // /concurrency/limits/:id), then the cooldown (PUT /concurrency/cooldown/:id).
+ // Stops + surfaces an inline error on the first failure.
+ async function handleSet(): Promise<void> {
+ if (!dirty || saving || removing) return;
+ saving = true;
+ error = null;
+ try {
+ if (dirtyLimit && parsedLimit !== null) {
+ const r = await save(limit.providerId, parsedLimit);
+ if (!r.ok) {
+ error = r.error;
+ return;
+ }
+ // Reflect the echoed limit back immediately (the prop re-asserts it via
+ // the seed effect once the parent reloads).
+ limitDraft = String(r.limit);
+ lastLimitSeed = limitDraft;
+ }
+ if (dirtyCooldown && parsedCooldown !== null) {
+ const r = await saveCooldown(limit.providerId, parsedCooldown);
+ if (!r.ok) {
+ error = r.error;
+ return;
+ }
+ cooldownDraft = String(r.cooldownMs);
+ lastCooldownSeed = cooldownDraft;
+ }
+ justSaved = true;
+ } finally {
+ saving = false;
+ }
+ }
+
+ 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">
+ <!-- Line 1: provider + limit + cooldown + Set + ✕ (all on one line — nowrap so
+ the buttons never wrap; tight gap + narrow inputs keep the provider name
+ visible; the provider shrinks via flex-1 + min-w-0). -->
+ <div class="flex flex-nowrap items-center gap-1">
+ <span class="min-w-0 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-12 min-w-0 font-mono"
+ aria-label={`Concurrency limit for ${limit.providerId}`}
+ bind:value={limitDraft}
+ oninput={onInput}
+ disabled={saving || removing}
+ />
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-14 min-w-0 font-mono"
+ aria-label={`Release cooldown (ms) for ${limit.providerId}`}
+ bind:value={cooldownDraft}
+ oninput={onInput}
+ disabled={saving || removing}
+ />
+ <span class="shrink-0 text-[10px] opacity-50">ms</span>
+ <button
+ type="button"
+ class="btn btn-primary btn-xs shrink-0"
+ aria-label={`Set concurrency for ${limit.providerId}`}
+ disabled={!dirty || saving || removing}
+ onclick={handleSet}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs shrink-0 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>
+
+ <!-- Line 2: in-flight count (left) + status badge (right). Hidden until the
+ first status poll for this provider lands. -->
+ {#if status !== null}
+ <div class="flex items-center justify-between gap-2 text-xs opacity-70">
+ <span title="In-flight slots held vs cap">{status.inFlightLabel} in flight</span>
+ <span class="badge badge-sm {badgeClass[status.badge]} gap-1">
+ {#if status.busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {statusLabel(status)}
+ </span>
+ </div>
+ {/if}
+
+ {#if error}
+ <span class="font-mono text-xs text-error">{error}</span>
+ {:else if justSaved && !dirty}
+ <span class="text-xs text-success">Saved.</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..aadb8d1
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyView.svelte
@@ -0,0 +1,433 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+ import {
+ autoReduceNotices,
+ DEFAULT_COOLDOWN_MS,
+ parseCooldownInput,
+ parseLimitInput,
+ providerOptions,
+ summarizeLimits,
+ viewConcurrencyLimits,
+ viewConcurrencyStatus,
+ type ConcurrencyStatusView,
+ } from "../logic/view-model";
+ import type {
+ ConcurrencyLimitEntry,
+ DeleteConcurrencyLimit,
+ LoadConcurrencyLimits,
+ LoadConcurrencyStatus,
+ RestoreOutcome,
+ SaveConcurrencyCooldown,
+ SaveConcurrencyLimit,
+ } from "../logic/types";
+ import AutoReduceBanner from "./AutoReduceBanner.svelte";
+ import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte";
+
+ let {
+ models,
+ loadLimits,
+ saveLimit,
+ deleteLimit,
+ loadStatus,
+ saveCooldown,
+ }: {
+ /** Available models (`<provider>/<model>`) — the source of provider ids for the Add dropdown. */
+ models: readonly string[];
+ loadLimits: LoadConcurrencyLimits;
+ saveLimit: SaveConcurrencyLimit;
+ deleteLimit: DeleteConcurrencyLimit;
+ loadStatus: LoadConcurrencyStatus;
+ saveCooldown: SaveConcurrencyCooldown;
+ } = $props();
+
+ // ── Limits (config: list / add / update / remove) ────────────────────────────
+ let limits = $state<readonly ConcurrencyLimitEntry[]>([]);
+ let limitsError = $state<string | null>(null);
+ /** True after the first load settles (gates the empty state). */
+ let hasLoadedLimits = $state(false);
+ /** Re-entrancy guard for background/silent refreshes (no UI — prevents
+ * overlapping fetches). The refresh is near-instant, so a visible loading
+ * indicator would flicker every poll/reload; it stays INVISIBLE (mirrors the
+ * heartbeat runs list). */
+ let limitsInFlight = false;
+
+ // Add-row state. The provider id is chosen from a dropdown of known providers
+ // (derived from the available models + any already-configured limit providers).
+ // The row is revealed by the "Add" button; "Set" saves it, ✕ cancels.
+ let addOpen = $state(false);
+ let newProviderId = $state("");
+ let newLimitInput = $state("");
+ let newCooldownInput = $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 parsedNewCooldown = $derived(parseCooldownInput(newCooldownInput));
+ /** Only send a cooldown PUT when the user moved it off the server default. */
+ const newCooldownChanged = $derived(
+ parsedNewCooldown !== null && parsedNewCooldown !== DEFAULT_COOLDOWN_MS,
+ );
+ const canSet = $derived(
+ newProviderId !== "" &&
+ parsedNewLimit !== null &&
+ parsedNewCooldown !== null &&
+ !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> {
+ if (limitsInFlight) return;
+ limitsInFlight = true;
+ const result = await loadLimits();
+ limitsInFlight = false;
+ hasLoadedLimits = true;
+ if (result.ok) {
+ limits = result.limits;
+ // Clear the error only on success so it stays visible (stable, no flicker)
+ // during an in-flight retry rather than vanishing mid-refresh.
+ limitsError = null;
+ } else {
+ limitsError = result.error;
+ }
+ }
+
+ function startAdd(): void {
+ addOpen = true;
+ addError = null;
+ newLimitInput = "";
+ newCooldownInput = String(DEFAULT_COOLDOWN_MS);
+ // newProviderId is kept valid (defaults to the first option) by the effect above.
+ }
+
+ function cancelAdd(): void {
+ addOpen = false;
+ addError = null;
+ newLimitInput = "";
+ newCooldownInput = "";
+ }
+
+ // "Set" on the add row: save the limit, then the cooldown (only when the user
+ // moved it off the server default of 350ms — the backend defaults to 350 when a
+ // limit is set, so an unchanged value needs no extra PUT). On full success the
+ // add row closes + the limits/status reload (the new limit appears as a row).
+ async function handleAdd(): Promise<void> {
+ if (parsedNewLimit === null || parsedNewCooldown === null || newProviderId === "") return;
+ adding = true;
+ addError = null;
+ const limitResult = await saveLimit(newProviderId, parsedNewLimit);
+ if (!limitResult.ok) {
+ adding = false;
+ addError = limitResult.error;
+ return;
+ }
+ if (newCooldownChanged) {
+ const cooldownResult = await saveCooldown(newProviderId, parsedNewCooldown);
+ adding = false;
+ if (!cooldownResult.ok) {
+ // The limit was saved (→ a row will appear after reload); the cooldown PUT
+ // failed. Surface the error but keep the add row open so it's visible. The
+ // user can edit the cooldown on the now-saved row.
+ addError = cooldownResult.error;
+ void refreshLimits();
+ void refreshStatus();
+ return;
+ }
+ } else {
+ adding = false;
+ }
+ addOpen = false;
+ newLimitInput = "";
+ newCooldownInput = "";
+ void refreshLimits();
+ void refreshStatus();
+ }
+
+ // 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;
+ }
+
+ // Wrap the cooldown save so a successful PUT refreshes the live status (which
+ // re-carries the new `cooldownMs`). The row still gets the result to drive its
+ // own UI.
+ async function cooldownSave(providerId: string, cooldownMs: number) {
+ const result = await saveCooldown(providerId, cooldownMs);
+ if (result.ok) {
+ void refreshStatus();
+ }
+ return result;
+ }
+
+ // ── Live status (polls while mounted — seeds cooldown inputs + drives the
+ // auto-reduce banners; the poll is silent, no status cards) ────────────────
+ let statusEntries = $state<readonly ConcurrencyStatusEntry[]>([]);
+ let statusError = $state<string | null>(null);
+ /** True after the first load settles (gates the empty state). */
+ let hasLoadedStatus = $state(false);
+ /** Re-entrancy guard for the 2s background poll (no UI — a visible loading
+ * indicator flickered every poll because the refresh is near-instant; it stays
+ * INVISIBLE, mirroring the heartbeat runs list). */
+ let statusInFlight = false;
+
+ // Per-provider status view (from the live status poll) so each saved limit row
+ // renders its in-flight count + status badge + seeds its cooldown input. Null
+ // when a provider has no status entry yet (the row falls back to Idle + the
+ // server-default cooldown of 350).
+ const statusByProvider = $derived.by(() => {
+ const map = new Map<string, ConcurrencyStatusView>();
+ for (const e of statusEntries) map.set(e.providerId, viewConcurrencyStatus(e));
+ return map;
+ });
+
+ // ── Auto-reduce banners (persist while autoReduced===true; dismissible) ───────
+ //
+ // When a provider's limit is auto-reduced by a 429, `GET /concurrency/status`
+ // carries `autoReduced: true` (+ `autoReducedFrom` + `notice`). We render a
+ // banner per such provider. The banner is DISMISSIBLE: a dismissed provider
+ // stays hidden while it remains auto-reduced (persist-while-true), and is
+ // UN-dismissed the moment a poll shows it no longer auto-reduced — so a future
+ // auto-reduce re-shows the banner. Restoring the limit (PUT) clears
+ // `autoReduced` server-side → the next poll drops the banner automatically.
+ //
+ // The dismissed set is intentionally COMPONENT-LOCAL (NOT persisted to
+ // localStorage / a module-global): it resets on remount (sidebar view switch /
+ // reload). This is correct — `autoReduced` is a REAL persisted degraded state,
+ // so re-showing the banner on a fresh mount reminds the user. Persisting a
+ // dismissal across reloads would risk HIDING an ongoing degradation (a
+ // footgun), and AGENTS.md forbids module-global ambient state. Mirrors the
+ // component-local `limitsError`/`statusError` pattern.
+ let dismissedAutoReduce = $state<ReadonlySet<string>>(new Set());
+
+ const allNotices = $derived(autoReduceNotices(statusEntries));
+ const visibleNotices = $derived(
+ allNotices.filter((n) => !dismissedAutoReduce.has(n.providerId)),
+ );
+
+ // Reconcile the dismissed set against the live auto-reduced providers: keep a
+ // dismissed entry ONLY while its provider is still auto-reduced. A provider
+ // that has been restored (no longer in `allNotices`) is dropped from the
+ // dismissed set so a future auto-reduce re-shows its banner.
+ $effect(() => {
+ const autoReducedIds = new Set(allNotices.map((n) => n.providerId));
+ untrack(() => {
+ let changed = false;
+ const next = new Set<string>();
+ for (const id of dismissedAutoReduce) {
+ if (autoReducedIds.has(id)) next.add(id);
+ else changed = true;
+ }
+ if (changed) dismissedAutoReduce = next;
+ });
+ });
+
+ function dismissAutoReduce(providerId: string): void {
+ if (dismissedAutoReduce.has(providerId)) return;
+ dismissedAutoReduce = new Set([...dismissedAutoReduce, providerId]);
+ }
+
+ // "Restore to N" — PUT the limit back to `autoReducedFrom` via the limits
+ // endpoint (a manual PUT clears `autoReduced` server-side). Refreshes limits +
+ // status on success; the next status poll shows `autoReduced===false` and the
+ // banner drops (the dismissed-set effect above un-dismisses it too). The banner
+ // component owns its own restoring-spinner + inline error; on FAILURE the
+ // outcome is bubbled back so the banner shows the error inline (instead of
+ // silently re-enabling the button / surfacing it only in the limits section).
+ async function restoreLimit(providerId: string, limit: number): Promise<RestoreOutcome> {
+ const result = await saveLimit(providerId, limit);
+ if (result.ok) {
+ void refreshLimits();
+ void refreshStatus();
+ return { ok: true };
+ }
+ return { ok: false, error: result.error };
+ }
+
+ async function refreshStatus(): Promise<void> {
+ if (statusInFlight) return;
+ statusInFlight = true;
+ const result = await loadStatus();
+ statusInFlight = false;
+ hasLoadedStatus = true;
+ if (result.ok) {
+ statusEntries = result.providers;
+ // Clear the error only on success so it stays visible (stable, no flicker)
+ // during an in-flight retry rather than vanishing mid-poll.
+ statusError = null;
+ } else {
+ statusError = result.error;
+ }
+ }
+
+ const STATUS_POLL_MS = 2000;
+
+ // Load limits + status on mount, and poll the live status while the view is
+ // alive (so a saved limit's cooldown input re-seeds + auto-reduce banners 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">
+ <!-- Auto-reduce banners (appear when a provider's limit was auto-reduced by a 429) -->
+ {#if visibleNotices.length > 0}
+ <section class="flex flex-col gap-2" aria-label="Concurrency auto-reduce notices">
+ {#each visibleNotices as notice (notice.providerId)}
+ <AutoReduceBanner {notice} onRestore={restoreLimit} onDismiss={dismissAutoReduce} />
+ {/each}
+ </section>
+ {/if}
+
+ <!-- Limits (config) — a single list of editable rows. -->
+ <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"
+ onclick={() => {
+ void refreshLimits();
+ void refreshStatus();
+ }}
+ aria-label="Refresh concurrency limits"
+ >
+ Refresh
+ </button>
+ </div>
+
+ <span class="text-xs opacity-70">{limitsSummary}</span>
+
+ {#if limitsError}
+ <p class="text-xs text-error">{limitsError}</p>
+ {:else if hasLoadedLimits && limitViews.length === 0 && !addOpen}
+ <p class="text-xs opacity-60">No limits configured — providers run unlimited.</p>
+ {/if}
+
+ <ul class="flex flex-col gap-2">
+ {#each limitViews as limit (limit.providerId)}
+ <li>
+ <ConcurrencyLimitRow
+ {limit}
+ status={statusByProvider.get(limit.providerId) ?? null}
+ save={rowSave}
+ saveCooldown={cooldownSave}
+ remove={rowRemove}
+ />
+ </li>
+ {/each}
+ </ul>
+
+ <!-- Add row: an "Add" button reveals a new item (dropdown + limit + cooldown
+ + Set + ✕). Set saves the limit (+ cooldown when moved off the default);
+ ✕ cancels the draft. -->
+ {#if addOpen}
+ <div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <!-- All on one line — nowrap so the buttons never wrap (tight gap + narrow
+ inputs keep the provider dropdown visible; it shrinks via flex-1 + min-w-0). -->
+ <div class="flex flex-nowrap items-center gap-1">
+ <select
+ class="select select-bordered select-xs min-w-0 flex-1 font-mono"
+ aria-label="Provider"
+ bind:value={newProviderId}
+ 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>
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-12 min-w-0 font-mono"
+ placeholder="4"
+ aria-label="New concurrency limit"
+ bind:value={newLimitInput}
+ disabled={adding}
+ />
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-14 min-w-0 font-mono"
+ aria-label="New release cooldown (ms)"
+ bind:value={newCooldownInput}
+ disabled={adding}
+ />
+ <span class="shrink-0 text-[10px] opacity-50">ms</span>
+ <button
+ type="button"
+ class="btn btn-primary btn-xs shrink-0"
+ disabled={!canSet}
+ onclick={handleAdd}
+ >
+ {#if adding}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs shrink-0 text-error"
+ aria-label="Cancel add"
+ disabled={adding}
+ onclick={cancelAdd}
+ >
+ ✕
+ </button>
+ </div>
+ {#if addError}
+ <p class="font-mono text-xs text-error">{addError}</p>
+ {/if}
+ </div>
+ {:else}
+ <button type="button" class="btn btn-ghost btn-xs w-fit" onclick={startAdd}>
+ + Add
+ </button>
+ {/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..a8163c2
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyView.test.ts
@@ -0,0 +1,559 @@
+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 {
+ ConcurrencyCooldownResult,
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusResult,
+} 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;
+
+// A status entry factory (defaults to a healthy limited provider). The new
+// concurrency-fixes fields (`cooldownMs`, `autoReduced`) are always present.
+function statusEntry(over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEntry {
+ return {
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 1,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ ...over,
+ };
+}
+
+// Fakes for the injected ports. Each resolves immediately so the mount effect's
+// initial load settles in a microtask (assertions await via findBy*). The status
+// list is mutable so a test can flip `autoReduced` between polls to simulate a
+// restore clearing the banner.
+function makeFakes(opts?: {
+ limits?: readonly { providerId: string; limit: number }[];
+ status?: ConcurrencyStatusEntry[];
+ /**
+ * When set, `saveLimit` rejects with this error (returns `ok: false`) — used
+ * to test the auto-reduce banner's inline restore-error feedback.
+ */
+ saveLimitError?: string;
+ /**
+ * Optional hook invoked inside `saveLimit` AFTER recording the call. Lets a
+ * test simulate a backend side-effect of the PUT (e.g. clearing `autoReduced`
+ * on the next status poll). Receives the providerId + limit + the fakes bag so
+ * it can mutate the status list. (A plain method reassignment would NOT reach
+ * the already-rendered component — the prop captured the original closure.)
+ */
+ onSaveLimit?: (
+ providerId: string,
+ limit: number,
+ self: { calls: MakeFakesCalls; setStatus: (next: ConcurrencyStatusEntry[]) => void },
+ ) => void;
+}) {
+ let limits = opts?.limits ?? [{ providerId: "umans", limit: 4 }];
+ let status = opts?.status ?? [statusEntry()];
+ const onSaveLimit = opts?.onSaveLimit;
+ const saveLimitError = opts?.saveLimitError;
+
+ const calls: MakeFakesCalls = {
+ loadLimits: 0,
+ loadStatus: 0,
+ saves: [] as { providerId: string; limit: number }[],
+ deletes: [] as string[],
+ cooldownSaves: [] as { providerId: string; cooldownMs: number }[],
+ };
+
+ function setStatus(next: ConcurrencyStatusEntry[]): void {
+ status = next;
+ }
+
+ return {
+ calls,
+ // Allow a test to mutate the status list between polls (e.g. clear
+ // autoReduced after a restore to simulate the next poll).
+ setStatus,
+ loadLimits: async (): Promise<ConcurrencyLimitsResult> => {
+ calls.loadLimits++;
+ return { ok: true, limits };
+ },
+ saveLimit: async (providerId: string, limit: number): Promise<ConcurrencyLimitResult> => {
+ calls.saves.push({ providerId, limit });
+ if (saveLimitError !== undefined) {
+ return { ok: false, error: saveLimitError };
+ }
+ // Reflect the new limit into the list the next load returns.
+ limits = [...limits.filter((l) => l.providerId !== providerId), { providerId, limit }];
+ if (onSaveLimit !== undefined) onSaveLimit(providerId, limit, { calls, setStatus });
+ 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 };
+ },
+ saveCooldown: async (
+ providerId: string,
+ cooldownMs: number,
+ ): Promise<ConcurrencyCooldownResult> => {
+ calls.cooldownSaves.push({ providerId, cooldownMs });
+ // Reflect the new cooldown into the status list the next load returns.
+ status = status.map((s) => (s.providerId === providerId ? { ...s, cooldownMs } : s));
+ return { ok: true, providerId, cooldownMs };
+ },
+ };
+}
+
+type MakeFakesCalls = {
+ loadLimits: number;
+ loadStatus: number;
+ saves: { providerId: string; limit: number }[];
+ deletes: string[];
+ cooldownSaves: { providerId: string; cooldownMs: number }[];
+};
+
+function props(fakes: ReturnType<typeof makeFakes>) {
+ return {
+ models: MODELS as unknown as readonly string[],
+ loadLimits: fakes.loadLimits,
+ saveLimit: fakes.saveLimit,
+ deleteLimit: fakes.deleteLimit,
+ loadStatus: fakes.loadStatus,
+ saveCooldown: fakes.saveCooldown,
+ };
+}
+
+describe("ConcurrencyView", () => {
+ it("loads + renders the configured limits list on mount", async () => {
+ const fakes = makeFakes();
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // The limits summary + the row's remove control (unique to the limits list).
+ expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument();
+ expect(await screen.findByLabelText("Remove concurrency limit for umans")).toBeVisible();
+ expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(1);
+ expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1);
+ });
+
+ it("renders the per-provider cooldown input seeded from the live status", async () => {
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // The saved row's cooldown input (in the same row as the limit) is seeded 350.
+ const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans");
+ expect((cooldownInput as HTMLInputElement).value).toBe("350");
+ });
+
+ it("renders a status line (in-flight count left + badge right) below the edit line", async () => {
+ // Default status: limit 4, inFlight 2, queued 1, not paused → Active, "2/4".
+ const fakes = makeFakes();
+ render(ConcurrencyView, { props: props(fakes) });
+
+ expect(await screen.findByText("2/4 in flight")).toBeVisible();
+ expect(await screen.findByText("Active")).toBeVisible();
+ });
+
+ it("shows the At-capacity badge when in-flight is at the cap with a queue", async () => {
+ const fakes = makeFakes({
+ status: [statusEntry({ inFlight: 4, limit: 4, queued: 3 })],
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ expect(await screen.findByText("4/4 in flight")).toBeVisible();
+ expect(await screen.findByText("At capacity")).toBeVisible();
+ });
+
+ it("shows the Idle badge when no slots are in flight", async () => {
+ const fakes = makeFakes({
+ status: [statusEntry({ inFlight: 0, limit: 4, queued: 0 })],
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ expect(await screen.findByText("0/4 in flight")).toBeVisible();
+ expect(await screen.findByText("Idle")).toBeVisible();
+ });
+
+ it("surfaces NO loading indicator during refresh (background poll is silent — no flicker)", async () => {
+ // The 2s status poll + post-mutation reloads are SILENT: they never toggle a
+ // visible loading state, so the Refresh button is plain-text (no spinner).
+ const fakes = makeFakes();
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/1 limit configured/);
+
+ const limitsRefresh = screen.getByLabelText("Refresh concurrency limits");
+ expect(limitsRefresh).toHaveTextContent("Refresh");
+ expect(limitsRefresh.querySelector(".loading-spinner")).toBeNull();
+ expect(limitsRefresh).not.toBeDisabled();
+
+ // A manual refresh stays silent too (no spinner appears).
+ await fakes.loadStatus();
+ expect(limitsRefresh.querySelector(".loading-spinner")).toBeNull();
+ });
+
+ it("shows an empty state + Add button when no limits are configured", async () => {
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ expect(await screen.findByText(/No limits configured/)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "+ Add" })).toBeVisible();
+ // No provider dropdown until Add is clicked.
+ expect(screen.queryByLabelText("Provider")).toBeNull();
+ });
+
+ it("reveals a new item row (dropdown + limit + cooldown + Set + ✕) when Add is clicked", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+
+ // The new-item row appears with a provider dropdown (auto-selected first),
+ // a limit input, a cooldown input (seeded with the default 350), Set + ✕.
+ const providerSelect = screen.getByLabelText("Provider");
+ expect((providerSelect as HTMLSelectElement).value).not.toBe("");
+ expect(screen.getByPlaceholderText("4")).toBeVisible();
+ const cooldownInput = screen.getByLabelText("New release cooldown (ms)");
+ expect((cooldownInput as HTMLInputElement).value).toBe("350");
+ expect(screen.getByRole("button", { name: "Set" })).toBeVisible();
+ expect(screen.getByRole("button", { name: "Cancel add" })).toBeVisible();
+ });
+
+ it("adds a provider limit via the new-item row Set (calls saveLimit + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+
+ const providerSelect = screen.getByLabelText("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: "Set" }));
+
+ expect(fakes.calls.saves).toEqual([{ providerId: "anthropic", limit: 8 }]);
+ // The cooldown was left at the default (350) → no extra cooldown PUT fired.
+ expect(fakes.calls.cooldownSaves).toHaveLength(0);
+ // 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();
+ // The add row closed back to the Add button.
+ expect(screen.getByRole("button", { name: "+ Add" })).toBeVisible();
+ });
+
+ it("sends a cooldown PUT when the new item's cooldown is moved off the default", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+
+ await user.selectOptions(screen.getByLabelText("Provider"), "anthropic");
+ await user.type(screen.getByPlaceholderText("4"), "8");
+ const cooldownInput = screen.getByLabelText("New release cooldown (ms)");
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "500");
+ await user.click(screen.getByRole("button", { name: "Set" }));
+
+ expect(fakes.calls.saves).toEqual([{ providerId: "anthropic", limit: 8 }]);
+ expect(fakes.calls.cooldownSaves).toEqual([{ providerId: "anthropic", cooldownMs: 500 }]);
+ });
+
+ it("disables Set when the limit is empty/invalid (provider is auto-selected)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+
+ const providerSelect = screen.getByLabelText("Provider");
+ // A provider is auto-selected from the dropdown.
+ expect((providerSelect as HTMLSelectElement).value).not.toBe("");
+ const setBtn = screen.getByRole("button", { name: "Set" });
+ expect(setBtn).toBeDisabled(); // no limit entered yet
+
+ // An invalid (non-numeric) limit keeps Set disabled.
+ await user.type(screen.getByPlaceholderText("4"), "abc");
+ expect(setBtn).toBeDisabled();
+
+ // A valid positive-integer limit enables Set.
+ const limitInput = screen.getByPlaceholderText("4");
+ await user.clear(limitInput);
+ await user.type(limitInput, "5");
+ expect(setBtn).toBeEnabled();
+ });
+
+ it("shows no-providers + disables the dropdown when there are no models", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, {
+ props: { ...props(fakes), models: [] as unknown as readonly string[] },
+ });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+
+ const providerSelect = screen.getByLabelText("Provider");
+ expect(providerSelect).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
+ });
+
+ it("cancels the new-item row (✕) without saving", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+ await user.type(screen.getByPlaceholderText("4"), "8");
+ await user.click(screen.getByRole("button", { name: "Cancel add" }));
+
+ // The row collapses back to the Add button; nothing was saved.
+ expect(screen.queryByLabelText("Provider")).toBeNull();
+ expect(screen.getByRole("button", { name: "+ Add" })).toBeVisible();
+ expect(fakes.calls.saves).toHaveLength(0);
+ });
+
+ it("removes a provider limit via the row ✕ (calls deleteLimit + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes();
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // 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 = {
+ models: MODELS as unknown as readonly string[],
+ 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: [] }),
+ saveCooldown: async (): Promise<ConcurrencyCooldownResult> => ({ ok: false, error: "noop" }),
+ };
+ render(ConcurrencyView, { props: failing });
+ expect(await screen.findByText("Concurrency service not available")).toBeVisible();
+ });
+
+ // ── Concurrency-fixes: auto-reduce banner + cooldown editing ────────────────
+
+ it("renders an auto-reduce banner (with the backend notice + Restore) when a provider is auto-reduced", async () => {
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429 — restore manually when ready.",
+ }),
+ ],
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // The banner shows the backend notice verbatim + a "Restore to 4" action.
+ expect(await screen.findByText(/auto-reduced to 3 after a 429/)).toBeVisible();
+ expect(await screen.findByRole("button", { name: /Restore to 4/ })).toBeVisible();
+ // The "Was 4, now 3." provenance line is shown.
+ expect(await screen.findByText(/Was 4, now 3\./)).toBeVisible();
+ });
+
+ it("clears the banner after Restore (next status poll shows autoReduced===false)", async () => {
+ const user = userEvent.setup();
+ // Start auto-reduced (limit 3, was 4). The restore PUT clears `autoReduced`
+ // server-side; the next status poll returns limit 4 + autoReduced===false →
+ // the banner drops.
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ onSaveLimit: (_providerId, limit, self) => {
+ // Simulate the backend clearing `autoReduced` on the manual PUT: the next
+ // status load returns the restored limit with autoReduced===false.
+ self.setStatus([statusEntry({ limit, autoReduced: false })]);
+ },
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ });
+ await user.click(restoreBtn);
+
+ // The restore PUT the limit back to the original (autoReducedFrom = 4).
+ expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]);
+ // The banner is gone (no Restore button, no notice text); the limits list
+ // now reflects the restored limit (the row's limit input re-seeds to 4).
+ const limitInput = await screen.findByLabelText("Concurrency limit for umans");
+ expect((limitInput as HTMLInputElement).value).toBe("4");
+ expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull();
+ expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull();
+ });
+
+ it("dismisses the auto-reduce banner locally while it stays auto-reduced", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByRole("button", { name: /Restore to 4/ });
+ // Dismiss the banner (hide locally — the provider is still auto-reduced).
+ await user.click(screen.getByLabelText("Dismiss auto-reduce notice for umans"));
+ expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull();
+ expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull();
+ });
+
+ it("shows an inline error in the banner when the Restore PUT fails (no silent re-enable)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ saveLimitError: "Concurrency service not available",
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ });
+ await user.click(restoreBtn);
+
+ // The error surfaces INLINE in the banner (near the restore action), not
+ // only in the far-away limits section. The banner is still present (restore
+ // did not succeed) and the button re-enabled for a retry.
+ expect(await screen.findByTestId("restore-error-umans")).toHaveTextContent(
+ "Concurrency service not available",
+ );
+ expect(screen.getByRole("button", { name: /Restore to 4/ })).toBeVisible();
+ expect(screen.getByRole("button", { name: /Restore to 4/ })).not.toBeDisabled();
+ // The restore PUT was attempted.
+ expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]);
+ });
+
+ it("clears the inline restore error on a retry that succeeds", async () => {
+ const user = userEvent.setup();
+ // First restore fails; the second succeeds (clears autoReduced). Reassigning
+ // `fakes.saveLimit` BEFORE `props(fakes)` is captured would NOT reach the
+ // rendered component, so swap it BEFORE render here.
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ onSaveLimit: (_providerId, limit, self) => {
+ self.setStatus([statusEntry({ limit, autoReduced: false })]);
+ },
+ });
+ let attempts = 0;
+ const succeeding = fakes.saveLimit;
+ fakes.saveLimit = async (providerId, limit) => {
+ attempts++;
+ if (attempts === 1) return { ok: false, error: "Concurrency service not available" };
+ return succeeding(providerId, limit);
+ };
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ });
+ await user.click(restoreBtn);
+ // First attempt: inline error appears.
+ expect(await screen.findByTestId("restore-error-umans")).toBeInTheDocument();
+
+ // Retry: the error clears, the banner drops (restore succeeded).
+ await user.click(screen.getByRole("button", { name: /Restore to 4/ }));
+ const limitInput = await screen.findByLabelText("Concurrency limit for umans");
+ expect((limitInput as HTMLInputElement).value).toBe("4");
+ expect(screen.queryByTestId("restore-error-umans")).toBeNull();
+ expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull();
+ });
+
+ it("edits the per-provider cooldown in the limit row (PUT /concurrency/cooldown + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // Wait for the saved row + its cooldown input (seeded with 350).
+ const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans");
+ expect((cooldownInput as HTMLInputElement).value).toBe("350");
+
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "500");
+ await user.click(screen.getByRole("button", { name: "Set concurrency for umans" }));
+
+ // The cooldown PUT fired with the new value.
+ expect(fakes.calls.cooldownSaves).toEqual([{ providerId: "umans", cooldownMs: 500 }]);
+ // The limit was NOT re-saved (unchanged) — only the cooldown PUT fired.
+ expect(fakes.calls.saves).toHaveLength(0);
+ });
+
+ it("edits the per-provider limit in the row (PUT /concurrency/limits + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes();
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const limitInput = await screen.findByLabelText("Concurrency limit for umans");
+ expect((limitInput as HTMLInputElement).value).toBe("4");
+
+ await user.clear(limitInput);
+ await user.type(limitInput, "8");
+ await user.click(screen.getByRole("button", { name: "Set concurrency for umans" }));
+
+ expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 8 }]);
+ // Cooldown unchanged → no cooldown PUT.
+ expect(fakes.calls.cooldownSaves).toHaveLength(0);
+ });
+
+ it("rejects a negative cooldown input (Set disabled — non-negative integer only)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans");
+ // 0 is valid (no cooldown); a negative is not.
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "0");
+ expect(screen.getByRole("button", { name: "Set concurrency for umans" })).toBeEnabled();
+
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "-5");
+ expect(screen.getByRole("button", { name: "Set concurrency for umans" })).toBeDisabled();
+ expect(fakes.calls.cooldownSaves).toHaveLength(0);
+ });
+});