diff options
Diffstat (limited to 'docs/superpowers/plans/2026-07-06-tasks-board.md')
| -rw-r--r-- | docs/superpowers/plans/2026-07-06-tasks-board.md | 1163 |
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. |
