Files
web-terminal/public/preview-grid.ts
Yaojia Wang f6ef19ebf6 fix(sessions): orphan cleanup was reading the wrong tmux clock
Adversarial review of the previous commit found ten defects. The critical one
would have destroyed exactly the work this feature exists to protect.

CRITICAL — `#{session_activity}` is a CLIENT clock, not an output clock.
It advances on attach and on keypresses, and never moves for a detached session
however much its shell is printing. Measured on tmux 3.6a: a detached session
running `while true; do echo tick; sleep 1; done` still reported
session_activity == session_created after 6 s, while `#{window_activity}` tracked
the output exactly. So "idle for 7 days" was really "nobody has typed for 7 days",
and `Clean up idle 7d+` would have killed long-running unattended sessions that
were busy working — the walk-away case the whole app is built around.
Measured against this host's 69 sessions: the old clock condemned 36, the new one
condemns 33, so three sessions doing real work were one click from deletion.
Now reads window_activity, with a test that fails if the format string regresses.

HIGH — no timeout on any tmux call. maxBuffer bounds a flood but nothing bounded
the wait, and a synchronous exec cannot be interrupted by the event loop, so a
tmux server stopped mid-syscall hung the whole server permanently. All five calls
(including the three that predate this feature) now carry one.

HIGH — listSessions and capturePane were synchronous on a POLLED path. Measured
73 ms and 58 ms per call on this host; every home screen on every device refreshes
on a 5 s timer, and each of those was 73 ms with the event loop stopped, i.e.
73 ms in which no PTY byte reached any terminal anywhere. Both are async now, so
the manager's listOrphans/captureOrphan/killOrphansIdleSince are too. The server
is a byte-shuttle first: nothing polled may block it.

HIGH — GET /orphan-sessions had no rate limiter although, unlike every other read
route, it spawns a subprocess. It shares the preview bucket now.

HIGH — the cleanup confirmation counted CARDS, and the grid is capped at 24 while
the cleanup matches across every session the server enumerates. On this host it
would have said "24" and ended 33 shells. New GET /orphan-sessions/count-idle
answers the real question, and the dialog states that number, or refuses to
proceed if it cannot be determined.

MEDIUM — fetchOrphanSessions collapsed every failure into [], so one 429 or 500
read as "all recovered sessions are gone", tearing down every mounted xterm and
rebuilding it on the next success. It returns null for failure now, and the
refresh leaves the section untouched.

MEDIUM — the preview latch was "card was created", not "preview succeeded", so a
card that lost its single request stayed blank forever. Latches on success.

MEDIUM — overlapping refreshes. refresh() is entered from a 5 s timer, from
killOrphan and from cleanup, with awaits inside and no guard, so a slow poll could
resurrect cards for sessions already killed or re-mount them into a root that
setVisible(false) had just cleared. A generation counter, bumped on teardown and
checked after every await, invalidates stale runs.

MEDIUM — renderPreview could write to a disposed xterm (it throws "Object has
been disposed"). PreviewCard carries an explicit `disposed` flag set by a new
disposeCard() helper, so no caller can dispose a terminal and forget the flag.
Deliberately NOT el.isConnected: a card not yet appended is alive, and two
existing preview-grid tests correctly caught that conflation.

LOW — kill and cleanup discarded their results, so a refusal (403 behind a proxy
whose Origin differs, 404 on a lost race) looked like a dead button. Both report.

Tests: 2209 unit, 27 e2e, 8 orphan integration. Verified live against all 69 real
sessions with none harmed; the window_activity fix confirmed on an isolated tmux
socket (-L) so the developer's sessions were never involved.
2026-07-30 11:28:39 +02:00

392 lines
14 KiB
TypeScript
Raw Permalink 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/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<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
/** 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 <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, 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<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.
* 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<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 []
}
}
/* ── 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<OrphanSessionInfo[] | null> {
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<number | null> {
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<SessionPreview | null> {
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<boolean> {
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<string[]> {
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)))
}