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).
275 lines
9.5 KiB
TypeScript
275 lines
9.5 KiB
TypeScript
/**
|
|
* public/preview-grid.ts — shared live-preview thumbnail helpers.
|
|
*
|
|
* The launcher (home session chooser) and the manage page both render a grid of
|
|
* the host's running sessions as read-only xterm thumbnails fed by
|
|
* GET /live-sessions/:id/preview. This module holds the DRY core: the small DOM
|
|
* helper, time/status formatting, the preview card factory, the scaling fit, and
|
|
* the preview fetch — so both pages import it instead of duplicating ~80%.
|
|
*/
|
|
|
|
import { Terminal } from '@xterm/xterm'
|
|
import type { LiveSessionInfo, StatusTelemetry, ClaudeStatus } from '../src/types.js'
|
|
|
|
/** Shape of GET /live-sessions/:id/preview. */
|
|
export interface SessionPreview {
|
|
id: string
|
|
cols: number
|
|
rows: number
|
|
data: string
|
|
}
|
|
|
|
/** Reset screen + scrollback + cursor before writing the tail. */
|
|
export const PREVIEW_CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m'
|
|
export const PREVIEW_THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' }
|
|
|
|
/** Create an element with an optional class and text content. */
|
|
export 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
|
|
}
|
|
|
|
/** Coarse "Ns / Nm / Nh / Nd ago" formatter. */
|
|
export function relTime(ms: number): string {
|
|
const s = Math.max(0, (Date.now() - ms) / 1000)
|
|
if (s < 60) return `${Math.floor(s)}s`
|
|
if (s < 3600) return `${Math.floor(s / 60)}m`
|
|
if (s < 86400) return `${Math.floor(s / 3600)}h`
|
|
return `${Math.floor(s / 86400)}d`
|
|
}
|
|
|
|
/** Human label for a Claude activity status. Supports all ClaudeStatus values including 'stuck'. */
|
|
export function statusText(s: LiveSessionInfo['status']): string {
|
|
if (s === 'working') return '⚙ working'
|
|
if (s === 'waiting') return '⏳ waiting'
|
|
if (s === 'idle') return '✓ idle'
|
|
if (s === 'stuck') return '⚠ stuck'
|
|
return '·'
|
|
}
|
|
|
|
/** Display name for a session: last cwd segment, else the short id. */
|
|
export function sessionName(s: LiveSessionInfo): string {
|
|
return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8)
|
|
}
|
|
|
|
/** A rendered preview card: the element tree plus the live xterm and its parts. */
|
|
export interface PreviewCard {
|
|
el: HTMLElement
|
|
term: Terminal
|
|
inner: HTMLElement
|
|
thumb: HTMLElement
|
|
watch: HTMLElement
|
|
status: HTMLElement
|
|
meta: HTMLElement
|
|
}
|
|
|
|
export interface MakeCardOpts {
|
|
/** Called when the card (thumbnail or Open action) is activated. */
|
|
onOpen: (id: string) => void
|
|
/** Optional extra action button(s) appended to the card actions row. */
|
|
extraActions?: (s: LiveSessionInfo) => HTMLElement[]
|
|
/** Use an <a href> Open link instead of a button (manage page). */
|
|
openHref?: (id: string) => string
|
|
}
|
|
|
|
/** Build a preview card for one session (read-only xterm thumbnail). */
|
|
export function makePreviewCard(s: LiveSessionInfo, opts: MakeCardOpts): PreviewCard {
|
|
const cardEl = el('div', 'mg-card')
|
|
|
|
const head = el('div', 'mg-card-head')
|
|
const title = el('span', 'mg-name', sessionName(s))
|
|
const status = el('span', `mg-status mg-${s.status}`, statusText(s.status))
|
|
const watch = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`)
|
|
head.append(title, status, watch)
|
|
|
|
const thumb = el('div', 'mg-thumb')
|
|
const inner = el('div', 'mg-thumb-inner')
|
|
thumb.append(inner)
|
|
thumb.addEventListener('click', () => opts.onOpen(s.id))
|
|
|
|
const meta = el('div', 'mg-meta')
|
|
|
|
const actions = el('div', 'mg-actions')
|
|
if (opts.openHref) {
|
|
const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement
|
|
open.href = opts.openHref(s.id)
|
|
actions.append(open)
|
|
} else {
|
|
const open = el('button', 'mg-open', 'Open ↗')
|
|
open.addEventListener('click', () => opts.onOpen(s.id))
|
|
actions.append(open)
|
|
}
|
|
if (opts.extraActions) actions.append(...opts.extraActions(s))
|
|
|
|
const term = new Terminal({
|
|
cols: Math.max(2, s.cols),
|
|
rows: Math.max(2, s.rows),
|
|
disableStdin: true,
|
|
cursorBlink: false,
|
|
fontFamily: 'Menlo, Consolas, monospace',
|
|
fontSize: 12,
|
|
scrollback: 0,
|
|
theme: PREVIEW_THEME,
|
|
})
|
|
term.open(inner)
|
|
|
|
cardEl.append(head, thumb, meta, actions)
|
|
return { el: cardEl, term, inner, thumb, watch, status, meta }
|
|
}
|
|
|
|
/** Re-apply a session's live status/watch/meta fields to its card in place. */
|
|
export function updatePreviewCard(card: PreviewCard, s: LiveSessionInfo): void {
|
|
card.status.className = `mg-status mg-${s.status}`
|
|
card.status.textContent = statusText(s.status)
|
|
card.watch.className = s.clientCount > 0 ? 'mg-watch live' : 'mg-watch'
|
|
card.watch.textContent = `👁 ${s.clientCount}`
|
|
}
|
|
|
|
/** Scale the rendered xterm down so the full screen fits `thumbW` px wide. */
|
|
export function fitThumb(card: PreviewCard, thumbW: number, thumbMaxH: number): void {
|
|
const w = card.inner.offsetWidth
|
|
const h = card.inner.offsetHeight
|
|
if (w === 0 || h === 0) return
|
|
const scale = Math.min(1, thumbW / w)
|
|
card.inner.style.transform = `scale(${scale})`
|
|
card.thumb.style.height = `${Math.min(h * scale, thumbMaxH)}px`
|
|
}
|
|
|
|
/** Fetch a session preview, returning null on any failure (best-effort). */
|
|
export async function fetchPreview(id: string): Promise<SessionPreview | null> {
|
|
try {
|
|
const res = await fetch(`/live-sessions/${id}/preview`)
|
|
if (!res.ok) return null
|
|
return (await res.json()) as SessionPreview
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Render a preview into a card's xterm, resizing + scaling to fit. */
|
|
export function renderPreview(card: PreviewCard, p: SessionPreview, thumbW: number, thumbMaxH: number): void {
|
|
if (card.term.cols !== Math.max(2, p.cols) || card.term.rows !== Math.max(2, p.rows)) {
|
|
card.term.resize(Math.max(2, p.cols), Math.max(2, p.rows))
|
|
}
|
|
card.term.reset()
|
|
card.term.write(PREVIEW_CLEAR + p.data, () => requestAnimationFrame(() => fitThumb(card, thumbW, thumbMaxH)))
|
|
}
|
|
|
|
/** Fetch + render a session's preview into its card (best-effort, no throw). */
|
|
export async function loadPreviewInto(
|
|
id: string,
|
|
card: PreviewCard,
|
|
thumbW: number,
|
|
thumbMaxH: number,
|
|
): Promise<void> {
|
|
const p = await fetchPreview(id)
|
|
if (p) renderPreview(card, p, thumbW, thumbMaxH)
|
|
}
|
|
|
|
/** Fetch the host's live sessions list, returning [] on failure. */
|
|
export async function fetchLiveSessions(): Promise<LiveSessionInfo[]> {
|
|
try {
|
|
const res = await fetch('/live-sessions')
|
|
const data: unknown = await res.json()
|
|
return Array.isArray(data) ? (data as LiveSessionInfo[]) : []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render a telemetry gauge into `container` (clears first).
|
|
*
|
|
* Shows: context-usage bar (>80% = warning colour), $cost chip, model chip,
|
|
* and a PR badge. When `telemetry.at` is older than `staleTtlMs` the container
|
|
* receives the class `tg-stale` so CSS can grey it out. When `costBudgetUsd` is
|
|
* set (>0) and `costUsd >= costBudgetUsd`, the cost chip gets `tg-cost-warn`
|
|
* (W3 quick-wins b), mirroring the ctx>80% warn path.
|
|
*
|
|
* Security: all telemetry strings are set via `textContent` (SEC-H5); the PR
|
|
* link href is only set when `url.protocol === 'https:'` (SEC-L5).
|
|
* Zero `innerHTML` anywhere.
|
|
*/
|
|
export function renderTelemetryGauge(
|
|
container: HTMLElement,
|
|
telemetry: StatusTelemetry | null,
|
|
staleTtlMs: number,
|
|
costBudgetUsd?: number,
|
|
): void {
|
|
// Clear existing children
|
|
while (container.firstChild) container.removeChild(container.firstChild)
|
|
|
|
if (!telemetry) {
|
|
container.classList.remove('tg-stale')
|
|
return
|
|
}
|
|
|
|
const isStale = Date.now() - telemetry.at > staleTtlMs
|
|
if (isStale) container.classList.add('tg-stale')
|
|
else container.classList.remove('tg-stale')
|
|
|
|
// Context-usage bar
|
|
if (telemetry.contextUsedPct !== undefined) {
|
|
const bar = el('div', 'tg-ctx-bar')
|
|
const fill = el('div', 'tg-ctx-fill')
|
|
fill.style.width = `${Math.min(100, telemetry.contextUsedPct)}%`
|
|
if (telemetry.contextUsedPct > 80) fill.classList.add('tg-ctx-warn')
|
|
bar.append(fill, el('span', 'tg-ctx-label', `ctx ${Math.round(telemetry.contextUsedPct)}%`))
|
|
container.append(bar)
|
|
}
|
|
|
|
// Cost chip — W3(b): warn-styled once cost crosses the configured budget.
|
|
if (telemetry.costUsd !== undefined) {
|
|
const cost = el('span', 'tg-cost', `$${telemetry.costUsd.toFixed(4)}`)
|
|
if (costBudgetUsd !== undefined && costBudgetUsd > 0 && telemetry.costUsd >= costBudgetUsd) {
|
|
cost.classList.add('tg-cost-warn')
|
|
}
|
|
container.append(cost)
|
|
}
|
|
|
|
// Model chip
|
|
if (telemetry.model !== undefined) {
|
|
container.append(el('span', 'tg-model', telemetry.model))
|
|
}
|
|
|
|
// PR badge
|
|
if (telemetry.pr !== undefined) {
|
|
const badge = el('span', 'tg-pr')
|
|
const link = el('a', 'tg-pr-link')
|
|
link.textContent = `PR #${telemetry.pr.number}`
|
|
|
|
// SEC-L5: only set href for https URLs
|
|
try {
|
|
const parsed = new URL(telemetry.pr.url)
|
|
if (parsed.protocol === 'https:') {
|
|
link.href = telemetry.pr.url
|
|
link.target = '_blank'
|
|
link.rel = 'noopener noreferrer'
|
|
}
|
|
} catch {
|
|
// Invalid URL — leave href unset
|
|
}
|
|
|
|
badge.append(link)
|
|
if (telemetry.pr.reviewState !== undefined) {
|
|
badge.append(el('span', 'tg-pr-state', telemetry.pr.reviewState))
|
|
}
|
|
container.append(badge)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render a Claude status badge into `container` (clears first).
|
|
* 'stuck' shows the ⚠ warning symbol (A5). Uses only textContent (SEC-H5).
|
|
*/
|
|
export function renderStatusBadge(container: HTMLElement, status: ClaudeStatus): void {
|
|
while (container.firstChild) container.removeChild(container.firstChild)
|
|
container.append(el('span', `sb-badge sb-${status}`, statusText(status)))
|
|
}
|