aimeat 1.29.0 → 1.30.1

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.
Files changed (127) hide show
  1. package/dist/locales/en.json +25 -1
  2. package/dist/locales/fi.json +25 -1
  3. package/dist/public/css/views/inbox.css +62 -1
  4. package/dist/public/css/views/profile.css +18 -2
  5. package/dist/public/js/services/messages.js +4 -3
  6. package/dist/public/llms-template.txt +38 -1
  7. package/dist/public/views/profile/agents-tab.js +1 -0
  8. package/dist/public/views/profile/inbox-tab.js +175 -28
  9. package/dist/public/views/profile/landing-page.js +67 -0
  10. package/dist/public/views/profile/organisms-tab.js +61 -0
  11. package/dist/src/cli/connect/mcp/tools/dm-messages.d.ts +13 -0
  12. package/dist/src/cli/connect/mcp/tools/dm-messages.d.ts.map +1 -0
  13. package/dist/src/cli/connect/mcp/tools/dm-messages.js +62 -0
  14. package/dist/src/cli/connect/mcp/tools/dm-messages.js.map +1 -0
  15. package/dist/src/cli/connect/mcp/tools/index.d.ts.map +1 -1
  16. package/dist/src/cli/connect/mcp/tools/index.js +2 -0
  17. package/dist/src/cli/connect/mcp/tools/index.js.map +1 -1
  18. package/dist/src/cli/connect/tool-call.d.ts.map +1 -1
  19. package/dist/src/cli/connect/tool-call.js +54 -0
  20. package/dist/src/cli/connect/tool-call.js.map +1 -1
  21. package/dist/src/generated/api-types.d.ts +194 -2
  22. package/dist/src/generated/api-types.d.ts.map +1 -1
  23. package/dist/src/generated/prisma-postgres/edge.js +4 -3
  24. package/dist/src/generated/prisma-postgres/index-browser.js +1 -0
  25. package/dist/src/generated/prisma-postgres/index.d.ts +30 -1
  26. package/dist/src/generated/prisma-postgres/index.js +4 -3
  27. package/dist/src/generated/prisma-postgres/package.json +1 -1
  28. package/dist/src/generated/prisma-postgres/schema.prisma +1 -0
  29. package/dist/src/generated/prisma-postgres/wasm.js +4 -3
  30. package/dist/src/index.js +2 -0
  31. package/dist/src/index.js.map +1 -1
  32. package/dist/src/mcp/annotations.d.ts.map +1 -1
  33. package/dist/src/mcp/annotations.js +3 -0
  34. package/dist/src/mcp/annotations.js.map +1 -1
  35. package/dist/src/mcp/catalog/definitions.d.ts.map +1 -1
  36. package/dist/src/mcp/catalog/definitions.js +33 -0
  37. package/dist/src/mcp/catalog/definitions.js.map +1 -1
  38. package/dist/src/mcp/catalog/scopes.d.ts.map +1 -1
  39. package/dist/src/mcp/catalog/scopes.js +6 -1
  40. package/dist/src/mcp/catalog/scopes.js.map +1 -1
  41. package/dist/src/mcp/catalog/surfaces.d.ts.map +1 -1
  42. package/dist/src/mcp/catalog/surfaces.js +1 -0
  43. package/dist/src/mcp/catalog/surfaces.js.map +1 -1
  44. package/dist/src/mcp/dm-messages.d.ts +20 -0
  45. package/dist/src/mcp/dm-messages.d.ts.map +1 -0
  46. package/dist/src/mcp/dm-messages.js +120 -0
  47. package/dist/src/mcp/dm-messages.js.map +1 -0
  48. package/dist/src/mcp/index.d.ts +2 -1
  49. package/dist/src/mcp/index.d.ts.map +1 -1
  50. package/dist/src/mcp/index.js +3 -1
  51. package/dist/src/mcp/index.js.map +1 -1
  52. package/dist/src/models/message-schemas.d.ts +3 -0
  53. package/dist/src/models/message-schemas.d.ts.map +1 -1
  54. package/dist/src/models/message-schemas.js +4 -0
  55. package/dist/src/models/message-schemas.js.map +1 -1
  56. package/dist/src/routes/app-grants.d.ts.map +1 -1
  57. package/dist/src/routes/app-grants.js +2 -0
  58. package/dist/src/routes/app-grants.js.map +1 -1
  59. package/dist/src/routes/bootstrap.js +1 -1
  60. package/dist/src/routes/bootstrap.js.map +1 -1
  61. package/dist/src/routes/catalogue.d.ts.map +1 -1
  62. package/dist/src/routes/catalogue.js +17 -8
  63. package/dist/src/routes/catalogue.js.map +1 -1
  64. package/dist/src/routes/federation-sync.d.ts.map +1 -1
  65. package/dist/src/routes/federation-sync.js +40 -16
  66. package/dist/src/routes/federation-sync.js.map +1 -1
  67. package/dist/src/routes/memory.d.ts.map +1 -1
  68. package/dist/src/routes/memory.js +10 -3
  69. package/dist/src/routes/memory.js.map +1 -1
  70. package/dist/src/routes/messages.d.ts.map +1 -1
  71. package/dist/src/routes/messages.js +22 -19
  72. package/dist/src/routes/messages.js.map +1 -1
  73. package/dist/src/routes/public-stats.d.ts +2 -0
  74. package/dist/src/routes/public-stats.d.ts.map +1 -1
  75. package/dist/src/routes/public-stats.js +93 -104
  76. package/dist/src/routes/public-stats.js.map +1 -1
  77. package/dist/src/routes/stats.d.ts.map +1 -1
  78. package/dist/src/routes/stats.js +6 -0
  79. package/dist/src/routes/stats.js.map +1 -1
  80. package/dist/src/routes/usage.d.ts +17 -0
  81. package/dist/src/routes/usage.d.ts.map +1 -0
  82. package/dist/src/routes/usage.js +35 -0
  83. package/dist/src/routes/usage.js.map +1 -0
  84. package/dist/src/routes/wallet.d.ts +2 -0
  85. package/dist/src/routes/wallet.d.ts.map +1 -1
  86. package/dist/src/routes/wallet.js +13 -6
  87. package/dist/src/routes/wallet.js.map +1 -1
  88. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  89. package/dist/src/server-bootstrap/routes-loader.js +18 -2
  90. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  91. package/dist/src/services/cache.d.ts +61 -0
  92. package/dist/src/services/cache.d.ts.map +1 -0
  93. package/dist/src/services/cache.js +143 -0
  94. package/dist/src/services/cache.js.map +1 -0
  95. package/dist/src/services/message-delivery.d.ts.map +1 -1
  96. package/dist/src/services/message-delivery.js +8 -3
  97. package/dist/src/services/message-delivery.js.map +1 -1
  98. package/dist/src/services/message-send.d.ts +13 -0
  99. package/dist/src/services/message-send.d.ts.map +1 -1
  100. package/dist/src/services/message-send.js +43 -6
  101. package/dist/src/services/message-send.js.map +1 -1
  102. package/dist/src/services/usage-summary.d.ts +77 -0
  103. package/dist/src/services/usage-summary.d.ts.map +1 -0
  104. package/dist/src/services/usage-summary.js +85 -0
  105. package/dist/src/services/usage-summary.js.map +1 -0
  106. package/dist/src/storage/interface.d.ts +5 -1
  107. package/dist/src/storage/interface.d.ts.map +1 -1
  108. package/dist/src/storage/providers/mongodb/index.d.ts +15 -0
  109. package/dist/src/storage/providers/mongodb/index.d.ts.map +1 -1
  110. package/dist/src/storage/providers/mongodb/index.js +49 -1
  111. package/dist/src/storage/providers/mongodb/index.js.map +1 -1
  112. package/dist/src/storage/providers/sqlite/index.d.ts +15 -0
  113. package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
  114. package/dist/src/storage/providers/sqlite/index.js +16 -2
  115. package/dist/src/storage/providers/sqlite/index.js.map +1 -1
  116. package/dist/src/storage/providers/sqlite/repos/direct-message.d.ts +15 -0
  117. package/dist/src/storage/providers/sqlite/repos/direct-message.d.ts.map +1 -1
  118. package/dist/src/storage/providers/sqlite/repos/direct-message.js +28 -2
  119. package/dist/src/storage/providers/sqlite/repos/direct-message.js.map +1 -1
  120. package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
  121. package/dist/src/storage/providers/sqlite/schema.js +4 -0
  122. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  123. package/dist/src/storage/repositories/direct-message.repository.d.ts +20 -0
  124. package/dist/src/storage/repositories/direct-message.repository.d.ts.map +1 -1
  125. package/package.json +1 -1
  126. package/prisma/schema.postgres.prisma +1 -0
  127. package/prisma/schema.prisma +1 -0
@@ -18,6 +18,14 @@
18
18
  "inbox.empty": "Nothing here yet.",
19
19
  "inbox.attachmentPending": "attachment pending",
20
20
  "inbox.attachmentExpired": "attachment expired",
21
+ "inbox.attachmentOpen": "Open in a new tab",
22
+ "inbox.attachmentView": "View",
23
+ "inbox.attachmentOpenRaw": "Open raw",
24
+ "inbox.attachmentDownload": "Download",
25
+ "inbox.attachmentLoadError": "Could not load this attachment.",
26
+ "inbox.close": "Close",
27
+ "inbox.directThread": "Direct",
28
+ "inbox.subjectPlaceholder": "Subject (optional — starts a new thread)",
21
29
  "admin.messages.title": "Direct messages",
22
30
  "admin.messages.desc": "Delivery telemetry for user-to-user messages. No message content or participant identities are shown.",
23
31
  "admin.messages.total": "Total sent",
@@ -1108,6 +1116,7 @@
1108
1116
  "domainStorage": "Storage (files)",
1109
1117
  "domainWork": "Work",
1110
1118
  "domainSocial": "Social",
1119
+ "domainMessages": "Direct messages (inbox)",
1111
1120
  "domainWallet": "Wallet",
1112
1121
  "domainConsent": "Consent",
1113
1122
  "domainTunnel": "Tunnel",
@@ -1122,6 +1131,7 @@
1122
1131
  "permWrite": "Write",
1123
1132
  "permDelete": "Delete",
1124
1133
  "permRequest": "Request",
1134
+ "permSend": "Send",
1125
1135
  "permAccept": "Accept",
1126
1136
  "permPublish": "Publish",
1127
1137
  "permManage": "Manage",
@@ -1506,7 +1516,7 @@
1506
1516
  "readme": "README",
1507
1517
  "integration": "Integration",
1508
1518
  "tasks": "Tasks",
1509
- "messages": "Messages",
1519
+ "messages": "Chat",
1510
1520
  "data_access": "Memory",
1511
1521
  "directives": "Directives",
1512
1522
  "agent_config": "Agent Config",
@@ -3481,6 +3491,19 @@
3481
3491
  "viewBtn": "View",
3482
3492
  "continueTitle": "Continue",
3483
3493
  "agentsTitle": "Agents",
3494
+ "usageTitle": "Usage & quotas",
3495
+ "usageMemory": "Memory",
3496
+ "usageStorage": "Files",
3497
+ "usageMicro": "Micro-memory",
3498
+ "usageFilesWord": "files",
3499
+ "usageSetsWord": "sets",
3500
+ "usageAgents": "Agents",
3501
+ "usageOrganisms": "Organisms",
3502
+ "usageApps": "Apps",
3503
+ "usageEcoApps": "Connected apps",
3504
+ "usageExtensions": "Extensions",
3505
+ "usageCortexes": "Cortexes",
3506
+ "usageServices": "Services",
3484
3507
  "activeTodayCount": "{n} active today",
3485
3508
  "agentActiveToday": "active today",
3486
3509
  "nextRunAt": "next run {time}",
@@ -6359,6 +6382,7 @@
6359
6382
  "search": {
6360
6383
  "wsPlaceholder": "Search this workspace…",
6361
6384
  "orgPlaceholder": "Search this organism…",
6385
+ "allOrgsPlaceholder": "Search across all organisms…",
6362
6386
  "clear": "Clear",
6363
6387
  "noMatches": "No matches",
6364
6388
  "paletteTitle": "Search everything",
@@ -18,6 +18,14 @@
18
18
  "inbox.empty": "Ei mitään vielä.",
19
19
  "inbox.attachmentPending": "liite odottaa",
20
20
  "inbox.attachmentExpired": "liite vanhentui",
21
+ "inbox.attachmentOpen": "Avaa uudessa välilehdessä",
22
+ "inbox.attachmentView": "Näytä",
23
+ "inbox.attachmentOpenRaw": "Avaa raakana",
24
+ "inbox.attachmentDownload": "Lataa",
25
+ "inbox.attachmentLoadError": "Liitettä ei voitu ladata.",
26
+ "inbox.close": "Sulje",
27
+ "inbox.directThread": "Suora",
28
+ "inbox.subjectPlaceholder": "Aihe (valinnainen — aloittaa uuden ketjun)",
21
29
  "admin.messages.title": "Suorat viestit",
22
30
  "admin.messages.desc": "Toimitustelemetria käyttäjien välisille viesteille. Viestien sisältöä tai osapuolten henkilöllisyyttä ei näytetä.",
23
31
  "admin.messages.total": "Lähetetty yhteensä",
@@ -1108,6 +1116,7 @@
1108
1116
  "domainStorage": "Tallennustila (tiedostot)",
1109
1117
  "domainWork": "Työ",
1110
1118
  "domainSocial": "Sosiaalinen",
1119
+ "domainMessages": "Suorat viestit (postilaatikko)",
1111
1120
  "domainWallet": "Lompakko",
1112
1121
  "domainConsent": "Suostumus",
1113
1122
  "domainTunnel": "Tunneli",
@@ -1122,6 +1131,7 @@
1122
1131
  "permWrite": "Kirjoitus",
1123
1132
  "permDelete": "Poisto",
1124
1133
  "permRequest": "Pyyntö",
1134
+ "permSend": "Lähetys",
1125
1135
  "permAccept": "Hyväksyntä",
1126
1136
  "permPublish": "Julkaisu",
1127
1137
  "permManage": "Hallinta",
@@ -1506,7 +1516,7 @@
1506
1516
  "readme": "README",
1507
1517
  "integration": "Integraatio",
1508
1518
  "tasks": "Tehtävät",
1509
- "messages": "Viestit",
1519
+ "messages": "Keskustelu",
1510
1520
  "data_access": "Muisti",
1511
1521
  "directives": "Ohjeistukset",
1512
1522
  "agent_config": "Agenttiasetukset",
@@ -3481,6 +3491,19 @@
3481
3491
  "viewBtn": "Avaa",
3482
3492
  "continueTitle": "Jatka",
3483
3493
  "agentsTitle": "Agentit",
3494
+ "usageTitle": "Käyttö & kiintiöt",
3495
+ "usageMemory": "Muisti",
3496
+ "usageStorage": "Tiedostot",
3497
+ "usageMicro": "Mikromuisti",
3498
+ "usageFilesWord": "tiedostoa",
3499
+ "usageSetsWord": "settiä",
3500
+ "usageAgents": "Agentit",
3501
+ "usageOrganisms": "Organismit",
3502
+ "usageApps": "Sovellukset",
3503
+ "usageEcoApps": "Liitetyt sovellukset",
3504
+ "usageExtensions": "Laajennukset",
3505
+ "usageCortexes": "Korteksit",
3506
+ "usageServices": "Palvelut",
3484
3507
  "activeTodayCount": "{n} aktiivista tänään",
3485
3508
  "agentActiveToday": "aktiivinen tänään",
3486
3509
  "nextRunAt": "seuraava ajo {time}",
@@ -6359,6 +6382,7 @@
6359
6382
  "search": {
6360
6383
  "wsPlaceholder": "Hae tästä työtilasta…",
6361
6384
  "orgPlaceholder": "Hae tästä organismista…",
6385
+ "allOrgsPlaceholder": "Hae kaikista organismeista…",
6362
6386
  "clear": "Tyhjennä",
6363
6387
  "noMatches": "Ei osumia",
6364
6388
  "paletteTitle": "Hae kaikkea",
@@ -116,6 +116,25 @@
116
116
  }
117
117
  .inbox-conv:hover { background: var(--card-bg-alt); }
118
118
  .inbox-conv--active { background: var(--accent-subtle); }
119
+
120
+ /* A person group: header (the human) + their threads (human + agents) nested + indented beneath. */
121
+ .inbox-conv-group { margin-bottom: 2px; }
122
+ .inbox-conv-group-head {
123
+ display: flex; align-items: center; gap: 8px;
124
+ padding: 6px 8px 2px; font-weight: 600;
125
+ }
126
+ .inbox-conv-group-head .inbox-name { flex: 1; min-width: 0; }
127
+ .inbox-conv--nested { padding-left: 18px; }
128
+ .inbox-conv--nested .inbox-name { font-weight: 400; }
129
+ .inbox-conv-subico {
130
+ width: 28px; flex: none; text-align: center; font-size: 15px; opacity: 0.8;
131
+ }
132
+ /* Subject thread chips (conversation list + thread header). */
133
+ .inbox-conv-subject {
134
+ flex: none; max-width: 60%; font-size: 11px; color: var(--accent);
135
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
136
+ }
137
+ .inbox-thread-subject { font-size: 12px; color: var(--accent); margin-top: 1px; }
119
138
  .inbox-conv-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
120
139
  .inbox-conv-line1, .inbox-conv-line2 { display: flex; align-items: center; gap: 6px; }
121
140
  .inbox-conv-line1 .inbox-name { flex: 1; }
@@ -228,10 +247,52 @@
228
247
  text-decoration: none;
229
248
  color: inherit;
230
249
  }
231
- .inbox-attach-chip--pending { opacity: 0.55; pointer-events: none; }
250
+ .inbox-attach-chip--pending { opacity: 0.55; }
232
251
  .inbox-attach-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
233
252
  .inbox-attach-pending { color: var(--text-dim); font-style: italic; }
234
253
 
254
+ /* The chip's main affordance (link or button) opens/views the file; the download button sits beside it. */
255
+ .inbox-attach-open {
256
+ display: inline-flex; align-items: center; gap: 5px; min-width: 0;
257
+ background: none; border: none; padding: 0; margin: 0; cursor: pointer;
258
+ font: inherit; color: inherit; text-decoration: none;
259
+ }
260
+ .inbox-attach-open:hover .inbox-attach-name { text-decoration: underline; }
261
+ .inbox-attach-dl {
262
+ flex-shrink: 0; text-decoration: none; color: var(--text-dim);
263
+ font-size: 13px; line-height: 1; padding: 2px 4px; border-radius: var(--radius-xs);
264
+ }
265
+ .inbox-attach-dl:hover { color: var(--text); background: var(--bg-dim); }
266
+
267
+ /* Image attachment: thumbnail + caption row (name + download). */
268
+ .inbox-attach-item {
269
+ display: inline-flex; flex-direction: column; gap: 4px; max-width: 200px;
270
+ }
271
+ .inbox-attach-thumb-link { display: block; line-height: 0; }
272
+ .inbox-attach-thumb {
273
+ max-width: 200px; max-height: 160px; border-radius: var(--radius-sm);
274
+ border: 1px solid var(--border); object-fit: cover; cursor: zoom-in;
275
+ }
276
+ .inbox-attach-cap { display: flex; align-items: center; gap: 6px; font-size: 12px; max-width: 200px; }
277
+ .inbox-attach-cap .inbox-attach-name { flex: 1; min-width: 0; color: var(--text-dim); }
278
+
279
+ /* In-app markdown viewer overlay. */
280
+ .inbox-mdviewer-overlay {
281
+ position: fixed; inset: 0; z-index: 1200; background: rgba(0, 0, 0, 0.6);
282
+ display: flex; align-items: center; justify-content: center; padding: 24px;
283
+ }
284
+ .inbox-mdviewer {
285
+ display: flex; flex-direction: column; width: min(860px, 100%); max-height: 88vh;
286
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden;
287
+ }
288
+ .inbox-mdviewer-head {
289
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
290
+ padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--surface);
291
+ }
292
+ .inbox-mdviewer-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
293
+ .inbox-mdviewer-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
294
+ .inbox-mdviewer-body { padding: 18px 22px; overflow: auto; }
295
+
235
296
  /* ── Composer ── */
236
297
  .inbox-compose-fields { padding: 12px 12px 0; }
237
298
  .inbox-composer {
@@ -4,6 +4,8 @@
4
4
  * wallet, memory, agents, extensions, packages, knowledge, generator, and
5
5
  * edit panels. Consumes theme.css tokens so it flips with dark/light mode.
6
6
  * @version-history
7
+ * v2.7.0 — 2026-06-22 — Home "Usage & quotas" card: .pf-usage-card/.pf-usage-row/.pf-usage-bar/
8
+ * .pf-usage-fill (quota bars) + .pf-usage-chips/.pf-usage-chip (count chips).
7
9
  * v2.6.0 — 2026-06-22 — Scalable Memory tab: .pf-mem-quota* (storage-usage bar), .pf-mem-size
8
10
  * (value-size column), .mem-tools-bar/.mem-import-group (server search + export/import row),
9
11
  * .mem-group-actions (hover search/delete on group headers), .mem-search-summary.
@@ -2207,8 +2209,8 @@
2207
2209
  .pj-empty { color: var(--muted); font-size: 0.85rem; padding: 0.2rem 0; }
2208
2210
 
2209
2211
  /* ── In-context content search (Kerros 1): filter tabs to matches + grouped result list ── */
2210
- .pj-ws-searchbar { display: flex; align-items: center; gap: 0.5rem; margin: 0.4rem 0 0.6rem; }
2211
- .pj-ws-searchbar .search-bar { flex: 1; }
2212
+ .pj-ws-searchbar, .pj-org-globalsearch { display: flex; align-items: center; gap: 0.5rem; margin: 0.4rem 0 0.6rem; }
2213
+ .pj-ws-searchbar .search-bar, .pj-org-globalsearch .search-bar { flex: 1; }
2212
2214
  .pj-org-tab-match { background: var(--accent); color: #fff; }
2213
2215
  .pj-search-results { margin-top: 0.4rem; }
2214
2216
  .pj-search-group { margin: 0 0 0.9rem; }
@@ -2723,6 +2725,20 @@
2723
2725
  .pf-home-row-meta.pf-ok { color: var(--success-fg, var(--text)); }
2724
2726
  .pf-lp-stat-link { background: none; border: none; padding: 0.15rem 0.35rem; border-radius: 8px; cursor: pointer; font: inherit; color: inherit; }
2725
2727
  .pf-lp-stat-link:hover { background: var(--bg-surface); }
2728
+ /* Home "Usage & quotas" card — quota bars + count chips */
2729
+ .pf-usage-card { grid-column: 1 / -1; }
2730
+ .pf-usage-row { margin-bottom: 0.55rem; }
2731
+ .pf-usage-head { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; margin-bottom: 0.25rem; }
2732
+ .pf-usage-label { font-size: 0.82rem; font-weight: 600; color: var(--text); }
2733
+ .pf-usage-bar { height: 6px; border-radius: 999px; background: var(--bg-dim); overflow: hidden; }
2734
+ .pf-usage-fill { height: 100%; background: var(--accent); border-radius: 999px; transition: width 0.3s; }
2735
+ .pf-usage-fill--danger { background: var(--danger); }
2736
+ .pf-usage-chips { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.7rem; }
2737
+ .pf-usage-chip { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 0.1rem; padding: 0.35rem 0.6rem; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-surface); cursor: pointer; text-align: left; }
2738
+ .pf-usage-chip[disabled] { cursor: default; }
2739
+ .pf-usage-chip:not([disabled]):hover { border-color: var(--accent); }
2740
+ .pf-usage-chip-val { font-size: 0.95rem; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
2741
+ .pf-usage-chip-label { font-size: 0.7rem; color: var(--muted); }
2726
2742
  .pf-promo-dismiss { float: right; background: none; border: none; cursor: pointer; color: var(--muted); font-size: 0.85rem; padding: 0 0.2rem; }
2727
2743
  .pf-promo-dismiss:hover { color: var(--text); }
2728
2744
  /* Edit profile / password dialogs */
@@ -15,11 +15,12 @@ import { api, apiGet } from '/js/api.js';
15
15
 
16
16
  const enc = encodeURIComponent;
17
17
 
18
- /** Send a direct message. `attachments` is an array of descriptors (storage_key, mime, size, kind, inline, id). */
19
- export async function send({ to, body, attachments, replyTo } = /** @type {{ to?: any, body?: any, attachments?: any, replyTo?: any }} */ ({})) {
18
+ /** Send a direct message. `attachments` is an array of descriptors (storage_key, mime, size, kind, inline, id).
19
+ * `subject` opens a new topic thread; `conversationId` continues a specific existing thread. */
20
+ export async function send({ to, body, attachments, replyTo, subject, conversationId } = /** @type {{ to?: any, body?: any, attachments?: any, replyTo?: any, subject?: any, conversationId?: any }} */ ({})) {
20
21
  return api('/v1/messages', {
21
22
  method: 'POST',
22
- body: JSON.stringify({ to, body, attachments, reply_to: replyTo }),
23
+ body: JSON.stringify({ to, body, attachments, reply_to: replyTo, subject, conversation_id: conversationId }),
23
24
  });
24
25
  }
25
26
 
@@ -1059,7 +1059,11 @@ POST /v1/agents/me/telemetry
1059
1059
  ```
1060
1060
  Types: `llm_call`, `tool_call`, `agent_report`
1061
1061
 
1062
- ### Messages — Send
1062
+ ### Messages — Send (agent ↔ your owner)
1063
+
1064
+ This is the private dashboard channel between you and YOUR OWNER (task coordination, prompts). It is NOT
1065
+ federated and does not reach anyone else. To message other people/agents across the network, use the
1066
+ Federated Direct Messages below.
1063
1067
 
1064
1068
  ```
1065
1069
  POST /v1/agents/me/messages
@@ -1070,6 +1074,39 @@ POST /v1/agents/me/messages
1070
1074
  }
1071
1075
  ```
1072
1076
 
1077
+ ### Federated Direct Messages (Inbox) — message anyone on the network
1078
+
1079
+ A separate, federation-wide messenger (the human "Postilaatikko"). You send FROM your own agent identity
1080
+ TO any person (`owner@node`), agent (`agent#owner@node`) or app (`eco:app#owner@node`), across nodes. The
1081
+ recipient sees the message is from you. First contact lands in their requests until they accept.
1082
+
1083
+ Requires scopes: `messages:send` (send), `messages:read` (read replies). MCP tools: `aimeat_dm_send`,
1084
+ `aimeat_dm_inbox`, `aimeat_dm_thread` — distinct from the `aimeat_message_*` owner-dashboard tools above.
1085
+
1086
+ Send (REST equivalent of `aimeat_dm_send`):
1087
+ ```
1088
+ POST {{BASE_URL}}/v1/messages
1089
+ {
1090
+ "to": "alice@aimeat-fi-001", // or "claude#alice@aimeat-fi-001", or "eco:app#alice@aimeat-fi-001"
1091
+ "body": "Markdown supported.",
1092
+ "reply_to": "<message-id>", // optional — keep the same thread
1093
+ "subject": "Project Falcon", // optional — open a NEW topic thread (avoids one endless chat)
1094
+ "conversation_id": "<thread-id>", // optional — continue a specific existing thread
1095
+ "attachments": [ // optional — up to 20
1096
+ { "storage_key": "<key>", "mime": "image/png", "kind": "image", "size": 2048, "name": "shot.png" }
1097
+ ]
1098
+ }
1099
+ ```
1100
+
1101
+ Attachments travel via storage, NOT through MCP/the body: upload each file first (`aimeat_storage_upload`
1102
+ presigned, or `POST /v1/storage`), then pass the returned storage key(s) in `attachments`.
1103
+
1104
+ Read replies addressed to you:
1105
+ ```
1106
+ GET {{BASE_URL}}/v1/messages/agent-inbox — recent DMs addressed to you (newest first)
1107
+ GET {{BASE_URL}}/v1/messages/agent-thread/{conversationId} — a full thread (your sent + received)
1108
+ ```
1109
+
1073
1110
  ### Onboarding — Confirm a step
1074
1111
 
1075
1112
  ```
@@ -77,6 +77,7 @@ const SCOPE_DOMAINS = [
77
77
  { key: 'storage', permissions: ['read', 'write'] },
78
78
  { key: 'work', permissions: ['request', 'read', 'accept', 'publish'] },
79
79
  { key: 'social', permissions: ['read', 'write'] },
80
+ { key: 'messages', permissions: ['send', 'read'] },
80
81
  { key: 'wallet', permissions: ['read'] },
81
82
  { key: 'consent', permissions: ['manage'] },
82
83
  { key: 'tunnel', permissions: ['connect'] },
@@ -85,6 +85,33 @@ function peerName(id) {
85
85
  return beforeAt.includes('#') ? beforeAt.split('#').pop() : beforeAt;
86
86
  }
87
87
 
88
+ // ── Grouping a person + their agents ──
89
+ // A peer is a human GHII (owner@node), an agent GAII (agent#owner@node) or an app GEAI
90
+ // (eco:app#owner@node). They all belong to ONE person — the owner — so we group conversations under
91
+ // the owner key and keep each thread separate beneath it.
92
+ function ownerKeyOf(ghii) {
93
+ const s = String(ghii || '');
94
+ const hash = s.indexOf('#'); // 'agent#owner@node' / 'eco:app#owner@node' → 'owner@node'
95
+ return hash >= 0 ? s.slice(hash + 1) : s; // human 'owner@node' → itself
96
+ }
97
+ function isAgentPeer(ghii) { return String(ghii).includes('#'); }
98
+ function ownerDisplayName(ownerKey) { return String(ownerKey).split('@')[0]; }
99
+ /** Label for one thread INSIDE a person group: the agent/app id before '#', or null for the human thread. */
100
+ function subThreadLabel(ghii) {
101
+ const at = String(ghii || '').split('@')[0];
102
+ return at.includes('#') ? at.split('#')[0] : null;
103
+ }
104
+ /** Group conversations by the owner behind each peer, preserving input (recency) order. */
105
+ function groupConversations(conversations) {
106
+ const map = new Map();
107
+ for (const c of conversations) {
108
+ const key = ownerKeyOf(c.peerGhii);
109
+ if (!map.has(key)) map.set(key, { ownerKey: key, convs: [] });
110
+ map.get(key).convs.push(c);
111
+ }
112
+ return [...map.values()];
113
+ }
114
+
88
115
  function Avatar({ seed, size = 36 }) {
89
116
  const svg = minidenticon(seed || 'user');
90
117
  return html`<span class="inbox-avatar" style=${`width:${size}px;height:${size}px`}
@@ -134,7 +161,92 @@ function trackStateLabel(state) {
134
161
  }
135
162
  }
136
163
 
137
- function MessageBubble({ msg, mine, urlMap, starred, onStar, onTrack, tracked }) {
164
+ /** Classify an attachment for rendering: how to view it (thumbnail / native tab / markdown viewer). */
165
+ const ATTACH_ICO = { image: '🖼', pdf: '📄', markdown: '📄', audio: '🎵', video: '🎬', file: '📎' };
166
+ function attachKind(a) {
167
+ const mime = a.mime || '';
168
+ const name = a.name || a.storageKey || '';
169
+ if (a.kind === 'image' || /^image\//.test(mime)) return 'image';
170
+ if (mime === 'application/pdf' || /\.pdf$/i.test(name)) return 'pdf';
171
+ if (/markdown/.test(mime) || /\.(md|markdown|mdx)$/i.test(name)) return 'markdown';
172
+ if (a.kind === 'audio' || /^audio\//.test(mime)) return 'audio';
173
+ if (a.kind === 'video' || /^video\//.test(mime)) return 'video';
174
+ return 'file';
175
+ }
176
+
177
+ /** One received/sent attachment. Images render as a thumbnail (click → full-size in a new tab);
178
+ * PDF/audio/video/file open natively in a new tab; markdown opens the in-app rendered viewer. Every
179
+ * ready attachment gets a download button. Not-yet-duplicated / expired attachments show their state. */
180
+ function AttachmentItem({ a, url, onOpenMarkdown }) {
181
+ const kind = attachKind(a);
182
+ const name = a.name || a.storageKey;
183
+ const ready = !!url && !a.expired;
184
+
185
+ if (!ready) {
186
+ const status = a.expired ? t('inbox.attachmentExpired') : (a.mode !== 'duplicate' ? t('inbox.attachmentPending') : null);
187
+ return html`<div class="inbox-attach-chip inbox-attach-chip--pending">
188
+ <span class="inbox-attach-ico">${ATTACH_ICO[kind]}</span>
189
+ <span class="inbox-attach-name">${escHtml(name)}</span>
190
+ ${status ? html`<span class="inbox-attach-pending">${status}</span>` : null}
191
+ </div>`;
192
+ }
193
+
194
+ const download = html`<a class="inbox-attach-dl" href=${url} download=${name} title=${t('inbox.attachmentDownload')}>⬇</a>`;
195
+
196
+ if (kind === 'image') {
197
+ return html`<div class="inbox-attach-item">
198
+ <a class="inbox-attach-thumb-link" href=${url} target="_blank" rel="noopener" title=${t('inbox.attachmentOpen')}>
199
+ <img class="inbox-attach-thumb" src=${url} alt=${escHtml(name)} loading="lazy" />
200
+ </a>
201
+ <div class="inbox-attach-cap"><span class="inbox-attach-name">${escHtml(name)}</span>${download}</div>
202
+ </div>`;
203
+ }
204
+ if (kind === 'markdown') {
205
+ return html`<div class="inbox-attach-chip">
206
+ <button class="inbox-attach-open" onClick=${() => onOpenMarkdown?.(url, name)} title=${t('inbox.attachmentView')}>
207
+ <span class="inbox-attach-ico">📄</span><span class="inbox-attach-name">${escHtml(name)}</span>
208
+ </button>${download}
209
+ </div>`;
210
+ }
211
+ // pdf / audio / video / file — let the browser open it in a new tab.
212
+ return html`<div class="inbox-attach-chip">
213
+ <a class="inbox-attach-open" href=${url} target="_blank" rel="noopener" title=${t('inbox.attachmentOpen')}>
214
+ <span class="inbox-attach-ico">${ATTACH_ICO[kind]}</span><span class="inbox-attach-name">${escHtml(name)}</span>
215
+ </a>${download}
216
+ </div>`;
217
+ }
218
+
219
+ /** In-app viewer for a markdown attachment — browsers don't render .md, so we fetch the (same-origin,
220
+ * presigned) file and render it with the shared safe Markdown component. Offers open-raw + download. */
221
+ function MarkdownViewer({ url, name, onClose }) {
222
+ const [text, setText] = useState(null);
223
+ const [failed, setFailed] = useState(false);
224
+ useEffect(() => {
225
+ let alive = true;
226
+ fetch(url).then(r => r.ok ? r.text() : Promise.reject(new Error('http'))).then(tx => { if (alive) setText(tx); }).catch(() => { if (alive) setFailed(true); });
227
+ return () => { alive = false; };
228
+ }, [url]);
229
+ return html`
230
+ <div class="inbox-mdviewer-overlay" onClick=${onClose}>
231
+ <div class="inbox-mdviewer" onClick=${e => e.stopPropagation()}>
232
+ <div class="inbox-mdviewer-head">
233
+ <span class="inbox-mdviewer-title">📄 ${escHtml(name)}</span>
234
+ <div class="inbox-mdviewer-actions">
235
+ <a class="btn-ghost btn-sm" href=${url} target="_blank" rel="noopener">${t('inbox.attachmentOpenRaw')}</a>
236
+ <a class="btn-ghost btn-sm" href=${url} download=${name}>${t('inbox.attachmentDownload')}</a>
237
+ <button class="btn-ghost btn-sm" onClick=${onClose} title=${t('inbox.close')}>✕</button>
238
+ </div>
239
+ </div>
240
+ <div class="inbox-mdviewer-body">
241
+ ${failed ? html`<div class="inbox-empty-sm">${t('inbox.attachmentLoadError')}</div>`
242
+ : text === null ? html`<div class="inbox-empty-sm">…</div>`
243
+ : html`<${Markdown} text=${text} />`}
244
+ </div>
245
+ </div>
246
+ </div>`;
247
+ }
248
+
249
+ function MessageBubble({ msg, mine, urlMap, starred, onStar, onTrack, tracked, onOpenMarkdown }) {
138
250
  const nonInline = (msg.attachments || []).filter(a => !a.inline);
139
251
  const expiredIds = new Set((msg.attachments || []).filter(a => a.expired).map(a => a.id));
140
252
  const trk = tracked ? trackStateLabel(tracked.state) : null;
@@ -152,14 +264,7 @@ function MessageBubble({ msg, mine, urlMap, starred, onStar, onTrack, tracked })
152
264
  <div class="inbox-bubble-body"><${Markdown} text=${prepareBody(msg.body, urlMap, expiredIds)} /></div>
153
265
  ${nonInline.length > 0 && html`
154
266
  <div class="inbox-attach-row">
155
- ${nonInline.map(a => html`
156
- <a key=${a.id} class=${`inbox-attach-chip${(urlMap[a.id] && !a.expired) ? '' : ' inbox-attach-chip--pending'}`}
157
- href=${(urlMap[a.id] && !a.expired) ? urlMap[a.id] : '#'} target="_blank" rel="noopener">
158
- <span class="inbox-attach-ico">${a.kind === 'image' ? '🖼' : a.kind === 'audio' ? '🎵' : a.kind === 'video' ? '🎬' : '📎'}</span>
159
- <span class="inbox-attach-name">${escHtml(a.name || a.storageKey)}</span>
160
- ${a.expired ? html`<span class="inbox-attach-pending">${t('inbox.attachmentExpired')}</span>`
161
- : a.mode !== 'duplicate' ? html`<span class="inbox-attach-pending">${t('inbox.attachmentPending')}</span>` : null}
162
- </a>`)}
267
+ ${nonInline.map(a => html`<${AttachmentItem} key=${a.id} a=${a} url=${urlMap[a.id]} onOpenMarkdown=${onOpenMarkdown} />`)}
163
268
  </div>`}
164
269
  <div class="inbox-bubble-meta">
165
270
  ${trk ? html`<span class=${`inbox-track-badge inbox-track-badge--${trk.tone}`} title=${t('inbox.trackResponse')}>🔗 ${trk.text}</span>` : null}
@@ -256,6 +361,8 @@ export default function InboxTab({ showToast }) {
256
361
  const [activeConv, setActiveConv] = useState(null); // { conversationId, peerGhii }
257
362
  const [thread, setThread] = useState([]);
258
363
  const [urlMap, setUrlMap] = useState({});
364
+ const [mdViewer, setMdViewer] = useState(null); // { url, name } — open markdown attachment viewer
365
+ const [composeSubject, setComposeSubject] = useState(''); // optional subject → opens a new topic thread
259
366
  const [mode, setMode] = useState('idle'); // 'idle' | 'compose' | 'thread'
260
367
  const [to, setTo] = useState('');
261
368
  const [sending, setSending] = useState(false);
@@ -385,8 +492,16 @@ export default function InboxTab({ showToast }) {
385
492
  setThread(msgs);
386
493
  const map = {};
387
494
  await Promise.all(msgs.flatMap(m => (m.attachments || [])
388
- .filter(a => a.mode === 'duplicate' && a.localKey)
389
- .map(async a => { const u = await messages.attachmentUrl(a.localKey).catch(() => null); if (u) map[a.id] = u; })));
495
+ .filter(a => !a.inline)
496
+ .map(async a => {
497
+ // Inbound: only the recipient's duplicated local copy is fetchable (after accept). Outbound:
498
+ // the sender owns the original storage key, so resolve that — you can view/download what you sent.
499
+ const key = (a.mode === 'duplicate' && a.localKey) ? a.localKey
500
+ : (m.direction === 'outbound' && a.storageKey) ? a.storageKey : null;
501
+ if (!key) return;
502
+ const u = await messages.attachmentUrl(key).catch(() => null);
503
+ if (u) map[a.id] = u;
504
+ })));
390
505
  setUrlMap(map);
391
506
  if (markRead) await messages.markConversationRead(conv.conversationId).catch(() => {});
392
507
  }, []);
@@ -434,7 +549,7 @@ export default function InboxTab({ showToast }) {
434
549
  await loadThread(conv, true); // mark read only on explicit open (avoids a refresh loop)
435
550
  loadLists();
436
551
  };
437
- const startCompose = () => { setMode('compose'); setActiveConv(null); setTo(''); };
552
+ const startCompose = () => { setMode('compose'); setActiveConv(null); setTo(''); setComposeSubject(''); };
438
553
 
439
554
  // Open the conversation for a tracked response, then (for awaiting-approval) seed the suggested reply.
440
555
  const openTracked = async (tr) => {
@@ -485,10 +600,13 @@ export default function InboxTab({ showToast }) {
485
600
  const desc = await messages.uploadAttachment(files[i]);
486
601
  attachments.push({ ...desc, inline: false, id: `at${i}` });
487
602
  }
488
- const resp = await messages.send({ to: recipient, body, attachments });
603
+ // A subject (compose mode only) opens a new topic thread; replies stay in the active thread.
604
+ const subject = (mode === 'compose' && composeSubject.trim()) ? composeSubject.trim() : undefined;
605
+ const resp = await messages.send({ to: recipient, body, attachments, subject });
489
606
  if (resp?.ok === false) { showToast?.(resp?.error?.message || t('inbox.failed'), true); }
490
607
  else {
491
608
  reset?.();
609
+ setComposeSubject('');
492
610
  // If this send fulfils a Tracked Response awaiting approval, mark it replied.
493
611
  if (replyingTrId) {
494
612
  await tracked.markTrackedResponseReplied(replyingTrId, resp?.data?.message?.id).catch(() => {});
@@ -528,23 +646,47 @@ export default function InboxTab({ showToast }) {
528
646
 
529
647
  <div class="inbox-list-section">${t('inbox.conversations')}</div>
530
648
  ${conversations.length === 0 ? html`<div class="inbox-empty-sm">${t('inbox.noConversations')}</div>` : null}
531
- ${conversations.map(c => html`
532
- <button class=${`inbox-conv${activeConv?.conversationId === c.conversationId ? ' inbox-conv--active' : ''}`}
533
- key=${c.conversationId} onClick=${() => openConversation(c)}>
534
- <${Avatar} seed=${c.peerGhii} size=${40} />
535
- <div class="inbox-conv-main">
536
- <div class="inbox-conv-line1">
537
- <span class="inbox-name">${escHtml(peerName(c.peerGhii))} <${PresenceDot} ghii=${c.peerGhii} /></span>
538
- <span class="inbox-conv-time">${c.updatedAt ? timeShort(c.updatedAt) : ''}</span>
649
+ ${groupConversations(conversations).map(g => {
650
+ // A person with a single human-only thread (no agents) renders flat, as before.
651
+ if (g.convs.length === 1 && !isAgentPeer(g.convs[0].peerGhii)) return convRow(g.convs[0], false);
652
+ // Otherwise: a group header for the person + a nested row per thread (human + each agent).
653
+ const unread = g.convs.reduce((n, c) => n + (c.unread || 0), 0);
654
+ return html`
655
+ <div class="inbox-conv-group" key=${g.ownerKey}>
656
+ <div class="inbox-conv-group-head">
657
+ <${Avatar} seed=${g.ownerKey} size=${28} />
658
+ <span class="inbox-name">${escHtml(ownerDisplayName(g.ownerKey))} <${PresenceDot} ghii=${g.ownerKey} /></span>
659
+ ${unread > 0 ? html`<span class="inbox-conv-badge">${unread}</span>` : null}
539
660
  </div>
540
- <div class="inbox-conv-line2">
541
- <span class="inbox-conv-preview">${c.lastDirection === 'outbound' ? `${t('inbox.youPrefix')} ` : ''}${escHtml(c.lastMessage || '')}</span>
542
- ${c.unread > 0 ? html`<span class="inbox-conv-badge">${c.unread}</span>` : null}
543
- </div>
544
- </div>
545
- </button>`)}
661
+ ${g.convs.map(c => convRow(c, true))}
662
+ </div>`;
663
+ })}
546
664
  </div>`;
547
665
 
666
+ /** One conversation row. `nested` = inside a person group (labelled by the agent/thread, indented). */
667
+ const convRow = (c, nested) => {
668
+ const active = activeConv?.conversationId === c.conversationId ? ' inbox-conv--active' : '';
669
+ const sub = subThreadLabel(c.peerGhii);
670
+ // Nested: a subject thread shows its topic; otherwise the agent name / "Direct". Flat: the person.
671
+ const label = nested ? (c.subject || sub || t('inbox.directThread')) : peerName(c.peerGhii);
672
+ const icon = sub ? '🤖' : (c.subject ? '🏷' : '💬');
673
+ return html`
674
+ <button class=${`inbox-conv${active}${nested ? ' inbox-conv--nested' : ''}`} key=${c.conversationId} onClick=${() => openConversation(c)}>
675
+ ${nested ? html`<span class="inbox-conv-subico">${icon}</span>` : html`<${Avatar} seed=${c.peerGhii} size=${40} />`}
676
+ <div class="inbox-conv-main">
677
+ <div class="inbox-conv-line1">
678
+ <span class="inbox-name">${escHtml(label)} ${nested ? '' : html`<${PresenceDot} ghii=${c.peerGhii} />`}</span>
679
+ <span class="inbox-conv-time">${c.updatedAt ? timeShort(c.updatedAt) : ''}</span>
680
+ </div>
681
+ <div class="inbox-conv-line2">
682
+ ${(!nested && c.subject) ? html`<span class="inbox-conv-subject">🏷 ${escHtml(c.subject)}</span>` : ''}
683
+ <span class="inbox-conv-preview">${c.lastDirection === 'outbound' ? `${t('inbox.youPrefix')} ` : ''}${escHtml(c.lastMessage || '')}</span>
684
+ ${c.unread > 0 ? html`<span class="inbox-conv-badge">${c.unread}</span>` : null}
685
+ </div>
686
+ </div>
687
+ </button>`;
688
+ };
689
+
548
690
  const renderThread = () => {
549
691
  let lastDay = '';
550
692
  return html`
@@ -553,6 +695,7 @@ export default function InboxTab({ showToast }) {
553
695
  <${Avatar} seed=${activeConv.peerGhii} size=${36} />
554
696
  <div class="inbox-thread-id">
555
697
  <div class="inbox-name">${escHtml(peerName(activeConv.peerGhii))} <${PresenceDot} ghii=${activeConv.peerGhii} label=${true} /></div>
698
+ ${activeConv.subject ? html`<div class="inbox-thread-subject">🏷 ${escHtml(activeConv.subject)}</div>` : null}
556
699
  <div class="inbox-sub">${escHtml(activeConv.peerGhii)}</div>
557
700
  </div>
558
701
  </div>
@@ -564,7 +707,8 @@ export default function InboxTab({ showToast }) {
564
707
  return html`
565
708
  ${showDay ? html`<div class="inbox-day" key=${'d' + m.id}><span>${dayLabel(m.createdAt)}</span></div>` : null}
566
709
  <${MessageBubble} key=${m.id + m.direction} msg=${m} mine=${m.direction === 'outbound'} urlMap=${urlMap}
567
- starred=${important.has(m.id)} onStar=${toggleImportant} onTrack=${onTrackMsg} tracked=${trackedByMsg[m.id]} />`;
710
+ starred=${important.has(m.id)} onStar=${toggleImportant} onTrack=${onTrackMsg} tracked=${trackedByMsg[m.id]}
711
+ onOpenMarkdown=${(url, name) => setMdViewer({ url, name })} />`;
568
712
  })}
569
713
  </div>
570
714
  ${awaitingForConv.map(tr => html`
@@ -653,6 +797,8 @@ export default function InboxTab({ showToast }) {
653
797
  <div class="inbox-compose-fields">
654
798
  <input class="inbox-input" type="text" placeholder=${t('inbox.toPlaceholder')}
655
799
  value=${to} onInput=${(e) => setTo(e.target.value)} />
800
+ <input class="inbox-input" type="text" placeholder=${t('inbox.subjectPlaceholder')}
801
+ value=${composeSubject} onInput=${(e) => setComposeSubject(e.target.value)} />
656
802
  </div>
657
803
  <${Composer} key="c-new" recipient=${to.trim()} sendLabel=${t('inbox.send')}
658
804
  sending=${sending} onSend=${doSend} />
@@ -673,5 +819,6 @@ export default function InboxTab({ showToast }) {
673
819
 
674
820
  <${TrackResponseModal} open=${!!trackMsg} msg=${trackMsg}
675
821
  onClose=${() => setTrackMsg(null)} onDone=${loadLists} showToast=${showToast} />
822
+ ${mdViewer && html`<${MarkdownViewer} url=${mdViewer.url} name=${mdViewer.name} onClose=${() => setMdViewer(null)} />`}
676
823
  </div>`;
677
824
  }