# 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: `
` 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 ``` to: ```html ``` And change: ```html ``` to (adding the new panel directly before the `drops` panel): ```html ``` - [ ] **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 ``; 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.