summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/app.js320
-rw-r--r--web/index.html32
-rw-r--r--web/style.css135
-rw-r--r--web/test/stories.test.mjs164
4 files changed, 651 insertions, 0 deletions
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 = '<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() : '—');
+
+ 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 @@
<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="stories" title="Stories">📖</button>
<button class="tab" data-tab="stats" title="Stats">📊</button>
<button class="tab" data-tab="settings" title="Settings">⚙️</button>
</nav>
@@ -54,6 +55,7 @@
<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>
@@ -126,6 +128,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 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');
+ });
+});