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.
Files changed (173) hide show
  1. package/dist/locales/en.json +71 -2
  2. package/dist/locales/fi.json +71 -2
  3. package/dist/public/css/views/agents-detail.css +11 -0
  4. package/dist/public/css/views/inbox.css +62 -1
  5. package/dist/public/css/views/profile.css +87 -10
  6. package/dist/public/js/services/memory.js +62 -4
  7. package/dist/public/js/services/messages.js +4 -3
  8. package/dist/public/js/services/organisms.js +49 -3
  9. package/dist/public/js/services/workflows.js +4 -3
  10. package/dist/public/llms-template.txt +38 -1
  11. package/dist/public/spa.html +3 -0
  12. package/dist/public/views/command-palette.js +145 -0
  13. package/dist/public/views/profile/agents-tab.js +1 -0
  14. package/dist/public/views/profile/inbox-tab.js +175 -28
  15. package/dist/public/views/profile/knowledge-tab.js +6 -9
  16. package/dist/public/views/profile/landing-page.js +74 -29
  17. package/dist/public/views/profile/memory-tab.js +251 -26
  18. package/dist/public/views/profile/organisms/agents.js +6 -11
  19. package/dist/public/views/profile/organisms/home.js +1 -1
  20. package/dist/public/views/profile/organisms/members.js +9 -5
  21. package/dist/public/views/profile/organisms/mindmap.js +157 -59
  22. package/dist/public/views/profile/organisms/panels.js +36 -19
  23. package/dist/public/views/profile/organisms/timeline-panel.js +30 -1
  24. package/dist/public/views/profile/organisms/workspace-comments.js +15 -5
  25. package/dist/public/views/profile/organisms/workspace-list.js +35 -23
  26. package/dist/public/views/profile/organisms/workspace.js +166 -31
  27. package/dist/public/views/profile/organisms-tab.js +78 -5
  28. package/dist/public/views/profile/workflows-tab.js +6 -6
  29. package/dist/src/cli/connect/mcp/local-server.d.ts.map +1 -1
  30. package/dist/src/cli/connect/mcp/local-server.js +28 -0
  31. package/dist/src/cli/connect/mcp/local-server.js.map +1 -1
  32. package/dist/src/cli/connect/mcp/tools/dm-messages.d.ts +13 -0
  33. package/dist/src/cli/connect/mcp/tools/dm-messages.d.ts.map +1 -0
  34. package/dist/src/cli/connect/mcp/tools/dm-messages.js +62 -0
  35. package/dist/src/cli/connect/mcp/tools/dm-messages.js.map +1 -0
  36. package/dist/src/cli/connect/mcp/tools/index.d.ts.map +1 -1
  37. package/dist/src/cli/connect/mcp/tools/index.js +2 -0
  38. package/dist/src/cli/connect/mcp/tools/index.js.map +1 -1
  39. package/dist/src/generated/api-types.d.ts +527 -12
  40. package/dist/src/generated/api-types.d.ts.map +1 -1
  41. package/dist/src/generated/prisma-postgres/edge.js +4 -3
  42. package/dist/src/generated/prisma-postgres/index-browser.js +1 -0
  43. package/dist/src/generated/prisma-postgres/index.d.ts +30 -1
  44. package/dist/src/generated/prisma-postgres/index.js +4 -3
  45. package/dist/src/generated/prisma-postgres/package.json +1 -1
  46. package/dist/src/generated/prisma-postgres/schema.prisma +1 -0
  47. package/dist/src/generated/prisma-postgres/wasm.js +4 -3
  48. package/dist/src/index.js +2 -0
  49. package/dist/src/index.js.map +1 -1
  50. package/dist/src/mcp/annotations.d.ts.map +1 -1
  51. package/dist/src/mcp/annotations.js +3 -0
  52. package/dist/src/mcp/annotations.js.map +1 -1
  53. package/dist/src/mcp/catalog/definitions.d.ts.map +1 -1
  54. package/dist/src/mcp/catalog/definitions.js +33 -0
  55. package/dist/src/mcp/catalog/definitions.js.map +1 -1
  56. package/dist/src/mcp/catalog/scopes.d.ts.map +1 -1
  57. package/dist/src/mcp/catalog/scopes.js +6 -1
  58. package/dist/src/mcp/catalog/scopes.js.map +1 -1
  59. package/dist/src/mcp/catalog/surfaces.d.ts.map +1 -1
  60. package/dist/src/mcp/catalog/surfaces.js +1 -0
  61. package/dist/src/mcp/catalog/surfaces.js.map +1 -1
  62. package/dist/src/mcp/dm-messages.d.ts +20 -0
  63. package/dist/src/mcp/dm-messages.d.ts.map +1 -0
  64. package/dist/src/mcp/dm-messages.js +120 -0
  65. package/dist/src/mcp/dm-messages.js.map +1 -0
  66. package/dist/src/mcp/index.d.ts +2 -1
  67. package/dist/src/mcp/index.d.ts.map +1 -1
  68. package/dist/src/mcp/index.js +3 -1
  69. package/dist/src/mcp/index.js.map +1 -1
  70. package/dist/src/models/message-schemas.d.ts +3 -0
  71. package/dist/src/models/message-schemas.d.ts.map +1 -1
  72. package/dist/src/models/message-schemas.js +4 -0
  73. package/dist/src/models/message-schemas.js.map +1 -1
  74. package/dist/src/routes/agent-tasks.d.ts.map +1 -1
  75. package/dist/src/routes/agent-tasks.js +13 -0
  76. package/dist/src/routes/agent-tasks.js.map +1 -1
  77. package/dist/src/routes/app-grants.d.ts.map +1 -1
  78. package/dist/src/routes/app-grants.js +2 -0
  79. package/dist/src/routes/app-grants.js.map +1 -1
  80. package/dist/src/routes/bootstrap.js +1 -1
  81. package/dist/src/routes/bootstrap.js.map +1 -1
  82. package/dist/src/routes/catalogue.d.ts.map +1 -1
  83. package/dist/src/routes/catalogue.js +17 -8
  84. package/dist/src/routes/catalogue.js.map +1 -1
  85. package/dist/src/routes/federation-sync.d.ts.map +1 -1
  86. package/dist/src/routes/federation-sync.js +1 -0
  87. package/dist/src/routes/federation-sync.js.map +1 -1
  88. package/dist/src/routes/memory.d.ts +3 -0
  89. package/dist/src/routes/memory.d.ts.map +1 -1
  90. package/dist/src/routes/memory.js +242 -14
  91. package/dist/src/routes/memory.js.map +1 -1
  92. package/dist/src/routes/messages.d.ts.map +1 -1
  93. package/dist/src/routes/messages.js +22 -19
  94. package/dist/src/routes/messages.js.map +1 -1
  95. package/dist/src/routes/organisms.d.ts.map +1 -1
  96. package/dist/src/routes/organisms.js +321 -3
  97. package/dist/src/routes/organisms.js.map +1 -1
  98. package/dist/src/routes/public-stats.d.ts +2 -0
  99. package/dist/src/routes/public-stats.d.ts.map +1 -1
  100. package/dist/src/routes/public-stats.js +93 -104
  101. package/dist/src/routes/public-stats.js.map +1 -1
  102. package/dist/src/routes/stats.d.ts.map +1 -1
  103. package/dist/src/routes/stats.js +6 -0
  104. package/dist/src/routes/stats.js.map +1 -1
  105. package/dist/src/routes/usage.d.ts +17 -0
  106. package/dist/src/routes/usage.d.ts.map +1 -0
  107. package/dist/src/routes/usage.js +35 -0
  108. package/dist/src/routes/usage.js.map +1 -0
  109. package/dist/src/routes/wallet.d.ts +2 -0
  110. package/dist/src/routes/wallet.d.ts.map +1 -1
  111. package/dist/src/routes/wallet.js +13 -6
  112. package/dist/src/routes/wallet.js.map +1 -1
  113. package/dist/src/routes/workflows.d.ts.map +1 -1
  114. package/dist/src/routes/workflows.js +13 -2
  115. package/dist/src/routes/workflows.js.map +1 -1
  116. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  117. package/dist/src/server-bootstrap/routes-loader.js +18 -2
  118. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  119. package/dist/src/services/cache.d.ts +61 -0
  120. package/dist/src/services/cache.d.ts.map +1 -0
  121. package/dist/src/services/cache.js +143 -0
  122. package/dist/src/services/cache.js.map +1 -0
  123. package/dist/src/services/connect-tunnel.d.ts +11 -0
  124. package/dist/src/services/connect-tunnel.d.ts.map +1 -1
  125. package/dist/src/services/connect-tunnel.js +36 -0
  126. package/dist/src/services/connect-tunnel.js.map +1 -1
  127. package/dist/src/services/message-delivery.d.ts.map +1 -1
  128. package/dist/src/services/message-delivery.js +1 -0
  129. package/dist/src/services/message-delivery.js.map +1 -1
  130. package/dist/src/services/message-send.d.ts +13 -0
  131. package/dist/src/services/message-send.d.ts.map +1 -1
  132. package/dist/src/services/message-send.js +28 -5
  133. package/dist/src/services/message-send.js.map +1 -1
  134. package/dist/src/services/organism-search.d.ts +12 -7
  135. package/dist/src/services/organism-search.d.ts.map +1 -1
  136. package/dist/src/services/organism-search.js +93 -60
  137. package/dist/src/services/organism-search.js.map +1 -1
  138. package/dist/src/services/usage-summary.d.ts +77 -0
  139. package/dist/src/services/usage-summary.d.ts.map +1 -0
  140. package/dist/src/services/usage-summary.js +85 -0
  141. package/dist/src/services/usage-summary.js.map +1 -0
  142. package/dist/src/services/workspace-enrichment.d.ts +76 -0
  143. package/dist/src/services/workspace-enrichment.d.ts.map +1 -0
  144. package/dist/src/services/workspace-enrichment.js +144 -0
  145. package/dist/src/services/workspace-enrichment.js.map +1 -0
  146. package/dist/src/storage/interface.d.ts +5 -1
  147. package/dist/src/storage/interface.d.ts.map +1 -1
  148. package/dist/src/storage/providers/mongodb/index.d.ts +16 -0
  149. package/dist/src/storage/providers/mongodb/index.d.ts.map +1 -1
  150. package/dist/src/storage/providers/mongodb/index.js +51 -1
  151. package/dist/src/storage/providers/mongodb/index.js.map +1 -1
  152. package/dist/src/storage/providers/sqlite/index.d.ts +16 -0
  153. package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
  154. package/dist/src/storage/providers/sqlite/index.js +20 -2
  155. package/dist/src/storage/providers/sqlite/index.js.map +1 -1
  156. package/dist/src/storage/providers/sqlite/repos/direct-message.d.ts +15 -0
  157. package/dist/src/storage/providers/sqlite/repos/direct-message.d.ts.map +1 -1
  158. package/dist/src/storage/providers/sqlite/repos/direct-message.js +28 -2
  159. package/dist/src/storage/providers/sqlite/repos/direct-message.js.map +1 -1
  160. package/dist/src/storage/providers/sqlite/repos/memory.d.ts +1 -0
  161. package/dist/src/storage/providers/sqlite/repos/memory.d.ts.map +1 -1
  162. package/dist/src/storage/providers/sqlite/repos/memory.js +4 -0
  163. package/dist/src/storage/providers/sqlite/repos/memory.js.map +1 -1
  164. package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
  165. package/dist/src/storage/providers/sqlite/schema.js +4 -0
  166. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  167. package/dist/src/storage/repositories/direct-message.repository.d.ts +20 -0
  168. package/dist/src/storage/repositories/direct-message.repository.d.ts.map +1 -1
  169. package/dist/src/storage/repositories/memory.repository.d.ts +1 -0
  170. package/dist/src/storage/repositories/memory.repository.d.ts.map +1 -1
  171. package/package.json +1 -1
  172. package/prisma/schema.postgres.prisma +1 -0
  173. 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
- export async function discoverWorkspaces(orgId) {
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 resp = await apiGet(`/v1/organisms/${encodeURIComponent(orgId)}/workspaces`);
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
- export async function listWorkflows() {
28
- return apiGet('/v1/workflows');
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
  ```
@@ -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
- function MessageBubble({ msg, mine, urlMap, starred, onStar, onTrack, tracked }) {
164
+ /** Classify an attachment for rendering: how to view it (thumbnail / native tab / markdown viewer). */
165
+ const ATTACH_ICO = { image: '🖼', pdf: '📄', markdown: '📄', audio: '🎵', video: '🎬', file: '📎' };
166
+ function attachKind(a) {
167
+ const mime = a.mime || '';
168
+ const name = a.name || a.storageKey || '';
169
+ if (a.kind === 'image' || /^image\//.test(mime)) return 'image';
170
+ if (mime === 'application/pdf' || /\.pdf$/i.test(name)) return 'pdf';
171
+ if (/markdown/.test(mime) || /\.(md|markdown|mdx)$/i.test(name)) return 'markdown';
172
+ if (a.kind === 'audio' || /^audio\//.test(mime)) return 'audio';
173
+ if (a.kind === 'video' || /^video\//.test(mime)) return 'video';
174
+ return 'file';
175
+ }
176
+
177
+ /** One received/sent attachment. Images render as a thumbnail (click → full-size in a new tab);
178
+ * PDF/audio/video/file open natively in a new tab; markdown opens the in-app rendered viewer. Every
179
+ * ready attachment gets a download button. Not-yet-duplicated / expired attachments show their state. */
180
+ function AttachmentItem({ a, url, onOpenMarkdown }) {
181
+ const kind = attachKind(a);
182
+ const name = a.name || a.storageKey;
183
+ const ready = !!url && !a.expired;
184
+
185
+ if (!ready) {
186
+ const status = a.expired ? t('inbox.attachmentExpired') : (a.mode !== 'duplicate' ? t('inbox.attachmentPending') : null);
187
+ return html`<div class="inbox-attach-chip inbox-attach-chip--pending">
188
+ <span class="inbox-attach-ico">${ATTACH_ICO[kind]}</span>
189
+ <span class="inbox-attach-name">${escHtml(name)}</span>
190
+ ${status ? html`<span class="inbox-attach-pending">${status}</span>` : null}
191
+ </div>`;
192
+ }
193
+
194
+ const download = html`<a class="inbox-attach-dl" href=${url} download=${name} title=${t('inbox.attachmentDownload')}>⬇</a>`;
195
+
196
+ if (kind === 'image') {
197
+ return html`<div class="inbox-attach-item">
198
+ <a class="inbox-attach-thumb-link" href=${url} target="_blank" rel="noopener" title=${t('inbox.attachmentOpen')}>
199
+ <img class="inbox-attach-thumb" src=${url} alt=${escHtml(name)} loading="lazy" />
200
+ </a>
201
+ <div class="inbox-attach-cap"><span class="inbox-attach-name">${escHtml(name)}</span>${download}</div>
202
+ </div>`;
203
+ }
204
+ if (kind === 'markdown') {
205
+ return html`<div class="inbox-attach-chip">
206
+ <button class="inbox-attach-open" onClick=${() => onOpenMarkdown?.(url, name)} title=${t('inbox.attachmentView')}>
207
+ <span class="inbox-attach-ico">📄</span><span class="inbox-attach-name">${escHtml(name)}</span>
208
+ </button>${download}
209
+ </div>`;
210
+ }
211
+ // pdf / audio / video / file — let the browser open it in a new tab.
212
+ return html`<div class="inbox-attach-chip">
213
+ <a class="inbox-attach-open" href=${url} target="_blank" rel="noopener" title=${t('inbox.attachmentOpen')}>
214
+ <span class="inbox-attach-ico">${ATTACH_ICO[kind]}</span><span class="inbox-attach-name">${escHtml(name)}</span>
215
+ </a>${download}
216
+ </div>`;
217
+ }
218
+
219
+ /** In-app viewer for a markdown attachment — browsers don't render .md, so we fetch the (same-origin,
220
+ * presigned) file and render it with the shared safe Markdown component. Offers open-raw + download. */
221
+ function MarkdownViewer({ url, name, onClose }) {
222
+ const [text, setText] = useState(null);
223
+ const [failed, setFailed] = useState(false);
224
+ useEffect(() => {
225
+ let alive = true;
226
+ fetch(url).then(r => r.ok ? r.text() : Promise.reject(new Error('http'))).then(tx => { if (alive) setText(tx); }).catch(() => { if (alive) setFailed(true); });
227
+ return () => { alive = false; };
228
+ }, [url]);
229
+ return html`
230
+ <div class="inbox-mdviewer-overlay" onClick=${onClose}>
231
+ <div class="inbox-mdviewer" onClick=${e => e.stopPropagation()}>
232
+ <div class="inbox-mdviewer-head">
233
+ <span class="inbox-mdviewer-title">📄 ${escHtml(name)}</span>
234
+ <div class="inbox-mdviewer-actions">
235
+ <a class="btn-ghost btn-sm" href=${url} target="_blank" rel="noopener">${t('inbox.attachmentOpenRaw')}</a>
236
+ <a class="btn-ghost btn-sm" href=${url} download=${name}>${t('inbox.attachmentDownload')}</a>
237
+ <button class="btn-ghost btn-sm" onClick=${onClose} title=${t('inbox.close')}>✕</button>
238
+ </div>
239
+ </div>
240
+ <div class="inbox-mdviewer-body">
241
+ ${failed ? html`<div class="inbox-empty-sm">${t('inbox.attachmentLoadError')}</div>`
242
+ : text === null ? html`<div class="inbox-empty-sm">…</div>`
243
+ : html`<${Markdown} text=${text} />`}
244
+ </div>
245
+ </div>
246
+ </div>`;
247
+ }
248
+
249
+ function MessageBubble({ msg, mine, urlMap, starred, onStar, onTrack, tracked, onOpenMarkdown }) {
138
250
  const nonInline = (msg.attachments || []).filter(a => !a.inline);
139
251
  const expiredIds = new Set((msg.attachments || []).filter(a => a.expired).map(a => a.id));
140
252
  const trk = tracked ? trackStateLabel(tracked.state) : null;
@@ -152,14 +264,7 @@ function MessageBubble({ msg, mine, urlMap, starred, onStar, onTrack, tracked })
152
264
  <div class="inbox-bubble-body"><${Markdown} text=${prepareBody(msg.body, urlMap, expiredIds)} /></div>
153
265
  ${nonInline.length > 0 && html`
154
266
  <div class="inbox-attach-row">
155
- ${nonInline.map(a => html`
156
- <a key=${a.id} class=${`inbox-attach-chip${(urlMap[a.id] && !a.expired) ? '' : ' inbox-attach-chip--pending'}`}
157
- href=${(urlMap[a.id] && !a.expired) ? urlMap[a.id] : '#'} target="_blank" rel="noopener">
158
- <span class="inbox-attach-ico">${a.kind === 'image' ? '🖼' : a.kind === 'audio' ? '🎵' : a.kind === 'video' ? '🎬' : '📎'}</span>
159
- <span class="inbox-attach-name">${escHtml(a.name || a.storageKey)}</span>
160
- ${a.expired ? html`<span class="inbox-attach-pending">${t('inbox.attachmentExpired')}</span>`
161
- : a.mode !== 'duplicate' ? html`<span class="inbox-attach-pending">${t('inbox.attachmentPending')}</span>` : null}
162
- </a>`)}
267
+ ${nonInline.map(a => html`<${AttachmentItem} key=${a.id} a=${a} url=${urlMap[a.id]} onOpenMarkdown=${onOpenMarkdown} />`)}
163
268
  </div>`}
164
269
  <div class="inbox-bubble-meta">
165
270
  ${trk ? html`<span class=${`inbox-track-badge inbox-track-badge--${trk.tone}`} title=${t('inbox.trackResponse')}>🔗 ${trk.text}</span>` : null}
@@ -256,6 +361,8 @@ export default function InboxTab({ showToast }) {
256
361
  const [activeConv, setActiveConv] = useState(null); // { conversationId, peerGhii }
257
362
  const [thread, setThread] = useState([]);
258
363
  const [urlMap, setUrlMap] = useState({});
364
+ const [mdViewer, setMdViewer] = useState(null); // { url, name } — open markdown attachment viewer
365
+ const [composeSubject, setComposeSubject] = useState(''); // optional subject → opens a new topic thread
259
366
  const [mode, setMode] = useState('idle'); // 'idle' | 'compose' | 'thread'
260
367
  const [to, setTo] = useState('');
261
368
  const [sending, setSending] = useState(false);
@@ -385,8 +492,16 @@ export default function InboxTab({ showToast }) {
385
492
  setThread(msgs);
386
493
  const map = {};
387
494
  await Promise.all(msgs.flatMap(m => (m.attachments || [])
388
- .filter(a => a.mode === 'duplicate' && a.localKey)
389
- .map(async a => { const u = await messages.attachmentUrl(a.localKey).catch(() => null); if (u) map[a.id] = u; })));
495
+ .filter(a => !a.inline)
496
+ .map(async a => {
497
+ // Inbound: only the recipient's duplicated local copy is fetchable (after accept). Outbound:
498
+ // the sender owns the original storage key, so resolve that — you can view/download what you sent.
499
+ const key = (a.mode === 'duplicate' && a.localKey) ? a.localKey
500
+ : (m.direction === 'outbound' && a.storageKey) ? a.storageKey : null;
501
+ if (!key) return;
502
+ const u = await messages.attachmentUrl(key).catch(() => null);
503
+ if (u) map[a.id] = u;
504
+ })));
390
505
  setUrlMap(map);
391
506
  if (markRead) await messages.markConversationRead(conv.conversationId).catch(() => {});
392
507
  }, []);
@@ -434,7 +549,7 @@ export default function InboxTab({ showToast }) {
434
549
  await loadThread(conv, true); // mark read only on explicit open (avoids a refresh loop)
435
550
  loadLists();
436
551
  };
437
- const startCompose = () => { setMode('compose'); setActiveConv(null); setTo(''); };
552
+ const startCompose = () => { setMode('compose'); setActiveConv(null); setTo(''); setComposeSubject(''); };
438
553
 
439
554
  // Open the conversation for a tracked response, then (for awaiting-approval) seed the suggested reply.
440
555
  const openTracked = async (tr) => {
@@ -485,10 +600,13 @@ export default function InboxTab({ showToast }) {
485
600
  const desc = await messages.uploadAttachment(files[i]);
486
601
  attachments.push({ ...desc, inline: false, id: `at${i}` });
487
602
  }
488
- const resp = await messages.send({ to: recipient, body, attachments });
603
+ // A subject (compose mode only) opens a new topic thread; replies stay in the active thread.
604
+ const subject = (mode === 'compose' && composeSubject.trim()) ? composeSubject.trim() : undefined;
605
+ const resp = await messages.send({ to: recipient, body, attachments, subject });
489
606
  if (resp?.ok === false) { showToast?.(resp?.error?.message || t('inbox.failed'), true); }
490
607
  else {
491
608
  reset?.();
609
+ setComposeSubject('');
492
610
  // If this send fulfils a Tracked Response awaiting approval, mark it replied.
493
611
  if (replyingTrId) {
494
612
  await tracked.markTrackedResponseReplied(replyingTrId, resp?.data?.message?.id).catch(() => {});
@@ -528,23 +646,47 @@ export default function InboxTab({ showToast }) {
528
646
 
529
647
  <div class="inbox-list-section">${t('inbox.conversations')}</div>
530
648
  ${conversations.length === 0 ? html`<div class="inbox-empty-sm">${t('inbox.noConversations')}</div>` : null}
531
- ${conversations.map(c => html`
532
- <button class=${`inbox-conv${activeConv?.conversationId === c.conversationId ? ' inbox-conv--active' : ''}`}
533
- key=${c.conversationId} onClick=${() => openConversation(c)}>
534
- <${Avatar} seed=${c.peerGhii} size=${40} />
535
- <div class="inbox-conv-main">
536
- <div class="inbox-conv-line1">
537
- <span class="inbox-name">${escHtml(peerName(c.peerGhii))} <${PresenceDot} ghii=${c.peerGhii} /></span>
538
- <span class="inbox-conv-time">${c.updatedAt ? timeShort(c.updatedAt) : ''}</span>
649
+ ${groupConversations(conversations).map(g => {
650
+ // A person with a single human-only thread (no agents) renders flat, as before.
651
+ if (g.convs.length === 1 && !isAgentPeer(g.convs[0].peerGhii)) return convRow(g.convs[0], false);
652
+ // Otherwise: a group header for the person + a nested row per thread (human + each agent).
653
+ const unread = g.convs.reduce((n, c) => n + (c.unread || 0), 0);
654
+ return html`
655
+ <div class="inbox-conv-group" key=${g.ownerKey}>
656
+ <div class="inbox-conv-group-head">
657
+ <${Avatar} seed=${g.ownerKey} size=${28} />
658
+ <span class="inbox-name">${escHtml(ownerDisplayName(g.ownerKey))} <${PresenceDot} ghii=${g.ownerKey} /></span>
659
+ ${unread > 0 ? html`<span class="inbox-conv-badge">${unread}</span>` : null}
539
660
  </div>
540
- <div class="inbox-conv-line2">
541
- <span class="inbox-conv-preview">${c.lastDirection === 'outbound' ? `${t('inbox.youPrefix')} ` : ''}${escHtml(c.lastMessage || '')}</span>
542
- ${c.unread > 0 ? html`<span class="inbox-conv-badge">${c.unread}</span>` : null}
543
- </div>
544
- </div>
545
- </button>`)}
661
+ ${g.convs.map(c => convRow(c, true))}
662
+ </div>`;
663
+ })}
546
664
  </div>`;
547
665
 
666
+ /** One conversation row. `nested` = inside a person group (labelled by the agent/thread, indented). */
667
+ const convRow = (c, nested) => {
668
+ const active = activeConv?.conversationId === c.conversationId ? ' inbox-conv--active' : '';
669
+ const sub = subThreadLabel(c.peerGhii);
670
+ // Nested: a subject thread shows its topic; otherwise the agent name / "Direct". Flat: the person.
671
+ const label = nested ? (c.subject || sub || t('inbox.directThread')) : peerName(c.peerGhii);
672
+ const icon = sub ? '🤖' : (c.subject ? '🏷' : '💬');
673
+ return html`
674
+ <button class=${`inbox-conv${active}${nested ? ' inbox-conv--nested' : ''}`} key=${c.conversationId} onClick=${() => openConversation(c)}>
675
+ ${nested ? html`<span class="inbox-conv-subico">${icon}</span>` : html`<${Avatar} seed=${c.peerGhii} size=${40} />`}
676
+ <div class="inbox-conv-main">
677
+ <div class="inbox-conv-line1">
678
+ <span class="inbox-name">${escHtml(label)} ${nested ? '' : html`<${PresenceDot} ghii=${c.peerGhii} />`}</span>
679
+ <span class="inbox-conv-time">${c.updatedAt ? timeShort(c.updatedAt) : ''}</span>
680
+ </div>
681
+ <div class="inbox-conv-line2">
682
+ ${(!nested && c.subject) ? html`<span class="inbox-conv-subject">🏷 ${escHtml(c.subject)}</span>` : ''}
683
+ <span class="inbox-conv-preview">${c.lastDirection === 'outbound' ? `${t('inbox.youPrefix')} ` : ''}${escHtml(c.lastMessage || '')}</span>
684
+ ${c.unread > 0 ? html`<span class="inbox-conv-badge">${c.unread}</span>` : null}
685
+ </div>
686
+ </div>
687
+ </button>`;
688
+ };
689
+
548
690
  const renderThread = () => {
549
691
  let lastDay = '';
550
692
  return html`
@@ -553,6 +695,7 @@ export default function InboxTab({ showToast }) {
553
695
  <${Avatar} seed=${activeConv.peerGhii} size=${36} />
554
696
  <div class="inbox-thread-id">
555
697
  <div class="inbox-name">${escHtml(peerName(activeConv.peerGhii))} <${PresenceDot} ghii=${activeConv.peerGhii} label=${true} /></div>
698
+ ${activeConv.subject ? html`<div class="inbox-thread-subject">🏷 ${escHtml(activeConv.subject)}</div>` : null}
556
699
  <div class="inbox-sub">${escHtml(activeConv.peerGhii)}</div>
557
700
  </div>
558
701
  </div>
@@ -564,7 +707,8 @@ export default function InboxTab({ showToast }) {
564
707
  return html`
565
708
  ${showDay ? html`<div class="inbox-day" key=${'d' + m.id}><span>${dayLabel(m.createdAt)}</span></div>` : null}
566
709
  <${MessageBubble} key=${m.id + m.direction} msg=${m} mine=${m.direction === 'outbound'} urlMap=${urlMap}
567
- starred=${important.has(m.id)} onStar=${toggleImportant} onTrack=${onTrackMsg} tracked=${trackedByMsg[m.id]} />`;
710
+ starred=${important.has(m.id)} onStar=${toggleImportant} onTrack=${onTrackMsg} tracked=${trackedByMsg[m.id]}
711
+ onOpenMarkdown=${(url, name) => setMdViewer({ url, name })} />`;
568
712
  })}
569
713
  </div>
570
714
  ${awaitingForConv.map(tr => html`
@@ -653,6 +797,8 @@ export default function InboxTab({ showToast }) {
653
797
  <div class="inbox-compose-fields">
654
798
  <input class="inbox-input" type="text" placeholder=${t('inbox.toPlaceholder')}
655
799
  value=${to} onInput=${(e) => setTo(e.target.value)} />
800
+ <input class="inbox-input" type="text" placeholder=${t('inbox.subjectPlaceholder')}
801
+ value=${composeSubject} onInput=${(e) => setComposeSubject(e.target.value)} />
656
802
  </div>
657
803
  <${Composer} key="c-new" recipient=${to.trim()} sendLabel=${t('inbox.send')}
658
804
  sending=${sending} onSend=${doSend} />
@@ -673,5 +819,6 @@ export default function InboxTab({ showToast }) {
673
819
 
674
820
  <${TrackResponseModal} open=${!!trackMsg} msg=${trackMsg}
675
821
  onClose=${() => setTrackMsg(null)} onDone=${loadLists} showToast=${showToast} />
822
+ ${mdViewer && html`<${MarkdownViewer} url=${mdViewer.url} name=${mdViewer.name} onClose=${() => setMdViewer(null)} />`}
676
823
  </div>`;
677
824
  }