aimeat 1.28.0 → 1.30.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 +71 -2
- package/dist/locales/fi.json +71 -2
- package/dist/public/css/views/agents-detail.css +11 -0
- package/dist/public/css/views/inbox.css +62 -1
- package/dist/public/css/views/profile.css +87 -10
- package/dist/public/js/services/memory.js +62 -4
- package/dist/public/js/services/messages.js +4 -3
- package/dist/public/js/services/organisms.js +49 -3
- package/dist/public/js/services/workflows.js +4 -3
- package/dist/public/llms-template.txt +38 -1
- package/dist/public/spa.html +3 -0
- package/dist/public/views/command-palette.js +145 -0
- package/dist/public/views/profile/agents-tab.js +1 -0
- package/dist/public/views/profile/inbox-tab.js +175 -28
- package/dist/public/views/profile/knowledge-tab.js +6 -9
- package/dist/public/views/profile/landing-page.js +74 -29
- package/dist/public/views/profile/memory-tab.js +251 -26
- package/dist/public/views/profile/organisms/agents.js +6 -11
- package/dist/public/views/profile/organisms/home.js +1 -1
- package/dist/public/views/profile/organisms/members.js +9 -5
- package/dist/public/views/profile/organisms/mindmap.js +157 -59
- package/dist/public/views/profile/organisms/panels.js +36 -19
- package/dist/public/views/profile/organisms/timeline-panel.js +30 -1
- package/dist/public/views/profile/organisms/workspace-comments.js +15 -5
- package/dist/public/views/profile/organisms/workspace-list.js +35 -23
- package/dist/public/views/profile/organisms/workspace.js +166 -31
- package/dist/public/views/profile/organisms-tab.js +78 -5
- package/dist/public/views/profile/workflows-tab.js +6 -6
- package/dist/src/cli/connect/mcp/local-server.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/local-server.js +28 -0
- package/dist/src/cli/connect/mcp/local-server.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/dm-messages.d.ts +13 -0
- package/dist/src/cli/connect/mcp/tools/dm-messages.d.ts.map +1 -0
- package/dist/src/cli/connect/mcp/tools/dm-messages.js +62 -0
- package/dist/src/cli/connect/mcp/tools/dm-messages.js.map +1 -0
- package/dist/src/cli/connect/mcp/tools/index.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/index.js +2 -0
- package/dist/src/cli/connect/mcp/tools/index.js.map +1 -1
- package/dist/src/generated/api-types.d.ts +527 -12
- package/dist/src/generated/api-types.d.ts.map +1 -1
- package/dist/src/generated/prisma-postgres/edge.js +4 -3
- package/dist/src/generated/prisma-postgres/index-browser.js +1 -0
- package/dist/src/generated/prisma-postgres/index.d.ts +30 -1
- package/dist/src/generated/prisma-postgres/index.js +4 -3
- package/dist/src/generated/prisma-postgres/package.json +1 -1
- package/dist/src/generated/prisma-postgres/schema.prisma +1 -0
- package/dist/src/generated/prisma-postgres/wasm.js +4 -3
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcp/annotations.d.ts.map +1 -1
- package/dist/src/mcp/annotations.js +3 -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 -0
- 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 +6 -1
- 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 +1 -0
- package/dist/src/mcp/catalog/surfaces.js.map +1 -1
- package/dist/src/mcp/dm-messages.d.ts +20 -0
- package/dist/src/mcp/dm-messages.d.ts.map +1 -0
- package/dist/src/mcp/dm-messages.js +120 -0
- package/dist/src/mcp/dm-messages.js.map +1 -0
- package/dist/src/mcp/index.d.ts +2 -1
- package/dist/src/mcp/index.d.ts.map +1 -1
- package/dist/src/mcp/index.js +3 -1
- package/dist/src/mcp/index.js.map +1 -1
- package/dist/src/models/message-schemas.d.ts +3 -0
- package/dist/src/models/message-schemas.d.ts.map +1 -1
- package/dist/src/models/message-schemas.js +4 -0
- package/dist/src/models/message-schemas.js.map +1 -1
- package/dist/src/routes/agent-tasks.d.ts.map +1 -1
- package/dist/src/routes/agent-tasks.js +13 -0
- package/dist/src/routes/agent-tasks.js.map +1 -1
- package/dist/src/routes/app-grants.d.ts.map +1 -1
- package/dist/src/routes/app-grants.js +2 -0
- package/dist/src/routes/app-grants.js.map +1 -1
- package/dist/src/routes/bootstrap.js +1 -1
- package/dist/src/routes/bootstrap.js.map +1 -1
- package/dist/src/routes/catalogue.d.ts.map +1 -1
- package/dist/src/routes/catalogue.js +17 -8
- package/dist/src/routes/catalogue.js.map +1 -1
- package/dist/src/routes/federation-sync.d.ts.map +1 -1
- package/dist/src/routes/federation-sync.js +1 -0
- package/dist/src/routes/federation-sync.js.map +1 -1
- package/dist/src/routes/memory.d.ts +3 -0
- package/dist/src/routes/memory.d.ts.map +1 -1
- package/dist/src/routes/memory.js +242 -14
- package/dist/src/routes/memory.js.map +1 -1
- package/dist/src/routes/messages.d.ts.map +1 -1
- package/dist/src/routes/messages.js +22 -19
- package/dist/src/routes/messages.js.map +1 -1
- package/dist/src/routes/organisms.d.ts.map +1 -1
- package/dist/src/routes/organisms.js +321 -3
- package/dist/src/routes/organisms.js.map +1 -1
- package/dist/src/routes/public-stats.d.ts +2 -0
- package/dist/src/routes/public-stats.d.ts.map +1 -1
- package/dist/src/routes/public-stats.js +93 -104
- package/dist/src/routes/public-stats.js.map +1 -1
- package/dist/src/routes/stats.d.ts.map +1 -1
- package/dist/src/routes/stats.js +6 -0
- package/dist/src/routes/stats.js.map +1 -1
- package/dist/src/routes/usage.d.ts +17 -0
- package/dist/src/routes/usage.d.ts.map +1 -0
- package/dist/src/routes/usage.js +35 -0
- package/dist/src/routes/usage.js.map +1 -0
- package/dist/src/routes/wallet.d.ts +2 -0
- package/dist/src/routes/wallet.d.ts.map +1 -1
- package/dist/src/routes/wallet.js +13 -6
- package/dist/src/routes/wallet.js.map +1 -1
- package/dist/src/routes/workflows.d.ts.map +1 -1
- package/dist/src/routes/workflows.js +13 -2
- package/dist/src/routes/workflows.js.map +1 -1
- package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
- package/dist/src/server-bootstrap/routes-loader.js +18 -2
- package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
- package/dist/src/services/cache.d.ts +61 -0
- package/dist/src/services/cache.d.ts.map +1 -0
- package/dist/src/services/cache.js +143 -0
- package/dist/src/services/cache.js.map +1 -0
- package/dist/src/services/connect-tunnel.d.ts +11 -0
- package/dist/src/services/connect-tunnel.d.ts.map +1 -1
- package/dist/src/services/connect-tunnel.js +36 -0
- package/dist/src/services/connect-tunnel.js.map +1 -1
- package/dist/src/services/message-delivery.d.ts.map +1 -1
- package/dist/src/services/message-delivery.js +1 -0
- package/dist/src/services/message-delivery.js.map +1 -1
- package/dist/src/services/message-send.d.ts +13 -0
- package/dist/src/services/message-send.d.ts.map +1 -1
- package/dist/src/services/message-send.js +28 -5
- package/dist/src/services/message-send.js.map +1 -1
- package/dist/src/services/organism-search.d.ts +12 -7
- package/dist/src/services/organism-search.d.ts.map +1 -1
- package/dist/src/services/organism-search.js +93 -60
- package/dist/src/services/organism-search.js.map +1 -1
- package/dist/src/services/usage-summary.d.ts +77 -0
- package/dist/src/services/usage-summary.d.ts.map +1 -0
- package/dist/src/services/usage-summary.js +85 -0
- package/dist/src/services/usage-summary.js.map +1 -0
- package/dist/src/services/workspace-enrichment.d.ts +76 -0
- package/dist/src/services/workspace-enrichment.d.ts.map +1 -0
- package/dist/src/services/workspace-enrichment.js +144 -0
- package/dist/src/services/workspace-enrichment.js.map +1 -0
- package/dist/src/storage/interface.d.ts +5 -1
- package/dist/src/storage/interface.d.ts.map +1 -1
- package/dist/src/storage/providers/mongodb/index.d.ts +16 -0
- package/dist/src/storage/providers/mongodb/index.d.ts.map +1 -1
- package/dist/src/storage/providers/mongodb/index.js +51 -1
- package/dist/src/storage/providers/mongodb/index.js.map +1 -1
- package/dist/src/storage/providers/sqlite/index.d.ts +16 -0
- package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
- package/dist/src/storage/providers/sqlite/index.js +20 -2
- package/dist/src/storage/providers/sqlite/index.js.map +1 -1
- package/dist/src/storage/providers/sqlite/repos/direct-message.d.ts +15 -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 +28 -2
- package/dist/src/storage/providers/sqlite/repos/direct-message.js.map +1 -1
- package/dist/src/storage/providers/sqlite/repos/memory.d.ts +1 -0
- package/dist/src/storage/providers/sqlite/repos/memory.d.ts.map +1 -1
- package/dist/src/storage/providers/sqlite/repos/memory.js +4 -0
- package/dist/src/storage/providers/sqlite/repos/memory.js.map +1 -1
- package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
- package/dist/src/storage/providers/sqlite/schema.js +4 -0
- package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
- package/dist/src/storage/repositories/direct-message.repository.d.ts +20 -0
- package/dist/src/storage/repositories/direct-message.repository.d.ts.map +1 -1
- package/dist/src/storage/repositories/memory.repository.d.ts +1 -0
- package/dist/src/storage/repositories/memory.repository.d.ts.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.postgres.prisma +1 -0
- package/prisma/schema.prisma +1 -0
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* resolve gate approvals.
|
|
7
7
|
* @usage import * as orgService from '/js/services/organisms.js';
|
|
8
8
|
* @version-history
|
|
9
|
+
* v1.3.0 — 2026-06-22 — Batch endpoints: discoverWorkspaces({include:'enrichment'}) folds the
|
|
10
|
+
* per-workspace fan-out; new getWaiting() (home widget) and listCommentsBatch() (workspace comments).
|
|
9
11
|
* v1.2.1 — 2026-06-21 — Decode HTML entities in workspace names on read (listWorkspaces +
|
|
10
12
|
* discoverWorkspaces) so legacy double-escaped registry names (e.g. "STT & Voice") render
|
|
11
13
|
* as plain text in the list + breadcrumb.
|
|
@@ -38,6 +40,7 @@ export async function listOrganisms(opts = {}) {
|
|
|
38
40
|
if (opts.interest) params.set('interest', opts.interest);
|
|
39
41
|
if (opts.visibility) params.set('visibility', opts.visibility);
|
|
40
42
|
if (opts.member) params.set('member', opts.member);
|
|
43
|
+
if (opts.include) params.set('include', opts.include); // 'counts' → each org gains workspace_count
|
|
41
44
|
return apiGet(`/v1/organisms?${params.toString()}`);
|
|
42
45
|
}
|
|
43
46
|
|
|
@@ -90,12 +93,43 @@ export async function reviewJoinRequest(organismId, requestId, decision) {
|
|
|
90
93
|
return apiPost(`/v1/organisms/${encodeURIComponent(organismId)}/join-requests/${encodeURIComponent(requestId)}/review`, { decision });
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
/** "Waiting for you" — pending reviews + join-requests + invitations aggregated across the caller's
|
|
97
|
+
* member organisms in ONE request (replaces the home widget's per-org listApprovals/listJoinRequests/
|
|
98
|
+
* listWorkspaces fan-out). Returns the flat `items` array ({kind:'review'|'join'|'invite', …}). */
|
|
99
|
+
export async function getWaiting() {
|
|
100
|
+
try {
|
|
101
|
+
const resp = await apiGet('/v1/organisms/waiting');
|
|
102
|
+
return Array.isArray(resp?.data?.items) ? resp.data.items : [];
|
|
103
|
+
} catch { return []; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Agent activity aggregated across every readable workspace in ONE request (organism Agents tab).
|
|
107
|
+
* Returns { agentName: { count, lastAt, workspaces:[names] } } (replaces per-workspace activity fetches). */
|
|
108
|
+
export async function getAgentsActivity(orgId) {
|
|
109
|
+
try {
|
|
110
|
+
const resp = await apiGet(`/v1/organisms/${encodeURIComponent(orgId)}/agents/activity`);
|
|
111
|
+
return resp?.data?.agents || {};
|
|
112
|
+
} catch { return {}; }
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
/** List the comment thread on a workspace object (record or document). */
|
|
94
116
|
export async function listComments(orgId, ws, space, instanceId) {
|
|
95
117
|
const params = new URLSearchParams({ ws, space, instance_id: instanceId });
|
|
96
118
|
return apiGet(`/v1/organisms/${encodeURIComponent(orgId)}/comments?${params.toString()}`);
|
|
97
119
|
}
|
|
98
120
|
|
|
121
|
+
/** Composite key the comments-batch response is keyed by (NUL-joined, never collides with any id). */
|
|
122
|
+
export const commentBatchKey = (ws, space, instanceId) => `${ws}\u0000${space}\u0000${instanceId}`;
|
|
123
|
+
|
|
124
|
+
/** Fetch comment threads for MANY workspace objects in one POST (body, not URL params, so a long
|
|
125
|
+
* target list never bloats the URL). Returns the raw `{ "ws\0space\0id": {comments,total} }` map. */
|
|
126
|
+
export async function listCommentsBatch(orgId, instances) {
|
|
127
|
+
try {
|
|
128
|
+
const resp = await apiPost(`/v1/organisms/${encodeURIComponent(orgId)}/comments/batch`, { instances });
|
|
129
|
+
return (resp?.data?.comments) || {};
|
|
130
|
+
} catch { return {}; }
|
|
131
|
+
}
|
|
132
|
+
|
|
99
133
|
/** Add a comment to a workspace object. anchor: { section?|quote? }; parentId for a threaded reply. */
|
|
100
134
|
export async function addComment(orgId, { ws, space, instanceId, body, anchor, parentId }) {
|
|
101
135
|
return apiPost(`/v1/organisms/${encodeURIComponent(orgId)}/comments`, { ws, space, instance_id: instanceId, body, anchor, parent_id: parentId });
|
|
@@ -579,10 +613,13 @@ export async function saveWorkspaceRegistry(orgId, workspaces) {
|
|
|
579
613
|
* Organism membership lets you DISCOVER every workspace (names + creator + your access status);
|
|
580
614
|
* reading a workspace's content needs the creator's approval. ── */
|
|
581
615
|
|
|
582
|
-
/** Discover all workspaces in the org with your access status ('owner' | 'granted' | 'none').
|
|
583
|
-
|
|
616
|
+
/** Discover all workspaces in the org with your access status ('owner' | 'granted' | 'none').
|
|
617
|
+
* opts.include='enrichment' folds the per-workspace getWorkspace+activity+participants fan-out into
|
|
618
|
+
* one response: each readable row gains `enrichment:{hasManifest,recs,docs,lastEvent,participants,pendingReviews}`. */
|
|
619
|
+
export async function discoverWorkspaces(orgId, opts = {}) {
|
|
584
620
|
try {
|
|
585
|
-
const
|
|
621
|
+
const qs = opts.include ? `?include=${encodeURIComponent(opts.include)}` : '';
|
|
622
|
+
const resp = await apiGet(`/v1/organisms/${encodeURIComponent(orgId)}/workspaces${qs}`);
|
|
586
623
|
const list = Array.isArray(resp?.data?.workspaces) ? resp.data.workspaces : [];
|
|
587
624
|
// Names are plain text; legacy import paths stored them HTML-escaped. Decode for display.
|
|
588
625
|
return list.map(w => (w && w.name ? { ...w, name: decodeEntities(w.name) } : w));
|
|
@@ -615,6 +652,15 @@ export async function getWorkspaceAccess(orgId, ws) {
|
|
|
615
652
|
} catch { return { requests: [], members: [] }; }
|
|
616
653
|
}
|
|
617
654
|
|
|
655
|
+
/** Access rosters for ALL of my owned workspaces in one request (Members tab — replaces a per-owned-
|
|
656
|
+
* workspace getWorkspaceAccess fan-out). Returns [{ ws, name, members, requests }]. */
|
|
657
|
+
export async function getWorkspaceAccessAll(orgId) {
|
|
658
|
+
try {
|
|
659
|
+
const resp = await apiGet(`/v1/organisms/${encodeURIComponent(orgId)}/workspace-access?all=1`);
|
|
660
|
+
return Array.isArray(resp?.data?.workspaces) ? resp.data.workspaces : [];
|
|
661
|
+
} catch { return []; }
|
|
662
|
+
}
|
|
663
|
+
|
|
618
664
|
/** Directly add (or re-role) a member: role 'viewer' (read) | 'contributor' (read+write). grantee = an
|
|
619
665
|
* owner name, GHII, or GAII — the grant applies to that owner (so all their agents inherit it). */
|
|
620
666
|
export async function grantWorkspaceRole(orgId, ws, grantee, role) {
|
|
@@ -23,9 +23,10 @@ export async function getAgentOffers(agentName) {
|
|
|
23
23
|
return apiGet(`/v1/agents/${encodeURIComponent(agentName)}/offers`);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** List the owner's workflow definitions: { workflows, count }.
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
/** List the owner's workflow definitions: { workflows, count }. opts.include='health' attaches each
|
|
27
|
+
* workflow's run-health inline (avoids a per-workflow getHealth fan-out in the list view). */
|
|
28
|
+
export async function listWorkflows(opts = {}) {
|
|
29
|
+
return apiGet(`/v1/workflows${opts.include ? `?include=${encodeURIComponent(opts.include)}` : ''}`);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export async function getWorkflow(id) {
|
|
@@ -1059,7 +1059,11 @@ POST /v1/agents/me/telemetry
|
|
|
1059
1059
|
```
|
|
1060
1060
|
Types: `llm_call`, `tool_call`, `agent_report`
|
|
1061
1061
|
|
|
1062
|
-
### Messages — Send
|
|
1062
|
+
### Messages — Send (agent ↔ your owner)
|
|
1063
|
+
|
|
1064
|
+
This is the private dashboard channel between you and YOUR OWNER (task coordination, prompts). It is NOT
|
|
1065
|
+
federated and does not reach anyone else. To message other people/agents across the network, use the
|
|
1066
|
+
Federated Direct Messages below.
|
|
1063
1067
|
|
|
1064
1068
|
```
|
|
1065
1069
|
POST /v1/agents/me/messages
|
|
@@ -1070,6 +1074,39 @@ POST /v1/agents/me/messages
|
|
|
1070
1074
|
}
|
|
1071
1075
|
```
|
|
1072
1076
|
|
|
1077
|
+
### Federated Direct Messages (Inbox) — message anyone on the network
|
|
1078
|
+
|
|
1079
|
+
A separate, federation-wide messenger (the human "Postilaatikko"). You send FROM your own agent identity
|
|
1080
|
+
TO any person (`owner@node`), agent (`agent#owner@node`) or app (`eco:app#owner@node`), across nodes. The
|
|
1081
|
+
recipient sees the message is from you. First contact lands in their requests until they accept.
|
|
1082
|
+
|
|
1083
|
+
Requires scopes: `messages:send` (send), `messages:read` (read replies). MCP tools: `aimeat_dm_send`,
|
|
1084
|
+
`aimeat_dm_inbox`, `aimeat_dm_thread` — distinct from the `aimeat_message_*` owner-dashboard tools above.
|
|
1085
|
+
|
|
1086
|
+
Send (REST equivalent of `aimeat_dm_send`):
|
|
1087
|
+
```
|
|
1088
|
+
POST {{BASE_URL}}/v1/messages
|
|
1089
|
+
{
|
|
1090
|
+
"to": "alice@aimeat-fi-001", // or "claude#alice@aimeat-fi-001", or "eco:app#alice@aimeat-fi-001"
|
|
1091
|
+
"body": "Markdown supported.",
|
|
1092
|
+
"reply_to": "<message-id>", // optional — keep the same thread
|
|
1093
|
+
"subject": "Project Falcon", // optional — open a NEW topic thread (avoids one endless chat)
|
|
1094
|
+
"conversation_id": "<thread-id>", // optional — continue a specific existing thread
|
|
1095
|
+
"attachments": [ // optional — up to 20
|
|
1096
|
+
{ "storage_key": "<key>", "mime": "image/png", "kind": "image", "size": 2048, "name": "shot.png" }
|
|
1097
|
+
]
|
|
1098
|
+
}
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
Attachments travel via storage, NOT through MCP/the body: upload each file first (`aimeat_storage_upload`
|
|
1102
|
+
presigned, or `POST /v1/storage`), then pass the returned storage key(s) in `attachments`.
|
|
1103
|
+
|
|
1104
|
+
Read replies addressed to you:
|
|
1105
|
+
```
|
|
1106
|
+
GET {{BASE_URL}}/v1/messages/agent-inbox — recent DMs addressed to you (newest first)
|
|
1107
|
+
GET {{BASE_URL}}/v1/messages/agent-thread/{conversationId} — a full thread (your sent + received)
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1073
1110
|
### Onboarding — Confirm a step
|
|
1074
1111
|
|
|
1075
1112
|
```
|
package/dist/public/spa.html
CHANGED
|
@@ -240,6 +240,7 @@
|
|
|
240
240
|
"/views/profile/organisms/sources-panel.js": "/views/profile/organisms/sources-panel.js",
|
|
241
241
|
"/views/profile/organisms/schema-form.js": "/views/profile/organisms/schema-form.js",
|
|
242
242
|
"/views/profile/organisms/document.js": "/views/profile/organisms/document.js",
|
|
243
|
+
"/views/command-palette.js": "/views/command-palette.js",
|
|
243
244
|
"/views/profile/organisms/mindmap.js": "/views/profile/organisms/mindmap.js",
|
|
244
245
|
"/views/profile/organisms/readme-panel.js": "/views/profile/organisms/readme-panel.js",
|
|
245
246
|
"/views/profile/organisms/timeline-panel.js": "/views/profile/organisms/timeline-panel.js"
|
|
@@ -286,6 +287,7 @@
|
|
|
286
287
|
import { loadTranslations, t, getLocale, switchLocale, onLocaleChange } from '/js/i18n.js';
|
|
287
288
|
import { initThemeSystem, getTheme, toggleTheme, onThemeChange } from '/js/theme.js';
|
|
288
289
|
import { NotificationBell } from '/components/NotificationBell.js';
|
|
290
|
+
import { CommandPalette } from '/views/command-palette.js';
|
|
289
291
|
import { openAppSandboxed, isAppHtmlUrl } from '/js/app-sandbox.js';
|
|
290
292
|
// generateStars no longer needed for light theme
|
|
291
293
|
|
|
@@ -467,6 +469,7 @@
|
|
|
467
469
|
|
|
468
470
|
return html`
|
|
469
471
|
<${Header} navigate=${navigate} locale=${locale} />
|
|
472
|
+
<${CommandPalette} navigate=${navigate} />
|
|
470
473
|
<div class="page-content">
|
|
471
474
|
${viewError
|
|
472
475
|
? html`<div style="text-align:center; padding:3rem 1.5rem; color:var(--text-dim,#888);">
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file command-palette.js
|
|
3
|
+
* @description Global command palette / quick-switcher (Cmd/Ctrl-K) — Layer 2 of the search feature.
|
|
4
|
+
* Mounted once at the app shell so it works on any authenticated page. Empty query shows recents;
|
|
5
|
+
* typing runs the indexed cross-owner librarian search (GET /v1/librarian/search, own scope) plus a
|
|
6
|
+
* name match over the caller's organisms. Results are grouped (Organisms / content hits), keyboard-
|
|
7
|
+
* navigable (↑↓/Enter/Esc), and selecting one jumps to that organism/workspace — pre-filling the
|
|
8
|
+
* in-workspace search (Layer 1) with the same query so you land on the filtered list. Navigation
|
|
9
|
+
* hands off via sessionStorage (read by OrganismsTab on mount) + an `aimeat-open-organism` event
|
|
10
|
+
* (picked up if the tab is already mounted) + a route change to the organisms tab.
|
|
11
|
+
* @structure CommandPalette({ navigate })
|
|
12
|
+
* @usage import { CommandPalette } from '/views/command-palette.js'; html`<${CommandPalette} navigate=${navigate} />`
|
|
13
|
+
* @version-history
|
|
14
|
+
* v1.0.0 — 2026-06-22 — Initial: Cmd-K quick-switcher over librarian search + organism names + recents.
|
|
15
|
+
*/
|
|
16
|
+
import { h } from 'preact';
|
|
17
|
+
import { useState, useEffect, useRef, useCallback } from 'preact/hooks';
|
|
18
|
+
import htm from 'htm';
|
|
19
|
+
const html = htm.bind(h);
|
|
20
|
+
import { t } from '/js/i18n.js';
|
|
21
|
+
import { Modal } from '/components/Modal.js';
|
|
22
|
+
import { SearchBar } from '/components/SearchBar.js';
|
|
23
|
+
import { Spinner } from '/components/Spinner.js';
|
|
24
|
+
import { EmptyState } from '/components/EmptyState.js';
|
|
25
|
+
import { librarianSearch } from '/js/services/memory.js';
|
|
26
|
+
import { listOrganisms } from '/js/services/organisms.js';
|
|
27
|
+
import { getOwner } from '/js/services/auth.js';
|
|
28
|
+
import { listRecents } from '/js/recents.js';
|
|
29
|
+
|
|
30
|
+
/** Hand off to the organisms tab using the app's existing nav contract: prime the sessionStorage the
|
|
31
|
+
* OrganismsTab reads on mount (open id + workspace + an optional ws-search pre-fill), then switch to
|
|
32
|
+
* the Organisms tab via `aimeat-open-tab` (the LandingPage listener). Also fire `aimeat-open-organism`
|
|
33
|
+
* for an ALREADY-mounted OrganismsTab (it won't remount, so it can't re-read sessionStorage), and a
|
|
34
|
+
* route fallback for when we're not on /v1/profile at all. */
|
|
35
|
+
function openTarget(navigate, { orgId, wsId, query }) {
|
|
36
|
+
try {
|
|
37
|
+
if (orgId) sessionStorage.setItem('aimeat.ws.openId', orgId);
|
|
38
|
+
if (wsId) sessionStorage.setItem('aimeat.ws.openWs', wsId); else sessionStorage.removeItem('aimeat.ws.openWs');
|
|
39
|
+
if (orgId && wsId && query) sessionStorage.setItem(`aimeat.ws.${orgId}.${wsId}.search`, query);
|
|
40
|
+
} catch { /* private mode — the route fallback still works */ }
|
|
41
|
+
try {
|
|
42
|
+
window.dispatchEvent(new CustomEvent('aimeat-open-tab', { detail: { tabId: 'organisms' } }));
|
|
43
|
+
window.dispatchEvent(new CustomEvent('aimeat-open-organism', { detail: { orgId, wsId: wsId || null } }));
|
|
44
|
+
} catch { /* noop */ }
|
|
45
|
+
if (!location.pathname.startsWith('/v1/profile')) navigate('/v1/profile?tab=organisms');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function CommandPalette({ navigate }) {
|
|
49
|
+
const [open, setOpen] = useState(false);
|
|
50
|
+
const [q, setQ] = useState('');
|
|
51
|
+
const [hits, setHits] = useState(null); // librarian content hits
|
|
52
|
+
const [orgs, setOrgs] = useState([]); // caller's organisms (for name match)
|
|
53
|
+
const [busy, setBusy] = useState(false);
|
|
54
|
+
const [sel, setSel] = useState(0);
|
|
55
|
+
const inputRef = useRef(null);
|
|
56
|
+
|
|
57
|
+
// Global Cmd/Ctrl-K to open (ignore when typing in a field, except to toggle); Esc handled by Modal.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const onKey = (e) => {
|
|
60
|
+
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
setOpen(o => !o);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
document.addEventListener('keydown', onKey);
|
|
66
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
// On open: load recents + the caller's organisms (for name matching), focus the input.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!open) { setQ(''); setHits(null); setSel(0); return; }
|
|
72
|
+
const me = getOwner();
|
|
73
|
+
if (me) listOrganisms({ member: me }).then(r => setOrgs((r?.data?.organisms) || [])).catch(() => {});
|
|
74
|
+
setTimeout(() => inputRef.current?.querySelector('input')?.focus(), 30);
|
|
75
|
+
}, [open]);
|
|
76
|
+
|
|
77
|
+
// Debounced content search.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!open) return undefined;
|
|
80
|
+
const query = q.trim();
|
|
81
|
+
if (query.length < 2) { setHits(null); setBusy(false); return undefined; }
|
|
82
|
+
let cancelled = false;
|
|
83
|
+
setBusy(true);
|
|
84
|
+
const tid = setTimeout(async () => {
|
|
85
|
+
const r = await librarianSearch(query, 30, 'own').catch(() => []);
|
|
86
|
+
if (!cancelled) { setHits(r || []); setBusy(false); setSel(0); }
|
|
87
|
+
}, 200);
|
|
88
|
+
return () => { cancelled = true; clearTimeout(tid); };
|
|
89
|
+
}, [q, open]);
|
|
90
|
+
|
|
91
|
+
const orgName = useCallback((id) => orgs.find(o => o.id === id)?.name || id, [orgs]);
|
|
92
|
+
|
|
93
|
+
// Build a flat, selectable item list (with group headers derived for rendering).
|
|
94
|
+
const query = q.trim();
|
|
95
|
+
const orgMatches = query.length >= 2
|
|
96
|
+
? orgs.filter(o => (o.name || '').toLowerCase().includes(query.toLowerCase())).slice(0, 6)
|
|
97
|
+
.map(o => ({ kind: 'org', orgId: o.id, label: o.name, sub: t(`organisms.types.${o.type}`) || o.type }))
|
|
98
|
+
: [];
|
|
99
|
+
const contentItems = (hits || []).map(hh => ({
|
|
100
|
+
kind: 'hit', orgId: hh.organismId, wsId: hh.workspaceId, label: hh.title || hh.key,
|
|
101
|
+
sub: [orgName(hh.organismId), hh.workspaceId, hh.space].filter(Boolean).join(' · '), snippet: hh.snippet,
|
|
102
|
+
})).filter(it => it.orgId);
|
|
103
|
+
const recents = (!query) ? listRecents(8).filter(r => r.type === 'workspace' && r.data?.orgId)
|
|
104
|
+
.map(r => ({ kind: 'recent', orgId: r.data.orgId, wsId: r.data.wsId, label: r.label, sub: r.sub })) : [];
|
|
105
|
+
const items = [...orgMatches, ...contentItems, ...recents];
|
|
106
|
+
|
|
107
|
+
const choose = (it) => {
|
|
108
|
+
if (!it) return;
|
|
109
|
+
openTarget(navigate, { orgId: it.orgId, wsId: it.wsId, query: it.kind === 'hit' ? query : '' });
|
|
110
|
+
setOpen(false);
|
|
111
|
+
};
|
|
112
|
+
const onInputKey = (e) => {
|
|
113
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(s + 1, items.length - 1)); }
|
|
114
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(s - 1, 0)); }
|
|
115
|
+
else if (e.key === 'Enter') { e.preventDefault(); choose(items[sel]); }
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (!open) return null;
|
|
119
|
+
let idx = -1;
|
|
120
|
+
const row = (it) => { idx++; const i = idx; return html`
|
|
121
|
+
<button class=${'cmdk-item' + (i === sel ? ' is-sel' : '')} key=${it.kind + (it.orgId || '') + (it.wsId || '') + it.label}
|
|
122
|
+
onMouseEnter=${() => setSel(i)} onClick=${() => choose(it)}>
|
|
123
|
+
<span class="cmdk-item-label">${it.kind === 'org' ? '🏢 ' : it.kind === 'recent' ? '🕘 ' : '📄 '}${it.label}</span>
|
|
124
|
+
${it.sub ? html`<span class="cmdk-item-sub">${it.sub}</span>` : null}
|
|
125
|
+
${it.snippet ? html`<span class="cmdk-item-snippet">${it.snippet}</span>` : null}
|
|
126
|
+
</button>`; };
|
|
127
|
+
|
|
128
|
+
return html`
|
|
129
|
+
<${Modal} open=${open} onClose=${() => setOpen(false)} title=${t('search.paletteTitle') || 'Search everything'} className="cmdk-modal">
|
|
130
|
+
<div class="cmdk" ref=${inputRef} onKeyDown=${onInputKey}>
|
|
131
|
+
<${SearchBar} value=${q} onInput=${e => setQ(e.target.value)} placeholder=${t('search.palettePlaceholder') || 'Search organisms, workspaces, records…'} />
|
|
132
|
+
<div class="cmdk-hint section-desc">${t('search.openHint') || '↵ open · Esc close'}</div>
|
|
133
|
+
<div class="cmdk-list">
|
|
134
|
+
${busy && !items.length ? html`<${Spinner} text=${t('search.searching') || 'Searching…'} />` : null}
|
|
135
|
+
${!busy && query.length >= 2 && !items.length ? html`<${EmptyState} text=${t('search.noMatches') || 'No matches'} />` : null}
|
|
136
|
+
${orgMatches.length ? html`<div class="cmdk-group">${t('organisms.title') || 'Organisms'}</div>` : null}
|
|
137
|
+
${orgMatches.map(row)}
|
|
138
|
+
${contentItems.length ? html`<div class="cmdk-group">${t('search.results') || 'Results'}</div>` : null}
|
|
139
|
+
${contentItems.map(row)}
|
|
140
|
+
${recents.length ? html`<div class="cmdk-group">${t('search.recents') || 'Recent'}</div>` : null}
|
|
141
|
+
${recents.map(row)}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</${Modal}>`;
|
|
145
|
+
}
|
|
@@ -77,6 +77,7 @@ const SCOPE_DOMAINS = [
|
|
|
77
77
|
{ key: 'storage', permissions: ['read', 'write'] },
|
|
78
78
|
{ key: 'work', permissions: ['request', 'read', 'accept', 'publish'] },
|
|
79
79
|
{ key: 'social', permissions: ['read', 'write'] },
|
|
80
|
+
{ key: 'messages', permissions: ['send', 'read'] },
|
|
80
81
|
{ key: 'wallet', permissions: ['read'] },
|
|
81
82
|
{ key: 'consent', permissions: ['manage'] },
|
|
82
83
|
{ key: 'tunnel', permissions: ['connect'] },
|
|
@@ -85,6 +85,33 @@ function peerName(id) {
|
|
|
85
85
|
return beforeAt.includes('#') ? beforeAt.split('#').pop() : beforeAt;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// ── Grouping a person + their agents ──
|
|
89
|
+
// A peer is a human GHII (owner@node), an agent GAII (agent#owner@node) or an app GEAI
|
|
90
|
+
// (eco:app#owner@node). They all belong to ONE person — the owner — so we group conversations under
|
|
91
|
+
// the owner key and keep each thread separate beneath it.
|
|
92
|
+
function ownerKeyOf(ghii) {
|
|
93
|
+
const s = String(ghii || '');
|
|
94
|
+
const hash = s.indexOf('#'); // 'agent#owner@node' / 'eco:app#owner@node' → 'owner@node'
|
|
95
|
+
return hash >= 0 ? s.slice(hash + 1) : s; // human 'owner@node' → itself
|
|
96
|
+
}
|
|
97
|
+
function isAgentPeer(ghii) { return String(ghii).includes('#'); }
|
|
98
|
+
function ownerDisplayName(ownerKey) { return String(ownerKey).split('@')[0]; }
|
|
99
|
+
/** Label for one thread INSIDE a person group: the agent/app id before '#', or null for the human thread. */
|
|
100
|
+
function subThreadLabel(ghii) {
|
|
101
|
+
const at = String(ghii || '').split('@')[0];
|
|
102
|
+
return at.includes('#') ? at.split('#')[0] : null;
|
|
103
|
+
}
|
|
104
|
+
/** Group conversations by the owner behind each peer, preserving input (recency) order. */
|
|
105
|
+
function groupConversations(conversations) {
|
|
106
|
+
const map = new Map();
|
|
107
|
+
for (const c of conversations) {
|
|
108
|
+
const key = ownerKeyOf(c.peerGhii);
|
|
109
|
+
if (!map.has(key)) map.set(key, { ownerKey: key, convs: [] });
|
|
110
|
+
map.get(key).convs.push(c);
|
|
111
|
+
}
|
|
112
|
+
return [...map.values()];
|
|
113
|
+
}
|
|
114
|
+
|
|
88
115
|
function Avatar({ seed, size = 36 }) {
|
|
89
116
|
const svg = minidenticon(seed || 'user');
|
|
90
117
|
return html`<span class="inbox-avatar" style=${`width:${size}px;height:${size}px`}
|
|
@@ -134,7 +161,92 @@ function trackStateLabel(state) {
|
|
|
134
161
|
}
|
|
135
162
|
}
|
|
136
163
|
|
|
137
|
-
|
|
164
|
+
/** Classify an attachment for rendering: how to view it (thumbnail / native tab / markdown viewer). */
|
|
165
|
+
const ATTACH_ICO = { image: '🖼', pdf: '📄', markdown: '📄', audio: '🎵', video: '🎬', file: '📎' };
|
|
166
|
+
function attachKind(a) {
|
|
167
|
+
const mime = a.mime || '';
|
|
168
|
+
const name = a.name || a.storageKey || '';
|
|
169
|
+
if (a.kind === 'image' || /^image\//.test(mime)) return 'image';
|
|
170
|
+
if (mime === 'application/pdf' || /\.pdf$/i.test(name)) return 'pdf';
|
|
171
|
+
if (/markdown/.test(mime) || /\.(md|markdown|mdx)$/i.test(name)) return 'markdown';
|
|
172
|
+
if (a.kind === 'audio' || /^audio\//.test(mime)) return 'audio';
|
|
173
|
+
if (a.kind === 'video' || /^video\//.test(mime)) return 'video';
|
|
174
|
+
return 'file';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** One received/sent attachment. Images render as a thumbnail (click → full-size in a new tab);
|
|
178
|
+
* PDF/audio/video/file open natively in a new tab; markdown opens the in-app rendered viewer. Every
|
|
179
|
+
* ready attachment gets a download button. Not-yet-duplicated / expired attachments show their state. */
|
|
180
|
+
function AttachmentItem({ a, url, onOpenMarkdown }) {
|
|
181
|
+
const kind = attachKind(a);
|
|
182
|
+
const name = a.name || a.storageKey;
|
|
183
|
+
const ready = !!url && !a.expired;
|
|
184
|
+
|
|
185
|
+
if (!ready) {
|
|
186
|
+
const status = a.expired ? t('inbox.attachmentExpired') : (a.mode !== 'duplicate' ? t('inbox.attachmentPending') : null);
|
|
187
|
+
return html`<div class="inbox-attach-chip inbox-attach-chip--pending">
|
|
188
|
+
<span class="inbox-attach-ico">${ATTACH_ICO[kind]}</span>
|
|
189
|
+
<span class="inbox-attach-name">${escHtml(name)}</span>
|
|
190
|
+
${status ? html`<span class="inbox-attach-pending">${status}</span>` : null}
|
|
191
|
+
</div>`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const download = html`<a class="inbox-attach-dl" href=${url} download=${name} title=${t('inbox.attachmentDownload')}>⬇</a>`;
|
|
195
|
+
|
|
196
|
+
if (kind === 'image') {
|
|
197
|
+
return html`<div class="inbox-attach-item">
|
|
198
|
+
<a class="inbox-attach-thumb-link" href=${url} target="_blank" rel="noopener" title=${t('inbox.attachmentOpen')}>
|
|
199
|
+
<img class="inbox-attach-thumb" src=${url} alt=${escHtml(name)} loading="lazy" />
|
|
200
|
+
</a>
|
|
201
|
+
<div class="inbox-attach-cap"><span class="inbox-attach-name">${escHtml(name)}</span>${download}</div>
|
|
202
|
+
</div>`;
|
|
203
|
+
}
|
|
204
|
+
if (kind === 'markdown') {
|
|
205
|
+
return html`<div class="inbox-attach-chip">
|
|
206
|
+
<button class="inbox-attach-open" onClick=${() => onOpenMarkdown?.(url, name)} title=${t('inbox.attachmentView')}>
|
|
207
|
+
<span class="inbox-attach-ico">📄</span><span class="inbox-attach-name">${escHtml(name)}</span>
|
|
208
|
+
</button>${download}
|
|
209
|
+
</div>`;
|
|
210
|
+
}
|
|
211
|
+
// pdf / audio / video / file — let the browser open it in a new tab.
|
|
212
|
+
return html`<div class="inbox-attach-chip">
|
|
213
|
+
<a class="inbox-attach-open" href=${url} target="_blank" rel="noopener" title=${t('inbox.attachmentOpen')}>
|
|
214
|
+
<span class="inbox-attach-ico">${ATTACH_ICO[kind]}</span><span class="inbox-attach-name">${escHtml(name)}</span>
|
|
215
|
+
</a>${download}
|
|
216
|
+
</div>`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** In-app viewer for a markdown attachment — browsers don't render .md, so we fetch the (same-origin,
|
|
220
|
+
* presigned) file and render it with the shared safe Markdown component. Offers open-raw + download. */
|
|
221
|
+
function MarkdownViewer({ url, name, onClose }) {
|
|
222
|
+
const [text, setText] = useState(null);
|
|
223
|
+
const [failed, setFailed] = useState(false);
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
let alive = true;
|
|
226
|
+
fetch(url).then(r => r.ok ? r.text() : Promise.reject(new Error('http'))).then(tx => { if (alive) setText(tx); }).catch(() => { if (alive) setFailed(true); });
|
|
227
|
+
return () => { alive = false; };
|
|
228
|
+
}, [url]);
|
|
229
|
+
return html`
|
|
230
|
+
<div class="inbox-mdviewer-overlay" onClick=${onClose}>
|
|
231
|
+
<div class="inbox-mdviewer" onClick=${e => e.stopPropagation()}>
|
|
232
|
+
<div class="inbox-mdviewer-head">
|
|
233
|
+
<span class="inbox-mdviewer-title">📄 ${escHtml(name)}</span>
|
|
234
|
+
<div class="inbox-mdviewer-actions">
|
|
235
|
+
<a class="btn-ghost btn-sm" href=${url} target="_blank" rel="noopener">${t('inbox.attachmentOpenRaw')}</a>
|
|
236
|
+
<a class="btn-ghost btn-sm" href=${url} download=${name}>${t('inbox.attachmentDownload')}</a>
|
|
237
|
+
<button class="btn-ghost btn-sm" onClick=${onClose} title=${t('inbox.close')}>✕</button>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
<div class="inbox-mdviewer-body">
|
|
241
|
+
${failed ? html`<div class="inbox-empty-sm">${t('inbox.attachmentLoadError')}</div>`
|
|
242
|
+
: text === null ? html`<div class="inbox-empty-sm">…</div>`
|
|
243
|
+
: html`<${Markdown} text=${text} />`}
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
</div>`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function MessageBubble({ msg, mine, urlMap, starred, onStar, onTrack, tracked, onOpenMarkdown }) {
|
|
138
250
|
const nonInline = (msg.attachments || []).filter(a => !a.inline);
|
|
139
251
|
const expiredIds = new Set((msg.attachments || []).filter(a => a.expired).map(a => a.id));
|
|
140
252
|
const trk = tracked ? trackStateLabel(tracked.state) : null;
|
|
@@ -152,14 +264,7 @@ function MessageBubble({ msg, mine, urlMap, starred, onStar, onTrack, tracked })
|
|
|
152
264
|
<div class="inbox-bubble-body"><${Markdown} text=${prepareBody(msg.body, urlMap, expiredIds)} /></div>
|
|
153
265
|
${nonInline.length > 0 && html`
|
|
154
266
|
<div class="inbox-attach-row">
|
|
155
|
-
${nonInline.map(a => html
|
|
156
|
-
<a key=${a.id} class=${`inbox-attach-chip${(urlMap[a.id] && !a.expired) ? '' : ' inbox-attach-chip--pending'}`}
|
|
157
|
-
href=${(urlMap[a.id] && !a.expired) ? urlMap[a.id] : '#'} target="_blank" rel="noopener">
|
|
158
|
-
<span class="inbox-attach-ico">${a.kind === 'image' ? '🖼' : a.kind === 'audio' ? '🎵' : a.kind === 'video' ? '🎬' : '📎'}</span>
|
|
159
|
-
<span class="inbox-attach-name">${escHtml(a.name || a.storageKey)}</span>
|
|
160
|
-
${a.expired ? html`<span class="inbox-attach-pending">${t('inbox.attachmentExpired')}</span>`
|
|
161
|
-
: a.mode !== 'duplicate' ? html`<span class="inbox-attach-pending">${t('inbox.attachmentPending')}</span>` : null}
|
|
162
|
-
</a>`)}
|
|
267
|
+
${nonInline.map(a => html`<${AttachmentItem} key=${a.id} a=${a} url=${urlMap[a.id]} onOpenMarkdown=${onOpenMarkdown} />`)}
|
|
163
268
|
</div>`}
|
|
164
269
|
<div class="inbox-bubble-meta">
|
|
165
270
|
${trk ? html`<span class=${`inbox-track-badge inbox-track-badge--${trk.tone}`} title=${t('inbox.trackResponse')}>🔗 ${trk.text}</span>` : null}
|
|
@@ -256,6 +361,8 @@ export default function InboxTab({ showToast }) {
|
|
|
256
361
|
const [activeConv, setActiveConv] = useState(null); // { conversationId, peerGhii }
|
|
257
362
|
const [thread, setThread] = useState([]);
|
|
258
363
|
const [urlMap, setUrlMap] = useState({});
|
|
364
|
+
const [mdViewer, setMdViewer] = useState(null); // { url, name } — open markdown attachment viewer
|
|
365
|
+
const [composeSubject, setComposeSubject] = useState(''); // optional subject → opens a new topic thread
|
|
259
366
|
const [mode, setMode] = useState('idle'); // 'idle' | 'compose' | 'thread'
|
|
260
367
|
const [to, setTo] = useState('');
|
|
261
368
|
const [sending, setSending] = useState(false);
|
|
@@ -385,8 +492,16 @@ export default function InboxTab({ showToast }) {
|
|
|
385
492
|
setThread(msgs);
|
|
386
493
|
const map = {};
|
|
387
494
|
await Promise.all(msgs.flatMap(m => (m.attachments || [])
|
|
388
|
-
.filter(a => a.
|
|
389
|
-
.map(async a => {
|
|
495
|
+
.filter(a => !a.inline)
|
|
496
|
+
.map(async a => {
|
|
497
|
+
// Inbound: only the recipient's duplicated local copy is fetchable (after accept). Outbound:
|
|
498
|
+
// the sender owns the original storage key, so resolve that — you can view/download what you sent.
|
|
499
|
+
const key = (a.mode === 'duplicate' && a.localKey) ? a.localKey
|
|
500
|
+
: (m.direction === 'outbound' && a.storageKey) ? a.storageKey : null;
|
|
501
|
+
if (!key) return;
|
|
502
|
+
const u = await messages.attachmentUrl(key).catch(() => null);
|
|
503
|
+
if (u) map[a.id] = u;
|
|
504
|
+
})));
|
|
390
505
|
setUrlMap(map);
|
|
391
506
|
if (markRead) await messages.markConversationRead(conv.conversationId).catch(() => {});
|
|
392
507
|
}, []);
|
|
@@ -434,7 +549,7 @@ export default function InboxTab({ showToast }) {
|
|
|
434
549
|
await loadThread(conv, true); // mark read only on explicit open (avoids a refresh loop)
|
|
435
550
|
loadLists();
|
|
436
551
|
};
|
|
437
|
-
const startCompose = () => { setMode('compose'); setActiveConv(null); setTo(''); };
|
|
552
|
+
const startCompose = () => { setMode('compose'); setActiveConv(null); setTo(''); setComposeSubject(''); };
|
|
438
553
|
|
|
439
554
|
// Open the conversation for a tracked response, then (for awaiting-approval) seed the suggested reply.
|
|
440
555
|
const openTracked = async (tr) => {
|
|
@@ -485,10 +600,13 @@ export default function InboxTab({ showToast }) {
|
|
|
485
600
|
const desc = await messages.uploadAttachment(files[i]);
|
|
486
601
|
attachments.push({ ...desc, inline: false, id: `at${i}` });
|
|
487
602
|
}
|
|
488
|
-
|
|
603
|
+
// A subject (compose mode only) opens a new topic thread; replies stay in the active thread.
|
|
604
|
+
const subject = (mode === 'compose' && composeSubject.trim()) ? composeSubject.trim() : undefined;
|
|
605
|
+
const resp = await messages.send({ to: recipient, body, attachments, subject });
|
|
489
606
|
if (resp?.ok === false) { showToast?.(resp?.error?.message || t('inbox.failed'), true); }
|
|
490
607
|
else {
|
|
491
608
|
reset?.();
|
|
609
|
+
setComposeSubject('');
|
|
492
610
|
// If this send fulfils a Tracked Response awaiting approval, mark it replied.
|
|
493
611
|
if (replyingTrId) {
|
|
494
612
|
await tracked.markTrackedResponseReplied(replyingTrId, resp?.data?.message?.id).catch(() => {});
|
|
@@ -528,23 +646,47 @@ export default function InboxTab({ showToast }) {
|
|
|
528
646
|
|
|
529
647
|
<div class="inbox-list-section">${t('inbox.conversations')}</div>
|
|
530
648
|
${conversations.length === 0 ? html`<div class="inbox-empty-sm">${t('inbox.noConversations')}</div>` : null}
|
|
531
|
-
${conversations.map(
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
649
|
+
${groupConversations(conversations).map(g => {
|
|
650
|
+
// A person with a single human-only thread (no agents) renders flat, as before.
|
|
651
|
+
if (g.convs.length === 1 && !isAgentPeer(g.convs[0].peerGhii)) return convRow(g.convs[0], false);
|
|
652
|
+
// Otherwise: a group header for the person + a nested row per thread (human + each agent).
|
|
653
|
+
const unread = g.convs.reduce((n, c) => n + (c.unread || 0), 0);
|
|
654
|
+
return html`
|
|
655
|
+
<div class="inbox-conv-group" key=${g.ownerKey}>
|
|
656
|
+
<div class="inbox-conv-group-head">
|
|
657
|
+
<${Avatar} seed=${g.ownerKey} size=${28} />
|
|
658
|
+
<span class="inbox-name">${escHtml(ownerDisplayName(g.ownerKey))} <${PresenceDot} ghii=${g.ownerKey} /></span>
|
|
659
|
+
${unread > 0 ? html`<span class="inbox-conv-badge">${unread}</span>` : null}
|
|
539
660
|
</div>
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
</div>
|
|
544
|
-
</div>
|
|
545
|
-
</button>`)}
|
|
661
|
+
${g.convs.map(c => convRow(c, true))}
|
|
662
|
+
</div>`;
|
|
663
|
+
})}
|
|
546
664
|
</div>`;
|
|
547
665
|
|
|
666
|
+
/** One conversation row. `nested` = inside a person group (labelled by the agent/thread, indented). */
|
|
667
|
+
const convRow = (c, nested) => {
|
|
668
|
+
const active = activeConv?.conversationId === c.conversationId ? ' inbox-conv--active' : '';
|
|
669
|
+
const sub = subThreadLabel(c.peerGhii);
|
|
670
|
+
// Nested: a subject thread shows its topic; otherwise the agent name / "Direct". Flat: the person.
|
|
671
|
+
const label = nested ? (c.subject || sub || t('inbox.directThread')) : peerName(c.peerGhii);
|
|
672
|
+
const icon = sub ? '🤖' : (c.subject ? '🏷' : '💬');
|
|
673
|
+
return html`
|
|
674
|
+
<button class=${`inbox-conv${active}${nested ? ' inbox-conv--nested' : ''}`} key=${c.conversationId} onClick=${() => openConversation(c)}>
|
|
675
|
+
${nested ? html`<span class="inbox-conv-subico">${icon}</span>` : html`<${Avatar} seed=${c.peerGhii} size=${40} />`}
|
|
676
|
+
<div class="inbox-conv-main">
|
|
677
|
+
<div class="inbox-conv-line1">
|
|
678
|
+
<span class="inbox-name">${escHtml(label)} ${nested ? '' : html`<${PresenceDot} ghii=${c.peerGhii} />`}</span>
|
|
679
|
+
<span class="inbox-conv-time">${c.updatedAt ? timeShort(c.updatedAt) : ''}</span>
|
|
680
|
+
</div>
|
|
681
|
+
<div class="inbox-conv-line2">
|
|
682
|
+
${(!nested && c.subject) ? html`<span class="inbox-conv-subject">🏷 ${escHtml(c.subject)}</span>` : ''}
|
|
683
|
+
<span class="inbox-conv-preview">${c.lastDirection === 'outbound' ? `${t('inbox.youPrefix')} ` : ''}${escHtml(c.lastMessage || '')}</span>
|
|
684
|
+
${c.unread > 0 ? html`<span class="inbox-conv-badge">${c.unread}</span>` : null}
|
|
685
|
+
</div>
|
|
686
|
+
</div>
|
|
687
|
+
</button>`;
|
|
688
|
+
};
|
|
689
|
+
|
|
548
690
|
const renderThread = () => {
|
|
549
691
|
let lastDay = '';
|
|
550
692
|
return html`
|
|
@@ -553,6 +695,7 @@ export default function InboxTab({ showToast }) {
|
|
|
553
695
|
<${Avatar} seed=${activeConv.peerGhii} size=${36} />
|
|
554
696
|
<div class="inbox-thread-id">
|
|
555
697
|
<div class="inbox-name">${escHtml(peerName(activeConv.peerGhii))} <${PresenceDot} ghii=${activeConv.peerGhii} label=${true} /></div>
|
|
698
|
+
${activeConv.subject ? html`<div class="inbox-thread-subject">🏷 ${escHtml(activeConv.subject)}</div>` : null}
|
|
556
699
|
<div class="inbox-sub">${escHtml(activeConv.peerGhii)}</div>
|
|
557
700
|
</div>
|
|
558
701
|
</div>
|
|
@@ -564,7 +707,8 @@ export default function InboxTab({ showToast }) {
|
|
|
564
707
|
return html`
|
|
565
708
|
${showDay ? html`<div class="inbox-day" key=${'d' + m.id}><span>${dayLabel(m.createdAt)}</span></div>` : null}
|
|
566
709
|
<${MessageBubble} key=${m.id + m.direction} msg=${m} mine=${m.direction === 'outbound'} urlMap=${urlMap}
|
|
567
|
-
starred=${important.has(m.id)} onStar=${toggleImportant} onTrack=${onTrackMsg} tracked=${trackedByMsg[m.id]}
|
|
710
|
+
starred=${important.has(m.id)} onStar=${toggleImportant} onTrack=${onTrackMsg} tracked=${trackedByMsg[m.id]}
|
|
711
|
+
onOpenMarkdown=${(url, name) => setMdViewer({ url, name })} />`;
|
|
568
712
|
})}
|
|
569
713
|
</div>
|
|
570
714
|
${awaitingForConv.map(tr => html`
|
|
@@ -653,6 +797,8 @@ export default function InboxTab({ showToast }) {
|
|
|
653
797
|
<div class="inbox-compose-fields">
|
|
654
798
|
<input class="inbox-input" type="text" placeholder=${t('inbox.toPlaceholder')}
|
|
655
799
|
value=${to} onInput=${(e) => setTo(e.target.value)} />
|
|
800
|
+
<input class="inbox-input" type="text" placeholder=${t('inbox.subjectPlaceholder')}
|
|
801
|
+
value=${composeSubject} onInput=${(e) => setComposeSubject(e.target.value)} />
|
|
656
802
|
</div>
|
|
657
803
|
<${Composer} key="c-new" recipient=${to.trim()} sendLabel=${t('inbox.send')}
|
|
658
804
|
sending=${sending} onSend=${doSend} />
|
|
@@ -673,5 +819,6 @@ export default function InboxTab({ showToast }) {
|
|
|
673
819
|
|
|
674
820
|
<${TrackResponseModal} open=${!!trackMsg} msg=${trackMsg}
|
|
675
821
|
onClose=${() => setTrackMsg(null)} onDone=${loadLists} showToast=${showToast} />
|
|
822
|
+
${mdViewer && html`<${MarkdownViewer} url=${mdViewer.url} name=${mdViewer.name} onClose=${() => setMdViewer(null)} />`}
|
|
676
823
|
</div>`;
|
|
677
824
|
}
|