aimeat 1.30.2 โ†’ 1.32.0

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 (192) hide show
  1. package/dist/locales/en.json +246 -1
  2. package/dist/locales/fi.json +246 -1
  3. package/dist/public/components/InboxLink.js +29 -0
  4. package/dist/public/components/offer-card-view.js +87 -0
  5. package/dist/public/css/views/companies.css +65 -0
  6. package/dist/public/css/views/inbox.css +153 -1
  7. package/dist/public/css/views/members.css +33 -0
  8. package/dist/public/css/views/my-company.css +315 -0
  9. package/dist/public/css/views/profile.css +22 -0
  10. package/dist/public/js/services/messages.js +21 -3
  11. package/dist/public/js/services/notebook.js +16 -6
  12. package/dist/public/llms-template.txt +7 -0
  13. package/dist/public/spa.html +14 -0
  14. package/dist/public/views/companies.js +190 -0
  15. package/dist/public/views/members.js +42 -0
  16. package/dist/public/views/my-company.js +664 -0
  17. package/dist/public/views/portfolio-themes.js +77 -0
  18. package/dist/public/views/profile/agents-tab.js +4 -1
  19. package/dist/public/views/profile/discover-tab.js +118 -0
  20. package/dist/public/views/profile/inbox-tab.js +663 -16
  21. package/dist/public/views/profile/landing-page.js +2 -1
  22. package/dist/public/views/profile/notebook-card.js +11 -3
  23. package/dist/public/views/profile/offers-tab.js +5 -24
  24. package/dist/public/views/profile/organisms/home.js +5 -3
  25. package/dist/public/views/profile/organisms/mindmap.js +14 -3
  26. package/dist/public/views/profile/organisms/workspace.js +5 -2
  27. package/dist/public/views/profile/organisms-tab.js +12 -4
  28. package/dist/public/views/profile.js +2 -0
  29. package/dist/src/cli/connect/mcp/tools/core.d.ts.map +1 -1
  30. package/dist/src/cli/connect/mcp/tools/core.js +34 -0
  31. package/dist/src/cli/connect/mcp/tools/core.js.map +1 -1
  32. package/dist/src/cli/connect/mcp/tools/dm-messages.d.ts +1 -0
  33. package/dist/src/cli/connect/mcp/tools/dm-messages.d.ts.map +1 -1
  34. package/dist/src/cli/connect/mcp/tools/dm-messages.js +23 -0
  35. package/dist/src/cli/connect/mcp/tools/dm-messages.js.map +1 -1
  36. package/dist/src/cli/connect/mcp/tools/organisms.d.ts.map +1 -1
  37. package/dist/src/cli/connect/mcp/tools/organisms.js +31 -0
  38. package/dist/src/cli/connect/mcp/tools/organisms.js.map +1 -1
  39. package/dist/src/cli/connect/mcp/tools/workspaces.d.ts.map +1 -1
  40. package/dist/src/cli/connect/mcp/tools/workspaces.js +4 -0
  41. package/dist/src/cli/connect/mcp/tools/workspaces.js.map +1 -1
  42. package/dist/src/generated/api-types.d.ts +249 -0
  43. package/dist/src/generated/api-types.d.ts.map +1 -1
  44. package/dist/src/generated/prisma-postgres/edge.js +6 -3
  45. package/dist/src/generated/prisma-postgres/index-browser.js +3 -0
  46. package/dist/src/generated/prisma-postgres/index.d.ts +112 -1
  47. package/dist/src/generated/prisma-postgres/index.js +6 -3
  48. package/dist/src/generated/prisma-postgres/package.json +1 -1
  49. package/dist/src/generated/prisma-postgres/schema.prisma +4 -0
  50. package/dist/src/generated/prisma-postgres/wasm.js +6 -3
  51. package/dist/src/mcp/annotations.d.ts.map +1 -1
  52. package/dist/src/mcp/annotations.js +2 -0
  53. package/dist/src/mcp/annotations.js.map +1 -1
  54. package/dist/src/mcp/catalog/definitions.d.ts.map +1 -1
  55. package/dist/src/mcp/catalog/definitions.js +33 -1
  56. package/dist/src/mcp/catalog/definitions.js.map +1 -1
  57. package/dist/src/mcp/catalog/scopes.d.ts.map +1 -1
  58. package/dist/src/mcp/catalog/scopes.js +1 -0
  59. package/dist/src/mcp/catalog/scopes.js.map +1 -1
  60. package/dist/src/mcp/catalog/surfaces.d.ts.map +1 -1
  61. package/dist/src/mcp/catalog/surfaces.js +7 -4
  62. package/dist/src/mcp/catalog/surfaces.js.map +1 -1
  63. package/dist/src/mcp/core.d.ts.map +1 -1
  64. package/dist/src/mcp/core.js +49 -0
  65. package/dist/src/mcp/core.js.map +1 -1
  66. package/dist/src/mcp/dm-messages.d.ts +2 -0
  67. package/dist/src/mcp/dm-messages.d.ts.map +1 -1
  68. package/dist/src/mcp/dm-messages.js +45 -1
  69. package/dist/src/mcp/dm-messages.js.map +1 -1
  70. package/dist/src/models/message-schemas.d.ts +140 -2
  71. package/dist/src/models/message-schemas.d.ts.map +1 -1
  72. package/dist/src/models/message-schemas.js +60 -3
  73. package/dist/src/models/message-schemas.js.map +1 -1
  74. package/dist/src/routes/bootstrap.d.ts.map +1 -1
  75. package/dist/src/routes/bootstrap.js +3 -0
  76. package/dist/src/routes/bootstrap.js.map +1 -1
  77. package/dist/src/routes/discover.d.ts +22 -0
  78. package/dist/src/routes/discover.d.ts.map +1 -0
  79. package/dist/src/routes/discover.js +123 -0
  80. package/dist/src/routes/discover.js.map +1 -0
  81. package/dist/src/routes/federation-sync.d.ts.map +1 -1
  82. package/dist/src/routes/federation-sync.js +69 -2
  83. package/dist/src/routes/federation-sync.js.map +1 -1
  84. package/dist/src/routes/messages.d.ts.map +1 -1
  85. package/dist/src/routes/messages.js +102 -3
  86. package/dist/src/routes/messages.js.map +1 -1
  87. package/dist/src/routes/organisms.d.ts +3 -0
  88. package/dist/src/routes/organisms.d.ts.map +1 -1
  89. package/dist/src/routes/organisms.js +11 -32
  90. package/dist/src/routes/organisms.js.map +1 -1
  91. package/dist/src/routes/portal.d.ts.map +1 -1
  92. package/dist/src/routes/portal.js +3 -0
  93. package/dist/src/routes/portal.js.map +1 -1
  94. package/dist/src/routes/portfolio.d.ts.map +1 -1
  95. package/dist/src/routes/portfolio.js +30 -0
  96. package/dist/src/routes/portfolio.js.map +1 -1
  97. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  98. package/dist/src/server-bootstrap/routes-loader.js +2 -0
  99. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  100. package/dist/src/services/connect-tunnel.d.ts +3 -0
  101. package/dist/src/services/connect-tunnel.d.ts.map +1 -1
  102. package/dist/src/services/connect-tunnel.js +6 -0
  103. package/dist/src/services/connect-tunnel.js.map +1 -1
  104. package/dist/src/services/discovery/__tests__/discovery.test.d.ts +2 -0
  105. package/dist/src/services/discovery/__tests__/discovery.test.d.ts.map +1 -0
  106. package/dist/src/services/discovery/__tests__/discovery.test.js +178 -0
  107. package/dist/src/services/discovery/__tests__/discovery.test.js.map +1 -0
  108. package/dist/src/services/discovery/classify.d.ts +36 -0
  109. package/dist/src/services/discovery/classify.d.ts.map +1 -0
  110. package/dist/src/services/discovery/classify.js +61 -0
  111. package/dist/src/services/discovery/classify.js.map +1 -0
  112. package/dist/src/services/discovery/facets.d.ts +21 -0
  113. package/dist/src/services/discovery/facets.d.ts.map +1 -0
  114. package/dist/src/services/discovery/facets.js +0 -0
  115. package/dist/src/services/discovery/facets.js.map +1 -0
  116. package/dist/src/services/discovery/index.d.ts +22 -0
  117. package/dist/src/services/discovery/index.d.ts.map +1 -0
  118. package/dist/src/services/discovery/index.js +22 -0
  119. package/dist/src/services/discovery/index.js.map +1 -0
  120. package/dist/src/services/discovery/normalize.d.ts +25 -0
  121. package/dist/src/services/discovery/normalize.d.ts.map +1 -0
  122. package/dist/src/services/discovery/normalize.js +108 -0
  123. package/dist/src/services/discovery/normalize.js.map +1 -0
  124. package/dist/src/services/discovery/registry.d.ts +30 -0
  125. package/dist/src/services/discovery/registry.d.ts.map +1 -0
  126. package/dist/src/services/discovery/registry.js +31 -0
  127. package/dist/src/services/discovery/registry.js.map +1 -0
  128. package/dist/src/services/discovery/run.d.ts +23 -0
  129. package/dist/src/services/discovery/run.d.ts.map +1 -0
  130. package/dist/src/services/discovery/run.js +20 -0
  131. package/dist/src/services/discovery/run.js.map +1 -0
  132. package/dist/src/services/discovery/setup.d.ts +16 -0
  133. package/dist/src/services/discovery/setup.d.ts.map +1 -0
  134. package/dist/src/services/discovery/setup.js +12 -0
  135. package/dist/src/services/discovery/setup.js.map +1 -0
  136. package/dist/src/services/discovery/sources/memory-source.d.ts +24 -0
  137. package/dist/src/services/discovery/sources/memory-source.d.ts.map +1 -0
  138. package/dist/src/services/discovery/sources/memory-source.js +183 -0
  139. package/dist/src/services/discovery/sources/memory-source.js.map +1 -0
  140. package/dist/src/services/discovery/sources/table-sources.d.ts +27 -0
  141. package/dist/src/services/discovery/sources/table-sources.d.ts.map +1 -0
  142. package/dist/src/services/discovery/sources/table-sources.js +128 -0
  143. package/dist/src/services/discovery/sources/table-sources.js.map +1 -0
  144. package/dist/src/services/discovery/types.d.ts +117 -0
  145. package/dist/src/services/discovery/types.d.ts.map +1 -0
  146. package/dist/src/services/discovery/types.js +19 -0
  147. package/dist/src/services/discovery/types.js.map +1 -0
  148. package/dist/src/services/handbooks/agent.d.ts +1 -1
  149. package/dist/src/services/handbooks/agent.d.ts.map +1 -1
  150. package/dist/src/services/handbooks/agent.js +9 -3
  151. package/dist/src/services/handbooks/agent.js.map +1 -1
  152. package/dist/src/services/message-broadcast.d.ts +49 -0
  153. package/dist/src/services/message-broadcast.d.ts.map +1 -0
  154. package/dist/src/services/message-broadcast.js +116 -0
  155. package/dist/src/services/message-broadcast.js.map +1 -0
  156. package/dist/src/services/message-delivery.d.ts.map +1 -1
  157. package/dist/src/services/message-delivery.js +3 -0
  158. package/dist/src/services/message-delivery.js.map +1 -1
  159. package/dist/src/services/message-send.d.ts +9 -1
  160. package/dist/src/services/message-send.d.ts.map +1 -1
  161. package/dist/src/services/message-send.js +11 -5
  162. package/dist/src/services/message-send.js.map +1 -1
  163. package/dist/src/services/presence.d.ts +8 -0
  164. package/dist/src/services/presence.d.ts.map +1 -1
  165. package/dist/src/services/presence.js +38 -0
  166. package/dist/src/services/presence.js.map +1 -1
  167. package/dist/src/services/workspace-enrichment.d.ts +15 -5
  168. package/dist/src/services/workspace-enrichment.d.ts.map +1 -1
  169. package/dist/src/services/workspace-enrichment.js +52 -14
  170. package/dist/src/services/workspace-enrichment.js.map +1 -1
  171. package/dist/src/storage/interface.d.ts +49 -0
  172. package/dist/src/storage/interface.d.ts.map +1 -1
  173. package/dist/src/storage/providers/mongodb/index.d.ts +1 -0
  174. package/dist/src/storage/providers/mongodb/index.d.ts.map +1 -1
  175. package/dist/src/storage/providers/mongodb/index.js +16 -0
  176. package/dist/src/storage/providers/mongodb/index.js.map +1 -1
  177. package/dist/src/storage/providers/sqlite/index.d.ts +1 -0
  178. package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
  179. package/dist/src/storage/providers/sqlite/index.js +3 -0
  180. package/dist/src/storage/providers/sqlite/index.js.map +1 -1
  181. package/dist/src/storage/providers/sqlite/repos/direct-message.d.ts +3 -0
  182. package/dist/src/storage/providers/sqlite/repos/direct-message.d.ts.map +1 -1
  183. package/dist/src/storage/providers/sqlite/repos/direct-message.js +15 -2
  184. package/dist/src/storage/providers/sqlite/repos/direct-message.js.map +1 -1
  185. package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
  186. package/dist/src/storage/providers/sqlite/schema.js +10 -0
  187. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  188. package/dist/src/storage/repositories/direct-message.repository.d.ts +3 -0
  189. package/dist/src/storage/repositories/direct-message.repository.d.ts.map +1 -1
  190. package/package.json +1 -1
  191. package/prisma/schema.postgres.prisma +4 -0
  192. package/prisma/schema.prisma +4 -0
@@ -8,9 +8,54 @@
8
8
  * Toast UI editor used by workspace documents (Markdownโ‡„WYSIWYG toggle, lazy-loaded), with a
9
9
  * markdown-textarea + live-preview fallback. First contact is gated as a request (accept/block).
10
10
  * Re-fetches on SSE updates.
11
- * @structure InboxTab (default) ยท Composer (Toast UI) ยท MessageBubble ยท Avatar ยท helpers
11
+ * @structure InboxTab (default) ยท Composer (Toast UI) ยท MessageBubble ยท InteractiveForm ยท Avatar ยท helpers
12
12
  * @usage Lazy-loaded profile tab; registered in profile.js TABS as id `messages`.
13
13
  * @version-history
14
+ * v1.14.0 -- 2026-06-23 -- Per-message ๐Ÿ““ action parks a message straight into the notebook for later
15
+ * processing (instant copy via parkMessageToNotebook โ€” keeps the source link + reply intent; no AI step).
16
+ * v1.13.0 -- 2026-06-23 -- Agent capabilities in chat: (A) a peer agent's public `chat.commands` render
17
+ * as fill-in command chips above the composer โ€” pick one, fill the params, the filled template drops
18
+ * into the composer to review + send; (B) for the human's OWN agents, a ๐Ÿ“… schedule panel lists the
19
+ * agent's scheduled tasks and creates a recurring agent_task (node scheduler scoped to the agent).
20
+ * v1.12.1 -- 2026-06-23 -- Operator broadcast audience is now a select: All node users OR All federation
21
+ * users (genesis operator โ†’ every owner across the federation; delegated per-peer fan-out server-side).
22
+ * v1.12.0 -- 2026-06-23 -- Operator broadcast: an operator sees an "๐Ÿ“ฃ All node users" audience in the
23
+ * broadcast compose (sends audience:'node-users', gated operator-only server-side) โ€” e.g. a node-wide
24
+ * announcement. Announcement read stats are in the existing Results view (delivered/read per recipient).
25
+ * v1.11.0 -- 2026-06-23 -- Inbox-links: the tab reads ?to=<id[,id]>&subject= on mount and opens a
26
+ * prefilled compose (single) or broadcast (multiple), then clears the URL. Pairs with the reusable
27
+ * <InboxLink> component (mailto-style one-click DM), wired into the agent profile.
28
+ * v1.10.0 -- 2026-06-23 -- Drafts: the composer auto-saves its text (debounced) to localStorage keyed by
29
+ * conversation (or `new`), restoring it when you reopen the thread/compose so an in-progress message
30
+ * isn't lost when switching threads; cleared on send. A suggested reply (Tracked Response) still wins.
31
+ * v1.9.0 -- 2026-06-23 -- Polls: the broadcast compose gains a Message/Poll toggle + a PollBuilder
32
+ * (questions, options, multi/Other/required) that fans out an interactive AskUserQuestion to the
33
+ * audience; a Results view aggregates per-option tallies + delivered/read/answered counts. Sent
34
+ * broadcasts are tracked in localStorage so results stay re-accessible (๐Ÿ“Š Results).
35
+ * v1.8.1 -- 2026-06-23 -- Perf: cache resolved attachment URLs per conversation. A refresh / new message
36
+ * reused to re-resolve EVERY attachment (fresh presigned URL each time โ†’ new <img src> โ†’ the whole
37
+ * image gallery re-downloaded on every send/reply โ€” hundreds of MB on an image-heavy thread). Now only
38
+ * not-yet-resolved attachments are fetched; existing URLs stay stable so the browser doesn't re-fetch.
39
+ * v1.8.0 -- 2026-06-23 -- Send-to-many (broadcast): a ๐Ÿ“ข Broadcast compose with a recipient-chip picker
40
+ * (+ Share Group audience) and a mode toggle โ€” Message (repliable) vs Announcement (read-only). An
41
+ * announcement thread hides the reply composer for the recipient.
42
+ * v1.7.4 -- 2026-06-23 -- Recipient suggestions when composing: the "to" field autocompletes (datalist)
43
+ * with your own agents (GAIIs, labelled) + everyone you have a thread with โ€” no retyping long GAIIs.
44
+ * v1.7.3 -- 2026-06-23 -- Show an AGENT's own presence dot on its conversation row (a nested agent row
45
+ * now renders <PresenceDot> for the agent GAII โ€” connected = available, recently-seen = away, else
46
+ * offline โ€” instead of nothing). The owner group header still shows the human's presence.
47
+ * v1.7.2 -- 2026-06-23 -- Delivery ticks now distinguish delivered from sent: delivered = โœ“โœ“ (grey,
48
+ * "reached the mailbox" โ€” incl. agent threads, which never send a read receipt), read = โœ“โœ“ coloured,
49
+ * sent = โœ“, queued = clock. Previously delivered rendered as a single โœ“ identical to sent, so an
50
+ * agent DM never showed a "got there" confirmation.
51
+ * v1.7.1 -- 2026-06-23 -- Fix thread-splitting: a reply in an open thread now pins to that
52
+ * conversationId. Without it, replying in a SUBJECT thread fell back to the default per-pair thread,
53
+ * so the reply spawned a brand-new thread named after the agent (e.g. "concierge") and the subject
54
+ * thread was abandoned.
55
+ * v1.7.0 -- 2026-06-23 -- Interactive messages (federated AskUserQuestion): a question message renders
56
+ * inline as a form (radio/checkbox + "Other" + Submit, gated on required); submitting sends a normal
57
+ * reply carrying a human-readable summary + machine-readable `interactive.answers`. Answered questions
58
+ * show a read-only summary. (InteractiveForm/InteractiveAnswered + submitInteractiveAnswers.)
14
59
  * v1.6.2 -- 2026-06-21 -- Fix live-update request storm: the SSE payload's `domains` is a Set, but
15
60
  * the handler tested `Array.isArray(domains)` (always false) so the domain filter never matched and
16
61
  * the inbox re-fetched its whole state on EVERY change (memory/organism/task churn from dozens of
@@ -53,6 +98,12 @@ import { minidenticon } from '/lib/minidenticons.min.js';
53
98
  import { PresenceDot } from '/components/PresenceDot.js';
54
99
  import * as messages from '/js/services/messages.js';
55
100
  import * as tracked from '/js/services/tracked-responses.js';
101
+ import * as agentsSvc from '/js/services/agents.js';
102
+ import * as schedules from '/js/services/schedules.js';
103
+ import { parkMessageToNotebook } from '/js/services/notebook.js';
104
+ import { firstLine } from './notebook-helpers.js';
105
+ import { apiGet } from '/js/api.js';
106
+ import { getSession } from '/js/services/auth.js';
56
107
  import { TrackResponseModal } from './track-response-modal.js';
57
108
 
58
109
  /* Lazy-load the vendored Toast UI Editor (MIT, /lib/toastui/) โ€” the same editor the workspace
@@ -118,7 +169,10 @@ function Avatar({ seed, size = 36 }) {
118
169
  dangerouslySetInnerHTML=${{ __html: svg }}></span>`;
119
170
  }
120
171
 
121
- const TICK = { sent: 'โœ“', delivered: 'โœ“', read: 'โœ“โœ“', queued: '๐Ÿ•’', failed: 'โš ', undeliverable: 'โš ' };
172
+ // WhatsApp-style ticks: sent = one โœ“; delivered (reached the recipient's mailbox, incl. an agent's) =
173
+ // two โœ“โœ“ (grey); read (read receipt โ€” humans send one, agents don't yet) = two โœ“โœ“ coloured via
174
+ // inbox-tick--read; queued (cross-node, peer unreachable) = a clock.
175
+ const TICK = { sent: 'โœ“', delivered: 'โœ“โœ“', read: 'โœ“โœ“', queued: '๐Ÿ•’', failed: 'โš ', undeliverable: 'โš ' };
122
176
  function statusTick(status) { return TICK[status] || ''; }
123
177
 
124
178
  function timeShort(s) {
@@ -246,7 +300,171 @@ function MarkdownViewer({ url, name, onClose }) {
246
300
  </div>`;
247
301
  }
248
302
 
249
- function MessageBubble({ msg, mine, urlMap, starred, onStar, onTrack, tracked, onOpenMarkdown }) {
303
+ // Sentinel option id for the freeform "Other" choice (never collides with a real option id).
304
+ const IFORM_OTHER = '__other__';
305
+
306
+ /** Build the human-readable markdown summary that becomes the reply body (so the thread + un-upgraded
307
+ * peers read the answer naturally, alongside the machine-readable `interactive.answers`). */
308
+ function buildAnswerSummary(spec, answers) {
309
+ const lines = [`**${t('inbox.answer.summaryTitle')}**`];
310
+ for (const q of (spec?.questions || [])) {
311
+ const a = answers[q.id] || { selected: [], other: null };
312
+ const labels = (q.options || []).filter(o => a.selected.includes(o.id)).map(o => o.label);
313
+ if (a.other) labels.push(`${t('inbox.answer.other')}: ${a.other}`);
314
+ lines.push(`- ${q.header || q.prompt}: ${labels.length ? labels.join(', ') : 'โ€”'}`);
315
+ }
316
+ return lines.join('\n');
317
+ }
318
+
319
+ /** The interactive question form rendered inline in the thread (a federated AskUserQuestion): radio
320
+ * groups (single-select), checkbox groups (multiSelect), an always-available "Other" freeform, and a
321
+ * Submit button gated until every `required` question is answered. */
322
+ function InteractiveForm({ spec, submitting, onSubmit }) {
323
+ const questions = spec?.questions || [];
324
+ const [sel, setSel] = useState(() => {
325
+ const init = {};
326
+ for (const q of questions) init[q.id] = { picks: new Set(), other: '' };
327
+ return init;
328
+ });
329
+ const setQ = (qid, updater) => setSel(prev => ({ ...prev, [qid]: updater(prev[qid] || { picks: new Set(), other: '' }) }));
330
+ const pickSingle = (qid, optId) => setQ(qid, s => ({ picks: new Set([optId]), other: optId === IFORM_OTHER ? s.other : '' }));
331
+ const toggleMulti = (qid, optId) => setQ(qid, s => {
332
+ const picks = new Set(s.picks);
333
+ if (picks.has(optId)) picks.delete(optId); else picks.add(optId);
334
+ return { picks, other: picks.has(IFORM_OTHER) ? s.other : '' };
335
+ });
336
+ const setOther = (qid, text) => setQ(qid, s => ({ picks: s.picks, other: text }));
337
+
338
+ const answeredOk = (q) => {
339
+ const s = sel[q.id]; if (!s) return false;
340
+ const realPicks = [...s.picks].filter(p => p !== IFORM_OTHER);
341
+ const otherOk = s.picks.has(IFORM_OTHER) && s.other.trim().length > 0;
342
+ return realPicks.length > 0 || otherOk;
343
+ };
344
+ const canSubmit = questions.every(q => !q.required || answeredOk(q));
345
+
346
+ const submit = () => {
347
+ if (!canSubmit || submitting) return;
348
+ const answers = {};
349
+ for (const q of questions) {
350
+ const s = sel[q.id] || { picks: new Set(), other: '' };
351
+ const selected = [...s.picks].filter(p => p !== IFORM_OTHER);
352
+ const other = (s.picks.has(IFORM_OTHER) && s.other.trim()) ? s.other.trim() : null;
353
+ answers[q.id] = { selected, other };
354
+ }
355
+ onSubmit?.(answers);
356
+ };
357
+
358
+ const renderOpt = (q, optId, label) => {
359
+ const multi = !!q.multiSelect;
360
+ const on = sel[q.id]?.picks.has(optId);
361
+ return html`
362
+ <label class=${`inbox-iform-opt${on ? ' inbox-iform-opt--on' : ''}`} key=${optId}>
363
+ <input type=${multi ? 'checkbox' : 'radio'} name=${`q-${q.id}`} checked=${!!on}
364
+ onChange=${() => multi ? toggleMulti(q.id, optId) : pickSingle(q.id, optId)} />
365
+ <span class="inbox-iform-opt-label">${escHtml(label)}</span>
366
+ </label>`;
367
+ };
368
+
369
+ return html`
370
+ <div class="inbox-iform">
371
+ ${questions.map(q => html`
372
+ <div class="inbox-iform-q" key=${q.id}>
373
+ ${q.header ? html`<span class="inbox-iform-chip">${escHtml(q.header)}</span>` : null}
374
+ <div class="inbox-iform-prompt">${escHtml(q.prompt)}${q.required ? html`<span class="inbox-iform-req"> *</span>` : null}</div>
375
+ <div class="inbox-iform-opts" role=${q.multiSelect ? 'group' : 'radiogroup'}>
376
+ ${(q.options || []).map(o => renderOpt(q, o.id, o.label))}
377
+ ${q.allowOther !== false ? html`
378
+ ${renderOpt(q, IFORM_OTHER, t('inbox.answer.other'))}
379
+ ${sel[q.id]?.picks.has(IFORM_OTHER) ? html`
380
+ <input class="inbox-iform-other" type="text" value=${sel[q.id]?.other || ''}
381
+ placeholder=${t('inbox.answer.otherPlaceholder')} onInput=${e => setOther(q.id, e.target.value)} />` : null}` : null}
382
+ </div>
383
+ </div>`)}
384
+ <button class="btn-primary btn-sm inbox-iform-submit" disabled=${!canSubmit || submitting} onClick=${submit}>
385
+ ${submitting ? t('inbox.sending') : (spec?.submitLabel || t('inbox.answer.send'))}
386
+ </button>
387
+ </div>`;
388
+ }
389
+
390
+ /** Read-only summary shown on a question bubble once it has been answered. */
391
+ function InteractiveAnswered({ spec, answers }) {
392
+ return html`
393
+ <div class="inbox-iform inbox-iform--done">
394
+ ${(spec?.questions || []).map(q => {
395
+ const a = answers[q.id] || { selected: [], other: null };
396
+ const labels = (q.options || []).filter(o => a.selected.includes(o.id)).map(o => o.label);
397
+ if (a.other) labels.push(`${t('inbox.answer.other')}: ${a.other}`);
398
+ return html`
399
+ <div class="inbox-iform-q" key=${q.id}>
400
+ <span class="inbox-iform-chip">${escHtml(q.header || q.prompt)}</span>
401
+ <div class="inbox-iform-answered">โœ“ ${labels.length ? escHtml(labels.join(', ')) : 'โ€”'}</div>
402
+ </div>`;
403
+ })}
404
+ </div>`;
405
+ }
406
+
407
+ /** Compose the questions for a poll broadcast (a fanned-out AskUserQuestion). Controlled โ€” owns no state;
408
+ * edits go through setQuestions. */
409
+ function PollBuilder({ questions, setQuestions }) {
410
+ const uid = () => 'q' + Math.random().toString(36).slice(2, 8);
411
+ const oid = () => 'o' + Math.random().toString(36).slice(2, 8);
412
+ const update = (i, patch) => setQuestions(questions.map((q, j) => (j === i ? { ...q, ...patch } : q)));
413
+ const addQ = () => setQuestions([...questions, {
414
+ id: uid(), header: '', prompt: '', options: [{ id: oid(), label: '' }, { id: oid(), label: '' }],
415
+ multiSelect: false, allowOther: true, required: false,
416
+ }]);
417
+ const removeQ = (i) => setQuestions(questions.filter((_, j) => j !== i));
418
+ const updateOpt = (qi, oi, label) => update(qi, { options: questions[qi].options.map((o, j) => (j === oi ? { ...o, label } : o)) });
419
+ const addOpt = (qi) => update(qi, { options: [...questions[qi].options, { id: oid(), label: '' }] });
420
+ const removeOpt = (qi, oi) => update(qi, { options: questions[qi].options.filter((_, j) => j !== oi) });
421
+
422
+ return html`
423
+ <div class="inbox-poll-builder">
424
+ ${questions.map((q, qi) => html`
425
+ <div class="inbox-poll-q" key=${q.id}>
426
+ <div class="inbox-poll-q-head">
427
+ <span class="inbox-poll-q-n">${qi + 1}.</span>
428
+ <input class="inbox-input" placeholder=${t('inbox.pollHeader')} value=${q.header} onInput=${(e) => update(qi, { header: e.target.value })} />
429
+ <button class="inbox-bc-chip-x" title=${t('inbox.pollRemoveQ')} onClick=${() => removeQ(qi)}>โœ•</button>
430
+ </div>
431
+ <input class="inbox-input" placeholder=${t('inbox.pollPrompt')} value=${q.prompt} onInput=${(e) => update(qi, { prompt: e.target.value })} />
432
+ <div class="inbox-poll-opts">
433
+ ${q.options.map((o, oi) => html`<div class="inbox-poll-opt" key=${o.id}>
434
+ <input class="inbox-input" placeholder=${`${t('inbox.pollOption')} ${oi + 1}`} value=${o.label} onInput=${(e) => updateOpt(qi, oi, e.target.value)} />
435
+ ${q.options.length > 1 ? html`<button class="inbox-bc-chip-x" onClick=${() => removeOpt(qi, oi)}>โœ•</button>` : null}
436
+ </div>`)}
437
+ <button class="btn-ghost btn-sm" onClick=${() => addOpt(qi)}>+ ${t('inbox.pollAddOption')}</button>
438
+ </div>
439
+ <div class="inbox-poll-flags">
440
+ <label><input type="checkbox" checked=${q.multiSelect} onChange=${(e) => update(qi, { multiSelect: e.target.checked })} /> ${t('inbox.pollMulti')}</label>
441
+ <label><input type="checkbox" checked=${q.allowOther} onChange=${(e) => update(qi, { allowOther: e.target.checked })} /> ${t('inbox.pollAllowOther')}</label>
442
+ <label><input type="checkbox" checked=${q.required} onChange=${(e) => update(qi, { required: e.target.checked })} /> ${t('inbox.pollRequired')}</label>
443
+ </div>
444
+ </div>`)}
445
+ <button class="btn-outline btn-sm" onClick=${addQ}>+ ${t('inbox.pollAddQuestion')}</button>
446
+ </div>`;
447
+ }
448
+
449
+ /** Aggregate poll answers across a broadcast's recipients into per-option tallies for the results view. */
450
+ function tallyPoll(spec, recipients) {
451
+ const out = [];
452
+ for (const q of (spec?.questions || [])) {
453
+ const counts = {};
454
+ const others = [];
455
+ for (const o of (q.options || [])) counts[o.id] = 0;
456
+ for (const r of recipients) {
457
+ const a = r.answers?.[q.id];
458
+ if (!a) continue;
459
+ for (const sel of (a.selected || [])) if (sel in counts) counts[sel]++;
460
+ if (a.other) others.push(a.other);
461
+ }
462
+ out.push({ q, counts, others });
463
+ }
464
+ return out;
465
+ }
466
+
467
+ function MessageBubble({ msg, mine, urlMap, starred, onStar, onTrack, onPark, tracked, onOpenMarkdown, answeredWith, onAnswer, submitting }) {
250
468
  const nonInline = (msg.attachments || []).filter(a => !a.inline);
251
469
  const expiredIds = new Set((msg.attachments || []).filter(a => a.expired).map(a => a.id));
252
470
  const trk = tracked ? trackStateLabel(tracked.state) : null;
@@ -260,8 +478,16 @@ function MessageBubble({ msg, mine, urlMap, starred, onStar, onTrack, tracked, o
260
478
  <button class=${`inbox-bubble-act${tracked ? ' inbox-bubble-act--on' : ''}`}
261
479
  title=${tracked ? `${t('inbox.trackResponse')} โ€” ${trk.text}` : t('inbox.trackResponse')}
262
480
  onClick=${() => onTrack?.(msg)}>๐Ÿ”—</button>
481
+ <button class="inbox-bubble-act" title=${t('inbox.parkToNotebook')}
482
+ onClick=${() => onPark?.(msg)}>๐Ÿ““</button>
263
483
  </div>
264
484
  <div class="inbox-bubble-body"><${Markdown} text=${prepareBody(msg.body, urlMap, expiredIds)} /></div>
485
+ ${msg.interactive?.role === 'questions' ? (
486
+ answeredWith
487
+ ? html`<${InteractiveAnswered} spec=${msg.interactive} answers=${answeredWith.answers || {}} />`
488
+ : html`<${InteractiveForm} spec=${msg.interactive} submitting=${submitting}
489
+ onSubmit=${(answers) => onAnswer?.(msg, answers)} />`
490
+ ) : null}
265
491
  ${nonInline.length > 0 && html`
266
492
  <div class="inbox-attach-row">
267
493
  ${nonInline.map(a => html`<${AttachmentItem} key=${a.id} a=${a} url=${urlMap[a.id]} onOpenMarkdown=${onOpenMarkdown} />`)}
@@ -279,13 +505,26 @@ function MessageBubble({ msg, mine, urlMap, starred, onStar, onTrack, tracked, o
279
505
  * markdown-textarea + live-preview fallback if the editor can't load. Owns its own draft + file
280
506
  * state; calls onSend(recipient, markdown, files, reset). Remount it (via key) per conversation so
281
507
  * the draft doesn't leak between threads. */
282
- function Composer({ recipient, sendLabel, sending, onSend, initialText = '' }) {
508
+ function Composer({ recipient, sendLabel, sending, onSend, initialText = '', draftKey = '' }) {
509
+ // Restore an in-progress draft for this conversation/compose (localStorage), or the passed initialText.
510
+ const readDraft = () => { try { return draftKey ? (localStorage.getItem(draftKey) || '') : ''; } catch { return ''; } };
511
+ const seeded = initialText || readDraft(); // an explicit suggested reply wins; else restore a draft
283
512
  const [mode, setMode] = useState('rich'); // 'rich' = Toast UI; 'markdown' = fallback textarea
284
- const [md, setMd] = useState(initialText);
513
+ const [md, setMd] = useState(seeded);
285
514
  const [files, setFiles] = useState([]);
286
515
  const containerRef = useRef(null);
287
516
  const editorRef = useRef(null);
288
517
  const fileRef = useRef(null);
518
+ const saveTimer = useRef(null);
519
+ // Debounced auto-save of the draft (skipped when there's no key). Empty text clears the draft.
520
+ const saveDraft = (text) => {
521
+ if (!draftKey) return;
522
+ if (saveTimer.current) clearTimeout(saveTimer.current);
523
+ saveTimer.current = setTimeout(() => {
524
+ try { if (text && text.trim()) localStorage.setItem(draftKey, text); else localStorage.removeItem(draftKey); } catch { /* quota */ }
525
+ }, 400);
526
+ };
527
+ const clearDraft = () => { try { if (draftKey) localStorage.removeItem(draftKey); } catch { /* noop */ } };
289
528
 
290
529
  useEffect(() => {
291
530
  if (mode !== 'rich') return undefined;
@@ -300,8 +539,11 @@ function Composer({ recipient, sendLabel, sending, onSend, initialText = '' }) {
300
539
  height: '160px',
301
540
  initialEditType: 'markdown', // open in markdown mode; the built-in toggle switches to WYSIWYG
302
541
  previewStyle: 'tab',
303
- initialValue: initialText || '', // seed the suggested reply (Composer remounts via key on change)
542
+ initialValue: seeded, // seed from a saved draft / suggested reply (remounts via key)
304
543
  usageStatistics: false,
544
+ // editorRef is set only AFTER construction, so the constructor's own change (initialValue) is
545
+ // skipped โ€” only real user edits auto-save the draft.
546
+ events: { change: () => { if (editorRef.current) saveDraft(editorRef.current.getMarkdown()); } },
305
547
  toolbarItems: [
306
548
  ['bold', 'italic', 'strike'],
307
549
  ['ul', 'ol', 'task'],
@@ -329,6 +571,7 @@ function Composer({ recipient, sendLabel, sending, onSend, initialText = '' }) {
329
571
  const reset = () => {
330
572
  try { editorRef.current?.setMarkdown(''); } catch { /* noop */ }
331
573
  setMd(''); setFiles([]); if (fileRef.current) fileRef.current.value = '';
574
+ clearDraft(); // a sent message is no longer a draft
332
575
  };
333
576
  const submit = () => onSend(recipient, getText(), files, reset);
334
577
 
@@ -341,7 +584,7 @@ function Composer({ recipient, sendLabel, sending, onSend, initialText = '' }) {
341
584
  ? html`<div class="inbox-editor" ref=${containerRef}></div>`
342
585
  : html`<div class="inbox-md-fallback">
343
586
  <textarea class="inbox-textarea" rows="3" placeholder=${t('inbox.bodyPlaceholder')}
344
- value=${md} onInput=${(e) => setMd(e.target.value)}></textarea>
587
+ value=${md} onInput=${(e) => { setMd(e.target.value); saveDraft(e.target.value); }}></textarea>
345
588
  <div class="inbox-md-preview"><${Markdown} text=${md} /></div>
346
589
  </div>`}
347
590
  <div class="inbox-composer-bar">
@@ -355,6 +598,87 @@ function Composer({ recipient, sendLabel, sending, onSend, initialText = '' }) {
355
598
  </div>`;
356
599
  }
357
600
 
601
+ /* โ”€โ”€ Agent chat commands (Phase A) โ€” a peer agent advertises fill-in templates via its public
602
+ * `chat.commands` memory key ([{id,label,description,template,params:[{name,type,required,placeholder,
603
+ * default,options}]}]). We render a chip per command; the human fills the params; the resulting prose
604
+ * drops into the composer to review + send. The agent receives the filled template it advertised. โ”€โ”€ */
605
+ function CommandBar({ commands, onPick }) {
606
+ return html`<div class="inbox-cmdbar">
607
+ <span class="inbox-cmdbar-label">โšก ${t('inbox.cmdTitle')}</span>
608
+ ${commands.map(c => html`<button class="inbox-cmd-chip" key=${c.id} title=${c.description || ''}
609
+ onClick=${() => onPick(c)}>${escHtml(c.label || c.id)}</button>`)}
610
+ </div>`;
611
+ }
612
+
613
+ function CommandFill({ command, onInsert, onCancel }) {
614
+ const [values, setValues] = useState({});
615
+ const params = Array.isArray(command.params) ? command.params : [];
616
+ const valOf = (p) => String(values[p.name] ?? p.default ?? '');
617
+ const missing = params.some(p => p.required && !valOf(p).trim());
618
+ return html`<div class="inbox-cmdfill">
619
+ <div class="inbox-cmdfill-head">โšก ${escHtml(command.label || command.id)}
620
+ <button class="btn-ghost btn-sm" onClick=${onCancel} title=${t('inbox.close')}>โœ•</button></div>
621
+ ${command.description ? html`<div class="inbox-cmdfill-desc">${escHtml(command.description)}</div>` : null}
622
+ ${params.map(p => html`<label class="inbox-cmdfill-field" key=${p.name}>
623
+ <span class="inbox-cmdfill-pname">${escHtml(p.name)}${p.required ? ' *' : ''}</span>
624
+ ${p.type === 'select' && Array.isArray(p.options)
625
+ ? html`<select class="inbox-input" value=${valOf(p)}
626
+ onChange=${e => setValues(v => ({ ...v, [p.name]: e.target.value }))}>
627
+ ${p.options.map(o => html`<option key=${o} value=${o}>${escHtml(String(o))}</option>`)}</select>`
628
+ : html`<input class="inbox-input" type=${p.type === 'number' ? 'number' : 'text'}
629
+ placeholder=${p.placeholder || ''} value=${valOf(p)}
630
+ onInput=${e => setValues(v => ({ ...v, [p.name]: e.target.value }))} />`}
631
+ </label>`)}
632
+ <button class="btn-primary btn-sm inbox-cmdfill-go" disabled=${missing}
633
+ onClick=${() => onInsert(command, values)}>${t('inbox.cmdInsert')}</button>
634
+ </div>`;
635
+ }
636
+
637
+ /* โ”€โ”€ Agent schedule (Phase B) โ€” surfaces the node scheduler scoped to one of YOUR OWN agents
638
+ * (GET/POST /v1/agents/:name/schedules, which always resolve under the caller's owner). List the
639
+ * agent's managed jobs + create a recurring agent_task. Only shown for the human's own agents. โ”€โ”€ */
640
+ function SchedulePanel({ agentName, onClose, showToast }) {
641
+ const [jobs, setJobs] = useState(null);
642
+ const [title, setTitle] = useState('');
643
+ const [cron, setCron] = useState('0 9 * * *');
644
+ const [desc, setDesc] = useState('');
645
+ const [busy, setBusy] = useState(false);
646
+ const load = async () => {
647
+ try { const r = await schedules.listAgentSchedules(agentName); setJobs(r?.data?.managed || []); }
648
+ catch { setJobs([]); }
649
+ };
650
+ useEffect(() => { setJobs(null); load(); /* eslint-disable-next-line */ }, [agentName]);
651
+ const create = async () => {
652
+ if (!title.trim() || !cron.trim() || busy) return;
653
+ setBusy(true);
654
+ try {
655
+ await schedules.createAgentSchedule(agentName, {
656
+ kind: 'agent_task', cron: cron.trim(), task_title: title.trim(),
657
+ task_description: desc.trim(), display_name: title.trim(),
658
+ });
659
+ setTitle(''); setDesc(''); showToast?.(t('inbox.schedCreated'));
660
+ await load();
661
+ } catch (e) { showToast?.(e?.message || t('inbox.schedError'), true); }
662
+ finally { setBusy(false); }
663
+ };
664
+ return html`<div class="inbox-sched">
665
+ <div class="inbox-sched-head">๐Ÿ“… ${t('inbox.schedTitle')}
666
+ <button class="btn-ghost btn-sm" onClick=${onClose} title=${t('inbox.close')}>โœ•</button></div>
667
+ ${jobs == null ? html`<div class="inbox-empty-sm">${t('inbox.loading')}</div>`
668
+ : jobs.length === 0 ? html`<div class="inbox-empty-sm">${t('inbox.schedNone')}</div>`
669
+ : html`<ul class="inbox-sched-list">${jobs.map(j => html`<li class="inbox-sched-item" key=${j.id}>
670
+ <span class="inbox-sched-name">${escHtml(j.displayName || j.input?.taskTemplate?.title || j.id)}</span>
671
+ <span class="inbox-sched-cron">${escHtml(j.cron)}${j.enabled === false ? ' ยท ' + t('inbox.schedOff') : ''}</span>
672
+ </li>`)}</ul>`}
673
+ <div class="inbox-sched-new">
674
+ <input class="inbox-input" placeholder=${t('inbox.schedTaskPh')} value=${title} onInput=${e => setTitle(e.target.value)} />
675
+ <input class="inbox-input" placeholder="0 9 * * *" value=${cron} onInput=${e => setCron(e.target.value)} />
676
+ <textarea class="inbox-input inbox-sched-desc" placeholder=${t('inbox.schedDescPh')} value=${desc} onInput=${e => setDesc(e.target.value)}></textarea>
677
+ <button class="btn-primary btn-sm" disabled=${busy || !title.trim() || !cron.trim()} onClick=${create}>${t('inbox.schedCreate')}</button>
678
+ </div>
679
+ </div>`;
680
+ }
681
+
358
682
  export default function InboxTab({ showToast }) {
359
683
  const [requests, setRequests] = useState([]);
360
684
  const [conversations, setConversations] = useState([]);
@@ -365,14 +689,34 @@ export default function InboxTab({ showToast }) {
365
689
  const [composeSubject, setComposeSubject] = useState(''); // optional subject โ†’ opens a new topic thread
366
690
  const [mode, setMode] = useState('idle'); // 'idle' | 'compose' | 'thread'
367
691
  const [to, setTo] = useState('');
692
+ const [myAgents, setMyAgents] = useState([]); // the owner's own agents (recipient suggestions)
693
+ const [bcRecipients, setBcRecipients] = useState([]); // broadcast: selected recipient ids
694
+ const [bcInput, setBcInput] = useState(''); // broadcast: add-recipient field
695
+ const [bcMode, setBcMode] = useState('broadcast'); // broadcast: 'broadcast' | 'announcement'
696
+ const [bcGroupId, setBcGroupId] = useState(''); // broadcast: optional Share Group audience
697
+ const [bcType, setBcType] = useState('message'); // broadcast content: 'message' | 'poll'
698
+ const [bcQuestions, setBcQuestions] = useState([]); // poll: the questions being built
699
+ const [bcAudience, setBcAudience] = useState(''); // broadcast: '' | 'node-users' | 'federation-users' (operator)
700
+ const isOperator = (getSession()?.roles || []).includes('operator');
701
+ const [myGroups, setMyGroups] = useState([]); // the owner's Share Groups (audiences)
702
+ const [resultsId, setResultsId] = useState(null); // broadcast id whose results are shown
703
+ const [results, setResults] = useState(null); // fetched broadcast results
704
+ const [recentBroadcasts, setRecentBroadcasts] = useState([]); // localStorage-tracked sent broadcasts
368
705
  const [sending, setSending] = useState(false);
369
706
  const [important, setImportant] = useState(new Set()); // message ids flagged important (Tier 1)
370
707
  const [trackedList, setTrackedList] = useState([]); // active Tracked Responses (Tier 2)
371
708
  const [trackMsg, setTrackMsg] = useState(null); // message being tracked (opens modal)
372
- const [draftPrefill, setDraftPrefill] = useState(''); // suggested reply seeded into the composer
709
+ const [draftPrefill, setDraftPrefill] = useState(''); // suggested reply / filled command seeded into the composer
710
+ const [prefillNonce, setPrefillNonce] = useState(0); // bump to force a composer remount on each insert
711
+ const [agentCommands, setAgentCommands] = useState(null); // peer agent's chat.commands (Phase A)
712
+ const [cmdFill, setCmdFill] = useState(null); // a command being filled in (param form)
713
+ const [schedOpen, setSchedOpen] = useState(false); // schedule panel open (own agent, Phase B)
373
714
  const [replyingTrId, setReplyingTrId] = useState(null); // contract id whose approved reply is being sent
374
715
  const [awaitingDrafts, setAwaitingDrafts] = useState({}); // tr.id โ†’ suggested reply body (for the bubble)
375
716
  const msgsRef = useRef(null);
717
+ // Resolved attachment URLs cached per conversation, so a refresh / new message reuses them instead of
718
+ // re-resolving (re-downloading) every image. { convId, map: { attachmentId โ†’ presignedUrl } }.
719
+ const urlCacheRef = useRef({ convId: null, map: {} });
376
720
  // Ids the user just cancelled โ€” kept so a stale re-fetch (replica lag on a multi-node node) can't
377
721
  // re-add a row the user already dismissed until the cancel has propagated.
378
722
  const dismissedRef = useRef(new Set());
@@ -428,6 +772,15 @@ export default function InboxTab({ showToast }) {
428
772
  setTrackMsg(msg);
429
773
  };
430
774
 
775
+ // Clicking ๐Ÿ““: copy the message straight into the notebook for later processing (no AI step) โ€” keeps the
776
+ // source link + reply intent so it can be replied to or enriched/filed from the notebook later.
777
+ const onParkMsg = async (msg) => {
778
+ try {
779
+ await parkMessageToNotebook(msg, { title: firstLine(msg.body) });
780
+ showToast?.(t('inbox.parkedToNotebook'));
781
+ } catch (e) { showToast?.(e?.message || t('inbox.trackFailed'), true); }
782
+ };
783
+
431
784
  const cancelTracked = async (tr) => {
432
785
  let resp;
433
786
  try { resp = await tracked.cancelTrackedResponse(tr.id); }
@@ -490,10 +843,16 @@ export default function InboxTab({ showToast }) {
490
843
  if (!conv) return;
491
844
  const msgs = (await messages.getConversation(conv.conversationId).catch(() => [])).slice().reverse();
492
845
  setThread(msgs);
846
+ // REUSE already-resolved attachment URLs for the SAME conversation: a refresh / new message must NOT
847
+ // re-resolve (and thus re-download via a fresh presigned URL) every existing image. Only fetch URLs
848
+ // for attachments we haven't resolved yet. A different conversation starts a fresh cache.
849
+ const sameConv = urlCacheRef.current.convId === conv.conversationId;
850
+ const prev = sameConv ? urlCacheRef.current.map : {};
493
851
  const map = {};
494
852
  await Promise.all(msgs.flatMap(m => (m.attachments || [])
495
853
  .filter(a => !a.inline)
496
854
  .map(async a => {
855
+ if (prev[a.id]) { map[a.id] = prev[a.id]; return; } // keep the stable URL โ†’ browser won't re-fetch
497
856
  // Inbound: only the recipient's duplicated local copy is fetchable (after accept). Outbound:
498
857
  // the sender owns the original storage key, so resolve that โ€” you can view/download what you sent.
499
858
  const key = (a.mode === 'duplicate' && a.localKey) ? a.localKey
@@ -502,11 +861,74 @@ export default function InboxTab({ showToast }) {
502
861
  const u = await messages.attachmentUrl(key).catch(() => null);
503
862
  if (u) map[a.id] = u;
504
863
  })));
864
+ urlCacheRef.current = { convId: conv.conversationId, map };
505
865
  setUrlMap(map);
506
866
  if (markRead) await messages.markConversationRead(conv.conversationId).catch(() => {});
507
867
  }, []);
508
868
 
509
869
  useEffect(() => { loadLists(); }, [loadLists]);
870
+ // The owner's own agents โ€” recipient suggestions when composing (so you don't retype long GAIIs).
871
+ useEffect(() => { agentsSvc.listAgents().then(a => setMyAgents(a || [])).catch(() => {}); }, []);
872
+
873
+ // Phase A: when the active thread is with an AGENT, read its public `chat.commands` so the composer can
874
+ // offer fill-in command chips. Absent/empty key โ†’ no chips (graceful). Reset per-thread UI on switch.
875
+ const activePeer = activeConv?.peerGhii;
876
+ useEffect(() => {
877
+ setAgentCommands(null); setCmdFill(null); setSchedOpen(false);
878
+ if (!activePeer || !isAgentPeer(activePeer)) return;
879
+ let cancelled = false;
880
+ apiGet(`/v1/memory/${encodeURIComponent(activePeer)}/${encodeURIComponent('chat.commands')}`)
881
+ .then(r => {
882
+ const val = r?.data?.value;
883
+ const cmds = val && Array.isArray(val.commands) ? val.commands.filter(c => c && c.id) : [];
884
+ if (!cancelled && cmds.length) setAgentCommands(cmds);
885
+ })
886
+ .catch(() => { /* no commands published โ€” fine */ });
887
+ return () => { cancelled = true; };
888
+ }, [activePeer]);
889
+ // The owner's Share Groups โ€” reusable broadcast audiences (distribution lists).
890
+ useEffect(() => { apiGet('/v1/groups').then(r => setMyGroups(r?.data?.groups || [])).catch(() => {}); }, []);
891
+
892
+ // Recent broadcasts/polls the user sent โ€” tracked in localStorage so results stay re-accessible.
893
+ const BC_STORE = 'aimeat.inbox.broadcasts';
894
+ useEffect(() => { try { setRecentBroadcasts(JSON.parse(localStorage.getItem(BC_STORE) || '[]')); } catch { /* none */ } }, []);
895
+ const trackBroadcast = (entry) => setRecentBroadcasts(prev => {
896
+ const next = [entry, ...prev.filter(b => b.id !== entry.id)].slice(0, 30);
897
+ try { localStorage.setItem(BC_STORE, JSON.stringify(next)); } catch { /* quota */ }
898
+ return next;
899
+ });
900
+ const openResults = async (id) => {
901
+ setMode('results'); setResultsId(id); setResults(null); setActiveConv(null);
902
+ setResults(await messages.getBroadcastResults(id).catch(() => null));
903
+ };
904
+
905
+ // Inbox-link (mailto-style): /v1/profile?tab=messages&to=<id>[,<id>]&subject=<s> opens a prefilled
906
+ // compose (single recipient) or broadcast (multiple). Cleared from the URL so a refresh doesn't re-open.
907
+ useEffect(() => {
908
+ const params = new URLSearchParams(window.location.search);
909
+ const toParam = (params.get('to') || '').trim();
910
+ if (!toParam) return;
911
+ const recips = toParam.split(',').map(s => s.trim()).filter(Boolean);
912
+ const subject = params.get('subject') || '';
913
+ if (recips.length > 1) {
914
+ setMode('broadcast'); setActiveConv(null); setBcRecipients(recips); setBcMode('broadcast'); setBcType('message'); setBcGroupId('');
915
+ } else {
916
+ setMode('compose'); setActiveConv(null); setTo(recips[0]); setComposeSubject(subject);
917
+ }
918
+ try { window.history.replaceState({}, '', '/v1/profile?tab=messages'); } catch { /* noop */ }
919
+ }, []);
920
+
921
+ // Recipient suggestions for a new message: your own agents (GAIIs) + everyone you've a thread with.
922
+ const contactOptions = (() => {
923
+ const map = new Map();
924
+ for (const a of myAgents) {
925
+ if (a?.gaii) map.set(a.gaii, `${a.name || a.gaii} ${t('inbox.contactAgentSuffix')}`);
926
+ }
927
+ for (const c of conversations) {
928
+ if (c?.peerGhii && !map.has(c.peerGhii)) map.set(c.peerGhii, peerName(c.peerGhii));
929
+ }
930
+ return [...map.entries()].map(([id, label]) => ({ id, label }));
931
+ })();
510
932
 
511
933
  const liveRef = useRef(null);
512
934
  liveRef.current = () => { loadLists(); if (activeConv) loadThread(activeConv); };
@@ -550,6 +972,64 @@ export default function InboxTab({ showToast }) {
550
972
  loadLists();
551
973
  };
552
974
  const startCompose = () => { setMode('compose'); setActiveConv(null); setTo(''); setComposeSubject(''); };
975
+ const startBroadcast = () => { setMode('broadcast'); setActiveConv(null); setBcRecipients([]); setBcInput(''); setBcMode('broadcast'); setBcGroupId(''); setBcType('message'); setBcQuestions([]); setBcAudience(''); };
976
+ const addBcRecipient = (id) => {
977
+ const v = (id ?? bcInput).trim();
978
+ if (v && !bcRecipients.includes(v)) setBcRecipients([...bcRecipients, v]);
979
+ setBcInput('');
980
+ };
981
+ const removeBcRecipient = (id) => setBcRecipients(bcRecipients.filter(r => r !== id));
982
+
983
+ // Send one message to many: explicit recipients and/or a Share Group audience. doBroadcast is the
984
+ // Composer's onSend for the broadcast panel.
985
+ const doBroadcast = async (_recipient, text, files, reset) => {
986
+ if (sending) return;
987
+ const body = (text || '').trim();
988
+ if (bcRecipients.length === 0 && !bcGroupId && !bcAudience) { showToast?.(t('inbox.bcNoRecipients'), true); return; }
989
+
990
+ let interactive;
991
+ if (bcType === 'poll') {
992
+ const questions = bcQuestions
993
+ .map(q => ({ ...q, options: (q.options || []).filter(o => o.label.trim()) }))
994
+ .filter(q => q.prompt.trim() && q.options.length >= 1)
995
+ .map(q => ({
996
+ id: q.id, header: (q.header || q.prompt).slice(0, 80), prompt: q.prompt.trim(),
997
+ options: q.options.map(o => ({ id: o.id, label: o.label.trim() })),
998
+ multiSelect: !!q.multiSelect, allowOther: q.allowOther !== false, required: !!q.required,
999
+ }));
1000
+ if (!questions.length) { showToast?.(t('inbox.pollNeedQuestion'), true); return; }
1001
+ interactive = { role: 'questions', v: 1, questions };
1002
+ } else if (!body && files.length === 0) { return; }
1003
+
1004
+ setSending(true);
1005
+ try {
1006
+ const attachments = [];
1007
+ for (let i = 0; i < files.length; i++) {
1008
+ const desc = await messages.uploadAttachment(files[i]);
1009
+ attachments.push({ ...desc, inline: false, id: `at${i}` });
1010
+ }
1011
+ const resp = await messages.sendBroadcast({
1012
+ to: bcRecipients, groupId: bcGroupId || undefined, audience: bcAudience || undefined,
1013
+ mode: bcType === 'poll' ? 'broadcast' : bcMode, // a poll must be repliable (recipients answer)
1014
+ body, attachments, interactive,
1015
+ });
1016
+ if (resp?.ok === false) { showToast?.(resp?.error?.message || t('inbox.failed'), true); }
1017
+ else {
1018
+ reset?.();
1019
+ const id = resp?.data?.broadcast_id;
1020
+ const titleSrc = (bcType === 'poll' ? (interactive.questions[0]?.prompt || '') : body) || '';
1021
+ if (id) trackBroadcast({
1022
+ id, type: bcType,
1023
+ title: titleSrc.slice(0, 60) || t('inbox.broadcast'),
1024
+ createdAt: new Date().toISOString(),
1025
+ });
1026
+ showToast?.(`${t('inbox.bcSent')} (${resp?.data?.sent ?? 0})`);
1027
+ loadLists();
1028
+ if (id) openResults(id); else setMode('idle');
1029
+ }
1030
+ } catch { showToast?.(t('inbox.failed'), true); }
1031
+ setSending(false);
1032
+ };
553
1033
 
554
1034
  // Open the conversation for a tracked response, then (for awaiting-approval) seed the suggested reply.
555
1035
  const openTracked = async (tr) => {
@@ -589,6 +1069,35 @@ export default function InboxTab({ showToast }) {
589
1069
  if (activeConv?.peerGhii === contactId) { setActiveConv(null); setThread([]); setMode('idle'); }
590
1070
  };
591
1071
 
1072
+ // Submit answers to an interactive question: send a normal reply (so it threads + reads naturally on
1073
+ // any peer) carrying both a human-readable summary body AND the machine-readable answers payload.
1074
+ const submitInteractiveAnswers = async (questionMsg, answers) => {
1075
+ if (sending || !activeConv) return;
1076
+ setSending(true);
1077
+ try {
1078
+ const resp = await messages.send({
1079
+ to: activeConv.peerGhii, replyTo: questionMsg.id, conversationId: activeConv.conversationId,
1080
+ body: buildAnswerSummary(questionMsg.interactive, answers),
1081
+ interactive: { role: 'answers', v: 1, answersFor: questionMsg.id, answers },
1082
+ });
1083
+ if (resp?.ok === false) showToast?.(resp?.error?.message || t('inbox.failed'), true);
1084
+ else { await loadThread(activeConv); loadLists(); }
1085
+ } catch { showToast?.(t('inbox.failed'), true); }
1086
+ setSending(false);
1087
+ };
1088
+
1089
+ // Phase A: substitute a command's {{param}} placeholders and drop the resulting prose into the
1090
+ // composer (the human reviews + sends it). Bump the nonce so the composer remounts on every insert.
1091
+ const insertCommand = (cmd, values) => {
1092
+ let text = cmd.template || '';
1093
+ for (const p of (Array.isArray(cmd.params) ? cmd.params : [])) {
1094
+ const raw = values[p.name];
1095
+ const v = (raw == null || raw === '') ? (p.default ?? '') : raw;
1096
+ text = text.split(`{{${p.name}}}`).join(String(v));
1097
+ }
1098
+ setCmdFill(null); setDraftPrefill(text); setPrefillNonce(n => n + 1);
1099
+ };
1100
+
592
1101
  const doSend = async (recipient, text, files, reset) => {
593
1102
  if (sending) return;
594
1103
  const body = (text || '').trim();
@@ -600,9 +1109,12 @@ export default function InboxTab({ showToast }) {
600
1109
  const desc = await messages.uploadAttachment(files[i]);
601
1110
  attachments.push({ ...desc, inline: false, id: `at${i}` });
602
1111
  }
603
- // A subject (compose mode only) opens a new topic thread; replies stay in the active thread.
1112
+ // A subject (compose mode only) opens a new topic thread. When REPLYING in an open thread, pin the
1113
+ // send to that exact conversationId โ€” without it the server derives the default per-pair thread, so
1114
+ // a reply to a subject thread (e.g. "keskustelu") spawned a brand-new thread named after the agent.
604
1115
  const subject = (mode === 'compose' && composeSubject.trim()) ? composeSubject.trim() : undefined;
605
- const resp = await messages.send({ to: recipient, body, attachments, subject });
1116
+ const conversationId = (mode === 'thread' && activeConv) ? activeConv.conversationId : undefined;
1117
+ const resp = await messages.send({ to: recipient, body, attachments, subject, conversationId });
606
1118
  if (resp?.ok === false) { showToast?.(resp?.error?.message || t('inbox.failed'), true); }
607
1119
  else {
608
1120
  reset?.();
@@ -675,7 +1187,7 @@ export default function InboxTab({ showToast }) {
675
1187
  ${nested ? html`<span class="inbox-conv-subico">${icon}</span>` : html`<${Avatar} seed=${c.peerGhii} size=${40} />`}
676
1188
  <div class="inbox-conv-main">
677
1189
  <div class="inbox-conv-line1">
678
- <span class="inbox-name">${escHtml(label)} ${nested ? '' : html`<${PresenceDot} ghii=${c.peerGhii} />`}</span>
1190
+ <span class="inbox-name">${escHtml(label)} ${(!nested || c.peerGhii?.includes('#')) ? html`<${PresenceDot} ghii=${c.peerGhii} />` : ''}</span>
679
1191
  <span class="inbox-conv-time">${c.updatedAt ? timeShort(c.updatedAt) : ''}</span>
680
1192
  </div>
681
1193
  <div class="inbox-conv-line2">
@@ -689,6 +1201,19 @@ export default function InboxTab({ showToast }) {
689
1201
 
690
1202
  const renderThread = () => {
691
1203
  let lastDay = '';
1204
+ // Map each interactive QUESTION message id โ†’ the answers reply that fulfils it, so an answered
1205
+ // question renders its read-only summary instead of the (already-used) form.
1206
+ const answersByQ = {};
1207
+ for (const m of thread) {
1208
+ if (m.interactive?.role === 'answers' && m.interactive.answersFor) answersByQ[m.interactive.answersFor] = m.interactive;
1209
+ }
1210
+ // An announcement (a non-respondable broadcast) is read-only for the recipient โ€” hide the composer.
1211
+ const isAnnouncement = thread.some(m => m.direction === 'inbound' && m.respondable === false);
1212
+ // Agent capabilities in chat: command chips for any agent peer (public chat.commands); the schedule
1213
+ // panel only for the human's OWN agents (the scheduler routes resolve under the caller's owner).
1214
+ const peerIsAgent = isAgentPeer(activeConv.peerGhii);
1215
+ const peerAgentName = peerIsAgent ? subThreadLabel(activeConv.peerGhii) : null;
1216
+ const peerIsMyAgent = peerIsAgent && ownerDisplayName(ownerKeyOf(activeConv.peerGhii)) === getSession()?.owner;
692
1217
  return html`
693
1218
  <div class="inbox-panel">
694
1219
  <div class="inbox-thread-head">
@@ -698,6 +1223,8 @@ export default function InboxTab({ showToast }) {
698
1223
  ${activeConv.subject ? html`<div class="inbox-thread-subject">๐Ÿท ${escHtml(activeConv.subject)}</div>` : null}
699
1224
  <div class="inbox-sub">${escHtml(activeConv.peerGhii)}</div>
700
1225
  </div>
1226
+ ${peerIsMyAgent ? html`<button class=${`btn-ghost btn-sm inbox-sched-btn${schedOpen ? ' inbox-sched-btn--on' : ''}`}
1227
+ onClick=${() => setSchedOpen(o => !o)} title=${t('inbox.schedTitle')}>๐Ÿ“…</button>` : null}
701
1228
  </div>
702
1229
  <div class="inbox-msgs" ref=${msgsRef}>
703
1230
  ${thread.length === 0 ? html`<div class="inbox-empty-sm">${t('inbox.noThread')}</div>` : null}
@@ -707,7 +1234,9 @@ export default function InboxTab({ showToast }) {
707
1234
  return html`
708
1235
  ${showDay ? html`<div class="inbox-day" key=${'d' + m.id}><span>${dayLabel(m.createdAt)}</span></div>` : null}
709
1236
  <${MessageBubble} key=${m.id + m.direction} msg=${m} mine=${m.direction === 'outbound'} urlMap=${urlMap}
710
- starred=${important.has(m.id)} onStar=${toggleImportant} onTrack=${onTrackMsg} tracked=${trackedByMsg[m.id]}
1237
+ starred=${important.has(m.id)} onStar=${toggleImportant} onTrack=${onTrackMsg} onPark=${onParkMsg} tracked=${trackedByMsg[m.id]}
1238
+ answeredWith=${m.interactive?.role === 'questions' ? answersByQ[m.id] : null}
1239
+ onAnswer=${submitInteractiveAnswers} submitting=${sending}
711
1240
  onOpenMarkdown=${(url, name) => setMdViewer({ url, name })} />`;
712
1241
  })}
713
1242
  </div>
@@ -723,8 +1252,18 @@ export default function InboxTab({ showToast }) {
723
1252
  </div>
724
1253
  </div>
725
1254
  </div>`)}
726
- <${Composer} key=${'c-' + activeConv.conversationId + (draftPrefill ? '-d' : '')} recipient=${activeConv.peerGhii}
727
- sendLabel=${t('inbox.reply')} sending=${sending} onSend=${doSend} initialText=${draftPrefill} />
1255
+ ${peerIsMyAgent && schedOpen
1256
+ ? html`<${SchedulePanel} agentName=${peerAgentName} showToast=${showToast} onClose=${() => setSchedOpen(false)} />` : null}
1257
+ ${!isAnnouncement && cmdFill
1258
+ ? html`<${CommandFill} command=${cmdFill} onInsert=${insertCommand} onCancel=${() => setCmdFill(null)} />`
1259
+ : (!isAnnouncement && agentCommands
1260
+ ? html`<${CommandBar} commands=${agentCommands} onPick=${(c) =>
1261
+ (Array.isArray(c.params) && c.params.length) ? setCmdFill(c) : insertCommand(c, {})} />` : null)}
1262
+ ${isAnnouncement
1263
+ ? html`<div class="inbox-announce-note">๐Ÿ“ข ${t('inbox.announcementNote')}</div>`
1264
+ : html`<${Composer} key=${'c-' + activeConv.conversationId + (draftPrefill ? '-d' + prefillNonce : '')} recipient=${activeConv.peerGhii}
1265
+ sendLabel=${t('inbox.reply')} sending=${sending} onSend=${doSend} initialText=${draftPrefill}
1266
+ draftKey=${'aimeat.inbox.draft.' + activeConv.conversationId} />`}
728
1267
  </div>`;
729
1268
  };
730
1269
 
@@ -772,6 +1311,59 @@ export default function InboxTab({ showToast }) {
772
1311
  </div>
773
1312
  </div>`;
774
1313
 
1314
+ // Broadcast/poll results: a list of recent broadcasts, or the tallies for the selected one.
1315
+ const renderResults = () => {
1316
+ if (!resultsId) {
1317
+ return html`<div class="inbox-panel">
1318
+ <div class="inbox-thread-head"><div class="inbox-name">๐Ÿ“Š ${t('inbox.resultsTitle')}</div></div>
1319
+ <div class="inbox-tracked-list">
1320
+ ${recentBroadcasts.length === 0 ? html`<div class="inbox-empty-sm">${t('inbox.resultsEmpty')}</div>` : null}
1321
+ ${recentBroadcasts.map(b => html`
1322
+ <button class="inbox-conv" key=${b.id} onClick=${() => openResults(b.id)}>
1323
+ <div class="inbox-conv-main">
1324
+ <div class="inbox-conv-line1">
1325
+ <span class="inbox-name">${b.type === 'poll' ? '๐Ÿ“Š' : '๐Ÿ“จ'} ${escHtml(b.title)}</span>
1326
+ <span class="inbox-conv-time">${b.createdAt ? timeShort(b.createdAt) : ''}</span>
1327
+ </div>
1328
+ </div>
1329
+ </button>`)}
1330
+ </div>
1331
+ </div>`;
1332
+ }
1333
+ const r = results;
1334
+ const isPoll = r?.interactive?.role === 'questions';
1335
+ const tallies = isPoll ? tallyPoll(r.interactive, r.recipients || []) : [];
1336
+ return html`<div class="inbox-panel">
1337
+ <div class="inbox-thread-head">
1338
+ <button class="btn-ghost btn-sm" onClick=${() => { setResultsId(null); setResults(null); }}>โ†</button>
1339
+ <div class="inbox-name">๐Ÿ“Š ${t('inbox.resultsTitle')}</div>
1340
+ </div>
1341
+ <div class="inbox-msgs">
1342
+ ${!r ? html`<div class="inbox-empty-sm">โ€ฆ</div>` : html`
1343
+ <div class="inbox-results-summary">
1344
+ ${t('inbox.resultsRecipients')}: ${r.total} ยท ${t('inbox.resultsDelivered')}: ${r.delivered} ยท ${t('inbox.resultsRead')}: ${r.read}${isPoll ? ` ยท ${t('inbox.resultsAnswered')}: ${r.answered}` : ''}
1345
+ </div>
1346
+ ${tallies.map(({ q, counts, others }) => html`
1347
+ <div class="inbox-results-q" key=${q.id}>
1348
+ <div class="inbox-results-prompt">${escHtml(q.prompt)}</div>
1349
+ ${(q.options || []).map(o => {
1350
+ const n = counts[o.id] || 0;
1351
+ const pct = r.answered ? Math.round((n / r.answered) * 100) : 0;
1352
+ return html`<div class="inbox-results-bar" key=${o.id}>
1353
+ <div class="inbox-results-bar-label"><span>${escHtml(o.label)}</span><span>${n}</span></div>
1354
+ <div class="inbox-results-bar-track"><div class="inbox-results-bar-fill" style=${`--w:${pct}%`}></div></div>
1355
+ </div>`;
1356
+ })}
1357
+ ${others.length ? html`<div class="inbox-results-others">${t('inbox.answer.other')}: ${others.map(o => escHtml(o)).join(', ')}</div>` : null}
1358
+ </div>`)}
1359
+ ${!isPoll ? html`<div class="inbox-results-reclist">
1360
+ ${(r.recipients || []).map(rec => html`<div class="inbox-results-rec" key=${rec.recipient}><span>${escHtml(peerName(rec.recipient))}</span><span>${rec.status}</span></div>`)}
1361
+ </div>` : null}
1362
+ `}
1363
+ </div>
1364
+ </div>`;
1365
+ };
1366
+
775
1367
  return html`
776
1368
  <div class="inbox">
777
1369
  <div class="inbox-head">
@@ -784,9 +1376,14 @@ export default function InboxTab({ showToast }) {
784
1376
  title=${awaitingCount ? t('inbox.trackReady') : ''}>
785
1377
  ๐Ÿ”— ${t('inbox.trackedTitle')}${activeTracked.length ? html` <span class="inbox-count">${activeTracked.length}</span>` : ''}
786
1378
  </button>
1379
+ ${recentBroadcasts.length ? html`<button class=${`btn-outline${mode === 'results' ? ' btn-outline--active' : ''}`} onClick=${() => { setMode('results'); setResultsId(null); setActiveConv(null); }}>๐Ÿ“Š ${t('inbox.results')}</button>` : null}
1380
+ <button class=${`btn-outline${mode === 'broadcast' ? ' btn-outline--active' : ''}`} onClick=${startBroadcast}>๐Ÿ“ข ${t('inbox.broadcast')}</button>
787
1381
  <button class="btn-primary" onClick=${startCompose}>โœ‰๏ธ ${t('inbox.new')}</button>
788
1382
  </div>
789
1383
  </div>
1384
+ <datalist id="inbox-contact-suggest">
1385
+ ${contactOptions.map(c => html`<option value=${c.id} key=${c.id}>${c.label}</option>`)}
1386
+ </datalist>
790
1387
 
791
1388
  <div class="inbox-body">
792
1389
  ${renderList()}
@@ -796,14 +1393,64 @@ export default function InboxTab({ showToast }) {
796
1393
  <div class="inbox-thread-head"><div class="inbox-name">${t('inbox.new')}</div></div>
797
1394
  <div class="inbox-compose-fields">
798
1395
  <input class="inbox-input" type="text" placeholder=${t('inbox.toPlaceholder')}
799
- value=${to} onInput=${(e) => setTo(e.target.value)} />
1396
+ list="inbox-contact-suggest" value=${to} onInput=${(e) => setTo(e.target.value)} />
800
1397
  <input class="inbox-input" type="text" placeholder=${t('inbox.subjectPlaceholder')}
801
1398
  value=${composeSubject} onInput=${(e) => setComposeSubject(e.target.value)} />
802
1399
  </div>
803
1400
  <${Composer} key="c-new" recipient=${to.trim()} sendLabel=${t('inbox.send')}
804
- sending=${sending} onSend=${doSend} />
1401
+ sending=${sending} onSend=${doSend} draftKey="aimeat.inbox.draft.new" />
805
1402
  </div>` : null}
806
1403
 
1404
+ ${mode === 'broadcast' ? html`
1405
+ <div class="inbox-panel">
1406
+ <div class="inbox-thread-head"><div class="inbox-name">๐Ÿ“ข ${t('inbox.broadcastTitle')}</div></div>
1407
+ <div class="inbox-compose-fields">
1408
+ <div class="inbox-bc-mode">
1409
+ <label class=${`inbox-bc-modeopt${bcType === 'message' ? ' inbox-bc-modeopt--on' : ''}`}>
1410
+ <input type="radio" name="bctype" checked=${bcType === 'message'} onChange=${() => setBcType('message')} />
1411
+ <span>๐Ÿ“จ ${t('inbox.bcTypeMessage')}</span>
1412
+ </label>
1413
+ <label class=${`inbox-bc-modeopt${bcType === 'poll' ? ' inbox-bc-modeopt--on' : ''}`}>
1414
+ <input type="radio" name="bctype" checked=${bcType === 'poll'} onChange=${() => setBcType('poll')} />
1415
+ <span>๐Ÿ“Š ${t('inbox.bcTypePoll')}</span>
1416
+ </label>
1417
+ </div>
1418
+ ${bcType === 'message' ? html`<div class="inbox-bc-mode">
1419
+ <label class=${`inbox-bc-modeopt${bcMode === 'broadcast' ? ' inbox-bc-modeopt--on' : ''}`}>
1420
+ <input type="radio" name="bcmode" checked=${bcMode === 'broadcast'} onChange=${() => setBcMode('broadcast')} />
1421
+ <span>${t('inbox.bcModeBroadcast')}</span>
1422
+ </label>
1423
+ <label class=${`inbox-bc-modeopt${bcMode === 'announcement' ? ' inbox-bc-modeopt--on' : ''}`}>
1424
+ <input type="radio" name="bcmode" checked=${bcMode === 'announcement'} onChange=${() => setBcMode('announcement')} />
1425
+ <span>${t('inbox.bcModeAnnouncement')}</span>
1426
+ </label>
1427
+ </div>` : html`<${PollBuilder} questions=${bcQuestions} setQuestions=${setBcQuestions} />`}
1428
+ ${bcRecipients.length ? html`<div class="inbox-bc-chips">
1429
+ ${bcRecipients.map(r => html`<span class="inbox-bc-chip" key=${r}>${escHtml(peerName(r))}
1430
+ <button class="inbox-bc-chip-x" title=${t('inbox.bcRemove')} onClick=${() => removeBcRecipient(r)}>โœ•</button></span>`)}
1431
+ </div>` : null}
1432
+ <div class="inbox-bc-add">
1433
+ <input class="inbox-input" type="text" list="inbox-contact-suggest" placeholder=${t('inbox.bcAddPlaceholder')}
1434
+ value=${bcInput} onInput=${(e) => setBcInput(e.target.value)}
1435
+ onKeyDown=${(e) => { if (e.key === 'Enter') { e.preventDefault(); addBcRecipient(); } }} />
1436
+ <button class="btn-outline btn-sm" onClick=${() => addBcRecipient()}>${t('inbox.bcAdd')}</button>
1437
+ </div>
1438
+ ${myGroups.length ? html`<select class="inbox-input" value=${bcGroupId} onChange=${(e) => setBcGroupId(e.target.value)}>
1439
+ <option value="">${t('inbox.bcNoGroup')}</option>
1440
+ ${myGroups.map(g => html`<option value=${g.id} key=${g.id}>${escHtml(g.name)} (${(g.members || []).length})</option>`)}
1441
+ </select>` : null}
1442
+ ${isOperator ? html`<select class=${`inbox-input${bcAudience ? ' inbox-bc-audience--on' : ''}`} value=${bcAudience} onChange=${(e) => setBcAudience(e.target.value)}>
1443
+ <option value="">${t('inbox.bcNoAudience')}</option>
1444
+ <option value="node-users">๐Ÿ“ฃ ${t('inbox.bcNodeUsers')}</option>
1445
+ <option value="federation-users">๐ŸŒ ${t('inbox.bcFederationUsers')}</option>
1446
+ </select>` : null}
1447
+ </div>
1448
+ <${Composer} key="c-bc" recipient=${(bcRecipients.length || bcGroupId || bcAudience) ? 'bc' : ''}
1449
+ sendLabel=${bcType === 'poll' ? t('inbox.pollSend') : t('inbox.bcSend')} sending=${sending} onSend=${doBroadcast} />
1450
+ </div>` : null}
1451
+
1452
+ ${mode === 'results' ? renderResults() : null}
1453
+
807
1454
  ${mode === 'thread' && activeConv ? renderThread() : null}
808
1455
 
809
1456
  ${mode === 'tracked' ? renderTrackedList() : null}