summaryrefslogtreecommitdiff
path: root/web/app.js
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-16 20:43:28 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-16 20:43:28 +0000
commit7f920ca63af5329c19a0e5a879c649c594beea35 (patch)
tree803150e7c895d3232bad35c729aad647aaa54348 /web/app.js
parent072652f617653dce74368cedb42b88189e5014fb (diff)
feat: add web push notifications and file drop
Web Push: - WebPushNotifier with VAPID auth; urgency mapped to event type (BLOCKED=urgent, FAILED=high, COMPLETED=low) - Auto-generates VAPID keys on first serve, persists to config file - push_subscriptions table in SQLite (upsert by endpoint) - GET /api/push/vapid-key, POST/DELETE /api/push/subscribe endpoints - Service worker (sw.js) handles push events and notification clicks - Notification bell button in web UI; subscribes on click File Drop: - GET /api/drops, GET /api/drops/{filename}, POST /api/drops - Persistent ~/.claudomator/drops/ directory - CLAUDOMATOR_DROP_DIR env var passed to agent subprocesses - Drops tab (πŸ“) in web UI with file listing and download links Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web/app.js')
-rw-r--r--web/app.js127
1 files changed, 127 insertions, 0 deletions
diff --git a/web/app.js b/web/app.js
index 9708727..cdc0da3 100644
--- a/web/app.js
+++ b/web/app.js
@@ -1165,6 +1165,9 @@ function renderActiveTab(allTasks) {
.then(execs => renderStatsPanel(allTasks, execs))
.catch(() => {});
break;
+ case 'drops':
+ renderDropsPanel();
+ break;
case 'settings':
renderSettingsPanel();
break;
@@ -2591,6 +2594,124 @@ function renderStatsPanel(tasks, executions) {
panel.appendChild(execSection);
}
+// ── Web Push Notifications ────────────────────────────────────────────────────
+
+async function registerServiceWorker() {
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;
+ return navigator.serviceWorker.register('/sw.js');
+}
+
+function urlBase64ToUint8Array(base64String) {
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
+ const rawData = window.atob(base64);
+ return Uint8Array.from([...rawData].map(c => c.charCodeAt(0)));
+}
+
+async function enableNotifications(btn) {
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
+ alert('Push notifications are not supported in this browser.');
+ return;
+ }
+ try {
+ const permission = await Notification.requestPermission();
+ if (permission !== 'granted') {
+ alert('Notification permission denied.');
+ return;
+ }
+
+ // Fetch VAPID public key.
+ const keyRes = await fetch(`${API_BASE}/api/push/vapid-key`);
+ if (!keyRes.ok) throw new Error(`Failed to get VAPID key: HTTP ${keyRes.status}`);
+ const { public_key: vapidKey } = await keyRes.json();
+
+ // Register service worker.
+ const registration = await registerServiceWorker();
+ if (!registration) {
+ alert('Service worker registration failed.');
+ return;
+ }
+
+ // Subscribe via PushManager.
+ const subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(vapidKey),
+ });
+
+ const subJSON = subscription.toJSON();
+ // POST subscription to server.
+ const res = await fetch(`${API_BASE}/api/push/subscribe`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ endpoint: subJSON.endpoint,
+ keys: { p256dh: subJSON.keys.p256dh, auth: subJSON.keys.auth },
+ }),
+ });
+ if (!res.ok) throw new Error(`Subscribe failed: HTTP ${res.status}`);
+
+ if (btn) {
+ btn.textContent = 'πŸ”” On';
+ btn.disabled = true;
+ }
+ } catch (err) {
+ alert(`Notification setup failed: ${err.message}`);
+ }
+}
+
+// ── File Drops ─────────────────────────────────────────────────────────────────
+
+async function fetchDrops() {
+ const res = await fetch(`${API_BASE}/api/drops`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
+async function renderDropsPanel() {
+ const panel = document.querySelector('[data-panel="drops"] .drops-panel');
+ if (!panel) return;
+ panel.innerHTML = '<p class="task-meta">Loading drops…</p>';
+
+ try {
+ const files = await fetchDrops();
+ panel.innerHTML = '';
+
+ const heading = document.createElement('h3');
+ heading.style.padding = '1rem 1rem 0.5rem';
+ heading.textContent = 'Dropped Files';
+ panel.appendChild(heading);
+
+ if (files.length === 0) {
+ const empty = document.createElement('p');
+ empty.className = 'task-meta';
+ empty.style.padding = '0 1rem';
+ empty.textContent = 'No files dropped yet. Agents can write files to the drops directory to share them here.';
+ panel.appendChild(empty);
+ } else {
+ const list = document.createElement('ul');
+ list.style.cssText = 'list-style:none;padding:0 1rem;margin:0';
+ for (const f of files) {
+ const li = document.createElement('li');
+ li.style.cssText = 'padding:0.5rem 0;border-bottom:1px solid var(--border,#e5e7eb)';
+ const a = document.createElement('a');
+ a.href = `${API_BASE}/api/drops/${encodeURIComponent(f.name)}`;
+ a.textContent = f.name;
+ a.download = f.name;
+ a.style.cssText = 'color:var(--accent,#2563eb);text-decoration:none';
+ const meta = document.createElement('span');
+ meta.className = 'task-meta';
+ meta.style.cssText = 'margin-left:1rem';
+ meta.textContent = `${(f.size / 1024).toFixed(1)} KB`;
+ li.append(a, meta);
+ list.appendChild(li);
+ }
+ panel.appendChild(list);
+ }
+ } catch (err) {
+ panel.innerHTML = `<p class="task-meta" style="padding:1rem">Failed to load drops: ${err.message}</p>`;
+ }
+}
+
// ── Tab switching ─────────────────────────────────────────────────────────────
function switchTab(name) {
@@ -2642,6 +2763,12 @@ if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded
document.getElementById('btn-cancel-task').addEventListener('click', closeTaskModal);
initProjectSelect();
+ // Push notifications button
+ const btnNotify = document.getElementById('btn-notifications');
+ if (btnNotify) {
+ btnNotify.addEventListener('click', () => enableNotifications(btnNotify));
+ }
+
// Validate button
document.getElementById('btn-validate').addEventListener('click', async () => {
const btn = document.getElementById('btn-validate');