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; } function truncateToWordBoundary(text, maxLen = 120) { if (!text || text.length <= maxLen) return text; const cut = text.lastIndexOf(' ', maxLen); return (cut > 0 ? text.slice(0, cut) : text.slice(0, maxLen)) + '…'; } function createTaskCard(task) { const card = document.createElement('div'); card.className = 'task-card'; card.dataset.taskId = task.id; // Header: name + state badge const header = document.createElement('div'); header.className = 'task-card-header'; const name = document.createElement('span'); name.className = 'task-name'; name.textContent = task.name; const badge = document.createElement('span'); badge.className = 'state-badge'; badge.dataset.state = task.state; badge.textContent = task.state.replace(/_/g, ' '); header.append(name, badge); card.appendChild(header); // Meta: priority + created_at const meta = document.createElement('div'); meta.className = 'task-meta'; if (task.priority) { const prio = document.createElement('span'); prio.textContent = task.priority; meta.appendChild(prio); } if (task.created_at) { const when = document.createElement('span'); when.textContent = formatDate(task.created_at); meta.appendChild(when); } if (task.project) { const proj = document.createElement('span'); proj.className = 'task-project'; proj.textContent = task.project; meta.appendChild(proj); } if (meta.children.length) card.appendChild(meta); // Description (truncated via CSS) if (task.description) { const desc = document.createElement('div'); desc.className = 'task-description'; desc.textContent = task.description; card.appendChild(desc); } // Changestats badge for COMPLETED/READY tasks const CHANGESTATS_STATES = new Set(['COMPLETED', 'READY']); if (CHANGESTATS_STATES.has(task.state) && task.changestats != null) { const csBadge = renderChangestatsBadge(task.changestats); if (csBadge) card.appendChild(csBadge); } // Deployment status badge for READY tasks — only when there are tracked commits to check. if (task.state === 'READY' && task.deployment_status != null && task.deployment_status.fix_commits && task.deployment_status.fix_commits.length > 0) { const depBadge = renderDeploymentBadge(task.deployment_status); if (depBadge) card.appendChild(depBadge); } // Footer: action buttons based on state // Interrupted states (CANCELLED, FAILED, BUDGET_EXCEEDED) show both Resume and Restart. // TIMED_OUT shows Resume only. Others show a single action. const RESUME_STATES = new Set(['TIMED_OUT', 'CANCELLED', 'FAILED', 'BUDGET_EXCEEDED']); const RESTART_STATES = new Set(['CANCELLED', 'FAILED', 'BUDGET_EXCEEDED']); if (task.state === 'PENDING' || task.state === 'RUNNING' || task.state === 'READY' || task.state === 'BLOCKED' || RESUME_STATES.has(task.state)) { const footer = document.createElement('div'); footer.className = 'task-card-footer'; if (task.state === 'PENDING') { const btn = document.createElement('button'); btn.className = 'btn-run'; btn.textContent = 'Run'; btn.addEventListener('click', (e) => { e.stopPropagation(); handleRun(task.id, btn, footer); }); footer.appendChild(btn); } else if (task.state === 'RUNNING') { const btn = document.createElement('button'); btn.className = 'btn-cancel'; btn.textContent = 'Cancel'; btn.addEventListener('click', (e) => { e.stopPropagation(); handleCancel(task.id, btn, footer); }); footer.appendChild(btn); } else if (task.state === 'READY') { renderSubtaskRollup(task, footer); const acceptBtn = document.createElement('button'); acceptBtn.className = 'btn-accept'; acceptBtn.textContent = 'Accept'; acceptBtn.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(task.id, acceptBtn, footer); }); const rejectBtn = document.createElement('button'); rejectBtn.className = 'btn-reject'; rejectBtn.textContent = 'Reject'; rejectBtn.addEventListener('click', (e) => { e.stopPropagation(); handleReject(task.id, rejectBtn, footer); }); footer.appendChild(acceptBtn); footer.appendChild(rejectBtn); } else if (task.state === 'BLOCKED') { if (task.question) { renderQuestionFooter(task, footer); } else { renderSubtaskRollup(task, footer); } const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn-cancel'; cancelBtn.textContent = 'Cancel'; cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); handleCancel(task.id, cancelBtn, footer); }); footer.appendChild(cancelBtn); } else if (RESUME_STATES.has(task.state)) { const resumeBtn = document.createElement('button'); resumeBtn.className = 'btn-resume'; resumeBtn.textContent = 'Resume'; resumeBtn.addEventListener('click', (e) => { e.stopPropagation(); handleResume(task.id, resumeBtn, footer); }); footer.appendChild(resumeBtn); if (RESTART_STATES.has(task.state)) { const restartBtn = document.createElement('button'); restartBtn.className = 'btn-restart'; restartBtn.textContent = 'Restart'; restartBtn.addEventListener('click', (e) => { e.stopPropagation(); handleRestart(task.id, restartBtn, footer); }); footer.appendChild(restartBtn); } } card.appendChild(footer); } if (!NON_DELETABLE_STATES.has(task.state)) { const delBtn = document.createElement('button'); delBtn.className = 'btn-delete-task'; delBtn.title = 'Delete task'; delBtn.textContent = '✕'; delBtn.addEventListener('click', (e) => { e.stopPropagation(); handleDelete(task.id, card); }); card.appendChild(delBtn); } if (EDITABLE_STATES.has(task.state)) { card.classList.add('task-card--editable'); const editForm = createEditForm(task); editForm.hidden = true; card.appendChild(editForm); card.addEventListener('click', () => { editForm.hidden = !editForm.hidden; }); } else { card.addEventListener('click', () => openTaskPanel(task.id)); } return card; } /** * Returns true if the user is currently editing a text field or has a modal open. * Used to avoid destructive DOM refreshes during polling. */ export function isUserEditing(activeEl = (typeof document !== 'undefined' ? document.activeElement : null)) { if (!activeEl) return false; const tag = activeEl.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return true; if (activeEl.isContentEditable) return true; if (activeEl.closest('[role="dialog"]') || activeEl.closest('dialog')) return true; // Also block re-renders when any modal/panel is open, even without focus. if (typeof document !== 'undefined') { if (document.querySelector('dialog[open]')) return true; if (document.getElementById('task-panel')?.classList.contains('open')) return true; } return false; } /** * Partitions tasks into 'running' and 'ready' arrays for the Active tab view. * Both arrays are sorted by created_at ascending (oldest first). */ export function partitionActivePaneTasks(tasks) { const running = tasks .filter(t => t.state === 'RUNNING') .sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const ready = tasks .filter(t => t.state === 'READY') .sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); return { running, ready }; } // ── Sort ────────────────────────────────────────────────────────────────────── function sortTasksByDate(tasks, descend = false) { return [...tasks].sort((a, b) => { if (!a.created_at && !b.created_at) return 0; if (!a.created_at) return 1; if (!b.created_at) return -1; const diff = new Date(a.created_at) - new Date(b.created_at); return descend ? -diff : diff; }); } // ── Filter ──────────────────────────────────────────────────────────────────── const HIDE_STATES = new Set(['COMPLETED', 'FAILED']); const ACTIVE_STATES = new Set(['PENDING', 'QUEUED', 'RUNNING', 'READY']); const INTERRUPTED_STATES = new Set(['CANCELLED', 'FAILED', 'BUDGET_EXCEEDED', 'BLOCKED']); const DONE_STATES = new Set(['COMPLETED', 'TIMED_OUT']); // filterActiveTasks uses its own set (excludes PENDING — tasks "in-flight" only) const _PANEL_ACTIVE_STATES = new Set(['RUNNING', 'READY', 'QUEUED', 'BLOCKED']); export function filterTasks(tasks, hideCompletedFailed = false) { if (!hideCompletedFailed) return tasks; return tasks.filter(t => !HIDE_STATES.has(t.state)); } export function filterActiveTasks(tasks) { return tasks.filter(t => _PANEL_ACTIVE_STATES.has(t.state)); } // The New Task button is always visible regardless of active tab. export function newTaskButtonShouldShowOnTab(_tab) { return true; } export function filterTasksByTab(tasks, tab) { if (tab === 'active') return tasks.filter(t => ACTIVE_STATES.has(t.state)); if (tab === 'interrupted') return tasks.filter(t => INTERRUPTED_STATES.has(t.state)); if (tab === 'done') { const now = new Date(); const twentyFourHoursAgo = new Date(now.getTime() - (24 * 60 * 60 * 1000)); return tasks.filter(t => { if (!DONE_STATES.has(t.state)) return false; if (!t.created_at) return true; // keep if no date return new Date(t.created_at) > twentyFourHoursAgo; }); } return tasks; } // Returns tasks with state QUEUED or PENDING. export function filterQueueTasks(tasks) { return tasks.filter(t => t.state === 'QUEUED' || t.state === 'PENDING'); } // Returns tasks with state READY. export function filterReadyTasks(tasks) { return tasks.filter(t => t.state === 'READY'); } // Returns COMPLETED, TIMED_OUT, BUDGET_EXCEEDED tasks from last 24h. // Pass since24h=false to disable the time filter. export function filterAllDoneTasks(tasks, since24h = true) { const DONE_TAB_STATES = new Set(['COMPLETED', 'TIMED_OUT', 'BUDGET_EXCEEDED']); return tasks.filter(t => { if (!DONE_TAB_STATES.has(t.state)) return false; if (!since24h) return true; if (!t.created_at) return true; // defensive: keep if no date const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); return new Date(t.created_at) > twentyFourHoursAgo; }); } export function getTaskFilterTab() { return localStorage.getItem('taskFilterTab') ?? 'active'; } export function setTaskFilterTab(tab) { localStorage.setItem('taskFilterTab', tab); } // ── Tab badge counts ─────────────────────────────────────────────────────────── /** * Computes badge counts for the 'interrupted', 'ready', and 'running' tabs. * Returns { interrupted: N, ready: N, running: N }. */ export function computeTabBadgeCounts(tasks) { let interrupted = 0; let ready = 0; let running = 0; let all = 0; const now = Date.now(); const twentyFourHoursAgo = now - 24 * 60 * 60 * 1000; for (const t of tasks) { if (INTERRUPTED_STATES.has(t.state)) interrupted++; if (t.state === 'READY') ready++; if (t.state === 'RUNNING') running++; if (DONE_STATES.has(t.state)) { if (!t.created_at || new Date(t.created_at).getTime() > twentyFourHoursAgo) { all++; } } } return { interrupted, ready, running, all }; } /** * Updates the badge count spans inside the tab buttons for * 'interrupted', 'ready', 'running', and 'all'. * Badge is hidden (display:none) when count is zero. */ export function updateTabBadges(tasks, doc = (typeof document !== 'undefined' ? document : null)) { if (!doc) return; const counts = computeTabBadgeCounts(tasks); for (const [tab, count] of Object.entries(counts)) { const btn = doc.querySelector(`.tab[data-tab="${tab}"]`); if (!btn) continue; let badge = btn.querySelector('.tab-count-badge'); if (!badge) { badge = doc.createElement('span'); badge.className = 'tab-count-badge'; btn.appendChild(badge); } badge.textContent = String(count); badge.hidden = count === 0; } } // ── Stats computations ───────────────────────────────────────────────────────── /** * Computes task state distribution from a task array. * Returns { byState: { [state]: count } } — only states with tasks are included. */ export function computeTaskStats(tasks) { const byState = {}; for (const t of tasks) { byState[t.state] = (byState[t.state] || 0) + 1; } return { byState }; } /** * Computes execution health metrics from a RecentExecution array. * Returns: * { total, successRate, totalCostUSD, avgDurationMs, byOutcome } * where successRate is a fraction (0–1), avgDurationMs is null if no durations. */ export function computeExecutionStats(executions) { if (executions.length === 0) { return { total: 0, successRate: 0, totalCostUSD: 0, avgDurationMs: null, byOutcome: {} }; } let completed = 0; let totalCost = 0; let durationSum = 0; let durationCount = 0; const byOutcome = {}; for (const e of executions) { const outcome = e.state || 'unknown'; byOutcome[outcome] = (byOutcome[outcome] || 0) + 1; if (outcome === 'completed') completed++; totalCost += e.cost_usd || 0; if (e.duration_ms != null) { durationSum += e.duration_ms; durationCount++; } } return { total: executions.length, successRate: executions.length > 0 ? completed / executions.length : 0, totalCostUSD: totalCost, avgDurationMs: durationCount > 0 ? Math.round(durationSum / durationCount) : null, byOutcome, }; } export function updateFilterTabs() { const current = getTaskFilterTab(); document.querySelectorAll('.filter-tab[data-filter]').forEach(el => { el.classList.toggle('active', el.dataset.filter === current); }); } function getHideCompletedFailed() { const stored = localStorage.getItem('hideCompletedFailed'); return stored === null ? true : stored === 'true'; } function setHideCompletedFailed(val) { localStorage.setItem('hideCompletedFailed', String(val)); } function updateToggleButton() { const btn = document.getElementById('btn-toggle-completed'); if (!btn) return; btn.textContent = getHideCompletedFailed() ? 'Show completed/failed' : 'Hide completed/failed'; } // Shared helper: renders an array of tasks as cards into a container element. // Now updated to be non-destructive by reusing/updating existing task-card elements. function renderTasksIntoContainer(tasks, container, emptyMsg) { if (!tasks || tasks.length === 0) { container.innerHTML = `
${emptyMsg}
`; return; } // Remove empty message if it exists const empty = container.querySelector('.task-empty'); if (empty) empty.remove(); const existingCards = new Map(); container.querySelectorAll('.task-card').forEach(card => { existingCards.set(card.dataset.taskId, card); }); const taskIds = new Set(tasks.map(t => t.id)); // Remove cards for tasks no longer in this list for (const [id, card] of existingCards.entries()) { if (!taskIds.has(id)) { card.remove(); existingCards.delete(id); } } // Create or update cards and maintain order tasks.forEach((task, index) => { let card = existingCards.get(task.id); const newCard = createTaskCard(task); if (card) { // If the content is exactly the same, we could skip replacing, // but createTaskCard is fast and ensures we have the latest state. // We replace the card in-place to preserve its position if possible. if (card.innerHTML !== newCard.innerHTML) { // Special case: if user is interacting with THIS card, we might want to skip or merge. // For now, createTaskCard ensures we don't disrupt if NOT editing. container.replaceChild(newCard, card); } } else { // Append new card container.appendChild(newCard); } }); // Re-sort cards in DOM to match task list order if they were out of sync const currentCards = Array.from(container.querySelectorAll('.task-card')); tasks.forEach((task, index) => { const card = container.querySelector(`[data-task-id="${task.id}"]`); if (container.children[index] !== card) { container.insertBefore(card, container.children[index]); } }); } function renderQueuePanel(tasks) { const container = document.querySelector('[data-panel="queue"] .panel-task-list'); if (!container) return; const visible = sortTasksByDate(filterQueueTasks(tasks)); renderTasksIntoContainer(visible, container, 'No tasks queued.'); } function renderInterruptedPanel(tasks) { const container = document.querySelector('[data-panel="interrupted"] .panel-task-list'); if (!container) return; const visible = sortTasksByDate(tasks.filter(t => INTERRUPTED_STATES.has(t.state)), true); renderTasksIntoContainer(visible, container, 'No interrupted tasks.'); } function renderReadyPanel(tasks) { const container = document.querySelector('[data-panel="ready"] .panel-task-list'); if (!container) return; const visible = sortTasksByDate(filterReadyTasks(tasks)); renderTasksIntoContainer(visible, container, 'No tasks awaiting review.'); } function renderAllPanel(tasks) { const container = document.querySelector('[data-panel="all"] .all-history'); if (!container) return; const visible = sortTasksByDate(filterAllDoneTasks(tasks), true); renderTasksIntoContainer(visible, container, 'No completed tasks in the last 24h.'); } // ── Run action ──────────────────────────────────────────────────────────────── async function runTask(taskId, agent) { const url = agent && agent !== 'auto' ? `${API_BASE}/api/tasks/${taskId}/run?agent=${agent}` : `${API_BASE}/api/tasks/${taskId}/run`; const res = await fetch(url, { method: 'POST' }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || body.message || msg; } catch {} throw new Error(msg); } return res.json(); } async function handleRun(taskId, btn, footer) { const agentSelector = document.getElementById('select-agent'); const agent = agentSelector ? agentSelector.value : 'auto'; btn.disabled = true; btn.textContent = 'Queuing…'; // Remove any previous error const prev = footer.querySelector('.task-error'); if (prev) prev.remove(); try { await runTask(taskId, agent); // Refresh active panel so state flips to QUEUED await poll(); } catch (err) { btn.disabled = false; btn.textContent = 'Run'; const errEl = document.createElement('span'); errEl.className = 'task-error'; errEl.textContent = `Failed to queue: ${err.message}`; footer.appendChild(errEl); } } // ── Cancel / Restart actions ────────────────────────────────────────────────── async function cancelTask(taskId) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/cancel`, { method: 'POST' }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || body.message || msg; } catch {} throw new Error(msg); } return res.json(); } async function restartTask(taskId) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/run`, { method: 'POST' }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || body.message || msg; } catch {} throw new Error(msg); } return res.json(); } async function resumeTask(taskId) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/resume`, { method: 'POST' }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || body.message || msg; } catch {} throw new Error(msg); } return res.json(); } const NON_DELETABLE_STATES = new Set(['RUNNING', 'QUEUED']); const EDITABLE_STATES = new Set(['PENDING', 'FAILED', 'CANCELLED', 'TIMED_OUT', 'BUDGET_EXCEEDED']); // Convert Duration JSON {"Duration": } to a human string for a text input (e.g. "15m"). function formatDurationForInput(timeout) { const ns = timeout && timeout.Duration; if (!ns) return ''; const secs = Math.round(ns / 1e9); if (secs < 60) return `${secs}s`; const mins = Math.floor(secs / 60); const remSecs = secs % 60; if (mins < 60) return remSecs > 0 ? `${mins}m${remSecs}s` : `${mins}m`; const hrs = Math.floor(mins / 60); const remMins = mins % 60; return remMins > 0 ? `${hrs}h${remMins}m` : `${hrs}h`; } async function deleteTask(taskId) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}`, { method: 'DELETE' }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || body.message || msg; } catch {} throw new Error(msg); } } async function handleDelete(taskId, card) { if (!confirm('Delete this task? This cannot be undone.')) return; try { await deleteTask(taskId); card.remove(); } catch (err) { alert(`Failed to delete: ${err.message}`); } } // ── Inline task editor ──────────────────────────────────────────────────────── async function updateTask(taskId, body) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const b = await res.json(); msg = b.error || b.message || msg; } catch {} throw new Error(msg); } return res.json(); } function createEditForm(task) { const a = task.agent || {}; const form = document.createElement('div'); form.className = 'task-inline-edit'; // Prevent card-level click from toggling this form while user interacts inside it. form.addEventListener('click', (e) => e.stopPropagation()); function makeField(labelText, tag, attrs) { const label = document.createElement('label'); label.textContent = labelText; const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === 'value') el.value = v; else el.setAttribute(k, v); } label.appendChild(el); return label; } form.appendChild(makeField('Name', 'input', { type: 'text', name: 'name', value: task.name || '' })); form.appendChild(makeField('Description', 'textarea', { name: 'description', rows: '2', value: task.description || '' })); form.appendChild(makeField('Instructions', 'textarea', { name: 'instructions', rows: '4', value: a.instructions || '' })); form.appendChild(makeField('Project Directory', 'input', { type: 'text', name: 'project_dir', value: a.project_dir || a.working_dir || '', placeholder: '/path/to/repo' })); form.appendChild(makeField('Max Budget (USD)', 'input', { type: 'number', name: 'max_budget_usd', step: '0.01', value: a.max_budget_usd != null ? String(a.max_budget_usd) : '1.00' })); form.appendChild(makeField('Timeout', 'input', { type: 'text', name: 'timeout', value: formatDurationForInput(task.timeout) || '15m', placeholder: '15m' })); const prioLabel = document.createElement('label'); prioLabel.textContent = 'Priority'; const prioSel = document.createElement('select'); prioSel.name = 'priority'; for (const val of ['high', 'normal', 'low']) { const opt = document.createElement('option'); opt.value = val; opt.textContent = val.charAt(0).toUpperCase() + val.slice(1); if (val === (task.priority || 'normal')) opt.selected = true; prioSel.appendChild(opt); } prioLabel.appendChild(prioSel); form.appendChild(prioLabel); const errEl = document.createElement('div'); errEl.className = 'inline-edit-error'; errEl.hidden = true; form.appendChild(errEl); const actions = document.createElement('div'); actions.className = 'inline-edit-actions'; const cancelBtn = document.createElement('button'); cancelBtn.type = 'button'; cancelBtn.textContent = 'Cancel'; cancelBtn.addEventListener('click', () => { form.hidden = true; }); const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'btn-primary btn-sm'; saveBtn.textContent = 'Save'; saveBtn.addEventListener('click', () => handleEditSave(task.id, form, saveBtn)); actions.append(cancelBtn, saveBtn); form.appendChild(actions); return form; } async function handleEditSave(taskId, form, saveBtn) { const get = name => form.querySelector(`[name="${name}"]`)?.value ?? ''; const body = { name: get('name'), description: get('description'), agent: { instructions: get('instructions'), project_dir: get('project_dir'), max_budget_usd: parseFloat(get('max_budget_usd')), }, timeout: get('timeout'), priority: get('priority'), }; const errEl = form.querySelector('.inline-edit-error'); errEl.hidden = true; saveBtn.disabled = true; saveBtn.textContent = 'Saving…'; try { await updateTask(taskId, body); form.hidden = true; // Brief success flash on the card const card = form.closest('.task-card'); const flash = document.createElement('div'); flash.className = 'inline-edit-success'; flash.textContent = 'Saved'; card.appendChild(flash); setTimeout(() => flash.remove(), 2000); await poll(); } catch (err) { errEl.textContent = `Failed to save: ${err.message}`; errEl.hidden = false; } finally { saveBtn.disabled = false; saveBtn.textContent = 'Save'; } } function renderQuestionFooter(task, footer) { // Prevent any tap inside the question footer from opening the detail panel. footer.addEventListener('click', (e) => e.stopPropagation()); let question = { text: 'Waiting for your input.', options: [] }; if (task.question) { try { question = JSON.parse(task.question); } catch {} } const questionEl = document.createElement('p'); questionEl.className = 'task-question-text'; questionEl.textContent = question.text; footer.appendChild(questionEl); if (question.options && question.options.length > 0) { question.options.forEach(opt => { const btn = document.createElement('button'); btn.className = 'btn-answer'; btn.textContent = opt; btn.addEventListener('click', (e) => { e.stopPropagation(); handleAnswer(task.id, opt, footer); }); footer.appendChild(btn); }); } else { const row = document.createElement('div'); row.className = 'task-answer-row'; const input = document.createElement('input'); input.type = 'text'; input.className = 'task-answer-input'; input.placeholder = 'Your answer…'; const btn = document.createElement('button'); btn.className = 'btn-answer'; btn.textContent = 'Submit'; btn.addEventListener('click', (e) => { e.stopPropagation(); if (input.value.trim()) handleAnswer(task.id, input.value.trim(), footer); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && input.value.trim()) { e.stopPropagation(); handleAnswer(task.id, input.value.trim(), footer); } }); row.append(input, btn); footer.appendChild(row); } } const STATE_EMOJI = { PENDING: '⏳', QUEUED: '🕐', RUNNING: '⚡', COMPLETED: '✅', FAILED: '❌', CANCELLED: '🚫', TIMED_OUT: '⏱', BUDGET_EXCEEDED: '💸', READY: '👀', BLOCKED: '⏸', }; async function renderSubtaskRollup(task, footer) { footer.addEventListener('click', (e) => e.stopPropagation()); const container = document.createElement('div'); container.className = 'subtask-rollup'; footer.prepend(container); try { const res = await fetch(`${API_BASE}/api/tasks/${task.id}/subtasks`); const subtasks = await res.json(); if (!subtasks || subtasks.length === 0) { const blurb = task.elaboration_input || task.description || task.name; container.textContent = blurb ? truncateToWordBoundary(blurb) : 'Waiting for subtasks…'; return; } const ul = document.createElement('ul'); ul.className = 'subtask-list'; for (const st of subtasks) { const li = document.createElement('li'); li.className = `subtask-item subtask-${st.state.toLowerCase()}`; li.textContent = `${STATE_EMOJI[st.state] || '•'} ${st.name}`; ul.appendChild(li); } container.appendChild(ul); } catch { container.textContent = 'Could not load subtasks.'; } } async function handleAnswer(taskId, answer, footer) { const btns = footer.querySelectorAll('button, input'); btns.forEach(el => { el.disabled = true; }); const prev = footer.querySelector('.task-error'); if (prev) prev.remove(); try { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/answer`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ answer }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `HTTP ${res.status}`); } await poll(); } catch (err) { btns.forEach(el => { el.disabled = false; }); const errEl = document.createElement('span'); errEl.className = 'task-error'; errEl.textContent = `Failed: ${err.message}`; footer.appendChild(errEl); } } async function handleCancel(taskId, btn, footer) { btn.disabled = true; btn.textContent = 'Cancelling…'; const prev = footer.querySelector('.task-error'); if (prev) prev.remove(); try { await cancelTask(taskId); await poll(); } catch (err) { btn.disabled = false; btn.textContent = 'Cancel'; const errEl = document.createElement('span'); errEl.className = 'task-error'; errEl.textContent = `Failed: ${err.message}`; footer.appendChild(errEl); } } async function handleRestart(taskId, btn, footer) { btn.disabled = true; btn.textContent = 'Restarting…'; const prev = footer.querySelector('.task-error'); if (prev) prev.remove(); try { await restartTask(taskId); await poll(); } catch (err) { btn.disabled = false; btn.textContent = 'Restart'; const errEl = document.createElement('span'); errEl.className = 'task-error'; errEl.textContent = `Failed: ${err.message}`; footer.appendChild(errEl); } } async function handleResume(taskId, btn, footer) { btn.disabled = true; btn.textContent = 'Resuming…'; const prev = footer.querySelector('.task-error'); if (prev) prev.remove(); try { await resumeTask(taskId); await poll(); } catch (err) { btn.disabled = false; btn.textContent = 'Resume'; const errEl = document.createElement('span'); errEl.className = 'task-error'; errEl.textContent = `Failed: ${err.message}`; footer.appendChild(errEl); } } // ── Accept / Reject actions ──────────────────────────────────────────────────── async function acceptTask(taskId) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/accept`, { method: 'POST' }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || msg; } catch {} throw new Error(msg); } return res.json(); } async function rejectTask(taskId, comment) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ comment: comment || '' }), }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || msg; } catch {} throw new Error(msg); } return res.json(); } async function handleAccept(taskId, btn, footer) { btn.disabled = true; btn.textContent = 'Accepting…'; const prev = footer.querySelector('.task-error'); if (prev) prev.remove(); try { await acceptTask(taskId); await poll(); } catch (err) { btn.disabled = false; btn.textContent = 'Accept'; const errEl = document.createElement('span'); errEl.className = 'task-error'; errEl.textContent = `Failed: ${err.message}`; footer.appendChild(errEl); } } async function handleReject(taskId, btn, footer) { const comment = prompt('Reason for rejection (optional):', ''); if (comment === null) return; // User cancelled prompt btn.disabled = true; btn.textContent = 'Rejecting…'; const prev = footer.querySelector('.task-error'); if (prev) prev.remove(); try { await rejectTask(taskId, comment); await poll(); } catch (err) { btn.disabled = false; btn.textContent = 'Reject'; const errEl = document.createElement('span'); errEl.className = 'task-error'; errEl.textContent = `Failed: ${err.message}`; footer.appendChild(errEl); } } // ── Start-next-task ───────────────────────────────────────────────────────────── async function startNextTask(agent) { const url = agent && agent !== 'auto' ? `${API_BASE}/api/scripts/start-next-task?agent=${agent}` : `${API_BASE}/api/scripts/start-next-task`; const res = await fetch(url, { method: 'POST' }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || msg; } catch {} throw new Error(msg); } return res.json(); } async function handleStartNextTask(btn) { const agentSelector = document.getElementById('select-agent'); const agent = agentSelector ? agentSelector.value : 'auto'; btn.disabled = true; btn.textContent = 'Starting…'; try { const result = await startNextTask(agent); const output = (result.output || '').trim(); btn.textContent = output || 'No task to start'; setTimeout(() => { btn.textContent = 'Start Next'; btn.disabled = false; }, 3000); if (output && output !== 'No task to start.') await poll(); } catch (err) { btn.textContent = `Error: ${err.message}`; setTimeout(() => { btn.textContent = 'Start Next'; btn.disabled = false; }, 3000); } } // ── Polling ─────────────────────────────────────────────────────────────────── let taskCache = new Map(); let lastServerUpdate = null; let pollTimeout = null; let lastUserInteraction = Date.now(); let lastHistoryFetch = 0; function getActiveTab() { const active = document.querySelector('.tab.active'); return active ? active.dataset.tab : 'queue'; } function getRefreshInterval() { const stored = localStorage.getItem('refreshInterval'); return stored ? parseInt(stored, 10) : 10_000; } async function fetchHealth() { const res = await fetch(`${API_BASE}/api/health`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } function renderActiveTab(allTasks) { const activeTab = getActiveTab(); switch (activeTab) { case 'queue': renderQueuePanel(allTasks); break; case 'interrupted': renderInterruptedPanel(allTasks); break; case 'ready': renderReadyPanel(allTasks); break; case 'running': renderRunningView(allTasks); if (Date.now() - lastHistoryFetch > 60_000) { lastHistoryFetch = Date.now(); fetchRecentExecutions(BASE_PATH, fetch) .then(execs => renderRunningHistory(execs)) .catch(() => { const histEl = document.querySelector('.running-history'); if (histEl) histEl.innerHTML = '

Could not load execution history.

'; }); } break; case 'all': renderAllPanel(allTasks); break; case 'stats': fetchRecentExecutions(BASE_PATH, fetch) .then(execs => renderStatsPanel(allTasks, execs)) .catch(() => {}); break; case 'drops': renderDropsPanel(); break; case 'settings': renderSettingsPanel(); break; } } async function poll() { try { const health = await fetchHealth(); const serverUpdate = health.last_updated; // If server says nothing changed, skip fetching but still render (e.g. tab was just switched). if (lastServerUpdate && serverUpdate <= lastServerUpdate && taskCache.size > 0) { renderActiveTab(Array.from(taskCache.values())); return; } const tasks = await fetchTasks(lastServerUpdate); lastServerUpdate = serverUpdate; // Update cache with new/changed tasks for (const t of tasks) { taskCache.set(t.id, t); } if (isUserEditing()) return; const allTasks = Array.from(taskCache.values()); updateTabBadges(allTasks); renderActiveTab(allTasks); } catch (err) { console.error('Polling failed:', err); const panel = document.querySelector('[data-panel="queue"] .panel-task-list'); if (panel && taskCache.size === 0) { panel.innerHTML = '
Could not reach server.
'; } } } function startPolling() { if (pollTimeout) clearTimeout(pollTimeout); const runPoll = async () => { const interval = getRefreshInterval(); if (interval > 0) { const now = Date.now(); const timeSinceInteraction = now - lastUserInteraction; // If user is active, we might want to delay polling slightly, // but for now we just follow the interval if not editing. if (!isUserEditing()) { await poll(); } } pollTimeout = setTimeout(runPoll, getRefreshInterval() || 10_000); }; runPoll(); } // Reset timer on interaction if (typeof window !== 'undefined') { ['mousedown', 'keydown', 'touchstart', 'mousemove'].forEach(evt => { window.addEventListener(evt, () => { lastUserInteraction = Date.now(); }, { passive: true }); }); } function renderSettingsPanel() { const panel = document.querySelector('[data-panel="settings"]'); if (!panel) return; panel.innerHTML = ''; const section = document.createElement('div'); section.className = 'stats-section'; section.style.padding = '1rem'; const heading = document.createElement('h2'); heading.textContent = 'User Settings'; section.appendChild(heading); const refreshLabel = document.createElement('label'); refreshLabel.style.display = 'block'; refreshLabel.style.marginBottom = '0.5rem'; refreshLabel.textContent = 'Auto-Refresh Interval'; const refreshSelect = document.createElement('select'); refreshSelect.className = 'agent-selector'; refreshSelect.style.width = '100%'; const options = [ { label: '5 seconds', value: '5000' }, { label: '10 seconds (default)', value: '10000' }, { label: '30 seconds', value: '30000' }, { label: '1 minute', value: '60000' }, { label: 'Manual only', value: '0' }, ]; const current = String(getRefreshInterval()); options.forEach(opt => { const o = document.createElement('option'); o.value = opt.value; o.textContent = opt.label; if (opt.value === current) o.selected = true; refreshSelect.appendChild(o); }); refreshSelect.addEventListener('change', () => { localStorage.setItem('refreshInterval', refreshSelect.value); startPolling(); // restart with new interval }); refreshLabel.appendChild(refreshSelect); section.appendChild(refreshLabel); panel.appendChild(section); } // ── WebSocket (real-time events) ────────────────────────────────────────────── let ws = null; let activeLogSource = null; function connectWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = `${protocol}//${window.location.host}${BASE_PATH}/api/ws`; ws = new WebSocket(url); ws.onmessage = (event) => { try { const data = JSON.parse(event.data); handleWsEvent(data); } catch { /* ignore parse errors */ } }; ws.onclose = () => { // Reconnect after 3 seconds. setTimeout(connectWebSocket, 3000); }; ws.onerror = () => { ws.close(); }; } function handleWsEvent(data) { switch (data.type) { case 'task_completed': // Force a poll immediately regardless of interval poll(); break; case 'task_question': showQuestionBanner(data); break; } } // ── Question UI ─────────────────────────────────────────────────────────────── function showQuestionBanner(data) { const taskId = data.task_id; const questionId = data.question_id; const questionData = data.data || {}; const questions = questionData.questions || []; // Find the task card for this task. const card = document.querySelector(`.task-card[data-task-id="${taskId}"]`); if (!card) return; // Remove any existing question banner on this card. const existing = card.querySelector('.question-banner'); if (existing) existing.remove(); const banner = document.createElement('div'); banner.className = 'question-banner'; for (const q of questions) { const qDiv = document.createElement('div'); qDiv.className = 'question-item'; const label = document.createElement('div'); label.className = 'question-text'; label.textContent = q.question || 'The agent has a question'; qDiv.appendChild(label); const options = q.options || []; if (options.length > 0) { const btnGroup = document.createElement('div'); btnGroup.className = 'question-options'; for (const opt of options) { const btn = document.createElement('button'); btn.className = 'btn-question-option'; btn.textContent = opt.label; if (opt.description) btn.title = opt.description; btn.addEventListener('click', () => { submitAnswer(taskId, questionId, opt.label, banner); }); btnGroup.appendChild(btn); } qDiv.appendChild(btnGroup); } // Always show a free-text input as fallback. const inputRow = document.createElement('div'); inputRow.className = 'question-input-row'; const input = document.createElement('input'); input.type = 'text'; input.className = 'question-input'; input.placeholder = 'Type an answer…'; const sendBtn = document.createElement('button'); sendBtn.className = 'btn-question-send'; sendBtn.textContent = 'Send'; sendBtn.addEventListener('click', () => { const val = input.value.trim(); if (val) submitAnswer(taskId, questionId, val, banner); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const val = input.value.trim(); if (val) submitAnswer(taskId, questionId, val, banner); } }); inputRow.append(input, sendBtn); qDiv.appendChild(inputRow); banner.appendChild(qDiv); } card.appendChild(banner); } async function submitAnswer(taskId, questionId, answer, banner) { // Disable all buttons in the banner. banner.querySelectorAll('button').forEach(b => { b.disabled = true; }); banner.querySelector('.question-input')?.setAttribute('disabled', ''); try { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/answer`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question_id: questionId, answer }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `HTTP ${res.status}`); } banner.remove(); } catch (err) { const errEl = document.createElement('div'); errEl.className = 'question-error'; errEl.textContent = `Failed: ${err.message}`; banner.appendChild(errEl); // Re-enable buttons. banner.querySelectorAll('button').forEach(b => { b.disabled = false; }); banner.querySelector('.question-input')?.removeAttribute('disabled'); } } // ── Elaborate (Draft with AI) ───────────────────────────────────────────────── async function elaborateTask(prompt, workingDir) { const res = await fetch(`${API_BASE}/api/tasks/elaborate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, project_dir: workingDir }), }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || msg; } catch {} throw new Error(msg); } return res.json(); } // ── Validate ────────────────────────────────────────────────────────────────── async function validateTask(payload) { const res = await fetch(`${API_BASE}/api/tasks/validate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) { let msg = res.statusText; try { const body = await res.json(); msg = body.error || body.message || msg; } catch {} throw new Error(msg); } return res.json(); } function buildValidatePayload() { const f = document.getElementById('task-form'); const name = f.querySelector('[name="name"]').value; const instructions = f.querySelector('[name="instructions"]').value; const project_dir = f.querySelector('#project-select').value; const allowedToolsEl = f.querySelector('[name="allowed_tools"]'); const allowed_tools = allowedToolsEl ? allowedToolsEl.value.split(',').map(s => s.trim()).filter(Boolean) : []; return { name, agent: { instructions, project_dir, allowed_tools } }; } function renderValidationResult(result) { const container = document.getElementById('validate-result'); container.removeAttribute('hidden'); container.dataset.clarity = result.clarity; let icon; if (result.ready === true) { icon = '✓'; } else if (result.clarity === 'ambiguous') { icon = '⚠'; } else { icon = '✗'; } container.innerHTML = ''; const header = document.createElement('div'); header.className = 'validate-header'; const iconSpan = document.createElement('span'); iconSpan.className = 'validate-icon'; iconSpan.textContent = icon; const summarySpan = document.createElement('span'); summarySpan.textContent = ' ' + (result.summary || ''); header.append(iconSpan, summarySpan); container.appendChild(header); if (result.questions && result.questions.length > 0) { const ul = document.createElement('ul'); ul.className = 'validate-questions'; for (const q of result.questions) { const li = document.createElement('li'); li.className = q.severity === 'blocking' ? 'validate-blocking' : 'validate-minor'; li.textContent = q.text; ul.appendChild(li); } container.appendChild(ul); } if (result.suggestions && result.suggestions.length > 0) { const ul = document.createElement('ul'); ul.className = 'validate-suggestions'; for (const s of result.suggestions) { const li = document.createElement('li'); li.className = 'validate-suggestion'; li.textContent = s; ul.appendChild(li); } container.appendChild(ul); } } // ── Task modal ──────────────────────────────────────────────────────────────── async function openTaskModal() { document.getElementById('task-modal').showModal(); await populateProjectSelect(); } async function populateProjectSelect() { const select = document.getElementById('project-select'); const current = select.value; try { const res = await fetch(`${API_BASE}/api/workspaces`); const dirs = await res.json(); select.innerHTML = ''; dirs.forEach(dir => { const opt = document.createElement('option'); opt.value = dir; opt.textContent = dir; if (dir === current || dir === '/workspace/claudomator') opt.selected = true; select.appendChild(opt); }); } catch { // keep whatever options are already there } // Ensure "Create new project…" option is always last const newOpt = document.createElement('option'); newOpt.value = '__new__'; newOpt.textContent = 'Create new project…'; select.appendChild(newOpt); } function initProjectSelect() { const select = document.getElementById('project-select'); const newRow = document.getElementById('new-project-row'); const newInput = document.getElementById('new-project-input'); if (!select) return; select.addEventListener('change', () => { if (select.value === '__new__') { newRow.hidden = false; newInput.required = true; newInput.focus(); } else { newRow.hidden = true; newInput.required = false; newInput.value = ''; } }); } function closeTaskModal() { document.getElementById('task-modal').close(); document.getElementById('task-form').reset(); document.getElementById('elaborate-prompt').value = ''; const validateResult = document.getElementById('validate-result'); validateResult.setAttribute('hidden', ''); validateResult.innerHTML = ''; validateResult.removeAttribute('data-clarity'); } async function createTask(formData) { const selectVal = formData.get('project_dir'); const workingDir = selectVal === '__new__' ? document.getElementById('new-project-input').value.trim() : selectVal; const elaboratePromptEl = document.getElementById('elaborate-prompt'); const elaborationInput = elaboratePromptEl ? elaboratePromptEl.value.trim() : ''; const body = { name: formData.get('name'), description: '', elaboration_input: elaborationInput || undefined, agent: { instructions: formData.get('instructions'), project_dir: workingDir, max_budget_usd: parseFloat(formData.get('max_budget_usd')), }, timeout: formData.get('timeout'), priority: formData.get('priority'), tags: [], }; const res = await fetch(`${API_BASE}/api/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text(); throw new Error(text || `HTTP ${res.status}`); } closeTaskModal(); await poll(); } // ── Task side panel ─────────────────────────────────────────────────────────── // Format Go's task.Duration JSON value {"Duration": } to human string. function formatDurationNs(timeout) { const ns = timeout && timeout.Duration; if (!ns) return '—'; const secs = ns / 1e9; if (secs < 60) return `${secs.toFixed(1)}s`; const mins = Math.floor(secs / 60); const remSecs = Math.floor(secs % 60); if (mins < 60) return remSecs > 0 ? `${mins}m ${remSecs}s` : `${mins}m`; const hrs = Math.floor(mins / 60); const remMins = mins % 60; return remMins > 0 ? `${hrs}h ${remMins}m` : `${hrs}h`; } function formatDateLong(iso) { if (!iso) return '—'; return new Date(iso).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', }); } function openTaskPanel(taskId) { const panel = document.getElementById('task-panel'); const backdrop = document.getElementById('task-panel-backdrop'); const content = document.getElementById('task-panel-content'); document.getElementById('task-panel-title').textContent = 'Task Details'; content.innerHTML = ''; const loading = document.createElement('div'); loading.className = 'panel-loading'; loading.textContent = 'Loading…'; content.appendChild(loading); backdrop.hidden = false; panel.classList.add('open'); Promise.all([ fetch(`${API_BASE}/api/tasks/${taskId}`).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }), fetch(`${API_BASE}/api/tasks/${taskId}/executions`).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }), ]).then(([task, executions]) => { renderTaskPanel(task, executions); }).catch(err => { content.innerHTML = ''; const errEl = document.createElement('div'); errEl.className = 'panel-fetch-error'; errEl.textContent = `Failed to load: ${err.message}`; content.appendChild(errEl); }); } function closeTaskPanel() { closeLogViewer(); document.getElementById('task-panel').classList.remove('open'); document.getElementById('task-panel-backdrop').hidden = true; } function makeSection(title) { const section = document.createElement('div'); section.className = 'panel-section'; const hdr = document.createElement('div'); hdr.className = 'panel-section-title'; hdr.textContent = title; section.appendChild(hdr); return section; } function makeMetaItem(label, valueText, opts = {}) { const item = document.createElement('div'); item.className = 'meta-item' + (opts.fullWidth ? ' full-width' : ''); const lbl = document.createElement('div'); lbl.className = 'meta-label'; lbl.textContent = label; item.appendChild(lbl); if (opts.badge) { const badge = document.createElement('span'); badge.className = 'state-badge'; badge.dataset.state = valueText; badge.textContent = valueText.replace(/_/g, ' '); item.appendChild(badge); } else if (opts.code) { const pre = document.createElement('pre'); pre.className = 'panel-code'; pre.textContent = valueText; item.appendChild(pre); } else if (opts.tags) { const wrap = document.createElement('div'); if (opts.tags.length > 0) { wrap.className = 'panel-tags'; for (const tag of opts.tags) { const chip = document.createElement('span'); chip.className = 'tag-chip'; chip.textContent = tag; wrap.appendChild(chip); } } else { wrap.className = 'meta-value muted'; wrap.textContent = '—'; } item.appendChild(wrap); } else { const val = document.createElement('div'); val.className = 'meta-value' + (opts.mono ? ' mono' : '') + (opts.muted ? ' muted' : ''); val.textContent = valueText || '—'; item.appendChild(val); } return item; } function renderTaskPanel(task, executions) { document.getElementById('task-panel-title').textContent = task.name; const content = document.getElementById('task-panel-content'); content.innerHTML = ''; // ── Summary ── if (task.summary) { const summarySection = makeSection('Summary'); const summaryEl = document.createElement('p'); summaryEl.className = 'task-summary'; summaryEl.textContent = task.summary; summarySection.appendChild(summaryEl); content.appendChild(summarySection); } // ── Q&A History ── if (task.interactions && task.interactions.length > 0) { const qaSection = makeSection('Q&A History'); const qaList = document.createElement('div'); qaList.className = 'qa-list'; for (const interaction of task.interactions) { const qaItem = document.createElement('div'); qaItem.className = 'qa-item'; const qEl = document.createElement('div'); qEl.className = 'qa-question'; qEl.textContent = interaction.question_text || '(question)'; qaItem.appendChild(qEl); if (interaction.options && interaction.options.length > 0) { const opts = document.createElement('div'); opts.className = 'qa-options'; opts.textContent = 'Options: ' + interaction.options.join(', '); qaItem.appendChild(opts); } if (interaction.answer) { const aEl = document.createElement('div'); aEl.className = 'qa-answer'; aEl.textContent = interaction.answer; qaItem.appendChild(aEl); } qaList.appendChild(qaItem); } qaSection.appendChild(qaList); content.appendChild(qaSection); } // ── Overview ── const overview = makeSection('Overview'); const overviewGrid = document.createElement('div'); overviewGrid.className = 'meta-grid'; overviewGrid.append( makeMetaItem('State', task.state, { badge: true }), makeMetaItem('Priority', task.priority), makeMetaItem('Created', formatDateLong(task.created_at)), makeMetaItem('Updated', formatDateLong(task.updated_at)), makeMetaItem('ID', task.id, { fullWidth: true, mono: true }), ); if (task.parent_task_id) { overviewGrid.append(makeMetaItem('Parent Task', task.parent_task_id, { fullWidth: true, mono: true })); } if (task.tags && task.tags.length >= 0) { overviewGrid.append(makeMetaItem('Tags', '', { fullWidth: true, tags: task.tags || [] })); } if (task.project) { overviewGrid.append(makeMetaItem('Project', task.project)); } if (task.description) { overviewGrid.append(makeMetaItem('Description', task.description, { fullWidth: true })); } overview.appendChild(overviewGrid); content.appendChild(overview); // ── Summary ── if (task.summary) { const summarySection = makeSection("Summary"); const summaryText = document.createElement("div"); summaryText.className = "task-summary-text"; summaryText.textContent = task.summary; summarySection.appendChild(summaryText); content.appendChild(summarySection); } // ── Agent Config ── const a = task.agent || {}; const agentSection = makeSection('Agent Config'); const agentGrid = document.createElement('div'); agentGrid.className = 'meta-grid'; agentGrid.append( makeMetaItem('Type', a.type || 'claude'), makeMetaItem('Model', a.model), makeMetaItem('Max Budget', a.max_budget_usd != null ? `$${a.max_budget_usd.toFixed(2)}` : '—'), makeMetaItem('Project Dir', a.project_dir || a.working_dir), makeMetaItem('Permission Mode', a.permission_mode || 'default'), ); if (a.allowed_tools && a.allowed_tools.length > 0) { agentGrid.append(makeMetaItem('Allowed Tools', a.allowed_tools.join(', '), { fullWidth: true })); } if (a.disallowed_tools && a.disallowed_tools.length > 0) { agentGrid.append(makeMetaItem('Disallowed Tools', a.disallowed_tools.join(', '), { fullWidth: true })); } if (a.instructions) { agentGrid.append(makeMetaItem('Instructions', a.instructions, { fullWidth: true, code: true })); } if (a.system_prompt_append) { agentGrid.append(makeMetaItem('System Prompt Append', a.system_prompt_append, { fullWidth: true, code: true })); } agentSection.appendChild(agentGrid); content.appendChild(agentSection); // ── Q&A History ── let interactions = []; if (task.interactions) { try { interactions = JSON.parse(task.interactions); } catch {} } if (interactions.length > 0) { const qaSection = makeSection("Q&A History"); const timeline = document.createElement("div"); timeline.className = "qa-timeline"; for (const item of interactions) { const entry = document.createElement("div"); entry.className = `qa-item qa-${item.type}`; const label = document.createElement("span"); label.className = "qa-label"; label.textContent = item.type === "question" ? "Agent asked:" : "User answered:"; const text = document.createElement("div"); text.className = "qa-content"; text.textContent = item.content; const ts = document.createElement("span"); ts.className = "qa-timestamp"; ts.textContent = item.timestamp ? formatDate(item.timestamp) : ""; entry.append(label, text, ts); timeline.appendChild(entry); } qaSection.appendChild(timeline); content.appendChild(qaSection); } // ── Execution Settings ── const settingsSection = makeSection('Execution Settings'); const settingsGrid = document.createElement('div'); settingsGrid.className = 'meta-grid'; settingsGrid.append( makeMetaItem('Timeout', formatDurationNs(task.timeout)), makeMetaItem('Retry Attempts', String(task.retry ? task.retry.max_attempts : 1)), makeMetaItem('Backoff', task.retry ? task.retry.backoff : '—'), ); if (task.depends_on && task.depends_on.length > 0) { settingsGrid.append(makeMetaItem('Depends On', task.depends_on.join(', '), { fullWidth: true, mono: true })); } settingsSection.appendChild(settingsGrid); content.appendChild(settingsSection); // ── Executions ── const execSection = makeSection('Executions'); if (!executions || executions.length === 0) { const none = document.createElement('div'); none.className = 'meta-value muted'; none.textContent = 'No executions yet.'; execSection.appendChild(none); } else { const list = document.createElement('div'); list.className = 'executions-list'; // Newest first for (const exec of [...executions].reverse()) { const row = document.createElement('div'); row.className = 'execution-row'; const shortId = document.createElement('span'); shortId.className = 'execution-id'; shortId.textContent = exec.ID ? exec.ID.slice(0, 8) : '—'; row.appendChild(shortId); const badge = document.createElement('span'); badge.className = 'state-badge'; badge.dataset.state = exec.Status || ''; badge.textContent = (exec.Status || '—').replace(/_/g, ' '); row.appendChild(badge); const times = document.createElement('span'); times.className = 'execution-times'; const start = exec.StartTime ? formatDate(exec.StartTime) : '?'; const end = exec.EndTime && exec.EndTime !== '0001-01-01T00:00:00Z' ? formatDate(exec.EndTime) : '…'; times.textContent = `${start} → ${end}`; row.appendChild(times); if (exec.CostUSD != null && exec.CostUSD > 0) { const cost = document.createElement('span'); cost.className = 'execution-cost'; cost.textContent = `$${exec.CostUSD.toFixed(4)}`; row.appendChild(cost); } const exitEl = document.createElement('span'); exitEl.className = 'execution-exit'; exitEl.textContent = `exit: ${exec.ExitCode ?? '—'}`; row.appendChild(exitEl); if (exec.Changestats != null) { const csBadge = renderChangestatsBadge(exec.Changestats); if (csBadge) row.appendChild(csBadge); } if (exec.Commits && exec.Commits.length > 0) { const commitList = document.createElement('div'); commitList.className = 'execution-commits'; for (const commit of exec.Commits) { const item = document.createElement('div'); item.className = 'commit-item'; const hash = document.createElement('span'); hash.className = 'commit-hash'; hash.textContent = commit.hash.slice(0, 7); item.appendChild(hash); const msg = document.createElement('span'); msg.className = 'commit-msg'; msg.textContent = commit.message; item.appendChild(msg); commitList.appendChild(item); } row.appendChild(commitList); } const logsBtn = document.createElement('button'); logsBtn.className = 'btn-view-logs'; logsBtn.textContent = 'View Logs'; logsBtn.addEventListener('click', () => { const panelContent = document.getElementById('task-panel-content'); openLogViewer(exec.ID, panelContent); }); row.appendChild(logsBtn); list.appendChild(row); } execSection.appendChild(list); } content.appendChild(execSection); } async function handleViewLogs(execId) { const modal = document.getElementById('logs-modal'); const body = document.getElementById('logs-modal-body'); document.getElementById('logs-modal-title').textContent = `Execution ${execId.slice(0, 8)}`; body.innerHTML = '
Loading…
'; modal.showModal(); try { const res = await fetch(`${API_BASE}/api/executions/${execId}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const exec = await res.json(); body.innerHTML = ''; const grid = document.createElement('div'); grid.className = 'meta-grid'; const entries = [ ['ID', exec.ID, { fullWidth: true, mono: true }], ['Status', exec.Status, { badge: true }], ['Exit Code', String(exec.ExitCode ?? '—'), {}], ['Cost', exec.CostUSD > 0 ? `$${exec.CostUSD.toFixed(4)}` : '—', {}], ['Start', formatDateLong(exec.StartTime), {}], ['End', exec.EndTime && !exec.EndTime.startsWith('0001-') ? formatDateLong(exec.EndTime) : '—', {}], ['Error', exec.ErrorMsg || '—', { fullWidth: true }], ['Stdout', exec.StdoutPath || '—', { fullWidth: true, mono: true }], ['Stderr', exec.StderrPath || '—', { fullWidth: true, mono: true }], ]; for (const [label, value, opts] of entries) { grid.appendChild(makeMetaItem(label, value, opts)); } body.appendChild(grid); } catch (err) { body.innerHTML = `
Failed to load: ${err.message}
`; } } // ── Log viewer ──────────────────────────────────────────────────────────────── function openLogViewer(execId, containerEl) { // Save original children so Back can restore them (with event listeners intact) const originalChildren = [...containerEl.childNodes]; containerEl.innerHTML = ''; const viewer = document.createElement('div'); viewer.className = 'log-viewer'; // Back button const backBtn = document.createElement('button'); backBtn.className = 'log-back-btn'; backBtn.textContent = '← Back'; backBtn.addEventListener('click', () => { closeLogViewer(); containerEl.innerHTML = ''; for (const node of originalChildren) containerEl.appendChild(node); }); viewer.appendChild(backBtn); // Pulsing status indicator const statusEl = document.createElement('div'); statusEl.className = 'log-status-indicator'; statusEl.textContent = 'Streaming...'; viewer.appendChild(statusEl); // Log output area const logOutput = document.createElement('div'); logOutput.className = 'log-output'; logOutput.style.fontFamily = 'monospace'; logOutput.style.overflowY = 'auto'; logOutput.style.maxHeight = '400px'; viewer.appendChild(logOutput); containerEl.appendChild(viewer); let userScrolled = false; logOutput.addEventListener('scroll', () => { const nearBottom = logOutput.scrollHeight - logOutput.scrollTop - logOutput.clientHeight < 50; userScrolled = !nearBottom; }); const source = new EventSource(`${API_BASE}/api/executions/${execId}/logs/stream`); activeLogSource = source; source.onmessage = (event) => { let data; try { data = JSON.parse(event.data); } catch { return; } const line = document.createElement('div'); line.className = 'log-line'; switch (data.type) { case 'text': { line.classList.add('log-text'); line.textContent = data.text ?? data.content ?? ''; break; } case 'tool_use': { line.classList.add('log-tool-use'); const toolName = document.createElement('span'); toolName.className = 'tool-name'; toolName.textContent = `[${data.name ?? 'Tool'}]`; line.appendChild(toolName); const inputStr = data.input ? JSON.stringify(data.input) : ''; const inputPreview = document.createElement('span'); inputPreview.textContent = ' ' + inputStr.slice(0, 120); line.appendChild(inputPreview); break; } case 'tool_result': { line.classList.add('log-tool-result'); line.style.opacity = '0.6'; const content = Array.isArray(data.content) ? data.content.map(c => c.text ?? '').join(' ') : (data.content ?? ''); line.textContent = String(content).slice(0, 120); break; } case 'cost': { line.classList.add('log-cost'); const cost = data.total_cost ?? data.cost ?? 0; line.textContent = `Cost: $${Number(cost).toFixed(3)}`; break; } default: return; } logOutput.appendChild(line); if (!userScrolled) { logOutput.scrollTop = logOutput.scrollHeight; } }; source.addEventListener('done', () => { source.close(); activeLogSource = null; userScrolled = false; statusEl.classList.remove('log-status-indicator'); statusEl.textContent = 'Stream complete'; }); source.onerror = () => { source.close(); activeLogSource = null; statusEl.hidden = true; const errEl = document.createElement('div'); errEl.className = 'log-line log-error'; errEl.textContent = 'Connection error. Stream closed.'; logOutput.appendChild(errEl); }; } function closeLogViewer() { activeLogSource?.close(); activeLogSource = null; } // ── Running view ─────────────────────────────────────────────────────────────── // Map of taskId → EventSource for live log streams in the Running tab. const runningViewLogSources = {}; function renderRunningView(tasks) { const currentEl = document.querySelector('.running-current'); if (!currentEl) return; const running = filterRunningTasks(tasks); // Close SSE streams for tasks that are no longer RUNNING. for (const [id, src] of Object.entries(runningViewLogSources)) { if (!running.find(t => t.id === id)) { src.close(); delete runningViewLogSources[id]; } } // Update elapsed spans in place if the same tasks are still running. const existingCards = currentEl.querySelectorAll('[data-task-id]'); const existingIds = new Set([...existingCards].map(c => c.dataset.taskId)); const unchanged = running.length > 0 && running.length === existingCards.length && running.every(t => existingIds.has(t.id)); if (unchanged) { updateRunningElapsed(); return; } // Full re-render. currentEl.innerHTML = ''; const h2 = document.createElement('h2'); h2.textContent = 'Currently Running'; currentEl.appendChild(h2); if (running.length === 0) { const empty = document.createElement('p'); empty.className = 'task-meta'; empty.textContent = 'No tasks are currently running.'; currentEl.appendChild(empty); return; } for (const task of running) { const card = document.createElement('div'); card.className = 'running-task-card task-card'; card.dataset.taskId = task.id; const header = document.createElement('div'); header.className = 'task-card-header'; const name = document.createElement('span'); name.className = 'task-name'; name.textContent = task.name; const badge = document.createElement('span'); badge.className = 'state-badge'; badge.dataset.state = task.state; badge.textContent = task.state; const elapsed = document.createElement('span'); elapsed.className = 'running-elapsed'; elapsed.dataset.startedAt = task.updated_at ?? ''; elapsed.textContent = formatElapsed(task.updated_at); header.append(name, badge, elapsed); card.appendChild(header); // Parent context (async fetch) if (task.parent_task_id) { const parentEl = document.createElement('div'); parentEl.className = 'task-meta'; parentEl.textContent = 'Subtask of: …'; card.appendChild(parentEl); fetch(`${API_BASE}/api/tasks/${task.parent_task_id}`) .then(r => r.ok ? r.json() : null) .then(parent => { if (parent) parentEl.textContent = `Subtask of: ${parent.name}`; }) .catch(() => { parentEl.textContent = ''; }); } // Meta row: agent type + model + execution ID (exec ID filled in async) const metaRow = document.createElement('div'); metaRow.className = 'task-meta running-exec-meta'; const agentType = (task.agent && task.agent.type) ? task.agent.type : 'claude'; const agentModel = (task.agent && task.agent.model) ? task.agent.model : ''; const agentSpan = document.createElement('span'); agentSpan.textContent = agentModel ? `${agentType} (${agentModel})` : `Agent: ${agentType}`; const execIdSpan = document.createElement('span'); execIdSpan.className = 'execution-id running-exec-id'; execIdSpan.textContent = 'exec: …'; metaRow.append(agentSpan, execIdSpan); card.appendChild(metaRow); // Log area const logArea = document.createElement('div'); logArea.className = 'running-log'; logArea.dataset.logTarget = task.id; card.appendChild(logArea); // Footer with Cancel button const footer = document.createElement('div'); footer.className = 'task-card-footer'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn-cancel'; cancelBtn.textContent = 'Cancel'; cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); handleCancel(task.id, cancelBtn, footer); }); footer.appendChild(cancelBtn); card.appendChild(footer); currentEl.appendChild(card); // Open SSE stream if not already streaming for this task. if (!runningViewLogSources[task.id]) { startRunningLogStream(task.id, logArea); } } } function startRunningLogStream(taskId, logArea) { fetch(`${API_BASE}/api/executions?task_id=${taskId}&limit=1`) .then(r => r.ok ? r.json() : []) .then(execs => { if (!execs || execs.length === 0) return; const execId = execs[0].id; // Update the exec ID shown in the running card. const card = document.querySelector(`[data-task-id="${taskId}"]`); if (card) { const execIdEl = card.querySelector('.running-exec-id'); if (execIdEl) execIdEl.textContent = `exec: ${execId.slice(0, 8)}`; } let userScrolled = false; logArea.addEventListener('scroll', () => { const nearBottom = logArea.scrollHeight - logArea.scrollTop - logArea.clientHeight < 50; userScrolled = !nearBottom; }); const src = new EventSource(`${API_BASE}/api/executions/${execId}/logs/stream`); runningViewLogSources[taskId] = src; src.onmessage = (event) => { let data; try { data = JSON.parse(event.data); } catch { return; } const line = document.createElement('div'); line.className = 'log-line'; switch (data.type) { case 'text': { line.classList.add('log-text'); line.textContent = data.text ?? data.content ?? ''; break; } case 'tool_use': { line.classList.add('log-tool-use'); const toolName = document.createElement('span'); toolName.className = 'tool-name'; toolName.textContent = `[${data.name ?? 'Tool'}]`; line.appendChild(toolName); const inputStr = data.input ? JSON.stringify(data.input) : ''; const inputPreview = document.createElement('span'); inputPreview.textContent = ' ' + inputStr.slice(0, 120); line.appendChild(inputPreview); break; } case 'cost': { line.classList.add('log-cost'); const cost = data.total_cost ?? data.cost ?? 0; line.textContent = `Cost: $${Number(cost).toFixed(3)}`; break; } default: return; } logArea.appendChild(line); // Trim to last 500 lines. while (logArea.childElementCount > 500) { logArea.removeChild(logArea.firstElementChild); } if (!userScrolled) logArea.scrollTop = logArea.scrollHeight; }; src.addEventListener('done', () => { src.close(); delete runningViewLogSources[taskId]; }); src.onerror = () => { src.close(); delete runningViewLogSources[taskId]; const errEl = document.createElement('div'); errEl.className = 'log-line log-error'; errEl.textContent = 'Stream closed.'; logArea.appendChild(errEl); }; }) .catch(() => {}); } function updateRunningElapsed() { document.querySelectorAll('.running-elapsed[data-started-at]').forEach(el => { el.textContent = formatElapsed(el.dataset.startedAt || null); }); } function isRunningTabActive() { const panel = document.querySelector('[data-panel="running"]'); return panel && !panel.hasAttribute('hidden'); } function sortExecutionsByDate(executions) { return sortExecutionsDesc(executions); } function renderRunningHistory(executions) { const histEl = document.querySelector('.running-history'); if (!histEl) return; histEl.innerHTML = ''; const h2 = document.createElement('h2'); h2.textContent = 'Execution History (Last 24h)'; histEl.appendChild(h2); if (!executions || executions.length === 0) { const empty = document.createElement('p'); empty.className = 'task-meta'; empty.textContent = 'No executions in the last 24h'; histEl.appendChild(empty); return; } const sorted = sortExecutionsDesc(executions); const table = document.createElement('table'); table.className = 'history-table'; const thead = document.createElement('thead'); const headerRow = document.createElement('tr'); for (const col of ['Date', 'Task', 'Status', 'Duration', 'Cost', 'Exit', 'Logs']) { const th = document.createElement('th'); th.textContent = col; headerRow.appendChild(th); } thead.appendChild(headerRow); table.appendChild(thead); const tbody = document.createElement('tbody'); for (const exec of sorted) { const tr = document.createElement('tr'); const tdDate = document.createElement('td'); tdDate.textContent = formatDate(exec.started_at); tr.appendChild(tdDate); const tdTask = document.createElement('td'); tdTask.textContent = exec.task_name || exec.task_id || '—'; tr.appendChild(tdTask); const tdStatus = document.createElement('td'); const stateBadge = document.createElement('span'); stateBadge.className = 'state-badge'; stateBadge.dataset.state = exec.state || ''; stateBadge.textContent = exec.state || '—'; tdStatus.appendChild(stateBadge); tr.appendChild(tdStatus); const tdDur = document.createElement('td'); tdDur.textContent = formatDuration(exec.started_at, exec.finished_at ?? null); tr.appendChild(tdDur); const tdCost = document.createElement('td'); tdCost.textContent = exec.cost_usd > 0 ? `$${exec.cost_usd.toFixed(4)}` : '—'; tr.appendChild(tdCost); const tdExit = document.createElement('td'); tdExit.textContent = exec.exit_code != null ? String(exec.exit_code) : '—'; tr.appendChild(tdExit); const tdLogs = document.createElement('td'); const viewBtn = document.createElement('button'); viewBtn.className = 'btn-sm'; viewBtn.textContent = 'View Logs'; viewBtn.addEventListener('click', () => openLogViewer(exec.id, histEl)); tdLogs.appendChild(viewBtn); tr.appendChild(tdLogs); tbody.appendChild(tr); } table.appendChild(tbody); histEl.appendChild(table); } // ── Stats rendering ─────────────────────────────────────────────────────────── // State display order for the task overview grid. const STATS_STATE_ORDER = [ 'RUNNING', 'QUEUED', 'READY', 'BLOCKED', 'PENDING', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', ]; function formatDurationMs(ms) { if (ms == null) return '—'; const s = Math.round(ms / 1000); if (s < 60) return `${s}s`; const m = Math.floor(s / 60); const rs = s % 60; if (m < 60) return rs > 0 ? `${m}m ${rs}s` : `${m}m`; const h = Math.floor(m / 60); const rm = m % 60; return rm > 0 ? `${h}h ${rm}m` : `${h}h`; } function renderStatsPanel(tasks, executions) { const panel = document.querySelector('[data-panel="stats"]'); if (!panel) return; const taskStats = computeTaskStats(tasks); const execStats = computeExecutionStats(executions); panel.innerHTML = ''; // ── Task Overview ────────────────────────────────────────────────────────── const taskSection = document.createElement('div'); taskSection.className = 'stats-section'; const taskHeading = document.createElement('h2'); taskHeading.textContent = 'Task Overview'; taskSection.appendChild(taskHeading); const countsGrid = document.createElement('div'); countsGrid.className = 'stats-counts'; const orderedStates = STATS_STATE_ORDER.filter(s => taskStats.byState[s] > 0); const otherStates = Object.keys(taskStats.byState).filter(s => !STATS_STATE_ORDER.includes(s)); for (const state of [...orderedStates, ...otherStates]) { const count = taskStats.byState[state] || 0; if (count === 0) continue; const box = document.createElement('div'); box.className = 'stats-count-box'; box.dataset.state = state; const num = document.createElement('span'); num.className = 'stats-count-number'; num.textContent = String(count); const label = document.createElement('span'); label.className = 'stats-count-label'; label.textContent = state.replace(/_/g, ' '); box.appendChild(num); box.appendChild(label); countsGrid.appendChild(box); } if (orderedStates.length === 0 && otherStates.length === 0) { const empty = document.createElement('p'); empty.className = 'task-meta'; empty.textContent = 'No tasks yet.'; countsGrid.appendChild(empty); } taskSection.appendChild(countsGrid); panel.appendChild(taskSection); // ── Execution Health ─────────────────────────────────────────────────────── const execSection = document.createElement('div'); execSection.className = 'stats-section'; const execHeading = document.createElement('h2'); execHeading.textContent = 'Executions (Last 24h)'; execSection.appendChild(execHeading); const kpisRow = document.createElement('div'); kpisRow.className = 'stats-kpis'; const kpis = [ { label: 'Total Runs', value: String(execStats.total) }, { label: 'Success Rate', value: execStats.total > 0 ? `${Math.round(execStats.successRate * 100)}%` : '—' }, { label: 'Total Cost', value: execStats.totalCostUSD > 0 ? `$${execStats.totalCostUSD.toFixed(2)}` : '$0.00' }, { label: 'Avg Duration', value: formatDurationMs(execStats.avgDurationMs) }, ]; for (const kpi of kpis) { const box = document.createElement('div'); box.className = 'stats-kpi-box'; const val = document.createElement('span'); val.className = 'stats-kpi-value'; val.textContent = kpi.value; const lbl = document.createElement('span'); lbl.className = 'stats-kpi-label'; lbl.textContent = kpi.label; box.appendChild(val); box.appendChild(lbl); kpisRow.appendChild(box); } execSection.appendChild(kpisRow); // Bar chart of outcome distribution. if (execStats.total > 0) { const chartSection = document.createElement('div'); chartSection.className = 'stats-bar-chart'; const chartLabel = document.createElement('p'); chartLabel.className = 'stats-bar-chart-label'; chartLabel.textContent = 'Outcome breakdown'; chartSection.appendChild(chartLabel); const bars = document.createElement('div'); bars.className = 'stats-bars'; for (const [outcome, count] of Object.entries(execStats.byOutcome)) { const pct = (count / execStats.total) * 100; const row = document.createElement('div'); row.className = 'stats-bar-row'; const barLabel = document.createElement('span'); barLabel.className = 'stats-bar-row-label'; barLabel.textContent = outcome.replace(/_/g, ' '); const barTrack = document.createElement('div'); barTrack.className = 'stats-bar-track'; const barFill = document.createElement('div'); barFill.className = 'stats-bar-fill'; barFill.dataset.state = outcome.toUpperCase(); barFill.style.width = `${pct.toFixed(1)}%`; const barCount = document.createElement('span'); barCount.className = 'stats-bar-count'; barCount.textContent = `${count} (${Math.round(pct)}%)`; barTrack.appendChild(barFill); row.appendChild(barLabel); row.appendChild(barTrack); row.appendChild(barCount); bars.appendChild(row); } chartSection.appendChild(bars); execSection.appendChild(chartSection); } panel.appendChild(execSection); } // ── Web Push Notifications ──────────────────────────────────────────────────── async function registerServiceWorker() { if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null; return navigator.serviceWorker.register(BASE_PATH + '/api/push/sw.js', { scope: BASE_PATH + '/' }); } function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); return Uint8Array.from([...rawData].map(c => c.charCodeAt(0))); } async function enableNotifications(btn) { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { alert('Push notifications are not supported in this browser.'); return; } try { const permission = await Notification.requestPermission(); if (permission !== 'granted') { alert('Notification permission denied.'); return; } // Fetch VAPID public key. const keyRes = await fetch(`${API_BASE}/api/push/vapid-key`); if (!keyRes.ok) throw new Error(`Failed to get VAPID key: HTTP ${keyRes.status}`); const { public_key: vapidKey } = await keyRes.json(); // Register service worker. const registration = await registerServiceWorker(); if (!registration) { alert('Service worker registration failed.'); return; } // Subscribe via PushManager. const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidKey), }); const subJSON = subscription.toJSON(); // POST subscription to server. const res = await fetch(`${API_BASE}/api/push/subscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ endpoint: subJSON.endpoint, keys: { p256dh: subJSON.keys.p256dh, auth: subJSON.keys.auth }, }), }); if (!res.ok) throw new Error(`Subscribe failed: HTTP ${res.status}`); if (btn) { btn.textContent = '🔔'; btn.disabled = true; } } catch (err) { alert(`Notification setup failed: ${err.message}`); } } // ── File Drops ───────────────────────────────────────────────────────────────── async function fetchDrops() { const res = await fetch(`${API_BASE}/api/drops`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } async function renderDropsPanel() { const panel = document.querySelector('[data-panel="drops"] .drops-panel'); if (!panel) return; panel.innerHTML = '

Loading drops…

'; try { const files = await fetchDrops(); panel.innerHTML = ''; const heading = document.createElement('h3'); heading.style.padding = '1rem 1rem 0.5rem'; heading.textContent = 'Dropped Files'; panel.appendChild(heading); if (files.length === 0) { const empty = document.createElement('p'); empty.className = 'task-meta'; empty.style.padding = '0 1rem'; empty.textContent = 'No files dropped yet. Agents can write files to the drops directory to share them here.'; panel.appendChild(empty); } else { const list = document.createElement('ul'); list.style.cssText = 'list-style:none;padding:0 1rem;margin:0'; for (const f of files) { const li = document.createElement('li'); li.style.cssText = 'padding:0.5rem 0;border-bottom:1px solid var(--border,#e5e7eb)'; const a = document.createElement('a'); a.href = `${API_BASE}/api/drops/${encodeURIComponent(f.name)}`; a.textContent = f.name; a.download = f.name; a.style.cssText = 'color:var(--accent,#2563eb);text-decoration:none'; const meta = document.createElement('span'); meta.className = 'task-meta'; meta.style.cssText = 'margin-left:1rem'; meta.textContent = `${(f.size / 1024).toFixed(1)} KB`; li.append(a, meta); list.appendChild(li); } panel.appendChild(list); } } catch (err) { panel.innerHTML = `

Failed to load drops: ${err.message}

`; } } // ── Tab switching ───────────────────────────────────────────────────────────── function switchTab(name) { // Update tab button active state document.querySelectorAll('.tab').forEach(btn => { btn.classList.toggle('active', btn.dataset.tab === name); }); // Show/hide panels document.querySelectorAll('[data-panel]').forEach(panel => { if (panel.dataset.panel === name) { panel.removeAttribute('hidden'); } else { panel.setAttribute('hidden', ''); } }); // Trigger immediate render for the newly active tab poll(); } // ── Boot ────────────────────────────────────────────────────────────────────── if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded', () => { document.getElementById('btn-start-next').addEventListener('click', function() { handleStartNextTask(this); }); switchTab('queue'); startPolling(); connectWebSocket(); // Side panel close document.getElementById('btn-close-panel').addEventListener('click', closeTaskPanel); document.getElementById('task-panel-backdrop').addEventListener('click', closeTaskPanel); // Execution logs modal close document.getElementById('btn-close-logs').addEventListener('click', () => { document.getElementById('logs-modal').close(); }); // Tab bar document.querySelectorAll('.tab').forEach(btn => { btn.addEventListener('click', () => switchTab(btn.dataset.tab)); }); // Task modal document.getElementById('btn-new-task').addEventListener('click', openTaskModal); document.getElementById('btn-cancel-task').addEventListener('click', closeTaskModal); initProjectSelect(); // Push notifications button const btnNotify = document.getElementById('btn-notifications'); if (btnNotify) { btnNotify.addEventListener('click', () => enableNotifications(btnNotify)); } // Validate button document.getElementById('btn-validate').addEventListener('click', async () => { const btn = document.getElementById('btn-validate'); const resultDiv = document.getElementById('validate-result'); btn.disabled = true; btn.textContent = 'Checking…'; try { const payload = buildValidatePayload(); const result = await validateTask(payload); renderValidationResult(result); } catch (err) { resultDiv.removeAttribute('hidden'); resultDiv.textContent = 'Validation failed: ' + err.message; } finally { btn.disabled = false; btn.textContent = 'Validate Instructions'; } }); // Draft with AI button const btnElaborate = document.getElementById('btn-elaborate'); btnElaborate.addEventListener('click', async () => { const prompt = document.getElementById('elaborate-prompt').value.trim(); if (!prompt) { const form = document.getElementById('task-form'); // Remove previous error const prev = form.querySelector('.form-error'); if (prev) prev.remove(); const errEl = document.createElement('p'); errEl.className = 'form-error'; errEl.textContent = 'Please enter a description before drafting.'; form.querySelector('.elaborate-section').appendChild(errEl); return; } btnElaborate.disabled = true; btnElaborate.textContent = 'Drafting…'; // Remove any previous errors or banners const form = document.getElementById('task-form'); form.querySelectorAll('.form-error, .elaborate-banner').forEach(el => el.remove()); try { const sel = document.getElementById('project-select'); const workingDir = sel.value === '__new__' ? document.getElementById('new-project-input').value.trim() : sel.value; const result = await elaborateTask(prompt, workingDir); // Populate form fields const f = document.getElementById('task-form'); if (result.name) f.querySelector('[name="name"]').value = result.name; if (result.agent && result.agent.instructions) f.querySelector('[name="instructions"]').value = result.agent.instructions; if (result.agent && (result.agent.project_dir || result.agent.working_dir)) { const pDir = result.agent.project_dir || result.agent.working_dir; const pSel = document.getElementById('project-select'); const exists = [...pSel.options].some(o => o.value === pDir); if (exists) { pSel.value = pDir; } else { pSel.value = '__new__'; document.getElementById('new-project-row').hidden = false; document.getElementById('new-project-input').value = pDir; } } if (result.agent && result.agent.max_budget_usd != null) f.querySelector('[name="max_budget_usd"]').value = result.agent.max_budget_usd; if (result.timeout) f.querySelector('[name="timeout"]').value = result.timeout; if (result.priority) { const sel = f.querySelector('[name="priority"]'); if ([...sel.options].some(o => o.value === result.priority)) { sel.value = result.priority; } } // Show success banner const banner = document.createElement('p'); banner.className = 'elaborate-banner'; banner.textContent = 'AI draft ready — review and submit.'; document.getElementById('task-form').querySelector('.elaborate-section').appendChild(banner); // Auto-validate after elaboration try { const result = await validateTask(buildValidatePayload()); renderValidationResult(result); } catch (_) { // silent - elaboration already succeeded, validation is bonus } } catch (err) { const errEl = document.createElement('p'); errEl.className = 'form-error'; errEl.textContent = `Elaboration failed: ${err.message}`; document.getElementById('task-form').querySelector('.elaborate-section').appendChild(errEl); } finally { btnElaborate.disabled = false; btnElaborate.textContent = 'Draft with AI ✦'; } }); document.getElementById('task-form').addEventListener('submit', async e => { e.preventDefault(); // Remove any previous error const prev = e.target.querySelector('.form-error'); if (prev) prev.remove(); const btn = e.submitter; btn.disabled = true; btn.textContent = 'Creating…'; try { const validateResult = document.getElementById('validate-result'); if (!validateResult.hasAttribute('hidden') && validateResult.dataset.clarity && validateResult.dataset.clarity !== 'clear') { if (!window.confirm('The validator flagged issues. Create task anyway?')) { return; } } await createTask(new FormData(e.target)); } catch (err) { const errEl = document.createElement('p'); errEl.className = 'form-error'; errEl.textContent = err.message; e.target.appendChild(errEl); } finally { btn.disabled = false; btn.textContent = 'Create & Queue'; } }); });