diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/concurrency/index.ts | 1 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyCooldownRow.svelte | 93 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyLimitRow.svelte | 114 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.svelte | 315 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.test.ts | 177 |
5 files changed, 364 insertions, 336 deletions
diff --git a/src/features/concurrency/index.ts b/src/features/concurrency/index.ts index 95eb5ed..9499989 100644 --- a/src/features/concurrency/index.ts +++ b/src/features/concurrency/index.ts @@ -52,7 +52,6 @@ export { viewConcurrencyStatuses, } from "./logic/view-model"; export { default as AutoReduceBanner } from "./ui/AutoReduceBanner.svelte"; -export { default as ConcurrencyCooldownRow } from "./ui/ConcurrencyCooldownRow.svelte"; export { default as ConcurrencyLimitRow } from "./ui/ConcurrencyLimitRow.svelte"; export { default as ConcurrencyView } from "./ui/ConcurrencyView.svelte"; diff --git a/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte b/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte deleted file mode 100644 index 03ddec9..0000000 --- a/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte +++ /dev/null @@ -1,93 +0,0 @@ -<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/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte index 5aacb88..666b769 100644 --- a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte +++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte @@ -1,63 +1,100 @@ <script lang="ts"> import { untrack } from "svelte"; - import { parseLimitInput, type ConcurrencyLimitView } from "../logic/view-model"; - import type { DeleteConcurrencyLimit, SaveConcurrencyLimit } from "../logic/types"; + import { + parseCooldownInput, + parseLimitInput, + type ConcurrencyLimitView, + } from "../logic/view-model"; + import type { + DeleteConcurrencyLimit, + SaveConcurrencyCooldown, + SaveConcurrencyLimit, + } from "../logic/types"; let { limit, + cooldownMs, save, + saveCooldown, remove, }: { /** The configured limit row (providerId + current limit). */ limit: ConcurrencyLimitView; + /** The current per-slot release cooldown (ms) from the live status poll. */ + cooldownMs: number; save: SaveConcurrencyLimit; + saveCooldown: SaveConcurrencyCooldown; 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(""); + // 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 (mirrors ChatLimitField). - * Cleared when the field is edited again. */ + /** Brief "Saved" confirmation after a successful save; cleared on edit. */ let justSaved = $state(false); $effect(() => { - const incoming = String(limit.limit); + const incomingLimit = String(limit.limit); + const incomingCooldown = String(cooldownMs); untrack(() => { - if (draft === lastSeed) draft = incoming; - lastSeed = incoming; + if (limitDraft === lastLimitSeed) limitDraft = incomingLimit; + lastLimitSeed = incomingLimit; + if (cooldownDraft === lastCooldownSeed) cooldownDraft = incomingCooldown; + lastCooldownSeed = incomingCooldown; }); }); - const parsed = $derived(parseLimitInput(draft)); - const dirty = $derived(parsed !== null && parsed !== limit.limit); + 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 the field. + // Clear the "Saved" hint + any error as soon as the user edits either field. function onInput(): void { justSaved = false; error = null; } - async function handleSave(): Promise<void> { - if (parsed === null || parsed === limit.limit) return; + // "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; - 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; + 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; - } else { - error = result.error; + } finally { + saving = false; } } @@ -74,27 +111,40 @@ </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> + <div class="flex flex-wrap items-center gap-2"> + <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-20 font-mono" aria-label={`Concurrency limit for ${limit.providerId}`} - bind:value={draft} + bind:value={limitDraft} + oninput={onInput} + disabled={saving || removing} + /> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-24 font-mono" + aria-label={`Release cooldown (ms) for ${limit.providerId}`} + bind:value={cooldownDraft} oninput={onInput} disabled={saving || removing} /> + <span class="text-[10px] opacity-50">ms</span> <button type="button" class="btn btn-primary btn-xs" + aria-label={`Set concurrency for ${limit.providerId}`} disabled={!dirty || saving || removing} - onclick={handleSave} + onclick={handleSet} > {#if saving} <span class="loading loading-spinner loading-xs"></span> {:else} - Save + Set {/if} </button> <button diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte index 6078b1a..2f7435a 100644 --- a/src/features/concurrency/ui/ConcurrencyView.svelte +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -3,13 +3,12 @@ import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; import { autoReduceNotices, - type Badge, + DEFAULT_COOLDOWN_MS, + parseCooldownInput, parseLimitInput, providerOptions, summarizeLimits, - summarizeStatus, viewConcurrencyLimits, - viewConcurrencyStatuses, } from "../logic/view-model"; import type { ConcurrencyLimitEntry, @@ -21,7 +20,6 @@ SaveConcurrencyLimit, } from "../logic/types"; import AutoReduceBanner from "./AutoReduceBanner.svelte"; - import ConcurrencyCooldownRow from "./ConcurrencyCooldownRow.svelte"; import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte"; let { @@ -41,13 +39,6 @@ saveCooldown: SaveConcurrencyCooldown; } = $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 limitsError = $state<string | null>(null); @@ -59,10 +50,13 @@ * heartbeat runs list). */ let limitsInFlight = false; - // Add-form state. The provider id is chosen from a dropdown of known providers + // 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); @@ -70,9 +64,15 @@ const limitViews = $derived(viewConcurrencyLimits(limits)); const limitsSummary = $derived(summarizeLimits(limits)); const parsedNewLimit = $derived(parseLimitInput(newLimitInput)); - const canAdd = $derived( + 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, ); @@ -108,19 +108,55 @@ } } + 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 || newProviderId === "") return; + if (parsedNewLimit === null || parsedNewCooldown === null || newProviderId === "") return; adding = true; addError = null; - const result = await saveLimit(newProviderId, parsedNewLimit); - adding = false; - if (result.ok) { - newLimitInput = ""; - void refreshLimits(); - void refreshStatus(); + 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 { - addError = result.error; + adding = false; } + addOpen = false; + newLimitInput = ""; + newCooldownInput = ""; + void refreshLimits(); + void refreshStatus(); } // Wrap the ports so a row's save/remove reloads the authoritative list + status @@ -145,8 +181,7 @@ // 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). + // own UI. async function cooldownSave(providerId: string, cooldownMs: number) { const result = await saveCooldown(providerId, cooldownMs); if (result.ok) { @@ -155,7 +190,8 @@ return result; } - // ── Live status (polls while mounted) ─────────────────────────────────────── + // ── 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). */ @@ -164,19 +200,16 @@ * indicator flickered every poll because the refresh is near-instant; it stays * INVISIBLE, mirroring the heartbeat runs list). */ let statusInFlight = 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); + // Per-provider cooldown lookup (from the live status poll) so each saved limit + // row seeds its cooldown input. Defaults to the server default (350) when a + // provider has no status entry yet. + const cooldownByProvider = $derived.by(() => { + const map = new Map<string, number>(); + for (const s of statusEntries) map.set(s.providerId, s.cooldownMs); + return map; }); - 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` @@ -259,8 +292,8 @@ 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. + // 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(); @@ -278,161 +311,113 @@ {#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} - /> + <AutoReduceBanner {notice} onRestore={restoreLimit} onDismiss={dismissAutoReduce} /> {/each} </section> {/if} - <!-- Limits (config) --> + <!-- 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={() => refreshLimits()} + onclick={() => { + void refreshLimits(); + void refreshStatus(); + }} aria-label="Refresh concurrency limits" > Refresh </button> </div> - <!-- 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</span> - <select - class="select select-bordered select-xs w-40 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> - </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} + {:else if hasLoadedLimits && limitViews.length === 0 && !addOpen} <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" - onclick={() => refreshStatus()} - aria-label="Refresh concurrency status" - > - Refresh - </button> - </div> - <span class="text-xs opacity-70">{statusSummary}</span> + <ul class="flex flex-col gap-2"> + {#each limitViews as limit (limit.providerId)} + <li> + <ConcurrencyLimitRow + {limit} + cooldownMs={cooldownByProvider.get(limit.providerId) ?? DEFAULT_COOLDOWN_MS} + save={rowSave} + saveCooldown={cooldownSave} + remove={rowRemove} + /> + </li> + {/each} + </ul> - {#if statusError} - <p class="text-xs text-error">{statusError}</p> - {:else if hasLoadedStatus && statusViews.length === 0} - <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> - <span title="Per-slot release cooldown">cooldown {s.cooldownLabel}</span> - </div> - {#if s.pausedLabel} - <span class="text-xs text-warning">{s.pausedLabel}</span> + <!-- 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"> + <div class="flex flex-wrap items-center gap-2"> + <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} - {#if s.autoReduced} - <span class="text-xs text-warning"> - Limit auto-reduced{#if s.autoReducedFrom !== null} - from {s.autoReducedFrom} to {s.limit}{/if}. - </span> + </select> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-20 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-24 font-mono" + aria-label="New release cooldown (ms)" + bind:value={newCooldownInput} + disabled={adding} + /> + <span class="text-[10px] opacity-50">ms</span> + <button type="button" class="btn btn-primary btn-xs" disabled={!canSet} onclick={handleAdd}> + {#if adding} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Set {/if} - <ConcurrencyCooldownRow - providerId={s.providerId} - cooldownMs={s.cooldownMs} - save={cooldownSave} - /> - </li> - {/each} - </ul> + </button> + <button + type="button" + class="btn btn-ghost btn-xs 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 index 854f13b..68856a1 100644 --- a/src/features/concurrency/ui/ConcurrencyView.test.ts +++ b/src/features/concurrency/ui/ConcurrencyView.test.ts @@ -131,103 +131,171 @@ function props(fakes: ReturnType<typeof makeFakes>) { } describe("ConcurrencyView", () => { - it("loads + renders the configured limits and live status on mount", async () => { + it("loads + renders the configured limits list on mount", async () => { const fakes = makeFakes(); render(ConcurrencyView, { props: props(fakes) }); - // The provider dropdown is populated from the available models' providers. - const providerSelect = await screen.findByLabelText("Provider"); - expect(providerSelect).toBeVisible(); - // Limits summary (unique to the limits section) + the row's remove control. + // 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(); - // 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("renders the per-provider cooldown label from the live status", async () => { + 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 status card shows the cooldown alongside in-flight/queue. - expect(await screen.findByText(/cooldown 350ms/)).toBeInTheDocument(); + // 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("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). + // visible loading state, so the Refresh button is plain-text (no spinner). const fakes = makeFakes(); render(ConcurrencyView, { props: props(fakes) }); - await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/); - - const statusRefresh = screen.getByLabelText("Refresh concurrency status"); - expect(statusRefresh).toHaveTextContent("Refresh"); - expect(statusRefresh.querySelector(".loading-spinner")).toBeNull(); - expect(statusRefresh).not.toBeDisabled(); + 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(statusRefresh.querySelector(".loading-spinner")).toBeNull(); + 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("adds a provider limit via the dropdown form (calls saveLimit + reloads)", async () => { + 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) }); - const providerSelect = await screen.findByLabelText("Provider"); + 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: "Add" })); + 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 Add when the limit is empty/invalid (provider is auto-selected)", async () => { + 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) }); - const providerSelect = await screen.findByLabelText("Provider"); + 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 addBtn = screen.getByRole("button", { name: "Add" }); - expect(addBtn).toBeDisabled(); // no limit entered yet + const setBtn = screen.getByRole("button", { name: "Set" }); + expect(setBtn).toBeDisabled(); // no limit entered yet - // An invalid (non-numeric) limit keeps Add disabled. + // An invalid (non-numeric) limit keeps Set disabled. await user.type(screen.getByPlaceholderText("4"), "abc"); - expect(addBtn).toBeDisabled(); + expect(setBtn).toBeDisabled(); - // A valid positive-integer limit enables Add. + // A valid positive-integer limit enables Set. const limitInput = screen.getByPlaceholderText("4"); await user.clear(limitInput); await user.type(limitInput, "5"); - expect(addBtn).toBeEnabled(); + 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[] }, }); - const providerSelect = await screen.findByLabelText("Provider"); + 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: "Add" })).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 () => { @@ -308,9 +376,10 @@ describe("ConcurrencyView", () => { // 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/); + // 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(); }); @@ -400,31 +469,49 @@ describe("ConcurrencyView", () => { // 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/); + 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 (PUT /concurrency/cooldown + reloads status)", async () => { + 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 status card + its cooldown input (seeded with 350). + // 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: "Save cooldown for umans" })); + 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 }]); - // After save the component reloads status (which now carries cooldownMs=500). - expect(await screen.findByText(/cooldown 500ms/)).toBeInTheDocument(); + // 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 (Save disabled — non-negative integer only)", async () => { + 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) }); @@ -433,11 +520,11 @@ describe("ConcurrencyView", () => { // 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(); + expect(screen.getByRole("button", { name: "Set concurrency for umans" })).toBeEnabled(); await user.clear(cooldownInput); await user.type(cooldownInput, "-5"); - expect(screen.getByRole("button", { name: "Save cooldown for umans" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Set concurrency for umans" })).toBeDisabled(); expect(fakes.calls.cooldownSaves).toHaveLength(0); }); }); |
