summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 19:29:20 +0900
committerAdam Malczewski <[email protected]>2026-06-28 19:29:20 +0900
commitbce51318295bd8608c27c530566ab8a6eb0cb9f9 (patch)
tree4a7834c8cbb461d3054cf9b907f9112dddba9e4e /src/features
parentac68d6f1a9e6d7e59ce43a7c950f6519a87c504d (diff)
downloaddispatch-web-bce51318295bd8608c27c530566ab8a6eb0cb9f9.tar.gz
dispatch-web-bce51318295bd8608c27c530566ab8a6eb0cb9f9.zip
refactor(concurrency): simplify view to add-button list with inline limit + cooldown
Diffstat (limited to 'src/features')
-rw-r--r--src/features/concurrency/index.ts1
-rw-r--r--src/features/concurrency/ui/ConcurrencyCooldownRow.svelte93
-rw-r--r--src/features/concurrency/ui/ConcurrencyLimitRow.svelte114
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.svelte315
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.test.ts177
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);
});
});