@imenam/mcp-messenger 1.0.23 → 1.0.24

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
@@ -391,7 +391,9 @@ correspondants et l'état de lecture :
391
391
  **replié par défaut**, et sélectionner un agent n'ouvre rien. Recliquer ne relâche rien
392
392
  non plus : on revient à la vue générale par « Tous les agents », en tête de colonne. La
393
393
  colonne se replie sur les seuls avatars d'agents quand la place manque, son bouton de
394
- repli restant en place : c'est lui qui la rouvre.
394
+ repli restant en place : c'est lui qui la rouvre. **Repliée, elle garde deux signes
395
+ distincts** : l'anneau désigne l'agent sélectionné, la pastille bleue dit qu'il reste du
396
+ non-lu — la même convention que le point de la liste des fils.
395
397
 
396
398
  **Le classement se fait en deux temps : d'abord ce qui a servi, ensuite ce qui est
397
399
  vide ; puis, dans chacun des deux, la dernière activité, la plus récente en tête.** Une
@@ -418,7 +420,13 @@ correspondants et l'état de lecture :
418
420
  - **trois puces** au-dessus de la liste : tous, non lus, priorité haute ;
419
421
  - **le lecteur du fil** : la conversation entière dans l'ordre, un bloc par message, avec
420
422
  qui écrit à qui — en clair, pas en adresses — et une pastille disant si le destinataire
421
- l'a lu. **Le corps d'un message est rendu comme du markdown** : titres, listes,
423
+ l'a lu. **Les messages s'ouvrent repliés** : chacun tient alors sur sa ligne d'identité —
424
+ pastille, qui écrit à qui, heure — et sur **l'objet du message**, qui est ce qui
425
+ l'identifie. Les deux forment le bandeau qui l'ouvre et le referme, cliquable d'un bout à
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.
428
+ Un fil se parcourt avant de se lire, et le rafraîchissement automatique ne referme pas
429
+ ce qui est en train d'être lu. **Le corps d'un message est rendu comme du markdown** : titres, listes,
422
430
  citations, tableaux, blocs de code, emphase et liens. C'est la langue dans laquelle les
423
431
  agents s'écrivent, la console la lit donc ainsi plutôt que d'afficher sa syntaxe. Le
424
432
  rendu construit des nœuds un par un et n'insère jamais de HTML : rien de ce qu'un agent
@@ -630,7 +638,7 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
630
638
 
631
639
  ### Tests
632
640
 
633
- 274 tests couvrant : les suppressions de boîtes commandées depuis la console — une boîte
641
+ 284 tests couvrant : les suppressions de boîtes commandées depuis la console — une boîte
634
642
  désignée à la main, les vides, celles sans activité depuis une date — avec leur aperçu,
635
643
  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
636
644
  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
@@ -54,6 +54,11 @@ const state = {
54
54
  threadId: null,
55
55
  /** Message dont le volet de détail est ouvert, ou null si le volet est fermé. */
56
56
  messageId: null,
57
+ /**
58
+ * Messages dépliés dans le lecteur, par identifiant. Vide au départ : un fil s'ouvre
59
+ * replié, et se parcourt avant de se lire.
60
+ */
61
+ openMessages: new Set(),
57
62
  collapsed: false,
58
63
  /** Annuaire des boîtes, indexé par adresse : rempli par chaque `/admin/overview`. */
59
64
  directory: new Map(),
@@ -869,29 +874,82 @@ function renderThread(data) {
869
874
  el.reader.replaceChildren(container);
870
875
  }
871
876
 
877
+ /**
878
+ * Un message du fil, replié ou déplié.
879
+ *
880
+ * Un fil se relit d'abord de haut en bas : ce qui doit sauter aux yeux est qui a écrit,
881
+ * quand, et sur quoi. Les corps sont donc repliés au départ, chaque message tenant sur sa
882
+ * ligne de titre, et c'est cette ligne qui l'ouvre. L'état est retenu par identifiant de
883
+ * message : le rafraîchissement automatique ne referme donc pas ce qu'on est en train de
884
+ * lire.
885
+ */
872
886
  function messageRow(message, isLast) {
873
887
  const item = node("li");
888
+ fillMessage(item, message, isLast);
889
+ return item;
890
+ }
891
+
892
+ /**
893
+ * Remplit la ligne d'un message, dépliée ou non.
894
+ *
895
+ * Le nœud est donné plutôt que rendu : ouvrir un message ne redessine que lui, et c'est
896
+ * bien cette ligne-là, celle qui est dans la page, qu'il faut réécrire — en reconstruire
897
+ * une autre reviendrait à modifier un élément que personne ne regarde.
898
+ */
899
+ function fillMessage(item, message, isLast) {
900
+ const open = state.openMessages.has(message.message_id);
874
901
  const classes = ["message"];
902
+ if (open) classes.push("is-expanded");
875
903
  if (state.messageId === message.message_id) classes.push("is-open");
876
904
  else if (isLast) classes.push("is-last");
877
905
  const row = node("div", classes.join(" "));
906
+ const column = node("div", "message-body-col");
878
907
 
879
- row.appendChild(agentAvatar(message.from));
908
+ /*
909
+ Tout ce qui est visible d'un message replié est cliquable, pastille comprise : le
910
+ bandeau est le bouton, et le survol le grise pour qu'on le voie comme tel. Pas de
911
+ chevron — sur une ligne qui s'annonce déjà comme une cible, il n'apprend rien.
912
+ */
913
+ const head = node("button", "message-head");
914
+ head.type = "button";
915
+ head.setAttribute("aria-expanded", open ? "true" : "false");
916
+ head.appendChild(agentAvatar(message.from));
917
+
918
+ 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}`));
921
+ if (message.priority === "high") identity.appendChild(tag("tag tag--high", "priorité haute"));
922
+ if (message.priority === "low") identity.appendChild(tag("tag", "priorité basse"));
923
+ if (!message.read) {
924
+ const dot = node("span", "message-dot");
925
+ dot.title = `non lu par ${party(message.to).name}`;
926
+ identity.appendChild(dot);
927
+ }
928
+ identity.appendChild(node("span", "message-time", formatShort(message.created_at)));
929
+ head.appendChild(identity);
880
930
 
881
- const column = node("div", "message-body-col");
882
- const head = node("div", "message-head");
883
- head.appendChild(node("span", "message-from", party(message.from).name));
884
- head.appendChild(node("span", "message-to", `→ ${party(message.to).name}`));
885
- if (message.priority === "high") head.appendChild(tag("tag tag--high", "priorité haute"));
886
- if (message.priority === "low") head.appendChild(tag("tag", "priorité basse"));
887
- head.appendChild(node("span", "message-time", formatShort(message.created_at)));
931
+ // L'objet, toujours : c'est le titre du message, et c'est lui qu'on cherche du regard.
932
+ // Le bandeau ne bouge donc pas quand le message s'ouvre, seul le corps vient dessous.
933
+ head.appendChild(node("span", "message-subject", message.subject || "sans objet"));
934
+
935
+ head.addEventListener("click", () => {
936
+ if (open) state.openMessages.delete(message.message_id);
937
+ else state.openMessages.add(message.message_id);
938
+ fillMessage(item, message, isLast);
939
+ });
888
940
  column.appendChild(head);
889
941
 
890
- // Le sujet n'est répété que s'il diffère du nom du fil : sinon c'est du bruit.
891
- if (message.subject && message.subject !== message.thread_title) {
892
- column.appendChild(node("p", "message-subject", message.subject));
942
+ if (!open) {
943
+ row.appendChild(column);
944
+ item.replaceChildren(row);
945
+ return;
893
946
  }
894
- column.appendChild(messageText(message));
947
+
948
+ // Ce qui suit la ligne de titre s'aligne sous le texte, pas sous la pastille.
949
+ const content = node("div", "message-content");
950
+ column.appendChild(content);
951
+
952
+ content.appendChild(messageText(message));
895
953
 
896
954
  const foot = node("div", "message-foot");
897
955
  foot.appendChild(
@@ -899,18 +957,18 @@ function messageRow(message, isLast) {
899
957
  ? tag("tag tag--ok", `lu par ${party(message.to).name}`, "check")
900
958
  : tag("tag tag--warn", `non lu par ${party(message.to).name}`, "clock")
901
959
  );
902
- const open = state.messageId === message.message_id;
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;
903
962
  foot.appendChild(
904
- button("btn btn--link", open ? "détails ouverts" : "détails", () => {
905
- if (open) closeSheet();
963
+ button("btn btn--link", sheetOpen ? "détails ouverts" : "détails", () => {
964
+ if (sheetOpen) closeSheet();
906
965
  else openMessage(message.message_id);
907
966
  })
908
967
  );
909
- column.appendChild(foot);
968
+ content.appendChild(foot);
910
969
 
911
970
  row.appendChild(column);
912
- item.appendChild(row);
913
- return item;
971
+ item.replaceChildren(row);
914
972
  }
915
973
 
916
974
  /**
@@ -1047,6 +1105,9 @@ function closeSheet() {
1047
1105
  async function openThread(threadId) {
1048
1106
  state.threadId = threadId;
1049
1107
  state.messageId = null;
1108
+ // Un fil s'ouvre replié, même celui qu'on rouvre : ce qui était déplié l'était pour
1109
+ // l'autre conversation.
1110
+ state.openMessages.clear();
1050
1111
  el.sheet.hidden = true;
1051
1112
  el.console.classList.remove("has-sheet");
1052
1113
  await refreshThread();
@@ -992,11 +992,39 @@ body {
992
992
  padding-left: 8px;
993
993
  }
994
994
 
995
- /* Le badge des non-lus survit au repli, réduit à une pastille sur l'avatar. */
996
- .is-collapsed .agent.has-unread .avatar {
995
+ /*
996
+ Colonne repliée, deux signes distincts et souvent simultanés.
997
+
998
+ L'anneau dit ce qui est **sélectionné** : c'est un contour, il entoure, il désigne — la
999
+ teinte de fond qui sert à ça dans la colonne dépliée n'a plus de place ici. La pastille
1000
+ dit qu'il reste du **non-lu** : un point posé sur l'avatar, la même convention que la
1001
+ liste des fils. L'un n'implique pas l'autre, ils doivent donc se lire séparément.
1002
+ */
1003
+ .is-collapsed .agent.is-selected {
1004
+ background: none;
1005
+ }
1006
+
1007
+ .is-collapsed .agent.is-selected .avatar {
997
1008
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
998
1009
  }
999
1010
 
1011
+ .is-collapsed .agent.has-unread .avatar {
1012
+ position: relative;
1013
+ }
1014
+
1015
+ .is-collapsed .agent.has-unread .avatar::after {
1016
+ content: "";
1017
+ position: absolute;
1018
+ top: -1px;
1019
+ right: -1px;
1020
+ width: 9px;
1021
+ height: 9px;
1022
+ border-radius: 50%;
1023
+ background: var(--accent);
1024
+ /* Le liseré détache la pastille de l'avatar, quelle que soit la couleur de celui-ci. */
1025
+ box-shadow: 0 0 0 2px var(--bg);
1026
+ }
1027
+
1000
1028
  .is-collapsed .agents {
1001
1029
  overflow-x: hidden;
1002
1030
  }
@@ -1320,14 +1348,25 @@ body {
1320
1348
  .messages {
1321
1349
  list-style: none;
1322
1350
  margin: 0;
1323
- padding: 20px 32px 40px;
1351
+ /* Le retrait latéral du texte vient d'ici plus du cadre d'un message : 20 + 12. */
1352
+ padding: 12px 20px 40px;
1353
+ }
1354
+
1355
+ /*
1356
+ Un trait entre deux messages, et non un simple blanc.
1357
+
1358
+ Repliés, les messages se suivent de près : sans démarcation, on devine où l'un s'arrête
1359
+ à la seule présence de la pastille suivante. Le trait court sur toute la largeur de la
1360
+ liste, et l'écart de part et d'autre vient du cadre des messages eux-mêmes.
1361
+ */
1362
+ .messages > li + li {
1363
+ border-top-width: 1px;
1364
+ border-top-style: solid;
1365
+ border-top-color: var(--border);
1324
1366
  }
1325
1367
 
1326
1368
  .message {
1327
- display: flex;
1328
- gap: 12px;
1329
1369
  padding: 12px;
1330
- margin: 0 -12px;
1331
1370
  border-radius: var(--radius);
1332
1371
  }
1333
1372
 
@@ -1342,15 +1381,83 @@ body {
1342
1381
  }
1343
1382
 
1344
1383
  .message-body-col {
1345
- flex: 1;
1346
1384
  min-width: 0;
1347
1385
  }
1348
1386
 
1387
+ /*
1388
+ Le bandeau qui ouvre et referme le message.
1389
+
1390
+ Il tient tout ce qu'un message replié montre : la pastille, qui écrit à qui, l'heure, et
1391
+ la ligne de résumé. Rien n'y ressemble à un bouton tant qu'on ne le survole pas — c'est
1392
+ le fond gris qui dit alors que la zone entière est une cible, mieux qu'un chevron.
1393
+
1394
+ Deux colonnes, deux lignes : la pastille tient sur les deux, le texte occupe la seconde
1395
+ colonne — qui écrit à qui sur la première ligne, l'objet du message sur la seconde.
1396
+ */
1349
1397
  .message-head {
1398
+ display: grid;
1399
+ grid-template-columns: auto minmax(0, 1fr);
1400
+ align-items: center;
1401
+ gap: 2px 12px;
1402
+ width: 100%;
1403
+ padding: 6px;
1404
+ margin: -6px;
1405
+ border: 0;
1406
+ border-radius: var(--radius);
1407
+ background: none;
1408
+ font: inherit;
1409
+ color: inherit;
1410
+ text-align: left;
1411
+ cursor: pointer;
1412
+ }
1413
+
1414
+ .message-head:hover {
1415
+ background: var(--neutral-soft);
1416
+ }
1417
+
1418
+ .message-head:focus-visible {
1419
+ outline: 2px solid var(--accent);
1420
+ outline-offset: -2px;
1421
+ }
1422
+
1423
+ .message-head > .avatar {
1424
+ grid-row: span 2;
1425
+ }
1426
+
1427
+ /* Qui écrit à qui, et quand : l'heure est poussée au bout de la ligne. */
1428
+ .message-identity {
1350
1429
  display: flex;
1351
- align-items: baseline;
1430
+ align-items: center;
1352
1431
  gap: 8px;
1353
1432
  flex-wrap: wrap;
1433
+ min-width: 0;
1434
+ }
1435
+
1436
+ /*
1437
+ 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.
1440
+ */
1441
+ .message-subject {
1442
+ font-weight: 600;
1443
+ min-width: 0;
1444
+ white-space: nowrap;
1445
+ overflow: hidden;
1446
+ text-overflow: ellipsis;
1447
+ }
1448
+
1449
+ /* Le corps s'aligne sous le texte du bandeau, jamais sous la pastille. */
1450
+ .message-content {
1451
+ padding-left: 40px;
1452
+ }
1453
+
1454
+ /* Non lu : le même point que dans la liste des fils, et non un anneau ni une couleur. */
1455
+ .message-dot {
1456
+ width: 8px;
1457
+ height: 8px;
1458
+ border-radius: 50%;
1459
+ background: var(--accent);
1460
+ flex: none;
1354
1461
  }
1355
1462
 
1356
1463
  .message-from {
@@ -1369,15 +1476,8 @@ body {
1369
1476
  white-space: nowrap;
1370
1477
  }
1371
1478
 
1372
- .message-subject {
1373
- margin: 8px 0 0;
1374
- font-size: 13px;
1375
- font-weight: 600;
1376
- color: var(--muted);
1377
- }
1378
-
1379
1479
  .message-text {
1380
- margin: 6px 0 0;
1480
+ margin: 8px 0 0;
1381
1481
  white-space: pre-wrap;
1382
1482
  overflow-wrap: anywhere;
1383
1483
  }
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.23";
1
+ export declare const APP_VERSION = "1.0.24";
@@ -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.23";
2
+ export const APP_VERSION = "1.0.24";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.23",
3
+ "version": "1.0.24",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",