diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/app.js | 803 | ||||
| -rw-r--r-- | web/index.html | 78 | ||||
| -rw-r--r-- | web/style.css | 66 | ||||
| -rw-r--r-- | web/test/budget.test.mjs | 58 | ||||
| -rw-r--r-- | web/test/event-timeline.test.mjs | 112 | ||||
| -rw-r--r-- | web/test/new-task-button.test.mjs | 24 | ||||
| -rw-r--r-- | web/test/stories.test.mjs | 164 |
7 files changed, 383 insertions, 922 deletions
@@ -115,6 +115,136 @@ export function renderDeploymentBadge(status, doc = (typeof document !== 'undefi return span; } +// ── Event timeline ────────────────────────────────────────────────────────── +// The observability event stream (GET /api/tasks/{id}/events) replaces the +// ad-hoc question/summary panels. formatEventText turns one event into a human +// line; renderEventTimeline builds the list element. + +// formatEventText returns a one-line human description of a task event. +export function formatEventText(event) { + if (!event) return ''; + const p = event.payload || {}; + switch (event.kind) { + case 'state_change': + return `State: ${p.from || '?'} → ${p.to || '?'}`; + case 'summary': + return `Summary: ${p.text || p.summary || ''}`; + case 'clarification_request': + return `Question: ${p.text || ''}`; + case 'clarification_answer': + return `Answer: ${p.answer || ''}`; + case 'subtask_spawned': + return `Spawned subtask: ${p.name || p.subtask_id || ''}`; + case 'commit_made': + return `Commit: ${p.message || p.hash || ''}`; + case 'cost_report': + return `Cost: $${p.cost_usd != null ? p.cost_usd : 0}`; + case 'agent_message': + case 'human_message': + return p.text || p.message || ''; + case 'execution_started': + return 'Execution started'; + case 'execution_ended': + return p.status ? `Execution ended (${p.status})` : 'Execution ended'; + default: + return event.kind || ''; + } +} + +// renderEventTimeline builds a <ul class="event-timeline"> from an events array. +// Accepts an optional doc parameter for testability (defaults to document). +export function renderEventTimeline(events, doc = (typeof document !== 'undefined' ? document : null)) { + if (doc == null) return null; + const ul = doc.createElement('ul'); + ul.className = 'event-timeline'; + if (!events || events.length === 0) { + const empty = doc.createElement('li'); + empty.className = 'event-timeline__empty'; + empty.textContent = 'No events yet.'; + ul.appendChild(empty); + return ul; + } + for (const event of events) { + const li = doc.createElement('li'); + li.className = `event-timeline__item event-timeline__item--${event.kind || 'unknown'}`; + + const actor = doc.createElement('span'); + actor.className = 'event-timeline__actor'; + actor.textContent = event.actor || ''; + li.appendChild(actor); + + const text = doc.createElement('span'); + text.className = 'event-timeline__text'; + text.textContent = formatEventText(event); + li.appendChild(text); + + ul.appendChild(li); + } + return ul; +} + +// fetchTaskEvents loads a task's event stream. fetchImpl defaults to the global +// fetch; pass a stub in tests. Returns [] on any error so the UI degrades +// gracefully. +export async function fetchTaskEvents(taskId, fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) { + if (!fetchImpl) return []; + try { + const resp = await fetchImpl(`/api/tasks/${taskId}/events`); + if (!resp.ok) return []; + const data = await resp.json(); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +// ── Budget headroom ───────────────────────────────────────────────────────── +// Renders the per-provider rolling-window spend headroom from GET /api/budget. + +// formatBudgetHeadroom turns one provider's headroom into a short label. +// Returns '' for unlimited providers (nothing to show). +export function formatBudgetHeadroom(h) { + if (!h || !h.limited) return ''; + const pct = Math.round((h.fraction_remaining || 0) * 100); + const remaining = (h.remaining_usd || 0).toFixed(2); + const limit = (h.limit_usd || 0).toFixed(2); + const name = h.provider ? h.provider.charAt(0).toUpperCase() + h.provider.slice(1) : '?'; + return `${name}: ${pct}% left ($${remaining} of $${limit})`; +} + +// renderBudgetHeadroom builds a <span> chip per limited provider, flagging +// providers under 20% remaining with a --low modifier. Returns the container. +export function renderBudgetHeadroom(headrooms, doc = (typeof document !== 'undefined' ? document : null)) { + if (doc == null) return null; + const wrap = doc.createElement('div'); + wrap.className = 'budget-bar'; + for (const h of headrooms || []) { + if (!h || !h.limited) continue; + const chip = doc.createElement('span'); + chip.className = 'budget-chip' + ((h.fraction_remaining || 0) < 0.2 ? ' budget-chip--low' : ''); + chip.textContent = formatBudgetHeadroom(h); + wrap.appendChild(chip); + } + return wrap; +} + +// loadBudget fetches headroom and injects chips into #budget-bar. Best-effort. +async function loadBudget(fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) { + if (!fetchImpl || typeof document === 'undefined') return; + const slot = document.getElementById('budget-bar'); + if (!slot) return; + try { + const resp = await fetchImpl(`${BASE_PATH}/api/budget`); + if (!resp.ok) return; + const headrooms = await resp.json(); + const rendered = renderBudgetHeadroom(headrooms); + slot.innerHTML = ''; + if (rendered) for (const c of rendered.children) slot.appendChild(c); + } catch { + // budget display is non-critical; ignore. + } +} + function truncateToWordBoundary(text, maxLen = 120) { if (!text || text.length <= maxLen) return text; const cut = text.lastIndexOf(' ', maxLen); @@ -382,9 +512,6 @@ 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)); @@ -531,90 +658,6 @@ export function computeExecutionStats(executions) { }; } -// ── Stories ─────────────────────────────────────────────────────────────────── - -const STORY_STATUS_LABELS = { - PENDING: 'Pending', - IN_PROGRESS: 'In Progress', - SHIPPABLE: 'Shippable', - DEPLOYED: 'Deployed', - VALIDATING: 'Validating', - REVIEW_READY: 'Review Ready', - NEEDS_FIX: 'Needs Fix', -}; - -export function storyStatusLabel(status) { - return STORY_STATUS_LABELS[status] || status; -} - -export function renderStoryCard(story, doc = document) { - const card = doc.createElement('div'); - card.className = 'story-card'; - card.dataset.storyId = story.id; - - const header = doc.createElement('div'); - header.className = 'story-card-header'; - - const name = doc.createElement('span'); - name.className = 'story-name'; - name.textContent = story.name; - header.appendChild(name); - - const badge = doc.createElement('span'); - badge.className = 'story-status-badge'; - badge.dataset.status = story.status; - badge.textContent = storyStatusLabel(story.status); - header.appendChild(badge); - - card.appendChild(header); - - const meta = doc.createElement('div'); - meta.className = 'story-meta'; - - const project = doc.createElement('span'); - project.className = 'story-project'; - project.textContent = story.project_id || '—'; - meta.appendChild(project); - - if (story.branch_name) { - const branch = doc.createElement('span'); - branch.className = 'story-branch'; - branch.textContent = story.branch_name; - meta.appendChild(branch); - } - - card.appendChild(meta); - - // Ship button for SHIPPABLE stories. - if (story.status === 'SHIPPABLE') { - const shipBtn = doc.createElement('button'); - shipBtn.className = 'btn-primary story-ship-btn'; - shipBtn.textContent = 'Ship'; - shipBtn.addEventListener('click', async (e) => { - e.stopPropagation(); - shipBtn.disabled = true; - shipBtn.textContent = 'Shipping…'; - try { - const res = await fetch(`${API_BASE}/api/stories/${story.id}/ship`, { method: 'POST' }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - alert(body.error || `Ship failed (${res.status})`); - shipBtn.disabled = false; - shipBtn.textContent = 'Ship'; - } else { - renderStoriesPanel(); - } - } catch { - shipBtn.disabled = false; - shipBtn.textContent = 'Ship'; - } - }); - card.appendChild(shipBtn); - } - - return card; -} - export function updateFilterTabs() { const current = getTaskFilterTab(); document.querySelectorAll('.filter-tab[data-filter]').forEach(el => { @@ -1290,9 +1333,6 @@ function renderActiveTab(allTasks) { .then(([execs, agentData, dashStats]) => renderStatsPanel(allTasks, execs, agentData, dashStats)) .catch(() => {}); break; - case 'stories': - renderStoriesPanel(); - break; case 'drops': renderDropsPanel(); break; @@ -1304,6 +1344,7 @@ function renderActiveTab(allTasks) { async function poll() { try { + loadBudget(); // fire-and-forget; budget can change independently of tasks const health = await fetchHealth(); const serverUpdate = health.last_updated; @@ -1559,152 +1600,6 @@ async function submitAnswer(taskId, questionId, answer, banner) { // ── 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 repository_url = document.getElementById('repository-url').value; - const container_image = document.getElementById('container-image').value; - const allowedToolsEl = f.querySelector('[name="allowed_tools"]'); - const allowed_tools = allowedToolsEl - ? allowedToolsEl.value.split(',').map(s => s.trim()).filter(Boolean) - : []; - return { name, repository_url, agent: { instructions, container_image, 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(); -} - -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 repository_url = formData.get('repository_url'); - const container_image = formData.get('container_image'); - const elaboratePromptEl = document.getElementById('elaborate-prompt'); - const elaborationInput = elaboratePromptEl ? elaboratePromptEl.value.trim() : ''; - const body = { - name: formData.get('name'), - description: '', - elaboration_input: elaborationInput || undefined, - repository_url: repository_url, - agent: { - instructions: formData.get('instructions'), - container_image: container_image, - max_budget_usd: parseFloat(formData.get('max_budget_usd')), - type: 'container', - }, - 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": <nanoseconds>} to human string. @@ -2055,6 +1950,21 @@ export function renderTaskPanel(task, executions) { execSection.appendChild(list); } content.appendChild(execSection); + + // ── Timeline ── + // Observability event stream, loaded asynchronously. Guarded so the sync + // panel render (and its unit tests, which have no fetch) is unaffected. + const timelineSection = makeSection('Timeline'); + const timelineContainer = document.createElement('div'); + timelineContainer.className = 'event-timeline-container'; + timelineSection.appendChild(timelineContainer); + content.appendChild(timelineSection); + if (typeof fetch !== 'undefined') { + fetchTaskEvents(task.id).then(events => { + timelineContainer.innerHTML = ''; + timelineContainer.appendChild(renderEventTimeline(events)); + }); + } } async function handleViewLogs(execId) { @@ -3124,217 +3034,6 @@ async function fetchDrops() { // ── Stories panel ───────────────────────────────────────────────────────────── -async function renderStoriesPanel() { - const panel = document.querySelector('[data-panel="stories"]'); - if (!panel) return; - - let stories; - try { - const res = await fetch(`${BASE_PATH}/api/stories`); - stories = res.ok ? await res.json() : []; - } catch { - panel.innerHTML = '<p class="task-meta" style="padding:1rem">Failed to load stories.</p>'; - return; - } - - panel.innerHTML = ''; - - const toolbar = document.createElement('div'); - toolbar.className = 'stories-toolbar'; - const btnNew = document.createElement('button'); - btnNew.className = 'btn-primary'; - btnNew.textContent = 'New Story'; - btnNew.addEventListener('click', openStoryModal); - toolbar.appendChild(btnNew); - panel.appendChild(toolbar); - - if (!stories || stories.length === 0) { - const empty = document.createElement('p'); - empty.className = 'task-empty'; - empty.textContent = 'No stories yet. Create one to get started.'; - panel.appendChild(empty); - return; - } - - const list = document.createElement('div'); - list.className = 'stories-list'; - for (const story of stories) { - const card = renderStoryCard(story); - card.addEventListener('click', () => openStoryDetail(story)); - list.appendChild(card); - } - panel.appendChild(list); -} - -function openStoryDetail(story) { - const modal = document.getElementById('story-detail-modal'); - if (!modal) return; - - document.getElementById('story-detail-name').textContent = story.name; - - const body = document.getElementById('story-detail-body'); - body.innerHTML = ''; - - function addRow(label, value) { - const row = document.createElement('div'); - row.className = 'meta-item'; - const lbl = document.createElement('div'); - lbl.className = 'meta-label'; - lbl.textContent = label; - const val = document.createElement('div'); - val.className = 'meta-value'; - val.textContent = value || '—'; - row.appendChild(lbl); - row.appendChild(val); - body.appendChild(row); - } - - const badge = document.createElement('span'); - badge.className = 'story-status-badge'; - badge.dataset.status = story.status; - badge.textContent = storyStatusLabel(story.status); - - const statusRow = document.createElement('div'); - statusRow.className = 'meta-item'; - const statusLbl = document.createElement('div'); - statusLbl.className = 'meta-label'; - statusLbl.textContent = 'Status'; - statusRow.appendChild(statusLbl); - statusRow.appendChild(badge); - body.appendChild(statusRow); - - addRow('Project', story.project_id); - addRow('Branch', story.branch_name); - addRow('Created', story.created_at ? new Date(story.created_at).toLocaleString() : '—'); - - // Load tasks for this story. - const tasksSection = document.createElement('div'); - tasksSection.className = 'story-detail-tasks'; - tasksSection.innerHTML = '<p class="task-meta">Loading tasks…</p>'; - body.appendChild(tasksSection); - - fetch(`${API_BASE}/api/stories/${story.id}/tasks`) - .then(r => r.ok ? r.json() : []) - .then(async tasks => { - tasksSection.innerHTML = ''; - const topLevel = tasks.filter(t => !t.parent_task_id); - if (topLevel.length === 0) { - tasksSection.innerHTML = '<p class="task-meta">No tasks yet.</p>'; - return; - } - const ol = document.createElement('ol'); - ol.className = 'story-detail-task-list'; - for (const t of topLevel) { - const li = document.createElement('li'); - li.className = `story-detail-task story-detail-task-${t.state.toLowerCase()}`; - li.textContent = `${STATE_EMOJI[t.state] || '•'} ${t.name}`; - const subs = tasks.filter(s => s.parent_task_id === t.id); - if (subs.length > 0) { - const ul = document.createElement('ul'); - ul.className = 'story-detail-subtask-list'; - for (const s of subs) { - const sli = document.createElement('li'); - sli.className = `subtask-item subtask-${s.state.toLowerCase()}`; - sli.textContent = `${STATE_EMOJI[s.state] || '•'} ${s.name}`; - ul.appendChild(sli); - } - li.appendChild(ul); - } - ol.appendChild(li); - } - tasksSection.appendChild(ol); - }) - .catch(() => { tasksSection.innerHTML = '<p class="task-meta">Could not load tasks.</p>'; }); - - modal.showModal(); -} - -function openStoryModal() { - const modal = document.getElementById('story-modal'); - if (!modal) return; - - // Reset form state - document.getElementById('story-goal').value = ''; - const planArea = document.getElementById('story-plan-area'); - planArea.innerHTML = ''; - planArea.setAttribute('hidden', ''); - - const btnElaborate = document.getElementById('btn-story-elaborate'); - btnElaborate.disabled = false; - btnElaborate.textContent = 'Elaborate with AI ✦'; - - const btnApprove = document.getElementById('btn-story-approve'); - btnApprove.setAttribute('hidden', ''); - btnApprove._elaboratedPlan = null; - - // Populate project dropdown - fetch(`${BASE_PATH}/api/projects`) - .then(r => r.ok ? r.json() : []) - .then(projects => { - const sel = document.getElementById('story-project'); - sel.innerHTML = ''; - for (const p of projects) { - const opt = document.createElement('option'); - opt.value = p.id; - opt.textContent = p.name; - sel.appendChild(opt); - } - }) - .catch(() => {}); - - modal.showModal(); -} - -function renderElaboratedPlan(plan) { - const planArea = document.getElementById('story-plan-area'); - planArea.innerHTML = ''; - planArea.removeAttribute('hidden'); - - const nameEl = document.createElement('p'); - nameEl.className = 'story-plan-name'; - nameEl.textContent = `Story: ${plan.name}`; - planArea.appendChild(nameEl); - - if (plan.branch_name) { - const branchEl = document.createElement('p'); - branchEl.className = 'story-plan-branch'; - branchEl.textContent = `Branch: ${plan.branch_name}`; - planArea.appendChild(branchEl); - } - - if (plan.tasks && plan.tasks.length > 0) { - const tasksHeader = document.createElement('p'); - tasksHeader.className = 'story-plan-section'; - tasksHeader.textContent = `Tasks (${plan.tasks.length}):`; - planArea.appendChild(tasksHeader); - - const taskList = document.createElement('ol'); - taskList.className = 'story-plan-tasks'; - for (const t of plan.tasks) { - const li = document.createElement('li'); - li.textContent = t.name; - if (t.subtasks && t.subtasks.length > 0) { - const subList = document.createElement('ul'); - for (const s of t.subtasks) { - const subLi = document.createElement('li'); - subLi.textContent = s.name; - subList.appendChild(subLi); - } - li.appendChild(subList); - } - taskList.appendChild(li); - } - planArea.appendChild(taskList); - } - - if (plan.validation && plan.validation.type) { - const valHeader = document.createElement('p'); - valHeader.className = 'story-plan-section'; - valHeader.textContent = `Validation: ${plan.validation.type}`; - planArea.appendChild(valHeader); - } -} - async function renderDropsPanel() { const panel = document.querySelector('[data-panel="drops"] .drops-panel'); if (!panel) return; @@ -3447,9 +3146,6 @@ if (typeof document !== 'undefined') { 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); // Push notifications button const btnNotify = document.getElementById('btn-notifications'); @@ -3457,213 +3153,6 @@ if (typeof document !== 'undefined') { 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 repoUrl = document.getElementById('repository-url').value.trim(); - const result = await elaborateTask(prompt, repoUrl); - - // 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.repository_url || result.agent?.repository_url) { - document.getElementById('repository-url').value = result.repository_url || result.agent.repository_url; - } - if (result.agent && result.agent.container_image) { - document.getElementById('container-image').value = result.agent.container_image; - } - 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'; - } - }); - - // Story modal - const storyModal = document.getElementById('story-modal'); - if (storyModal) { - document.getElementById('btn-close-story-modal').addEventListener('click', () => storyModal.close()); - - document.getElementById('btn-story-elaborate').addEventListener('click', async () => { - const btn = document.getElementById('btn-story-elaborate'); - const goal = document.getElementById('story-goal').value.trim(); - const projectId = document.getElementById('story-project').value; - - if (!goal) { - const errEl = document.createElement('p'); - errEl.className = 'form-error'; - errEl.textContent = 'Please enter a goal before elaborating.'; - storyModal.querySelector('.story-modal-body').appendChild(errEl); - return; - } - - storyModal.querySelectorAll('.form-error').forEach(el => el.remove()); - btn.disabled = true; - btn.textContent = 'Elaborating…'; - - try { - const res = await fetch(`${BASE_PATH}/api/stories/elaborate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ goal, project_id: projectId }), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(err.error || res.statusText); - } - const plan = await res.json(); - renderElaboratedPlan(plan); - - const btnApprove = document.getElementById('btn-story-approve'); - btnApprove._elaboratedPlan = { ...plan, project_id: projectId }; - btnApprove.removeAttribute('hidden'); - } catch (err) { - const errEl = document.createElement('p'); - errEl.className = 'form-error'; - errEl.textContent = `Elaboration failed: ${err.message}`; - storyModal.querySelector('.story-modal-body').appendChild(errEl); - } finally { - btn.disabled = false; - btn.textContent = 'Elaborate with AI ✦'; - } - }); - - document.getElementById('btn-story-approve').addEventListener('click', async () => { - const btn = document.getElementById('btn-story-approve'); - const plan = btn._elaboratedPlan; - if (!plan) return; - - btn.disabled = true; - btn.textContent = 'Approving…'; - - try { - const res = await fetch(`${BASE_PATH}/api/stories/approve`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(plan), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(err.error || res.statusText); - } - storyModal.close(); - renderStoriesPanel(); - } catch (err) { - const errEl = document.createElement('p'); - errEl.className = 'form-error'; - errEl.textContent = `Approve failed: ${err.message}`; - storyModal.querySelector('.story-modal-body').appendChild(errEl); - btn.disabled = false; - btn.textContent = 'Approve & Queue'; - } - }); - } - - // Story detail modal - const storyDetailModal = document.getElementById('story-detail-modal'); - if (storyDetailModal) { - document.getElementById('btn-close-story-detail').addEventListener('click', () => storyDetailModal.close()); - } }); } diff --git a/web/index.html b/web/index.html index 0632cd7..5aa7b44 100644 --- a/web/index.html +++ b/web/index.html @@ -36,11 +36,10 @@ </select> <button id="btn-notifications" class="btn-secondary" title="Enable push notifications">🔔</button> <button id="btn-start-next" class="btn-secondary">Start Next</button> - <button id="btn-new-task" class="btn-primary">New Task</button> </div> + <div id="budget-bar" class="budget-bar"></div> </header> <nav class="tab-bar"> - <button class="tab" data-tab="stories" title="Stories">📖</button> <button class="tab active" data-tab="queue" title="Queue">⏳</button> <button class="tab" data-tab="interrupted" title="Interrupted">⚠️<span class="tab-count-badge" hidden></span></button> <button class="tab" data-tab="ready" title="Ready">✅<span class="tab-count-badge" hidden></span></button> @@ -69,57 +68,12 @@ <div data-panel="drops" hidden> <div class="drops-panel"></div> </div> - <div data-panel="stories" hidden></div> <div data-panel="stats" hidden></div> <div data-panel="settings" hidden> <p class="task-meta" style="padding:1rem">Settings coming soon.</p> </div> </main> - <dialog id="task-modal"> - <form id="task-form" method="dialog"> - <h2>New Task</h2> - <div class="elaborate-section"> - <label>Describe what you want the agent to do - <textarea id="elaborate-prompt" rows="3" - placeholder="e.g. run tests with race detector and check coverage"></textarea> - </label> - <button type="button" id="btn-elaborate" class="btn-secondary"> - Draft with AI ✦ - </button> - <p class="elaborate-hint">AI will fill in the form fields below. You can edit before submitting.</p> - </div> - <hr class="form-divider"> - <label>Repository URL - <input name="repository_url" id="repository-url" placeholder="https://github.com/user/repo.git" required> - </label> - <label>Container Image - <input name="container_image" id="container-image" placeholder="claudomator-agent:latest" value="claudomator-agent:latest"> - </label> - <label>Name <input name="name" required></label> - <label>Instructions <textarea name="instructions" rows="6" required></textarea></label> - <div class="validate-section"> - <button type="button" id="btn-validate" class="btn-secondary"> - Validate Instructions - </button> - <div id="validate-result" hidden></div> - </div> - <label>Max Budget (USD) <input name="max_budget_usd" type="number" step="0.01" value="1.00"></label> - <label>Timeout <input name="timeout" value="15m"></label> - <label>Priority - <select name="priority"> - <option value="normal" selected>Normal</option> - <option value="high">High</option> - <option value="low">Low</option> - </select> - </label> - <div class="form-actions"> - <button type="button" id="btn-cancel-task">Cancel</button> - <button type="submit" class="btn-primary">Create & Queue</button> - </div> - </form> - </dialog> - <!-- Side panel backdrop --> <div id="task-panel-backdrop" class="panel-backdrop" hidden></div> @@ -142,36 +96,6 @@ </div> </dialog> - <!-- New Story modal --> - <dialog id="story-modal"> - <div class="story-modal-header"> - <h2>New Story</h2> - <button id="btn-close-story-modal" class="btn-close-panel" aria-label="Close">✕</button> - </div> - <div class="story-modal-body"> - <label>Project - <select id="story-project"></select> - </label> - <label>Goal - <textarea id="story-goal" rows="4" placeholder="Describe the feature or change you want to build…"></textarea> - </label> - <button type="button" id="btn-story-elaborate" class="btn-secondary">Elaborate with AI ✦</button> - <div id="story-plan-area" hidden></div> - <div class="form-actions"> - <button type="button" id="btn-story-approve" class="btn-primary" hidden>Approve & Queue</button> - </div> - </div> - </dialog> - - <!-- Story detail modal --> - <dialog id="story-detail-modal"> - <div class="story-modal-header"> - <h2 id="story-detail-name">Story</h2> - <button id="btn-close-story-detail" class="btn-close-panel" aria-label="Close">✕</button> - </div> - <div id="story-detail-body" class="story-detail-body meta-grid"></div> - </dialog> - <script type="module" src="app.js"></script> </body> </html> diff --git a/web/style.css b/web/style.css index d3b01d0..b6f0484 100644 --- a/web/style.css +++ b/web/style.css @@ -1989,3 +1989,69 @@ dialog label select:focus { opacity: 0.85; padding: 0.1rem 0; } + +/* Event timeline (task observability stream) */ +.event-timeline { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.event-timeline__item { + display: flex; + align-items: baseline; + gap: 10px; + padding: 6px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-left: 3px solid var(--border); + border-radius: 6px; + font-size: 0.85rem; +} +.event-timeline__item--state_change { border-left-color: var(--state-queued); } +.event-timeline__item--clarification_request { border-left-color: var(--state-blocked); } +.event-timeline__item--clarification_answer { border-left-color: var(--state-blocked); } +.event-timeline__item--summary { border-left-color: var(--state-completed); } +.event-timeline__item--subtask_spawned { border-left-color: var(--accent); } +.event-timeline__item--execution_started, +.event-timeline__item--execution_ended { border-left-color: var(--state-running); } +.event-timeline__actor { + flex: 0 0 auto; + text-transform: uppercase; + font-size: 0.65rem; + letter-spacing: 0.04em; + color: var(--text-muted); + min-width: 56px; +} +.event-timeline__text { + color: var(--text); + word-break: break-word; +} +.event-timeline__empty { + color: var(--text-muted); + font-size: 0.85rem; + padding: 6px 10px; +} + +/* Budget headroom chips */ +.budget-bar { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.budget-chip { + font-size: 0.72rem; + padding: 2px 8px; + border-radius: 999px; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text-muted); + white-space: nowrap; +} +.budget-chip--low { + border-color: var(--state-budget-exceeded); + color: var(--state-budget-exceeded); +} diff --git a/web/test/budget.test.mjs b/web/test/budget.test.mjs new file mode 100644 index 0000000..35d5256 --- /dev/null +++ b/web/test/budget.test.mjs @@ -0,0 +1,58 @@ +// budget.test.mjs — Unit tests for budget headroom display. +// +// Run with: node --test web/test/budget.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatBudgetHeadroom, renderBudgetHeadroom } from '../app.js'; + +function makeDoc() { + return { + createElement(tag) { + return { + tag, + className: '', + textContent: '', + children: [], + appendChild(child) { this.children.push(child); return child; }, + }; + }, + }; +} + +describe('formatBudgetHeadroom', () => { + it('formats a limited provider with percent and dollars', () => { + assert.equal( + formatBudgetHeadroom({ provider: 'claude', limited: true, limit_usd: 10, remaining_usd: 6, fraction_remaining: 0.6 }), + 'Claude: 60% left ($6.00 of $10.00)', + ); + }); + + it('returns empty string for unlimited providers', () => { + assert.equal(formatBudgetHeadroom({ provider: 'local', limited: false }), ''); + assert.equal(formatBudgetHeadroom(null), ''); + }); +}); + +describe('renderBudgetHeadroom', () => { + it('renders one chip per limited provider, skipping unlimited', () => { + const wrap = renderBudgetHeadroom([ + { provider: 'claude', limited: true, limit_usd: 10, remaining_usd: 6, fraction_remaining: 0.6 }, + { provider: 'local', limited: false }, + ], makeDoc()); + assert.equal(wrap.children.length, 1); + assert.equal(wrap.children[0].className, 'budget-chip'); + assert.match(wrap.children[0].textContent, /Claude: 60% left/); + }); + + it('flags providers under 20% remaining with --low', () => { + const wrap = renderBudgetHeadroom([ + { provider: 'gemini', limited: true, limit_usd: 5, remaining_usd: 0.5, fraction_remaining: 0.1 }, + ], makeDoc()); + assert.equal(wrap.children[0].className, 'budget-chip budget-chip--low'); + }); + + it('returns null when doc is null', () => { + assert.equal(renderBudgetHeadroom([], null), null); + }); +}); diff --git a/web/test/event-timeline.test.mjs b/web/test/event-timeline.test.mjs new file mode 100644 index 0000000..20ef61a --- /dev/null +++ b/web/test/event-timeline.test.mjs @@ -0,0 +1,112 @@ +// event-timeline.test.mjs — Unit tests for the event timeline component. +// +// Run with: node --test web/test/event-timeline.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatEventText, renderEventTimeline, fetchTaskEvents } from '../app.js'; + +function makeDoc() { + return { + createElement(tag) { + return { + tag, + className: '', + textContent: '', + children: [], + appendChild(child) { this.children.push(child); return child; }, + }; + }, + }; +} + +describe('formatEventText', () => { + it('formats state_change with from/to', () => { + assert.equal( + formatEventText({ kind: 'state_change', payload: { from: 'QUEUED', to: 'RUNNING' } }), + 'State: QUEUED → RUNNING', + ); + }); + + it('formats summary text', () => { + assert.equal( + formatEventText({ kind: 'summary', payload: { text: 'did the thing' } }), + 'Summary: did the thing', + ); + }); + + it('formats clarification_request and answer', () => { + assert.equal( + formatEventText({ kind: 'clarification_request', payload: { text: 'which branch?' } }), + 'Question: which branch?', + ); + assert.equal( + formatEventText({ kind: 'clarification_answer', payload: { answer: 'main' } }), + 'Answer: main', + ); + }); + + it('formats subtask_spawned by name', () => { + assert.equal( + formatEventText({ kind: 'subtask_spawned', payload: { name: 'write tests', subtask_id: 'x' } }), + 'Spawned subtask: write tests', + ); + }); + + it('falls back to kind for unknown events', () => { + assert.equal(formatEventText({ kind: 'mystery', payload: {} }), 'mystery'); + }); + + it('handles null/empty defensively', () => { + assert.equal(formatEventText(null), ''); + assert.equal(formatEventText({ kind: 'agent_message', payload: { message: 'hi' } }), 'hi'); + }); +}); + +describe('renderEventTimeline', () => { + it('renders one item per event with actor and text', () => { + const events = [ + { kind: 'state_change', actor: 'system', payload: { from: 'PENDING', to: 'QUEUED' } }, + { kind: 'summary', actor: 'agent', payload: { text: 'done' } }, + ]; + const ul = renderEventTimeline(events, makeDoc()); + assert.equal(ul.className, 'event-timeline'); + assert.equal(ul.children.length, 2); + // each item has [actor, text] + assert.equal(ul.children[0].children[0].textContent, 'system'); + assert.equal(ul.children[0].children[1].textContent, 'State: PENDING → QUEUED'); + assert.equal(ul.children[1].children[1].textContent, 'Summary: done'); + }); + + it('renders an empty-state item for no events', () => { + const ul = renderEventTimeline([], makeDoc()); + assert.equal(ul.children.length, 1); + assert.equal(ul.children[0].className, 'event-timeline__empty'); + }); + + it('returns null when doc is null', () => { + assert.equal(renderEventTimeline([], null), null); + }); +}); + +describe('fetchTaskEvents', () => { + it('returns parsed array on success', async () => { + const stub = async () => ({ ok: true, json: async () => [{ kind: 'summary', payload: {} }] }); + const events = await fetchTaskEvents('t1', stub); + assert.equal(events.length, 1); + }); + + it('returns [] on non-ok response', async () => { + const stub = async () => ({ ok: false, json: async () => ({}) }); + assert.deepEqual(await fetchTaskEvents('t1', stub), []); + }); + + it('returns [] when fetch throws', async () => { + const stub = async () => { throw new Error('network'); }; + assert.deepEqual(await fetchTaskEvents('t1', stub), []); + }); + + it('returns [] when no fetch implementation is available', async () => { + assert.deepEqual(await fetchTaskEvents('t1', null), []); + }); +}); diff --git a/web/test/new-task-button.test.mjs b/web/test/new-task-button.test.mjs deleted file mode 100644 index 45a3548..0000000 --- a/web/test/new-task-button.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -// new-task-button.test.mjs — visibility contract for the New Task button -// -// The New Task button lives in the global header and must be visible on all tabs. -// Run with: node --test web/test/new-task-button.test.mjs - -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { newTaskButtonShouldShowOnTab } from '../app.js'; - -const ALL_TABS = ['tasks', 'active', 'running', 'stats']; - -describe('new task button visibility', () => { - for (const tab of ALL_TABS) { - it(`is visible on "${tab}" tab`, () => { - assert.equal(newTaskButtonShouldShowOnTab(tab), true, `expected button to be visible on tab "${tab}"`); - }); - } - - it('is visible on any unknown future tab', () => { - assert.equal(newTaskButtonShouldShowOnTab('help'), true); - assert.equal(newTaskButtonShouldShowOnTab('templates'), true); - assert.equal(newTaskButtonShouldShowOnTab(''), true); - }); -}); diff --git a/web/test/stories.test.mjs b/web/test/stories.test.mjs deleted file mode 100644 index 263f202..0000000 --- a/web/test/stories.test.mjs +++ /dev/null @@ -1,164 +0,0 @@ -// stories.test.mjs — TDD tests for stories UI functions. -// -// Run with: node --test web/test/stories.test.mjs - -import { describe, it, beforeEach } from 'node:test'; -import assert from 'node:assert/strict'; -import { renderStoryCard, storyStatusLabel } from '../app.js'; - -// ── Minimal DOM mock ────────────────────────────────────────────────────────── - -function makeMockDoc() { - function createElement(tag) { - return { - tag, - className: '', - textContent: '', - innerHTML: '', - hidden: false, - dataset: {}, - children: [], - _listeners: {}, - style: {}, - appendChild(child) { this.children.push(child); return child; }, - prepend(...nodes) { this.children.unshift(...nodes); }, - append(...nodes) { nodes.forEach(n => this.children.push(n)); }, - addEventListener(ev, fn) { this._listeners[ev] = fn; }, - querySelector(sel) { - const cls = sel.startsWith('.') ? sel.slice(1) : null; - function search(el) { - if (cls && el.className && el.className.split(' ').includes(cls)) return el; - for (const c of el.children || []) { - const found = search(c); - if (found) return found; - } - return null; - } - return search(this); - }, - querySelectorAll(sel) { - const cls = sel.startsWith('.') ? sel.slice(1) : null; - const results = []; - function search(el) { - if (cls && el.className && el.className.split(' ').includes(cls)) results.push(el); - for (const c of el.children || []) search(c); - } - search(this); - return results; - }, - }; - } - return { createElement }; -} - -function makeStory(overrides = {}) { - return { - id: 'story-1', - name: 'Add login page', - project_id: 'claudomator', - branch_name: 'story/add-login-page', - status: 'PENDING', - created_at: '2026-03-25T10:00:00Z', - updated_at: '2026-03-25T10:00:00Z', - ...overrides, - }; -} - -// ── storyStatusLabel ────────────────────────────────────────────────────────── - -describe('storyStatusLabel', () => { - it('returns human-readable label for PENDING', () => { - assert.equal(storyStatusLabel('PENDING'), 'Pending'); - }); - - it('returns human-readable label for IN_PROGRESS', () => { - assert.equal(storyStatusLabel('IN_PROGRESS'), 'In Progress'); - }); - - it('returns human-readable label for SHIPPABLE', () => { - assert.equal(storyStatusLabel('SHIPPABLE'), 'Shippable'); - }); - - it('returns human-readable label for DEPLOYED', () => { - assert.equal(storyStatusLabel('DEPLOYED'), 'Deployed'); - }); - - it('returns human-readable label for VALIDATING', () => { - assert.equal(storyStatusLabel('VALIDATING'), 'Validating'); - }); - - it('returns human-readable label for REVIEW_READY', () => { - assert.equal(storyStatusLabel('REVIEW_READY'), 'Review Ready'); - }); - - it('returns human-readable label for NEEDS_FIX', () => { - assert.equal(storyStatusLabel('NEEDS_FIX'), 'Needs Fix'); - }); - - it('falls back to the raw status for unknown values', () => { - assert.equal(storyStatusLabel('UNKNOWN_STATE'), 'UNKNOWN_STATE'); - }); -}); - -// ── renderStoryCard ─────────────────────────────────────────────────────────── - -describe('renderStoryCard', () => { - let doc; - beforeEach(() => { doc = makeMockDoc(); }); - - it('renders the story name', () => { - const card = renderStoryCard(makeStory(), doc); - function findText(el, text) { - if (el.textContent === text) return true; - return (el.children || []).some(c => findText(c, text)); - } - assert.ok(findText(card, 'Add login page'), 'card should contain story name'); - }); - - it('has story-card class', () => { - const card = renderStoryCard(makeStory(), doc); - assert.ok(card.className.split(' ').includes('story-card'), 'root element should have story-card class'); - }); - - it('status badge has data-status matching story status', () => { - const card = renderStoryCard(makeStory({ status: 'IN_PROGRESS' }), doc); - const badge = card.querySelector('.story-status-badge'); - assert.ok(badge, 'badge element should exist'); - assert.equal(badge.dataset.status, 'IN_PROGRESS'); - }); - - it('status badge shows human-readable label', () => { - const card = renderStoryCard(makeStory({ status: 'REVIEW_READY' }), doc); - const badge = card.querySelector('.story-status-badge'); - assert.equal(badge.textContent, 'Review Ready'); - }); - - it('shows project_id', () => { - const card = renderStoryCard(makeStory({ project_id: 'nav' }), doc); - function findText(el, text) { - if (el.textContent === text) return true; - return (el.children || []).some(c => findText(c, text)); - } - assert.ok(findText(card, 'nav'), 'card should show project_id'); - }); - - it('shows branch_name when present', () => { - const card = renderStoryCard(makeStory({ branch_name: 'story/my-feature' }), doc); - function findText(el, text) { - if (el.textContent && el.textContent.includes(text)) return true; - return (el.children || []).some(c => findText(c, text)); - } - assert.ok(findText(card, 'story/my-feature'), 'card should show branch_name'); - }); - - it('does not show branch section when branch_name is empty', () => { - const card = renderStoryCard(makeStory({ branch_name: '' }), doc); - const branchEl = card.querySelector('.story-branch'); - assert.ok(!branchEl, 'no .story-branch element when branch is empty'); - }); - - it('card dataset.storyId is set to story id', () => { - const card = renderStoryCard(makeStory({ id: 'abc-123' }), doc); - assert.equal(card.dataset.storyId, 'abc-123'); - }); -}); |
