@agenticmail/api 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5198,6 +5198,13 @@ function createDispatcherActivityRoutes() {
5198
5198
  existing.lastHeartbeatMs = Date.now();
5199
5199
  if (typeof body.lastTool === "string") existing.lastTool = body.lastTool;
5200
5200
  if (typeof body.turnCount === "number") existing.turnCount = body.turnCount;
5201
+ try {
5202
+ pushSystemEvent({
5203
+ type: "worker_heartbeat",
5204
+ worker: { ...existing }
5205
+ });
5206
+ } catch {
5207
+ }
5201
5208
  res.json({ ok: true });
5202
5209
  });
5203
5210
  router.get("/dispatcher/activity", requireMaster, (_req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "REST API server for AgenticMail — email and SMS endpoints for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/public/index.html CHANGED
@@ -39,6 +39,12 @@
39
39
  <button id="search-clear" class="search-clear-btn" title="Clear (Esc)" data-icon="close"></button>
40
40
  </div>
41
41
  <div class="topbar-right">
42
+ <!-- Real-time worker activity badges. Populated by SSE; one
43
+ badge per active dispatcher worker showing what each
44
+ agent is doing right now (reading, editing, running
45
+ shell, etc.). Empty when no workers are active. -->
46
+ <div id="activity-badges" class="activity-badges"></div>
47
+ <button class="icon-btn" id="sound-toggle-btn" title="Notification sound"></button>
42
48
  <button class="icon-btn" id="refresh-btn" title="Refresh (r)" data-icon="refresh"></button>
43
49
  <button id="profile-btn" class="profile-trigger" title="Account">
44
50
  <span id="profile-avatar"></span>
@@ -0,0 +1,132 @@
1
+ // Real-time worker activity badges in the topbar.
2
+ //
3
+ // The dispatcher posts worker_started / worker_heartbeat /
4
+ // worker_finished events to /system/events. This module
5
+ // subscribes (master-key auth), maintains a map of active
6
+ // workers, and paints a small badge per active worker
7
+ // between the search bar and the notification bell.
8
+ //
9
+ // Each badge shows: agent avatar/initial · friendly status
10
+ // derived from the last tool the worker invoked. Updates
11
+ // arrive at the heartbeat cadence (30 s) so the badge text
12
+ // reflects what the agent is doing right now.
13
+
14
+ import { state, API_URL } from './state.js';
15
+
16
+ const BADGE_CONTAINER_ID = 'activity-badges';
17
+
18
+ /**
19
+ * Map of workerId → { agentName, kind, lastTool, turnCount,
20
+ * startedAtMs }. Maintained off the SSE stream; rendered into
21
+ * the badge container on every event.
22
+ */
23
+ const workers = new Map();
24
+ let sseController = null;
25
+
26
+ /**
27
+ * Map an SDK tool name (or the truncated head we capture in
28
+ * dispatcher logs) to a short verb. Falls back to "working"
29
+ * when we don't recognise the tool. The mapping is intentionally
30
+ * generic — exotic tools default to "working" rather than
31
+ * leaking the raw tool name to a user-facing badge.
32
+ */
33
+ function statusFor(lastTool) {
34
+ if (!lastTool) return 'starting';
35
+ const t = lastTool.toLowerCase();
36
+ if (t.startsWith('read')) return 'reading';
37
+ if (t.startsWith('write')) return 'writing code';
38
+ if (t.startsWith('edit')) return 'editing code';
39
+ if (t.startsWith('bash')) return 'running shell';
40
+ if (t.startsWith('grep')) return 'searching';
41
+ if (t.startsWith('glob')) return 'searching';
42
+ if (t.startsWith('webfetch')) return 'fetching web';
43
+ if (t.startsWith('websearch')) return 'searching web';
44
+ if (t.startsWith('notebookedit')) return 'editing notebook';
45
+ if (t.includes('send_email')) return 'sending mail';
46
+ if (t.includes('reply_email')) return 'replying';
47
+ if (t.includes('read_email')) return 'reading mail';
48
+ if (t.includes('list_inbox')) return 'checking inbox';
49
+ if (t.includes('search_emails')) return 'searching mail';
50
+ if (t.includes('call_agent')) return 'delegating';
51
+ if (t.includes('submit_result')) return 'finishing';
52
+ if (t.includes('save_thread_memory')) return 'saving memory';
53
+ if (t.startsWith('mcp__')) return 'using tool';
54
+ return 'working';
55
+ }
56
+
57
+ function render() {
58
+ const root = document.getElementById(BADGE_CONTAINER_ID);
59
+ if (!root) return;
60
+ const list = Array.from(workers.values()).sort((a, b) => (a.startedAtMs ?? 0) - (b.startedAtMs ?? 0));
61
+ if (list.length === 0) { root.innerHTML = ''; return; }
62
+ root.innerHTML = list.map(w => {
63
+ const initial = (w.agentName ?? '?').slice(0, 1).toUpperCase();
64
+ const status = statusFor(w.lastTool);
65
+ const tooltip = `${w.agentName} — ${status}${w.turnCount ? ` · ${w.turnCount} tool calls` : ''}${w.lastTool ? `\nlast tool: ${w.lastTool}` : ''}`;
66
+ return `
67
+ <div class="activity-badge" title="${escapeAttr(tooltip)}" data-worker-id="${escapeAttr(w.workerId ?? '')}">
68
+ <span class="badge-dot"></span>
69
+ <span class="badge-initial">${escapeHtml(initial)}</span>
70
+ <span class="badge-name">${escapeHtml(w.agentName ?? '?')}</span>
71
+ <span class="badge-status">${escapeHtml(status)}</span>
72
+ </div>
73
+ `;
74
+ }).join('');
75
+ }
76
+
77
+ function handleEvent(event) {
78
+ if (!event || typeof event !== 'object') return;
79
+ const w = event.worker;
80
+ if (!w?.workerId) return;
81
+ if (event.type === 'worker_started' || event.type === 'worker_heartbeat') {
82
+ // Merge so a heartbeat-after-started preserves the start
83
+ // metadata without re-fetching.
84
+ const existing = workers.get(w.workerId) ?? {};
85
+ workers.set(w.workerId, { ...existing, ...w });
86
+ render();
87
+ } else if (event.type === 'worker_finished') {
88
+ workers.delete(w.workerId);
89
+ render();
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Subscribe to /system/events with the master key. The web UI
95
+ * already holds the master key in state.masterKey (set on
96
+ * sign-in). Re-subscribes idempotently — safe to call after
97
+ * agent-list refresh.
98
+ */
99
+ export function subscribeToActivity() {
100
+ if (sseController) { try { sseController.abort(); } catch {} }
101
+ sseController = new AbortController();
102
+ fetch(`${API_URL}/api/agenticmail/system/events`, {
103
+ headers: { Authorization: `Bearer ${state.masterKey}`, Accept: 'text/event-stream' },
104
+ signal: sseController.signal,
105
+ }).then(async res => {
106
+ if (!res.ok || !res.body) return;
107
+ const reader = res.body.getReader();
108
+ const dec = new TextDecoder();
109
+ let buf = '';
110
+ while (!sseController.signal.aborted) {
111
+ const { done, value } = await reader.read();
112
+ if (done) break;
113
+ buf += dec.decode(value, { stream: true });
114
+ let i;
115
+ while ((i = buf.indexOf('\n\n')) !== -1) {
116
+ const frame = buf.slice(0, i); buf = buf.slice(i + 2);
117
+ for (const line of frame.split('\n')) {
118
+ if (!line.startsWith('data: ')) continue;
119
+ try { handleEvent(JSON.parse(line.slice(6))); } catch {}
120
+ }
121
+ }
122
+ }
123
+ }).catch(() => { /* dropped — user can refresh to reconnect */ });
124
+ }
125
+
126
+ // Tiny HTML escapers (kept local to avoid an import cycle).
127
+ function escapeHtml(s) {
128
+ return String(s ?? '')
129
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
130
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
131
+ }
132
+ function escapeAttr(s) { return escapeHtml(s); }
package/public/js/app.js CHANGED
@@ -14,7 +14,9 @@ import { loadList, renderList, clearSearch, ensureFolderCache } from './list-vie
14
14
  import { openMessage } from './message-view.js';
15
15
  import { populateComposeFrom, openCompose, openDraft, closeCompose, discardCompose, sendCompose } from './compose.js';
16
16
  import { subscribeToAllAgents, maybeRequestNotificationPermission } from './sse.js';
17
+ import { subscribeToActivity } from './activity-badges.js';
17
18
  import { icon } from './icons.js';
19
+ import { isSoundEnabled, setSoundEnabled, playNotificationSound } from './sound.js';
18
20
 
19
21
  // Hydrate every `data-icon="name"` placeholder in the static HTML
20
22
  // with the corresponding inline SVG. Done once on load so we don't
@@ -97,6 +99,11 @@ async function bootstrap() {
97
99
  renderProfile();
98
100
  populateComposeFrom();
99
101
  subscribeToAllAgents();
102
+ // Real-time worker activity badges. Master-key-scoped SSE on
103
+ // /system/events; the dispatcher's worker_started /
104
+ // worker_heartbeat / worker_finished events drive the badge
105
+ // rendering. Idempotent — safe to call after bootstrap reruns.
106
+ subscribeToActivity();
100
107
  maybeRequestNotificationPermission();
101
108
  // Initial route: if the URL already has a hash (e.g. a refresh
102
109
  // on /#/folder/sent), respect it; otherwise default to inbox.
@@ -121,6 +128,8 @@ async function selectAgent(agent) {
121
128
  // account that uses different folder names (e.g. Gmail relay
122
129
  // vs vanilla Stalwart) keeps the previous cache.
123
130
  state.folderNames = {};
131
+ // Reset pagination — each inbox starts at page 1.
132
+ state.pagination = { offset: 0, limit: 50, total: 0 };
124
133
  // Discover folders BEFORE the first sidebar render so the
125
134
  // `requiresDiscovery` hide-rule (All Mail on non-Gmail servers)
126
135
  // has the cache to consult. Falls back to defaults on failure.
@@ -169,6 +178,10 @@ function route() {
169
178
  const folder = folderMatch ? folderMatch[1] : 'inbox';
170
179
  if (state.selectedFolder !== folder) {
171
180
  state.selectedFolder = folder;
181
+ // Reset pagination on every folder switch — a fresh folder
182
+ // starts at page 1. Preserved across silent SSE refreshes so
183
+ // a new arrival doesn't yank the user back from page 3.
184
+ state.pagination = { offset: 0, limit: 50, total: 0 };
172
185
  renderSidebar(onFolderSelect);
173
186
  }
174
187
  if (state.selectedAgent) loadList(state.selectedAgent, folder);
@@ -188,6 +201,27 @@ document.getElementById('sidebar-backdrop').addEventListener('click', () => {
188
201
  document.getElementById('main')?.classList.remove('sidebar-open');
189
202
  });
190
203
 
204
+ // Sound toggle. Stateful icon button — bell (on) / bell-slash (off).
205
+ // Clicking flips the preference (persisted to localStorage by the
206
+ // sound module), updates the icon, and plays a single test chime
207
+ // on transitions to ON so the user hears what they just enabled.
208
+ function renderSoundToggle() {
209
+ const btn = document.getElementById('sound-toggle-btn');
210
+ if (!btn) return;
211
+ const on = isSoundEnabled();
212
+ btn.innerHTML = icon(on ? 'soundOn' : 'soundOff', { size: 18 });
213
+ btn.title = on ? 'Notification sound: on (click to mute)' : 'Notification sound: off (click to enable)';
214
+ btn.classList.toggle('sound-on', on);
215
+ btn.classList.toggle('sound-off', !on);
216
+ }
217
+ document.getElementById('sound-toggle-btn')?.addEventListener('click', () => {
218
+ const next = !isSoundEnabled();
219
+ setSoundEnabled(next);
220
+ renderSoundToggle();
221
+ if (next) playNotificationSound(); // sample the chime on enable
222
+ });
223
+ renderSoundToggle();
224
+
191
225
  document.getElementById('refresh-btn').addEventListener('click', async () => {
192
226
  if (state.selectedAgent) {
193
227
  await loadList(state.selectedAgent, state.selectedFolder);
@@ -47,6 +47,13 @@ const PATHS = {
47
47
  // ─── Status / dots ──────────────────────────────────────────────
48
48
  dot: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z',
49
49
  check: 'M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z',
50
+ // Material-style notifications bell — used for the sound-on
51
+ // state. Off uses the same path with a diagonal slash overlay
52
+ // applied via CSS (.icon-svg.off → ::after rule in styles.css).
53
+ soundOn: 'M12 22a2 2 0 0 0 2-2h-4a2 2 0 0 0 2 2zm6-6V11c0-3.07-1.64-5.64-4.5-6.32V4a1.5 1.5 0 0 0-3 0v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z',
54
+ // Bell with a slash through it (off state). Single-path so the
55
+ // icon hot-swaps cleanly without dependency on overlay rules.
56
+ soundOff: 'M16.27 19.27 18 21l-1.27 1.27-1.45-1.45A1.98 1.98 0 0 1 14 22h-4a2 2 0 0 1-2-2h4a2 2 0 0 0 .27-.27L3 9.46 4.27 8.19 16.27 19.27zM18 16v-5a6 6 0 0 0-4.5-5.81V5a1.5 1.5 0 0 0-3 0v.19a6 6 0 0 0-2.16.91L18 16z',
50
57
  };
51
58
 
52
59
  export function icon(name, opts = {}) {
@@ -107,9 +107,19 @@ export async function loadList(agent, folder) {
107
107
  </div>
108
108
  <div class="list-toolbar-spacer"></div>
109
109
  <span class="count-text" id="list-count"></span>
110
+ <button class="icon-btn pager-btn" id="pager-prev" title="Newer" data-icon="back"></button>
111
+ <button class="icon-btn pager-btn" id="pager-next" title="Older"></button>
110
112
  </div>
111
113
  <div class="list-rows" id="list-rows"><div class="empty">Loading…</div></div>
112
114
  `;
115
+ // Pager buttons. "Newer" goes back to a lower offset (closer to
116
+ // the head of the inbox); "Older" advances to higher offsets.
117
+ // Each handler clamps to valid bounds and refetches via loadList
118
+ // with the new offset preserved in state.pagination.
119
+ document.getElementById('pager-next').innerHTML = icon('back', { size: 18 });
120
+ document.getElementById('pager-next').style.transform = 'rotate(180deg)';
121
+ document.getElementById('pager-prev')?.addEventListener('click', () => goToPage(agent, folder, -1));
122
+ document.getElementById('pager-next')?.addEventListener('click', () => goToPage(agent, folder, +1));
113
123
  document.getElementById('list-refresh-btn')?.addEventListener('click', () => loadList(agent, folder));
114
124
  document.getElementById('list-select-all-input')?.addEventListener('change', (e) => {
115
125
  const checked = e.target.checked;
@@ -152,9 +162,18 @@ export async function loadList(agent, folder) {
152
162
  // Previously we used `/mail/inbox` (no preview) and `/mail/
153
163
  // folders/:folder` (no preview, wrong folder names), which left
154
164
  // every row stuck on subject + sender alone.
155
- const url = `/mail/digest?folder=${encodeURIComponent(imap)}&limit=50&offset=0&previewLength=240`;
165
+ const { offset, limit } = state.pagination;
166
+ const url = `/mail/digest?folder=${encodeURIComponent(imap)}&limit=${limit}&offset=${offset}&previewLength=240`;
156
167
  const data = await apiGet(url, { agentKey: agent.apiKey });
157
168
  state.messages = data.messages ?? [];
169
+ // The digest endpoint returns `total` — use it for the pager.
170
+ // Fall back to (offset + page-size) when missing so the
171
+ // pager still functions in a degraded "we don't know the
172
+ // upper bound" mode (Next stays enabled until a fetch
173
+ // returns a short page).
174
+ state.pagination.total = typeof data?.total === 'number'
175
+ ? data.total
176
+ : (state.messages.length === limit ? offset + limit + 1 : offset + state.messages.length);
158
177
  renderList();
159
178
  } catch (err) {
160
179
  const msg = String(err.message ?? err);
@@ -188,7 +207,11 @@ function folderTitle(folder) {
188
207
  async function loadDraftsList(agent) {
189
208
  try {
190
209
  const data = await apiGet('/drafts', { agentKey: agent.apiKey });
191
- const rows = Array.isArray(data?.drafts) ? data.drafts : [];
210
+ const all = Array.isArray(data?.drafts) ? data.drafts : [];
211
+ const { offset, limit } = state.pagination;
212
+ // Drafts come from SQL as a single list; paginate client-side.
213
+ const rows = all.slice(offset, offset + limit);
214
+ state.pagination.total = all.length;
192
215
  state.messages = rows.map(r => ({
193
216
  // We store the draft id under `uid` so renderList +
194
217
  // click handlers can use the same field. Drafts also
@@ -214,6 +237,73 @@ async function loadDraftsList(agent) {
214
237
  }
215
238
  }
216
239
 
240
+ /**
241
+ * Advance the pager by one page in the given direction (-1 newer,
242
+ * +1 older). Clamps to valid bounds, then triggers loadList for the
243
+ * fresh offset. Bound to the prev/next toolbar buttons.
244
+ */
245
+ function goToPage(agent, folder, direction) {
246
+ const { offset, limit, total } = state.pagination;
247
+ const next = offset + direction * limit;
248
+ if (next < 0) return; // already at newest
249
+ if (next >= total && direction > 0) return; // already at oldest
250
+ state.pagination.offset = Math.max(0, next);
251
+ // Scroll to top so the user lands on the first row of the new
252
+ // page rather than at the bottom of where the previous page
253
+ // ended.
254
+ const rows = document.getElementById('list-rows');
255
+ if (rows) rows.scrollTop = 0;
256
+ loadList(agent, folder);
257
+ }
258
+
259
+ /**
260
+ * Refresh the currently-rendered list without rebuilding the
261
+ * toolbar. Used by the SSE new-mail handler so a new email
262
+ * silently slides into the list — no flicker, no "Loading…",
263
+ * no bulk-action selection wiped, no scroll jump.
264
+ *
265
+ * Drafts have their own loader; everything else uses the
266
+ * generic digest path. Falls through to a noop on errors
267
+ * (the SSE notification already pinged the user, so a silent
268
+ * refresh failure is acceptable — the next user-driven
269
+ * refresh / folder switch will repair).
270
+ */
271
+ export async function silentRefresh(agent, folder) {
272
+ if (!agent) return;
273
+ try {
274
+ const { offset, limit } = state.pagination;
275
+ if (folder === 'drafts') {
276
+ const data = await apiGet('/drafts', { agentKey: agent.apiKey });
277
+ const all = Array.isArray(data?.drafts) ? data.drafts : [];
278
+ state.pagination.total = all.length;
279
+ const rows = all.slice(offset, offset + limit);
280
+ state.messages = rows.map(r => ({
281
+ uid: r.id,
282
+ __draftId: r.id,
283
+ subject: r.subject || '(no subject)',
284
+ from: [{ name: agent.name, address: agent.email }],
285
+ date: r.updated_at ? `${r.updated_at}Z`.replace('ZZ', 'Z') : null,
286
+ preview: (r.text_body || '').slice(0, 240),
287
+ flags: [],
288
+ __recipient: r.to_addr || '(no recipient)',
289
+ }));
290
+ renderList();
291
+ return;
292
+ }
293
+ await ensureFolderCache(agent);
294
+ const isStarred = folder === 'starred';
295
+ const imap = isStarred ? 'INBOX' : (state.folderNames?.[folder]);
296
+ if (!imap) return;
297
+ const url = `/mail/digest?folder=${encodeURIComponent(imap)}&limit=${limit}&offset=${offset}&previewLength=240`;
298
+ const data = await apiGet(url, { agentKey: agent.apiKey });
299
+ state.messages = data.messages ?? [];
300
+ state.pagination.total = typeof data?.total === 'number'
301
+ ? data.total
302
+ : (state.messages.length === limit ? offset + limit + 1 : offset + state.messages.length);
303
+ renderList();
304
+ } catch { /* silent — next user action will repair */ }
305
+ }
306
+
217
307
  export function renderList() {
218
308
  const root = document.getElementById('list-rows');
219
309
  if (!root) return;
@@ -253,8 +343,23 @@ export function renderList() {
253
343
  } else if (hintEl) {
254
344
  hintEl.classList.remove('show');
255
345
  }
346
+ // Gmail-style "X-Y of Z" pager label + button enable/disable.
347
+ // We show the current PAGE range against the server-reported
348
+ // total, not just the filtered window — pagination is page-
349
+ // level; search is row-level filtering within the current page.
256
350
  const countEl = document.getElementById('list-count');
257
- if (countEl) countEl.textContent = `${filtered.length} of ${state.messages.length}`;
351
+ const { offset, limit, total } = state.pagination;
352
+ const pageStart = state.messages.length > 0 ? offset + 1 : 0;
353
+ const pageEnd = offset + state.messages.length;
354
+ if (countEl) {
355
+ countEl.textContent = total > 0
356
+ ? `${pageStart}–${pageEnd} of ${total}`
357
+ : (state.messages.length > 0 ? `${pageStart}–${pageEnd}` : '0');
358
+ }
359
+ const prevBtn = document.getElementById('pager-prev');
360
+ const nextBtn = document.getElementById('pager-next');
361
+ if (prevBtn) prevBtn.disabled = offset <= 0;
362
+ if (nextBtn) nextBtn.disabled = pageEnd >= total || state.messages.length < limit;
258
363
 
259
364
  if (filtered.length === 0) {
260
365
  root.innerHTML = q
@@ -0,0 +1,61 @@
1
+ // Notification sound — soft 2-note chime synthesised via Web Audio
2
+ // API (no external asset shipped, zero network cost). User
3
+ // preference (on/off) lives in localStorage.
4
+ //
5
+ // Why Web Audio and not an <audio src="...">: the asset would have
6
+ // to be bundled (cache invalidation, MIME types, paths under
7
+ // `/branding/`, etc.), and we'd still need code to gate playback
8
+ // on a user-toggle. Synthesizing a chime is one short function
9
+ // with no asset surface and lets us tweak the timbre by editing
10
+ // numbers.
11
+
12
+ const STORAGE_KEY = 'agenticmail.notif.soundEnabled';
13
+
14
+ /** True if the user has the chime turned on. Defaults to true. */
15
+ export function isSoundEnabled() {
16
+ // null = never set → default ON. 'false' string = explicitly off.
17
+ return localStorage.getItem(STORAGE_KEY) !== 'false';
18
+ }
19
+
20
+ /** Persist the user's choice. */
21
+ export function setSoundEnabled(enabled) {
22
+ try { localStorage.setItem(STORAGE_KEY, enabled ? 'true' : 'false'); } catch { /* private mode */ }
23
+ }
24
+
25
+ /**
26
+ * Play the new-mail chime. Two short sine pulses an octave apart
27
+ * (E5 → A5), 220 ms total, gain envelope quick attack + 60 ms
28
+ * decay so it reads as a soft "ding" rather than a buzz. Bails
29
+ * silently when sound is disabled or the browser blocks audio
30
+ * (e.g. tab hasn't received a user gesture yet — first arrival
31
+ * after a page load with no interaction may be muted by the
32
+ * autoplay policy; subsequent arrivals work).
33
+ */
34
+ export function playNotificationSound() {
35
+ if (!isSoundEnabled()) return;
36
+ try {
37
+ const Ctor = window.AudioContext || window.webkitAudioContext;
38
+ if (!Ctor) return;
39
+ const ctx = new Ctor();
40
+ const now = ctx.currentTime;
41
+ const playTone = (freq, startOffset, duration = 0.08) => {
42
+ const osc = ctx.createOscillator();
43
+ const gain = ctx.createGain();
44
+ osc.type = 'sine';
45
+ osc.frequency.value = freq;
46
+ const start = now + startOffset;
47
+ // Quick attack + exponential decay = "chime" not "beep".
48
+ gain.gain.setValueAtTime(0.0001, start);
49
+ gain.gain.exponentialRampToValueAtTime(0.18, start + 0.01);
50
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
51
+ osc.connect(gain).connect(ctx.destination);
52
+ osc.start(start);
53
+ osc.stop(start + duration);
54
+ };
55
+ playTone(659.25, 0); // E5
56
+ playTone(880.00, 0.12); // A5 — major sixth above
57
+ // Close the context shortly after the tones end so we don't
58
+ // leak audio contexts. Some browsers cap at ~6 concurrent.
59
+ setTimeout(() => { try { ctx.close(); } catch {} }, 600);
60
+ } catch { /* audio blocked; user-toggle is still respected */ }
61
+ }
package/public/js/sse.js CHANGED
@@ -1,13 +1,16 @@
1
1
  // Real-time mail delivery via Server-Sent Events. Every agent gets
2
2
  // its own subscription; the dispatcher pushes a `new` event per
3
3
  // arrived message. We fan that out to:
4
- // 1. List view — reload if it's the active inbox (instant ping)
4
+ // 1. List view — silent in-place refresh (no flicker, no scroll
5
+ // jump, no bulk-selection wipe) if it's the active inbox
5
6
  // 2. Profile dropdown — bump the per-agent unread counter
6
7
  // 3. Browser notification — system ping when the tab is in the background
8
+ // 4. Soft chime (toggleable) when sound is enabled
7
9
  import { state, API_URL } from './state.js';
8
10
  import { toast } from './utils.js';
9
11
  import { renderProfile } from './profile.js';
10
- import { loadList } from './list-view.js';
12
+ import { silentRefresh } from './list-view.js';
13
+ import { playNotificationSound } from './sound.js';
11
14
 
12
15
  export function subscribeToAllAgents() {
13
16
  // Tear down previous controllers (called on agent-list refresh).
@@ -49,11 +52,23 @@ async function handleSseEvent(agent, event) {
49
52
 
50
53
  const isOpen = state.selectedAgent?.id === agent.id;
51
54
  if (isOpen) {
52
- await loadList(agent, state.selectedFolder);
55
+ // Silent in-place refresh — re-fetches the list digest and
56
+ // re-renders ONLY the rows div. Toolbar (select-all, refresh,
57
+ // bulk-actions) is untouched; existing row checkboxes survive;
58
+ // scroll position is preserved by the browser since we replace
59
+ // only the inner content. No "Loading…" flicker.
60
+ await silentRefresh(agent, state.selectedFolder);
53
61
  state.unread[agent.id] = 0; // user is looking — clear badge
54
62
  renderProfile();
55
63
  }
56
64
 
65
+ // Soft chime — respects the user's sound toggle. Plays for every
66
+ // arrival regardless of whether the tab is focused, because that
67
+ // is the whole point of the chime (a foregrounded tab still
68
+ // benefits from the audible ping when the user's attention is
69
+ // elsewhere on screen).
70
+ playNotificationSound();
71
+
57
72
  fireBrowserNotification(agent, event, isOpen);
58
73
 
59
74
  if (!isOpen) {
@@ -24,6 +24,16 @@ export const state = {
24
24
  * showed.
25
25
  */
26
26
  folderNames: {}, // { [sidebarId]: imapFolderName }
27
+ /**
28
+ * Pagination state for the currently-rendered list. `offset` is
29
+ * the index of the FIRST message in the current view; `limit` is
30
+ * the page size; `total` is the server-reported total count for
31
+ * the folder (or the local row count for drafts). Reset to
32
+ * offset=0 on folder switch + agent switch; preserved across
33
+ * silent SSE refreshes so a new arrival doesn't yank the user
34
+ * back to page 1.
35
+ */
36
+ pagination: { offset: 0, limit: 50, total: 0 },
27
37
  };
28
38
 
29
39
  export const API_URL = window.location.origin;
package/public/styles.css CHANGED
@@ -159,6 +159,65 @@ a { color: var(--accent-strong); }
159
159
  color: var(--ink-soft); font-size: 18px;
160
160
  }
161
161
  .icon-btn:hover { background: var(--bg-hover); }
162
+ /* Sound-toggle state colours. On = brand pink (alive); off = muted
163
+ so the button reads as "currently silent". */
164
+ #sound-toggle-btn.sound-on { color: var(--pink); }
165
+ #sound-toggle-btn.sound-off { color: var(--muted); }
166
+
167
+ /* ─── Activity badges (live worker status) ───────────────────────
168
+ One pill per active dispatcher worker. Populated by SSE on the
169
+ `worker_started` / `worker_heartbeat` / `worker_finished`
170
+ events. Each badge shows the agent's initial + name + a short
171
+ verb derived from the worker's `lastTool`. The green dot
172
+ gently pulses so the row reads as "alive" rather than static. */
173
+ .activity-badges {
174
+ display: flex; align-items: center; gap: 6px;
175
+ max-width: 480px;
176
+ overflow-x: auto;
177
+ scrollbar-width: none;
178
+ margin-right: 4px;
179
+ }
180
+ .activity-badges::-webkit-scrollbar { display: none; }
181
+ .activity-badge {
182
+ display: inline-flex; align-items: center; gap: 6px;
183
+ padding: 4px 10px 4px 6px;
184
+ background: var(--bg);
185
+ border: 1px solid var(--line);
186
+ border-radius: 999px;
187
+ font-size: 12px;
188
+ color: var(--ink-soft);
189
+ white-space: nowrap;
190
+ cursor: default;
191
+ }
192
+ .activity-badge:hover { background: var(--bg-hover); }
193
+ .activity-badge .badge-dot {
194
+ width: 6px; height: 6px; border-radius: 50%;
195
+ background: #34a853; /* live-green */
196
+ flex-shrink: 0;
197
+ box-shadow: 0 0 0 0 rgba(52, 168, 83, 0.5);
198
+ animation: activity-pulse 2s infinite;
199
+ }
200
+ @keyframes activity-pulse {
201
+ 0% { box-shadow: 0 0 0 0 rgba(52, 168, 83, 0.5); }
202
+ 70% { box-shadow: 0 0 0 6px rgba(52, 168, 83, 0); }
203
+ 100% { box-shadow: 0 0 0 0 rgba(52, 168, 83, 0); }
204
+ }
205
+ .activity-badge .badge-initial {
206
+ display: inline-flex; align-items: center; justify-content: center;
207
+ width: 18px; height: 18px; border-radius: 50%;
208
+ background: var(--pink); color: white;
209
+ font-size: 10px; font-weight: 700;
210
+ flex-shrink: 0;
211
+ }
212
+ .activity-badge .badge-name { font-weight: 500; color: var(--ink); }
213
+ .activity-badge .badge-status { color: var(--muted); }
214
+ .activity-badge .badge-status::before { content: '· '; opacity: .6; }
215
+
216
+ /* Mobile — hide badges on narrow viewports; they fold to the
217
+ topbar's account avatar dropdown anyway. */
218
+ @media (max-width: 800px) {
219
+ .activity-badges { display: none; }
220
+ }
162
221
 
163
222
  /* ─── Profile (top right) ─────────────────────────────────────────── */
164
223
  .profile-trigger {
@@ -417,6 +476,19 @@ a { color: var(--accent-strong); }
417
476
  .list-toolbar-spacer { flex: 1; }
418
477
  .list-toolbar .count-text {
419
478
  font-size: 12px; color: var(--muted);
479
+ margin-right: 8px;
480
+ }
481
+ /* Gmail-style pager. Buttons inherit .icon-btn styling; disabled
482
+ state mutes the colour so the user can tell at a glance whether
483
+ prev/next is available without trying to click. */
484
+ .pager-btn {
485
+ width: 32px; height: 32px;
486
+ color: var(--ink-soft);
487
+ }
488
+ .pager-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--ink); }
489
+ .pager-btn:disabled {
490
+ color: var(--line);
491
+ cursor: default;
420
492
  }
421
493
 
422
494
  /* Bulk-action toolbar — appears between select-all and refresh