diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/app.js | 187 | ||||
| -rw-r--r-- | web/index.html | 33 | ||||
| -rw-r--r-- | web/style.css | 55 | ||||
| -rw-r--r-- | web/test/task-actions.test.mjs | 14 |
4 files changed, 38 insertions, 251 deletions
@@ -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'); + }); }); |
