summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/app.js92
-rw-r--r--web/test/task-card-di.test.mjs169
-rw-r--r--web/test/tasks-board.test.mjs81
3 files changed, 132 insertions, 210 deletions
diff --git a/web/app.js b/web/app.js
index 5444511..8bc6a9f 100644
--- a/web/app.js
+++ b/web/app.js
@@ -294,13 +294,6 @@ export function createTaskCard(task, doc = document) {
badge.textContent = task.state.replace(/_/g, ' ');
header.append(name, badge);
- if (task.state === 'RUNNING') {
- const elapsed = doc.createElement('span');
- elapsed.className = 'running-elapsed';
- elapsed.dataset.startedAt = task.updated_at ?? '';
- elapsed.textContent = formatElapsed(task.updated_at);
- header.append(elapsed);
- }
card.appendChild(header);
// Meta: priority + created_at
@@ -324,6 +317,17 @@ export function createTaskCard(task, doc = document) {
}
if (meta.children.length) card.appendChild(meta);
+ // 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);
+ }
+
// Description (truncated via CSS)
if (task.description) {
const desc = doc.createElement('div');
@@ -370,14 +374,21 @@ export function createTaskCard(task, doc = document) {
if (depBadge) card.appendChild(depBadge);
}
- // Log-tail placeholder for PENDING/QUEUED tasks (no execution exists yet).
+ // 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-log-tail';
- const placeholder = doc.createElement('p');
- placeholder.className = 'task-log-placeholder';
- placeholder.textContent = 'Waiting to start\u2026';
- logTail.appendChild(placeholder);
+ logTail.className = task.state === 'RUNNING' || task.state === 'BLOCKED'
+ ? 'task-log-tail running-log'
+ : 'task-log-tail';
+ logTail.dataset.logTarget = task.id;
card.appendChild(logTail);
}
@@ -409,7 +420,7 @@ export function createTaskCard(task, doc = document) {
});
footer.appendChild(btn);
} else if (task.state === 'READY') {
- renderSubtaskRollup(task, footer);
+ renderSubtaskRollup(task, footer, doc);
const acceptBtn = doc.createElement('button');
acceptBtn.className = 'btn-accept';
acceptBtn.textContent = 'Accept';
@@ -428,9 +439,9 @@ export function createTaskCard(task, doc = document) {
footer.appendChild(rejectBtn);
} else if (task.state === 'BLOCKED') {
if (task.question) {
- renderQuestionFooter(task, footer);
+ renderQuestionFooter(task, footer, doc);
} else {
- renderSubtaskRollup(task, footer);
+ renderSubtaskRollup(task, footer, doc);
}
const cancelBtn = doc.createElement('button');
cancelBtn.className = 'btn-cancel';
@@ -478,7 +489,7 @@ export function createTaskCard(task, doc = document) {
if (EDITABLE_STATES.has(task.state)) {
card.classList.add('task-card--editable');
- const editForm = createEditForm(task);
+ const editForm = createEditForm(task, doc);
editForm.hidden = true;
card.appendChild(editForm);
card.addEventListener('click', () => { editForm.hidden = !editForm.hidden; });
@@ -950,21 +961,20 @@ async function updateTask(taskId, body) {
return res.json();
}
-function createEditForm(task) {
+function createEditForm(task, doc = document) {
const a = task.agent || {};
- const form = document.createElement('div');
+ const form = doc.createElement('div');
form.className = 'task-inline-edit';
// Prevent card-level click from toggling this form while user interacts inside it.
form.addEventListener('click', (e) => e.stopPropagation());
function makeField(labelText, tag, attrs) {
- const label = document.createElement('label');
+ const label = doc.createElement('label');
label.textContent = labelText;
- const el = document.createElement(tag);
+ const el = doc.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
- if (k === 'value') el.value = v;
- else el.setAttribute(k, v);
+ el[k] = v;
}
label.appendChild(el);
return label;
@@ -973,17 +983,17 @@ function createEditForm(task) {
form.appendChild(makeField('Name', 'input', { type: 'text', name: 'name', value: task.name || '' }));
form.appendChild(makeField('Description', 'textarea', { name: 'description', rows: '2', value: task.description || '' }));
form.appendChild(makeField('Instructions', 'textarea', { name: 'instructions', rows: '4', value: a.instructions || '' }));
-
+
form.appendChild(makeField('Project Directory', 'input', { type: 'text', name: 'project_dir', value: a.project_dir || a.working_dir || '', placeholder: '/path/to/repo' }));
form.appendChild(makeField('Max Budget (USD)', 'input', { type: 'number', name: 'max_budget_usd', step: '0.01', value: a.max_budget_usd != null ? String(a.max_budget_usd) : '1.00' }));
form.appendChild(makeField('Timeout', 'input', { type: 'text', name: 'timeout', value: formatDurationForInput(task.timeout) || '15m', placeholder: '15m' }));
- const prioLabel = document.createElement('label');
+ const prioLabel = doc.createElement('label');
prioLabel.textContent = 'Priority';
- const prioSel = document.createElement('select');
+ const prioSel = doc.createElement('select');
prioSel.name = 'priority';
for (const val of ['high', 'normal', 'low']) {
- const opt = document.createElement('option');
+ const opt = doc.createElement('option');
opt.value = val;
opt.textContent = val.charAt(0).toUpperCase() + val.slice(1);
if (val === (task.priority || 'normal')) opt.selected = true;
@@ -992,20 +1002,20 @@ function createEditForm(task) {
prioLabel.appendChild(prioSel);
form.appendChild(prioLabel);
- const errEl = document.createElement('div');
+ const errEl = doc.createElement('div');
errEl.className = 'inline-edit-error';
errEl.hidden = true;
form.appendChild(errEl);
- const actions = document.createElement('div');
+ const actions = doc.createElement('div');
actions.className = 'inline-edit-actions';
- const cancelBtn = document.createElement('button');
+ const cancelBtn = doc.createElement('button');
cancelBtn.type = 'button';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => { form.hidden = true; });
- const saveBtn = document.createElement('button');
+ const saveBtn = doc.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'btn-primary btn-sm';
saveBtn.textContent = 'Save';
@@ -1059,7 +1069,7 @@ async function handleEditSave(taskId, form, saveBtn) {
}
}
-function renderQuestionFooter(task, footer) {
+function renderQuestionFooter(task, footer, doc = document) {
// Prevent any tap inside the question footer from opening the detail panel.
footer.addEventListener('click', (e) => e.stopPropagation());
@@ -1068,14 +1078,14 @@ function renderQuestionFooter(task, footer) {
try { question = JSON.parse(task.question); } catch {}
}
- const questionEl = document.createElement('p');
+ const questionEl = doc.createElement('p');
questionEl.className = 'task-question-text';
questionEl.textContent = question.text;
footer.appendChild(questionEl);
if (question.options && question.options.length > 0) {
question.options.forEach(opt => {
- const btn = document.createElement('button');
+ const btn = doc.createElement('button');
btn.className = 'btn-answer';
btn.textContent = opt;
btn.addEventListener('click', (e) => {
@@ -1085,13 +1095,13 @@ function renderQuestionFooter(task, footer) {
footer.appendChild(btn);
});
} else {
- const row = document.createElement('div');
+ const row = doc.createElement('div');
row.className = 'task-answer-row';
- const input = document.createElement('input');
+ const input = doc.createElement('input');
input.type = 'text';
input.className = 'task-answer-input';
input.placeholder = 'Your answer…';
- const btn = document.createElement('button');
+ const btn = doc.createElement('button');
btn.className = 'btn-answer';
btn.textContent = 'Submit';
btn.addEventListener('click', (e) => {
@@ -1115,9 +1125,9 @@ const STATE_EMOJI = {
READY: '👀', BLOCKED: '⏸',
};
-async function renderSubtaskRollup(task, footer) {
+async function renderSubtaskRollup(task, footer, doc = document) {
footer.addEventListener('click', (e) => e.stopPropagation());
- const container = document.createElement('div');
+ const container = doc.createElement('div');
container.className = 'subtask-rollup';
footer.prepend(container);
@@ -1129,10 +1139,10 @@ async function renderSubtaskRollup(task, footer) {
container.textContent = blurb ? truncateToWordBoundary(blurb) : 'Waiting for subtasks…';
return;
}
- const ul = document.createElement('ul');
+ const ul = doc.createElement('ul');
ul.className = 'subtask-list';
for (const st of subtasks) {
- const li = document.createElement('li');
+ const li = doc.createElement('li');
li.className = `subtask-item subtask-${st.state.toLowerCase()}`;
li.textContent = `${STATE_EMOJI[st.state] || '•'} ${st.name}`;
ul.appendChild(li);
diff --git a/web/test/task-card-di.test.mjs b/web/test/task-card-di.test.mjs
deleted file mode 100644
index e5dff74..0000000
--- a/web/test/task-card-di.test.mjs
+++ /dev/null
@@ -1,169 +0,0 @@
-// task-card-di.test.mjs — Unit tests for the createTaskCard DI doc param,
-// log-tail placeholder (PENDING/QUEUED), and elapsed timer (RUNNING).
-//
-// Run with: node --test web/test/task-card-di.test.mjs
-
-import { describe, it } from 'node:test';
-import assert from 'node:assert/strict';
-import { createTaskCard } from '../app.js';
-
-// ── Mock DOM ───────────────────────────────────────────────────────────────────
-
-function makeEl(tag) {
- return {
- tag,
- className: '',
- textContent: '',
- title: '',
- hidden: false,
- dataset: {},
- children: [],
- classList: { add() {} },
- append(...nodes) {
- for (const n of nodes) {
- if (n != null && typeof n === 'object') this.children.push(n);
- }
- },
- appendChild(child) {
- if (child != null) this.children.push(child);
- return child;
- },
- addEventListener() {},
- setAttribute() {},
- getAttribute() { return null; },
- };
-}
-
-function makeDoc() {
- return {
- createElement(tag) { return makeEl(tag); },
- };
-}
-
-// Depth-first search for first element with the given class name.
-function findByClass(el, cls) {
- for (const child of el.children || []) {
- if (typeof child.className === 'string' &&
- child.className.split(' ').includes(cls)) return child;
- const found = findByClass(child, cls);
- if (found) return found;
- }
- return null;
-}
-
-// ── Tests: DI doc param ────────────────────────────────────────────────────────
-
-describe('createTaskCard doc DI param', () => {
- it('accepts a custom doc and uses it for createElement', () => {
- const created = [];
- const doc = {
- createElement(tag) {
- const el = makeEl(tag);
- created.push(tag);
- return el;
- },
- };
- createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test Task' }, doc);
- assert.ok(created.length > 0, 'doc.createElement should have been called');
- assert.ok(created.includes('div'), 'should create at least one div');
- assert.ok(created.includes('span'), 'should create at least one span');
- });
-
- it('returns a card element whose tag is div', () => {
- const card = createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test' }, makeDoc());
- assert.equal(card.tag, 'div');
- assert.equal(card.className, 'task-card');
- assert.equal(card.dataset.taskId, 't1');
- });
-});
-
-// ── Tests: log-tail placeholder (QUEUED) ──────────────────────────────────────
-
-describe('log-tail placeholder for queue column', () => {
- it('QUEUED task has a task-log-placeholder element', () => {
- const card = createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test' }, makeDoc());
- const placeholder = findByClass(card, 'task-log-placeholder');
- assert.ok(placeholder, 'QUEUED card should have a task-log-placeholder element');
- });
-
- it('QUEUED task log-tail placeholder text is "Waiting to start\u2026"', () => {
- const card = createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test' }, makeDoc());
- const placeholder = findByClass(card, 'task-log-placeholder');
- assert.equal(placeholder.textContent, 'Waiting to start\u2026');
- });
-
- it('QUEUED task has a task-log-tail wrapper containing the placeholder', () => {
- const card = createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test' }, makeDoc());
- const logTail = findByClass(card, 'task-log-tail');
- assert.ok(logTail, 'QUEUED card should have a task-log-tail wrapper');
- const placeholder = findByClass(logTail, 'task-log-placeholder');
- assert.ok(placeholder, 'task-log-tail should contain task-log-placeholder');
- });
-
- it('RUNNING task does not have a task-log-placeholder', () => {
- const card = createTaskCard({ id: 't1', state: 'RUNNING', name: 'Test' }, makeDoc());
- const placeholder = findByClass(card, 'task-log-placeholder');
- assert.equal(placeholder, null, 'RUNNING card should not have a task-log-placeholder');
- });
-
- it('COMPLETED task does not have a task-log-placeholder', () => {
- const card = createTaskCard({ id: 't1', state: 'COMPLETED', name: 'Test' }, makeDoc());
- const placeholder = findByClass(card, 'task-log-placeholder');
- assert.equal(placeholder, null, 'COMPLETED card should not have a task-log-placeholder');
- });
-});
-
-// ── Tests: elapsed timer (RUNNING) ────────────────────────────────────────────
-
-describe('elapsed timer for RUNNING tasks', () => {
- it('RUNNING task has a running-elapsed span', () => {
- const card = createTaskCard(
- { id: 't1', state: 'RUNNING', name: 'Test', updated_at: '2024-01-01T00:00:00Z' },
- makeDoc(),
- );
- const elapsed = findByClass(card, 'running-elapsed');
- assert.ok(elapsed, 'RUNNING card should have a running-elapsed element');
- });
-
- it('RUNNING task running-elapsed has data-started-at from task.updated_at', () => {
- const card = createTaskCard(
- { id: 't1', state: 'RUNNING', name: 'Test', updated_at: '2024-06-15T12:00:00Z' },
- makeDoc(),
- );
- const elapsed = findByClass(card, 'running-elapsed');
- assert.equal(elapsed.dataset.startedAt, '2024-06-15T12:00:00Z');
- });
-
- it('RUNNING task running-elapsed data-started-at is empty string when updated_at is absent', () => {
- const card = createTaskCard(
- { id: 't1', state: 'RUNNING', name: 'Test' },
- makeDoc(),
- );
- const elapsed = findByClass(card, 'running-elapsed');
- assert.equal(elapsed.dataset.startedAt, '');
- });
-
- it('running-elapsed is inside the card header', () => {
- const card = createTaskCard(
- { id: 't1', state: 'RUNNING', name: 'Test', updated_at: '2024-01-01T00:00:00Z' },
- makeDoc(),
- );
- // The header is the first child of the card (a div.task-card-header).
- const header = findByClass(card, 'task-card-header');
- assert.ok(header, 'card should have a task-card-header');
- const elapsed = findByClass(header, 'running-elapsed');
- assert.ok(elapsed, 'running-elapsed should be inside the task-card-header');
- });
-
- it('QUEUED task does not have a running-elapsed span', () => {
- const card = createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test' }, makeDoc());
- const elapsed = findByClass(card, 'running-elapsed');
- assert.equal(elapsed, null, 'QUEUED card should not have a running-elapsed element');
- });
-
- it('COMPLETED task does not have a running-elapsed span', () => {
- const card = createTaskCard({ id: 't1', state: 'COMPLETED', name: 'Test' }, makeDoc());
- const elapsed = findByClass(card, 'running-elapsed');
- assert.equal(elapsed, null, 'COMPLETED card should not have a running-elapsed element');
- });
-});
diff --git a/web/test/tasks-board.test.mjs b/web/test/tasks-board.test.mjs
index 560e8c8..db61880 100644
--- a/web/test/tasks-board.test.mjs
+++ b/web/test/tasks-board.test.mjs
@@ -10,6 +10,7 @@ import {
TASK_COLUMNS,
columnForTaskState,
groupTasksByColumn,
+ createTaskCard,
} from '../app.js';
describe('columnForTaskState', () => {
@@ -146,3 +147,83 @@ describe('groupTasksByColumn', () => {
}
});
});
+
+// ── 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.
+
+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);
+ });
+});