Files
web-terminal/public/timeline.ts
Yaojia Wang d6809c65c4 feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.

Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.

New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).

Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).

Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
2026-06-30 17:42:18 +02:00

227 lines
7.8 KiB
TypeScript

/**
* 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<string> = new Set<TimelineClass>([
'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<string, unknown>
// 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 <div class="tl-row">
* with three child spans: tl-time, tl-icon-<class>, 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<TimelineEvent[]> {
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<typeof setInterval> | 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<void> {
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
}
},
}
}