@imenam/mcp-messenger 1.0.11 → 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.
package/README.md CHANGED
@@ -340,9 +340,12 @@ configuration partagent alors la même boîte. Utile pour un service unique et s
340
340
  organisés autour du fil — l'unité d'une correspondance, celle qui porte le sujet, les deux
341
341
  correspondants et l'état de lecture :
342
342
 
343
- - **une colonne d'agents** : un par boîte, avec son nom d'agent, ce dont la session
344
- s'occupe et son nombre de messages non lus. La sélection filtre les fils sur cette
345
- boîte ; la colonne se replie sur ses seuls avatars quand la place manque ;
343
+ - **une colonne d'agents à deux niveaux** : un agent tient une boîte par session ouverte,
344
+ la colonne l'écrit donc ainsi. Chaque agent est **replié par défaut** et se déplie d'un
345
+ chevron sur ses boîtes ; une boîte que son agent n'a pas nommée s'affiche à son adresse,
346
+ la seule chose qui la distingue alors de ses sœurs. Cliquer l'agent filtre les fils sur
347
+ **toutes ses sessions**, cliquer une boîte sur **cette seule session**. La colonne se
348
+ replie sur les seuls avatars d'agents quand la place manque ;
346
349
  - **la liste des fils**, du plus récemment actif au plus ancien, chacun résumé par son
347
350
  nom, ses correspondants, son dernier message et sa priorité la plus haute. Trois puces
348
351
  la filtrent : tous, non lus, priorité haute ;
@@ -357,9 +360,11 @@ correspondants et l'état de lecture :
357
360
  - un rafraîchissement automatique toutes les 5 secondes, débrayable.
358
361
 
359
362
  **Les adresses ne sont pas de l'information de lecture.** La console résout partout
360
- `box_9f2c4a01b3d7e5f8` en nom d'agent, et ne montre l'adresse qu'aux deux endroits où elle
361
- sert vraiment : le volet de détail et l'en-tête d'un fil, chaque fois avec un bouton de
362
- copie. Une boîte jamais connectée se distingue à son avatar en contour et à la mention
363
+ `box_9f2c4a01b3d7e5f8` en nom d'agent, et ne montre l'adresse qu'aux endroits où elle sert
364
+ vraiment : le volet de détail, l'en-tête d'un fil et les boîtes sans nom, chaque fois avec
365
+ un bouton de copie. Quand plusieurs sessions d'un même projet apparaissent, le nom de
366
+ l'agent est suivi de celui de la boîte entre parenthèses — sans quoi un fil se lirait
367
+ « Documentor Dev → Documentor Dev » sans dire de quelles sessions il parle. Une boîte jamais connectée se distingue à son avatar en contour et à la mention
363
368
  « jamais connectée » — elle reste listée, puisqu'elle a du courrier en attente.
364
369
 
365
370
  À l'ouverture, la console demande `MESSENGER_ADMIN_TOKEN` dans un formulaire. Le serveur
@@ -440,7 +445,7 @@ la console (cookie), qui elle n'ouvre rien d'autre.
440
445
  | `GET` `POST` `DELETE` | `/api/folders[/:name]` | Gérer les dossiers |
441
446
  | `GET` | `/api/wait` | Attente longue (`timeout_seconds`, `folder`, `watcher`) |
442
447
  | `GET` | `/admin/overview` · `/admin/messages[/:id]` | Supervision, lecture seule |
443
- | `GET` | `/admin/threads` | Fils du serveur entier (`agent`, `search`, `unread_only`, `priority`, `limit`, `offset`) |
448
+ | `GET` | `/admin/threads` | Fils du serveur entier (`agent` — une ou plusieurs adresses séparées par des virgules —, `search`, `unread_only`, `priority`, `limit`, `offset`) |
444
449
  | `GET` | `/admin/threads/:id` | Fil complet, corps inclus |
445
450
  | `POST` | `/ui/login` · `/ui/logout` | Session de la console (`{ "token": … }` → cookie `HttpOnly`) |
446
451
 
@@ -104,7 +104,14 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
104
104
  */
105
105
  listAllThreadsAdmin(opts?: {
106
106
  search?: string;
107
- agent?: string;
107
+ /**
108
+ * Adresses de boîtes : un fil est gardé dès que l'une d'elles y participe.
109
+ *
110
+ * Une liste, et non une seule adresse, parce qu'un agent tient autant de boîtes
111
+ * qu'il a de sessions ouvertes : « les fils du projet Documentor » se demande en
112
+ * passant les adresses de toutes ses sessions d'un coup.
113
+ */
114
+ agents?: string[];
108
115
  unread_only?: boolean;
109
116
  priority?: Priority;
110
117
  limit?: number;
@@ -626,12 +626,13 @@ export class MessageStore extends EventEmitter {
626
626
  const where = ["1 = 1"];
627
627
  const having = [];
628
628
  const params = [];
629
- // Un agent « participe » au fil dès qu'il y a une livraison, qu'il ait écrit ou reçu :
630
- // c'est la correspondance qui est filtrée, pas les messages qu'il a signés.
631
- if (opts.agent) {
629
+ // Une boîte « participe » au fil dès qu'il y a une livraison, qu'elle ait écrit ou
630
+ // reçu : c'est la correspondance qui est filtrée, pas les messages qu'elle a signés.
631
+ const agents = dedupe((opts.agents ?? []).map((id) => normalizeAgentId(id)).filter(Boolean));
632
+ if (agents.length > 0) {
632
633
  where.push(`EXISTS (SELECT 1 FROM deliveries d JOIN messages mp ON mp.id = d.message_id
633
- WHERE mp.thread_id = m.thread_id AND d.agent_id = ?)`);
634
- params.push(opts.agent);
634
+ WHERE mp.thread_id = m.thread_id AND d.agent_id IN (${agents.map(() => "?").join(",")}))`);
635
+ params.push(...agents);
635
636
  }
636
637
  // La recherche porte sur le fil entier : son nom, sa description, et le texte de
637
638
  // n'importe lequel de ses messages. Un fil non nommé reste ainsi trouvable.
@@ -81,7 +81,9 @@ const waitQuerySchema = z.object({
81
81
  const loginSchema = z.object({ token: z.string().min(1).max(1024) });
82
82
  const adminThreadsSchema = z.object({
83
83
  search: z.string().trim().min(1).optional(),
84
- agent: z.string().trim().min(1).optional(),
84
+ // Une ou plusieurs adresses, séparées par des virgules : un agent tient une boîte par
85
+ // session ouverte, et la console filtre sur toutes celles d'un projet à la fois.
86
+ agent: z.string().trim().min(1).max(4096).optional(),
85
87
  unread_only: z.enum(["true", "1"]).optional(),
86
88
  priority: z.enum(PRIORITIES).optional(),
87
89
  limit: z.coerce.number().int().min(1).max(100).optional(),
@@ -176,7 +178,11 @@ export function createApp({ store, config }) {
176
178
  res.json({
177
179
  threads: store.listAllThreadsAdmin({
178
180
  search: query.search,
179
- agent: query.agent,
181
+ agents: query.agent
182
+ ?.split(",")
183
+ .map((id) => id.trim())
184
+ .filter(Boolean)
185
+ .slice(0, 100),
180
186
  unread_only: query.unread_only !== undefined,
181
187
  priority: query.priority,
182
188
  limit: query.limit,
@@ -19,11 +19,20 @@ const ICONS = {
19
19
  clock: ["M12 6v6l4 2", "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z"],
20
20
  close: ["M18 6 6 18", "m6 6 12 12"],
21
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"],
22
23
  };
23
24
 
24
25
  const state = {
25
- /** Adresse de la boîte sur laquelle la liste des fils est filtrée, ou null. */
26
- agent: null,
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(),
27
36
  /** "all" | "unread" | "high" — les puces au-dessus de la liste des fils. */
28
37
  filter: "all",
29
38
  search: "",
@@ -33,6 +42,8 @@ const state = {
33
42
  collapsed: false,
34
43
  /** Annuaire des boîtes, indexé par adresse : rempli par chaque `/admin/overview`. */
35
44
  directory: new Map(),
45
+ /** Boîtes regroupées par nom d'agent, dans l'ordre de l'annuaire. */
46
+ groups: [],
36
47
  };
37
48
 
38
49
  const el = {
@@ -126,47 +137,78 @@ function tag(className, label, iconName) {
126
137
  * Deux noms par boîte, deux questions : l'agent dit d'où l'on écrit (le projet), la boîte
127
138
  * de quoi cette session s'occupe. La console montre les deux, et n'affiche l'adresse que
128
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.
129
144
  */
130
145
  function party(agentId) {
131
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);
132
157
  return {
133
158
  id: agentId,
134
- name: (known && known.agent_name) || shortAddress(agentId),
135
- box: (known && known.display_name) || null,
159
+ name,
160
+ agentName,
161
+ box: boxName,
136
162
  registered: !known || known.registered,
137
163
  unread: (known && known.unread) || 0,
138
164
  };
139
165
  }
140
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
+
141
177
  function shortAddress(agentId) {
142
178
  const id = String(agentId || "");
143
179
  return id.length > 12 ? `${id.slice(0, 10)}…` : id || "—";
144
180
  }
145
181
 
146
- function initials(agentId) {
147
- const name = party(agentId).name;
148
- const words = name.split(/[\s_-]+/).filter(Boolean);
149
- const letters = words.length >= 2 ? words[0][0] + words[1][0] : name.slice(0, 2);
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);
150
185
  return letters.toUpperCase();
151
186
  }
152
187
 
153
- /** Teinte stable : la même adresse donne toujours la même couleur d'avatar. */
154
- function colorIndex(agentId) {
188
+ /** Teinte stable : la même graine donne toujours la même couleur d'avatar. */
189
+ function colorIndex(seed) {
155
190
  let hash = 0;
156
- for (const char of String(agentId)) hash = (hash * 31 + char.charCodeAt(0)) % 100003;
191
+ for (const char of String(seed)) hash = (hash * 31 + char.charCodeAt(0)) % 100003;
157
192
  return hash % AVATAR_COLORS;
158
193
  }
159
194
 
160
- function avatar(agentId, extraClass) {
161
- const who = party(agentId);
195
+ /** Pastille d'initiales. `seed` teinte, `label` écrit, `title` explique au survol. */
196
+ function avatarFor(seed, label, title, extraClass, absent) {
162
197
  const classes = ["avatar"];
163
198
  if (extraClass) classes.push(extraClass);
164
- classes.push(who.registered ? `avatar--c${colorIndex(agentId)}` : "avatar--absent");
165
- const element = node("span", classes.join(" "), initials(agentId));
166
- element.title = who.box ? `${who.name} — ${who.box}` : who.name;
199
+ classes.push(absent ? "avatar--absent" : `avatar--c${colorIndex(seed)}`);
200
+ const element = node("span", classes.join(" "), initials(label));
201
+ element.title = title;
167
202
  return element;
168
203
  }
169
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
+
170
212
  // --- Dates -----------------------------------------------------------------
171
213
 
172
214
  function formatDate(iso) {
@@ -229,71 +271,205 @@ function setStatus(kind, label) {
229
271
 
230
272
  // --- Colonne des agents ----------------------------------------------------
231
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
+
232
313
  function renderAgents(overview) {
233
- state.directory = new Map(overview.agents.map((agent) => [agent.id, agent]));
314
+ state.directory = new Map(overview.agents.map((box) => [box.id, box]));
315
+ state.groups = groupByAgent(overview.agents);
234
316
  el.agentsFoot.textContent = [
235
- plural(overview.agents.length, "agent"),
236
- plural(overview.messages, "message"),
317
+ plural(state.groups.length, "agent"),
318
+ plural(overview.agents.length, "boîte"),
237
319
  plural(overview.threads, "fil"),
238
320
  ].join(" · ");
239
321
 
240
322
  el.agents.replaceChildren();
241
- el.agents.appendChild(agentRow(null, overview));
242
- for (const agent of overview.agents) el.agents.appendChild(agentRow(agent, overview));
323
+ el.agents.appendChild(allRow(overview));
324
+ for (const group of state.groups) el.agents.appendChild(groupRow(group));
243
325
  }
244
326
 
245
- function agentRow(agent, overview) {
327
+ function allRow(overview) {
246
328
  const item = node("li");
247
- const selected = agent ? state.agent === agent.id : state.agent === null;
248
- const unread = agent ? agent.unread : overview.agents.reduce((total, a) => total + a.unread, 0);
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);
249
372
 
250
373
  const classes = ["agent"];
251
- if (selected) classes.push("is-selected");
252
- if (unread > 0) classes.push("has-unread");
253
- if (agent && !agent.registered) classes.push("agent--muted");
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");
254
377
  const row = node("button", classes.join(" "));
255
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
+ );
256
388
 
257
- if (agent) {
258
- row.appendChild(avatar(agent.id));
259
- } else {
260
- const all = node("span", "avatar avatar--absent", "∗");
261
- all.title = "Tous les agents";
262
- row.appendChild(all);
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);
263
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));
264
421
 
265
422
  const text = node("div", "agent-text");
266
- text.appendChild(node("div", "agent-name", agent ? party(agent.id).name : "Tous les agents"));
267
- if (agent) {
268
- // Une boîte jamais connectée n'a pas de nom : le dire vaut mieux qu'une ligne vide.
269
- const boxName = agent.display_name || (agent.registered ? "boîte sans nom" : "jamais connectée");
270
- const box = node("div", "agent-box", boxName);
271
- if (!agent.display_name) box.classList.add("is-absent");
272
- text.appendChild(box);
273
- } else {
274
- text.appendChild(node("div", "agent-box", plural(overview.threads, "fil")));
275
- }
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
+ );
276
435
  row.appendChild(text);
277
436
 
278
- if (unread > 0) row.appendChild(node("span", "count count--accent", String(unread)));
279
- row.title = agent ? agent.id : "Tous les agents";
280
-
281
- row.addEventListener("click", () => {
282
- const next = agent ? agent.id : null;
283
- state.agent = state.agent === next ? null : next;
284
- state.threadId = null;
285
- state.messageId = null;
286
- refresh();
287
- });
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));
288
440
  item.appendChild(row);
289
441
  return item;
290
442
  }
291
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
+
292
468
  // --- Colonne des fils ------------------------------------------------------
293
469
 
294
470
  function renderThreads(threads) {
295
- const scope = state.agent ? party(state.agent).name : "Toutes les boîtes";
296
- el.threadsScope.textContent = `${scope} · ${plural(threads.length, "fil")}`;
471
+ state.threadCount = threads.length;
472
+ el.threadsScope.textContent = `${selectionLabel()} · ${plural(threads.length, "fil")}`;
297
473
 
298
474
  el.threads.replaceChildren();
299
475
  if (threads.length === 0) {
@@ -310,7 +486,8 @@ function emptyReason() {
310
486
  if (state.search) return "Aucun fil ne correspond à cette recherche.";
311
487
  if (state.filter === "unread") return "Tout est lu de ce côté.";
312
488
  if (state.filter === "high") return "Aucun message en priorité haute.";
313
- if (state.agent) return "Cette boîte n'a encore échangé avec personne.";
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é.";
314
491
  return "Aucun agent n'a encore écrit sur ce serveur.";
315
492
  }
316
493
 
@@ -596,7 +773,8 @@ async function refresh(options) {
596
773
  const [overview, list] = await Promise.all([
597
774
  api("/admin/overview"),
598
775
  api("/admin/threads", {
599
- agent: state.agent,
776
+ // Une sélection d'agent recouvre toutes ses sessions : la route prend la liste.
777
+ agent: (selectedAddresses() || []).join(","),
600
778
  search: state.search,
601
779
  unread_only: state.filter === "unread" ? "true" : undefined,
602
780
  priority: state.filter === "high" ? "high" : undefined,
@@ -682,10 +860,12 @@ el.loginForm.addEventListener("submit", async (event) => {
682
860
 
683
861
  el.logout.addEventListener("click", async () => {
684
862
  await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
685
- state.agent = null;
863
+ state.selection = { kind: "all", key: null };
864
+ state.expanded = new Set();
686
865
  state.threadId = null;
687
866
  state.messageId = null;
688
867
  state.directory = new Map();
868
+ state.groups = [];
689
869
  el.sheet.hidden = true;
690
870
  el.console.classList.remove("has-sheet");
691
871
  el.agents.replaceChildren();
@@ -503,6 +503,80 @@ body {
503
503
  color: var(--muted);
504
504
  }
505
505
 
506
+ /* --- Agent et ses boîtes --------------------------------------------------- */
507
+
508
+ /* Deux zones de clic sur une même ligne : le chevron ouvre, le reste filtre. */
509
+ .agent-head {
510
+ display: flex;
511
+ align-items: center;
512
+ }
513
+
514
+ .agent-toggle {
515
+ display: flex;
516
+ align-items: center;
517
+ justify-content: center;
518
+ width: 20px;
519
+ height: 28px;
520
+ padding: 0;
521
+ border: 0;
522
+ border-radius: 4px;
523
+ background: none;
524
+ color: var(--muted);
525
+ cursor: pointer;
526
+ flex: none;
527
+ }
528
+
529
+ .agent-toggle:hover {
530
+ color: var(--text);
531
+ }
532
+
533
+ .agent-toggle:focus-visible {
534
+ outline: 2px solid var(--accent);
535
+ outline-offset: 1px;
536
+ }
537
+
538
+ .agent-toggle .icon {
539
+ width: 14px;
540
+ height: 14px;
541
+ transition: transform 0.15s;
542
+ }
543
+
544
+ .agent-head.is-open .agent-toggle .icon {
545
+ transform: rotate(90deg);
546
+ }
547
+
548
+ /* Le chevron mange sa gouttière : la ligne d'agent reste alignée sur « Tous les agents ». */
549
+ .agent-head .agent {
550
+ padding-left: 4px;
551
+ }
552
+
553
+ /* Les boîtes vivent sous leur agent : un cran d'indentation et un filet de rattachement. */
554
+ .box-list {
555
+ list-style: none;
556
+ margin: 0 0 4px 22px;
557
+ padding: 0 0 0 14px;
558
+ border-left: 1px solid var(--border);
559
+ }
560
+
561
+ .box {
562
+ padding: 7px 10px;
563
+ }
564
+
565
+ .box .agent-name {
566
+ font-weight: 500;
567
+ font-size: 13px;
568
+ }
569
+
570
+ /* Une boîte sans nom se lit à son adresse : en mono, pour ce que c'est. */
571
+ .box .agent-name.is-absent {
572
+ font-weight: 400;
573
+ color: var(--muted);
574
+ }
575
+
576
+ .box .agent-box {
577
+ font-size: 11px;
578
+ }
579
+
506
580
  .agents-foot {
507
581
  padding: 16px 20px;
508
582
  font-size: 12px;
@@ -574,10 +648,13 @@ body {
574
648
  color: var(--accent);
575
649
  }
576
650
 
577
- /* Colonne repliée : il ne reste que les avatars, chacun avec son nom en info-bulle. */
651
+ /* Colonne repliée : il ne reste que les avatars d'agents, nom en info-bulle. La
652
+ hiérarchie n'y tient pas — les boîtes se retrouvent en dépliant la colonne. */
578
653
  .is-collapsed .agents-head,
579
654
  .is-collapsed .agent-text,
580
655
  .is-collapsed .agents-foot,
656
+ .is-collapsed .agent-toggle,
657
+ .is-collapsed .box-list,
581
658
  .is-collapsed .agent .count {
582
659
  display: none;
583
660
  }
@@ -591,6 +668,10 @@ body {
591
668
  justify-content: center;
592
669
  }
593
670
 
671
+ .is-collapsed .agent-head .agent {
672
+ padding-left: 8px;
673
+ }
674
+
594
675
  /* Le badge des non-lus survit au repli, réduit à une pastille sur l'avatar. */
595
676
  .is-collapsed .agent.has-unread .avatar {
596
677
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.11";
1
+ export declare const APP_VERSION = "1.0.13";
@@ -1,2 +1,2 @@
1
1
  // Généré par scripts/generate-version.js — ne pas modifier à la main.
2
- export const APP_VERSION = "1.0.11";
2
+ export const APP_VERSION = "1.0.13";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",