summaryrefslogtreecommitdiff
path: root/web/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'web/app.js')
-rw-r--r--web/app.js1355
1 files changed, 1137 insertions, 218 deletions
diff --git a/web/app.js b/web/app.js
index e1782dd..ff8e381 100644
--- a/web/app.js
+++ b/web/app.js
@@ -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,'&quot;')}">${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());
}
});
-});
+}