summaryrefslogtreecommitdiff
path: root/web/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'web/app.js')
-rw-r--r--web/app.js212
1 files changed, 190 insertions, 22 deletions
diff --git a/web/app.js b/web/app.js
index 8d78f90..a661fe7 100644
--- a/web/app.js
+++ b/web/app.js
@@ -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);