@agenticmail/api 0.9.3 → 0.9.5
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 +2 -2
- package/public/index.html +5 -0
- package/public/js/activity-badges.js +132 -0
- package/public/js/app.js +12 -0
- package/public/js/list-view.js +68 -5
- package/public/js/state.js +10 -0
- package/public/styles.css +68 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agenticmail/api",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
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",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"uuid": "^11.1.0"
|
|
37
37
|
},
|
|
38
38
|
"optionalDependencies": {
|
|
39
|
-
"@agenticmail/claudecode": "^0.2.
|
|
39
|
+
"@agenticmail/claudecode": "^0.2.4"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/cors": "^2.8.17",
|
package/public/index.html
CHANGED
|
@@ -39,6 +39,11 @@
|
|
|
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>
|
|
42
47
|
<button class="icon-btn" id="sound-toggle-btn" title="Notification sound"></button>
|
|
43
48
|
<button class="icon-btn" id="refresh-btn" title="Refresh (r)" data-icon="refresh"></button>
|
|
44
49
|
<button id="profile-btn" class="profile-trigger" title="Account">
|
|
@@ -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,6 +14,7 @@ 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';
|
|
18
19
|
import { isSoundEnabled, setSoundEnabled, playNotificationSound } from './sound.js';
|
|
19
20
|
|
|
@@ -98,6 +99,11 @@ async function bootstrap() {
|
|
|
98
99
|
renderProfile();
|
|
99
100
|
populateComposeFrom();
|
|
100
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();
|
|
101
107
|
maybeRequestNotificationPermission();
|
|
102
108
|
// Initial route: if the URL already has a hash (e.g. a refresh
|
|
103
109
|
// on /#/folder/sent), respect it; otherwise default to inbox.
|
|
@@ -122,6 +128,8 @@ async function selectAgent(agent) {
|
|
|
122
128
|
// account that uses different folder names (e.g. Gmail relay
|
|
123
129
|
// vs vanilla Stalwart) keeps the previous cache.
|
|
124
130
|
state.folderNames = {};
|
|
131
|
+
// Reset pagination — each inbox starts at page 1.
|
|
132
|
+
state.pagination = { offset: 0, limit: 50, total: 0 };
|
|
125
133
|
// Discover folders BEFORE the first sidebar render so the
|
|
126
134
|
// `requiresDiscovery` hide-rule (All Mail on non-Gmail servers)
|
|
127
135
|
// has the cache to consult. Falls back to defaults on failure.
|
|
@@ -170,6 +178,10 @@ function route() {
|
|
|
170
178
|
const folder = folderMatch ? folderMatch[1] : 'inbox';
|
|
171
179
|
if (state.selectedFolder !== folder) {
|
|
172
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 };
|
|
173
185
|
renderSidebar(onFolderSelect);
|
|
174
186
|
}
|
|
175
187
|
if (state.selectedAgent) loadList(state.selectedAgent, folder);
|
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,25 @@ 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
|
+
|
|
217
259
|
/**
|
|
218
260
|
* Refresh the currently-rendered list without rebuilding the
|
|
219
261
|
* toolbar. Used by the SSE new-mail handler so a new email
|
|
@@ -229,9 +271,12 @@ async function loadDraftsList(agent) {
|
|
|
229
271
|
export async function silentRefresh(agent, folder) {
|
|
230
272
|
if (!agent) return;
|
|
231
273
|
try {
|
|
274
|
+
const { offset, limit } = state.pagination;
|
|
232
275
|
if (folder === 'drafts') {
|
|
233
276
|
const data = await apiGet('/drafts', { agentKey: agent.apiKey });
|
|
234
|
-
const
|
|
277
|
+
const all = Array.isArray(data?.drafts) ? data.drafts : [];
|
|
278
|
+
state.pagination.total = all.length;
|
|
279
|
+
const rows = all.slice(offset, offset + limit);
|
|
235
280
|
state.messages = rows.map(r => ({
|
|
236
281
|
uid: r.id,
|
|
237
282
|
__draftId: r.id,
|
|
@@ -249,9 +294,12 @@ export async function silentRefresh(agent, folder) {
|
|
|
249
294
|
const isStarred = folder === 'starred';
|
|
250
295
|
const imap = isStarred ? 'INBOX' : (state.folderNames?.[folder]);
|
|
251
296
|
if (!imap) return;
|
|
252
|
-
const url = `/mail/digest?folder=${encodeURIComponent(imap)}&limit
|
|
297
|
+
const url = `/mail/digest?folder=${encodeURIComponent(imap)}&limit=${limit}&offset=${offset}&previewLength=240`;
|
|
253
298
|
const data = await apiGet(url, { agentKey: agent.apiKey });
|
|
254
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);
|
|
255
303
|
renderList();
|
|
256
304
|
} catch { /* silent — next user action will repair */ }
|
|
257
305
|
}
|
|
@@ -295,8 +343,23 @@ export function renderList() {
|
|
|
295
343
|
} else if (hintEl) {
|
|
296
344
|
hintEl.classList.remove('show');
|
|
297
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.
|
|
298
350
|
const countEl = document.getElementById('list-count');
|
|
299
|
-
|
|
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;
|
|
300
363
|
|
|
301
364
|
if (filtered.length === 0) {
|
|
302
365
|
root.innerHTML = q
|
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
|
@@ -164,6 +164,61 @@ a { color: var(--accent-strong); }
|
|
|
164
164
|
#sound-toggle-btn.sound-on { color: var(--pink); }
|
|
165
165
|
#sound-toggle-btn.sound-off { color: var(--muted); }
|
|
166
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
|
+
}
|
|
221
|
+
|
|
167
222
|
/* ─── Profile (top right) ─────────────────────────────────────────── */
|
|
168
223
|
.profile-trigger {
|
|
169
224
|
width: 40px; height: 40px; border-radius: 50%;
|
|
@@ -421,6 +476,19 @@ a { color: var(--accent-strong); }
|
|
|
421
476
|
.list-toolbar-spacer { flex: 1; }
|
|
422
477
|
.list-toolbar .count-text {
|
|
423
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;
|
|
424
492
|
}
|
|
425
493
|
|
|
426
494
|
/* Bulk-action toolbar — appears between select-all and refresh
|