herdr-plugin-amq 0.1.2

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 ADDED
@@ -0,0 +1,3066 @@
1
+ // AGmail Client Application
2
+ (function () {
3
+ const state = {
4
+ activeAccount: "all",
5
+ activePersona: localStorage.getItem("agmail_persona") || "user",
6
+ activeFolder: "inbox",
7
+ activeCategory: "all",
8
+ viewMode: "threads", // "threads" (Conversation view) or "flat" (Individual transmissions)
9
+ searchQuery: "",
10
+ selectedThreadId: null,
11
+ selectedMessageId: null,
12
+ starredIds: new Set(JSON.parse(localStorage.getItem("agmail_starred") || "[]")),
13
+ items: [], // threads or messages depending on viewMode
14
+ page: 1,
15
+ pageSize: 50,
16
+ total: 0,
17
+ totalPages: 1,
18
+ agents: [],
19
+ worktrees: [],
20
+ briefs: [],
21
+ status: {},
22
+ contextTarget: null,
23
+ currentView: "mail", // "mail" or "board"
24
+ board: null,
25
+ boardFilterAgent: "all",
26
+ boardSearchQuery: "",
27
+ selectedTaskId: null,
28
+ };
29
+
30
+ // DOM Elements
31
+ const mailListEl = document.getElementById("mail-list");
32
+ const mailDetailViewEl = document.getElementById("mail-detail-view");
33
+ const searchInputEl = document.getElementById("search-input");
34
+ const clearSearchBtn = document.getElementById("clear-search");
35
+ const headerSearchContainer = document.getElementById("header-search-container");
36
+ const searchBarEl = document.getElementById("search-bar");
37
+ const searchAutocompleteDropdownEl = document.getElementById("search-autocomplete-dropdown");
38
+ const searchSuggestionsListEl = document.getElementById("search-suggestions-list");
39
+ const bridgeStatusPill = document.getElementById("bridge-status-pill");
40
+ const bridgeStatusText = document.getElementById("bridge-status-text");
41
+ const refreshBtn = document.getElementById("refresh-btn");
42
+ const presenceListEl = document.getElementById("presence-list");
43
+ const inboxUnreadCountEl = document.getElementById("inbox-unread-count");
44
+ const pageInfoEl = document.getElementById("page-info");
45
+ const prevPageBtn = document.getElementById("prev-page-btn");
46
+ const nextPageBtn = document.getElementById("next-page-btn");
47
+ const filterInfoEl = document.getElementById("filter-info");
48
+ const backToListBtn = document.getElementById("back-to-list-btn");
49
+ const storageUsedEl = document.getElementById("storage-used-text");
50
+ const storageFillEl = document.getElementById("storage-bar-fill");
51
+ const toggleSidebarBtn = document.getElementById("toggle-sidebar");
52
+ const sidebarBackdrop = document.getElementById("sidebar-backdrop");
53
+ const sidebarEl = document.getElementById("sidebar");
54
+
55
+ // Account Menu Elements
56
+ const userProfileBtn = document.getElementById("user-profile-btn");
57
+ const accountDropdown = document.getElementById("account-dropdown");
58
+ const accountDropdownList = document.getElementById("account-dropdown-list");
59
+ const headerAccountLabel = document.getElementById("header-account-label");
60
+ const currentUserAvatar = document.getElementById("current-user-avatar");
61
+ const dropdownLargeAvatar = document.getElementById("dropdown-large-avatar");
62
+ const dropdownUserTitle = document.getElementById("dropdown-user-title");
63
+ const dropdownUserEmail = document.getElementById("dropdown-user-email");
64
+
65
+ // Context Menu Elements
66
+ const chatContextMenu = document.getElementById("chat-context-menu");
67
+ const ctxHeader = document.getElementById("ctx-header");
68
+ const ctxHeaderTitle = document.getElementById("ctx-header-title");
69
+ const ctxHeaderSub = document.getElementById("ctx-header-sub");
70
+ const ctxReply = document.getElementById("ctx-reply");
71
+ const ctxReplyText = document.getElementById("ctx-reply-text");
72
+ const ctxQuote = document.getElementById("ctx-quote");
73
+ const ctxQuoteText = document.getElementById("ctx-quote-text");
74
+ const ctxFilterSender = document.getElementById("ctx-filter-sender");
75
+ const ctxFilterSenderText = document.getElementById("ctx-filter-sender-text");
76
+ const ctxCopyId = document.getElementById("ctx-copy-id");
77
+ const ctxCopyIdText = document.getElementById("ctx-copy-id-text");
78
+ const ctxRegisterAgent = document.getElementById("ctx-register-agent");
79
+ const ctxRegisterText = document.getElementById("ctx-register-text");
80
+
81
+ // Register Agent Modal Elements
82
+ const registerAgentBackdrop = document.getElementById("register-agent-backdrop");
83
+ const agentModalTitle = document.getElementById("agent-modal-title");
84
+ const openRegisterAgentBtn = document.getElementById("open-register-agent-btn");
85
+ const closeRegisterAgentBtn = document.getElementById("close-register-agent-btn");
86
+ const cancelRegisterAgentBtn = document.getElementById("cancel-register-agent-btn");
87
+ const registerAgentForm = document.getElementById("register-agent-form");
88
+ const briefChipsList = document.getElementById("brief-chips-list");
89
+ const pullDiskBriefBtn = document.getElementById("pull-disk-brief-btn");
90
+ const newAgentHandleInput = document.getElementById("new-agent-handle");
91
+ const newAgentNameInput = document.getElementById("new-agent-name");
92
+ const newAgentRoleInput = document.getElementById("new-agent-role");
93
+ const newAgentModelInput = document.getElementById("new-agent-model");
94
+ const modelSuggestionsDatalist = document.getElementById("model-suggestions");
95
+ const newAgentPromptInput = document.getElementById("new-agent-prompt");
96
+ const briefSourceBadge = document.getElementById("brief-source-badge");
97
+
98
+ // View Mode Elements
99
+ const viewModeThreadsBtn = document.getElementById("view-mode-threads");
100
+ const viewModeFlatBtn = document.getElementById("view-mode-flat");
101
+
102
+ // Detail View Elements
103
+ const detailSubjectEl = document.getElementById("detail-subject");
104
+ const detailThreadBadgeEl = document.getElementById("detail-thread-badge");
105
+ const threadMessagesListEl = document.getElementById("thread-messages-list");
106
+ const expandCollapseBtn = document.getElementById("expand-collapse-btn");
107
+ const replyAsUserEl = document.getElementById("reply-as-user");
108
+ const quickReplyTextEl = document.getElementById("quick-reply-text");
109
+ const sendQuickReplyBtn = document.getElementById("send-quick-reply-btn");
110
+ const smartRepliesChipsEl = document.getElementById("smart-replies-chips");
111
+
112
+ // Compose Elements
113
+ const composeModalEl = document.getElementById("compose-modal");
114
+ const openComposeBtn = document.getElementById("open-compose-btn");
115
+ const closeComposeBtn = document.getElementById("close-compose-btn");
116
+ const composeFormEl = document.getElementById("compose-form");
117
+ const composeTemplateEl = document.getElementById("compose-template");
118
+ const composeFromEl = document.getElementById("compose-from");
119
+ const composeToEl = document.getElementById("compose-to");
120
+ const composeSubjectEl = document.getElementById("compose-subject");
121
+ const composeThreadEl = document.getElementById("compose-thread");
122
+ const composeBodyEl = document.getElementById("compose-body");
123
+
124
+ // Lightbox Elements
125
+ const lightboxModal = document.getElementById("lightbox-modal");
126
+ const lightboxImg = document.getElementById("lightbox-img");
127
+ const lightboxTitle = document.getElementById("lightbox-title");
128
+ const lightboxDownloadLink = document.getElementById("lightbox-download-link");
129
+ const lightboxCloseBtn = document.getElementById("lightbox-close-btn");
130
+ const lightboxBackdrop = document.getElementById("lightbox-backdrop");
131
+
132
+ // Kanban Board Elements
133
+ const navViewMail = document.getElementById("nav-view-mail");
134
+ const navViewBoard = document.getElementById("nav-view-board");
135
+ const mailViewSection = document.getElementById("mail-view-section");
136
+ const boardViewSection = document.getElementById("board-view-section");
137
+ const boardTotalCountEl = document.getElementById("board-total-count");
138
+ const boardSearchInput = document.getElementById("board-search-input");
139
+ const refreshBoardBtn = document.getElementById("refresh-board-btn");
140
+ const openNewTaskBtn = document.getElementById("open-new-task-btn");
141
+ const boardAgentFilterBar = document.getElementById("board-agent-filter-bar");
142
+ const statTotalEl = document.getElementById("stat-total");
143
+ const statProgressEl = document.getElementById("stat-progress");
144
+ const statBlockedEl = document.getElementById("stat-blocked");
145
+ const statDoneEl = document.getElementById("stat-done");
146
+ const cardsBacklogEl = document.getElementById("cards-backlog");
147
+ const cardsInProgressEl = document.getElementById("cards-in_progress");
148
+ const cardsBlockedEl = document.getElementById("cards-blocked");
149
+ const cardsDoneEl = document.getElementById("cards-done");
150
+ const colCountBacklog = document.getElementById("col-count-backlog");
151
+ const colCountInProgress = document.getElementById("col-count-in_progress");
152
+ const colCountBlocked = document.getElementById("col-count-blocked");
153
+ const colCountDone = document.getElementById("col-count-done");
154
+
155
+ // Task Modal Elements
156
+ const taskModalBackdrop = document.getElementById("task-modal-backdrop");
157
+ const closeTaskModalBtn = document.getElementById("close-task-modal-btn");
158
+ const cancelTaskBtn = document.getElementById("cancel-task-btn");
159
+ const newTaskForm = document.getElementById("new-task-form");
160
+ const taskTitleInput = document.getElementById("task-title-input");
161
+ const taskOwnerSelect = document.getElementById("task-owner-select");
162
+ const taskStatusSelect = document.getElementById("task-status-select");
163
+ const taskDescInput = document.getElementById("task-desc-input");
164
+
165
+ // Task Sheet (Ficha do Card) Elements
166
+ const taskSheetBackdrop = document.getElementById("task-sheet-backdrop");
167
+ const taskSheetDrawer = document.getElementById("task-sheet-drawer");
168
+ const closeTaskSheetBtn = document.getElementById("close-task-sheet-btn");
169
+ const sheetBackBtn = document.getElementById("sheet-back-btn");
170
+ const sheetTaskId = document.getElementById("sheet-task-id");
171
+ const sheetTaskSource = document.getElementById("sheet-task-source");
172
+ const sheetCopyIdBtn = document.getElementById("sheet-copy-id-btn");
173
+ const sheetTaskTitle = document.getElementById("sheet-task-title");
174
+ const sheetStageButtons = document.getElementById("sheet-stage-buttons");
175
+ const sheetOwnerAvatar = document.getElementById("sheet-owner-avatar");
176
+ const sheetOwnerSelect = document.getElementById("sheet-owner-select");
177
+ const sheetThreadId = document.getElementById("sheet-thread-id");
178
+ const sheetTaskDesc = document.getElementById("sheet-task-desc");
179
+ const sheetDescBackBtn = document.getElementById("sheet-desc-back-btn");
180
+ const sheetDescExpandBtn = document.getElementById("sheet-desc-expand-btn");
181
+ const sheetDescExpandIcon = document.getElementById("sheet-desc-expand-icon");
182
+ const sheetDescExpandText = document.getElementById("sheet-desc-expand-text");
183
+ const sheetWideBtn = document.getElementById("sheet-wide-btn");
184
+ const sheetWideIcon = document.getElementById("sheet-wide-icon");
185
+ const sheetWideText = document.getElementById("sheet-wide-text");
186
+ const sheetTabsBar = document.getElementById("sheet-tabs-bar");
187
+ const sheetTabBadge = document.getElementById("sheet-tab-badge");
188
+ const sheetThreadBadge = document.getElementById("sheet-thread-badge");
189
+ const sheetRefreshThreadBtn = document.getElementById("sheet-refresh-thread-btn");
190
+ const sheetThreadList = document.getElementById("sheet-thread-list");
191
+ const sheetDispatchFrom = document.getElementById("sheet-dispatch-from");
192
+ const sheetDispatchTo = document.getElementById("sheet-dispatch-to");
193
+ const sheetDispatchChips = document.getElementById("sheet-dispatch-chips");
194
+ const sheetDispatchBody = document.getElementById("sheet-dispatch-body");
195
+ const sheetDispatchThreadPreview = document.getElementById("sheet-dispatch-thread-preview");
196
+ const sheetDispatchSendBtn = document.getElementById("sheet-dispatch-send-btn");
197
+
198
+
199
+ // ─── Data Fetching ──────────────────────────────────────────────────────────
200
+
201
+ async function fetchStatus() {
202
+ try {
203
+ const res = await fetch("/api/status");
204
+ const data = await res.json();
205
+ state.status = data;
206
+
207
+ if (data.daemonRunning) {
208
+ bridgeStatusPill.className = "status-pill status-running";
209
+ bridgeStatusText.textContent = `Bridge: Running (${data.pid})`;
210
+ } else {
211
+ bridgeStatusPill.className = "status-pill status-stopped";
212
+ bridgeStatusText.textContent = "Bridge: Stopped (click to start)";
213
+ }
214
+
215
+ inboxUnreadCountEl.textContent = data.totalUnread || 0;
216
+
217
+ if (data.storage && storageUsedEl && storageFillEl) {
218
+ storageUsedEl.textContent = data.storage.display;
219
+ const pct = Math.min(100, Math.round((data.storage.mb / 100) * 100));
220
+ storageFillEl.style.width = `${Math.max(3, pct)}%`;
221
+ }
222
+ } catch {
223
+ bridgeStatusText.textContent = "Bridge: Offline";
224
+ }
225
+ }
226
+
227
+ async function fetchAgents() {
228
+ try {
229
+ const res = await fetch("/api/agents");
230
+ const list = await res.json();
231
+ state.agents = list;
232
+ renderAccountDropdown(list);
233
+ renderPresenceList(list);
234
+ populateComposeDropdowns(list);
235
+ } catch {}
236
+ }
237
+
238
+ async function fetchWorktrees() {
239
+ try {
240
+ const res = await fetch("/api/worktrees");
241
+ const list = await res.json();
242
+ state.worktrees = Array.isArray(list) ? list : [];
243
+ renderPresenceList(state.agents);
244
+ } catch {}
245
+ }
246
+
247
+ async function fetchModels() {
248
+ try {
249
+ const res = await fetch("/api/models");
250
+ const list = await res.json();
251
+ if (Array.isArray(list) && modelSuggestionsDatalist) {
252
+ modelSuggestionsDatalist.innerHTML = list
253
+ .map((m) => `<option value="${escapeHtml(m.id)}">${escapeHtml(m.name || m.id)}</option>`)
254
+ .join("");
255
+ }
256
+ } catch {}
257
+ }
258
+
259
+ async function fetchData() {
260
+ try {
261
+ const endpoint = state.viewMode === "threads" ? "/api/threads" : "/api/messages";
262
+ const currentPersona = state.activeAccount === "all" ? (state.activePersona || "user") : state.activeAccount;
263
+ const params = new URLSearchParams({
264
+ account: state.activeAccount,
265
+ persona: currentPersona,
266
+ folder: state.searchQuery ? "all" : state.activeFolder,
267
+ query: state.searchQuery,
268
+ page: String(state.page),
269
+ pageSize: String(state.pageSize),
270
+ paginate: "true",
271
+ });
272
+
273
+ const res = await fetch(`${endpoint}?${params}`);
274
+ const data = await res.json();
275
+ if (data && typeof data === "object" && "items" in data) {
276
+ state.items = data.items || [];
277
+ state.total = data.total || 0;
278
+ state.page = data.page || 1;
279
+ state.totalPages = data.totalPages || 1;
280
+ } else {
281
+ state.items = Array.isArray(data) ? data : [];
282
+ state.total = state.items.length;
283
+ state.page = 1;
284
+ state.totalPages = 1;
285
+ }
286
+ renderList();
287
+ } catch (e) {
288
+ mailListEl.innerHTML = `<div class="empty-state">Error loading transmissions: ${e.message}</div>`;
289
+ }
290
+ }
291
+
292
+ // ─── Google Account Dropdown ────────────────────────────────────────────────
293
+
294
+ function renderAccountDropdown(agents) {
295
+ 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>
304
+ </button>
305
+ `;
306
+
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>` : "";
311
+
312
+ 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>
321
+ ${unreadBadge}
322
+ </button>
323
+ `;
324
+ }
325
+
326
+ accountDropdownList.innerHTML = html;
327
+
328
+ accountDropdownList.querySelectorAll(".account-item-btn").forEach((btn) => {
329
+ btn.addEventListener("click", () => {
330
+ switchActiveAccount(btn.dataset.handle);
331
+ accountDropdown.classList.add("hidden");
332
+ });
333
+ });
334
+ }
335
+
336
+ function switchActiveAccount(handle) {
337
+ state.activeAccount = handle;
338
+ if (handle !== "all") {
339
+ state.activePersona = handle;
340
+ localStorage.setItem("agmail_persona", handle);
341
+ }
342
+
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;
346
+
347
+ if (handle === "all") {
348
+ headerAccountLabel.textContent = "👑 God Mode";
349
+ currentUserAvatar.textContent = "👑";
350
+ currentUserAvatar.style.backgroundColor = "var(--primary-blue)";
351
+ dropdownLargeAvatar.textContent = "👑";
352
+ dropdownLargeAvatar.style.backgroundColor = "var(--primary-blue)";
353
+ dropdownUserTitle.textContent = `God Mode (Persona: ${personaName})`;
354
+ dropdownUserEmail.textContent = "amq://all-agents";
355
+ } else {
356
+ const agentObj = state.agents.find((x) => x.handle === handle);
357
+ const prof = agentObj?.profile || { name: handle, emoji: handle.slice(0, 1).toUpperCase(), color: "#1a73e8", role: "Persona" };
358
+ headerAccountLabel.textContent = prof.name;
359
+ currentUserAvatar.textContent = prof.emoji;
360
+ currentUserAvatar.style.backgroundColor = prof.color;
361
+ dropdownLargeAvatar.textContent = prof.emoji;
362
+ dropdownLargeAvatar.style.backgroundColor = prof.color;
363
+ dropdownUserTitle.textContent = `${prof.name} (${prof.role})`;
364
+ dropdownUserEmail.textContent = `<${handle}@amq>`;
365
+ }
366
+
367
+ replyAsUserEl.textContent = currentPersona;
368
+ renderAccountDropdown(state.agents);
369
+ fetchData();
370
+ }
371
+
372
+ // Toggle account dropdown on user profile click
373
+ userProfileBtn.addEventListener("click", (e) => {
374
+ e.stopPropagation();
375
+ accountDropdown.classList.toggle("hidden");
376
+ });
377
+
378
+ // Close dropdown on outside click
379
+ document.addEventListener("click", (e) => {
380
+ if (!accountDropdown.contains(e.target) && !userProfileBtn.contains(e.target)) {
381
+ accountDropdown.classList.add("hidden");
382
+ }
383
+ });
384
+
385
+ // ─── Swarm Presence List ────────────────────────────────────────────────────
386
+
387
+ function renderPresenceList(agents) {
388
+ 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}`;
414
+
415
+ 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>
426
+ `;
427
+ }
428
+ presenceListEl.innerHTML = html;
429
+ }
430
+
431
+
432
+ function levenshtein(a, b) {
433
+ if (a.length === 0) return b.length;
434
+ if (b.length === 0) return a.length;
435
+ const matrix = [];
436
+ for (let i = 0; i <= b.length; i++) matrix[i] = [i];
437
+ for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
438
+ for (let i = 1; i <= b.length; i++) {
439
+ for (let j = 1; j <= a.length; j++) {
440
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
441
+ matrix[i][j] = matrix[i - 1][j - 1];
442
+ } else {
443
+ matrix[i][j] = Math.min(
444
+ matrix[i - 1][j - 1] + 1,
445
+ Math.min(matrix[i][j - 1] + 1, matrix[i - 1][j] + 1)
446
+ );
447
+ }
448
+ }
449
+ }
450
+ return matrix[b.length][a.length];
451
+ }
452
+
453
+ function highlightTerms(htmlText, query) {
454
+ if (!htmlText || !query) return htmlText;
455
+
456
+ const rawTokens = (query.match(/(?:[^\s"]+|"[^"]*")+/g) || []);
457
+ const tokens = [];
458
+
459
+ for (const raw of rawTokens) {
460
+ const clean = raw.replace(/^"|"$/g, "").trim();
461
+ if (!clean) continue;
462
+ if (clean.includes(":")) {
463
+ const idx = clean.indexOf(":");
464
+ const prefix = clean.slice(0, idx).toLowerCase();
465
+ const val = clean.slice(idx + 1).replace(/^"|"$/g, "").trim();
466
+ if ((prefix === "from" || prefix === "to" || prefix === "recipient") && val && val.toLowerCase() !== "me") {
467
+ tokens.push(val);
468
+ }
469
+ } else if (clean.length >= 1) {
470
+ tokens.push(clean);
471
+ }
472
+ }
473
+
474
+ if (!tokens.length) return htmlText;
475
+
476
+ let result = htmlText;
477
+ for (const term of tokens) {
478
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
479
+ const regex = new RegExp(`(?![^<]*>)(${escaped})`, "gi");
480
+ if (regex.test(result)) {
481
+ result = result.replace(regex, '<mark class="search-highlight">$1</mark>');
482
+ } else if (term.length >= 4) {
483
+ // Fuzzy word match highlighting for typo queries (e.g. filtwr -> filter)
484
+ const t = term.toLowerCase();
485
+ result = result.replace(/(?![^<]*>)\b([a-zA-Z0-9_-]+)\b/g, (match, word) => {
486
+ const w = word.toLowerCase();
487
+ if (Math.abs(w.length - t.length) <= 2 && levenshtein(w, t) <= (t.length <= 5 ? 1 : 2)) {
488
+ return `<mark class="search-highlight">${word}</mark>`;
489
+ }
490
+ return match;
491
+ });
492
+ }
493
+ }
494
+ return result;
495
+ }
496
+
497
+
498
+ function getContextualSmartReplies(subject = "", body = "", kind = "") {
499
+ const text = (subject + " " + body).toLowerCase();
500
+ if (text.includes("lock") || text.includes("contention") || text.includes("blocked")) {
501
+ return [
502
+ "Understood, clearing contention on my lane.",
503
+ "Lane clear, proceeding with coordination lock.",
504
+ "Checked, no conflicting process active.",
505
+ ];
506
+ }
507
+ if (text.includes("test") || text.includes("verify") || text.includes("gate") || text.includes("pass")) {
508
+ return [
509
+ "Verification pass confirmed.",
510
+ "Investigating test results on my branch.",
511
+ "Evidence confirmed in logs.",
512
+ ];
513
+ }
514
+ if (kind === "todo" || text.includes("todo") || text.includes("claim") || text.includes("task")) {
515
+ return [
516
+ "Ack, task claimed and starting work.",
517
+ "Acknowledged, on it.",
518
+ "Task noted, will report back with evidence.",
519
+ ];
520
+ }
521
+ if (text.includes("question") || text.includes("?") || text.includes("consult")) {
522
+ return [
523
+ "Reviewing the question, investigating options.",
524
+ "Understood, checking requirements.",
525
+ "Will provide details shortly.",
526
+ ];
527
+ }
528
+ return [
529
+ "Ack, received and processing.",
530
+ "Understood, thank you.",
531
+ "Acknowledged, checking status.",
532
+ ];
533
+ }
534
+
535
+ // ─── Mail List & Thread Rendering ──────────────────────────────────────────
536
+
537
+ function renderList() {
538
+ let filtered = [...state.items];
539
+
540
+ // Filter by category
541
+ if (state.activeCategory === "gate") {
542
+ filtered = filtered.filter((item) => {
543
+ const text = (item.subject + " " + (item.latestSnippet || item.snippet || "")).toLowerCase();
544
+ return text.includes("gate") || text.includes("verify") || text.includes("pass") || text.includes("lock");
545
+ });
546
+ } else if (state.activeCategory === "brainstorm") {
547
+ filtered = filtered.filter((item) => {
548
+ const text = (item.subject + " " + (item.latestSnippet || item.snippet || "")).toLowerCase();
549
+ return text.includes("brainstorm") || text.includes("survey") || text.includes("design");
550
+ });
551
+ }
552
+
553
+ const countLabel = state.viewMode === "threads" ? "threads" : "messages";
554
+ const start = state.total === 0 ? 0 : (state.page - 1) * state.pageSize + 1;
555
+ const end = Math.min(state.page * state.pageSize, state.total);
556
+ pageInfoEl.textContent = state.total > 0 ? `${start}-${end} of ${state.total.toLocaleString()} ${countLabel}` : `0 ${countLabel}`;
557
+
558
+ if (prevPageBtn) prevPageBtn.disabled = state.page <= 1;
559
+ if (nextPageBtn) nextPageBtn.disabled = state.page >= state.totalPages;
560
+
561
+ const currentPersona = state.activeAccount === "all" ? (state.activePersona || "user") : state.activeAccount;
562
+ filterInfoEl.textContent = `Account: ${state.activeAccount} • Persona: ${currentPersona} • Mode: ${state.viewMode}`;
563
+
564
+ if (!filtered.length) {
565
+ mailListEl.innerHTML = `<div class="empty-state">No transmissions found matching criteria.</div>`;
566
+ return;
567
+ }
568
+
569
+ if (state.viewMode === "threads") {
570
+ renderThreadList(filtered);
571
+ } else {
572
+ renderFlatList(filtered);
573
+ }
574
+ }
575
+
576
+ function renderThreadList(threads) {
577
+ let html = "";
578
+ for (const t of threads) {
579
+ const isUnread = t.hasUnread;
580
+ const isStarred = state.starredIds.has(t.threadId);
581
+ const dateStr = formatTimestamp(t.latestCreated);
582
+
583
+ const senders = t.participants.join(", ");
584
+ const countSuffix = t.messageCount > 1 ? `<span class="thread-count-badge">(${t.messageCount})</span>` : "";
585
+
586
+ let badgesHtml = "";
587
+ if (isUnread) badgesHtml += `<span class="tag-badge tag-new">New</span>`;
588
+ if (t.hasImage) badgesHtml += `<span title="Contains image artifacts">🖼️</span>`;
589
+ if (t.hasAttachment) badgesHtml += `<span title="Contains file attachments">📎</span>`;
590
+ if ((t.subject + " " + t.latestSnippet).includes("PASS")) badgesHtml += `<span class="tag-badge tag-gate">Gate</span>`;
591
+
592
+ const subjectHighlighted = highlightTerms(escapeHtml(t.subject || "(no subject)"), state.searchQuery);
593
+ const snippetHighlighted = highlightTerms(escapeHtml(t.latestSnippet || ""), state.searchQuery);
594
+ const sendersHighlighted = highlightTerms(escapeHtml(senders), state.searchQuery);
595
+
596
+ html += `
597
+ <div class="mail-row ${isUnread ? "unread" : "read"}" data-thread-id="${t.threadId}">
598
+ <div class="mail-row-actions">
599
+ <button class="star-btn ${isStarred ? "starred" : ""}" data-star-id="${t.threadId}" title="Star thread">
600
+ ${isStarred ? "★" : "☆"}
601
+ </button>
602
+ </div>
603
+ <div class="mail-sender" title="${senders}">
604
+ <span>${sendersHighlighted}</span> ${countSuffix}
605
+ </div>
606
+ <div class="mail-subject-wrap">
607
+ <span class="mail-subject">${subjectHighlighted}</span>
608
+ <span class="mail-snippet">: ${snippetHighlighted}</span>
609
+ <div class="mail-badges">${badgesHtml}</div>
610
+ </div>
611
+ <div class="mail-date">${dateStr}</div>
612
+ </div>
613
+ `;
614
+ }
615
+
616
+ mailListEl.innerHTML = html;
617
+
618
+ mailListEl.querySelectorAll(".mail-row").forEach((row) => {
619
+ row.addEventListener("click", (e) => {
620
+ if (e.target.closest(".star-btn")) return;
621
+ const threadId = row.dataset.threadId;
622
+ const thread = state.items.find((t) => t.threadId === threadId);
623
+ if (thread) openThread(thread);
624
+ });
625
+ });
626
+
627
+ attachStarListeners();
628
+ }
629
+
630
+ function renderFlatList(messages) {
631
+ let html = "";
632
+ for (const m of messages) {
633
+ const isStarred = state.starredIds.has(m.id);
634
+ const isUnread = m.isNew;
635
+ const dateStr = formatTimestamp(m.created);
636
+
637
+ let badgesHtml = "";
638
+ if (isUnread) badgesHtml += `<span class="tag-badge tag-new">New</span>`;
639
+ if (m.hasImage) badgesHtml += `<span title="Contains image artifacts">🖼️</span>`;
640
+ if (m.hasAttachment) badgesHtml += `<span title="Contains file attachments">📎</span>`;
641
+ if ((m.subject + " " + m.body).includes("PASS")) badgesHtml += `<span class="tag-badge tag-gate">Gate</span>`;
642
+
643
+ const subjectHighlighted = highlightTerms(escapeHtml(m.subject || "(no subject)"), state.searchQuery);
644
+ const snippetHighlighted = highlightTerms(escapeHtml(m.snippet || ""), state.searchQuery);
645
+ const senderHighlighted = highlightTerms(escapeHtml(m.from || "unknown"), state.searchQuery);
646
+
647
+ html += `
648
+ <div class="mail-row ${isUnread ? "unread" : "read"}" data-msg-id="${m.id}">
649
+ <div class="mail-row-actions">
650
+ <button class="star-btn ${isStarred ? "starred" : ""}" data-star-id="${m.id}" title="Star message">
651
+ ${isStarred ? "★" : "☆"}
652
+ </button>
653
+ </div>
654
+ <div class="mail-sender" title="${m.from || "unknown"}">
655
+ ${senderHighlighted}
656
+ </div>
657
+ <div class="mail-subject-wrap">
658
+ <span class="mail-subject">${subjectHighlighted}</span>
659
+ <span class="mail-snippet">: ${snippetHighlighted}</span>
660
+ <div class="mail-badges">${badgesHtml}</div>
661
+ </div>
662
+ <div class="mail-date">${dateStr}</div>
663
+ </div>
664
+ `;
665
+ }
666
+
667
+ mailListEl.innerHTML = html;
668
+
669
+ mailListEl.querySelectorAll(".mail-row").forEach((row) => {
670
+ row.addEventListener("click", (e) => {
671
+ if (e.target.closest(".star-btn")) return;
672
+ const msgId = row.dataset.msgId;
673
+ const msg = state.items.find((m) => m.id === msgId);
674
+ if (msg) openSingleMessage(msg);
675
+ });
676
+ });
677
+
678
+ attachStarListeners();
679
+ }
680
+
681
+ function attachStarListeners() {
682
+ mailListEl.querySelectorAll(".star-btn").forEach((btn) => {
683
+ btn.addEventListener("click", (e) => {
684
+ e.stopPropagation();
685
+ const id = btn.dataset.starId;
686
+ if (state.starredIds.has(id)) {
687
+ state.starredIds.delete(id);
688
+ } else {
689
+ state.starredIds.add(id);
690
+ }
691
+ localStorage.setItem("agmail_starred", JSON.stringify([...state.starredIds]));
692
+ renderList();
693
+ });
694
+ });
695
+ }
696
+
697
+ // ─── Open Thread (Conversation View) ────────────────────────────────────────
698
+
699
+ function openThread(thread) {
700
+ state.selectedThreadId = thread.threadId;
701
+
702
+ if (state.searchQuery) {
703
+ detailSubjectEl.innerHTML = highlightTerms(escapeHtml(thread.subject || "(no subject)"), state.searchQuery);
704
+ } else {
705
+ detailSubjectEl.textContent = thread.subject || "(no subject)";
706
+ }
707
+ detailThreadBadgeEl.textContent = `#${thread.threadId} (${thread.messageCount})`;
708
+
709
+ const msgs = thread.messages || [];
710
+ let html = "";
711
+
712
+ msgs.forEach((m, idx) => {
713
+ const isLatest = idx === msgs.length - 1;
714
+ const agentObj = state.agents.find((x) => x.handle === m.from);
715
+ const prof = agentObj?.profile || { name: m.from, emoji: (m.from || "?").slice(0, 1).toUpperCase(), color: "#1a73e8", role: "Persona" };
716
+ const dateStr = m.created ? new Date(m.created).toLocaleString() : "";
717
+ const toStr = Array.isArray(m.to) ? m.to.join(", ") : (m.to || "all");
718
+ const renderedBody = renderMarkdown(m.body);
719
+ const highlightedBody = state.searchQuery ? highlightTerms(renderedBody, state.searchQuery) : renderedBody;
720
+ const attachmentsHtml = renderAttachmentsSection(m.attachments);
721
+
722
+ html += `
723
+ <div class="thread-card ${isLatest ? "expanded" : "collapsed"}" data-card-idx="${idx}" data-msg-id="${m.id}" data-msg-from="${m.from}">
724
+ <div class="thread-card-header">
725
+ <div class="thread-card-header-left">
726
+ <div class="thread-card-avatar" style="background:${prof.color};color:#fff;">${prof.emoji}</div>
727
+ <strong class="thread-card-author">${prof.name}</strong>
728
+ <span class="thread-card-snippet">${escapeHtml(m.snippet || "")}</span>
729
+ </div>
730
+ <div class="thread-card-date">${dateStr}</div>
731
+ </div>
732
+ <div class="thread-card-body">
733
+ <div style="font-size:12px;color:#5f6368;margin-bottom:12px;">
734
+ <span>from: <strong>${m.from}</strong> (${prof.role}) &lt;${m.from}@amq&gt;</span> •
735
+ <span>to: ${toStr}</span> •
736
+ <span>date: ${dateStr}</span>
737
+ </div>
738
+ <div class="md-content">${highlightedBody}</div>
739
+ ${attachmentsHtml}
740
+ </div>
741
+ </div>
742
+ `;
743
+ });
744
+
745
+ threadMessagesListEl.innerHTML = html;
746
+
747
+ // Attach card expansion clicks
748
+ threadMessagesListEl.querySelectorAll(".thread-card-header").forEach((header) => {
749
+ header.addEventListener("click", () => {
750
+ const card = header.closest(".thread-card");
751
+ card.classList.toggle("collapsed");
752
+ card.classList.toggle("expanded");
753
+ });
754
+ });
755
+
756
+ // Set reply context to latest message
757
+ const latestMsg = msgs[msgs.length - 1];
758
+ state.selectedMessageId = latestMsg ? latestMsg.id : null;
759
+ quickReplyTextEl.value = "";
760
+
761
+ // Dynamic contextual Smart Replies
762
+ const replies = getContextualSmartReplies(latestMsg?.subject || "", latestMsg?.body || "", latestMsg?.kind || "");
763
+ smartRepliesChipsEl.innerHTML = replies
764
+ .map((r) => `<button class="chip" data-text="${escapeHtml(r)}">${escapeHtml(r)}</button>`)
765
+ .join("");
766
+
767
+ mailDetailViewEl.classList.remove("hidden");
768
+ }
769
+
770
+ function openSingleMessage(msg) {
771
+ state.selectedMessageId = msg.id;
772
+
773
+ if (state.searchQuery) {
774
+ detailSubjectEl.innerHTML = highlightTerms(escapeHtml(msg.subject || "(no subject)"), state.searchQuery);
775
+ } else {
776
+ detailSubjectEl.textContent = msg.subject || "(no subject)";
777
+ }
778
+ detailThreadBadgeEl.textContent = msg.thread ? `#${msg.thread}` : "";
779
+
780
+ const agentObj = state.agents.find((x) => x.handle === msg.from);
781
+ const prof = agentObj?.profile || { name: msg.from, emoji: (msg.from || "?").slice(0, 1).toUpperCase(), color: "#1a73e8", role: "Persona" };
782
+ const dateStr = msg.created ? new Date(msg.created).toLocaleString() : "";
783
+ const toStr = Array.isArray(msg.to) ? msg.to.join(", ") : (msg.to || "all");
784
+ const renderedBody = renderMarkdown(msg.body);
785
+ const highlightedBody = state.searchQuery ? highlightTerms(renderedBody, state.searchQuery) : renderedBody;
786
+ const attachmentsHtml = renderAttachmentsSection(msg.attachments);
787
+
788
+ threadMessagesListEl.innerHTML = `
789
+ <div class="thread-card expanded" data-msg-id="${msg.id}" data-msg-from="${msg.from}">
790
+ <div class="thread-card-header">
791
+ <div class="thread-card-header-left">
792
+ <div class="thread-card-avatar" style="background:${prof.color};color:#fff;">${prof.emoji}</div>
793
+ <strong class="thread-card-author">${prof.name}</strong>
794
+ </div>
795
+ <div class="thread-card-date">${dateStr}</div>
796
+ </div>
797
+ <div class="thread-card-body">
798
+ <div style="font-size:12px;color:#5f6368;margin-bottom:12px;">
799
+ <span>from: <strong>${msg.from}</strong> (${prof.role}) &lt;${msg.from}@amq&gt;</span> •
800
+ <span>to: ${toStr}</span> •
801
+ <span>date: ${dateStr}</span>
802
+ </div>
803
+ <div class="md-content">${highlightedBody}</div>
804
+ ${attachmentsHtml}
805
+ </div>
806
+ </div>
807
+ `;
808
+
809
+ quickReplyTextEl.value = "";
810
+
811
+ // Dynamic contextual Smart Replies
812
+ const replies = getContextualSmartReplies(msg.subject || "", msg.body || "", msg.kind || "");
813
+ smartRepliesChipsEl.innerHTML = replies
814
+ .map((r) => `<button class="chip" data-text="${escapeHtml(r)}">${escapeHtml(r)}</button>`)
815
+ .join("");
816
+
817
+ mailDetailViewEl.classList.remove("hidden");
818
+ }
819
+
820
+ // Expand / collapse all cards in thread
821
+ let allExpanded = false;
822
+ expandCollapseBtn.addEventListener("click", () => {
823
+ allExpanded = !allExpanded;
824
+ threadMessagesListEl.querySelectorAll(".thread-card").forEach((card) => {
825
+ if (allExpanded) {
826
+ card.classList.remove("collapsed");
827
+ card.classList.add("expanded");
828
+ } else {
829
+ card.classList.add("collapsed");
830
+ card.classList.remove("expanded");
831
+ }
832
+ });
833
+ });
834
+
835
+ // ─── Markdown Renderer ──────────────────────────────────────────────────────
836
+
837
+ function renderMarkdown(md) {
838
+ if (!md) return "";
839
+
840
+ // 1. Normalize line endings to LF
841
+ const text = String(md).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
842
+
843
+ // 2. Extract fenced code blocks line-by-line so comments (# ...) are never treated as headings
844
+ const lines = text.split("\n");
845
+ let inCodeBlock = false;
846
+ let codeFence = "";
847
+ let codeLang = "";
848
+ let codeLines = [];
849
+ const proseLines = [];
850
+ const codeBlocks = [];
851
+
852
+ for (let i = 0; i < lines.length; i++) {
853
+ const line = lines[i];
854
+ if (!inCodeBlock) {
855
+ const fenceMatch = line.match(/^[ \t]*(`{3,}|~{3,})([a-zA-Z0-9_+#.-]*)[^\n]*$/);
856
+ if (fenceMatch) {
857
+ inCodeBlock = true;
858
+ codeFence = fenceMatch[1];
859
+ codeLang = (fenceMatch[2] || "code").trim();
860
+ codeLines = [];
861
+ continue;
862
+ }
863
+ proseLines.push(line);
864
+ } else {
865
+ const closeMatch = line.match(/^[ \t]*(`{3,}|~{3,})[ \t]*$/);
866
+ if (closeMatch && closeMatch[1][0] === codeFence[0] && closeMatch[1].length >= codeFence.length) {
867
+ inCodeBlock = false;
868
+ const cleanCode = codeLines.join("\n");
869
+ const escapedCode = escapeHtml(cleanCode);
870
+ const blockHtml = `<div class="code-block-wrapper">
871
+ <div class="code-block-header">
872
+ <span class="code-lang">${escapeHtml(codeLang || "code")}</span>
873
+ <button class="copy-code-btn" onclick="copyCode(this)">Copy</button>
874
+ </div>
875
+ <pre><code>${escapedCode}</code></pre>
876
+ </div>`;
877
+ const idx = codeBlocks.length;
878
+ codeBlocks.push(blockHtml);
879
+ proseLines.push(`\x00AMQ_BLOCK_${idx}_\x00`);
880
+ continue;
881
+ }
882
+ codeLines.push(line);
883
+ }
884
+ }
885
+
886
+ // Handle unclosed fenced code block at end of input
887
+ if (inCodeBlock) {
888
+ const cleanCode = codeLines.join("\n");
889
+ const escapedCode = escapeHtml(cleanCode);
890
+ const blockHtml = `<div class="code-block-wrapper">
891
+ <div class="code-block-header">
892
+ <span class="code-lang">${escapeHtml(codeLang || "code")}</span>
893
+ <button class="copy-code-btn" onclick="copyCode(this)">Copy</button>
894
+ </div>
895
+ <pre><code>${escapedCode}</code></pre>
896
+ </div>`;
897
+ const idx = codeBlocks.length;
898
+ codeBlocks.push(blockHtml);
899
+ proseLines.push(`\x00AMQ_BLOCK_${idx}_\x00`);
900
+ }
901
+
902
+ let prose = proseLines.join("\n");
903
+
904
+ // 3. Extract inline code so inline snippets are not affected by prose formatting
905
+ const inlineCodes = [];
906
+ prose = prose.replace(/(`+)([\s\S]*?[^`])\1(?!`)/g, (match, fence, code) => {
907
+ const escaped = escapeHtml(code);
908
+ const idx = inlineCodes.length;
909
+ inlineCodes.push(`<code class="inline-code">${escaped}</code>`);
910
+ return `\x00AMQ_INLINE_${idx}_\x00`;
911
+ });
912
+
913
+ // 4. Escape remaining HTML in prose for injection protection
914
+ let html = escapeHtml(prose);
915
+
916
+ // 5. Blockquotes (handling both escaped &gt; and unescaped >)
917
+ html = html.replace(/^(?:&gt;|>)[ \t]?(.*$)/gm, '<blockquote class="md-quote">$1</blockquote>');
918
+ html = html.replace(/<\/blockquote>\n<blockquote class="md-quote">/g, "<br>");
919
+
920
+ // 6. Headings in prose (requiring whitespace after # to avoid false matches on tags or includes)
921
+ html = html.replace(/^######[ \t]+(.*$)/gm, '<h6 class="md-h6">$1</h6>');
922
+ html = html.replace(/^#####[ \t]+(.*$)/gm, '<h5 class="md-h5">$1</h5>');
923
+ html = html.replace(/^####[ \t]+(.*$)/gm, '<h4 class="md-h4">$1</h4>');
924
+ html = html.replace(/^###[ \t]+(.*$)/gm, '<h3 class="md-h3">$1</h3>');
925
+ html = html.replace(/^##[ \t]+(.*$)/gm, '<h2 class="md-h2">$1</h2>');
926
+ html = html.replace(/^#[ \t]+(.*$)/gm, '<h1 class="md-h1">$1</h1>');
927
+
928
+ // 7. Markdown tables in prose
929
+ const tableRegex = /((?:^[ \t]*\|?[^\n\r|]+(?:\|[^\n\r|]+)+\|?[ \t]*\n)(?:^[ \t]*\|?(?:[ \t]*:?-+:?[ \t]*\|)+(?:[ \t]*:?-+:?[ \t]*)\|?[ \t]*\n)(?:^[ \t]*\|?[^\n\r|]+(?:\|[^\n\r|]+)+\|?[ \t]*(?:\n|$))+)/gm;
930
+ html = html.replace(tableRegex, (match) => {
931
+ const tableLines = match.trim().split(/\n/).map((l) => l.trim()).filter(Boolean);
932
+ if (tableLines.length < 2) return match;
933
+ const parseRow = (line) => {
934
+ let clean = line;
935
+ if (clean.startsWith("|")) clean = clean.slice(1);
936
+ if (clean.endsWith("|")) clean = clean.slice(0, -1);
937
+ return clean.split("|").map((c) => c.trim());
938
+ };
939
+ const headerCols = parseRow(tableLines[0]);
940
+ const alignLine = parseRow(tableLines[1]);
941
+ const aligns = alignLine.map((col) => {
942
+ const left = col.startsWith(":");
943
+ const right = col.endsWith(":");
944
+ if (left && right) return "center";
945
+ if (right) return "right";
946
+ return "left";
947
+ });
948
+ let tableHtml = '<div class="table-container"><table class="md-table"><thead><tr>';
949
+ headerCols.forEach((col, idx) => {
950
+ const align = aligns[idx] || "left";
951
+ tableHtml += `<th style="text-align: ${align}">${col}</th>`;
952
+ });
953
+ tableHtml += "</tr></thead><tbody>";
954
+ for (let j = 2; j < tableLines.length; j++) {
955
+ const rowCols = parseRow(tableLines[j]);
956
+ tableHtml += "<tr>";
957
+ headerCols.forEach((_, idx) => {
958
+ const cell = rowCols[idx] !== undefined ? rowCols[idx] : "";
959
+ const align = aligns[idx] || "left";
960
+ tableHtml += `<td style="text-align: ${align}">${cell}</td>`;
961
+ });
962
+ tableHtml += "</tr>";
963
+ }
964
+ tableHtml += "</tbody></table></div>\n";
965
+ return tableHtml;
966
+ });
967
+
968
+ // 8. Bold, Italic, Strikethrough in prose
969
+ html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
970
+ html = html.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, "$1<em>$2</em>");
971
+ html = html.replace(/~~([^~]+)~~/g, "<del>$1</del>");
972
+
973
+ // 9. Lists in prose
974
+ html = html.replace(/^([0-9]+\.|\([0-9]+\))[ \t]+(.*$)/gm, '<div class="md-list-item"><span class="md-list-num">$1</span> <span>$2</span></div>');
975
+ html = html.replace(/^[-*+][ \t]+(.*$)/gm, '<div class="md-bullet-item">• $1</div>');
976
+
977
+ // 10. Links in prose: [text](url)
978
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" class="md-link">$1</a>');
979
+
980
+ // 11. Paragraph breaks in prose
981
+ html = html.replace(/\n{2,}/g, '<div class="md-para-break"></div>');
982
+
983
+ // 12. Restore inline code
984
+ for (let k = 0; k < inlineCodes.length; k++) {
985
+ html = html.replace(`\x00AMQ_INLINE_${k}_\x00`, () => inlineCodes[k]);
986
+ }
987
+
988
+ // 13. Restore code blocks
989
+ for (let b = 0; b < codeBlocks.length; b++) {
990
+ html = html.replace(`\x00AMQ_BLOCK_${b}_\x00`, () => codeBlocks[b]);
991
+ }
992
+
993
+ return html;
994
+ }
995
+
996
+ // ─── Attachments Section ───────────────────────────────────────────────────
997
+
998
+ function renderAttachmentsSection(attachments) {
999
+ if (!attachments || !attachments.length) return "";
1000
+
1001
+ const images = attachments.filter((a) => a.isImage);
1002
+ const otherFiles = attachments.filter((a) => !a.isImage);
1003
+
1004
+ let html = `<div class="attachments-container">`;
1005
+ html += `<div class="attachments-header"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5a2.5 2.5 0 0 1 5 0v10.5c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5V6H9v9.5a3 3 0 0 0 6 0V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg> <span>Attachments & Visual Artifacts (${attachments.length})</span></div>`;
1006
+
1007
+ if (images.length) {
1008
+ html += `<div class="attachment-gallery">`;
1009
+ for (const img of images) {
1010
+ if (!img.exists) {
1011
+ html += `
1012
+ <div class="attachment-image-card missing" title="Image referenced in transmission but not found on disk: ${escapeHtml(img.originalRef || img.path)}">
1013
+ <div class="attachment-thumb-wrap missing-thumb">
1014
+ <span class="missing-thumb-icon">⚠️</span>
1015
+ <span class="missing-thumb-text">Image not found on disk</span>
1016
+ </div>
1017
+ <span class="attachment-filename">${escapeHtml(img.name)}</span>
1018
+ </div>
1019
+ `;
1020
+ } else {
1021
+ const fileUrl = img.url || `/api/file?path=${encodeURIComponent(img.path)}`;
1022
+ html += `
1023
+ <div class="attachment-image-card" onclick="openImageLightbox('${fileUrl}', '${escapeHtml(img.name)}')">
1024
+ <div class="attachment-thumb-wrap">
1025
+ <img src="${fileUrl}" alt="${escapeHtml(img.name)}" loading="lazy" onerror="this.onerror=null;this.parentElement.innerHTML='<div class=\\'broken-img\\'>🖼️ ${escapeHtml(img.name)}</div>'">
1026
+ </div>
1027
+ <span class="attachment-filename">${escapeHtml(img.name)}</span>
1028
+ </div>
1029
+ `;
1030
+ }
1031
+ }
1032
+ html += `</div>`;
1033
+ }
1034
+
1035
+ if (otherFiles.length) {
1036
+ html += `<div class="attachment-files-list">`;
1037
+ for (const f of otherFiles) {
1038
+ if (!f.exists) {
1039
+ html += `
1040
+ <div class="attachment-file-chip missing" title="File referenced in transmission but not found on disk: ${escapeHtml(f.originalRef || f.path)}">
1041
+ <span class="file-icon">⚠️</span>
1042
+ <span class="file-name">${escapeHtml(f.name)}</span>
1043
+ <span class="file-missing-badge">missing</span>
1044
+ </div>
1045
+ `;
1046
+ } else {
1047
+ const fileUrl = f.url || `/api/file?path=${encodeURIComponent(f.path)}`;
1048
+ const icon = f.isLog ? "📄" : "📎";
1049
+ const sizeBadge = f.sizeDisplay ? `<span class="file-size">(${escapeHtml(f.sizeDisplay)})</span>` : "";
1050
+ html += `
1051
+ <a href="${fileUrl}" target="_blank" class="attachment-file-chip" title="${escapeHtml(f.path)}">
1052
+ <span class="file-icon">${icon}</span>
1053
+ <span class="file-name">${escapeHtml(f.name)}</span>
1054
+ ${sizeBadge}
1055
+ <span class="file-action">↗</span>
1056
+ </a>
1057
+ `;
1058
+ }
1059
+ }
1060
+ html += `</div>`;
1061
+ }
1062
+
1063
+ html += `</div>`;
1064
+ return html;
1065
+ }
1066
+
1067
+ // ─── Toolbar View Mode Toggle ───────────────────────────────────────────────
1068
+
1069
+ viewModeThreadsBtn.addEventListener("click", () => {
1070
+ viewModeThreadsBtn.classList.add("active");
1071
+ viewModeFlatBtn.classList.remove("active");
1072
+ state.viewMode = "threads";
1073
+ state.page = 1;
1074
+ fetchData();
1075
+ });
1076
+
1077
+ viewModeFlatBtn.addEventListener("click", () => {
1078
+ viewModeFlatBtn.classList.add("active");
1079
+ viewModeThreadsBtn.classList.remove("active");
1080
+ state.viewMode = "flat";
1081
+ state.page = 1;
1082
+ fetchData();
1083
+ });
1084
+
1085
+ // ─── Folders & Categories ───────────────────────────────────────────────────
1086
+
1087
+ document.querySelectorAll(".folder-list .nav-item").forEach((btn) => {
1088
+ btn.addEventListener("click", () => {
1089
+ document.querySelectorAll(".folder-list .nav-item").forEach((b) => b.classList.remove("active"));
1090
+ btn.classList.add("active");
1091
+ state.activeFolder = btn.dataset.folder;
1092
+ state.page = 1;
1093
+ fetchData();
1094
+ });
1095
+ });
1096
+
1097
+ document.querySelectorAll(".category-tab").forEach((btn) => {
1098
+ btn.addEventListener("click", () => {
1099
+ document.querySelectorAll(".category-tab").forEach((b) => b.classList.remove("active"));
1100
+ btn.classList.add("active");
1101
+ state.activeCategory = btn.dataset.category;
1102
+ state.page = 1;
1103
+ renderList();
1104
+ });
1105
+ });
1106
+
1107
+ // ─── Search & Google-style Autocomplete Dropdown ───────────────────────────
1108
+
1109
+ let selectedSuggestionIndex = -1;
1110
+ let currentSuggestions = [];
1111
+
1112
+ function updateSearchState() {
1113
+ const val = searchInputEl.value.trim();
1114
+ if (val.length > 0) {
1115
+ headerSearchContainer.classList.add("has-query");
1116
+ clearSearchBtn.classList.remove("hidden");
1117
+ } else {
1118
+ headerSearchContainer.classList.remove("has-query");
1119
+ clearSearchBtn.classList.add("hidden");
1120
+ }
1121
+ }
1122
+
1123
+ function getSearchSuggestions(inputVal) {
1124
+ const isTrailingSpace = (inputVal || "").endsWith(" ");
1125
+ const parts = (inputVal || "").split(/\s+/).filter(Boolean);
1126
+ const existingTokens = new Set(parts.map((p) => p.toLowerCase()));
1127
+ const activeToken = isTrailingSpace ? "" : (parts[parts.length - 1] || "").toLowerCase();
1128
+
1129
+ const currentPersona = state.activeAccount === "all" ? (state.activePersona || "user") : state.activeAccount;
1130
+ const personaObj = Array.isArray(state.agents) ? state.agents.find((x) => x.handle === currentPersona) : null;
1131
+ const personaTitle = personaObj?.profile?.name || currentPersona;
1132
+ const personaAvatar = personaObj?.profile?.emoji || "👤";
1133
+
1134
+ // Standard filter operators
1135
+ const baseItems = [
1136
+ { token: "from:me", icon: personaAvatar, desc: `Sent by me (${personaTitle})` },
1137
+ { token: "to:me", icon: "📥", desc: `Received by me (${personaTitle})` },
1138
+ { token: "is:unread", icon: "📬", desc: "Unread transmissions" },
1139
+ { token: "is:starred", icon: "⭐", desc: "Starred transmissions" },
1140
+ { token: "is:read", icon: "✉️", desc: "Read transmissions" },
1141
+ { token: "has:attachment", icon: "📎", desc: "Transmissions with attachments" },
1142
+ { token: "has:image", icon: "🖼️", desc: "Transmissions with images" },
1143
+ ];
1144
+
1145
+ // Dynamic agent filters derived from state.agents (never hardcoded)
1146
+ const agentItems = [];
1147
+ if (Array.isArray(state.agents)) {
1148
+ for (const agent of state.agents) {
1149
+ const handle = agent.name;
1150
+ const title = agent.title || "Agent";
1151
+ const avatar = agent.avatar || "🤖";
1152
+ agentItems.push({
1153
+ token: `from:${handle}`,
1154
+ icon: avatar,
1155
+ desc: `Transmissions sent by ${handle} (${title})`,
1156
+ });
1157
+ agentItems.push({
1158
+ token: `to:${handle}`,
1159
+ icon: "📨",
1160
+ desc: `Transmissions addressed to ${handle}`,
1161
+ });
1162
+ }
1163
+ }
1164
+
1165
+ const allCandidates = [...baseItems, ...agentItems];
1166
+
1167
+ // Exclude tokens that are already completely typed into the search bar
1168
+ const available = allCandidates.filter((item) => {
1169
+ const lower = item.token.toLowerCase();
1170
+ if (lower === activeToken) return false;
1171
+ if (existingTokens.has(lower) && activeToken !== lower) return false;
1172
+ return true;
1173
+ });
1174
+
1175
+ if (!activeToken) {
1176
+ // When input is blank or after a space, show quick filter suggestions
1177
+ return available.slice(0, 10);
1178
+ }
1179
+
1180
+ // Score and filter by active typing token
1181
+ const scored = [];
1182
+ for (const item of available) {
1183
+ const lowerTok = item.token.toLowerCase();
1184
+ const lowerDesc = item.desc.toLowerCase();
1185
+
1186
+ if (lowerTok.startsWith(activeToken)) {
1187
+ scored.push({ item, score: 100 });
1188
+ } else if (lowerTok.includes(activeToken)) {
1189
+ scored.push({ item, score: 70 });
1190
+ } else if (lowerDesc.includes(activeToken)) {
1191
+ scored.push({ item, score: 40 });
1192
+ }
1193
+ }
1194
+
1195
+ scored.sort((a, b) => b.score - a.score);
1196
+ return scored.map((s) => s.item).slice(0, 10);
1197
+ }
1198
+
1199
+ function highlightMatch(text, query) {
1200
+ if (!query) return escapeHtml(text);
1201
+ const idx = text.toLowerCase().indexOf(query.toLowerCase());
1202
+ if (idx === -1) return escapeHtml(text);
1203
+ const before = escapeHtml(text.slice(0, idx));
1204
+ const match = escapeHtml(text.slice(idx, idx + query.length));
1205
+ const after = escapeHtml(text.slice(idx + query.length));
1206
+ return `${before}<strong>${match}</strong>${after}`;
1207
+ }
1208
+
1209
+ function renderSuggestions(query) {
1210
+ currentSuggestions = getSearchSuggestions(query);
1211
+ selectedSuggestionIndex = -1;
1212
+
1213
+ if (!currentSuggestions.length) {
1214
+ searchAutocompleteDropdownEl.classList.add("hidden");
1215
+ searchBarEl.classList.remove("dropdown-open");
1216
+ return;
1217
+ }
1218
+
1219
+ const tokens = (query || "").split(/\s+/).filter(Boolean);
1220
+ const isTrailingSpace = (query || "").endsWith(" ");
1221
+ const activeToken = isTrailingSpace ? "" : (tokens[tokens.length - 1] || "");
1222
+
1223
+ let html = "";
1224
+ currentSuggestions.forEach((item, idx) => {
1225
+ html += `
1226
+ <div class="suggestion-item" data-token="${escapeHtml(item.token)}" data-index="${idx}">
1227
+ <span class="suggestion-icon">${item.icon}</span>
1228
+ <div class="suggestion-content">
1229
+ <span class="suggestion-token">${highlightMatch(item.token, activeToken)}</span>
1230
+ <span class="suggestion-desc">${escapeHtml(item.desc)}</span>
1231
+ </div>
1232
+ </div>
1233
+ `;
1234
+ });
1235
+
1236
+ searchSuggestionsListEl.innerHTML = html;
1237
+ searchAutocompleteDropdownEl.classList.remove("hidden");
1238
+ searchBarEl.classList.add("dropdown-open");
1239
+ }
1240
+
1241
+ function hideSearchSuggestions() {
1242
+ searchAutocompleteDropdownEl.classList.add("hidden");
1243
+ searchBarEl.classList.remove("dropdown-open");
1244
+ selectedSuggestionIndex = -1;
1245
+ }
1246
+
1247
+ function updateSuggestionSelection() {
1248
+ const items = searchSuggestionsListEl.querySelectorAll(".suggestion-item");
1249
+ items.forEach((item, idx) => {
1250
+ const isSel = idx === selectedSuggestionIndex;
1251
+ item.classList.toggle("selected", isSel);
1252
+ if (isSel) {
1253
+ item.scrollIntoView({ block: "nearest" });
1254
+ }
1255
+ });
1256
+ }
1257
+
1258
+ function applySuggestion(token) {
1259
+ let val = searchInputEl.value;
1260
+ const parts = val.split(/\s+/).filter(Boolean);
1261
+
1262
+ if (parts.length > 0 && !val.endsWith(" ")) {
1263
+ const last = parts[parts.length - 1].toLowerCase();
1264
+ if (
1265
+ token.toLowerCase().startsWith(last) ||
1266
+ (last.includes(":") && token.toLowerCase().startsWith(last.split(":")[0] + ":")) ||
1267
+ token.toLowerCase().includes(last)
1268
+ ) {
1269
+ parts[parts.length - 1] = token;
1270
+ val = parts.join(" ") + " ";
1271
+ } else {
1272
+ val = parts.join(" ") + " " + token + " ";
1273
+ }
1274
+ } else {
1275
+ val = (val.trim() ? val.trim() + " " : "") + token + " ";
1276
+ }
1277
+
1278
+ // Deduplicate tokens
1279
+ const unique = [];
1280
+ for (const p of val.split(/\s+/).filter(Boolean)) {
1281
+ if (!unique.includes(p)) {
1282
+ unique.push(p);
1283
+ }
1284
+ }
1285
+
1286
+ const nextVal = unique.join(" ") + " ";
1287
+ searchInputEl.value = nextVal;
1288
+ state.searchQuery = nextVal.trim();
1289
+ updateSearchState();
1290
+ fetchData();
1291
+
1292
+ // Re-render suggestions for the updated query so user can click another token immediately
1293
+ renderSuggestions(nextVal);
1294
+ searchInputEl.focus();
1295
+ }
1296
+
1297
+ // Prevent mousedown inside dropdown from stealing focus or triggering blur on search input
1298
+ searchAutocompleteDropdownEl.addEventListener("mousedown", (e) => {
1299
+ e.preventDefault();
1300
+ });
1301
+
1302
+ // Click suggestion row to add to query
1303
+ searchSuggestionsListEl.addEventListener("click", (e) => {
1304
+ const row = e.target.closest(".suggestion-item");
1305
+ if (!row) return;
1306
+ const token = row.dataset.token;
1307
+ if (token) {
1308
+ applySuggestion(token);
1309
+ }
1310
+ });
1311
+
1312
+ // Focus and input listeners
1313
+ searchInputEl.addEventListener("focus", () => {
1314
+ headerSearchContainer.classList.add("search-focused");
1315
+ renderSuggestions(searchInputEl.value);
1316
+ });
1317
+
1318
+ searchInputEl.addEventListener("blur", () => {
1319
+ setTimeout(() => {
1320
+ headerSearchContainer.classList.remove("search-focused");
1321
+ hideSearchSuggestions();
1322
+ }, 150);
1323
+ });
1324
+
1325
+ let searchTimer = null;
1326
+ searchInputEl.addEventListener("input", (e) => {
1327
+ clearTimeout(searchTimer);
1328
+ const val = e.target.value;
1329
+ updateSearchState();
1330
+ renderSuggestions(val);
1331
+ searchTimer = setTimeout(() => {
1332
+ state.searchQuery = val.trim();
1333
+ fetchData();
1334
+ }, 200);
1335
+ });
1336
+
1337
+ // Keyboard navigation for dropdown
1338
+ searchInputEl.addEventListener("keydown", (e) => {
1339
+ const isOpen = !searchAutocompleteDropdownEl.classList.contains("hidden");
1340
+
1341
+ if (e.key === "ArrowDown") {
1342
+ if (!isOpen) {
1343
+ renderSuggestions(searchInputEl.value);
1344
+ return;
1345
+ }
1346
+ e.preventDefault();
1347
+ if (currentSuggestions.length > 0) {
1348
+ selectedSuggestionIndex = (selectedSuggestionIndex + 1) % currentSuggestions.length;
1349
+ updateSuggestionSelection();
1350
+ }
1351
+ } else if (e.key === "ArrowUp") {
1352
+ if (!isOpen) return;
1353
+ e.preventDefault();
1354
+ if (currentSuggestions.length > 0) {
1355
+ selectedSuggestionIndex = (selectedSuggestionIndex - 1 + currentSuggestions.length) % currentSuggestions.length;
1356
+ updateSuggestionSelection();
1357
+ }
1358
+ } else if (e.key === "Enter") {
1359
+ if (isOpen && selectedSuggestionIndex >= 0 && currentSuggestions[selectedSuggestionIndex]) {
1360
+ e.preventDefault();
1361
+ applySuggestion(currentSuggestions[selectedSuggestionIndex].token);
1362
+ } else {
1363
+ hideSearchSuggestions();
1364
+ state.searchQuery = searchInputEl.value.trim();
1365
+ fetchData();
1366
+ }
1367
+ } else if (e.key === "Escape") {
1368
+ if (isOpen) {
1369
+ e.preventDefault();
1370
+ hideSearchSuggestions();
1371
+ }
1372
+ }
1373
+ });
1374
+
1375
+ clearSearchBtn.addEventListener("click", () => {
1376
+ searchInputEl.value = "";
1377
+ updateSearchState();
1378
+ state.searchQuery = "";
1379
+ hideSearchSuggestions();
1380
+ fetchData();
1381
+ searchInputEl.focus();
1382
+ });
1383
+
1384
+ document.addEventListener("click", (e) => {
1385
+ if (!headerSearchContainer.contains(e.target)) {
1386
+ hideSearchSuggestions();
1387
+ }
1388
+ });
1389
+
1390
+ backToListBtn.addEventListener("click", () => {
1391
+ mailDetailViewEl.classList.add("hidden");
1392
+ });
1393
+
1394
+ refreshBtn.addEventListener("click", () => {
1395
+ fetchStatus();
1396
+ fetchAgents();
1397
+ fetchData();
1398
+ });
1399
+
1400
+ // Toggle bridge daemon
1401
+ bridgeStatusPill.addEventListener("click", async () => {
1402
+ try {
1403
+ await fetch("/api/bridge/toggle", { method: "POST" });
1404
+ fetchStatus();
1405
+ } catch {}
1406
+ });
1407
+
1408
+ // Smart Replies
1409
+ smartRepliesChipsEl.addEventListener("click", (e) => {
1410
+ const chip = e.target.closest(".chip");
1411
+ if (!chip) return;
1412
+ quickReplyTextEl.value = chip.dataset.text;
1413
+ quickReplyTextEl.focus();
1414
+ });
1415
+
1416
+ // Send Quick Reply
1417
+ sendQuickReplyBtn.addEventListener("click", async () => {
1418
+ const text = quickReplyTextEl.value.trim();
1419
+ if (!text || !state.selectedMessageId) return;
1420
+
1421
+ sendQuickReplyBtn.disabled = true;
1422
+ sendQuickReplyBtn.textContent = "Sending...";
1423
+
1424
+ try {
1425
+ const currentPersona = state.activeAccount === "all" ? (state.activePersona || "user") : state.activeAccount;
1426
+ const res = await fetch("/api/reply", {
1427
+ method: "POST",
1428
+ headers: { "Content-Type": "application/json" },
1429
+ body: JSON.stringify({
1430
+ from: currentPersona,
1431
+ replyToId: state.selectedMessageId,
1432
+ body: text,
1433
+ }),
1434
+ });
1435
+
1436
+ const data = await res.json();
1437
+ if (data.ok) {
1438
+ quickReplyTextEl.value = "";
1439
+ fetchData();
1440
+ } else {
1441
+ alert("Error sending reply: " + (data.error || "unknown"));
1442
+ }
1443
+ } catch (e) {
1444
+ alert("Error: " + e.message);
1445
+ } finally {
1446
+ sendQuickReplyBtn.disabled = false;
1447
+ sendQuickReplyBtn.innerHTML = `
1448
+ <svg viewBox="0 0 24 24"><path fill="currentColor" d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
1449
+ <span>Send Reply (amq)</span>
1450
+ `;
1451
+ }
1452
+ });
1453
+
1454
+ // ─── Compose Modal ─────────────────────────────────────────────────────────
1455
+
1456
+ function populateComposeDropdowns(agents) {
1457
+ let fromOptions = `<option value="user">user (Human operator)</option>`;
1458
+ let toOptions = `<option value="coordinator">coordinator</option>`;
1459
+
1460
+ for (const a of agents) {
1461
+ fromOptions += `<option value="${a.handle}">${a.handle}</option>`;
1462
+ if (a.handle !== "coordinator") {
1463
+ toOptions += `<option value="${a.handle}">${a.handle}</option>`;
1464
+ }
1465
+ }
1466
+
1467
+ composeFromEl.innerHTML = fromOptions;
1468
+ composeToEl.innerHTML = toOptions;
1469
+ }
1470
+
1471
+ openComposeBtn.addEventListener("click", () => {
1472
+ composeModalEl.classList.remove("hidden");
1473
+ const currentPersona = state.activeAccount === "all" ? (state.activePersona || "user") : state.activeAccount;
1474
+ composeFromEl.value = currentPersona;
1475
+ composeSubjectEl.focus();
1476
+ });
1477
+
1478
+ if (composeTemplateEl) {
1479
+ composeTemplateEl.addEventListener("change", (e) => {
1480
+ const val = e.target.value;
1481
+ if (val === "task") {
1482
+ composeSubjectEl.value = "todo: [summary]";
1483
+ composeBodyEl.value = "Task request (reply needed / ack):\n\nContext:\n[Brief context]\n\nWhat needs to be done:\n- [Action item 1]\n- [Action item 2]\n";
1484
+ composeToEl.value = "coordinator";
1485
+ } else if (val === "verify") {
1486
+ composeSubjectEl.value = "verify-all: PASS";
1487
+ composeBodyEl.value = "Verification Report (5 parts):\n1. What was asked: \n2. What was done + files touched: \n3. Evidence (numbers, logs): `bash tools/verify-all.sh --quick`\n4. Blockers / dependencies: none\n5. Board status: updated in STATUS.md\n";
1488
+ composeToEl.value = "coordinator";
1489
+ } else if (val === "question") {
1490
+ composeSubjectEl.value = "question: [topic]";
1491
+ composeBodyEl.value = "Question:\n\nContext and alternatives evaluated:\n";
1492
+ } else if (val === "lock") {
1493
+ composeSubjectEl.value = "notice: godot lock coordination";
1494
+ composeBodyEl.value = "Lock notice:\nLane clear. Using tools/godot-lock.sh only.\npgrep check: clean.\n";
1495
+ }
1496
+ });
1497
+ }
1498
+
1499
+ closeComposeBtn.addEventListener("click", () => {
1500
+ composeModalEl.classList.add("hidden");
1501
+ });
1502
+
1503
+ composeFormEl.addEventListener("submit", async (e) => {
1504
+ e.preventDefault();
1505
+ const from = composeFromEl.value;
1506
+ const to = composeToEl.value;
1507
+ const subject = composeSubjectEl.value;
1508
+ const thread = composeThreadEl.value;
1509
+ const body = composeBodyEl.value;
1510
+
1511
+ const submitBtn = document.getElementById("compose-submit-btn");
1512
+ submitBtn.disabled = true;
1513
+ submitBtn.textContent = "Sending...";
1514
+
1515
+ try {
1516
+ const res = await fetch("/api/send", {
1517
+ method: "POST",
1518
+ headers: { "Content-Type": "application/json" },
1519
+ body: JSON.stringify({ from, to, subject, thread, body }),
1520
+ });
1521
+ const data = await res.json();
1522
+
1523
+ if (data.ok) {
1524
+ composeModalEl.classList.add("hidden");
1525
+ composeSubjectEl.value = "";
1526
+ composeBodyEl.value = "";
1527
+ composeThreadEl.value = "";
1528
+ fetchData();
1529
+ } else {
1530
+ alert("Send failed: " + (data.error || "unknown"));
1531
+ }
1532
+ } catch (err) {
1533
+ alert("Error: " + err.message);
1534
+ } finally {
1535
+ submitBtn.disabled = false;
1536
+ submitBtn.textContent = "Send";
1537
+ }
1538
+ });
1539
+
1540
+ // ─── Server-Sent Events (SSE) ───────────────────────────────────────────────
1541
+
1542
+ function initSSE() {
1543
+ try {
1544
+ const evtSource = new EventSource("/api/events");
1545
+ evtSource.onmessage = (e) => {
1546
+ try {
1547
+ const payload = JSON.parse(e.data);
1548
+ if (payload.type === "mail_update" || payload.type === "presence_update") {
1549
+ fetchStatus();
1550
+ fetchAgents();
1551
+ fetchData();
1552
+ if (state.selectedTaskId) {
1553
+ const currentTask = findTaskById(state.selectedTaskId);
1554
+ if (currentTask) renderSheetTransmissions(currentTask);
1555
+ }
1556
+ } else if (payload.type === "board_update") {
1557
+ fetchBoard().then(() => {
1558
+ if (state.selectedTaskId) {
1559
+ const currentTask = findTaskById(state.selectedTaskId);
1560
+ if (currentTask) {
1561
+ renderSheetTransmissions(currentTask);
1562
+ if (sheetStageButtons) {
1563
+ sheetStageButtons.querySelectorAll(".sheet-stage-btn").forEach((btn) => {
1564
+ btn.classList.toggle("active", btn.dataset.stage === currentTask.status);
1565
+ });
1566
+ }
1567
+ }
1568
+ }
1569
+ });
1570
+ } else if (payload.type === "herdr_agent_update") {
1571
+ // Fast path: update just this agent's status dot without full re-fetch
1572
+ 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
+ }
1584
+ }
1585
+ }
1586
+ } catch {}
1587
+ };
1588
+ evtSource.onerror = () => {
1589
+ setTimeout(initSSE, 5000);
1590
+ };
1591
+ } catch {}
1592
+ }
1593
+
1594
+
1595
+ // ─── Global Window Helpers for Lightbox & Code Copy ─────────────────────────
1596
+
1597
+ window.openImageLightbox = function (url, filename) {
1598
+ lightboxImg.src = url;
1599
+ lightboxTitle.textContent = filename || "Image Preview";
1600
+ lightboxDownloadLink.href = url;
1601
+ lightboxModal.classList.remove("hidden");
1602
+ };
1603
+
1604
+ window.closeLightbox = function () {
1605
+ lightboxModal.classList.add("hidden");
1606
+ lightboxImg.src = "";
1607
+ };
1608
+
1609
+ lightboxCloseBtn.addEventListener("click", window.closeLightbox);
1610
+ lightboxBackdrop.addEventListener("click", window.closeLightbox);
1611
+
1612
+ window.copyCode = function (btn) {
1613
+ const pre = btn.closest(".code-block-wrapper").querySelector("pre code");
1614
+ if (!pre) return;
1615
+ navigator.clipboard.writeText(pre.innerText).then(() => {
1616
+ const original = btn.textContent;
1617
+ btn.textContent = "Copied!";
1618
+ btn.style.color = "#81c995";
1619
+ setTimeout(() => {
1620
+ btn.textContent = original;
1621
+ btn.style.color = "";
1622
+ }, 1500);
1623
+ });
1624
+ };
1625
+
1626
+ // ─── Utilities ─────────────────────────────────────────────────────────────
1627
+
1628
+ function formatTimestamp(isoStr) {
1629
+ if (!isoStr) return "";
1630
+ const d = new Date(isoStr);
1631
+ const now = new Date();
1632
+ const isToday = d.toDateString() === now.toDateString();
1633
+ if (isToday) {
1634
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1635
+ }
1636
+ return d.toLocaleDateString([], { month: "short", day: "numeric" });
1637
+ }
1638
+
1639
+ function escapeHtml(str) {
1640
+ return String(str || "")
1641
+ .replace(/&/g, "&amp;")
1642
+ .replace(/</g, "&lt;")
1643
+ .replace(/>/g, "&gt;")
1644
+ .replace(/"/g, "&quot;")
1645
+ .replace(/'/g, "&#039;");
1646
+ }
1647
+
1648
+ function showToast(msg) {
1649
+ const toast = document.createElement("div");
1650
+ toast.className = "agmail-toast";
1651
+ toast.textContent = msg;
1652
+ toast.style.cssText = "position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#202124;color:#fff;padding:10px 20px;border-radius:20px;font-size:13px;box-shadow:0 4px 12px rgba(0,0,0,0.25);z-index:100000;transition:opacity 0.25s ease;";
1653
+ document.body.appendChild(toast);
1654
+ setTimeout(() => {
1655
+ toast.style.opacity = "0";
1656
+ setTimeout(() => toast.remove(), 260);
1657
+ }, 2500);
1658
+ }
1659
+
1660
+ function findMessageById(id) {
1661
+ if (!id) return null;
1662
+ if (state.viewMode === "flat") {
1663
+ return state.items.find((m) => m.id === id);
1664
+ }
1665
+ for (const thread of state.items) {
1666
+ if (thread.messages) {
1667
+ const found = thread.messages.find((m) => m.id === id);
1668
+ if (found) return found;
1669
+ }
1670
+ }
1671
+ return null;
1672
+ }
1673
+
1674
+ // ─── Pagination Controls Setup ──────────────────────────────────────────────
1675
+
1676
+ function setupPagination() {
1677
+ if (prevPageBtn) {
1678
+ prevPageBtn.addEventListener("click", () => {
1679
+ if (state.page > 1) {
1680
+ state.page--;
1681
+ fetchData();
1682
+ }
1683
+ });
1684
+ }
1685
+ if (nextPageBtn) {
1686
+ nextPageBtn.addEventListener("click", () => {
1687
+ if (state.page < state.totalPages) {
1688
+ state.page++;
1689
+ fetchData();
1690
+ }
1691
+ });
1692
+ }
1693
+ }
1694
+
1695
+ // ─── Right-Click Context Menu Setup ─────────────────────────────────────────
1696
+
1697
+ function setupContextMenu() {
1698
+ if (!chatContextMenu) return;
1699
+
1700
+ document.addEventListener("contextmenu", (e) => {
1701
+ const targetCard = e.target.closest(".thread-card, .mail-row");
1702
+ const targetPresence = e.target.closest(".presence-item");
1703
+ const inAppMain = e.target.closest(".app-main, .mail-detail-container, .mail-list-container, .app-sidebar");
1704
+
1705
+ if (!targetCard && !targetPresence && !inAppMain) {
1706
+ chatContextMenu.classList.add("hidden");
1707
+ return;
1708
+ }
1709
+
1710
+ e.preventDefault();
1711
+
1712
+ const sel = window.getSelection();
1713
+ const selectedText = sel ? sel.toString().trim() : "";
1714
+
1715
+ let targetMsgId = targetCard?.dataset?.msgId || state.selectedMessageId;
1716
+ let targetMsgFrom = targetCard?.dataset?.msgFrom;
1717
+ let targetHandle = targetPresence?.querySelector(".presence-name")?.textContent?.trim();
1718
+
1719
+ if (targetCard && targetMsgId) {
1720
+ const found = findMessageById(targetMsgId);
1721
+ if (found) {
1722
+ targetMsgFrom = found.from;
1723
+ }
1724
+ }
1725
+
1726
+ const activeAuthor = targetMsgFrom || targetHandle;
1727
+
1728
+ state.contextTarget = {
1729
+ msgId: targetMsgId,
1730
+ msgFrom: activeAuthor,
1731
+ handle: targetHandle,
1732
+ selectedText,
1733
+ };
1734
+
1735
+ if (targetPresence && targetHandle) {
1736
+ // Context: Specific Agent in Presence list
1737
+ ctxHeaderTitle.textContent = "Agent Swarm";
1738
+ ctxHeaderSub.textContent = `@${targetHandle}`;
1739
+
1740
+ ctxReply.style.display = "flex";
1741
+ ctxReplyText.textContent = `Message @${targetHandle}`;
1742
+
1743
+ ctxQuote.style.display = "none";
1744
+
1745
+ ctxFilterSender.style.display = "flex";
1746
+ ctxFilterSenderText.textContent = `Filter messages from:${targetHandle}`;
1747
+
1748
+ ctxCopyId.style.display = "flex";
1749
+ ctxCopyIdText.textContent = `Copy address (${targetHandle}@amq)`;
1750
+
1751
+ ctxRegisterAgent.style.display = "flex";
1752
+ ctxRegisterText.textContent = `Configure profile for @${targetHandle}...`;
1753
+ } else if (targetCard && targetMsgId) {
1754
+ // Context: Specific Message Card or Mail Row
1755
+ ctxHeaderTitle.textContent = "Transmission";
1756
+ ctxHeaderSub.textContent = activeAuthor ? `@${activeAuthor} • ${targetMsgId}` : targetMsgId;
1757
+
1758
+ ctxReply.style.display = "flex";
1759
+ ctxReplyText.textContent = activeAuthor ? `Reply to @${activeAuthor}` : "Reply to transmission";
1760
+
1761
+ ctxQuote.style.display = (selectedText || targetMsgId) ? "flex" : "none";
1762
+ ctxQuoteText.textContent = selectedText
1763
+ ? `Quote: "${selectedText.slice(0, 24)}..."`
1764
+ : "Quote message in reply";
1765
+
1766
+ ctxFilterSender.style.display = activeAuthor ? "flex" : "none";
1767
+ ctxFilterSenderText.textContent = `View all from @${activeAuthor}`;
1768
+
1769
+ ctxCopyId.style.display = "flex";
1770
+ ctxCopyIdText.textContent = `Copy message ID (${targetMsgId})`;
1771
+
1772
+ ctxRegisterAgent.style.display = "flex";
1773
+ ctxRegisterText.textContent = activeAuthor
1774
+ ? `Configure profile for @${activeAuthor}...`
1775
+ : "Register new agent profile...";
1776
+ } else {
1777
+ // Context: General Swarm Workspace
1778
+ ctxHeaderTitle.textContent = "AGmail Swarm";
1779
+ ctxHeaderSub.textContent = `Account: ${state.activeAccount} (${state.total} items)`;
1780
+
1781
+ ctxReply.style.display = "flex";
1782
+ ctxReplyText.textContent = "Compose new transmission";
1783
+
1784
+ ctxQuote.style.display = "none";
1785
+ ctxFilterSender.style.display = "none";
1786
+ ctxCopyId.style.display = "none";
1787
+
1788
+ ctxRegisterAgent.style.display = "flex";
1789
+ ctxRegisterText.textContent = "Register new agent profile...";
1790
+ }
1791
+
1792
+ const menuWidth = 240;
1793
+ const menuHeight = 220;
1794
+ let x = e.clientX;
1795
+ let y = e.clientY;
1796
+ if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
1797
+ if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
1798
+
1799
+ chatContextMenu.style.left = `${Math.max(4, x)}px`;
1800
+ chatContextMenu.style.top = `${Math.max(4, y)}px`;
1801
+ chatContextMenu.classList.remove("hidden");
1802
+ });
1803
+
1804
+ document.addEventListener("click", (e) => {
1805
+ if (!chatContextMenu.contains(e.target)) {
1806
+ chatContextMenu.classList.add("hidden");
1807
+ }
1808
+ });
1809
+
1810
+ if (ctxReply) {
1811
+ ctxReply.addEventListener("click", () => {
1812
+ chatContextMenu.classList.add("hidden");
1813
+ const author = state.contextTarget?.msgFrom || state.contextTarget?.handle;
1814
+ if (state.contextTarget?.msgId && !mailDetailViewEl.classList.contains("hidden")) {
1815
+ state.selectedMessageId = state.contextTarget.msgId;
1816
+ quickReplyTextEl.focus();
1817
+ if (author && !quickReplyTextEl.value) {
1818
+ quickReplyTextEl.value = `@${author} `;
1819
+ }
1820
+ } else {
1821
+ // Open compose dialog pre-addressed to author
1822
+ composeModalEl.classList.remove("hidden");
1823
+ if (author && composeToEl) {
1824
+ composeToEl.value = author;
1825
+ }
1826
+ composeSubjectEl.focus();
1827
+ }
1828
+ });
1829
+ }
1830
+
1831
+ if (ctxQuote) {
1832
+ ctxQuote.addEventListener("click", () => {
1833
+ chatContextMenu.classList.add("hidden");
1834
+ quickReplyTextEl.focus();
1835
+ const quoteText = state.contextTarget?.selectedText || "";
1836
+ if (quoteText) {
1837
+ const quoted = quoteText.split("\n").map((l) => `> ${l}`).join("\n");
1838
+ quickReplyTextEl.value = `${quoted}\n\n${quickReplyTextEl.value}`;
1839
+ } else if (state.contextTarget?.msgId) {
1840
+ const msg = findMessageById(state.contextTarget.msgId);
1841
+ if (msg?.body) {
1842
+ const snippet = msg.body.slice(0, 200).split("\n").map((l) => `> ${l}`).join("\n");
1843
+ quickReplyTextEl.value = `${snippet}\n\n${quickReplyTextEl.value}`;
1844
+ }
1845
+ }
1846
+ });
1847
+ }
1848
+
1849
+ if (ctxFilterSender) {
1850
+ ctxFilterSender.addEventListener("click", () => {
1851
+ chatContextMenu.classList.add("hidden");
1852
+ const author = state.contextTarget?.msgFrom || state.contextTarget?.handle;
1853
+ if (author) {
1854
+ searchInputEl.value = `from:${author} `;
1855
+ state.searchQuery = `from:${author}`;
1856
+ state.page = 1;
1857
+ fetchData();
1858
+ }
1859
+ });
1860
+ }
1861
+
1862
+ if (ctxCopyId) {
1863
+ ctxCopyId.addEventListener("click", () => {
1864
+ chatContextMenu.classList.add("hidden");
1865
+ const toCopy = state.contextTarget?.msgId || (state.contextTarget?.handle ? `${state.contextTarget.handle}@amq` : "");
1866
+ if (toCopy) {
1867
+ navigator.clipboard.writeText(toCopy).then(() => {
1868
+ showToast(`Copied: ${toCopy}`);
1869
+ }).catch(() => {});
1870
+ }
1871
+ });
1872
+ }
1873
+
1874
+ if (ctxRegisterAgent) {
1875
+ ctxRegisterAgent.addEventListener("click", () => {
1876
+ chatContextMenu.classList.add("hidden");
1877
+ const author = state.contextTarget?.msgFrom || state.contextTarget?.handle;
1878
+ openRegisterAgentModal(author);
1879
+ });
1880
+ }
1881
+ }
1882
+
1883
+ // ─── Agent Briefs & Profile Configuration ───────────────────────────────────
1884
+
1885
+ async function fetchBriefs() {
1886
+ try {
1887
+ const res = await fetch("/api/agent-briefs");
1888
+ if (res.ok) {
1889
+ state.briefs = await res.json();
1890
+ renderBriefChips();
1891
+ }
1892
+ } catch {}
1893
+ }
1894
+
1895
+ function renderBriefChips() {
1896
+ if (!briefChipsList) return;
1897
+ if (!state.briefs || state.briefs.length === 0) {
1898
+ briefChipsList.innerHTML = '<span class="brief-chips-loading">No brief files found in .opencode/agents, .agents, .pi</span>';
1899
+ return;
1900
+ }
1901
+ let html = "";
1902
+ for (const b of state.briefs) {
1903
+ html += `<button type="button" class="brief-chip" data-handle="${escapeHtml(b.handle)}" title="${escapeHtml(b.role || b.description || b.handle)}">${escapeHtml(b.handle)}</button>`;
1904
+ }
1905
+ briefChipsList.innerHTML = html;
1906
+
1907
+ briefChipsList.querySelectorAll(".brief-chip").forEach((btn) => {
1908
+ btn.addEventListener("click", () => {
1909
+ const handle = btn.dataset.handle;
1910
+ if (handle) {
1911
+ applyBriefByHandle(handle);
1912
+ }
1913
+ });
1914
+ });
1915
+ }
1916
+
1917
+ async function applyBriefByHandle(handle) {
1918
+ if (!handle) return;
1919
+ const cleanHandle = handle.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
1920
+ newAgentHandleInput.value = cleanHandle;
1921
+
1922
+ // Check in-memory briefs first
1923
+ let brief = state.briefs?.find((b) => b.handle === cleanHandle);
1924
+ if (!brief) {
1925
+ try {
1926
+ const res = await fetch(`/api/agent-briefs?handle=${encodeURIComponent(cleanHandle)}`);
1927
+ if (res.ok) {
1928
+ const data = await res.json();
1929
+ brief = data.brief;
1930
+ }
1931
+ } catch {}
1932
+ }
1933
+
1934
+ if (brief) {
1935
+ if (newAgentNameInput) {
1936
+ newAgentNameInput.value = brief.name || formatAgentTitle(cleanHandle);
1937
+ }
1938
+ if (newAgentRoleInput) {
1939
+ newAgentRoleInput.value = brief.role || brief.description || "";
1940
+ }
1941
+ if (newAgentModelInput && brief.model) {
1942
+ newAgentModelInput.value = brief.model;
1943
+ }
1944
+ if (newAgentPromptInput) {
1945
+ newAgentPromptInput.value = brief.prompt || "";
1946
+ }
1947
+ if (briefSourceBadge) {
1948
+ briefSourceBadge.textContent = brief.source ? `📄 ${brief.source}` : "📄 disk brief";
1949
+ briefSourceBadge.style.display = "inline-block";
1950
+ }
1951
+ showToast(`Pulled brief for @${cleanHandle}`);
1952
+ } else {
1953
+ // Check existing agent profile in state.agents
1954
+ const existing = state.agents.find((a) => a.handle === cleanHandle);
1955
+ if (existing?.profile) {
1956
+ if (newAgentNameInput) newAgentNameInput.value = existing.profile.name || "";
1957
+ if (newAgentRoleInput) newAgentRoleInput.value = existing.profile.role || "";
1958
+ if (newAgentModelInput) newAgentModelInput.value = existing.profile.model || "";
1959
+ if (newAgentPromptInput) newAgentPromptInput.value = existing.profile.prompt || "";
1960
+ if (briefSourceBadge) {
1961
+ briefSourceBadge.textContent = existing.profile.briefSource ? `📄 ${existing.profile.briefSource}` : "Saved profile";
1962
+ briefSourceBadge.style.display = "inline-block";
1963
+ }
1964
+ } else {
1965
+ if (briefSourceBadge) briefSourceBadge.style.display = "none";
1966
+ }
1967
+ }
1968
+ }
1969
+
1970
+ function openRegisterAgentModal(prefillHandle) {
1971
+ if (!registerAgentBackdrop) return;
1972
+ registerAgentForm.reset();
1973
+ if (briefSourceBadge) {
1974
+ briefSourceBadge.style.display = "none";
1975
+ briefSourceBadge.textContent = "";
1976
+ }
1977
+
1978
+ if (prefillHandle && typeof prefillHandle === "string") {
1979
+ if (agentModalTitle) agentModalTitle.textContent = `Configure Profile: @${prefillHandle}`;
1980
+ newAgentHandleInput.value = prefillHandle;
1981
+ applyBriefByHandle(prefillHandle);
1982
+ } else {
1983
+ if (agentModalTitle) agentModalTitle.textContent = "Configure Agent Profile";
1984
+ }
1985
+
1986
+ fetchBriefs();
1987
+ registerAgentBackdrop.classList.remove("hidden");
1988
+ if (!prefillHandle) {
1989
+ newAgentHandleInput.focus();
1990
+ }
1991
+ }
1992
+
1993
+ function closeRegisterAgentModal() {
1994
+ if (registerAgentBackdrop) registerAgentBackdrop.classList.add("hidden");
1995
+ }
1996
+
1997
+ function setupRegisterAgentModal() {
1998
+ if (openRegisterAgentBtn) {
1999
+ openRegisterAgentBtn.addEventListener("click", () => openRegisterAgentModal());
2000
+ }
2001
+ if (closeRegisterAgentBtn) {
2002
+ closeRegisterAgentBtn.addEventListener("click", closeRegisterAgentModal);
2003
+ }
2004
+ if (cancelRegisterAgentBtn) {
2005
+ cancelRegisterAgentBtn.addEventListener("click", closeRegisterAgentModal);
2006
+ }
2007
+
2008
+ if (pullDiskBriefBtn) {
2009
+ pullDiskBriefBtn.addEventListener("click", () => {
2010
+ const handle = newAgentHandleInput.value.trim();
2011
+ if (handle) {
2012
+ applyBriefByHandle(handle);
2013
+ } else {
2014
+ showToast("Enter an agent handle first");
2015
+ }
2016
+ });
2017
+ }
2018
+
2019
+ let handleDebounce;
2020
+ if (newAgentHandleInput) {
2021
+ newAgentHandleInput.addEventListener("input", () => {
2022
+ clearTimeout(handleDebounce);
2023
+ handleDebounce = setTimeout(() => {
2024
+ const val = newAgentHandleInput.value.trim().toLowerCase();
2025
+ const match = state.briefs?.find((b) => b.handle === val);
2026
+ if (match) {
2027
+ applyBriefByHandle(val);
2028
+ }
2029
+ }, 400);
2030
+ });
2031
+ }
2032
+
2033
+ if (registerAgentForm) {
2034
+ registerAgentForm.addEventListener("submit", async (e) => {
2035
+ e.preventDefault();
2036
+ const handle = newAgentHandleInput.value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
2037
+ if (!handle) return;
2038
+
2039
+ const payload = {
2040
+ handle,
2041
+ name: newAgentNameInput.value.trim() || undefined,
2042
+ role: newAgentRoleInput.value.trim() || undefined,
2043
+ model: (newAgentModelInput?.value || "").trim() || "claude-3-7-sonnet",
2044
+ prompt: newAgentPromptInput ? newAgentPromptInput.value.trim() || undefined : undefined,
2045
+ };
2046
+
2047
+ try {
2048
+ const res = await fetch("/api/agents", {
2049
+ method: "POST",
2050
+ headers: { "Content-Type": "application/json" },
2051
+ body: JSON.stringify(payload),
2052
+ });
2053
+ const data = await res.json();
2054
+ if (data.ok) {
2055
+ closeRegisterAgentModal();
2056
+ await fetchAgents();
2057
+ await fetchBriefs();
2058
+ showToast(`Agent @${handle} saved successfully!`);
2059
+ } else {
2060
+ alert(`Failed to save agent: ${data.error || "Unknown error"}`);
2061
+ }
2062
+ } catch (err) {
2063
+ alert(`Error: ${err.message}`);
2064
+ }
2065
+ });
2066
+ }
2067
+ }
2068
+
2069
+ // ─── Swarm Coordination Kanban Board ───────────────────────────────────────
2070
+
2071
+ async function fetchBoard() {
2072
+ try {
2073
+ const res = await fetch("/api/board");
2074
+ const data = await res.json();
2075
+ if (data.ok) {
2076
+ state.board = data;
2077
+ if (boardTotalCountEl) {
2078
+ boardTotalCountEl.textContent = data.stats?.total || 0;
2079
+ }
2080
+ if (state.currentView === "board") {
2081
+ renderBoard();
2082
+ }
2083
+ }
2084
+ } catch (err) {
2085
+ console.warn("[board] Failed to fetch board:", err.message);
2086
+ }
2087
+ }
2088
+
2089
+ function switchView(viewName) {
2090
+ state.currentView = viewName;
2091
+
2092
+ if (viewName === "board") {
2093
+ navViewMail?.classList.remove("active");
2094
+ navViewBoard?.classList.add("active");
2095
+ mailViewSection?.classList.add("hidden");
2096
+ boardViewSection?.classList.remove("hidden");
2097
+ fetchBoard();
2098
+ } else {
2099
+ navViewBoard?.classList.remove("active");
2100
+ navViewMail?.classList.add("active");
2101
+ boardViewSection?.classList.add("hidden");
2102
+ mailViewSection?.classList.remove("hidden");
2103
+ }
2104
+ }
2105
+
2106
+ function renderBoard() {
2107
+ if (!state.board || !state.board.columns) return;
2108
+ const { columns, stats, owners } = state.board;
2109
+
2110
+ // Update Stats Pills
2111
+ if (statTotalEl) statTotalEl.textContent = stats.total || 0;
2112
+ if (statProgressEl) statProgressEl.textContent = stats.in_progress || 0;
2113
+ if (statBlockedEl) statBlockedEl.textContent = stats.blocked || 0;
2114
+ if (statDoneEl) statDoneEl.textContent = stats.done || 0;
2115
+
2116
+ if (colCountBacklog) colCountBacklog.textContent = columns.backlog.length;
2117
+ if (colCountInProgress) colCountInProgress.textContent = columns.in_progress.length;
2118
+ if (colCountBlocked) colCountBlocked.textContent = columns.blocked.length;
2119
+ if (colCountDone) colCountDone.textContent = columns.done.length;
2120
+
2121
+ // Render Agent Filter Chips
2122
+ if (boardAgentFilterBar) {
2123
+ const filterOwners = ["all", ...(owners || [])];
2124
+ let chipsHtml = "";
2125
+ for (const o of filterOwners) {
2126
+ const isActive = state.boardFilterAgent === o;
2127
+ const agentObj = state.agents.find((a) => a.handle === o);
2128
+ const name = o === "all" ? "All Agents" : (agentObj?.profile?.name || o);
2129
+ const emoji = o === "all" ? "👥" : (agentObj?.profile?.emoji || "🤖");
2130
+ chipsHtml += `
2131
+ <button class="board-filter-chip ${isActive ? "active" : ""}" data-agent="${escapeHtml(o)}">
2132
+ <span>${emoji}</span>
2133
+ <span>${escapeHtml(name)}</span>
2134
+ </button>
2135
+ `;
2136
+ }
2137
+ boardAgentFilterBar.innerHTML = chipsHtml;
2138
+
2139
+ boardAgentFilterBar.querySelectorAll(".board-filter-chip").forEach((btn) => {
2140
+ btn.addEventListener("click", () => {
2141
+ state.boardFilterAgent = btn.dataset.agent;
2142
+ renderBoard();
2143
+ });
2144
+ });
2145
+ }
2146
+
2147
+ const query = (state.boardSearchQuery || "").toLowerCase();
2148
+
2149
+ function filterCards(cardList) {
2150
+ return (cardList || []).filter((c) => {
2151
+ if (state.boardFilterAgent !== "all" && c.owner !== state.boardFilterAgent) {
2152
+ return false;
2153
+ }
2154
+ if (query) {
2155
+ const matchText = `${c.title} ${c.owner} ${c.description || ""}`.toLowerCase();
2156
+ if (!matchText.includes(query)) return false;
2157
+ }
2158
+ return true;
2159
+ });
2160
+ }
2161
+
2162
+ const colContainers = {
2163
+ backlog: cardsBacklogEl,
2164
+ in_progress: cardsInProgressEl,
2165
+ blocked: cardsBlockedEl,
2166
+ done: cardsDoneEl,
2167
+ };
2168
+
2169
+ const emptyMessages = {
2170
+ backlog: "No tasks in backlog",
2171
+ in_progress: "No tasks in flight",
2172
+ blocked: "No blocked items",
2173
+ done: "No completed tasks",
2174
+ };
2175
+
2176
+ for (const [colName, containerEl] of Object.entries(colContainers)) {
2177
+ if (!containerEl) continue;
2178
+ const filtered = filterCards(columns[colName]);
2179
+
2180
+ if (!filtered.length) {
2181
+ containerEl.innerHTML = `<div class="kanban-empty">${emptyMessages[colName]}</div>`;
2182
+ continue;
2183
+ }
2184
+
2185
+ let cardsHtml = "";
2186
+ for (const card of filtered) {
2187
+ const agentObj = state.agents.find((a) => a.handle === card.owner);
2188
+ const prof = agentObj?.profile || {
2189
+ name: card.owner,
2190
+ emoji: (card.owner || "?").slice(0, 1).toUpperCase(),
2191
+ color: "#1a73e8",
2192
+ };
2193
+
2194
+ const isEmVoo = card.source === "status_em_voo";
2195
+ const isCustom = card.source === "custom";
2196
+ const sourceBadge = isEmVoo
2197
+ ? `<span class="kanban-source-badge badge-em-voo">Em Voo</span>`
2198
+ : isCustom
2199
+ ? `<span class="kanban-source-badge">Custom</span>`
2200
+ : "";
2201
+
2202
+ // Navigation move buttons
2203
+ let actionsHtml = "";
2204
+ if (colName === "backlog") {
2205
+ actionsHtml += `<button class="kanban-move-btn" data-move-id="${card.id}" data-target-col="in_progress" title="Move to Em Voo">Start ➡️</button>`;
2206
+ } else if (colName === "in_progress") {
2207
+ actionsHtml += `<button class="kanban-move-btn" data-move-id="${card.id}" data-target-col="backlog" title="Back to backlog">⬅️</button>`;
2208
+ actionsHtml += `<button class="kanban-move-btn" data-move-id="${card.id}" data-target-col="blocked" title="Mark Blocked">🛑</button>`;
2209
+ actionsHtml += `<button class="kanban-move-btn" data-move-id="${card.id}" data-target-col="done" title="Mark Done">✅</button>`;
2210
+ } else if (colName === "blocked") {
2211
+ actionsHtml += `<button class="kanban-move-btn" data-move-id="${card.id}" data-target-col="in_progress" title="Unblock / Resume">🚀</button>`;
2212
+ actionsHtml += `<button class="kanban-move-btn" data-move-id="${card.id}" data-target-col="done" title="Mark Done">✅</button>`;
2213
+ } else if (colName === "done") {
2214
+ actionsHtml += `<button class="kanban-move-btn" data-move-id="${card.id}" data-target-col="in_progress" title="Reopen to Em Voo">↩️</button>`;
2215
+ }
2216
+
2217
+ if (isCustom) {
2218
+ actionsHtml += `<button class="kanban-move-btn" data-delete-id="${card.id}" title="Delete custom task">🗑️</button>`;
2219
+ }
2220
+
2221
+ cardsHtml += `
2222
+ <div class="kanban-card" id="kcard-${escapeHtml(card.id)}" data-task-id="${escapeHtml(card.id)}" data-col="${colName}" draggable="true">
2223
+ <div class="kanban-card-header">
2224
+ <div class="kanban-card-title">${escapeHtml(card.title)}</div>
2225
+ ${sourceBadge}
2226
+ </div>
2227
+ ${card.description ? `<div class="kanban-card-desc">${escapeHtml(card.description)}</div>` : ""}
2228
+ <div class="kanban-card-footer">
2229
+ <div class="kanban-owner-badge" title="Owner: ${escapeHtml(card.owner)}">
2230
+ <div class="kanban-owner-avatar" style="background:${prof.color};">${prof.emoji}</div>
2231
+ <span>${escapeHtml(prof.name)}</span>
2232
+ </div>
2233
+ <div class="kanban-card-actions">
2234
+ ${actionsHtml}
2235
+ </div>
2236
+ </div>
2237
+ </div>
2238
+ `;
2239
+ }
2240
+
2241
+ containerEl.innerHTML = cardsHtml;
2242
+ }
2243
+
2244
+ attachKanbanListeners();
2245
+ }
2246
+
2247
+ function attachKanbanListeners() {
2248
+ // 1. Action move buttons
2249
+ document.querySelectorAll(".kanban-move-btn").forEach((btn) => {
2250
+ btn.addEventListener("click", (e) => {
2251
+ e.stopPropagation();
2252
+ const deleteId = btn.dataset.deleteId;
2253
+ if (deleteId) {
2254
+ if (confirm("Delete this custom task?")) {
2255
+ deleteTaskCard(deleteId);
2256
+ }
2257
+ return;
2258
+ }
2259
+
2260
+ const taskId = btn.dataset.moveId;
2261
+ const targetCol = btn.dataset.targetCol;
2262
+ if (taskId && targetCol) {
2263
+ moveTaskCard(taskId, targetCol);
2264
+ }
2265
+ });
2266
+ });
2267
+
2268
+ // 2. Drag and drop + card click opening for Task Sheet
2269
+ let isDragging = false;
2270
+ document.querySelectorAll(".kanban-card").forEach((card) => {
2271
+ card.addEventListener("dragstart", (e) => {
2272
+ isDragging = true;
2273
+ const taskId = card.dataset.taskId;
2274
+ e.dataTransfer.setData("text/plain", taskId);
2275
+ e.dataTransfer.effectAllowed = "move";
2276
+ card.classList.add("is-dragging");
2277
+ });
2278
+
2279
+ card.addEventListener("dragend", () => {
2280
+ card.classList.remove("is-dragging");
2281
+ setTimeout(() => {
2282
+ isDragging = false;
2283
+ }, 80);
2284
+ });
2285
+
2286
+ // Click card to open Ficha (Task Sheet)
2287
+ card.addEventListener("click", (e) => {
2288
+ if (isDragging) return;
2289
+ if (e.target.closest(".kanban-move-btn")) return;
2290
+ const taskId = card.dataset.taskId;
2291
+ if (taskId) {
2292
+ openTaskSheet(taskId);
2293
+ }
2294
+ });
2295
+ });
2296
+
2297
+ // 3. Drop zones on each column list
2298
+ document.querySelectorAll(".kanban-cards-list").forEach((list) => {
2299
+ list.addEventListener("dragover", (e) => {
2300
+ e.preventDefault();
2301
+ e.dataTransfer.dropEffect = "move";
2302
+ list.classList.add("drag-over");
2303
+ });
2304
+
2305
+ list.addEventListener("dragleave", () => {
2306
+ list.classList.remove("drag-over");
2307
+ });
2308
+
2309
+ list.addEventListener("drop", (e) => {
2310
+ e.preventDefault();
2311
+ list.classList.remove("drag-over");
2312
+ const taskId = e.dataTransfer.getData("text/plain");
2313
+ const targetCol = list.dataset.col;
2314
+ if (taskId && targetCol) {
2315
+ moveTaskCard(taskId, targetCol);
2316
+ }
2317
+ });
2318
+ });
2319
+ }
2320
+
2321
+ async function moveTaskCard(taskId, targetCol) {
2322
+ if (!state.board || !state.board.columns) return;
2323
+
2324
+ // Optimistic local state update
2325
+ let movedTask = null;
2326
+ for (const [colName, list] of Object.entries(state.board.columns)) {
2327
+ const idx = list.findIndex((t) => t.id === taskId);
2328
+ if (idx >= 0) {
2329
+ movedTask = list.splice(idx, 1)[0];
2330
+ break;
2331
+ }
2332
+ }
2333
+
2334
+ if (movedTask) {
2335
+ movedTask.status = targetCol;
2336
+ state.board.columns[targetCol].unshift(movedTask);
2337
+ renderBoard();
2338
+ }
2339
+
2340
+ // Sync sheet stage button if sheet is open for this task
2341
+ if (state.selectedTaskId === taskId && sheetStageButtons) {
2342
+ sheetStageButtons.querySelectorAll(".sheet-stage-btn").forEach((btn) => {
2343
+ btn.classList.toggle("active", btn.dataset.stage === targetCol);
2344
+ });
2345
+ }
2346
+
2347
+ try {
2348
+ await fetch(`/api/board/tasks/${encodeURIComponent(taskId)}`, {
2349
+ method: "PATCH",
2350
+ headers: { "Content-Type": "application/json" },
2351
+ body: JSON.stringify({
2352
+ status: targetCol,
2353
+ from: state.activePersona || "coordinator",
2354
+ notify: true,
2355
+ }),
2356
+ });
2357
+ showToast(`Task moved to ${targetCol} (AMQ alert sent)`);
2358
+ } catch (err) {
2359
+ console.warn("[board] Move task failed:", err.message);
2360
+ fetchBoard();
2361
+ }
2362
+ }
2363
+
2364
+ async function deleteTaskCard(taskId) {
2365
+ if (state.selectedTaskId === taskId) {
2366
+ closeTaskSheet();
2367
+ }
2368
+ try {
2369
+ await fetch(`/api/board/tasks/${encodeURIComponent(taskId)}`, {
2370
+ method: "DELETE",
2371
+ });
2372
+ fetchBoard();
2373
+ } catch (err) {
2374
+ alert("Delete failed: " + err.message);
2375
+ }
2376
+ }
2377
+
2378
+ function openNewTaskModal() {
2379
+ if (!taskModalBackdrop) return;
2380
+
2381
+ // Populate assigned agent options
2382
+ if (taskOwnerSelect) {
2383
+ let optionsHtml = "";
2384
+ const currentPersona = state.activePersona || "coordinator";
2385
+ const handles = state.agents.map((a) => a.handle);
2386
+ if (!handles.includes("coordinator")) handles.unshift("coordinator");
2387
+
2388
+ for (const h of handles) {
2389
+ const agentObj = state.agents.find((a) => a.handle === h);
2390
+ const name = agentObj?.profile?.name || h;
2391
+ const selected = h === currentPersona ? "selected" : "";
2392
+ optionsHtml += `<option value="${escapeHtml(h)}" ${selected}>@${escapeHtml(h)} (${escapeHtml(name)})</option>`;
2393
+ }
2394
+ taskOwnerSelect.innerHTML = optionsHtml;
2395
+ }
2396
+
2397
+ if (taskTitleInput) taskTitleInput.value = "";
2398
+ if (taskDescInput) taskDescInput.value = "";
2399
+ if (taskStatusSelect) taskStatusSelect.value = "backlog";
2400
+
2401
+ const notifyCheckbox = document.getElementById("task-notify-checkbox");
2402
+ if (notifyCheckbox) notifyCheckbox.checked = true;
2403
+
2404
+ taskModalBackdrop.classList.remove("hidden");
2405
+ taskTitleInput?.focus();
2406
+ }
2407
+
2408
+ function closeNewTaskModal() {
2409
+ taskModalBackdrop?.classList.add("hidden");
2410
+ }
2411
+
2412
+ async function submitNewTask(e) {
2413
+ e.preventDefault();
2414
+ const title = taskTitleInput?.value.trim();
2415
+ if (!title) return;
2416
+
2417
+ const notifyVal = document.getElementById("task-notify-checkbox")?.checked ?? true;
2418
+ const payload = {
2419
+ title,
2420
+ owner: taskOwnerSelect?.value || "coordinator",
2421
+ status: taskStatusSelect?.value || "backlog",
2422
+ description: taskDescInput?.value.trim() || "",
2423
+ notify: notifyVal,
2424
+ from: state.activePersona || "coordinator",
2425
+ };
2426
+
2427
+ try {
2428
+ const res = await fetch("/api/board/tasks", {
2429
+ method: "POST",
2430
+ headers: { "Content-Type": "application/json" },
2431
+ body: JSON.stringify(payload),
2432
+ });
2433
+ const data = await res.json();
2434
+ if (data.ok) {
2435
+ closeNewTaskModal();
2436
+ await fetchBoard();
2437
+ const notifyMsg = notifyVal && payload.owner !== "coordinator" ? " & automail dispatched" : "";
2438
+ showToast(`Task "${title.slice(0, 30)}" created${notifyMsg}!`);
2439
+ } else {
2440
+ alert("Failed to create task: " + (data.error || "unknown"));
2441
+ }
2442
+ } catch (err) {
2443
+ alert("Error: " + err.message);
2444
+ }
2445
+ }
2446
+
2447
+ // ─── Task Sheet (Ficha do Card) Logic ───────────────────────────────────────
2448
+
2449
+ function findTaskById(taskId) {
2450
+ if (!state.board?.columns) return null;
2451
+ for (const list of Object.values(state.board.columns)) {
2452
+ const match = list.find((t) => t.id === taskId);
2453
+ if (match) return match;
2454
+ }
2455
+ return null;
2456
+ }
2457
+
2458
+ async function fetchTaskTransmissions(task) {
2459
+ if (!task) return [];
2460
+ try {
2461
+ const res = await fetch(`/api/messages?account=all&folder=all&pageSize=100&query=${encodeURIComponent(task.id)}`);
2462
+ const data = await res.json();
2463
+ let msgs = Array.isArray(data) ? data : data?.items || [];
2464
+
2465
+ const cleanTitle = (task.title || "").replace(/^\[em voo\]\s*/i, "").trim();
2466
+ if (cleanTitle.length >= 6) {
2467
+ try {
2468
+ const res2 = await fetch(`/api/messages?account=all&folder=all&pageSize=50&query=${encodeURIComponent(cleanTitle.slice(0, 25))}`);
2469
+ const data2 = await res2.json();
2470
+ const msgs2 = Array.isArray(data2) ? data2 : data2?.items || [];
2471
+ for (const m of msgs2) {
2472
+ if (!msgs.some((x) => x.id === m.id)) msgs.push(m);
2473
+ }
2474
+ } catch {}
2475
+ }
2476
+
2477
+ const taskId = (task.id || "").toLowerCase();
2478
+ const taskThread = `agboard/${task.id}`.toLowerCase();
2479
+ const lowerTitle = cleanTitle.toLowerCase();
2480
+
2481
+ const matched = msgs.filter((m) => {
2482
+ const mThread = (m.thread || "").toLowerCase();
2483
+ const mSubj = (m.subject || "").toLowerCase();
2484
+ const mBody = (m.body || "").toLowerCase();
2485
+ return (
2486
+ mThread === taskThread ||
2487
+ mThread === taskId ||
2488
+ mThread.endsWith(`/${taskId}`) ||
2489
+ mSubj.includes(taskId) ||
2490
+ mBody.includes(taskId) ||
2491
+ (lowerTitle.length >= 6 && (mSubj.includes(lowerTitle) || (mSubj.includes("[agboard]") && mSubj.includes(lowerTitle.slice(0, 15)))))
2492
+ );
2493
+ });
2494
+
2495
+ matched.sort((a, b) => {
2496
+ const tA = a.created ? new Date(a.created).getTime() : 0;
2497
+ const tB = b.created ? new Date(b.created).getTime() : 0;
2498
+ return tA - tB;
2499
+ });
2500
+
2501
+ return matched;
2502
+ } catch {
2503
+ return [];
2504
+ }
2505
+ }
2506
+
2507
+ async function renderSheetTransmissions(task) {
2508
+ if (!task || !sheetThreadList) return;
2509
+ sheetThreadList.innerHTML = `<div class="sheet-msg-empty"><span>Carregando transmissões...</span></div>`;
2510
+
2511
+ const msgs = await fetchTaskTransmissions(task);
2512
+ if (sheetThreadBadge) sheetThreadBadge.textContent = msgs.length;
2513
+ if (sheetTabBadge) sheetTabBadge.textContent = msgs.length;
2514
+
2515
+ if (!msgs.length) {
2516
+ sheetThreadList.innerHTML = `
2517
+ <div class="sheet-msg-empty">
2518
+ <span>📬 Nenhuma transmissão registrada ainda nesta ficha.</span><br>
2519
+ <span style="font-size:11.5px;color:var(--text-muted);margin-top:4px;display:inline-block;">Use o campo abaixo para enviar a primeira ordem ou atualização diretamente pelo AMQ.</span>
2520
+ </div>
2521
+ `;
2522
+ return;
2523
+ }
2524
+
2525
+ let html = "";
2526
+ msgs.forEach((m, idx) => {
2527
+ const isLatest = idx === msgs.length - 1;
2528
+ const agentObj = state.agents.find((x) => x.handle === m.from);
2529
+ const prof = agentObj?.profile || {
2530
+ name: m.from,
2531
+ emoji: (m.from || "?").slice(0, 1).toUpperCase(),
2532
+ color: "#1a73e8",
2533
+ role: "Persona",
2534
+ };
2535
+ const dateStr = m.created ? new Date(m.created).toLocaleString() : "";
2536
+ const toStr = Array.isArray(m.to) ? m.to.join(", ") : (m.to || "all");
2537
+ const renderedBody = renderMarkdown(m.body || "");
2538
+ const attachmentsHtml = renderAttachmentsSection(m.attachments);
2539
+
2540
+ html += `
2541
+ <div class="sheet-msg-card ${isLatest ? "expanded" : "collapsed"}" data-msg-id="${escapeHtml(m.id)}">
2542
+ <div class="sheet-msg-header">
2543
+ <div class="sheet-msg-header-left">
2544
+ <div class="sheet-msg-avatar" style="background:${prof.color};">${prof.emoji}</div>
2545
+ <strong class="sheet-msg-author">${escapeHtml(prof.name)}</strong>
2546
+ <span class="sheet-msg-snippet">${escapeHtml(m.snippet || "")}</span>
2547
+ </div>
2548
+ <div class="sheet-msg-date">${dateStr}</div>
2549
+ </div>
2550
+ <div class="sheet-msg-body" style="display:${isLatest ? "block" : "none"};">
2551
+ <div style="font-size:11.5px;color:#5f6368;margin-bottom:8px;">
2552
+ <span>De: <strong>${escapeHtml(m.from || "")}</strong> (${escapeHtml(prof.role)}) &lt;${escapeHtml(m.from || "")}@amq&gt;</span> •
2553
+ <span>Para: ${escapeHtml(toStr)}</span> •
2554
+ <span>${dateStr}</span>
2555
+ </div>
2556
+ <div class="md-content">${renderedBody}</div>
2557
+ ${attachmentsHtml}
2558
+ </div>
2559
+ </div>
2560
+ `;
2561
+ });
2562
+
2563
+ sheetThreadList.innerHTML = html;
2564
+
2565
+ sheetThreadList.querySelectorAll(".sheet-msg-header").forEach((hdr) => {
2566
+ hdr.addEventListener("click", () => {
2567
+ const card = hdr.closest(".sheet-msg-card");
2568
+ const body = card.querySelector(".sheet-msg-body");
2569
+ if (body.style.display === "none") {
2570
+ body.style.display = "block";
2571
+ card.classList.remove("collapsed");
2572
+ card.classList.add("expanded");
2573
+ } else {
2574
+ body.style.display = "none";
2575
+ card.classList.remove("expanded");
2576
+ card.classList.add("collapsed");
2577
+ }
2578
+ });
2579
+ });
2580
+ }
2581
+
2582
+ function openTaskSheet(taskId) {
2583
+ if (!taskId) return;
2584
+ const foundTask = findTaskById(taskId);
2585
+ if (!foundTask) return;
2586
+
2587
+ state.selectedTaskId = taskId;
2588
+
2589
+ // Header info
2590
+ if (sheetTaskId) sheetTaskId.textContent = `#${foundTask.id}`;
2591
+ if (sheetTaskSource) {
2592
+ const isEmVoo = foundTask.source === "status_em_voo";
2593
+ const isCustom = foundTask.source === "custom";
2594
+ sheetTaskSource.textContent = isEmVoo ? "Em Voo (STATUS.md)" : isCustom ? "Custom AMQ" : "STATUS.md";
2595
+ sheetTaskSource.className = `kanban-source-badge ${isEmVoo ? "badge-em-voo" : ""}`;
2596
+ }
2597
+ if (sheetTaskTitle) sheetTaskTitle.textContent = foundTask.title;
2598
+
2599
+ // Stage buttons
2600
+ if (sheetStageButtons) {
2601
+ sheetStageButtons.querySelectorAll(".sheet-stage-btn").forEach((btn) => {
2602
+ btn.classList.toggle("active", btn.dataset.stage === foundTask.status);
2603
+ });
2604
+ }
2605
+
2606
+ // Populate Owner Select & Avatar
2607
+ if (sheetOwnerSelect) {
2608
+ let ownerOptions = "";
2609
+ const handles = state.agents.map((a) => a.handle);
2610
+ if (!handles.includes("coordinator")) handles.unshift("coordinator");
2611
+
2612
+ for (const h of handles) {
2613
+ const agentObj = state.agents.find((a) => a.handle === h);
2614
+ const name = agentObj?.profile?.name || h;
2615
+ const sel = h === foundTask.owner ? "selected" : "";
2616
+ optionsHtml = `<option value="${escapeHtml(h)}" ${sel}>@${escapeHtml(h)} (${escapeHtml(name)})</option>`;
2617
+ ownerOptions += optionsHtml;
2618
+ }
2619
+ sheetOwnerSelect.innerHTML = ownerOptions;
2620
+ sheetOwnerSelect.value = foundTask.owner || "coordinator";
2621
+ }
2622
+
2623
+ if (sheetOwnerAvatar) {
2624
+ const currentOwnerObj = state.agents.find((a) => a.handle === foundTask.owner);
2625
+ const prof = currentOwnerObj?.profile || {
2626
+ name: foundTask.owner,
2627
+ emoji: (foundTask.owner || "?").slice(0, 1).toUpperCase(),
2628
+ color: "#1a73e8",
2629
+ };
2630
+ sheetOwnerAvatar.textContent = prof.emoji;
2631
+ sheetOwnerAvatar.style.backgroundColor = prof.color;
2632
+ }
2633
+
2634
+ // Thread ID
2635
+ const threadKey = `agboard/${foundTask.id}`;
2636
+ if (sheetThreadId) sheetThreadId.textContent = threadKey;
2637
+ if (sheetDispatchThreadPreview) sheetDispatchThreadPreview.textContent = threadKey;
2638
+
2639
+ // Description / Context
2640
+ if (sheetTaskDesc) {
2641
+ sheetTaskDesc.classList.remove("is-compact-scroll");
2642
+ if (sheetDescExpandIcon) sheetDescExpandIcon.textContent = "📖";
2643
+ if (sheetDescExpandText) sheetDescExpandText.textContent = "Foco na Descrição";
2644
+ sheetTaskDesc.scrollTop = 0;
2645
+
2646
+ const rawDesc = foundTask.description || "";
2647
+ if (rawDesc.trim()) {
2648
+ sheetTaskDesc.innerHTML = renderMarkdown(rawDesc);
2649
+ } else {
2650
+ sheetTaskDesc.innerHTML = `<em style="color:var(--text-muted);font-size:13px;">Nenhuma descrição adicional informada para esta tarefa.</em>`;
2651
+ }
2652
+ }
2653
+
2654
+ // Populate Dispatch From & To
2655
+ const handles = state.agents.map((a) => a.handle);
2656
+ if (!handles.includes("coordinator")) handles.unshift("coordinator");
2657
+
2658
+ if (sheetDispatchFrom) {
2659
+ let fromOptions = "";
2660
+ const currentPersona = state.activePersona || "coordinator";
2661
+ for (const h of handles) {
2662
+ const sel = h === currentPersona ? "selected" : "";
2663
+ fromOptions += `<option value="${escapeHtml(h)}" ${sel}>${escapeHtml(h)}</option>`;
2664
+ }
2665
+ if (!handles.includes("user")) {
2666
+ fromOptions += `<option value="user" ${currentPersona === "user" ? "selected" : ""}>user (Human operator)</option>`;
2667
+ }
2668
+ sheetDispatchFrom.innerHTML = fromOptions;
2669
+ }
2670
+
2671
+ if (sheetDispatchTo) {
2672
+ let toOptions = "";
2673
+ for (const h of handles) {
2674
+ const sel = h === foundTask.owner ? "selected" : "";
2675
+ toOptions += `<option value="${escapeHtml(h)}" ${sel}>${escapeHtml(h)}</option>`;
2676
+ }
2677
+ sheetDispatchTo.innerHTML = toOptions;
2678
+ sheetDispatchTo.value = foundTask.owner || "coordinator";
2679
+ }
2680
+
2681
+ if (sheetDispatchBody) sheetDispatchBody.value = "";
2682
+
2683
+ // Reset view tab to integrated view
2684
+ switchSheetTab("all");
2685
+
2686
+ // Render linked transmissions
2687
+ renderSheetTransmissions(foundTask);
2688
+
2689
+ // Show drawer
2690
+ taskSheetBackdrop?.classList.remove("hidden");
2691
+ taskSheetDrawer?.classList.remove("hidden");
2692
+ }
2693
+
2694
+ function switchSheetTab(tabName) {
2695
+ if (!taskSheetDrawer) return;
2696
+ taskSheetDrawer.setAttribute("data-tab", tabName);
2697
+ if (sheetTabsBar) {
2698
+ sheetTabsBar.querySelectorAll(".sheet-tab-btn").forEach((btn) => {
2699
+ btn.classList.toggle("active", btn.dataset.tab === tabName);
2700
+ });
2701
+ }
2702
+ if (sheetDescBackBtn) {
2703
+ sheetDescBackBtn.style.display = tabName === "desc" ? "inline-flex" : "none";
2704
+ }
2705
+ if (sheetDescExpandIcon && sheetDescExpandText) {
2706
+ if (tabName === "desc") {
2707
+ sheetDescExpandIcon.textContent = "📜";
2708
+ sheetDescExpandText.textContent = "Visão Integrada";
2709
+ } else {
2710
+ sheetDescExpandIcon.textContent = "📖";
2711
+ sheetDescExpandText.textContent = "Foco na Descrição";
2712
+ }
2713
+ }
2714
+ }
2715
+
2716
+ function closeTaskSheet() {
2717
+ if (taskSheetDrawer) {
2718
+ taskSheetDrawer.classList.add("hidden");
2719
+ taskSheetDrawer.classList.remove("drawer-wide");
2720
+ taskSheetDrawer.removeAttribute("data-tab");
2721
+ if (sheetWideIcon) sheetWideIcon.textContent = "↔️";
2722
+ if (sheetWideText) sheetWideText.textContent = "Expandir Ficha";
2723
+ }
2724
+ taskSheetBackdrop?.classList.add("hidden");
2725
+ state.selectedTaskId = null;
2726
+ }
2727
+
2728
+ function setupKanbanBoard() {
2729
+ // Navigation view switching
2730
+ navViewMail?.addEventListener("click", () => switchView("mail"));
2731
+ navViewBoard?.addEventListener("click", () => switchView("board"));
2732
+
2733
+ // Board search filter
2734
+ boardSearchInput?.addEventListener("input", (e) => {
2735
+ state.boardSearchQuery = e.target.value.trim();
2736
+ renderBoard();
2737
+ });
2738
+
2739
+ // Refresh & sync button
2740
+ refreshBoardBtn?.addEventListener("click", () => {
2741
+ fetchBoard();
2742
+ });
2743
+
2744
+ // New task modal triggers
2745
+ openNewTaskBtn?.addEventListener("click", openNewTaskModal);
2746
+ closeTaskModalBtn?.addEventListener("click", closeNewTaskModal);
2747
+ cancelTaskBtn?.addEventListener("click", closeNewTaskModal);
2748
+ newTaskForm?.addEventListener("submit", submitNewTask);
2749
+
2750
+ taskModalBackdrop?.addEventListener("click", (e) => {
2751
+ if (e.target === taskModalBackdrop) closeNewTaskModal();
2752
+ });
2753
+
2754
+ // Task Sheet triggers
2755
+ closeTaskSheetBtn?.addEventListener("click", closeTaskSheet);
2756
+ sheetBackBtn?.addEventListener("click", closeTaskSheet);
2757
+ sheetDescBackBtn?.addEventListener("click", () => switchSheetTab("all"));
2758
+ taskSheetBackdrop?.addEventListener("click", closeTaskSheet);
2759
+
2760
+ sheetTabsBar?.addEventListener("click", (e) => {
2761
+ const btn = e.target.closest(".sheet-tab-btn");
2762
+ if (!btn) return;
2763
+ const tab = btn.dataset.tab;
2764
+ if (tab) switchSheetTab(tab);
2765
+ });
2766
+
2767
+ sheetDescExpandBtn?.addEventListener("click", () => {
2768
+ const currentTab = taskSheetDrawer?.getAttribute("data-tab") || "all";
2769
+ switchSheetTab(currentTab === "desc" ? "all" : "desc");
2770
+ });
2771
+
2772
+ sheetWideBtn?.addEventListener("click", () => {
2773
+ if (!taskSheetDrawer) return;
2774
+ const isWide = taskSheetDrawer.classList.toggle("drawer-wide");
2775
+ if (sheetWideIcon) sheetWideIcon.textContent = isWide ? "><" : "↔️";
2776
+ if (sheetWideText) sheetWideText.textContent = isWide ? "Recolher Ficha" : "Expandir Ficha";
2777
+ });
2778
+
2779
+ sheetCopyIdBtn?.addEventListener("click", () => {
2780
+ if (state.selectedTaskId) {
2781
+ navigator.clipboard.writeText(state.selectedTaskId).then(() => {
2782
+ showToast(`ID copiado: ${state.selectedTaskId}`);
2783
+ }).catch(() => {});
2784
+ }
2785
+ });
2786
+
2787
+ sheetRefreshThreadBtn?.addEventListener("click", () => {
2788
+ if (state.selectedTaskId) {
2789
+ const task = findTaskById(state.selectedTaskId);
2790
+ if (task) renderSheetTransmissions(task);
2791
+ }
2792
+ });
2793
+
2794
+ sheetStageButtons?.addEventListener("click", async (e) => {
2795
+ const btn = e.target.closest(".sheet-stage-btn");
2796
+ if (!btn) return;
2797
+ const targetStage = btn.dataset.stage;
2798
+ if (state.selectedTaskId && targetStage) {
2799
+ await moveTaskCard(state.selectedTaskId, targetStage);
2800
+ sheetStageButtons.querySelectorAll(".sheet-stage-btn").forEach((b) => {
2801
+ b.classList.toggle("active", b.dataset.stage === targetStage);
2802
+ });
2803
+ setTimeout(async () => {
2804
+ const task = findTaskById(state.selectedTaskId);
2805
+ if (task) renderSheetTransmissions(task);
2806
+ }, 350);
2807
+ }
2808
+ });
2809
+
2810
+ sheetOwnerSelect?.addEventListener("change", async () => {
2811
+ const newOwner = sheetOwnerSelect.value;
2812
+ if (state.selectedTaskId && newOwner) {
2813
+ try {
2814
+ await fetch(`/api/board/tasks/${encodeURIComponent(state.selectedTaskId)}`, {
2815
+ method: "PATCH",
2816
+ headers: { "Content-Type": "application/json" },
2817
+ body: JSON.stringify({
2818
+ owner: newOwner,
2819
+ from: state.activePersona || "coordinator",
2820
+ notify: true,
2821
+ }),
2822
+ });
2823
+ showToast(`Responsável alterado para @${newOwner} (notificação enviada)`);
2824
+ await fetchBoard();
2825
+ openTaskSheet(state.selectedTaskId);
2826
+ } catch (err) {
2827
+ alert("Erro ao alterar responsável: " + err.message);
2828
+ }
2829
+ }
2830
+ });
2831
+
2832
+ sheetDispatchChips?.addEventListener("click", (e) => {
2833
+ const chip = e.target.closest(".chip");
2834
+ if (!chip) return;
2835
+ const tpl = chip.dataset.tpl;
2836
+ const task = findTaskById(state.selectedTaskId);
2837
+ const title = task?.title || "tarefa";
2838
+
2839
+ if (tpl === "status") {
2840
+ sheetDispatchBody.value = `Status update requested for task: ${title}\n\nPlease report current verification gates and blockers.`;
2841
+ } else if (tpl === "block") {
2842
+ sheetDispatchBody.value = `⚠️ Task blocked: ${title}\n\nBlocker reason: [Descreva o bloqueio]\nAssistance needed from: [coordinator/outro agente]`;
2843
+ } else if (tpl === "done") {
2844
+ sheetDispatchBody.value = `✅ Task completed: ${title}\n\nEvidence / proof:\n- Verification command: bash tools/verify-all.sh --quick\n- Result: PASS`;
2845
+ } else if (tpl === "ack") {
2846
+ sheetDispatchBody.value = `ACK. Order received for task: ${title}. Work in progress.`;
2847
+ }
2848
+ sheetDispatchBody.focus();
2849
+ });
2850
+
2851
+ sheetDispatchSendBtn?.addEventListener("click", async () => {
2852
+ if (!state.selectedTaskId) return;
2853
+ const bodyVal = sheetDispatchBody?.value.trim();
2854
+ if (!bodyVal) {
2855
+ alert("Digite uma mensagem para transmitir.");
2856
+ sheetDispatchBody?.focus();
2857
+ return;
2858
+ }
2859
+
2860
+ const fromVal = sheetDispatchFrom?.value || state.activePersona || "coordinator";
2861
+ const toVal = sheetDispatchTo?.value || "coordinator";
2862
+ const task = findTaskById(state.selectedTaskId);
2863
+
2864
+ sheetDispatchSendBtn.disabled = true;
2865
+ sheetDispatchSendBtn.innerHTML = `<span>Enviando...</span>`;
2866
+
2867
+ try {
2868
+ const res = await fetch("/api/send", {
2869
+ method: "POST",
2870
+ headers: { "Content-Type": "application/json" },
2871
+ body: JSON.stringify({
2872
+ from: fromVal,
2873
+ to: [toVal],
2874
+ subject: `[AGboard] ${task ? task.title.slice(0, 50) : "Task update"}`,
2875
+ body: bodyVal,
2876
+ thread: `agboard/${state.selectedTaskId}`,
2877
+ kind: "task",
2878
+ }),
2879
+ });
2880
+
2881
+ const data = await res.json();
2882
+ if (data.ok) {
2883
+ if (sheetDispatchBody) sheetDispatchBody.value = "";
2884
+ showToast(`Transmissão AMQ enviada para @${toVal}!`);
2885
+ await fetchData();
2886
+ const updatedTask = findTaskById(state.selectedTaskId);
2887
+ if (updatedTask) renderSheetTransmissions(updatedTask);
2888
+ } else {
2889
+ alert("Falha ao enviar transmissão: " + (data.error || "desconhecido"));
2890
+ }
2891
+ } catch (err) {
2892
+ alert("Erro: " + err.message);
2893
+ } finally {
2894
+ sheetDispatchSendBtn.disabled = false;
2895
+ sheetDispatchSendBtn.innerHTML = `
2896
+ <svg viewBox="0 0 24 24"><path fill="currentColor" d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
2897
+ <span>Enviar Transmissão (AMQ)</span>
2898
+ `;
2899
+ }
2900
+ });
2901
+
2902
+ // Close on Escape key
2903
+ document.addEventListener("keydown", (e) => {
2904
+ if (e.key === "Escape") {
2905
+ if (taskSheetDrawer && !taskSheetDrawer.classList.contains("hidden")) {
2906
+ closeTaskSheet();
2907
+ }
2908
+ }
2909
+ });
2910
+ }
2911
+
2912
+ // ─── Mobile Responsiveness & Drawer Setup ───────────────────────────────────
2913
+
2914
+ function setupMobileLayout() {
2915
+ if (toggleSidebarBtn && sidebarBackdrop) {
2916
+ toggleSidebarBtn.addEventListener("click", () => {
2917
+ document.body.classList.toggle("sidebar-open");
2918
+ sidebarBackdrop.classList.toggle("hidden", !document.body.classList.contains("sidebar-open"));
2919
+ });
2920
+
2921
+ sidebarBackdrop.addEventListener("click", () => {
2922
+ document.body.classList.remove("sidebar-open");
2923
+ sidebarBackdrop.classList.add("hidden");
2924
+ });
2925
+
2926
+ // On mobile, clicking any nav folder item closes drawer
2927
+ document.querySelectorAll(".folder-list .nav-item, .sidebar-views-nav .view-tab-btn").forEach((btn) => {
2928
+ btn.addEventListener("click", () => {
2929
+ if (window.innerWidth <= 768) {
2930
+ document.body.classList.remove("sidebar-open");
2931
+ sidebarBackdrop.classList.add("hidden");
2932
+ }
2933
+ });
2934
+ });
2935
+ }
2936
+ }
2937
+
2938
+ // ─── Pull / Scroll Down to Reload ──────────────────────────────────────────
2939
+
2940
+ function setupPullToRefresh() {
2941
+ const ptrEl = document.getElementById("pull-to-refresh");
2942
+ const ptrLabel = document.getElementById("ptr-label");
2943
+ if (!ptrEl) return;
2944
+
2945
+ let startY = 0;
2946
+ let currentY = 0;
2947
+ let isPulling = false;
2948
+ let isRefreshing = false;
2949
+ const threshold = 50;
2950
+
2951
+ function getActiveScrollableContainer() {
2952
+ if (state.currentView === "board") {
2953
+ return document.querySelector(".kanban-grid") || boardViewSection;
2954
+ }
2955
+ return document.querySelector(".mail-list-container") || mailListEl;
2956
+ }
2957
+
2958
+ function isAtTop() {
2959
+ const container = getActiveScrollableContainer();
2960
+ if (!container) return window.scrollY <= 0;
2961
+ return container.scrollTop <= 0 && window.scrollY <= 0;
2962
+ }
2963
+
2964
+ async function triggerReload() {
2965
+ if (isRefreshing) return;
2966
+ isRefreshing = true;
2967
+ ptrEl.classList.remove("pulling", "ready");
2968
+ ptrEl.classList.add("refreshing");
2969
+ if (ptrLabel) ptrLabel.textContent = "Atualizando dados...";
2970
+
2971
+ try {
2972
+ await Promise.allSettled([
2973
+ fetchStatus(),
2974
+ fetchAgents(),
2975
+ fetchBriefs(),
2976
+ fetchWorktrees(),
2977
+ fetchData(),
2978
+ fetchBoard(),
2979
+ ]);
2980
+ if (ptrLabel) ptrLabel.textContent = "Atualizado!";
2981
+ } catch {
2982
+ if (ptrLabel) ptrLabel.textContent = "Erro ao atualizar";
2983
+ } finally {
2984
+ setTimeout(() => {
2985
+ ptrEl.classList.remove("refreshing");
2986
+ ptrEl.style.height = "0px";
2987
+ ptrEl.style.maxHeight = "0px";
2988
+ if (ptrLabel) ptrLabel.textContent = "Puxe para atualizar";
2989
+ isRefreshing = false;
2990
+ }, 400);
2991
+ }
2992
+ }
2993
+
2994
+ // Touch events for mobile pull-to-refresh
2995
+ document.addEventListener("touchstart", (e) => {
2996
+ if (isRefreshing) return;
2997
+ if (isAtTop() && e.touches.length === 1) {
2998
+ startY = e.touches[0].clientY;
2999
+ currentY = startY;
3000
+ isPulling = true;
3001
+ }
3002
+ }, { passive: true });
3003
+
3004
+ document.addEventListener("touchmove", (e) => {
3005
+ if (!isPulling || isRefreshing) return;
3006
+ currentY = e.touches[0].clientY;
3007
+ const diff = currentY - startY;
3008
+
3009
+ if (diff > 0 && isAtTop()) {
3010
+ const pullDist = Math.min(70, diff * 0.45);
3011
+ ptrEl.classList.add("pulling");
3012
+ ptrEl.style.height = `${pullDist}px`;
3013
+ ptrEl.style.maxHeight = `${pullDist}px`;
3014
+
3015
+ if (pullDist >= threshold) {
3016
+ ptrEl.classList.add("ready");
3017
+ if (ptrLabel) ptrLabel.textContent = "Solte para atualizar";
3018
+ } else {
3019
+ ptrEl.classList.remove("ready");
3020
+ if (ptrLabel) ptrLabel.textContent = "Puxe para atualizar";
3021
+ }
3022
+ } else {
3023
+ ptrEl.classList.remove("pulling", "ready");
3024
+ ptrEl.style.height = "0px";
3025
+ ptrEl.style.maxHeight = "0px";
3026
+ }
3027
+ }, { passive: true });
3028
+
3029
+ document.addEventListener("touchend", () => {
3030
+ if (!isPulling || isRefreshing) return;
3031
+ isPulling = false;
3032
+ const diff = currentY - startY;
3033
+ const pullDist = Math.min(70, diff * 0.45);
3034
+
3035
+ if (pullDist >= threshold && isAtTop()) {
3036
+ triggerReload();
3037
+ } else {
3038
+ ptrEl.classList.remove("pulling", "ready");
3039
+ ptrEl.style.height = "0px";
3040
+ ptrEl.style.maxHeight = "0px";
3041
+ }
3042
+ });
3043
+
3044
+ // Click on indicator also triggers reload
3045
+ ptrEl.addEventListener("click", () => {
3046
+ triggerReload();
3047
+ });
3048
+ }
3049
+
3050
+ // Boot
3051
+ fetchStatus();
3052
+ fetchAgents();
3053
+ fetchBriefs();
3054
+ fetchWorktrees();
3055
+ fetchModels();
3056
+ fetchData();
3057
+ fetchBoard();
3058
+ initSSE();
3059
+ setupPagination();
3060
+ setupContextMenu();
3061
+ setupRegisterAgentModal();
3062
+ setupKanbanBoard();
3063
+ setupMobileLayout();
3064
+ setupPullToRefresh();
3065
+ })();
3066
+