diff options
Diffstat (limited to 'web/app.js')
| -rw-r--r-- | web/app.js | 1355 |
1 files changed, 1137 insertions, 218 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()); } }); -}); +} |
