@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 +18 -7
- package/dist/src/core/store.d.ts +8 -1
- package/dist/src/core/store.js +6 -5
- package/dist/src/server/app.js +8 -2
- package/dist/src/server/ui/app.js +270 -58
- package/dist/src/server/ui/styles.css +82 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
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
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
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
|
|
package/dist/src/core/store.d.ts
CHANGED
|
@@ -104,7 +104,14 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
|
|
|
104
104
|
*/
|
|
105
105
|
listAllThreadsAdmin(opts?: {
|
|
106
106
|
search?: string;
|
|
107
|
-
|
|
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;
|
package/dist/src/core/store.js
CHANGED
|
@@ -626,12 +626,13 @@ export class MessageStore extends EventEmitter {
|
|
|
626
626
|
const where = ["1 = 1"];
|
|
627
627
|
const having = [];
|
|
628
628
|
const params = [];
|
|
629
|
-
//
|
|
630
|
-
// c'est la correspondance qui est filtrée, pas les messages qu'
|
|
631
|
-
|
|
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(
|
|
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.
|
package/dist/src/server/app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
26
|
-
|
|
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
|
|
135
|
-
|
|
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(
|
|
147
|
-
const
|
|
148
|
-
const
|
|
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
|
|
154
|
-
function colorIndex(
|
|
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(
|
|
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
|
-
|
|
161
|
-
|
|
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(
|
|
165
|
-
const element = node("span", classes.join(" "), initials(
|
|
166
|
-
element.title =
|
|
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((
|
|
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(
|
|
236
|
-
plural(overview.
|
|
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(
|
|
242
|
-
for (const
|
|
352
|
+
el.agents.appendChild(allRow(overview));
|
|
353
|
+
for (const group of state.groups) el.agents.appendChild(groupRow(group));
|
|
243
354
|
}
|
|
244
355
|
|
|
245
|
-
function
|
|
356
|
+
function allRow(overview) {
|
|
246
357
|
const item = node("li");
|
|
247
|
-
const
|
|
248
|
-
const
|
|
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 (
|
|
252
|
-
if (unread > 0) classes.push("has-unread");
|
|
253
|
-
if (
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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 =
|
|
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
|
-
|
|
296
|
-
el.threadsScope.textContent = `${
|
|
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.
|
|
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:
|
|
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.
|
|
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,
|
|
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);
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const APP_VERSION = "1.0.
|
|
1
|
+
export declare const APP_VERSION = "1.0.14";
|
package/dist/src/version.js
CHANGED
|
@@ -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.
|
|
2
|
+
export const APP_VERSION = "1.0.14";
|