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