@imenam/mcp-messenger 1.0.10 → 1.0.13

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.
@@ -1,372 +1,883 @@
1
- // Console de supervision : lecture seule sur les routes /admin du serveur.
2
- // Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement.
3
-
4
- const REFRESH_MS = 5000;
5
-
6
- // L'authentification tient dans un cookie de session HttpOnly posé par `/ui/login` :
7
- // ce script ne voit jamais le jeton, et ne stocke rien dans le navigateur.
8
-
9
- const state = {
10
- agent: null,
11
- search: "",
12
- selectedMessageId: null,
13
- threadId: null,
14
- threadTitle: null,
15
- // Historique déplié ou non sous le message ouvert. En mémoire de page, comme le reste :
16
- // rien n'est écrit dans le navigateur, mais le choix tient d'un message à l'autre.
17
- historyExpanded: false,
18
- };
19
-
20
- const el = {
21
- statusDot: document.getElementById("status-dot"),
22
- serverStats: document.getElementById("server-stats"),
23
- agents: document.getElementById("agents"),
24
- messages: document.getElementById("messages"),
25
- detail: document.getElementById("detail"),
26
- search: document.getElementById("search"),
27
- filterLabel: document.getElementById("filter-label"),
28
- clearFilters: document.getElementById("clear-filters"),
29
- refresh: document.getElementById("refresh"),
30
- autoRefresh: document.getElementById("auto-refresh"),
31
- logout: document.getElementById("logout"),
32
- loginForm: document.getElementById("login-form"),
33
- tokenInput: document.getElementById("token"),
34
- tokenError: document.getElementById("token-error"),
35
- };
36
-
37
- async function api(path, params) {
38
- const url = new URL(path, window.location.origin);
39
- for (const [key, value] of Object.entries(params || {})) {
40
- if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, value);
41
- }
42
-
43
- const response = await fetch(url, { headers: { accept: "application/json" }, credentials: "same-origin" });
44
- if (response.status === 401) {
45
- setLoggedIn(false);
46
- throw new Error("unauthorized");
47
- }
48
- if (!response.ok) {
49
- const payload = await response.json().catch(() => ({}));
50
- throw new Error(payload.error || response.statusText);
51
- }
52
- return response.json();
53
- }
54
-
55
- function setLoggedIn(loggedIn) {
56
- el.loginForm.hidden = loggedIn;
57
- el.logout.hidden = !loggedIn;
58
- if (!loggedIn) el.tokenInput.focus();
59
- }
60
-
61
- /** Remplace le contenu du panneau de détail (et retire l'état « vide » hérité du HTML). */
62
- function setDetail(child, empty) {
63
- el.detail.className = empty ? "empty" : "detail-content";
64
- el.detail.replaceChildren(child);
65
- }
66
-
67
- function node(tag, className, text) {
68
- const element = document.createElement(tag);
69
- if (className) element.className = className;
70
- if (text !== undefined) element.textContent = text;
71
- return element;
72
- }
73
-
74
- function formatDate(iso) {
75
- const date = new Date(iso);
76
- return Number.isNaN(date.getTime()) ? iso : date.toLocaleString();
77
- }
78
-
79
- /** Le titre suit l'id : le bandeau de filtre doit rester lisible, pas afficher un id nu. */
80
- function setThreadFilter(threadId, title) {
81
- state.threadId = threadId;
82
- state.threadTitle = title;
83
- }
84
-
85
- function renderAgents(overview) {
86
- el.serverStats.textContent =
87
- overview.messages + " message(s) · " + overview.threads + " fil(s) · " + overview.agents.length + " agent(s)";
88
- el.statusDot.className = "dot ok";
89
- el.agents.replaceChildren();
90
-
91
- for (const agent of overview.agents) {
92
- const item = node("li", state.agent === agent.id ? "selected" : "");
93
- // Deux noms, deux questions : l'agent dit d'où l'on écrit (le projet), la boîte de quoi
94
- // cette session s'occupe. L'un sans l'autre ne suffit pas à s'y retrouver.
95
- // L'adresse ne dit rien à l'utilisateur, elle reste là pour être copiée.
96
- item.appendChild(node("div", "name", agent.agent_name || "agent sans nom"));
97
- item.appendChild(node("div", "mailbox-name", agent.display_name || "boîte sans nom"));
98
- item.appendChild(node("div", "id", agent.id));
99
- item.appendChild(
100
- node("div", "muted", "envoyés " + agent.sent + " · reçus " + agent.received + " · non lus " + agent.unread)
101
- );
102
- // La liste est classée sur cette date : sans elle, l'ordre paraîtrait arbitraire.
103
- item.appendChild(
104
- node("div", "muted", agent.last_activity_at ? "activité " + formatDate(agent.last_activity_at) : "aucune activité")
105
- );
106
- if (!agent.registered) item.appendChild(node("div", "muted", "jamais connecté"));
107
- item.addEventListener("click", () => {
108
- state.agent = state.agent === agent.id ? null : agent.id;
109
- setThreadFilter(null, null);
110
- refresh();
111
- });
112
- el.agents.appendChild(item);
113
- }
114
- }
115
-
116
- function renderMessages(messages) {
117
- el.filterLabel.textContent = [
118
- state.agent ? "agent : " + state.agent : null,
119
- state.threadId ? "fil : " + (state.threadTitle || state.threadId) : null,
120
- ]
121
- .filter(Boolean)
122
- .join(" · ");
123
-
124
- el.messages.replaceChildren();
125
- if (messages.length === 0) {
126
- el.messages.appendChild(node("li", "empty", "Aucun message."));
127
- return;
128
- }
129
-
130
- for (const message of messages) {
131
- const item = node("li", state.selectedMessageId === message.message_id ? "selected" : "");
132
- const subject = node("div", "subject", message.subject);
133
- if (message.priority !== "normal") {
134
- const badge = node("span", "badge" + (message.priority === "high" ? " high" : ""), message.priority);
135
- subject.appendChild(badge);
136
- }
137
- item.appendChild(subject);
138
- // Le nom du fil dit de quelle conversation relève le message ; sans nom, rien à afficher.
139
- if (message.thread_title) item.appendChild(node("div", "thread-name", message.thread_title));
140
- item.appendChild(
141
- node("div", "route", message.from + " → " + (message.to || "—") + " · " + formatDate(message.created_at))
142
- );
143
- item.appendChild(node("div", "preview", message.preview));
144
- item.addEventListener("click", () => openMessage(message.message_id));
145
- el.messages.appendChild(item);
146
- }
147
- }
148
-
149
- async function openMessage(messageId) {
150
- state.selectedMessageId = messageId;
151
- try {
152
- const message = await api("/admin/messages/" + encodeURIComponent(messageId));
153
- // Le fil est chargé avec le message : déplier l'historique ne coûte pas d'aller-retour.
154
- // Son échec n'empêche pas de lire le message, il ne fait que priver de l'historique.
155
- const thread = await api("/admin/threads/" + encodeURIComponent(message.thread_id)).catch(() => null);
156
- renderDetail(message, thread);
157
- await refresh({ keepDetail: true });
158
- } catch (error) {
159
- setDetail(node("div", "error", error.message), false);
160
- }
161
- }
162
-
163
- /**
164
- * Les messages du fil antérieurs à celui-ci, du plus récent au plus ancien.
165
- *
166
- * Un message se lit comme un mail : ce qui l'a précédé l'explique, ce qui l'a suivi appartient
167
- * à la suite du fil et se lit dans la vue du fil complet.
168
- */
169
- function earlierMessages(message, thread) {
170
- const messages = (thread && thread.messages) || [];
171
- const index = messages.findIndex((m) => m.message_id === message.message_id);
172
- const earlier = index === -1 ? messages.filter((m) => m.created_at < message.created_at) : messages.slice(0, index);
173
- return earlier.slice().reverse();
174
- }
175
-
176
- /** Bouton d'ouverture et bloc cité : replié par défaut, comme le fait une boîte mail. */
177
- function renderHistory(container, earlier) {
178
- if (earlier.length === 0) return;
179
- const label = () =>
180
- (state.historyExpanded ? "Masquer" : "Afficher") + " les " + earlier.length + " message(s) précédent(s)";
181
- const toggle = node("button", "history-toggle", label());
182
- const history = node("div", "history");
183
- history.hidden = !state.historyExpanded;
184
-
185
- for (const previous of earlier) {
186
- const block = node("div", "quoted");
187
- block.appendChild(
188
- node("div", "quoted-head", previous.from + " → " + (previous.to || "—") + " · " + formatDate(previous.created_at))
189
- );
190
- block.appendChild(node("div", "quoted-subject", previous.subject));
191
- // `body` vient du fil complet ; l'aperçu ne sert que si une réponse ancienne n'en a pas.
192
- block.appendChild(node("div", "quoted-body", previous.body || previous.preview || ""));
193
- history.appendChild(block);
194
- }
195
-
196
- toggle.addEventListener("click", () => {
197
- state.historyExpanded = !state.historyExpanded;
198
- history.hidden = !state.historyExpanded;
199
- toggle.textContent = label();
200
- });
201
- container.appendChild(toggle);
202
- container.appendChild(history);
203
- }
204
-
205
- function renderDetail(message, thread) {
206
- const container = node("div", "detail");
207
- container.appendChild(node("h3", null, message.subject));
208
- container.appendChild(
209
- node(
210
- "div",
211
- "meta",
212
- "De " +
213
- message.from +
214
- " · À " +
215
- (message.to || "—") +
216
- "\n" +
217
- formatDate(message.created_at) +
218
- " · priorité " +
219
- message.priority +
220
- "\nid " +
221
- message.message_id +
222
- " · fil " +
223
- (message.thread_title ? message.thread_title + " (" + message.thread_id + ")" : message.thread_id)
224
- )
225
- );
226
-
227
- container.appendChild(node("div", "body", message.body));
228
-
229
- // Sous le corps, comme dans une boîte mail : d'abord ce qui a mené à ce message, replié ;
230
- // la vue chronologique du fil entier reste une action à part, elle ne se déplie pas ici.
231
- renderHistory(container, earlierMessages(message, thread));
232
-
233
- const threadButton = node("button", "thread-link", "Voir le fil complet");
234
- threadButton.addEventListener("click", () => showThread(message.thread_id));
235
- container.appendChild(threadButton);
236
-
237
- const deliveries = node("div", "deliveries");
238
- deliveries.appendChild(node("h2", null, "Distribution"));
239
- const list = node("ul");
240
- for (const delivery of message.deliveries) {
241
- list.appendChild(
242
- node(
243
- "li",
244
- null,
245
- delivery.agent_id +
246
- " · " +
247
- delivery.role +
248
- " · dossier " +
249
- delivery.folder +
250
- " · " +
251
- (delivery.read ? "lu" : "non lu")
252
- )
253
- );
254
- }
255
- deliveries.appendChild(list);
256
- container.appendChild(deliveries);
257
-
258
- setDetail(container, false);
259
- }
260
-
261
- async function showThread(threadId) {
262
- try {
263
- const data = await api("/admin/threads/" + encodeURIComponent(threadId));
264
- const container = node("div", "detail");
265
- const title = (data.thread && data.thread.title) || null;
266
- container.appendChild(node("h3", null, (title || "Fil " + threadId) + " — " + data.messages.length + " message(s)"));
267
- if (title) container.appendChild(node("div", "meta", "fil " + threadId));
268
- if (data.thread && data.thread.description) {
269
- container.appendChild(node("div", "thread-description", data.thread.description));
270
- }
271
-
272
- const filterButton = node("button", null, "Filtrer la liste sur ce fil");
273
- filterButton.addEventListener("click", () => {
274
- setThreadFilter(threadId, title);
275
- refresh({ keepDetail: true });
276
- });
277
- container.appendChild(filterButton);
278
-
279
- for (const message of data.messages) {
280
- const block = node("div", "thread-message");
281
- block.appendChild(node("div", "subject", message.subject));
282
- block.appendChild(
283
- node("div", "meta", message.from + " → " + (message.to || "—") + " · " + formatDate(message.created_at))
284
- );
285
- block.appendChild(node("div", "preview", message.preview));
286
- const openButton = node("button", "link", "ouvrir");
287
- openButton.addEventListener("click", () => openMessage(message.message_id));
288
- block.appendChild(openButton);
289
- container.appendChild(block);
290
- }
291
- setDetail(container, false);
292
- } catch (error) {
293
- setDetail(node("div", "error", error.message), false);
294
- }
295
- }
296
-
297
- async function refresh(options) {
298
- try {
299
- const [overview, list] = await Promise.all([
300
- api("/admin/overview"),
301
- api("/admin/messages", { agent: state.agent, search: state.search, thread_id: state.threadId, limit: 100 }),
302
- ]);
303
- setLoggedIn(true);
304
- renderAgents(overview);
305
- renderMessages(list.messages);
306
- if (!options || !options.keepDetail) {
307
- if (!state.selectedMessageId) {
308
- setDetail(node("div", null, "Sélectionnez un message pour voir son contenu."), true);
309
- }
310
- }
311
- } catch (error) {
312
- el.statusDot.className = "dot ko";
313
- if (error.message === "unauthorized") {
314
- el.serverStats.textContent = "authentification requise";
315
- } else {
316
- el.serverStats.textContent = "erreur : " + error.message;
317
- }
318
- }
319
- }
320
-
321
- el.refresh.addEventListener("click", () => refresh({ keepDetail: true }));
322
- el.clearFilters.addEventListener("click", () => {
323
- state.agent = null;
324
- setThreadFilter(null, null);
325
- state.search = "";
326
- el.search.value = "";
327
- refresh({ keepDetail: true });
328
- });
329
-
330
- let searchTimer = null;
331
- el.search.addEventListener("input", (event) => {
332
- state.search = event.target.value.trim();
333
- clearTimeout(searchTimer);
334
- searchTimer = setTimeout(() => refresh({ keepDetail: true }), 250);
335
- });
336
-
337
- el.loginForm.addEventListener("submit", async (event) => {
338
- event.preventDefault();
339
- el.tokenError.textContent = "";
340
- const token = el.tokenInput.value.trim();
341
- if (!token) return;
342
- const response = await fetch("/ui/login", {
343
- method: "POST",
344
- headers: { "content-type": "application/json" },
345
- credentials: "same-origin",
346
- body: JSON.stringify({ token }),
347
- }).catch(() => null);
348
- // Le champ est vidé dans tous les cas : le jeton ne reste pas affiché dans la page.
349
- el.tokenInput.value = "";
350
- if (!response || !response.ok) {
351
- el.tokenError.textContent = response && response.status === 429 ? "trop de tentatives, réessayez plus tard" : "jeton refusé";
352
- return;
353
- }
354
- refresh();
355
- });
356
-
357
- el.logout.addEventListener("click", async () => {
358
- await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
359
- setLoggedIn(false);
360
- el.statusDot.className = "dot";
361
- el.serverStats.textContent = "déconnecté";
362
- el.agents.replaceChildren();
363
- el.messages.replaceChildren();
364
- state.selectedMessageId = null;
365
- setDetail(node("div", null, "Sélectionnez un message pour voir son contenu."), true);
366
- });
367
-
368
- setInterval(() => {
369
- if (el.autoRefresh.checked && el.loginForm.hidden) refresh({ keepDetail: true });
370
- }, REFRESH_MS);
371
-
372
- refresh();
1
+ // Console de supervision : lecture seule sur les routes /admin du serveur.
2
+ //
3
+ // Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement,
4
+ // jamais par innerHTML. Les seuls fragments de balisage construits ici sont les icônes,
5
+ // écrites en dur dans ce fichier.
6
+ //
7
+ // L'authentification tient dans un cookie de session HttpOnly posé par `/ui/login` :
8
+ // ce script ne voit jamais le jeton et n'écrit rien dans le navigateur.
9
+
10
+ const REFRESH_MS = 5000;
11
+
12
+ // Une adresse ne dit rien à qui lit la console : elle sert de graine à la couleur de
13
+ // l'avatar, pour que la même boîte garde la même teinte d'un écran à l'autre.
14
+ const AVATAR_COLORS = 8;
15
+
16
+ const ICONS = {
17
+ copy: ["M9 3h9a2 2 0 0 1 2 2v9", "M5 7h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z"],
18
+ check: ["M20 6 9 17l-5-5"],
19
+ clock: ["M12 6v6l4 2", "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z"],
20
+ close: ["M18 6 6 18", "m6 6 12 12"],
21
+ link: ["M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1", "M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"],
22
+ chevron: ["m9 18 6-6-6-6"],
23
+ };
24
+
25
+ const state = {
26
+ /**
27
+ * Ce sur quoi la liste des fils est filtrée.
28
+ *
29
+ * `{ kind: "all" }` ne filtre rien ; `{ kind: "agent", key }` retient toutes les boîtes
30
+ * d'un projet ; `{ kind: "box", key }` une seule session. Deux niveaux, parce qu'un
31
+ * agent tient une boîte par session ouverte et que les deux questions se posent.
32
+ */
33
+ selection: { kind: "all", key: null },
34
+ /** Noms d'agents dépliés dans la colonne de gauche. Tout est replié au départ. */
35
+ expanded: new Set(),
36
+ /** "all" | "unread" | "high" — les puces au-dessus de la liste des fils. */
37
+ filter: "all",
38
+ search: "",
39
+ threadId: null,
40
+ /** Message dont le volet de détail est ouvert, ou null si le volet est fermé. */
41
+ messageId: null,
42
+ collapsed: false,
43
+ /** Annuaire des boîtes, indexé par adresse : rempli par chaque `/admin/overview`. */
44
+ directory: new Map(),
45
+ /** Boîtes regroupées par nom d'agent, dans l'ordre de l'annuaire. */
46
+ groups: [],
47
+ };
48
+
49
+ const el = {
50
+ status: document.getElementById("status"),
51
+ statusLabel: document.getElementById("status-label"),
52
+ searchBox: document.getElementById("search-box"),
53
+ search: document.getElementById("search"),
54
+ controls: document.getElementById("controls"),
55
+ autoRefresh: document.getElementById("auto-refresh"),
56
+ refresh: document.getElementById("refresh"),
57
+ logout: document.getElementById("logout"),
58
+ login: document.getElementById("login"),
59
+ loginForm: document.getElementById("login-form"),
60
+ loginOrigin: document.getElementById("login-origin"),
61
+ tokenField: document.querySelector("#login-form .field"),
62
+ token: document.getElementById("token"),
63
+ tokenReveal: document.getElementById("token-reveal"),
64
+ tokenError: document.getElementById("token-error"),
65
+ console: document.getElementById("console"),
66
+ agentsPane: document.getElementById("agents-pane"),
67
+ agents: document.getElementById("agents"),
68
+ agentsFoot: document.getElementById("agents-foot"),
69
+ collapse: document.getElementById("collapse"),
70
+ threadsScope: document.getElementById("threads-scope"),
71
+ chips: document.getElementById("chips"),
72
+ threads: document.getElementById("threads"),
73
+ reader: document.getElementById("reader"),
74
+ sheet: document.getElementById("sheet"),
75
+ };
76
+
77
+ // --- Fabrique de noeuds ----------------------------------------------------
78
+
79
+ function node(tag, className, text) {
80
+ const element = document.createElement(tag);
81
+ if (className) element.className = className;
82
+ if (text !== undefined) element.textContent = text;
83
+ return element;
84
+ }
85
+
86
+ function icon(name) {
87
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
88
+ svg.setAttribute("class", "icon");
89
+ svg.setAttribute("viewBox", "0 0 24 24");
90
+ svg.setAttribute("aria-hidden", "true");
91
+ for (const d of ICONS[name]) {
92
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
93
+ path.setAttribute("d", d);
94
+ svg.appendChild(path);
95
+ }
96
+ return svg;
97
+ }
98
+
99
+ function button(className, label, onClick) {
100
+ const element = node("button", className, label);
101
+ element.type = "button";
102
+ element.addEventListener("click", onClick);
103
+ return element;
104
+ }
105
+
106
+ /** Bouton de copie : les adresses et les identifiants ne se recopient pas à la main. */
107
+ function copyButton(value, label) {
108
+ const element = node("button", "copy");
109
+ element.type = "button";
110
+ element.title = label;
111
+ element.setAttribute("aria-label", label);
112
+ element.appendChild(icon("copy"));
113
+ element.addEventListener("click", async (event) => {
114
+ event.stopPropagation();
115
+ try {
116
+ await navigator.clipboard.writeText(value);
117
+ element.replaceChildren(icon("check"));
118
+ setTimeout(() => element.replaceChildren(icon("copy")), 1200);
119
+ } catch {
120
+ // Presse-papiers refusé (contexte non sécurisé, permission) : la valeur reste
121
+ // affichée à côté, donc sélectionnable. Rien d'autre à signaler.
122
+ }
123
+ });
124
+ return element;
125
+ }
126
+
127
+ function tag(className, label, iconName) {
128
+ const element = node("span", className);
129
+ if (iconName) element.appendChild(icon(iconName));
130
+ element.appendChild(document.createTextNode(label));
131
+ return element;
132
+ }
133
+
134
+ // --- Annuaire des boîtes ---------------------------------------------------
135
+
136
+ /**
137
+ * Deux noms par boîte, deux questions : l'agent dit d'où l'on écrit (le projet), la boîte
138
+ * de quoi cette session s'occupe. La console montre les deux, et n'affiche l'adresse que
139
+ * là où elle sert à être copiée.
140
+ *
141
+ * Un nom d'agent ne désigne personne : plusieurs sessions du même projet le portent. Dès
142
+ * qu'il est partagé, `name` lui accole le nom de la boîte — sans quoi un fil afficherait
143
+ * « Documentor Dev → Documentor Dev » sans dire de quelles sessions il parle.
144
+ */
145
+ function party(agentId) {
146
+ const known = state.directory.get(agentId);
147
+ const agentName = (known && known.agent_name) || null;
148
+ const boxName = (known && known.display_name) || null;
149
+ const shared = agentName ? state.groups.some((g) => g.key === agentName && g.boxes.length > 1) : false;
150
+ // Parenthèses, et non un séparateur plat : les participants d'un fil sont eux-mêmes
151
+ // listés avec un point médian, qui ne doit pas se confondre avec cette précision.
152
+ const name = agentName
153
+ ? shared
154
+ ? `${agentName} (${boxName || shortAddress(agentId)})`
155
+ : agentName
156
+ : shortAddress(agentId);
157
+ return {
158
+ id: agentId,
159
+ name,
160
+ agentName,
161
+ box: boxName,
162
+ registered: !known || known.registered,
163
+ unread: (known && known.unread) || 0,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Nom de la boîte, ou son adresse à défaut.
169
+ *
170
+ * Une boîte n'est nommée que si l'agent l'a fait lui-même (`rename_mailbox`) ; tant qu'il
171
+ * ne l'a pas fait, seule l'adresse la distingue de ses sœurs, et c'est donc elle qu'on lit.
172
+ */
173
+ function boxLabel(box) {
174
+ return box.display_name || box.id;
175
+ }
176
+
177
+ function shortAddress(agentId) {
178
+ const id = String(agentId || "");
179
+ return id.length > 12 ? `${id.slice(0, 10)}…` : id || "—";
180
+ }
181
+
182
+ function initials(label) {
183
+ const words = String(label).split(/[\s_·-]+/).filter(Boolean);
184
+ const letters = words.length >= 2 ? words[0][0] + words[1][0] : String(label).slice(0, 2);
185
+ return letters.toUpperCase();
186
+ }
187
+
188
+ /** Teinte stable : la même graine donne toujours la même couleur d'avatar. */
189
+ function colorIndex(seed) {
190
+ let hash = 0;
191
+ for (const char of String(seed)) hash = (hash * 31 + char.charCodeAt(0)) % 100003;
192
+ return hash % AVATAR_COLORS;
193
+ }
194
+
195
+ /** Pastille d'initiales. `seed` teinte, `label` écrit, `title` explique au survol. */
196
+ function avatarFor(seed, label, title, extraClass, absent) {
197
+ const classes = ["avatar"];
198
+ if (extraClass) classes.push(extraClass);
199
+ classes.push(absent ? "avatar--absent" : `avatar--c${colorIndex(seed)}`);
200
+ const element = node("span", classes.join(" "), initials(label));
201
+ element.title = title;
202
+ return element;
203
+ }
204
+
205
+ function avatar(agentId, extraClass) {
206
+ const who = party(agentId);
207
+ const label = who.box || who.agentName || shortAddress(agentId);
208
+ const title = [who.agentName, who.box, agentId].filter(Boolean).join(" — ");
209
+ return avatarFor(agentId, label, title, extraClass, !who.registered);
210
+ }
211
+
212
+ // --- Dates -----------------------------------------------------------------
213
+
214
+ function formatDate(iso) {
215
+ const date = new Date(iso);
216
+ return Number.isNaN(date.getTime()) ? String(iso) : date.toLocaleString();
217
+ }
218
+
219
+ /** Heure seule aujourd'hui, « hier » la veille, date courte au-delà : la liste doit se survoler. */
220
+ function formatShort(iso) {
221
+ const date = new Date(iso);
222
+ if (Number.isNaN(date.getTime())) return String(iso);
223
+ const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
224
+ const day = new Date(date.getFullYear(), date.getMonth(), date.getDate());
225
+ const today = new Date();
226
+ const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
227
+ const days = Math.round((midnight - day) / 86400000);
228
+ if (days === 0) return time;
229
+ if (days === 1) return `hier ${time}`;
230
+ return date.toLocaleDateString([], { day: "2-digit", month: "2-digit" });
231
+ }
232
+
233
+ function plural(count, singular, pluralForm) {
234
+ return `${count} ${count > 1 ? pluralForm || `${singular}s` : singular}`;
235
+ }
236
+
237
+ // --- Accès au serveur ------------------------------------------------------
238
+
239
+ async function api(path, params) {
240
+ const url = new URL(path, window.location.origin);
241
+ for (const [key, value] of Object.entries(params || {})) {
242
+ if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, value);
243
+ }
244
+ const response = await fetch(url, { headers: { accept: "application/json" }, credentials: "same-origin" });
245
+ if (response.status === 401) {
246
+ setLoggedIn(false);
247
+ throw new Error("unauthorized");
248
+ }
249
+ if (!response.ok) {
250
+ const payload = await response.json().catch(() => ({}));
251
+ throw new Error(payload.error || response.statusText);
252
+ }
253
+ return response.json();
254
+ }
255
+
256
+ function setLoggedIn(loggedIn) {
257
+ el.login.hidden = loggedIn;
258
+ el.console.hidden = !loggedIn;
259
+ el.controls.hidden = !loggedIn;
260
+ el.searchBox.hidden = !loggedIn;
261
+ if (!loggedIn) {
262
+ setStatus("idle", "non connecté");
263
+ el.token.focus();
264
+ }
265
+ }
266
+
267
+ function setStatus(kind, label) {
268
+ el.status.className = `pill pill--${kind}`;
269
+ el.statusLabel.textContent = label;
270
+ }
271
+
272
+ // --- Colonne des agents ----------------------------------------------------
273
+
274
+ /**
275
+ * Regroupe les boîtes sous leur nom d'agent.
276
+ *
277
+ * L'agent dit d'où l'on écrit, la boîte quelle session parle : un projet qui a deux
278
+ * sessions ouvertes tient deux boîtes, et la colonne doit montrer cette imbrication plutôt
279
+ * que d'aligner des boîtes qui portent toutes le même nom. Une boîte sans nom d'agent est
280
+ * son propre groupe : rien ne permet de la rattacher à un projet.
281
+ *
282
+ * L'annuaire arrive déjà classé par activité : le groupe hérite du rang de sa boîte la
283
+ * plus récente, sans nouveau tri.
284
+ */
285
+ function groupByAgent(boxes) {
286
+ const groups = new Map();
287
+ for (const box of boxes) {
288
+ const key = box.agent_name || box.id;
289
+ if (!groups.has(key)) {
290
+ groups.set(key, { key, name: box.agent_name || boxLabel(box), boxes: [], unread: 0 });
291
+ }
292
+ const group = groups.get(key);
293
+ group.boxes.push(box);
294
+ group.unread += box.unread;
295
+ }
296
+ return [...groups.values()];
297
+ }
298
+
299
+ function isSelected(kind, key) {
300
+ return state.selection.kind === kind && state.selection.key === key;
301
+ }
302
+
303
+ function select(kind, key) {
304
+ // Recliquer sur la sélection courante la relâche : le filtre ne s'emprisonne pas.
305
+ state.selection = isSelected(kind, key) ? { kind: "all", key: null } : { kind, key };
306
+ state.threadId = null;
307
+ state.messageId = null;
308
+ el.sheet.hidden = true;
309
+ el.console.classList.remove("has-sheet");
310
+ refresh();
311
+ }
312
+
313
+ function renderAgents(overview) {
314
+ state.directory = new Map(overview.agents.map((box) => [box.id, box]));
315
+ state.groups = groupByAgent(overview.agents);
316
+ el.agentsFoot.textContent = [
317
+ plural(state.groups.length, "agent"),
318
+ plural(overview.agents.length, "boîte"),
319
+ plural(overview.threads, "fil"),
320
+ ].join(" · ");
321
+
322
+ el.agents.replaceChildren();
323
+ el.agents.appendChild(allRow(overview));
324
+ for (const group of state.groups) el.agents.appendChild(groupRow(group));
325
+ }
326
+
327
+ function allRow(overview) {
328
+ const item = node("li");
329
+ const unread = overview.agents.reduce((total, box) => total + box.unread, 0);
330
+ const row = node("button", `agent${isSelected("all", null) ? " is-selected" : ""}`);
331
+ row.type = "button";
332
+
333
+ const star = node("span", "avatar avatar--absent", "∗");
334
+ star.title = "Tous les agents";
335
+ row.appendChild(star);
336
+
337
+ const text = node("div", "agent-text");
338
+ text.appendChild(node("div", "agent-name", "Tous les agents"));
339
+ text.appendChild(node("div", "agent-box", plural(overview.threads, "fil")));
340
+ row.appendChild(text);
341
+ if (unread > 0) row.appendChild(node("span", "count count--accent", String(unread)));
342
+
343
+ row.addEventListener("click", () => select("all", null));
344
+ item.appendChild(row);
345
+ return item;
346
+ }
347
+
348
+ /**
349
+ * Un agent et, dépliées sous lui, ses boîtes.
350
+ *
351
+ * Deux zones de clic, deux gestes distincts : le chevron ouvre ou ferme, le nom filtre sur
352
+ * l'ensemble des sessions du projet. Filtrer déplie au passage, pour que la sélection
353
+ * montre toujours ce qu'elle recouvre.
354
+ */
355
+ function groupRow(group) {
356
+ const item = node("li", "agent-group");
357
+ const open = state.expanded.has(group.key);
358
+
359
+ const header = node("div", `agent-head${open ? " is-open" : ""}`);
360
+
361
+ const toggle = node("button", "agent-toggle");
362
+ toggle.type = "button";
363
+ toggle.setAttribute("aria-expanded", open ? "true" : "false");
364
+ toggle.setAttribute("aria-label", `${open ? "Replier" : "Déplier"} les boîtes de ${group.name}`);
365
+ toggle.appendChild(icon("chevron"));
366
+ toggle.addEventListener("click", () => {
367
+ if (open) state.expanded.delete(group.key);
368
+ else state.expanded.add(group.key);
369
+ renderAgents({ agents: [...state.directory.values()], threads: currentThreadCount() });
370
+ });
371
+ header.appendChild(toggle);
372
+
373
+ const classes = ["agent"];
374
+ if (isSelected("agent", group.key)) classes.push("is-selected");
375
+ if (group.unread > 0) classes.push("has-unread");
376
+ if (group.boxes.every((box) => !box.registered)) classes.push("agent--muted");
377
+ const row = node("button", classes.join(" "));
378
+ row.type = "button";
379
+ row.appendChild(
380
+ avatarFor(
381
+ group.key,
382
+ group.name,
383
+ group.name,
384
+ null,
385
+ group.boxes.every((box) => !box.registered)
386
+ )
387
+ );
388
+
389
+ const text = node("div", "agent-text");
390
+ text.appendChild(node("div", "agent-name", group.name));
391
+ text.appendChild(node("div", "agent-box", plural(group.boxes.length, "boîte")));
392
+ row.appendChild(text);
393
+ if (group.unread > 0) row.appendChild(node("span", "count count--accent", String(group.unread)));
394
+ row.title = `${group.name} · ${plural(group.boxes.length, "boîte")}`;
395
+ row.addEventListener("click", () => {
396
+ state.expanded.add(group.key);
397
+ select("agent", group.key);
398
+ });
399
+ header.appendChild(row);
400
+ item.appendChild(header);
401
+
402
+ if (open) {
403
+ const list = node("ul", "box-list");
404
+ for (const box of group.boxes) list.appendChild(boxRow(box));
405
+ item.appendChild(list);
406
+ }
407
+ return item;
408
+ }
409
+
410
+ function boxRow(box) {
411
+ const item = node("li");
412
+ const classes = ["agent", "box"];
413
+ if (isSelected("box", box.id)) classes.push("is-selected");
414
+ if (box.unread > 0) classes.push("has-unread");
415
+ if (!box.registered) classes.push("agent--muted");
416
+ const row = node("button", classes.join(" "));
417
+ row.type = "button";
418
+
419
+ const label = boxLabel(box);
420
+ row.appendChild(avatarFor(box.id, label, box.id, "avatar--sm", !box.registered));
421
+
422
+ const text = node("div", "agent-text");
423
+ // Sans nom donné par l'agent, l'adresse tient lieu de nom : elle est alors la seule
424
+ // chose qui distingue cette session de ses sœurs.
425
+ const name = node("div", "agent-name", label);
426
+ if (!box.display_name) name.classList.add("mono", "is-absent");
427
+ text.appendChild(name);
428
+ text.appendChild(
429
+ node(
430
+ "div",
431
+ "agent-box",
432
+ box.registered ? `${plural(box.sent, "envoyé")} · ${plural(box.received, "reçu")}` : "jamais connectée"
433
+ )
434
+ );
435
+ row.appendChild(text);
436
+
437
+ if (box.unread > 0) row.appendChild(node("span", "count count--accent", String(box.unread)));
438
+ row.title = box.id;
439
+ row.addEventListener("click", () => select("box", box.id));
440
+ item.appendChild(row);
441
+ return item;
442
+ }
443
+
444
+ /** Nombre de fils affiché en pied de colonne, conservé entre deux rendus locaux. */
445
+ function currentThreadCount() {
446
+ return state.threadCount || 0;
447
+ }
448
+
449
+ /** Adresses que la sélection courante recouvre, ou null quand elle ne filtre rien. */
450
+ function selectedAddresses() {
451
+ if (state.selection.kind === "box") return [state.selection.key];
452
+ if (state.selection.kind === "agent") {
453
+ const group = state.groups.find((g) => g.key === state.selection.key);
454
+ return group ? group.boxes.map((box) => box.id) : [];
455
+ }
456
+ return null;
457
+ }
458
+
459
+ function selectionLabel() {
460
+ if (state.selection.kind === "box") {
461
+ const box = state.directory.get(state.selection.key);
462
+ return box ? boxLabel(box) : shortAddress(state.selection.key);
463
+ }
464
+ if (state.selection.kind === "agent") return state.selection.key;
465
+ return "Toutes les boîtes";
466
+ }
467
+
468
+ // --- Colonne des fils ------------------------------------------------------
469
+
470
+ function renderThreads(threads) {
471
+ state.threadCount = threads.length;
472
+ el.threadsScope.textContent = `${selectionLabel()} · ${plural(threads.length, "fil")}`;
473
+
474
+ el.threads.replaceChildren();
475
+ if (threads.length === 0) {
476
+ const empty = node("li", "empty");
477
+ empty.appendChild(node("span", "empty-title", "Aucun fil"));
478
+ empty.appendChild(document.createTextNode(emptyReason()));
479
+ el.threads.appendChild(empty);
480
+ return;
481
+ }
482
+ for (const thread of threads) el.threads.appendChild(threadRow(thread));
483
+ }
484
+
485
+ function emptyReason() {
486
+ if (state.search) return "Aucun fil ne correspond à cette recherche.";
487
+ if (state.filter === "unread") return "Tout est lu de ce côté.";
488
+ if (state.filter === "high") return "Aucun message en priorité haute.";
489
+ if (state.selection.kind === "box") return "Cette boîte n'a encore échangé avec personne.";
490
+ if (state.selection.kind === "agent") return "Aucune session de cet agent n'a encore échangé.";
491
+ return "Aucun agent n'a encore écrit sur ce serveur.";
492
+ }
493
+
494
+ function threadRow(thread) {
495
+ const item = node("li");
496
+ const classes = ["thread"];
497
+ if (state.threadId === thread.thread_id) classes.push("is-selected");
498
+ if (thread.unread > 0) classes.push("is-unread");
499
+ const row = node("button", classes.join(" "));
500
+ row.type = "button";
501
+
502
+ const top = node("div", "thread-top");
503
+ // Le titre du fil prime ; tant que personne ne l'a nommé, le sujet d'ouverture en tient lieu.
504
+ top.appendChild(node("span", "thread-title", thread.title || thread.subject));
505
+ if (thread.priority === "high") top.appendChild(tag("tag tag--high", "haute"));
506
+ top.appendChild(node("span", "thread-time", formatShort(thread.last_message_at)));
507
+ row.appendChild(top);
508
+
509
+ row.appendChild(node("div", "thread-who", thread.participants.map((id) => party(id).name).join(" · ")));
510
+
511
+ const bottom = node("div", "thread-bottom");
512
+ bottom.appendChild(node("span", "thread-preview", thread.last_message_preview));
513
+ const count = node("span", thread.unread > 0 ? "count count--accent" : "count", String(thread.messages));
514
+ count.title = thread.unread > 0 ? `${plural(thread.messages, "message")}, ${thread.unread} non lu(s)` : plural(thread.messages, "message");
515
+ bottom.appendChild(count);
516
+ row.appendChild(bottom);
517
+
518
+ row.addEventListener("click", () => openThread(thread.thread_id));
519
+ item.appendChild(row);
520
+ return item;
521
+ }
522
+
523
+ // --- Lecteur du fil --------------------------------------------------------
524
+
525
+ function renderEmptyReader() {
526
+ const empty = node("div", "empty");
527
+ empty.appendChild(node("span", "empty-title", "Aucun fil ouvert"));
528
+ empty.appendChild(document.createTextNode("Choisissez un fil pour lire la conversation."));
529
+ el.reader.replaceChildren(empty);
530
+ }
531
+
532
+ function renderThread(data) {
533
+ const thread = data.thread || {};
534
+ const messages = data.messages || [];
535
+ const container = document.createDocumentFragment();
536
+
537
+ const head = node("div", "reader-head");
538
+ const opening = messages.length > 0 ? messages[0].subject : "";
539
+ head.appendChild(node("h2", null, thread.title || opening || data.thread_id));
540
+ if (thread.description) head.appendChild(node("p", "reader-desc", thread.description));
541
+
542
+ const participants = [];
543
+ for (const message of messages) {
544
+ for (const id of [message.from, message.to]) {
545
+ if (id && !participants.includes(id)) participants.push(id);
546
+ }
547
+ }
548
+
549
+ const meta = node("div", "reader-meta");
550
+ const stack = node("div", "avatar-stack");
551
+ for (const id of participants) stack.appendChild(avatar(id, "avatar--sm avatar--stack"));
552
+ meta.appendChild(stack);
553
+ meta.appendChild(
554
+ node(
555
+ "span",
556
+ "reader-participants",
557
+ `${participants.map((id) => party(id).name).join(", ")} · ${plural(messages.length, "message")}`
558
+ )
559
+ );
560
+ const threadId = node("span", "mono muted", data.thread_id);
561
+ meta.appendChild(threadId);
562
+ meta.appendChild(copyButton(data.thread_id, "Copier l'identifiant du fil"));
563
+ head.appendChild(meta);
564
+ container.appendChild(head);
565
+
566
+ const list = node("ul", "messages");
567
+ messages.forEach((message, index) => {
568
+ list.appendChild(messageRow(message, index === messages.length - 1));
569
+ });
570
+ container.appendChild(list);
571
+
572
+ el.reader.replaceChildren(container);
573
+ }
574
+
575
+ function messageRow(message, isLast) {
576
+ const item = node("li");
577
+ const classes = ["message"];
578
+ if (state.messageId === message.message_id) classes.push("is-open");
579
+ else if (isLast) classes.push("is-last");
580
+ const row = node("div", classes.join(" "));
581
+
582
+ row.appendChild(avatar(message.from));
583
+
584
+ const column = node("div", "message-body-col");
585
+ const head = node("div", "message-head");
586
+ head.appendChild(node("span", "message-from", party(message.from).name));
587
+ head.appendChild(node("span", "message-to", `→ ${party(message.to).name}`));
588
+ if (message.priority === "high") head.appendChild(tag("tag tag--high", "priorité haute"));
589
+ if (message.priority === "low") head.appendChild(tag("tag", "priorité basse"));
590
+ head.appendChild(node("span", "message-time", formatShort(message.created_at)));
591
+ column.appendChild(head);
592
+
593
+ // Le sujet n'est répété que s'il diffère du nom du fil : sinon c'est du bruit.
594
+ if (message.subject && message.subject !== message.thread_title) {
595
+ column.appendChild(node("p", "message-subject", message.subject));
596
+ }
597
+ column.appendChild(node("p", "message-text", message.body || message.preview || ""));
598
+
599
+ const foot = node("div", "message-foot");
600
+ foot.appendChild(
601
+ message.read
602
+ ? tag("tag tag--ok", `lu par ${party(message.to).name}`, "check")
603
+ : tag("tag tag--warn", `non lu par ${party(message.to).name}`, "clock")
604
+ );
605
+ const open = state.messageId === message.message_id;
606
+ foot.appendChild(
607
+ button("btn btn--link", open ? "détails ouverts" : "détails", () => {
608
+ if (open) closeSheet();
609
+ else openMessage(message.message_id);
610
+ })
611
+ );
612
+ column.appendChild(foot);
613
+
614
+ row.appendChild(column);
615
+ item.appendChild(row);
616
+ return item;
617
+ }
618
+
619
+ // --- Volet de détail -------------------------------------------------------
620
+
621
+ function renderSheet(message) {
622
+ const container = document.createDocumentFragment();
623
+
624
+ const head = node("div", "sheet-head");
625
+ const titles = node("div");
626
+ titles.appendChild(node("h3", null, "Détails du message"));
627
+ titles.appendChild(node("p", "sheet-subject", message.subject));
628
+ head.appendChild(titles);
629
+ const close = node("button", "btn btn--icon btn--ghost");
630
+ close.type = "button";
631
+ close.title = "Fermer";
632
+ close.setAttribute("aria-label", "Fermer le volet");
633
+ close.appendChild(icon("close"));
634
+ close.addEventListener("click", closeSheet);
635
+ head.appendChild(close);
636
+ container.appendChild(head);
637
+
638
+ const ids = node("div", "sheet-section");
639
+ ids.appendChild(node("span", "section-label", "Message"));
640
+ ids.appendChild(kvCopy("Identifiant", message.message_id));
641
+ ids.appendChild(kvCopy("Fil", message.thread_id));
642
+ ids.appendChild(kv("Envoyé le", node("span", "kv-value", formatDate(message.created_at))));
643
+ ids.appendChild(kv("Priorité", priorityTag(message.priority)));
644
+ container.appendChild(ids);
645
+
646
+ container.appendChild(partySection("De", message.from));
647
+ container.appendChild(partySection("À", message.to));
648
+
649
+ const deliveries = node("div", "sheet-section");
650
+ deliveries.appendChild(node("span", "section-label", "Distribution"));
651
+ deliveries.appendChild(deliveriesTable(message.deliveries || []));
652
+ container.appendChild(deliveries);
653
+
654
+ el.sheet.replaceChildren(container);
655
+ el.sheet.hidden = false;
656
+ el.console.classList.add("has-sheet");
657
+ }
658
+
659
+ function kv(key, valueNode) {
660
+ const row = node("div", "kv");
661
+ row.appendChild(node("span", "kv-key", key));
662
+ row.appendChild(valueNode);
663
+ row.appendChild(node("span"));
664
+ return row;
665
+ }
666
+
667
+ function kvCopy(key, value) {
668
+ const row = node("div", "kv");
669
+ row.appendChild(node("span", "kv-key", key));
670
+ row.appendChild(node("span", "kv-value mono", value));
671
+ row.appendChild(copyButton(value, `Copier ${key.toLowerCase()}`));
672
+ return row;
673
+ }
674
+
675
+ function priorityTag(priority) {
676
+ const labels = { high: "haute", normal: "normale", low: "basse" };
677
+ return tag(priority === "high" ? "tag tag--high" : "tag", labels[priority] || priority);
678
+ }
679
+
680
+ /**
681
+ * Une boîte, telle qu'elle doit se lire : le nom de l'agent, ce dont la session s'occupe,
682
+ * et seulement ensuite l'adresse — la seule chose qui identifie vraiment la session.
683
+ */
684
+ function partySection(label, agentId) {
685
+ const who = party(agentId);
686
+ const section = node("div", "sheet-section");
687
+ section.appendChild(node("span", "section-label", label));
688
+
689
+ const block = node("div", "party");
690
+ block.appendChild(avatar(agentId, "avatar--sm"));
691
+
692
+ const text = node("div", "agent-text");
693
+ text.appendChild(node("div", "party-name", who.name));
694
+ text.appendChild(node("div", "party-box", who.box || (who.registered ? "boîte sans nom" : "jamais connectée")));
695
+ const addr = node("div", "party-addr");
696
+ addr.appendChild(node("span", "mono", agentId));
697
+ addr.appendChild(copyButton(agentId, "Copier l'adresse"));
698
+ text.appendChild(addr);
699
+
700
+ block.appendChild(text);
701
+ section.appendChild(block);
702
+ return section;
703
+ }
704
+
705
+ function deliveriesTable(deliveries) {
706
+ const table = node("table", "deliveries");
707
+ const head = node("tr");
708
+ for (const label of ["Agent", "Rôle", "Dossier", "État"]) head.appendChild(node("th", null, label));
709
+ table.appendChild(head);
710
+
711
+ for (const delivery of deliveries) {
712
+ const row = node("tr");
713
+ const who = node("td");
714
+ who.appendChild(document.createTextNode(party(delivery.agent_id).name));
715
+ row.appendChild(who);
716
+ row.appendChild(node("td", "role", delivery.role === "sender" ? "expéditeur" : "destinataire"));
717
+ row.appendChild(node("td", "folder", delivery.folder));
718
+ const stateCell = node("td");
719
+ stateCell.appendChild(
720
+ delivery.read ? tag("tag tag--ok", "lu") : tag("tag tag--warn", delivery.role === "sender" ? "—" : "non lu")
721
+ );
722
+ row.appendChild(stateCell);
723
+ table.appendChild(row);
724
+ }
725
+ return table;
726
+ }
727
+
728
+ function closeSheet() {
729
+ state.messageId = null;
730
+ el.sheet.hidden = true;
731
+ el.sheet.replaceChildren();
732
+ el.console.classList.remove("has-sheet");
733
+ if (state.threadId) refreshThread();
734
+ }
735
+
736
+ // --- Navigation ------------------------------------------------------------
737
+
738
+ async function openThread(threadId) {
739
+ state.threadId = threadId;
740
+ state.messageId = null;
741
+ el.sheet.hidden = true;
742
+ el.console.classList.remove("has-sheet");
743
+ await refreshThread();
744
+ await refresh({ keepThread: true });
745
+ }
746
+
747
+ async function refreshThread() {
748
+ if (!state.threadId) {
749
+ renderEmptyReader();
750
+ return;
751
+ }
752
+ try {
753
+ renderThread(await api(`/admin/threads/${encodeURIComponent(state.threadId)}`));
754
+ } catch (error) {
755
+ if (error.message !== "unauthorized") el.reader.replaceChildren(node("div", "banner", error.message));
756
+ }
757
+ }
758
+
759
+ async function openMessage(messageId) {
760
+ state.messageId = messageId;
761
+ try {
762
+ renderSheet(await api(`/admin/messages/${encodeURIComponent(messageId)}`));
763
+ await refreshThread();
764
+ } catch (error) {
765
+ if (error.message !== "unauthorized") el.sheet.replaceChildren(node("div", "banner", error.message));
766
+ }
767
+ }
768
+
769
+ // --- Boucle de rafraîchissement --------------------------------------------
770
+
771
+ async function refresh(options) {
772
+ try {
773
+ const [overview, list] = await Promise.all([
774
+ api("/admin/overview"),
775
+ api("/admin/threads", {
776
+ // Une sélection d'agent recouvre toutes ses sessions : la route prend la liste.
777
+ agent: (selectedAddresses() || []).join(","),
778
+ search: state.search,
779
+ unread_only: state.filter === "unread" ? "true" : undefined,
780
+ priority: state.filter === "high" ? "high" : undefined,
781
+ limit: 100,
782
+ }),
783
+ ]);
784
+ setLoggedIn(true);
785
+ setStatus("ok", `connecté · ${window.location.host}`);
786
+ renderAgents(overview);
787
+ renderThreads(list.threads);
788
+ if (!options || !options.keepThread) {
789
+ if (state.threadId) await refreshThread();
790
+ else renderEmptyReader();
791
+ }
792
+ } catch (error) {
793
+ if (error.message === "unauthorized") {
794
+ setStatus("idle", "authentification requise");
795
+ return;
796
+ }
797
+ setStatus("ko", `erreur : ${error.message}`);
798
+ }
799
+ }
800
+
801
+ // --- Écouteurs -------------------------------------------------------------
802
+
803
+ el.refresh.addEventListener("click", () => refresh({ keepThread: true }));
804
+
805
+ el.collapse.addEventListener("click", () => {
806
+ state.collapsed = !state.collapsed;
807
+ el.console.classList.toggle("is-collapsed", state.collapsed);
808
+ el.collapse.title = state.collapsed ? "Déplier" : "Replier";
809
+ });
810
+
811
+ el.chips.addEventListener("click", (event) => {
812
+ const chip = event.target.closest(".chip");
813
+ if (!chip) return;
814
+ state.filter = chip.dataset.filter;
815
+ for (const other of el.chips.querySelectorAll(".chip")) {
816
+ other.classList.toggle("is-active", other === chip);
817
+ }
818
+ refresh({ keepThread: true });
819
+ });
820
+
821
+ let searchTimer = null;
822
+ el.search.addEventListener("input", (event) => {
823
+ state.search = event.target.value.trim();
824
+ clearTimeout(searchTimer);
825
+ searchTimer = setTimeout(() => refresh({ keepThread: true }), 250);
826
+ });
827
+
828
+ el.tokenReveal.addEventListener("click", () => {
829
+ const shown = el.token.type === "text";
830
+ el.token.type = shown ? "password" : "text";
831
+ el.tokenReveal.setAttribute("aria-label", shown ? "Afficher le jeton" : "Masquer le jeton");
832
+ el.token.focus();
833
+ });
834
+
835
+ el.loginForm.addEventListener("submit", async (event) => {
836
+ event.preventDefault();
837
+ el.tokenError.textContent = "";
838
+ el.tokenField.classList.remove("is-invalid");
839
+ const token = el.token.value.trim();
840
+ if (!token) return;
841
+ const response = await fetch("/ui/login", {
842
+ method: "POST",
843
+ headers: { "content-type": "application/json" },
844
+ credentials: "same-origin",
845
+ body: JSON.stringify({ token }),
846
+ }).catch(() => null);
847
+ // Le champ est vidé dans tous les cas : le jeton ne reste pas affiché dans la page.
848
+ el.token.value = "";
849
+ el.token.type = "password";
850
+ if (!response || !response.ok) {
851
+ el.tokenField.classList.add("is-invalid");
852
+ el.tokenError.textContent =
853
+ response && response.status === 429
854
+ ? "Trop de tentatives, réessayez dans quelques minutes."
855
+ : "Jeton refusé. Vérifiez MESSENGER_ADMIN_TOKEN côté serveur.";
856
+ return;
857
+ }
858
+ refresh();
859
+ });
860
+
861
+ el.logout.addEventListener("click", async () => {
862
+ await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
863
+ state.selection = { kind: "all", key: null };
864
+ state.expanded = new Set();
865
+ state.threadId = null;
866
+ state.messageId = null;
867
+ state.directory = new Map();
868
+ state.groups = [];
869
+ el.sheet.hidden = true;
870
+ el.console.classList.remove("has-sheet");
871
+ el.agents.replaceChildren();
872
+ el.threads.replaceChildren();
873
+ renderEmptyReader();
874
+ setLoggedIn(false);
875
+ });
876
+
877
+ setInterval(() => {
878
+ if (el.autoRefresh.checked && el.login.hidden) refresh({ keepThread: true });
879
+ }, REFRESH_MS);
880
+
881
+ el.loginOrigin.textContent = `MCP Messenger · ${window.location.host}`;
882
+ renderEmptyReader();
883
+ refresh();