summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-09 01:17:05 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-09 01:17:05 +0000
commit13846ea4ce4bacfadda6f86c5e48e5e76e13db07 (patch)
tree20838cbcbc343e8cd8b8f6bb0f8ad347c3fa52b3 /web
parentf9d2056f4d10fe7dde71f5d17d8e8c473b3a611f (diff)
feat: delete templates feature and allow requeueing BUDGET_EXCEEDED tasks
Removed all template-related code from frontend (tabs, modals, logic) and backend (routes, files, DB table). Updated BUDGET_EXCEEDED tasks to be requeueable with a Restart button. Fixed ReferenceError in isUserEditing for Node.js tests.
Diffstat (limited to 'web')
-rw-r--r--web/app.js187
-rw-r--r--web/index.html33
-rw-r--r--web/style.css55
-rw-r--r--web/test/task-actions.test.mjs14
4 files changed, 38 insertions, 251 deletions
diff --git a/web/app.js b/web/app.js
index 0049522..3ff4809 100644
--- a/web/app.js
+++ b/web/app.js
@@ -9,12 +9,6 @@ async function fetchTasks() {
return res.json();
}
-async function fetchTemplates() {
- const res = await fetch(`${API_BASE}/api/templates`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- return res.json();
-}
-
// Fetches recent executions (last 24h) from /api/executions?since=24h.
// fetchFn defaults to window.fetch; injectable for tests.
async function fetchRecentExecutions(basePath = BASE_PATH, fetchFn = fetch) {
@@ -215,6 +209,35 @@ function createTaskCard(task) {
return card;
}
+/**
+ * Returns true if the user is currently editing a text field or has a modal open.
+ * Used to avoid destructive DOM refreshes during polling.
+ */
+export function isUserEditing(activeEl = (typeof document !== 'undefined' ? document.activeElement : null)) {
+ if (!activeEl) return false;
+ const tag = activeEl.tagName;
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
+ if (activeEl.isContentEditable) return true;
+ if (activeEl.closest('[role="dialog"]') || activeEl.closest('dialog')) return true;
+ return false;
+}
+
+/**
+ * Partitions tasks into 'running' and 'ready' arrays for the Active tab view.
+ * Both arrays are sorted by created_at ascending (oldest first).
+ */
+export function partitionActivePaneTasks(tasks) {
+ const running = tasks
+ .filter(t => t.state === 'RUNNING')
+ .sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
+
+ const ready = tasks
+ .filter(t => t.state === 'READY')
+ .sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
+
+ return { running, ready };
+}
+
// ── Sort ──────────────────────────────────────────────────────────────────────
function sortTasksByDate(tasks, descend = false) {
@@ -330,62 +353,6 @@ function renderActiveTaskList(tasks) {
}
}
-function createTemplateCard(tmpl) {
- const card = document.createElement('div');
- card.className = 'template-card';
-
- const name = document.createElement('div');
- name.className = 'template-name';
- name.textContent = tmpl.name;
- card.appendChild(name);
-
- if (tmpl.description) {
- const desc = document.createElement('div');
- desc.className = 'template-description';
- desc.textContent = tmpl.description;
- card.appendChild(desc);
- }
-
- if (tmpl.tags && tmpl.tags.length > 0) {
- const tagsEl = document.createElement('div');
- tagsEl.className = 'template-tags';
- for (const tag of tmpl.tags) {
- const chip = document.createElement('span');
- chip.className = 'tag-chip';
- chip.textContent = tag;
- tagsEl.appendChild(chip);
- }
- card.appendChild(tagsEl);
- }
-
- const footer = document.createElement('div');
- footer.className = 'template-card-footer';
-
- const delBtn = document.createElement('button');
- delBtn.className = 'btn-danger btn-sm';
- delBtn.textContent = 'Delete';
- delBtn.addEventListener('click', () => deleteTemplate(tmpl.id));
-
- footer.appendChild(delBtn);
- card.appendChild(footer);
-
- return card;
-}
-
-function renderTemplateList(templates) {
- const container = document.querySelector('.template-list');
-
- if (!templates || templates.length === 0) {
- container.innerHTML = '<div id="loading">No templates yet.</div>';
- return;
- }
-
- container.innerHTML = '';
- for (const tmpl of templates) {
- container.appendChild(createTemplateCard(tmpl));
- }
-}
-
// ── Run action ────────────────────────────────────────────────────────────────
async function runTask(taskId) {
@@ -839,23 +806,6 @@ async function handleStartNextTask(btn) {
}
}
-// ── Delete template ────────────────────────────────────────────────────────────
-
-async function deleteTemplate(id) {
- if (!window.confirm('Delete this template?')) return;
-
- const res = await fetch(`${API_BASE}/api/templates/${id}`, { method: 'DELETE' });
- if (!res.ok) {
- let msg = `HTTP ${res.status}`;
- try { const body = await res.json(); msg = body.error || body.message || msg; } catch {}
- alert(`Failed to delete: ${msg}`);
- return;
- }
-
- const templates = await fetchTemplates();
- renderTemplateList(templates);
-}
-
// ── Polling ───────────────────────────────────────────────────────────────────
async function poll() {
@@ -1212,50 +1162,6 @@ async function createTask(formData) {
renderTaskList(tasks);
}
-// ── Template modal ────────────────────────────────────────────────────────────
-
-function openTemplateModal() {
- document.getElementById('template-modal').showModal();
-}
-
-function closeTemplateModal() {
- document.getElementById('template-modal').close();
- document.getElementById('template-form').reset();
-}
-
-async function saveTemplate(formData) {
- const splitTrim = val => val.split(',').map(s => s.trim()).filter(Boolean);
-
- const body = {
- name: formData.get('name'),
- description: formData.get('description'),
- agent: {
- instructions: formData.get('instructions'),
- project_dir: formData.get('project_dir'),
- max_budget_usd: parseFloat(formData.get('max_budget_usd')),
- allowed_tools: splitTrim(formData.get('allowed_tools') || ''),
- },
- timeout: formData.get('timeout'),
- priority: formData.get('priority'),
- tags: splitTrim(formData.get('tags') || ''),
- };
-
- const res = await fetch(`${API_BASE}/api/templates`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
-
- if (!res.ok) {
- const text = await res.text();
- throw new Error(text || `HTTP ${res.status}`);
- }
-
- closeTemplateModal();
- const templates = await fetchTemplates();
- renderTemplateList(templates);
-}
-
// ── Task side panel ───────────────────────────────────────────────────────────
// Format Go's task.Duration JSON value {"Duration": <nanoseconds>} to human string.
@@ -1966,13 +1872,6 @@ function switchTab(name) {
document.getElementById('btn-new-task').style.display =
name === 'tasks' ? '' : 'none';
- if (name === 'templates') {
- fetchTemplates().then(renderTemplateList).catch(() => {
- document.querySelector('.template-list').innerHTML =
- '<div id="loading">Could not reach server.</div>';
- });
- }
-
if (name === 'running') {
fetchTasks().then(renderRunningView).catch(() => {
const currentEl = document.querySelector('.running-current');
@@ -2158,32 +2057,4 @@ if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded
btn.textContent = 'Create & Queue';
}
});
-
- // Template modal
- document.getElementById('btn-new-template').addEventListener('click', openTemplateModal);
- document.getElementById('btn-cancel-template').addEventListener('click', closeTemplateModal);
-
- document.getElementById('template-form').addEventListener('submit', async e => {
- e.preventDefault();
-
- // Remove any previous error
- const prev = e.target.querySelector('.form-error');
- if (prev) prev.remove();
-
- const btn = e.submitter;
- btn.disabled = true;
- btn.textContent = 'Saving…';
-
- try {
- await saveTemplate(new FormData(e.target));
- } catch (err) {
- const errEl = document.createElement('p');
- errEl.className = 'form-error';
- errEl.textContent = err.message;
- e.target.appendChild(errEl);
- } finally {
- btn.disabled = false;
- btn.textContent = 'Save Template';
- }
- });
});
diff --git a/web/index.html b/web/index.html
index ad79cee..0b4ee35 100644
--- a/web/index.html
+++ b/web/index.html
@@ -16,7 +16,6 @@
</header>
<nav class="tab-bar">
<button class="tab active" data-tab="tasks">Tasks</button>
- <button class="tab" data-tab="templates">Templates</button>
<button class="tab" data-tab="active">Active</button>
<button class="tab" data-tab="running">Running</button>
</nav>
@@ -32,13 +31,6 @@
<div id="loading">Loading tasks…</div>
</div>
</div>
- <div data-panel="templates" hidden>
- <div class="panel-header">
- <h2>Templates</h2>
- <button id="btn-new-template" class="btn-primary">New Template</button>
- </div>
- <div class="template-list"></div>
- </div>
<div data-panel="active" hidden>
<div class="active-task-list"></div>
</div>
@@ -95,31 +87,6 @@
</form>
</dialog>
- <dialog id="template-modal">
- <form id="template-form" method="dialog">
- <h2>New Template</h2>
- <label>Name <input name="name" required></label>
- <label>Description <textarea name="description" rows="2"></textarea></label>
- <label>Instructions <textarea name="instructions" rows="6" required></textarea></label>
- <label>Project Directory <input name="project_dir" placeholder="/path/to/repo"></label>
- <label>Max Budget (USD) <input name="max_budget_usd" type="number" step="0.01" value="1.00"></label>
- <label>Allowed Tools <input name="allowed_tools" placeholder="Bash, Read, Write"></label>
- <label>Timeout <input name="timeout" value="15m"></label>
- <label>Priority
- <select name="priority">
- <option value="normal" selected>Normal</option>
- <option value="high">High</option>
- <option value="low">Low</option>
- </select>
- </label>
- <label>Tags <input name="tags" placeholder="ci, daily"></label>
- <div class="form-actions">
- <button type="button" id="btn-cancel-template">Cancel</button>
- <button type="submit" class="btn-primary">Save Template</button>
- </div>
- </form>
- </dialog>
-
<!-- Side panel backdrop -->
<div id="task-panel-backdrop" class="panel-backdrop" hidden></div>
diff --git a/web/style.css b/web/style.css
index 9cfe140..2b872fe 100644
--- a/web/style.css
+++ b/web/style.css
@@ -517,61 +517,6 @@ dialog label select:focus {
margin-top: 8px;
}
-/* Template list */
-.template-list {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
-}
-
-/* Template card */
-.template-card {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: 0.5rem;
- padding: 0.875rem 1rem;
- display: flex;
- flex-direction: column;
- gap: 0.375rem;
-}
-
-.template-name {
- font-weight: 600;
- font-size: 0.95rem;
-}
-
-.template-description {
- font-size: 0.82rem;
- color: var(--text-muted);
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.template-tags {
- display: flex;
- flex-wrap: wrap;
- gap: 0.375rem;
- margin-top: 0.125rem;
-}
-
-.tag-chip {
- font-size: 0.7rem;
- font-weight: 600;
- padding: 0.2em 0.55em;
- border-radius: 999px;
- background: var(--border);
- color: var(--text-muted);
-}
-
-.template-card-footer {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- margin-top: 0.25rem;
-}
-
/* ── Side panel ───────────────────────────────────────────────────────────── */
.panel-backdrop {
diff --git a/web/test/task-actions.test.mjs b/web/test/task-actions.test.mjs
index 36c0e8b..c7d666b 100644
--- a/web/test/task-actions.test.mjs
+++ b/web/test/task-actions.test.mjs
@@ -7,7 +7,7 @@ import assert from 'node:assert/strict';
// ── Logic under test ──────────────────────────────────────────────────────────
-const RESTART_STATES = new Set(['FAILED', 'CANCELLED']);
+const RESTART_STATES = new Set(['FAILED', 'CANCELLED', 'BUDGET_EXCEEDED']);
function getCardAction(state) {
if (state === 'PENDING') return 'run';
@@ -47,6 +47,10 @@ describe('task card action buttons', () => {
assert.equal(getCardAction('CANCELLED'), 'restart');
});
+ it('shows Restart button for BUDGET_EXCEEDED', () => {
+ assert.equal(getCardAction('BUDGET_EXCEEDED'), 'restart');
+ });
+
it('shows approve buttons for READY', () => {
assert.equal(getCardAction('READY'), 'approve');
});
@@ -58,10 +62,6 @@ describe('task card action buttons', () => {
it('shows no button for QUEUED', () => {
assert.equal(getCardAction('QUEUED'), null);
});
-
- it('shows no button for BUDGET_EXCEEDED', () => {
- assert.equal(getCardAction('BUDGET_EXCEEDED'), null);
- });
});
describe('task action API endpoints', () => {
@@ -76,4 +76,8 @@ describe('task action API endpoints', () => {
it('CANCELLED uses /run endpoint', () => {
assert.equal(getApiEndpoint('CANCELLED'), '/run');
});
+
+ it('BUDGET_EXCEEDED uses /run endpoint', () => {
+ assert.equal(getApiEndpoint('BUDGET_EXCEEDED'), '/run');
+ });
});