const BASE_PATH = (typeof document !== 'undefined') ? document.querySelector('meta[name="base-path"]')?.content ?? '' : ''; const API_BASE = (typeof window !== 'undefined') ? window.location.origin + BASE_PATH : ''; // ── Fetch ───────────────────────────────────────────────────────────────────── async function fetchTasks(since = null) { let url = `${API_BASE}/api/tasks`; if (since) { url += `?since=${encodeURIComponent(since)}`; } const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } // Fetches recent executions (last 24h) from /api/executions?since=24h. // fetchFn defaults to window.fetch; injectable for tests. async function fetchRecentExecutions(basePath = BASE_PATH, fetchFn = fetch) { const res = await fetchFn(`${basePath}/api/executions?since=24h`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } // Returns only tasks currently in state RUNNING. function filterRunningTasks(tasks) { return tasks.filter(t => t.state === 'RUNNING'); } // Returns human-readable elapsed time from an ISO timestamp to now. function formatElapsed(startISO) { if (startISO == null) return ''; const elapsed = Math.floor((Date.now() - new Date(startISO).getTime()) / 1000); if (elapsed < 0) return '0s'; const h = Math.floor(elapsed / 3600); const m = Math.floor((elapsed % 3600) / 60); const s = elapsed % 60; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; } // Returns human-readable duration between two ISO timestamps. // If endISO is null, uses now (for in-progress tasks). // If startISO is null, returns '--'. function formatDuration(startISO, endISO) { if (startISO == null) return '--'; const start = new Date(startISO).getTime(); const end = endISO != null ? new Date(endISO).getTime() : Date.now(); const elapsed = Math.max(0, Math.floor((end - start) / 1000)); const h = Math.floor(elapsed / 3600); const m = Math.floor((elapsed % 3600) / 60); const s = elapsed % 60; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; } // Returns last max lines from array (for testability). function extractLogLines(lines, max = 500) { if (lines.length <= max) return lines; return lines.slice(lines.length - max); } // Returns a new array of executions sorted by started_at descending. function sortExecutionsDesc(executions) { return [...executions].sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime(), ); } // ── Render ──────────────────────────────────────────────────────────────────── function formatDate(iso) { if (!iso) return ''; return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } // Returns formatted string for changestats, e.g. "5 files, +127 -43". // Returns empty string for null/undefined input. export function formatChangestats(stats) { if (stats == null) return ''; return `${stats.files_changed} files, +${stats.lines_added} -${stats.lines_removed}`; } // Returns a element for the given stats, // or null if stats is null/undefined. // Accepts an optional doc parameter for testability (defaults to document). export function renderChangestatsBadge(stats, doc = (typeof document !== 'undefined' ? document : null)) { if (stats == null || doc == null) return null; const span = doc.createElement('span'); span.className = 'changestats-badge'; span.textContent = formatChangestats(stats); return span; } // Returns a element indicating whether the // currently-deployed server includes the task's fix commits. // Returns null if status is null/undefined or doc is null. // Accepts an optional doc parameter for testability (defaults to document). export function renderDeploymentBadge(status, doc = (typeof document !== 'undefined' ? document : null)) { if (status == null || doc == null) return null; const span = doc.createElement('span'); if (status.includes_fix) { span.className = 'deployment-badge deployment-badge--deployed'; span.textContent = '✓ Deployed'; } else { return null; } if (status.deployed_commit) { span.title = `Deployed commit: ${status.deployed_commit.slice(0, 8)}`; } 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