diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/app.js | 1355 | ||||
| -rw-r--r-- | web/index.html | 71 | ||||
| -rw-r--r-- | web/style.css | 477 | ||||
| -rw-r--r-- | web/sw.js | 14 | ||||
| -rw-r--r-- | web/test/deployment-badge.test.mjs | 66 | ||||
| -rw-r--r-- | web/test/enable-notifications.test.mjs | 64 | ||||
| -rw-r--r-- | web/test/stories.test.mjs | 164 | ||||
| -rw-r--r-- | web/test/tab-persistence.test.mjs | 58 | ||||
| -rw-r--r-- | web/test/task-panel-summary.test.mjs | 144 |
9 files changed, 2180 insertions, 233 deletions
@@ -96,6 +96,25 @@ export function renderChangestatsBadge(stats, doc = (typeof document !== 'undefi return span; } +// Returns a <span class="deployment-badge"> 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); @@ -136,6 +155,12 @@ function createTaskCard(task) { 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) @@ -146,6 +171,30 @@ function createTaskCard(task) { card.appendChild(desc); } + // Error message for failed tasks + const FAILED_STATES = new Set(['FAILED', 'BUDGET_EXCEEDED', 'TIMED_OUT']); + if (FAILED_STATES.has(task.state) && task.error_msg) { + const errEl = document.createElement('div'); + errEl.className = 'task-error-msg'; + errEl.textContent = task.error_msg; + errEl.title = task.error_msg; + card.appendChild(errEl); + } + + // Checker report for READY tasks where the checker flagged a problem. + if (task.state === 'READY' && task.checker_report) { + const reportEl = document.createElement('div'); + reportEl.className = 'task-checker-report'; + const label = document.createElement('span'); + label.className = 'task-checker-report-label'; + label.textContent = '⚠ Checker flagged:'; + const text = document.createElement('span'); + text.textContent = task.checker_report; + reportEl.appendChild(label); + reportEl.appendChild(text); + card.appendChild(reportEl); + } + // Changestats badge for COMPLETED/READY tasks const CHANGESTATS_STATES = new Set(['COMPLETED', 'READY']); if (CHANGESTATS_STATES.has(task.state) && task.changestats != null) { @@ -153,6 +202,13 @@ function createTaskCard(task) { 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. @@ -375,6 +431,14 @@ export function setTaskFilterTab(tab) { localStorage.setItem('taskFilterTab', tab); } +export function getActiveMainTab() { + return localStorage.getItem('activeMainTab') ?? 'queue'; +} + +export function setActiveMainTab(tab) { + localStorage.setItem('activeMainTab', tab); +} + // ── Tab badge counts ─────────────────────────────────────────────────────────── /** @@ -385,21 +449,13 @@ 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 }; + return { interrupted, ready, running }; } /** @@ -475,6 +531,90 @@ 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 => { @@ -575,13 +715,23 @@ function renderReadyPanel(tasks) { 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.'); + const completedContainer = document.querySelector('[data-panel="ready"] .ready-completed-history'); + if (!completedContainer) return; + const done = sortTasksByDate(filterAllDoneTasks(tasks), true); + if (!completedContainer.querySelector('.ready-completed-label')) { + const label = document.createElement('h2'); + label.className = 'ready-completed-label'; + label.textContent = 'Completed (24h)'; + completedContainer.prepend(label); + } + const list = completedContainer.querySelector('.ready-completed-list') || (() => { + const el = document.createElement('div'); + el.className = 'ready-completed-list'; + completedContainer.appendChild(el); + return el; + })(); + renderTasksIntoContainer(done, list, 'No completed tasks in the last 24h.'); } // ── Run action ──────────────────────────────────────────────────────────────── @@ -1131,14 +1281,21 @@ function renderActiveTab(allTasks) { }); } break; - case 'all': - renderAllPanel(allTasks); - break; case 'stats': - fetchRecentExecutions(BASE_PATH, fetch) - .then(execs => renderStatsPanel(allTasks, execs)) + Promise.all([ + fetchRecentExecutions(BASE_PATH, fetch), + fetch(`${BASE_PATH}/api/agents/status?since=${encodeURIComponent(new Date(Date.now() - 24*60*60*1000).toISOString())}`).then(r => r.ok ? r.json() : { agents: [], events: [] }), + fetch(`${BASE_PATH}/api/stats?window=7d`).then(r => r.ok ? r.json() : { throughput: [], billing: [], failures: [] }), + ]) + .then(([execs, agentData, dashStats]) => renderStatsPanel(allTasks, execs, agentData, dashStats)) .catch(() => {}); break; + case 'stories': + renderStoriesPanel(); + break; + case 'drops': + renderDropsPanel(); + break; case 'settings': renderSettingsPanel(); break; @@ -1288,6 +1445,7 @@ function connectWebSocket() { function handleWsEvent(data) { switch (data.type) { + case 'task_started': case 'task_completed': // Force a poll immediately regardless of interval poll(); @@ -1435,12 +1593,13 @@ 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 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, agent: { instructions, project_dir, allowed_tools } }; + return { name, repository_url, agent: { instructions, container_image, allowed_tools } }; } function renderValidationResult(result) { @@ -1498,49 +1657,6 @@ function renderValidationResult(result) { 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() { @@ -1554,20 +1670,20 @@ function closeTaskModal() { } async function createTask(formData) { - const selectVal = formData.get('project_dir'); - const workingDir = selectVal === '__new__' - ? document.getElementById('new-project-input').value.trim() - : selectVal; + 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'), - project_dir: workingDir, + container_image: container_image, max_budget_usd: parseFloat(formData.get('max_budget_usd')), + type: 'container', }, timeout: formData.get('timeout'), priority: formData.get('priority'), @@ -1708,7 +1824,7 @@ function makeMetaItem(label, valueText, opts = {}) { return item; } -function renderTaskPanel(task, executions) { +export function renderTaskPanel(task, executions) { document.getElementById('task-panel-title').textContent = task.name; const content = document.getElementById('task-panel-content'); content.innerHTML = ''; @@ -1774,22 +1890,15 @@ function renderTaskPanel(task, executions) { 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'); @@ -2428,7 +2537,7 @@ function formatDurationMs(ms) { return rm > 0 ? `${h}h ${rm}m` : `${h}h`; } -function renderStatsPanel(tasks, executions) { +function renderStatsPanel(tasks, executions, agentData = { agents: [], events: [] }, dashStats = { throughput: [], billing: [], failures: [] }) { const panel = document.querySelector('[data-panel="stats"]'); if (!panel) return; @@ -2562,12 +2671,720 @@ function renderStatsPanel(tasks, executions) { execSection.appendChild(chartSection); } + // ── Per-execution detail table ───────────────────────────────────────────── + if (executions.length > 0) { + const tableWrap = document.createElement('div'); + tableWrap.className = 'stats-exec-table-wrap'; + + const tableLabel = document.createElement('p'); + tableLabel.className = 'stats-bar-chart-label'; + tableLabel.textContent = 'Recent runs'; + tableWrap.appendChild(tableLabel); + + const table = document.createElement('table'); + table.className = 'stats-exec-table'; + table.innerHTML = '<thead><tr><th>Task</th><th>Outcome</th><th>Cost</th><th>Duration</th><th>Started</th></tr></thead>'; + const tbody = document.createElement('tbody'); + for (const ex of executions.slice(0, 20)) { + const tr = document.createElement('tr'); + const durationMs = ex.duration_ms != null ? formatDurationMs(ex.duration_ms) : '—'; + const cost = ex.cost_usd > 0 ? `$${ex.cost_usd.toFixed(3)}` : '—'; + const started = ex.started_at ? new Date(ex.started_at).toLocaleTimeString() : '—'; + const state = (ex.state || '').toUpperCase(); + tr.innerHTML = `<td class="stats-exec-name">${ex.task_name || ex.task_id}</td><td><span class="state-badge" data-state="${state}">${state.replace(/_/g,' ')}</span></td><td>${cost}</td><td>${durationMs}</td><td>${started}</td>`; + tbody.appendChild(tr); + } + table.appendChild(tbody); + tableWrap.appendChild(table); + execSection.appendChild(tableWrap); + } + panel.appendChild(execSection); + + // ── Errors ──────────────────────────────────────────────────────────────── + const failures = dashStats.failures || []; + const errSection = document.createElement('div'); + errSection.className = 'stats-section'; + + const errHeading = document.createElement('h2'); + errHeading.textContent = 'Errors (Last 7d)'; + errSection.appendChild(errHeading); + + if (failures.length === 0) { + const none = document.createElement('p'); + none.className = 'task-meta'; + none.textContent = 'No failures in the last 7 days.'; + errSection.appendChild(none); + } else { + // Category summary bar + const cats = {}; + for (const f of failures) cats[f.category] = (cats[f.category] || 0) + 1; + const catOrder = ['quota', 'rate_limit', 'timeout', 'git', 'failed']; + const catLabels = { quota: 'Quota', rate_limit: 'Rate limit', timeout: 'Timeout', git: 'Git', failed: 'Failed' }; + const catColors = { quota: 'var(--state-budget-exceeded)', rate_limit: 'var(--state-failed)', timeout: 'var(--state-timed-out)', git: 'var(--state-cancelled)', failed: 'var(--state-failed)' }; + + const catRow = document.createElement('div'); + catRow.className = 'stats-kpis'; + const allCats = [...catOrder, ...Object.keys(cats).filter(c => !catOrder.includes(c))]; + for (const cat of allCats) { + if (!cats[cat]) continue; + const box = document.createElement('div'); + box.className = 'stats-kpi-box stats-err-cat'; + box.style.setProperty('--cat-color', catColors[cat] || 'var(--state-failed)'); + const val = document.createElement('span'); + val.className = 'stats-kpi-value'; + val.textContent = String(cats[cat]); + const lbl = document.createElement('span'); + lbl.className = 'stats-kpi-label'; + lbl.textContent = catLabels[cat] || cat; + box.appendChild(val); + box.appendChild(lbl); + catRow.appendChild(box); + } + errSection.appendChild(catRow); + + // Failure table + const errTable = document.createElement('table'); + errTable.className = 'stats-exec-table'; + errTable.style.marginTop = '0.75rem'; + errTable.innerHTML = '<thead><tr><th>Task</th><th>Category</th><th>Error</th><th>Time</th></tr></thead>'; + const errTbody = document.createElement('tbody'); + for (const f of failures.slice(0, 25)) { + const tr = document.createElement('tr'); + const ts = new Date(f.started_at).toLocaleString(); + const short = f.error_msg.length > 80 ? f.error_msg.slice(0, 80) + '…' : f.error_msg; + const catColor = catColors[f.category] || 'var(--state-failed)'; + tr.innerHTML = `<td class="stats-exec-name">${f.task_name}</td><td><span class="stats-err-badge" style="background:${catColor}">${catLabels[f.category] || f.category}</span></td><td class="stats-err-msg" title="${f.error_msg.replace(/"/g,'"')}">${short}</td><td style="white-space:nowrap">${ts}</td>`; + errTbody.appendChild(tr); + } + errTable.appendChild(errTbody); + errSection.appendChild(errTable); + } + + panel.appendChild(errSection); + + // ── Throughput ──────────────────────────────────────────────────────────── + const throughput = dashStats.throughput || []; + const tpSection = document.createElement('div'); + tpSection.className = 'stats-section'; + + const tpHeading = document.createElement('h2'); + tpHeading.textContent = 'Throughput (Last 7d)'; + tpSection.appendChild(tpHeading); + + if (throughput.length === 0) { + const none = document.createElement('p'); + none.className = 'task-meta'; + none.textContent = 'No execution data yet.'; + tpSection.appendChild(none); + } else { + const maxTotal = Math.max(...throughput.map(b => b.completed + b.failed + b.other), 1); + const chart = document.createElement('div'); + chart.className = 'stats-tp-chart'; + + for (const bucket of throughput) { + const total = bucket.completed + bucket.failed + bucket.other; + const col = document.createElement('div'); + col.className = 'stats-tp-col'; + const heightPct = (total / maxTotal) * 100; + const label = new Date(bucket.hour).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit' }); + col.title = `${label}\n✓ ${bucket.completed} ✗ ${bucket.failed} ○ ${bucket.other}`; + + if (total > 0) { + const bar = document.createElement('div'); + bar.className = 'stats-tp-bar'; + bar.style.height = `${heightPct.toFixed(1)}%`; + + const cPct = (bucket.completed / total) * 100; + const fPct = (bucket.failed / total) * 100; + const oPct = 100 - cPct - fPct; + + bar.style.background = `linear-gradient(to top, + var(--state-failed) 0% ${fPct.toFixed(1)}%, + var(--state-timed-out) ${fPct.toFixed(1)}% ${(fPct+oPct).toFixed(1)}%, + var(--state-completed) ${(fPct+oPct).toFixed(1)}% 100%)`; + + col.appendChild(bar); + } + + chart.appendChild(col); + } + tpSection.appendChild(chart); + + const tpLegend = document.createElement('div'); + tpLegend.className = 'stats-tp-legend'; + tpLegend.innerHTML = ` + <span class="stats-tp-legend-item"><span class="stats-tp-swatch" style="background:var(--state-completed)"></span>Completed</span> + <span class="stats-tp-legend-item"><span class="stats-tp-swatch" style="background:var(--state-failed)"></span>Failed</span> + <span class="stats-tp-legend-item"><span class="stats-tp-swatch" style="background:var(--state-timed-out)"></span>Other</span> + `; + tpSection.appendChild(tpLegend); + } + + panel.appendChild(tpSection); + + // ── Billing ─────────────────────────────────────────────────────────────── + const billing = dashStats.billing || []; + const billSection = document.createElement('div'); + billSection.className = 'stats-section'; + + const billHeading = document.createElement('h2'); + billHeading.textContent = 'Cost (Last 7d)'; + billSection.appendChild(billHeading); + + if (billing.length === 0) { + const none = document.createElement('p'); + none.className = 'task-meta'; + none.textContent = 'No cost data yet.'; + billSection.appendChild(none); + } else { + const totalCost = billing.reduce((s, d) => s + d.cost_usd, 0); + const totalRuns = billing.reduce((s, d) => s + d.runs, 0); + + const billKpis = document.createElement('div'); + billKpis.className = 'stats-kpis'; + for (const kpi of [ + { label: '7d Total', value: `$${totalCost.toFixed(2)}` }, + { label: 'Avg/Day', value: billing.length > 0 ? `$${(totalCost / billing.length).toFixed(2)}` : '—' }, + { label: 'Cost/Run', value: totalRuns > 0 ? `$${(totalCost / totalRuns).toFixed(3)}` : '—' }, + { label: 'Total Runs', value: String(totalRuns) }, + ]) { + 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); + billKpis.appendChild(box); + } + billSection.appendChild(billKpis); + + // Daily cost bar chart + const maxCost = Math.max(...billing.map(d => d.cost_usd), 0.001); + const billChart = document.createElement('div'); + billChart.className = 'stats-bill-chart'; + + for (const day of billing) { + const col = document.createElement('div'); + col.className = 'stats-bill-col'; + col.title = `${day.day}\n$${day.cost_usd.toFixed(3)} (${day.runs} runs)`; + + const bar = document.createElement('div'); + bar.className = 'stats-bill-bar'; + bar.style.height = `${((day.cost_usd / maxCost) * 100).toFixed(1)}%`; + + const dayLabel = document.createElement('span'); + dayLabel.className = 'stats-bill-day-label'; + const d = new Date(day.day + 'T12:00:00Z'); + dayLabel.textContent = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + + col.appendChild(bar); + col.appendChild(dayLabel); + billChart.appendChild(col); + } + billSection.appendChild(billChart); + } + + panel.appendChild(billSection); + + // ── Agent Status ─────────────────────────────────────────────────────────── + const agentSection = document.createElement('div'); + agentSection.className = 'stats-section'; + + const agentHeading = document.createElement('h2'); + agentHeading.textContent = 'Agent Status'; + agentSection.appendChild(agentHeading); + + const agents = agentData.agents || []; + const agentEvents = agentData.events || []; + + if (agents.length === 0) { + const none = document.createElement('p'); + none.className = 'task-meta'; + none.textContent = 'No agents registered.'; + agentSection.appendChild(none); + } else { + // Status cards row + const cardsRow = document.createElement('div'); + cardsRow.className = 'stats-agent-cards'; + for (const ag of agents) { + const card = document.createElement('div'); + card.className = 'stats-agent-card'; + const statusClass = ag.drained ? 'agent-drained' : ag.rate_limited ? 'agent-rate-limited' : 'agent-available'; + card.classList.add(statusClass); + + const nameEl = document.createElement('span'); + nameEl.className = 'stats-agent-name'; + nameEl.textContent = ag.agent; + + const statusEl = document.createElement('span'); + statusEl.className = 'stats-agent-status'; + if (ag.drained) { + statusEl.textContent = 'Drain locked — needs manual undrain'; + } else if (ag.rate_limited && ag.until) { + const untilDate = new Date(ag.until); + const minsLeft = Math.max(0, Math.round((untilDate - Date.now()) / 60000)); + statusEl.textContent = `Rate limited — ${minsLeft}m remaining`; + } else { + statusEl.textContent = ag.active_tasks > 0 ? `Active (${ag.active_tasks} running)` : 'Available'; + } + + card.appendChild(nameEl); + card.appendChild(statusEl); + cardsRow.appendChild(card); + } + agentSection.appendChild(cardsRow); + + // Availability timeline (last 24h) + const now = Date.now(); + const windowMs = 24 * 60 * 60 * 1000; + const windowStart = now - windowMs; + + const timelineHeading = document.createElement('p'); + timelineHeading.className = 'stats-bar-chart-label'; + timelineHeading.textContent = 'Availability last 24h'; + agentSection.appendChild(timelineHeading); + + // Group events by agent + const eventsByAgent = {}; + for (const ev of agentEvents) { + if (!eventsByAgent[ev.agent]) eventsByAgent[ev.agent] = []; + eventsByAgent[ev.agent].push(ev); + } + + for (const ag of agents) { + const evs = (eventsByAgent[ag.agent] || []).slice().sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); + + const row = document.createElement('div'); + row.className = 'stats-timeline-row'; + + const label = document.createElement('span'); + label.className = 'stats-timeline-label'; + label.textContent = ag.agent; + row.appendChild(label); + + const track = document.createElement('div'); + track.className = 'stats-timeline-track'; + + // Build segments: walk events and produce [start, end, state] intervals + const segments = []; + let cursor = windowStart; + // Reconstruct: before first event, assume available unless currently rate-limited with an until before window + let inRateLimit = false; + + for (const ev of evs) { + const evTime = Math.max(windowStart, new Date(ev.timestamp).getTime()); + if (evTime > cursor) { + segments.push({ start: cursor, end: evTime, limited: inRateLimit }); + } + cursor = evTime; + if (ev.event === 'rate_limited') { + inRateLimit = true; + } else if (ev.event === 'available') { + inRateLimit = false; + } + } + // Tail to now + if (cursor < now) { + // If currently rate limited use current agent state + segments.push({ start: cursor, end: now, limited: ag.rate_limited || inRateLimit }); + } + + for (const seg of segments) { + const pct = ((seg.end - seg.start) / windowMs) * 100; + if (pct < 0.01) continue; + const span = document.createElement('div'); + span.className = 'stats-timeline-seg'; + span.classList.add(seg.limited ? 'seg-limited' : 'seg-available'); + span.style.width = `${pct.toFixed(2)}%`; + const mins = Math.round((seg.end - seg.start) / 60000); + span.title = `${seg.limited ? 'Rate limited' : 'Available'} — ${mins}m`; + track.appendChild(span); + } + + row.appendChild(track); + + // Legend labels + const timeLabels = document.createElement('div'); + timeLabels.className = 'stats-timeline-timelabels'; + timeLabels.innerHTML = '<span>24h ago</span><span>now</span>'; + row.appendChild(timeLabels); + + agentSection.appendChild(row); + } + + // Rate-limit event log + if (agentEvents.length > 0) { + const evLogLabel = document.createElement('p'); + evLogLabel.className = 'stats-bar-chart-label'; + evLogLabel.textContent = 'Rate-limit events (last 24h)'; + agentSection.appendChild(evLogLabel); + + const evTable = document.createElement('table'); + evTable.className = 'stats-exec-table'; + evTable.innerHTML = '<thead><tr><th>Agent</th><th>Event</th><th>Reason</th><th>Until</th><th>Time</th></tr></thead>'; + const evTbody = document.createElement('tbody'); + for (const ev of agentEvents.slice(0, 30)) { + const tr = document.createElement('tr'); + const until = ev.until ? new Date(ev.until).toLocaleTimeString() : '—'; + const ts = new Date(ev.timestamp).toLocaleTimeString(); + const eventClass = ev.event === 'rate_limited' ? 'state-badge" data-state="FAILED' : 'state-badge" data-state="COMPLETED'; + tr.innerHTML = `<td>${ev.agent}</td><td><span class="${eventClass}">${ev.event.replace(/_/g,' ')}</span></td><td>${ev.reason || '—'}</td><td>${until}</td><td>${ts}</td>`; + evTbody.appendChild(tr); + } + evTable.appendChild(evTbody); + agentSection.appendChild(evTable); + } + } + + panel.appendChild(agentSection); +} + +// ── 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 and wait for it to become active. + await registerServiceWorker(); + const registration = await navigator.serviceWorker.ready; + + // Unsubscribe any stale subscription (e.g. from a VAPID key rotation). + // PushManager.subscribe() throws "applicationServerKey is not valid" if the + // existing subscription was created with a different key. + const existingSub = await registration.pushManager.getSubscription(); + if (existingSub) { + await existingSub.unsubscribe(); + } + + // 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(); +} + +// ── 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; + panel.innerHTML = '<p class="task-meta">Loading drops…</p>'; + + 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 = `<p class="task-meta" style="padding:1rem">Failed to load drops: ${err.message}</p>`; + } } // ── Tab switching ───────────────────────────────────────────────────────────── function switchTab(name) { + setActiveMainTab(name); + // Update tab button active state document.querySelectorAll('.tab').forEach(btn => { btn.classList.toggle('active', btn.dataset.tab === name); @@ -2586,165 +3403,267 @@ function switchTab(name) { poll(); } +// ── Version color ───────────────────────────────────────────────────────────── + +async function applyVersionColor() { + try { + const res = await fetch(`${BASE_PATH}/api/version`); + if (!res.ok) return; + const { version } = await res.json(); + // Use first 6 hex chars of version as hue seed (works for commit hashes and "dev") + const hex = version.replace(/[^0-9a-f]/gi, '').slice(0, 6).padEnd(6, '0'); + const hue = Math.round((parseInt(hex, 16) / 0xffffff) * 360); + const h1 = document.querySelector('header h1'); + if (h1) h1.style.color = `hsl(${hue}, 70%, 55%)`; + } catch { + // non-fatal — logo stays default color + } +} + // ── Boot ────────────────────────────────────────────────────────────────────── -if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded', () => { - document.getElementById('btn-start-next').addEventListener('click', function() { - handleStartNextTask(this); - }); +if (typeof document !== 'undefined') { + document.addEventListener('DOMContentLoaded', () => { + document.getElementById('btn-start-next').addEventListener('click', function() { + handleStartNextTask(this); + }); - switchTab('queue'); - startPolling(); - connectWebSocket(); + applyVersionColor(); + switchTab(getActiveMainTab()); + startPolling(); + connectWebSocket(); - // Side panel close - document.getElementById('btn-close-panel').addEventListener('click', closeTaskPanel); - document.getElementById('task-panel-backdrop').addEventListener('click', closeTaskPanel); + // 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(); - }); + // 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)); - }); + // 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(); - - // 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'; - } - }); + // Task modal + document.getElementById('btn-new-task').addEventListener('click', openTaskModal); + document.getElementById('btn-cancel-task').addEventListener('click', closeTaskModal); - // 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; + // Push notifications button + const btnNotify = document.getElementById('btn-notifications'); + if (btnNotify) { + btnNotify.addEventListener('click', () => enableNotifications(btnNotify)); } - btnElaborate.disabled = true; - btnElaborate.textContent = 'Drafting…'; + // 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; + } - // Remove any previous errors or banners - const form = document.getElementById('task-form'); - form.querySelectorAll('.form-error, .elaborate-banner').forEach(el => el.remove()); + btnElaborate.disabled = true; + btnElaborate.textContent = 'Drafting…'; - 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; + // 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.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; + 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(); - // 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); + // 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…'; - // Auto-validate after elaboration try { - const result = await validateTask(buildValidatePayload()); - renderValidationResult(result); - } catch (_) { - // silent - elaboration already succeeded, validation is bonus + 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'; } - } 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(); + // 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; + } - // Remove any previous error - const prev = e.target.querySelector('.form-error'); - if (prev) prev.remove(); + storyModal.querySelectorAll('.form-error').forEach(el => el.remove()); + btn.disabled = true; + btn.textContent = 'Elaborating…'; - const btn = e.submitter; - btn.disabled = true; - btn.textContent = 'Creating…'; + 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 ✦'; + } + }); - 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; + 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'; } - } - 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 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 7c0b030..0632cd7 100644 --- a/web/index.html +++ b/web/index.html @@ -7,8 +7,24 @@ <meta name="base-path" content="/claudomator"> <link rel="stylesheet" href="style.css" /> <link rel="icon" href="data:,"> + <script> + (function() { + var I = 5 * 60 * 1000; + function b() { return Math.floor(Date.now() / I); } + function u(n) { return "url('https://picsum.photos/1920/1080?random=" + n + "')"; } + function apply(n) { document.body.style.setProperty('--bg-url', u(n)); } + function schedule() { + setTimeout(function() { apply(b()); new Image().src = u(b() + 1); schedule(); }, I - (Date.now() % I)); + } + document.addEventListener('DOMContentLoaded', function() { + apply(b()); + new Image().src = u(b() + 1); + schedule(); + }); + })(); + </script> </head> -<body> +<body class="bg-body"> <header> <h1>Claudomator</h1> <div class="header-actions"> @@ -18,16 +34,18 @@ <option value="gemini">Gemini</option> <option value="local">Local</option> </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> </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> <button class="tab" data-tab="running" title="Running">▶️<span class="tab-count-badge" hidden></span></button> - <button class="tab" data-tab="all" title="All">☰<span class="tab-count-badge" hidden></span></button> + <button class="tab" data-tab="drops" title="Drops">📁</button> <button class="tab" data-tab="stats" title="Stats">📊</button> <button class="tab" data-tab="settings" title="Settings">⚙️</button> </nav> @@ -42,14 +60,16 @@ </div> <div data-panel="ready" hidden> <div class="panel-task-list"></div> + <div class="ready-completed-history"></div> </div> <div data-panel="running" hidden> <div class="running-current"></div> <div class="running-history"></div> </div> - <div data-panel="all" hidden> - <div class="all-history"></div> + <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> @@ -70,15 +90,12 @@ <p class="elaborate-hint">AI will fill in the form fields below. You can edit before submitting.</p> </div> <hr class="form-divider"> - <label>Project - <select name="project_dir" id="project-select"> - <option value="/workspace/claudomator" selected>/workspace/claudomator</option> - <option value="__new__">Create new project…</option> - </select> + <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> - <div id="new-project-row" hidden> - <label>New Project Path <input id="new-project-input" placeholder="/workspace/my-new-app"></label> - </div> <label>Name <input name="name" required></label> <label>Instructions <textarea name="instructions" rows="6" required></textarea></label> <div class="validate-section"> @@ -125,6 +142,36 @@ </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 e7d1de4..d3b01d0 100644 --- a/web/style.css +++ b/web/style.css @@ -10,9 +10,9 @@ --state-budget-exceeded: #fb923c; --state-blocked: #818cf8; - --bg: #0f172a; - --surface: #1e293b; - --border: #334155; + --bg: rgba(15, 23, 42, 0.8); + --surface: rgba(30, 41, 59, 0.75); + --border: rgba(51, 65, 85, 0.7); --text: #e2e8f0; --text-muted: #94a3b8; --accent: #38bdf8; @@ -32,6 +32,12 @@ body { min-height: 100dvh; } +body.bg-body { + background-image: linear-gradient(rgba(2, 6, 23, 0.65), rgba(2, 6, 23, 0.65)), var(--bg-url, none); + background-size: cover; + background-position: center; +} + /* Header */ header { background: var(--surface); @@ -264,6 +270,17 @@ main { flex-wrap: wrap; } +.task-project { + font-size: 0.72rem; + font-weight: 600; + color: var(--text-muted); + background: var(--bg-elevated, #2a2a2a); + border: 1px solid var(--border, #444); + border-radius: 3px; + padding: 0 0.35rem; + letter-spacing: 0.02em; +} + .task-description { font-size: 0.82rem; color: var(--text-muted); @@ -272,6 +289,15 @@ main { text-overflow: ellipsis; } +.task-error-msg { + font-size: 0.78rem; + color: var(--state-failed); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 2px; +} + /* Run button */ .task-card-footer { display: flex; @@ -863,6 +889,27 @@ dialog label select:focus { white-space: nowrap; } +.deployment-badge { + display: inline-block; + font-size: 0.72rem; + font-weight: 600; + padding: 0.15em 0.45em; + border-radius: 0.25rem; + white-space: nowrap; +} + +.deployment-badge--deployed { + background: color-mix(in srgb, var(--success, #22c55e) 15%, transparent); + color: var(--success, #16a34a); + border: 1px solid color-mix(in srgb, var(--success, #22c55e) 35%, transparent); +} + +.deployment-badge--pending { + background: color-mix(in srgb, var(--warn, #f59e0b) 15%, transparent); + color: var(--warn, #b45309); + border: 1px solid color-mix(in srgb, var(--warn, #f59e0b) 35%, transparent); +} + .btn-view-logs { font-size: 0.72rem; font-weight: 600; @@ -1158,6 +1205,36 @@ dialog label select:focus { word-break: break-word; } +.ready-completed-history { + margin-top: 2rem; + border-top: 1px solid var(--border); + padding-top: 1rem; +} + +.ready-completed-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.75rem; +} + +.task-checker-report { + margin: 0.5rem 0; + padding: 0.5rem 0.75rem; + background: var(--warning-bg, rgba(255, 180, 0, 0.12)); + border-left: 3px solid var(--warning, #f0a500); + border-radius: 4px; + font-size: 0.8rem; + color: var(--text); +} + +.task-checker-report-label { + font-weight: 600; + margin-right: 0.4rem; +} + .running-history { margin-top: 1.5rem; overflow-x: auto; @@ -1518,3 +1595,397 @@ dialog label select:focus { width: 80px; flex-shrink: 0; } + +/* ── Error category badge ───────────────────────────────────────────────── */ +.stats-err-badge { + display: inline-block; + padding: 0.15rem 0.45rem; + border-radius: 4px; + font-size: 0.72rem; + font-weight: 600; + color: #fff; + white-space: nowrap; +} + +.stats-err-cat { + border-top: 3px solid var(--cat-color, var(--state-failed)); +} + +.stats-err-msg { + font-size: 0.75rem; + color: var(--text-muted); + max-width: 360px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Throughput chart ───────────────────────────────────────────────────── */ +.stats-tp-chart { + display: flex; + align-items: flex-end; + gap: 2px; + height: 120px; + margin: 0.75rem 0 0.25rem; + border-bottom: 1px solid var(--border); +} + +.stats-tp-col { + flex: 1; + display: flex; + align-items: flex-end; + height: 100%; + min-width: 0; +} + +.stats-tp-bar { + width: 100%; + border-radius: 2px 2px 0 0; + min-height: 2px; +} + +.stats-tp-legend { + display: flex; + gap: 1rem; + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.4rem; +} + +.stats-tp-legend-item { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.stats-tp-swatch { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; +} + +/* ── Billing chart ──────────────────────────────────────────────────────── */ +.stats-bill-chart { + display: flex; + align-items: flex-end; + gap: 4px; + height: 100px; + margin: 0.75rem 0 0; + border-bottom: 1px solid var(--border); +} + +.stats-bill-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + height: 100%; + min-width: 0; +} + +.stats-bill-bar { + width: 100%; + background: var(--state-queued); + border-radius: 3px 3px 0 0; + min-height: 2px; +} + +.stats-bill-day-label { + font-size: 0.65rem; + color: var(--text-muted); + margin-top: 0.25rem; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} + +/* ── Execution detail table ─────────────────────────────────────────────── */ +.stats-exec-table-wrap { + margin-top: 1rem; +} + +.stats-exec-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.stats-exec-table th, +.stats-exec-table td { + padding: 0.35rem 0.5rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.stats-exec-table th { + color: var(--text-muted); + font-weight: 500; +} + +.stats-exec-name { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; +} + +/* ── Agent Status ───────────────────────────────────────────────────────── */ +.stats-agent-cards { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} + +.stats-agent-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem 1rem; + border-radius: 8px; + border: 1px solid var(--border); + min-width: 160px; +} + +.stats-agent-card.agent-available { + border-color: var(--state-completed); + background: color-mix(in srgb, var(--state-completed) 8%, transparent); +} + +.stats-agent-card.agent-rate-limited { + border-color: var(--state-failed); + background: color-mix(in srgb, var(--state-failed) 8%, transparent); +} + +.stats-agent-card.agent-drained { + border-color: var(--state-cancelled); + background: color-mix(in srgb, var(--state-cancelled) 8%, transparent); +} + +.stats-agent-name { + font-weight: 600; + font-size: 0.9rem; + text-transform: capitalize; +} + +.stats-agent-status { + font-size: 0.75rem; + color: var(--text-muted); +} + +/* ── Availability Timeline ─────────────────────────────────────────────── */ +.stats-timeline-row { + margin-bottom: 0.75rem; +} + +.stats-timeline-label { + display: block; + font-size: 0.78rem; + color: var(--text-muted); + margin-bottom: 0.2rem; + text-transform: capitalize; +} + +.stats-timeline-track { + display: flex; + height: 18px; + border-radius: 4px; + overflow: hidden; + background: var(--bg-card); + width: 100%; +} + +.stats-timeline-seg { + height: 100%; + transition: opacity 0.1s; +} + +.stats-timeline-seg:hover { + opacity: 0.8; +} + +.seg-available { + background: var(--state-completed); +} + +.seg-limited { + background: var(--state-failed); +} + +.stats-timeline-timelabels { + display: flex; + justify-content: space-between; + font-size: 0.68rem; + color: var(--text-muted); + margin-top: 0.15rem; +} + +/* ── Stories ───────────────────────────────────────────────────────────────── */ + +[data-panel="stories"] { + padding: 1rem; +} + +.stories-toolbar { + margin-bottom: 1rem; +} + +.stories-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.story-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.875rem 1rem; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.story-card:hover { + border-color: var(--accent); + background: var(--surface-hover, var(--surface)); +} + +.story-card-header { + display: flex; + align-items: center; + gap: 0.625rem; + margin-bottom: 0.375rem; +} + +.story-name { + font-weight: 600; + flex: 1; +} + +.story-status-badge { + font-size: 0.72rem; + font-weight: 600; + padding: 0.15em 0.55em; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.04em; + background: var(--state-pending); + color: #0f172a; +} + +.story-status-badge[data-status="PENDING"] { background: var(--state-pending); } +.story-status-badge[data-status="IN_PROGRESS"] { background: var(--state-running); } +.story-status-badge[data-status="SHIPPABLE"] { background: var(--state-completed); } +.story-status-badge[data-status="DEPLOYED"] { background: #60a5fa; } +.story-status-badge[data-status="VALIDATING"] { background: #c084fc; } +.story-status-badge[data-status="REVIEW_READY"] { background: var(--state-completed); } +.story-status-badge[data-status="NEEDS_FIX"] { background: var(--state-failed); color: #fff; } + +.story-meta { + display: flex; + gap: 1rem; + font-size: 0.8rem; + color: var(--text-muted); +} + +.story-branch { + font-family: var(--font-mono, monospace); +} + +/* Story modals */ +.story-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.story-modal-header h2 { + margin: 0; +} + +.story-modal-body { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +#story-plan-area { + background: var(--code-bg, #1e293b); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.875rem; + font-size: 0.85rem; +} + +.story-plan-name { + font-weight: 600; + margin: 0 0 0.25rem; +} + +.story-plan-branch { + font-family: var(--font-mono, monospace); + font-size: 0.8rem; + color: var(--text-muted); + margin: 0 0 0.75rem; +} + +.story-plan-section { + font-weight: 600; + margin: 0.75rem 0 0.25rem; +} + +.story-plan-tasks { + margin: 0; + padding-left: 1.25rem; +} + +.story-plan-tasks li { + margin-bottom: 0.25rem; +} + +.story-plan-tasks ul { + font-size: 0.8rem; + color: var(--text-muted); + margin: 0.2rem 0 0; + padding-left: 1rem; +} + +.story-detail-body { + padding: 0.25rem 0; +} + +.story-detail-tasks { + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.story-detail-task-list { + margin: 0.5rem 0 0; + padding-left: 1.25rem; + list-style: decimal; +} + +.story-detail-task-list > li { + padding: 0.2rem 0; + font-size: 0.9rem; +} + +.story-detail-subtask-list { + margin: 0.25rem 0 0.25rem 0.5rem; + padding-left: 1rem; + list-style: none; +} + +.story-detail-subtask-list li { + font-size: 0.85rem; + opacity: 0.85; + padding: 0.1rem 0; +} diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 0000000..09b53a6 --- /dev/null +++ b/web/sw.js @@ -0,0 +1,14 @@ +self.addEventListener('push', function(event) { + const data = event.data ? event.data.json() : {}; + const title = data.title || 'Claudomator'; + const options = { + body: data.body || '', + tag: data.tag || 'claudomator', + }; + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', function(event) { + event.notification.close(); + event.waitUntil(clients.openWindow('/')); +}); diff --git a/web/test/deployment-badge.test.mjs b/web/test/deployment-badge.test.mjs new file mode 100644 index 0000000..438fb27 --- /dev/null +++ b/web/test/deployment-badge.test.mjs @@ -0,0 +1,66 @@ +// deployment-badge.test.mjs — Unit tests for deployment status badge. +// +// Run with: node --test web/test/deployment-badge.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { renderDeploymentBadge } from '../app.js'; + +function makeDoc() { + return { + createElement(tag) { + const el = { + tag, + className: '', + textContent: '', + title: '', + children: [], + appendChild(child) { this.children.push(child); return child; }, + }; + return el; + }, + }; +} + +describe('renderDeploymentBadge', () => { + it('returns null for null status', () => { + const el = renderDeploymentBadge(null, makeDoc()); + assert.equal(el, null); + }); + + it('returns null for undefined status', () => { + const el = renderDeploymentBadge(undefined, makeDoc()); + assert.equal(el, null); + }); + + it('returns null when includes_fix is false', () => { + const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: false }; + const el = renderDeploymentBadge(status, makeDoc()); + assert.equal(el, null); + }); + + it('returns element with deployment-badge class when includes_fix is true', () => { + const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: true }; + const el = renderDeploymentBadge(status, makeDoc()); + assert.ok(el, 'element should not be null'); + assert.ok(el.className.includes('deployment-badge'), `className should include deployment-badge, got: ${el.className}`); + }); + + it('shows "Deployed" text when includes_fix is true', () => { + const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: true }; + const el = renderDeploymentBadge(status, makeDoc()); + assert.ok(el.textContent.includes('Deployed'), `expected "Deployed" in "${el.textContent}"`); + }); + + it('applies deployed class when includes_fix is true', () => { + const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: true }; + const el = renderDeploymentBadge(status, makeDoc()); + assert.ok(el.className.includes('deployment-badge--deployed'), `className: ${el.className}`); + }); + + it('returns null for doc=null', () => { + const status = { deployed_commit: 'abc', fix_commits: [], includes_fix: true }; + const el = renderDeploymentBadge(status, null); + assert.equal(el, null); + }); +}); diff --git a/web/test/enable-notifications.test.mjs b/web/test/enable-notifications.test.mjs new file mode 100644 index 0000000..c8afdd3 --- /dev/null +++ b/web/test/enable-notifications.test.mjs @@ -0,0 +1,64 @@ +// enable-notifications.test.mjs — Tests for the enableNotifications subscription flow. +// +// Run with: node --test web/test/enable-notifications.test.mjs + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Logic under test ────────────────────────────────────────────────────────── +// +// When subscribing to push notifications, any existing stale subscription +// (e.g. from before a VAPID key rotation) must be unsubscribed first. +// Otherwise the browser rejects subscribe() with "applicationServerKey is not valid". + +/** + * Extracted subscription logic (mirrors enableNotifications in app.js). + * Returns the new subscription endpoint, or throws on error. + */ +async function subscribeWithUnsubscribeStale(pushManager, applicationServerKey) { + // Clear any stale existing subscription. + const existing = await pushManager.getSubscription(); + if (existing) { + await existing.unsubscribe(); + } + const sub = await pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }); + return sub; +} + +describe('subscribeWithUnsubscribeStale', () => { + it('unsubscribes existing subscription before subscribing', async () => { + let unsubscribeCalled = false; + const existingSub = { + unsubscribe: async () => { unsubscribeCalled = true; }, + }; + let subscribeCalled = false; + const pushManager = { + getSubscription: async () => existingSub, + subscribe: async (opts) => { + subscribeCalled = true; + return { endpoint: 'https://push.example.com/sub/new', toJSON: () => ({}) }; + }, + }; + + await subscribeWithUnsubscribeStale(pushManager, new Uint8Array([4, 1, 2])); + + assert.equal(unsubscribeCalled, true, 'existing subscription should have been unsubscribed'); + assert.equal(subscribeCalled, true, 'new subscription should have been created'); + }); + + it('subscribes normally when no existing subscription', async () => { + let subscribeCalled = false; + const pushManager = { + getSubscription: async () => null, + subscribe: async (opts) => { + subscribeCalled = true; + return { endpoint: 'https://push.example.com/sub/new', toJSON: () => ({}) }; + }, + }; + + const sub = await subscribeWithUnsubscribeStale(pushManager, new Uint8Array([4, 1, 2])); + + assert.equal(subscribeCalled, true, 'subscribe should have been called'); + assert.ok(sub, 'subscription object should be returned'); + }); +}); diff --git a/web/test/stories.test.mjs b/web/test/stories.test.mjs new file mode 100644 index 0000000..263f202 --- /dev/null +++ b/web/test/stories.test.mjs @@ -0,0 +1,164 @@ +// 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'); + }); +}); diff --git a/web/test/tab-persistence.test.mjs b/web/test/tab-persistence.test.mjs new file mode 100644 index 0000000..9311453 --- /dev/null +++ b/web/test/tab-persistence.test.mjs @@ -0,0 +1,58 @@ +// tab-persistence.test.mjs — TDD tests for main-tab localStorage persistence +// +// Run with: node --test web/test/tab-persistence.test.mjs + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── localStorage mock ────────────────────────────────────────────────────────── +// Must be set up before importing app.js so the module sees the global. +const store = new Map(); +globalThis.localStorage = { + getItem: (k) => store.has(k) ? store.get(k) : null, + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), + clear: () => store.clear(), +}; + +import { getActiveMainTab, setActiveMainTab } from '../app.js'; + +describe('getActiveMainTab', () => { + beforeEach(() => store.clear()); + + it('returns "queue" when localStorage has no stored value', () => { + assert.equal(getActiveMainTab(), 'queue'); + }); + + it('returns the tab name stored by setActiveMainTab', () => { + setActiveMainTab('settings'); + assert.equal(getActiveMainTab(), 'settings'); + }); + + it('returns "queue" after localStorage value is removed', () => { + setActiveMainTab('stats'); + localStorage.removeItem('activeMainTab'); + assert.equal(getActiveMainTab(), 'queue'); + }); + + it('reflects the most recent setActiveMainTab call', () => { + setActiveMainTab('stats'); + setActiveMainTab('running'); + assert.equal(getActiveMainTab(), 'running'); + }); +}); + +describe('setActiveMainTab', () => { + beforeEach(() => store.clear()); + + it('writes the tab name to localStorage under key "activeMainTab"', () => { + setActiveMainTab('drops'); + assert.equal(localStorage.getItem('activeMainTab'), 'drops'); + }); + + it('overwrites a previously stored tab', () => { + setActiveMainTab('queue'); + setActiveMainTab('interrupted'); + assert.equal(localStorage.getItem('activeMainTab'), 'interrupted'); + }); +}); diff --git a/web/test/task-panel-summary.test.mjs b/web/test/task-panel-summary.test.mjs new file mode 100644 index 0000000..1777003 --- /dev/null +++ b/web/test/task-panel-summary.test.mjs @@ -0,0 +1,144 @@ +// task-panel-summary.test.mjs — verifies task summary renders exactly once in panel. +// +// Run with: node --test web/test/task-panel-summary.test.mjs + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Minimal DOM mock ────────────────────────────────────────────────────────── + +function makeMockDOM() { + const elements = {}; + + function createElement(tag) { + const el = { + tag, + className: '', + textContent: '', + innerHTML: '', + hidden: false, + children: [], + dataset: {}, + _listeners: {}, + appendChild(child) { this.children.push(child); return child; }, + prepend(...nodes) { this.children.unshift(...nodes); }, + append(...nodes) { nodes.forEach(n => this.children.push(n)); }, + 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; + }, + addEventListener(ev, fn) {}, + }; + return el; + } + + // Named panel elements referenced by getElementById + const panelTitle = createElement('h2'); + const panelContent = createElement('div'); + elements['task-panel-title'] = panelTitle; + elements['task-panel-content'] = panelContent; + + const doc = { + createElement, + getElementById(id) { return elements[id] || null; }, + }; + + return { doc, panelContent }; +} + +// ── Import renderTaskPanel ──────────────────────────────────────────────────── + +import { renderTaskPanel } from '../app.js'; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('renderTaskPanel summary rendering', () => { + it('renders task summary exactly once for a COMPLETED task', () => { + const { doc, panelContent } = makeMockDOM(); + + // Must set global document before calling renderTaskPanel + global.document = doc; + + const task = { + id: 'task-1', + name: 'Fix the bug', + state: 'COMPLETED', + summary: 'Resolved the nil pointer in the payment handler.', + priority: 'normal', + created_at: '2026-03-17T10:00:00Z', + updated_at: '2026-03-17T10:05:00Z', + tags: [], + }; + + renderTaskPanel(task, []); + + // Count all elements with class 'task-summary' or 'task-summary-text' + const summaryEls = panelContent.querySelectorAll('.task-summary'); + const summaryTextEls = panelContent.querySelectorAll('.task-summary-text'); + + const total = summaryEls.length + summaryTextEls.length; + assert.equal(total, 1, `Expected exactly 1 summary element, got ${total} (task-summary: ${summaryEls.length}, task-summary-text: ${summaryTextEls.length})`); + }); + + it('uses task-summary class (not task-summary-text) for good contrast', () => { + const { doc, panelContent } = makeMockDOM(); + global.document = doc; + + const task = { + id: 'task-2', + name: 'Another task', + state: 'COMPLETED', + summary: 'All done.', + priority: 'high', + created_at: '2026-03-17T10:00:00Z', + updated_at: '2026-03-17T10:05:00Z', + tags: [], + }; + + renderTaskPanel(task, []); + + const summaryEls = panelContent.querySelectorAll('.task-summary'); + assert.equal(summaryEls.length, 1, 'Expected .task-summary element'); + assert.equal(summaryEls[0].textContent, task.summary); + }); + + it('renders no summary section when task has no summary', () => { + const { doc, panelContent } = makeMockDOM(); + global.document = doc; + + const task = { + id: 'task-3', + name: 'Pending task', + state: 'PENDING', + summary: null, + priority: 'normal', + created_at: '2026-03-17T10:00:00Z', + updated_at: '2026-03-17T10:00:00Z', + tags: [], + }; + + renderTaskPanel(task, []); + + const summaryEls = panelContent.querySelectorAll('.task-summary'); + const summaryTextEls = panelContent.querySelectorAll('.task-summary-text'); + assert.equal(summaryEls.length + summaryTextEls.length, 0, 'Expected no summary when task.summary is null'); + }); +}); |
