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.
- package/dist/locales/en.json +246 -1
- package/dist/locales/fi.json +246 -1
- package/dist/public/components/InboxLink.js +29 -0
- package/dist/public/components/offer-card-view.js +87 -0
- package/dist/public/css/views/companies.css +65 -0
- package/dist/public/css/views/inbox.css +153 -1
- package/dist/public/css/views/members.css +33 -0
- package/dist/public/css/views/my-company.css +315 -0
- package/dist/public/css/views/profile.css +22 -0
- package/dist/public/js/services/messages.js +21 -3
- package/dist/public/js/services/notebook.js +16 -6
- package/dist/public/llms-template.txt +7 -0
- package/dist/public/spa.html +14 -0
- package/dist/public/views/companies.js +190 -0
- package/dist/public/views/members.js +42 -0
- package/dist/public/views/my-company.js +664 -0
- package/dist/public/views/portfolio-themes.js +77 -0
- package/dist/public/views/profile/agents-tab.js +4 -1
- package/dist/public/views/profile/discover-tab.js +118 -0
- package/dist/public/views/profile/inbox-tab.js +663 -16
- package/dist/public/views/profile/landing-page.js +2 -1
- package/dist/public/views/profile/notebook-card.js +11 -3
- package/dist/public/views/profile/offers-tab.js +5 -24
- package/dist/public/views/profile/organisms/home.js +5 -3
- package/dist/public/views/profile/organisms/mindmap.js +14 -3
- package/dist/public/views/profile/organisms/workspace.js +5 -2
- package/dist/public/views/profile/organisms-tab.js +12 -4
- package/dist/public/views/profile.js +2 -0
- package/dist/src/cli/connect/mcp/tools/core.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/core.js +34 -0
- package/dist/src/cli/connect/mcp/tools/core.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/dm-messages.d.ts +1 -0
- package/dist/src/cli/connect/mcp/tools/dm-messages.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/dm-messages.js +23 -0
- package/dist/src/cli/connect/mcp/tools/dm-messages.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/organisms.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/organisms.js +31 -0
- package/dist/src/cli/connect/mcp/tools/organisms.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/workspaces.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/workspaces.js +4 -0
- package/dist/src/cli/connect/mcp/tools/workspaces.js.map +1 -1
- package/dist/src/generated/api-types.d.ts +249 -0
- package/dist/src/generated/api-types.d.ts.map +1 -1
- package/dist/src/generated/prisma-postgres/edge.js +6 -3
- package/dist/src/generated/prisma-postgres/index-browser.js +3 -0
- package/dist/src/generated/prisma-postgres/index.d.ts +112 -1
- package/dist/src/generated/prisma-postgres/index.js +6 -3
- package/dist/src/generated/prisma-postgres/package.json +1 -1
- package/dist/src/generated/prisma-postgres/schema.prisma +4 -0
- package/dist/src/generated/prisma-postgres/wasm.js +6 -3
- package/dist/src/mcp/annotations.d.ts.map +1 -1
- package/dist/src/mcp/annotations.js +2 -0
- package/dist/src/mcp/annotations.js.map +1 -1
- package/dist/src/mcp/catalog/definitions.d.ts.map +1 -1
- package/dist/src/mcp/catalog/definitions.js +33 -1
- package/dist/src/mcp/catalog/definitions.js.map +1 -1
- package/dist/src/mcp/catalog/scopes.d.ts.map +1 -1
- package/dist/src/mcp/catalog/scopes.js +1 -0
- package/dist/src/mcp/catalog/scopes.js.map +1 -1
- package/dist/src/mcp/catalog/surfaces.d.ts.map +1 -1
- package/dist/src/mcp/catalog/surfaces.js +7 -4
- package/dist/src/mcp/catalog/surfaces.js.map +1 -1
- package/dist/src/mcp/core.d.ts.map +1 -1
- package/dist/src/mcp/core.js +49 -0
- package/dist/src/mcp/core.js.map +1 -1
- package/dist/src/mcp/dm-messages.d.ts +2 -0
- package/dist/src/mcp/dm-messages.d.ts.map +1 -1
- package/dist/src/mcp/dm-messages.js +45 -1
- package/dist/src/mcp/dm-messages.js.map +1 -1
- package/dist/src/models/message-schemas.d.ts +140 -2
- package/dist/src/models/message-schemas.d.ts.map +1 -1
- package/dist/src/models/message-schemas.js +60 -3
- package/dist/src/models/message-schemas.js.map +1 -1
- package/dist/src/routes/bootstrap.d.ts.map +1 -1
- package/dist/src/routes/bootstrap.js +3 -0
- package/dist/src/routes/bootstrap.js.map +1 -1
- package/dist/src/routes/discover.d.ts +22 -0
- package/dist/src/routes/discover.d.ts.map +1 -0
- package/dist/src/routes/discover.js +123 -0
- package/dist/src/routes/discover.js.map +1 -0
- package/dist/src/routes/federation-sync.d.ts.map +1 -1
- package/dist/src/routes/federation-sync.js +69 -2
- package/dist/src/routes/federation-sync.js.map +1 -1
- package/dist/src/routes/messages.d.ts.map +1 -1
- package/dist/src/routes/messages.js +102 -3
- package/dist/src/routes/messages.js.map +1 -1
- package/dist/src/routes/organisms.d.ts +3 -0
- package/dist/src/routes/organisms.d.ts.map +1 -1
- package/dist/src/routes/organisms.js +11 -32
- package/dist/src/routes/organisms.js.map +1 -1
- package/dist/src/routes/portal.d.ts.map +1 -1
- package/dist/src/routes/portal.js +3 -0
- package/dist/src/routes/portal.js.map +1 -1
- package/dist/src/routes/portfolio.d.ts.map +1 -1
- package/dist/src/routes/portfolio.js +30 -0
- package/dist/src/routes/portfolio.js.map +1 -1
- package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
- package/dist/src/server-bootstrap/routes-loader.js +2 -0
- package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
- package/dist/src/services/connect-tunnel.d.ts +3 -0
- package/dist/src/services/connect-tunnel.d.ts.map +1 -1
- package/dist/src/services/connect-tunnel.js +6 -0
- package/dist/src/services/connect-tunnel.js.map +1 -1
- package/dist/src/services/discovery/__tests__/discovery.test.d.ts +2 -0
- package/dist/src/services/discovery/__tests__/discovery.test.d.ts.map +1 -0
- package/dist/src/services/discovery/__tests__/discovery.test.js +178 -0
- package/dist/src/services/discovery/__tests__/discovery.test.js.map +1 -0
- package/dist/src/services/discovery/classify.d.ts +36 -0
- package/dist/src/services/discovery/classify.d.ts.map +1 -0
- package/dist/src/services/discovery/classify.js +61 -0
- package/dist/src/services/discovery/classify.js.map +1 -0
- package/dist/src/services/discovery/facets.d.ts +21 -0
- package/dist/src/services/discovery/facets.d.ts.map +1 -0
- package/dist/src/services/discovery/facets.js +0 -0
- package/dist/src/services/discovery/facets.js.map +1 -0
- package/dist/src/services/discovery/index.d.ts +22 -0
- package/dist/src/services/discovery/index.d.ts.map +1 -0
- package/dist/src/services/discovery/index.js +22 -0
- package/dist/src/services/discovery/index.js.map +1 -0
- package/dist/src/services/discovery/normalize.d.ts +25 -0
- package/dist/src/services/discovery/normalize.d.ts.map +1 -0
- package/dist/src/services/discovery/normalize.js +108 -0
- package/dist/src/services/discovery/normalize.js.map +1 -0
- package/dist/src/services/discovery/registry.d.ts +30 -0
- package/dist/src/services/discovery/registry.d.ts.map +1 -0
- package/dist/src/services/discovery/registry.js +31 -0
- package/dist/src/services/discovery/registry.js.map +1 -0
- package/dist/src/services/discovery/run.d.ts +23 -0
- package/dist/src/services/discovery/run.d.ts.map +1 -0
- package/dist/src/services/discovery/run.js +20 -0
- package/dist/src/services/discovery/run.js.map +1 -0
- package/dist/src/services/discovery/setup.d.ts +16 -0
- package/dist/src/services/discovery/setup.d.ts.map +1 -0
- package/dist/src/services/discovery/setup.js +12 -0
- package/dist/src/services/discovery/setup.js.map +1 -0
- package/dist/src/services/discovery/sources/memory-source.d.ts +24 -0
- package/dist/src/services/discovery/sources/memory-source.d.ts.map +1 -0
- package/dist/src/services/discovery/sources/memory-source.js +183 -0
- package/dist/src/services/discovery/sources/memory-source.js.map +1 -0
- package/dist/src/services/discovery/sources/table-sources.d.ts +27 -0
- package/dist/src/services/discovery/sources/table-sources.d.ts.map +1 -0
- package/dist/src/services/discovery/sources/table-sources.js +128 -0
- package/dist/src/services/discovery/sources/table-sources.js.map +1 -0
- package/dist/src/services/discovery/types.d.ts +117 -0
- package/dist/src/services/discovery/types.d.ts.map +1 -0
- package/dist/src/services/discovery/types.js +19 -0
- package/dist/src/services/discovery/types.js.map +1 -0
- package/dist/src/services/handbooks/agent.d.ts +1 -1
- package/dist/src/services/handbooks/agent.d.ts.map +1 -1
- package/dist/src/services/handbooks/agent.js +9 -3
- package/dist/src/services/handbooks/agent.js.map +1 -1
- package/dist/src/services/message-broadcast.d.ts +49 -0
- package/dist/src/services/message-broadcast.d.ts.map +1 -0
- package/dist/src/services/message-broadcast.js +116 -0
- package/dist/src/services/message-broadcast.js.map +1 -0
- package/dist/src/services/message-delivery.d.ts.map +1 -1
- package/dist/src/services/message-delivery.js +3 -0
- package/dist/src/services/message-delivery.js.map +1 -1
- package/dist/src/services/message-send.d.ts +9 -1
- package/dist/src/services/message-send.d.ts.map +1 -1
- package/dist/src/services/message-send.js +11 -5
- package/dist/src/services/message-send.js.map +1 -1
- package/dist/src/services/presence.d.ts +8 -0
- package/dist/src/services/presence.d.ts.map +1 -1
- package/dist/src/services/presence.js +38 -0
- package/dist/src/services/presence.js.map +1 -1
- package/dist/src/services/workspace-enrichment.d.ts +15 -5
- package/dist/src/services/workspace-enrichment.d.ts.map +1 -1
- package/dist/src/services/workspace-enrichment.js +52 -14
- package/dist/src/services/workspace-enrichment.js.map +1 -1
- package/dist/src/storage/interface.d.ts +49 -0
- package/dist/src/storage/interface.d.ts.map +1 -1
- package/dist/src/storage/providers/mongodb/index.d.ts +1 -0
- package/dist/src/storage/providers/mongodb/index.d.ts.map +1 -1
- package/dist/src/storage/providers/mongodb/index.js +16 -0
- package/dist/src/storage/providers/mongodb/index.js.map +1 -1
- package/dist/src/storage/providers/sqlite/index.d.ts +1 -0
- package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
- package/dist/src/storage/providers/sqlite/index.js +3 -0
- package/dist/src/storage/providers/sqlite/index.js.map +1 -1
- package/dist/src/storage/providers/sqlite/repos/direct-message.d.ts +3 -0
- package/dist/src/storage/providers/sqlite/repos/direct-message.d.ts.map +1 -1
- package/dist/src/storage/providers/sqlite/repos/direct-message.js +15 -2
- package/dist/src/storage/providers/sqlite/repos/direct-message.js.map +1 -1
- package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
- package/dist/src/storage/providers/sqlite/schema.js +10 -0
- package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
- package/dist/src/storage/repositories/direct-message.repository.d.ts +3 -0
- package/dist/src/storage/repositories/direct-message.repository.d.ts.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.postgres.prisma +4 -0
- 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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
727
|
-
|
|
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}
|