@agenticmail/api 0.9.10 → 0.9.12
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 +23 -21
- package/package.json +1 -1
- package/public/index.html +10 -2
- package/public/js/activity-badges.js +14 -0
- package/public/js/list-view.js +12 -1
- package/public/styles.css +39 -4
package/dist/index.js
CHANGED
|
@@ -1471,6 +1471,20 @@ function createEventRoutes(accountManager, config, db) {
|
|
|
1471
1471
|
safeWrite(`data: ${JSON.stringify({ type: "connected", agentId: agent.id })}
|
|
1472
1472
|
|
|
1473
1473
|
`);
|
|
1474
|
+
const broadcastNew = (e) => {
|
|
1475
|
+
safeWrite(`data: ${JSON.stringify(e)}
|
|
1476
|
+
|
|
1477
|
+
`);
|
|
1478
|
+
try {
|
|
1479
|
+
pushSystemEvent({
|
|
1480
|
+
type: "new_mail",
|
|
1481
|
+
agentId: agent.id,
|
|
1482
|
+
agentName: agent.name,
|
|
1483
|
+
event: e
|
|
1484
|
+
});
|
|
1485
|
+
} catch {
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1474
1488
|
watcher.on("new", async (event) => {
|
|
1475
1489
|
if (db) touchActivity(db, agent.id);
|
|
1476
1490
|
if (db && event.uid) {
|
|
@@ -1512,9 +1526,7 @@ function createEventRoutes(accountManager, config, db) {
|
|
|
1512
1526
|
if (actions.move_to) await receiver.moveMessage(event.uid, "INBOX", actions.move_to);
|
|
1513
1527
|
event.ruleApplied = { ruleId: ruleResult2.ruleId, actions };
|
|
1514
1528
|
}
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
`);
|
|
1529
|
+
broadcastNew(event);
|
|
1518
1530
|
return;
|
|
1519
1531
|
}
|
|
1520
1532
|
const spamResult = scoreEmail(parsed);
|
|
@@ -1540,9 +1552,7 @@ function createEventRoutes(accountManager, config, db) {
|
|
|
1540
1552
|
}
|
|
1541
1553
|
await receiver.moveMessage(event.uid, "INBOX", "Spam");
|
|
1542
1554
|
event.spam = { score: spamResult.score, category: spamResult.topCategory, movedToSpam: true };
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
`);
|
|
1555
|
+
broadcastNew(event);
|
|
1546
1556
|
return;
|
|
1547
1557
|
}
|
|
1548
1558
|
if (spamResult.isWarning) {
|
|
@@ -1566,18 +1576,7 @@ function createEventRoutes(accountManager, config, db) {
|
|
|
1566
1576
|
console.error("[SSE] Spam/rule evaluation error:", err.message);
|
|
1567
1577
|
}
|
|
1568
1578
|
}
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
`);
|
|
1572
|
-
try {
|
|
1573
|
-
pushSystemEvent({
|
|
1574
|
-
type: "new_mail",
|
|
1575
|
-
agentId: agent.id,
|
|
1576
|
-
agentName: agent.name,
|
|
1577
|
-
event
|
|
1578
|
-
});
|
|
1579
|
-
} catch {
|
|
1580
|
-
}
|
|
1579
|
+
broadcastNew(event);
|
|
1581
1580
|
});
|
|
1582
1581
|
watcher.on("expunge", (event) => {
|
|
1583
1582
|
safeWrite(`data: ${JSON.stringify(event)}
|
|
@@ -2572,19 +2571,22 @@ function createMailRoutes(accountManager, config, db, gatewayManager) {
|
|
|
2572
2571
|
router.post("/mail/batch/move", requireAgent, async (req, res, next) => {
|
|
2573
2572
|
try {
|
|
2574
2573
|
const agent = req.agent;
|
|
2575
|
-
const
|
|
2576
|
-
const uids = validateUids(
|
|
2574
|
+
const body = req.body || {};
|
|
2575
|
+
const uids = validateUids(body.uids);
|
|
2577
2576
|
if (!uids) {
|
|
2578
2577
|
res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
|
|
2579
2578
|
return;
|
|
2580
2579
|
}
|
|
2580
|
+
const fromFolder = body.from ?? body.folder;
|
|
2581
|
+
const toFolder = body.to ?? body.toFolder;
|
|
2581
2582
|
if (!toFolder) {
|
|
2582
|
-
res.status(400).json({ error: "
|
|
2583
|
+
res.status(400).json({ error: "destination folder is required (pass as `to` or `toFolder`)" });
|
|
2583
2584
|
return;
|
|
2584
2585
|
}
|
|
2585
2586
|
const password = getAgentPassword(agent);
|
|
2586
2587
|
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2587
2588
|
await receiver.batchMove(uids, fromFolder || "INBOX", toFolder);
|
|
2589
|
+
for (const uid of uids) invalidateParsedMessage(agent.id, uid);
|
|
2588
2590
|
res.json({ ok: true, moved: uids.length });
|
|
2589
2591
|
} catch (err) {
|
|
2590
2592
|
next(err);
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -42,8 +42,16 @@
|
|
|
42
42
|
<!-- Real-time worker activity badges. Populated by SSE; one
|
|
43
43
|
badge per active dispatcher worker showing what each
|
|
44
44
|
agent is doing right now (reading, editing, running
|
|
45
|
-
shell, etc.). Empty when no workers are active.
|
|
46
|
-
|
|
45
|
+
shell, etc.). Empty when no workers are active.
|
|
46
|
+
|
|
47
|
+
The count pill is always rendered first by activity-badges.js
|
|
48
|
+
when there's at least one worker — gives users a stable
|
|
49
|
+
"3 active" affordance independent of how many badges fit
|
|
50
|
+
in the horizontal-scroll viewport. -->
|
|
51
|
+
<div id="activity-badges-shell" class="activity-badges-shell" hidden>
|
|
52
|
+
<span id="activity-badges-count" class="activity-badges-count" title="Active agents"></span>
|
|
53
|
+
<div id="activity-badges" class="activity-badges"></div>
|
|
54
|
+
</div>
|
|
47
55
|
<button class="icon-btn" id="sound-toggle-btn" title="Notification sound"></button>
|
|
48
56
|
<button class="icon-btn" id="refresh-btn" title="Refresh (r)" data-icon="refresh"></button>
|
|
49
57
|
<button id="profile-btn" class="profile-trigger" title="Account">
|
|
@@ -15,6 +15,8 @@ import { onSystemEvent } from './system-stream.js';
|
|
|
15
15
|
import { state, API_URL } from './state.js';
|
|
16
16
|
|
|
17
17
|
const BADGE_CONTAINER_ID = 'activity-badges';
|
|
18
|
+
const SHELL_ID = 'activity-badges-shell';
|
|
19
|
+
const COUNT_ID = 'activity-badges-count';
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Map of workerId → { agentName, kind, lastTool, turnCount,
|
|
@@ -59,8 +61,20 @@ function statusFor(lastTool) {
|
|
|
59
61
|
|
|
60
62
|
function render() {
|
|
61
63
|
const root = document.getElementById(BADGE_CONTAINER_ID);
|
|
64
|
+
const shell = document.getElementById(SHELL_ID);
|
|
65
|
+
const countEl = document.getElementById(COUNT_ID);
|
|
62
66
|
if (!root) return;
|
|
63
67
|
const list = Array.from(workers.values()).sort((a, b) => (a.startedAtMs ?? 0) - (b.startedAtMs ?? 0));
|
|
68
|
+
// Drive the shell + count pill so users see "3 active" even when only
|
|
69
|
+
// 2 badges fit in the scroll viewport. Empty state hides the whole
|
|
70
|
+
// shell so the topbar doesn't carry a dangling "0" pill.
|
|
71
|
+
if (shell) shell.hidden = list.length === 0;
|
|
72
|
+
if (countEl) {
|
|
73
|
+
countEl.textContent = String(list.length);
|
|
74
|
+
countEl.title = list.length === 1
|
|
75
|
+
? '1 agent active'
|
|
76
|
+
: `${list.length} agents active`;
|
|
77
|
+
}
|
|
64
78
|
if (list.length === 0) { root.innerHTML = ''; return; }
|
|
65
79
|
root.innerHTML = list.map(w => {
|
|
66
80
|
const initial = (w.agentName ?? '?').slice(0, 1).toUpperCase();
|
package/public/js/list-view.js
CHANGED
|
@@ -157,7 +157,18 @@ export async function loadList(agent, folder) {
|
|
|
157
157
|
// side flag filter (Gmail convention); other folders need a real
|
|
158
158
|
// mailbox name from the discovery cache.
|
|
159
159
|
const isStarred = folder === 'starred';
|
|
160
|
-
|
|
160
|
+
let imap = isStarred ? 'INBOX' : imapNameFor(folder);
|
|
161
|
+
if (!imap) {
|
|
162
|
+
// The cache was populated at agent-switch time; some folders are
|
|
163
|
+
// created LATER on demand (the API auto-creates Archive on first
|
|
164
|
+
// archive, Spam on first report-as-spam). If we don't see the
|
|
165
|
+
// requested folder in the cache, force a fresh discovery once
|
|
166
|
+
// before declaring "no such folder" — covers the "moved to
|
|
167
|
+
// Archive but Archive tab is empty" report.
|
|
168
|
+
state.folderNames = {};
|
|
169
|
+
await ensureFolderCache(agent);
|
|
170
|
+
imap = imapNameFor(folder);
|
|
171
|
+
}
|
|
161
172
|
if (!imap) {
|
|
162
173
|
document.getElementById('list-rows').innerHTML =
|
|
163
174
|
`<div class="empty"><div class="big">📭</div>No ${escapeHtml(folderTitle(folder))} folder on this server.</div>`;
|
package/public/styles.css
CHANGED
|
@@ -170,14 +170,49 @@ a { color: var(--accent-strong); }
|
|
|
170
170
|
events. Each badge shows the agent's initial + name + a short
|
|
171
171
|
verb derived from the worker's `lastTool`. The green dot
|
|
172
172
|
gently pulses so the row reads as "alive" rather than static. */
|
|
173
|
+
/* Shell wraps the always-visible count pill + the scrollable badge
|
|
174
|
+
row. Two children, side-by-side; the count never scrolls so the
|
|
175
|
+
user can see how many agents are active even when the scroll viewport
|
|
176
|
+
is full. */
|
|
177
|
+
.activity-badges-shell {
|
|
178
|
+
display: flex; align-items: center; gap: 6px;
|
|
179
|
+
margin-right: 4px;
|
|
180
|
+
min-width: 0; /* lets the inner scrollable strip shrink without
|
|
181
|
+
pushing siblings off the topbar */
|
|
182
|
+
}
|
|
183
|
+
.activity-badges-count {
|
|
184
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
185
|
+
height: 22px; min-width: 22px; padding: 0 7px;
|
|
186
|
+
background: var(--pink, #ff4d8c);
|
|
187
|
+
color: #fff;
|
|
188
|
+
font-size: 11px; font-weight: 700;
|
|
189
|
+
border-radius: 999px;
|
|
190
|
+
flex-shrink: 0;
|
|
191
|
+
user-select: none;
|
|
192
|
+
}
|
|
193
|
+
/* Horizontal-scrolling strip of badges. We deliberately allow
|
|
194
|
+
`overflow-x: auto` so the user can swipe / trackpad-scroll through
|
|
195
|
+
more than fit on screen, and use an edge fade mask so it's visually
|
|
196
|
+
clear there's more content past the right edge. Scrollbar stays
|
|
197
|
+
hidden (cleaner topbar) but the count pill + edge fade make
|
|
198
|
+
"there's more" discoverable. */
|
|
173
199
|
.activity-badges {
|
|
174
200
|
display: flex; align-items: center; gap: 6px;
|
|
175
201
|
max-width: 480px;
|
|
176
202
|
overflow-x: auto;
|
|
177
203
|
scrollbar-width: none;
|
|
178
|
-
|
|
204
|
+
-ms-overflow-style: none;
|
|
205
|
+
/* Mask creates a gentle fade-out on each end so badges visually
|
|
206
|
+
hint "I'm continuing past the viewport edge". Pure CSS, no JS. */
|
|
207
|
+
mask-image: linear-gradient(to right, transparent 0, black 12px, black calc(100% - 16px), transparent 100%);
|
|
208
|
+
-webkit-mask-image: linear-gradient(to right, transparent 0, black 12px, black calc(100% - 16px), transparent 100%);
|
|
209
|
+
/* Snap-scroll so swiping never leaves a badge half-clipped. */
|
|
210
|
+
scroll-snap-type: x proximity;
|
|
211
|
+
/* Ensure the inner strip can shrink inside its flex parent. */
|
|
212
|
+
min-width: 0;
|
|
179
213
|
}
|
|
180
214
|
.activity-badges::-webkit-scrollbar { display: none; }
|
|
215
|
+
.activity-badge { scroll-snap-align: start; }
|
|
181
216
|
.activity-badge {
|
|
182
217
|
display: inline-flex; align-items: center; gap: 6px;
|
|
183
218
|
padding: 4px 10px 4px 6px;
|
|
@@ -213,10 +248,10 @@ a { color: var(--accent-strong); }
|
|
|
213
248
|
.activity-badge .badge-status { color: var(--muted); }
|
|
214
249
|
.activity-badge .badge-status::before { content: '· '; opacity: .6; }
|
|
215
250
|
|
|
216
|
-
/* Mobile — hide
|
|
217
|
-
|
|
251
|
+
/* Mobile — hide the whole activity-badge shell (count pill + scrolling
|
|
252
|
+
strip) on narrow viewports; folds into the profile dropdown anyway. */
|
|
218
253
|
@media (max-width: 800px) {
|
|
219
|
-
.activity-badges { display: none; }
|
|
254
|
+
.activity-badges-shell { display: none !important; }
|
|
220
255
|
}
|
|
221
256
|
|
|
222
257
|
/* ─── Profile (top right) ─────────────────────────────────────────── */
|