@imenam/mcp-messenger 1.0.29 → 1.0.31

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.
@@ -1,7 +1,7 @@
1
- // Console de supervision : les routes /admin du serveur, en lecture — à trois exceptions
2
- // près, les suppressions de boîtes, seules commandes de cette page qui écrivent, et qui
3
- // détruisent. Elles passent toutes par la même fenêtre : un aperçu de ce qui va partir,
4
- // puis une confirmation en deux clics.
1
+ // Console de supervision : les routes /admin du serveur. Deux familles de commandes
2
+ // écrivent : les liaisons entre boîtes (création, suppression), et les suppressions de
3
+ // boîtes, qui passent toutes par la même fenêtre — un aperçu de ce qui va partir, puis une
4
+ // confirmation en deux clics.
5
5
  //
6
6
  // Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement,
7
7
  // jamais par innerHTML. Les seuls fragments de balisage construits ici sont les icônes,
@@ -27,10 +27,24 @@ const ICONS = {
27
27
  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"],
28
28
  chevron: ["m9 18 6-6-6-6"],
29
29
  info: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z", "M12 11v5", "M12 8h.01"],
30
+ paperclip: ["m21 11-8.5 8.5a5 5 0 0 1-7-7L14 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L15 7"],
30
31
  trash: ["M3 6h18", "M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2", "M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"],
32
+ unlink: [
33
+ "m18.84 12.25 1.72-1.71a5 5 0 0 0-7.07-7.07l-1.72 1.71",
34
+ "m5.17 11.75-1.71 1.71a5 5 0 0 0 7.07 7.07l1.71-1.71",
35
+ "M8 2v3",
36
+ "M2 8h3",
37
+ "M16 22v-3",
38
+ "M22 16h-3",
39
+ ],
40
+ search: ["m21 21-4.3-4.3", "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"],
31
41
  };
32
42
 
33
43
  const state = {
44
+ /** "threads" | "links" — l'écran affiché, choisi par les onglets de la barre du haut. */
45
+ view: "threads",
46
+ /** Liaisons du serveur, relues à chaque rafraîchissement. */
47
+ links: [],
34
48
  /**
35
49
  * Ce sur quoi la liste des fils est filtrée.
36
50
  *
@@ -107,6 +121,13 @@ const el = {
107
121
  purgePreviewEmpty: document.getElementById("purge-preview-empty"),
108
122
  purgeResult: document.getElementById("purge-result"),
109
123
  purgeConfirm: document.getElementById("purge-confirm"),
124
+ tabs: document.getElementById("tabs"),
125
+ linksBadge: document.getElementById("links-badge"),
126
+ links: document.getElementById("links"),
127
+ linksWindow: document.getElementById("links-window"),
128
+ linksScroll: document.getElementById("links-scroll"),
129
+ linksCanvas: document.getElementById("links-canvas"),
130
+ linksPanel: document.getElementById("links-panel"),
110
131
  };
111
132
 
112
133
  // --- Fabrique de noeuds ----------------------------------------------------
@@ -360,9 +381,12 @@ async function action(method, path, body) {
360
381
 
361
382
  function setLoggedIn(loggedIn) {
362
383
  el.login.hidden = loggedIn;
363
- el.console.hidden = !loggedIn;
384
+ el.console.hidden = !loggedIn || state.view !== "threads";
385
+ el.links.hidden = !loggedIn || state.view !== "links";
386
+ el.tabs.hidden = !loggedIn;
364
387
  el.controls.hidden = !loggedIn;
365
- el.searchBox.hidden = !loggedIn;
388
+ // La recherche porte sur les fils ; le volet des liaisons a la sienne.
389
+ el.searchBox.hidden = !loggedIn || state.view !== "threads";
366
390
  if (!loggedIn) {
367
391
  setStatus("idle", "non connecté");
368
392
  el.token.focus();
@@ -961,6 +985,9 @@ function fillMessage(item, message, isLast) {
961
985
  column.appendChild(content);
962
986
 
963
987
  content.appendChild(messageText(message));
988
+ if (message.attachments && message.attachments.length > 0) {
989
+ content.appendChild(attachmentList(message.attachments));
990
+ }
964
991
 
965
992
  const foot = node("div", "message-foot");
966
993
  foot.appendChild(
@@ -986,6 +1013,30 @@ function messageText(message) {
986
1013
  return container;
987
1014
  }
988
1015
 
1016
+ /** Pièces jointes d'un message : un lien de téléchargement par fichier. */
1017
+ function attachmentList(attachments) {
1018
+ const list = node("ul", "attachments");
1019
+ for (const attachment of attachments) {
1020
+ const item = node("li");
1021
+ const link = node("a", "attachment");
1022
+ link.href = `/admin/attachments/${encodeURIComponent(attachment.attachment_id)}`;
1023
+ link.download = attachment.filename;
1024
+ link.title = attachment.mime_type;
1025
+ link.appendChild(icon("paperclip"));
1026
+ link.appendChild(node("span", "attachment-name", attachment.filename));
1027
+ link.appendChild(node("span", "attachment-size", formatSize(attachment.size)));
1028
+ item.appendChild(link);
1029
+ list.appendChild(item);
1030
+ }
1031
+ return list;
1032
+ }
1033
+
1034
+ function formatSize(bytes) {
1035
+ if (bytes < 1024) return `${bytes} o`;
1036
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
1037
+ return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
1038
+ }
1039
+
989
1040
  // --- Détails d'un message --------------------------------------------------
990
1041
 
991
1042
  /**
@@ -1159,8 +1210,12 @@ async function refreshThread() {
1159
1210
  // --- Boucle de rafraîchissement --------------------------------------------
1160
1211
 
1161
1212
  async function refresh(options) {
1213
+ if (state.view === "links") {
1214
+ await refreshLinks();
1215
+ return;
1216
+ }
1162
1217
  try {
1163
- const [overview, list] = await Promise.all([
1218
+ const [overview, list, links] = await Promise.all([
1164
1219
  api("/admin/overview"),
1165
1220
  api("/admin/threads", {
1166
1221
  // Une sélection d'agent recouvre toutes ses sessions : la route prend la liste.
@@ -1170,10 +1225,13 @@ async function refresh(options) {
1170
1225
  priority: state.filter === "high" ? "high" : undefined,
1171
1226
  limit: 100,
1172
1227
  }),
1228
+ api("/admin/links"),
1173
1229
  ]);
1174
1230
  setLoggedIn(true);
1175
1231
  setStatus("ok", `connecté · ${window.location.host}`);
1176
1232
  renderAgents(overview);
1233
+ state.links = links.links;
1234
+ renderLinksBadge();
1177
1235
  renderThreads(list.threads);
1178
1236
  if (!options || !options.keepThread) {
1179
1237
  if (state.threadId) await refreshThread();
@@ -1293,6 +1351,784 @@ for (const id of Object.keys(PANELS)) {
1293
1351
 
1294
1352
  loadLayout();
1295
1353
 
1354
+ // --- Liaisons --------------------------------------------------------------
1355
+
1356
+ /**
1357
+ * Carte des liaisons : une colonne par agent, celle dont la dernière boîte est la plus
1358
+ * récente à gauche ; en hauteur, l'heure exacte de création de chaque boîte, la plus
1359
+ * récente en haut. Les cinq dernières minutes occupent la plus grande part de la hauteur,
1360
+ * le reste de la fenêtre détaillée est resserré. Au-delà de la fenêtre, les boîtes sont
1361
+ * empilées en liste sous leur agent.
1362
+ */
1363
+ const LINKS_WINDOW_KEY = "messenger.links.window";
1364
+ const LINKS_WINDOWS = [5, 10, 15, 30, 60, 120, 240];
1365
+ const ZOOM_MINUTES = 5;
1366
+ const MAP = {
1367
+ axis: 92,
1368
+ lane: 180,
1369
+ gap: 12,
1370
+ head: 56,
1371
+ card: 46,
1372
+ cardGap: 4,
1373
+ /** Hauteur d'une minute dans la zone agrandie. */
1374
+ zoomPx: 64,
1375
+ /** Hauteur de la zone resserrée, quelle que soit sa durée. */
1376
+ restPx: 200,
1377
+ oldRow: 30,
1378
+ oldShown: 5,
1379
+ /** Boîtes plus anciennes que la fenêtre proposées dans le volet avant « Afficher ». */
1380
+ oldCandidates: 8,
1381
+ };
1382
+
1383
+ const linksView = {
1384
+ windowMinutes: 30,
1385
+ /** Adresse de la boîte choisie sur la carte. */
1386
+ selected: null,
1387
+ /** Boîte pour laquelle le volet est construit : ses champs de saisie survivent aux rafraîchissements. */
1388
+ panelFor: null,
1389
+ search: "",
1390
+ note: "",
1391
+ /** Colonnes dont la liste des boîtes anciennes est entièrement dépliée. */
1392
+ showOld: new Set(),
1393
+ showAllCandidates: false,
1394
+ /** Liaison dont la suppression attend son second clic. */
1395
+ armedUnlink: null,
1396
+ /** Glissé en cours depuis la poignée d'une boîte. */
1397
+ drag: null,
1398
+ /** Position de chaque boîte affichée sur la carte, par adresse. */
1399
+ rects: new Map(),
1400
+ error: "",
1401
+ };
1402
+
1403
+ function loadLinksWindow() {
1404
+ let saved = null;
1405
+ try {
1406
+ saved = Number(window.localStorage.getItem(LINKS_WINDOW_KEY));
1407
+ } catch {
1408
+ saved = null;
1409
+ }
1410
+ if (LINKS_WINDOWS.includes(saved)) linksView.windowMinutes = saved;
1411
+ el.linksWindow.value = String(linksView.windowMinutes);
1412
+ }
1413
+
1414
+ function saveLinksWindow() {
1415
+ try {
1416
+ window.localStorage.setItem(LINKS_WINDOW_KEY, String(linksView.windowMinutes));
1417
+ } catch {
1418
+ // Stockage refusé : le réglage vaut pour cette visite.
1419
+ }
1420
+ }
1421
+
1422
+ /** Heure à la seconde : deux boîtes ouvertes dans la même minute doivent se distinguer. */
1423
+ function formatClock(value) {
1424
+ const date = new Date(value);
1425
+ return Number.isNaN(date.getTime())
1426
+ ? String(value)
1427
+ : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
1428
+ }
1429
+
1430
+ /** Âge lisible : « 38 s », « 4 min », « 2 h 05 ». */
1431
+ function formatAge(ms) {
1432
+ const seconds = Math.max(0, Math.floor(ms / 1000));
1433
+ if (seconds < 60) return `${seconds} s`;
1434
+ const minutes = Math.floor(seconds / 60);
1435
+ if (minutes < 60) return `${minutes} min`;
1436
+ const hours = Math.floor(minutes / 60);
1437
+ return `${hours} h ${String(minutes % 60).padStart(2, "0")}`;
1438
+ }
1439
+
1440
+ function formatOffset(minutes) {
1441
+ if (minutes < 60) return `−${minutes} min`;
1442
+ const hours = Math.floor(minutes / 60);
1443
+ const rest = minutes % 60;
1444
+ return rest ? `−${hours} h ${String(rest).padStart(2, "0")}` : `−${hours} h`;
1445
+ }
1446
+
1447
+ /** Boîtes qu'une liaison peut relier : celles qu'une session a ouvertes. */
1448
+ function linkableBoxes() {
1449
+ return [...state.directory.values()].filter((box) => box.registered);
1450
+ }
1451
+
1452
+ function ageOf(box, now) {
1453
+ return now - new Date(box.created_at).getTime();
1454
+ }
1455
+
1456
+ function isRecent(box, now) {
1457
+ return ageOf(box, now) <= linksView.windowMinutes * 60_000;
1458
+ }
1459
+
1460
+ /** Rang de création des boîtes de la fenêtre détaillée : 1 pour la plus récente. */
1461
+ function creationRanks(boxes, now) {
1462
+ const recent = boxes.filter((box) => isRecent(box, now)).sort(byCreation);
1463
+ return new Map(recent.map((box, index) => [box.id, index + 1]));
1464
+ }
1465
+
1466
+ function byCreation(a, b) {
1467
+ return a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : a.id < b.id ? -1 : 1;
1468
+ }
1469
+
1470
+ /** Adresses liées à chaque boîte. */
1471
+ function linkIndex() {
1472
+ const index = new Map();
1473
+ for (const link of state.links) {
1474
+ const [a, b] = link.mailboxes.map((end) => end.mailbox);
1475
+ if (!index.has(a)) index.set(a, new Set());
1476
+ if (!index.has(b)) index.set(b, new Set());
1477
+ index.get(a).add(b);
1478
+ index.get(b).add(a);
1479
+ }
1480
+ return index;
1481
+ }
1482
+
1483
+ /** Colonnes de la carte : les boîtes groupées par agent, l'agent le plus récent en tête. */
1484
+ function linkLanes(boxes) {
1485
+ const lanes = new Map();
1486
+ for (const box of boxes) {
1487
+ const key = box.agent_name || box.id;
1488
+ if (!lanes.has(key)) lanes.set(key, { key, name: box.agent_name || "agent sans nom", seed: key, boxes: [] });
1489
+ lanes.get(key).boxes.push(box);
1490
+ }
1491
+ const list = [...lanes.values()];
1492
+ for (const lane of list) lane.boxes.sort(byCreation);
1493
+ list.sort((a, b) => byCreation(a.boxes[0], b.boxes[0]));
1494
+ return list;
1495
+ }
1496
+
1497
+ /** Nombre de boîtes de la fenêtre détaillée que rien ne relie encore. */
1498
+ function renderLinksBadge() {
1499
+ const now = Date.now();
1500
+ const linked = linkIndex();
1501
+ const waiting = linkableBoxes().filter((box) => isRecent(box, now) && !linked.has(box.id)).length;
1502
+ el.linksBadge.hidden = waiting === 0;
1503
+ el.linksBadge.textContent = String(waiting);
1504
+ el.linksBadge.title = `${plural(waiting, "boîte récente")} sans liaison`;
1505
+ }
1506
+
1507
+ async function refreshLinks() {
1508
+ try {
1509
+ const [overview, links] = await Promise.all([api("/admin/overview"), api("/admin/links")]);
1510
+ setLoggedIn(true);
1511
+ setStatus("ok", `connecté · ${window.location.host}`);
1512
+ renderAgents(overview);
1513
+ state.links = links.links;
1514
+ renderLinksView();
1515
+ } catch (error) {
1516
+ if (error.message === "unauthorized") {
1517
+ setStatus("idle", "authentification requise");
1518
+ return;
1519
+ }
1520
+ setStatus("ko", `erreur : ${error.message}`);
1521
+ }
1522
+ }
1523
+
1524
+ function renderLinksView() {
1525
+ renderLinksBadge();
1526
+ if (linksView.selected && !state.directory.has(linksView.selected)) linksView.selected = null;
1527
+ // Un rafraîchissement pendant un glissé effacerait le fil qu'on est en train de tirer.
1528
+ if (!linksView.drag) renderLinksMap();
1529
+ renderLinksPanel();
1530
+ }
1531
+
1532
+ function setView(view) {
1533
+ if (state.view === view) return;
1534
+ state.view = view;
1535
+ for (const tab of el.tabs.querySelectorAll(".tab")) {
1536
+ tab.classList.toggle("is-active", tab.dataset.view === view);
1537
+ }
1538
+ setLoggedIn(true);
1539
+ refresh({ keepThread: true });
1540
+ }
1541
+
1542
+ // Carte ---------------------------------------------------------------------
1543
+
1544
+ function mapGeometry() {
1545
+ const minutes = linksView.windowMinutes;
1546
+ const zoomMinutes = Math.min(ZOOM_MINUTES, minutes);
1547
+ const nowY = MAP.head + MAP.card / 2 + 6;
1548
+ const zoomH = zoomMinutes * MAP.zoomPx;
1549
+ const restH = minutes > zoomMinutes ? MAP.restPx : 0;
1550
+ return { minutes, zoomMinutes, nowY, zoomH, restH, bottom: nowY + zoomH + restH };
1551
+ }
1552
+
1553
+ /** Hauteur, sur la carte, d'une boîte ouverte il y a `ageMinutes`. */
1554
+ function yForAge(geometry, ageMinutes) {
1555
+ const { minutes, zoomMinutes, nowY, zoomH, restH } = geometry;
1556
+ if (ageMinutes <= zoomMinutes) return nowY + ageMinutes * MAP.zoomPx;
1557
+ return nowY + zoomH + ((ageMinutes - zoomMinutes) / (minutes - zoomMinutes)) * restH;
1558
+ }
1559
+
1560
+ /** Graduations de la zone resserrée : quelques repères ronds, et la fin de la fenêtre. */
1561
+ function restTicks(geometry) {
1562
+ const { minutes, zoomMinutes } = geometry;
1563
+ if (minutes <= zoomMinutes) return [];
1564
+ const span = minutes - zoomMinutes;
1565
+ const step = [5, 10, 15, 30, 60].find((candidate) => span / candidate <= 4) || 60;
1566
+ const ticks = [];
1567
+ for (let m = Math.ceil((zoomMinutes + 1) / step) * step; m < minutes; m += step) ticks.push(m);
1568
+ ticks.push(minutes);
1569
+ return ticks;
1570
+ }
1571
+
1572
+ function placed(element, x, y, width, height) {
1573
+ element.style.left = `${x}px`;
1574
+ element.style.top = `${y}px`;
1575
+ if (width !== undefined) element.style.width = `${width}px`;
1576
+ if (height !== undefined) element.style.height = `${height}px`;
1577
+ return element;
1578
+ }
1579
+
1580
+ function renderLinksMap() {
1581
+ const now = Date.now();
1582
+ const geometry = mapGeometry();
1583
+ const boxes = linkableBoxes();
1584
+ const lanes = linkLanes(boxes);
1585
+ const ranks = creationRanks(boxes, now);
1586
+ const linked = linkIndex();
1587
+ const canvas = el.linksCanvas;
1588
+ linksView.rects = new Map();
1589
+
1590
+ if (lanes.length === 0) {
1591
+ const empty = node("div", "empty");
1592
+ empty.appendChild(node("span", "empty-title", "Aucune boîte"));
1593
+ empty.appendChild(document.createTextNode("Les boîtes apparaissent ici dès qu'une session se connecte."));
1594
+ canvas.replaceChildren(empty);
1595
+ canvas.style.width = "";
1596
+ canvas.style.height = "";
1597
+ return;
1598
+ }
1599
+
1600
+ const width = MAP.axis + lanes.length * (MAP.lane + MAP.gap);
1601
+ const fragment = document.createDocumentFragment();
1602
+
1603
+ // Colonnes, et ce qui les place : la position de chaque boîte est calculée ici, une fois.
1604
+ const laneLayouts = lanes.map((lane, index) => {
1605
+ const x = MAP.axis + index * (MAP.lane + MAP.gap);
1606
+ const recent = lane.boxes.filter((box) => isRecent(box, now));
1607
+ const old = lane.boxes.filter((box) => !isRecent(box, now));
1608
+ let previousBottom = MAP.head + 4;
1609
+ const cards = recent.map((box) => {
1610
+ const center = yForAge(geometry, ageOf(box, now) / 60_000);
1611
+ return { box, top: center - MAP.card / 2 };
1612
+ });
1613
+ // Deux boîtes trop proches dans le temps se chevaucheraient : la plus ancienne descend.
1614
+ for (const card of cards) {
1615
+ card.top = Math.max(card.top, previousBottom + MAP.cardGap);
1616
+ previousBottom = card.top + MAP.card;
1617
+ }
1618
+ return { lane, x, cards, old, cardsBottom: previousBottom };
1619
+ });
1620
+
1621
+ // La marge laisse la place au libellé de la dernière graduation, écrit sous son trait.
1622
+ const timeBottom = Math.max(geometry.bottom + 32, ...laneLayouts.map((layout) => layout.cardsBottom + 12));
1623
+ const oldTop = timeBottom + 28;
1624
+ const oldRows = Math.max(
1625
+ 1,
1626
+ ...laneLayouts.map((layout) =>
1627
+ linksView.showOld.has(layout.lane.key) ? layout.old.length + 1 : Math.min(layout.old.length, MAP.oldShown) + 1
1628
+ )
1629
+ );
1630
+ const height = oldTop + oldRows * MAP.oldRow + 16;
1631
+
1632
+ for (const layout of laneLayouts) fragment.appendChild(laneColumn(layout, height, now));
1633
+
1634
+ fragment.appendChild(timeAxis(geometry, width, now));
1635
+
1636
+ const separator = placed(node("div", "links-separator"), 8, timeBottom + 8, width - 16);
1637
+ fragment.appendChild(separator);
1638
+ const oldLabel = placed(node("div", "links-axis-label links-axis-old"), 8, timeBottom + 14, MAP.axis - 16);
1639
+ oldLabel.appendChild(node("strong", null, `Plus de ${formatOffset(linksView.windowMinutes).slice(1)}`));
1640
+ oldLabel.appendChild(node("span", null, "listes empilées"));
1641
+ fragment.appendChild(oldLabel);
1642
+
1643
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1644
+ svg.setAttribute("class", "links-wires");
1645
+ svg.setAttribute("width", String(width));
1646
+ svg.setAttribute("height", String(height));
1647
+ fragment.appendChild(svg);
1648
+
1649
+ for (const layout of laneLayouts) {
1650
+ for (const card of layout.cards) {
1651
+ fragment.appendChild(boxCard(card.box, layout.x + 10, card.top, ranks.get(card.box.id), linked, now));
1652
+ }
1653
+ fragment.appendChild(oldList(layout, oldTop, linked));
1654
+ }
1655
+
1656
+ canvas.replaceChildren(fragment);
1657
+ canvas.style.width = `${width}px`;
1658
+ canvas.style.height = `${height}px`;
1659
+ drawWires(svg);
1660
+ }
1661
+
1662
+ function laneColumn(layout, height, now) {
1663
+ const { lane, x } = layout;
1664
+ const column = placed(node("div", "links-lane"), x, 0, MAP.lane, height);
1665
+ const head = node("div", "links-lane-head");
1666
+ head.appendChild(avatarFor(lane.seed, lane.name, lane.name, null, false));
1667
+ const text = node("div", "links-lane-text");
1668
+ text.appendChild(node("div", "links-lane-name", lane.name));
1669
+ const age = ageOf(lane.boxes[0], now);
1670
+ const last = node("div", "links-lane-last", `dernière : il y a ${formatAge(age)}`);
1671
+ if (age <= ZOOM_MINUTES * 60_000) last.classList.add("is-fresh");
1672
+ text.appendChild(last);
1673
+ head.appendChild(text);
1674
+ head.title = `${lane.name} · ${plural(lane.boxes.length, "boîte")}`;
1675
+ column.appendChild(head);
1676
+ return column;
1677
+ }
1678
+
1679
+ function timeAxis(geometry, width, now) {
1680
+ const axis = document.createDocumentFragment();
1681
+ axis.appendChild(placed(node("div", "links-zoom"), MAP.axis - 8, geometry.nowY, width - MAP.axis, geometry.zoomH));
1682
+
1683
+ const tick = (minutes, kind) => {
1684
+ const y = yForAge(geometry, minutes);
1685
+ axis.appendChild(placed(node("div", `links-tick links-tick--${kind}`), 8, y, width - 16));
1686
+ const label = placed(node("div", `links-axis-label links-axis-label--${kind}`), 8, y + 3, MAP.axis - 16);
1687
+ label.appendChild(node("strong", null, minutes === 0 ? "maintenant" : formatOffset(minutes)));
1688
+ if (kind !== "rest") label.appendChild(node("span", "mono", formatClock(now - minutes * 60_000)));
1689
+ axis.appendChild(label);
1690
+ };
1691
+
1692
+ for (let m = 0; m <= geometry.zoomMinutes; m += 1) tick(m, m === 0 ? "now" : "zoom");
1693
+ for (const m of restTicks(geometry)) tick(m, "rest");
1694
+ return axis;
1695
+ }
1696
+
1697
+ function boxCard(box, x, y, rank, linked, now) {
1698
+ const classes = ["links-card"];
1699
+ const age = ageOf(box, now);
1700
+ if (linksView.selected === box.id) classes.push("is-selected");
1701
+ if (linksView.selected && linked.get(linksView.selected)?.has(box.id)) classes.push("is-linked");
1702
+ const card = placed(node("button", classes.join(" ")), x, y, MAP.lane - 20, MAP.card);
1703
+ card.type = "button";
1704
+ card.dataset.box = box.id;
1705
+ card.title = boxTitle(box.id);
1706
+
1707
+ card.appendChild(node("span", rank <= 3 ? "links-rank is-top" : "links-rank", String(rank)));
1708
+ const text = node("span", "links-card-text");
1709
+ const name = node("span", "links-card-name", box.display_name || box.id);
1710
+ if (!box.display_name) name.classList.add("mono", "is-absent");
1711
+ text.appendChild(name);
1712
+ const meta = node("span", "links-card-meta");
1713
+ meta.appendChild(node("span", "mono", formatClock(box.created_at)));
1714
+ const ago = node("span", "links-card-ago", formatAge(age));
1715
+ if (age <= ZOOM_MINUTES * 60_000) ago.classList.add("is-fresh");
1716
+ meta.appendChild(ago);
1717
+ text.appendChild(meta);
1718
+ card.appendChild(text);
1719
+
1720
+ const count = linked.get(box.id)?.size || 0;
1721
+ if (count > 0) {
1722
+ const badge = node("span", "links-card-count");
1723
+ badge.appendChild(icon("link"));
1724
+ badge.appendChild(document.createTextNode(String(count)));
1725
+ badge.title = plural(count, "liaison");
1726
+ card.appendChild(badge);
1727
+ }
1728
+
1729
+ card.appendChild(dragHandle(box));
1730
+ card.addEventListener("click", () => selectLinkBox(box.id));
1731
+ linksView.rects.set(box.id, { x, y, w: MAP.lane - 20, h: MAP.card });
1732
+ return card;
1733
+ }
1734
+
1735
+ function oldList(layout, top, linked) {
1736
+ const { lane, x, old } = layout;
1737
+ const list = document.createDocumentFragment();
1738
+ const expanded = linksView.showOld.has(lane.key);
1739
+ const shown = expanded ? old : old.slice(0, MAP.oldShown);
1740
+
1741
+ if (old.length === 0) {
1742
+ list.appendChild(placed(node("div", "links-old-none", "aucune autre boîte"), x + 10, top, MAP.lane - 20, 24));
1743
+ return list;
1744
+ }
1745
+
1746
+ shown.forEach((box, index) => {
1747
+ const y = top + index * MAP.oldRow;
1748
+ const classes = ["links-old-row"];
1749
+ if (linksView.selected === box.id) classes.push("is-selected");
1750
+ if (linksView.selected && linked.get(linksView.selected)?.has(box.id)) classes.push("is-linked");
1751
+ const row = placed(node("button", classes.join(" ")), x + 10, y, MAP.lane - 20, MAP.oldRow - 4);
1752
+ row.type = "button";
1753
+ row.dataset.box = box.id;
1754
+ row.title = boxTitle(box.id);
1755
+ const name = node("span", "links-old-name", box.display_name || box.id);
1756
+ if (!box.display_name) name.classList.add("mono", "is-absent");
1757
+ row.appendChild(name);
1758
+ if (linked.get(box.id)?.size) row.appendChild(icon("link"));
1759
+ row.appendChild(node("span", "links-old-date", formatShort(box.created_at)));
1760
+ row.addEventListener("click", () => selectLinkBox(box.id));
1761
+ linksView.rects.set(box.id, { x: x + 10, y, w: MAP.lane - 20, h: MAP.oldRow - 4 });
1762
+ list.appendChild(row);
1763
+ });
1764
+
1765
+ if (old.length > MAP.oldShown) {
1766
+ const more = expanded ? "Replier" : `+ ${old.length - MAP.oldShown} autres`;
1767
+ const toggle = placed(
1768
+ button("links-old-more", more, () => {
1769
+ if (expanded) linksView.showOld.delete(lane.key);
1770
+ else linksView.showOld.add(lane.key);
1771
+ renderLinksMap();
1772
+ }),
1773
+ x + 10,
1774
+ top + shown.length * MAP.oldRow,
1775
+ MAP.lane - 20
1776
+ );
1777
+ list.appendChild(toggle);
1778
+ }
1779
+ return list;
1780
+ }
1781
+
1782
+ /** Courbe d'une boîte à l'autre : des bords qui se font face, ou par la droite dans une même colonne. */
1783
+ function wirePath(from, to) {
1784
+ const ay = from.y + from.h / 2;
1785
+ const by = to.y + to.h / 2;
1786
+ if (from.x + from.w <= to.x || to.x + to.w <= from.x) {
1787
+ const [left, right, ly, ry] = from.x < to.x ? [from, to, ay, by] : [to, from, by, ay];
1788
+ const x1 = left.x + left.w;
1789
+ const x2 = right.x;
1790
+ const dx = Math.max(24, (x2 - x1) / 2);
1791
+ return `M${x1} ${ly} C${x1 + dx} ${ly} ${x2 - dx} ${ry} ${x2} ${ry}`;
1792
+ }
1793
+ const x = from.x + from.w;
1794
+ return `M${x} ${ay} C${x + 40} ${ay} ${x + 40} ${by} ${x} ${by}`;
1795
+ }
1796
+
1797
+ function wire(svg, d, className) {
1798
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
1799
+ path.setAttribute("d", d);
1800
+ path.setAttribute("class", className);
1801
+ svg.appendChild(path);
1802
+ return path;
1803
+ }
1804
+
1805
+ function drawWires(svg) {
1806
+ for (const link of state.links) {
1807
+ const [a, b] = link.mailboxes.map((end) => end.mailbox);
1808
+ const from = linksView.rects.get(a);
1809
+ const to = linksView.rects.get(b);
1810
+ if (!from || !to) continue;
1811
+ const active = linksView.selected === a || linksView.selected === b;
1812
+ wire(svg, wirePath(from, to), active ? "links-wire is-active" : "links-wire");
1813
+ }
1814
+ }
1815
+
1816
+ /** Poignée d'une boîte : la tirer jusqu'à une autre boîte les lie. */
1817
+ function dragHandle(box) {
1818
+ const handle = node("span", "links-handle");
1819
+ handle.title = "Tirer jusqu'à une autre boîte pour les lier";
1820
+ handle.addEventListener("click", (event) => event.stopPropagation());
1821
+ handle.addEventListener("pointerdown", (event) => {
1822
+ event.preventDefault();
1823
+ event.stopPropagation();
1824
+ startLinkDrag(event, box.id, handle);
1825
+ });
1826
+ return handle;
1827
+ }
1828
+
1829
+ function canvasPoint(event) {
1830
+ const rect = el.linksCanvas.getBoundingClientRect();
1831
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top };
1832
+ }
1833
+
1834
+ function startLinkDrag(event, boxId, handle) {
1835
+ const svg = el.linksCanvas.querySelector(".links-wires");
1836
+ const from = linksView.rects.get(boxId);
1837
+ if (!svg || !from) return;
1838
+ const start = { x: from.x + from.w, y: from.y + from.h / 2 };
1839
+ const path = wire(svg, "", "links-wire is-drawing");
1840
+ linksView.drag = { from: boxId };
1841
+ el.links.classList.add("is-linking");
1842
+ handle.setPointerCapture?.(event.pointerId);
1843
+
1844
+ const onMove = (move) => {
1845
+ const point = canvasPoint(move);
1846
+ const dx = Math.max(24, Math.abs(point.x - start.x) / 2);
1847
+ path.setAttribute("d", `M${start.x} ${start.y} C${start.x + dx} ${start.y} ${point.x - dx} ${point.y} ${point.x} ${point.y}`);
1848
+ };
1849
+ const onUp = (up) => {
1850
+ window.removeEventListener("pointermove", onMove);
1851
+ window.removeEventListener("pointerup", onUp);
1852
+ el.links.classList.remove("is-linking");
1853
+ linksView.drag = null;
1854
+ const target = document.elementFromPoint(up.clientX, up.clientY)?.closest("[data-box]");
1855
+ const to = target?.dataset.box;
1856
+ if (to && to !== boxId) createLink(boxId, to);
1857
+ else renderLinksMap();
1858
+ };
1859
+ window.addEventListener("pointermove", onMove);
1860
+ window.addEventListener("pointerup", onUp);
1861
+ }
1862
+
1863
+ function selectLinkBox(boxId) {
1864
+ linksView.selected = linksView.selected === boxId ? null : boxId;
1865
+ linksView.armedUnlink = null;
1866
+ linksView.error = "";
1867
+ renderLinksMap();
1868
+ renderLinksPanel();
1869
+ }
1870
+
1871
+ async function createLink(first, second) {
1872
+ linksView.error = "";
1873
+ try {
1874
+ await post("/admin/links", { mailboxes: [first, second], note: linksView.note.trim() || null });
1875
+ linksView.note = "";
1876
+ const note = el.linksPanel.querySelector("#links-note");
1877
+ if (note) note.value = "";
1878
+ } catch (error) {
1879
+ linksView.error = error.message;
1880
+ }
1881
+ await refreshLinks();
1882
+ }
1883
+
1884
+ async function deleteLink(linkId) {
1885
+ if (linksView.armedUnlink !== linkId) {
1886
+ linksView.armedUnlink = linkId;
1887
+ renderLinksPanel();
1888
+ return;
1889
+ }
1890
+ linksView.armedUnlink = null;
1891
+ linksView.error = "";
1892
+ try {
1893
+ await remove(`/admin/links/${encodeURIComponent(linkId)}`);
1894
+ } catch (error) {
1895
+ linksView.error = error.message;
1896
+ }
1897
+ await refreshLinks();
1898
+ }
1899
+
1900
+ // Volet ---------------------------------------------------------------------
1901
+
1902
+ /**
1903
+ * Volet de la boîte choisie. Il n'est reconstruit qu'au changement de boîte : ses champs
1904
+ * de saisie gardent leur contenu et le focus pendant les rafraîchissements, seules les
1905
+ * listes sont redessinées.
1906
+ */
1907
+ function renderLinksPanel() {
1908
+ const id = linksView.selected;
1909
+ if (!id) {
1910
+ linksView.panelFor = null;
1911
+ const empty = node("div", "empty");
1912
+ empty.appendChild(node("span", "empty-title", "Aucune boîte choisie"));
1913
+ empty.appendChild(document.createTextNode("Choisissez une boîte sur la carte pour voir ses liaisons et en créer."));
1914
+ el.linksPanel.replaceChildren(empty, deliveryNote());
1915
+ return;
1916
+ }
1917
+ if (linksView.panelFor !== id) buildLinksPanel(id);
1918
+ fillLinksPanel(id);
1919
+ }
1920
+
1921
+ function buildLinksPanel(id) {
1922
+ linksView.panelFor = id;
1923
+ linksView.search = "";
1924
+ linksView.showAllCandidates = false;
1925
+ const panel = document.createDocumentFragment();
1926
+
1927
+ const head = node("div", "links-panel-head");
1928
+ head.appendChild(node("span", "section-label", "Boîte sélectionnée"));
1929
+ const close = node("button", "btn btn--icon btn--ghost");
1930
+ close.type = "button";
1931
+ close.title = "Fermer";
1932
+ close.setAttribute("aria-label", "Désélectionner la boîte");
1933
+ close.appendChild(icon("close"));
1934
+ close.addEventListener("click", () => selectLinkBox(id));
1935
+ head.appendChild(close);
1936
+ panel.appendChild(head);
1937
+
1938
+ panel.appendChild(node("div", "links-panel-box"));
1939
+
1940
+ const linksSection = node("div", "links-panel-section");
1941
+ linksSection.appendChild(node("h3", null, "Liaisons"));
1942
+ linksSection.appendChild(node("ul", "links-panel-links"));
1943
+ panel.appendChild(linksSection);
1944
+
1945
+ const linkTo = node("div", "links-panel-section");
1946
+ const title = node("div", "links-panel-title");
1947
+ title.appendChild(node("h3", null, "Lier à…"));
1948
+ title.appendChild(node("span", "muted", "rang de création"));
1949
+ linkTo.appendChild(title);
1950
+
1951
+ const note = document.createElement("input");
1952
+ note.type = "text";
1953
+ note.id = "links-note";
1954
+ note.className = "links-input";
1955
+ note.maxLength = 500;
1956
+ note.placeholder = "Motif transmis aux deux agents (facultatif)";
1957
+ note.value = linksView.note;
1958
+ note.addEventListener("input", () => {
1959
+ linksView.note = note.value;
1960
+ });
1961
+ linkTo.appendChild(note);
1962
+
1963
+ const search = node("label", "links-search");
1964
+ search.appendChild(icon("search"));
1965
+ const input = document.createElement("input");
1966
+ input.type = "search";
1967
+ input.placeholder = "Agent ou nom de boîte…";
1968
+ input.addEventListener("input", () => {
1969
+ linksView.search = input.value.trim().toLowerCase();
1970
+ fillLinksPanel(id);
1971
+ });
1972
+ search.appendChild(input);
1973
+ linkTo.appendChild(search);
1974
+
1975
+ linkTo.appendChild(node("p", "links-error"));
1976
+ linkTo.appendChild(node("ul", "links-candidates"));
1977
+ panel.appendChild(linkTo);
1978
+ panel.appendChild(deliveryNote());
1979
+
1980
+ el.linksPanel.replaceChildren(panel);
1981
+ }
1982
+
1983
+ function deliveryNote() {
1984
+ const note = node("p", "links-delivery");
1985
+ note.appendChild(icon("info"));
1986
+ note.appendChild(
1987
+ document.createTextNode(
1988
+ "Chaque agent reçoit la liaison à sa prochaine connexion (whoami), ou aussitôt si ses notifications sont armées."
1989
+ )
1990
+ );
1991
+ return note;
1992
+ }
1993
+
1994
+ function fillLinksPanel(id) {
1995
+ const box = state.directory.get(id);
1996
+ const now = Date.now();
1997
+ const boxes = linkableBoxes();
1998
+ const ranks = creationRanks(boxes, now);
1999
+
2000
+ const summary = el.linksPanel.querySelector(".links-panel-box");
2001
+ summary.replaceChildren();
2002
+ const identity = node("div", "links-panel-identity");
2003
+ identity.appendChild(agentAvatar(id, "avatar--lg"));
2004
+ const names = node("div", "agent-text");
2005
+ names.appendChild(node("div", "links-panel-agent", agentLabel(id)));
2006
+ const address = node("div", "party-addr");
2007
+ address.appendChild(node("span", null, box.display_name || "boîte sans nom"));
2008
+ address.appendChild(node("span", "mono", `· ${id}`));
2009
+ address.appendChild(copyButton(id, "Copier l'adresse"));
2010
+ names.appendChild(address);
2011
+ identity.appendChild(names);
2012
+ summary.appendChild(identity);
2013
+ const rank = ranks.get(id);
2014
+ summary.appendChild(
2015
+ node(
2016
+ "p",
2017
+ "links-panel-meta",
2018
+ [`Créée à ${formatClock(box.created_at)}`, `il y a ${formatAge(ageOf(box, now))}`, rank ? `rang #${rank}` : null]
2019
+ .filter(Boolean)
2020
+ .join(" · ")
2021
+ )
2022
+ );
2023
+ if (box.description) summary.appendChild(node("p", "links-panel-meta", box.description));
2024
+
2025
+ const own = state.links.filter((link) => link.mailboxes.some((end) => end.mailbox === id));
2026
+ const list = el.linksPanel.querySelector(".links-panel-links");
2027
+ list.replaceChildren();
2028
+ if (own.length === 0) list.appendChild(node("li", "links-panel-none", "Aucune liaison."));
2029
+ for (const link of own) list.appendChild(linkRow(link, id));
2030
+
2031
+ el.linksPanel.querySelector(".links-error").textContent = linksView.error;
2032
+
2033
+ const linked = linkIndex().get(id) || new Set();
2034
+ const search = linksView.search;
2035
+ const candidates = boxes
2036
+ .filter((other) => other.id !== id && !linked.has(other.id))
2037
+ .filter(
2038
+ (other) =>
2039
+ !search ||
2040
+ [other.agent_name, other.display_name, other.id].some((value) => (value || "").toLowerCase().includes(search))
2041
+ )
2042
+ .sort(byCreation);
2043
+ const recent = candidates.filter((other) => isRecent(other, now));
2044
+ const old = candidates.filter((other) => !isRecent(other, now));
2045
+
2046
+ const container = el.linksPanel.querySelector(".links-candidates");
2047
+ container.replaceChildren();
2048
+ if (candidates.length === 0) {
2049
+ container.appendChild(node("li", "links-panel-none", search ? "Aucune boîte ne correspond." : "Aucune autre boîte à lier."));
2050
+ return;
2051
+ }
2052
+ for (const other of recent) container.appendChild(candidateRow(id, other, ranks.get(other.id), now));
2053
+ if (old.length > 0) {
2054
+ container.appendChild(
2055
+ node("li", "links-candidates-sep", `Plus de ${formatOffset(linksView.windowMinutes).slice(1)}`)
2056
+ );
2057
+ const shown = linksView.showAllCandidates ? old : old.slice(0, MAP.oldCandidates);
2058
+ for (const other of shown) container.appendChild(candidateRow(id, other, null, now));
2059
+ if (shown.length < old.length) {
2060
+ const item = node("li");
2061
+ item.appendChild(
2062
+ button("btn btn--link", `Afficher les ${old.length - shown.length} autres`, () => {
2063
+ linksView.showAllCandidates = true;
2064
+ fillLinksPanel(id);
2065
+ })
2066
+ );
2067
+ container.appendChild(item);
2068
+ }
2069
+ }
2070
+ }
2071
+
2072
+ function linkRow(link, selfId) {
2073
+ const item = node("li", "links-panel-link");
2074
+ const otherId = link.mailboxes.find((end) => end.mailbox !== selfId).mailbox;
2075
+ const other = state.directory.get(otherId);
2076
+
2077
+ const top = node("div", "links-panel-link-top");
2078
+ top.appendChild(agentAvatar(otherId, "avatar--sm"));
2079
+ const text = node("div", "agent-text");
2080
+ text.appendChild(node("div", "agent-name", agentLabel(otherId)));
2081
+ text.appendChild(node("div", "agent-box", other ? other.display_name || otherId : otherId));
2082
+ top.appendChild(text);
2083
+
2084
+ const armed = linksView.armedUnlink === link.link_id;
2085
+ const unlink = node("button", armed ? "btn btn--danger links-unlink" : "btn btn--icon btn--ghost links-unlink");
2086
+ unlink.type = "button";
2087
+ unlink.title = armed ? "Confirmer la suppression de la liaison" : "Supprimer la liaison";
2088
+ unlink.setAttribute("aria-label", unlink.title);
2089
+ if (armed) unlink.textContent = "Confirmer";
2090
+ else unlink.appendChild(icon("unlink"));
2091
+ unlink.addEventListener("click", () => deleteLink(link.link_id));
2092
+ top.appendChild(unlink);
2093
+ item.appendChild(top);
2094
+
2095
+ if (link.note) item.appendChild(node("p", "links-panel-note", link.note));
2096
+
2097
+ const status = node("div", "links-panel-status");
2098
+ status.appendChild(node("span", "muted", `liée à ${formatClock(link.created_at)}`));
2099
+ for (const end of link.mailboxes) {
2100
+ const who = agentLabel(end.mailbox);
2101
+ status.appendChild(
2102
+ end.seen_at ? tag("tag tag--ok", `reçue par ${who}`, "check") : tag("tag tag--warn", `en attente : ${who}`, "clock")
2103
+ );
2104
+ }
2105
+ item.appendChild(status);
2106
+ return item;
2107
+ }
2108
+
2109
+ function candidateRow(selfId, other, rank, now) {
2110
+ const item = node("li", "links-candidate");
2111
+ if (rank) item.appendChild(node("span", rank <= 3 ? "links-rank is-top" : "links-rank", String(rank)));
2112
+ else item.appendChild(node("span", "links-rank is-old", "·"));
2113
+ item.appendChild(agentAvatar(other.id, "avatar--sm"));
2114
+ const text = node("div", "agent-text");
2115
+ text.appendChild(node("div", "agent-name", agentLabel(other.id)));
2116
+ const age = ageOf(other, now);
2117
+ const meta = node(
2118
+ "div",
2119
+ "agent-box",
2120
+ rank
2121
+ ? `${other.display_name || shortAddress(other.id)} · ${formatClock(other.created_at)} · ${formatAge(age)}`
2122
+ : `${other.display_name || shortAddress(other.id)} · ${formatShort(other.created_at)}`
2123
+ );
2124
+ if (rank && age <= ZOOM_MINUTES * 60_000) meta.classList.add("is-fresh");
2125
+ text.appendChild(meta);
2126
+ item.appendChild(text);
2127
+ item.title = boxTitle(other.id);
2128
+ item.appendChild(button("btn links-link", "Lier", () => createLink(selfId, other.id)));
2129
+ return item;
2130
+ }
2131
+
1296
2132
  // --- Purge des boîtes inactives --------------------------------------------
1297
2133
 
1298
2134
  /**
@@ -1519,6 +2355,24 @@ el.purgeConfirm.addEventListener("click", confirmPurge);
1519
2355
 
1520
2356
  el.refresh.addEventListener("click", () => refresh({ keepThread: true }));
1521
2357
 
2358
+ el.tabs.addEventListener("click", (event) => {
2359
+ const tab = event.target.closest(".tab");
2360
+ if (tab) setView(tab.dataset.view);
2361
+ });
2362
+
2363
+ el.linksWindow.addEventListener("change", () => {
2364
+ linksView.windowMinutes = Number(el.linksWindow.value);
2365
+ saveLinksWindow();
2366
+ renderLinksView();
2367
+ });
2368
+
2369
+ // Échap relâche la boîte choisie sur la carte, sauf depuis un champ du volet.
2370
+ document.addEventListener("keydown", (event) => {
2371
+ if (event.key !== "Escape" || state.view !== "links" || !linksView.selected) return;
2372
+ if (event.target.closest && event.target.closest("input, select, dialog")) return;
2373
+ selectLinkBox(linksView.selected);
2374
+ });
2375
+
1522
2376
  // Le bouton reste en place une fois la colonne repliée : il est le seul moyen de la
1523
2377
  // rouvrir. Seul ce qu'il annonce change.
1524
2378
  el.collapse.addEventListener("click", () => {
@@ -1589,6 +2443,11 @@ el.logout.addEventListener("click", async () => {
1589
2443
  state.openMessages = new Set();
1590
2444
  state.directory = new Map();
1591
2445
  state.groups = [];
2446
+ state.links = [];
2447
+ linksView.selected = null;
2448
+ linksView.panelFor = null;
2449
+ el.linksCanvas.replaceChildren();
2450
+ el.linksPanel.replaceChildren();
1592
2451
  el.agents.replaceChildren();
1593
2452
  el.threads.replaceChildren();
1594
2453
  renderEmptyReader();
@@ -1600,5 +2459,6 @@ setInterval(() => {
1600
2459
  }, REFRESH_MS);
1601
2460
 
1602
2461
  el.loginOrigin.textContent = `MCP Messenger · ${window.location.host}`;
2462
+ loadLinksWindow();
1603
2463
  renderEmptyReader();
1604
2464
  refresh();