/** * 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, OrphanSessionInfo, 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( 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 /** Set by disposeCard. A preview is fetched fire-and-forget, so a card can be * torn down while its request is in flight, and xterm throws "Object has been * disposed" on a disposed terminal. Checked by renderPreview. */ disposed: boolean 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 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, disposed: false } } /** 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}` } /** Tear a card down: mark it, dispose its terminal, detach its element. One place, * so no caller can dispose the terminal and forget to set the flag. */ export function disposeCard(card: PreviewCard): void { card.disposed = true card.term.dispose() card.el.remove() } /** 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 { 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. * cols/rows of 0 mean "size unknown, keep what the card already has" — the * orphan preview omits them because its dimensions came with the list, and * resizing to Math.max(2, 0) would collapse the thumbnail to 2×2. */ export function renderPreview(card: PreviewCard, p: SessionPreview, thumbW: number, thumbMaxH: number): void { // A preview is fetched fire-and-forget, so the card can be torn down while its // request is in flight, and xterm throws "Object has been disposed". An explicit // flag, not el.isConnected: a card that has not been appended YET is alive, and // conflating the two would drop the first render. if (card.disposed) return if (p.cols > 0 && p.rows > 0 && (card.term.cols !== p.cols || card.term.rows !== 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 { 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 { try { const res = await fetch('/live-sessions') const data: unknown = await res.json() return Array.isArray(data) ? (data as LiveSessionInfo[]) : [] } catch { return [] } } /* ── A: orphan tmux sessions ───────────────────────────────────────────────── Sessions that outlived the server process which created them. They reuse the card machinery above through orphanAsLive, so there is one thumbnail implementation rather than two that drift. */ /** * Fetch untracked tmux sessions. Returns null on FAILURE, distinct from [] meaning * "there are none" — collapsing the two would let a 429, a 500 or a dropped request * read as "all recovered sessions are gone", tearing the grid down and re-mounting * every xterm on the next success. */ export async function fetchOrphanSessions(): Promise { try { const res = await fetch('/orphan-sessions') if (!res.ok) return null const data: unknown = await res.json() return Array.isArray(data) ? (data as OrphanSessionInfo[]) : null } catch { return null } } /** How many sessions the bulk cleanup would actually end. null on failure. */ export async function countIdleOrphans(days: number): Promise { try { const res = await fetch(`/orphan-sessions/count-idle?idleDays=${encodeURIComponent(String(days))}`) if (!res.ok) return null const body = (await res.json()) as { count?: unknown } return typeof body.count === 'number' ? body.count : null } catch { return null } } /** Fetch an orphan's screen (tmux capture-pane). null on any failure. */ export async function fetchOrphanPreview(id: string): Promise { try { const res = await fetch(`/orphan-sessions/${encodeURIComponent(id)}/preview`) if (!res.ok) return null return (await res.json()) as SessionPreview } catch { return null } } /** * Present an orphan as a LiveSessionInfo so makePreviewCard can render it. * * `status` is 'unknown', never 'idle': status comes from Claude Code's hooks into * a session this server is streaming, and for an orphan we have not looked — so * claiming 'idle' would assert something we do not know. `cwd` is null for the * same reason. `clientCount` mirrors tmux's client count, so a session someone is * attached to from a real terminal reads as watched rather than abandoned. */ export function orphanAsLive(o: OrphanSessionInfo): LiveSessionInfo { return { id: o.id, createdAt: o.createdAt, clientCount: o.attached ? 1 : 0, status: 'unknown', exited: false, cwd: null, cols: o.cols, rows: o.rows, lastOutputAt: o.lastActivityAt, } } /** Kill one orphan. Resolves true on 204. */ export async function killOrphanReq(id: string): Promise { try { const res = await fetch(`/orphan-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }) return res.status === 204 } catch { return false } } /** Bulk-kill unattached orphans idle longer than `days`. Returns ids killed. */ export async function cleanupOrphansReq(days: number): Promise { try { const res = await fetch(`/orphan-sessions?idleDays=${encodeURIComponent(String(days))}`, { method: 'DELETE', }) if (!res.ok) return [] const body = (await res.json()) as { ids?: unknown } return Array.isArray(body.ids) ? (body.ids as string[]) : [] } 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))) }