diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-08 04:35:22 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-08 04:35:22 +0000 |
| commit | 0525ed028bf2877d5423e3197c12b384b6bf809f (patch) | |
| tree | b681fc57a8213ec55a0bc07b0611a33f615cea5a /docs/superpowers | |
| parent | 2d9ae7e8b5660de32eeb12f74e845417e70a7e64 (diff) | |
docs: add implementation plan for unified Tasks board (was never committed)
This should have been committed alongside the design spec (f626155) before
task execution began. It wasn't -- every claudomator container that cloned
this repo during the Tasks board build never actually had this file, since
containers clone from the pushed remote, not the local working tree. Tasks
dispatched with only a "read this file" instruction had nothing to read and
silently improvised instead, which explains most of the review-cycle churn
during that build far better than "the implementer didn't follow
instructions" did. Committing now for the historical record and so the
gap can't repeat for this specific file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD
Diffstat (limited to 'docs/superpowers')
| -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. |
