diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/cache-warming/index.ts | 8 | ||||
| -rw-r--r-- | src/features/cache-warming/logic/view-model.test.ts | 220 | ||||
| -rw-r--r-- | src/features/cache-warming/logic/view-model.ts | 242 | ||||
| -rw-r--r-- | src/features/cache-warming/ui/CacheWarmingView.svelte | 234 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 11 | ||||
| -rw-r--r-- | src/features/markdown/index.ts | 8 | ||||
| -rw-r--r-- | src/features/markdown/logic/markdown.test.ts | 58 | ||||
| -rw-r--r-- | src/features/markdown/logic/markdown.ts | 165 | ||||
| -rw-r--r-- | src/features/markdown/ui/Markdown.svelte | 58 | ||||
| -rw-r--r-- | src/features/markdown/ui/markdown.test.ts | 40 | ||||
| -rw-r--r-- | src/features/surface-host/logic/plan.test.ts | 41 | ||||
| -rw-r--r-- | src/features/surface-host/logic/plan.ts | 33 | ||||
| -rw-r--r-- | src/features/surface-host/logic/types.ts | 17 | ||||
| -rw-r--r-- | src/features/surface-host/ui/Number.svelte | 43 | ||||
| -rw-r--r-- | src/features/surface-host/ui/SurfaceView.svelte | 3 |
15 files changed, 1178 insertions, 3 deletions
diff --git a/src/features/cache-warming/index.ts b/src/features/cache-warming/index.ts new file mode 100644 index 0000000..c432de6 --- /dev/null +++ b/src/features/cache-warming/index.ts @@ -0,0 +1,8 @@ +export type { WarmFeedback, WarmNow } from "./logic/view-model"; +export { default as CacheWarmingView } from "./ui/CacheWarmingView.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "cache-warming", + description: "Prompt-cache warming controls, history, and countdown", +} as const; diff --git a/src/features/cache-warming/logic/view-model.test.ts b/src/features/cache-warming/logic/view-model.test.ts new file mode 100644 index 0000000..3d6f6d0 --- /dev/null +++ b/src/features/cache-warming/logic/view-model.test.ts @@ -0,0 +1,220 @@ +import type { SurfaceSpec } from "@dispatch/ui-contract"; +import { describe, expect, it } from "vitest"; +import { + clampMinutes, + clampSeconds, + colorClass, + formatCountdown, + formatWarmLabel, + fromMinSec, + initialWarmingState, + observeWarm, + parseControls, + parsePct, + secondsUntilNext, + statusForPct, + toMinSec, +} from "./view-model"; + +const spec = (fields: SurfaceSpec["fields"]): SurfaceSpec => ({ + id: "cache-warming", + region: "side", + title: "Cache Warming", + fields, +}); + +describe("parsePct", () => { + it("parses a percentage string", () => { + expect(parsePct("100%")).toBe(100); + expect(parsePct("93 %")).toBe(93); + expect(parsePct("0%")).toBe(0); + }); + it("returns null for a dash / non-numeric", () => { + expect(parsePct("—")).toBeNull(); + expect(parsePct("n/a")).toBeNull(); + }); +}); + +describe("parseControls", () => { + it("returns empty defaults for a null spec", () => { + const c = parseControls(null); + expect(c).toEqual({ + enabled: false, + toggleActionId: null, + intervalSeconds: 0, + setIntervalActionId: null, + lastPct: null, + retentionPct: null, + nextWarmAt: null, + lastWarmAt: null, + }); + }); + + it("extracts toggle / number / both stats / timer by kind", () => { + const c = parseControls( + spec([ + { + kind: "toggle", + label: "Enabled", + value: true, + action: { actionId: "cache-warming/toggle" }, + }, + { + kind: "number", + label: "Interval", + value: 240, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }, + { kind: "stat", label: "Last cache rate", value: "61%" }, + { kind: "stat", label: "Cache retention", value: "100%" }, + { + kind: "custom", + rendererId: "cache-warming-timer", + payload: { nextWarmAt: 1_700_000_240_000, lastWarmAt: 1_700_000_000_000 }, + }, + ]), + ); + expect(c).toEqual({ + enabled: true, + toggleActionId: "cache-warming/toggle", + intervalSeconds: 240, + setIntervalActionId: "cache-warming/set-interval", + lastPct: 61, + retentionPct: 100, + nextWarmAt: 1_700_000_240_000, + lastWarmAt: 1_700_000_000_000, + }); + }); + + it("tells the retention stat apart from the rate stat by label", () => { + const c = parseControls( + spec([ + { kind: "stat", label: "Cache retention", value: "100%" }, + { kind: "stat", label: "Last cache rate", value: "61%" }, + ]), + ); + expect(c.retentionPct).toBe(100); + expect(c.lastPct).toBe(61); + }); + + it("treats a '—' stat as no pct", () => { + const c = parseControls(spec([{ kind: "stat", label: "Last cache rate", value: "—" }])); + expect(c.lastPct).toBeNull(); + }); + + it("ignores an unknown custom renderer and a malformed timer payload", () => { + const c = parseControls( + spec([ + { kind: "custom", rendererId: "something-else", payload: { nextWarmAt: 5 } }, + { kind: "custom", rendererId: "cache-warming-timer", payload: "nope" }, + ]), + ); + expect(c.nextWarmAt).toBeNull(); + expect(c.lastWarmAt).toBeNull(); + }); +}); + +describe("interval ↔ min/sec", () => { + it("clampSeconds caps at 0..59", () => { + expect(clampSeconds(75)).toBe(59); + expect(clampSeconds(-3)).toBe(0); + expect(clampSeconds(30)).toBe(30); + expect(clampSeconds(Number.NaN)).toBe(0); + }); + it("clampMinutes floors at 0", () => { + expect(clampMinutes(-1)).toBe(0); + expect(clampMinutes(4)).toBe(4); + }); + it("toMinSec splits total seconds", () => { + expect(toMinSec(240)).toEqual({ minutes: 4, seconds: 0 }); + expect(toMinSec(125)).toEqual({ minutes: 2, seconds: 5 }); + expect(toMinSec(45)).toEqual({ minutes: 0, seconds: 45 }); + }); + it("fromMinSec combines (clamping seconds to 59)", () => { + expect(fromMinSec(4, 0)).toBe(240); + expect(fromMinSec(2, 5)).toBe(125); + expect(fromMinSec(1, 75)).toBe(119); // 75s clamped to 59 + }); +}); + +describe("status + formatting", () => { + it("statusForPct buckets high/mid/low", () => { + expect(statusForPct(100)).toBe("success"); + expect(statusForPct(80)).toBe("success"); + expect(statusForPct(60)).toBe("warning"); + expect(statusForPct(40)).toBe("warning"); + expect(statusForPct(10)).toBe("error"); + }); + it("colorClass maps to literal DaisyUI classes", () => { + expect(colorClass("success")).toBe("text-success"); + expect(colorClass("warning")).toBe("text-warning"); + expect(colorClass("error")).toBe("text-error"); + }); + it("formatWarmLabel matches the manual-warm phrasing", () => { + expect(formatWarmLabel(100)).toBe("Warmed — 100% cache hit"); + expect(formatWarmLabel(92.6)).toBe("Warmed — 93% cache hit"); + }); + it("formatCountdown renders s and m:ss", () => { + expect(formatCountdown(9)).toBe("9s"); + expect(formatCountdown(59)).toBe("59s"); + expect(formatCountdown(60)).toBe("1:00"); + expect(formatCountdown(185)).toBe("3:05"); + expect(formatCountdown(-5)).toBe("0s"); + }); +}); + +describe("warming history reducer (observeWarm)", () => { + it("starts empty", () => { + const s = initialWarmingState(); + expect(s.history).toEqual([]); + expect(s.lastWarmAt).toBeNull(); + }); + + it("records a new entry on each new authoritative lastWarmAt", () => { + let s = initialWarmingState(); + s = observeWarm(s, 1000, 100); + s = observeWarm(s, 2000, 90); + expect(s.history).toEqual([ + { pct: 90, at: 2000 }, + { pct: 100, at: 1000 }, + ]); + expect(s.lastWarmAt).toBe(2000); + }); + + it("de-duplicates on the timestamp, not the pct (a re-pushed surface → no dup)", () => { + let s = initialWarmingState(); + s = observeWarm(s, 1000, 100); // warm + s = observeWarm(s, 1000, 100); // toggle/interval re-push, same lastWarmAt → skip + expect(s.history).toHaveLength(1); + }); + + it("records two warms with the SAME pct (distinct timestamps both count)", () => { + let s = initialWarmingState(); + s = observeWarm(s, 1000, 100); + s = observeWarm(s, 2000, 100); + expect(s.history.map((e) => e.at)).toEqual([2000, 1000]); + }); + + it("ignores a null lastWarmAt; a null pct advances the key without an entry", () => { + let s = initialWarmingState(); + s = observeWarm(s, null, 100); + expect(s.history).toEqual([]); + s = observeWarm(s, 1000, null); + expect(s.history).toEqual([]); + expect(s.lastWarmAt).toBe(1000); + }); +}); + +describe("secondsUntilNext (authoritative, from nextWarmAt)", () => { + it("is null when nothing is scheduled (nextWarmAt null)", () => { + expect(secondsUntilNext(null, 5000)).toBeNull(); + }); + + it("counts down to nextWarmAt, floored at 0", () => { + expect(secondsUntilNext(10_000, 10_000)).toBe(0); + expect(secondsUntilNext(250_000, 10_000)).toBe(240); + expect(secondsUntilNext(70_000, 10_000)).toBe(60); + expect(secondsUntilNext(5_000, 999_999)).toBe(0); // already past + }); +}); diff --git a/src/features/cache-warming/logic/view-model.ts b/src/features/cache-warming/logic/view-model.ts new file mode 100644 index 0000000..f7740d7 --- /dev/null +++ b/src/features/cache-warming/logic/view-model.ts @@ -0,0 +1,242 @@ +import type { SurfaceSpec } from "@dispatch/ui-contract"; + +/** + * Pure core for the cache-warming view — zero DOM, zero effects, zero Svelte. + * + * The backend's `cache-warming` surface carries a toggle, a number interval (in + * seconds), two `stat`s ("last cache rate" + "cache retention"), and a `custom` + * `cache-warming-timer` field bearing the AUTHORITATIVE `nextWarmAt`/`lastWarmAt` + * epoch-ms timestamps. This module turns those inputs into the view-model the + * (thin) Svelte component renders: parsed controls, a warming-history reducer + * keyed off the authoritative `lastWarmAt`, an authoritative countdown, and the + * status/format helpers. + */ + +// ── Manual-warm port (consumer-defines-port; the composition root adapts the +// store's `POST /chat/warm` result to this shape). ────────────────────────── +export type WarmFeedback = + | { readonly ok: true; readonly cachePct: number; readonly expectedCacheRate: number } + | { readonly ok: false; readonly error: string }; + +export type WarmNow = () => Promise<WarmFeedback | null>; + +// ── Parsed surface controls ─────────────────────────────────────────────────── + +export interface ParsedControls { + readonly enabled: boolean; + readonly toggleActionId: string | null; + readonly intervalSeconds: number; + readonly setIntervalActionId: string | null; + /** Most recent warm's cache-hit %, from the "last cache rate" stat (`null` when "—"/absent). */ + readonly lastPct: number | null; + /** Cross-turn retention %, from the "cache retention" stat (`null` when "—"/absent). */ + readonly retentionPct: number | null; + /** Authoritative epoch-ms the next AUTOMATIC warm fires, or `null` when not scheduled. */ + readonly nextWarmAt: number | null; + /** Authoritative epoch-ms of the most recent completed warm, or `null` if none. */ + readonly lastWarmAt: number | null; +} + +const EMPTY_CONTROLS: ParsedControls = { + enabled: false, + toggleActionId: null, + intervalSeconds: 0, + setIntervalActionId: null, + lastPct: null, + retentionPct: null, + nextWarmAt: null, + lastWarmAt: null, +}; + +/** The `cache-warming-timer` custom field's renderer id (this feature owns it). */ +const TIMER_RENDERER_ID = "cache-warming-timer"; + +/** Parse a stat's display string (e.g. "100%", "93 %", "—") into a number or null. */ +export function parsePct(value: string): number | null { + const match = value.match(/-?\d+(?:\.\d+)?/); + if (match === null) return null; + const n = Number(match[0]); + return Number.isFinite(n) ? n : null; +} + +/** A finite number, else null. */ +function numOrNull(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +/** Pull the authoritative `nextWarmAt`/`lastWarmAt` out of the timer custom payload. */ +function parseTimer(payload: unknown): { nextWarmAt: number | null; lastWarmAt: number | null } { + if (typeof payload !== "object" || payload === null) { + return { nextWarmAt: null, lastWarmAt: null }; + } + const p = payload as Record<string, unknown>; + return { nextWarmAt: numOrNull(p.nextWarmAt), lastWarmAt: numOrNull(p.lastWarmAt) }; +} + +/** + * Extract the cache-warming controls from the surface spec by FIELD KIND. The + * surface has one toggle, one number, two stats (rate + retention, told apart by + * label), and one `custom` timer field. Returns empty defaults when the spec is + * absent. + */ +export function parseControls(spec: SurfaceSpec | null): ParsedControls { + if (spec === null) return EMPTY_CONTROLS; + let enabled = false; + let toggleActionId: string | null = null; + let intervalSeconds = 0; + let setIntervalActionId: string | null = null; + let lastPct: number | null = null; + let retentionPct: number | null = null; + let nextWarmAt: number | null = null; + let lastWarmAt: number | null = null; + let seenToggle = false; + let seenNumber = false; + let seenRateStat = false; + for (const field of spec.fields) { + if (field.kind === "toggle" && !seenToggle) { + enabled = field.value; + toggleActionId = field.action.actionId; + seenToggle = true; + } else if (field.kind === "number" && !seenNumber) { + intervalSeconds = field.value; + setIntervalActionId = field.action.actionId; + seenNumber = true; + } else if (field.kind === "stat") { + // Retention is told apart by its label; everything else is the cache rate + // (first one wins, so a stray later stat can't clobber it). + if (/retention/i.test(field.label)) { + retentionPct = parsePct(field.value); + } else if (!seenRateStat) { + lastPct = parsePct(field.value); + seenRateStat = true; + } + } else if (field.kind === "custom" && field.rendererId === TIMER_RENDERER_ID) { + const timer = parseTimer(field.payload); + nextWarmAt = timer.nextWarmAt; + lastWarmAt = timer.lastWarmAt; + } + } + return { + enabled, + toggleActionId, + intervalSeconds, + setIntervalActionId, + lastPct, + retentionPct, + nextWarmAt, + lastWarmAt, + }; +} + +// ── Interval ↔ minutes/seconds (seconds capped at 59) ───────────────────────── + +export interface MinSec { + readonly minutes: number; + readonly seconds: number; +} + +export function clampSeconds(n: number): number { + if (!Number.isFinite(n)) return 0; + return Math.min(59, Math.max(0, Math.floor(n))); +} + +export function clampMinutes(n: number): number { + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.floor(n)); +} + +export function toMinSec(totalSeconds: number): MinSec { + const total = Math.max(0, Math.floor(totalSeconds)); + return { minutes: Math.floor(total / 60), seconds: total % 60 }; +} + +/** Combine a minutes + seconds pair (each clamped) into total seconds. */ +export function fromMinSec(minutes: number, seconds: number): number { + return clampMinutes(minutes) * 60 + clampSeconds(seconds); +} + +// ── Status + formatting ─────────────────────────────────────────────────────── + +export type WarmStatus = "success" | "warning" | "error"; + +/** Cache-hit % → semantic status (green high, yellow mid, red low). */ +export function statusForPct(pct: number): WarmStatus { + if (pct >= 80) return "success"; + if (pct >= 40) return "warning"; + return "error"; +} + +/** A status → its DaisyUI text-colour class (full literal so Tailwind keeps it). */ +export function colorClass(status: WarmStatus): string { + switch (status) { + case "success": + return "text-success"; + case "warning": + return "text-warning"; + case "error": + return "text-error"; + } +} + +/** The status line for a warm, matching the manual-warm feedback phrasing. */ +export function formatWarmLabel(pct: number): string { + return `Warmed — ${Math.round(pct)}% cache hit`; +} + +/** Seconds → a short countdown string (e.g. "3:05", "9s"). */ +export function formatCountdown(seconds: number): string { + const s = Math.max(0, Math.floor(seconds)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = s % 60; + return `${m}:${String(rem).padStart(2, "0")}`; +} + +// ── Warming history reducer (keyed off the authoritative `lastWarmAt`) ───────── + +export interface WarmEntry { + readonly pct: number; + /** Authoritative epoch-ms of this warm (the surface's `lastWarmAt`). */ + readonly at: number; +} + +export interface WarmingViewState { + /** Warmings, MOST RECENT FIRST. */ + readonly history: readonly WarmEntry[]; + /** The last authoritative `lastWarmAt` recorded, for change-detection (de-dup key). */ + readonly lastWarmAt: number | null; +} + +const MAX_HISTORY = 50; + +export function initialWarmingState(): WarmingViewState { + return { history: [], lastWarmAt: null }; +} + +/** + * Fold the surface's authoritative `lastWarmAt` + current "last cache rate" into + * history. Records a new entry only when `lastWarmAt` CHANGED (a toggle/interval + * update re-pushes the same timestamp → no entry), de-duplicated on the timestamp + * (not the pct, so two warms with the same % both count). A null `lastWarmAt` is + * ignored; a null pct advances the de-dup key without adding an entry. + */ +export function observeWarm( + state: WarmingViewState, + lastWarmAt: number | null, + pct: number | null, +): WarmingViewState { + if (lastWarmAt === null || lastWarmAt === state.lastWarmAt) return state; + if (pct === null) return { ...state, lastWarmAt }; + const history = [{ pct, at: lastWarmAt }, ...state.history].slice(0, MAX_HISTORY); + return { history, lastWarmAt }; +} + +/** + * Seconds until the next automatic warm, AUTHORITATIVE: derived straight from the + * backend's `nextWarmAt` epoch-ms (never FE-anchored/guessed). `null` when nothing + * is scheduled (disabled, or a turn is generating so the timer is cancelled). + */ +export function secondsUntilNext(nextWarmAt: number | null, now: number): number | null { + if (nextWarmAt === null) return null; + return Math.max(0, Math.ceil((nextWarmAt - now) / 1000)); +} 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> diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index 3d1421d..00691aa 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -3,10 +3,12 @@ import { interleaveTurnMetrics, viewCacheRate, + viewExpectedCache, viewStepMetrics, viewTurnMetrics, type TurnMetricsEntry, } from "../../../core/metrics"; + import { Markdown } from "../../markdown"; const badgeClass = { success: "badge-success", @@ -113,7 +115,7 @@ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> <div class="chat-bubble w-full bg-transparent"> {#if rendered.chunk.type === "text"} - <p>{rendered.chunk.text}</p> + <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} /> {:else if rendered.chunk.type === "error"} <div class="text-error" role="alert"> {rendered.chunk.message} @@ -146,6 +148,7 @@ {@const turnView = viewTurnMetrics(row.turn)} {@const lastCache = viewCacheRate(row.turn.usage)} {@const chatCache = viewCacheRate(row.cumulativeUsage)} + {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)} <div class="chat chat-start"> <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> <div class="flex flex-col gap-1 text-xs"> @@ -163,6 +166,12 @@ <span class="opacity-70">Chat Total:</span> <span class="badge badge-sm {badgeClass[chatCache.level]}">{chatCache.pct}%</span> </span> + {#if retention} + <span class="flex items-center gap-1"> + <span class="opacity-70">Retention:</span> + <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span> + </span> + {/if} </div> </div> </div> diff --git a/src/features/markdown/index.ts b/src/features/markdown/index.ts new file mode 100644 index 0000000..f5406b2 --- /dev/null +++ b/src/features/markdown/index.ts @@ -0,0 +1,8 @@ +export { renderMarkdown } from "./logic/markdown"; +export { default as Markdown } from "./ui/Markdown.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "markdown", + description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)", +} as const; diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts new file mode 100644 index 0000000..7dbb878 --- /dev/null +++ b/src/features/markdown/logic/markdown.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { renderMarkdown } from "./markdown"; + +describe("renderMarkdown", () => { + it("renders GFM markdown (headings, emphasis)", () => { + const html = renderMarkdown("# Title\n\nSome **bold** text."); + expect(html).toContain("<h1"); + expect(html).toContain("Title"); + expect(html).toContain("<strong>bold</strong>"); + }); + + it("highlights fenced code for a known language", () => { + const html = renderMarkdown("```javascript\nconst x = 1;\n```"); + expect(html).toContain("language-javascript"); + expect(html).toContain("hljs-keyword"); // `const` got highlighted + }); + + it("resolves language aliases (js -> javascript)", () => { + const html = renderMarkdown("```js\nconst x = 1;\n```"); + expect(html).toContain("hljs-keyword"); + }); + + it("escapes code for an unknown language without throwing", () => { + const html = renderMarkdown("```nope\n<b>x</b>\n```"); + expect(html).toContain("<b>"); + }); + + it("sanitizes dangerous HTML", () => { + const html = renderMarkdown("Hi <script>alert(1)</script> there"); + expect(html).not.toContain("<script>"); + expect(html).toContain("Hi"); + }); + + it("balances dangling bold emphasis while streaming", () => { + expect(renderMarkdown("a **bold", { streaming: true })).toContain("<strong>bold</strong>"); + }); + + it("does not balance delimiters when not streaming", () => { + expect(renderMarkdown("a **bold")).not.toContain("<strong>"); + }); + + it("wraps fenced code blocks with a copy button", () => { + const html = renderMarkdown("```js\nconst x = 1;\n```"); + expect(html).toContain("code-block"); + expect(html).toContain("data-copy"); + expect(html).toContain("<pre>"); + }); + + it("does not add a copy button to inline code", () => { + const html = renderMarkdown("use `npm run dev` please"); + expect(html).not.toContain("data-copy"); + expect(html).toContain("<code>npm run dev</code>"); + }); + + it("returns an empty string for empty input", () => { + expect(renderMarkdown("")).toBe(""); + }); +}); diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts new file mode 100644 index 0000000..3a6e5a6 --- /dev/null +++ b/src/features/markdown/logic/markdown.ts @@ -0,0 +1,165 @@ +/** + * Pure Markdown → sanitized-HTML renderer for assistant messages. + * + * Mirrors old Dispatch's stack (marked + marked-highlight + highlight.js + + * DOMPurify; GFM + line breaks; streaming delimiter-closing), but kept fully + * SYNCHRONOUS and pure: `input → output`, no effects, no `$effect`. Languages + * are a fixed "hot set" registered at module load (no lazy dynamic import), so a + * single `renderMarkdown(text)` call is deterministic and unit-testable. + * + * The only ambient dependency is DOMPurify, which sanitizes against the DOM — + * present in the browser and in the jsdom test env. + */ + +import DOMPurify from "dompurify"; +import type { LanguageFn } from "highlight.js"; +import hljs from "highlight.js/lib/core"; +import bash from "highlight.js/lib/languages/bash"; +import c from "highlight.js/lib/languages/c"; +import cpp from "highlight.js/lib/languages/cpp"; +import csharp from "highlight.js/lib/languages/csharp"; +import css from "highlight.js/lib/languages/css"; +import go from "highlight.js/lib/languages/go"; +import java from "highlight.js/lib/languages/java"; +import javascript from "highlight.js/lib/languages/javascript"; +import json from "highlight.js/lib/languages/json"; +import markdownLang from "highlight.js/lib/languages/markdown"; +import php from "highlight.js/lib/languages/php"; +import plaintext from "highlight.js/lib/languages/plaintext"; +import python from "highlight.js/lib/languages/python"; +import ruby from "highlight.js/lib/languages/ruby"; +import rust from "highlight.js/lib/languages/rust"; +import shell from "highlight.js/lib/languages/shell"; +import sql from "highlight.js/lib/languages/sql"; +import typescript from "highlight.js/lib/languages/typescript"; +import xml from "highlight.js/lib/languages/xml"; +import yaml from "highlight.js/lib/languages/yaml"; +import { Marked } from "marked"; +import { markedHighlight } from "marked-highlight"; + +// Hot set: registered eagerly so common code blocks highlight on first paint. +const HOT_LANGUAGES: Record<string, LanguageFn> = { + bash, + c, + cpp, + csharp, + css, + go, + java, + javascript, + json, + markdown: markdownLang, + php, + plaintext, + python, + ruby, + rust, + shell, + sql, + typescript, + xml, + yaml, +}; +for (const [name, lang] of Object.entries(HOT_LANGUAGES)) { + hljs.registerLanguage(name, lang); +} + +// Normalize common fence aliases to canonical highlight.js names. +const ALIASES: Record<string, string> = { + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + ts: "typescript", + tsx: "typescript", + py: "python", + py3: "python", + rb: "ruby", + sh: "bash", + zsh: "bash", + yml: "yaml", + "c++": "cpp", + cxx: "cpp", + "c#": "csharp", + cs: "csharp", + htm: "xml", + html: "xml", + svg: "xml", + md: "markdown", + mdx: "markdown", + golang: "go", + rs: "rust", +}; + +function normalizeLang(lang: string): string { + const lower = lang.toLowerCase().trim(); + return ALIASES[lower] ?? lower; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +const md = new Marked( + markedHighlight({ + emptyLangClass: "hljs", + langPrefix: "hljs language-", + highlight(code: string, lang: string): string { + if (!lang) return escapeHtml(code); + const name = normalizeLang(lang); + if (!hljs.getLanguage(name)) return escapeHtml(code); + try { + return hljs.highlight(code, { language: name, ignoreIllegals: true }).value; + } catch { + return escapeHtml(code); + } + }, + }), + { gfm: true, breaks: true }, +); + +/** + * While a message is still streaming, balance dangling fences / emphasis so the + * partial text renders cleanly instead of flashing raw markers. + */ +function closeOpenDelimiters(src: string): string { + let out = src; + const fenceCount = (out.match(/^```/gm) ?? []).length; + if (fenceCount % 2 !== 0) out += "\n```"; + const boldCount = (out.match(/\*\*/g) ?? []).length; + if (boldCount % 2 !== 0) out += "**"; + const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length; + if (inlineCode % 2 !== 0) out += "`"; + return out; +} + +// Wrap each fenced code block (`<pre>…</pre>`) in a positioned container with a +// copy button. marked emits exactly one `<pre>`/`</pre>` pair per block and +// escapes `<`/`>` inside code, so these literal tags only ever delimit blocks. +// `data-copy` is the delegation hook the component listens for; DOMPurify keeps +// `<button>` + `data-*` by default. Inline `<code>` has no `<pre>`, so it's untouched. +const COPY_BUTTON = + '<button type="button" data-copy aria-label="Copy code"' + + ' class="copy-btn btn btn-xs absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">Copy</button>'; + +function addCopyButtons(html: string): string { + return html + .replace(/<pre>/g, `<div class="code-block group relative">${COPY_BUTTON}<pre>`) + .replace(/<\/pre>/g, "</pre></div>"); +} + +/** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */ +export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string { + const src = opts?.streaming === true ? closeOpenDelimiters(text) : text; + try { + const raw = md.parse(src) as string; + return DOMPurify.sanitize(addCopyButtons(raw)); + } catch { + return ""; + } +} diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte new file mode 100644 index 0000000..b828ab9 --- /dev/null +++ b/src/features/markdown/ui/Markdown.svelte @@ -0,0 +1,58 @@ +<script lang="ts"> + import { renderMarkdown } from "../logic/markdown"; + + let { + text, + streaming = false, + }: { + text: string; + /** Balance dangling delimiters while the message is still generating. */ + streaming?: boolean; + } = $props(); + + // Pure transform; the HTML is already DOMPurify-sanitized in renderMarkdown. + const html = $derived(renderMarkdown(text, { streaming })); + + let container: HTMLElement; + + // One delegated listener on the stable container handles every code block's + // copy button — including blocks re-created when `html` changes (streaming), + // since the listener lives on the container, not the buttons. Clipboard is the + // edge effect; absent (insecure context) → no-op. + $effect(() => { + const el = container; + if (el === undefined) return; + + const onClick = (event: Event): void => { + const target = event.target; + if (!(target instanceof Element)) return; + const button = target.closest<HTMLButtonElement>("[data-copy]"); + if (button === null) return; + + const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? ""; + const clipboard = navigator.clipboard; + if (clipboard === undefined) return; + + void clipboard + .writeText(code) + .then(() => { + const prev = button.textContent; + button.textContent = "Copied"; + setTimeout(() => { + button.textContent = prev; + }, 1200); + }) + .catch(() => { + // Clipboard denied — leave the button as-is. + }); + }; + + el.addEventListener("click", onClick); + return () => el.removeEventListener("click", onClick); + }); +</script> + +<div class="markdown-body" bind:this={container}> + <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. --> + {@html html} +</div> diff --git a/src/features/markdown/ui/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts new file mode 100644 index 0000000..e34a4af --- /dev/null +++ b/src/features/markdown/ui/markdown.test.ts @@ -0,0 +1,40 @@ +import { fireEvent, render, screen } from "@testing-library/svelte"; +import { describe, expect, it, vi } from "vitest"; +import Markdown from "./Markdown.svelte"; + +describe("Markdown", () => { + it("renders markdown into a .markdown-body container", () => { + const { container } = render(Markdown, { props: { text: "# Hello\n\n**hi**" } }); + + expect(container.querySelector(".markdown-body")).not.toBeNull(); + expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument(); + expect(container.querySelector("strong")?.textContent).toBe("hi"); + }); + + it("strips dangerous markup", () => { + const { container } = render(Markdown, { + props: { text: "before <script>alert(1)</script> after" }, + }); + + expect(container.querySelector("script")).toBeNull(); + expect(container.textContent).toContain("before"); + }); + + it("renders a copy button on a code block that copies the code to the clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + + const { container } = render(Markdown, { + props: { text: "```js\nconst x = 1;\n```" }, + }); + + const button = container.querySelector<HTMLElement>("[data-copy]"); + expect(button).not.toBeNull(); + if (button === null) throw new Error("expected a copy button"); + + await fireEvent.click(button); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;"); + }); +}); diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts index a5727b4..be296a7 100644 --- a/src/features/surface-host/logic/plan.test.ts +++ b/src/features/surface-host/logic/plan.test.ts @@ -57,6 +57,47 @@ describe("planSurface", () => { expect(plan.fields).toEqual([{ kind: "stat", label: "Tokens", value: "1,234" }]); }); + it("maps a number field to a NumberFieldView, carrying optional hints", () => { + const plan = planSurface( + makeSpec({ + kind: "number", + label: "Interval", + value: 240, + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }), + ); + expect(plan.fields).toEqual([ + { + kind: "number", + label: "Interval", + value: 240, + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }, + ]); + }); + + it("omits absent number hints (no max key when undefined)", () => { + const plan = planSurface( + makeSpec({ + kind: "number", + label: "Interval", + value: 240, + min: 1, + action: { actionId: "set" }, + }), + ); + const field = plan.fields[0]; + expect(field).not.toHaveProperty("max"); + expect(field).not.toHaveProperty("step"); + expect(field).not.toHaveProperty("unit"); + }); + it("maps a button field to a ButtonFieldView", () => { const plan = planSurface( makeSpec({ kind: "button", label: "Retry", action: { actionId: "retry" } }), diff --git a/src/features/surface-host/logic/plan.ts b/src/features/surface-host/logic/plan.ts index 769f9f9..89088c3 100644 --- a/src/features/surface-host/logic/plan.ts +++ b/src/features/surface-host/logic/plan.ts @@ -1,7 +1,21 @@ import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; -import type { FieldView, RenderGroup, StatFieldView, SurfaceRenderPlan } from "./types"; +import type { + FieldView, + NumberFieldView, + RenderGroup, + StatFieldView, + SurfaceRenderPlan, +} from "./types"; -const KNOWN_KINDS = new Set(["toggle", "progress", "selector", "stat", "button", "custom"]); +const KNOWN_KINDS = new Set([ + "toggle", + "progress", + "selector", + "stat", + "number", + "button", + "custom", +]); /** * Validate and normalise a SurfaceSpec into a renderable plan. @@ -46,6 +60,21 @@ export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan { value: field.value, }); break; + case "number": { + // Carry optional hints only when present (exactOptionalPropertyTypes). + const view: NumberFieldView = { + kind: "number", + label: field.label, + value: field.value, + action: field.action, + ...(field.min !== undefined ? { min: field.min } : {}), + ...(field.max !== undefined ? { max: field.max } : {}), + ...(field.step !== undefined ? { step: field.step } : {}), + ...(field.unit !== undefined ? { unit: field.unit } : {}), + }; + fields.push(view); + break; + } case "button": fields.push({ kind: "button", diff --git a/src/features/surface-host/logic/types.ts b/src/features/surface-host/logic/types.ts index d1888a2..23f8757 100644 --- a/src/features/surface-host/logic/types.ts +++ b/src/features/surface-host/logic/types.ts @@ -31,6 +31,22 @@ export interface StatFieldView { readonly value: string; } +/** + * Normalised view-model for a number field — the free-value counterpart to + * selector. `min`/`max`/`step`/`unit` are optional semantic hints (absent when + * the spec omits them). The renderer posts the new number as the action payload. + */ +export interface NumberFieldView { + readonly kind: "number"; + readonly label: string; + readonly value: number; + readonly min?: number; + readonly max?: number; + readonly step?: number; + readonly unit?: string; + readonly action: ActionRef; +} + /** Normalised view-model for a button field. */ export interface ButtonFieldView { readonly kind: "button"; @@ -55,6 +71,7 @@ export type FieldView = | ProgressFieldView | SelectorFieldView | StatFieldView + | NumberFieldView | ButtonFieldView | CustomFieldView; diff --git a/src/features/surface-host/ui/Number.svelte b/src/features/surface-host/ui/Number.svelte new file mode 100644 index 0000000..0f3323d --- /dev/null +++ b/src/features/surface-host/ui/Number.svelte @@ -0,0 +1,43 @@ +<script lang="ts"> + import type { InvokeMessage } from "@dispatch/ui-contract"; + import type { NumberFieldView } from "../logic/types"; + + let { + field, + surfaceId, + onInvoke, + }: { field: NumberFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = + $props(); + + // Commit on change/Enter rather than every keystroke. Ignore empty/non-numeric + // input (the backend also floors/validates); send the new number as payload. + function commit(event: Event) { + const target = event.target as HTMLInputElement; + const next = target.valueAsNumber; + if (Number.isNaN(next)) return; + onInvoke({ + type: "invoke", + surfaceId, + actionId: field.action.actionId, + payload: next, + }); + } +</script> + +<label class="flex items-center justify-between gap-2 text-sm"> + <span>{field.label}</span> + <span class="flex items-center gap-1"> + <input + type="number" + class="input input-bordered input-sm w-24" + value={field.value} + min={field.min} + max={field.max} + step={field.step} + onchange={commit} + /> + {#if field.unit} + <span class="opacity-60">{field.unit}</span> + {/if} + </span> +</label> diff --git a/src/features/surface-host/ui/SurfaceView.svelte b/src/features/surface-host/ui/SurfaceView.svelte index 5210e8c..24be8b8 100644 --- a/src/features/surface-host/ui/SurfaceView.svelte +++ b/src/features/surface-host/ui/SurfaceView.svelte @@ -2,6 +2,7 @@ import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; import { groupRenderFields, planSurface } from "../logic/plan"; import Button from "./Button.svelte"; + import Number from "./Number.svelte"; import Progress from "./Progress.svelte"; import Selector from "./Selector.svelte"; import StatTable from "./StatTable.svelte"; @@ -30,6 +31,8 @@ <Progress field={group.field} /> {:else if group.field.kind === "selector"} <Selector field={group.field} surfaceId={spec.id} {onInvoke} /> + {:else if group.field.kind === "number"} + <Number field={group.field} surfaceId={spec.id} {onInvoke} /> {:else if group.field.kind === "button"} <Button field={group.field} surfaceId={spec.id} {onInvoke} /> {:else if group.field.kind === "custom"} |
