Files
web-terminal/public/digest.ts
Yaojia Wang 1dd12b035a feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)
Four small, high-delight features that turn passive capture into glanceable signals.

- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
  into the existing concurrent per-repo metadata pass (git rev-list --count
  --left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
  (Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
  on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
  frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
  /config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
  totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
  GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.

All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
2026-07-12 21:27:20 +02:00

157 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* public/digest.ts (W3 quick-wins c) — "while you were away" reconnect banner.
*
* On (re)connect, fetch GET /digest?since=<last-seen> 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<K extends keyof HTMLElementTagNameMap>(
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<string, unknown>, 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<string, unknown>
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<DigestResult | null> {
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<HTMLElement | null> {
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
}