summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/plans/2026-07-06-tasks-board.md1163
1 files changed, 1163 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-06-tasks-board.md b/docs/superpowers/plans/2026-07-06-tasks-board.md
new file mode 100644
index 0000000..7daf0db
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-06-tasks-board.md
@@ -0,0 +1,1163 @@
+# Unified Tasks Board — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a "Tasks" tab — a 5-column Kanban board (Queue/Running/Ready/Interrupted/Done) covering every task regardless of story association, with an inline auto-tailing execution-log excerpt on every card (no click required).
+
+**Architecture:** Almost entirely a generalization of existing, working (but currently dead/disconnected) code in `web/app.js`: `createTaskCard`, `renderTasksIntoContainer`, the Stories board's column-grouping pattern (`STORY_COLUMNS`/`columnForStatus`/`groupStoriesByColumn`), and the old Running tab's per-task SSE log-tailing (`startRunningLogStream`). No Go/backend changes — every REST endpoint this needs (`/api/tasks`, `/api/executions?task_id=`, `/api/executions/{id}/logs/stream`) already exists and is already exercised by existing Go tests.
+
+**Tech Stack:** Vanilla JS (no framework), `node --test` for unit tests (hand-rolled DOM mocks per test file, no jsdom), existing CSS conventions in `web/style.css`.
+
+## Global Constraints
+
+- No backend/Go changes in this plan — verify with `go test ./...` at the end regardless, as a safety check (per spec).
+- Follow the existing DOM-mock testing convention: no jsdom: dependency-inject `doc`/`fetchFn`/`EventSourceImpl` params with real-global defaults, matching `renderEventTimeline(events, doc = document)` and `fetchRecentExecutions(basePath, fetchFn = fetch)`'s existing pattern in `web/app.js`.
+- Column partition (from the approved spec, `docs/superpowers/specs/2026-07-06-tasks-board-design.md`) is fixed: Queue = PENDING/QUEUED; Running = RUNNING/BLOCKED; Ready = READY; Interrupted = FAILED/TIMED_OUT/CANCELLED/BUDGET_EXCEEDED; Done = COMPLETED.
+- Default landing tab stays `'stories'` — do not revert to `'queue'`.
+- Do not touch `renderRunningHistory`, `sortExecutionsByDate`/`sortExecutionsDesc`, or `fetchRecentExecutions` — these back a separate, still-valid (if currently unreached) system-wide execution-history feature, not superseded by this board.
+- Do not touch `filterQueueTasks`, `filterReadyTasks`, `filterAllDoneTasks`, `filterTasksByTab`, `filterActiveTasks`, `filterTasks`, or their test files (`tab-filters.test.mjs`, `filter-tabs.test.mjs`, `active-tasks-tab.test.mjs`, `filter.test.mjs`) — dead-but-harmless, unrelated pre-existing debt, out of scope for this plan.
+
+---
+
+## File Map
+
+| File | Change |
+|---|---|
+| `web/app.js` | Add `TASK_COLUMNS`, `columnForTaskState`, `groupTasksByColumn`; extend `createTaskCard` (DI `doc` param, log-tail placeholder, elapsed timer); add `cardContentSignature`, modify `renderTasksIntoContainer`; add `ensureTaskLogStream`/`taskLogStreams` (replacing `startRunningLogStream`/`runningViewLogSources`); add `renderTasksBoard`; wire `case 'tasks':` into `renderActiveTab`; remove `renderQueuePanel`, `renderInterruptedPanel`, `renderReadyPanel`, `renderRunningView`, `isRunningTabActive`, old `startRunningLogStream`, `runningViewLogSources`, old `updateRunningElapsed` |
+| `web/index.html` | Add Tasks nav button + `[data-panel="tasks"]` container |
+| `web/style.css` | Add `.tasks-board`/`.tasks-column*` (reusing `.stories-board`/`.stories-column*` declarations) and `.task-log-tail` (compact log excerpt) |
+| `web/test/tasks-board.test.mjs` | New — column model + card/log-tail/diff-preservation unit tests |
+| `web/test/tab-persistence.test.mjs` | Fix 2 pre-existing failing assertions (`'queue'` → `'stories'`) |
+
+---
+
+## Task 1: Column model — `TASK_COLUMNS`, `columnForTaskState`, `groupTasksByColumn`
+
+**Files:**
+- Modify: `web/app.js` (add near `STORY_COLUMNS`, e.g. directly after `groupStoriesByColumn`, ~line 3360)
+- Test: `web/test/tasks-board.test.mjs` (new file)
+
+**Interfaces:**
+- Produces: `TASK_COLUMNS` (array of `{key, label, states}>`), `columnForTaskState(state) → string`, `groupTasksByColumn(tasks) → {[columnKey]: task[]}` — consumed by Task 7 (`renderTasksBoard`).
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `web/test/tasks-board.test.mjs`:
+
+```js
+// tasks-board.test.mjs — Unit tests for the unified Tasks board's pure
+// column-grouping logic. Mirrors web/test/stories-board.test.mjs's structure.
+//
+// Run with: node --test web/test/tasks-board.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { TASK_COLUMNS, columnForTaskState, groupTasksByColumn } from '../app.js';
+
+const ALL_TASK_STATES = [
+ 'PENDING', 'QUEUED', 'RUNNING', 'BLOCKED', 'READY',
+ 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', 'COMPLETED',
+];
+
+describe('columnForTaskState', () => {
+ it('maps each documented task state to a column', () => {
+ assert.equal(columnForTaskState('PENDING'), 'queue');
+ assert.equal(columnForTaskState('QUEUED'), 'queue');
+ assert.equal(columnForTaskState('RUNNING'), 'running');
+ assert.equal(columnForTaskState('BLOCKED'), 'running');
+ assert.equal(columnForTaskState('READY'), 'ready');
+ assert.equal(columnForTaskState('FAILED'), 'interrupted');
+ assert.equal(columnForTaskState('TIMED_OUT'), 'interrupted');
+ assert.equal(columnForTaskState('CANCELLED'), 'interrupted');
+ assert.equal(columnForTaskState('BUDGET_EXCEEDED'), 'interrupted');
+ assert.equal(columnForTaskState('COMPLETED'), 'done');
+ });
+
+ it('falls back to queue for an unrecognized/empty state', () => {
+ assert.equal(columnForTaskState(''), 'queue');
+ assert.equal(columnForTaskState(undefined), 'queue');
+ assert.equal(columnForTaskState('SOME_FUTURE_STATE'), 'queue');
+ });
+
+ it('every column key is unique and the partition covers every state exactly once', () => {
+ const keys = TASK_COLUMNS.map(c => c.key);
+ assert.equal(new Set(keys).size, keys.length);
+ const allStates = TASK_COLUMNS.flatMap(c => c.states);
+ assert.equal(new Set(allStates).size, allStates.length, 'a state must map to exactly one column');
+ for (const s of ALL_TASK_STATES) {
+ assert.ok(allStates.includes(s), `${s} missing from any column`);
+ }
+ });
+});
+
+describe('groupTasksByColumn', () => {
+ function makeTask(state, created_at) {
+ return { id: `${state}-${created_at}`, name: `task-${state}`, state, created_at };
+ }
+
+ it('groups tasks into their mapped columns', () => {
+ const tasks = [makeTask('PENDING', '2026-01-01'), makeTask('RUNNING', '2026-01-01'), makeTask('DONE-n/a', '2026-01-01')];
+ // Replace the bogus 'DONE-n/a' state task with a real COMPLETED one for a clean grouping check.
+ tasks[2] = makeTask('COMPLETED', '2026-01-01');
+ const groups = groupTasksByColumn(tasks);
+ assert.equal(groups.queue.length, 1);
+ assert.equal(groups.running.length, 1);
+ assert.equal(groups.done.length, 1);
+ assert.equal(groups.ready.length, 0);
+ assert.equal(groups.interrupted.length, 0);
+ });
+
+ it('returns an entry for every column even when empty', () => {
+ const groups = groupTasksByColumn([]);
+ for (const col of TASK_COLUMNS) {
+ assert.ok(Array.isArray(groups[col.key]), `${col.key} should be an (empty) array`);
+ assert.equal(groups[col.key].length, 0);
+ }
+ });
+
+ it('sorts queue oldest-first (FIFO)', () => {
+ const tasks = [
+ makeTask('QUEUED', '2026-01-03'),
+ makeTask('QUEUED', '2026-01-01'),
+ makeTask('QUEUED', '2026-01-02'),
+ ];
+ const groups = groupTasksByColumn(tasks);
+ assert.deepEqual(groups.queue.map(t => t.created_at), ['2026-01-01', '2026-01-02', '2026-01-03']);
+ });
+
+ it('sorts ready oldest-first (longest-waiting-for-review surfaces first)', () => {
+ const tasks = [makeTask('READY', '2026-01-02'), makeTask('READY', '2026-01-01')];
+ const groups = groupTasksByColumn(tasks);
+ assert.deepEqual(groups.ready.map(t => t.created_at), ['2026-01-01', '2026-01-02']);
+ });
+
+ it('sorts interrupted newest-first (most recent failure most urgent)', () => {
+ const tasks = [makeTask('FAILED', '2026-01-01'), makeTask('FAILED', '2026-01-02')];
+ const groups = groupTasksByColumn(tasks);
+ assert.deepEqual(groups.interrupted.map(t => t.created_at), ['2026-01-02', '2026-01-01']);
+ });
+
+ it('sorts done newest-first (most recent completion most relevant)', () => {
+ const tasks = [makeTask('COMPLETED', '2026-01-01'), makeTask('COMPLETED', '2026-01-02')];
+ const groups = groupTasksByColumn(tasks);
+ assert.deepEqual(groups.done.map(t => t.created_at), ['2026-01-02', '2026-01-01']);
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `node --test web/test/tasks-board.test.mjs`
+Expected: FAIL — `TASK_COLUMNS`/`columnForTaskState`/`groupTasksByColumn` are not exported from `../app.js` yet (`SyntaxError` or `undefined` import errors).
+
+- [ ] **Step 3: Implement**
+
+In `web/app.js`, immediately after `groupStoriesByColumn`'s closing brace (after the block ending `storyHasReachedValidation` — insert right after that function, before the `// ── Stories tab: epic swimlanes` comment, i.e. directly following the Stories-board pure-function section), add:
+
+```js
+// ── Tasks tab: board/column model (pure — unit-tested in web/test) ─────────
+//
+// Columns partition the full task.State lifecycle (internal/task/task.go)
+// into 5 mutually-exclusive buckets. BLOCKED groups with Running (not an
+// error — the task is actively in-flight, just paused on a subtask or an
+// ask_user answer). TIMED_OUT/BUDGET_EXCEEDED group with Interrupted (both
+// are abnormal stops needing a human decision, same bucket as FAILED/
+// CANCELLED — all four already have Resume/Restart buttons via createTaskCard).
+export const TASK_COLUMNS = [
+ { key: 'queue', label: 'Queue', states: ['PENDING', 'QUEUED'] },
+ { key: 'running', label: 'Running', states: ['RUNNING', 'BLOCKED'] },
+ { key: 'ready', label: 'Ready', states: ['READY'] },
+ { key: 'interrupted', label: 'Interrupted', states: ['FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED'] },
+ { key: 'done', label: 'Done', states: ['COMPLETED'] },
+];
+
+// columnForTaskState returns the column key for a given task state, falling
+// back to 'queue' for an empty/unrecognized state so a task is never
+// dropped off the board entirely.
+export function columnForTaskState(state) {
+ const col = TASK_COLUMNS.find(c => c.states.includes(state));
+ return col ? col.key : 'queue';
+}
+
+// Per-column sort direction, matching the semantics the old (removed)
+// queue/interrupted/ready panels already used — not a uniform order. See
+// docs/superpowers/specs/2026-07-06-tasks-board-design.md for rationale.
+const TASK_COLUMN_SORT_DESCEND = {
+ queue: false, // oldest-first (FIFO)
+ running: true, // newest-first (no faithful precedent; inconsequential, bounded by max_concurrent)
+ ready: false, // oldest-first (longest-waiting-for-review surfaces first)
+ interrupted: true, // newest-first (most recent failure most urgent)
+ done: true, // newest-first (most recent completion most relevant)
+};
+
+// groupTasksByColumn returns { [columnKey]: task[] }, each sub-array sorted
+// per TASK_COLUMN_SORT_DESCEND via the existing sortTasksByDate helper.
+export function groupTasksByColumn(tasks) {
+ const groups = {};
+ for (const col of TASK_COLUMNS) groups[col.key] = [];
+ for (const t of tasks || []) {
+ const key = columnForTaskState(t.state);
+ if (!groups[key]) groups[key] = [];
+ groups[key].push(t);
+ }
+ for (const key of Object.keys(groups)) {
+ groups[key] = sortTasksByDate(groups[key], TASK_COLUMN_SORT_DESCEND[key]);
+ }
+ return groups;
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `node --test web/test/tasks-board.test.mjs`
+Expected: PASS, all 9 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/app.js web/test/tasks-board.test.mjs
+git commit -m "feat(web): add Tasks board column model (TASK_COLUMNS, columnForTaskState, groupTasksByColumn)"
+```
+
+---
+
+## Task 2: Fix pre-existing failing default-tab tests
+
+**Files:**
+- Modify: `web/test/tab-persistence.test.mjs`
+
+**Interfaces:**
+- Consumes: `getActiveMainTab()` (existing, returns `'stories'` by default per `cc6b323`) — no production code change in this task, test-only fix.
+
+- [ ] **Step 1: Confirm the current failure**
+
+Run: `node --test web/test/tab-persistence.test.mjs`
+Expected: FAIL — 2 of 6 tests fail (`'returns "queue" when localStorage has no stored value'` and `'returns "queue" after localStorage value is removed'`), asserting a default that no longer matches `getActiveMainTab()`'s actual `'stories'` default (changed in commit `cc6b323`, test never updated).
+
+- [ ] **Step 2: Fix the two stale assertions**
+
+In `web/test/tab-persistence.test.mjs`, change:
+
+```js
+ it('returns "queue" when localStorage has no stored value', () => {
+ assert.equal(getActiveMainTab(), 'queue');
+ });
+```
+
+to:
+
+```js
+ it('returns "stories" when localStorage has no stored value', () => {
+ assert.equal(getActiveMainTab(), 'stories');
+ });
+```
+
+and change:
+
+```js
+ it('returns "queue" after localStorage value is removed', () => {
+ setActiveMainTab('stats');
+ localStorage.removeItem('activeMainTab');
+ assert.equal(getActiveMainTab(), 'queue');
+ });
+```
+
+to:
+
+```js
+ it('returns "stories" after localStorage value is removed', () => {
+ setActiveMainTab('stats');
+ localStorage.removeItem('activeMainTab');
+ assert.equal(getActiveMainTab(), 'stories');
+ });
+```
+
+- [ ] **Step 3: Run tests to verify they pass**
+
+Run: `node --test web/test/tab-persistence.test.mjs`
+Expected: PASS, all 6 tests.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web/test/tab-persistence.test.mjs
+git commit -m "test(web): fix stale default-tab assertions (queue -> stories, per cc6b323)"
+```
+
+---
+
+## Task 3: Nav entry point — `index.html` + CSS
+
+**Files:**
+- Modify: `web/index.html`
+- Modify: `web/style.css`
+
+**Interfaces:**
+- Produces: `<div data-panel="tasks">` container with a `.tasks-board` child, and a `[data-tab="tasks"]` nav button — consumed by Task 7's `renderTasksBoard`, and by the existing generic `switchTab`/`renderActiveTab` dispatch (no changes needed there beyond Task 7's new `case`, since both are already driven generically by `data-tab`/`data-panel` attributes).
+
+This task is structural markup/CSS, not logic — there is no meaningful unit test for static HTML/CSS in this codebase's existing conventions (no other `data-panel` container has a dedicated test). Verify visually per Step 3.
+
+- [ ] **Step 1: Add the nav button and panel container**
+
+In `web/index.html`, change:
+
+```html
+ <nav class="tab-bar">
+ <button class="tab active" data-tab="stories" title="Tracker">📋</button>
+ <button class="tab" data-tab="stats" title="Model Dashboard">📊</button>
+ <button class="tab" data-tab="drops" title="Drops">📁</button>
+ <button class="tab" data-tab="settings" title="Settings">⚙️</button>
+ </nav>
+```
+
+to:
+
+```html
+ <nav class="tab-bar">
+ <button class="tab active" data-tab="stories" title="Tracker">📋</button>
+ <button class="tab" data-tab="tasks" title="Tasks">📝</button>
+ <button class="tab" data-tab="stats" title="Model Dashboard">📊</button>
+ <button class="tab" data-tab="drops" title="Drops">📁</button>
+ <button class="tab" data-tab="settings" title="Settings">⚙️</button>
+ </nav>
+```
+
+And change:
+
+```html
+ <div data-panel="drops" hidden>
+ <div class="drops-panel"></div>
+ </div>
+```
+
+to (adding the new panel directly before the `drops` panel):
+
+```html
+ <div data-panel="tasks" hidden>
+ <div class="tasks-board"></div>
+ </div>
+ <div data-panel="drops" hidden>
+ <div class="drops-panel"></div>
+ </div>
+```
+
+- [ ] **Step 2: Add CSS**
+
+In `web/style.css`, directly after the `.stories-column-list` rule block (ends `overflow-y: auto;\n}` around line 2163, right before the `.story-card { cursor: grab; }` rule), add:
+
+```css
+.tasks-board {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ overflow-x: auto;
+ padding-bottom: 0.5rem;
+}
+
+.tasks-column {
+ flex: 0 0 220px;
+ width: 220px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ padding: 0.625rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.tasks-column-header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: var(--text-muted);
+ padding-bottom: 0.4rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.tasks-column-count {
+ font-weight: 700;
+ color: var(--text);
+ background: var(--surface);
+ border-radius: 999px;
+ padding: 0 0.45em;
+ font-size: 0.7rem;
+}
+
+.tasks-column-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ min-height: 40px;
+ max-height: 70vh;
+ overflow-y: auto;
+}
+
+.task-log-tail {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 0.5rem 0.625rem;
+ font-family: monospace;
+ font-size: 0.75rem;
+ max-height: 90px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.task-log-tail-placeholder {
+ color: var(--text-muted);
+ font-size: 0.8rem;
+ font-style: italic;
+}
+```
+
+- [ ] **Step 3: Verify visually**
+
+Run the claudomator server locally (or check the already-running production instance after deploy), navigate to the dashboard, and confirm:
+- A new "Tasks" (📝) button appears in the nav between Tracker and Model Dashboard.
+- Clicking it shows an empty `.tasks-board` container (no columns yet — those render starting in Task 7; this is expected at this point in the plan).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web/index.html web/style.css
+git commit -m "feat(web): add Tasks nav tab entry point + board/column/log-tail CSS"
+```
+
+---
+
+## Task 4: Extend `createTaskCard` — DI `doc` param, log-tail placeholder, elapsed timer
+
+**Files:**
+- Modify: `web/app.js` (`createTaskCard`, ~line 278)
+- Test: `web/test/tasks-board.test.mjs` (append)
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces: `createTaskCard(task, doc = document)` (added `doc` param, default preserves existing callers' behavior unchanged); every returned card now has a stable child element findable via `card.querySelector('.task-log-tail, .task-log-tail-placeholder')` for non-terminal-vs-Queue distinction, and RUNNING cards additionally have `card.querySelector('.task-elapsed[data-started-at]')`. Consumed by Task 5 (diff-preserving render) and Task 6 (log-stream attachment).
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `web/test/tasks-board.test.mjs`:
+
+```js
+// ── createTaskCard: log-tail placeholder + elapsed timer ────────────────────
+//
+// createTaskCard uses the real global `document` by default (existing
+// convention throughout app.js — e.g. renderEventTimeline(events, doc =
+// document)) but accepts an injectable `doc` for Node-based unit testing
+// without jsdom, matching the hand-rolled mock-DOM convention already used
+// by web/test/task-panel-summary.test.mjs and web/test/render-dedup.test.mjs.
+
+import { createTaskCard } from '../app.js';
+
+function makeMockDoc() {
+ function makeEl(tag) {
+ return {
+ tag,
+ className: '',
+ classList: {
+ _set: new Set(),
+ add(...cls) { cls.forEach(c => this._set.add(c)); },
+ toggle(cls, on) { on ? this._set.add(cls) : this._set.delete(cls); },
+ contains(cls) { return this._set.has(cls); },
+ },
+ textContent: '',
+ title: '',
+ hidden: false,
+ dataset: {},
+ children: [],
+ _listeners: {},
+ appendChild(child) { this.children.push(child); return child; },
+ append(...nodes) { nodes.forEach(n => this.children.push(n)); },
+ prepend(...nodes) { this.children.unshift(...nodes); },
+ addEventListener(type, fn) { this._listeners[type] = fn; },
+ querySelector(sel) {
+ const cls = sel.split(',')[0].trim().replace(/^\./, '');
+ const search = (el) => {
+ if (el.className && el.className.split(' ').includes(cls)) return el;
+ if (el.dataset && sel.includes('[data-started-at]') && el.className.includes('task-elapsed') && 'startedAt' in el.dataset) return el;
+ for (const c of el.children) {
+ const found = search(c);
+ if (found) return found;
+ }
+ return null;
+ };
+ return search(this);
+ },
+ };
+ }
+ return { createElement: (tag) => makeEl(tag) };
+}
+
+describe('createTaskCard log-tail placeholder', () => {
+ it('shows a "waiting to start" placeholder for PENDING/QUEUED tasks (no execution exists yet)', () => {
+ const doc = makeMockDoc();
+ for (const state of ['PENDING', 'QUEUED']) {
+ const card = createTaskCard({ id: 't1', name: 'Task', state }, doc);
+ const placeholder = card.querySelector('.task-log-tail-placeholder');
+ const tail = card.querySelector('.task-log-tail');
+ assert.ok(placeholder, `expected a placeholder for ${state}`);
+ assert.equal(tail, null, `expected no .task-log-tail element for ${state}`);
+ }
+ });
+
+ it('includes a .task-log-tail element for every non-Queue state', () => {
+ const doc = makeMockDoc();
+ for (const state of ['RUNNING', 'BLOCKED', 'READY', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', 'COMPLETED']) {
+ const card = createTaskCard({ id: 't1', name: 'Task', state }, doc);
+ const tail = card.querySelector('.task-log-tail');
+ assert.ok(tail, `expected .task-log-tail for ${state}`);
+ }
+ });
+});
+
+describe('createTaskCard elapsed timer', () => {
+ it('includes a .task-elapsed[data-started-at] element only for RUNNING tasks', () => {
+ const doc = makeMockDoc();
+ const running = createTaskCard({ id: 't1', name: 'Task', state: 'RUNNING', updated_at: '2026-01-01T00:00:00Z' }, doc);
+ assert.ok(running.querySelector('.task-elapsed[data-started-at]'));
+
+ const ready = createTaskCard({ id: 't2', name: 'Task', state: 'READY' }, doc);
+ assert.equal(ready.querySelector('.task-elapsed[data-started-at]'), null);
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `node --test web/test/tasks-board.test.mjs`
+Expected: FAIL — `createTaskCard` is not exported from `../app.js` yet, and even once exported, the log-tail/elapsed-timer elements don't exist yet.
+
+- [ ] **Step 3: Implement**
+
+In `web/app.js`, change the `createTaskCard` function signature and body. First, make it exported and accept `doc`:
+
+Change:
+```js
+function createTaskCard(task) {
+ const card = document.createElement('div');
+```
+to:
+```js
+export function createTaskCard(task, doc = document) {
+ const card = doc.createElement('div');
+```
+
+Then replace every other bare `document.createElement(...)` call *inside* `createTaskCard` (there are several: `header`, `name`, `badge`, `meta`, `prio`, `when`, `proj`, `desc`, `errEl`, `reportEl`/`label`/`text`, `footer`, buttons, `delBtn`) with `doc.createElement(...)`. This is a mechanical find-and-replace scoped to this one function only — do not touch `document.createElement` calls in other functions.
+
+Next, add the log-tail placeholder and elapsed timer. Immediately after the "Meta: priority + created_at" block (after `if (meta.children.length) card.appendChild(meta);`) and before the "Description (truncated via CSS)" block, insert:
+
+```js
+ // Elapsed timer for RUNNING tasks (no separate ticking interval — updated
+ // on each poll tick alongside the rest of the board, same convention the
+ // old Running-tab card used).
+ if (task.state === 'RUNNING') {
+ const elapsed = doc.createElement('span');
+ elapsed.className = 'task-elapsed running-elapsed';
+ elapsed.dataset.startedAt = task.updated_at ?? '';
+ elapsed.textContent = formatElapsed(task.updated_at);
+ card.appendChild(elapsed);
+ }
+```
+
+Then, immediately before the footer block (before the line `const RESUME_STATES = new Set(['TIMED_OUT', 'CANCELLED', 'FAILED', 'BUDGET_EXCEEDED']);`), insert the log-tail placeholder/element:
+
+```js
+ // Inline log tail (no click required) — see docs/superpowers/specs/2026-07-06-tasks-board-design.md
+ // section 4. PENDING/QUEUED tasks have no execution row yet, so there is
+ // nothing to tail; every other state gets a .task-log-tail element that
+ // Task 6's ensureTaskLogStream() attaches an SSE stream to.
+ if (task.state === 'PENDING' || task.state === 'QUEUED') {
+ const placeholder = doc.createElement('div');
+ placeholder.className = 'task-log-tail-placeholder';
+ placeholder.textContent = 'Waiting to start…';
+ card.appendChild(placeholder);
+ } else {
+ const logTail = doc.createElement('div');
+ logTail.className = task.state === 'RUNNING' || task.state === 'BLOCKED'
+ ? 'task-log-tail running-log'
+ : 'task-log-tail';
+ logTail.dataset.logTarget = task.id;
+ card.appendChild(logTail);
+ }
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `node --test web/test/tasks-board.test.mjs`
+Expected: PASS, all tests including the new ones.
+
+- [ ] **Step 5: Run the full existing suite to check for regressions**
+
+Run: `node --test web/test/*.test.mjs`
+Expected: PASS for everything except the 2 pre-existing `tab-persistence.test.mjs` failures already fixed in Task 2 (so: full pass at this point). `render-dedup.test.mjs` uses its own separate inline mock (not the real `createTaskCard`), so it is unaffected by this change.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add web/app.js web/test/tasks-board.test.mjs
+git commit -m "feat(web): createTaskCard - inject doc param, add log-tail placeholder + elapsed timer"
+```
+
+---
+
+## Task 5: Diff-preserving render — protect live log-tail content across re-renders
+
+**Files:**
+- Modify: `web/app.js` (`renderTasksIntoContainer`, ~line 718)
+- Test: `web/test/tasks-board.test.mjs` (append)
+
+**Interfaces:**
+- Consumes: `createTaskCard` (Task 4).
+- Produces: `cardContentSignature(cardEl)` (exported for testing) and a modified `renderTasksIntoContainer` that no longer tears down a card's `.task-log-tail`/`.running-log` subtree when nothing else about the card changed, and transplants the existing (already-streaming) log-tail element across a replace when something else *did* change. Consumed by Task 6 (so a live `EventSource`'s target element survives poll-driven re-renders) and Task 7.
+
+**Why this task exists:** `renderTasksIntoContainer` currently does `if (card.innerHTML !== newCard.innerHTML) { container.replaceChild(newCard, card); }`. Once cards contain a log-tail that accumulates streamed lines (Task 6), `card.innerHTML` changes on essentially every poll tick even when nothing else changed, causing a full teardown-and-rebuild — which would close and reopen the `EventSource` connection repeatedly (flicker, wasted reconnects, lost partial output). This task fixes that by excluding the log-tail subtree from the comparison and preserving the real (streaming) DOM node across any replace that does occur.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `web/test/tasks-board.test.mjs`:
+
+```js
+// ── renderTasksIntoContainer: log-tail preservation across re-renders ──────
+
+import { cardContentSignature } from '../app.js';
+
+describe('cardContentSignature', () => {
+ it('excludes .task-log-tail content from the signature', () => {
+ const doc = makeMockDoc();
+ const cardA = createTaskCard({ id: 't1', name: 'Task', state: 'READY' }, doc);
+ const cardB = createTaskCard({ id: 't1', name: 'Task', state: 'READY' }, doc);
+ // Simulate cardB having accumulated live log lines that cardA (freshly built) doesn't have.
+ const tailB = cardB.querySelector('.task-log-tail');
+ tailB.children.push({ tag: 'div', className: 'log-line', textContent: 'some streamed output', children: [] });
+ assert.equal(cardContentSignature(cardA), cardContentSignature(cardB));
+ });
+
+ it('still differs when a non-log field changes', () => {
+ const doc = makeMockDoc();
+ const cardA = createTaskCard({ id: 't1', name: 'Task', state: 'READY' }, doc);
+ const cardB = createTaskCard({ id: 't1', name: 'Task', state: 'FAILED' }, doc);
+ assert.notEqual(cardContentSignature(cardA), cardContentSignature(cardB));
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `node --test web/test/tasks-board.test.mjs`
+Expected: FAIL — `cardContentSignature` is not exported yet. (Note: the mock DOM's `querySelector`/`children` handling needs a `serialize`-style read for the signature to work meaningfully in the test; the implementation step below defines `cardContentSignature` to walk `.children`/`.className`/`.textContent` recursively rather than relying on a real `innerHTML` getter, since the hand-rolled mock has no `innerHTML`. This keeps the function testable without jsdom.)
+
+- [ ] **Step 3: Implement**
+
+In `web/app.js`, add `cardContentSignature` directly before `renderTasksIntoContainer`:
+
+```js
+// cardContentSignature returns a structural fingerprint of a task card,
+// excluding any .task-log-tail/.running-log subtree — live-streamed log
+// content must never cause renderTasksIntoContainer to think the card
+// "changed" and tear it down (see Task 5 of the tasks-board plan).
+export function cardContentSignature(cardEl) {
+ function walk(el) {
+ if (!el) return '';
+ const isLogTail = el.className && (
+ el.className.split(' ').includes('task-log-tail') ||
+ el.className.split(' ').includes('running-log')
+ );
+ if (isLogTail) return `<logtail:${el.dataset && el.dataset.logTarget || ''}>`;
+ const childSig = (el.children || []).map(walk).join('');
+ return `<${el.tag || ''} class="${el.className || ''}" data-state="${(el.dataset && el.dataset.state) || ''}">${el.textContent || ''}${childSig}`;
+ }
+ return walk(cardEl);
+}
+```
+
+Then change `renderTasksIntoContainer`'s update branch. Replace:
+
+```js
+ if (card) {
+ // If the content is exactly the same, we could skip replacing,
+ // but createTaskCard is fast and ensures we have the latest state.
+ // We replace the card in-place to preserve its position if possible.
+ if (card.innerHTML !== newCard.innerHTML) {
+ // Special case: if user is interacting with THIS card, we might want to skip or merge.
+ // For now, createTaskCard ensures we don't disrupt if NOT editing.
+ container.replaceChild(newCard, card);
+ }
+ } else {
+ // Append new card
+ container.appendChild(newCard);
+ }
+```
+
+with:
+
+```js
+ if (card) {
+ // Compare everything except live-streamed log content (see
+ // cardContentSignature) so an appending .task-log-tail never triggers
+ // a teardown-and-rebuild of the whole card on its own.
+ if (cardContentSignature(card) !== cardContentSignature(newCard)) {
+ // Preserve the existing (potentially already-streaming) log-tail
+ // element across the swap so its EventSource's target node stays
+ // attached and its accumulated content survives.
+ const oldLogTail = card.querySelector('.task-log-tail');
+ const newLogTail = newCard.querySelector('.task-log-tail');
+ if (oldLogTail && newLogTail) newLogTail.replaceWith(oldLogTail);
+ container.replaceChild(newCard, card);
+ }
+ } else {
+ // Append new card
+ container.appendChild(newCard);
+ }
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `node --test web/test/tasks-board.test.mjs`
+Expected: PASS, all tests.
+
+- [ ] **Step 5: Run the full existing suite to check for regressions**
+
+Run: `node --test web/test/*.test.mjs`
+Expected: PASS for everything. `render-dedup.test.mjs` tests a separate inline-mocked contract for the *removal/dedup* behavior of `renderTasksIntoContainer`, not the `innerHTML`-vs-signature comparison itself, so it is unaffected — confirm this by reading its assertions if any failure appears here (do not silently ignore a failure).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add web/app.js web/test/tasks-board.test.mjs
+git commit -m "fix(web): exclude live log-tail content from card re-render diffing"
+```
+
+---
+
+## Task 6: Generalized log-tail streaming — `ensureTaskLogStream`
+
+**Files:**
+- Modify: `web/app.js` (add new function; remove `startRunningLogStream`, `runningViewLogSources`, old `updateRunningElapsed` — removal happens in Task 8, not here, to avoid breaking `renderRunningView` before it's removed)
+- Test: `web/test/tasks-board.test.mjs` (append)
+
+**Interfaces:**
+- Consumes: `card.querySelector('.task-log-tail, .running-log')` elements from Task 4/5.
+- Produces: `ensureTaskLogStream(taskId, logAreaEl, opts)` where `opts = { fetchFn = fetch, EventSourceImpl = (typeof EventSource !== 'undefined' ? EventSource : undefined), apiBase = API_BASE } = {}`. Also produces the module-level `taskLogStreams` map (`{ [taskId]: { source, execId } }`). Consumed by Task 7 (`renderTasksBoard` calls this once per visible card after each render pass) and Task 8 (removal target for the old equivalents).
+
+**Design note (from the approved spec):** `/api/executions/{id}/logs/stream` already replays-then-closes for a terminal execution and live-tails for a RUNNING one — the *same* function works for every column. The one thing `ensureTaskLogStream` must get right: don't reopen a stream for an execution it's already attached to (checked via the tracked `execId`), and do close+reopen when the task's latest execution ID has changed (e.g. a Resume/Restart produced a new execution).
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `web/test/tasks-board.test.mjs`:
+
+```js
+// ── ensureTaskLogStream: stream lifecycle (reuse vs. reopen vs. no-op) ─────
+
+import { ensureTaskLogStream, taskLogStreams } from '../app.js';
+
+function makeFakeEventSource() {
+ const instances = [];
+ function FakeEventSource(url) {
+ this.url = url;
+ this.closed = false;
+ this._listeners = {};
+ instances.push(this);
+ }
+ FakeEventSource.prototype.close = function () { this.closed = true; };
+ FakeEventSource.prototype.addEventListener = function (type, fn) { this._listeners[type] = fn; };
+ Object.defineProperty(FakeEventSource.prototype, 'onmessage', { writable: true, value: null });
+ Object.defineProperty(FakeEventSource.prototype, 'onerror', { writable: true, value: null });
+ FakeEventSource.instances = instances;
+ return FakeEventSource;
+}
+
+function makeFakeLogArea() {
+ return {
+ children: [],
+ appendChild(c) { this.children.push(c); },
+ removeChild(c) { this.children = this.children.filter(x => x !== c); },
+ get childElementCount() { return this.children.length; },
+ get firstElementChild() { return this.children[0]; },
+ get innerHTML() { return ''; },
+ set innerHTML(_) { this.children = []; }, // mirrors real DOM: assigning innerHTML clears children
+ scrollTop: 0, scrollHeight: 0, clientHeight: 0, addEventListener() {},
+ };
+}
+
+describe('ensureTaskLogStream', () => {
+ it('does nothing when the task has no executions yet (Queue)', async () => {
+ const fetchFn = async () => ({ ok: true, json: async () => [] });
+ const FakeES = makeFakeEventSource();
+ const logArea = makeFakeLogArea();
+ await ensureTaskLogStream('task-queue-1', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' });
+ assert.equal(FakeES.instances.length, 0);
+ });
+
+ it('opens a stream for a task with an execution', async () => {
+ const fetchFn = async () => ({ ok: true, json: async () => [{ id: 'exec-1' }] });
+ const FakeES = makeFakeEventSource();
+ const logArea = makeFakeLogArea();
+ await ensureTaskLogStream('task-1', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' });
+ assert.equal(FakeES.instances.length, 1);
+ assert.match(FakeES.instances[0].url, /\/api\/executions\/exec-1\/logs\/stream/);
+ assert.equal(taskLogStreams['task-1'].execId, 'exec-1');
+ });
+
+ it('does not reopen a stream already attached to the same execution', async () => {
+ const fetchFn = async () => ({ ok: true, json: async () => [{ id: 'exec-2' }] });
+ const FakeES = makeFakeEventSource();
+ const logArea = makeFakeLogArea();
+ await ensureTaskLogStream('task-2', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' });
+ await ensureTaskLogStream('task-2', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' });
+ assert.equal(FakeES.instances.length, 1, 'should not open a second stream for the same execution');
+ });
+
+ it('closes the old stream and opens a new one when the execution id changes', async () => {
+ let call = 0;
+ const fetchFn = async () => {
+ call++;
+ return { ok: true, json: async () => [{ id: call === 1 ? 'exec-a' : 'exec-b' }] };
+ };
+ const FakeES = makeFakeEventSource();
+ const logArea = makeFakeLogArea();
+ await ensureTaskLogStream('task-3', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' });
+ await ensureTaskLogStream('task-3', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' });
+ assert.equal(FakeES.instances.length, 2);
+ assert.ok(FakeES.instances[0].closed, 'old stream should be closed');
+ assert.equal(taskLogStreams['task-3'].execId, 'exec-b');
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `node --test web/test/tasks-board.test.mjs`
+Expected: FAIL — `ensureTaskLogStream`/`taskLogStreams` not exported yet.
+
+- [ ] **Step 3: Implement**
+
+In `web/app.js`, add directly after the (soon-to-be-removed-in-Task-8, but for now still present) `runningViewLogSources` declaration — actually, to keep this task's diff self-contained and not depend on Task 8's removal, add the new code as a distinct block right before the existing `function startRunningLogStream(taskId, logArea) {` definition:
+
+```js
+// taskId -> { source: EventSource, execId: string } — tracks which execution
+// each visible task's inline log-tail is currently attached to, so a poll-
+// driven re-render never reopens a stream for the same execution twice, and
+// correctly reopens when a Resume/Restart produces a fresh execution.
+export const taskLogStreams = {};
+
+// ensureTaskLogStream attaches (or leaves alone) a log stream for a task's
+// most recent execution into logAreaEl. Every column's card uses this same
+// mechanism — see docs/superpowers/specs/2026-07-06-tasks-board-design.md
+// section 4: the /api/executions/{id}/logs/stream endpoint already replays-
+// then-closes for a terminal execution and live-tails for a RUNNING one, so
+// there is no separate "static" vs. "live" code path.
+export async function ensureTaskLogStream(taskId, logAreaEl, {
+ fetchFn = fetch,
+ EventSourceImpl = (typeof EventSource !== 'undefined' ? EventSource : undefined),
+ apiBase = API_BASE,
+} = {}) {
+ let execs;
+ try {
+ const res = await fetchFn(`${apiBase}/api/executions?task_id=${taskId}&limit=1`);
+ execs = res.ok ? await res.json() : [];
+ } catch {
+ return;
+ }
+ if (!execs || execs.length === 0) return; // no execution yet (Queue)
+
+ const execId = execs[0].id;
+ const existing = taskLogStreams[taskId];
+ if (existing && existing.execId === execId) return; // already attached to this execution
+
+ if (existing) existing.source.close();
+ // .children is a read-only live collection on a real DOM element — the
+ // only correct way to clear it is innerHTML (the fake log-area mock in
+ // web/test/tasks-board.test.mjs mirrors this via an innerHTML setter).
+ logAreaEl.innerHTML = '';
+
+ const src = new EventSourceImpl(`${apiBase}/api/executions/${execId}/logs/stream`);
+ taskLogStreams[taskId] = { source: src, execId };
+
+ let userScrolled = false;
+ if (logAreaEl.addEventListener) {
+ logAreaEl.addEventListener('scroll', () => {
+ const nearBottom = logAreaEl.scrollHeight - logAreaEl.scrollTop - logAreaEl.clientHeight < 50;
+ userScrolled = !nearBottom;
+ });
+ }
+
+ src.onmessage = (event) => {
+ let data;
+ try { data = JSON.parse(event.data); } catch { return; }
+
+ const doc = (typeof document !== 'undefined') ? document : { createElement: (t) => ({ tag: t, className: '', textContent: '', children: [], appendChild(c) { this.children.push(c); } }) };
+ const line = doc.createElement('div');
+ line.className = 'log-line';
+
+ switch (data.type) {
+ case 'text':
+ line.classList.add('log-text');
+ line.textContent = data.text ?? data.content ?? '';
+ break;
+ case 'tool_use': {
+ line.classList.add('log-tool-use');
+ const toolName = doc.createElement('span');
+ toolName.className = 'tool-name';
+ toolName.textContent = `[${data.name ?? 'Tool'}]`;
+ line.appendChild(toolName);
+ const inputStr = data.input ? JSON.stringify(data.input) : '';
+ const inputPreview = doc.createElement('span');
+ inputPreview.textContent = ' ' + inputStr.slice(0, 120);
+ line.appendChild(inputPreview);
+ break;
+ }
+ case 'cost':
+ line.classList.add('log-cost');
+ line.textContent = `Cost: $${Number(data.total_cost ?? data.cost ?? 0).toFixed(3)}`;
+ break;
+ default:
+ return;
+ }
+
+ logAreaEl.appendChild(line);
+ while (logAreaEl.childElementCount > 200) {
+ logAreaEl.removeChild(logAreaEl.firstElementChild);
+ }
+ if (!userScrolled) logAreaEl.scrollTop = logAreaEl.scrollHeight;
+ };
+
+ src.addEventListener('done', () => {
+ src.close();
+ if (taskLogStreams[taskId] && taskLogStreams[taskId].source === src) delete taskLogStreams[taskId];
+ });
+
+ src.onerror = () => {
+ src.close();
+ if (taskLogStreams[taskId] && taskLogStreams[taskId].source === src) delete taskLogStreams[taskId];
+ };
+}
+```
+
+Note: the 500-line trim used by the old `startRunningLogStream` is reduced to 200 here, since compact `.task-log-tail` cards are ~90px tall (a handful of visible lines) — 200 is still generous headroom and avoids unbounded memory growth for a long-running task's card sitting open for hours.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `node --test web/test/tasks-board.test.mjs`
+Expected: PASS, all tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/app.js web/test/tasks-board.test.mjs
+git commit -m "feat(web): add ensureTaskLogStream - generalized per-card log tailing for every column"
+```
+
+---
+
+## Task 7: `renderTasksBoard` + wire into `renderActiveTab`
+
+**Files:**
+- Modify: `web/app.js` (add `renderTasksBoard`; modify `renderActiveTab`)
+
+**Interfaces:**
+- Consumes: `groupTasksByColumn` (Task 1), `TASK_COLUMNS` (Task 1), `renderTasksIntoContainer` (Task 5, already modified), `ensureTaskLogStream` (Task 6).
+- Produces: `renderTasksBoard(tasks, container)`, wired into `renderActiveTab`'s `case 'tasks':`.
+
+- [ ] **Step 1: Implement** (no new unit test for this step — it's DOM orchestration glue over already-tested pure functions; verified via Step 2's manual smoke test, matching this codebase's existing convention of not unit-testing top-level `render*Panel` orchestration functions)
+
+In `web/app.js`, add directly after `renderStoriesBoard`'s closing brace (before the `// ── Stories tab: epic swimlanes` comment):
+
+```js
+// ── Tasks tab: unified board ─────────────────────────────────────────────────
+
+function renderTasksBoard(tasks, container) {
+ const groups = groupTasksByColumn(tasks);
+ let board = container.querySelector('.tasks-board');
+ if (!board) {
+ board = document.createElement('div');
+ board.className = 'tasks-board';
+ container.appendChild(board);
+ }
+
+ for (const col of TASK_COLUMNS) {
+ let colEl = board.querySelector(`[data-column-key="${col.key}"]`);
+ if (!colEl) {
+ colEl = document.createElement('div');
+ colEl.className = 'tasks-column';
+ colEl.dataset.columnKey = col.key;
+
+ const header = document.createElement('div');
+ header.className = 'tasks-column-header';
+ const title = document.createElement('span');
+ title.textContent = col.label;
+ const count = document.createElement('span');
+ count.className = 'tasks-column-count';
+ header.append(title, count);
+ colEl.appendChild(header);
+
+ const list = document.createElement('div');
+ list.className = 'tasks-column-list';
+ colEl.appendChild(list);
+
+ board.appendChild(colEl);
+ }
+
+ const countEl = colEl.querySelector('.tasks-column-count');
+ countEl.textContent = String(groups[col.key].length);
+
+ const list = colEl.querySelector('.tasks-column-list');
+ renderTasksIntoContainer(groups[col.key], list, `No tasks in ${col.label.toLowerCase()}.`);
+ }
+
+ // Attach/refresh the inline log tail for every visible card that has one
+ // (createTaskCard omits it for PENDING/QUEUED — see Task 4).
+ board.querySelectorAll('.task-log-tail').forEach((logArea) => {
+ const taskId = logArea.dataset.logTarget;
+ if (taskId) ensureTaskLogStream(taskId, logArea);
+ });
+
+ // Elapsed timers update on every poll tick (same convention the old
+ // Running-tab card used — no separate ticking interval).
+ board.querySelectorAll('.task-elapsed[data-started-at]').forEach((el) => {
+ el.textContent = formatElapsed(el.dataset.startedAt || null);
+ });
+}
+```
+
+Then, in `renderActiveTab`'s switch, add a `case 'tasks':` branch. Change:
+
+```js
+function renderActiveTab(allTasks) {
+ const activeTab = getActiveTab();
+ switch (activeTab) {
+ 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;
+```
+
+to:
+
+```js
+function renderActiveTab(allTasks) {
+ const activeTab = getActiveTab();
+ switch (activeTab) {
+ 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 'tasks': {
+ const panel = document.querySelector('[data-panel="tasks"]');
+ if (panel) renderTasksBoard(allTasks, panel);
+ break;
+ }
+```
+
+- [ ] **Step 2: Manual smoke test**
+
+Run the claudomator dev server, log in, click the new Tasks (📝) tab. Confirm:
+- 5 columns render: Queue, Running, Ready, Interrupted, Done, each with a count badge.
+- Submit a test task via the chatbot MCP (or `POST /api/tasks` + `/run`) and watch it move Queue → Running → Ready/Interrupted/Done across poll ticks without the page needing a manual refresh.
+- While a task is RUNNING, confirm its card shows live-appending log lines with no click required.
+- Click a card in any column — confirm the existing side panel (`openTaskPanel`) still opens with full detail, unaffected by this change.
+
+- [ ] **Step 3: Run the full existing suite to check for regressions**
+
+Run: `node --test web/test/*.test.mjs`
+Expected: PASS for everything.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web/app.js
+git commit -m "feat(web): add renderTasksBoard, wire Tasks tab into renderActiveTab"
+```
+
+---
+
+## Task 8: Remove superseded dead code
+
+**Files:**
+- Modify: `web/app.js`
+
+**Interfaces:** none — pure removal of code with zero remaining callers after Tasks 1–7.
+
+**Explicitly NOT removed** (per Global Constraints above): `renderRunningHistory`, `sortExecutionsByDate`/`sortExecutionsDesc`, `fetchRecentExecutions` (separate system-wide execution-history feature, not superseded by this board); `filterQueueTasks`, `filterReadyTasks`, `filterAllDoneTasks`, `filterTasksByTab`, `filterActiveTasks`, `filterTasks` and their test files (pre-existing unrelated dead code, out of scope).
+
+- [ ] **Step 1: Confirm zero remaining callers before deleting anything**
+
+Run each of these and confirm the count matches "definition only" (no other callers), to guard against deleting something Task 7 turned out to still need:
+
+```bash
+grep -c '\brenderQueuePanel\b' web/app.js # expect 1
+grep -c '\brenderInterruptedPanel\b' web/app.js # expect 1
+grep -c '\brenderReadyPanel\b' web/app.js # expect 1
+grep -c '\brenderRunningView\b' web/app.js # expect 1
+grep -c '\bisRunningTabActive\b' web/app.js # expect 1
+grep -c '\brunningViewLogSources\b' web/app.js # expect exactly the definition + internal uses within startRunningLogStream/renderRunningView (all being deleted together)
+```
+
+If any count is higher than expected, stop and investigate the extra caller before proceeding — do not delete a function something else still depends on.
+
+- [ ] **Step 2: Delete `renderQueuePanel`, `renderInterruptedPanel`, `renderReadyPanel`**
+
+In `web/app.js`, delete these three function definitions in full (each superseded by `renderTasksBoard`'s Queue/Interrupted/Ready columns):
+
+```js
+function renderQueuePanel(tasks) {
+ const container = document.querySelector('[data-panel="queue"] .panel-task-list');
+ if (!container) return;
+ const visible = sortTasksByDate(filterQueueTasks(tasks));
+ renderTasksIntoContainer(visible, container, 'No tasks queued.');
+}
+
+function renderInterruptedPanel(tasks) {
+ const container = document.querySelector('[data-panel="interrupted"] .panel-task-list');
+ if (!container) return;
+ const visible = sortTasksByDate(tasks.filter(t => INTERRUPTED_STATES.has(t.state)), true);
+ renderTasksIntoContainer(visible, container, 'No interrupted tasks.');
+}
+```
+
+and (further down) the full `renderReadyPanel` function (from `function renderReadyPanel(tasks) {` through its closing `}`, the version shown in Task investigation that renders both the ready list and the `ready-completed-history` sub-section).
+
+- [ ] **Step 3: Delete `renderRunningView`, `isRunningTabActive`, old `startRunningLogStream`, `runningViewLogSources`, old `updateRunningElapsed`**
+
+Delete, in full:
+- The `runningViewLogSources` map declaration (`const runningViewLogSources = {};` and its preceding comment).
+- The entire `function renderRunningView(tasks) { ... }` body.
+- The entire old `function startRunningLogStream(taskId, logArea) { ... }` body (superseded by `ensureTaskLogStream` from Task 6 — this is the version that referenced `runningViewLogSources` and hardcoded a 500-line trim; the new `ensureTaskLogStream` added in Task 6 is a separate, already-exported function and is unaffected by this deletion).
+- The entire `function updateRunningElapsed() { ... }` body (superseded by the inline elapsed-timer update loop added directly in `renderTasksBoard`, Task 7).
+- The entire `function isRunningTabActive() { ... }` body.
+
+- [ ] **Step 4: Run the full test suite**
+
+Run: `node --test web/test/*.test.mjs`
+Expected: PASS for everything (no test file imported any of the deleted functions by name — confirmed during plan research; `running-view.test.mjs` tests an inline-duplicated `filterRunningTasks`/`formatElapsed`/`extractLogLines`, not the real functions, and is untouched).
+
+- [ ] **Step 5: Run `go build`/`go test` as a final safety check**
+
+Run: `go build ./... && go test ./...`
+Expected: PASS — this plan makes no Go changes, but this confirms nothing else in the repo was inadvertently affected.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add web/app.js
+git commit -m "chore(web): remove queue/interrupted/ready/running panel code superseded by the unified Tasks board"
+```
+
+---
+
+## Final Verification
+
+- [ ] Run `node --test web/test/*.test.mjs` — full suite passes.
+- [ ] Run `go build ./... && go test ./...` — passes (no Go changes expected, this is a safety net).
+- [ ] Manual smoke test (repeat Task 7 Step 2) against the deployed instance after rollout: submit a real task via chatbot MCP, watch it traverse Queue → Running → Ready/Done on the board with live log output, with zero clicks required to see it happening.