summaryrefslogtreecommitdiff
path: root/web/app.js
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-06 04:44:09 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-06 04:44:09 +0000
commit1c6babc51fb7e3a36c2ad4c4f248f1b97b0852f6 (patch)
tree7cef58395b08bcbe9244e89967873e4ce88c62d7 /web/app.js
parente26c2a1246d21a9d2d6f6d8be2b9481e07931f9d (diff)
fix: stop click propagation in question footer to prevent panel stealing focus
Tapping the answer input or question text on mobile bubbled up to the card's click handler, opening the detail panel and stealing focus. Stopping propagation at the footer level covers all child elements. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web/app.js')
-rw-r--r--web/app.js237
1 files changed, 232 insertions, 5 deletions
diff --git a/web/app.js b/web/app.js
index 271a18e..ce1394d 100644
--- a/web/app.js
+++ b/web/app.js
@@ -70,8 +70,8 @@ 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' || task.state === 'READY' || task.state === 'BLOCKED' || RESTART_STATES.has(task.state)) {
+ const RESTART_STATES = new Set(['FAILED', 'CANCELLED']);
+ if (task.state === 'PENDING' || task.state === 'RUNNING' || task.state === 'READY' || task.state === 'BLOCKED' || task.state === 'TIMED_OUT' || RESTART_STATES.has(task.state)) {
const footer = document.createElement('div');
footer.className = 'task-card-footer';
@@ -112,6 +112,15 @@ function createTaskCard(task) {
footer.appendChild(rejectBtn);
} else if (task.state === 'BLOCKED') {
renderQuestionFooter(task, footer);
+ } else if (task.state === 'TIMED_OUT') {
+ const btn = document.createElement('button');
+ btn.className = 'btn-resume';
+ btn.textContent = 'Resume';
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ handleResume(task.id, btn, footer);
+ });
+ footer.appendChild(btn);
} else if (RESTART_STATES.has(task.state)) {
const btn = document.createElement('button');
btn.className = 'btn-restart';
@@ -126,7 +135,27 @@ function createTaskCard(task) {
card.appendChild(footer);
}
- card.addEventListener('click', () => openTaskPanel(task.id));
+ if (!NON_DELETABLE_STATES.has(task.state)) {
+ const delBtn = document.createElement('button');
+ delBtn.className = 'btn-delete-task';
+ delBtn.title = 'Delete task';
+ delBtn.textContent = '✕';
+ delBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ handleDelete(task.id, card);
+ });
+ card.appendChild(delBtn);
+ }
+
+ if (EDITABLE_STATES.has(task.state)) {
+ card.classList.add('task-card--editable');
+ const editForm = createEditForm(task);
+ editForm.hidden = true;
+ card.appendChild(editForm);
+ card.addEventListener('click', () => { editForm.hidden = !editForm.hidden; });
+ } else {
+ card.addEventListener('click', () => openTaskPanel(task.id));
+ }
return card;
}
@@ -314,7 +343,182 @@ async function restartTask(taskId) {
return res.json();
}
+async function resumeTask(taskId) {
+ const res = await fetch(`${API_BASE}/api/tasks/${taskId}/resume`, { method: 'POST' });
+ if (!res.ok) {
+ let msg = `HTTP ${res.status}`;
+ try { const body = await res.json(); msg = body.error || body.message || msg; } catch {}
+ throw new Error(msg);
+ }
+ return res.json();
+}
+
+const NON_DELETABLE_STATES = new Set(['RUNNING', 'QUEUED']);
+const EDITABLE_STATES = new Set(['PENDING', 'FAILED', 'CANCELLED', 'TIMED_OUT', 'BUDGET_EXCEEDED']);
+
+// Convert Duration JSON {"Duration": <ns>} to a human string for a text input (e.g. "15m").
+function formatDurationForInput(timeout) {
+ const ns = timeout && timeout.Duration;
+ if (!ns) return '';
+ const secs = Math.round(ns / 1e9);
+ if (secs < 60) return `${secs}s`;
+ const mins = Math.floor(secs / 60);
+ const remSecs = secs % 60;
+ if (mins < 60) return remSecs > 0 ? `${mins}m${remSecs}s` : `${mins}m`;
+ const hrs = Math.floor(mins / 60);
+ const remMins = mins % 60;
+ return remMins > 0 ? `${hrs}h${remMins}m` : `${hrs}h`;
+}
+
+async function deleteTask(taskId) {
+ const res = await fetch(`${API_BASE}/api/tasks/${taskId}`, { method: 'DELETE' });
+ if (!res.ok) {
+ let msg = `HTTP ${res.status}`;
+ try { const body = await res.json(); msg = body.error || body.message || msg; } catch {}
+ throw new Error(msg);
+ }
+}
+
+async function handleDelete(taskId, card) {
+ if (!confirm('Delete this task? This cannot be undone.')) return;
+ try {
+ await deleteTask(taskId);
+ card.remove();
+ } catch (err) {
+ alert(`Failed to delete: ${err.message}`);
+ }
+}
+
+// ── Inline task editor ────────────────────────────────────────────────────────
+
+async function updateTask(taskId, body) {
+ const res = await fetch(`${API_BASE}/api/tasks/${taskId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ let msg = `HTTP ${res.status}`;
+ try { const b = await res.json(); msg = b.error || b.message || msg; } catch {}
+ throw new Error(msg);
+ }
+ return res.json();
+}
+
+function createEditForm(task) {
+ const c = task.claude || {};
+
+ const form = document.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');
+ label.textContent = labelText;
+ const el = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs)) {
+ if (k === 'value') el.value = v;
+ else el.setAttribute(k, v);
+ }
+ label.appendChild(el);
+ return label;
+ }
+
+ 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: c.instructions || '' }));
+ form.appendChild(makeField('Model', 'input', { type: 'text', name: 'model', value: c.model || 'sonnet' }));
+ form.appendChild(makeField('Working Directory', 'input', { type: 'text', name: 'working_dir', value: c.working_dir || '', placeholder: '/path/to/repo' }));
+ form.appendChild(makeField('Max Budget (USD)', 'input', { type: 'number', name: 'max_budget_usd', step: '0.01', value: c.max_budget_usd != null ? String(c.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');
+ prioLabel.textContent = 'Priority';
+ const prioSel = document.createElement('select');
+ prioSel.name = 'priority';
+ for (const val of ['high', 'normal', 'low']) {
+ const opt = document.createElement('option');
+ opt.value = val;
+ opt.textContent = val.charAt(0).toUpperCase() + val.slice(1);
+ if (val === (task.priority || 'normal')) opt.selected = true;
+ prioSel.appendChild(opt);
+ }
+ prioLabel.appendChild(prioSel);
+ form.appendChild(prioLabel);
+
+ const errEl = document.createElement('div');
+ errEl.className = 'inline-edit-error';
+ errEl.hidden = true;
+ form.appendChild(errEl);
+
+ const actions = document.createElement('div');
+ actions.className = 'inline-edit-actions';
+
+ const cancelBtn = document.createElement('button');
+ cancelBtn.type = 'button';
+ cancelBtn.textContent = 'Cancel';
+ cancelBtn.addEventListener('click', () => { form.hidden = true; });
+
+ const saveBtn = document.createElement('button');
+ saveBtn.type = 'button';
+ saveBtn.className = 'btn-primary btn-sm';
+ saveBtn.textContent = 'Save';
+ saveBtn.addEventListener('click', () => handleEditSave(task.id, form, saveBtn));
+
+ actions.append(cancelBtn, saveBtn);
+ form.appendChild(actions);
+
+ return form;
+}
+
+async function handleEditSave(taskId, form, saveBtn) {
+ const get = name => form.querySelector(`[name="${name}"]`)?.value ?? '';
+
+ const body = {
+ name: get('name'),
+ description: get('description'),
+ claude: {
+ model: get('model'),
+ instructions: get('instructions'),
+ working_dir: get('working_dir'),
+ max_budget_usd: parseFloat(get('max_budget_usd')),
+ },
+ timeout: get('timeout'),
+ priority: get('priority'),
+ };
+
+ const errEl = form.querySelector('.inline-edit-error');
+ errEl.hidden = true;
+ saveBtn.disabled = true;
+ saveBtn.textContent = 'Saving…';
+
+ try {
+ await updateTask(taskId, body);
+ form.hidden = true;
+
+ // Brief success flash on the card
+ const card = form.closest('.task-card');
+ const flash = document.createElement('div');
+ flash.className = 'inline-edit-success';
+ flash.textContent = 'Saved';
+ card.appendChild(flash);
+ setTimeout(() => flash.remove(), 2000);
+
+ await poll();
+ } catch (err) {
+ errEl.textContent = `Failed to save: ${err.message}`;
+ errEl.hidden = false;
+ } finally {
+ saveBtn.disabled = false;
+ saveBtn.textContent = 'Save';
+ }
+}
+
function renderQuestionFooter(task, footer) {
+ // Prevent any tap inside the question footer from opening the detail panel.
+ footer.addEventListener('click', (e) => e.stopPropagation());
+
let question = { text: 'Waiting for your input.', options: [] };
if (task.question) {
try { question = JSON.parse(task.question); } catch {}
@@ -425,6 +629,25 @@ async function handleRestart(taskId, btn, footer) {
}
}
+async function handleResume(taskId, btn, footer) {
+ btn.disabled = true;
+ btn.textContent = 'Resuming…';
+ const prev = footer.querySelector('.task-error');
+ if (prev) prev.remove();
+
+ try {
+ await resumeTask(taskId);
+ await poll();
+ } catch (err) {
+ btn.disabled = false;
+ btn.textContent = 'Resume';
+ const errEl = document.createElement('span');
+ errEl.className = 'task-error';
+ errEl.textContent = `Failed: ${err.message}`;
+ footer.appendChild(errEl);
+ }
+}
+
// ── Accept / Reject actions ────────────────────────────────────────────────────
async function acceptTask(taskId) {
@@ -939,6 +1162,7 @@ function openTaskPanel(taskId) {
}
function closeTaskPanel() {
+ closeLogViewer();
document.getElementById('task-panel').classList.remove('open');
document.getElementById('task-panel-backdrop').hidden = true;
}
@@ -1114,7 +1338,10 @@ function renderTaskPanel(task, executions) {
const logsBtn = document.createElement('button');
logsBtn.className = 'btn-view-logs';
logsBtn.textContent = 'View Logs';
- logsBtn.addEventListener('click', () => handleViewLogs(exec.ID));
+ logsBtn.addEventListener('click', () => {
+ const panelContent = document.getElementById('task-panel-content');
+ openLogViewer(exec.ID, panelContent);
+ });
row.appendChild(logsBtn);
list.appendChild(row);
@@ -1201,7 +1428,7 @@ function openLogViewer(execId, containerEl) {
let userScrolled = false;
logOutput.addEventListener('scroll', () => {
const nearBottom = logOutput.scrollHeight - logOutput.scrollTop - logOutput.clientHeight < 50;
- if (!nearBottom) userScrolled = true;
+ userScrolled = !nearBottom;
});
const source = new EventSource(`${API_BASE}/api/executions/${execId}/logs/stream`);