diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/app.js | 212 | ||||
| -rw-r--r-- | web/index.html | 2 |
2 files changed, 191 insertions, 23 deletions
@@ -3,8 +3,12 @@ const API_BASE = (typeof window !== 'undefined') ? window.location.origin + BASE // ── Fetch ───────────────────────────────────────────────────────────────────── -async function fetchTasks() { - const res = await fetch(`${API_BASE}/api/tasks`); +async function fetchTasks(since = null) { + let url = `${API_BASE}/api/tasks`; + if (since) { + url += `?since=${encodeURIComponent(since)}`; + } + const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } @@ -375,17 +379,26 @@ export function computeTabBadgeCounts(tasks) { let interrupted = 0; let ready = 0; let running = 0; + let all = 0; + const now = Date.now(); + const twentyFourHoursAgo = now - 24 * 60 * 60 * 1000; + for (const t of tasks) { if (INTERRUPTED_STATES.has(t.state)) interrupted++; if (t.state === 'READY') ready++; if (t.state === 'RUNNING') running++; + if (DONE_STATES.has(t.state)) { + if (!t.created_at || new Date(t.created_at).getTime() > twentyFourHoursAgo) { + all++; + } + } } - return { interrupted, ready, running }; + return { interrupted, ready, running, all }; } /** * Updates the badge count spans inside the tab buttons for - * 'interrupted', 'ready', and 'running'. + * 'interrupted', 'ready', 'running', and 'all'. * Badge is hidden (display:none) when count is zero. */ export function updateTabBadges(tasks, doc = (typeof document !== 'undefined' ? document : null)) { @@ -481,15 +494,60 @@ function updateToggleButton() { } // Shared helper: renders an array of tasks as cards into a container element. +// Now updated to be non-destructive by reusing/updating existing task-card elements. function renderTasksIntoContainer(tasks, container, emptyMsg) { if (!tasks || tasks.length === 0) { container.innerHTML = `<div class="task-empty">${emptyMsg}</div>`; return; } - container.innerHTML = ''; - for (const task of tasks) { - container.appendChild(createTaskCard(task)); + + // Remove empty message if it exists + const empty = container.querySelector('.task-empty'); + if (empty) empty.remove(); + + const existingCards = new Map(); + container.querySelectorAll('.task-card').forEach(card => { + existingCards.set(card.dataset.taskId, card); + }); + + const taskIds = new Set(tasks.map(t => t.id)); + + // Remove cards for tasks no longer in this list + for (const [id, card] of existingCards.entries()) { + if (!taskIds.has(id)) { + card.remove(); + existingCards.delete(id); + } } + + // Create or update cards and maintain order + tasks.forEach((task, index) => { + let card = existingCards.get(task.id); + const newCard = createTaskCard(task); + + if (card) { + // If the content is exactly the same, we could skip replacing, + // but createTaskCard is fast and ensures we have the latest state. + // We replace the card in-place to preserve its position if possible. + if (card.innerHTML !== newCard.innerHTML) { + // Special case: if user is interacting with THIS card, we might want to skip or merge. + // For now, createTaskCard ensures we don't disrupt if NOT editing. + container.replaceChild(newCard, card); + } + } else { + // Append new card + container.appendChild(newCard); + } + }); + + // Re-sort cards in DOM to match task list order if they were out of sync + const currentCards = Array.from(container.querySelectorAll('.task-card')); + tasks.forEach((task, index) => { + const card = container.querySelector(`[data-task-id="${task.id}"]`); + if (container.children[index] !== card) { + container.insertBefore(card, container.children[index]); + } + }); } function renderQueuePanel(tasks) { @@ -1017,31 +1075,63 @@ async function handleStartNextTask(btn) { // ── Polling ─────────────────────────────────────────────────────────────────── +let taskCache = new Map(); +let lastServerUpdate = null; +let pollTimeout = null; +let lastUserInteraction = Date.now(); + function getActiveTab() { const active = document.querySelector('.tab.active'); return active ? active.dataset.tab : 'queue'; } +function getRefreshInterval() { + const stored = localStorage.getItem('refreshInterval'); + return stored ? parseInt(stored, 10) : 10_000; +} + +async function fetchHealth() { + const res = await fetch(`${API_BASE}/api/health`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + async function poll() { try { - const tasks = await fetchTasks(); + const health = await fetchHealth(); + const serverUpdate = health.last_updated; + + // If server says nothing changed, we skip fetching tasks. + if (lastServerUpdate && serverUpdate <= lastServerUpdate && taskCache.size > 0) { + return; + } + + const tasks = await fetchTasks(lastServerUpdate); + lastServerUpdate = serverUpdate; + + // Update cache with new/changed tasks + for (const t of tasks) { + taskCache.set(t.id, t); + } + if (isUserEditing()) return; - updateTabBadges(tasks); + const allTasks = Array.from(taskCache.values()); + updateTabBadges(allTasks); const activeTab = getActiveTab(); switch (activeTab) { case 'queue': - renderQueuePanel(tasks); + renderQueuePanel(allTasks); break; case 'interrupted': - renderInterruptedPanel(tasks); + renderInterruptedPanel(allTasks); break; case 'ready': - renderReadyPanel(tasks); + renderReadyPanel(allTasks); break; case 'running': - renderRunningView(tasks); + renderRunningView(allTasks); fetchRecentExecutions(BASE_PATH, fetch) .then(execs => renderRunningHistory(execs)) .catch(() => { @@ -1050,29 +1140,106 @@ async function poll() { }); break; case 'all': - renderAllPanel(tasks); + renderAllPanel(allTasks); break; case 'stats': fetchRecentExecutions(BASE_PATH, fetch) - .then(execs => renderStatsPanel(tasks, execs)) + .then(execs => renderStatsPanel(allTasks, execs)) .catch(() => {}); break; case 'settings': - // nothing to render + renderSettingsPanel(); break; } - } catch { + } catch (err) { + console.error('Polling failed:', err); const panel = document.querySelector('[data-panel="queue"] .panel-task-list'); - if (panel) panel.innerHTML = '<div class="task-empty">Could not reach server.</div>'; + if (panel && taskCache.size === 0) { + panel.innerHTML = '<div class="task-empty">Could not reach server.</div>'; + } } } -function startPolling(intervalMs = 10_000) { - poll(); - setInterval(poll, intervalMs); +function startPolling() { + if (pollTimeout) clearTimeout(pollTimeout); + + const runPoll = async () => { + const interval = getRefreshInterval(); + if (interval > 0) { + const now = Date.now(); + const timeSinceInteraction = now - lastUserInteraction; + + // If user is active, we might want to delay polling slightly, + // but for now we just follow the interval if not editing. + if (!isUserEditing()) { + await poll(); + } + } + pollTimeout = setTimeout(runPoll, getRefreshInterval() || 10_000); + }; + + runPoll(); } +// Reset timer on interaction +if (typeof window !== 'undefined') { + ['mousedown', 'keydown', 'touchstart', 'mousemove'].forEach(evt => { + window.addEventListener(evt, () => { + lastUserInteraction = Date.now(); + }, { passive: true }); + }); +} + + +function renderSettingsPanel() { + const panel = document.querySelector('[data-panel="settings"]'); + if (!panel) return; + + panel.innerHTML = ''; + const section = document.createElement('div'); + section.className = 'stats-section'; + section.style.padding = '1rem'; + + const heading = document.createElement('h2'); + heading.textContent = 'User Settings'; + section.appendChild(heading); + + const refreshLabel = document.createElement('label'); + refreshLabel.style.display = 'block'; + refreshLabel.style.marginBottom = '0.5rem'; + refreshLabel.textContent = 'Auto-Refresh Interval'; + + const refreshSelect = document.createElement('select'); + refreshSelect.className = 'agent-selector'; + refreshSelect.style.width = '100%'; + + const options = [ + { label: '5 seconds', value: '5000' }, + { label: '10 seconds (default)', value: '10000' }, + { label: '30 seconds', value: '30000' }, + { label: '1 minute', value: '60000' }, + { label: 'Manual only', value: '0' }, + ]; + + const current = String(getRefreshInterval()); + options.forEach(opt => { + const o = document.createElement('option'); + o.value = opt.value; + o.textContent = opt.label; + if (opt.value === current) o.selected = true; + refreshSelect.appendChild(o); + }); + + refreshSelect.addEventListener('change', () => { + localStorage.setItem('refreshInterval', refreshSelect.value); + startPolling(); // restart with new interval + }); + + refreshLabel.appendChild(refreshSelect); + section.appendChild(refreshLabel); + panel.appendChild(section); +} // ── WebSocket (real-time events) ────────────────────────────────────────────── @@ -1104,7 +1271,8 @@ function connectWebSocket() { function handleWsEvent(data) { switch (data.type) { case 'task_completed': - poll(); // refresh task list + // Force a poll immediately regardless of interval + poll(); break; case 'task_question': showQuestionBanner(data); diff --git a/web/index.html b/web/index.html index 59bc56e..1746baf 100644 --- a/web/index.html +++ b/web/index.html @@ -26,7 +26,7 @@ <button class="tab" data-tab="interrupted" title="Interrupted">⚠️<span class="tab-count-badge" hidden></span></button> <button class="tab" data-tab="ready" title="Ready">✅<span class="tab-count-badge" hidden></span></button> <button class="tab" data-tab="running" title="Running">▶️<span class="tab-count-badge" hidden></span></button> - <button class="tab" data-tab="all" title="All">☰</button> + <button class="tab" data-tab="all" title="All">☰<span class="tab-count-badge" hidden></span></button> <button class="tab" data-tab="stats" title="Stats">📊</button> <button class="tab" data-tab="settings" title="Settings">⚙️</button> </nav> |
