summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/app.js1355
-rw-r--r--web/index.html71
-rw-r--r--web/style.css477
-rw-r--r--web/sw.js14
-rw-r--r--web/test/deployment-badge.test.mjs66
-rw-r--r--web/test/enable-notifications.test.mjs64
-rw-r--r--web/test/stories.test.mjs164
-rw-r--r--web/test/tab-persistence.test.mjs58
-rw-r--r--web/test/task-panel-summary.test.mjs144
9 files changed, 2180 insertions, 233 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());
}
});
-});
+}
diff --git a/web/index.html b/web/index.html
index 7c0b030..0632cd7 100644
--- a/web/index.html
+++ b/web/index.html
@@ -7,8 +7,24 @@
<meta name="base-path" content="/claudomator">
<link rel="stylesheet" href="style.css" />
<link rel="icon" href="data:,">
+ <script>
+ (function() {
+ var I = 5 * 60 * 1000;
+ function b() { return Math.floor(Date.now() / I); }
+ function u(n) { return "url('https://picsum.photos/1920/1080?random=" + n + "')"; }
+ function apply(n) { document.body.style.setProperty('--bg-url', u(n)); }
+ function schedule() {
+ setTimeout(function() { apply(b()); new Image().src = u(b() + 1); schedule(); }, I - (Date.now() % I));
+ }
+ document.addEventListener('DOMContentLoaded', function() {
+ apply(b());
+ new Image().src = u(b() + 1);
+ schedule();
+ });
+ })();
+ </script>
</head>
-<body>
+<body class="bg-body">
<header>
<h1>Claudomator</h1>
<div class="header-actions">
@@ -18,16 +34,18 @@
<option value="gemini">Gemini</option>
<option value="local">Local</option>
</select>
+ <button id="btn-notifications" class="btn-secondary" title="Enable push notifications">🔔</button>
<button id="btn-start-next" class="btn-secondary">Start Next</button>
<button id="btn-new-task" class="btn-primary">New Task</button>
</div>
</header>
<nav class="tab-bar">
+ <button class="tab" data-tab="stories" title="Stories">📖</button>
<button class="tab active" data-tab="queue" title="Queue">⏳</button>
<button class="tab" data-tab="interrupted" title="Interrupted">⚠️<span class="tab-count-badge" hidden></span></button>
<button class="tab" data-tab="ready" title="Ready">✅<span class="tab-count-badge" hidden></span></button>
<button class="tab" data-tab="running" title="Running">▶️<span class="tab-count-badge" hidden></span></button>
- <button class="tab" data-tab="all" title="All">☰<span class="tab-count-badge" hidden></span></button>
+ <button class="tab" data-tab="drops" title="Drops">📁</button>
<button class="tab" data-tab="stats" title="Stats">📊</button>
<button class="tab" data-tab="settings" title="Settings">⚙️</button>
</nav>
@@ -42,14 +60,16 @@
</div>
<div data-panel="ready" hidden>
<div class="panel-task-list"></div>
+ <div class="ready-completed-history"></div>
</div>
<div data-panel="running" hidden>
<div class="running-current"></div>
<div class="running-history"></div>
</div>
- <div data-panel="all" hidden>
- <div class="all-history"></div>
+ <div data-panel="drops" hidden>
+ <div class="drops-panel"></div>
</div>
+ <div data-panel="stories" hidden></div>
<div data-panel="stats" hidden></div>
<div data-panel="settings" hidden>
<p class="task-meta" style="padding:1rem">Settings coming soon.</p>
@@ -70,15 +90,12 @@
<p class="elaborate-hint">AI will fill in the form fields below. You can edit before submitting.</p>
</div>
<hr class="form-divider">
- <label>Project
- <select name="project_dir" id="project-select">
- <option value="/workspace/claudomator" selected>/workspace/claudomator</option>
- <option value="__new__">Create new project…</option>
- </select>
+ <label>Repository URL
+ <input name="repository_url" id="repository-url" placeholder="https://github.com/user/repo.git" required>
+ </label>
+ <label>Container Image
+ <input name="container_image" id="container-image" placeholder="claudomator-agent:latest" value="claudomator-agent:latest">
</label>
- <div id="new-project-row" hidden>
- <label>New Project Path <input id="new-project-input" placeholder="/workspace/my-new-app"></label>
- </div>
<label>Name <input name="name" required></label>
<label>Instructions <textarea name="instructions" rows="6" required></textarea></label>
<div class="validate-section">
@@ -125,6 +142,36 @@
</div>
</dialog>
+ <!-- New Story modal -->
+ <dialog id="story-modal">
+ <div class="story-modal-header">
+ <h2>New Story</h2>
+ <button id="btn-close-story-modal" class="btn-close-panel" aria-label="Close">&#x2715;</button>
+ </div>
+ <div class="story-modal-body">
+ <label>Project
+ <select id="story-project"></select>
+ </label>
+ <label>Goal
+ <textarea id="story-goal" rows="4" placeholder="Describe the feature or change you want to build…"></textarea>
+ </label>
+ <button type="button" id="btn-story-elaborate" class="btn-secondary">Elaborate with AI ✦</button>
+ <div id="story-plan-area" hidden></div>
+ <div class="form-actions">
+ <button type="button" id="btn-story-approve" class="btn-primary" hidden>Approve &amp; Queue</button>
+ </div>
+ </div>
+ </dialog>
+
+ <!-- Story detail modal -->
+ <dialog id="story-detail-modal">
+ <div class="story-modal-header">
+ <h2 id="story-detail-name">Story</h2>
+ <button id="btn-close-story-detail" class="btn-close-panel" aria-label="Close">&#x2715;</button>
+ </div>
+ <div id="story-detail-body" class="story-detail-body meta-grid"></div>
+ </dialog>
+
<script type="module" src="app.js"></script>
</body>
</html>
diff --git a/web/style.css b/web/style.css
index e7d1de4..d3b01d0 100644
--- a/web/style.css
+++ b/web/style.css
@@ -10,9 +10,9 @@
--state-budget-exceeded: #fb923c;
--state-blocked: #818cf8;
- --bg: #0f172a;
- --surface: #1e293b;
- --border: #334155;
+ --bg: rgba(15, 23, 42, 0.8);
+ --surface: rgba(30, 41, 59, 0.75);
+ --border: rgba(51, 65, 85, 0.7);
--text: #e2e8f0;
--text-muted: #94a3b8;
--accent: #38bdf8;
@@ -32,6 +32,12 @@ body {
min-height: 100dvh;
}
+body.bg-body {
+ background-image: linear-gradient(rgba(2, 6, 23, 0.65), rgba(2, 6, 23, 0.65)), var(--bg-url, none);
+ background-size: cover;
+ background-position: center;
+}
+
/* Header */
header {
background: var(--surface);
@@ -264,6 +270,17 @@ main {
flex-wrap: wrap;
}
+.task-project {
+ font-size: 0.72rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ background: var(--bg-elevated, #2a2a2a);
+ border: 1px solid var(--border, #444);
+ border-radius: 3px;
+ padding: 0 0.35rem;
+ letter-spacing: 0.02em;
+}
+
.task-description {
font-size: 0.82rem;
color: var(--text-muted);
@@ -272,6 +289,15 @@ main {
text-overflow: ellipsis;
}
+.task-error-msg {
+ font-size: 0.78rem;
+ color: var(--state-failed);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-top: 2px;
+}
+
/* Run button */
.task-card-footer {
display: flex;
@@ -863,6 +889,27 @@ dialog label select:focus {
white-space: nowrap;
}
+.deployment-badge {
+ display: inline-block;
+ font-size: 0.72rem;
+ font-weight: 600;
+ padding: 0.15em 0.45em;
+ border-radius: 0.25rem;
+ white-space: nowrap;
+}
+
+.deployment-badge--deployed {
+ background: color-mix(in srgb, var(--success, #22c55e) 15%, transparent);
+ color: var(--success, #16a34a);
+ border: 1px solid color-mix(in srgb, var(--success, #22c55e) 35%, transparent);
+}
+
+.deployment-badge--pending {
+ background: color-mix(in srgb, var(--warn, #f59e0b) 15%, transparent);
+ color: var(--warn, #b45309);
+ border: 1px solid color-mix(in srgb, var(--warn, #f59e0b) 35%, transparent);
+}
+
.btn-view-logs {
font-size: 0.72rem;
font-weight: 600;
@@ -1158,6 +1205,36 @@ dialog label select:focus {
word-break: break-word;
}
+.ready-completed-history {
+ margin-top: 2rem;
+ border-top: 1px solid var(--border);
+ padding-top: 1rem;
+}
+
+.ready-completed-label {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.75rem;
+}
+
+.task-checker-report {
+ margin: 0.5rem 0;
+ padding: 0.5rem 0.75rem;
+ background: var(--warning-bg, rgba(255, 180, 0, 0.12));
+ border-left: 3px solid var(--warning, #f0a500);
+ border-radius: 4px;
+ font-size: 0.8rem;
+ color: var(--text);
+}
+
+.task-checker-report-label {
+ font-weight: 600;
+ margin-right: 0.4rem;
+}
+
.running-history {
margin-top: 1.5rem;
overflow-x: auto;
@@ -1518,3 +1595,397 @@ dialog label select:focus {
width: 80px;
flex-shrink: 0;
}
+
+/* ── Error category badge ───────────────────────────────────────────────── */
+.stats-err-badge {
+ display: inline-block;
+ padding: 0.15rem 0.45rem;
+ border-radius: 4px;
+ font-size: 0.72rem;
+ font-weight: 600;
+ color: #fff;
+ white-space: nowrap;
+}
+
+.stats-err-cat {
+ border-top: 3px solid var(--cat-color, var(--state-failed));
+}
+
+.stats-err-msg {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ max-width: 360px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* ── Throughput chart ───────────────────────────────────────────────────── */
+.stats-tp-chart {
+ display: flex;
+ align-items: flex-end;
+ gap: 2px;
+ height: 120px;
+ margin: 0.75rem 0 0.25rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.stats-tp-col {
+ flex: 1;
+ display: flex;
+ align-items: flex-end;
+ height: 100%;
+ min-width: 0;
+}
+
+.stats-tp-bar {
+ width: 100%;
+ border-radius: 2px 2px 0 0;
+ min-height: 2px;
+}
+
+.stats-tp-legend {
+ display: flex;
+ gap: 1rem;
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ margin-top: 0.4rem;
+}
+
+.stats-tp-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+.stats-tp-swatch {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ flex-shrink: 0;
+}
+
+/* ── Billing chart ──────────────────────────────────────────────────────── */
+.stats-bill-chart {
+ display: flex;
+ align-items: flex-end;
+ gap: 4px;
+ height: 100px;
+ margin: 0.75rem 0 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.stats-bill-col {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-end;
+ height: 100%;
+ min-width: 0;
+}
+
+.stats-bill-bar {
+ width: 100%;
+ background: var(--state-queued);
+ border-radius: 3px 3px 0 0;
+ min-height: 2px;
+}
+
+.stats-bill-day-label {
+ font-size: 0.65rem;
+ color: var(--text-muted);
+ margin-top: 0.25rem;
+ text-align: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 100%;
+}
+
+/* ── Execution detail table ─────────────────────────────────────────────── */
+.stats-exec-table-wrap {
+ margin-top: 1rem;
+}
+
+.stats-exec-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8rem;
+}
+
+.stats-exec-table th,
+.stats-exec-table td {
+ padding: 0.35rem 0.5rem;
+ text-align: left;
+ border-bottom: 1px solid var(--border);
+}
+
+.stats-exec-table th {
+ color: var(--text-muted);
+ font-weight: 500;
+}
+
+.stats-exec-name {
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: block;
+}
+
+/* ── Agent Status ───────────────────────────────────────────────────────── */
+.stats-agent-cards {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ margin-bottom: 1.25rem;
+}
+
+.stats-agent-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ min-width: 160px;
+}
+
+.stats-agent-card.agent-available {
+ border-color: var(--state-completed);
+ background: color-mix(in srgb, var(--state-completed) 8%, transparent);
+}
+
+.stats-agent-card.agent-rate-limited {
+ border-color: var(--state-failed);
+ background: color-mix(in srgb, var(--state-failed) 8%, transparent);
+}
+
+.stats-agent-card.agent-drained {
+ border-color: var(--state-cancelled);
+ background: color-mix(in srgb, var(--state-cancelled) 8%, transparent);
+}
+
+.stats-agent-name {
+ font-weight: 600;
+ font-size: 0.9rem;
+ text-transform: capitalize;
+}
+
+.stats-agent-status {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
+
+/* ── Availability Timeline ─────────────────────────────────────────────── */
+.stats-timeline-row {
+ margin-bottom: 0.75rem;
+}
+
+.stats-timeline-label {
+ display: block;
+ font-size: 0.78rem;
+ color: var(--text-muted);
+ margin-bottom: 0.2rem;
+ text-transform: capitalize;
+}
+
+.stats-timeline-track {
+ display: flex;
+ height: 18px;
+ border-radius: 4px;
+ overflow: hidden;
+ background: var(--bg-card);
+ width: 100%;
+}
+
+.stats-timeline-seg {
+ height: 100%;
+ transition: opacity 0.1s;
+}
+
+.stats-timeline-seg:hover {
+ opacity: 0.8;
+}
+
+.seg-available {
+ background: var(--state-completed);
+}
+
+.seg-limited {
+ background: var(--state-failed);
+}
+
+.stats-timeline-timelabels {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.68rem;
+ color: var(--text-muted);
+ margin-top: 0.15rem;
+}
+
+/* ── Stories ───────────────────────────────────────────────────────────────── */
+
+[data-panel="stories"] {
+ padding: 1rem;
+}
+
+.stories-toolbar {
+ margin-bottom: 1rem;
+}
+
+.stories-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.story-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 0.875rem 1rem;
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s;
+}
+
+.story-card:hover {
+ border-color: var(--accent);
+ background: var(--surface-hover, var(--surface));
+}
+
+.story-card-header {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ margin-bottom: 0.375rem;
+}
+
+.story-name {
+ font-weight: 600;
+ flex: 1;
+}
+
+.story-status-badge {
+ font-size: 0.72rem;
+ font-weight: 600;
+ padding: 0.15em 0.55em;
+ border-radius: 3px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ background: var(--state-pending);
+ color: #0f172a;
+}
+
+.story-status-badge[data-status="PENDING"] { background: var(--state-pending); }
+.story-status-badge[data-status="IN_PROGRESS"] { background: var(--state-running); }
+.story-status-badge[data-status="SHIPPABLE"] { background: var(--state-completed); }
+.story-status-badge[data-status="DEPLOYED"] { background: #60a5fa; }
+.story-status-badge[data-status="VALIDATING"] { background: #c084fc; }
+.story-status-badge[data-status="REVIEW_READY"] { background: var(--state-completed); }
+.story-status-badge[data-status="NEEDS_FIX"] { background: var(--state-failed); color: #fff; }
+
+.story-meta {
+ display: flex;
+ gap: 1rem;
+ font-size: 0.8rem;
+ color: var(--text-muted);
+}
+
+.story-branch {
+ font-family: var(--font-mono, monospace);
+}
+
+/* Story modals */
+.story-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+}
+
+.story-modal-header h2 {
+ margin: 0;
+}
+
+.story-modal-body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.875rem;
+}
+
+#story-plan-area {
+ background: var(--code-bg, #1e293b);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 0.875rem;
+ font-size: 0.85rem;
+}
+
+.story-plan-name {
+ font-weight: 600;
+ margin: 0 0 0.25rem;
+}
+
+.story-plan-branch {
+ font-family: var(--font-mono, monospace);
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ margin: 0 0 0.75rem;
+}
+
+.story-plan-section {
+ font-weight: 600;
+ margin: 0.75rem 0 0.25rem;
+}
+
+.story-plan-tasks {
+ margin: 0;
+ padding-left: 1.25rem;
+}
+
+.story-plan-tasks li {
+ margin-bottom: 0.25rem;
+}
+
+.story-plan-tasks ul {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ margin: 0.2rem 0 0;
+ padding-left: 1rem;
+}
+
+.story-detail-body {
+ padding: 0.25rem 0;
+}
+
+.story-detail-tasks {
+ margin-top: 1rem;
+ padding-top: 0.75rem;
+ border-top: 1px solid var(--border);
+}
+
+.story-detail-task-list {
+ margin: 0.5rem 0 0;
+ padding-left: 1.25rem;
+ list-style: decimal;
+}
+
+.story-detail-task-list > li {
+ padding: 0.2rem 0;
+ font-size: 0.9rem;
+}
+
+.story-detail-subtask-list {
+ margin: 0.25rem 0 0.25rem 0.5rem;
+ padding-left: 1rem;
+ list-style: none;
+}
+
+.story-detail-subtask-list li {
+ font-size: 0.85rem;
+ opacity: 0.85;
+ padding: 0.1rem 0;
+}
diff --git a/web/sw.js b/web/sw.js
new file mode 100644
index 0000000..09b53a6
--- /dev/null
+++ b/web/sw.js
@@ -0,0 +1,14 @@
+self.addEventListener('push', function(event) {
+ const data = event.data ? event.data.json() : {};
+ const title = data.title || 'Claudomator';
+ const options = {
+ body: data.body || '',
+ tag: data.tag || 'claudomator',
+ };
+ event.waitUntil(self.registration.showNotification(title, options));
+});
+
+self.addEventListener('notificationclick', function(event) {
+ event.notification.close();
+ event.waitUntil(clients.openWindow('/'));
+});
diff --git a/web/test/deployment-badge.test.mjs b/web/test/deployment-badge.test.mjs
new file mode 100644
index 0000000..438fb27
--- /dev/null
+++ b/web/test/deployment-badge.test.mjs
@@ -0,0 +1,66 @@
+// deployment-badge.test.mjs — Unit tests for deployment status badge.
+//
+// Run with: node --test web/test/deployment-badge.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { renderDeploymentBadge } from '../app.js';
+
+function makeDoc() {
+ return {
+ createElement(tag) {
+ const el = {
+ tag,
+ className: '',
+ textContent: '',
+ title: '',
+ children: [],
+ appendChild(child) { this.children.push(child); return child; },
+ };
+ return el;
+ },
+ };
+}
+
+describe('renderDeploymentBadge', () => {
+ it('returns null for null status', () => {
+ const el = renderDeploymentBadge(null, makeDoc());
+ assert.equal(el, null);
+ });
+
+ it('returns null for undefined status', () => {
+ const el = renderDeploymentBadge(undefined, makeDoc());
+ assert.equal(el, null);
+ });
+
+ it('returns null when includes_fix is false', () => {
+ const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: false };
+ const el = renderDeploymentBadge(status, makeDoc());
+ assert.equal(el, null);
+ });
+
+ it('returns element with deployment-badge class when includes_fix is true', () => {
+ const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: true };
+ const el = renderDeploymentBadge(status, makeDoc());
+ assert.ok(el, 'element should not be null');
+ assert.ok(el.className.includes('deployment-badge'), `className should include deployment-badge, got: ${el.className}`);
+ });
+
+ it('shows "Deployed" text when includes_fix is true', () => {
+ const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: true };
+ const el = renderDeploymentBadge(status, makeDoc());
+ assert.ok(el.textContent.includes('Deployed'), `expected "Deployed" in "${el.textContent}"`);
+ });
+
+ it('applies deployed class when includes_fix is true', () => {
+ const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: true };
+ const el = renderDeploymentBadge(status, makeDoc());
+ assert.ok(el.className.includes('deployment-badge--deployed'), `className: ${el.className}`);
+ });
+
+ it('returns null for doc=null', () => {
+ const status = { deployed_commit: 'abc', fix_commits: [], includes_fix: true };
+ const el = renderDeploymentBadge(status, null);
+ assert.equal(el, null);
+ });
+});
diff --git a/web/test/enable-notifications.test.mjs b/web/test/enable-notifications.test.mjs
new file mode 100644
index 0000000..c8afdd3
--- /dev/null
+++ b/web/test/enable-notifications.test.mjs
@@ -0,0 +1,64 @@
+// enable-notifications.test.mjs — Tests for the enableNotifications subscription flow.
+//
+// Run with: node --test web/test/enable-notifications.test.mjs
+
+import { describe, it, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+
+// ── Logic under test ──────────────────────────────────────────────────────────
+//
+// When subscribing to push notifications, any existing stale subscription
+// (e.g. from before a VAPID key rotation) must be unsubscribed first.
+// Otherwise the browser rejects subscribe() with "applicationServerKey is not valid".
+
+/**
+ * Extracted subscription logic (mirrors enableNotifications in app.js).
+ * Returns the new subscription endpoint, or throws on error.
+ */
+async function subscribeWithUnsubscribeStale(pushManager, applicationServerKey) {
+ // Clear any stale existing subscription.
+ const existing = await pushManager.getSubscription();
+ if (existing) {
+ await existing.unsubscribe();
+ }
+ const sub = await pushManager.subscribe({ userVisibleOnly: true, applicationServerKey });
+ return sub;
+}
+
+describe('subscribeWithUnsubscribeStale', () => {
+ it('unsubscribes existing subscription before subscribing', async () => {
+ let unsubscribeCalled = false;
+ const existingSub = {
+ unsubscribe: async () => { unsubscribeCalled = true; },
+ };
+ let subscribeCalled = false;
+ const pushManager = {
+ getSubscription: async () => existingSub,
+ subscribe: async (opts) => {
+ subscribeCalled = true;
+ return { endpoint: 'https://push.example.com/sub/new', toJSON: () => ({}) };
+ },
+ };
+
+ await subscribeWithUnsubscribeStale(pushManager, new Uint8Array([4, 1, 2]));
+
+ assert.equal(unsubscribeCalled, true, 'existing subscription should have been unsubscribed');
+ assert.equal(subscribeCalled, true, 'new subscription should have been created');
+ });
+
+ it('subscribes normally when no existing subscription', async () => {
+ let subscribeCalled = false;
+ const pushManager = {
+ getSubscription: async () => null,
+ subscribe: async (opts) => {
+ subscribeCalled = true;
+ return { endpoint: 'https://push.example.com/sub/new', toJSON: () => ({}) };
+ },
+ };
+
+ const sub = await subscribeWithUnsubscribeStale(pushManager, new Uint8Array([4, 1, 2]));
+
+ assert.equal(subscribeCalled, true, 'subscribe should have been called');
+ assert.ok(sub, 'subscription object should be returned');
+ });
+});
diff --git a/web/test/stories.test.mjs b/web/test/stories.test.mjs
new file mode 100644
index 0000000..263f202
--- /dev/null
+++ b/web/test/stories.test.mjs
@@ -0,0 +1,164 @@
+// stories.test.mjs — TDD tests for stories UI functions.
+//
+// Run with: node --test web/test/stories.test.mjs
+
+import { describe, it, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+import { renderStoryCard, storyStatusLabel } from '../app.js';
+
+// ── Minimal DOM mock ──────────────────────────────────────────────────────────
+
+function makeMockDoc() {
+ function createElement(tag) {
+ return {
+ tag,
+ className: '',
+ textContent: '',
+ innerHTML: '',
+ hidden: false,
+ dataset: {},
+ children: [],
+ _listeners: {},
+ style: {},
+ appendChild(child) { this.children.push(child); return child; },
+ prepend(...nodes) { this.children.unshift(...nodes); },
+ append(...nodes) { nodes.forEach(n => this.children.push(n)); },
+ addEventListener(ev, fn) { this._listeners[ev] = fn; },
+ querySelector(sel) {
+ const cls = sel.startsWith('.') ? sel.slice(1) : null;
+ function search(el) {
+ if (cls && el.className && el.className.split(' ').includes(cls)) return el;
+ for (const c of el.children || []) {
+ const found = search(c);
+ if (found) return found;
+ }
+ return null;
+ }
+ return search(this);
+ },
+ querySelectorAll(sel) {
+ const cls = sel.startsWith('.') ? sel.slice(1) : null;
+ const results = [];
+ function search(el) {
+ if (cls && el.className && el.className.split(' ').includes(cls)) results.push(el);
+ for (const c of el.children || []) search(c);
+ }
+ search(this);
+ return results;
+ },
+ };
+ }
+ return { createElement };
+}
+
+function makeStory(overrides = {}) {
+ return {
+ id: 'story-1',
+ name: 'Add login page',
+ project_id: 'claudomator',
+ branch_name: 'story/add-login-page',
+ status: 'PENDING',
+ created_at: '2026-03-25T10:00:00Z',
+ updated_at: '2026-03-25T10:00:00Z',
+ ...overrides,
+ };
+}
+
+// ── storyStatusLabel ──────────────────────────────────────────────────────────
+
+describe('storyStatusLabel', () => {
+ it('returns human-readable label for PENDING', () => {
+ assert.equal(storyStatusLabel('PENDING'), 'Pending');
+ });
+
+ it('returns human-readable label for IN_PROGRESS', () => {
+ assert.equal(storyStatusLabel('IN_PROGRESS'), 'In Progress');
+ });
+
+ it('returns human-readable label for SHIPPABLE', () => {
+ assert.equal(storyStatusLabel('SHIPPABLE'), 'Shippable');
+ });
+
+ it('returns human-readable label for DEPLOYED', () => {
+ assert.equal(storyStatusLabel('DEPLOYED'), 'Deployed');
+ });
+
+ it('returns human-readable label for VALIDATING', () => {
+ assert.equal(storyStatusLabel('VALIDATING'), 'Validating');
+ });
+
+ it('returns human-readable label for REVIEW_READY', () => {
+ assert.equal(storyStatusLabel('REVIEW_READY'), 'Review Ready');
+ });
+
+ it('returns human-readable label for NEEDS_FIX', () => {
+ assert.equal(storyStatusLabel('NEEDS_FIX'), 'Needs Fix');
+ });
+
+ it('falls back to the raw status for unknown values', () => {
+ assert.equal(storyStatusLabel('UNKNOWN_STATE'), 'UNKNOWN_STATE');
+ });
+});
+
+// ── renderStoryCard ───────────────────────────────────────────────────────────
+
+describe('renderStoryCard', () => {
+ let doc;
+ beforeEach(() => { doc = makeMockDoc(); });
+
+ it('renders the story name', () => {
+ const card = renderStoryCard(makeStory(), doc);
+ function findText(el, text) {
+ if (el.textContent === text) return true;
+ return (el.children || []).some(c => findText(c, text));
+ }
+ assert.ok(findText(card, 'Add login page'), 'card should contain story name');
+ });
+
+ it('has story-card class', () => {
+ const card = renderStoryCard(makeStory(), doc);
+ assert.ok(card.className.split(' ').includes('story-card'), 'root element should have story-card class');
+ });
+
+ it('status badge has data-status matching story status', () => {
+ const card = renderStoryCard(makeStory({ status: 'IN_PROGRESS' }), doc);
+ const badge = card.querySelector('.story-status-badge');
+ assert.ok(badge, 'badge element should exist');
+ assert.equal(badge.dataset.status, 'IN_PROGRESS');
+ });
+
+ it('status badge shows human-readable label', () => {
+ const card = renderStoryCard(makeStory({ status: 'REVIEW_READY' }), doc);
+ const badge = card.querySelector('.story-status-badge');
+ assert.equal(badge.textContent, 'Review Ready');
+ });
+
+ it('shows project_id', () => {
+ const card = renderStoryCard(makeStory({ project_id: 'nav' }), doc);
+ function findText(el, text) {
+ if (el.textContent === text) return true;
+ return (el.children || []).some(c => findText(c, text));
+ }
+ assert.ok(findText(card, 'nav'), 'card should show project_id');
+ });
+
+ it('shows branch_name when present', () => {
+ const card = renderStoryCard(makeStory({ branch_name: 'story/my-feature' }), doc);
+ function findText(el, text) {
+ if (el.textContent && el.textContent.includes(text)) return true;
+ return (el.children || []).some(c => findText(c, text));
+ }
+ assert.ok(findText(card, 'story/my-feature'), 'card should show branch_name');
+ });
+
+ it('does not show branch section when branch_name is empty', () => {
+ const card = renderStoryCard(makeStory({ branch_name: '' }), doc);
+ const branchEl = card.querySelector('.story-branch');
+ assert.ok(!branchEl, 'no .story-branch element when branch is empty');
+ });
+
+ it('card dataset.storyId is set to story id', () => {
+ const card = renderStoryCard(makeStory({ id: 'abc-123' }), doc);
+ assert.equal(card.dataset.storyId, 'abc-123');
+ });
+});
diff --git a/web/test/tab-persistence.test.mjs b/web/test/tab-persistence.test.mjs
new file mode 100644
index 0000000..9311453
--- /dev/null
+++ b/web/test/tab-persistence.test.mjs
@@ -0,0 +1,58 @@
+// tab-persistence.test.mjs — TDD tests for main-tab localStorage persistence
+//
+// Run with: node --test web/test/tab-persistence.test.mjs
+
+import { describe, it, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+
+// ── localStorage mock ──────────────────────────────────────────────────────────
+// Must be set up before importing app.js so the module sees the global.
+const store = new Map();
+globalThis.localStorage = {
+ getItem: (k) => store.has(k) ? store.get(k) : null,
+ setItem: (k, v) => store.set(k, String(v)),
+ removeItem: (k) => store.delete(k),
+ clear: () => store.clear(),
+};
+
+import { getActiveMainTab, setActiveMainTab } from '../app.js';
+
+describe('getActiveMainTab', () => {
+ beforeEach(() => store.clear());
+
+ it('returns "queue" when localStorage has no stored value', () => {
+ assert.equal(getActiveMainTab(), 'queue');
+ });
+
+ it('returns the tab name stored by setActiveMainTab', () => {
+ setActiveMainTab('settings');
+ assert.equal(getActiveMainTab(), 'settings');
+ });
+
+ it('returns "queue" after localStorage value is removed', () => {
+ setActiveMainTab('stats');
+ localStorage.removeItem('activeMainTab');
+ assert.equal(getActiveMainTab(), 'queue');
+ });
+
+ it('reflects the most recent setActiveMainTab call', () => {
+ setActiveMainTab('stats');
+ setActiveMainTab('running');
+ assert.equal(getActiveMainTab(), 'running');
+ });
+});
+
+describe('setActiveMainTab', () => {
+ beforeEach(() => store.clear());
+
+ it('writes the tab name to localStorage under key "activeMainTab"', () => {
+ setActiveMainTab('drops');
+ assert.equal(localStorage.getItem('activeMainTab'), 'drops');
+ });
+
+ it('overwrites a previously stored tab', () => {
+ setActiveMainTab('queue');
+ setActiveMainTab('interrupted');
+ assert.equal(localStorage.getItem('activeMainTab'), 'interrupted');
+ });
+});
diff --git a/web/test/task-panel-summary.test.mjs b/web/test/task-panel-summary.test.mjs
new file mode 100644
index 0000000..1777003
--- /dev/null
+++ b/web/test/task-panel-summary.test.mjs
@@ -0,0 +1,144 @@
+// task-panel-summary.test.mjs — verifies task summary renders exactly once in panel.
+//
+// Run with: node --test web/test/task-panel-summary.test.mjs
+
+import { describe, it, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+
+// ── Minimal DOM mock ──────────────────────────────────────────────────────────
+
+function makeMockDOM() {
+ const elements = {};
+
+ function createElement(tag) {
+ const el = {
+ tag,
+ className: '',
+ textContent: '',
+ innerHTML: '',
+ hidden: false,
+ children: [],
+ dataset: {},
+ _listeners: {},
+ appendChild(child) { this.children.push(child); return child; },
+ prepend(...nodes) { this.children.unshift(...nodes); },
+ append(...nodes) { nodes.forEach(n => this.children.push(n)); },
+ querySelector(sel) {
+ const cls = sel.startsWith('.') ? sel.slice(1) : null;
+ function search(el) {
+ if (cls && el.className && el.className.split(' ').includes(cls)) return el;
+ for (const c of el.children || []) {
+ const found = search(c);
+ if (found) return found;
+ }
+ return null;
+ }
+ return search(this);
+ },
+ querySelectorAll(sel) {
+ const cls = sel.startsWith('.') ? sel.slice(1) : null;
+ const results = [];
+ function search(el) {
+ if (cls && el.className && el.className.split(' ').includes(cls)) results.push(el);
+ for (const c of el.children || []) search(c);
+ }
+ search(this);
+ return results;
+ },
+ addEventListener(ev, fn) {},
+ };
+ return el;
+ }
+
+ // Named panel elements referenced by getElementById
+ const panelTitle = createElement('h2');
+ const panelContent = createElement('div');
+ elements['task-panel-title'] = panelTitle;
+ elements['task-panel-content'] = panelContent;
+
+ const doc = {
+ createElement,
+ getElementById(id) { return elements[id] || null; },
+ };
+
+ return { doc, panelContent };
+}
+
+// ── Import renderTaskPanel ────────────────────────────────────────────────────
+
+import { renderTaskPanel } from '../app.js';
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('renderTaskPanel summary rendering', () => {
+ it('renders task summary exactly once for a COMPLETED task', () => {
+ const { doc, panelContent } = makeMockDOM();
+
+ // Must set global document before calling renderTaskPanel
+ global.document = doc;
+
+ const task = {
+ id: 'task-1',
+ name: 'Fix the bug',
+ state: 'COMPLETED',
+ summary: 'Resolved the nil pointer in the payment handler.',
+ priority: 'normal',
+ created_at: '2026-03-17T10:00:00Z',
+ updated_at: '2026-03-17T10:05:00Z',
+ tags: [],
+ };
+
+ renderTaskPanel(task, []);
+
+ // Count all elements with class 'task-summary' or 'task-summary-text'
+ const summaryEls = panelContent.querySelectorAll('.task-summary');
+ const summaryTextEls = panelContent.querySelectorAll('.task-summary-text');
+
+ const total = summaryEls.length + summaryTextEls.length;
+ assert.equal(total, 1, `Expected exactly 1 summary element, got ${total} (task-summary: ${summaryEls.length}, task-summary-text: ${summaryTextEls.length})`);
+ });
+
+ it('uses task-summary class (not task-summary-text) for good contrast', () => {
+ const { doc, panelContent } = makeMockDOM();
+ global.document = doc;
+
+ const task = {
+ id: 'task-2',
+ name: 'Another task',
+ state: 'COMPLETED',
+ summary: 'All done.',
+ priority: 'high',
+ created_at: '2026-03-17T10:00:00Z',
+ updated_at: '2026-03-17T10:05:00Z',
+ tags: [],
+ };
+
+ renderTaskPanel(task, []);
+
+ const summaryEls = panelContent.querySelectorAll('.task-summary');
+ assert.equal(summaryEls.length, 1, 'Expected .task-summary element');
+ assert.equal(summaryEls[0].textContent, task.summary);
+ });
+
+ it('renders no summary section when task has no summary', () => {
+ const { doc, panelContent } = makeMockDOM();
+ global.document = doc;
+
+ const task = {
+ id: 'task-3',
+ name: 'Pending task',
+ state: 'PENDING',
+ summary: null,
+ priority: 'normal',
+ created_at: '2026-03-17T10:00:00Z',
+ updated_at: '2026-03-17T10:00:00Z',
+ tags: [],
+ };
+
+ renderTaskPanel(task, []);
+
+ const summaryEls = panelContent.querySelectorAll('.task-summary');
+ const summaryTextEls = panelContent.querySelectorAll('.task-summary-text');
+ assert.equal(summaryEls.length + summaryTextEls.length, 0, 'Expected no summary when task.summary is null');
+ });
+});