summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-05 19:02:24 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-05 19:02:24 +0000
commit9e790e35708f834abe1a09af52e43742e164cb63 (patch)
tree926f9b323f80dfdd3c030f98b7abebf9f02501d1 /web
parentab93297426353d70ec7c877c710a049b664e4fd0 (diff)
web: add Accept/Reject for READY tasks, Start Next button
- READY state task cards show Accept + Reject buttons - Accept POSTs to /api/tasks/{id}/accept (→ COMPLETED) - Reject POSTs to /api/tasks/{id}/reject (→ PENDING) - "Start Next" button in toolbar POSTs to /api/scripts/start-next-task - CSS for .btn-accept and .btn-reject Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web')
-rw-r--r--web/app.js114
-rw-r--r--web/index.html1
-rw-r--r--web/style.css35
-rw-r--r--web/test/task-actions.test.mjs5
4 files changed, 154 insertions, 1 deletions
diff --git a/web/app.js b/web/app.js
index 6d2a029..578d9a8 100644
--- a/web/app.js
+++ b/web/app.js
@@ -71,7 +71,7 @@ function createTaskCard(task) {
// Footer: action buttons based on state
const RESTART_STATES = new Set(['FAILED', 'TIMED_OUT', 'CANCELLED']);
- if (task.state === 'PENDING' || task.state === 'RUNNING' || RESTART_STATES.has(task.state)) {
+ if (task.state === 'PENDING' || task.state === 'RUNNING' || task.state === 'READY' || RESTART_STATES.has(task.state)) {
const footer = document.createElement('div');
footer.className = 'task-card-footer';
@@ -93,6 +93,23 @@ function createTaskCard(task) {
handleCancel(task.id, btn, footer);
});
footer.appendChild(btn);
+ } else if (task.state === 'READY') {
+ const acceptBtn = document.createElement('button');
+ acceptBtn.className = 'btn-accept';
+ acceptBtn.textContent = 'Accept';
+ acceptBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ handleAccept(task.id, acceptBtn, footer);
+ });
+ const rejectBtn = document.createElement('button');
+ rejectBtn.className = 'btn-reject';
+ rejectBtn.textContent = 'Reject';
+ rejectBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ handleReject(task.id, rejectBtn, footer);
+ });
+ footer.appendChild(acceptBtn);
+ footer.appendChild(rejectBtn);
} else if (RESTART_STATES.has(task.state)) {
const btn = document.createElement('button');
btn.className = 'btn-restart';
@@ -333,6 +350,97 @@ async function handleRestart(taskId, btn, footer) {
}
}
+// ── Accept / Reject actions ────────────────────────────────────────────────────
+
+async function acceptTask(taskId) {
+ const res = await fetch(`${API_BASE}/api/tasks/${taskId}/accept`, { method: 'POST' });
+ if (!res.ok) {
+ let msg = `HTTP ${res.status}`;
+ try { const body = await res.json(); msg = body.error || msg; } catch {}
+ throw new Error(msg);
+ }
+ return res.json();
+}
+
+async function rejectTask(taskId) {
+ const res = await fetch(`${API_BASE}/api/tasks/${taskId}/reject`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ comment: '' }),
+ });
+ if (!res.ok) {
+ let msg = `HTTP ${res.status}`;
+ try { const body = await res.json(); msg = body.error || msg; } catch {}
+ throw new Error(msg);
+ }
+ return res.json();
+}
+
+async function handleAccept(taskId, btn, footer) {
+ btn.disabled = true;
+ btn.textContent = 'Accepting…';
+ const prev = footer.querySelector('.task-error');
+ if (prev) prev.remove();
+
+ try {
+ await acceptTask(taskId);
+ await poll();
+ } catch (err) {
+ btn.disabled = false;
+ btn.textContent = 'Accept';
+ const errEl = document.createElement('span');
+ errEl.className = 'task-error';
+ errEl.textContent = `Failed: ${err.message}`;
+ footer.appendChild(errEl);
+ }
+}
+
+async function handleReject(taskId, btn, footer) {
+ btn.disabled = true;
+ btn.textContent = 'Rejecting…';
+ const prev = footer.querySelector('.task-error');
+ if (prev) prev.remove();
+
+ try {
+ await rejectTask(taskId);
+ await poll();
+ } catch (err) {
+ btn.disabled = false;
+ btn.textContent = 'Reject';
+ const errEl = document.createElement('span');
+ errEl.className = 'task-error';
+ errEl.textContent = `Failed: ${err.message}`;
+ footer.appendChild(errEl);
+ }
+}
+
+// ── Start-next-task ─────────────────────────────────────────────────────────────
+
+async function startNextTask() {
+ const res = await fetch(`${API_BASE}/api/scripts/start-next-task`, { method: 'POST' });
+ if (!res.ok) {
+ let msg = `HTTP ${res.status}`;
+ try { const body = await res.json(); msg = body.error || msg; } catch {}
+ throw new Error(msg);
+ }
+ return res.json();
+}
+
+async function handleStartNextTask(btn) {
+ btn.disabled = true;
+ btn.textContent = 'Starting…';
+ try {
+ const result = await startNextTask();
+ const output = (result.output || '').trim();
+ btn.textContent = output || 'No task to start';
+ setTimeout(() => { btn.textContent = 'Start Next'; btn.disabled = false; }, 3000);
+ if (output && output !== 'No task to start.') await poll();
+ } catch (err) {
+ btn.textContent = `Error: ${err.message}`;
+ setTimeout(() => { btn.textContent = 'Start Next'; btn.disabled = false; }, 3000);
+ }
+}
+
// ── Delete template ────────────────────────────────────────────────────────────
async function deleteTemplate(id) {
@@ -1137,6 +1245,10 @@ document.addEventListener('DOMContentLoaded', () => {
await poll();
});
+ document.getElementById('btn-start-next').addEventListener('click', function() {
+ handleStartNextTask(this);
+ });
+
startPolling();
connectWebSocket();
diff --git a/web/index.html b/web/index.html
index 482b9a9..2bc8222 100644
--- a/web/index.html
+++ b/web/index.html
@@ -20,6 +20,7 @@
<div data-panel="tasks">
<div class="task-list-toolbar">
<button id="btn-toggle-completed" class="btn-secondary btn-sm"></button>
+ <button id="btn-start-next" class="btn-secondary btn-sm">Start Next</button>
</div>
<div class="task-list">
<div id="loading">Loading tasks…</div>
diff --git a/web/style.css b/web/style.css
index 268f80c..1478b36 100644
--- a/web/style.css
+++ b/web/style.css
@@ -259,6 +259,41 @@ main {
cursor: not-allowed;
}
+.btn-accept {
+ font-size: 0.8rem;
+ font-weight: 600;
+ padding: 0.35em 0.85em;
+ border-radius: 0.375rem;
+ border: none;
+ cursor: pointer;
+ background: var(--state-completed);
+ color: #0f172a;
+ transition: opacity 0.15s;
+}
+
+.btn-accept:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-reject {
+ font-size: 0.8rem;
+ font-weight: 600;
+ padding: 0.35em 0.85em;
+ border-radius: 0.375rem;
+ border: none;
+ cursor: pointer;
+ background: var(--text-muted);
+ color: #0f172a;
+ transition: opacity 0.15s;
+ margin-left: 0.375rem;
+}
+
+.btn-reject:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
.task-error {
font-size: 0.78rem;
color: var(--state-failed);
diff --git a/web/test/task-actions.test.mjs b/web/test/task-actions.test.mjs
index f2c21c4..2df6523 100644
--- a/web/test/task-actions.test.mjs
+++ b/web/test/task-actions.test.mjs
@@ -12,6 +12,7 @@ const RESTART_STATES = new Set(['FAILED', 'TIMED_OUT', 'CANCELLED']);
function getCardAction(state) {
if (state === 'PENDING') return 'run';
if (state === 'RUNNING') return 'cancel';
+ if (state === 'READY') return 'approve';
if (RESTART_STATES.has(state)) return 'restart';
return null;
}
@@ -39,6 +40,10 @@ describe('task card action buttons', () => {
assert.equal(getCardAction('CANCELLED'), 'restart');
});
+ it('shows approve buttons for READY', () => {
+ assert.equal(getCardAction('READY'), 'approve');
+ });
+
it('shows no button for COMPLETED', () => {
assert.equal(getCardAction('COMPLETED'), null);
});