summaryrefslogtreecommitdiffhomepage
path: root/src/features/cache-warming/ui/CacheWarmingView.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-11 16:06:48 +0900
committerAdam Malczewski <[email protected]>2026-06-11 16:06:48 +0900
commite45cab2a2d9d7bf5e48ace7111fd84b1b9bf2df3 (patch)
treee9cd8665a3eea609ef1e027906be4abdfe67d876 /src/features/cache-warming/ui/CacheWarmingView.svelte
parentb3f7ba523f644224364d155b575fa3f9f13c5eb9 (diff)
downloaddispatch-web-e45cab2a2d9d7bf5e48ace7111fd84b1b9bf2df3.tar.gz
dispatch-web-e45cab2a2d9d7bf5e48ace7111fd84b1b9bf2df3.zip
feat(cache-warming,surfaces,metrics,markdown): conversation-scoped surfaces, cache warming + retention, markdown
Consumes the backend cache-warming + cache-rate handoffs end-to-end and adds supporting infra: - protocol/transport: conversation-scoped surfaces (conversationId on subscribe/invoke/surface + staleness routing); store auto-subscribes the catalog with the focused conversation and re-scopes on switch. - surface-host: generic Number field renderer + custom rendererId dispatch (graceful skip on unknown). - cache-warming feature: enabled toggle, min+sec interval, AUTHORITATIVE countdown from the surface's cache-warming-timer nextWarmAt, manual Warm now (POST /chat/warm), lastWarmAt-keyed history, cache-retention stat, expectedCacheRate headline. - metrics: cross-turn expected-cache (retention) derivation + bubble badge; cache-rate fix needs no code change (inputTokens now total). - markdown feature: marked + marked-highlight + highlight.js + dompurify, rendered in ChatView. - fixes (gemini review): {#key activeConversationId} remount of CacheWarmingView to stop history/feedback leaking across tabs; guard NaN interval inputs from committing 0. - docs/contracts: regenerated transport/ui-contract mirrors; backend-handoff updated (CR-3 resolved). Verified: svelte-check 0 errors, biome clean, 494 tests pass, vite build OK.
Diffstat (limited to 'src/features/cache-warming/ui/CacheWarmingView.svelte')
-rw-r--r--src/features/cache-warming/ui/CacheWarmingView.svelte234
1 files changed, 234 insertions, 0 deletions
diff --git a/src/features/cache-warming/ui/CacheWarmingView.svelte b/src/features/cache-warming/ui/CacheWarmingView.svelte
new file mode 100644
index 0000000..ced5e99
--- /dev/null
+++ b/src/features/cache-warming/ui/CacheWarmingView.svelte
@@ -0,0 +1,234 @@
+<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;
+ }
+ }
+</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}
+</div>