@imenam/mcp-messenger 1.0.22 → 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
@@ -382,13 +382,18 @@ organisés autour du fil — l'unité d'une correspondance, celle qui porte le s
382
382
  correspondants et l'état de lecture :
383
383
 
384
384
  - **une colonne d'agents à deux niveaux** : un agent tient une boîte par session ouverte,
385
- la colonne l'écrit donc ainsi. Chaque agent est **replié par défaut** et se déplie d'un
386
- chevron sur ses boîtes ; une boîte que son agent n'a pas nommée s'affiche à son adresse,
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**. 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
390
- replie sur les seuls avatars d'agents quand la place manque, son bouton de repli restant
391
- en place : c'est lui qui la rouvre.
385
+ la colonne l'écrit donc ainsi. Une boîte que son agent n'a pas nommée s'affiche à son
386
+ adresse, la seule chose qui la distingue alors de ses sœurs.
387
+
388
+ **Deux gestes, deux effets, sans recouvrement.** Cliquer le nom d'un agent filtre les
389
+ fils sur **toutes ses sessions**, cliquer une boîte sur **cette seule session** ; le
390
+ chevron, lui, déplie ou replie les boîtes sans toucher au filtre. Chaque agent est
391
+ **replié par défaut**, et sélectionner un agent n'ouvre rien. Recliquer ne relâche rien
392
+ non plus : on revient à la vue générale par « Tous les agents », en tête de colonne. La
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. **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.
392
397
 
393
398
  **Le classement se fait en deux temps : d'abord ce qui a servi, ensuite ce qui est
394
399
  vide ; puis, dans chacun des deux, la dernière activité, la plus récente en tête.** Une
@@ -415,7 +420,13 @@ correspondants et l'état de lecture :
415
420
  - **trois puces** au-dessus de la liste : tous, non lus, priorité haute ;
416
421
  - **le lecteur du fil** : la conversation entière dans l'ordre, un bloc par message, avec
417
422
  qui écrit à qui — en clair, pas en adresses — et une pastille disant si le destinataire
418
- 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,
419
430
  citations, tableaux, blocs de code, emphase et liens. C'est la langue dans laquelle les
420
431
  agents s'écrivent, la console la lit donc ainsi plutôt que d'afficher sa syntaxe. Le
421
432
  rendu construit des nœuds un par un et n'insère jamais de HTML : rien de ce qu'un agent
@@ -627,7 +638,7 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
627
638
 
628
639
  ### Tests
629
640
 
630
- 273 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
631
642
  désignée à la main, les vides, celles sans activité depuis une date — avec leur aperçu,
632
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
633
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(),
@@ -528,9 +533,10 @@ function allRow(overview) {
528
533
  /**
529
534
  * Un agent et, dépliées sous lui, ses boîtes.
530
535
  *
531
- * Deux zones de clic, deux gestes distincts : le chevron ouvre ou ferme, le nom filtre sur
532
- * l'ensemble des sessions du projet. Filtrer déplie au passage, pour que la sélection
533
- * montre toujours ce qu'elle recouvre.
536
+ * Deux zones de clic, deux gestes distincts et sans effet l'un sur l'autre : le chevron
537
+ * ouvre ou ferme, le nom filtre sur l'ensemble des sessions du projet. Filtrer ne déplie
538
+ * pas — un clic ne doit faire que ce qu'il annonce, et voir les sessions d'un agent est
539
+ * une autre question que ne lire que ses fils.
534
540
  */
535
541
  function groupRow(group) {
536
542
  const item = node("li", "agent-group");
@@ -577,10 +583,7 @@ function groupRow(group) {
577
583
  row.appendChild(text);
578
584
  if (group.unread > 0) row.appendChild(node("span", "count count--accent", String(group.unread)));
579
585
  row.title = `${group.name} · ${counts}`;
580
- row.addEventListener("click", () => {
581
- state.expanded.add(group.key);
582
- select("agent", group.key);
583
- });
586
+ row.addEventListener("click", () => select("agent", group.key));
584
587
  header.appendChild(row);
585
588
  item.appendChild(header);
586
589
 
@@ -871,29 +874,82 @@ function renderThread(data) {
871
874
  el.reader.replaceChildren(container);
872
875
  }
873
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
+ */
874
886
  function messageRow(message, isLast) {
875
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);
876
901
  const classes = ["message"];
902
+ if (open) classes.push("is-expanded");
877
903
  if (state.messageId === message.message_id) classes.push("is-open");
878
904
  else if (isLast) classes.push("is-last");
879
905
  const row = node("div", classes.join(" "));
906
+ const column = node("div", "message-body-col");
907
+
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
- row.appendChild(agentAvatar(message.from));
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"));
882
934
 
883
- const column = node("div", "message-body-col");
884
- const head = node("div", "message-head");
885
- head.appendChild(node("span", "message-from", party(message.from).name));
886
- head.appendChild(node("span", "message-to", `→ ${party(message.to).name}`));
887
- if (message.priority === "high") head.appendChild(tag("tag tag--high", "priorité haute"));
888
- if (message.priority === "low") head.appendChild(tag("tag", "priorité basse"));
889
- head.appendChild(node("span", "message-time", formatShort(message.created_at)));
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
+ });
890
940
  column.appendChild(head);
891
941
 
892
- // Le sujet n'est répété que s'il diffère du nom du fil : sinon c'est du bruit.
893
- if (message.subject && message.subject !== message.thread_title) {
894
- column.appendChild(node("p", "message-subject", message.subject));
942
+ if (!open) {
943
+ row.appendChild(column);
944
+ item.replaceChildren(row);
945
+ return;
895
946
  }
896
- 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));
897
953
 
898
954
  const foot = node("div", "message-foot");
899
955
  foot.appendChild(
@@ -901,18 +957,18 @@ function messageRow(message, isLast) {
901
957
  ? tag("tag tag--ok", `lu par ${party(message.to).name}`, "check")
902
958
  : tag("tag tag--warn", `non lu par ${party(message.to).name}`, "clock")
903
959
  );
904
- 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;
905
962
  foot.appendChild(
906
- button("btn btn--link", open ? "détails ouverts" : "détails", () => {
907
- if (open) closeSheet();
963
+ button("btn btn--link", sheetOpen ? "détails ouverts" : "détails", () => {
964
+ if (sheetOpen) closeSheet();
908
965
  else openMessage(message.message_id);
909
966
  })
910
967
  );
911
- column.appendChild(foot);
968
+ content.appendChild(foot);
912
969
 
913
970
  row.appendChild(column);
914
- item.appendChild(row);
915
- return item;
971
+ item.replaceChildren(row);
916
972
  }
917
973
 
918
974
  /**
@@ -1049,6 +1105,9 @@ function closeSheet() {
1049
1105
  async function openThread(threadId) {
1050
1106
  state.threadId = threadId;
1051
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();
1052
1111
  el.sheet.hidden = true;
1053
1112
  el.console.classList.remove("has-sheet");
1054
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.22";
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.22";
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.22",
3
+ "version": "1.0.24",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",