@imenam/mcp-messenger 1.0.11 → 1.0.13
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 +12 -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 +238 -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,12 @@ 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 ;
|
|
346
349
|
- **la liste des fils**, du plus récemment actif au plus ancien, chacun résumé par son
|
|
347
350
|
nom, ses correspondants, son dernier message et sa priorité la plus haute. Trois puces
|
|
348
351
|
la filtrent : tous, non lus, priorité haute ;
|
|
@@ -357,9 +360,11 @@ correspondants et l'état de lecture :
|
|
|
357
360
|
- un rafraîchissement automatique toutes les 5 secondes, débrayable.
|
|
358
361
|
|
|
359
362
|
**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
|
-
|
|
363
|
+
`box_9f2c4a01b3d7e5f8` en nom d'agent, et ne montre l'adresse qu'aux endroits où elle sert
|
|
364
|
+
vraiment : le volet de détail, l'en-tête d'un fil et les boîtes sans nom, chaque fois avec
|
|
365
|
+
un bouton de copie. Quand plusieurs sessions d'un même projet apparaissent, le nom de
|
|
366
|
+
l'agent est suivi de celui de la boîte entre parenthèses — sans quoi un fil se lirait
|
|
367
|
+
« 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
368
|
« jamais connectée » — elle reste listée, puisqu'elle a du courrier en attente.
|
|
364
369
|
|
|
365
370
|
À l'ouverture, la console demande `MESSENGER_ADMIN_TOKEN` dans un formulaire. Le serveur
|
|
@@ -440,7 +445,7 @@ la console (cookie), qui elle n'ouvre rien d'autre.
|
|
|
440
445
|
| `GET` `POST` `DELETE` | `/api/folders[/:name]` | Gérer les dossiers |
|
|
441
446
|
| `GET` | `/api/wait` | Attente longue (`timeout_seconds`, `folder`, `watcher`) |
|
|
442
447
|
| `GET` | `/admin/overview` · `/admin/messages[/:id]` | Supervision, lecture seule |
|
|
443
|
-
| `GET` | `/admin/threads` | Fils du serveur entier (`agent
|
|
448
|
+
| `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
449
|
| `GET` | `/admin/threads/:id` | Fil complet, corps inclus |
|
|
445
450
|
| `POST` | `/ui/login` · `/ui/logout` | Session de la console (`{ "token": … }` → cookie `HttpOnly`) |
|
|
446
451
|
|
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,205 @@ 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 });
|
|
291
|
+
}
|
|
292
|
+
const group = groups.get(key);
|
|
293
|
+
group.boxes.push(box);
|
|
294
|
+
group.unread += box.unread;
|
|
295
|
+
}
|
|
296
|
+
return [...groups.values()];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function isSelected(kind, key) {
|
|
300
|
+
return state.selection.kind === kind && state.selection.key === key;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function select(kind, key) {
|
|
304
|
+
// Recliquer sur la sélection courante la relâche : le filtre ne s'emprisonne pas.
|
|
305
|
+
state.selection = isSelected(kind, key) ? { kind: "all", key: null } : { kind, key };
|
|
306
|
+
state.threadId = null;
|
|
307
|
+
state.messageId = null;
|
|
308
|
+
el.sheet.hidden = true;
|
|
309
|
+
el.console.classList.remove("has-sheet");
|
|
310
|
+
refresh();
|
|
311
|
+
}
|
|
312
|
+
|
|
232
313
|
function renderAgents(overview) {
|
|
233
|
-
state.directory = new Map(overview.agents.map((
|
|
314
|
+
state.directory = new Map(overview.agents.map((box) => [box.id, box]));
|
|
315
|
+
state.groups = groupByAgent(overview.agents);
|
|
234
316
|
el.agentsFoot.textContent = [
|
|
235
|
-
plural(
|
|
236
|
-
plural(overview.
|
|
317
|
+
plural(state.groups.length, "agent"),
|
|
318
|
+
plural(overview.agents.length, "boîte"),
|
|
237
319
|
plural(overview.threads, "fil"),
|
|
238
320
|
].join(" · ");
|
|
239
321
|
|
|
240
322
|
el.agents.replaceChildren();
|
|
241
|
-
el.agents.appendChild(
|
|
242
|
-
for (const
|
|
323
|
+
el.agents.appendChild(allRow(overview));
|
|
324
|
+
for (const group of state.groups) el.agents.appendChild(groupRow(group));
|
|
243
325
|
}
|
|
244
326
|
|
|
245
|
-
function
|
|
327
|
+
function allRow(overview) {
|
|
246
328
|
const item = node("li");
|
|
247
|
-
const
|
|
248
|
-
const
|
|
329
|
+
const unread = overview.agents.reduce((total, box) => total + box.unread, 0);
|
|
330
|
+
const row = node("button", `agent${isSelected("all", null) ? " is-selected" : ""}`);
|
|
331
|
+
row.type = "button";
|
|
332
|
+
|
|
333
|
+
const star = node("span", "avatar avatar--absent", "∗");
|
|
334
|
+
star.title = "Tous les agents";
|
|
335
|
+
row.appendChild(star);
|
|
336
|
+
|
|
337
|
+
const text = node("div", "agent-text");
|
|
338
|
+
text.appendChild(node("div", "agent-name", "Tous les agents"));
|
|
339
|
+
text.appendChild(node("div", "agent-box", plural(overview.threads, "fil")));
|
|
340
|
+
row.appendChild(text);
|
|
341
|
+
if (unread > 0) row.appendChild(node("span", "count count--accent", String(unread)));
|
|
342
|
+
|
|
343
|
+
row.addEventListener("click", () => select("all", null));
|
|
344
|
+
item.appendChild(row);
|
|
345
|
+
return item;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Un agent et, dépliées sous lui, ses boîtes.
|
|
350
|
+
*
|
|
351
|
+
* Deux zones de clic, deux gestes distincts : le chevron ouvre ou ferme, le nom filtre sur
|
|
352
|
+
* l'ensemble des sessions du projet. Filtrer déplie au passage, pour que la sélection
|
|
353
|
+
* montre toujours ce qu'elle recouvre.
|
|
354
|
+
*/
|
|
355
|
+
function groupRow(group) {
|
|
356
|
+
const item = node("li", "agent-group");
|
|
357
|
+
const open = state.expanded.has(group.key);
|
|
358
|
+
|
|
359
|
+
const header = node("div", `agent-head${open ? " is-open" : ""}`);
|
|
360
|
+
|
|
361
|
+
const toggle = node("button", "agent-toggle");
|
|
362
|
+
toggle.type = "button";
|
|
363
|
+
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
364
|
+
toggle.setAttribute("aria-label", `${open ? "Replier" : "Déplier"} les boîtes de ${group.name}`);
|
|
365
|
+
toggle.appendChild(icon("chevron"));
|
|
366
|
+
toggle.addEventListener("click", () => {
|
|
367
|
+
if (open) state.expanded.delete(group.key);
|
|
368
|
+
else state.expanded.add(group.key);
|
|
369
|
+
renderAgents({ agents: [...state.directory.values()], threads: currentThreadCount() });
|
|
370
|
+
});
|
|
371
|
+
header.appendChild(toggle);
|
|
249
372
|
|
|
250
373
|
const classes = ["agent"];
|
|
251
|
-
if (
|
|
252
|
-
if (unread > 0) classes.push("has-unread");
|
|
253
|
-
if (
|
|
374
|
+
if (isSelected("agent", group.key)) classes.push("is-selected");
|
|
375
|
+
if (group.unread > 0) classes.push("has-unread");
|
|
376
|
+
if (group.boxes.every((box) => !box.registered)) classes.push("agent--muted");
|
|
254
377
|
const row = node("button", classes.join(" "));
|
|
255
378
|
row.type = "button";
|
|
379
|
+
row.appendChild(
|
|
380
|
+
avatarFor(
|
|
381
|
+
group.key,
|
|
382
|
+
group.name,
|
|
383
|
+
group.name,
|
|
384
|
+
null,
|
|
385
|
+
group.boxes.every((box) => !box.registered)
|
|
386
|
+
)
|
|
387
|
+
);
|
|
256
388
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
389
|
+
const text = node("div", "agent-text");
|
|
390
|
+
text.appendChild(node("div", "agent-name", group.name));
|
|
391
|
+
text.appendChild(node("div", "agent-box", plural(group.boxes.length, "boîte")));
|
|
392
|
+
row.appendChild(text);
|
|
393
|
+
if (group.unread > 0) row.appendChild(node("span", "count count--accent", String(group.unread)));
|
|
394
|
+
row.title = `${group.name} · ${plural(group.boxes.length, "boîte")}`;
|
|
395
|
+
row.addEventListener("click", () => {
|
|
396
|
+
state.expanded.add(group.key);
|
|
397
|
+
select("agent", group.key);
|
|
398
|
+
});
|
|
399
|
+
header.appendChild(row);
|
|
400
|
+
item.appendChild(header);
|
|
401
|
+
|
|
402
|
+
if (open) {
|
|
403
|
+
const list = node("ul", "box-list");
|
|
404
|
+
for (const box of group.boxes) list.appendChild(boxRow(box));
|
|
405
|
+
item.appendChild(list);
|
|
263
406
|
}
|
|
407
|
+
return item;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function boxRow(box) {
|
|
411
|
+
const item = node("li");
|
|
412
|
+
const classes = ["agent", "box"];
|
|
413
|
+
if (isSelected("box", box.id)) classes.push("is-selected");
|
|
414
|
+
if (box.unread > 0) classes.push("has-unread");
|
|
415
|
+
if (!box.registered) classes.push("agent--muted");
|
|
416
|
+
const row = node("button", classes.join(" "));
|
|
417
|
+
row.type = "button";
|
|
418
|
+
|
|
419
|
+
const label = boxLabel(box);
|
|
420
|
+
row.appendChild(avatarFor(box.id, label, box.id, "avatar--sm", !box.registered));
|
|
264
421
|
|
|
265
422
|
const text = node("div", "agent-text");
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
423
|
+
// Sans nom donné par l'agent, l'adresse tient lieu de nom : elle est alors la seule
|
|
424
|
+
// chose qui distingue cette session de ses sœurs.
|
|
425
|
+
const name = node("div", "agent-name", label);
|
|
426
|
+
if (!box.display_name) name.classList.add("mono", "is-absent");
|
|
427
|
+
text.appendChild(name);
|
|
428
|
+
text.appendChild(
|
|
429
|
+
node(
|
|
430
|
+
"div",
|
|
431
|
+
"agent-box",
|
|
432
|
+
box.registered ? `${plural(box.sent, "envoyé")} · ${plural(box.received, "reçu")}` : "jamais connectée"
|
|
433
|
+
)
|
|
434
|
+
);
|
|
276
435
|
row.appendChild(text);
|
|
277
436
|
|
|
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
|
-
});
|
|
437
|
+
if (box.unread > 0) row.appendChild(node("span", "count count--accent", String(box.unread)));
|
|
438
|
+
row.title = box.id;
|
|
439
|
+
row.addEventListener("click", () => select("box", box.id));
|
|
288
440
|
item.appendChild(row);
|
|
289
441
|
return item;
|
|
290
442
|
}
|
|
291
443
|
|
|
444
|
+
/** Nombre de fils affiché en pied de colonne, conservé entre deux rendus locaux. */
|
|
445
|
+
function currentThreadCount() {
|
|
446
|
+
return state.threadCount || 0;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Adresses que la sélection courante recouvre, ou null quand elle ne filtre rien. */
|
|
450
|
+
function selectedAddresses() {
|
|
451
|
+
if (state.selection.kind === "box") return [state.selection.key];
|
|
452
|
+
if (state.selection.kind === "agent") {
|
|
453
|
+
const group = state.groups.find((g) => g.key === state.selection.key);
|
|
454
|
+
return group ? group.boxes.map((box) => box.id) : [];
|
|
455
|
+
}
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function selectionLabel() {
|
|
460
|
+
if (state.selection.kind === "box") {
|
|
461
|
+
const box = state.directory.get(state.selection.key);
|
|
462
|
+
return box ? boxLabel(box) : shortAddress(state.selection.key);
|
|
463
|
+
}
|
|
464
|
+
if (state.selection.kind === "agent") return state.selection.key;
|
|
465
|
+
return "Toutes les boîtes";
|
|
466
|
+
}
|
|
467
|
+
|
|
292
468
|
// --- Colonne des fils ------------------------------------------------------
|
|
293
469
|
|
|
294
470
|
function renderThreads(threads) {
|
|
295
|
-
|
|
296
|
-
el.threadsScope.textContent = `${
|
|
471
|
+
state.threadCount = threads.length;
|
|
472
|
+
el.threadsScope.textContent = `${selectionLabel()} · ${plural(threads.length, "fil")}`;
|
|
297
473
|
|
|
298
474
|
el.threads.replaceChildren();
|
|
299
475
|
if (threads.length === 0) {
|
|
@@ -310,7 +486,8 @@ function emptyReason() {
|
|
|
310
486
|
if (state.search) return "Aucun fil ne correspond à cette recherche.";
|
|
311
487
|
if (state.filter === "unread") return "Tout est lu de ce côté.";
|
|
312
488
|
if (state.filter === "high") return "Aucun message en priorité haute.";
|
|
313
|
-
if (state.
|
|
489
|
+
if (state.selection.kind === "box") return "Cette boîte n'a encore échangé avec personne.";
|
|
490
|
+
if (state.selection.kind === "agent") return "Aucune session de cet agent n'a encore échangé.";
|
|
314
491
|
return "Aucun agent n'a encore écrit sur ce serveur.";
|
|
315
492
|
}
|
|
316
493
|
|
|
@@ -596,7 +773,8 @@ async function refresh(options) {
|
|
|
596
773
|
const [overview, list] = await Promise.all([
|
|
597
774
|
api("/admin/overview"),
|
|
598
775
|
api("/admin/threads", {
|
|
599
|
-
agent:
|
|
776
|
+
// Une sélection d'agent recouvre toutes ses sessions : la route prend la liste.
|
|
777
|
+
agent: (selectedAddresses() || []).join(","),
|
|
600
778
|
search: state.search,
|
|
601
779
|
unread_only: state.filter === "unread" ? "true" : undefined,
|
|
602
780
|
priority: state.filter === "high" ? "high" : undefined,
|
|
@@ -682,10 +860,12 @@ el.loginForm.addEventListener("submit", async (event) => {
|
|
|
682
860
|
|
|
683
861
|
el.logout.addEventListener("click", async () => {
|
|
684
862
|
await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
|
|
685
|
-
state.
|
|
863
|
+
state.selection = { kind: "all", key: null };
|
|
864
|
+
state.expanded = new Set();
|
|
686
865
|
state.threadId = null;
|
|
687
866
|
state.messageId = null;
|
|
688
867
|
state.directory = new Map();
|
|
868
|
+
state.groups = [];
|
|
689
869
|
el.sheet.hidden = true;
|
|
690
870
|
el.console.classList.remove("has-sheet");
|
|
691
871
|
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.13";
|
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.13";
|