aimeat 1.23.2 → 1.23.3

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 (154) hide show
  1. package/dist/locales/en.json +536 -21
  2. package/dist/locales/fi.json +548 -33
  3. package/dist/public/components/ContactCard.js +28 -0
  4. package/dist/public/css/views/admin.css +12 -0
  5. package/dist/public/css/views/agents-detail.css +62 -10
  6. package/dist/public/css/views/landing.css +87 -0
  7. package/dist/public/css/views/offers.css +96 -0
  8. package/dist/public/css/views/portfolio.css +18 -0
  9. package/dist/public/css/views/profile.css +153 -8
  10. package/dist/public/css/views/start.css +96 -0
  11. package/dist/public/img/business-hero.png +0 -0
  12. package/dist/public/img/toimitus.png +0 -0
  13. package/dist/public/js/components/tag-cloud.js +46 -31
  14. package/dist/public/js/components/tag-editor.js +2 -2
  15. package/dist/public/js/recents.js +40 -0
  16. package/dist/public/js/services/admin.js +6 -0
  17. package/dist/public/js/services/agents.js +34 -0
  18. package/dist/public/js/services/offers.js +152 -0
  19. package/dist/public/js/services/packages.js +1 -0
  20. package/dist/public/spa.html +32 -2
  21. package/dist/public/views/admin/subdomains-tab.js +182 -0
  22. package/dist/public/views/admin.js +2 -0
  23. package/dist/public/views/business.js +119 -0
  24. package/dist/public/views/how-it-works.js +159 -0
  25. package/dist/public/views/landing.js +235 -0
  26. package/dist/public/views/portfolio.js +120 -23
  27. package/dist/public/views/pricing.js +75 -0
  28. package/dist/public/views/profile/access-tab.js +124 -51
  29. package/dist/public/views/profile/agents/agent-card.js +68 -37
  30. package/dist/public/views/profile/agents/shared-board.js +56 -22
  31. package/dist/public/views/profile/agents/tab-activity.js +24 -12
  32. package/dist/public/views/profile/agents/tab-agent-config.js +31 -1
  33. package/dist/public/views/profile/agents/tab-data-access.js +9 -13
  34. package/dist/public/views/profile/agents/tab-integration.js +27 -1
  35. package/dist/public/views/profile/agents/tab-messages.js +6 -11
  36. package/dist/public/views/profile/agents/tab-quality.js +45 -5
  37. package/dist/public/views/profile/agents-tab.js +41 -6
  38. package/dist/public/views/profile/apps-tab.js +7 -1
  39. package/dist/public/views/profile/knowledge-tab.js +4 -1
  40. package/dist/public/views/profile/landing-page.js +427 -123
  41. package/dist/public/views/profile/memory-tab.js +409 -211
  42. package/dist/public/views/profile/nodes-tab.js +18 -3
  43. package/dist/public/views/profile/offers-tab.js +310 -0
  44. package/dist/public/views/profile/organisms-tab.js +289 -28
  45. package/dist/public/views/profile/packages-tab.js +152 -16
  46. package/dist/public/views/profile/portfolio-tab.js +98 -31
  47. package/dist/public/views/profile.js +2 -0
  48. package/dist/public/views/start-flows.js +281 -0
  49. package/dist/public/views/start.js +258 -0
  50. package/dist/src/generated/api-types.d.ts +856 -17
  51. package/dist/src/generated/api-types.d.ts.map +1 -1
  52. package/dist/src/generated/prisma-postgres/edge.js +15 -3
  53. package/dist/src/generated/prisma-postgres/index-browser.js +12 -0
  54. package/dist/src/generated/prisma-postgres/index.d.ts +1476 -160
  55. package/dist/src/generated/prisma-postgres/index.js +15 -3
  56. package/dist/src/generated/prisma-postgres/package.json +1 -1
  57. package/dist/src/generated/prisma-postgres/schema.prisma +11 -0
  58. package/dist/src/generated/prisma-postgres/wasm.js +15 -3
  59. package/dist/src/middleware/subdomain.d.ts +28 -0
  60. package/dist/src/middleware/subdomain.d.ts.map +1 -0
  61. package/dist/src/middleware/subdomain.js +32 -0
  62. package/dist/src/middleware/subdomain.js.map +1 -0
  63. package/dist/src/models/offer-schemas.d.ts +229 -0
  64. package/dist/src/models/offer-schemas.d.ts.map +1 -0
  65. package/dist/src/models/offer-schemas.js +100 -0
  66. package/dist/src/models/offer-schemas.js.map +1 -0
  67. package/dist/src/routes/agent-tasks.d.ts.map +1 -1
  68. package/dist/src/routes/agent-tasks.js +33 -0
  69. package/dist/src/routes/agent-tasks.js.map +1 -1
  70. package/dist/src/routes/agents.d.ts.map +1 -1
  71. package/dist/src/routes/agents.js +146 -1
  72. package/dist/src/routes/agents.js.map +1 -1
  73. package/dist/src/routes/apps-backup.d.ts +20 -0
  74. package/dist/src/routes/apps-backup.d.ts.map +1 -0
  75. package/dist/src/routes/apps-backup.js +165 -0
  76. package/dist/src/routes/apps-backup.js.map +1 -0
  77. package/dist/src/routes/ghii.d.ts.map +1 -1
  78. package/dist/src/routes/ghii.js +4 -0
  79. package/dist/src/routes/ghii.js.map +1 -1
  80. package/dist/src/routes/instances.d.ts +2 -1
  81. package/dist/src/routes/instances.d.ts.map +1 -1
  82. package/dist/src/routes/instances.js +59 -1
  83. package/dist/src/routes/instances.js.map +1 -1
  84. package/dist/src/routes/packages.d.ts.map +1 -1
  85. package/dist/src/routes/packages.js +32 -0
  86. package/dist/src/routes/packages.js.map +1 -1
  87. package/dist/src/routes/permissions.d.ts +12 -0
  88. package/dist/src/routes/permissions.d.ts.map +1 -1
  89. package/dist/src/routes/permissions.js +35 -3
  90. package/dist/src/routes/permissions.js.map +1 -1
  91. package/dist/src/routes/portal.d.ts +3 -0
  92. package/dist/src/routes/portal.d.ts.map +1 -1
  93. package/dist/src/routes/portal.js +12 -0
  94. package/dist/src/routes/portal.js.map +1 -1
  95. package/dist/src/routes/public-stats.d.ts +15 -0
  96. package/dist/src/routes/public-stats.d.ts.map +1 -0
  97. package/dist/src/routes/public-stats.js +90 -0
  98. package/dist/src/routes/public-stats.js.map +1 -0
  99. package/dist/src/routes/sse.d.ts +7 -2
  100. package/dist/src/routes/sse.d.ts.map +1 -1
  101. package/dist/src/routes/sse.js +35 -5
  102. package/dist/src/routes/sse.js.map +1 -1
  103. package/dist/src/routes/subdomains.d.ts +33 -0
  104. package/dist/src/routes/subdomains.d.ts.map +1 -0
  105. package/dist/src/routes/subdomains.js +208 -0
  106. package/dist/src/routes/subdomains.js.map +1 -0
  107. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  108. package/dist/src/server-bootstrap/routes-loader.js +12 -1
  109. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  110. package/dist/src/server.d.ts.map +1 -1
  111. package/dist/src/server.js +4 -0
  112. package/dist/src/server.js.map +1 -1
  113. package/dist/src/services/apps-backup-export.d.ts +22 -0
  114. package/dist/src/services/apps-backup-export.d.ts.map +1 -0
  115. package/dist/src/services/apps-backup-export.js +136 -0
  116. package/dist/src/services/apps-backup-export.js.map +1 -0
  117. package/dist/src/services/apps-backup-import.d.ts +120 -0
  118. package/dist/src/services/apps-backup-import.d.ts.map +1 -0
  119. package/dist/src/services/apps-backup-import.js +259 -0
  120. package/dist/src/services/apps-backup-import.js.map +1 -0
  121. package/dist/src/services/component-registrar.d.ts +17 -0
  122. package/dist/src/services/component-registrar.d.ts.map +1 -1
  123. package/dist/src/services/component-registrar.js +63 -5
  124. package/dist/src/services/component-registrar.js.map +1 -1
  125. package/dist/src/services/handbooks/appdev.d.ts +1 -1
  126. package/dist/src/services/handbooks/appdev.d.ts.map +1 -1
  127. package/dist/src/services/handbooks/appdev.js +5 -2
  128. package/dist/src/services/handbooks/appdev.js.map +1 -1
  129. package/dist/src/storage/interface.d.ts +11 -1
  130. package/dist/src/storage/interface.d.ts.map +1 -1
  131. package/dist/src/storage/interface.js.map +1 -1
  132. package/dist/src/storage/providers/mongodb/index.d.ts +10 -1
  133. package/dist/src/storage/providers/mongodb/index.d.ts.map +1 -1
  134. package/dist/src/storage/providers/mongodb/index.js +76 -0
  135. package/dist/src/storage/providers/mongodb/index.js.map +1 -1
  136. package/dist/src/storage/providers/sqlite/index.d.ts +8 -1
  137. package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
  138. package/dist/src/storage/providers/sqlite/index.js +61 -0
  139. package/dist/src/storage/providers/sqlite/index.js.map +1 -1
  140. package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
  141. package/dist/src/storage/providers/sqlite/schema.js +11 -0
  142. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  143. package/dist/src/storage/repositories/index.d.ts +1 -0
  144. package/dist/src/storage/repositories/index.d.ts.map +1 -1
  145. package/dist/src/storage/repositories/package.repository.d.ts +2 -0
  146. package/dist/src/storage/repositories/package.repository.d.ts.map +1 -1
  147. package/dist/src/storage/repositories/subdomain-site.repository.d.ts +19 -0
  148. package/dist/src/storage/repositories/subdomain-site.repository.d.ts.map +1 -0
  149. package/dist/src/storage/repositories/subdomain-site.repository.js +2 -0
  150. package/dist/src/storage/repositories/subdomain-site.repository.js.map +1 -0
  151. package/dist/static/app-catalog.html +1139 -28
  152. package/package.json +1 -1
  153. package/prisma/schema.postgres.prisma +11 -0
  154. package/prisma/schema.prisma +11 -0
@@ -15,6 +15,28 @@
15
15
  * AppStrip — home/section sub-components
16
16
  * - LandingPage — main orchestrator (default export)
17
17
  * @version-history
18
+ * v3.2.0 — 2026-06-10 — Sidebar reorg: groups follow the information-refinement pipeline
19
+ * (Information / Automation / Activity / Build & Share / Account / Infrastructure) —
20
+ * Daily/Personal/Technical are gone. Pinned section under Home (pin-on-hover 📌, max 5,
21
+ * defaults organisms/agents/memory/scheduler, persisted in user memory `sidebar.pins`);
22
+ * groups collapse/expand with localStorage memory; Infrastructure renders AND opens only
23
+ * for operators (open() refuses infra ids; APIs operator-gated server-side); Node Stats
24
+ * left the menu (now a sub-tab on the Nodes page); "work" parked at Build & Share bottom
25
+ * pending placement decision.
26
+ * v3.1.0 — 2026-06-10 — Sidebar identity shows the generated identicon (same seed as the profile
27
+ * card) instead of a plain accent ball; home-card list rows lightened (weight 500, dim color) so
28
+ * card titles read as headers again. Agents-card rows deep-link to the agent (primes
29
+ * aimeat.agents.open → agents tab expands + scrolls to it); the next-run row opens the Scheduler.
30
+ * v3.0.0 — 2026-06-10 — Home is a DASHBOARD ("what happened, what waits for me"): new
31
+ * WaitingForYou box (pending publish approvals per org/workspace + join requests + incoming
32
+ * invitations, with Review/View buttons that prime the organisms tab), Continue card (cross-type
33
+ * recents from /js/recents.js with real display names — replaces the AppStrip file listing) and
34
+ * Agents card (active today / last seen + next scheduled run). Extensions promo shows only while
35
+ * apps < 3 and is dismissable (localStorage). ProfileCard stats are navigation (click → section);
36
+ * profile editing moved behind a Profile button / avatar click. Edit-profile modal: avatar live
37
+ * preview, locale hint, real "Change in the Email tab" link, Change-password link in the footer.
38
+ * Change-password modal: live requirement checklist, inline mismatch, neutral Show/Hide toggles,
39
+ * button verb "Change password". Sidebar agents count badge removed (badges = action only).
18
40
  * v2.3.0 — 2026-06-10 — Drop the "← Home" button from the content header (visual noise,
19
41
  * duplicated the sidebar's 🏠 Home item); the header keeps the current-tab label.
20
42
  * v2.2.0 — 2026-06-10 — Listen for the `aimeat-open-tab` CustomEvent so tab components can
@@ -33,13 +55,69 @@ import { h } from 'preact';
33
55
  import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
34
56
  import htm from 'htm';
35
57
  const html = htm.bind(h);
36
- import { t } from '/js/i18n.js';
58
+ import { t, getLocale } from '/js/i18n.js';
37
59
  import { escHtml } from '/js/utils.js';
38
60
  import { getNodeUrl, getProfile, updateProfile, changePassword } from '/js/services/auth.js';
39
61
  import { listApps } from '/js/services/apps.js';
62
+ import { listAgents } from '/js/services/agents.js';
63
+ import { listAllSchedules } from '/js/services/schedules.js';
64
+ import * as orgService from '/js/services/organisms.js';
65
+ import { listRecents } from '/js/recents.js';
66
+ import { getMemory, createMemory } from '/js/services/memory.js';
40
67
  import { Spinner } from './shared.js';
41
68
  import { minidenticon } from '/lib/minidenticons.min.js';
42
69
 
70
+ /* ───── Small time helpers (reuse the organisms rel-time keys) ───── */
71
+
72
+ function fmtDateLocal(s) {
73
+ return new Date(s).toLocaleDateString(getLocale() === 'fi' ? 'fi-FI' : undefined);
74
+ }
75
+ function relTime(s) {
76
+ const ts = new Date(s).getTime();
77
+ if (!Number.isFinite(ts)) return '';
78
+ const mins = Math.max(0, Math.round((Date.now() - ts) / 60000));
79
+ if (mins < 60) return (t('organisms.relMin') || '{n} min ago').replace('{n}', String(mins));
80
+ const hours = Math.round(mins / 60);
81
+ if (hours < 24) return (t('organisms.relHours') || '{n} h ago').replace('{n}', String(hours));
82
+ const days = Math.round(hours / 24);
83
+ if (days <= 7) return (t('organisms.relDays') || '{n} d ago').replace('{n}', String(days));
84
+ return fmtDateLocal(s);
85
+ }
86
+ function fmtClock(s) {
87
+ const d = new Date(s);
88
+ if (!Number.isFinite(d.getTime())) return '';
89
+ const loc = getLocale() === 'fi' ? 'fi-FI' : undefined;
90
+ return d.toDateString() === new Date().toDateString()
91
+ ? d.toLocaleTimeString(loc, { hour: '2-digit', minute: '2-digit' })
92
+ : d.toLocaleString(loc, { day: 'numeric', month: 'numeric', hour: '2-digit', minute: '2-digit' });
93
+ }
94
+
95
+ /* ───── Home-card navigation: prime the organisms tab's sessionStorage, then open it ───── */
96
+
97
+ function openProfileTab(tabId) {
98
+ window.dispatchEvent(new CustomEvent('aimeat-open-tab', { detail: { tabId } }));
99
+ }
100
+ function gotoWorkspace(orgId, wsId, wsTab) {
101
+ try {
102
+ sessionStorage.setItem('aimeat.ws.openId', orgId);
103
+ sessionStorage.setItem('aimeat.ws.openWs', wsId);
104
+ if (wsTab) sessionStorage.setItem(`aimeat.ws.${orgId}.${wsId}.tab`, wsTab);
105
+ } catch { /* noop */ }
106
+ openProfileTab('organisms');
107
+ }
108
+ function gotoOrganism(orgId, homeTab) {
109
+ try {
110
+ sessionStorage.setItem('aimeat.ws.openId', orgId);
111
+ sessionStorage.removeItem('aimeat.ws.openWs');
112
+ if (homeTab) sessionStorage.setItem('aimeat.org.tab', homeTab);
113
+ } catch { /* noop */ }
114
+ openProfileTab('organisms');
115
+ }
116
+ function gotoOrganismsList() {
117
+ try { sessionStorage.removeItem('aimeat.ws.openId'); sessionStorage.removeItem('aimeat.ws.openWs'); } catch { /* noop */ }
118
+ openProfileTab('organisms');
119
+ }
120
+
43
121
  /* ───── Tier heuristic ───── */
44
122
 
45
123
  const TIER_LEVELS = { 'new': 0, 'active': 1, 'experienced': 2 };
@@ -72,7 +150,7 @@ export function tierLevel(tier) {
72
150
 
73
151
  /* ───── Edit Profile Modal ───── */
74
152
 
75
- function EditProfileModal({ session, onClose, onSaved }) {
153
+ function EditProfileModal({ session, onClose, onSaved, onChangePassword }) {
76
154
  const [loading, setLoading] = useState(true);
77
155
  const [saving, setSaving] = useState(false);
78
156
  const [fields, setFields] = useState({ display_name: '', bio: '', avatar: '', locale: 'en' });
@@ -149,10 +227,13 @@ function EditProfileModal({ session, onClose, onSaved }) {
149
227
  </label>
150
228
  <label class="pf-edit-label">
151
229
  ${t('profile.landing.editAvatar')}
152
- <input type="text" class="pf-edit-input" value=${fields.avatar}
153
- placeholder=${t('profile.landing.editAvatarPlaceholder')}
154
- maxlength="50"
155
- onInput=${(e) => set('avatar', e.target.value)} />
230
+ <div class="pf-avatar-row">
231
+ <input type="text" class="pf-edit-input" value=${fields.avatar}
232
+ placeholder=${t('profile.landing.editAvatarPlaceholder')}
233
+ maxlength="50"
234
+ onInput=${(e) => set('avatar', e.target.value)} />
235
+ <span class="pf-avatar-preview" aria-hidden="true">${fields.avatar || '🙂'}</span>
236
+ </div>
156
237
  </label>
157
238
  <label class="pf-edit-label">
158
239
  ${t('profile.landing.editLocale')}
@@ -161,14 +242,18 @@ function EditProfileModal({ session, onClose, onSaved }) {
161
242
  <option value="en">English</option>
162
243
  <option value="fi">Suomi</option>
163
244
  </select>
245
+ <div class="pf-edit-hint">${t('profile.landing.editLocaleHint') || 'Your preferred language — used for the portal UI; agents can read it from your profile to answer in it.'}</div>
164
246
  </label>
165
247
  <div class="pf-edit-label">
166
248
  ${t('profile.landing.editEmail')}
167
249
  <div class="pf-edit-readonly">${currentEmail || t('profile.landing.editEmailNone')}</div>
168
- <div class="pf-edit-hint">${t('profile.landing.editEmailHint')}</div>
250
+ <a href="#" class="pf-edit-link" onClick=${(e) => { e.preventDefault(); onClose(); window.dispatchEvent(new CustomEvent('aimeat-open-tab', { detail: { tabId: 'email' } })); }}>
251
+ ${t('profile.landing.editEmailLink') || 'Change in the Email tab →'}</a>
169
252
  </div>
170
253
  </div>
171
254
  <div class="pf-edit-footer">
255
+ <a href="#" class="pf-edit-link pf-edit-footer-left" onClick=${(e) => { e.preventDefault(); onChangePassword?.(); }}>
256
+ ${t('profile.landing.changePassword')}…</a>
172
257
  <button class="btn-outline" onClick=${onClose} disabled=${saving}>
173
258
  ${t('profile.landing.editCancel')}
174
259
  </button>
@@ -184,6 +269,18 @@ function EditProfileModal({ session, onClose, onSaved }) {
184
269
 
185
270
  /* ───── Change Password Modal ───── */
186
271
 
272
+ /* Password input with a neutral show/hide toggle (text-presentation eye, gray — red would read
273
+ * as an error). */
274
+ function PwInput({ value, onInput }) {
275
+ const [show, setShow] = useState(false);
276
+ return html`
277
+ <div class="pf-pw-wrap">
278
+ <input type=${show ? 'text' : 'password'} class="pf-edit-input" value=${value} onInput=${onInput} />
279
+ <button type="button" class="pf-pw-eye"
280
+ onClick=${() => setShow(s => !s)}>${show ? (t('profile.landing.hidePassword') || 'Hide') : (t('profile.landing.showPassword') || 'Show')}</button>
281
+ </div>`;
282
+ }
283
+
187
284
  function ChangePasswordModal({ onClose, onChanged }) {
188
285
  const [current, setCurrent] = useState('');
189
286
  const [newPw, setNewPw] = useState('');
@@ -191,6 +288,16 @@ function ChangePasswordModal({ onClose, onChanged }) {
191
288
  const [saving, setSaving] = useState(false);
192
289
  const [err, setErr] = useState('');
193
290
 
291
+ // Live checklist — the requirements line becomes useful when it ticks green while typing.
292
+ const rules = [
293
+ { ok: newPw.length >= 8, label: t('profile.landing.pwMin') || 'At least 8 characters' },
294
+ { ok: /[A-Z]/.test(newPw), label: t('profile.landing.pwUpper') || 'An uppercase letter' },
295
+ { ok: /[a-z]/.test(newPw), label: t('profile.landing.pwLower') || 'A lowercase letter' },
296
+ { ok: /\d/.test(newPw), label: t('profile.landing.pwDigit') || 'A number' },
297
+ ];
298
+ const rulesOk = rules.every(r => r.ok);
299
+ const mismatch = confirm.length > 0 && newPw !== confirm;
300
+
194
301
  const save = async () => {
195
302
  setErr('');
196
303
  if (newPw !== confirm) {
@@ -225,28 +332,28 @@ function ChangePasswordModal({ onClose, onChanged }) {
225
332
  <div class="pf-edit-body">
226
333
  <label class="pf-edit-label">
227
334
  ${t('profile.landing.currentPassword')}
228
- <input type="password" class="pf-edit-input" value=${current}
229
- onInput=${(e) => setCurrent(e.target.value)} />
335
+ <${PwInput} value=${current} onInput=${(e) => setCurrent(e.target.value)} />
230
336
  </label>
231
337
  <label class="pf-edit-label">
232
338
  ${t('profile.landing.newPassword')}
233
- <input type="password" class="pf-edit-input" value=${newPw}
234
- onInput=${(e) => setNewPw(e.target.value)} />
339
+ <${PwInput} value=${newPw} onInput=${(e) => setNewPw(e.target.value)} />
235
340
  </label>
341
+ <ul class="pf-pw-rules">
342
+ ${rules.map(r => html`<li class=${r.ok ? 'ok' : ''} key=${r.label}>${r.ok ? '✓' : '○'} ${r.label}</li>`)}
343
+ </ul>
236
344
  <label class="pf-edit-label">
237
345
  ${t('profile.landing.confirmPassword')}
238
- <input type="password" class="pf-edit-input" value=${confirm}
239
- onInput=${(e) => setConfirm(e.target.value)} />
346
+ <${PwInput} value=${confirm} onInput=${(e) => setConfirm(e.target.value)} />
240
347
  </label>
241
- <div class="pf-edit-hint">${t('profile.landing.passwordRequirements')}</div>
348
+ ${mismatch ? html`<div class="pf-edit-error">${t('profile.landing.passwordMismatch')}</div>` : null}
242
349
  ${err && html`<div class="pf-edit-error">${err}</div>`}
243
350
  </div>
244
351
  <div class="pf-edit-footer">
245
352
  <button class="btn-outline" onClick=${onClose} disabled=${saving}>
246
353
  ${t('profile.landing.editCancel')}
247
354
  </button>
248
- <button class="btn-primary" onClick=${save} disabled=${saving || !current || !newPw || !confirm}>
249
- ${saving ? t('profile.landing.passwordChanging') : t('profile.landing.editSave')}
355
+ <button class="btn-primary" onClick=${save} disabled=${saving || !current || !rulesOk || !confirm || mismatch}>
356
+ ${saving ? t('profile.landing.passwordChanging') : (t('profile.landing.changePasswordBtn') || 'Change password')}
250
357
  </button>
251
358
  </div>
252
359
  </div>
@@ -254,27 +361,181 @@ function ChangePasswordModal({ onClose, onChanged }) {
254
361
  `;
255
362
  }
256
363
 
364
+ /* ───── Home dashboard cards ───── */
365
+
366
+ /* "Waiting for you" — everything that needs the user's decision, aggregated across organisms:
367
+ * pending publish approvals (per workspace), pending join requests (orgs they manage), and
368
+ * incoming organism invitations. Renders nothing when there is nothing to do. */
369
+ function WaitingForYou({ owner }) {
370
+ const [items, setItems] = useState(null);
371
+ const load = useCallback(async () => {
372
+ try {
373
+ const out = [];
374
+ const orgsResp = await orgService.listOrganisms({ member: owner }).catch(() => null);
375
+ const orgs = orgsResp?.data?.organisms || [];
376
+ await Promise.all(orgs.map(async (org) => {
377
+ const canManage = org.creatorGhii === owner || (org.admins || []).includes(owner);
378
+ const [aps, reqs] = await Promise.all([
379
+ orgService.listApprovals(org.id, 'pending').catch(() => []),
380
+ canManage ? orgService.listJoinRequests(org.id).catch(() => null) : Promise.resolve(null),
381
+ ]);
382
+ if ((aps || []).length > 0) {
383
+ const byWs = {};
384
+ for (const a of aps) { const w = a.arguments?.ws || ''; byWs[w] = (byWs[w] || 0) + 1; }
385
+ let names = {};
386
+ try { names = Object.fromEntries((await orgService.listWorkspaces(org.id)).map(w => [w.id, w.name])); } catch { /* ids will do */ }
387
+ for (const [wsId, n] of Object.entries(byWs)) {
388
+ out.push({ kind: 'review', n, orgId: org.id, orgName: org.name, wsId, wsName: names[wsId] || wsId });
389
+ }
390
+ }
391
+ const pend = ((reqs?.data?.join_requests) || []).filter(r => r.status === 'pending').length;
392
+ if (pend > 0) out.push({ kind: 'join', n: pend, orgId: org.id, orgName: org.name });
393
+ }));
394
+ const inv = await orgService.listMyInvitations().catch(() => null);
395
+ for (const e of (inv?.data?.invitations || [])) out.push({ kind: 'invite', orgName: e?.organism?.name || '' });
396
+ setItems(out);
397
+ } catch { setItems([]); }
398
+ }, [owner]);
399
+ useEffect(() => { load(); }, [load]);
400
+ const liveRef = useRef(load); liveRef.current = load;
401
+ useEffect(() => {
402
+ const h = () => liveRef.current();
403
+ window.addEventListener('aimeat-live-update', h);
404
+ return () => window.removeEventListener('aimeat-live-update', h);
405
+ }, []);
406
+
407
+ if (!items || items.length === 0) return null;
408
+ return html`
409
+ <div class="pf-waiting">
410
+ <div class="pf-waiting-title">${'📨 '}${t('profile.landing.waitingTitle') || 'Waiting for you'}</div>
411
+ ${items.map((it, i) => html`
412
+ <div class="pf-waiting-row" key=${i}>
413
+ <span class="pf-waiting-text">
414
+ ${it.kind === 'review' ? html`
415
+ <b>${(t('profile.landing.draftsToReview') || '{n} drafts to review').replace('{n}', String(it.n))}</b>
416
+ <span class="pf-waiting-where"> · ${escHtml(it.orgName)} / ${escHtml(it.wsName)}</span>` : null}
417
+ ${it.kind === 'join' ? html`
418
+ <b>${it.n === 1 ? (t('profile.landing.joinReqOne') || '1 join request') : (t('profile.landing.joinReqMany') || '{n} join requests').replace('{n}', String(it.n))}</b>
419
+ <span class="pf-waiting-where"> · ${escHtml(it.orgName)}</span>` : null}
420
+ ${it.kind === 'invite' ? html`
421
+ <b>${t('profile.landing.inviteWaiting') || 'You’re invited'}</b>
422
+ <span class="pf-waiting-where"> · ${escHtml(it.orgName)}</span>` : null}
423
+ </span>
424
+ ${it.kind === 'review' ? html`
425
+ <button class="btn-outline btn-sm" onClick=${() => (it.wsId ? gotoWorkspace(it.orgId, it.wsId, 'review') : gotoOrganism(it.orgId))}>${t('profile.landing.reviewBtn') || 'Review'}</button>` : null}
426
+ ${it.kind === 'join' ? html`
427
+ <button class="btn-outline btn-sm" onClick=${() => gotoOrganism(it.orgId, 'members')}>${t('profile.landing.viewBtn') || 'View'}</button>` : null}
428
+ ${it.kind === 'invite' ? html`
429
+ <button class="btn-outline btn-sm" onClick=${() => gotoOrganismsList()}>${t('profile.landing.viewBtn') || 'View'}</button>` : null}
430
+ </div>
431
+ `)}
432
+ </div>
433
+ `;
434
+ }
435
+
436
+ /* "Continue" — the last opened things across types (workspace / app / organism), with real
437
+ * display names. Backed by /js/recents.js (device-local). Renders nothing when empty. */
438
+ const RECENT_ICONS = { workspace: '🗂', app: '▦', organism: '🏢', board: '📋' };
439
+ function ContinueCard() {
440
+ const [items] = useState(() => listRecents(5));
441
+ if (!items.length) return null;
442
+ const openItem = (it) => {
443
+ if (it.type === 'workspace' && it.data?.orgId) gotoWorkspace(it.data.orgId, it.data.wsId);
444
+ else if (it.type === 'organism' && it.data?.orgId) gotoOrganism(it.data.orgId);
445
+ else if (it.type === 'app' && it.data?.filename) window.open(`/v1/apps/${encodeURIComponent(it.data.owner)}/${encodeURIComponent(it.data.filename)}?mode=inline`, '_blank');
446
+ };
447
+ return html`
448
+ <div class="pf-home-card">
449
+ <div class="pf-home-card-title">${t('profile.landing.continueTitle') || 'Continue'}</div>
450
+ ${items.map(it => html`
451
+ <button class="pf-home-row" key=${it.type + it.id} onClick=${() => openItem(it)}>
452
+ <span class="pf-home-row-ico">${RECENT_ICONS[it.type] || '•'}</span>
453
+ <span class="pf-home-row-label">${escHtml(it.label)}</span>
454
+ <span class="pf-home-row-meta">${relTime(it.at)}</span>
455
+ </button>
456
+ `)}
457
+ </div>
458
+ `;
459
+ }
460
+
461
+ /* "Agents" — who has been active today, who is idle, and the next scheduled run. */
462
+ function AgentsCard({ owner }) {
463
+ const [agents, setAgents] = useState(null);
464
+ const [nextJob, setNextJob] = useState(null);
465
+ const load = useCallback(async () => {
466
+ try {
467
+ const list = (await listAgents(owner)).filter(a => !String(a.name || '').startsWith('session-'));
468
+ list.sort((a, b) => String(b.last_seen || '').localeCompare(String(a.last_seen || '')));
469
+ setAgents(list);
470
+ } catch { setAgents([]); }
471
+ try {
472
+ const r = await listAllSchedules();
473
+ const all = [...(r?.data?.managed || []), ...(r?.data?.extensions || []), ...(r?.data?.agentInternal || [])]
474
+ .filter(s => s.enabled !== false && s.nextRunAt && new Date(s.nextRunAt).getTime() > Date.now())
475
+ .sort((a, b) => String(a.nextRunAt).localeCompare(String(b.nextRunAt)));
476
+ setNextJob(all[0] || null);
477
+ } catch { /* scheduler row is optional */ }
478
+ }, [owner]);
479
+ useEffect(() => { load(); }, [load]);
480
+ const liveRef = useRef(load); liveRef.current = load;
481
+ useEffect(() => {
482
+ const h = () => liveRef.current();
483
+ window.addEventListener('aimeat-live-update', h);
484
+ return () => window.removeEventListener('aimeat-live-update', h);
485
+ }, []);
486
+
487
+ if (!agents || (agents.length === 0 && !nextJob)) return null;
488
+ const todayStr = new Date().toDateString();
489
+ const isToday = (s) => s && new Date(s).toDateString() === todayStr;
490
+ const activeToday = agents.filter(a => isToday(a.last_seen)).length;
491
+ return html`
492
+ <div class="pf-home-card">
493
+ <button class="pf-home-card-title pf-home-card-link" onClick=${() => openProfileTab('agents')}>
494
+ ${t('profile.landing.agentsTitle') || 'Agents'}
495
+ ${activeToday > 0 ? html`<span class="pf-home-card-note"> · ${(t('profile.landing.activeTodayCount') || '{n} active today').replace('{n}', String(activeToday))}</span>` : null}
496
+ </button>
497
+ ${agents.slice(0, 3).map(a => html`
498
+ <button class="pf-home-row" key=${a.gaii || a.name}
499
+ onClick=${() => { try { sessionStorage.setItem('aimeat.agents.open', a.name); } catch { /* noop */ } openProfileTab('agents'); }}>
500
+ <span class="pf-home-row-ico">${'🤖'}</span>
501
+ <span class="pf-home-row-label">${escHtml(a.display_name || a.name)}</span>
502
+ <span class="pf-home-row-meta ${isToday(a.last_seen) ? 'pf-ok' : ''}">
503
+ ${a.last_seen ? (isToday(a.last_seen) ? (t('profile.landing.agentActiveToday') || 'active today') : relTime(a.last_seen)) : '—'}
504
+ </span>
505
+ </button>
506
+ `)}
507
+ ${nextJob ? html`
508
+ <button class="pf-home-row" key="nextjob" onClick=${() => openProfileTab('scheduler')}>
509
+ <span class="pf-home-row-ico">⏰</span>
510
+ <span class="pf-home-row-label">${escHtml(nextJob.name || nextJob.id || '')}</span>
511
+ <span class="pf-home-row-meta">${(t('profile.landing.nextRunAt') || 'next run {time}').replace('{time}', fmtClock(nextJob.nextRunAt))}</span>
512
+ </button>` : null}
513
+ </div>
514
+ `;
515
+ }
516
+
257
517
  /* ───── Sub-components ───── */
258
518
 
259
- function ProfileCard({ tier, stats, session, onEditProfile, onChangePassword }) {
519
+ function ProfileCard({ tier, stats, session, onEditProfile, switchTab }) {
260
520
  const NODE_URL = getNodeUrl();
261
521
  const isNew = tier === 'new';
262
522
  const isExperienced = tier === 'experienced';
263
523
  const avatarSvg = minidenticon(session.owner || 'user');
264
524
 
525
+ // Stats are NAVIGATION, not decoration \u2014 each one opens its own section.
526
+ const stat = (icon, val, labelKey, tabId, green) => html`
527
+ <button class="pf-lp-stat pf-lp-stat-link" onClick=${() => switchTab?.(tabId)}>
528
+ ${icon} <span class="pf-lp-stat-val${green ? ' pf-lp-stat-green' : ''}">${val}</span> ${t(labelKey)}
529
+ </button>`;
530
+
265
531
  return html`
266
532
  <div class="pf-lp-card">
267
533
  <div class="pf-lp-card-header">
268
- <div class="pf-lp-avatar" dangerouslySetInnerHTML=${{ __html: avatarSvg }}></div>
534
+ <div class="pf-lp-avatar" role="button" tabindex="0" title=${t('profile.landing.editProfile')}
535
+ onClick=${() => onEditProfile?.()} dangerouslySetInnerHTML=${{ __html: avatarSvg }}></div>
269
536
  <div class="pf-lp-info">
270
537
  <div class="pf-lp-name-row">
271
538
  <span class="pf-lp-name">${escHtml(session.displayName || session.owner)}</span>
272
- <a href="#" class="pf-lp-edit" onClick=${(e) => { e.preventDefault(); onEditProfile?.(); }}>
273
- ${t('profile.landing.editProfile')} \u2192
274
- </a>
275
- <a href="#" class="pf-lp-edit" onClick=${(e) => { e.preventDefault(); onChangePassword?.(); }}>
276
- ${t('profile.landing.changePassword')} \u2192
277
- </a>
278
539
  </div>
279
540
  <div class="pf-lp-ghii">${escHtml(session.ghii || '')}</div>
280
541
  <div class="pf-lp-node">${t('profile.node')}: ${escHtml(NODE_URL)}</div>
@@ -288,27 +549,21 @@ function ProfileCard({ tier, stats, session, onEditProfile, onChangePassword })
288
549
  </div>`
289
550
  }
290
551
  </div>
552
+ <button class="btn-outline btn-sm pf-lp-profile-btn" onClick=${() => onEditProfile?.()}>
553
+ ${t('profile.landing.profileBtn') || 'Profile'}</button>
291
554
  </div>
292
555
  <div class="pf-lp-stats">
293
556
  ${isNew ? html`
294
- ${stats.memory > 0 && html`
295
- <div class="pf-lp-stat">\u{1F9E0} <span class="pf-lp-stat-val">${stats.memory}</span> ${t('profile.stats.memories')}</div>
296
- `}
297
- ${(stats.balance != null && stats.balance !== '-' && stats.balance > 0) && html`
298
- <div class="pf-lp-stat">
299
- \u{1F48E} <span class="pf-lp-stat-val pf-lp-stat-green">${stats.balance}</span> ${t('profile.stats.morsels')}
300
- </div>
301
- `}
557
+ ${stats.memory > 0 && stat('\u{1F9E0}', stats.memory, 'profile.stats.memories', 'memory')}
558
+ ${(stats.balance != null && stats.balance !== '-' && stats.balance > 0)
559
+ && stat('\u{1F48E}', stats.balance, 'profile.stats.morsels', 'wallet', true)}
302
560
  ` : html`
303
- ${stats.apps > 0 && html`<div class="pf-lp-stat">\u{1F4F1} <span class="pf-lp-stat-val">${stats.apps}</span> ${t('profile.stats.apps')}</div>`}
304
- ${stats.memory > 0 && html`<div class="pf-lp-stat">\u{1F9E0} <span class="pf-lp-stat-val">${stats.memory}</span> ${t('profile.stats.memories')}</div>`}
305
- ${(stats.balance != null && stats.balance !== '-' && stats.balance > 0) && html`
306
- <div class="pf-lp-stat">\u{1F48E} <span class="pf-lp-stat-val pf-lp-stat-green">${stats.balance}</span> ${t('profile.stats.morsels')}</div>
307
- `}
308
- ${stats.services > 0 && html`<div class="pf-lp-stat">\u{1F50C} <span class="pf-lp-stat-val">${stats.services}</span> ${t('profile.stats.services')}</div>`}
309
- ${isExperienced && stats.agents > 0 && html`
310
- <div class="pf-lp-stat">\u{1F916} <span class="pf-lp-stat-val">${stats.agents}</span> ${t('profile.stats.agents')}</div>
311
- `}
561
+ ${stats.apps > 0 && stat('\u{1F4F1}', stats.apps, 'profile.stats.apps', 'apps')}
562
+ ${stats.memory > 0 && stat('\u{1F9E0}', stats.memory, 'profile.stats.memories', 'memory')}
563
+ ${(stats.balance != null && stats.balance !== '-' && stats.balance > 0)
564
+ && stat('\u{1F48E}', stats.balance, 'profile.stats.morsels', 'wallet', true)}
565
+ ${stats.services > 0 && stat('\u{1F50C}', stats.services, 'profile.stats.services', 'actions')}
566
+ ${isExperienced && stats.agents > 0 && stat('\u{1F916}', stats.agents, 'profile.stats.agents', 'agents')}
312
567
  `}
313
568
  </div>
314
569
  </div>
@@ -404,10 +659,15 @@ function GhostTiles({ switchTab }) {
404
659
  `;
405
660
  }
406
661
 
407
- function CortexSection({ switchTab }) {
662
+ /* Onboarding promo — shown only while the user has fewer than 3 apps, and dismissable for good.
663
+ * After that the same content lives on the Extensions page; for a seasoned user it was dead space. */
664
+ function CortexSection({ switchTab, onDismiss }) {
408
665
  return html`
409
- <div class="pf-landing-section">
410
- <div class="pf-menu-title">${t('profile.landing.cortexSectionTitle')}</div>
666
+ <div class="pf-landing-section pf-promo">
667
+ <div class="pf-menu-title">${t('profile.landing.cortexSectionTitle')}
668
+ <button class="pf-promo-dismiss" title=${t('profile.landing.promoDismiss') || 'Hide'}
669
+ onClick=${(e) => { e.stopPropagation(); onDismiss?.(); }}>✕</button>
670
+ </div>
411
671
  <div class="pf-cortex-grid">
412
672
  <div class="pf-cortex-card" onClick=${() => switchTab('extensions')}>
413
673
  <div class="pf-cortex-header">
@@ -426,48 +686,8 @@ function CortexSection({ switchTab }) {
426
686
  `;
427
687
  }
428
688
 
429
- function AppStrip({ apps, switchTab }) {
430
- const [expandedApp, setExpandedApp] = useState(null);
431
-
432
- const toggleApp = (filename) => {
433
- setExpandedApp(prev => prev === filename ? null : filename);
434
- };
435
-
436
- const launchApp = (e, app) => {
437
- e.stopPropagation();
438
- window.open(`/v1/apps/${app.owner}/${app.filename}?mode=inline`, '_blank');
439
- };
440
-
441
- return html`
442
- <div class="pf-app-strip">
443
- <div class="pf-app-strip-header">
444
- <div class="pf-menu-title">${t('profile.landing.myApps')}</div>
445
- <a href="#" class="pf-app-strip-link" onClick=${(e) => { e.preventDefault(); switchTab('apps'); }}>
446
- ${t('profile.landing.allApps')} \u2192
447
- </a>
448
- </div>
449
- <div class="pf-app-row">
450
- ${apps.length > 0 ? apps.map(app => html`
451
- <div class="pf-app-chip ${expandedApp === app.filename ? 'pf-app-chip-expanded' : ''}"
452
- key=${app.filename || app.name}
453
- onClick=${() => toggleApp(app.filename)}>
454
- <span class="pf-chip-icon">\u{1F4F1}</span>
455
- ${escHtml(app.name || app.filename || 'App')}
456
- ${expandedApp === app.filename && html`
457
- <button class="pf-chip-launch" onClick=${(e) => launchApp(e, app)}
458
- title=${t('profile.landing.openApp')} aria-label=${t('profile.landing.openApp')}>\u{1F517}</button>
459
- `}
460
- </div>
461
- `) : html`
462
- <div class="pf-app-chip pf-app-chip-ghost" onClick=${() => switchTab('packages')}>
463
- <span class="pf-chip-icon">+</span>
464
- ${t('profile.landing.installFirst')} \u2192
465
- </div>
466
- `}
467
- </div>
468
- </div>
469
- `;
470
- }
689
+ /* (AppStrip removed \u2014 the cross-type "Continue" card replaced it: raw filenames in a horizontal
690
+ * scroller duplicated the Apps tab and read as a file listing.) */
471
691
 
472
692
  /* ───── Generic layout components ───── */
473
693
 
@@ -521,46 +741,62 @@ function InlineView({ tabId, label, onClose, renderTab }) {
521
741
  * Every tab is always present and grouped into stable sections — no activity-based
522
742
  * hiding, so humans and agentic developers can predict where each tab lives.
523
743
  * Group titles reuse existing i18n keys; tab labels reuse profile.tabs.* / *.tabLabel. */
744
+ /* Grouping follows the information-refinement pipeline and usage frequency, not an
745
+ * abstract taxonomy (the old Daily/Personal/Technical groups are gone). Badges remain
746
+ * reserved for action-required counts only — never static totals. */
524
747
  const SIDEBAR_GROUPS = [
525
- { titleKey: 'profile.landing.menuDaily', items: [
526
- { id: 'agents', icon: '\u{1F916}', labelKey: 'profile.tabs.agents', badgeStat: 'agents' },
527
- { id: 'scheduler', icon: '⏰', labelKey: 'profile.tabs.scheduler' },
748
+ { titleKey: 'profile.landing.menuInformation', items: [ // raw → curated → governed
749
+ { id: 'organisms', icon: '\u{1F3E2}', labelKey: 'profile.tabs.organisms' },
528
750
  { id: 'memory', icon: '\u{1F9E0}', labelKey: 'profile.tabs.memory' },
529
- { id: 'boards', icon: '\u{1F4CB}', labelKey: 'profile.tabs.boards' },
530
751
  { id: 'knowledge', icon: '\u{1F4DA}', labelKey: 'knowledge.tabLabel' },
752
+ { id: 'boards', icon: '\u{1F4CB}', labelKey: 'profile.tabs.boards' },
753
+ ] },
754
+ { titleKey: 'profile.landing.menuAutomation', items: [ // agents + their infrastructure
755
+ { id: 'agents', icon: '\u{1F916}', labelKey: 'profile.tabs.agents' },
756
+ { id: 'offers', icon: '\u{1F9F0}', labelKey: 'profile.tabs.offers' },
757
+ { id: 'scheduler', icon: '⏰', labelKey: 'profile.tabs.scheduler' },
758
+ { id: 'actions', icon: '\u{1F6E0}️', labelKey: 'profile.tabs.services' },
759
+ { id: 'mcp', icon: '\u{1F517}', labelKey: 'profile.tabs.mcp' },
760
+ ] },
761
+ { titleKey: 'profile.landing.menuActivity', items: [ // communication + events
531
762
  { id: 'notifications', icon: '\u{1F514}', labelKey: 'profile.tabs.notifications' },
532
- { id: 'apps', icon: '⚙️', labelKey: 'profile.tabs.apps' },
763
+ { id: 'email', icon: '\u{1F4E7}', labelKey: 'profile.tabs.email' },
764
+ { id: 'chatsessions', icon: '\u{1F4AC}', labelKey: 'profile.tabs.chatSessions' },
533
765
  ] },
534
766
  { titleKey: 'profile.landing.menuBuildShare', items: [
767
+ { id: 'apps', icon: '⚙️', labelKey: 'profile.tabs.apps' },
535
768
  { id: 'generator', icon: '\u{1F534}', labelKey: 'profile.generator.tabLabel' },
536
- { id: 'foundry', icon: '\u{1F3ED}', labelKey: 'profile.foundry.tabLabel' },
537
- { id: 'calibrator', icon: '\u{1F3AF}', labelKey: 'profile.calibrator.tabLabel' },
769
+ /* foundry removed from the menu 2026-06-10 (owner: not in use). The tab module and
770
+ * its route id still exist — restore by re-adding this item. */
538
771
  { id: 'extensions', icon: '\u{1F50C}', labelKey: 'profile.tabs.extensions' },
539
772
  { id: 'capabilities', icon: '⚡', labelKey: 'capabilities.tabLabel' },
540
- { id: 'portfolio', icon: '\u{1F3A8}', labelKey: 'portfolio.tabLabel' },
541
773
  { id: 'packages', icon: '\u{1F4E6}', labelKey: 'profile.tabs.packages' },
542
- ] },
543
- { titleKey: 'profile.landing.menuTechnical', items: [
544
- { id: 'actions', icon: '\u{1F6E0}️', labelKey: 'profile.tabs.services' },
774
+ { id: 'portfolio', icon: '\u{1F3A8}', labelKey: 'portfolio.tabLabel' },
775
+ { id: 'calibrator', icon: '\u{1F3AF}', labelKey: 'profile.calibrator.tabLabel' },
776
+ /* TODO(owner 2026-06-10): "work" placement is undecided — parked at the bottom of
777
+ * Build & Share until re-evaluated. */
545
778
  { id: 'work', icon: '\u{1F4CB}', labelKey: 'profile.tabs.work' },
546
- { id: 'mcp', icon: '\u{1F517}', labelKey: 'profile.tabs.mcp' },
547
- { id: 'chatsessions', icon: '\u{1F4AC}', labelKey: 'profile.tabs.chatSessions' },
548
779
  ] },
549
- { titleKey: 'profile.landing.menuPersonal', items: [
780
+ { titleKey: 'profile.landing.menuAccount', items: [
550
781
  { id: 'wallet', icon: '\u{1F48E}', labelKey: 'profile.tabs.wallet' },
551
- { id: 'email', icon: '\u{1F4E7}', labelKey: 'profile.tabs.email' },
552
- { id: 'access', icon: '\u{1F510}', labelKey: 'profile.tabs.access' },
553
- { id: 'organisms', icon: '\u{1F3E2}', labelKey: 'profile.tabs.organisms' },
554
782
  { id: 'dataWallet', icon: '\u{1F512}', labelKey: 'profile.tabs.dataWallet' },
783
+ { id: 'access', icon: '\u{1F510}', labelKey: 'profile.tabs.access' },
555
784
  ] },
556
- { titleKey: 'profile.landing.menuInfra', items: [
785
+ /* Operator-only: the group AND its routes are gated on the operator role (open()
786
+ * refuses these ids for non-operators; the underlying APIs enforce server-side).
787
+ * nodeStats left the menu — it lives as a tab on the Nodes page now. */
788
+ { titleKey: 'profile.landing.menuInfra', adminOnly: true, items: [
557
789
  { id: 'federation', icon: '\u{1F310}', labelKey: 'profile.tabs.federation' },
558
790
  { id: 'nodes', icon: '\u{1F5A5}️', labelKey: 'profile.tabs.nodes' },
559
- { id: 'nodeStats', icon: '\u{1F4CA}', labelKey: 'profile.tabs.nodeStats' },
560
791
  { id: 'security', icon: '\u{1F6E1}️', labelKey: 'profile.tabs.security' },
561
792
  ] },
562
793
  ];
563
794
 
795
+ // Flat item lookup (pinned section renders items by id).
796
+ const SIDEBAR_ITEM_BY_ID = Object.fromEntries(SIDEBAR_GROUPS.flatMap(g => g.items.map(it => [it.id, it])));
797
+ const INFRA_TAB_IDS = new Set(['federation', 'nodes', 'nodeStats', 'security']);
798
+ const DEFAULT_PINS = ['organisms', 'agents', 'memory', 'scheduler'];
799
+
564
800
  /* ───── Main landing page ───── */
565
801
 
566
802
  export default function LandingPage({ tier, stats, session, navigate, showToast, locale, renderTab, getTabLabel }) {
@@ -583,6 +819,9 @@ export default function LandingPage({ tier, stats, session, navigate, showToast,
583
819
  });
584
820
  const [expanded, setExpanded] = useState(false);
585
821
  const [drawerOpen, setDrawerOpen] = useState(false);
822
+ // Extensions promo: onboarding-only (apps < 3) and dismissable for good.
823
+ const [showPromo, setShowPromo] = useState(() => { try { return localStorage.getItem('aimeat.cortexPromoDismissed') !== '1'; } catch { return true; } });
824
+ const dismissPromo = () => { setShowPromo(false); try { localStorage.setItem('aimeat.cortexPromoDismissed', '1'); } catch { /* noop */ } };
586
825
 
587
826
  const owner = session.owner;
588
827
 
@@ -606,7 +845,13 @@ export default function LandingPage({ tier, stats, session, navigate, showToast,
606
845
  }, []);
607
846
 
608
847
  /* Open a tab inline below a specific slot. Toggle if same tab clicked again. */
848
+ const isOperator = (session?.roles || []).includes('operator');
849
+
609
850
  const open = useCallback((tabId, slot) => {
851
+ // Infrastructure tabs are operator-only — refuse them here too so deep links
852
+ // (sessionStorage restore, aimeat-open-tab events) can't open the views.
853
+ // The underlying APIs enforce the role server-side regardless.
854
+ if (INFRA_TAB_IDS.has(tabId) && !isOperator) return;
610
855
  setOpenView(prev => {
611
856
  const next = (prev?.tabId === tabId) ? null : { tabId, slot };
612
857
  if (next) {
@@ -616,7 +861,39 @@ export default function LandingPage({ tier, stats, session, navigate, showToast,
616
861
  }
617
862
  return next;
618
863
  });
864
+ }, [isOperator]);
865
+
866
+ /* ── Pinned items: 3–5 favourites under Home, persisted per user (memory key
867
+ `sidebar.pins`, same pattern as organisms.ui). Defaults for new users. ── */
868
+ const [pins, setPins] = useState(DEFAULT_PINS);
869
+ useEffect(() => {
870
+ getMemory('sidebar.pins')
871
+ .then(r => { const v = r?.data?.value; if (Array.isArray(v) && v.length) setPins(v.filter(id => SIDEBAR_ITEM_BY_ID[id])); })
872
+ .catch(() => { /* defaults stand */ });
619
873
  }, []);
874
+ const togglePin = (id) => {
875
+ setPins(prev => {
876
+ let next;
877
+ if (prev.includes(id)) next = prev.filter(x => x !== id);
878
+ else if (prev.length >= 5) { showToast?.(t('profile.landing.pinLimit') || 'Max 5 pinned items'); return prev; }
879
+ else next = [...prev, id];
880
+ createMemory('sidebar.pins', next, 'private').catch(() => {});
881
+ return next;
882
+ });
883
+ };
884
+
885
+ /* ── Group collapse/expand, remembered per browser. ── */
886
+ const [collapsedGroups, setCollapsedGroups] = useState(() => {
887
+ try { return new Set(JSON.parse(localStorage.getItem('aimeat.sidebar.collapsed') || '[]')); } catch { return new Set(); }
888
+ });
889
+ const toggleGroup = (key) => {
890
+ setCollapsedGroups(prev => {
891
+ const next = new Set(prev);
892
+ if (next.has(key)) next.delete(key); else next.add(key);
893
+ try { localStorage.setItem('aimeat.sidebar.collapsed', JSON.stringify([...next])); } catch { /* noop */ }
894
+ return next;
895
+ });
896
+ };
620
897
 
621
898
  const close = useCallback(() => {
622
899
  sessionStorage.removeItem('aimeat-profile-tab');
@@ -659,6 +936,7 @@ export default function LandingPage({ tier, stats, session, navigate, showToast,
659
936
  session=${session}
660
937
  onClose=${() => setEditOpen(false)}
661
938
  onSaved=${() => { setEditOpen(false); showToast?.(t('profile.landing.editSaved')); }}
939
+ onChangePassword=${() => { setEditOpen(false); setPwOpen(true); }}
662
940
  />`}
663
941
 
664
942
  ${pwOpen && html`<${ChangePasswordModal}
@@ -671,7 +949,7 @@ export default function LandingPage({ tier, stats, session, navigate, showToast,
671
949
 
672
950
  <aside class="pf-sidebar">
673
951
  <div class="pf-side-identity">
674
- <div class="pf-side-avatar"></div>
952
+ <div class="pf-side-avatar" dangerouslySetInnerHTML=${{ __html: minidenticon(owner || 'user') }}></div>
675
953
  <div class="pf-side-id-name">${session.displayName || owner}</div>
676
954
  </div>
677
955
 
@@ -680,20 +958,41 @@ export default function LandingPage({ tier, stats, session, navigate, showToast,
680
958
  <span class="pf-side-ico">\u{1F3E0}</span><span class="pf-side-label">${t('profile.landing.home')}</span>
681
959
  </button>
682
960
 
683
- ${SIDEBAR_GROUPS.map(g => html`
684
- <div class="pf-side-group">
685
- <div class="pf-side-group-title">${t(g.titleKey)}</div>
686
- ${g.items.map(it => html`
687
- <button class="pf-side-item${isOpen(it.id) ? ' pf-side-item--active' : ''}"
688
- onClick=${() => { open(it.id, 'main'); setDrawerOpen(false); }}>
689
- <span class="pf-side-ico">${it.icon}</span>
690
- <span class="pf-side-label">${t(it.labelKey)}</span>
691
- ${it.badgeStat && typeof stats?.[it.badgeStat] === 'number' && stats[it.badgeStat] > 0
692
- ? html`<span class="pf-side-badge">${stats[it.badgeStat]}</span>` : null}
693
- </button>
694
- `)}
695
- </div>
696
- `)}
961
+ ${(() => {
962
+ const renderItem = (it, pinned) => html`
963
+ <button class="pf-side-item${isOpen(it.id) ? ' pf-side-item--active' : ''}" key=${(pinned ? 'pin-' : '') + it.id}
964
+ onClick=${() => { open(it.id, 'main'); setDrawerOpen(false); }}>
965
+ <span class="pf-side-ico">${it.icon}</span>
966
+ <span class="pf-side-label">${t(it.labelKey)}</span>
967
+ ${it.badgeStat && typeof stats?.[it.badgeStat] === 'number' && stats[it.badgeStat] > 0
968
+ ? html`<span class="pf-side-badge">${stats[it.badgeStat]}</span>` : null}
969
+ <span class="pf-side-pin${pins.includes(it.id) ? ' pf-side-pin--on' : ''}"
970
+ role="button" tabindex="-1"
971
+ title=${pins.includes(it.id) ? (t('profile.landing.pinRemove') || 'Unpin') : (t('profile.landing.pinAdd') || 'Pin')}
972
+ onClick=${(e) => { e.stopPropagation(); togglePin(it.id); }}>📌</span>
973
+ </button>`;
974
+ const pinnedItems = pins.map(id => SIDEBAR_ITEM_BY_ID[id]).filter(Boolean)
975
+ .filter(it => !(INFRA_TAB_IDS.has(it.id) && !isOperator));
976
+ return html`
977
+ ${pinnedItems.length > 0 && html`
978
+ <div class="pf-side-group">
979
+ <div class="pf-side-group-title">${t('profile.landing.menuPinned') || 'Pinned'}</div>
980
+ ${pinnedItems.map(it => renderItem(it, true))}
981
+ </div>
982
+ `}
983
+ ${SIDEBAR_GROUPS.filter(g => !g.adminOnly || isOperator).map(g => {
984
+ const collapsed = collapsedGroups.has(g.titleKey);
985
+ return html`
986
+ <div class="pf-side-group" key=${g.titleKey}>
987
+ <button class="pf-side-group-title pf-side-group-toggle" onClick=${() => toggleGroup(g.titleKey)}>
988
+ <span class="pf-chevron ${collapsed ? '' : 'pf-chevron-open'}">▼</span> ${t(g.titleKey)}
989
+ </button>
990
+ ${!collapsed && g.items.map(it => renderItem(it, false))}
991
+ </div>
992
+ `;
993
+ })}
994
+ `;
995
+ })()}
697
996
  </aside>
698
997
 
699
998
  <main class="pf-content">
@@ -705,12 +1004,17 @@ export default function LandingPage({ tier, stats, session, navigate, showToast,
705
1004
  ` : html`
706
1005
  <${ProfileCard} tier=${tier} stats=${stats} session=${session}
707
1006
  onEditProfile=${() => setEditOpen(true)}
708
- onChangePassword=${() => setPwOpen(true)} />
1007
+ switchTab=${(id) => open(id, 'main')} />
709
1008
  ${isNew ? html`<${HeroOnboarding} switchTab=${(id) => open(id, 'main')} />` : null}
710
1009
  ${(isNew || isActive) ? html`<${KnowledgeCallout} switchTab=${() => open('knowledge', 'main')} />` : null}
711
1010
  ${isNew ? html`<${GhostTiles} switchTab=${() => open('packages', 'main')} />` : null}
712
- <${CortexSection} switchTab=${() => open('extensions', 'main')} />
713
- <${AppStrip} apps=${apps} switchTab=${(id) => open(id, 'main')} />
1011
+ <${WaitingForYou} owner=${owner} />
1012
+ <div class="pf-home-grid">
1013
+ <${ContinueCard} />
1014
+ <${AgentsCard} owner=${owner} />
1015
+ </div>
1016
+ ${(showPromo && apps.length < 3) ? html`
1017
+ <${CortexSection} switchTab=${() => open('extensions', 'main')} onDismiss=${dismissPromo} />` : null}
714
1018
  `}
715
1019
  </main>
716
1020
  </div>