From 36d50b5049996064fbdcb9338e70d4f81c2e6873 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 25 Mar 2026 23:28:22 +0000 Subject: feat: add Stories UI β€” tab, list, new story elaborate/approve flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stories tab (πŸ“–) with story card list, status badges, project/branch meta - New Story modal: project picker + goal textarea + AI elaboration β†’ plan review β†’ approve & queue - Story detail modal: status, project, branch, created date - Export storyStatusLabel() and renderStoryCard() with 16 unit tests - CSS for story cards, status badges, and modals Co-Authored-By: Claude Sonnet 4.6 --- web/app.js | 320 ++++++++++++++++++++++++++++++++++++++++++++++ web/index.html | 32 +++++ web/style.css | 135 +++++++++++++++++++ web/test/stories.test.mjs | 164 ++++++++++++++++++++++++ 4 files changed, 651 insertions(+) create mode 100644 web/test/stories.test.mjs (limited to 'web') diff --git a/web/app.js b/web/app.js index 2241c67..25bdee6 100644 --- a/web/app.js +++ b/web/app.js @@ -525,6 +525,63 @@ 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); + + return card; +} + export function updateFilterTabs() { const current = getTaskFilterTab(); document.querySelectorAll('.filter-tab[data-filter]').forEach(el => { @@ -1193,6 +1250,9 @@ function renderActiveTab(allTasks) { .then(([execs, agentData, dashStats]) => renderStatsPanel(allTasks, execs, agentData, dashStats)) .catch(() => {}); break; + case 'stories': + renderStoriesPanel(); + break; case 'drops': renderDropsPanel(); break; @@ -3019,6 +3079,180 @@ async function fetchDrops() { 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 = '

Failed to load stories.

'; + 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() : 'β€”'); + + 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; @@ -3263,5 +3497,91 @@ if (typeof document !== 'undefined') { 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 7d52458..53fbf3c 100644 --- a/web/index.html +++ b/web/index.html @@ -29,6 +29,7 @@ + @@ -54,6 +55,7 @@ + + + +
+

New Story

+ +
+
+ + + + +
+ +
+
+
+ + + +
+

Story

+ +
+
+
+ diff --git a/web/style.css b/web/style.css index 96a6602..2bba8dc 100644 --- a/web/style.css +++ b/web/style.css @@ -1784,3 +1784,138 @@ dialog label select:focus { 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; +} 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'); + }); +}); -- cgit v1.2.3