/** * public/digest.ts (W3 quick-wins c) — "while you were away" reconnect banner. * * On (re)connect, fetch GET /digest?since= and, if anything happened * while away (finished / waiting / stuck), show ONE compact dismissible banner. * The last-seen watermark is stored per-device in localStorage and advanced to * the digest's generatedAt after each render so it never re-nags for old news. * * Best-effort: any fetch/parse failure → no banner (never throws). All text is * set via textContent (SEC-H5) — session titles are attacker-influenced. */ import type { DigestResult } from '../src/types.js' const LAST_SEEN_KEY = 'web-terminal:digest-last-seen' /* ── DOM helper ──────────────────────────────────────────────────────────────── */ function el( tag: K, cls?: string, text?: string, ): HTMLElementTagNameMap[K] { const node = document.createElement(tag) if (cls) node.className = cls if (text !== undefined) node.textContent = text return node } /* ── last-seen watermark (per-device) ────────────────────────────────────────── */ /** Read the stored last-seen epoch-ms, or 0 (everything is new). Never throws. */ export function getLastSeen(): number { try { const raw = localStorage.getItem(LAST_SEEN_KEY) if (raw === null) return 0 const n = Number(raw) return Number.isFinite(n) && n >= 0 ? n : 0 } catch { return 0 } } /** Persist the last-seen epoch-ms watermark. Best-effort. */ export function setLastSeen(ms: number): void { try { localStorage.setItem(LAST_SEEN_KEY, String(Math.floor(ms))) } catch { // storage unavailable (private mode) — the banner just re-shows next time } } /* ── normalize (never trust the API shape) ───────────────────────────────────── */ function num(o: Record, key: string): number { const v = o[key] return typeof v === 'number' && Number.isFinite(v) ? v : 0 } /** Coerce an untrusted GET /digest response into a DigestResult, or null. */ export function normalizeDigest(raw: unknown): DigestResult | null { if (raw === null || typeof raw !== 'object') return null const o = raw as Record if (typeof o['generatedAt'] !== 'number' || !Number.isFinite(o['generatedAt'])) return null return { since: num(o, 'since'), generatedAt: o['generatedAt'], total: num(o, 'total'), finished: num(o, 'finished'), needsInput: num(o, 'needsInput'), stuck: num(o, 'stuck'), working: num(o, 'working'), totalCostUsd: num(o, 'totalCostUsd'), sessions: Array.isArray(o['sessions']) ? (o['sessions'] as DigestResult['sessions']) : [], } } /* ── fetch ───────────────────────────────────────────────────────────────────── */ /** Fetch the digest since `since`. null on any error (best-effort). */ export async function fetchDigest(since: number): Promise { try { if (typeof fetch === 'undefined') return null const res = await fetch(`/digest?since=${encodeURIComponent(String(since))}`) if (!res.ok) return null return normalizeDigest(await res.json()) } catch { return null } } /* ── render (pure) ───────────────────────────────────────────────────────────── */ /** Count of things worth surfacing (finished / waiting / stuck). */ export function digestHighlightCount(d: DigestResult): number { return d.finished + d.needsInput + d.stuck } /** Compact human summary, e.g. "2 finished · 1 waiting · 1 stuck". */ export function digestSummary(d: DigestResult): string { const parts: string[] = [] if (d.finished > 0) parts.push(`${d.finished} finished`) if (d.needsInput > 0) parts.push(`${d.needsInput} waiting for input`) if (d.stuck > 0) parts.push(`${d.stuck} stuck`) return parts.join(' · ') } /** * Build the banner element for a digest, or null when nothing is worth showing. * `onDismiss` is wired to the × button. All text via textContent (SEC-H5). */ export function renderDigestBanner(d: DigestResult, onDismiss: () => void): HTMLElement | null { if (digestHighlightCount(d) === 0) return null const banner = el('div', 'wya-banner') banner.setAttribute('role', 'status') banner.append(el('span', 'wya-title', 'While you were away')) banner.append(el('span', 'wya-summary', digestSummary(d))) if (d.totalCostUsd > 0) { banner.append(el('span', 'wya-cost', `$${d.totalCostUsd.toFixed(2)} total`)) } const dismiss = el('button', 'wya-dismiss', '✕') dismiss.title = 'Dismiss' dismiss.setAttribute('aria-label', 'Dismiss') dismiss.addEventListener('click', onDismiss) banner.append(dismiss) return banner } /* ── mount ───────────────────────────────────────────────────────────────────── */ /** * Fetch the digest since the stored last-seen and, if anything happened, prepend * a dismissible banner to `host`. Advances the last-seen watermark to the * digest's generatedAt so it doesn't re-nag on the next reconnect. Best-effort: * a fetch failure shows no banner. Returns the banner element (or null). */ export async function mountDigest(host: HTMLElement): Promise { const since = getLastSeen() const d = await fetchDigest(since) if (d === null) return null // best-effort — no banner on failure // Advance the watermark now so a refresh (without a dismiss) doesn't re-nag. setLastSeen(d.generatedAt) const banner = renderDigestBanner(d, () => { setLastSeen(d.generatedAt) banner?.remove() }) if (banner === null) return null host.prepend(banner) return banner }