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).
157 lines
6.1 KiB
TypeScript
157 lines
6.1 KiB
TypeScript
/**
|
||
* 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
|
||
}
|