@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 +7 -0
- package/package.json +1 -1
- package/public/index.html +6 -0
- package/public/js/activity-badges.js +132 -0
- package/public/js/app.js +34 -0
- package/public/js/icons.js +7 -0
- package/public/js/list-view.js +108 -3
- package/public/js/sound.js +61 -0
- package/public/js/sse.js +18 -3
- package/public/js/state.js +10 -0
- package/public/styles.css +72 -0
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
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
130
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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);
|
package/public/js/icons.js
CHANGED
|
@@ -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 = {}) {
|
package/public/js/list-view.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 —
|
|
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 {
|
|
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
|
-
|
|
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) {
|
package/public/js/state.js
CHANGED
|
@@ -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
|