1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
|
<script lang="ts">
import { untrack } from "svelte";
import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
import {
autoReduceNotices,
DEFAULT_COOLDOWN_MS,
parseCooldownInput,
parseLimitInput,
providerOptions,
summarizeLimits,
viewConcurrencyLimits,
viewConcurrencyStatus,
type ConcurrencyStatusView,
} from "../logic/view-model";
import type {
ConcurrencyLimitEntry,
DeleteConcurrencyLimit,
LoadConcurrencyLimits,
LoadConcurrencyStatus,
RestoreOutcome,
SaveConcurrencyCooldown,
SaveConcurrencyLimit,
} from "../logic/types";
import AutoReduceBanner from "./AutoReduceBanner.svelte";
import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte";
let {
models,
loadLimits,
saveLimit,
deleteLimit,
loadStatus,
saveCooldown,
}: {
/** Available models (`<provider>/<model>`) — the source of provider ids for the Add dropdown. */
models: readonly string[];
loadLimits: LoadConcurrencyLimits;
saveLimit: SaveConcurrencyLimit;
deleteLimit: DeleteConcurrencyLimit;
loadStatus: LoadConcurrencyStatus;
saveCooldown: SaveConcurrencyCooldown;
} = $props();
// ── Limits (config: list / add / update / remove) ────────────────────────────
let limits = $state<readonly ConcurrencyLimitEntry[]>([]);
let limitsError = $state<string | null>(null);
/** True after the first load settles (gates the empty state). */
let hasLoadedLimits = $state(false);
/** Re-entrancy guard for background/silent refreshes (no UI — prevents
* overlapping fetches). The refresh is near-instant, so a visible loading
* indicator would flicker every poll/reload; it stays INVISIBLE (mirrors the
* heartbeat runs list). */
let limitsInFlight = false;
// Add-row state. The provider id is chosen from a dropdown of known providers
// (derived from the available models + any already-configured limit providers).
// The row is revealed by the "Add" button; "Set" saves it, ✕ cancels.
let addOpen = $state(false);
let newProviderId = $state("");
let newLimitInput = $state("");
let newCooldownInput = $state("");
let adding = $state(false);
let addError = $state<string | null>(null);
const providerOpts = $derived(providerOptions(models, limits));
const limitViews = $derived(viewConcurrencyLimits(limits));
const limitsSummary = $derived(summarizeLimits(limits));
const parsedNewLimit = $derived(parseLimitInput(newLimitInput));
const parsedNewCooldown = $derived(parseCooldownInput(newCooldownInput));
/** Only send a cooldown PUT when the user moved it off the server default. */
const newCooldownChanged = $derived(
parsedNewCooldown !== null && parsedNewCooldown !== DEFAULT_COOLDOWN_MS,
);
const canSet = $derived(
newProviderId !== "" &&
parsedNewLimit !== null &&
parsedNewCooldown !== null &&
!limits.some((l) => l.providerId === newProviderId) &&
!adding,
);
// Keep the dropdown selection valid: default to the first option, and if the
// selected provider is removed from the options (e.g. its limit was deleted and
// it has no models), fall back to the first remaining option. Runs untracked so
// it doesn't loop on its own assignment.
$effect(() => {
const opts = providerOpts;
untrack(() => {
if (opts.length === 0) {
if (newProviderId !== "") newProviderId = "";
return;
}
if (!opts.includes(newProviderId)) newProviderId = opts[0] ?? "";
});
});
async function refreshLimits(): Promise<void> {
if (limitsInFlight) return;
limitsInFlight = true;
const result = await loadLimits();
limitsInFlight = false;
hasLoadedLimits = true;
if (result.ok) {
limits = result.limits;
// Clear the error only on success so it stays visible (stable, no flicker)
// during an in-flight retry rather than vanishing mid-refresh.
limitsError = null;
} else {
limitsError = result.error;
}
}
function startAdd(): void {
addOpen = true;
addError = null;
newLimitInput = "";
newCooldownInput = String(DEFAULT_COOLDOWN_MS);
// newProviderId is kept valid (defaults to the first option) by the effect above.
}
function cancelAdd(): void {
addOpen = false;
addError = null;
newLimitInput = "";
newCooldownInput = "";
}
// "Set" on the add row: save the limit, then the cooldown (only when the user
// moved it off the server default of 350ms — the backend defaults to 350 when a
// limit is set, so an unchanged value needs no extra PUT). On full success the
// add row closes + the limits/status reload (the new limit appears as a row).
async function handleAdd(): Promise<void> {
if (parsedNewLimit === null || parsedNewCooldown === null || newProviderId === "") return;
adding = true;
addError = null;
const limitResult = await saveLimit(newProviderId, parsedNewLimit);
if (!limitResult.ok) {
adding = false;
addError = limitResult.error;
return;
}
if (newCooldownChanged) {
const cooldownResult = await saveCooldown(newProviderId, parsedNewCooldown);
adding = false;
if (!cooldownResult.ok) {
// The limit was saved (→ a row will appear after reload); the cooldown PUT
// failed. Surface the error but keep the add row open so it's visible. The
// user can edit the cooldown on the now-saved row.
addError = cooldownResult.error;
void refreshLimits();
void refreshStatus();
return;
}
} else {
adding = false;
}
addOpen = false;
newLimitInput = "";
newCooldownInput = "";
void refreshLimits();
void refreshStatus();
}
// Wrap the ports so a row's save/remove reloads the authoritative list + status
// on success (the row still gets the result to drive its own UI).
async function rowSave(providerId: string, limit: number) {
const result = await saveLimit(providerId, limit);
if (result.ok) {
void refreshLimits();
void refreshStatus();
}
return result;
}
async function rowRemove(providerId: string) {
const result = await deleteLimit(providerId);
if (result.ok) {
void refreshLimits();
void refreshStatus();
}
return result;
}
// Wrap the cooldown save so a successful PUT refreshes the live status (which
// re-carries the new `cooldownMs`). The row still gets the result to drive its
// own UI.
async function cooldownSave(providerId: string, cooldownMs: number) {
const result = await saveCooldown(providerId, cooldownMs);
if (result.ok) {
void refreshStatus();
}
return result;
}
// ── Live status (polls while mounted — seeds cooldown inputs + drives the
// auto-reduce banners; the poll is silent, no status cards) ────────────────
let statusEntries = $state<readonly ConcurrencyStatusEntry[]>([]);
let statusError = $state<string | null>(null);
/** True after the first load settles (gates the empty state). */
let hasLoadedStatus = $state(false);
/** Re-entrancy guard for the 2s background poll (no UI — a visible loading
* indicator flickered every poll because the refresh is near-instant; it stays
* INVISIBLE, mirroring the heartbeat runs list). */
let statusInFlight = false;
// Per-provider status view (from the live status poll) so each saved limit row
// renders its in-flight count + status badge + seeds its cooldown input. Null
// when a provider has no status entry yet (the row falls back to Idle + the
// server-default cooldown of 350).
const statusByProvider = $derived.by(() => {
const map = new Map<string, ConcurrencyStatusView>();
for (const e of statusEntries) map.set(e.providerId, viewConcurrencyStatus(e));
return map;
});
// ── Auto-reduce banners (persist while autoReduced===true; dismissible) ───────
//
// When a provider's limit is auto-reduced by a 429, `GET /concurrency/status`
// carries `autoReduced: true` (+ `autoReducedFrom` + `notice`). We render a
// banner per such provider. The banner is DISMISSIBLE: a dismissed provider
// stays hidden while it remains auto-reduced (persist-while-true), and is
// UN-dismissed the moment a poll shows it no longer auto-reduced — so a future
// auto-reduce re-shows the banner. Restoring the limit (PUT) clears
// `autoReduced` server-side → the next poll drops the banner automatically.
//
// The dismissed set is intentionally COMPONENT-LOCAL (NOT persisted to
// localStorage / a module-global): it resets on remount (sidebar view switch /
// reload). This is correct — `autoReduced` is a REAL persisted degraded state,
// so re-showing the banner on a fresh mount reminds the user. Persisting a
// dismissal across reloads would risk HIDING an ongoing degradation (a
// footgun), and AGENTS.md forbids module-global ambient state. Mirrors the
// component-local `limitsError`/`statusError` pattern.
let dismissedAutoReduce = $state<ReadonlySet<string>>(new Set());
const allNotices = $derived(autoReduceNotices(statusEntries));
const visibleNotices = $derived(
allNotices.filter((n) => !dismissedAutoReduce.has(n.providerId)),
);
// Reconcile the dismissed set against the live auto-reduced providers: keep a
// dismissed entry ONLY while its provider is still auto-reduced. A provider
// that has been restored (no longer in `allNotices`) is dropped from the
// dismissed set so a future auto-reduce re-shows its banner.
$effect(() => {
const autoReducedIds = new Set(allNotices.map((n) => n.providerId));
untrack(() => {
let changed = false;
const next = new Set<string>();
for (const id of dismissedAutoReduce) {
if (autoReducedIds.has(id)) next.add(id);
else changed = true;
}
if (changed) dismissedAutoReduce = next;
});
});
function dismissAutoReduce(providerId: string): void {
if (dismissedAutoReduce.has(providerId)) return;
dismissedAutoReduce = new Set([...dismissedAutoReduce, providerId]);
}
// "Restore to N" — PUT the limit back to `autoReducedFrom` via the limits
// endpoint (a manual PUT clears `autoReduced` server-side). Refreshes limits +
// status on success; the next status poll shows `autoReduced===false` and the
// banner drops (the dismissed-set effect above un-dismisses it too). The banner
// component owns its own restoring-spinner + inline error; on FAILURE the
// outcome is bubbled back so the banner shows the error inline (instead of
// silently re-enabling the button / surfacing it only in the limits section).
async function restoreLimit(providerId: string, limit: number): Promise<RestoreOutcome> {
const result = await saveLimit(providerId, limit);
if (result.ok) {
void refreshLimits();
void refreshStatus();
return { ok: true };
}
return { ok: false, error: result.error };
}
async function refreshStatus(): Promise<void> {
if (statusInFlight) return;
statusInFlight = true;
const result = await loadStatus();
statusInFlight = false;
hasLoadedStatus = true;
if (result.ok) {
statusEntries = result.providers;
// Clear the error only on success so it stays visible (stable, no flicker)
// during an in-flight retry rather than vanishing mid-poll.
statusError = null;
} else {
statusError = result.error;
}
}
const STATUS_POLL_MS = 2000;
// Load limits + status on mount, and poll the live status while the view is
// alive (so a saved limit's cooldown input re-seeds + auto-reduce banners stay
// fresh without a manual refresh). Runs once — no reactive deps read inside.
$effect(() => {
untrack(() => {
void refreshLimits();
void refreshStatus();
});
const h = setInterval(() => {
void refreshStatus();
}, STATUS_POLL_MS);
return () => clearInterval(h);
});
</script>
<div class="flex flex-col gap-4">
<!-- Auto-reduce banners (appear when a provider's limit was auto-reduced by a 429) -->
{#if visibleNotices.length > 0}
<section class="flex flex-col gap-2" aria-label="Concurrency auto-reduce notices">
{#each visibleNotices as notice (notice.providerId)}
<AutoReduceBanner {notice} onRestore={restoreLimit} onDismiss={dismissAutoReduce} />
{/each}
</section>
{/if}
<!-- Limits (config) — a single list of editable rows. -->
<section class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-2">
<h3 class="text-xs font-semibold uppercase opacity-60">Concurrency limits</h3>
<button
type="button"
class="btn btn-ghost btn-xs"
onclick={() => {
void refreshLimits();
void refreshStatus();
}}
aria-label="Refresh concurrency limits"
>
Refresh
</button>
</div>
<span class="text-xs opacity-70">{limitsSummary}</span>
{#if limitsError}
<p class="text-xs text-error">{limitsError}</p>
{:else if hasLoadedLimits && limitViews.length === 0 && !addOpen}
<p class="text-xs opacity-60">No limits configured — providers run unlimited.</p>
{/if}
<ul class="flex flex-col gap-2">
{#each limitViews as limit (limit.providerId)}
<li>
<ConcurrencyLimitRow
{limit}
status={statusByProvider.get(limit.providerId) ?? null}
save={rowSave}
saveCooldown={cooldownSave}
remove={rowRemove}
/>
</li>
{/each}
</ul>
<!-- Add row: an "Add" button reveals a new item (dropdown + limit + cooldown
+ Set + ✕). Set saves the limit (+ cooldown when moved off the default);
✕ cancels the draft. -->
{#if addOpen}
<div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
<!-- All on one line — nowrap so the buttons never wrap (tight gap + narrow
inputs keep the provider dropdown visible; it shrinks via flex-1 + min-w-0). -->
<div class="flex flex-nowrap items-center gap-1">
<select
class="select select-bordered select-xs min-w-0 flex-1 font-mono"
aria-label="Provider"
bind:value={newProviderId}
disabled={adding || providerOpts.length === 0}
>
{#if providerOpts.length === 0}
<option value="" disabled>No providers available</option>
{:else}
{#each providerOpts as provider (provider)}
<option value={provider}>{provider}</option>
{/each}
{/if}
</select>
<input
type="text"
inputmode="numeric"
class="input input-bordered input-xs w-12 min-w-0 font-mono"
placeholder="4"
aria-label="New concurrency limit"
bind:value={newLimitInput}
disabled={adding}
/>
<input
type="text"
inputmode="numeric"
class="input input-bordered input-xs w-14 min-w-0 font-mono"
aria-label="New release cooldown (ms)"
bind:value={newCooldownInput}
disabled={adding}
/>
<span class="shrink-0 text-[10px] opacity-50">ms</span>
<button
type="button"
class="btn btn-primary btn-xs shrink-0"
disabled={!canSet}
onclick={handleAdd}
>
{#if adding}
<span class="loading loading-spinner loading-xs"></span>
{:else}
Set
{/if}
</button>
<button
type="button"
class="btn btn-ghost btn-xs shrink-0 text-error"
aria-label="Cancel add"
disabled={adding}
onclick={cancelAdd}
>
✕
</button>
</div>
{#if addError}
<p class="font-mono text-xs text-error">{addError}</p>
{/if}
</div>
{:else}
<button type="button" class="btn btn-ghost btn-xs w-fit" onclick={startAdd}>
+ Add
</button>
{/if}
</section>
</div>
|