@imenam/mcp-messenger 1.0.11 → 1.0.14

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
@@ -340,9 +340,18 @@ configuration partagent alors la même boîte. Utile pour un service unique et s
340
340
  organisés autour du fil — l'unité d'une correspondance, celle qui porte le sujet, les deux
341
341
  correspondants et l'état de lecture :
342
342
 
343
- - **une colonne d'agents** : un par boîte, avec son nom d'agent, ce dont la session
344
- s'occupe et son nombre de messages non lus. La sélection filtre les fils sur cette
345
- boîte ; la colonne se replie sur ses seuls avatars quand la place manque ;
343
+ - **une colonne d'agents à deux niveaux** : un agent tient une boîte par session ouverte,
344
+ la colonne l'écrit donc ainsi. Chaque agent est **replié par défaut** et se déplie d'un
345
+ chevron sur ses boîtes ; une boîte que son agent n'a pas nommée s'affiche à son adresse,
346
+ la seule chose qui la distingue alors de ses sœurs. Cliquer l'agent filtre les fils sur
347
+ **toutes ses sessions**, cliquer une boîte sur **cette seule session**. La colonne se
348
+ replie sur les seuls avatars d'agents quand la place manque.
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 ;
346
355
  - **la liste des fils**, du plus récemment actif au plus ancien, chacun résumé par son
347
356
  nom, ses correspondants, son dernier message et sa priorité la plus haute. Trois puces
348
357
  la filtrent : tous, non lus, priorité haute ;
@@ -357,9 +366,11 @@ correspondants et l'état de lecture :
357
366
  - un rafraîchissement automatique toutes les 5 secondes, débrayable.
358
367
 
359
368
  **Les adresses ne sont pas de l'information de lecture.** La console résout partout
360
- `box_9f2c4a01b3d7e5f8` en nom d'agent, et ne montre l'adresse qu'aux deux endroits où elle
361
- sert vraiment : le volet de détail et l'en-tête d'un fil, chaque fois avec un bouton de
362
- copie. Une boîte jamais connectée se distingue à son avatar en contour et à la mention
369
+ `box_9f2c4a01b3d7e5f8` en nom d'agent, et ne montre l'adresse qu'aux endroits où elle sert
370
+ vraiment : le volet de détail, l'en-tête d'un fil et les boîtes sans nom, chaque fois avec
371
+ un bouton de copie. Quand plusieurs sessions d'un même projet apparaissent, le nom de
372
+ l'agent est suivi de celui de la boîte entre parenthèses — sans quoi un fil se lirait
373
+ « 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
363
374
  « jamais connectée » — elle reste listée, puisqu'elle a du courrier en attente.
364
375
 
365
376
  À l'ouverture, la console demande `MESSENGER_ADMIN_TOKEN` dans un formulaire. Le serveur
@@ -440,7 +451,7 @@ la console (cookie), qui elle n'ouvre rien d'autre.
440
451
  | `GET` `POST` `DELETE` | `/api/folders[/:name]` | Gérer les dossiers |
441
452
  | `GET` | `/api/wait` | Attente longue (`timeout_seconds`, `folder`, `watcher`) |
442
453
  | `GET` | `/admin/overview` · `/admin/messages[/:id]` | Supervision, lecture seule |
443
- | `GET` | `/admin/threads` | Fils du serveur entier (`agent`, `search`, `unread_only`, `priority`, `limit`, `offset`) |
454
+ | `GET` | `/admin/threads` | Fils du serveur entier (`agent` — une ou plusieurs adresses séparées par des virgules —, `search`, `unread_only`, `priority`, `limit`, `offset`) |
444
455
  | `GET` | `/admin/threads/:id` | Fil complet, corps inclus |
445
456
  | `POST` | `/ui/login` · `/ui/logout` | Session de la console (`{ "token": … }` → cookie `HttpOnly`) |
446
457
 
@@ -104,7 +104,14 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
104
104
  */
105
105
  listAllThreadsAdmin(opts?: {
106
106
  search?: string;
107
- agent?: string;
107
+ /**
108
+ * Adresses de boîtes : un fil est gardé dès que l'une d'elles y participe.
109
+ *
110
+ * Une liste, et non une seule adresse, parce qu'un agent tient autant de boîtes
111
+ * qu'il a de sessions ouvertes : « les fils du projet Documentor » se demande en
112
+ * passant les adresses de toutes ses sessions d'un coup.
113
+ */
114
+ agents?: string[];
108
115
  unread_only?: boolean;
109
116
  priority?: Priority;
110
117
  limit?: number;
@@ -626,12 +626,13 @@ export class MessageStore extends EventEmitter {
626
626
  const where = ["1 = 1"];
627
627
  const having = [];
628
628
  const params = [];
629
- // Un agent « participe » au fil dès qu'il y a une livraison, qu'il ait écrit ou reçu :
630
- // c'est la correspondance qui est filtrée, pas les messages qu'il a signés.
631
- if (opts.agent) {
629
+ // Une boîte « participe » au fil dès qu'il y a une livraison, qu'elle ait écrit ou
630
+ // reçu : c'est la correspondance qui est filtrée, pas les messages qu'elle a signés.
631
+ const agents = dedupe((opts.agents ?? []).map((id) => normalizeAgentId(id)).filter(Boolean));
632
+ if (agents.length > 0) {
632
633
  where.push(`EXISTS (SELECT 1 FROM deliveries d JOIN messages mp ON mp.id = d.message_id
633
- WHERE mp.thread_id = m.thread_id AND d.agent_id = ?)`);
634
- params.push(opts.agent);
634
+ WHERE mp.thread_id = m.thread_id AND d.agent_id IN (${agents.map(() => "?").join(",")}))`);
635
+ params.push(...agents);
635
636
  }
636
637
  // La recherche porte sur le fil entier : son nom, sa description, et le texte de
637
638
  // n'importe lequel de ses messages. Un fil non nommé reste ainsi trouvable.
@@ -81,7 +81,9 @@ const waitQuerySchema = z.object({
81
81
  const loginSchema = z.object({ token: z.string().min(1).max(1024) });
82
82
  const adminThreadsSchema = z.object({
83
83
  search: z.string().trim().min(1).optional(),
84
- agent: z.string().trim().min(1).optional(),
84
+ // Une ou plusieurs adresses, séparées par des virgules : un agent tient une boîte par
85
+ // session ouverte, et la console filtre sur toutes celles d'un projet à la fois.
86
+ agent: z.string().trim().min(1).max(4096).optional(),
85
87
  unread_only: z.enum(["true", "1"]).optional(),
86
88
  priority: z.enum(PRIORITIES).optional(),
87
89
  limit: z.coerce.number().int().min(1).max(100).optional(),
@@ -176,7 +178,11 @@ export function createApp({ store, config }) {
176
178
  res.json({
177
179
  threads: store.listAllThreadsAdmin({
178
180
  search: query.search,
179
- agent: query.agent,
181
+ agents: query.agent
182
+ ?.split(",")
183
+ .map((id) => id.trim())
184
+ .filter(Boolean)
185
+ .slice(0, 100),
180
186
  unread_only: query.unread_only !== undefined,
181
187
  priority: query.priority,
182
188
  limit: query.limit,
@@ -19,11 +19,20 @@ const ICONS = {
19
19
  clock: ["M12 6v6l4 2", "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z"],
20
20
  close: ["M18 6 6 18", "m6 6 12 12"],
21
21
  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"],
22
+ chevron: ["m9 18 6-6-6-6"],
22
23
  };
23
24
 
24
25
  const state = {
25
- /** Adresse de la boîte sur laquelle la liste des fils est filtrée, ou null. */
26
- agent: null,
26
+ /**
27
+ * Ce sur quoi la liste des fils est filtrée.
28
+ *
29
+ * `{ kind: "all" }` ne filtre rien ; `{ kind: "agent", key }` retient toutes les boîtes
30
+ * d'un projet ; `{ kind: "box", key }` une seule session. Deux niveaux, parce qu'un
31
+ * agent tient une boîte par session ouverte et que les deux questions se posent.
32
+ */
33
+ selection: { kind: "all", key: null },
34
+ /** Noms d'agents dépliés dans la colonne de gauche. Tout est replié au départ. */
35
+ expanded: new Set(),
27
36
  /** "all" | "unread" | "high" — les puces au-dessus de la liste des fils. */
28
37
  filter: "all",
29
38
  search: "",
@@ -33,6 +42,8 @@ const state = {
33
42
  collapsed: false,
34
43
  /** Annuaire des boîtes, indexé par adresse : rempli par chaque `/admin/overview`. */
35
44
  directory: new Map(),
45
+ /** Boîtes regroupées par nom d'agent, dans l'ordre de l'annuaire. */
46
+ groups: [],
36
47
  };
37
48
 
38
49
  const el = {
@@ -126,47 +137,78 @@ function tag(className, label, iconName) {
126
137
  * Deux noms par boîte, deux questions : l'agent dit d'où l'on écrit (le projet), la boîte
127
138
  * de quoi cette session s'occupe. La console montre les deux, et n'affiche l'adresse que
128
139
  * là où elle sert à être copiée.
140
+ *
141
+ * Un nom d'agent ne désigne personne : plusieurs sessions du même projet le portent. Dès
142
+ * qu'il est partagé, `name` lui accole le nom de la boîte — sans quoi un fil afficherait
143
+ * « Documentor Dev → Documentor Dev » sans dire de quelles sessions il parle.
129
144
  */
130
145
  function party(agentId) {
131
146
  const known = state.directory.get(agentId);
147
+ const agentName = (known && known.agent_name) || null;
148
+ const boxName = (known && known.display_name) || null;
149
+ const shared = agentName ? state.groups.some((g) => g.key === agentName && g.boxes.length > 1) : false;
150
+ // Parenthèses, et non un séparateur plat : les participants d'un fil sont eux-mêmes
151
+ // listés avec un point médian, qui ne doit pas se confondre avec cette précision.
152
+ const name = agentName
153
+ ? shared
154
+ ? `${agentName} (${boxName || shortAddress(agentId)})`
155
+ : agentName
156
+ : shortAddress(agentId);
132
157
  return {
133
158
  id: agentId,
134
- name: (known && known.agent_name) || shortAddress(agentId),
135
- box: (known && known.display_name) || null,
159
+ name,
160
+ agentName,
161
+ box: boxName,
136
162
  registered: !known || known.registered,
137
163
  unread: (known && known.unread) || 0,
138
164
  };
139
165
  }
140
166
 
167
+ /**
168
+ * Nom de la boîte, ou son adresse à défaut.
169
+ *
170
+ * Une boîte n'est nommée que si l'agent l'a fait lui-même (`rename_mailbox`) ; tant qu'il
171
+ * ne l'a pas fait, seule l'adresse la distingue de ses sœurs, et c'est donc elle qu'on lit.
172
+ */
173
+ function boxLabel(box) {
174
+ return box.display_name || box.id;
175
+ }
176
+
141
177
  function shortAddress(agentId) {
142
178
  const id = String(agentId || "");
143
179
  return id.length > 12 ? `${id.slice(0, 10)}…` : id || "—";
144
180
  }
145
181
 
146
- function initials(agentId) {
147
- const name = party(agentId).name;
148
- const words = name.split(/[\s_-]+/).filter(Boolean);
149
- const letters = words.length >= 2 ? words[0][0] + words[1][0] : name.slice(0, 2);
182
+ function initials(label) {
183
+ const words = String(label).split(/[\s_·-]+/).filter(Boolean);
184
+ const letters = words.length >= 2 ? words[0][0] + words[1][0] : String(label).slice(0, 2);
150
185
  return letters.toUpperCase();
151
186
  }
152
187
 
153
- /** Teinte stable : la même adresse donne toujours la même couleur d'avatar. */
154
- function colorIndex(agentId) {
188
+ /** Teinte stable : la même graine donne toujours la même couleur d'avatar. */
189
+ function colorIndex(seed) {
155
190
  let hash = 0;
156
- for (const char of String(agentId)) hash = (hash * 31 + char.charCodeAt(0)) % 100003;
191
+ for (const char of String(seed)) hash = (hash * 31 + char.charCodeAt(0)) % 100003;
157
192
  return hash % AVATAR_COLORS;
158
193
  }
159
194
 
160
- function avatar(agentId, extraClass) {
161
- const who = party(agentId);
195
+ /** Pastille d'initiales. `seed` teinte, `label` écrit, `title` explique au survol. */
196
+ function avatarFor(seed, label, title, extraClass, absent) {
162
197
  const classes = ["avatar"];
163
198
  if (extraClass) classes.push(extraClass);
164
- classes.push(who.registered ? `avatar--c${colorIndex(agentId)}` : "avatar--absent");
165
- const element = node("span", classes.join(" "), initials(agentId));
166
- element.title = who.box ? `${who.name} — ${who.box}` : who.name;
199
+ classes.push(absent ? "avatar--absent" : `avatar--c${colorIndex(seed)}`);
200
+ const element = node("span", classes.join(" "), initials(label));
201
+ element.title = title;
167
202
  return element;
168
203
  }
169
204
 
205
+ function avatar(agentId, extraClass) {
206
+ const who = party(agentId);
207
+ const label = who.box || who.agentName || shortAddress(agentId);
208
+ const title = [who.agentName, who.box, agentId].filter(Boolean).join(" — ");
209
+ return avatarFor(agentId, label, title, extraClass, !who.registered);
210
+ }
211
+
170
212
  // --- Dates -----------------------------------------------------------------
171
213
 
172
214
  function formatDate(iso) {
@@ -229,71 +271,237 @@ function setStatus(kind, label) {
229
271
 
230
272
  // --- Colonne des agents ----------------------------------------------------
231
273
 
274
+ /**
275
+ * Regroupe les boîtes sous leur nom d'agent.
276
+ *
277
+ * L'agent dit d'où l'on écrit, la boîte quelle session parle : un projet qui a deux
278
+ * sessions ouvertes tient deux boîtes, et la colonne doit montrer cette imbrication plutôt
279
+ * que d'aligner des boîtes qui portent toutes le même nom. Une boîte sans nom d'agent est
280
+ * son propre groupe : rien ne permet de la rattacher à un projet.
281
+ *
282
+ * L'annuaire arrive déjà classé par activité : le groupe hérite du rang de sa boîte la
283
+ * plus récente, sans nouveau tri.
284
+ */
285
+ function groupByAgent(boxes) {
286
+ const groups = new Map();
287
+ for (const box of boxes) {
288
+ const key = box.agent_name || box.id;
289
+ if (!groups.has(key)) {
290
+ groups.set(key, { key, name: box.agent_name || boxLabel(box), boxes: [], unread: 0, activity: "" });
291
+ }
292
+ const group = groups.get(key);
293
+ group.boxes.push(box);
294
+ group.unread += box.unread;
295
+ // L'agent hérite de l'activité de sa session la plus vivante.
296
+ if (activityOf(box) > group.activity) group.activity = activityOf(box);
297
+ }
298
+ const list = [...groups.values()];
299
+ for (const group of list) group.boxes.sort(byActivity);
300
+ // 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
+ return list;
305
+ }
306
+
307
+ /** Dernière activité d'une boîte, ou chaîne vide quand elle n'a jamais rien fait. */
308
+ function activityOf(box) {
309
+ return box.last_activity_at || "";
310
+ }
311
+
312
+ /**
313
+ * Compare deux dates ISO, la plus récente d'abord.
314
+ *
315
+ * Les dates ISO se comparent comme du texte, et la chaîne vide — une boîte qui n'a jamais
316
+ * vécu — est plus petite que toute date : elle finit donc en queue, sans cas particulier.
317
+ */
318
+ function compareActivity(left, right) {
319
+ if (left === right) return 0;
320
+ return left < right ? 1 : -1;
321
+ }
322
+
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);
326
+ }
327
+
328
+ function isSelected(kind, key) {
329
+ return state.selection.kind === kind && state.selection.key === key;
330
+ }
331
+
332
+ function select(kind, key) {
333
+ // Recliquer sur la sélection courante la relâche : le filtre ne s'emprisonne pas.
334
+ state.selection = isSelected(kind, key) ? { kind: "all", key: null } : { kind, key };
335
+ state.threadId = null;
336
+ state.messageId = null;
337
+ el.sheet.hidden = true;
338
+ el.console.classList.remove("has-sheet");
339
+ refresh();
340
+ }
341
+
232
342
  function renderAgents(overview) {
233
- state.directory = new Map(overview.agents.map((agent) => [agent.id, agent]));
343
+ state.directory = new Map(overview.agents.map((box) => [box.id, box]));
344
+ state.groups = groupByAgent(overview.agents);
234
345
  el.agentsFoot.textContent = [
235
- plural(overview.agents.length, "agent"),
236
- plural(overview.messages, "message"),
346
+ plural(state.groups.length, "agent"),
347
+ plural(overview.agents.length, "boîte"),
237
348
  plural(overview.threads, "fil"),
238
349
  ].join(" · ");
239
350
 
240
351
  el.agents.replaceChildren();
241
- el.agents.appendChild(agentRow(null, overview));
242
- for (const agent of overview.agents) el.agents.appendChild(agentRow(agent, overview));
352
+ el.agents.appendChild(allRow(overview));
353
+ for (const group of state.groups) el.agents.appendChild(groupRow(group));
243
354
  }
244
355
 
245
- function agentRow(agent, overview) {
356
+ function allRow(overview) {
246
357
  const item = node("li");
247
- const selected = agent ? state.agent === agent.id : state.agent === null;
248
- const unread = agent ? agent.unread : overview.agents.reduce((total, a) => total + a.unread, 0);
358
+ const unread = overview.agents.reduce((total, box) => total + box.unread, 0);
359
+ const row = node("button", `agent${isSelected("all", null) ? " is-selected" : ""}`);
360
+ row.type = "button";
361
+
362
+ const star = node("span", "avatar avatar--absent", "∗");
363
+ star.title = "Tous les agents";
364
+ row.appendChild(star);
365
+
366
+ const text = node("div", "agent-text");
367
+ text.appendChild(node("div", "agent-name", "Tous les agents"));
368
+ text.appendChild(node("div", "agent-box", plural(overview.threads, "fil")));
369
+ row.appendChild(text);
370
+ if (unread > 0) row.appendChild(node("span", "count count--accent", String(unread)));
371
+
372
+ row.addEventListener("click", () => select("all", null));
373
+ item.appendChild(row);
374
+ return item;
375
+ }
376
+
377
+ /**
378
+ * Un agent et, dépliées sous lui, ses boîtes.
379
+ *
380
+ * Deux zones de clic, deux gestes distincts : le chevron ouvre ou ferme, le nom filtre sur
381
+ * l'ensemble des sessions du projet. Filtrer déplie au passage, pour que la sélection
382
+ * montre toujours ce qu'elle recouvre.
383
+ */
384
+ function groupRow(group) {
385
+ const item = node("li", "agent-group");
386
+ const open = state.expanded.has(group.key);
387
+
388
+ const header = node("div", `agent-head${open ? " is-open" : ""}`);
389
+
390
+ const toggle = node("button", "agent-toggle");
391
+ toggle.type = "button";
392
+ toggle.setAttribute("aria-expanded", open ? "true" : "false");
393
+ toggle.setAttribute("aria-label", `${open ? "Replier" : "Déplier"} les boîtes de ${group.name}`);
394
+ toggle.appendChild(icon("chevron"));
395
+ toggle.addEventListener("click", () => {
396
+ if (open) state.expanded.delete(group.key);
397
+ else state.expanded.add(group.key);
398
+ renderAgents({ agents: [...state.directory.values()], threads: currentThreadCount() });
399
+ });
400
+ header.appendChild(toggle);
249
401
 
250
402
  const classes = ["agent"];
251
- if (selected) classes.push("is-selected");
252
- if (unread > 0) classes.push("has-unread");
253
- if (agent && !agent.registered) classes.push("agent--muted");
403
+ if (isSelected("agent", group.key)) classes.push("is-selected");
404
+ if (group.unread > 0) classes.push("has-unread");
405
+ if (group.boxes.every((box) => !box.registered)) classes.push("agent--muted");
254
406
  const row = node("button", classes.join(" "));
255
407
  row.type = "button";
408
+ row.appendChild(
409
+ avatarFor(
410
+ group.key,
411
+ group.name,
412
+ group.name,
413
+ null,
414
+ group.boxes.every((box) => !box.registered)
415
+ )
416
+ );
256
417
 
257
- if (agent) {
258
- row.appendChild(avatar(agent.id));
259
- } else {
260
- const all = node("span", "avatar avatar--absent", "∗");
261
- all.title = "Tous les agents";
262
- row.appendChild(all);
418
+ const text = node("div", "agent-text");
419
+ text.appendChild(node("div", "agent-name", group.name));
420
+ text.appendChild(node("div", "agent-box", plural(group.boxes.length, "boîte")));
421
+ row.appendChild(text);
422
+ 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")}`;
424
+ row.addEventListener("click", () => {
425
+ state.expanded.add(group.key);
426
+ select("agent", group.key);
427
+ });
428
+ header.appendChild(row);
429
+ item.appendChild(header);
430
+
431
+ if (open) {
432
+ const list = node("ul", "box-list");
433
+ for (const box of group.boxes) list.appendChild(boxRow(box));
434
+ item.appendChild(list);
263
435
  }
436
+ return item;
437
+ }
438
+
439
+ function boxRow(box) {
440
+ const item = node("li");
441
+ const classes = ["agent", "box"];
442
+ if (isSelected("box", box.id)) classes.push("is-selected");
443
+ if (box.unread > 0) classes.push("has-unread");
444
+ if (!box.registered) classes.push("agent--muted");
445
+ const row = node("button", classes.join(" "));
446
+ row.type = "button";
447
+
448
+ const label = boxLabel(box);
449
+ row.appendChild(avatarFor(box.id, label, box.id, "avatar--sm", !box.registered));
264
450
 
265
451
  const text = node("div", "agent-text");
266
- text.appendChild(node("div", "agent-name", agent ? party(agent.id).name : "Tous les agents"));
267
- if (agent) {
268
- // Une boîte jamais connectée n'a pas de nom : le dire vaut mieux qu'une ligne vide.
269
- const boxName = agent.display_name || (agent.registered ? "boîte sans nom" : "jamais connectée");
270
- const box = node("div", "agent-box", boxName);
271
- if (!agent.display_name) box.classList.add("is-absent");
272
- text.appendChild(box);
273
- } else {
274
- text.appendChild(node("div", "agent-box", plural(overview.threads, "fil")));
275
- }
452
+ // Sans nom donné par l'agent, l'adresse tient lieu de nom : elle est alors la seule
453
+ // chose qui distingue cette session de ses sœurs.
454
+ const name = node("div", "agent-name", label);
455
+ if (!box.display_name) name.classList.add("mono", "is-absent");
456
+ text.appendChild(name);
457
+ // Les sessions d'un agent sont classées sur cette date : sans elle, leur ordre
458
+ // paraîtrait arbitraire. Les compteurs passent dans l'info-bulle, faute de place.
459
+ const activity = box.last_activity_at
460
+ ? `activité ${formatShort(box.last_activity_at)}`
461
+ : box.registered
462
+ ? "aucune activité"
463
+ : "jamais connectée";
464
+ const meta = node("div", "agent-box", activity);
465
+ if (!box.last_activity_at) meta.classList.add("is-absent");
466
+ text.appendChild(meta);
276
467
  row.appendChild(text);
277
468
 
278
- if (unread > 0) row.appendChild(node("span", "count count--accent", String(unread)));
279
- row.title = agent ? agent.id : "Tous les agents";
280
-
281
- row.addEventListener("click", () => {
282
- const next = agent ? agent.id : null;
283
- state.agent = state.agent === next ? null : next;
284
- state.threadId = null;
285
- state.messageId = null;
286
- refresh();
287
- });
469
+ if (box.unread > 0) row.appendChild(node("span", "count count--accent", String(box.unread)));
470
+ row.title = `${box.id}\n${plural(box.sent, "envoyé")} · ${plural(box.received, "reçu")}`;
471
+ row.addEventListener("click", () => select("box", box.id));
288
472
  item.appendChild(row);
289
473
  return item;
290
474
  }
291
475
 
476
+ /** Nombre de fils affiché en pied de colonne, conservé entre deux rendus locaux. */
477
+ function currentThreadCount() {
478
+ return state.threadCount || 0;
479
+ }
480
+
481
+ /** Adresses que la sélection courante recouvre, ou null quand elle ne filtre rien. */
482
+ function selectedAddresses() {
483
+ if (state.selection.kind === "box") return [state.selection.key];
484
+ if (state.selection.kind === "agent") {
485
+ const group = state.groups.find((g) => g.key === state.selection.key);
486
+ return group ? group.boxes.map((box) => box.id) : [];
487
+ }
488
+ return null;
489
+ }
490
+
491
+ function selectionLabel() {
492
+ if (state.selection.kind === "box") {
493
+ const box = state.directory.get(state.selection.key);
494
+ return box ? boxLabel(box) : shortAddress(state.selection.key);
495
+ }
496
+ if (state.selection.kind === "agent") return state.selection.key;
497
+ return "Toutes les boîtes";
498
+ }
499
+
292
500
  // --- Colonne des fils ------------------------------------------------------
293
501
 
294
502
  function renderThreads(threads) {
295
- const scope = state.agent ? party(state.agent).name : "Toutes les boîtes";
296
- el.threadsScope.textContent = `${scope} · ${plural(threads.length, "fil")}`;
503
+ state.threadCount = threads.length;
504
+ el.threadsScope.textContent = `${selectionLabel()} · ${plural(threads.length, "fil")}`;
297
505
 
298
506
  el.threads.replaceChildren();
299
507
  if (threads.length === 0) {
@@ -310,7 +518,8 @@ function emptyReason() {
310
518
  if (state.search) return "Aucun fil ne correspond à cette recherche.";
311
519
  if (state.filter === "unread") return "Tout est lu de ce côté.";
312
520
  if (state.filter === "high") return "Aucun message en priorité haute.";
313
- if (state.agent) return "Cette boîte n'a encore échangé avec personne.";
521
+ if (state.selection.kind === "box") return "Cette boîte n'a encore échangé avec personne.";
522
+ if (state.selection.kind === "agent") return "Aucune session de cet agent n'a encore échangé.";
314
523
  return "Aucun agent n'a encore écrit sur ce serveur.";
315
524
  }
316
525
 
@@ -596,7 +805,8 @@ async function refresh(options) {
596
805
  const [overview, list] = await Promise.all([
597
806
  api("/admin/overview"),
598
807
  api("/admin/threads", {
599
- agent: state.agent,
808
+ // Une sélection d'agent recouvre toutes ses sessions : la route prend la liste.
809
+ agent: (selectedAddresses() || []).join(","),
600
810
  search: state.search,
601
811
  unread_only: state.filter === "unread" ? "true" : undefined,
602
812
  priority: state.filter === "high" ? "high" : undefined,
@@ -682,10 +892,12 @@ el.loginForm.addEventListener("submit", async (event) => {
682
892
 
683
893
  el.logout.addEventListener("click", async () => {
684
894
  await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
685
- state.agent = null;
895
+ state.selection = { kind: "all", key: null };
896
+ state.expanded = new Set();
686
897
  state.threadId = null;
687
898
  state.messageId = null;
688
899
  state.directory = new Map();
900
+ state.groups = [];
689
901
  el.sheet.hidden = true;
690
902
  el.console.classList.remove("has-sheet");
691
903
  el.agents.replaceChildren();
@@ -503,6 +503,80 @@ body {
503
503
  color: var(--muted);
504
504
  }
505
505
 
506
+ /* --- Agent et ses boîtes --------------------------------------------------- */
507
+
508
+ /* Deux zones de clic sur une même ligne : le chevron ouvre, le reste filtre. */
509
+ .agent-head {
510
+ display: flex;
511
+ align-items: center;
512
+ }
513
+
514
+ .agent-toggle {
515
+ display: flex;
516
+ align-items: center;
517
+ justify-content: center;
518
+ width: 20px;
519
+ height: 28px;
520
+ padding: 0;
521
+ border: 0;
522
+ border-radius: 4px;
523
+ background: none;
524
+ color: var(--muted);
525
+ cursor: pointer;
526
+ flex: none;
527
+ }
528
+
529
+ .agent-toggle:hover {
530
+ color: var(--text);
531
+ }
532
+
533
+ .agent-toggle:focus-visible {
534
+ outline: 2px solid var(--accent);
535
+ outline-offset: 1px;
536
+ }
537
+
538
+ .agent-toggle .icon {
539
+ width: 14px;
540
+ height: 14px;
541
+ transition: transform 0.15s;
542
+ }
543
+
544
+ .agent-head.is-open .agent-toggle .icon {
545
+ transform: rotate(90deg);
546
+ }
547
+
548
+ /* Le chevron mange sa gouttière : la ligne d'agent reste alignée sur « Tous les agents ». */
549
+ .agent-head .agent {
550
+ padding-left: 4px;
551
+ }
552
+
553
+ /* Les boîtes vivent sous leur agent : un cran d'indentation et un filet de rattachement. */
554
+ .box-list {
555
+ list-style: none;
556
+ margin: 0 0 4px 22px;
557
+ padding: 0 0 0 14px;
558
+ border-left: 1px solid var(--border);
559
+ }
560
+
561
+ .box {
562
+ padding: 7px 10px;
563
+ }
564
+
565
+ .box .agent-name {
566
+ font-weight: 500;
567
+ font-size: 13px;
568
+ }
569
+
570
+ /* Une boîte sans nom se lit à son adresse : en mono, pour ce que c'est. */
571
+ .box .agent-name.is-absent {
572
+ font-weight: 400;
573
+ color: var(--muted);
574
+ }
575
+
576
+ .box .agent-box {
577
+ font-size: 11px;
578
+ }
579
+
506
580
  .agents-foot {
507
581
  padding: 16px 20px;
508
582
  font-size: 12px;
@@ -574,10 +648,13 @@ body {
574
648
  color: var(--accent);
575
649
  }
576
650
 
577
- /* Colonne repliée : il ne reste que les avatars, chacun avec son nom en info-bulle. */
651
+ /* Colonne repliée : il ne reste que les avatars d'agents, nom en info-bulle. La
652
+ hiérarchie n'y tient pas — les boîtes se retrouvent en dépliant la colonne. */
578
653
  .is-collapsed .agents-head,
579
654
  .is-collapsed .agent-text,
580
655
  .is-collapsed .agents-foot,
656
+ .is-collapsed .agent-toggle,
657
+ .is-collapsed .box-list,
581
658
  .is-collapsed .agent .count {
582
659
  display: none;
583
660
  }
@@ -591,6 +668,10 @@ body {
591
668
  justify-content: center;
592
669
  }
593
670
 
671
+ .is-collapsed .agent-head .agent {
672
+ padding-left: 8px;
673
+ }
674
+
594
675
  /* Le badge des non-lus survit au repli, réduit à une pastille sur l'avatar. */
595
676
  .is-collapsed .agent.has-unread .avatar {
596
677
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.11";
1
+ export declare const APP_VERSION = "1.0.14";
@@ -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.11";
2
+ export const APP_VERSION = "1.0.14";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.11",
3
+ "version": "1.0.14",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",