herdr-plugin-amq 0.1.8 → 0.1.9

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/src/web/app.js CHANGED
@@ -2,7 +2,7 @@
2
2
  (function () {
3
3
  const state = {
4
4
  activeAccount: "all",
5
- activePersona: localStorage.getItem("agmail_persona") || "user",
5
+ activePersona: "user",
6
6
  activeFolder: "inbox",
7
7
  activeCategory: "all",
8
8
  viewMode: "threads", // "threads" (Conversation view) or "flat" (Individual transmissions)
@@ -21,15 +21,26 @@
21
21
  status: {},
22
22
  contextTarget: null,
23
23
  currentView: "mail", // "mail" or "board"
24
+ readingLayout: document.documentElement.dataset.readingLayout === "full" ? "full" : "split",
25
+ themePreference: ["system", "light", "dark"].includes(document.documentElement.dataset.themePreference) ? document.documentElement.dataset.themePreference : "system",
26
+ detailOpen: false,
24
27
  board: null,
25
28
  boardFilterAgent: "all",
26
29
  boardSearchQuery: "",
27
30
  selectedTaskId: null,
31
+ activeAgentHandle: null,
28
32
  };
29
33
 
34
+ function getActiveSender(selected = "") {
35
+ if (state.activeAccount === "all") return "user";
36
+ return selected || state.activeAccount || state.activePersona || "user";
37
+ }
38
+
30
39
  // DOM Elements
31
40
  const mailListEl = document.getElementById("mail-list");
41
+ const mailListViewEl = document.getElementById("mail-list-view");
32
42
  const mailDetailViewEl = document.getElementById("mail-detail-view");
43
+ const contentSplitterEl = document.getElementById("splitter-view");
33
44
  const searchInputEl = document.getElementById("search-input");
34
45
  const clearSearchBtn = document.getElementById("clear-search");
35
46
  const headerSearchContainer = document.getElementById("header-search-container");
@@ -52,6 +63,14 @@
52
63
  const sidebarBackdrop = document.getElementById("sidebar-backdrop");
53
64
  const sidebarEl = document.getElementById("sidebar");
54
65
 
66
+ const openSettingsBtn = document.getElementById("open-settings-btn");
67
+ const settingsBackdrop = document.getElementById("settings-backdrop");
68
+ const settingsForm = document.getElementById("settings-form");
69
+ const closeSettingsBtn = document.getElementById("close-settings-btn");
70
+ const cancelSettingsBtn = document.getElementById("cancel-settings-btn");
71
+ const readingLayoutInputs = [...document.querySelectorAll('input[name="reading-layout"]')];
72
+ const themeInputs = [...document.querySelectorAll('input[name="theme"]')];
73
+
55
74
  // Account Menu Elements
56
75
  const userProfileBtn = document.getElementById("user-profile-btn");
57
76
  const accountDropdown = document.getElementById("account-dropdown");
@@ -60,8 +79,26 @@
60
79
  const currentUserAvatar = document.getElementById("current-user-avatar");
61
80
  const dropdownLargeAvatar = document.getElementById("dropdown-large-avatar");
62
81
  const dropdownUserTitle = document.getElementById("dropdown-user-title");
82
+ const dropdownUserRole = document.getElementById("dropdown-user-role");
63
83
  const dropdownUserEmail = document.getElementById("dropdown-user-email");
64
84
 
85
+ const agentActivityDialog = document.getElementById("agent-activity-dialog");
86
+ const closeAgentActivityBtn = document.getElementById("close-agent-activity-btn");
87
+ const agentActivityAvatar = document.getElementById("agent-activity-avatar");
88
+ const agentActivityName = document.getElementById("agent-activity-name");
89
+ const agentActivityHandle = document.getElementById("agent-activity-handle");
90
+ const agentActivityRole = document.getElementById("agent-activity-role");
91
+ const agentActivityStatus = document.getElementById("agent-activity-status");
92
+ const agentActivityHeadline = document.getElementById("agent-activity-headline");
93
+ const agentActivityTask = document.getElementById("agent-activity-task");
94
+ const agentActivityTaskStatus = document.getElementById("agent-activity-task-status");
95
+ const agentActivityModel = document.getElementById("agent-activity-model");
96
+ const agentActivityUnread = document.getElementById("agent-activity-unread");
97
+ const agentActivityPane = document.getElementById("agent-activity-pane");
98
+ const agentActivityObserved = document.getElementById("agent-activity-observed");
99
+ const viewAgentInboxBtn = document.getElementById("view-agent-inbox-btn");
100
+ const viewAgentTaskBtn = document.getElementById("view-agent-task-btn");
101
+
65
102
  // Context Menu Elements
66
103
  const chatContextMenu = document.getElementById("chat-context-menu");
67
104
  const ctxHeader = document.getElementById("ctx-header");
@@ -159,6 +196,7 @@
159
196
  const newTaskForm = document.getElementById("new-task-form");
160
197
  const taskTitleInput = document.getElementById("task-title-input");
161
198
  const taskOwnerSelect = document.getElementById("task-owner-select");
199
+ const taskOwnerTip = document.getElementById("task-owner-tip");
162
200
  const taskStatusSelect = document.getElementById("task-status-select");
163
201
  const taskDescInput = document.getElementById("task-desc-input");
164
202
 
@@ -195,6 +233,95 @@
195
233
  const sheetDispatchThreadPreview = document.getElementById("sheet-dispatch-thread-preview");
196
234
  const sheetDispatchSendBtn = document.getElementById("sheet-dispatch-send-btn");
197
235
 
236
+ const systemThemeMedia = window.matchMedia("(prefers-color-scheme: dark)");
237
+ let settingsReturnFocus = null;
238
+
239
+ function persistPreference(key, value) {
240
+ try {
241
+ localStorage.setItem(key, value);
242
+ } catch {}
243
+ }
244
+
245
+ function resolveTheme(preference) {
246
+ return preference === "dark" || (preference === "system" && systemThemeMedia.matches) ? "dark" : "light";
247
+ }
248
+
249
+ function applyThemePreference(preference, persist = true) {
250
+ const normalized = ["system", "light", "dark"].includes(preference) ? preference : "system";
251
+ state.themePreference = normalized;
252
+ document.documentElement.dataset.themePreference = normalized;
253
+ document.documentElement.dataset.theme = resolveTheme(normalized);
254
+ if (persist) persistPreference("agmail_theme", normalized);
255
+ }
256
+
257
+ function applyReadingLayout(layout, persist = true) {
258
+ const normalized = layout === "full" ? "full" : "split";
259
+ state.readingLayout = normalized;
260
+ document.documentElement.dataset.readingLayout = normalized;
261
+ contentSplitterEl.dataset.readingLayout = normalized;
262
+ if (persist) persistPreference("agmail_reading_layout", normalized);
263
+ }
264
+
265
+ function syncSettingsForm() {
266
+ readingLayoutInputs.forEach((input) => {
267
+ input.checked = input.value === state.readingLayout;
268
+ });
269
+ themeInputs.forEach((input) => {
270
+ input.checked = input.value === state.themePreference;
271
+ });
272
+ }
273
+
274
+ function openSettings() {
275
+ settingsReturnFocus = document.activeElement;
276
+ syncSettingsForm();
277
+ settingsBackdrop.classList.remove("hidden");
278
+ requestAnimationFrame(() => closeSettingsBtn.focus());
279
+ }
280
+
281
+ function closeSettings() {
282
+ if (settingsBackdrop.classList.contains("hidden")) return;
283
+ settingsBackdrop.classList.add("hidden");
284
+ if (settingsReturnFocus && settingsReturnFocus.isConnected) settingsReturnFocus.focus();
285
+ settingsReturnFocus = null;
286
+ }
287
+
288
+ openSettingsBtn.addEventListener("click", openSettings);
289
+ closeSettingsBtn.addEventListener("click", closeSettings);
290
+ cancelSettingsBtn.addEventListener("click", closeSettings);
291
+ settingsBackdrop.addEventListener("click", (event) => {
292
+ if (event.target === settingsBackdrop) closeSettings();
293
+ });
294
+ settingsForm.addEventListener("submit", (event) => {
295
+ event.preventDefault();
296
+ const readingLayout = readingLayoutInputs.find((input) => input.checked)?.value || "split";
297
+ const theme = themeInputs.find((input) => input.checked)?.value || "system";
298
+ applyReadingLayout(readingLayout);
299
+ applyThemePreference(theme);
300
+ closeSettings();
301
+ });
302
+ settingsBackdrop.addEventListener("keydown", (event) => {
303
+ if (event.key === "Escape") {
304
+ event.preventDefault();
305
+ closeSettings();
306
+ return;
307
+ }
308
+ if (event.key !== "Tab") return;
309
+ const focusable = [...settingsBackdrop.querySelectorAll('button:not([disabled]), input:not([disabled])')];
310
+ if (!focusable.length) return;
311
+ const first = focusable[0];
312
+ const last = focusable[focusable.length - 1];
313
+ if (event.shiftKey && document.activeElement === first) {
314
+ event.preventDefault();
315
+ last.focus();
316
+ } else if (!event.shiftKey && document.activeElement === last) {
317
+ event.preventDefault();
318
+ first.focus();
319
+ }
320
+ });
321
+ systemThemeMedia.addEventListener("change", () => {
322
+ if (state.themePreference === "system") applyThemePreference("system", false);
323
+ });
324
+
198
325
 
199
326
  // ─── Data Fetching ──────────────────────────────────────────────────────────
200
327
 
@@ -259,7 +386,7 @@
259
386
  async function fetchData() {
260
387
  try {
261
388
  const endpoint = state.viewMode === "threads" ? "/api/threads" : "/api/messages";
262
- const currentPersona = state.activeAccount === "all" ? (state.activePersona || "user") : state.activeAccount;
389
+ const currentPersona = getActiveSender();
263
390
  const params = new URLSearchParams({
264
391
  account: state.activeAccount,
265
392
  persona: currentPersona,
@@ -292,32 +419,39 @@
292
419
  // ─── Google Account Dropdown ────────────────────────────────────────────────
293
420
 
294
421
  function renderAccountDropdown(agents) {
422
+ const allActive = state.activeAccount === "all";
295
423
  let html = `
296
- <button class="account-item-btn ${state.activeAccount === "all" ? "active" : ""}" data-handle="all">
297
- <div class="account-item-left">
298
- <div class="item-avatar" style="background:#1a73e8;color:#fff;">👑</div>
299
- <div>
300
- <strong>God Mode (All Accounts)</strong>
301
- <div style="font-size:11px;color:#5f6368;">View all swarm messages</div>
302
- </div>
303
- </div>
424
+ <button class="account-item-btn ${allActive ? "active" : ""}" data-handle="all" aria-current="${allActive ? "true" : "false"}">
425
+ <span class="account-item-left">
426
+ <span class="item-avatar" style="background:var(--primary-blue);color:#fff;">👑</span>
427
+ <span class="account-item-copy">
428
+ <strong class="account-item-name">God Mode</strong>
429
+ <span class="account-item-role">All accounts · sending as user</span>
430
+ <span class="account-item-address">amq://all-agents</span>
431
+ </span>
432
+ </span>
304
433
  </button>
305
434
  `;
306
435
 
307
- for (const a of agents) {
308
- const active = state.activeAccount === a.handle;
309
- const prof = a.profile || { name: a.handle, emoji: a.handle.slice(0, 1).toUpperCase(), color: "#1a73e8", role: "Specialist" };
310
- const unreadBadge = a.unreadCount > 0 ? `<span class="badge">${a.unreadCount} new</span>` : "";
436
+ for (const agent of agents) {
437
+ const active = state.activeAccount === agent.handle;
438
+ const profile = agent.profile || {};
439
+ const name = profile.name || agent.handle;
440
+ const role = profile.role || "Swarm Agent";
441
+ const emoji = profile.emoji || agent.handle.slice(0, 1).toUpperCase();
442
+ const color = safeCssColor(profile.color, "#1a73e8");
443
+ const unreadBadge = agent.unreadCount > 0 ? `<span class="badge">${agent.unreadCount} new</span>` : "";
311
444
 
312
445
  html += `
313
- <button class="account-item-btn ${active ? "active" : ""}" data-handle="${a.handle}">
314
- <div class="account-item-left">
315
- <div class="item-avatar" style="background:${prof.color};color:#fff;">${prof.emoji}</div>
316
- <div>
317
- <strong>${prof.name}</strong>
318
- <div style="font-size:11px;color:#5f6368;">${prof.role} • ${a.handle}@amq</div>
319
- </div>
320
- </div>
446
+ <button class="account-item-btn ${active ? "active" : ""}" data-handle="${escapeHtml(agent.handle)}" aria-current="${active ? "true" : "false"}" aria-label="Switch to ${escapeHtml(name)}">
447
+ <span class="account-item-left">
448
+ <span class="item-avatar" style="background:${escapeHtml(color)};color:#fff;">${escapeHtml(emoji)}</span>
449
+ <span class="account-item-copy">
450
+ <strong class="account-item-name">${escapeHtml(name)}</strong>
451
+ <span class="account-item-role">${escapeHtml(role)}</span>
452
+ <span class="account-item-address">${escapeHtml(agent.handle)}@amq</span>
453
+ </span>
454
+ </span>
321
455
  ${unreadBadge}
322
456
  </button>
323
457
  `;
@@ -329,20 +463,17 @@
329
463
  btn.addEventListener("click", () => {
330
464
  switchActiveAccount(btn.dataset.handle);
331
465
  accountDropdown.classList.add("hidden");
466
+ userProfileBtn.setAttribute("aria-expanded", "false");
332
467
  });
333
468
  });
334
469
  }
335
470
 
336
471
  function switchActiveAccount(handle) {
337
472
  state.activeAccount = handle;
338
- if (handle !== "all") {
339
- state.activePersona = handle;
340
- localStorage.setItem("agmail_persona", handle);
341
- }
473
+ state.activePersona = handle === "all" ? "user" : handle;
474
+ localStorage.setItem("agmail_persona", state.activePersona);
342
475
 
343
- const currentPersona = state.activeAccount === "all" ? (state.activePersona || "user") : state.activeAccount;
344
- const personaObj = state.agents.find((x) => x.handle === currentPersona);
345
- const personaName = personaObj?.profile?.name || currentPersona;
476
+ const currentPersona = getActiveSender();
346
477
 
347
478
  if (handle === "all") {
348
479
  headerAccountLabel.textContent = "👑 God Mode";
@@ -350,7 +481,8 @@
350
481
  currentUserAvatar.style.backgroundColor = "var(--primary-blue)";
351
482
  dropdownLargeAvatar.textContent = "👑";
352
483
  dropdownLargeAvatar.style.backgroundColor = "var(--primary-blue)";
353
- dropdownUserTitle.textContent = `God Mode (Persona: ${personaName})`;
484
+ dropdownUserTitle.textContent = "God Mode";
485
+ dropdownUserRole.textContent = "All transmissions · sending as user";
354
486
  dropdownUserEmail.textContent = "amq://all-agents";
355
487
  } else {
356
488
  const agentObj = state.agents.find((x) => x.handle === handle);
@@ -360,8 +492,9 @@
360
492
  currentUserAvatar.style.backgroundColor = prof.color;
361
493
  dropdownLargeAvatar.textContent = prof.emoji;
362
494
  dropdownLargeAvatar.style.backgroundColor = prof.color;
363
- dropdownUserTitle.textContent = `${prof.name} (${prof.role})`;
364
- dropdownUserEmail.textContent = `<${handle}@amq>`;
495
+ dropdownUserTitle.textContent = prof.name;
496
+ dropdownUserRole.textContent = prof.role || "Swarm Agent";
497
+ dropdownUserEmail.textContent = `${handle}@amq`;
365
498
  }
366
499
 
367
500
  replyAsUserEl.textContent = currentPersona;
@@ -372,62 +505,199 @@
372
505
  // Toggle account dropdown on user profile click
373
506
  userProfileBtn.addEventListener("click", (e) => {
374
507
  e.stopPropagation();
508
+ const opening = accountDropdown.classList.contains("hidden");
375
509
  accountDropdown.classList.toggle("hidden");
510
+ userProfileBtn.setAttribute("aria-expanded", String(opening));
376
511
  });
377
512
 
378
513
  // Close dropdown on outside click
379
514
  document.addEventListener("click", (e) => {
380
515
  if (!accountDropdown.contains(e.target) && !userProfileBtn.contains(e.target)) {
381
516
  accountDropdown.classList.add("hidden");
517
+ userProfileBtn.setAttribute("aria-expanded", "false");
382
518
  }
383
519
  });
384
520
 
385
521
  // ─── Swarm Presence List ────────────────────────────────────────────────────
386
522
 
523
+ function safeCssColor(value, fallback = "#1a73e8") {
524
+ const color = String(value || "").trim();
525
+ return /^#[0-9a-f]{3,8}$/i.test(color) ? color : fallback;
526
+ }
527
+
528
+ function agentStatusView(agent) {
529
+ const raw = String(agent?.herdrStatus && agent.herdrStatus !== "unknown" ? agent.herdrStatus : agent?.status || "offline").toLowerCase();
530
+ const status = raw === "online" || raw === "active" ? "idle" : raw;
531
+ const views = {
532
+ working: { label: "Working", tone: "working" },
533
+ idle: { label: "Idle", tone: "idle" },
534
+ done: { label: "Done", tone: "done" },
535
+ blocked: { label: "Blocked", tone: "blocked" },
536
+ error: { label: "Needs attention", tone: "blocked" },
537
+ offline: { label: "Offline", tone: "offline" },
538
+ };
539
+ return { status, ...(views[status] || views.offline) };
540
+ }
541
+
542
+ function getAgentTask(handle) {
543
+ if (!handle || !state.board?.columns) return null;
544
+ const rank = { in_progress: 0, blocked: 1, backlog: 2, done: 3 };
545
+ return Object.values(state.board.columns)
546
+ .flat()
547
+ .filter((task) => task.owner === handle)
548
+ .sort((left, right) => {
549
+ const rankDelta = (rank[left.status] ?? 9) - (rank[right.status] ?? 9);
550
+ if (rankDelta !== 0) return rankDelta;
551
+ return String(right.updated || right.created || "").localeCompare(String(left.updated || left.created || ""));
552
+ })[0] || null;
553
+ }
554
+
555
+ function agentActivityText(agent, task) {
556
+ const activity = agent?.herdrActivity || {};
557
+ const statusView = agentStatusView(agent);
558
+ const stateLabel = activity.stateLabels?.[statusView.status] || activity.stateLabels?.[agent?.herdrStatus] || "";
559
+ const token = Array.isArray(activity.tokens) ? activity.tokens.find((value) => typeof value === "string" && value.trim()) : "";
560
+ const title = [activity.title, activity.terminalTitle, agent?.herdrTitle]
561
+ .find((value) => {
562
+ const text = String(value || "").trim();
563
+ return text && !/^(opencode|codex|claude code|agent|herdr)$/i.test(text);
564
+ }) || "";
565
+ if (stateLabel) return stateLabel;
566
+ if (token) return token;
567
+ if (title) return title;
568
+ if (task?.title) return task.title;
569
+ if (agent?.herdrStatus && agent.herdrStatus !== "unknown") return statusView.tone === "working" ? "Working in Herdr" : "Live in Herdr";
570
+ return "No live Herdr activity signal";
571
+ }
572
+
387
573
  function renderPresenceList(agents) {
574
+ const priority = { working: 0, blocked: 1, idle: 2, done: 3, error: 4, offline: 5 };
575
+ const sorted = [...agents].sort((left, right) => {
576
+ const statusDelta = (priority[agentStatusView(left).status] ?? 9) - (priority[agentStatusView(right).status] ?? 9);
577
+ return statusDelta || left.handle.localeCompare(right.handle);
578
+ });
388
579
  let html = "";
389
- for (const a of agents) {
390
- const prof = a.profile || { name: a.handle, emoji: a.handle.slice(0, 1).toUpperCase(), color: "#1a73e8", role: "Specialist", model: "claude-3-7-sonnet" };
391
- // Prefer Herdr live status when available, fall back to AMQ status
392
- const effectiveStatus = a.herdrStatus && a.herdrStatus !== "unknown"
393
- ? a.herdrStatus
394
- : (a.status || "offline");
395
-
396
- let statusClass = "offline";
397
- let statusLabel = "offline";
398
-
399
- if (effectiveStatus === "working") {
400
- statusClass = "working";
401
- statusLabel = "working";
402
- } else if (effectiveStatus === "idle" || effectiveStatus === "done" || effectiveStatus === "active") {
403
- statusClass = "idle";
404
- statusLabel = effectiveStatus;
405
- } else if (effectiveStatus === "blocked" || effectiveStatus === "error") {
406
- statusClass = "blocked";
407
- statusLabel = effectiveStatus;
408
- }
409
-
410
- const isLive = Boolean(a.herdrStatus && a.herdrStatus !== "unknown");
411
- const titleText = a.herdrTitle
412
- ? `${escapeHtml(prof.name)} (${escapeHtml(prof.role)}) • ${statusLabel} — ${escapeHtml(a.herdrTitle)}`
413
- : `${escapeHtml(prof.name)} (${escapeHtml(prof.role)}) [${escapeHtml(prof.model || "model")}]: ${statusLabel}`;
580
+ for (const agent of sorted) {
581
+ const profile = agent.profile || {};
582
+ const name = profile.name || agent.handle;
583
+ const role = profile.role || "Swarm Agent";
584
+ const emoji = profile.emoji || agent.handle.slice(0, 1).toUpperCase();
585
+ const color = safeCssColor(profile.color);
586
+ const statusView = agentStatusView(agent);
587
+ const live = Boolean(agent.herdrStatus && agent.herdrStatus !== "unknown");
588
+ const task = getAgentTask(agent.handle);
589
+ const activity = agentActivityText(agent, task);
414
590
 
415
591
  html += `
416
- <div class="presence-item${isLive ? " presence-live" : ""}" title="${titleText}">
417
- <div class="presence-avatar-wrap">
418
- <div class="mini-avatar" style="background:${prof.color};color:#fff;">${prof.emoji}</div>
419
- <span class="presence-dot ${statusClass}"></span>
420
- </div>
421
- <div class="presence-details">
422
- <span class="presence-name">${a.handle}${isLive ? '<span class="herdr-live-dot" title="Herdr live">●</span>' : ""}</span>
423
- <span class="presence-status-text">${escapeHtml(prof.role)} • ${statusLabel}</span>
424
- </div>
425
- </div>
592
+ <button type="button" class="presence-item${live ? " presence-live" : ""}" data-agent-handle="${escapeHtml(agent.handle)}" aria-haspopup="dialog" aria-label="View activity for ${escapeHtml(name)}, ${statusView.label}" title="${escapeHtml(activity)}">
593
+ <span class="presence-avatar-wrap">
594
+ <span class="mini-avatar" style="background:${escapeHtml(color)};color:#fff;">${escapeHtml(emoji)}</span>
595
+ <span class="presence-dot ${statusView.tone}"></span>
596
+ </span>
597
+ <span class="presence-details">
598
+ <span class="presence-name-row">
599
+ <span class="presence-name">${escapeHtml(agent.handle)}</span>
600
+ <span class="presence-status-pill ${statusView.tone}">${statusView.label}</span>
601
+ </span>
602
+ <span class="presence-role">${escapeHtml(role)}</span>
603
+ </span>
604
+ <span class="presence-chevron" aria-hidden="true">›</span>
605
+ </button>
426
606
  `;
427
607
  }
428
608
  presenceListEl.innerHTML = html;
609
+ presenceListEl.querySelectorAll(".presence-item").forEach((button) => {
610
+ button.addEventListener("click", () => openAgentActivity(button.dataset.agentHandle));
611
+ });
612
+ if (agentActivityDialog?.open) renderAgentActivity();
613
+ }
614
+
615
+ function renderAgentActivity() {
616
+ const handle = state.activeAgentHandle;
617
+ const agent = state.agents.find((item) => item.handle === handle);
618
+ if (!agent || !agentActivityDialog) return false;
619
+ const profile = agent.profile || {};
620
+ const statusView = agentStatusView(agent);
621
+ const task = getAgentTask(handle);
622
+ const activity = agent.herdrActivity || {};
623
+ const title = String(activity.title || activity.terminalTitle || agent.herdrTitle || "").trim();
624
+ const observedAt = activity.observedAt || agent.herdrObservedAt;
625
+ const taskStatus = task ? task.status.replaceAll("_", " ") : "No active task assigned";
626
+ const taskTone = { in_progress: "working", blocked: "blocked", done: "done", backlog: "idle" }[task?.status] || "offline";
627
+
628
+ agentActivityAvatar.textContent = profile.emoji || handle.slice(0, 1).toUpperCase();
629
+ agentActivityAvatar.style.backgroundColor = safeCssColor(profile.color);
630
+ agentActivityName.textContent = profile.name || handle;
631
+ agentActivityHandle.textContent = `${handle}@amq`;
632
+ agentActivityRole.textContent = profile.role || "Swarm Agent";
633
+ agentActivityStatus.textContent = statusView.label;
634
+ agentActivityStatus.className = `agent-activity-status ${statusView.tone}`;
635
+ agentActivityHeadline.textContent = agentActivityText(agent, task);
636
+ agentActivityTask.textContent = task?.title || "No board task is currently assigned to this agent.";
637
+ agentActivityTaskStatus.textContent = taskStatus;
638
+ agentActivityTaskStatus.className = `agent-task-status ${taskTone}`;
639
+ const liveModel = agent.runtimeModel || activity.model || "";
640
+ const modelSource = agent.modelSource || activity.modelSource || "";
641
+ agentActivityModel.textContent = liveModel || profile.model || "Not configured";
642
+ agentActivityModel.title = liveModel
643
+ ? `Live harness model${modelSource ? ` · ${modelSource}` : ""}`
644
+ : profile.model
645
+ ? "Configured profile model"
646
+ : "No model reported by the harness or profile";
647
+ agentActivityUnread.textContent = String(agent.unreadCount || 0);
648
+ agentActivityPane.textContent = agent.herdrPaneId || "Not tracked";
649
+ agentActivityObserved.textContent = observedAt ? new Date(observedAt).toLocaleString() : "No live snapshot";
650
+ viewAgentTaskBtn.hidden = !task;
651
+ agentActivityDialog.dataset.agentHandle = handle;
652
+ return true;
653
+ }
654
+
655
+ function openAgentActivity(handle) {
656
+ state.activeAgentHandle = handle;
657
+ if (!renderAgentActivity()) {
658
+ state.activeAgentHandle = null;
659
+ return;
660
+ }
661
+ if (agentActivityDialog.open) return;
662
+ if (typeof agentActivityDialog.showModal === "function") agentActivityDialog.showModal();
663
+ else agentActivityDialog.setAttribute("open", "");
664
+ requestAnimationFrame(() => closeAgentActivityBtn?.focus());
429
665
  }
430
666
 
667
+ function closeAgentActivity() {
668
+ if (typeof agentActivityDialog.close === "function") agentActivityDialog.close();
669
+ else agentActivityDialog.removeAttribute("open");
670
+ }
671
+
672
+ closeAgentActivityBtn?.addEventListener("click", closeAgentActivity);
673
+ agentActivityDialog?.addEventListener("click", (event) => {
674
+ if (event.target !== agentActivityDialog) return;
675
+ const bounds = agentActivityDialog.getBoundingClientRect();
676
+ const outside = event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom;
677
+ if (outside) closeAgentActivity();
678
+ });
679
+ agentActivityDialog?.addEventListener("close", () => {
680
+ state.activeAgentHandle = null;
681
+ });
682
+ viewAgentInboxBtn?.addEventListener("click", () => {
683
+ const handle = state.activeAgentHandle;
684
+ closeAgentActivity();
685
+ if (!handle) return;
686
+ switchActiveAccount(handle);
687
+ switchView("mail");
688
+ if (window.innerWidth <= 768) {
689
+ document.body.classList.remove("sidebar-open");
690
+ sidebarBackdrop.classList.add("hidden");
691
+ }
692
+ });
693
+ viewAgentTaskBtn?.addEventListener("click", () => {
694
+ const task = getAgentTask(state.activeAgentHandle);
695
+ closeAgentActivity();
696
+ if (!task) return;
697
+ switchView("board");
698
+ requestAnimationFrame(() => openTaskSheet(task.id));
699
+ });
700
+
431
701
 
432
702
  function levenshtein(a, b) {
433
703
  if (a.length === 0) return b.length;
@@ -558,7 +828,7 @@
558
828
  if (prevPageBtn) prevPageBtn.disabled = state.page <= 1;
559
829
  if (nextPageBtn) nextPageBtn.disabled = state.page >= state.totalPages;
560
830
 
561
- const currentPersona = state.activeAccount === "all" ? (state.activePersona || "user") : state.activeAccount;
831
+ const currentPersona = getActiveSender();
562
832
  filterInfoEl.textContent = `Account: ${state.activeAccount} • Persona: ${currentPersona} • Mode: ${state.viewMode}`;
563
833
 
564
834
  if (!filtered.length) {
@@ -578,6 +848,7 @@
578
848
  for (const t of threads) {
579
849
  const isUnread = t.hasUnread;
580
850
  const isStarred = state.starredIds.has(t.threadId);
851
+ const isSelected = state.selectedThreadId === t.threadId;
581
852
  const dateStr = formatTimestamp(t.latestCreated);
582
853
 
583
854
  const senders = t.participants.join(", ");
@@ -594,7 +865,7 @@
594
865
  const sendersHighlighted = highlightTerms(escapeHtml(senders), state.searchQuery);
595
866
 
596
867
  html += `
597
- <div class="mail-row ${isUnread ? "unread" : "read"}" data-thread-id="${t.threadId}">
868
+ <div class="mail-row ${isUnread ? "unread" : "read"} ${isSelected ? "selected" : ""}" data-thread-id="${t.threadId}" role="button" tabindex="0" aria-current="${isSelected ? "true" : "false"}">
598
869
  <div class="mail-row-actions">
599
870
  <button class="star-btn ${isStarred ? "starred" : ""}" data-star-id="${t.threadId}" title="Star thread">
600
871
  ${isStarred ? "★" : "☆"}
@@ -616,11 +887,19 @@
616
887
  mailListEl.innerHTML = html;
617
888
 
618
889
  mailListEl.querySelectorAll(".mail-row").forEach((row) => {
619
- row.addEventListener("click", (e) => {
620
- if (e.target.closest(".star-btn")) return;
890
+ const activate = () => {
621
891
  const threadId = row.dataset.threadId;
622
892
  const thread = state.items.find((t) => t.threadId === threadId);
623
893
  if (thread) openThread(thread);
894
+ };
895
+ row.addEventListener("click", (e) => {
896
+ if (!e.target.closest(".star-btn")) activate();
897
+ });
898
+ row.addEventListener("keydown", (e) => {
899
+ if (e.key === "Enter" || e.key === " ") {
900
+ e.preventDefault();
901
+ activate();
902
+ }
624
903
  });
625
904
  });
626
905
 
@@ -632,6 +911,7 @@
632
911
  for (const m of messages) {
633
912
  const isStarred = state.starredIds.has(m.id);
634
913
  const isUnread = m.isNew;
914
+ const isSelected = state.selectedMessageId === m.id;
635
915
  const dateStr = formatTimestamp(m.created);
636
916
 
637
917
  let badgesHtml = "";
@@ -645,7 +925,7 @@
645
925
  const senderHighlighted = highlightTerms(escapeHtml(m.from || "unknown"), state.searchQuery);
646
926
 
647
927
  html += `
648
- <div class="mail-row ${isUnread ? "unread" : "read"}" data-msg-id="${m.id}">
928
+ <div class="mail-row ${isUnread ? "unread" : "read"} ${isSelected ? "selected" : ""}" data-msg-id="${m.id}" role="button" tabindex="0" aria-current="${isSelected ? "true" : "false"}">
649
929
  <div class="mail-row-actions">
650
930
  <button class="star-btn ${isStarred ? "starred" : ""}" data-star-id="${m.id}" title="Star message">
651
931
  ${isStarred ? "★" : "☆"}
@@ -667,11 +947,19 @@
667
947
  mailListEl.innerHTML = html;
668
948
 
669
949
  mailListEl.querySelectorAll(".mail-row").forEach((row) => {
670
- row.addEventListener("click", (e) => {
671
- if (e.target.closest(".star-btn")) return;
950
+ const activate = () => {
672
951
  const msgId = row.dataset.msgId;
673
952
  const msg = state.items.find((m) => m.id === msgId);
674
953
  if (msg) openSingleMessage(msg);
954
+ };
955
+ row.addEventListener("click", (e) => {
956
+ if (!e.target.closest(".star-btn")) activate();
957
+ });
958
+ row.addEventListener("keydown", (e) => {
959
+ if (e.key === "Enter" || e.key === " ") {
960
+ e.preventDefault();
961
+ activate();
962
+ }
675
963
  });
676
964
  });
677
965
 
@@ -694,6 +982,66 @@
694
982
  });
695
983
  }
696
984
 
985
+ function syncSelectedMailRow() {
986
+ mailListEl.querySelectorAll(".mail-row").forEach((row) => {
987
+ const selected = state.viewMode === "threads"
988
+ ? row.dataset.threadId === state.selectedThreadId
989
+ : row.dataset.msgId === state.selectedMessageId;
990
+ row.classList.toggle("selected", selected);
991
+ row.setAttribute("aria-current", selected ? "true" : "false");
992
+ });
993
+ }
994
+
995
+ function showMessageDetail() {
996
+ state.detailOpen = true;
997
+ contentSplitterEl.classList.add("detail-open");
998
+ mailDetailViewEl.classList.remove("hidden");
999
+ syncSelectedMailRow();
1000
+ mailDetailViewEl.scrollTop = 0;
1001
+ requestAnimationFrame(() => {
1002
+ const latestMessage = threadMessagesListEl.querySelector(".thread-card.expanded") || threadMessagesListEl.lastElementChild;
1003
+ if (latestMessage) {
1004
+ const detailTop = mailDetailViewEl.getBoundingClientRect().top;
1005
+ const headerHeight = mailDetailViewEl.querySelector(".detail-header")?.getBoundingClientRect().height || 0;
1006
+ const latestTop = latestMessage.getBoundingClientRect().top - detailTop;
1007
+ mailDetailViewEl.scrollTop = Math.max(0, latestTop - headerHeight - 12);
1008
+ if (state.readingLayout === "full" || window.matchMedia("(max-width: 768px)").matches) {
1009
+ latestMessage.tabIndex = -1;
1010
+ latestMessage.focus({ preventScroll: true });
1011
+ }
1012
+ } else if (state.readingLayout === "full" || window.matchMedia("(max-width: 768px)").matches) {
1013
+ mailDetailViewEl.focus({ preventScroll: true });
1014
+ }
1015
+ });
1016
+ }
1017
+
1018
+ function hideMessageDetail(restoreFocus = false) {
1019
+ state.detailOpen = false;
1020
+ contentSplitterEl.classList.remove("detail-open");
1021
+ mailDetailViewEl.classList.add("hidden");
1022
+ if (restoreFocus) {
1023
+ requestAnimationFrame(() => mailListEl.querySelector(".mail-row.selected")?.focus({ preventScroll: true }));
1024
+ }
1025
+ }
1026
+
1027
+ async function markMessageRead(message) {
1028
+ if (!message?.isNew || state.activeAccount === "all") return;
1029
+ const account = message.targetAccount || state.activeAccount;
1030
+ if (!account || account === "all") return;
1031
+ try {
1032
+ const res = await fetch(`/api/messages/${encodeURIComponent(message.id)}/read?account=${encodeURIComponent(account)}`, { method: "POST" });
1033
+ if (!res.ok) return;
1034
+ message.isNew = false;
1035
+ const thread = state.items.find((item) => item.threadId === state.selectedThreadId);
1036
+ if (thread) thread.hasUnread = (thread.messages || []).some((item) => item.isNew);
1037
+ renderList();
1038
+ } catch {}
1039
+ }
1040
+
1041
+ function markThreadRead(messages) {
1042
+ return Promise.all((messages || []).filter((message) => message.isNew).map((message) => markMessageRead(message)));
1043
+ }
1044
+
697
1045
  // ─── Open Thread (Conversation View) ────────────────────────────────────────
698
1046
 
699
1047
  function openThread(thread) {
@@ -764,7 +1112,8 @@
764
1112
  .map((r) => `<button class="chip" data-text="${escapeHtml(r)}">${escapeHtml(r)}</button>`)
765
1113
  .join("");
766
1114
 
767
- mailDetailViewEl.classList.remove("hidden");
1115
+ void markThreadRead(msgs);
1116
+ showMessageDetail();
768
1117
  }
769
1118
 
770
1119
  function openSingleMessage(msg) {
@@ -814,7 +1163,8 @@
814
1163
  .map((r) => `<button class="chip" data-text="${escapeHtml(r)}">${escapeHtml(r)}</button>`)
815
1164
  .join("");
816
1165
 
817
- mailDetailViewEl.classList.remove("hidden");
1166
+ void markMessageRead(msg);
1167
+ showMessageDetail();
818
1168
  }
819
1169
 
820
1170
  // Expand / collapse all cards in thread
@@ -1126,7 +1476,7 @@
1126
1476
  const existingTokens = new Set(parts.map((p) => p.toLowerCase()));
1127
1477
  const activeToken = isTrailingSpace ? "" : (parts[parts.length - 1] || "").toLowerCase();
1128
1478
 
1129
- const currentPersona = state.activeAccount === "all" ? (state.activePersona || "user") : state.activeAccount;
1479
+ const currentPersona = getActiveSender();
1130
1480
  const personaObj = Array.isArray(state.agents) ? state.agents.find((x) => x.handle === currentPersona) : null;
1131
1481
  const personaTitle = personaObj?.profile?.name || currentPersona;
1132
1482
  const personaAvatar = personaObj?.profile?.emoji || "👤";
@@ -1388,7 +1738,7 @@
1388
1738
  });
1389
1739
 
1390
1740
  backToListBtn.addEventListener("click", () => {
1391
- mailDetailViewEl.classList.add("hidden");
1741
+ hideMessageDetail(true);
1392
1742
  });
1393
1743
 
1394
1744
  refreshBtn.addEventListener("click", () => {
@@ -1422,7 +1772,7 @@
1422
1772
  sendQuickReplyBtn.textContent = "Sending...";
1423
1773
 
1424
1774
  try {
1425
- const currentPersona = state.activeAccount === "all" ? (state.activePersona || "user") : state.activeAccount;
1775
+ const currentPersona = getActiveSender();
1426
1776
  const res = await fetch("/api/reply", {
1427
1777
  method: "POST",
1428
1778
  headers: { "Content-Type": "application/json" },
@@ -1470,8 +1820,9 @@
1470
1820
 
1471
1821
  openComposeBtn.addEventListener("click", () => {
1472
1822
  composeModalEl.classList.remove("hidden");
1473
- const currentPersona = state.activeAccount === "all" ? (state.activePersona || "user") : state.activeAccount;
1823
+ const currentPersona = getActiveSender();
1474
1824
  composeFromEl.value = currentPersona;
1825
+ composeFromEl.disabled = state.activeAccount === "all";
1475
1826
  composeSubjectEl.focus();
1476
1827
  });
1477
1828
 
@@ -1502,7 +1853,7 @@
1502
1853
 
1503
1854
  composeFormEl.addEventListener("submit", async (e) => {
1504
1855
  e.preventDefault();
1505
- const from = composeFromEl.value;
1856
+ const from = getActiveSender(composeFromEl.value);
1506
1857
  const to = composeToEl.value;
1507
1858
  const subject = composeSubjectEl.value;
1508
1859
  const thread = composeThreadEl.value;
@@ -1567,21 +1918,17 @@
1567
1918
  }
1568
1919
  }
1569
1920
  });
1921
+ } else if (payload.type === "herdr_agents_refresh") {
1922
+ fetchAgents();
1570
1923
  } else if (payload.type === "herdr_agent_update") {
1571
- // Fast path: update just this agent's status dot without full re-fetch
1572
1924
  const { handle, herdrStatus } = payload;
1573
- if (handle && herdrStatus) {
1574
- // Update state.agents in place
1575
- const agent = state.agents.find((a) => a.handle === handle);
1576
- if (agent) {
1577
- agent.status = herdrStatus !== "unknown" ? herdrStatus : agent.status;
1578
- agent.herdrStatus = herdrStatus;
1579
- renderPresenceList(state.agents);
1580
- } else {
1581
- // Unknown agent — full refresh
1582
- fetchAgents();
1583
- }
1925
+ const agent = state.agents.find((item) => item.handle === handle);
1926
+ if (agent && herdrStatus) {
1927
+ agent.status = herdrStatus !== "unknown" ? herdrStatus : agent.status;
1928
+ agent.herdrStatus = herdrStatus;
1929
+ renderPresenceList(state.agents);
1584
1930
  }
1931
+ fetchAgents();
1585
1932
  }
1586
1933
  } catch {}
1587
1934
  };
@@ -2074,12 +2421,16 @@
2074
2421
  const data = await res.json();
2075
2422
  if (data.ok) {
2076
2423
  state.board = data;
2424
+ if (agentActivityDialog?.open) renderAgentActivity();
2077
2425
  if (boardTotalCountEl) {
2078
2426
  boardTotalCountEl.textContent = data.stats?.total || 0;
2079
2427
  }
2080
2428
  if (state.currentView === "board") {
2081
2429
  renderBoard();
2082
2430
  }
2431
+ if (taskModalBackdrop && !taskModalBackdrop.classList.contains("hidden")) {
2432
+ renderTaskOwnerTip();
2433
+ }
2083
2434
  }
2084
2435
  } catch (err) {
2085
2436
  console.warn("[board] Failed to fetch board:", err.message);
@@ -2350,7 +2701,7 @@
2350
2701
  headers: { "Content-Type": "application/json" },
2351
2702
  body: JSON.stringify({
2352
2703
  status: targetCol,
2353
- from: state.activePersona || "coordinator",
2704
+ from: getActiveSender(),
2354
2705
  notify: true,
2355
2706
  }),
2356
2707
  });
@@ -2375,19 +2726,42 @@
2375
2726
  }
2376
2727
  }
2377
2728
 
2729
+ function renderTaskOwnerTip() {
2730
+ if (!taskOwnerTip || !taskOwnerSelect) return;
2731
+ const owner = taskOwnerSelect.value;
2732
+ if (!owner) {
2733
+ taskOwnerTip.hidden = true;
2734
+ taskOwnerTip.textContent = "";
2735
+ return;
2736
+ }
2737
+ const tasks = [...(state.board?.columns?.in_progress || []), ...(state.board?.columns?.blocked || [])]
2738
+ .filter((task) => task.owner === owner);
2739
+ const claimed = tasks.filter((task) => task.status === "in_progress");
2740
+ const blocked = tasks.filter((task) => task.status === "blocked");
2741
+ taskOwnerTip.hidden = false;
2742
+ taskOwnerTip.className = tasks.length ? "task-owner-tip warning" : "task-owner-tip";
2743
+ if (!tasks.length) {
2744
+ taskOwnerTip.textContent = `@${owner} has no claimed or blocked board tasks.`;
2745
+ return;
2746
+ }
2747
+ const titles = tasks.slice(0, 2).map((task) => task.title).join(" · ");
2748
+ taskOwnerTip.textContent = `Before assigning: @${owner} has ${claimed.length} claimed and ${blocked.length} blocked. Review: ${titles}`;
2749
+ }
2750
+
2378
2751
  function openNewTaskModal() {
2379
2752
  if (!taskModalBackdrop) return;
2380
2753
 
2381
2754
  // Populate assigned agent options
2382
2755
  if (taskOwnerSelect) {
2383
2756
  let optionsHtml = "";
2384
- const currentPersona = state.activePersona || "coordinator";
2757
+ const currentPersona = getActiveSender();
2385
2758
  const handles = state.agents.map((a) => a.handle);
2386
2759
  if (!handles.includes("coordinator")) handles.unshift("coordinator");
2760
+ if (!handles.includes("user")) handles.push("user");
2387
2761
 
2388
2762
  for (const h of handles) {
2389
2763
  const agentObj = state.agents.find((a) => a.handle === h);
2390
- const name = agentObj?.profile?.name || h;
2764
+ const name = h === "user" ? "Human operator" : agentObj?.profile?.name || h;
2391
2765
  const selected = h === currentPersona ? "selected" : "";
2392
2766
  optionsHtml += `<option value="${escapeHtml(h)}" ${selected}>@${escapeHtml(h)} (${escapeHtml(name)})</option>`;
2393
2767
  }
@@ -2401,6 +2775,7 @@
2401
2775
  const notifyCheckbox = document.getElementById("task-notify-checkbox");
2402
2776
  if (notifyCheckbox) notifyCheckbox.checked = true;
2403
2777
 
2778
+ renderTaskOwnerTip();
2404
2779
  taskModalBackdrop.classList.remove("hidden");
2405
2780
  taskTitleInput?.focus();
2406
2781
  }
@@ -2415,13 +2790,14 @@
2415
2790
  if (!title) return;
2416
2791
 
2417
2792
  const notifyVal = document.getElementById("task-notify-checkbox")?.checked ?? true;
2793
+ const from = getActiveSender();
2418
2794
  const payload = {
2419
2795
  title,
2420
- owner: taskOwnerSelect?.value || "coordinator",
2796
+ owner: taskOwnerSelect?.value || from,
2421
2797
  status: taskStatusSelect?.value || "backlog",
2422
2798
  description: taskDescInput?.value.trim() || "",
2423
- notify: notifyVal,
2424
- from: state.activePersona || "coordinator",
2799
+ notify: notifyVal && taskOwnerSelect?.value !== from,
2800
+ from,
2425
2801
  };
2426
2802
 
2427
2803
  try {
@@ -2434,7 +2810,7 @@
2434
2810
  if (data.ok) {
2435
2811
  closeNewTaskModal();
2436
2812
  await fetchBoard();
2437
- const notifyMsg = notifyVal && payload.owner !== "coordinator" ? " & automail dispatched" : "";
2813
+ const notifyMsg = payload.notify ? " & automail dispatched" : "";
2438
2814
  showToast(`Task "${title.slice(0, 30)}" created${notifyMsg}!`);
2439
2815
  } else {
2440
2816
  alert("Failed to create task: " + (data.error || "unknown"));
@@ -2657,7 +3033,7 @@
2657
3033
 
2658
3034
  if (sheetDispatchFrom) {
2659
3035
  let fromOptions = "";
2660
- const currentPersona = state.activePersona || "coordinator";
3036
+ const currentPersona = getActiveSender();
2661
3037
  for (const h of handles) {
2662
3038
  const sel = h === currentPersona ? "selected" : "";
2663
3039
  fromOptions += `<option value="${escapeHtml(h)}" ${sel}>${escapeHtml(h)}</option>`;
@@ -2746,6 +3122,7 @@
2746
3122
  closeTaskModalBtn?.addEventListener("click", closeNewTaskModal);
2747
3123
  cancelTaskBtn?.addEventListener("click", closeNewTaskModal);
2748
3124
  newTaskForm?.addEventListener("submit", submitNewTask);
3125
+ taskOwnerSelect?.addEventListener("change", renderTaskOwnerTip);
2749
3126
 
2750
3127
  taskModalBackdrop?.addEventListener("click", (e) => {
2751
3128
  if (e.target === taskModalBackdrop) closeNewTaskModal();
@@ -2816,7 +3193,7 @@
2816
3193
  headers: { "Content-Type": "application/json" },
2817
3194
  body: JSON.stringify({
2818
3195
  owner: newOwner,
2819
- from: state.activePersona || "coordinator",
3196
+ from: getActiveSender(),
2820
3197
  notify: true,
2821
3198
  }),
2822
3199
  });
@@ -2857,7 +3234,7 @@
2857
3234
  return;
2858
3235
  }
2859
3236
 
2860
- const fromVal = sheetDispatchFrom?.value || state.activePersona || "coordinator";
3237
+ const fromVal = getActiveSender(sheetDispatchFrom?.value || "");
2861
3238
  const toVal = sheetDispatchTo?.value || "coordinator";
2862
3239
  const task = findTaskById(state.selectedTaskId);
2863
3240
 
@@ -2952,13 +3329,16 @@
2952
3329
  if (state.currentView === "board") {
2953
3330
  return document.querySelector(".kanban-grid") || boardViewSection;
2954
3331
  }
2955
- return document.querySelector(".mail-list-container") || mailListEl;
3332
+ if (state.detailOpen && !mailDetailViewEl.classList.contains("hidden")) {
3333
+ return mailDetailViewEl;
3334
+ }
3335
+ return mailListViewEl;
2956
3336
  }
2957
3337
 
2958
3338
  function isAtTop() {
2959
3339
  const container = getActiveScrollableContainer();
2960
3340
  if (!container) return window.scrollY <= 0;
2961
- return container.scrollTop <= 0 && window.scrollY <= 0;
3341
+ return container.scrollTop <= 1 && window.scrollY <= 0;
2962
3342
  }
2963
3343
 
2964
3344
  async function triggerReload() {
@@ -3048,6 +3428,8 @@
3048
3428
  }
3049
3429
 
3050
3430
  // Boot
3431
+ applyThemePreference(state.themePreference, false);
3432
+ applyReadingLayout(state.readingLayout, false);
3051
3433
  fetchStatus();
3052
3434
  fetchAgents();
3053
3435
  fetchBriefs();