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.
227 lines
7.8 KiB
TypeScript
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
|
|
}
|
|
},
|
|
}
|
|
}
|