summaryrefslogtreecommitdiff
path: root/web/app.js
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-04 05:31:42 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-04 05:31:42 +0000
commitd105eca610b0e737c3313e4978d6a917b4f55d10 (patch)
tree2ad75a33abd45ebb8225dcbee01a2d0a8d7a21ed /web/app.js
parent8cb291ad7cdb317ff80947278ee055b1a4925b41 (diff)
feat(web): add Stories tab -- Kanban board, epic swimlanes, story-detail DAG (Phase 9a)
First UI work on top of the harness's 8 backend phases -- pure web/* frontend, no backend changes, keeping the existing embedded vanilla-JS convention (no build step, no framework). - Kanban board: columns spanning the full story lifecycle (DISCOVERY/FRAMING -> BACKLOG -> PRIORITIZED -> IN_PROGRESS -> VALIDATING/REVIEW_READY -> DONE), cards showing priority/acceptance-criteria count/eval-verdict progress. Drag-and-drop reorders priority within a column (persisted via a partial PUT /api/stories/{id}); cross-column drag is disabled outright (a dragover handler only preventDefault()s when the dragged card's own status already maps to that column) rather than allowed-then-snapped-back, since the latter would visibly misplace a card for several seconds until the next poll -- reads as a bug, not a feature. - Epic swimlanes: stories grouped client-side by epic_id (incl. an "Unassigned" lane) with a DONE/total progress meter per epic, built from data the board already fetches (avoids N+1 GET /api/epics/{id}/stories calls for a result that's a strict subset of the already-loaded list). - Story detail modal: metadata/spec/acceptance criteria, a BFS-layered plain- SVG DAG of the story's task-tree (rows = BFS depth from root_task_id over both parent_task_id and depends_on edges, solid vs. dashed strokes distinguishing the two edge kinds), an event timeline rendering eval_verdict/arbitration_decided/human_accepted/retro_captured/ epic_proposed with human-readable text, and an Accept button wired to POST /api/stories/{id}/accept when status is REVIEW_READY. - DAG node coloring reuses the app's existing --state-* CSS custom properties (the dataviz skill's "reserved status palette" pattern, already used elsewhere in the app) rather than introducing a new palette. Running the skill's own palette validator against that pre-existing 9-color set fails its CVD/lightness checks (a pre-existing condition, out of scope to fix here since those colors are used elsewhere in the app) -- mitigated per the skill's documented remedy for a failing palette: every node always carries a text label (name + role + state) and a full legend, so identity never depends on color alone; edge kind is encoded by stroke style, not color. Verified with a real running server and a real headless-browser session (Chromium via playwright-core, offline-cached), not just code review: seeded real data (an epic, 11 stories across every lifecycle status, a 6-node Builder->4-Evaluator->Arbitration task tree, 4 story events) via the live REST API plus two throwaway fixture scripts for the two write paths the API doesn't expose (task depends_on, direct event writes), then confirmed via screenshots/DOM assertions: correct column placement for every status, working swimlane grouping/progress meter, correct 6-node/8-edge DAG render, working event timeline, a real Accept-button API call flipping REVIEW_READY->DONE, real drag-and-drop priority persistence (re-fetched to confirm), and a genuine cross-column-drag no-op. Caught and fixed a real bug during this process: DAG node-label truncation now measures getComputedTextLength() and binary-searches the longest fit, instead of a fixed character-count budget that collapsed every "Eval: evaluator_X" label to "Eval:..." (only one space in the string). go build ./... clean; node --test web/test/*.mjs -- 275 tests pass (19 new). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'web/app.js')
-rw-r--r--web/app.js928
1 files changed, 927 insertions, 1 deletions
diff --git a/web/app.js b/web/app.js
index 0328579..6f45253 100644
--- a/web/app.js
+++ b/web/app.js
@@ -146,6 +146,30 @@ export function formatEventText(event) {
return 'Execution started';
case 'execution_ended':
return p.status ? `Execution ended (${p.status})` : 'Execution ended';
+ case 'eval_verdict':
+ return `Eval verdict (${p.role || '?'}): ${p.summary || ''}`;
+ case 'arbitration_decided':
+ return `Arbitration decided: ${p.summary || ''}`;
+ case 'human_accepted':
+ return `Accepted: ${p.from || '?'} → ${p.to || '?'}`;
+ case 'retro_captured': {
+ const n = (p.proposals || []).length;
+ return `Retro captured (${n} config proposal${n === 1 ? '' : 's'}): ${p.summary || ''}`;
+ }
+ case 'epic_proposed':
+ return `Epic proposed: ${p.name || ''} (${(p.story_ids || []).length} stories)`;
+ case 'discovery_proposed':
+ return 'Discovery proposed';
+ case 'framing_decided':
+ return 'Framing decided';
+ case 'groomed':
+ return 'Groomed';
+ case 'prioritized':
+ return 'Prioritized';
+ case 'escalated':
+ return `Escalated: rung ${p.from_rung ?? '?'} → ${p.to_rung ?? '?'}${p.final ? ' (final)' : ''}`;
+ case 'role_config_proposed':
+ return `Role config proposed: ${p.role || '?'} v${p.version ?? '?'}`;
default:
return event.kind || '';
}
@@ -1333,6 +1357,12 @@ function renderActiveTab(allTasks) {
.then(([execs, agentData, dashStats]) => renderStatsPanel(allTasks, execs, agentData, dashStats))
.catch(() => {});
break;
+ case 'stories':
+ // Guard against yanking the board out from under an in-progress
+ // HTML5 drag (see draggingStoryId) — the next poll tick after the
+ // drag ends will pick up any server-side change.
+ if (!draggingStoryId) renderStoriesPanel();
+ break;
case 'drops':
renderDropsPanel();
break;
@@ -3133,7 +3163,7 @@ async function fetchDrops() {
return res.json();
}
-// ── Stories panel ─────────────────────────────────────────────────────────────
+// ── Drops panel ───────────────────────────────────────────────────────────────
async function renderDropsPanel() {
const panel = document.querySelector('[data-panel="drops"] .drops-panel');
@@ -3180,6 +3210,890 @@ async function renderDropsPanel() {
}
}
+// ── Stories tab: data fetch ──────────────────────────────────────────────────
+// Phase 9a: consumes the existing, unchanged epics/stories REST surface
+// (internal/api/stories.go, internal/api/epics.go) — no backend changes.
+
+async function fetchStories(fetchImpl = fetch) {
+ const res = await fetchImpl(`${API_BASE}/api/stories`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
+async function fetchEpics(fetchImpl = fetch) {
+ const res = await fetchImpl(`${API_BASE}/api/epics`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
+async function fetchEpic(epicId, fetchImpl = fetch) {
+ const res = await fetchImpl(`${API_BASE}/api/epics/${epicId}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
+async function fetchStory(id, fetchImpl = fetch) {
+ const res = await fetchImpl(`${API_BASE}/api/stories/${id}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
+// fetchStoryEvents loads a story's event stream. Mirrors fetchTaskEvents:
+// returns [] on any error so the board/modal degrade gracefully rather than
+// throwing.
+export async function fetchStoryEvents(storyId, fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) {
+ if (!fetchImpl) return [];
+ try {
+ const resp = await fetchImpl(`${API_BASE}/api/stories/${storyId}/events`);
+ if (!resp.ok) return [];
+ const data = await resp.json();
+ return Array.isArray(data) ? data : [];
+ } catch {
+ return [];
+ }
+}
+
+async function fetchStoryTaskTree(storyId, fetchImpl = fetch) {
+ const res = await fetchImpl(`${API_BASE}/api/stories/${storyId}/task-tree`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
+// updateStory issues a partial PUT — handleUpdateStory (internal/api/stories.go)
+// decodes the request body onto the already-fetched existing row, so fields
+// omitted from `body` are left untouched. Used here to write only `priority`
+// on drag-and-drop reorder without clobbering the rest of the story.
+async function updateStory(id, body) {
+ const res = await fetch(`${API_BASE}/api/stories/${id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ let msg = `HTTP ${res.status}`;
+ try { const b = await res.json(); msg = b.error || msg; } catch {}
+ throw new Error(msg);
+ }
+ return res.json();
+}
+
+async function acceptStory(id) {
+ const res = await fetch(`${API_BASE}/api/stories/${id}/accept`, { method: 'POST' });
+ if (!res.ok) {
+ let msg = `HTTP ${res.status}`;
+ try { const b = await res.json(); msg = b.error || msg; } catch {}
+ throw new Error(msg);
+ }
+ return res.json();
+}
+
+// ── Stories tab: board/column model (pure — unit-tested in web/test) ───────
+//
+// Columns collapse the full story.Status lifecycle (DISCOVERY|FRAMING|
+// BACKLOG|PRIORITIZED|IN_PROGRESS|SHIPPABLE|DEPLOYED|VALIDATING|
+// REVIEW_READY|NEEDS_FIX|DONE|CANCELLED — internal/story/story.go) into 7
+// columns. SHIPPABLE/DEPLOYED fold into "In Progress" (deploy-gating isn't
+// built yet, so there's nothing actionable to show separately for them);
+// VALIDATING/REVIEW_READY fold into one "Validating" column; NEEDS_FIX/
+// CANCELLED get their own small side column rather than a hidden filter
+// toggle, per the phase's "your judgment" call, so a story that needs
+// attention is never one click away from invisible.
+export const STORY_COLUMNS = [
+ { key: 'discovery', label: 'Discovery', statuses: ['DISCOVERY', 'FRAMING'] },
+ { key: 'backlog', label: 'Backlog', statuses: ['BACKLOG'] },
+ { key: 'prioritized', label: 'Prioritized', statuses: ['PRIORITIZED'] },
+ { key: 'in_progress', label: 'In Progress', statuses: ['IN_PROGRESS', 'SHIPPABLE', 'DEPLOYED'] },
+ { key: 'validating', label: 'Validating', statuses: ['VALIDATING', 'REVIEW_READY'] },
+ { key: 'done', label: 'Done', statuses: ['DONE'] },
+ { key: 'issues', label: 'Needs Fix / Cancelled', statuses: ['NEEDS_FIX', 'CANCELLED'] },
+];
+
+// columnForStatus returns the column key for a given story status, falling
+// back to 'backlog' for an empty/unrecognized status so a story is never
+// dropped off the board entirely.
+export function columnForStatus(status) {
+ const col = STORY_COLUMNS.find(c => c.statuses.includes(status));
+ return col ? col.key : 'backlog';
+}
+
+function priorityRank(p) {
+ // Number('') === 0 and Number(null) === 0, so both would otherwise sort
+ // as the highest priority — treat "no priority set" as missing, not zero.
+ if (p === null || p === undefined || p === '') return Number.POSITIVE_INFINITY;
+ const n = Number(p);
+ return Number.isFinite(n) ? n : Number.POSITIVE_INFINITY;
+}
+
+// storyPriorityComparator sorts ascending by numeric priority (this phase's
+// convention: drag-and-drop reorder writes the story's rank as a stringified
+// integer — story.Priority is a freeform string with no enum, so there's no
+// existing convention to collide with). Non-numeric/missing priority sorts
+// last; ties break by created_at ascending (oldest first).
+export function storyPriorityComparator(a, b) {
+ const ra = priorityRank(a.priority);
+ const rb = priorityRank(b.priority);
+ if (ra !== rb) return ra - rb;
+ return new Date(a.created_at || 0) - new Date(b.created_at || 0);
+}
+
+// groupStoriesByColumn returns { [columnKey]: story[] }, each sub-array
+// sorted by storyPriorityComparator.
+export function groupStoriesByColumn(stories) {
+ const groups = {};
+ for (const col of STORY_COLUMNS) groups[col.key] = [];
+ for (const s of stories || []) {
+ const key = columnForStatus(s.status);
+ if (!groups[key]) groups[key] = [];
+ groups[key].push(s);
+ }
+ for (const key of Object.keys(groups)) {
+ groups[key].sort(storyPriorityComparator);
+ }
+ return groups;
+}
+
+// A story "has reached validation" once evaluator verdicts could plausibly
+// exist for it — VALIDATING or later in the lifecycle (see StoryOrchestrator
+// stage 2 in CLAUDE.md). Used to gate the eval-verdict indicator so we don't
+// fetch/show "Evals: 0/4" for a story that's still in Backlog.
+export function storyHasReachedValidation(status) {
+ return ['VALIDATING', 'REVIEW_READY', 'NEEDS_FIX', 'DONE', 'CANCELLED'].includes(status);
+}
+
+// countEvalVerdicts counts event.KindEvalVerdict entries in a story's event
+// stream — up to 4 expected (evaluator_quality/security/correctness/performance).
+export function countEvalVerdicts(events) {
+ return (events || []).filter(e => e && e.kind === 'eval_verdict').length;
+}
+
+// ── Stories tab: rendering state ─────────────────────────────────────────────
+
+// draggingStoryId is set for the duration of an HTML5 drag so column
+// dragover handlers can check whether the dragged story's own status maps
+// to *this* column (reorder-in-place only — see renderStoriesBoard) and so
+// poll() can avoid re-rendering the board out from under an active drag.
+let draggingStoryId = null;
+// storiesById is refreshed on every renderStoriesPanel() call; column
+// dragover/drop handlers and persistColumnOrder consult it instead of
+// re-fetching per drag event.
+let storiesById = new Map();
+
+function getStoriesViewMode() {
+ return localStorage.getItem('storiesViewMode') || 'board';
+}
+function setStoriesViewMode(mode) {
+ localStorage.setItem('storiesViewMode', mode);
+}
+
+// ── Stories tab: card ─────────────────────────────────────────────────────────
+
+function createStoryCard(story, { draggable = true } = {}) {
+ const card = document.createElement('div');
+ card.className = 'story-card';
+ card.dataset.storyId = story.id;
+ card.draggable = draggable;
+
+ const header = document.createElement('div');
+ header.className = 'story-card-header';
+ const name = document.createElement('span');
+ name.className = 'story-name';
+ name.textContent = story.name;
+ const badge = document.createElement('span');
+ badge.className = 'story-status-badge';
+ badge.dataset.status = story.status;
+ badge.textContent = (story.status || '').replace(/_/g, ' ');
+ header.append(name, badge);
+ card.appendChild(header);
+
+ const meta = document.createElement('div');
+ meta.className = 'story-meta';
+ if (story.priority) {
+ const p = document.createElement('span');
+ p.textContent = `priority: ${story.priority}`;
+ meta.appendChild(p);
+ }
+ const ac = document.createElement('span');
+ ac.textContent = `AC: ${(story.acceptance_criteria || []).length}`;
+ meta.appendChild(ac);
+ card.appendChild(meta);
+
+ if (storyHasReachedValidation(story.status)) {
+ const evalEl = document.createElement('div');
+ evalEl.className = 'story-eval-indicator';
+ evalEl.textContent = 'Evals: …';
+ card.appendChild(evalEl);
+ fetchStoryEvents(story.id).then(events => {
+ evalEl.textContent = `Evals: ${countEvalVerdicts(events)}/4`;
+ });
+ }
+
+ card.addEventListener('click', () => openStoryModal(story.id));
+
+ if (draggable) {
+ card.addEventListener('dragstart', (e) => {
+ draggingStoryId = story.id;
+ card.classList.add('dragging');
+ e.dataTransfer.setData('text/plain', story.id);
+ e.dataTransfer.effectAllowed = 'move';
+ });
+ card.addEventListener('dragend', () => {
+ draggingStoryId = null;
+ card.classList.remove('dragging');
+ document.querySelectorAll('.stories-column.drag-over').forEach(c => c.classList.remove('drag-over'));
+ });
+ }
+
+ return card;
+}
+
+// getDragAfterElement finds the card the dragged element should be inserted
+// before, based on vertical cursor position — standard vanilla-JS
+// drag-reorder technique (no library).
+function getDragAfterElement(container, y) {
+ const els = [...container.querySelectorAll('.story-card:not(.dragging)')];
+ return els.reduce((closest, child) => {
+ const box = child.getBoundingClientRect();
+ const offset = y - box.top - box.height / 2;
+ if (offset < 0 && offset > closest.offset) {
+ return { offset, element: child };
+ }
+ return closest;
+ }, { offset: Number.NEGATIVE_INFINITY, element: null }).element;
+}
+
+// persistColumnOrder re-ranks every card currently in `list` (in DOM order)
+// as priority "0", "10", "20", … and PUTs any that actually changed.
+// Ranks are only unique within a column, never globally — sorting only ever
+// happens within one column's group (groupStoriesByColumn), so cross-column
+// rank collisions are harmless.
+async function persistColumnOrder(list) {
+ const ids = [...list.querySelectorAll('.story-card')].map(c => c.dataset.storyId);
+ await Promise.all(ids.map((id, idx) => {
+ const story = storiesById.get(id);
+ const newPriority = String(idx * 10);
+ if (!story || story.priority === newPriority) return Promise.resolve();
+ return updateStory(id, { priority: newPriority })
+ .then(() => { story.priority = newPriority; })
+ .catch(err => console.error('Failed to persist story priority reorder:', err));
+ }));
+}
+
+// ── Stories tab: Kanban board ─────────────────────────────────────────────────
+//
+// Drag-and-drop decision (documented per the phase spec's either/or): dragging
+// a card to a *different* column is disabled outright rather than allowed-but-
+// a-no-op. A column's dragover handler only ever calls preventDefault() when
+// the dragged story's own status already maps to that column — so a foreign
+// column never shows a reorder preview and the browser's native "not allowed"
+// drop-cursor kicks in. The alternative (accept the drop, snap back on next
+// poll) would let a card visibly move to the wrong column for several seconds,
+// which reads as a bug rather than a deliberate constraint.
+function renderStoriesBoard(stories, container) {
+ const groups = groupStoriesByColumn(stories);
+ container.innerHTML = '';
+ const board = document.createElement('div');
+ board.className = 'stories-board';
+
+ for (const col of STORY_COLUMNS) {
+ const colEl = document.createElement('div');
+ colEl.className = 'stories-column';
+ colEl.dataset.columnKey = col.key;
+
+ const header = document.createElement('div');
+ header.className = 'stories-column-header';
+ const title = document.createElement('span');
+ title.textContent = col.label;
+ const count = document.createElement('span');
+ count.className = 'stories-column-count';
+ count.textContent = String(groups[col.key].length);
+ header.append(title, count);
+ colEl.appendChild(header);
+
+ const list = document.createElement('div');
+ list.className = 'stories-column-list';
+ for (const story of groups[col.key]) {
+ list.appendChild(createStoryCard(story));
+ }
+ colEl.appendChild(list);
+
+ list.addEventListener('dragover', (e) => {
+ const dragged = storiesById.get(draggingStoryId);
+ if (!dragged || columnForStatus(dragged.status) !== col.key) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ colEl.classList.add('drag-over');
+ const draggingEl = list.querySelector('.dragging');
+ if (!draggingEl) return;
+ const after = getDragAfterElement(list, e.clientY);
+ if (after == null) list.appendChild(draggingEl);
+ else list.insertBefore(draggingEl, after);
+ });
+ list.addEventListener('dragleave', (e) => {
+ if (!colEl.contains(e.relatedTarget)) colEl.classList.remove('drag-over');
+ });
+ list.addEventListener('drop', (e) => {
+ e.preventDefault();
+ colEl.classList.remove('drag-over');
+ persistColumnOrder(list);
+ });
+
+ board.appendChild(colEl);
+ }
+
+ container.appendChild(board);
+}
+
+// ── Stories tab: epic swimlanes ───────────────────────────────────────────────
+
+function renderSwimlane(lane) {
+ const laneEl = document.createElement('div');
+ laneEl.className = 'epic-swimlane';
+
+ const header = document.createElement('div');
+ header.className = 'epic-swimlane-header';
+
+ const name = document.createElement('span');
+ name.className = 'epic-swimlane-name';
+ name.textContent = lane.name;
+ header.appendChild(name);
+
+ const total = lane.stories.length;
+ const done = lane.stories.filter(s => s.status === 'DONE').length;
+
+ const progressWrap = document.createElement('div');
+ progressWrap.className = 'epic-progress';
+ const track = document.createElement('div');
+ track.className = 'epic-progress-track';
+ const fill = document.createElement('div');
+ fill.className = 'epic-progress-fill';
+ fill.style.width = total > 0 ? `${Math.round((done / total) * 100)}%` : '0%';
+ track.appendChild(fill);
+ const label = document.createElement('span');
+ label.className = 'epic-progress-label';
+ label.textContent = `${done}/${total} done`;
+ progressWrap.append(track, label);
+ header.appendChild(progressWrap);
+
+ laneEl.appendChild(header);
+
+ const row = document.createElement('div');
+ row.className = 'epic-swimlane-stories';
+ if (lane.stories.length === 0) {
+ const empty = document.createElement('span');
+ empty.className = 'task-meta';
+ empty.textContent = 'No stories.';
+ row.appendChild(empty);
+ } else {
+ for (const s of lane.stories.slice().sort(storyPriorityComparator)) {
+ row.appendChild(createStoryCard(s, { draggable: false }));
+ }
+ }
+ laneEl.appendChild(row);
+
+ return laneEl;
+}
+
+async function renderEpicSwimlanes(stories, container) {
+ container.innerHTML = '<p class="task-meta">Loading epics…</p>';
+ let epics;
+ try {
+ epics = await fetchEpics();
+ } catch (err) {
+ container.innerHTML = `<p class="task-meta" style="padding:1rem">Failed to load epics: ${err.message}</p>`;
+ return;
+ }
+
+ const byEpic = new Map();
+ const unassigned = [];
+ for (const s of stories) {
+ if (s.epic_id) {
+ if (!byEpic.has(s.epic_id)) byEpic.set(s.epic_id, []);
+ byEpic.get(s.epic_id).push(s);
+ } else {
+ unassigned.push(s);
+ }
+ }
+
+ const lanes = epics.map(e => ({ id: e.id, name: e.name, stories: byEpic.get(e.id) || [] }));
+ if (unassigned.length > 0 || lanes.length === 0) {
+ lanes.push({ id: '', name: 'Unassigned', stories: unassigned });
+ }
+
+ container.innerHTML = '';
+ const wrap = document.createElement('div');
+ wrap.className = 'epic-swimlanes';
+ if (lanes.length === 0) {
+ wrap.innerHTML = '<p class="task-meta">No epics or stories yet.</p>';
+ } else {
+ for (const lane of lanes) wrap.appendChild(renderSwimlane(lane));
+ }
+ container.appendChild(wrap);
+}
+
+// ── Stories tab: panel entry point ───────────────────────────────────────────
+
+async function renderStoriesPanel() {
+ const panel = document.querySelector('[data-panel="stories"]');
+ if (!panel) return;
+ const container = panel.querySelector('.stories-view-container');
+ if (!container) return;
+
+ const mode = getStoriesViewMode();
+ panel.querySelectorAll('.view-toggle-btn').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.view === mode);
+ });
+
+ let stories;
+ try {
+ stories = await fetchStories();
+ } catch (err) {
+ container.innerHTML = `<p class="task-meta" style="padding:1rem">Failed to load stories: ${err.message}</p>`;
+ return;
+ }
+
+ storiesById = new Map(stories.map(s => [s.id, s]));
+
+ if (mode === 'epics') {
+ await renderEpicSwimlanes(stories, container);
+ } else {
+ renderStoriesBoard(stories, container);
+ }
+}
+
+// ── Story task-tree DAG ──────────────────────────────────────────────────────
+// Plain SVG + vanilla JS, no graph-layout library: a simple layered
+// top-to-bottom layout, rows assigned by BFS depth from root_task_id
+// following both "spawned" (parent_task_id) and "depends on" (depends_on)
+// edges — the same two edge types GET /api/stories/{id}/task-tree itself
+// walks server-side (ListSubtasks/ListDependents) to build the flat node
+// list this renders.
+
+// computeTaskTreeDepths returns Map<nodeId, depth>. Defensive fallbacks:
+// an unresolvable root, or a node genuinely unreachable from it (shouldn't
+// happen given how the API built the list, but a cycle or a stale
+// parent_task_id could in principle produce one), still get a depth so
+// nothing silently vanishes from the layout.
+export function computeTaskTreeDepths(nodes, rootId) {
+ const byId = new Map(nodes.map(n => [n.id, n]));
+ const childrenOf = new Map();
+ const addEdge = (fromId, toId) => {
+ if (!childrenOf.has(fromId)) childrenOf.set(fromId, []);
+ childrenOf.get(fromId).push(toId);
+ };
+ for (const n of nodes) {
+ if (n.parent_task_id && byId.has(n.parent_task_id)) addEdge(n.parent_task_id, n.id);
+ for (const dep of n.depends_on || []) {
+ if (byId.has(dep)) addEdge(dep, n.id);
+ }
+ }
+
+ const depths = new Map();
+ if (!byId.has(rootId)) {
+ for (const n of nodes) depths.set(n.id, 0);
+ return depths;
+ }
+
+ depths.set(rootId, 0);
+ const queue = [rootId];
+ while (queue.length > 0) {
+ const id = queue.shift();
+ const depth = depths.get(id);
+ for (const childId of childrenOf.get(id) || []) {
+ if (!depths.has(childId)) {
+ depths.set(childId, depth + 1);
+ queue.push(childId);
+ }
+ }
+ }
+ let maxDepth = 0;
+ for (const d of depths.values()) maxDepth = Math.max(maxDepth, d);
+ for (const n of nodes) {
+ if (!depths.has(n.id)) depths.set(n.id, maxDepth + 1);
+ }
+ return depths;
+}
+
+const DAG_NODE_WIDTH = 160;
+const DAG_NODE_HEIGHT = 44;
+const DAG_COL_GAP = 24;
+const DAG_ROW_GAP = 48;
+
+// layoutTaskTree assigns each node an {x, y} top-left position: rows by BFS
+// depth (top-to-bottom), columns by original node order within a row.
+export function layoutTaskTree(nodes, rootId) {
+ const depths = computeTaskTreeDepths(nodes, rootId);
+ const byDepth = new Map();
+ for (const n of nodes) {
+ const d = depths.get(n.id) || 0;
+ if (!byDepth.has(d)) byDepth.set(d, []);
+ byDepth.get(d).push(n);
+ }
+ const depthKeys = [...byDepth.keys()];
+ const maxDepth = depthKeys.length > 0 ? Math.max(...depthKeys) : 0;
+
+ const positions = new Map();
+ for (let d = 0; d <= maxDepth; d++) {
+ const row = byDepth.get(d) || [];
+ row.forEach((n, i) => {
+ positions.set(n.id, {
+ x: i * (DAG_NODE_WIDTH + DAG_COL_GAP),
+ y: d * (DAG_NODE_HEIGHT + DAG_ROW_GAP),
+ });
+ });
+ }
+ const rowLengths = [...byDepth.values()].map(r => r.length);
+ const maxCols = rowLengths.length > 0 ? Math.max(...rowLengths) : 1;
+ return {
+ positions,
+ width: Math.max(DAG_NODE_WIDTH, maxCols * (DAG_NODE_WIDTH + DAG_COL_GAP) - DAG_COL_GAP),
+ height: (maxDepth + 1) * (DAG_NODE_HEIGHT + DAG_ROW_GAP) - DAG_ROW_GAP,
+ };
+}
+
+const SVG_NS = 'http://www.w3.org/2000/svg';
+
+const DAG_LEGEND_STATES = [
+ 'PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED',
+ 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED',
+];
+
+function renderDagLegend() {
+ const wrap = document.createElement('div');
+
+ const stateLegend = document.createElement('div');
+ stateLegend.className = 'dag-legend';
+ for (const state of DAG_LEGEND_STATES) {
+ const item = document.createElement('span');
+ item.className = 'dag-legend-item';
+ const swatch = document.createElement('span');
+ swatch.className = 'dag-legend-swatch';
+ swatch.dataset.state = state;
+ const label = document.createElement('span');
+ label.textContent = state.replace(/_/g, ' ');
+ item.append(swatch, label);
+ stateLegend.appendChild(item);
+ }
+ wrap.appendChild(stateLegend);
+
+ const edgeLegend = document.createElement('div');
+ edgeLegend.className = 'dag-edge-legend';
+ const parentItem = document.createElement('span');
+ const parentSwatch = document.createElement('span');
+ parentSwatch.className = 'dag-edge-legend-swatch';
+ parentItem.append(parentSwatch, document.createTextNode('spawned (parent → child)'));
+ const dependsItem = document.createElement('span');
+ const dependsSwatch = document.createElement('span');
+ dependsSwatch.className = 'dag-edge-legend-swatch dag-edge-legend-swatch--depends';
+ dependsItem.append(dependsSwatch, document.createTextNode('depends on'));
+ edgeLegend.append(parentItem, dependsItem);
+ wrap.appendChild(edgeLegend);
+
+ return wrap;
+}
+
+function drawDagEdge(g, from, to, kind) {
+ const line = document.createElementNS(SVG_NS, 'line');
+ line.setAttribute('x1', from.x);
+ line.setAttribute('y1', from.y);
+ line.setAttribute('x2', to.x);
+ line.setAttribute('y2', to.y);
+ line.setAttribute('class', `dag-edge dag-edge--${kind}`);
+ line.setAttribute('marker-end', 'url(#dag-arrow)');
+ g.appendChild(line);
+}
+
+function renderDagNode(node, pos, pad) {
+ const g = document.createElementNS(SVG_NS, 'g');
+ g.setAttribute('class', 'dag-node');
+ g.setAttribute('transform', `translate(${pos.x + pad}, ${pos.y + pad})`);
+
+ const rect = document.createElementNS(SVG_NS, 'rect');
+ rect.setAttribute('width', DAG_NODE_WIDTH);
+ rect.setAttribute('height', DAG_NODE_HEIGHT);
+ rect.setAttribute('rx', 6);
+ rect.setAttribute('class', 'dag-node-rect');
+ rect.dataset.state = node.state;
+ g.appendChild(rect);
+
+ const title = document.createElementNS(SVG_NS, 'title');
+ title.textContent = `${node.name}\nState: ${node.state}${node.role ? `\nRole: ${node.role}` : ''}`;
+ g.appendChild(title);
+
+ const nameText = document.createElementNS(SVG_NS, 'text');
+ nameText.setAttribute('x', 8);
+ nameText.setAttribute('y', 18);
+ nameText.setAttribute('class', 'dag-node-name');
+ // Full text for now — fitDagNodeLabels (run once every node is attached to
+ // the live document) measures and truncates with an ellipsis, since a
+ // single long word (e.g. a role name) has no word-boundary to truncate at
+ // and a fixed character count either clips it anyway or wastes space on
+ // shorter names. "Measure first" per the dataviz guidance, not guess.
+ nameText.textContent = node.name || node.id;
+ g.appendChild(nameText);
+
+ const subText = document.createElementNS(SVG_NS, 'text');
+ subText.setAttribute('x', 8);
+ subText.setAttribute('y', 34);
+ subText.setAttribute('class', 'dag-node-sub');
+ subText.textContent = node.role ? `${node.role} · ${node.state}` : node.state;
+ g.appendChild(subText);
+
+ return g;
+}
+
+// renderTaskTreeDAG renders a GET /api/stories/{id}/task-tree response into
+// `container` as an SVG layered graph, color-coded by task.State (reusing
+// the app's existing --state-* tokens — see dag-node-rect rules in
+// style.css) with a text legend, plus a distinct name/state text label on
+// every node so identity is never color-alone.
+function renderTaskTreeDAG(tree, container) {
+ container.innerHTML = '';
+ const nodes = (tree && tree.nodes) || [];
+ if (nodes.length === 0) {
+ container.innerHTML = '<p class="task-meta">No task tree yet — this story has no root_task_id, or its root task has not been created.</p>';
+ return;
+ }
+
+ const { positions, width, height } = layoutTaskTree(nodes, tree.root_task_id);
+ const pad = 16;
+
+ const svg = document.createElementNS(SVG_NS, 'svg');
+ svg.setAttribute('viewBox', `0 0 ${width + pad * 2} ${height + pad * 2}`);
+ svg.setAttribute('width', width + pad * 2);
+ svg.setAttribute('height', height + pad * 2);
+ svg.classList.add('dag-svg');
+
+ const defs = document.createElementNS(SVG_NS, 'defs');
+ const marker = document.createElementNS(SVG_NS, 'marker');
+ marker.setAttribute('id', 'dag-arrow');
+ marker.setAttribute('viewBox', '0 0 10 10');
+ marker.setAttribute('refX', '9');
+ marker.setAttribute('refY', '5');
+ marker.setAttribute('markerWidth', '7');
+ marker.setAttribute('markerHeight', '7');
+ marker.setAttribute('orient', 'auto-start-reverse');
+ const arrowPath = document.createElementNS(SVG_NS, 'path');
+ arrowPath.setAttribute('d', 'M0,0 L10,5 L0,10 z');
+ arrowPath.setAttribute('class', 'dag-arrow-head');
+ marker.appendChild(arrowPath);
+ defs.appendChild(marker);
+ svg.appendChild(defs);
+
+ const byId = new Map(nodes.map(n => [n.id, n]));
+ function center(n) {
+ const p = positions.get(n.id) || { x: 0, y: 0 };
+ return { x: p.x + DAG_NODE_WIDTH / 2 + pad, y: p.y + DAG_NODE_HEIGHT / 2 + pad };
+ }
+
+ const edgesG = document.createElementNS(SVG_NS, 'g');
+ edgesG.setAttribute('class', 'dag-edges');
+ for (const n of nodes) {
+ if (n.parent_task_id && byId.has(n.parent_task_id)) {
+ drawDagEdge(edgesG, center(byId.get(n.parent_task_id)), center(n), 'parent');
+ }
+ for (const dep of n.depends_on || []) {
+ if (byId.has(dep)) {
+ drawDagEdge(edgesG, center(byId.get(dep)), center(n), 'depends');
+ }
+ }
+ }
+ svg.appendChild(edgesG);
+
+ const nodesG = document.createElementNS(SVG_NS, 'g');
+ nodesG.setAttribute('class', 'dag-nodes');
+ for (const n of nodes) {
+ nodesG.appendChild(renderDagNode(n, positions.get(n.id) || { x: 0, y: 0 }, pad));
+ }
+ svg.appendChild(nodesG);
+
+ const wrap = document.createElement('div');
+ wrap.className = 'dag-container';
+ wrap.appendChild(svg);
+ container.appendChild(wrap);
+ container.appendChild(renderDagLegend());
+
+ // Text elements are now part of the live document (container was already
+ // on-page) — getComputedTextLength() needs that to return real numbers.
+ fitDagNodeLabels(svg, DAG_NODE_WIDTH - 16);
+}
+
+// fitDagNodeLabels truncates each .dag-node-name/.dag-node-sub text element
+// to fit maxWidth, measuring actual rendered glyph width via
+// getComputedTextLength() rather than assuming a character-count budget —
+// a role name like "evaluator_performance" is one unbroken word with no
+// truncateToWordBoundary-style word break to fall back on, and different
+// browsers/fonts render the same string at different widths.
+function fitDagNodeLabels(svgRoot, maxWidth) {
+ const els = svgRoot.querySelectorAll('.dag-node-name, .dag-node-sub');
+ for (const el of els) {
+ const full = el.textContent;
+ let length;
+ try {
+ length = el.getComputedTextLength();
+ } catch {
+ continue; // no layout available (e.g. non-browser test env) — leave as-is
+ }
+ if (!Number.isFinite(length) || length <= maxWidth) continue;
+
+ let lo = 0;
+ let hi = full.length;
+ while (lo < hi) {
+ const mid = Math.ceil((lo + hi) / 2);
+ el.textContent = full.slice(0, mid) + '…';
+ if (el.getComputedTextLength() <= maxWidth) lo = mid;
+ else hi = mid - 1;
+ }
+ el.textContent = lo > 0 ? full.slice(0, lo) + '…' : '…';
+ }
+}
+
+// ── Story detail modal ────────────────────────────────────────────────────────
+
+function acListElement(items) {
+ const ul = document.createElement('ul');
+ if (!items || items.length === 0) {
+ ul.className = 'ac-list empty';
+ const li = document.createElement('li');
+ li.textContent = 'No acceptance criteria recorded.';
+ ul.appendChild(li);
+ return ul;
+ }
+ ul.className = 'ac-list';
+ for (const item of items) {
+ const li = document.createElement('li');
+ li.textContent = item;
+ ul.appendChild(li);
+ }
+ return ul;
+}
+
+async function renderStoryModalContent(storyId) {
+ const title = document.getElementById('story-modal-title');
+ const body = document.getElementById('story-modal-body');
+
+ let story, tree, events;
+ try {
+ [story, tree, events] = await Promise.all([
+ fetchStory(storyId),
+ fetchStoryTaskTree(storyId),
+ fetchStoryEvents(storyId),
+ ]);
+ } catch (err) {
+ title.textContent = 'Story';
+ body.innerHTML = `<div class="panel-fetch-error">Failed to load: ${err.message}</div>`;
+ return;
+ }
+
+ let epic = null;
+ if (story.epic_id) {
+ try { epic = await fetchEpic(story.epic_id); } catch { epic = null; }
+ }
+
+ title.textContent = story.name;
+ body.innerHTML = '';
+
+ // ── Metadata ──
+ const metaSection = makeSection('Story');
+ const grid = document.createElement('div');
+ grid.className = 'meta-grid';
+
+ const statusItem = document.createElement('div');
+ statusItem.className = 'meta-item';
+ const statusLbl = document.createElement('div');
+ statusLbl.className = 'meta-label';
+ statusLbl.textContent = 'Status';
+ const statusBadge = document.createElement('span');
+ statusBadge.className = 'story-status-badge';
+ statusBadge.dataset.status = story.status;
+ statusBadge.textContent = (story.status || '').replace(/_/g, ' ');
+ statusItem.append(statusLbl, statusBadge);
+ grid.appendChild(statusItem);
+
+ grid.append(
+ makeMetaItem('Priority', story.priority),
+ makeMetaItem('Epic', epic ? epic.name : (story.epic_id ? story.epic_id : '—'), { muted: !epic && !story.epic_id }),
+ makeMetaItem('Created', formatDateLong(story.created_at)),
+ makeMetaItem('Updated', formatDateLong(story.updated_at)),
+ makeMetaItem('ID', story.id, { fullWidth: true, mono: true }),
+ );
+ metaSection.appendChild(grid);
+
+ if (story.spec) {
+ const specLabel = document.createElement('div');
+ specLabel.className = 'meta-label';
+ specLabel.style.marginTop = '0.75rem';
+ specLabel.textContent = 'Spec';
+ const specText = document.createElement('p');
+ specText.className = 'task-summary';
+ specText.textContent = story.spec;
+ metaSection.appendChild(specLabel);
+ metaSection.appendChild(specText);
+ }
+
+ const acLabel = document.createElement('div');
+ acLabel.className = 'meta-label';
+ acLabel.style.marginTop = '0.75rem';
+ acLabel.textContent = 'Acceptance Criteria';
+ metaSection.appendChild(acLabel);
+ metaSection.appendChild(acListElement(story.acceptance_criteria));
+
+ if (story.status === 'REVIEW_READY') {
+ const acceptRow = document.createElement('div');
+ acceptRow.className = 'story-modal-actions';
+ const acceptBtn = document.createElement('button');
+ acceptBtn.className = 'btn-accept';
+ acceptBtn.textContent = 'Accept';
+ acceptBtn.addEventListener('click', async () => {
+ acceptBtn.disabled = true;
+ acceptBtn.textContent = 'Accepting…';
+ try {
+ await acceptStory(story.id);
+ await renderStoryModalContent(storyId);
+ renderStoriesPanel();
+ } catch (err) {
+ acceptBtn.disabled = false;
+ acceptBtn.textContent = 'Accept';
+ const errEl = document.createElement('span');
+ errEl.className = 'task-error';
+ errEl.textContent = `Failed: ${err.message}`;
+ acceptRow.appendChild(errEl);
+ }
+ });
+ acceptRow.appendChild(acceptBtn);
+ metaSection.appendChild(acceptRow);
+ }
+
+ body.appendChild(metaSection);
+
+ // ── Task tree DAG ──
+ const dagSection = makeSection('Task Tree');
+ const dagContainer = document.createElement('div');
+ dagSection.appendChild(dagContainer);
+ body.appendChild(dagSection);
+ renderTaskTreeDAG(tree, dagContainer);
+
+ // ── Event timeline ──
+ const timelineSection = makeSection('Timeline');
+ timelineSection.appendChild(renderEventTimeline(events));
+ body.appendChild(timelineSection);
+}
+
+async function openStoryModal(storyId) {
+ const modal = document.getElementById('story-modal');
+ const title = document.getElementById('story-modal-title');
+ const body = document.getElementById('story-modal-body');
+ if (!modal) return;
+ title.textContent = 'Loading…';
+ body.innerHTML = '<div class="panel-loading">Loading…</div>';
+ modal.showModal();
+ await renderStoryModalContent(storyId);
+}
+
+function closeStoryModal() {
+ const modal = document.getElementById('story-modal');
+ if (modal) modal.close();
+}
+
// ── Tab switching ─────────────────────────────────────────────────────────────
function switchTab(name) {
@@ -3242,6 +4156,18 @@ if (typeof document !== 'undefined') {
document.getElementById('logs-modal').close();
});
+ // Story detail modal close
+ const btnCloseStoryModal = document.getElementById('btn-close-story-modal');
+ if (btnCloseStoryModal) btnCloseStoryModal.addEventListener('click', closeStoryModal);
+
+ // Stories board/epics view toggle
+ document.querySelectorAll('.view-toggle-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ setStoriesViewMode(btn.dataset.view);
+ renderStoriesPanel();
+ });
+ });
+
// Tab bar
document.querySelectorAll('.tab').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));