@imenam/mcp-messenger 1.0.19 → 1.0.22

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
@@ -385,7 +385,8 @@ correspondants et l'état de lecture :
385
385
  la colonne l'écrit donc ainsi. Chaque agent est **replié par défaut** et se déplie d'un
386
386
  chevron sur ses boîtes ; une boîte que son agent n'a pas nommée s'affiche à son adresse,
387
387
  la seule chose qui la distingue alors de ses sœurs. Cliquer l'agent filtre les fils sur
388
- **toutes ses sessions**, cliquer une boîte sur **cette seule session**. La colonne se
388
+ **toutes ses sessions**, cliquer une boîte sur **cette seule session**. Recliquer ne
389
+ relâche rien : on revient à la vue générale par « Tous les agents », en tête de colonne. La colonne se
389
390
  replie sur les seuls avatars d'agents quand la place manque, son bouton de repli restant
390
391
  en place : c'est lui qui la rouvre.
391
392
 
@@ -401,8 +402,17 @@ correspondants et l'état de lecture :
401
402
  identiques sont départagées par l'adresse pour que l'ordre ne bouge pas d'un
402
403
  rafraîchissement à l'autre ;
403
404
  - **la liste des fils**, du plus récemment actif au plus ancien, chacun résumé par son
404
- nom, ses correspondants, son dernier message et sa priorité la plus haute. Trois puces
405
- la filtrent : tous, non lus, priorité haute ;
405
+ nom, ses correspondants, son dernier message et sa priorité la plus haute. Les deux
406
+ correspondants y sont d'abord **deux pastilles côte à côte**, séparées et non superposées
407
+ — leurs initiales doivent rester lisibles. **Ces pastilles sont celles des agents, pas
408
+ des boîtes** : dans une liste, ce qu'on cherche du regard est le projet, et deux sessions
409
+ d'un même agent portent donc la même. Ce qui les distingue, le nom de la boîte, se lit au
410
+ survol, avec le projet, l'adresse et la mention de celle qui a écrit en dernier. Le
411
+ lecteur du fil suit la même règle, en tête de chaque message ;
412
+ - **un en-tête qui dit sur quoi la liste est filtrée** : sans filtre elle s'intitule
413
+ « Fils », et dès qu'un agent est choisi c'est lui le sujet — sa pastille en grand, son
414
+ nom en titre, et en dessous ce que le filtre recouvre, toutes ses sessions ou une seule ;
415
+ - **trois puces** au-dessus de la liste : tous, non lus, priorité haute ;
406
416
  - **le lecteur du fil** : la conversation entière dans l'ordre, un bloc par message, avec
407
417
  qui écrit à qui — en clair, pas en adresses — et une pastille disant si le destinataire
408
418
  l'a lu. **Le corps d'un message est rendu comme du markdown** : titres, listes,
@@ -459,7 +469,13 @@ chose :**
459
469
  touchées depuis. **Une boîte créée après cette date est épargnée, même vide** — une
460
470
  session qui vient de démarrer et n'a pas encore parlé n'est pas une boîte à l'abandon.
461
471
 
462
- L'un comme l'autre se font en deux temps, parce qu'ils ne se rattrapent pas :
472
+ S'y ajoute la suppression d'**une boîte en particulier** : chaque ligne de la colonne de
473
+ gauche porte une corbeille, révélée au survol ou au clavier. Elle n'efface rien d'elle-même,
474
+ elle ouvre la même fenêtre sur cette seule boîte, avec ce qu'elle contient sous les yeux.
475
+ C'est la seule des trois qui ne demande aucun critère : l'adresse vient d'être montrée du
476
+ doigt, il n'y a rien à revérifier.
477
+
478
+ Toutes se font en deux temps, parce qu'elles ne se rattrapent pas :
463
479
 
464
480
  1. **L'aperçu** liste les boîtes concernées, chacune avec son nom, son adresse, son nombre
465
481
  de messages et sa dernière activité. Chaque ligne se décoche.
@@ -545,6 +561,7 @@ la console (cookie), qui elle n'ouvre rien d'autre.
545
561
  | `GET` | `/admin/mailboxes/stale` | Boîtes sans activité depuis `before`, avec ce que chacune contient |
546
562
  | `GET` | `/admin/mailboxes/empty` | Boîtes par lesquelles rien n'est jamais passé, sans critère de date |
547
563
  | `POST` | `/admin/mailboxes/purge` | Supprime les boîtes nommées (`mailboxes`, plus `before` **ou** `empty: true`) — refusé si l'une d'elles ne répond plus au critère |
564
+ | `DELETE` | `/admin/mailboxes/:id` | Supprime une boîte désignée, quel que soit son état — 404 si l'adresse est inconnue |
548
565
  | `POST` | `/ui/login` · `/ui/logout` | Session de la console (`{ "token": … }` → cookie `HttpOnly`) |
549
566
 
550
567
  ---
@@ -568,8 +585,8 @@ la console (cookie), qui elle n'ouvre rien d'autre.
568
585
  rendu en markdown par construction de nœuds, jamais par assignation de balisage, et la
569
586
  CSP interdit tout script tiers.
570
587
  - La console ne peut ni écrire ni modifier un message. Ses seules commandes destructrices
571
- sont les deux nettoyages de boîtes, réservés au jeton d'administration, confirmés deux
572
- fois et revérifiés par le serveur avant exécution.
588
+ suppriment des boîtes : réservées au jeton d'administration, confirmées deux fois, et
589
+ revérifiées par le serveur quand elles portent sur un lot.
573
590
  - Derrière un reverse proxy TLS, exposer le serveur en `https://` et donner cette URL aux
574
591
  agents via `MESSENGER_SERVER_URL`.
575
592
 
@@ -610,9 +627,9 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
610
627
 
611
628
  ### Tests
612
629
 
613
- 253 tests couvrant : les deux nettoyages de boîtes commandés depuis la console — vides,
614
- ou inactives depuis une date — avec leur aperçu, leur double confirmation et le refus
615
- nominatif d'une boîte qui ne répond plus au critère, la reprise de la boîte d'une session interrompue (clé de session de
630
+ 273 tests couvrant : les suppressions de boîtes commandées depuis la console — une boîte
631
+ désignée à la main, les vides, celles sans activité depuis une date — avec leur aperçu,
632
+ leur double confirmation et le refus nominatif d'une boîte qui ne répond plus au critère, la reprise de la boîte d'une session interrompue (clé de session de
616
633
  l'hôte, boîte orpheline, boîte d'une session vivante jamais reprise), le rendu markdown des messages et l'aplatissement des aperçus (dont
617
634
  l'innocuité du rendu : ni HTML interprété, ni lien vers un autre schéma qu'`http`, `https`
618
635
  ou `mailto`), le stockage et l'isolation des boîtes, la validation de configuration,
@@ -264,6 +264,23 @@ export function createApp({ store, config }) {
264
264
  logger.warn(`admin purge: ${removed.mailboxes} mailbox(es) deleted, ${reason}`);
265
265
  res.json({ before: input.before ?? null, empty: input.empty ?? false, ...removed });
266
266
  });
267
+ /**
268
+ * Suppression d'une boîte désignée à la main, quel que soit son état.
269
+ *
270
+ * Pas de critère à revérifier ici, et c'est voulu : les deux nettoyages en lot se
271
+ * protègent d'une liste qui aurait vieilli entre l'aperçu et la confirmation, alors que
272
+ * celle-ci porte sur une boîte que l'on vient de montrer du doigt. La boîte doit
273
+ * exister — supprimer une adresse inconnue serait un geste sans effet présenté comme un
274
+ * succès.
275
+ */
276
+ admin.delete("/mailboxes/:id", (req, res) => {
277
+ const id = normalizeAgentId(String(req.params.id));
278
+ if (!store.getAgent(id))
279
+ throw new MessengerError(`mailbox not found: ${id}`, 404);
280
+ const removed = store.deleteMailboxes([id]);
281
+ logger.warn(`admin delete: mailbox ${id} deleted (${removed.messages} message(s) erased)`);
282
+ res.json({ mailbox: id, ...removed });
283
+ });
267
284
  app.use("/admin", admin);
268
285
  // Porte d'entrée de tout le reste : aucune requête n'est servie sans jeton valide. Le
269
286
  // contrôle par route (identité de l'agent) vient ensuite.
@@ -1,7 +1,7 @@
1
- // Console de supervision : les routes /admin du serveur, en lecture — à deux exceptions
2
- // près, les nettoyages de boîtes, seules commandes de cette page qui écrivent, et qui
3
- // détruisent. Elles sont donc tenues à part : un aperçu, puis une confirmation, et rien
4
- // ne part sans le critère que le serveur revérifiera.
1
+ // Console de supervision : les routes /admin du serveur, en lecture — à trois exceptions
2
+ // près, les suppressions de boîtes, seules commandes de cette page qui écrivent, et qui
3
+ // détruisent. Elles passent toutes par la même fenêtre : un aperçu de ce qui va partir,
4
+ // puis une confirmation en deux clics.
5
5
  //
6
6
  // Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement,
7
7
  // jamais par innerHTML. Les seuls fragments de balisage construits ici sont les icônes,
@@ -26,6 +26,7 @@ const ICONS = {
26
26
  close: ["M18 6 6 18", "m6 6 12 12"],
27
27
  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"],
28
28
  chevron: ["m9 18 6-6-6-6"],
29
+ trash: ["M3 6h18", "M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2", "M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"],
29
30
  };
30
31
 
31
32
  const state = {
@@ -81,6 +82,8 @@ const el = {
81
82
  agents: document.getElementById("agents"),
82
83
  agentsFoot: document.getElementById("agents-foot"),
83
84
  collapse: document.getElementById("collapse"),
85
+ threadsAvatar: document.getElementById("threads-avatar"),
86
+ threadsName: document.getElementById("threads-name"),
84
87
  threadsScope: document.getElementById("threads-scope"),
85
88
  // Le panneau, pas la liste qu'il contient : c'est lui que le séparateur redimensionne.
86
89
  threadsPane: document.querySelector(".threads"),
@@ -234,6 +237,31 @@ function avatar(agentId, extraClass) {
234
237
  return avatarFor(agentId, label, title, extraClass, !who.registered);
235
238
  }
236
239
 
240
+ /**
241
+ * Pastille de l'agent, et non de la boîte.
242
+ *
243
+ * Dans une liste, ce qui se cherche du regard est le projet : deux sessions d'un même
244
+ * agent portent donc la même pastille, et c'est voulu — ce qui les distingue, le nom de
245
+ * la boîte, se lit au survol. La pastille de boîte reste celle du lecteur et du volet,
246
+ * là où l'on regarde une session en particulier.
247
+ */
248
+ function agentAvatar(agentId, extraClass, title) {
249
+ const who = party(agentId);
250
+ const seed = who.agentName || agentId;
251
+ const label = who.agentName || who.box || shortAddress(agentId);
252
+ return avatarFor(seed, label, title ?? boxTitle(agentId), extraClass, !who.registered);
253
+ }
254
+
255
+ /** Ce qu'une pastille dit au survol : le projet, la session, l'adresse. */
256
+ function boxTitle(agentId) {
257
+ const who = party(agentId);
258
+ return [
259
+ who.agentName || "agent sans nom",
260
+ who.box || (who.registered ? "boîte sans nom" : "boîte jamais connectée"),
261
+ agentId,
262
+ ].join("\n");
263
+ }
264
+
237
265
  // --- Dates -----------------------------------------------------------------
238
266
 
239
267
  function formatDate(iso) {
@@ -284,13 +312,22 @@ async function api(path, params) {
284
312
  return response.json();
285
313
  }
286
314
 
287
- /** Envoi d'une action à la console. La seule qui existe supprime : elle passe par ici. */
315
+ /** Envoi d'une action à la console. Les seules qui existent suppriment : elles passent par ici. */
288
316
  async function post(path, body) {
317
+ return action("POST", path, body);
318
+ }
319
+
320
+ /** Suppression d'une ressource nommée par son adresse : pas de corps, l'URL suffit. */
321
+ async function remove(path) {
322
+ return action("DELETE", path, null);
323
+ }
324
+
325
+ async function action(method, path, body) {
289
326
  const response = await fetch(path, {
290
- method: "POST",
327
+ method,
291
328
  headers: { "content-type": "application/json", accept: "application/json" },
292
329
  credentials: "same-origin",
293
- body: JSON.stringify(body),
330
+ body: body === null ? undefined : JSON.stringify(body),
294
331
  });
295
332
  if (response.status === 401) {
296
333
  setLoggedIn(false);
@@ -409,9 +446,16 @@ function isSelected(kind, key) {
409
446
  return state.selection.kind === kind && state.selection.key === key;
410
447
  }
411
448
 
449
+ /**
450
+ * Choisit ce que la liste des fils montre.
451
+ *
452
+ * Recliquer sur la sélection courante ne la relâche pas : cliquer un agent veut dire
453
+ * « montre-moi cet agent », et le redire ne peut pas vouloir dire le contraire. On revient
454
+ * à la vue générale par « Tous les agents », en tête de colonne, là où on l'attend.
455
+ */
412
456
  function select(kind, key) {
413
- // Recliquer sur la sélection courante la relâche : le filtre ne s'emprisonne pas.
414
- state.selection = isSelected(kind, key) ? { kind: "all", key: null } : { kind, key };
457
+ if (isSelected(kind, key)) return;
458
+ state.selection = { kind, key };
415
459
  state.threadId = null;
416
460
  state.messageId = null;
417
461
  el.sheet.hidden = true;
@@ -603,10 +647,37 @@ function boxRow(box) {
603
647
  if (box.unread > 0) row.appendChild(node("span", "count count--accent", String(box.unread)));
604
648
  row.title = `${box.id}\n${plural(box.sent, "envoyé")} · ${plural(box.received, "reçu")}`;
605
649
  row.addEventListener("click", () => select("box", box.id));
606
- item.appendChild(row);
650
+
651
+ // La ligne et son bouton de suppression sont deux boutons voisins, jamais imbriqués :
652
+ // un bouton dans un bouton n'est pas du HTML, et le clic n'y serait pas dissociable.
653
+ const line = node("div", "box-line");
654
+ line.appendChild(row);
655
+ line.appendChild(deleteBoxButton(box));
656
+ item.appendChild(line);
607
657
  return item;
608
658
  }
609
659
 
660
+ /**
661
+ * Suppression d'une boîte, à la main.
662
+ *
663
+ * Le bouton n'efface rien : il ouvre la fenêtre de nettoyage sur cette seule boîte, avec
664
+ * ce qu'elle contient sous les yeux et la même double confirmation que les nettoyages en
665
+ * lot. Un geste destructeur à un clic d'une liste ne peut pas être plus court que ça.
666
+ */
667
+ function deleteBoxButton(box) {
668
+ const label = `Supprimer la boîte ${boxLabel(box)}`;
669
+ const remove = node("button", "box-delete");
670
+ remove.type = "button";
671
+ remove.title = label;
672
+ remove.setAttribute("aria-label", label);
673
+ remove.appendChild(icon("trash"));
674
+ remove.addEventListener("click", (event) => {
675
+ event.stopPropagation();
676
+ openPurgeFor(box);
677
+ });
678
+ return remove;
679
+ }
680
+
610
681
  /** Nombre de fils affiché en pied de colonne, conservé entre deux rendus locaux. */
611
682
  function currentThreadCount() {
612
683
  return state.threadCount || 0;
@@ -635,7 +706,7 @@ function selectionLabel() {
635
706
 
636
707
  function renderThreads(threads) {
637
708
  state.threadCount = threads.length;
638
- el.threadsScope.textContent = `${selectionLabel()} · ${plural(threads.length, "fil")}`;
709
+ renderThreadsHead(threads.length);
639
710
 
640
711
  el.threads.replaceChildren();
641
712
  if (threads.length === 0) {
@@ -648,6 +719,43 @@ function renderThreads(threads) {
648
719
  for (const thread of threads) el.threads.appendChild(threadRow(thread));
649
720
  }
650
721
 
722
+ /**
723
+ * En-tête de la liste : ce sur quoi elle est filtrée, en grand.
724
+ *
725
+ * Sans filtre, elle s'intitule « Fils » et rien d'autre. Dès qu'un agent est choisi, c'est
726
+ * lui le sujet : sa pastille et son nom prennent la place du titre, et la ligne en dessous
727
+ * dit ce que le filtre recouvre — toutes ses sessions, ou une seule.
728
+ */
729
+ function renderThreadsHead(count) {
730
+ const { kind, key } = state.selection;
731
+ const fils = plural(count, "fil");
732
+
733
+ if (kind === "all") {
734
+ el.threadsAvatar.hidden = true;
735
+ el.threadsAvatar.replaceChildren();
736
+ el.threadsName.textContent = "Fils";
737
+ el.threadsScope.textContent = `Toutes les boîtes · ${fils}`;
738
+ return;
739
+ }
740
+
741
+ // Une boîte est d'abord une session de son agent : c'est donc l'agent qui s'affiche,
742
+ // et le nom de la boîte qui précise de quelle session il s'agit.
743
+ const address = kind === "box" ? key : (state.groups.find((g) => g.key === key)?.boxes[0] || {}).id;
744
+ const who = address ? party(address) : null;
745
+ const sessions = kind === "agent" ? (state.groups.find((g) => g.key === key)?.boxes.length ?? 0) : 0;
746
+ const box = kind === "box" ? state.directory.get(key) : null;
747
+
748
+ el.threadsAvatar.hidden = false;
749
+ el.threadsAvatar.replaceChildren(
750
+ address ? agentAvatar(address, "avatar--lg") : avatarFor(key, key, key, "avatar--lg", false)
751
+ );
752
+ el.threadsName.textContent = kind === "agent" ? key : who?.agentName || selectionLabel();
753
+ el.threadsScope.textContent =
754
+ kind === "agent"
755
+ ? `${plural(sessions, "session")} · ${fils}`
756
+ : `${box ? boxLabel(box) : shortAddress(key)} · ${fils}`;
757
+ }
758
+
651
759
  function emptyReason() {
652
760
  if (state.search) return "Aucun fil ne correspond à cette recherche.";
653
761
  if (state.filter === "unread") return "Tout est lu de ce côté.";
@@ -672,7 +780,7 @@ function threadRow(thread) {
672
780
  top.appendChild(node("span", "thread-time", formatShort(thread.last_message_at)));
673
781
  row.appendChild(top);
674
782
 
675
- row.appendChild(node("div", "thread-who", thread.participants.map((id) => party(id).name).join(" · ")));
783
+ row.appendChild(threadParticipants(thread));
676
784
 
677
785
  const bottom = node("div", "thread-bottom");
678
786
  bottom.appendChild(node("span", "thread-preview", thread.last_message_preview));
@@ -686,6 +794,29 @@ function threadRow(thread) {
686
794
  return item;
687
795
  }
688
796
 
797
+ /**
798
+ * Qui parle dans ce fil : les pastilles d'abord, les noms ensuite.
799
+ *
800
+ * Un fil lie exactement deux boîtes, la pastille suffit donc à les distinguer d'un coup
801
+ * d'œil, sans compter ni déborder. Chaque pastille garde la couleur de son adresse d'un
802
+ * écran à l'autre, et dit au survol tout ce qu'on peut vouloir savoir de la boîte : le
803
+ * projet d'où elle écrit, ce dont cette session s'occupe, son adresse, et si c'est elle
804
+ * qui a écrit en dernier.
805
+ */
806
+ function threadParticipants(thread) {
807
+ const who = node("div", "thread-who");
808
+
809
+ const stack = node("div", "avatar-stack");
810
+ for (const id of thread.participants) {
811
+ const last = id === thread.last_message_from ? "\na écrit le dernier message" : "";
812
+ stack.appendChild(agentAvatar(id, "avatar--sm avatar--stack", `${boxTitle(id)}${last}`));
813
+ }
814
+ who.appendChild(stack);
815
+
816
+ who.appendChild(node("span", "thread-names", thread.participants.map((id) => party(id).name).join(" · ")));
817
+ return who;
818
+ }
819
+
689
820
  // --- Lecteur du fil --------------------------------------------------------
690
821
 
691
822
  function renderEmptyReader() {
@@ -714,7 +845,9 @@ function renderThread(data) {
714
845
 
715
846
  const meta = node("div", "reader-meta");
716
847
  const stack = node("div", "avatar-stack");
717
- for (const id of participants) stack.appendChild(avatar(id, "avatar--sm avatar--stack"));
848
+ // Le projet d'abord : deux sessions d'un même agent portent la même pastille, le nom
849
+ // de la boîte se lisant au survol.
850
+ for (const id of participants) stack.appendChild(agentAvatar(id, "avatar--sm avatar--stack"));
718
851
  meta.appendChild(stack);
719
852
  meta.appendChild(
720
853
  node(
@@ -745,7 +878,7 @@ function messageRow(message, isLast) {
745
878
  else if (isLast) classes.push("is-last");
746
879
  const row = node("div", classes.join(" "));
747
880
 
748
- row.appendChild(avatar(message.from));
881
+ row.appendChild(agentAvatar(message.from));
749
882
 
750
883
  const column = node("div", "message-body-col");
751
884
  const head = node("div", "message-head");
@@ -1100,6 +1233,29 @@ function openPurge() {
1100
1233
  el.purge.showModal();
1101
1234
  }
1102
1235
 
1236
+ /**
1237
+ * Ouvre la fenêtre sur une seule boîte, celle qu'on vient de désigner dans la colonne.
1238
+ *
1239
+ * Rien à demander au serveur : l'annuaire vient d'être lu, et ce qu'il en dit est ce que
1240
+ * la ligne cliquée affiche. La liste est donc dressée sur place, avec la même mise en
1241
+ * forme et la même confirmation que les nettoyages en lot.
1242
+ */
1243
+ function openPurgeFor(box) {
1244
+ openPurge();
1245
+ purge.mode = "one";
1246
+ purge.mailboxes = [
1247
+ {
1248
+ id: box.id,
1249
+ agent_name: box.agent_name,
1250
+ display_name: box.display_name,
1251
+ last_activity_at: box.last_activity_at,
1252
+ messages: box.sent + box.received,
1253
+ },
1254
+ ];
1255
+ purge.selected = new Set([box.id]);
1256
+ renderPurgePreview();
1257
+ }
1258
+
1103
1259
  /** Toute modification remet la confirmation à zéro : on ne supprime que ce qu'on vient de voir. */
1104
1260
  function resetPurge() {
1105
1261
  purge.mode = null;
@@ -1141,19 +1297,28 @@ async function previewPurge(mode, load, before = null) {
1141
1297
  function renderPurgePreview() {
1142
1298
  const container = document.createDocumentFragment();
1143
1299
  const count = purge.mailboxes.length;
1144
- // Le critère est rappelé sous les yeux de qui va confirmer : les deux nettoyages
1145
- // remplissent la même liste, et ils ne suppriment pas les mêmes boîtes.
1146
- const since = purge.mode === "empty" ? "" : ` sans activité depuis le ${formatDay(purge.before)}`;
1147
- const found =
1148
- purge.mode === "empty" ? plural(count, "boîte vide", "boîtes vides") : `${plural(count, "boîte")}${since}`;
1300
+ // Le critère est rappelé sous les yeux de qui va confirmer : les trois suppressions
1301
+ // remplissent la même liste, et elles ne portent pas sur les mêmes boîtes.
1302
+ const since = ` sans activité depuis le ${formatDay(purge.before)}`;
1303
+ const criterion = purge.mode === "empty" ? "vide" : purge.mode === "one" ? "" : since;
1149
1304
 
1150
1305
  if (count === 0) {
1151
- container.appendChild(node("p", "purge-empty", `Aucune boîte ${purge.mode === "empty" ? "vide" : since.trim()}.`));
1306
+ container.appendChild(node("p", "purge-empty", `Aucune boîte ${criterion.trim()}.`));
1152
1307
  el.purgeResult.replaceChildren(container);
1153
1308
  return;
1154
1309
  }
1155
1310
 
1156
- container.appendChild(node("p", "purge-count", `${found}.`));
1311
+ container.appendChild(
1312
+ node(
1313
+ "p",
1314
+ "purge-count",
1315
+ purge.mode === "one"
1316
+ ? "Cette boîte, et tout ce que personne d'autre ne pourrait plus lire :"
1317
+ : purge.mode === "empty"
1318
+ ? `${plural(count, "boîte vide", "boîtes vides")}.`
1319
+ : `${plural(count, "boîte")}${since}.`
1320
+ )
1321
+ );
1157
1322
 
1158
1323
  const list = node("ul", "purge-list");
1159
1324
  for (const mailbox of purge.mailboxes) {
@@ -1207,12 +1372,18 @@ async function confirmPurge() {
1207
1372
  }
1208
1373
 
1209
1374
  el.purgeConfirm.disabled = true;
1375
+ const deleted = [...purge.selected];
1210
1376
  try {
1211
- const removed = await post("/admin/mailboxes/purge", {
1212
- // Le critère repart avec les adresses : c'est lui que le serveur revérifie.
1213
- ...(purge.mode === "empty" ? { empty: true } : { before: purge.before }),
1214
- mailboxes: [...purge.selected],
1215
- });
1377
+ // Une boîte désignée à la main s'efface par son adresse : il n'y a pas de critère à
1378
+ // revérifier, l'utilisateur vient de la montrer du doigt.
1379
+ const removed =
1380
+ purge.mode === "one"
1381
+ ? await remove(`/admin/mailboxes/${encodeURIComponent(deleted[0])}`)
1382
+ : await post("/admin/mailboxes/purge", {
1383
+ // Le critère repart avec les adresses : c'est lui que le serveur revérifie.
1384
+ ...(purge.mode === "empty" ? { empty: true } : { before: purge.before }),
1385
+ mailboxes: deleted,
1386
+ });
1216
1387
  resetPurge();
1217
1388
  el.purgeResult.replaceChildren(
1218
1389
  node(
@@ -1221,7 +1392,11 @@ async function confirmPurge() {
1221
1392
  `${plural(removed.mailboxes, "boîte")} supprimée(s), ${plural(removed.messages, "message")} et ${plural(removed.threads, "fil")} avec.`
1222
1393
  )
1223
1394
  );
1224
- // La console montre encore ce qui n'existe plus : elle se relit entièrement.
1395
+ // La console montre encore ce qui n'existe plus : elle se relit entièrement, et son
1396
+ // filtre est relâché s'il portait sur une boîte qui vient de disparaître.
1397
+ if (state.selection.kind === "box" && deleted.includes(state.selection.key)) {
1398
+ state.selection = { kind: "all", key: null };
1399
+ }
1225
1400
  state.threadId = null;
1226
1401
  state.messageId = null;
1227
1402
  el.sheet.hidden = true;
@@ -168,8 +168,17 @@
168
168
 
169
169
  <section class="threads">
170
170
  <div class="threads-head">
171
- <h2>Fils</h2>
172
- <p class="muted" id="threads-scope"></p>
171
+ <!--
172
+ L'en-tête dit sur quoi la liste est filtrée. Quand c'est un agent, il y tient la
173
+ place principale, pastille comprise : c'est le sujet de ce qu'on lit en dessous.
174
+ -->
175
+ <div class="threads-title">
176
+ <span class="threads-avatar" id="threads-avatar" hidden></span>
177
+ <div class="threads-heading">
178
+ <h2 id="threads-name">Fils</h2>
179
+ <p class="muted" id="threads-scope"></p>
180
+ </div>
181
+ </div>
173
182
  <div class="chips" id="chips">
174
183
  <button type="button" class="chip is-active" data-filter="all">Tous</button>
175
184
  <button type="button" class="chip" data-filter="unread">Non lus</button>
@@ -769,6 +769,54 @@ body {
769
769
  padding: 7px 10px;
770
770
  }
771
771
 
772
+ /* La ligne d'une boîte et son bouton de suppression, côte à côte. */
773
+ .box-line {
774
+ display: flex;
775
+ align-items: center;
776
+ gap: 2px;
777
+ }
778
+
779
+ .box-line .box {
780
+ flex: 1;
781
+ min-width: 0;
782
+ }
783
+
784
+ /*
785
+ Le bouton ne se montre qu'au survol ou au clavier : une corbeille par ligne, affichée en
786
+ permanence, transformerait la colonne en champ de mines. Il reste dans le flux — révélé
787
+ par l'opacité et non par `display` — pour que la ligne ne change pas de largeur.
788
+ */
789
+ .box-delete {
790
+ display: flex;
791
+ padding: 5px;
792
+ border: 0;
793
+ border-radius: var(--radius);
794
+ background: none;
795
+ color: var(--muted);
796
+ cursor: pointer;
797
+ opacity: 0;
798
+ }
799
+
800
+ .box-line:hover .box-delete,
801
+ .box-delete:focus-visible {
802
+ opacity: 1;
803
+ }
804
+
805
+ .box-delete:hover {
806
+ background: var(--danger-soft);
807
+ color: var(--danger);
808
+ }
809
+
810
+ .box-delete:focus-visible {
811
+ outline: 2px solid var(--accent);
812
+ outline-offset: -2px;
813
+ }
814
+
815
+ /* Colonne repliée : il n'y a plus de boîtes affichées, donc rien à supprimer. */
816
+ .is-collapsed .box-delete {
817
+ display: none;
818
+ }
819
+
772
820
  .box .agent-name {
773
821
  font-weight: 500;
774
822
  font-size: 13px;
@@ -966,14 +1014,40 @@ body {
966
1014
  padding: 16px 20px 12px;
967
1015
  }
968
1016
 
1017
+ /* L'agent filtré est le sujet de la liste : sa pastille et son nom ouvrent le panneau. */
1018
+ .threads-title {
1019
+ display: flex;
1020
+ align-items: center;
1021
+ gap: 12px;
1022
+ min-width: 0;
1023
+ }
1024
+
1025
+ .threads-heading {
1026
+ min-width: 0;
1027
+ }
1028
+
969
1029
  .threads-head h2 {
970
1030
  margin: 0;
971
- font-size: 15px;
1031
+ font-size: 17px;
1032
+ line-height: 1.25;
1033
+ white-space: nowrap;
1034
+ overflow: hidden;
1035
+ text-overflow: ellipsis;
972
1036
  }
973
1037
 
974
1038
  .threads-head p {
975
1039
  margin: 2px 0 0;
976
1040
  font-size: 12px;
1041
+ white-space: nowrap;
1042
+ overflow: hidden;
1043
+ text-overflow: ellipsis;
1044
+ }
1045
+
1046
+ .avatar--lg {
1047
+ width: 40px;
1048
+ height: 40px;
1049
+ font-size: 15px;
1050
+ flex: 0 0 auto;
977
1051
  }
978
1052
 
979
1053
  .chips {
@@ -1100,15 +1174,48 @@ body {
1100
1174
  color: var(--accent);
1101
1175
  }
1102
1176
 
1177
+ /* Les pastilles des deux correspondants, puis leurs noms : on voit qui parle avant de lire. */
1103
1178
  .thread-who {
1179
+ display: flex;
1180
+ align-items: center;
1181
+ gap: 8px;
1182
+ margin-top: 4px;
1183
+ min-width: 0;
1184
+ }
1185
+
1186
+ .thread-names {
1104
1187
  font-size: 12px;
1105
1188
  color: var(--muted);
1106
- margin-top: 2px;
1107
1189
  white-space: nowrap;
1108
1190
  overflow: hidden;
1109
1191
  text-overflow: ellipsis;
1110
1192
  }
1111
1193
 
1194
+ /* Dans une liste, la pastille sert de repère, pas de portrait : elle est plus petite. */
1195
+ .thread-who .avatar--sm {
1196
+ width: 22px;
1197
+ height: 22px;
1198
+ font-size: 10px;
1199
+ }
1200
+
1201
+ /*
1202
+ Ici les pastilles ne se chevauchent pas. Le chevauchement du lecteur dit « ces gens vont
1203
+ ensemble » quand la place manque ; dans une liste qui se survole, il mange les initiales,
1204
+ et ce sont justement elles qui identifient la boîte. L'anneau qui détachait les pastilles
1205
+ superposées n'a alors plus rien à détacher.
1206
+ */
1207
+ .thread-who .avatar-stack {
1208
+ gap: 4px;
1209
+ }
1210
+
1211
+ .thread-who .avatar-stack .avatar + .avatar {
1212
+ margin-left: 0;
1213
+ }
1214
+
1215
+ .thread-who .avatar--stack {
1216
+ box-shadow: none;
1217
+ }
1218
+
1112
1219
  .thread-bottom {
1113
1220
  display: flex;
1114
1221
  align-items: center;
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.19";
1
+ export declare const APP_VERSION = "1.0.22";
@@ -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.19";
2
+ export const APP_VERSION = "1.0.22";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.19",
3
+ "version": "1.0.22",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",