summaryrefslogtreecommitdiff
path: root/web/app.js
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-25 05:05:27 +0000
committerClaude <noreply@anthropic.com>2026-05-25 05:05:27 +0000
commitb44c8277465c3b4e03fe4de4dd93850413023b69 (patch)
treef79131cb5f4d7444f2c358f35ad7749bad612bda /web/app.js
parent6890bafa65a309b540cbe1562c39df137f202369 (diff)
feat(api,web): event-stream timeline (Phase 3)
Adds GET /api/tasks/{id}/events (seq-ordered, ?since_seq for incremental polling) as the timeline data source, and a per-task Timeline section in the web UI that fetches and renders the observability event stream. New pure, unit-tested JS helpers: formatEventText, renderEventTimeline, fetchTaskEvents. The timeline load is async and guarded on a global fetch so the synchronous renderTaskPanel path (and its DOM-mock unit tests) is unaffected. Verified via Go endpoint tests and node --test (272 JS tests pass). Note: the live in-browser panel render was not exercised in this environment — only unit-level coverage. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39
Diffstat (limited to 'web/app.js')
-rw-r--r--web/app.js98
1 files changed, 98 insertions, 0 deletions
diff --git a/web/app.js b/web/app.js
index ff8e381..09b545b 100644
--- a/web/app.js
+++ b/web/app.js
@@ -115,6 +115,89 @@ export function renderDeploymentBadge(status, doc = (typeof document !== 'undefi
return span;
}
+// ── Event timeline ──────────────────────────────────────────────────────────
+// The observability event stream (GET /api/tasks/{id}/events) replaces the
+// ad-hoc question/summary panels. formatEventText turns one event into a human
+// line; renderEventTimeline builds the list element.
+
+// formatEventText returns a one-line human description of a task event.
+export function formatEventText(event) {
+ if (!event) return '';
+ const p = event.payload || {};
+ switch (event.kind) {
+ case 'state_change':
+ return `State: ${p.from || '?'} → ${p.to || '?'}`;
+ case 'summary':
+ return `Summary: ${p.text || p.summary || ''}`;
+ case 'clarification_request':
+ return `Question: ${p.text || ''}`;
+ case 'clarification_answer':
+ return `Answer: ${p.answer || ''}`;
+ case 'subtask_spawned':
+ return `Spawned subtask: ${p.name || p.subtask_id || ''}`;
+ case 'commit_made':
+ return `Commit: ${p.message || p.hash || ''}`;
+ case 'cost_report':
+ return `Cost: $${p.cost_usd != null ? p.cost_usd : 0}`;
+ case 'agent_message':
+ case 'human_message':
+ return p.text || p.message || '';
+ case 'execution_started':
+ return 'Execution started';
+ case 'execution_ended':
+ return p.status ? `Execution ended (${p.status})` : 'Execution ended';
+ default:
+ return event.kind || '';
+ }
+}
+
+// renderEventTimeline builds a <ul class="event-timeline"> from an events array.
+// Accepts an optional doc parameter for testability (defaults to document).
+export function renderEventTimeline(events, doc = (typeof document !== 'undefined' ? document : null)) {
+ if (doc == null) return null;
+ const ul = doc.createElement('ul');
+ ul.className = 'event-timeline';
+ if (!events || events.length === 0) {
+ const empty = doc.createElement('li');
+ empty.className = 'event-timeline__empty';
+ empty.textContent = 'No events yet.';
+ ul.appendChild(empty);
+ return ul;
+ }
+ for (const event of events) {
+ const li = doc.createElement('li');
+ li.className = `event-timeline__item event-timeline__item--${event.kind || 'unknown'}`;
+
+ const actor = doc.createElement('span');
+ actor.className = 'event-timeline__actor';
+ actor.textContent = event.actor || '';
+ li.appendChild(actor);
+
+ const text = doc.createElement('span');
+ text.className = 'event-timeline__text';
+ text.textContent = formatEventText(event);
+ li.appendChild(text);
+
+ ul.appendChild(li);
+ }
+ return ul;
+}
+
+// fetchTaskEvents loads a task's event stream. fetchImpl defaults to the global
+// fetch; pass a stub in tests. Returns [] on any error so the UI degrades
+// gracefully.
+export async function fetchTaskEvents(taskId, fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) {
+ if (!fetchImpl) return [];
+ try {
+ const resp = await fetchImpl(`/api/tasks/${taskId}/events`);
+ if (!resp.ok) return [];
+ const data = await resp.json();
+ return Array.isArray(data) ? data : [];
+ } catch {
+ return [];
+ }
+}
+
function truncateToWordBoundary(text, maxLen = 120) {
if (!text || text.length <= maxLen) return text;
const cut = text.lastIndexOf(' ', maxLen);
@@ -2055,6 +2138,21 @@ export function renderTaskPanel(task, executions) {
execSection.appendChild(list);
}
content.appendChild(execSection);
+
+ // ── Timeline ──
+ // Observability event stream, loaded asynchronously. Guarded so the sync
+ // panel render (and its unit tests, which have no fetch) is unaffected.
+ const timelineSection = makeSection('Timeline');
+ const timelineContainer = document.createElement('div');
+ timelineContainer.className = 'event-timeline-container';
+ timelineSection.appendChild(timelineContainer);
+ content.appendChild(timelineSection);
+ if (typeof fetch !== 'undefined') {
+ fetchTaskEvents(task.id).then(events => {
+ timelineContainer.innerHTML = '';
+ timelineContainer.appendChild(renderEventTimeline(events));
+ });
+ }
}
async function handleViewLogs(execId) {