summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/app.js98
-rw-r--r--web/style.css45
-rw-r--r--web/test/event-timeline.test.mjs112
3 files changed, 255 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) {
diff --git a/web/style.css b/web/style.css
index d3b01d0..f4a9d91 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1989,3 +1989,48 @@ dialog label select:focus {
opacity: 0.85;
padding: 0.1rem 0;
}
+
+/* Event timeline (task observability stream) */
+.event-timeline {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.event-timeline__item {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ padding: 6px 10px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--border);
+ border-radius: 6px;
+ font-size: 0.85rem;
+}
+.event-timeline__item--state_change { border-left-color: var(--state-queued); }
+.event-timeline__item--clarification_request { border-left-color: var(--state-blocked); }
+.event-timeline__item--clarification_answer { border-left-color: var(--state-blocked); }
+.event-timeline__item--summary { border-left-color: var(--state-completed); }
+.event-timeline__item--subtask_spawned { border-left-color: var(--accent); }
+.event-timeline__item--execution_started,
+.event-timeline__item--execution_ended { border-left-color: var(--state-running); }
+.event-timeline__actor {
+ flex: 0 0 auto;
+ text-transform: uppercase;
+ font-size: 0.65rem;
+ letter-spacing: 0.04em;
+ color: var(--text-muted);
+ min-width: 56px;
+}
+.event-timeline__text {
+ color: var(--text);
+ word-break: break-word;
+}
+.event-timeline__empty {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ padding: 6px 10px;
+}
diff --git a/web/test/event-timeline.test.mjs b/web/test/event-timeline.test.mjs
new file mode 100644
index 0000000..20ef61a
--- /dev/null
+++ b/web/test/event-timeline.test.mjs
@@ -0,0 +1,112 @@
+// event-timeline.test.mjs — Unit tests for the event timeline component.
+//
+// Run with: node --test web/test/event-timeline.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { formatEventText, renderEventTimeline, fetchTaskEvents } from '../app.js';
+
+function makeDoc() {
+ return {
+ createElement(tag) {
+ return {
+ tag,
+ className: '',
+ textContent: '',
+ children: [],
+ appendChild(child) { this.children.push(child); return child; },
+ };
+ },
+ };
+}
+
+describe('formatEventText', () => {
+ it('formats state_change with from/to', () => {
+ assert.equal(
+ formatEventText({ kind: 'state_change', payload: { from: 'QUEUED', to: 'RUNNING' } }),
+ 'State: QUEUED → RUNNING',
+ );
+ });
+
+ it('formats summary text', () => {
+ assert.equal(
+ formatEventText({ kind: 'summary', payload: { text: 'did the thing' } }),
+ 'Summary: did the thing',
+ );
+ });
+
+ it('formats clarification_request and answer', () => {
+ assert.equal(
+ formatEventText({ kind: 'clarification_request', payload: { text: 'which branch?' } }),
+ 'Question: which branch?',
+ );
+ assert.equal(
+ formatEventText({ kind: 'clarification_answer', payload: { answer: 'main' } }),
+ 'Answer: main',
+ );
+ });
+
+ it('formats subtask_spawned by name', () => {
+ assert.equal(
+ formatEventText({ kind: 'subtask_spawned', payload: { name: 'write tests', subtask_id: 'x' } }),
+ 'Spawned subtask: write tests',
+ );
+ });
+
+ it('falls back to kind for unknown events', () => {
+ assert.equal(formatEventText({ kind: 'mystery', payload: {} }), 'mystery');
+ });
+
+ it('handles null/empty defensively', () => {
+ assert.equal(formatEventText(null), '');
+ assert.equal(formatEventText({ kind: 'agent_message', payload: { message: 'hi' } }), 'hi');
+ });
+});
+
+describe('renderEventTimeline', () => {
+ it('renders one item per event with actor and text', () => {
+ const events = [
+ { kind: 'state_change', actor: 'system', payload: { from: 'PENDING', to: 'QUEUED' } },
+ { kind: 'summary', actor: 'agent', payload: { text: 'done' } },
+ ];
+ const ul = renderEventTimeline(events, makeDoc());
+ assert.equal(ul.className, 'event-timeline');
+ assert.equal(ul.children.length, 2);
+ // each item has [actor, text]
+ assert.equal(ul.children[0].children[0].textContent, 'system');
+ assert.equal(ul.children[0].children[1].textContent, 'State: PENDING → QUEUED');
+ assert.equal(ul.children[1].children[1].textContent, 'Summary: done');
+ });
+
+ it('renders an empty-state item for no events', () => {
+ const ul = renderEventTimeline([], makeDoc());
+ assert.equal(ul.children.length, 1);
+ assert.equal(ul.children[0].className, 'event-timeline__empty');
+ });
+
+ it('returns null when doc is null', () => {
+ assert.equal(renderEventTimeline([], null), null);
+ });
+});
+
+describe('fetchTaskEvents', () => {
+ it('returns parsed array on success', async () => {
+ const stub = async () => ({ ok: true, json: async () => [{ kind: 'summary', payload: {} }] });
+ const events = await fetchTaskEvents('t1', stub);
+ assert.equal(events.length, 1);
+ });
+
+ it('returns [] on non-ok response', async () => {
+ const stub = async () => ({ ok: false, json: async () => ({}) });
+ assert.deepEqual(await fetchTaskEvents('t1', stub), []);
+ });
+
+ it('returns [] when fetch throws', async () => {
+ const stub = async () => { throw new Error('network'); };
+ assert.deepEqual(await fetchTaskEvents('t1', stub), []);
+ });
+
+ it('returns [] when no fetch implementation is available', async () => {
+ assert.deepEqual(await fetchTaskEvents('t1', null), []);
+ });
+});