diff options
Diffstat (limited to 'src/features/concurrency/ui/ConcurrencyLimitRow.svelte')
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyLimitRow.svelte | 204 |
1 files changed, 204 insertions, 0 deletions
diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte new file mode 100644 index 0000000..bf06ac0 --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte @@ -0,0 +1,204 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import { + DEFAULT_COOLDOWN_MS, + parseCooldownInput, + parseLimitInput, + statusLabel, + type Badge, + type ConcurrencyLimitView, + type ConcurrencyStatusView, + } from "../logic/view-model"; + import type { + DeleteConcurrencyLimit, + SaveConcurrencyCooldown, + SaveConcurrencyLimit, + } from "../logic/types"; + + let { + limit, + status, + save, + saveCooldown, + remove, + }: { + /** The configured limit row (providerId + current limit). */ + limit: ConcurrencyLimitView; + /** The provider's live status view (in-flight/queue/badge), or null when no + * status entry exists yet. Drives the status line + seeds the cooldown input. */ + status: ConcurrencyStatusView | null; + save: SaveConcurrencyLimit; + saveCooldown: SaveConcurrencyCooldown; + remove: DeleteConcurrencyLimit; + } = $props(); + + // The badge→color map (presentational). Mirrors the old status-card mapping. + const badgeClass: Record<Badge, string> = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + neutral: "badge-ghost", + }; + + // The cooldown input seed: the live cooldown when a status entry exists, else + // the server default (350). + const cooldownMs = $derived(status?.cooldownMs ?? DEFAULT_COOLDOWN_MS); + + // Inline-edit state for the limit + cooldown inputs. Each is seeded from its + // canonical value, but only while untouched — so a save echo / status-poll + // refresh re-syncs without clobbering an in-flight edit. Mirrors the + // ChatLimitField seed pattern (avoids reading the prop in the $state init). + let limitDraft = $state(""); + let lastLimitSeed = $state(""); + let cooldownDraft = $state(""); + let lastCooldownSeed = $state(""); + let saving = $state(false); + let removing = $state(false); + let error = $state<string | null>(null); + /** Brief "Saved" confirmation after a successful save; cleared on edit. */ + let justSaved = $state(false); + + $effect(() => { + const incomingLimit = String(limit.limit); + const incomingCooldown = String(cooldownMs); + untrack(() => { + if (limitDraft === lastLimitSeed) limitDraft = incomingLimit; + lastLimitSeed = incomingLimit; + if (cooldownDraft === lastCooldownSeed) cooldownDraft = incomingCooldown; + lastCooldownSeed = incomingCooldown; + }); + }); + + const parsedLimit = $derived(parseLimitInput(limitDraft)); + const parsedCooldown = $derived(parseCooldownInput(cooldownDraft)); + const dirtyLimit = $derived(parsedLimit !== null && parsedLimit !== limit.limit); + const dirtyCooldown = $derived(parsedCooldown !== null && parsedCooldown !== cooldownMs); + const dirty = $derived(dirtyLimit || dirtyCooldown); + + // Clear the "Saved" hint + any error as soon as the user edits either field. + function onInput(): void { + justSaved = false; + error = null; + } + + // "Set" saves whichever field is dirty: the limit first (PUT + // /concurrency/limits/:id), then the cooldown (PUT /concurrency/cooldown/:id). + // Stops + surfaces an inline error on the first failure. + async function handleSet(): Promise<void> { + if (!dirty || saving || removing) return; + saving = true; + error = null; + try { + if (dirtyLimit && parsedLimit !== null) { + const r = await save(limit.providerId, parsedLimit); + if (!r.ok) { + error = r.error; + return; + } + // Reflect the echoed limit back immediately (the prop re-asserts it via + // the seed effect once the parent reloads). + limitDraft = String(r.limit); + lastLimitSeed = limitDraft; + } + if (dirtyCooldown && parsedCooldown !== null) { + const r = await saveCooldown(limit.providerId, parsedCooldown); + if (!r.ok) { + error = r.error; + return; + } + cooldownDraft = String(r.cooldownMs); + lastCooldownSeed = cooldownDraft; + } + justSaved = true; + } finally { + saving = false; + } + } + + async function handleRemove(): Promise<void> { + removing = true; + error = null; + const result = await remove(limit.providerId); + removing = false; + if (!result.ok) { + error = result.error; + } + // On success the parent drops this row (re-loaded limits list). + } +</script> + +<div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> + <!-- Line 1: provider + limit + cooldown + Set + ✕ (all on one line — nowrap so + the buttons never wrap; tight gap + narrow inputs keep the provider name + visible; the provider shrinks via flex-1 + min-w-0). --> + <div class="flex flex-nowrap items-center gap-1"> + <span class="min-w-0 flex-1 truncate font-medium font-mono" title={limit.providerId} + >{limit.providerId}</span + > + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-12 min-w-0 font-mono" + aria-label={`Concurrency limit for ${limit.providerId}`} + bind:value={limitDraft} + oninput={onInput} + disabled={saving || removing} + /> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-14 min-w-0 font-mono" + aria-label={`Release cooldown (ms) for ${limit.providerId}`} + bind:value={cooldownDraft} + oninput={onInput} + disabled={saving || removing} + /> + <span class="shrink-0 text-[10px] opacity-50">ms</span> + <button + type="button" + class="btn btn-primary btn-xs shrink-0" + aria-label={`Set concurrency for ${limit.providerId}`} + disabled={!dirty || saving || removing} + onclick={handleSet} + > + {#if saving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Set + {/if} + </button> + <button + type="button" + class="btn btn-ghost btn-xs shrink-0 text-error" + aria-label={`Remove concurrency limit for ${limit.providerId}`} + disabled={saving || removing} + onclick={handleRemove} + > + {#if removing} + <span class="loading loading-spinner loading-xs"></span> + {:else} + ✕ + {/if} + </button> + </div> + + <!-- Line 2: in-flight count (left) + status badge (right). Hidden until the + first status poll for this provider lands. --> + {#if status !== null} + <div class="flex items-center justify-between gap-2 text-xs opacity-70"> + <span title="In-flight slots held vs cap">{status.inFlightLabel} in flight</span> + <span class="badge badge-sm {badgeClass[status.badge]} gap-1"> + {#if status.busy} + <span class="loading loading-spinner loading-xs"></span> + {/if} + {statusLabel(status)} + </span> + </div> + {/if} + + {#if error} + <span class="font-mono text-xs text-error">{error}</span> + {:else if justSaved && !dirty} + <span class="text-xs text-success">Saved.</span> + {/if} +</div> |
