@imenam/mcp-messenger 1.0.24 → 1.0.26

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
@@ -424,7 +424,9 @@ correspondants et l'état de lecture :
424
424
  pastille, qui écrit à qui, heure — et sur **l'objet du message**, qui est ce qui
425
425
  l'identifie. Les deux forment le bandeau qui l'ouvre et le referme, cliquable d'un bout à
426
426
  l'autre et grisé au survol pour se donner à voir comme une cible. Ouvrir un message ne
427
- change rien à ce bandeau : le corps vient simplement se poser dessous.
427
+ change rien à ce bandeau : le corps vient simplement se poser dessous. Le destinataire
428
+ n'y figure pas — un fil ne lie que deux boîtes, le répéter à chaque ligne n'apprend
429
+ rien ;
428
430
  Un fil se parcourt avant de se lire, et le rafraîchissement automatique ne referme pas
429
431
  ce qui est en train d'être lu. **Le corps d'un message est rendu comme du markdown** : titres, listes,
430
432
  citations, tableaux, blocs de code, emphase et liens. C'est la langue dans laquelle les
@@ -434,9 +436,10 @@ correspondants et l'état de lecture :
434
436
  suivis. Une image n'est jamais chargée, elle est montrée par son intitulé et son lien.
435
437
  Les aperçus, eux, sont débarrassés de leur syntaxe par le serveur : ils tiennent sur une
436
438
  ligne, où un dièse ou une paire d'astérisques ne serait que du bruit ;
437
- - **le volet de détail** d'un message, ouvert à la demande : identifiants du message et du
438
- fil, les deux noms et l'adresse de chaque boîte, et le tableau de distribution (rôle,
439
- dossier, état). Tout ce qui se recopie a son bouton de copie ;
439
+ - **les détails d'un message**, dans une modale ouverte par l'icône d'information au bout
440
+ de sa ligne : l'objet, le nom et l'identifiant du fil, les identifiants, la date, la
441
+ priorité, les deux noms et l'adresse de chaque boîte, et le tableau de distribution
442
+ (rôle, dossier, état). Tout ce qui se recopie a son bouton de copie ;
440
443
  - une recherche unique, qui couvre le nom d'un fil, sa description et le texte de ses
441
444
  messages ;
442
445
  - **des panneaux redimensionnables** : chaque ligne de séparation se saisit à la souris,
@@ -447,8 +450,8 @@ correspondants et l'état de lecture :
447
450
 
448
451
  **Les adresses ne sont pas de l'information de lecture.** La console résout partout
449
452
  `box_9f2c4a01b3d7e5f8` en nom d'agent, et ne montre l'adresse qu'aux endroits où elle sert
450
- vraiment : le volet de détail, l'en-tête d'un fil et les boîtes sans nom, chaque fois avec
451
- un bouton de copie. Quand plusieurs sessions d'un même projet apparaissent, le nom de
453
+ vraiment : les détails d'un message, l'en-tête d'un fil et les boîtes sans nom, chaque fois
454
+ avec un bouton de copie. Quand plusieurs sessions d'un même projet apparaissent, le nom de
452
455
  l'agent est suivi de celui de la boîte entre parenthèses — sans quoi un fil se lirait
453
456
  « 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
454
457
  « jamais connectée » — elle reste listée, puisqu'elle a du courrier en attente.
@@ -638,7 +641,7 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
638
641
 
639
642
  ### Tests
640
643
 
641
- 284 tests couvrant : les suppressions de boîtes commandées depuis la console — une boîte
644
+ 287 tests couvrant : les suppressions de boîtes commandées depuis la console — une boîte
642
645
  désignée à la main, les vides, celles sans activité depuis une date — avec leur aperçu,
643
646
  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
644
647
  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
@@ -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
+ info: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z", "M12 11v5", "M12 8h.01"],
29
30
  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"],
30
31
  };
31
32
 
@@ -52,8 +53,6 @@ const state = {
52
53
  filter: "all",
53
54
  search: "",
54
55
  threadId: null,
55
- /** Message dont le volet de détail est ouvert, ou null si le volet est fermé. */
56
- messageId: null,
57
56
  /**
58
57
  * Messages dépliés dans le lecteur, par identifiant. Vide au départ : un fil s'ouvre
59
58
  * replié, et se parcourt avant de se lire.
@@ -95,7 +94,8 @@ const el = {
95
94
  chips: document.getElementById("chips"),
96
95
  threads: document.getElementById("threads"),
97
96
  reader: document.getElementById("reader"),
98
- sheet: document.getElementById("sheet"),
97
+ details: document.getElementById("details"),
98
+ detailsBody: document.getElementById("details-body"),
99
99
  purge: document.getElementById("purge"),
100
100
  purgeOpen: document.getElementById("purge-open"),
101
101
  purgeClose: document.getElementById("purge-close"),
@@ -242,6 +242,19 @@ function avatar(agentId, extraClass) {
242
242
  return avatarFor(agentId, label, title, extraClass, !who.registered);
243
243
  }
244
244
 
245
+ /**
246
+ * Le nom de l'agent, et lui seul.
247
+ *
248
+ * `party().name` accole le nom de la boîte dès que plusieurs sessions d'un projet
249
+ * existent : indispensable là où deux sessions doivent se distinguer, encombrant dans le
250
+ * lecteur d'un fil, où l'on suit une conversation entre deux correspondants déjà connus.
251
+ * Ce que la boîte ajoute — son nom, son adresse — se lit dans les détails du message.
252
+ */
253
+ function agentLabel(agentId) {
254
+ const who = party(agentId);
255
+ return who.agentName || who.box || shortAddress(agentId);
256
+ }
257
+
245
258
  /**
246
259
  * Pastille de l'agent, et non de la boîte.
247
260
  *
@@ -462,9 +475,6 @@ function select(kind, key) {
462
475
  if (isSelected(kind, key)) return;
463
476
  state.selection = { kind, key };
464
477
  state.threadId = null;
465
- state.messageId = null;
466
- el.sheet.hidden = true;
467
- el.console.classList.remove("has-sheet");
468
478
  refresh();
469
479
  }
470
480
 
@@ -856,7 +866,7 @@ function renderThread(data) {
856
866
  node(
857
867
  "span",
858
868
  "reader-participants",
859
- `${participants.map((id) => party(id).name).join(", ")} · ${plural(messages.length, "message")}`
869
+ `${participants.map(agentLabel).join(", ")} · ${plural(messages.length, "message")}`
860
870
  )
861
871
  );
862
872
  const threadId = node("span", "mono muted", data.thread_id);
@@ -900,8 +910,7 @@ function fillMessage(item, message, isLast) {
900
910
  const open = state.openMessages.has(message.message_id);
901
911
  const classes = ["message"];
902
912
  if (open) classes.push("is-expanded");
903
- if (state.messageId === message.message_id) classes.push("is-open");
904
- else if (isLast) classes.push("is-last");
913
+ if (isLast) classes.push("is-last");
905
914
  const row = node("div", classes.join(" "));
906
915
  const column = node("div", "message-body-col");
907
916
 
@@ -916,16 +925,16 @@ function fillMessage(item, message, isLast) {
916
925
  head.appendChild(agentAvatar(message.from));
917
926
 
918
927
  const identity = node("span", "message-identity");
919
- identity.appendChild(node("span", "message-from", party(message.from).name));
920
- identity.appendChild(node("span", "message-to", `→ ${party(message.to).name}`));
928
+ identity.appendChild(node("span", "message-from", agentLabel(message.from)));
921
929
  if (message.priority === "high") identity.appendChild(tag("tag tag--high", "priorité haute"));
922
930
  if (message.priority === "low") identity.appendChild(tag("tag", "priorité basse"));
923
931
  if (!message.read) {
924
932
  const dot = node("span", "message-dot");
925
- dot.title = `non lu par ${party(message.to).name}`;
933
+ dot.title = `non lu par ${agentLabel(message.to)}`;
926
934
  identity.appendChild(dot);
927
935
  }
928
936
  identity.appendChild(node("span", "message-time", formatShort(message.created_at)));
937
+ identity.appendChild(detailsButton(message));
929
938
  head.appendChild(identity);
930
939
 
931
940
  // L'objet, toujours : c'est le titre du message, et c'est lui qu'on cherche du regard.
@@ -954,16 +963,8 @@ function fillMessage(item, message, isLast) {
954
963
  const foot = node("div", "message-foot");
955
964
  foot.appendChild(
956
965
  message.read
957
- ? tag("tag tag--ok", `lu par ${party(message.to).name}`, "check")
958
- : tag("tag tag--warn", `non lu par ${party(message.to).name}`, "clock")
959
- );
960
- // Le volet de détail, lui, s'ouvre à côté : c'est un autre état que le dépli du corps.
961
- const sheetOpen = state.messageId === message.message_id;
962
- foot.appendChild(
963
- button("btn btn--link", sheetOpen ? "détails ouverts" : "détails", () => {
964
- if (sheetOpen) closeSheet();
965
- else openMessage(message.message_id);
966
- })
966
+ ? tag("tag tag--ok", `lu par ${agentLabel(message.to)}`, "check")
967
+ : tag("tag tag--warn", `non lu par ${agentLabel(message.to)}`, "clock")
967
968
  );
968
969
  content.appendChild(foot);
969
970
 
@@ -983,44 +984,82 @@ function messageText(message) {
983
984
  return container;
984
985
  }
985
986
 
986
- // --- Volet de détail -------------------------------------------------------
987
+ // --- Détails d'un message --------------------------------------------------
988
+
989
+ /**
990
+ * Bouton d'information, au bout de la ligne.
991
+ *
992
+ * Un fil ne lie que deux boîtes : répéter à chaque message qui écrit à qui, sous quel nom
993
+ * de boîte, encombre la lecture pour une information déjà connue. Elle est donc rangée
994
+ * ici, à un clic, avec tout le reste — adresses, identifiants, distribution.
995
+ */
996
+ function detailsButton(message) {
997
+ const open = node("button", "message-info");
998
+ open.type = "button";
999
+ open.title = "Détails du message";
1000
+ open.setAttribute("aria-label", "Détails du message");
1001
+ open.appendChild(icon("info"));
1002
+ // Le bandeau entier ouvre le message : ce clic-ci ne doit pas le replier au passage.
1003
+ open.addEventListener("click", (event) => {
1004
+ event.stopPropagation();
1005
+ openDetails(message.message_id);
1006
+ });
1007
+ return open;
1008
+ }
1009
+
1010
+ async function openDetails(messageId) {
1011
+ el.detailsBody.replaceChildren(node("p", "muted", "Chargement…"));
1012
+ el.details.showModal();
1013
+ try {
1014
+ renderDetails(await api(`/admin/messages/${encodeURIComponent(messageId)}`));
1015
+ } catch (error) {
1016
+ if (error.message === "unauthorized") el.details.close();
1017
+ else el.detailsBody.replaceChildren(node("p", "purge-error", `Erreur : ${error.message}`));
1018
+ }
1019
+ }
987
1020
 
988
- function renderSheet(message) {
1021
+ /** Tout ce que la conversation ne montre pas, et que la supervision peut vouloir lire. */
1022
+ function renderDetails(message) {
989
1023
  const container = document.createDocumentFragment();
990
1024
 
991
- const head = node("div", "sheet-head");
1025
+ const head = node("div", "modal-head");
992
1026
  const titles = node("div");
993
- titles.appendChild(node("h3", null, "Détails du message"));
994
- titles.appendChild(node("p", "sheet-subject", message.subject));
1027
+ titles.appendChild(node("h2", null, "Détails du message"));
1028
+ titles.appendChild(node("p", "details-subject", message.subject));
995
1029
  head.appendChild(titles);
996
1030
  const close = node("button", "btn btn--icon btn--ghost");
997
1031
  close.type = "button";
998
1032
  close.title = "Fermer";
999
- close.setAttribute("aria-label", "Fermer le volet");
1033
+ close.setAttribute("aria-label", "Fermer les détails");
1000
1034
  close.appendChild(icon("close"));
1001
- close.addEventListener("click", closeSheet);
1035
+ close.addEventListener("click", () => el.details.close());
1002
1036
  head.appendChild(close);
1003
1037
  container.appendChild(head);
1004
1038
 
1005
- const ids = node("div", "sheet-section");
1039
+ const ids = node("div", "details-section");
1006
1040
  ids.appendChild(node("span", "section-label", "Message"));
1007
1041
  ids.appendChild(kvCopy("Identifiant", message.message_id));
1008
- ids.appendChild(kvCopy("Fil", message.thread_id));
1042
+ ids.appendChild(kv("Objet", node("span", "kv-value", message.subject)));
1009
1043
  ids.appendChild(kv("Envoyé le", node("span", "kv-value", formatDate(message.created_at))));
1010
1044
  ids.appendChild(kv("Priorité", priorityTag(message.priority)));
1011
1045
  container.appendChild(ids);
1012
1046
 
1047
+ const thread = node("div", "details-section");
1048
+ thread.appendChild(node("span", "section-label", "Fil"));
1049
+ // Sans nom donné par un participant, le fil n'a que son identifiant et le sujet d'ouverture.
1050
+ thread.appendChild(kv("Nom", node("span", "kv-value", message.thread_title || "fil sans nom")));
1051
+ thread.appendChild(kvCopy("Identifiant", message.thread_id));
1052
+ container.appendChild(thread);
1053
+
1013
1054
  container.appendChild(partySection("De", message.from));
1014
1055
  container.appendChild(partySection("À", message.to));
1015
1056
 
1016
- const deliveries = node("div", "sheet-section");
1057
+ const deliveries = node("div", "details-section");
1017
1058
  deliveries.appendChild(node("span", "section-label", "Distribution"));
1018
1059
  deliveries.appendChild(deliveriesTable(message.deliveries || []));
1019
1060
  container.appendChild(deliveries);
1020
1061
 
1021
- el.sheet.replaceChildren(container);
1022
- el.sheet.hidden = false;
1023
- el.console.classList.add("has-sheet");
1062
+ el.detailsBody.replaceChildren(container);
1024
1063
  }
1025
1064
 
1026
1065
  function kv(key, valueNode) {
@@ -1050,7 +1089,7 @@ function priorityTag(priority) {
1050
1089
  */
1051
1090
  function partySection(label, agentId) {
1052
1091
  const who = party(agentId);
1053
- const section = node("div", "sheet-section");
1092
+ const section = node("div", "details-section");
1054
1093
  section.appendChild(node("span", "section-label", label));
1055
1094
 
1056
1095
  const block = node("div", "party");
@@ -1092,24 +1131,13 @@ function deliveriesTable(deliveries) {
1092
1131
  return table;
1093
1132
  }
1094
1133
 
1095
- function closeSheet() {
1096
- state.messageId = null;
1097
- el.sheet.hidden = true;
1098
- el.sheet.replaceChildren();
1099
- el.console.classList.remove("has-sheet");
1100
- if (state.threadId) refreshThread();
1101
- }
1102
-
1103
1134
  // --- Navigation ------------------------------------------------------------
1104
1135
 
1105
1136
  async function openThread(threadId) {
1106
1137
  state.threadId = threadId;
1107
- state.messageId = null;
1108
1138
  // Un fil s'ouvre replié, même celui qu'on rouvre : ce qui était déplié l'était pour
1109
1139
  // l'autre conversation.
1110
1140
  state.openMessages.clear();
1111
- el.sheet.hidden = true;
1112
- el.console.classList.remove("has-sheet");
1113
1141
  await refreshThread();
1114
1142
  await refresh({ keepThread: true });
1115
1143
  }
@@ -1126,16 +1154,6 @@ async function refreshThread() {
1126
1154
  }
1127
1155
  }
1128
1156
 
1129
- async function openMessage(messageId) {
1130
- state.messageId = messageId;
1131
- try {
1132
- renderSheet(await api(`/admin/messages/${encodeURIComponent(messageId)}`));
1133
- await refreshThread();
1134
- } catch (error) {
1135
- if (error.message !== "unauthorized") el.sheet.replaceChildren(node("div", "banner", error.message));
1136
- }
1137
- }
1138
-
1139
1157
  // --- Boucle de rafraîchissement --------------------------------------------
1140
1158
 
1141
1159
  async function refresh(options) {
@@ -1173,14 +1191,13 @@ async function refresh(options) {
1173
1191
  /**
1174
1192
  * Ce que chaque séparateur commande.
1175
1193
  *
1176
- * `edge` dit de quel côté se trouve le panneau réglé : les deux premiers bordent le
1177
- * panneau qui les précède, celui du volet de détail borde le panneau qui le suit. Sans
1178
- * cette distinction, tirer vers la droite élargirait un volet ancré à droite.
1194
+ * `edge` dit de quel côté se trouve le panneau réglé : ici, chacun borde le panneau qui
1195
+ * le précède. La distinction reste écrite car un panneau ancré à droite se réglerait à
1196
+ * l'envers, en comptant depuis son bord droit.
1179
1197
  */
1180
1198
  const PANELS = {
1181
1199
  "resize-agents": { name: "--w-agents", panel: "agentsPane", edge: "before", min: 180, max: 520, initial: 240 },
1182
1200
  "resize-threads": { name: "--w-threads", panel: "threadsPane", edge: "before", min: 260, max: 720, initial: 400 },
1183
- "resize-sheet": { name: "--w-sheet", panel: "sheet", edge: "after", min: 280, max: 720, initial: 360 },
1184
1201
  };
1185
1202
 
1186
1203
  const LAYOUT_KEY = "messenger.layout";
@@ -1457,9 +1474,6 @@ async function confirmPurge() {
1457
1474
  state.selection = { kind: "all", key: null };
1458
1475
  }
1459
1476
  state.threadId = null;
1460
- state.messageId = null;
1461
- el.sheet.hidden = true;
1462
- el.console.classList.remove("has-sheet");
1463
1477
  await refresh();
1464
1478
  } catch (error) {
1465
1479
  purge.armed = false;
@@ -1547,11 +1561,9 @@ el.logout.addEventListener("click", async () => {
1547
1561
  state.expanded = new Set();
1548
1562
  state.showEmpty = new Set();
1549
1563
  state.threadId = null;
1550
- state.messageId = null;
1564
+ state.openMessages = new Set();
1551
1565
  state.directory = new Map();
1552
1566
  state.groups = [];
1553
- el.sheet.hidden = true;
1554
- el.console.classList.remove("has-sheet");
1555
1567
  el.agents.replaceChildren();
1556
1568
  el.threads.replaceChildren();
1557
1569
  renderEmptyReader();
@@ -1,217 +1,215 @@
1
- <!doctype html>
2
- <html lang="fr">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>MCP Messenger — supervision</title>
7
- <link rel="stylesheet" href="./styles.css" />
8
- </head>
9
- <body>
10
- <header class="topbar">
11
- <div class="brand">
12
- <svg class="brand-mark" viewBox="0 0 24 24" aria-hidden="true">
13
- <path d="M22 12h-6l-2 3h-4l-2-3H2" />
14
- <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
15
- </svg>
16
- <h1>MCP Messenger</h1>
17
- <span class="pill pill--idle" id="status">
18
- <span class="pill-dot"></span>
19
- <span id="status-label">connexion…</span>
20
- </span>
21
- </div>
22
-
23
- <div class="topbar-search" id="search-box">
24
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
25
- <circle cx="11" cy="11" r="8" />
26
- <path d="m21 21-4.3-4.3" />
27
- </svg>
28
- <input type="search" id="search" placeholder="Rechercher un sujet, un fil, un agent…" />
29
- </div>
30
-
31
- <div class="topbar-controls" id="controls" hidden>
32
- <label class="switch">
33
- <input type="checkbox" id="auto-refresh" checked />
34
- <span class="switch-track"><span class="switch-knob"></span></span>
35
- <span class="switch-label">auto · 5 s</span>
36
- </label>
37
- <button type="button" id="refresh" class="btn btn--icon" title="Rafraîchir" aria-label="Rafraîchir">
38
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
39
- <path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
40
- <path d="M21 3v5h-5" />
41
- <path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
42
- <path d="M3 21v-5h5" />
43
- </svg>
44
- </button>
45
- <button type="button" id="purge-open" class="btn btn--icon" title="Purger les boîtes inactives" aria-label="Purger les boîtes inactives">
46
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
47
- <path d="M3 6h18" />
48
- <path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2" />
49
- <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
50
- </svg>
51
- </button>
52
- <button type="button" id="logout" class="btn btn--link">Se déconnecter</button>
53
- </div>
54
- </header>
55
-
56
- <!--
57
- Purge des boîtes inactives : la seule action de la console qui écrit, et elle
58
- détruit. Elle se fait donc en deux temps, l'aperçu puis la confirmation, et ne porte
59
- jamais que sur les boîtes affichées.
60
- -->
61
- <dialog class="modal" id="purge">
62
- <div class="modal-card">
63
- <div class="modal-head">
64
- <h2>Nettoyer les boîtes</h2>
65
- <button type="button" class="btn btn--icon btn--ghost" id="purge-close" aria-label="Fermer">
66
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
67
- <path d="M18 6 6 18" />
68
- <path d="m6 6 12 12" />
69
- </svg>
70
- </button>
71
- </div>
72
- <p class="modal-sub">
73
- Deux nettoyages, deux critères. La suppression est définitive dans les deux cas, et emporte les messages que
74
- plus personne ne pourrait lire ; ce que le correspondant a reçu de son côté reste intact. Un agent dont toutes
75
- les boîtes disparaissent quitte l'annuaire avec elles.
76
- </p>
77
-
78
- <!-- Premier critère : la boîte n'a jamais servi. Il ne dépend d'aucune date. -->
79
- <div class="purge-controls">
80
- <div class="purge-criterion">
81
- <span class="field-label">Boîtes vides</span>
82
- <span class="purge-hint">Rien n'y est jamais passé, ni envoi ni réception, quel que soit leur âge.</span>
83
- </div>
84
- <button type="button" class="btn" id="purge-preview-empty">Prévisualiser</button>
85
- </div>
86
-
87
- <!-- Second critère : la boîte a servi, mais plus rien ne l'a touchée depuis. -->
88
- <div class="purge-controls">
89
- <label class="field">
90
- <span class="field-label">Boîtes sans activité depuis le</span>
91
- <span class="field-input"><input type="date" id="purge-before" /></span>
92
- <span class="purge-hint">Ni connexion, ni message. Une boîte créée après cette date est épargnée.</span>
93
- </label>
94
- <button type="button" class="btn" id="purge-preview">Prévisualiser</button>
95
- </div>
96
-
97
- <div class="purge-result" id="purge-result"></div>
98
-
99
- <div class="modal-foot">
100
- <button type="button" class="btn btn--link" id="purge-cancel">Fermer</button>
101
- <button type="button" class="btn btn--danger" id="purge-confirm" disabled>Supprimer</button>
102
- </div>
103
- </div>
104
- </dialog>
105
-
106
- <!-- Écran de connexion : une carte seule, sans les panneaux vides derrière elle. -->
107
- <main class="login" id="login" hidden>
108
- <form class="card" id="login-form">
109
- <div class="card-badge">
110
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
111
- <rect x="3" y="11" width="18" height="11" rx="2" />
112
- <path d="M7 11V7a5 5 0 0 1 10 0v4" />
113
- </svg>
114
- </div>
115
- <h2>Console de supervision</h2>
116
- <p class="card-sub" id="login-origin">MCP Messenger</p>
117
-
118
- <label class="field">
119
- <span class="field-label">Jeton administrateur</span>
120
- <span class="field-input">
121
- <input type="password" id="token" autocomplete="current-password" placeholder="MESSENGER_ADMIN_TOKEN" />
122
- <button type="button" class="field-eye" id="token-reveal" aria-label="Afficher le jeton">
123
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
124
- <path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z" />
125
- <circle cx="12" cy="12" r="3" />
126
- </svg>
127
- </button>
128
- </span>
129
- <span class="field-error" id="token-error"></span>
130
- </label>
131
-
132
- <button type="submit" class="btn btn--primary btn--block">Se connecter</button>
133
- <p class="card-note">
134
- Le jeton n'est jamais conservé dans le navigateur : la session tient dans un cookie HttpOnly, fermé à la
135
- déconnexion.
136
- </p>
137
- </form>
138
- </main>
139
-
140
- <!-- Console : agents, fils, lecteur du fil, volet de détail. -->
141
- <main class="console" id="console" hidden>
142
- <aside class="agents" id="agents-pane">
143
- <div class="agents-head">
144
- <span class="section-label">Agents</span>
145
- <button type="button" class="btn btn--icon btn--ghost" id="collapse" title="Replier" aria-label="Replier la liste des agents" aria-expanded="true">
146
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
147
- <rect x="3" y="3" width="18" height="18" rx="2" />
148
- <path d="M9 3v18" />
149
- </svg>
150
- </button>
151
- </div>
152
- <ul class="agent-list" id="agents"></ul>
153
- <p class="agents-foot muted" id="agents-foot"></p>
154
- </aside>
155
-
156
- <!--
157
- Séparateurs déplaçables. Chacun redimensionne le panneau qu'il borde, à la souris
158
- comme aux flèches du clavier ; un double-clic rend sa largeur d'origine.
159
- -->
160
- <div
161
- class="resizer"
162
- id="resize-agents"
163
- role="separator"
164
- aria-orientation="vertical"
165
- aria-label="Largeur de la colonne des agents"
166
- tabindex="0"
167
- ></div>
168
-
169
- <section class="threads">
170
- <div class="threads-head">
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>
182
- <div class="chips" id="chips">
183
- <button type="button" class="chip is-active" data-filter="all">Tous</button>
184
- <button type="button" class="chip" data-filter="unread">Non lus</button>
185
- <button type="button" class="chip" data-filter="high">Priorité haute</button>
186
- </div>
187
- </div>
188
- <ul class="thread-list" id="threads"></ul>
189
- </section>
190
-
191
- <div
192
- class="resizer"
193
- id="resize-threads"
194
- role="separator"
195
- aria-orientation="vertical"
196
- aria-label="Largeur de la liste des fils"
197
- tabindex="0"
198
- ></div>
199
-
200
- <section class="reader" id="reader"></section>
201
-
202
- <!-- Suit le volet de détail : il n'existe que lorsque le volet est ouvert. -->
203
- <div
204
- class="resizer resizer--sheet"
205
- id="resize-sheet"
206
- role="separator"
207
- aria-orientation="vertical"
208
- aria-label="Largeur du volet de détail"
209
- tabindex="0"
210
- ></div>
211
-
212
- <aside class="sheet" id="sheet" hidden></aside>
213
- </main>
214
-
215
- <script type="module" src="./app.js"></script>
216
- </body>
217
- </html>
1
+ <!doctype html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>MCP Messenger — supervision</title>
7
+ <link rel="stylesheet" href="./styles.css" />
8
+ </head>
9
+ <body>
10
+ <header class="topbar">
11
+ <div class="brand">
12
+ <svg class="brand-mark" viewBox="0 0 24 24" aria-hidden="true">
13
+ <path d="M22 12h-6l-2 3h-4l-2-3H2" />
14
+ <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
15
+ </svg>
16
+ <h1>MCP Messenger</h1>
17
+ <span class="pill pill--idle" id="status">
18
+ <span class="pill-dot"></span>
19
+ <span id="status-label">connexion…</span>
20
+ </span>
21
+ </div>
22
+
23
+ <div class="topbar-search" id="search-box">
24
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
25
+ <circle cx="11" cy="11" r="8" />
26
+ <path d="m21 21-4.3-4.3" />
27
+ </svg>
28
+ <input type="search" id="search" placeholder="Rechercher un sujet, un fil, un agent…" />
29
+ </div>
30
+
31
+ <div class="topbar-controls" id="controls" hidden>
32
+ <label class="switch">
33
+ <input type="checkbox" id="auto-refresh" checked />
34
+ <span class="switch-track"><span class="switch-knob"></span></span>
35
+ <span class="switch-label">auto · 5 s</span>
36
+ </label>
37
+ <button type="button" id="refresh" class="btn btn--icon" title="Rafraîchir" aria-label="Rafraîchir">
38
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
39
+ <path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
40
+ <path d="M21 3v5h-5" />
41
+ <path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
42
+ <path d="M3 21v-5h5" />
43
+ </svg>
44
+ </button>
45
+ <button type="button" id="purge-open" class="btn btn--icon" title="Purger les boîtes inactives" aria-label="Purger les boîtes inactives">
46
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
47
+ <path d="M3 6h18" />
48
+ <path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2" />
49
+ <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
50
+ </svg>
51
+ </button>
52
+ <button type="button" id="logout" class="btn btn--link">Se déconnecter</button>
53
+ </div>
54
+ </header>
55
+
56
+ <!--
57
+ Purge des boîtes inactives : la seule action de la console qui écrit, et elle
58
+ détruit. Elle se fait donc en deux temps, l'aperçu puis la confirmation, et ne porte
59
+ jamais que sur les boîtes affichées.
60
+ -->
61
+ <dialog class="modal" id="purge">
62
+ <div class="modal-card">
63
+ <div class="modal-head">
64
+ <h2>Nettoyer les boîtes</h2>
65
+ <button type="button" class="btn btn--icon btn--ghost" id="purge-close" aria-label="Fermer">
66
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
67
+ <path d="M18 6 6 18" />
68
+ <path d="m6 6 12 12" />
69
+ </svg>
70
+ </button>
71
+ </div>
72
+ <p class="modal-sub">
73
+ Deux nettoyages, deux critères. La suppression est définitive dans les deux cas, et emporte les messages que
74
+ plus personne ne pourrait lire ; ce que le correspondant a reçu de son côté reste intact. Un agent dont toutes
75
+ les boîtes disparaissent quitte l'annuaire avec elles.
76
+ </p>
77
+
78
+ <!-- Premier critère : la boîte n'a jamais servi. Il ne dépend d'aucune date. -->
79
+ <div class="purge-controls">
80
+ <div class="purge-criterion">
81
+ <span class="field-label">Boîtes vides</span>
82
+ <span class="purge-hint">Rien n'y est jamais passé, ni envoi ni réception, quel que soit leur âge.</span>
83
+ </div>
84
+ <button type="button" class="btn" id="purge-preview-empty">Prévisualiser</button>
85
+ </div>
86
+
87
+ <!-- Second critère : la boîte a servi, mais plus rien ne l'a touchée depuis. -->
88
+ <div class="purge-controls">
89
+ <label class="field">
90
+ <span class="field-label">Boîtes sans activité depuis le</span>
91
+ <span class="field-input"><input type="date" id="purge-before" /></span>
92
+ <span class="purge-hint">Ni connexion, ni message. Une boîte créée après cette date est épargnée.</span>
93
+ </label>
94
+ <button type="button" class="btn" id="purge-preview">Prévisualiser</button>
95
+ </div>
96
+
97
+ <div class="purge-result" id="purge-result"></div>
98
+
99
+ <div class="modal-foot">
100
+ <button type="button" class="btn btn--link" id="purge-cancel">Fermer</button>
101
+ <button type="button" class="btn btn--danger" id="purge-confirm" disabled>Supprimer</button>
102
+ </div>
103
+ </div>
104
+ </dialog>
105
+
106
+ <!--
107
+ Détails d'un message : tout ce que la conversation ne montre pas — adresses, noms de
108
+ boîtes, identifiants, distribution. Une modale, parce que c'est une consultation
109
+ ponctuelle : elle s'ouvre sur demande et rend la place en se fermant.
110
+ -->
111
+ <dialog class="modal" id="details">
112
+ <div class="modal-card" id="details-body"></div>
113
+ </dialog>
114
+
115
+ <!-- Écran de connexion : une carte seule, sans les panneaux vides derrière elle. -->
116
+ <main class="login" id="login" hidden>
117
+ <form class="card" id="login-form">
118
+ <div class="card-badge">
119
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
120
+ <rect x="3" y="11" width="18" height="11" rx="2" />
121
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
122
+ </svg>
123
+ </div>
124
+ <h2>Console de supervision</h2>
125
+ <p class="card-sub" id="login-origin">MCP Messenger</p>
126
+
127
+ <label class="field">
128
+ <span class="field-label">Jeton administrateur</span>
129
+ <span class="field-input">
130
+ <input type="password" id="token" autocomplete="current-password" placeholder="MESSENGER_ADMIN_TOKEN" />
131
+ <button type="button" class="field-eye" id="token-reveal" aria-label="Afficher le jeton">
132
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
133
+ <path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z" />
134
+ <circle cx="12" cy="12" r="3" />
135
+ </svg>
136
+ </button>
137
+ </span>
138
+ <span class="field-error" id="token-error"></span>
139
+ </label>
140
+
141
+ <button type="submit" class="btn btn--primary btn--block">Se connecter</button>
142
+ <p class="card-note">
143
+ Le jeton n'est jamais conservé dans le navigateur : la session tient dans un cookie HttpOnly, fermé à la
144
+ déconnexion.
145
+ </p>
146
+ </form>
147
+ </main>
148
+
149
+ <!-- Console : agents, fils, lecteur du fil. -->
150
+ <main class="console" id="console" hidden>
151
+ <aside class="agents" id="agents-pane">
152
+ <div class="agents-head">
153
+ <span class="section-label">Agents</span>
154
+ <button type="button" class="btn btn--icon btn--ghost" id="collapse" title="Replier" aria-label="Replier la liste des agents" aria-expanded="true">
155
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
156
+ <rect x="3" y="3" width="18" height="18" rx="2" />
157
+ <path d="M9 3v18" />
158
+ </svg>
159
+ </button>
160
+ </div>
161
+ <ul class="agent-list" id="agents"></ul>
162
+ <p class="agents-foot muted" id="agents-foot"></p>
163
+ </aside>
164
+
165
+ <!--
166
+ Séparateurs déplaçables. Chacun redimensionne le panneau qu'il borde, à la souris
167
+ comme aux flèches du clavier ; un double-clic rend sa largeur d'origine.
168
+ -->
169
+ <div
170
+ class="resizer"
171
+ id="resize-agents"
172
+ role="separator"
173
+ aria-orientation="vertical"
174
+ aria-label="Largeur de la colonne des agents"
175
+ tabindex="0"
176
+ ></div>
177
+
178
+ <section class="threads">
179
+ <div class="threads-head">
180
+ <!--
181
+ L'en-tête dit sur quoi la liste est filtrée. Quand c'est un agent, il y tient la
182
+ place principale, pastille comprise : c'est le sujet de ce qu'on lit en dessous.
183
+ -->
184
+ <div class="threads-title">
185
+ <span class="threads-avatar" id="threads-avatar" hidden></span>
186
+ <div class="threads-heading">
187
+ <h2 id="threads-name">Fils</h2>
188
+ <p class="muted" id="threads-scope"></p>
189
+ </div>
190
+ </div>
191
+ <div class="chips" id="chips">
192
+ <button type="button" class="chip is-active" data-filter="all">Tous</button>
193
+ <button type="button" class="chip" data-filter="unread">Non lus</button>
194
+ <button type="button" class="chip" data-filter="high">Priorité haute</button>
195
+ </div>
196
+ </div>
197
+ <ul class="thread-list" id="threads"></ul>
198
+ </section>
199
+
200
+ <div
201
+ class="resizer"
202
+ id="resize-threads"
203
+ role="separator"
204
+ aria-orientation="vertical"
205
+ aria-label="Largeur de la liste des fils"
206
+ tabindex="0"
207
+ ></div>
208
+
209
+ <section class="reader" id="reader"></section>
210
+
211
+ </main>
212
+
213
+ <script type="module" src="./app.js"></script>
214
+ </body>
215
+ </html>
@@ -554,29 +554,16 @@ body {
554
554
  .console {
555
555
  --w-agents: 240px;
556
556
  --w-threads: 400px;
557
- --w-sheet: 360px;
558
557
  --w-gutter: 5px;
559
558
  display: grid;
560
559
  grid-template-columns: var(--w-agents) var(--w-gutter) var(--w-threads) var(--w-gutter) minmax(0, 1fr);
561
560
  height: calc(100vh - var(--topbar-h));
562
561
  }
563
562
 
564
- .console.has-sheet {
565
- grid-template-columns:
566
- var(--w-agents) var(--w-gutter) var(--w-threads) var(--w-gutter)
567
- minmax(0, 1fr) var(--w-gutter) var(--w-sheet);
568
- }
569
-
570
563
  .console.is-collapsed {
571
564
  grid-template-columns: 57px var(--w-gutter) var(--w-threads) var(--w-gutter) minmax(0, 1fr);
572
565
  }
573
566
 
574
- .console.is-collapsed.has-sheet {
575
- grid-template-columns:
576
- 57px var(--w-gutter) var(--w-threads) var(--w-gutter)
577
- minmax(0, 1fr) var(--w-gutter) var(--w-sheet);
578
- }
579
-
580
567
  /* --- Séparateurs déplaçables ---------------------------------------------- */
581
568
 
582
569
  .resizer {
@@ -598,11 +585,6 @@ body {
598
585
  outline: none;
599
586
  }
600
587
 
601
- /* Le volet de détail n'est pas toujours là ; son séparateur non plus. */
602
- .console:not(.has-sheet) .resizer--sheet {
603
- display: none;
604
- }
605
-
606
588
  /* Colonne repliée : il n'y a plus de largeur à régler, seulement un rail d'avatars. */
607
589
  .console.is-collapsed #resize-agents {
608
590
  cursor: default;
@@ -617,8 +599,7 @@ body {
617
599
 
618
600
  .console.is-resizing .agents,
619
601
  .console.is-resizing .threads,
620
- .console.is-resizing .reader,
621
- .console.is-resizing .sheet {
602
+ .console.is-resizing .reader {
622
603
  pointer-events: none;
623
604
  }
624
605
 
@@ -1420,6 +1401,30 @@ body {
1420
1401
  outline-offset: -2px;
1421
1402
  }
1422
1403
 
1404
+ /*
1405
+ L'information s'ouvre depuis la ligne sans la replier. Visuellement c'est un bouton posé
1406
+ dans un autre ; dans le balisage, non — il est rangé au bout de la ligne d'identité, et
1407
+ son clic ne remonte pas au bandeau.
1408
+ */
1409
+ .message-info {
1410
+ display: flex;
1411
+ padding: 2px;
1412
+ border: 0;
1413
+ border-radius: 50%;
1414
+ background: none;
1415
+ color: var(--muted);
1416
+ cursor: pointer;
1417
+ }
1418
+
1419
+ .message-info:hover {
1420
+ color: var(--accent);
1421
+ }
1422
+
1423
+ .message-info:focus-visible {
1424
+ outline: 2px solid var(--accent);
1425
+ outline-offset: 1px;
1426
+ }
1427
+
1423
1428
  .message-head > .avatar {
1424
1429
  grid-row: span 2;
1425
1430
  }
@@ -1435,11 +1440,11 @@ body {
1435
1440
 
1436
1441
  /*
1437
1442
  L'objet du message, sur sa ligne, replié comme déplié : c'est ce qui identifie le
1438
- message, et le bandeau ne doit pas changer de forme en s'ouvrant. Ce qui dépasse est
1439
- coupé — la ligne suivante appartient au message suivant.
1443
+ message, et le bandeau ne doit pas changer de forme en s'ouvrant. Il se lit sans graisse
1444
+ — c'est déjà la ligne la plus longue du bandeau, l'appuyer en ferait un titre de section.
1445
+ Ce qui dépasse est coupé : la ligne suivante appartient au message suivant.
1440
1446
  */
1441
1447
  .message-subject {
1442
- font-weight: 600;
1443
1448
  min-width: 0;
1444
1449
  white-space: nowrap;
1445
1450
  overflow: hidden;
@@ -1464,11 +1469,6 @@ body {
1464
1469
  font-weight: 600;
1465
1470
  }
1466
1471
 
1467
- .message-to {
1468
- font-size: 13px;
1469
- color: var(--muted);
1470
- }
1471
-
1472
1472
  .message-time {
1473
1473
  margin-left: auto;
1474
1474
  font-size: 12px;
@@ -1639,48 +1639,26 @@ body {
1639
1639
  font-size: 12px;
1640
1640
  }
1641
1641
 
1642
- /* --- Volet de détail ------------------------------------------------------ */
1642
+ /* --- Détails d'un message ------------------------------------------------- */
1643
1643
 
1644
- .sheet {
1645
- border-left: 1px solid var(--border);
1646
- background: var(--panel);
1647
- overflow-y: auto;
1648
- min-width: 0;
1649
- }
1650
-
1651
- .sheet-head {
1652
- display: flex;
1653
- align-items: flex-start;
1654
- gap: 8px;
1655
- padding: 18px 20px 14px;
1656
- border-bottom: 1px solid var(--border);
1657
- }
1658
-
1659
- .sheet-head h3 {
1660
- margin: 0;
1661
- font-size: 15px;
1662
- }
1663
-
1664
- .sheet-subject {
1644
+ /* L'objet, sous le titre de la modale : il dit de quel message on lit les détails. */
1645
+ .details-subject {
1665
1646
  margin: 4px 0 0;
1666
1647
  font-size: 13px;
1667
1648
  color: var(--muted);
1668
1649
  }
1669
1650
 
1670
- .sheet-head .btn {
1671
- margin-left: auto;
1672
- }
1673
-
1674
- .sheet-section {
1675
- padding: 16px 20px;
1676
- border-bottom: 1px solid var(--border);
1651
+ .details-section {
1652
+ padding: 16px 0;
1653
+ border-bottom: 1px solid var(--border-soft);
1677
1654
  }
1678
1655
 
1679
- .sheet-section:last-of-type {
1656
+ .details-section:last-of-type {
1680
1657
  border-bottom: 0;
1658
+ padding-bottom: 0;
1681
1659
  }
1682
1660
 
1683
- .sheet-section .section-label {
1661
+ .details-section .section-label {
1684
1662
  display: block;
1685
1663
  margin-bottom: 12px;
1686
1664
  }
@@ -1817,13 +1795,6 @@ body {
1817
1795
 
1818
1796
  /* --- Écrans étroits ------------------------------------------------------- */
1819
1797
 
1820
- @media (max-width: 1200px) {
1821
- .console.has-sheet,
1822
- .console.is-collapsed.has-sheet {
1823
- grid-template-columns: 57px 320px minmax(0, 1fr) 320px;
1824
- }
1825
- }
1826
-
1827
1798
  @media (max-width: 1000px) {
1828
1799
  .topbar-search {
1829
1800
  max-width: 240px;
@@ -1831,22 +1802,6 @@ body {
1831
1802
 
1832
1803
  .console,
1833
1804
  .console.is-collapsed {
1834
- grid-template-columns: 57px 300px minmax(0, 1fr);
1835
- }
1836
-
1837
- /* Trop étroit pour quatre colonnes : le volet recouvre le lecteur. */
1838
- .console.has-sheet,
1839
- .console.is-collapsed.has-sheet {
1840
- grid-template-columns: 57px 300px minmax(0, 1fr);
1841
- }
1842
-
1843
- .console.has-sheet .sheet {
1844
- position: fixed;
1845
- top: var(--topbar-h);
1846
- right: 0;
1847
- bottom: 0;
1848
- width: 340px;
1849
- max-width: 100%;
1850
- box-shadow: -8px 0 24px rgba(0, 0, 0, 0.12);
1805
+ grid-template-columns: 57px var(--w-gutter) 300px var(--w-gutter) minmax(0, 1fr);
1851
1806
  }
1852
1807
  }
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.24";
1
+ export declare const APP_VERSION = "1.0.26";
@@ -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.24";
2
+ export const APP_VERSION = "1.0.26";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.24",
3
+ "version": "1.0.26",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",