summaryrefslogtreecommitdiffhomepage
path: root/src/features/concurrency/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 15:27:43 +0900
committerAdam Malczewski <[email protected]>2026-06-28 15:27:43 +0900
commit3899ecef48325014a3bb5d6333a7a3e1fc34296a (patch)
tree4068e66677dd97fd0f02208dac297b91c21b5755 /src/features/concurrency/ui
parent7f2d66d5c132b45f7d01b0d9107f341094b9c904 (diff)
parent0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3 (diff)
downloaddispatch-web-3899ecef48325014a3bb5d6333a7a3e1fc34296a.tar.gz
dispatch-web-3899ecef48325014a3bb5d6333a7a3e1fc34296a.zip
Merge branch 'feature/concurrency-fixes' into predev
# Conflicts: # backend-handoff.md
Diffstat (limited to 'src/features/concurrency/ui')
-rw-r--r--src/features/concurrency/ui/AutoReduceBanner.svelte81
-rw-r--r--src/features/concurrency/ui/ConcurrencyCooldownRow.svelte93
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.svelte107
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.test.ts339
4 files changed, 560 insertions, 60 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/ConcurrencyCooldownRow.svelte b/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte
new file mode 100644
index 0000000..03ddec9
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte
@@ -0,0 +1,93 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import { parseCooldownInput } from "../logic/view-model";
+ import type { SaveConcurrencyCooldown } from "../logic/types";
+
+ let {
+ providerId,
+ cooldownMs,
+ save,
+ }: {
+ /** The provider this cooldown row controls. */
+ providerId: string;
+ /** The current per-slot release cooldown (ms) from the live status poll. */
+ cooldownMs: number;
+ save: SaveConcurrencyCooldown;
+ } = $props();
+
+ // Inline-edit state: the raw text bound to the cooldown input. Seeded from the
+ // row's canonical cooldownMs, but only while the field is untouched — so a
+ // status-poll refresh re-syncs it without clobbering an in-flight edit. Mirrors
+ // the ConcurrencyLimitRow / ChatLimitField seed pattern.
+ let draft = $state("");
+ let lastSeed = $state("");
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ /** Brief "Saved." confirmation after a successful save; cleared on edit. */
+ let justSaved = $state(false);
+
+ $effect(() => {
+ const incoming = String(cooldownMs);
+ untrack(() => {
+ if (draft === lastSeed) draft = incoming;
+ lastSeed = incoming;
+ });
+ });
+
+ const parsed = $derived(parseCooldownInput(draft));
+ const dirty = $derived(parsed !== null && parsed !== cooldownMs);
+
+ function onInput(): void {
+ justSaved = false;
+ error = null;
+ }
+
+ async function handleSave(): Promise<void> {
+ if (parsed === null || parsed === cooldownMs) return;
+ saving = true;
+ error = null;
+ const result = await save(providerId, parsed);
+ saving = false;
+ if (result.ok) {
+ // Reflect the echoed cooldown back into the field immediately (the prop
+ // also re-asserts it via the seed effect above once the parent reloads).
+ draft = String(result.cooldownMs);
+ lastSeed = draft;
+ justSaved = true;
+ } else {
+ error = result.error;
+ }
+ }
+</script>
+
+<div class="flex items-center gap-2 text-xs">
+ <span class="opacity-60">cooldown</span>
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-20 font-mono"
+ aria-label={`Release cooldown (ms) for ${providerId}`}
+ bind:value={draft}
+ oninput={onInput}
+ disabled={saving}
+ />
+ <span class="opacity-50">ms</span>
+ <button
+ type="button"
+ class="btn btn-primary btn-xs"
+ aria-label={`Save cooldown for ${providerId}`}
+ disabled={!dirty || saving}
+ onclick={handleSave}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Save
+ {/if}
+ </button>
+ {#if error}
+ <span class="font-mono text-error">{error}</span>
+ {:else if justSaved && !dirty}
+ <span class="text-success">Saved.</span>
+ {/if}
+</div>
diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte
index f8a2199..6078b1a 100644
--- a/src/features/concurrency/ui/ConcurrencyView.svelte
+++ b/src/features/concurrency/ui/ConcurrencyView.svelte
@@ -2,6 +2,7 @@
import { untrack } from "svelte";
import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
import {
+ autoReduceNotices,
type Badge,
parseLimitInput,
providerOptions,
@@ -15,8 +16,12 @@
DeleteConcurrencyLimit,
LoadConcurrencyLimits,
LoadConcurrencyStatus,
+ RestoreOutcome,
+ SaveConcurrencyCooldown,
SaveConcurrencyLimit,
} from "../logic/types";
+ import AutoReduceBanner from "./AutoReduceBanner.svelte";
+ import ConcurrencyCooldownRow from "./ConcurrencyCooldownRow.svelte";
import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte";
let {
@@ -25,6 +30,7 @@
saveLimit,
deleteLimit,
loadStatus,
+ saveCooldown,
}: {
/** Available models (`<provider>/<model>`) — the source of provider ids for the Add dropdown. */
models: readonly string[];
@@ -32,6 +38,7 @@
saveLimit: SaveConcurrencyLimit;
deleteLimit: DeleteConcurrencyLimit;
loadStatus: LoadConcurrencyStatus;
+ saveCooldown: SaveConcurrencyCooldown;
} = $props();
const badgeClass: Record<Badge, string> = {
@@ -136,6 +143,18 @@
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. `getCooldown` is exposed for completeness/future use (the live status
+ // already carries `cooldownMs`, so the row seeds from the status view).
+ 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) ───────────────────────────────────────
let statusEntries = $state<readonly ConcurrencyStatusEntry[]>([]);
let statusError = $state<string | null>(null);
@@ -158,6 +177,69 @@
const statusViews = $derived(viewConcurrencyStatuses(statusEntries, now));
const statusSummary = $derived(summarizeStatus(statusEntries, now));
+ // ── 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;
@@ -192,6 +274,19 @@
</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) -->
<section class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-2">
@@ -319,10 +414,22 @@
<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>
+ <span title="Per-slot release cooldown">cooldown {s.cooldownLabel}</span>
</div>
{#if s.pausedLabel}
<span class="text-xs text-warning">{s.pausedLabel}</span>
{/if}
+ {#if s.autoReduced}
+ <span class="text-xs text-warning">
+ Limit auto-reduced{#if s.autoReducedFrom !== null}
+ from {s.autoReducedFrom} to {s.limit}{/if}.
+ </span>
+ {/if}
+ <ConcurrencyCooldownRow
+ providerId={s.providerId}
+ cooldownMs={s.cooldownMs}
+ save={cooldownSave}
+ />
</li>
{/each}
</ul>
diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts
index 3dc8e78..854f13b 100644
--- a/src/features/concurrency/ui/ConcurrencyView.test.ts
+++ b/src/features/concurrency/ui/ConcurrencyView.test.ts
@@ -3,6 +3,7 @@ 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,
@@ -13,35 +14,80 @@ 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*).
+// 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?: readonly ConcurrencyStatusEntry[];
+ 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 }];
- const status = opts?.status ?? [
- { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
- ];
+ let status = opts?.status ?? [statusEntry()];
+ const onSaveLimit = opts?.onSaveLimit;
+ const saveLimitError = opts?.saveLimitError;
- const calls = {
+ 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> => {
@@ -53,21 +99,41 @@ function makeFakes(opts?: {
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 and live status on mount", async () => {
const fakes = makeFakes();
- render(ConcurrencyView, {
- props: {
- models: MODELS,
- loadLimits: fakes.loadLimits,
- saveLimit: fakes.saveLimit,
- deleteLimit: fakes.deleteLimit,
- loadStatus: fakes.loadStatus,
- },
- });
+ render(ConcurrencyView, { props: props(fakes) });
// The provider dropdown is populated from the available models' providers.
const providerSelect = await screen.findByLabelText("Provider");
@@ -81,21 +147,21 @@ describe("ConcurrencyView", () => {
expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1);
});
+ it("renders the per-provider cooldown label from the live status", async () => {
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // The status card shows the cooldown alongside in-flight/queue.
+ expect(await screen.findByText(/cooldown 350ms/)).toBeInTheDocument();
+ });
+
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 buttons are plain-text (no spinner)
// and the list area never reflows mid-refresh. Regression guard for the
// flicker fix (mirrors the heartbeat runs list).
const fakes = makeFakes();
- render(ConcurrencyView, {
- props: {
- models: MODELS,
- loadLimits: fakes.loadLimits,
- saveLimit: fakes.saveLimit,
- deleteLimit: fakes.deleteLimit,
- loadStatus: fakes.loadStatus,
- },
- });
+ render(ConcurrencyView, { props: props(fakes) });
await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/);
@@ -116,15 +182,7 @@ describe("ConcurrencyView", () => {
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,
- loadStatus: fakes.loadStatus,
- },
- });
+ render(ConcurrencyView, { props: props(fakes) });
const providerSelect = await screen.findByLabelText("Provider");
// Choose "anthropic" from the dropdown (the list is auto-selected first).
@@ -142,15 +200,7 @@ describe("ConcurrencyView", () => {
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,
- loadStatus: fakes.loadStatus,
- },
- });
+ render(ConcurrencyView, { props: props(fakes) });
const providerSelect = await screen.findByLabelText("Provider");
// A provider is auto-selected from the dropdown.
@@ -172,13 +222,7 @@ describe("ConcurrencyView", () => {
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,
- },
+ props: { ...props(fakes), models: [] as unknown as readonly string[] },
});
const providerSelect = await screen.findByLabelText("Provider");
@@ -189,15 +233,7 @@ describe("ConcurrencyView", () => {
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,
- loadStatus: fakes.loadStatus,
- },
- });
+ render(ConcurrencyView, { props: props(fakes) });
// Wait for the limits to load (unique summary) before interacting.
await screen.findByText(/1 limit configured/);
@@ -209,7 +245,7 @@ describe("ConcurrencyView", () => {
it("surfaces a load error from the limits endpoint", async () => {
const failing = {
- models: MODELS,
+ models: MODELS as unknown as readonly string[],
loadLimits: async (): Promise<ConcurrencyLimitsResult> => ({
ok: false,
error: "Concurrency service not available",
@@ -217,8 +253,191 @@ describe("ConcurrencyView", () => {
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 status summary
+ // now reflects the restored limit (2/4 in flight).
+ await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/);
+ 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/ }));
+ await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/);
+ expect(screen.queryByTestId("restore-error-umans")).toBeNull();
+ expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull();
+ });
+
+ it("edits the per-provider cooldown (PUT /concurrency/cooldown + reloads status)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // Wait for the status card + 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: "Save cooldown for umans" }));
+
+ // The cooldown PUT fired with the new value.
+ expect(fakes.calls.cooldownSaves).toEqual([{ providerId: "umans", cooldownMs: 500 }]);
+ // After save the component reloads status (which now carries cooldownMs=500).
+ expect(await screen.findByText(/cooldown 500ms/)).toBeInTheDocument();
+ });
+
+ it("rejects a negative cooldown input (Save 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: "Save cooldown for umans" })).toBeEnabled();
+
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "-5");
+ expect(screen.getByRole("button", { name: "Save cooldown for umans" })).toBeDisabled();
+ expect(fakes.calls.cooldownSaves).toHaveLength(0);
+ });
});