/** * public/timeline.ts — Activity timeline panel (N-timeline-ui, A4). * * Renders a live-polling panel of server-derived timeline events for a session. * Fetches GET /live-sessions/:id/events and shows rows: "HH:MM · icon · label". * * All event strings are set via textContent only (SEC-H6 — labels may contain * tool-derived content such as file paths). Zero innerHTML with external data. * * Owned by: N-timeline-ui (W1, public/timeline.ts) * Depends on: T-types (TimelineEvent, TimelineClass) */ import type { TimelineEvent, TimelineClass } from '../src/types.js' import { el } from './preview-grid.js' /* ─── Constants ─────────────────────────────────────────────────────────── */ const DEFAULT_REFRESH_MS = 5_000 const DEFAULT_MAX_EVENTS = 50 const LABEL_MAX_LEN = 500 const TOOL_NAME_MAX_LEN = 200 const VALID_CLASSES: ReadonlySet = new Set([ 'tool', 'waiting', 'done', 'stuck', 'user', ]) /* ─── Public types ───────────────────────────────────────────────────────── */ /** Handle returned by mountTimeline; call dispose() to stop polling. */ export interface TimelineHandle { dispose(): void } /** Options for mountTimeline. */ export interface TimelineOpts { /** Polling interval in ms (default 5 000). */ refreshMs?: number /** Maximum number of events to display (default 50). */ maxEvents?: number } /* ─── normalizeTimelineEvent ─────────────────────────────────────────────── */ /** * Safely narrow an unknown value to TimelineEvent, returning null on failure. * Never throws. Validates all fields defensively (SEC-H6). */ export function normalizeTimelineEvent(raw: unknown): TimelineEvent | null { if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null const r = raw as Record // Validate `at` — must be a finite number if (typeof r['at'] !== 'number' || !Number.isFinite(r['at'])) return null // Validate `class` — must be a known TimelineClass string if (!isValidClass(r['class'])) return null // Validate `label` — must be a string if (typeof r['label'] !== 'string') return null const event: TimelineEvent = { at: r['at'], class: r['class'], label: r['label'].slice(0, LABEL_MAX_LEN), } // Optional toolName if (typeof r['toolName'] === 'string') { event.toolName = r['toolName'].slice(0, TOOL_NAME_MAX_LEN) } return event } function isValidClass(v: unknown): v is TimelineClass { return typeof v === 'string' && VALID_CLASSES.has(v) } /* ─── timelineIcon ───────────────────────────────────────────────────────── */ /** Map a TimelineClass to a display icon string. */ export function timelineIcon(cls: TimelineClass): string { switch (cls) { case 'tool': return '🔧' case 'waiting': return '⏳' case 'done': return '✓' case 'stuck': return '⚠' case 'user': return '💬' } } /* ─── Time formatting ────────────────────────────────────────────────────── */ /** Format a unix-ms timestamp as HH:MM (24-hour wall-clock). */ function formatHHMM(ms: number): string { const d = new Date(ms) const h = d.getHours().toString().padStart(2, '0') const m = d.getMinutes().toString().padStart(2, '0') return `${h}:${m}` } /* ─── renderTimelineEvent ────────────────────────────────────────────────── */ /** * Render one timeline event as a DOM row: "HH:MM · icon · label". * * All strings set via textContent (SEC-H6). Returns a
* with three child spans: tl-time, tl-icon-, tl-label. */ export function renderTimelineEvent(ev: TimelineEvent): HTMLElement { const row = el('div', 'tl-row') row.append( el('span', 'tl-time', formatHHMM(ev.at)), el('span', `tl-icon tl-icon-${ev.class}`, timelineIcon(ev.class)), el('span', 'tl-label', ev.label), // textContent only — SEC-H6 ) return row } /* ─── fetchTimeline ──────────────────────────────────────────────────────── */ /** * Fetch the activity timeline for `id` from GET /live-sessions/:id/events. * Returns [] on any error (network failure, non-ok response, bad shape). */ export async function fetchTimeline(id: string): Promise { try { const res = await fetch(`/live-sessions/${encodeURIComponent(id)}/events`) if (!res.ok) return [] const data: unknown = await res.json() if (!Array.isArray(data)) return [] return data .map((item: unknown) => normalizeTimelineEvent(item)) .filter((ev): ev is TimelineEvent => ev !== null) } catch { return [] } } /* ─── mountTimeline ──────────────────────────────────────────────────────── */ /** * Mount a live-polling activity timeline panel into `container`. * * - Fetches GET /live-sessions/:id/events immediately on mount. * - Re-polls every `refreshMs` ms (default 5 s) while the container is * in the viewport (IntersectionObserver when available, always-on fallback). * - Renders events newest-first, capped at `maxEvents`. * - Shows an empty-state message when there are no events. * - Call dispose() to stop polling and disconnect the observer. * * Security: event labels rendered via textContent only (SEC-H6). */ export function mountTimeline( container: HTMLElement, id: string, opts?: TimelineOpts, ): TimelineHandle { const refreshMs = opts?.refreshMs ?? DEFAULT_REFRESH_MS const maxEvents = opts?.maxEvents ?? DEFAULT_MAX_EVENTS let visible = true let intervalId: ReturnType | null = null let observer: IntersectionObserver | null = null let disposed = false /** Clear the container and re-render events newest-first. */ function render(events: TimelineEvent[]): void { while (container.firstChild) container.removeChild(container.firstChild) const capped = events.slice(0, maxEvents) // Reverse so newest is first (server returns oldest-first) const newestFirst = [...capped].reverse() if (newestFirst.length === 0) { container.append(el('div', 'tl-empty', 'No activity yet')) return } for (const ev of newestFirst) { container.append(renderTimelineEvent(ev)) } } async function poll(): Promise { if (!visible || disposed) return const events = await fetchTimeline(id) if (!disposed) render(events) } // Initial fetch void poll() // Start interval polling intervalId = setInterval(() => { void poll() }, refreshMs) // Pause/resume based on viewport visibility when IntersectionObserver is available if (typeof IntersectionObserver !== 'undefined') { observer = new IntersectionObserver((entries) => { const entry = entries[0] visible = entry?.isIntersecting ?? true }) observer.observe(container) } return { dispose(): void { if (disposed) return disposed = true if (intervalId !== null) { clearInterval(intervalId) intervalId = null } if (observer !== null) { observer.disconnect() observer = null } }, } }