@imenam/mcp-messenger 1.0.14 → 1.0.15

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
@@ -347,11 +347,16 @@ correspondants et l'état de lecture :
347
347
  **toutes ses sessions**, cliquer une boîte sur **cette seule session**. La colonne se
348
348
  replie sur les seuls avatars d'agents quand la place manque.
349
349
 
350
- **Le classement se fait sur la dernière activité, la plus récente en tête** — les
351
- sessions au sein d'un agent comme les agents entre eux, un agent héritant de l'activité
352
- de sa session la plus vivante. Chaque boîte affiche donc la date qui la classe ; celles
353
- qui n'ont jamais rien fait ferment la marche, et deux dates identiques sont départagées
354
- par l'adresse pour que l'ordre ne bouge pas d'un rafraîchissement à l'autre ;
350
+ **Le classement se fait en deux temps : d'abord ce qui a servi, ensuite ce qui est
351
+ vide ; puis, dans chacun des deux, la dernière activité, la plus récente en tête.** Une
352
+ boîte est vide tant que rien n'y est passé, ni envoi ni réception — le serveur en alloue
353
+ une par session ouverte, et beaucoup n'échangeront jamais rien. Ces boîtes-là sont
354
+ **masquées par défaut**, derrière un bouton en fin de dépli qui dit combien il y en a ;
355
+ les masquer ne cache jamais de courrier, une boîte sans réception n'ayant par
356
+ construction aucun non-lu. Un agent dont aucune session n'a rien échangé passe de même
357
+ derrière les autres. Chaque boîte affiche la date qui la classe, et deux dates
358
+ identiques sont départagées par l'adresse pour que l'ordre ne bouge pas d'un
359
+ rafraîchissement à l'autre ;
355
360
  - **la liste des fils**, du plus récemment actif au plus ancien, chacun résumé par son
356
361
  nom, ses correspondants, son dernier message et sa priorité la plus haute. Trois puces
357
362
  la filtrent : tous, non lus, priorité haute ;
@@ -33,6 +33,8 @@ const state = {
33
33
  selection: { kind: "all", key: null },
34
34
  /** Noms d'agents dépliés dans la colonne de gauche. Tout est replié au départ. */
35
35
  expanded: new Set(),
36
+ /** Agents dont on a demandé à voir les boîtes vides. Elles sont cachées au départ. */
37
+ showEmpty: new Set(),
36
38
  /** "all" | "unread" | "high" — les puces au-dessus de la liste des fils. */
37
39
  filter: "all",
38
40
  search: "",
@@ -287,23 +289,41 @@ function groupByAgent(boxes) {
287
289
  for (const box of boxes) {
288
290
  const key = box.agent_name || box.id;
289
291
  if (!groups.has(key)) {
290
- groups.set(key, { key, name: box.agent_name || boxLabel(box), boxes: [], unread: 0, activity: "" });
292
+ groups.set(key, { key, name: box.agent_name || boxLabel(box), boxes: [], unread: 0, filled: 0, activity: "" });
291
293
  }
292
294
  const group = groups.get(key);
293
295
  group.boxes.push(box);
294
296
  group.unread += box.unread;
297
+ if (!isEmptyBox(box)) group.filled += 1;
295
298
  // L'agent hérite de l'activité de sa session la plus vivante.
296
299
  if (activityOf(box) > group.activity) group.activity = activityOf(box);
297
300
  }
298
301
  const list = [...groups.values()];
299
- for (const group of list) group.boxes.sort(byActivity);
302
+ for (const group of list) group.boxes.sort(byBox);
300
303
  // Le classement est refait ici plutôt que laissé à l'ordre d'arrivée de l'annuaire :
301
- // ce que la colonne promet — le plus récent en tête — ne doit pas dépendre du hasard
302
- // d'un tri fait ailleurs.
303
- list.sort((a, b) => compareActivity(a.activity, b.activity) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
304
+ // ce que la colonne promet ne doit pas dépendre du hasard d'un tri fait ailleurs. Un
305
+ // agent dont aucune session n'a rien échangé passe derrière les autres, comme ses boîtes.
306
+ list.sort(
307
+ (a, b) =>
308
+ compareEmpty(a.filled === 0, b.filled === 0) ||
309
+ compareActivity(a.activity, b.activity) ||
310
+ (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)
311
+ );
304
312
  return list;
305
313
  }
306
314
 
315
+ /**
316
+ * Une boîte est vide tant que rien n'y est passé, ni envoi ni réception.
317
+ *
318
+ * Le serveur alloue une boîte à chaque session ouverte : beaucoup n'échangeront jamais
319
+ * rien et n'ont donc rien à dire à qui supervise. Une boîte vide n'a par construction
320
+ * aucun non-lu — `received` à zéro l'implique — la masquer ne cache donc jamais de
321
+ * courrier en attente.
322
+ */
323
+ function isEmptyBox(box) {
324
+ return box.sent + box.received === 0;
325
+ }
326
+
307
327
  /** Dernière activité d'une boîte, ou chaîne vide quand elle n'a jamais rien fait. */
308
328
  function activityOf(box) {
309
329
  return box.last_activity_at || "";
@@ -320,9 +340,23 @@ function compareActivity(left, right) {
320
340
  return left < right ? 1 : -1;
321
341
  }
322
342
 
323
- /** À activité égale, l'adresse départage : deux rendus donnent le même ordre. */
324
- function byActivity(a, b) {
325
- return compareActivity(activityOf(a), activityOf(b)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
343
+ /** Le plein avant le vide. Premier critère du classement, avant même la date. */
344
+ function compareEmpty(left, right) {
345
+ if (left === right) return 0;
346
+ return left ? 1 : -1;
347
+ }
348
+
349
+ /**
350
+ * Deux classements emboîtés : d'abord les boîtes qui ont servi, puis les vides ; dans
351
+ * chacun des deux, la plus récemment active en tête. À égalité, l'adresse départage, pour
352
+ * que l'ordre ne bouge pas d'un rafraîchissement à l'autre.
353
+ */
354
+ function byBox(a, b) {
355
+ return (
356
+ compareEmpty(isEmptyBox(a), isEmptyBox(b)) ||
357
+ compareActivity(activityOf(a), activityOf(b)) ||
358
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
359
+ );
326
360
  }
327
361
 
328
362
  function isSelected(kind, key) {
@@ -415,12 +449,17 @@ function groupRow(group) {
415
449
  )
416
450
  );
417
451
 
452
+ const empty = group.boxes.length - group.filled;
453
+ // Le décompte annonce ce que le dépli va montrer : sans quoi « 5 boîtes » suivi de deux
454
+ // lignes se lirait comme un bug plutôt que comme des boîtes vides mises de côté.
455
+ const counts = empty > 0 ? `${plural(group.filled, "boîte")} · ${plural(empty, "vide")}` : plural(group.filled, "boîte");
456
+
418
457
  const text = node("div", "agent-text");
419
458
  text.appendChild(node("div", "agent-name", group.name));
420
- text.appendChild(node("div", "agent-box", plural(group.boxes.length, "boîte")));
459
+ text.appendChild(node("div", "agent-box", counts));
421
460
  row.appendChild(text);
422
461
  if (group.unread > 0) row.appendChild(node("span", "count count--accent", String(group.unread)));
423
- row.title = `${group.name} · ${plural(group.boxes.length, "boîte")}`;
462
+ row.title = `${group.name} · ${counts}`;
424
463
  row.addEventListener("click", () => {
425
464
  state.expanded.add(group.key);
426
465
  select("agent", group.key);
@@ -430,15 +469,37 @@ function groupRow(group) {
430
469
 
431
470
  if (open) {
432
471
  const list = node("ul", "box-list");
433
- for (const box of group.boxes) list.appendChild(boxRow(box));
472
+ // Les vides sont derrière dans `group.boxes` : les montrer, c'est allonger la liste,
473
+ // jamais en réordonner le début.
474
+ const revealed = state.showEmpty.has(group.key);
475
+ for (const box of group.boxes) {
476
+ if (isEmptyBox(box) && !revealed) continue;
477
+ list.appendChild(boxRow(box));
478
+ }
479
+ if (empty > 0) list.appendChild(emptyToggleRow(group, empty, revealed));
434
480
  item.appendChild(list);
435
481
  }
436
482
  return item;
437
483
  }
438
484
 
485
+ /** Dernière ligne du dépli : ouvre ou referme les boîtes qui n'ont jamais rien échangé. */
486
+ function emptyToggleRow(group, empty, revealed) {
487
+ const item = node("li");
488
+ const what = empty > 1 ? `les ${empty} boîtes vides` : "la boîte vide";
489
+ const toggle = button("box-empty-toggle", `${revealed ? "Masquer" : "Afficher"} ${what}`, () => {
490
+ if (revealed) state.showEmpty.delete(group.key);
491
+ else state.showEmpty.add(group.key);
492
+ renderAgents({ agents: [...state.directory.values()], threads: currentThreadCount() });
493
+ });
494
+ toggle.setAttribute("aria-expanded", revealed ? "true" : "false");
495
+ item.appendChild(toggle);
496
+ return item;
497
+ }
498
+
439
499
  function boxRow(box) {
440
500
  const item = node("li");
441
501
  const classes = ["agent", "box"];
502
+ if (isEmptyBox(box)) classes.push("is-empty");
442
503
  if (isSelected("box", box.id)) classes.push("is-selected");
443
504
  if (box.unread > 0) classes.push("has-unread");
444
505
  if (!box.registered) classes.push("agent--muted");
@@ -894,6 +955,7 @@ el.logout.addEventListener("click", async () => {
894
955
  await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
895
956
  state.selection = { kind: "all", key: null };
896
957
  state.expanded = new Set();
958
+ state.showEmpty = new Set();
897
959
  state.threadId = null;
898
960
  state.messageId = null;
899
961
  state.directory = new Map();
@@ -577,6 +577,42 @@ body {
577
577
  font-size: 11px;
578
578
  }
579
579
 
580
+ /* Les boîtes vides sont repliées derrière ce bouton : le lien ferme la liste plutôt que
581
+ d'aligner des sessions qui n'ont jamais rien échangé. */
582
+ .box-empty-toggle {
583
+ display: block;
584
+ width: 100%;
585
+ padding: 6px 10px;
586
+ border: 0;
587
+ border-radius: var(--radius);
588
+ background: none;
589
+ font: inherit;
590
+ font-size: 12px;
591
+ color: var(--muted);
592
+ text-align: left;
593
+ cursor: pointer;
594
+ }
595
+
596
+ .box-empty-toggle:hover {
597
+ background: var(--border-soft);
598
+ color: var(--accent);
599
+ }
600
+
601
+ .box-empty-toggle:focus-visible {
602
+ outline: 2px solid var(--accent);
603
+ outline-offset: -2px;
604
+ }
605
+
606
+ /* Une boîte vide reste lisible mais s'efface : elle est là pour être retrouvée, pas lue. */
607
+ .box.is-empty .agent-name {
608
+ color: var(--muted);
609
+ font-weight: 400;
610
+ }
611
+
612
+ .box.is-empty .avatar {
613
+ opacity: 0.5;
614
+ }
615
+
580
616
  .agents-foot {
581
617
  padding: 16px 20px;
582
618
  font-size: 12px;
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.14";
1
+ export declare const APP_VERSION = "1.0.15";
@@ -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.14";
2
+ export const APP_VERSION = "1.0.15";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.14",
3
+ "version": "1.0.15",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",