diff options
Diffstat (limited to 'src/features/cache-warming/ui')
| -rw-r--r-- | src/features/cache-warming/ui/CacheWarmingView.svelte | 468 |
1 files changed, 239 insertions, 229 deletions
diff --git a/src/features/cache-warming/ui/CacheWarmingView.svelte b/src/features/cache-warming/ui/CacheWarmingView.svelte index ced5e99..9b3d694 100644 --- a/src/features/cache-warming/ui/CacheWarmingView.svelte +++ b/src/features/cache-warming/ui/CacheWarmingView.svelte @@ -1,234 +1,244 @@ <script lang="ts"> - import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; - import { onMount, untrack } from "svelte"; - import { - clampMinutes, - clampSeconds, - colorClass, - formatCountdown, - formatWarmLabel, - fromMinSec, - initialWarmingState, - observeWarm, - parseControls, - secondsUntilNext, - statusForPct, - toMinSec, - type WarmingViewState, - type WarmNow, - } from "../logic/view-model"; - - let { - spec, - canWarm, - onInvoke, - warmNow, - }: { - /** The cache-warming surface spec for the focused conversation, or null. */ - spec: SurfaceSpec | null; - /** Whether a real conversation is focused (a draft has nothing to warm). */ - canWarm: boolean; - onInvoke: (msg: InvokeMessage) => void; - warmNow: WarmNow; - } = $props(); - - const controls = $derived(parseControls(spec)); - - // View-model state (pure reducer) + the injected clock — owned here, not ambient. - let vm = $state<WarmingViewState>(initialWarmingState()); - let now = $state(Date.now()); - let warming = $state(false); - let errorText = $state<string | null>(null); - // Transient result of the most recent manual warm (immediate feedback; history - // itself is driven authoritatively by the surface's `lastWarmAt`). - let manualResult = $state<{ cachePct: number; expectedCacheRate: number } | null>(null); - - // Local interval inputs, seeded from the surface and re-seeded only when the - // surface's interval differs from what's shown (so a stray update mid-edit - // doesn't clobber typing). - let minutes = $state(0); - let seconds = $state(0); - - onMount(() => { - const id = setInterval(() => { - now = Date.now(); - }, 1000); - return () => clearInterval(id); - }); - - // Fold each authoritative warm (new `lastWarmAt`) into history. - $effect(() => { - const at = controls.lastWarmAt; - const pct = controls.lastPct; - untrack(() => { - vm = observeWarm(vm, at, pct); - }); - }); - - // Keep the min/sec inputs in sync with the surface's interval. - $effect(() => { - const target = controls.intervalSeconds; - untrack(() => { - if (fromMinSec(minutes, seconds) !== target) { - const ms = toMinSec(target); - minutes = ms.minutes; - seconds = ms.seconds; - } - }); - }); - - const remaining = $derived(secondsUntilNext(controls.nextWarmAt, now)); - const history = $derived(vm.history); - const latest = $derived(history[0] ?? null); - const earlier = $derived(history.slice(1)); - - function commitInterval() { - const actionId = controls.setIntervalActionId; - if (actionId === null || spec === null) return; - onInvoke({ type: "invoke", surfaceId: spec.id, actionId, payload: fromMinSec(minutes, seconds) }); - } - - function onMinutes(event: Event) { - const next = (event.target as HTMLInputElement).valueAsNumber; - if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0 - minutes = clampMinutes(next); - commitInterval(); - } - - function onSeconds(event: Event) { - const next = (event.target as HTMLInputElement).valueAsNumber; - if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0 - seconds = clampSeconds(next); - commitInterval(); - } - - function onToggle() { - const actionId = controls.toggleActionId; - if (actionId === null || spec === null) return; - // The toggle action FLIPS server-side; no payload. - onInvoke({ type: "invoke", surfaceId: spec.id, actionId }); - } - - async function handleWarm() { - if (warming) return; - warming = true; - errorText = null; - const result = await warmNow(); - warming = false; - if (result === null) return; - if (result.ok) { - // Immediate feedback only — the authoritative surface `update` (new - // `lastWarmAt`) drives the history via `observeWarm`. - manualResult = { cachePct: result.cachePct, expectedCacheRate: result.expectedCacheRate }; - } else { - manualResult = null; - errorText = result.error; - } - } + import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; + import { onMount, untrack } from "svelte"; + import { + clampMinutes, + clampSeconds, + colorClass, + formatCountdown, + formatWarmLabel, + fromMinSec, + initialWarmingState, + observeWarm, + parseControls, + secondsUntilNext, + statusForPct, + toMinSec, + type WarmingViewState, + type WarmNow, + } from "../logic/view-model"; + + let { + spec, + canWarm, + onInvoke, + warmNow, + }: { + /** The cache-warming surface spec for the focused conversation, or null. */ + spec: SurfaceSpec | null; + /** Whether a real conversation is focused (a draft has nothing to warm). */ + canWarm: boolean; + onInvoke: (msg: InvokeMessage) => void; + warmNow: WarmNow; + } = $props(); + + const controls = $derived(parseControls(spec)); + + // View-model state (pure reducer) + the injected clock — owned here, not ambient. + let vm = $state<WarmingViewState>(initialWarmingState()); + let now = $state(Date.now()); + let warming = $state(false); + let errorText = $state<string | null>(null); + // Transient result of the most recent manual warm (immediate feedback; history + // itself is driven authoritatively by the surface's `lastWarmAt`). + let manualResult = $state<{ cachePct: number; expectedCacheRate: number } | null>(null); + + // Local interval inputs, seeded from the surface and re-seeded only when the + // surface's interval differs from what's shown (so a stray update mid-edit + // doesn't clobber typing). + let minutes = $state(0); + let seconds = $state(0); + + onMount(() => { + const id = setInterval(() => { + now = Date.now(); + }, 1000); + return () => clearInterval(id); + }); + + // Fold each authoritative warm (new `lastWarmAt`) into history. + $effect(() => { + const at = controls.lastWarmAt; + const pct = controls.lastPct; + untrack(() => { + vm = observeWarm(vm, at, pct); + }); + }); + + // Keep the min/sec inputs in sync with the surface's interval. + $effect(() => { + const target = controls.intervalSeconds; + untrack(() => { + if (fromMinSec(minutes, seconds) !== target) { + const ms = toMinSec(target); + minutes = ms.minutes; + seconds = ms.seconds; + } + }); + }); + + const remaining = $derived(secondsUntilNext(controls.nextWarmAt, now)); + const history = $derived(vm.history); + const latest = $derived(history[0] ?? null); + const earlier = $derived(history.slice(1)); + + function commitInterval() { + const actionId = controls.setIntervalActionId; + if (actionId === null || spec === null) return; + onInvoke({ + type: "invoke", + surfaceId: spec.id, + actionId, + payload: fromMinSec(minutes, seconds), + }); + } + + function onMinutes(event: Event) { + const next = (event.target as HTMLInputElement).valueAsNumber; + if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0 + minutes = clampMinutes(next); + commitInterval(); + } + + function onSeconds(event: Event) { + const next = (event.target as HTMLInputElement).valueAsNumber; + if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0 + seconds = clampSeconds(next); + commitInterval(); + } + + function onToggle() { + const actionId = controls.toggleActionId; + if (actionId === null || spec === null) return; + // The toggle action FLIPS server-side; no payload. + onInvoke({ type: "invoke", surfaceId: spec.id, actionId }); + } + + async function handleWarm() { + if (warming) return; + warming = true; + errorText = null; + const result = await warmNow(); + warming = false; + if (result === null) return; + if (result.ok) { + // Immediate feedback only — the authoritative surface `update` (new + // `lastWarmAt`) drives the history via `observeWarm`. + manualResult = { cachePct: result.cachePct, expectedCacheRate: result.expectedCacheRate }; + } else { + manualResult = null; + errorText = result.error; + } + } </script> <div class="flex flex-col gap-3"> - <!-- Enabled --> - <label class="flex items-center justify-between gap-2 text-sm"> - <span>Enabled</span> - <input - type="checkbox" - class="toggle toggle-sm toggle-success" - checked={controls.enabled} - disabled={spec === null} - onchange={onToggle} - /> - </label> - - <!-- Refresh interval: minutes + seconds (seconds capped at 59) --> - <div class="flex items-center justify-between gap-2 text-sm"> - <span>Refresh interval</span> - <span class="flex items-center gap-1"> - <input - type="number" - class="input input-bordered input-sm w-16" - min="0" - value={minutes} - disabled={spec === null} - onchange={onMinutes} - aria-label="Interval minutes" - /> - <span class="opacity-60">m</span> - <input - type="number" - class="input input-bordered input-sm w-16" - min="0" - max="59" - value={seconds} - disabled={spec === null} - onchange={onSeconds} - aria-label="Interval seconds" - /> - <span class="opacity-60">s</span> - </span> - </div> - - <!-- Countdown to the next automatic warm (authoritative: driven by nextWarmAt) --> - {#if !controls.enabled} - <p class="text-xs opacity-50">Warming paused.</p> - {:else if remaining !== null} - <p class="text-xs opacity-70">Next warm in {formatCountdown(remaining)}</p> - {:else} - <p class="text-xs opacity-50">Next warm: waiting…</p> - {/if} - - <!-- Cross-turn retention (the "is warming working?" health signal) --> - {#if controls.retentionPct !== null} - <p class="text-xs {colorClass(statusForPct(controls.retentionPct))}"> - Cache retention: {controls.retentionPct}% - </p> - {/if} - - <!-- Manual trigger --> - <button - type="button" - class="btn btn-sm btn-outline" - disabled={!canWarm || warming} - onclick={handleWarm} - > - {#if warming} - <span class="loading loading-spinner loading-xs"></span> - Warming… - {:else} - Warm now - {/if} - </button> - - {#if !canWarm} - <p class="text-xs opacity-60">Open or start a conversation to control its cache warming.</p> - {:else if errorText} - <p class="text-xs text-error">{errorText}</p> - {:else if manualResult} - <!-- Headline the retention (cache health) over the raw hit %. --> - <p class="text-xs {colorClass(statusForPct(manualResult.expectedCacheRate))}"> - Warmed — {manualResult.expectedCacheRate}% retained ({manualResult.cachePct}% of prompt cached) - </p> - {/if} - - <!-- Warming history: collapse whose title is the most recent warm, coloured by - hit %, with the earlier warmings inside. --> - {#if latest} - <div class="collapse collapse-arrow bg-base-200"> - <input type="checkbox" aria-label="Toggle warming history" /> - <div class="collapse-title min-h-0 py-2 font-normal text-sm {colorClass(statusForPct(latest.pct))}"> - {formatWarmLabel(latest.pct)} - </div> - <div class="collapse-content flex flex-col gap-1 text-sm"> - {#if earlier.length > 0} - {#each earlier as entry, i (i)} - <p class={colorClass(statusForPct(entry.pct))}>{formatWarmLabel(entry.pct)}</p> - {/each} - {:else} - <p class="text-xs opacity-60">No earlier warmings.</p> - {/if} - </div> - </div> - {:else} - <p class="text-xs opacity-60">No warming yet.</p> - {/if} + <!-- Enabled --> + <label class="flex items-center justify-between gap-2 text-sm"> + <span>Enabled</span> + <input + type="checkbox" + class="toggle toggle-sm toggle-success" + checked={controls.enabled} + disabled={spec === null} + onchange={onToggle} + /> + </label> + + <!-- Refresh interval: minutes + seconds (seconds capped at 59) --> + <div class="flex items-center justify-between gap-2 text-sm"> + <span>Refresh interval</span> + <span class="flex items-center gap-1"> + <input + type="number" + class="input input-bordered input-sm w-16" + min="0" + value={minutes} + disabled={spec === null} + onchange={onMinutes} + aria-label="Interval minutes" + /> + <span class="opacity-60">m</span> + <input + type="number" + class="input input-bordered input-sm w-16" + min="0" + max="59" + value={seconds} + disabled={spec === null} + onchange={onSeconds} + aria-label="Interval seconds" + /> + <span class="opacity-60">s</span> + </span> + </div> + + <!-- Countdown to the next automatic warm (authoritative: driven by nextWarmAt) --> + {#if !controls.enabled} + <p class="text-xs opacity-50">Warming paused.</p> + {:else if remaining !== null} + <p class="text-xs opacity-70">Next warm in {formatCountdown(remaining)}</p> + {:else} + <p class="text-xs opacity-50">Next warm: waiting…</p> + {/if} + + <!-- Cross-turn retention (the "is warming working?" health signal) --> + {#if controls.retentionPct !== null} + <p class="text-xs {colorClass(statusForPct(controls.retentionPct))}"> + Cache retention: {controls.retentionPct}% + </p> + {/if} + + <!-- Manual trigger --> + <button + type="button" + class="btn btn-sm btn-outline" + disabled={!canWarm || warming} + onclick={handleWarm} + > + {#if warming} + <span class="loading loading-spinner loading-xs"></span> + Warming… + {:else} + Warm now + {/if} + </button> + + {#if !canWarm} + <p class="text-xs opacity-60">Open or start a conversation to control its cache warming.</p> + {:else if errorText} + <p class="text-xs text-error">{errorText}</p> + {:else if manualResult} + <!-- Headline the retention (cache health) over the raw hit %. --> + <p class="text-xs {colorClass(statusForPct(manualResult.expectedCacheRate))}"> + Warmed — {manualResult.expectedCacheRate}% retained ({manualResult.cachePct}% of prompt + cached) + </p> + {/if} + + <!-- Warming history: collapse whose title is the most recent warm, coloured by + hit %, with the earlier warmings inside. --> + {#if latest} + <div class="collapse collapse-arrow bg-base-200"> + <input type="checkbox" aria-label="Toggle warming history" /> + <div + class="collapse-title min-h-0 py-2 font-normal text-sm {colorClass( + statusForPct(latest.pct), + )}" + > + {formatWarmLabel(latest.pct)} + </div> + <div class="collapse-content flex flex-col gap-1 text-sm"> + {#if earlier.length > 0} + {#each earlier as entry, i (i)} + <p class={colorClass(statusForPct(entry.pct))}>{formatWarmLabel(entry.pct)}</p> + {/each} + {:else} + <p class="text-xs opacity-60">No earlier warmings.</p> + {/if} + </div> + </div> + {:else} + <p class="text-xs opacity-60">No warming yet.</p> + {/if} </div> |
