aimeat 1.23.0 → 1.23.2

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.
@@ -73,13 +73,77 @@
73
73
  * a legacy unsupported value like 'knowledge') render as a notice card instead of being filtered
74
74
  * out — published content disappearing without a trace is how the invisible-documents bug hid for
75
75
  * a month. Missing backing counts as memory; kind:'document' without mode renders as a doc space.
76
+ * v1.18.0 — 2026-06-10 — Workspace renew, phase 2: manifest-driven tabs — one tab per declared
77
+ * space (record/doc/unsupported-backing, manifest order, item counts) + fixed Review (approvals
78
+ * queue + publish-gate toggle, with a banner when items wait) / Activity (heatmap + decisions) /
79
+ * People (ParticipantsPanel) + a "+" pseudo-tab for add-space. Header gains a "For agents" menu
80
+ * (the 3 AI prompts) and Settings absorbs share / sources / edit-flow chart alongside the
81
+ * manifest form, spaces, restructure and danger zone. Old toolbar removed; nothing hidden —
82
+ * every former panel lives in a tab, the header menu, or Settings.
83
+ * v1.19.0 — 2026-06-10 — Organism-home tab contents redesigned (user feedback round): Agents tab
84
+ * gets OrgAgentsPanel — picker of own agents (free-text GAII behind "Advanced"), activity
85
+ * context per row. Workspaces tab: list first (map behind a 🗺 toggle), enriched rows
86
+ * (record/doc counts, last event, review pill), delete moved into the "…" menu, compact content
87
+ * search above the list. Members tab flipped: roster rows first (role badge, access line,
88
+ * joined date, "…" menu), "+ Invite" toggle, pending requests only when present + tab pill.
89
+ * Board tab: embedded preview (latest posts + composer) instead of a bare UUID + link. Red
90
+ * intra-tab titles dropped (the tab IS the title).
91
+ * v1.20.0 — 2026-06-10 — Organism Settings form redesigned: every field has a persistent label,
92
+ * grouped Identity / Access (with per-choice gray hints for join policy + visibility), interests
93
+ * as a TagInput (chips), read-only metadata collapsed to one line (Created · Creator · Board+copy;
94
+ * members/max moved to the Members tab), Save disabled until dirty (doubles as the unsaved
95
+ * indicator) + discard guard on settings-close/breadcrumb. Danger zone is its own red box with
96
+ * typed-name confirmation that states exactly what gets deleted (counted workspaces/records/docs).
97
+ * v1.20.1 — 2026-06-10 — Workspace breadcrumb unified with the organism home's: Organisms / {org} /
98
+ * {workspace} (registry name), both ancestors clickable — the list is one click away from inside
99
+ * a workspace (new onBackToList). Old "← Workspaces / 🧬 org" button row removed.
100
+ * v1.20.2 — 2026-06-10 — Fix: settings form opened EMPTY when the home mounted from an {id}-only
101
+ * stub (F5 before the list loaded) — the form now re-syncs whenever fresher org data arrives, as
102
+ * long as the user hasn't touched the fields. Workspace rows gain a clickable 👥 participants
103
+ * counter that expands an inline people list (like the access-request inbox).
104
+ * v1.21.0 — 2026-06-10 — Workspace settings adopts the organism-settings form pattern: meta line
105
+ * (template/last-saved), Identity + Agent policy groups, an autonomy hint, dirty-gated "Save
106
+ * changes" (no longer closes the panel) + reset-Cancel + discard guard on the ⚙ toggle (the
107
+ * populate effect only re-seeds untouched fields, so live updates never clobber typing). Spaces/
108
+ * Public-sharing headings restyled to group labels. Workspace-row counts get real singular/
109
+ * plural forms ("1 tietue", "{n} tietuetta").
110
+ * v1.22.0 — 2026-06-10 — SchemaForm usability: new records pre-fill `id` (editable, hinted) and
111
+ * requester-style fields (x-default:"currentUser" or requested_by/created_by/author names) with
112
+ * the signed-in GHII; Save draft is always clickable and a failed attempt outlines the still-empty
113
+ * required fields + lists them; readOnly (agent-filled) properties are hidden from the form and
114
+ * excluded from required; per-field hints from manifest i18n / schema description. Generated
115
+ * manifests can carry translations at manifest.i18n.{lang} ("{ns}.{field}", "{ns}.{field}.hint",
116
+ * "type.{name}") — used for form labels, record read-view labels, space tab + section titles.
117
+ * v1.23.0 — 2026-06-10 — Sources and Share are fixed workspace TABS (every workspace has them) —
118
+ * both had sunk into Settings, which read as "dropped entirely". Share lazy-loads on first tab
119
+ * open; Settings keeps form/spaces/chart/restructure/danger only. Fixed double-escaping: htm/
120
+ * preact already escapes text nodes, so every ${'${'}escHtml(...)} printed literal & — all 75
121
+ * wrappers removed (titles like "Design & Architecture" now render correctly).
122
+ * v1.24.0 — 2026-06-10 — Members tab lists each member's agents (🤖 line, from the enriched members
123
+ * route) — same-owner agents inherit the membership implicitly, so "who can act here" is now
124
+ * visible instead of an unlisted-access audit gap.
125
+ * v1.25.0 — 2026-06-10 — Settings no longer lies about the tab state: it REPLACES the tab content
126
+ * (no tab highlighted while open; clicking a tab closes it via the dirty guard; breadcrumb gains
127
+ * "/ Settings") in BOTH the workspace and the organism home. Workspace settings reordered:
128
+ * savable form bounded in its own card (Identity + Agent policy + Save), Spaces gets an
129
+ * immediate-actions hint, Process group title for the edit-flow chart + restructure, dividers
130
+ * between groups, danger zone last with nothing after it. Autonomy options show meanings
131
+ * ("L3 — balanced"). "For agents" menu → "Agent access" with verb items ("Copy chat prompt",
132
+ * "Copy coding agent prompt") and a divider before "Create contract agent" (KebabMenu supports
133
+ * { divider: true }). Space tab labels capitalized to match the fixed tabs.
134
+ * v1.25.1 — 2026-06-10 — Manual space adding is DOCUMENT-ONLY (the "+" tab panel and the Settings
135
+ * add form lost their document/records selects): a hand-added record type would have no usable
136
+ * schema — record types are designed with AI via Settings → Process (restructure). The panel
137
+ * text says so.
138
+ * v1.25.2 — 2026-06-10 — Settings Cancel works again: it discards changes AND closes the panel
139
+ * (both organism + workspace settings) — the dirty-only reset variant did nothing visible on a
140
+ * clean form, which read as a dead button.
76
141
  */
77
142
  import { h } from 'preact';
78
143
  import { useState, useEffect, useCallback, useRef, useMemo } from 'preact/hooks';
79
144
  import htm from 'htm';
80
145
  const html = htm.bind(h);
81
146
  import { t, getLocale } from '/js/i18n.js';
82
- import { escHtml } from '/js/utils.js';
83
147
  import { Spinner, VisibilityPill } from './shared.js';
84
148
  import { useConfirm } from '/components/Modal.js';
85
149
  import { EmptyState } from '/components/EmptyState.js';
@@ -88,6 +152,9 @@ import { SearchBar } from '/components/SearchBar.js';
88
152
  import * as orgService from '/js/services/organisms.js';
89
153
  import * as memoryService from '/js/services/memory.js';
90
154
  import * as knowledgeService from '/js/services/knowledge.js';
155
+ import { listAgents } from '/js/services/agents.js';
156
+ import { getGhii } from '/js/services/auth.js';
157
+ import { listPosts, createPost } from '/js/services/boards.js';
91
158
  import { OpenRouterSettings } from './generator-settings.js';
92
159
  import { copyToClipboard } from '/js/utils.js';
93
160
  import { dt, fmtBytes } from '/js/format.js';
@@ -99,6 +166,19 @@ function fmtDate(s) {
99
166
  return new Date(s).toLocaleDateString(getLocale() === 'fi' ? 'fi-FI' : undefined);
100
167
  }
101
168
 
169
+ /** Relative time for list metadata ("2 h ago" / "2 h sitten"); falls back to a locale date past 7 days. */
170
+ function relTime(s) {
171
+ const ts = new Date(s).getTime();
172
+ if (!Number.isFinite(ts)) return '';
173
+ const mins = Math.max(0, Math.round((Date.now() - ts) / 60000));
174
+ if (mins < 60) return (t('organisms.relMin') || '{n} min ago').replace('{n}', String(mins));
175
+ const hours = Math.round(mins / 60);
176
+ if (hours < 24) return (t('organisms.relHours') || '{n} h ago').replace('{n}', String(hours));
177
+ const days = Math.round(hours / 24);
178
+ if (days <= 7) return (t('organisms.relDays') || '{n} d ago').replace('{n}', String(days));
179
+ return fmtDate(s);
180
+ }
181
+
102
182
  /** Two-letter monogram for the list/home avatar (initials of the first two words, else first two chars). */
103
183
  function orgInitials(name) {
104
184
  const s = String(name || '?').trim();
@@ -107,8 +187,9 @@ function orgInitials(name) {
107
187
  return ini.charAt(0).toUpperCase() + (ini.charAt(1) || '').toLowerCase();
108
188
  }
109
189
 
110
- /** "…" actions menu — items: { label, icon?, danger?, onClick } (falsy items are skipped). Closes on outside click. */
111
- function KebabMenu({ items, label }) {
190
+ /** "…" actions menu — items: { label, icon?, danger?, onClick } (falsy items are skipped). Closes on
191
+ * outside click. Default trigger is a ⋮ icon button; pass trigger/btnClass for a labelled button. */
192
+ function KebabMenu({ items, label, trigger, btnClass }) {
112
193
  const [open, setOpen] = useState(false);
113
194
  const ref = useRef(null);
114
195
  useEffect(() => {
@@ -121,17 +202,39 @@ function KebabMenu({ items, label }) {
121
202
  if (visible.length === 0) return null;
122
203
  return html`
123
204
  <div class="pj-menu" ref=${ref}>
124
- <button class="pj-icon-btn pj-menu-btn" title=${label} aria-haspopup="menu" aria-expanded=${open}
125
- onClick=${(e) => { e.stopPropagation(); setOpen(o => !o); }}>${'⋮'}</button>
205
+ <button class=${btnClass || 'pj-icon-btn pj-menu-btn'} title=${label} aria-haspopup="menu" aria-expanded=${open}
206
+ onClick=${(e) => { e.stopPropagation(); setOpen(o => !o); }}>${trigger || '⋮'}</button>
126
207
  ${open ? html`
127
208
  <div class="pj-menu-pop" role="menu" onClick=${(e) => e.stopPropagation()}>
128
- ${visible.map(it => html`
209
+ ${visible.map((it, i) => it.divider
210
+ ? html`<div class="pj-menu-sep" key=${'sep' + i}></div>`
211
+ : html`
129
212
  <button class="pj-menu-item ${it.danger ? 'pj-menu-item-danger' : ''}" role="menuitem" key=${it.label}
130
213
  onClick=${() => { setOpen(false); it.onClick?.(); }}>${it.icon ? `${it.icon} ` : ''}${it.label}</button>`)}
131
214
  </div>` : null}
132
215
  </div>`;
133
216
  }
134
217
 
218
+ /** Tag chips + inline input — Enter/comma adds, × or Backspace-on-empty removes, blur commits.
219
+ * Shows immediately how the value parses (vs. a raw "comma separated" text field). */
220
+ function TagInput({ tags, onChange, placeholder }) {
221
+ const [val, setVal] = useState('');
222
+ const add = () => { const v = val.trim().replace(/,+$/, ''); if (v && !tags.includes(v)) onChange([...tags, v]); setVal(''); };
223
+ const onKey = (e) => {
224
+ if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); add(); }
225
+ else if (e.key === 'Backspace' && !val && tags.length) onChange(tags.slice(0, -1));
226
+ };
227
+ return html`
228
+ <div class="pj-taginput">
229
+ ${tags.map(tag => html`
230
+ <span class="file-tag pj-tag" key=${tag}>${(tag)}
231
+ <button class="pj-tag-x" title="×" onClick=${() => onChange(tags.filter(x => x !== tag))}>×</button>
232
+ </span>`)}
233
+ <input class="pj-taginput-field" value=${val} placeholder=${placeholder || 'Add…'}
234
+ onInput=${(e) => setVal(e.target.value)} onKeyDown=${onKey} onBlur=${add} />
235
+ </div>`;
236
+ }
237
+
135
238
  /** Download a whole-organism ZIP backup (used from the list "…" menu and the home header). */
136
239
  async function exportOrganismZip(org, showToast) {
137
240
  try {
@@ -168,10 +271,9 @@ function OrgSearch({ orgId, onOpenWorkspace }) {
168
271
  };
169
272
 
170
273
  return html`
171
- <div class="card-detail">
172
- <div class="section-title">${t('organisms.searchContent') || 'Search content'}</div>
274
+ <div class="pj-orgsearch">
173
275
  <div class="flex-row-wrap">
174
- <input class="input-field input-sm" placeholder=${t('organisms.searchPlaceholder') || 'Find records & documents…'} value=${q}
276
+ <input class="input-field input-sm pj-orgsearch-input" placeholder=${t('organisms.searchPlaceholder') || 'Find records & documents…'} value=${q}
175
277
  onInput=${(e) => setQ(e.target.value)} onKeyDown=${(e) => { if (e.key === 'Enter') doSearch(); }} />
176
278
  <button class="btn-outline btn-sm" disabled=${busy || q.trim().length < 2} onClick=${doSearch}>${t('organisms.search') || 'Search'}</button>
177
279
  </div>
@@ -179,9 +281,9 @@ function OrgSearch({ orgId, onOpenWorkspace }) {
179
281
  ${(results || []).map(r => html`
180
282
  <div class="pj-access-row card-clickable" key=${r.ws + '/' + r.space + '/' + r.id} role="button" onClick=${() => onOpenWorkspace?.(r.ws)}>
181
283
  <span>
182
- <b>${escHtml(r.title)}</b>
183
- <span class="pj-mini"> — ${escHtml(r.wsName || r.ws)} · ${escHtml(r.space)}</span>
184
- <div class="pj-mini">${escHtml(r.snippet)}</div>
284
+ <b>${(r.title)}</b>
285
+ <span class="pj-mini"> — ${(r.wsName || r.ws)} · ${(r.space)}</span>
286
+ <div class="pj-mini">${(r.snippet)}</div>
185
287
  </span>
186
288
  </div>
187
289
  `)}
@@ -224,7 +326,7 @@ function IncomingInvitations({ showToast, onChanged }) {
224
326
  <div class="section-title">${t('organisms.youAreInvited') || 'You’re invited'}</div>
225
327
  ${invites.map(({ membership, organism }) => html`
226
328
  <div class="pj-access-row" key=${organism.id}>
227
- <span><b>${escHtml(organism.name)}</b>${membership.invitedBy ? html` <span class="pj-mini">— ${(t('organisms.invitedByLabel') || 'invited by {who}').replace('{who}', escHtml(membership.invitedBy))}</span>` : null}</span>
329
+ <span><b>${(organism.name)}</b>${membership.invitedBy ? html` <span class="pj-mini">— ${(t('organisms.invitedByLabel') || 'invited by {who}').replace('{who}', (membership.invitedBy))}</span>` : null}</span>
228
330
  <span class="flex-row-wrap">
229
331
  <button class="btn-success btn-sm" disabled=${busy} onClick=${() => act(organism.id, true)}>${t('organisms.acceptInvite') || 'Accept'}</button>
230
332
  <button class="btn-ghost btn-sm" disabled=${busy} onClick=${() => act(organism.id, false)}>${t('organisms.declineInvite') || 'Decline'}</button>
@@ -243,8 +345,128 @@ function IncomingInvitations({ showToast, onChanged }) {
243
345
  * parent list via onChanged so member counts stay current.
244
346
  */
245
347
  /**
246
- * Member + agent management. `show` limits the rendered sections: 'members' (invite, pending
247
- * requests, roster, blocked) or 'agents' (attached agents); omitted = both (legacy layout).
348
+ * Agents tab — attached agents listed first; "+ Attach agent" opens a picker of the user's OWN
349
+ * agents (the node knows them — no GAII syntax needed), with free-text attach-by-ID behind an
350
+ * "Advanced" link for cross-node agents. Each row shows where the agent has acted (aggregated
351
+ * from the accessible workspaces' activity feeds) or, for own agents, when it was last active.
352
+ */
353
+ function OrgAgentsPanel({ org, ghii, canManage, showToast, onChanged }) {
354
+ const orgId = org.id;
355
+ const attached = org.agentGaiis || [];
356
+ const [mine, setMine] = useState(null); // own agents (picker) — null = loading
357
+ const [pick, setPick] = useState('');
358
+ const [showAttach, setShowAttach] = useState(false);
359
+ const [showAdvanced, setShowAdvanced] = useState(false);
360
+ const [agentId, setAgentId] = useState('');
361
+ const [busy, setBusy] = useState(false);
362
+ const [acted, setActed] = useState({}); // agent name → { count, lastAt, ws: Set }
363
+
364
+ useEffect(() => {
365
+ listAgents(ghii).then(a => setMine((a || []).filter(x => !String(x.name || '').startsWith('session-')))).catch(() => setMine([]));
366
+ }, [ghii]);
367
+
368
+ // Best-effort activity context: which workspaces each agent has touched, and when last.
369
+ useEffect(() => {
370
+ let cancelled = false;
371
+ (async () => {
372
+ try {
373
+ const wss = (await orgService.discoverWorkspaces(orgId)).filter(w => w.access !== 'none');
374
+ const map = {};
375
+ await Promise.all(wss.map(async (w) => {
376
+ const a = await orgService.getWorkspaceActivity(orgId, w.id);
377
+ for (const e of (a.events || [])) {
378
+ if (!e.agent) continue;
379
+ const m = map[e.agent] || (map[e.agent] = { count: 0, lastAt: '', ws: new Set() });
380
+ m.count++; m.ws.add(w.name || w.id);
381
+ if (!m.lastAt || e.at > m.lastAt) m.lastAt = e.at;
382
+ }
383
+ }));
384
+ if (!cancelled) setActed(map);
385
+ } catch { /* context is optional */ }
386
+ })();
387
+ return () => { cancelled = true; };
388
+ }, [orgId, attached.length]);
389
+
390
+ const run = async (fn, okMsg) => {
391
+ setBusy(true);
392
+ try {
393
+ const r = await fn();
394
+ if (r?.ok === false) showToast(r?.error?.message || (t('organisms.agentFailed') || 'Failed'));
395
+ else { if (okMsg) showToast(okMsg); onChanged?.(); }
396
+ } catch (e) { showToast((e && e.message) || (t('organisms.agentFailed') || 'Failed')); }
397
+ finally { setBusy(false); }
398
+ };
399
+ const attach = (g) => {
400
+ const id = (g || '').trim();
401
+ if (!id) return;
402
+ setPick(''); setAgentId(''); setShowAttach(false);
403
+ run(() => orgService.attachAgent(orgId, id), t('organisms.agentAttached') || 'Agent attached');
404
+ };
405
+ const detach = (g) => run(() => orgService.detachAgent(orgId, g), t('organisms.agentDetached') || 'Agent detached');
406
+
407
+ const attachedSet = new Set(attached);
408
+ const pickable = (mine || []).filter(a => a.gaii && !attachedSet.has(a.gaii));
409
+ const parseGaii = (g) => { const m = /^([^#]+)#([^@]+)@(.+)$/.exec(g) || []; return { name: m[1] || g, owner: m[2] || '', node: m[3] || '' }; };
410
+ const ownByGaii = new Map((mine || []).map(a => [a.gaii, a]));
411
+
412
+ return html`
413
+ <div class="card-detail">
414
+ <div class="pj-tabhead">
415
+ <div class="section-desc pj-tabhead-desc">${t('organisms.agentsDesc') || 'An attached agent works in this organism with its owner’s member rights — it shows up in workspace participants and activity.'}</div>
416
+ <button class="btn-primary btn-sm" onClick=${() => setShowAttach(s => !s)}>${'+ '}${t('organisms.attachAgent') || 'Attach agent'}</button>
417
+ </div>
418
+
419
+ ${showAttach ? html`
420
+ <div class="pj-attach">
421
+ ${mine === null ? html`<${Spinner} />` : (pickable.length > 0 ? html`
422
+ <div class="flex-row-wrap">
423
+ <select class="input-field input-sm" value=${pick} onChange=${e => setPick(e.target.value)}>
424
+ <option value="">${t('organisms.pickAgent') || 'Choose one of your agents…'}</option>
425
+ ${pickable.map(a => html`<option value=${a.gaii} key=${a.gaii}>${a.display_name || a.name}</option>`)}
426
+ </select>
427
+ <button class="btn-outline btn-sm" disabled=${busy || !pick} onClick=${() => attach(pick)}>${t('organisms.attach') || 'Attach'}</button>
428
+ </div>` : html`
429
+ <div class="section-desc">${t('organisms.noOwnAgentsLeft') || 'All your agents are already attached (or you have none yet).'}</div>`)}
430
+ <button class="pj-linklike" onClick=${() => setShowAdvanced(s => !s)}>${t('organisms.advancedAttach') || 'Advanced: attach by ID'}</button>
431
+ ${showAdvanced ? html`
432
+ <div class="flex-row-wrap">
433
+ <input class="input-field input-sm" placeholder=${t('organisms.agentGaiiPlaceholder') || 'agent#owner@node'} value=${agentId}
434
+ onInput=${(e) => setAgentId(e.target.value)} onKeyDown=${(e) => { if (e.key === 'Enter') attach(agentId); }} />
435
+ <button class="btn-outline btn-sm" disabled=${busy || !agentId.trim()} onClick=${() => attach(agentId)}>${t('organisms.attach') || 'Attach'}</button>
436
+ </div>` : null}
437
+ </div>` : null}
438
+
439
+ ${attached.length === 0 ? html`<${EmptyState} icon="🤖" text=${t('organisms.noAgents') || 'No agents attached.'} />` : null}
440
+ ${attached.map(g => {
441
+ const p = parseGaii(g);
442
+ const own = ownByGaii.get(g);
443
+ const act = acted[p.name];
444
+ return html`
445
+ <div class="pj-org-row" key=${'ag-' + g}>
446
+ <div class="pj-org-avatar" aria-hidden="true">${'🤖'}</div>
447
+ <div class="pj-org-main pj-org-main-static">
448
+ <div class="pj-org-titlerow">
449
+ <span class="pj-org-name">${(own?.display_name || p.name)}</span>
450
+ ${p.node ? html`<span class="badge badge-info">${(p.node)}</span>` : null}
451
+ </div>
452
+ <div class="pj-org-desc" title=${g}>
453
+ <span class="mono">${(p.owner ? `${p.name}#${p.owner}` : g)}</span>
454
+ ${act ? html` · ${t('organisms.agentActedIn') || 'active in'} ${([...act.ws].join(', '))} (${act.count}) · ${relTime(act.lastAt)}`
455
+ : (own?.last_seen ? html` · ${t('organisms.lastActive') || 'last active'} ${relTime(own.last_seen)}` : null)}
456
+ </div>
457
+ </div>
458
+ ${(canManage || g.includes('#' + ghii + '@'))
459
+ ? html`<button class="btn-outline btn-sm" disabled=${busy} onClick=${() => detach(g)}>${t('organisms.detach') || 'Detach'}</button>`
460
+ : null}
461
+ </div>`;
462
+ })}
463
+ </div>`;
464
+ }
465
+
466
+ /**
467
+ * Members tab — roster first (avatar rows with role badge, per-workspace access line, joined date,
468
+ * "…" menu for make-creator/remove/block), "+ Invite" toggles a compact invite row, and pending
469
+ * join requests render as highlighted rows only when there are any. Agents live in OrgAgentsPanel.
248
470
  */
249
471
  function OrgMemberManager({ org, ghii, canManage, isCreator, showToast, confirm, onChanged, show }) {
250
472
  const orgId = org.id;
@@ -253,8 +475,9 @@ function OrgMemberManager({ org, ghii, canManage, isCreator, showToast, confirm,
253
475
  const [banned, setBanned] = useState([]);
254
476
  const [invitations, setInvitations] = useState([]);
255
477
  const [inviteName, setInviteName] = useState('');
256
- const [agentName, setAgentName] = useState('');
478
+ const [showInvite, setShowInvite] = useState(false);
257
479
  const [busy, setBusy] = useState(false);
480
+ const [wsAccess, setWsAccess] = useState(null); // bare owner name → [{ ws, role }] (best effort)
258
481
 
259
482
  const load = useCallback(async () => {
260
483
  const tasks = [orgService.listMembers(orgId).catch(() => null)];
@@ -282,6 +505,32 @@ function OrgMemberManager({ org, ghii, canManage, isCreator, showToast, confirm,
282
505
  return () => window.removeEventListener('aimeat-live-update', handler);
283
506
  }, []);
284
507
 
508
+ // Per-member workspace access (best effort): workspace creators from discovery + access lists of
509
+ // the workspaces the viewer created. Keyed by bare owner name (memberships use bare names too).
510
+ useEffect(() => {
511
+ if (show !== 'members') return undefined;
512
+ let cancelled = false;
513
+ (async () => {
514
+ try {
515
+ const wss = await orgService.discoverWorkspaces(orgId);
516
+ const map = {};
517
+ const add = (who, wsName, role) => {
518
+ const bare = String(who || '').split('@')[0];
519
+ if (!bare) return;
520
+ const list = map[bare] || (map[bare] = []);
521
+ if (!list.some(x => x.ws === wsName)) list.push({ ws: wsName, role });
522
+ };
523
+ for (const w of wss) if (w.created_by) add(w.created_by, w.name || w.id, 'owner');
524
+ await Promise.all(wss.filter(w => w.access === 'owner').map(async (w) => {
525
+ const acc = await orgService.getWorkspaceAccess(orgId, w.id).catch(() => null);
526
+ for (const m of (acc?.members || [])) add(m.owner, w.name || w.id, m.role);
527
+ }));
528
+ if (!cancelled) setWsAccess(map);
529
+ } catch { /* the access line is optional */ }
530
+ })();
531
+ return () => { cancelled = true; };
532
+ }, [orgId, show]);
533
+
285
534
  const run = async (fn, okMsg, failKey) => {
286
535
  setBusy(true);
287
536
  try {
@@ -317,95 +566,93 @@ function OrgMemberManager({ org, ghii, canManage, isCreator, showToast, confirm,
317
566
  () => run(() => orgService.transferOwnership(orgId, toGhii), (t('organisms.ownershipTransferred') || 'Ownership transferred'), 'organisms.transferFailed'),
318
567
  { danger: true });
319
568
 
320
- const attachAgent = () => {
321
- const g = agentName.trim();
322
- if (!g) return;
323
- setAgentName('');
324
- run(() => orgService.attachAgent(orgId, g), (t('organisms.agentAttached') || 'Agent attached'), 'organisms.agentFailed');
325
- };
326
- const detachAgent = (g) => run(() => orgService.detachAgent(orgId, g), (t('organisms.agentDetached') || 'Agent detached'), 'organisms.agentFailed');
327
-
328
569
  const pending = (requests || []).filter(r => r.status === 'pending');
329
- const agents = org.agentGaiis || [];
330
-
331
570
  const showMembers = show !== 'agents';
332
- const showAgents = show !== 'members';
571
+
572
+ // "Access: Marketing (contributor)" line — creator shows "all workspaces" (mirrors reality:
573
+ // the organism creator governs every workspace it owns; per-ws data may be partial for others).
574
+ const accessLine = (m) => {
575
+ if (m.role === 'creator') return t('organisms.accessAll') || 'all workspaces';
576
+ const list = wsAccess?.[String(m.ghii || '').split('@')[0]] || [];
577
+ if (!list.length) return '';
578
+ return list.map(x => `${x.ws} (${x.role})`).join(', ');
579
+ };
333
580
 
334
581
  return html`
335
582
  <div class="card-detail">
336
583
  ${showMembers ? html`
337
- <div class="section-title">${canManage ? (t('organisms.manageMembers') || 'Manage members') : (t('organisms.membersRoster') || 'Members')}</div>
338
- ${canManage ? html`<div class="section-desc">${t('organisms.manageMembersDesc') || 'Invite, approve, block and revoke member access.'}</div>` : null}
584
+ <div class="pj-tabhead">
585
+ <div class="section-desc pj-tabhead-desc">${t('organisms.membersDesc') || 'Members can join workspaces; their agents inherit the role.'}
586
+ ${' '}<span class="pj-members-cap">${(members || []).length}/${org.maxMembers || 500}</span></div>
587
+ ${canManage ? html`<button class="btn-primary btn-sm" onClick=${() => setShowInvite(s => !s)}>${'+ '}${t('organisms.invite') || 'Invite'}</button>` : null}
588
+ </div>
339
589
 
340
- ${canManage ? html`
341
- <div class="detail-label">${t('organisms.inviteMember') || 'Invite a member'}</div>
342
- <div class="flex-row-wrap">
343
- <input class="input-field input-sm" placeholder=${t('organisms.inviteePlaceholder') || 'owner name'} value=${inviteName}
590
+ ${canManage && showInvite ? html`
591
+ <div class="flex-row-wrap pj-invite-row">
592
+ <input class="input-field input-sm" autofocus placeholder=${t('organisms.inviteePlaceholder') || 'owner name'} value=${inviteName}
344
593
  onInput=${(e) => setInviteName(e.target.value)} onKeyDown=${(e) => { if (e.key === 'Enter') invite(); }} />
345
- <button class="btn-primary btn-sm" disabled=${busy || !inviteName.trim()} onClick=${invite}>${t('organisms.invite') || 'Invite'}</button>
346
- </div>
347
- ${invitations.length > 0 ? html`
348
- <div class="section-desc">${t('organisms.outstandingInvites') || 'Awaiting acceptance'}: ${invitations.map(m => escHtml(m.ghii)).join(', ')}</div>
349
- ` : null}
350
-
351
- <div class="detail-label">${t('organisms.pendingRequests') || 'Pending join requests'}</div>
352
- ${requests === null
353
- ? html`<div class="section-desc">…</div>`
354
- : pending.length === 0
355
- ? html`<div class="section-desc">${t('organisms.noPendingRequests') || 'No pending requests.'}</div>`
356
- : pending.map(r => html`
357
- <div class="pj-access-row" key=${r.id}>
358
- <span>${escHtml(r.ghii)}${r.message ? html` <span class="pj-mini">— ${escHtml(r.message)}</span>` : null}</span>
359
- <span class="flex-row-wrap">
360
- <button class="btn-success btn-sm" disabled=${busy} onClick=${() => review(r.id, 'approved')}>${t('organisms.approve') || 'Approve'}</button>
361
- <button class="btn-ghost btn-sm" disabled=${busy} onClick=${() => review(r.id, 'rejected')}>${t('organisms.reject') || 'Reject'}</button>
362
- </span>
363
- </div>
364
- `)}
594
+ <button class="btn-outline btn-sm" disabled=${busy || !inviteName.trim()} onClick=${invite}>${t('organisms.invite') || 'Invite'}</button>
595
+ <button class="btn-ghost btn-sm" onClick=${() => setShowInvite(false)}>${t('organisms.cancel') || 'Cancel'}</button>
596
+ </div>` : null}
597
+ ${canManage && invitations.length > 0 ? html`
598
+ <div class="section-desc">${t('organisms.outstandingInvites') || 'Awaiting acceptance'}: ${invitations.map(m => (m.ghii)).join(', ')}</div>
365
599
  ` : null}
366
600
 
367
- <div class="detail-label">${t('organisms.members') || 'Members'}</div>
368
- ${(members || []).map(m => html`
369
- <div class="pj-access-row" key=${m.ghii}>
370
- <span>${escHtml(m.ghii)} <span class="badge ${m.role === 'creator' ? 'badge-success' : 'badge-info'} pj-mini">${escHtml(m.role || 'member')}</span></span>
371
- ${canManage && m.role !== 'creator' && m.ghii !== ghii ? html`
372
- <span class="flex-row-wrap">
373
- ${isCreator ? html`<button class="btn-ghost btn-sm" disabled=${busy} onClick=${() => transfer(m.ghii)}>${t('organisms.makeCreator') || 'Make creator'}</button>` : null}
374
- <button class="btn-outline btn-sm" disabled=${busy} onClick=${() => remove(m.ghii, false)}>${t('organisms.remove') || 'Remove'}</button>
375
- <button class="btn-danger btn-sm" disabled=${busy} onClick=${() => remove(m.ghii, true)}>${t('organisms.block') || 'Block'}</button>
376
- </span>
377
- ` : null}
601
+ ${canManage && pending.length > 0 ? pending.map(r => html`
602
+ <div class="pj-org-row pj-req-row" key=${r.id}>
603
+ <div class="pj-org-avatar" aria-hidden="true">${'🙋'}</div>
604
+ <div class="pj-org-main pj-org-main-static">
605
+ <div class="pj-org-titlerow">
606
+ <span class="pj-org-name">${(r.ghii)}</span>
607
+ <span class="pj-org-desc">${t('organisms.wantsToJoin') || 'wants to join'}${r.createdAt ? ` · ${relTime(r.createdAt)}` : ''}</span>
608
+ </div>
609
+ ${r.message ? html`<div class="pj-org-desc">${(r.message)}</div>` : null}
610
+ </div>
611
+ <button class="btn-ghost btn-sm" disabled=${busy} onClick=${() => review(r.id, 'rejected')}>${t('organisms.decline') || 'Decline'}</button>
612
+ <button class="btn-success btn-sm" disabled=${busy} onClick=${() => review(r.id, 'approved')}>${t('organisms.approve') || 'Approve'}</button>
378
613
  </div>
379
- `)}
614
+ `) : null}
615
+
616
+ <div class="pj-org-list">
617
+ ${(members || []).map(m => {
618
+ const acc = accessLine(m);
619
+ const menuItems = (canManage && m.role !== 'creator' && m.ghii !== ghii) ? [
620
+ isCreator && { label: t('organisms.makeCreator') || 'Make creator', icon: '👑', onClick: () => transfer(m.ghii) },
621
+ { label: t('organisms.remove') || 'Remove', danger: true, onClick: () => remove(m.ghii, false) },
622
+ { label: t('organisms.block') || 'Block', danger: true, onClick: () => remove(m.ghii, true) },
623
+ ] : [];
624
+ return html`
625
+ <div class="pj-org-row" key=${m.ghii}>
626
+ <div class="pj-org-avatar" aria-hidden="true">${orgInitials(m.ghii)}</div>
627
+ <div class="pj-org-main pj-org-main-static">
628
+ <div class="pj-org-titlerow">
629
+ <span class="pj-org-name">${(m.ghii)}</span>
630
+ <span class="badge ${m.role === 'creator' ? 'badge-success' : 'badge-info'}">${(m.role || 'member')}</span>
631
+ </div>
632
+ ${(acc || m.joinedAt) ? html`
633
+ <div class="pj-org-desc">
634
+ ${acc ? `${t('organisms.accessLabel') || 'Access'}: ${acc}` : ''}${acc && m.joinedAt ? ' · ' : ''}${m.joinedAt ? (t('organisms.joinedDate') || 'joined {date}').replace('{date}', fmtDate(m.joinedAt)) : ''}
635
+ </div>` : null}
636
+ ${(m.agents || []).length ? html`
637
+ <div class="pj-org-desc" title=${t('organisms.memberAgentsHint') || "This member's agents — they inherit the membership and can act in this organism"}>
638
+ ${'🤖 '}${t('organisms.memberAgents') || 'Agents'}: ${m.agents.map(a => a.name || a.gaii).join(', ')}
639
+ </div>` : null}
640
+ </div>
641
+ ${menuItems.length ? html`<${KebabMenu} label=${t('organisms.moreActions') || 'More actions'} items=${menuItems} />` : null}
642
+ </div>`;
643
+ })}
644
+ </div>
380
645
 
381
646
  ${canManage && banned.length > 0 ? html`
382
647
  <div class="detail-label">${t('organisms.blockedMembers') || 'Blocked'}</div>
383
648
  ${banned.map(m => html`
384
649
  <div class="pj-access-row" key=${'ban-' + m.ghii}>
385
- <span>${escHtml(m.ghii)}</span>
650
+ <span>${(m.ghii)}</span>
386
651
  <button class="btn-ghost btn-sm" disabled=${busy} onClick=${() => unban(m.ghii)}>${t('organisms.unblock') || 'Unblock'}</button>
387
652
  </div>
388
653
  `)}
389
654
  ` : null}
390
655
  ` : null}
391
-
392
- ${showAgents ? html`
393
- <div class="detail-label">${t('organisms.attachedAgents') || 'Attached agents'}</div>
394
- ${agents.length === 0 ? html`<div class="section-desc">${t('organisms.noAgents') || 'No agents attached.'}</div>` : null}
395
- ${agents.map(g => html`
396
- <div class="pj-access-row" key=${'ag-' + g}>
397
- <span class="mono">${escHtml(g)}</span>
398
- ${(canManage || g.includes('#' + ghii + '@'))
399
- ? html`<button class="btn-ghost btn-sm" disabled=${busy} onClick=${() => detachAgent(g)}>${t('organisms.detach') || 'Detach'}</button>`
400
- : null}
401
- </div>
402
- `)}
403
- <div class="flex-row-wrap">
404
- <input class="input-field input-sm" placeholder=${t('organisms.agentGaiiPlaceholder') || 'agent#you@node'} value=${agentName}
405
- onInput=${(e) => setAgentName(e.target.value)} onKeyDown=${(e) => { if (e.key === 'Enter') attachAgent(); }} />
406
- <button class="btn-outline btn-sm" disabled=${busy || !agentName.trim()} onClick=${attachAgent}>${t('organisms.attachAgent') || 'Attach agent'}</button>
407
- </div>
408
- ` : null}
409
656
  </div>
410
657
  `;
411
658
  }
@@ -640,7 +887,7 @@ export default function OrganismsTab({ session, showToast, onStats }) {
640
887
  <div class="detail-grid">
641
888
  <div class="detail-item">
642
889
  <span class="detail-label">${t('organisms.creator') || 'Creator'}</span>
643
- <span class="detail-value">${escHtml(org.creatorGhii)}</span>
890
+ <span class="detail-value">${(org.creatorGhii)}</span>
644
891
  </div>
645
892
  <div class="detail-item">
646
893
  <span class="detail-label">${t('organisms.memberCount') || 'Members'}</span>
@@ -659,7 +906,7 @@ export default function OrganismsTab({ session, showToast, onStats }) {
659
906
  </div>
660
907
  ${(org.interests || []).length > 0 ? html`
661
908
  <div class="flex-row-wrap">
662
- ${org.interests.map(tag => html`<span class="file-tag" key=${tag}>${escHtml(tag)}</span>`)}
909
+ ${org.interests.map(tag => html`<span class="file-tag" key=${tag}>${(tag)}</span>`)}
663
910
  </div>` : null}
664
911
  </div>`;
665
912
  };
@@ -696,11 +943,11 @@ export default function OrganismsTab({ session, showToast, onStats }) {
696
943
  <div class="pj-org-main" role="button" tabindex="0" onClick=${activate}
697
944
  onKeyDown=${(e) => { if (e.key === 'Enter') activate(); }}>
698
945
  <div class="pj-org-titlerow">
699
- <span class="pj-org-name">${escHtml(org.name)}</span>
946
+ <span class="pj-org-name">${(org.name)}</span>
700
947
  <span class="badge badge-info">${typeLabel}</span>
701
948
  ${org.visibility !== 'public' ? html`<span class="pj-org-lock" title=${visLabel}>${'\uD83D\uDD12'}</span>` : null}
702
949
  </div>
703
- ${org.description ? html`<div class="pj-org-desc">${escHtml(org.description)}</div>` : null}
950
+ ${org.description ? html`<div class="pj-org-desc">${(org.description)}</div>` : null}
704
951
  </div>
705
952
  <div class="pj-org-stats">
706
953
  ${isMine && wsCounts[org.id] !== undefined ? html`
@@ -723,7 +970,8 @@ export default function OrganismsTab({ session, showToast, onStats }) {
723
970
  // openWs chosen → that workspace; otherwise the organism's HOME page (tabs incl. workspaces).
724
971
  if (openWs) {
725
972
  return html`<${Workspace} org=${org} wsId=${openWs} session=${session} showToast=${showToast}
726
- onBack=${() => { setOpenWs(null); }} />`;
973
+ onBack=${() => { setOpenWs(null); }}
974
+ onBackToList=${() => { setOpenWs(null); setOpenId(null); setOpenSettings(false); loadData(); }} />`;
727
975
  }
728
976
  return html`
729
977
  <${OrganismHome} org=${org} ghii=${ghii} showToast=${showToast}
@@ -731,8 +979,7 @@ export default function OrganismsTab({ session, showToast, onStats }) {
731
979
  onOpenWs=${(wsId) => setOpenWs(wsId)}
732
980
  onBack=${() => { setOpenId(null); setOpenSettings(false); loadData(); }}
733
981
  onChanged=${loadData}
734
- onLeave=${() => handleLeave(org.id, org.name)}
735
- onDelete=${() => handleDelete(org.id, org.name)} />
982
+ onLeave=${() => handleLeave(org.id, org.name)} />
736
983
  <${ConfirmUI} />`;
737
984
  }
738
985
 
@@ -826,17 +1073,81 @@ export default function OrganismsTab({ session, showToast, onStats }) {
826
1073
  `;
827
1074
  }
828
1075
 
1076
+ /* Board tab — embedded preview of the organism's discussion board: latest posts + a composer,
1077
+ * with "Open in Boards" for the full view. The raw board UUID hides behind a copy icon. */
1078
+ function BoardPreview({ boardId, showToast }) {
1079
+ const [posts, setPosts] = useState(null); // null = loading
1080
+ const [text, setText] = useState('');
1081
+ const [busy, setBusy] = useState(false);
1082
+
1083
+ const load = useCallback(() => {
1084
+ listPosts(boardId).then(p => setPosts(Array.isArray(p) ? p : [])).catch(() => setPosts([]));
1085
+ }, [boardId]);
1086
+ useEffect(() => { load(); }, [load]);
1087
+ const liveRef = useRef(load); liveRef.current = load;
1088
+ useEffect(() => {
1089
+ const h = () => liveRef.current();
1090
+ window.addEventListener('aimeat-live-update', h);
1091
+ return () => window.removeEventListener('aimeat-live-update', h);
1092
+ }, []);
1093
+
1094
+ const send = async () => {
1095
+ const body = text.trim();
1096
+ if (!body) return;
1097
+ setBusy(true);
1098
+ try {
1099
+ const r = await createPost(boardId, body);
1100
+ if (r?.ok === false) showToast(r?.error?.message || 'Failed to post');
1101
+ else { setText(''); load(); }
1102
+ } catch (e) { showToast((e && e.message) || 'Failed to post'); }
1103
+ finally { setBusy(false); }
1104
+ };
1105
+ const copyId = async () => {
1106
+ const ok = await copyToClipboard(boardId);
1107
+ showToast(ok ? (t('organisms.copied') || 'Copied') : (t('organisms.copyFailed') || 'Could not copy'));
1108
+ };
1109
+
1110
+ const ts = (p) => p.created_at || p.createdAt || p.at || '';
1111
+ const latest = [...(posts || [])].sort((a, b) => String(ts(b)).localeCompare(String(ts(a)))).slice(0, 5);
1112
+
1113
+ return html`
1114
+ <div class="card-detail">
1115
+ <div class="pj-tabhead">
1116
+ <div class="section-desc pj-tabhead-desc">${t('organisms.boardPreviewDesc') || 'Latest messages on this organism’s board.'}</div>
1117
+ <button class="pj-icon-btn" title=${(t('organisms.copyId') || 'Copy ID') + ': ' + boardId} onClick=${copyId}>${'📋'}</button>
1118
+ <button class="btn-outline btn-sm" onClick=${() => window.dispatchEvent(new CustomEvent('aimeat-open-tab', { detail: { tabId: 'boards' } }))}>
1119
+ ${t('organisms.openBoardsTab') || 'Open in Boards'}</button>
1120
+ </div>
1121
+ ${posts === null ? html`<${Spinner} />`
1122
+ : latest.length === 0 ? html`<${EmptyState} icon="💬" text=${t('organisms.boardEmpty') || 'No messages yet — write the first one.'} />`
1123
+ : latest.map(p => html`
1124
+ <div class="pj-board-post" key=${p.id || ts(p)}>
1125
+ <div class="pj-board-post-head">
1126
+ <span class="pj-board-author">${(p.author_gaii || p.author || '?')}</span>
1127
+ ${ts(p) ? html`<span class="pj-board-time">${relTime(ts(p))}</span>` : null}
1128
+ </div>
1129
+ <div class="pj-board-body">${(String(p.body || p.content || '').slice(0, 400))}</div>
1130
+ </div>`)}
1131
+ <div class="flex-row-wrap pj-board-composer">
1132
+ <input class="input-field input-sm pj-board-input" placeholder=${t('organisms.writePost') || 'Write a message…'} value=${text}
1133
+ onInput=${(e) => setText(e.target.value)} onKeyDown=${(e) => { if (e.key === 'Enter') send(); }} />
1134
+ <button class="btn-outline btn-sm" disabled=${busy || !text.trim()} onClick=${send}>${t('organisms.send') || 'Send'}</button>
1135
+ </div>
1136
+ </div>`;
1137
+ }
1138
+
829
1139
  /* ───────────────── Organism home page ─────────────────
830
1140
  * One home per organism: breadcrumb header (avatar, name, badges, description,
831
1141
  * Export + Settings) and tabs — Workspaces (the workspace list + content search),
832
1142
  * Members (invite/approve/roster/blocked), Agents (attach/detach), Board (link to
833
1143
  * the Boards tab). The Settings panel hosts the edit form, the metadata that used
834
1144
  * to live in the expanded list card, and the danger zone (leave / delete). */
835
- function OrganismHome({ org, ghii, showToast, initialSettings, onOpenWs, onBack, onChanged, onLeave, onDelete }) {
1145
+ function OrganismHome({ org, ghii, showToast, initialSettings, onOpenWs, onBack, onChanged, onLeave }) {
836
1146
  const { confirm, ConfirmUI } = useConfirm();
837
1147
  const [tab, setTab] = useState(() => { try { return sessionStorage.getItem('aimeat.org.tab') || 'workspaces'; } catch { return 'workspaces'; } });
838
1148
  const [showSettings, setShowSettings] = useState(!!initialSettings);
839
1149
  const [wsCount, setWsCount] = useState(null);
1150
+ const [pendingJoin, setPendingJoin] = useState(0); // Members tab pill — visible without opening the tab
840
1151
  useEffect(() => { try { sessionStorage.setItem('aimeat.org.tab', tab); } catch { /* noop */ } }, [tab]);
841
1152
 
842
1153
  const isCreator = org.creatorGhii === ghii;
@@ -845,29 +1156,62 @@ function OrganismHome({ org, ghii, showToast, initialSettings, onOpenWs, onBack,
845
1156
  const canEdit = isCreator || isAdmin;
846
1157
  const typeLabel = t(`organisms.types.${org.type}`) || org.type;
847
1158
 
848
- /* ── Settings: edit form (admins), metadata, danger zone ── */
849
- const [editForm, setEditForm] = useState(null);
1159
+ useEffect(() => {
1160
+ if (!canEdit) return undefined;
1161
+ let cancelled = false;
1162
+ const fetchIt = () => orgService.listJoinRequests(org.id)
1163
+ .then(r => { if (!cancelled) setPendingJoin(((r?.data?.join_requests) || []).filter(x => x.status === 'pending').length); })
1164
+ .catch(() => {});
1165
+ fetchIt();
1166
+ window.addEventListener('aimeat-live-update', fetchIt);
1167
+ return () => { cancelled = true; window.removeEventListener('aimeat-live-update', fetchIt); };
1168
+ }, [org.id, canEdit]);
1169
+
1170
+ /* ── Settings: labelled + grouped form (Identity / Access) with live dirty-check, a one-line
1171
+ * read-only metadata row, per-choice access hints, and a clearly separated danger zone whose
1172
+ * delete needs the organism's name typed and states exactly what gets removed. ── */
1173
+ const baseline = useMemo(() => ({
1174
+ name: org.name || '', description: org.description || '', type: org.type || 'community',
1175
+ join_policy: org.joinPolicy || 'open', visibility: org.visibility || 'public',
1176
+ interests: [...(org.interests || [])],
1177
+ }), [org]);
1178
+ const [form, setForm] = useState(baseline);
850
1179
  const [saving, setSaving] = useState(false);
851
- // Re-seed the form when another organism is opened (org.id), not on every reload of the same org.
852
- useEffect(() => { setEditForm(null); }, [org.id]);
853
- const seedForm = () => ({
854
- name: org.name || '', description: org.description || '', type: org.type,
855
- join_policy: org.joinPolicy, visibility: org.visibility, interests: (org.interests || []).join(', '),
856
- });
1180
+ // Sync the form whenever fresher org data arrives (e.g. the home mounted from an F5 with only a
1181
+ // {id} stub before the list loaded) — but ONLY while the user hasn't touched the fields, so a
1182
+ // live-update reload never clobbers typing.
1183
+ const prevBaselineRef = useRef(baseline);
1184
+ useEffect(() => {
1185
+ const prev = prevBaselineRef.current;
1186
+ const untouched = form.name === prev.name && form.description === prev.description
1187
+ && form.type === prev.type && form.join_policy === prev.join_policy
1188
+ && form.visibility === prev.visibility && form.interests.join(' ') === prev.interests.join(' ');
1189
+ if (untouched) setForm(baseline);
1190
+ prevBaselineRef.current = baseline;
1191
+ }, [baseline]); // eslint-disable-line react-hooks/exhaustive-deps
1192
+ const dirty = form.name !== baseline.name || form.description !== baseline.description
1193
+ || form.type !== baseline.type || form.join_policy !== baseline.join_policy
1194
+ || form.visibility !== baseline.visibility
1195
+ || form.interests.join('') !== baseline.interests.join('');
1196
+ // Save doubles as the unsaved-changes indicator: enabled ⇔ something actually changed.
857
1197
  const saveEdit = async () => {
858
- if (!editForm?.name?.trim()) { showToast(t('organisms.nameRequired') || 'Name is required'); return; }
1198
+ if (!form.name.trim()) { showToast(t('organisms.nameRequired') || 'Name is required'); return; }
859
1199
  setSaving(true);
860
1200
  try {
861
- const interests = editForm.interests.split(',').map(s => s.trim()).filter(Boolean);
862
1201
  const result = await orgService.updateOrganism(org.id, {
863
- name: editForm.name.trim(), description: editForm.description.trim(),
864
- type: editForm.type, join_policy: editForm.join_policy, visibility: editForm.visibility, interests,
1202
+ name: form.name.trim(), description: form.description.trim(),
1203
+ type: form.type, join_policy: form.join_policy, visibility: form.visibility, interests: form.interests,
865
1204
  });
866
- if (result?.ok !== false) { showToast(t('organisms.updated') || 'Organism updated'); setEditForm(null); onChanged?.(); }
1205
+ if (result?.ok !== false) { showToast(t('organisms.updated') || 'Organism updated'); onChanged?.(); }
867
1206
  else showToast(result?.error?.message || (t('organisms.updateError') || 'Failed to update'));
868
1207
  } catch { showToast(t('organisms.updateError') || 'Failed to update'); }
869
1208
  finally { setSaving(false); }
870
1209
  };
1210
+ // Leaving a dirty form (closing settings / breadcrumb back) asks before dropping the changes.
1211
+ const guardDirty = (fn) => {
1212
+ if (showSettings && dirty) confirm(t('organisms.discardChanges') || 'Discard unsaved changes?', () => { setForm(baseline); fn(); }, { danger: true });
1213
+ else fn();
1214
+ };
871
1215
 
872
1216
  const boardIdShort = org.boardId && org.boardId.length > 20
873
1217
  ? `${org.boardId.slice(0, 12)}…${org.boardId.slice(-6)}` : (org.boardId || '');
@@ -876,157 +1220,198 @@ function OrganismHome({ org, ghii, showToast, initialSettings, onOpenWs, onBack,
876
1220
  showToast(ok ? (t('organisms.copied') || 'Copied') : (t('organisms.copyFailed') || 'Could not copy'));
877
1221
  };
878
1222
 
1223
+ // What a delete actually removes (counted from the accessible workspaces when settings opens).
1224
+ const [delStats, setDelStats] = useState(null); // { ws, recs, docs }
1225
+ const [delOpen, setDelOpen] = useState(false);
1226
+ const [delName, setDelName] = useState('');
1227
+ useEffect(() => {
1228
+ if (!showSettings || !isCreator) return undefined;
1229
+ let cancelled = false;
1230
+ (async () => {
1231
+ try {
1232
+ const wss = (await orgService.discoverWorkspaces(org.id)).filter(w => w.access !== 'none');
1233
+ let recs = 0, docs = 0;
1234
+ await Promise.all(wss.map(async (w) => {
1235
+ const wsData = await orgService.getWorkspace(org.id, w.id).catch(() => null);
1236
+ for (const ot of (wsData?.manifest?.objectTypes || []).filter(orgService.isMemorySpace)) {
1237
+ const n = new Set([...(wsData.drafts?.[ot.name] || []), ...(wsData.objects?.[ot.name] || [])].map(d => d.id)).size;
1238
+ if (orgService.isDocSpace(ot)) docs += n; else recs += n;
1239
+ }
1240
+ }));
1241
+ if (!cancelled) setDelStats({ ws: wss.length, recs, docs });
1242
+ } catch { /* the counts are a courtesy — the generic warning still shows */ }
1243
+ })();
1244
+ return () => { cancelled = true; };
1245
+ }, [org.id, showSettings, isCreator]);
1246
+ const delStatsText = delStats
1247
+ ? (t('organisms.deleteOrganismStats') || 'Deletes {w} workspaces, {r} records and {d} documents. This cannot be undone.')
1248
+ .replace('{w}', String(delStats.ws)).replace('{r}', String(delStats.recs)).replace('{d}', String(delStats.docs))
1249
+ : (t('organisms.deleteWarnGeneric') || 'Deletes the organism with all its workspaces and content. This cannot be undone.');
1250
+ const doDelete = () => {
1251
+ confirm(`${(t('organisms.confirmDeleteName') || 'Delete “{name}”?').replace('{name}', org.name || org.id)} ${delStatsText}`, async () => {
1252
+ try {
1253
+ await orgService.deleteOrganism(org.id);
1254
+ showToast(t('organisms.deleted') || 'Organism deleted');
1255
+ onBack(); onChanged?.();
1256
+ } catch { showToast(t('organisms.deleteError') || 'Failed to delete'); }
1257
+ }, { danger: true, title: t('organisms.deleteOrganismTitle') || 'Delete this organism' });
1258
+ };
1259
+
1260
+ const extraAdmins = (org.admins || []).filter(a => a !== org.creatorGhii);
1261
+ const visHint = t(`organisms.visHint.${form.visibility}`);
1262
+ const policyHint = t(`organisms.policyHint.${form.join_policy}`);
1263
+ const hintText = [visHint, policyHint].filter(h => h && !h.startsWith('organisms.')).join(' ');
1264
+
879
1265
  const renderSettings = () => html`
880
1266
  <div class="card-detail pj-org-settings">
881
- <div class="detail-grid">
882
- <div class="detail-item">
883
- <span class="detail-label">${t('organisms.creator') || 'Creator'}</span>
884
- <span class="detail-value">${escHtml(org.creatorGhii || '-')}</span>
885
- </div>
886
- <div class="detail-item">
887
- <span class="detail-label">${t('organisms.admins') || 'Admins'}</span>
888
- <span class="detail-value">${(org.admins || []).join(', ') || '-'}</span>
889
- </div>
890
- <div class="detail-item">
891
- <span class="detail-label">${t('organisms.memberCount') || 'Members'}</span>
892
- <span class="detail-value">${(org.members || []).length} / ${org.maxMembers || 500}</span>
893
- </div>
894
- ${org.createdAt ? html`
895
- <div class="detail-item">
896
- <span class="detail-label">${t('organisms.createdAt') || 'Created'}</span>
897
- <span class="detail-value">${fmtDate(org.createdAt)}</span>
898
- </div>` : null}
1267
+ <div class="pj-meta-line">
1268
+ ${org.createdAt ? html`<span>${t('organisms.createdAt') || 'Created'} ${fmtDate(org.createdAt)}</span>` : null}
1269
+ <span>${t('organisms.creator') || 'Creator'} ${(org.creatorGhii || '-')}</span>
1270
+ ${extraAdmins.length > 0 ? html`<span>${t('organisms.admins') || 'Admins'} ${(extraAdmins.join(', '))}</span>` : null}
899
1271
  ${org.boardId ? html`
900
- <div class="detail-item">
901
- <span class="detail-label">${t('organisms.board') || 'Board'}</span>
902
- <span class="detail-value mono" title=${escHtml(org.boardId)}>${escHtml(boardIdShort)}
903
- <button class="pj-icon-btn" title=${t('organisms.copyId') || 'Copy ID'} onClick=${copyBoardId}>${'📋'}</button>
904
- </span>
905
- </div>` : null}
1272
+ <span>${t('organisms.board') || 'Board'} <span class="mono" title=${(org.boardId)}>${(boardIdShort)}</span>
1273
+ <button class="pj-icon-btn" title=${t('organisms.copyId') || 'Copy ID'} onClick=${copyBoardId}>${'📋'}</button>
1274
+ </span>` : null}
906
1275
  </div>
907
- ${(org.interests || []).length > 0 ? html`
908
- <div class="flex-row-wrap">
909
- ${org.interests.map(tag => html`<span class="file-tag" key=${tag}>${escHtml(tag)}</span>`)}
910
- </div>` : null}
911
1276
 
912
- ${canEdit ? (editForm ? html`
913
- <div class="flex-col">
914
- <input type="text" value=${editForm.name} onInput=${(e) => setEditForm(f => ({ ...f, name: e.target.value }))}
915
- placeholder=${t('organisms.namePlaceholder') || 'Name'} class="input-field input-sm" />
916
- <textarea value=${editForm.description} onInput=${(e) => setEditForm(f => ({ ...f, description: e.target.value }))} rows="2"
917
- placeholder=${t('organisms.descPlaceholder') || 'Description'} class="input-field input-sm" />
918
- <input type="text" value=${editForm.interests} onInput=${(e) => setEditForm(f => ({ ...f, interests: e.target.value }))}
919
- placeholder=${t('organisms.interestsPlaceholder') || 'Interests (comma separated)'} class="input-field input-sm" />
920
- <div class="flex-row-wrap">
921
- <select value=${editForm.type} onChange=${(e) => setEditForm(f => ({ ...f, type: e.target.value }))} class="input-field input-sm">
1277
+ ${canEdit ? html`
1278
+ <div class="pj-form-group">${t('organisms.formIdentity') || 'Identity'}</div>
1279
+ <label class="pj-field"><span>${t('organisms.fieldName') || 'Name'}</span>
1280
+ <input type="text" class="input-field input-sm" value=${form.name} onInput=${(e) => setForm(f => ({ ...f, name: e.target.value }))} /></label>
1281
+ <label class="pj-field"><span>${t('organisms.fieldDescription') || 'Description'}</span>
1282
+ <textarea class="input-field input-sm" rows="2" value=${form.description} onInput=${(e) => setForm(f => ({ ...f, description: e.target.value }))}></textarea></label>
1283
+ <div class="pj-field"><span>${t('organisms.fieldInterests') || 'Interests'}</span>
1284
+ <${TagInput} tags=${form.interests} onChange=${(tags) => setForm(f => ({ ...f, interests: tags }))} placeholder=${t('organisms.addTag') || 'Add…'} /></div>
1285
+
1286
+ <div class="pj-form-group">${t('organisms.formAccess') || 'Access'}</div>
1287
+ <div class="pj-form-row">
1288
+ <label class="pj-field"><span>${t('organisms.fieldType') || 'Type'}</span>
1289
+ <select class="input-field input-sm" value=${form.type} onChange=${(e) => setForm(f => ({ ...f, type: e.target.value }))}>
922
1290
  <option value="community">${t('organisms.types.community') || 'Community'}</option>
923
1291
  <option value="team">${t('organisms.types.team') || 'Team'}</option>
924
1292
  <option value="club">${t('organisms.types.club') || 'Club'}</option>
925
1293
  <option value="cooperative">${t('organisms.types.cooperative') || 'Cooperative'}</option>
926
1294
  <option value="project">${t('organisms.types.project') || 'Project'}</option>
927
- </select>
928
- <select value=${editForm.join_policy} onChange=${(e) => setEditForm(f => ({ ...f, join_policy: e.target.value }))} class="input-field input-sm">
1295
+ </select></label>
1296
+ <label class="pj-field"><span>${t('organisms.policyLabel') || 'Join policy'}</span>
1297
+ <select class="input-field input-sm" value=${form.join_policy} onChange=${(e) => setForm(f => ({ ...f, join_policy: e.target.value }))}>
929
1298
  <option value="open">${t('organisms.policyOpen') || 'Open (anyone can join)'}</option>
930
1299
  <option value="approval_required">${t('organisms.policyApproval') || 'Approval required'}</option>
931
1300
  <option value="invite_only">${t('organisms.policyInvite') || 'Invite only'}</option>
932
- </select>
933
- <select value=${editForm.visibility} onChange=${(e) => setEditForm(f => ({ ...f, visibility: e.target.value }))} class="input-field input-sm">
1301
+ </select></label>
1302
+ <label class="pj-field"><span>${t('organisms.fieldVisibility') || 'Visibility'}</span>
1303
+ <select class="input-field input-sm" value=${form.visibility} onChange=${(e) => setForm(f => ({ ...f, visibility: e.target.value }))}>
934
1304
  <option value="public">${t('organisms.visPublic') || 'Public'}</option>
935
1305
  <option value="listed">${t('organisms.visListed') || 'Listed'}</option>
936
1306
  <option value="private">${t('organisms.visPrivate') || 'Private'}</option>
937
- </select>
938
- </div>
939
- <div class="flex-row-wrap">
940
- <button class="btn-primary btn-sm" onClick=${saveEdit} disabled=${saving}>${saving ? '...' : (t('organisms.save') || 'Save')}</button>
941
- <button class="btn-ghost btn-sm" onClick=${() => setEditForm(null)}>${t('organisms.cancel') || 'Cancel'}</button>
942
- </div>
1307
+ </select></label>
943
1308
  </div>
944
- ` : html`
945
- <div class="flex-row-wrap">
946
- <button class="btn-outline btn-sm" onClick=${() => setEditForm(seedForm())}>${t('organisms.edit') || 'Edit'}</button>
1309
+ ${hintText ? html`<div class="pj-form-hint">${hintText}</div>` : null}
1310
+
1311
+ <div class="form-actions">
1312
+ <button class="btn-primary btn-sm" onClick=${saveEdit} disabled=${saving || !dirty || !form.name.trim()}>
1313
+ ${saving ? '...' : (t('organisms.saveChanges') || 'Save changes')}</button>
1314
+ <button class="btn-ghost btn-sm" onClick=${() => { setForm(baseline); setShowSettings(false); }}>${t('organisms.cancel') || 'Cancel'}</button>
947
1315
  </div>
948
- `) : null}
1316
+ ` : null}
1317
+ </div>
949
1318
 
950
- ${(isCreator || isMember) ? html`
951
- <div class="detail-label">${t('organisms.dangerZone') || 'Danger zone'}</div>
952
- <div class="flex-row-wrap">
953
- ${isMember && !isCreator ? html`
954
- <button class="btn-danger btn-sm" onClick=${onLeave}>${t('organisms.leave') || 'Leave'}</button>` : null}
955
- ${isCreator ? html`
956
- <button class="btn-danger btn-sm" onClick=${onDelete}>${t('organisms.delete') || 'Delete'}</button>` : null}
957
- </div>` : null}
958
- </div>`;
1319
+ ${(isCreator || isMember) ? html`
1320
+ <div class="pj-danger pj-danger-box">
1321
+ ${isCreator ? html`
1322
+ <div class="pj-danger-row">
1323
+ <div class="pj-danger-text">
1324
+ <div class="pj-danger-title">${t('organisms.deleteOrganismTitle') || 'Delete this organism'}</div>
1325
+ <div class="pj-danger-sub">${delStatsText}</div>
1326
+ </div>
1327
+ <button class="btn-danger btn-sm" onClick=${() => { setDelOpen(o => !o); setDelName(''); }}>${t('organisms.deleteDots') || 'Delete…'}</button>
1328
+ </div>
1329
+ ${delOpen ? html`
1330
+ <div class="pj-danger-confirm">
1331
+ <label class="pj-field"><span>${(t('organisms.confirmTypeName') || 'Type the organism’s name to confirm') + ': ' + (org.name || '')}</span>
1332
+ <input type="text" class="input-field input-sm" value=${delName} onInput=${(e) => setDelName(e.target.value)} placeholder=${org.name || ''} /></label>
1333
+ <button class="btn-danger btn-sm" disabled=${delName.trim() !== (org.name || '').trim()} onClick=${doDelete}>${t('organisms.delete') || 'Delete'}</button>
1334
+ </div>` : null}
1335
+ ` : null}
1336
+ ${isMember && !isCreator ? html`
1337
+ <div class="pj-danger-row">
1338
+ <div class="pj-danger-text">
1339
+ <div class="pj-danger-title">${t('organisms.leave') || 'Leave'}</div>
1340
+ </div>
1341
+ <button class="btn-danger btn-sm" onClick=${onLeave}>${t('organisms.leave') || 'Leave'}</button>
1342
+ </div>` : null}
1343
+ </div>` : null}
1344
+ `;
959
1345
 
1346
+ const reqPill = pendingJoin > 0
1347
+ ? (pendingJoin === 1 ? (t('organisms.reqOne') || '1 request') : (t('organisms.reqMany') || '{n} requests').replace('{n}', String(pendingJoin)))
1348
+ : null;
960
1349
  const tabs = [
961
1350
  { id: 'workspaces', label: t('organisms.tabWorkspaces') || 'Workspaces', count: wsCount },
962
- { id: 'members', label: t('organisms.tabMembers') || 'Members', count: (org.members || []).length },
1351
+ { id: 'members', label: t('organisms.tabMembers') || 'Members', count: (org.members || []).length, pill: reqPill },
963
1352
  { id: 'agents', label: t('organisms.tabAgents') || 'Agents', count: (org.agentGaiis || []).length },
964
1353
  { id: 'board', label: t('organisms.tabBoard') || 'Board', count: null },
965
1354
  ];
966
1355
 
1356
+ // Settings REPLACES the tab content — while it is open no tab is highlighted (a lit tab above
1357
+ // settings content lies about what the user is looking at), and the breadcrumb says so.
1358
+ const activeHomeTab = showSettings ? '' : tab;
1359
+ const pickHomeTab = (id) => guardDirty(() => { setShowSettings(false); setTab(id); });
1360
+
967
1361
  return html`
968
1362
  <div class="pj-ws">
969
1363
  <div class="pj-org-breadcrumb">
970
- <button class="pj-org-crumb-link" onClick=${onBack}>${t('organisms.title') || 'Organisms'}</button>
1364
+ <button class="pj-org-crumb-link" onClick=${() => guardDirty(onBack)}>${t('organisms.title') || 'Organisms'}</button>
971
1365
  <span class="pj-org-crumb-sep">/</span>
972
- <span>${escHtml(org.name || org.id)}</span>
1366
+ ${showSettings ? html`
1367
+ <button class="pj-org-crumb-link" onClick=${() => guardDirty(() => setShowSettings(false))}>${(org.name || org.id)}</button>
1368
+ <span class="pj-org-crumb-sep">/</span>
1369
+ <span>${t('organisms.settings') || 'Settings'}</span>
1370
+ ` : html`<span>${(org.name || org.id)}</span>`}
973
1371
  </div>
974
1372
 
975
1373
  <div class="pj-org-home-head">
976
1374
  <div class="pj-org-avatar pj-org-avatar-lg" aria-hidden="true">${orgInitials(org.name)}</div>
977
1375
  <div class="pj-org-home-title">
978
1376
  <div class="pj-org-titlerow">
979
- <span class="pj-org-home-name">${escHtml(org.name || org.id)}</span>
1377
+ <span class="pj-org-home-name">${(org.name || org.id)}</span>
980
1378
  <span class="badge badge-info">${typeLabel}</span>
981
1379
  <span class="badge ${org.visibility === 'public' ? 'badge-success' : 'badge-warn'}">${org.visibility || ''}</span>
982
1380
  </div>
983
- ${org.description ? html`<div class="section-desc pj-org-home-desc">${escHtml(org.description)}</div>` : null}
1381
+ ${org.description ? html`<div class="section-desc pj-org-home-desc">${(org.description)}</div>` : null}
984
1382
  </div>
985
1383
  <div class="pj-org-home-actions">
986
1384
  <button class="btn-outline btn-sm" title=${t('organisms.exportOrgHint') || 'Download a ZIP backup of the whole organism (all workspaces)'}
987
1385
  onClick=${() => exportOrganismZip(org, showToast)}>${'⬇ '}${t('organisms.exportOrg') || 'Export'}</button>
988
- <button class="btn-outline btn-sm ${showSettings ? 'pj-org-btn-active' : ''}" onClick=${() => setShowSettings(s => !s)}>${'⚙ '}${t('organisms.settings') || 'Settings'}</button>
1386
+ <button class="btn-outline btn-sm ${showSettings ? 'pj-org-btn-active' : ''}" onClick=${() => guardDirty(() => setShowSettings(s => !s))}>${'⚙ '}${t('organisms.settings') || 'Settings'}</button>
989
1387
  </div>
990
1388
  </div>
991
1389
 
992
- ${showSettings ? renderSettings() : null}
993
-
994
1390
  <div class="pj-org-tabs" role="tablist">
995
1391
  ${tabs.map(tb => html`
996
- <button class="pj-org-tab ${tab === tb.id ? 'active' : ''}" role="tab" aria-selected=${tab === tb.id} key=${tb.id}
997
- onClick=${() => setTab(tb.id)}>
1392
+ <button class="pj-org-tab ${activeHomeTab === tb.id ? 'active' : ''}" role="tab" aria-selected=${activeHomeTab === tb.id} key=${tb.id}
1393
+ onClick=${() => pickHomeTab(tb.id)}>
998
1394
  ${tb.label}${tb.count !== null && tb.count !== undefined ? html`<span class="pj-org-tab-count">${tb.count}</span>` : null}
1395
+ ${tb.pill ? html`<span class="pj-tab-pill">${tb.pill}</span>` : null}
999
1396
  </button>`)}
1000
1397
  </div>
1001
1398
 
1002
- ${tab === 'workspaces' ? html`
1399
+ ${showSettings ? renderSettings() : null}
1400
+
1401
+ ${activeHomeTab === 'workspaces' ? html`
1003
1402
  <${WorkspaceList} org=${org} showToast=${showToast} onOpen=${onOpenWs} onCount=${setWsCount} />
1004
- <${OrgSearch} orgId=${org.id} onOpenWorkspace=${(ws) => onOpenWs(ws)} />
1005
1403
  ` : null}
1006
1404
 
1007
- ${tab === 'members' ? html`
1405
+ ${activeHomeTab === 'members' ? html`
1008
1406
  <${OrgMemberManager} org=${org} ghii=${ghii} canManage=${canEdit} isCreator=${isCreator}
1009
1407
  showToast=${showToast} confirm=${confirm} onChanged=${onChanged} show="members" />` : null}
1010
1408
 
1011
- ${tab === 'agents' ? html`
1012
- <${OrgMemberManager} org=${org} ghii=${ghii} canManage=${canEdit} isCreator=${isCreator}
1013
- showToast=${showToast} confirm=${confirm} onChanged=${onChanged} show="agents" />` : null}
1014
-
1015
- ${tab === 'board' ? html`
1016
- <div class="card-detail">
1017
- <div class="section-title">${t('organisms.tabBoard') || 'Board'}</div>
1018
- ${org.boardId ? html`
1019
- <div class="section-desc">${t('organisms.boardDesc') || 'This organism has a discussion board. Open it in the Boards view.'}</div>
1020
- <div class="pj-org-boardrow">
1021
- <span class="mono" title=${escHtml(org.boardId)}>${escHtml(boardIdShort)}</span>
1022
- <button class="pj-icon-btn" title=${t('organisms.copyId') || 'Copy ID'} onClick=${copyBoardId}>${'📋'}</button>
1023
- <button class="btn-outline btn-sm" onClick=${() => window.dispatchEvent(new CustomEvent('aimeat-open-tab', { detail: { tabId: 'boards' } }))}>
1024
- ${t('organisms.openBoardsTab') || 'Open in Boards'}</button>
1025
- </div>
1026
- ` : html`
1027
- <div class="section-desc">${t('organisms.noBoard') || 'This organism has no board.'}</div>
1028
- `}
1029
- </div>` : null}
1409
+ ${activeHomeTab === 'agents' ? html`
1410
+ <${OrgAgentsPanel} org=${org} ghii=${ghii} canManage=${canEdit} showToast=${showToast} onChanged=${onChanged} />` : null}
1411
+
1412
+ ${activeHomeTab === 'board' ? (org.boardId
1413
+ ? html`<${BoardPreview} boardId=${org.boardId} showToast=${showToast} />`
1414
+ : html`<div class="card-detail"><div class="section-desc">${t('organisms.noBoard') || 'This organism has no board.'}</div></div>`) : null}
1030
1415
 
1031
1416
  <${ConfirmUI} />
1032
1417
  </div>`;
@@ -1053,16 +1438,42 @@ function WorkspaceList({ org, showToast, onOpen, onCount }) {
1053
1438
  const [newName, setNewName] = useState('');
1054
1439
  const [busy, setBusy] = useState(false);
1055
1440
  const [overview, setOverview] = useState(''); // organism dependency-overview chart (mermaid)
1056
- const [showOverview, setShowOverview] = useState(true);
1441
+ const [showOverview, setShowOverview] = useState(false); // map hidden by default — the list is the content
1057
1442
  const [openReqWs, setOpenReqWs] = useState(null); // which owned workspace's request-inbox is open
1058
1443
  const [reqInbox, setReqInbox] = useState([]); // access requests for openReqWs
1444
+ const [openPeopleWs, setOpenPeopleWs] = useState(null); // which workspace's inline participants list is open
1445
+ const [wsStats, setWsStats] = useState({}); // wsId → { recs, docs, lastEv, hasManifest }
1446
+ const [apprByWs, setApprByWs] = useState({}); // wsId → pending review count
1059
1447
 
1060
1448
  // Discovery: every workspace in the org (membership-gated) with this user's access status. A member
1061
- // sees workspaces they can't yet read (access:'none') so they can request access.
1449
+ // sees workspaces they can't yet read (access:'none') so they can request access. Row enrichment
1450
+ // (record/doc counts, last event, review counter) loads per workspace afterwards, best effort.
1062
1451
  const load = useCallback(async () => {
1063
1452
  const l = await orgService.discoverWorkspaces(orgId);
1064
1453
  setList(l);
1065
1454
  onCount?.(Array.isArray(l) ? l.length : 0);
1455
+ orgService.listApprovals(orgId, 'pending').then(aps => {
1456
+ const by = {};
1457
+ for (const a of (aps || [])) { const w = a.arguments?.ws; if (w) by[w] = (by[w] || 0) + 1; }
1458
+ setApprByWs(by);
1459
+ }).catch(() => {});
1460
+ for (const w of (l || []).filter(x => x.access !== 'none')) {
1461
+ Promise.all([
1462
+ orgService.getWorkspace(orgId, w.id).catch(() => null),
1463
+ orgService.getWorkspaceActivity(orgId, w.id),
1464
+ orgService.getWorkspaceParticipants(orgId, w.id).catch(() => null),
1465
+ ]).then(([wsData, act, parts]) => {
1466
+ let recs = 0, docs = 0;
1467
+ for (const ot of (wsData?.manifest?.objectTypes || []).filter(orgService.isMemorySpace)) {
1468
+ const n = new Set([...(wsData.drafts?.[ot.name] || []), ...(wsData.objects?.[ot.name] || [])].map(d => d.id)).size;
1469
+ if (orgService.isDocSpace(ot)) docs += n; else recs += n;
1470
+ }
1471
+ const lastEv = (act?.events || []).reduce((b, e) => (!b || (e.at || '') > (b.at || '') ? e : b), null);
1472
+ const owners = [];
1473
+ for (const n of (parts?.nodes || [])) for (const o of (n.owners || [])) owners.push({ ...o, node: n.id, isLocalNode: n.isLocal });
1474
+ setWsStats(s => ({ ...s, [w.id]: { recs, docs, lastEv, hasManifest: !!wsData?.manifest, owners } }));
1475
+ }).catch(() => {});
1476
+ }
1066
1477
  }, [orgId, onCount]);
1067
1478
  useEffect(() => { load(); }, [load]);
1068
1479
 
@@ -1147,21 +1558,28 @@ function WorkspaceList({ org, showToast, onOpen, onCount }) {
1147
1558
  } catch (e) { showToast((e && e.message) || 'Import failed'); }
1148
1559
  finally { setBusy(false); }
1149
1560
  };
1561
+ // One enriched meta line per row: "{n} records · {n} docs · last: goal/x published 2 h ago".
1562
+ const metaLine = (w) => {
1563
+ const s = wsStats[w.id];
1564
+ if (!s) return w.access === 'owner' ? (t('organisms.yours') || 'yours') : (t('organisms.byCreator') || 'by {creator}').replace('{creator}', w.created_by || '?');
1565
+ if (!s.hasManifest) return t('organisms.wsNotSetUp') || 'new — set up when opened';
1566
+ const parts = [
1567
+ s.recs === 1 ? (t('organisms.recOne') || '1 record') : (t('organisms.recMany') || '{n} records').replace('{n}', String(s.recs)),
1568
+ s.docs === 1 ? (t('organisms.docOne') || '1 document') : (t('organisms.docMany') || '{n} documents').replace('{n}', String(s.docs)),
1569
+ ];
1570
+ if (s.lastEv) {
1571
+ const verb = s.lastEv.action === 'publish' ? (t('organisms.publishedVerb') || 'published') : (t('organisms.editedVerb') || 'edited');
1572
+ parts.push(`${t('organisms.lastLabel') || 'last:'} ${s.lastEv.type}/${s.lastEv.instance} ${verb} ${relTime(s.lastEv.at)}`);
1573
+ } else parts.push(t('organisms.noActivityYet') || 'no activity yet');
1574
+ return parts.join(' · ');
1575
+ };
1576
+
1150
1577
  return html`
1151
1578
  <div class="pj-ws-embedded">
1152
1579
  <${ConfirmUI} />
1153
1580
  <input type="file" accept=".zip,application/zip" ref=${fileRef} style="display:none" onChange=${(e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; doImport(f); }} />
1154
1581
  <div class="section-desc">${t('organisms.workspacesDesc') || 'Workspaces in this organism — each is an independent space with its own documents, records and history.'}</div>
1155
1582
 
1156
- ${overview ? html`
1157
- <div class="pj-chart">
1158
- <div class="pj-chart-head">
1159
- <span class="pj-chart-title">${'🔗 '}${t('organisms.overview') || 'Overview — who & what uses this organism'}</span>
1160
- <button class="btn-ghost btn-sm" onClick=${() => setShowOverview(s => !s)}>${showOverview ? (t('organisms.hide') || 'Hide') : (t('organisms.show') || 'Show')}</button>
1161
- </div>
1162
- ${showOverview ? html`<${Mermaid} chart=${overview} />` : null}
1163
- </div>` : null}
1164
-
1165
1583
  <div class="pj-ws-bar">
1166
1584
  ${creating ? html`
1167
1585
  <input class="input-field input-sm pj-ws-name-input" autofocus placeholder=${t('organisms.workspaceName') || 'Workspace name'}
@@ -1170,46 +1588,83 @@ function WorkspaceList({ org, showToast, onOpen, onCount }) {
1170
1588
  <button class="btn-ghost btn-sm" onClick=${() => { setCreating(false); setNewName(''); }}>${t('organisms.cancel') || 'Cancel'}</button>
1171
1589
  ` : html`
1172
1590
  <button class="btn-primary btn-sm" onClick=${() => setCreating(true)}>${'+ '}${t('organisms.newWorkspace') || 'New workspace'}</button>
1173
- <button class="btn-outline btn-sm" disabled=${busy} title=${t('organisms.importHint') || 'Restore a workspace from a .zip backup'} onClick=${() => fileRef.current && fileRef.current.click()}>${'⬆ '}${t('organisms.import') || 'Import'}</button>`}
1591
+ <button class="btn-outline btn-sm" disabled=${busy} title=${t('organisms.importHint') || 'Restore a workspace from a .zip backup'} onClick=${() => fileRef.current && fileRef.current.click()}>${'⬆ '}${t('organisms.import') || 'Import'}</button>
1592
+ ${overview ? html`<button class="btn-outline btn-sm ${showOverview ? 'pj-org-btn-active' : ''}" onClick=${() => setShowOverview(s => !s)}>${'🗺 '}${t('organisms.showMap') || 'Map'}</button>` : null}`}
1174
1593
  </div>
1175
1594
 
1595
+ <${OrgSearch} orgId=${orgId} onOpenWorkspace=${(ws) => onOpen(ws)} />
1596
+
1176
1597
  ${list === null ? html`<${Spinner} />`
1177
1598
  : list.length === 0 ? html`<${EmptyState} icon="🗂️" text=${t('organisms.noWorkspaces') || 'No workspaces yet — create one to get started.'} />`
1178
1599
  : html`
1179
- <div class="pj-ws-list-label">${t('organisms.currentWorkspaces') || 'Workspaces in this organism'}</div>
1180
- <div class="pj-ws-list">
1181
- ${list.map(w => html`
1182
- <div class="pj-ws-card" key=${w.id}>
1183
- ${w.access === 'none' ? html`
1184
- <div class="pj-ws-open pj-ws-locked">
1185
- <span class="pj-ws-card-name">${'🔒 '}${escHtml(w.name || w.id)}</span>
1186
- <span class="pj-ws-card-meta">${(t('organisms.byCreator') || 'by {creator}').replace('{creator}', w.created_by || '?')}</span>
1600
+ <div class="pj-org-list">
1601
+ ${list.map(w => {
1602
+ const locked = w.access === 'none';
1603
+ const reviews = apprByWs[w.id] || 0;
1604
+ const menuItems = w.access === 'owner' ? [
1605
+ { label: t('organisms.requests') || 'Access requests', icon: '👥', onClick: () => toggleInbox(w) },
1606
+ { label: t('organisms.export') || 'Export backup (.zip)', icon: '⬇', onClick: () => doExport(w) },
1607
+ { label: t('organisms.delete') || 'Delete', danger: true, onClick: () => remove(w.id, w.name || w.id) },
1608
+ ] : [];
1609
+ return html`
1610
+ <div class="pj-org-row" key=${w.id}>
1611
+ <div class="pj-org-avatar" aria-hidden="true">${'🗂'}</div>
1612
+ <div class="pj-org-main ${locked ? 'pj-org-main-static' : ''}" role=${locked ? undefined : 'button'} tabindex=${locked ? undefined : '0'}
1613
+ onClick=${locked ? undefined : (() => onOpen(w.id))}
1614
+ onKeyDown=${locked ? undefined : ((e) => { if (e.key === 'Enter') onOpen(w.id); })}>
1615
+ <div class="pj-org-titlerow">
1616
+ ${locked ? html`<span class="pj-org-lock">${'🔒'}</span>` : null}
1617
+ <span class="pj-org-name">${(w.name || w.id)}</span>
1618
+ ${reviews > 0 ? html`<span class="pj-tab-pill">${'📨 '}${(t('organisms.toReview') || '{n} to review').replace('{n}', String(reviews))}</span>` : null}
1187
1619
  </div>
1188
- <button class="btn-outline btn-sm" disabled=${busy} onClick=${() => requestAccess(w)}>${t('organisms.requestAccess') || 'Request access'}</button>
1189
- ` : html`
1190
- <button class="pj-ws-open" onClick=${() => onOpen(w.id)}>
1191
- <span class="pj-ws-card-name">${escHtml(w.name || w.id)}</span>
1192
- <span class="pj-ws-card-meta">${w.access === 'owner' ? (t('organisms.yours') || 'yours') : (t('organisms.byCreator') || 'by {creator}').replace('{creator}', w.created_by || '?')}${w.created_at ? ' · ' + dt(w.created_at) : ''}</span>
1193
- </button>
1194
- ${w.access === 'owner' ? html`
1195
- <button class="pj-icon-btn" title=${t('organisms.requests') || 'Access requests'} onClick=${() => toggleInbox(w)}>👥</button>
1196
- <button class="pj-icon-btn" title=${t('organisms.export') || 'Export backup (.zip)'} onClick=${() => doExport(w)}>⬇</button>
1197
- <button class="pj-icon-btn" title=${t('organisms.delete') || 'Delete'} disabled=${busy} onClick=${() => remove(w.id, w.name || w.id)}>✕</button>
1198
- ` : null}
1199
- `}
1620
+ <div class="pj-org-desc">${locked
1621
+ ? (t('organisms.byCreator') || 'by {creator}').replace('{creator}', w.created_by || '?')
1622
+ : metaLine(w)}</div>
1623
+ </div>
1624
+ <div class="pj-org-stats">
1625
+ ${(wsStats[w.id]?.owners || []).length > 0 ? html`
1626
+ <button class="pj-org-stat pj-stat-btn ${openPeopleWs === w.id ? 'active' : ''}" title=${t('organisms.participants') || 'Who works here'}
1627
+ onClick=${(e) => { e.stopPropagation(); setOpenPeopleWs(p => (p === w.id ? null : w.id)); }}>${'👥'} ${wsStats[w.id].owners.length}</button>` : null}
1628
+ ${w.created_at ? html`<span class="pj-org-stat pj-org-date" title=${t('organisms.createdAt') || 'Created'}>${fmtDate(w.created_at)}</span>` : null}
1629
+ </div>
1630
+ ${locked
1631
+ ? html`<button class="btn-outline btn-sm pj-org-openbtn" disabled=${busy} onClick=${() => requestAccess(w)}>${t('organisms.requestAccess') || 'Request access'}</button>`
1632
+ : html`<button class="btn-outline btn-sm pj-org-openbtn" onClick=${() => onOpen(w.id)}>${t('organisms.open') || 'Open'}</button>`}
1633
+ ${menuItems.length ? html`<${KebabMenu} label=${t('organisms.moreActions') || 'More actions'} items=${menuItems} />` : null}
1634
+ ${openPeopleWs === w.id ? html`
1635
+ <div class="pj-org-detail">
1636
+ ${(wsStats[w.id]?.owners || []).map(o => html`
1637
+ <div class="pj-ws-person" key=${'p-' + o.owner}>
1638
+ <span>${'👤 '}<strong>${(o.owner)}</strong></span>
1639
+ ${o.isCreator ? html`<span class="badge badge-success pj-mini">${t('organisms.creatorTag') || 'creator'}</span>` : null}
1640
+ ${o.isSelf ? html`<span class="badge badge-info pj-mini">${t('organisms.you') || 'you'}</span>` : null}
1641
+ ${!o.isLocalNode ? html`<span class="pj-mini">${'🌐 '}${(o.node)}</span>` : null}
1642
+ ${(o.agents || []).length > 0 ? html`<span class="pj-ws-person-agents">${'🤖'} ${(o.agents || []).length}</span>` : null}
1643
+ </div>`)}
1644
+ </div>` : null}
1200
1645
  ${openReqWs === w.id ? html`
1201
- <div class="pj-ws-inbox">
1646
+ <div class="pj-org-detail">
1202
1647
  ${reqInbox.length === 0 ? html`<div class="pj-ws-inbox-empty">${t('organisms.noRequests') || 'No access requests.'}</div>`
1203
1648
  : reqInbox.map(r => html`
1204
1649
  <div class="pj-ws-req" key=${r.requester}>
1205
- <span class="pj-ws-req-who">${escHtml(r.requester)}${r.message ? html` <span class="pj-ws-req-msg">— ${escHtml(r.message)}</span>` : null}</span>
1650
+ <span class="pj-ws-req-who">${(r.requester)}${r.message ? html` <span class="pj-ws-req-msg">— ${(r.message)}</span>` : null}</span>
1206
1651
  ${r.status === 'approved'
1207
1652
  ? html`<span class="pj-ws-req-ok">✓ ${t('organisms.approved') || 'approved'}</span><button class="btn-ghost btn-sm" disabled=${busy} onClick=${() => decide(w, r.requester, 'deny')}>${t('organisms.revoke') || 'Revoke'}</button>`
1208
1653
  : html`<button class="btn-success btn-sm" disabled=${busy} onClick=${() => decide(w, r.requester, 'approve')}>${t('organisms.approve') || 'Approve'}</button><button class="btn-ghost btn-sm" disabled=${busy} onClick=${() => decide(w, r.requester, 'deny')}>${t('organisms.deny') || 'Deny'}</button>`}
1209
1654
  </div>`)}
1210
1655
  </div>` : null}
1211
- </div>`)}
1656
+ </div>`;
1657
+ })}
1212
1658
  </div>`}
1659
+
1660
+ ${overview && showOverview ? html`
1661
+ <div class="pj-chart">
1662
+ <div class="pj-chart-head">
1663
+ <span class="pj-chart-title">${'🔗 '}${t('organisms.overview') || 'Overview — who & what uses this organism'}</span>
1664
+ <button class="btn-ghost btn-sm" onClick=${() => setShowOverview(false)}>${t('organisms.hide') || 'Hide'}</button>
1665
+ </div>
1666
+ <${Mermaid} chart=${overview} />
1667
+ </div>` : null}
1213
1668
  </div>`;
1214
1669
  }
1215
1670
 
@@ -1320,10 +1775,10 @@ function ActivityPanel({ orgId, wsId }) {
1320
1775
  ${events.slice(0, 20).map((e, i) => html`<div class="pj-act-item" key=${i}>
1321
1776
  <span class="pj-act-dot ${e.action}"></span>
1322
1777
  <span class="pj-act-time">${dt(e.at)}</span>
1323
- <span class="pj-act-who">${escHtml(e.actor)}</span>
1324
- ${e.agent ? html`<span class="pj-act-agent" title=${t('organisms.viaAgent') || 'via this agent'}>${'🤖 '}${escHtml(e.agent)}</span>` : null}
1778
+ <span class="pj-act-who">${(e.actor)}</span>
1779
+ ${e.agent ? html`<span class="pj-act-agent" title=${t('organisms.viaAgent') || 'via this agent'}>${'🤖 '}${(e.agent)}</span>` : null}
1325
1780
  <span class="pj-act-act">${e.action === 'publish' ? (t('organisms.publishedVerb') || 'published') : (t('organisms.editedVerb') || 'edited')}</span>
1326
- <span class="pj-act-what">${escHtml(e.mode === 'document' ? '📄' : '🗂')} ${escHtml(e.type)}${' / '}${escHtml(e.instance)}</span>
1781
+ <span class="pj-act-what">${(e.mode === 'document' ? '📄' : '🗂')} ${(e.type)}${' / '}${(e.instance)}</span>
1327
1782
  </div>`)}
1328
1783
  </div>
1329
1784
  </div>` : null}
@@ -1373,18 +1828,18 @@ function ParticipantsPanel({ orgId, wsId }) {
1373
1828
  <div class="pj-parts-list">
1374
1829
  ${owners.map((o, i) => html`<div class="pj-part-owner" key=${i}>
1375
1830
  <div class="pj-part-human">
1376
- <span>${'👤 '}<strong>${escHtml(o.owner)}</strong></span>
1831
+ <span>${'👤 '}<strong>${(o.owner)}</strong></span>
1377
1832
  ${o.isSelf ? html`<span class="badge badge-info pj-mini">${t('organisms.you') || 'you'}</span>` : null}
1378
1833
  ${o.isCreator ? html`<span class="badge badge-success pj-mini">${t('organisms.creatorTag') || 'creator'}</span>` : null}
1379
1834
  ${!o.isMember && !o.isSelf ? html`<span class="badge badge-warn pj-mini">${t('organisms.guest') || 'guest'}</span>` : null}
1380
- ${!o.isLocalNode ? html`<span class="pj-part-node">${'🌐 '}${escHtml(o.node)}</span>` : null}
1835
+ ${!o.isLocalNode ? html`<span class="pj-part-node">${'🌐 '}${(o.node)}</span>` : null}
1381
1836
  ${o.contributions ? html`<span class="pj-part-count" title=${t('organisms.contributions') || 'contributions'}>${o.contributions}</span>` : null}
1382
1837
  </div>
1383
1838
  ${(o.agents || []).length ? html`<div class="pj-part-agents">
1384
1839
  ${o.agents.map((a, j) => html`
1385
1840
  <span class="pj-part-agent ${a.isOwn ? 'own' : 'ghost'}" key=${j}
1386
1841
  title=${a.isOwn ? '' : (t('organisms.otherAgentHint') || 'Another owner’s agent — you see what it has done here, not its live status')}>
1387
- ${'🤖 '}${escHtml(a.name)}<span class="pj-part-count">${a.contributions}</span>
1842
+ ${'🤖 '}${(a.name)}<span class="pj-part-count">${a.contributions}</span>
1388
1843
  </span>`)}
1389
1844
  </div>` : null}
1390
1845
  </div>`)}
@@ -1393,7 +1848,7 @@ function ParticipantsPanel({ orgId, wsId }) {
1393
1848
  <div class="pj-access">
1394
1849
  <div class="pj-access-title">${t('organisms.manageAccess') || 'Manage who can work here'}</div>
1395
1850
  ${pending.map(r => html`<div class="pj-access-row" key=${'req-' + r.requester}>
1396
- <span class="pj-access-who">${'🙋 '}<strong>${escHtml(r.requester)}</strong> <span class="pj-access-note">${t('organisms.requestedAccess') || 'requested access'}</span></span>
1851
+ <span class="pj-access-who">${'🙋 '}<strong>${(r.requester)}</strong> <span class="pj-access-note">${t('organisms.requestedAccess') || 'requested access'}</span></span>
1397
1852
  <span class="pj-access-actions">
1398
1853
  <button class="btn-success btn-sm" disabled=${busy} onClick=${() => doDecide(r.requester, 'contributor')}>${t('organisms.addAsContributor') || 'Add as contributor'}</button>
1399
1854
  <button class="btn-outline btn-sm" disabled=${busy} onClick=${() => doDecide(r.requester, 'viewer')}>${t('organisms.addAsViewer') || 'as viewer'}</button>
@@ -1401,7 +1856,7 @@ function ParticipantsPanel({ orgId, wsId }) {
1401
1856
  </span>
1402
1857
  </div>`)}
1403
1858
  ${members.map(m => html`<div class="pj-access-row" key=${'mem-' + m.owner}>
1404
- <span class="pj-access-who">${'👤 '}<strong>${escHtml(m.owner)}</strong> <span class="badge ${m.role === 'contributor' ? 'badge-success' : 'badge-info'} pj-mini">${m.role === 'contributor' ? (t('organisms.roleContributorShort') || 'contributor') : (t('organisms.roleViewerShort') || 'viewer')}</span></span>
1859
+ <span class="pj-access-who">${'👤 '}<strong>${(m.owner)}</strong> <span class="badge ${m.role === 'contributor' ? 'badge-success' : 'badge-info'} pj-mini">${m.role === 'contributor' ? (t('organisms.roleContributorShort') || 'contributor') : (t('organisms.roleViewerShort') || 'viewer')}</span></span>
1405
1860
  <span class="pj-access-actions">
1406
1861
  ${m.role === 'viewer'
1407
1862
  ? html`<button class="btn-outline btn-sm" disabled=${busy} onClick=${() => doGrant(m.owner, 'contributor')}>${t('organisms.makeContributor') || '→ can write'}</button>`
@@ -1483,13 +1938,13 @@ function WorkspaceComments({ orgId, ws, space, instanceId, showToast }) {
1483
1938
  ${list.map(c => html`
1484
1939
  <div class="pj-comment ${c.parentId ? 'pj-comment-reply' : ''}" key=${c.id}>
1485
1940
  <div class="pj-comment-head">
1486
- <b>${escHtml(c.author || '?')}</b>
1941
+ <b>${(c.author || '?')}</b>
1487
1942
  ${c.parentId ? html`<span class="pj-mini"> · ${t('organisms.inReply') || 'reply'}</span>` : null}
1488
- ${c.anchor?.quote ? html`<span class="pj-mini"> · “${escHtml(String(c.anchor.quote).slice(0, 80))}”</span>` : null}
1489
- ${c.anchor?.section ? html`<span class="pj-mini"> · §${escHtml(c.anchor.section)}</span>` : null}
1943
+ ${c.anchor?.quote ? html`<span class="pj-mini"> · “${(String(c.anchor.quote).slice(0, 80))}”</span>` : null}
1944
+ ${c.anchor?.section ? html`<span class="pj-mini"> · §${(c.anchor.section)}</span>` : null}
1490
1945
  <span class="pj-mini"> · ${c.createdAt ? dt(c.createdAt) : ''}</span>
1491
1946
  </div>
1492
- <div class="pj-comment-body">${escHtml(c.body || '')}</div>
1947
+ <div class="pj-comment-body">${(c.body || '')}</div>
1493
1948
  <div class="pj-comment-actions">
1494
1949
  <button class="btn-ghost btn-sm" disabled=${busy} onClick=${() => setReplyTo({ id: c.id, body: c.body })}>${t('organisms.reply') || 'Reply'}</button>
1495
1950
  ${mine(c.author) ? html`<button class="btn-ghost btn-sm" disabled=${busy} onClick=${() => remove(c)}>${t('organisms.delete') || 'Delete'}</button>` : null}
@@ -1497,7 +1952,7 @@ function WorkspaceComments({ orgId, ws, space, instanceId, showToast }) {
1497
1952
  </div>
1498
1953
  `)}
1499
1954
  <div class="pj-comment-compose">
1500
- ${replyTo ? html`<div class="pj-mini">${t('organisms.replyingTo') || 'Replying to'}: “${escHtml(String(replyTo.body || '').slice(0, 60))}” <button class="btn-ghost btn-sm" onClick=${() => setReplyTo(null)}>${t('organisms.cancel') || 'Cancel'}</button></div>` : null}
1955
+ ${replyTo ? html`<div class="pj-mini">${t('organisms.replyingTo') || 'Replying to'}: “${(String(replyTo.body || '').slice(0, 60))}” <button class="btn-ghost btn-sm" onClick=${() => setReplyTo(null)}>${t('organisms.cancel') || 'Cancel'}</button></div>` : null}
1501
1956
  <input class="input-field input-sm" placeholder=${t('organisms.anchorQuotePlaceholder') || 'Optional: quote a passage to anchor the comment'} value=${anchorQuote} onInput=${(e) => setAnchorQuote(e.target.value)} />
1502
1957
  <textarea class="input-field input-sm" rows="2" placeholder=${t('organisms.commentPlaceholder') || 'Add a comment…'} value=${body} onInput=${(e) => setBody(e.target.value)}></textarea>
1503
1958
  <button class="btn-primary btn-sm" disabled=${busy || !body.trim()} onClick=${submit}>${t('organisms.postComment') || 'Comment'}</button>
@@ -1506,8 +1961,15 @@ function WorkspaceComments({ orgId, ws, space, instanceId, showToast }) {
1506
1961
  `;
1507
1962
  }
1508
1963
 
1509
- function Workspace({ org, wsId, showToast, onBack }) {
1964
+ function Workspace({ org, wsId, showToast, onBack, onBackToList }) {
1510
1965
  const orgId = org.id;
1966
+ // Registry name for the breadcrumb (the manifest's name can differ from the workspace's name).
1967
+ const [wsName, setWsName] = useState('');
1968
+ useEffect(() => {
1969
+ let cancelled = false;
1970
+ orgService.listWorkspaces(orgId).then(list => { if (!cancelled) setWsName((list.find(w => w.id === wsId)?.name) || ''); }).catch(() => {});
1971
+ return () => { cancelled = true; };
1972
+ }, [orgId, wsId]);
1511
1973
  // Pop a document out into its own window (served by doc-solo.js) so several can sit side by side,
1512
1974
  // each independent. The window name is unique per document, so re-clicking focuses the open one.
1513
1975
  const popOut = (typeName, docId) => {
@@ -1543,20 +2005,23 @@ function Workspace({ org, wsId, showToast, onBack }) {
1543
2005
  } catch (e) { /* noop */ }
1544
2006
  return null;
1545
2007
  });
2008
+ // Active tab — one tab per manifest space + fixed Review/Activity/People. Persisted per workspace
2009
+ // so an F5 (or back-and-forth) returns to the same tab. '' = not chosen yet → first space.
2010
+ const tabStoreKey = 'aimeat.ws.' + orgId + '.' + wsId + '.tab';
2011
+ const [tab, setTabState] = useState(() => { try { return sessionStorage.getItem(tabStoreKey) || ''; } catch (e) { return ''; } });
2012
+ const setTab = (id) => { setTabState(id); try { sessionStorage.setItem(tabStoreKey, id); } catch (e) { /* noop */ } };
1546
2013
  const [sectionsByType, setSectionsByType] = useState({}); // { typeName: [{id,name,parentId,documents:[docId]}] }
1547
2014
  const [editingSec, setEditingSec] = useState(null); // section id currently being renamed inline
1548
2015
  const draggedDoc = useRef(null); // { type, id } of the doc being dragged
1549
2016
  const [showRegenerate, setShowRegenerate] = useState(false);
1550
2017
  const [delConfirm, setDelConfirm] = useState(''); // typed-name confirmation for delete
1551
2018
  const [newSpaceName, setNewSpaceName] = useState('');
1552
- const [newSpaceMode, setNewSpaceMode] = useState('document');
1553
2019
  const [addingInitial, setAddingInitial] = useState(null); // record being edited (null = new draft)
1554
2020
  const [addingId, setAddingId] = useState(null); // its id, preserved so save overwrites
1555
2021
  const [expandedRec, setExpandedRec] = useState({}); // { "type:id": true } — records expanded to view fields
1556
2022
  const [showSpaces, setShowSpaces] = useState(false); // inline add-space form at the workspace top
1557
2023
  const [showFlow, setShowFlow] = useState(true); // the manifest-defined edit-flow chart (first item)
1558
- const [showShare, setShowShare] = useState(false); // public-sharing panel
1559
- const [share, setShare] = useState(null); // { public, spaces, docs } — lazy-loaded when the panel opens
2024
+ const [share, setShare] = useState(null); // { public, spaces, docs } — lazy-loaded when Settings opens
1560
2025
  const [shareBusy, setShareBusy] = useState(false);
1561
2026
 
1562
2027
  const load = useCallback(async () => {
@@ -1667,11 +2132,20 @@ function Workspace({ org, wsId, showToast, onBack }) {
1667
2132
  const cancelForm = () => { setAdding(null); setAddingSchema(null); setAddingInitial(null); setAddingId(null); };
1668
2133
  const toggleExpand = (ot, id) => setExpandedRec(s => ({ ...s, [ot.name + ':' + id]: !s[ot.name + ':' + id] }));
1669
2134
  // Read-only field view for a record (skips the underscore-prefixed metadata the read attaches).
1670
- const recordFields = (rec) => {
2135
+ // Manifest-carried workspace translations (generated workspaces): field labels at
2136
+ // "{namespace}.{field}", hints at "{namespace}.{field}.hint", space labels at "type.{name}".
2137
+ // Resolution: active locale → en → '' (callers fall back to the raw key/name).
2138
+ const wsT = useCallback((key) => {
2139
+ const m = ws?.manifest?.i18n;
2140
+ if (!m || typeof m !== 'object') return '';
2141
+ const lang = getLocale();
2142
+ return (m[lang] && m[lang][key]) || (m.en && m.en[key]) || '';
2143
+ }, [ws]);
2144
+ const recordFields = (ot, rec) => {
1671
2145
  const rows = Object.entries(rec || {}).filter(([k, v]) =>
1672
2146
  !k.startsWith('_') && v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && v.length === 0));
1673
2147
  if (!rows.length) return html`<div class="pj-muted pj-rec-empty">${t('organisms.noFields') || 'No fields'}</div>`;
1674
- return rows.map(([k, v]) => html`<${KeyValueRow} key=${k} label=${k}
2148
+ return rows.map(([k, v]) => html`<${KeyValueRow} key=${k} label=${wsT(`${ot.namespace}.${k}`) || k}
1675
2149
  value=${Array.isArray(v) ? v.join(', ') : (typeof v === 'object' ? JSON.stringify(v) : String(v))} />`);
1676
2150
  };
1677
2151
 
@@ -1777,14 +2251,40 @@ function Workspace({ org, wsId, showToast, onBack }) {
1777
2251
  finally { setBusy(false); }
1778
2252
  }, [orgId, gateOn, showToast]);
1779
2253
 
1780
- // Populate the settings fields from the manifest whenever the panel opens (incl. after generation).
2254
+ // Populate the settings fields from the manifest when the panel opens (incl. after generation) or
2255
+ // fresher data arrives — but only while the user hasn't touched the fields (seededRef compare), so
2256
+ // a live-update reload never clobbers typing.
2257
+ const seededRef = useRef(null); // { name, summary, autonomy } last written into the fields
1781
2258
  useEffect(() => {
1782
- if (showSettings && ws?.manifest) {
1783
- setSName(ws.manifest.name || '');
1784
- setSSummary(ws.manifest.summary || '');
1785
- setSAutonomy(ws.manifest.policy?.agentAutonomy || 'L3');
2259
+ if (!showSettings || !ws?.manifest) return;
2260
+ const sv = seededRef.current;
2261
+ const untouched = !sv || (sName === sv.name && sSummary === sv.summary && sAutonomy === sv.autonomy);
2262
+ if (untouched) {
2263
+ const next = { name: ws.manifest.name || '', summary: ws.manifest.summary || '', autonomy: ws.manifest.policy?.agentAutonomy || 'L3' };
2264
+ setSName(next.name); setSSummary(next.summary); setSAutonomy(next.autonomy);
2265
+ seededRef.current = next;
1786
2266
  }
1787
- }, [showSettings, ws]);
2267
+ }, [showSettings, ws]); // eslint-disable-line react-hooks/exhaustive-deps
2268
+
2269
+ // Public sharing lives in its own tab — lazy-load the share state the first time it opens.
2270
+ useEffect(() => {
2271
+ if (tab === 'share' && share === null && ws?.manifest) loadShare();
2272
+ }, [tab, ws]); // eslint-disable-line react-hooks/exhaustive-deps
2273
+
2274
+ // Dirty-check against the manifest: Save enables only on real changes (doubles as the
2275
+ // unsaved-changes indicator), Cancel resets the fields, and closing a dirty panel asks first.
2276
+ const wsDirty = !!ws?.manifest && (sName !== (ws.manifest.name || '')
2277
+ || sSummary !== (ws.manifest.summary || '')
2278
+ || sAutonomy !== (ws.manifest.policy?.agentAutonomy || 'L3'));
2279
+ const resetSettingsForm = () => {
2280
+ const next = { name: ws?.manifest?.name || '', summary: ws?.manifest?.summary || '', autonomy: ws?.manifest?.policy?.agentAutonomy || 'L3' };
2281
+ setSName(next.name); setSSummary(next.summary); setSAutonomy(next.autonomy);
2282
+ seededRef.current = next;
2283
+ };
2284
+ const guardWsDirty = (fn) => {
2285
+ if (showSettings && wsDirty) confirm(t('organisms.discardChanges') || 'Discard unsaved changes?', () => { resetSettingsForm(); fn(); }, { danger: true });
2286
+ else fn();
2287
+ };
1788
2288
 
1789
2289
  const saveSettings = useCallback(async () => {
1790
2290
  setBusy(true);
@@ -1798,7 +2298,6 @@ function Workspace({ org, wsId, showToast, onBack }) {
1798
2298
  };
1799
2299
  await orgService.saveManifest(orgId, wsId, m);
1800
2300
  showToast(t('organisms.settingsSaved') || 'Settings saved');
1801
- setShowSettings(false);
1802
2301
  await load();
1803
2302
  } catch (e) { showToast((e && e.message) || 'Failed to save settings'); }
1804
2303
  finally { setBusy(false); }
@@ -1831,13 +2330,15 @@ function Workspace({ org, wsId, showToast, onBack }) {
1831
2330
  if (!newSpaceName.trim() || !ws?.manifest) return;
1832
2331
  setBusy(true);
1833
2332
  try {
1834
- await orgService.addSpace(orgId, wsId, ws.manifest, newSpaceName.trim(), newSpaceMode);
2333
+ // Manual adds are document spaces ONLY — a record type needs a schema the user can't author
2334
+ // by hand; those are designed with AI via Settings → Process (restructure).
2335
+ await orgService.addSpace(orgId, wsId, ws.manifest, newSpaceName.trim(), 'document');
1835
2336
  showToast(t('organisms.spaceAdded') || 'Space added');
1836
2337
  setNewSpaceName(''); setShowSpaces(false);
1837
2338
  await load();
1838
2339
  } catch (e) { showToast((e && e.message) || 'Failed to add space'); }
1839
2340
  finally { setBusy(false); }
1840
- }, [newSpaceName, newSpaceMode, ws, wsId, orgId, showToast, load]);
2341
+ }, [newSpaceName, ws, wsId, orgId, showToast, load]);
1841
2342
 
1842
2343
  const removeSpaceHandler = useCallback((typeName) => {
1843
2344
  confirm(
@@ -1905,14 +2406,14 @@ function Workspace({ org, wsId, showToast, onBack }) {
1905
2406
  ${genFail && html`
1906
2407
  <div class="pj-errors">
1907
2408
  <div class="pj-errors-title">${t('organisms.genFailed') || 'Generation failed — try again'}</div>
1908
- <div class="pj-error-line">${escHtml(genFail)}</div>
2409
+ <div class="pj-error-line">${(genFail)}</div>
1909
2410
  </div>
1910
2411
  `}
1911
2412
 
1912
2413
  ${genErrors.length > 0 && html`
1913
2414
  <div class="pj-errors">
1914
2415
  <div class="pj-errors-title">${t('organisms.fixNeeded') || 'This needs fixing before it can be saved:'}</div>
1915
- ${genErrors.map((e, i) => html`<div class="pj-error-line" key=${i}>${escHtml(e)}</div>`)}
2416
+ ${genErrors.map((e, i) => html`<div class="pj-error-line" key=${i}>${(e)}</div>`)}
1916
2417
  <div class="form-actions">
1917
2418
  <button class="btn-outline btn-sm" onClick=${copyFixPrompt}>${t('organisms.copyFixPrompt') || 'Copy fix prompt for the AI'}</button>
1918
2419
  </div>
@@ -1927,11 +2428,19 @@ function Workspace({ org, wsId, showToast, onBack }) {
1927
2428
  </div>
1928
2429
  `;
1929
2430
 
2431
+ // Same breadcrumb pattern as the organism home: Organisms / {org} / {workspace} — both ancestors
2432
+ // are links, so the list is one click away from inside a workspace too.
1930
2433
  const back = html`
1931
- <div class="pj-ws-crumbs">
1932
- <button class="btn-ghost btn-sm" onClick=${onBack}>${'← '}${t('organisms.backToWorkspaces') || 'Workspaces'}</button>
1933
- <span class="pj-crumb-sep">/</span>
1934
- <span class="pj-crumb-org" title=${t('organisms.workspaceOrganism') || 'Organism'}>${'🧬 '}${escHtml(org.name || '')}</span>
2434
+ <div class="pj-org-breadcrumb">
2435
+ <button class="pj-org-crumb-link" onClick=${onBackToList || onBack}>${t('organisms.title') || 'Organisms'}</button>
2436
+ <span class="pj-org-crumb-sep">/</span>
2437
+ <button class="pj-org-crumb-link" onClick=${onBack}>${(org.name || org.id || '')}</button>
2438
+ <span class="pj-org-crumb-sep">/</span>
2439
+ ${showSettings ? html`
2440
+ <button class="pj-org-crumb-link" onClick=${() => guardWsDirty(() => setShowSettings(false))}>${(wsName || ws?.manifest?.name || '…')}</button>
2441
+ <span class="pj-org-crumb-sep">/</span>
2442
+ <span>${t('organisms.settings') || 'Settings'}</span>
2443
+ ` : html`<span>${(wsName || ws?.manifest?.name || '…')}</span>`}
1935
2444
  </div>`;
1936
2445
 
1937
2446
  if (ws === undefined) return html`<div>${back}<${Spinner} text=${t('organisms.loading') || 'Loading...'} /></div>`;
@@ -1940,7 +2449,7 @@ function Workspace({ org, wsId, showToast, onBack }) {
1940
2449
  return html`
1941
2450
  <div class="pj-ws">
1942
2451
  ${back}
1943
- <div class="section-title">${escHtml(org.name || 'Organism')}</div>
2452
+ <div class="section-title">${(org.name || 'Organism')}</div>
1944
2453
  <div class="section-desc">${t('organisms.noWorkspace') || 'This organism has no workspace yet. Set one up to track goals, plans, deliverables and decisions — versioned on publish.'}</div>
1945
2454
  <button class="btn-primary" onClick=${setup} disabled=${busy || genBusy}>${busy ? '...' : (t('organisms.setupWorkspace') || 'Set up workspace (project template)')}</button>
1946
2455
  ${renderGenerator()}
@@ -1954,7 +2463,6 @@ function Workspace({ org, wsId, showToast, onBack }) {
1954
2463
  // documents once became unfindable: writes succeeded, every list surface skipped the space).
1955
2464
  const allTypes = ws.manifest?.objectTypes || [];
1956
2465
  const types = allTypes.filter(orgService.isMemorySpace);
1957
- const otherTypes = allTypes.filter(ot => !orgService.isMemorySpace(ot));
1958
2466
  const isDocSpace = orgService.isDocSpace;
1959
2467
  const draftsFor = (name) => (ws.drafts && ws.drafts[name]) || [];
1960
2468
  const objectsFor = (name) => (ws.objects && ws.objects[name]) || [];
@@ -2032,7 +2540,7 @@ function Workspace({ org, wsId, showToast, onBack }) {
2032
2540
  ? html`<input class="input-field input-xs pj-sec-name" autofocus placeholder=${t('organisms.sectionName') || 'Section name'}
2033
2541
  value=${sec.name} onInput=${e => setSecName(ot.name, sec.id, e.target.value)}
2034
2542
  onBlur=${() => commitSecName(ot.name)} onKeyDown=${e => { if (e.key === 'Enter') e.target.blur(); }} />`
2035
- : html`<span class="pj-sec-name-text" onDblClick=${() => setEditingSec(sec.id)}>${escHtml(sec.name || t('organisms.unnamed') || '(unnamed)')}</span>`}
2543
+ : html`<span class="pj-sec-name-text" onDblClick=${() => setEditingSec(sec.id)}>${(sec.name || t('organisms.unnamed') || '(unnamed)')}</span>`}
2036
2544
  <button class="pj-icon-btn" title=${t('organisms.rename') || 'Rename'} onClick=${() => setEditingSec(sec.id)}>✎</button>
2037
2545
  <button class="pj-icon-btn" title=${t('organisms.newDocHere') || 'New document here'} onClick=${() => setActiveDoc({ type: ot.name, mode: 'edit', page: { id: '', title: '', markdown: '' }, sectionId: sec.id })}>+</button>
2038
2546
  <button class="pj-icon-btn" title=${t('organisms.addSubsection') || 'Sub-section'} onClick=${() => addSection(ot.name, sec.id)}>⊕</button>
@@ -2045,7 +2553,7 @@ function Workspace({ org, wsId, showToast, onBack }) {
2045
2553
  return html`
2046
2554
  <div class="pj-section" key=${ot.name}>
2047
2555
  <div class="pj-section-head">
2048
- <span class="pj-section-title">${escHtml(ot.name)}<span class="pj-doc-tag">${t('organisms.docs') || 'docs'}</span></span>
2556
+ <span class="pj-section-title">${(ot.name)}<span class="pj-doc-tag">${t('organisms.docs') || 'docs'}</span></span>
2049
2557
  <button class="btn-outline btn-sm" onClick=${() => addSection(ot.name, null)}>${'+ '}${t('organisms.section') || 'Section'}</button>
2050
2558
  <button class="btn-outline btn-sm" onClick=${() => setActiveDoc({ type: ot.name, mode: 'edit', page: { id: '', title: '', markdown: '' } })}>${'+ '}${t('organisms.newPage') || 'New document'}</button>
2051
2559
  </div>
@@ -2087,130 +2595,193 @@ function Workspace({ org, wsId, showToast, onBack }) {
2087
2595
  </div>`;
2088
2596
  };
2089
2597
 
2598
+ // A record-space tab: schema-form add/edit + draft and published record lists (with comments).
2599
+ const renderRecordSpace = (ot) => html`
2600
+ <div class="pj-section" key=${ot.name}>
2601
+ <div class="pj-section-head">
2602
+ <span class="pj-section-title">${(wsT('type.' + ot.name) || ot.name)}</span>
2603
+ ${ot.append ? null : html`<button class="btn-outline btn-sm" onClick=${() => startAdd(ot)}>${'+ '}${t('organisms.addDraft') || 'Add draft'}</button>`}
2604
+ </div>
2605
+
2606
+ ${adding === ot.name && (addingSchema
2607
+ ? html`<${SchemaForm} key=${'sf-' + (addingId || 'new')} schema=${addingSchema} busy=${busy} initial=${addingInitial}
2608
+ idPrefix=${ot.name} namespace=${ot.namespace} wsT=${wsT}
2609
+ onSave=${(v) => saveDraft(ot, addingId ? { ...v, id: addingId } : v)} onCancel=${cancelForm} />`
2610
+ : html`<${Spinner} />`)}
2611
+
2612
+ ${draftsFor(ot.name).map((d, i) => html`
2613
+ <div class="pj-rec" key=${'d' + i}>
2614
+ <div class="pj-item pj-item-draft">
2615
+ <span class="badge badge-warn">${t('organisms.draft') || 'draft'}</span>
2616
+ <button class="pj-rec-title" onClick=${() => toggleExpand(ot, d.id)}>${String(d[PRIMARY_FIELD[ot.name] || 'title'] || d.id || '')}</button>
2617
+ <button class="btn-ghost btn-sm" onClick=${() => startEdit(ot, d)} disabled=${busy}>${t('organisms.edit') || 'Edit'}</button>
2618
+ <button class="btn-primary btn-sm" onClick=${() => publish(ot, d.id)} disabled=${busy}>${t('organisms.publish') || 'Publish'}</button>
2619
+ <button class="pj-icon-btn" title=${t('organisms.delete') || 'Delete'} disabled=${busy} onClick=${() => removeObject(ot.namespace, d.id, String(d[PRIMARY_FIELD[ot.name] || 'title'] || d.id))}>🗑</button>
2620
+ </div>
2621
+ ${expandedRec[ot.name + ':' + d.id] ? html`<div class="pj-rec-fields">${recordFields(ot, d)}</div><${WorkspaceComments} orgId=${orgId} ws=${wsId} space=${ot.name} instanceId=${d.id} showToast=${showToast} />` : null}
2622
+ </div>
2623
+ `)}
2624
+
2625
+ ${objectsFor(ot.name).length === 0 && draftsFor(ot.name).length === 0
2626
+ ? html`<${EmptyState} text=${t('organisms.noneYet') || 'none yet'} />`
2627
+ : objectsFor(ot.name).map((o, i) => html`
2628
+ <div class="pj-rec" key=${'o' + i}>
2629
+ <div class="pj-item">
2630
+ <button class="pj-rec-title" onClick=${() => toggleExpand(ot, o.id)}>${String(o[PRIMARY_FIELD[ot.name] || 'title'] || o.summary || o.id || '')}</button>
2631
+ ${o.status ? html`<span class="badge badge-info">${(o.status)}</span>` : null}
2632
+ <button class="pj-icon-btn" title=${t('organisms.delete') || 'Delete'} disabled=${busy} onClick=${() => removeObject(ot.namespace, o.id, String(o[PRIMARY_FIELD[ot.name] || 'title'] || o.id))}>🗑</button>
2633
+ </div>
2634
+ ${expandedRec[ot.name + ':' + o.id] ? html`<div class="pj-rec-fields">${recordFields(ot, o)}</div><${WorkspaceComments} orgId=${orgId} ws=${wsId} space=${ot.name} instanceId=${o.id} showToast=${showToast} />` : null}
2635
+ </div>
2636
+ `)
2637
+ }
2638
+ </div>`;
2639
+
2640
+ // A declared space whose backing isn't memory — never silently hidden, its tab shows a notice.
2641
+ const renderSpaceNotice = (ot) => html`
2642
+ <div class="pj-section" key=${ot.name}>
2643
+ <div class="pj-section-head">
2644
+ <span class="pj-section-title">${(ot.name)}</span>
2645
+ <span class="badge badge-warn">${(String(ot.backing))}</span>
2646
+ </div>
2647
+ <div class="pj-space-notice">${ot.backing === 'tasks'
2648
+ ? (t('organisms.spaceTasksBacked') || 'This space points at the task system — its items are tasks, not workspace records. Manage them in the Tasks views.')
2649
+ : (t('organisms.spaceBackingUnsupported') || 'This space’s backing is not supported, so its content is not shown here. Edit the workspace (manifest) and set this space’s backing to "memory" to restore it — files and knowledge packages attach via Sources or document images instead.')}</div>
2650
+ </div>`;
2651
+
2652
+ // ── Tabs: one per declared manifest space (memory-backed or not), then the fixed tabs.
2653
+ // The "+" pseudo-tab opens the add-space form, so adding spaces stays one click away.
2654
+ // Space labels are capitalized so the row reads uniformly next to the fixed tabs.
2655
+ const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
2656
+ const spaceCount = (name) => new Set([...draftsFor(name), ...objectsFor(name)].map(d => d.id)).size;
2657
+ const spaceTabs = allTypes.map(ot => ({
2658
+ id: 'space:' + ot.name, ot, label: cap(wsT('type.' + ot.name) || ot.name),
2659
+ count: orgService.isMemorySpace(ot) ? spaceCount(ot.name) : null,
2660
+ }));
2661
+ const fixedTabs = [
2662
+ { id: 'sources', label: t('organisms.sources') || 'Sources', count: null },
2663
+ { id: 'share', label: t('organisms.share') || 'Share', count: null },
2664
+ { id: 'review', label: t('organisms.tabReview') || 'Review', count: approvals.length || null },
2665
+ { id: 'activity', label: t('organisms.activity') || 'Activity', count: null },
2666
+ { id: 'people', label: t('organisms.tabPeople') || 'People', count: null },
2667
+ ];
2668
+ const wsTabs = [...spaceTabs, ...fixedTabs];
2669
+ // Settings REPLACES the tab content: while it is open no tab is active (the previous state — a
2670
+ // highlighted tab above settings content — lied about what the user was looking at).
2671
+ const activeTab = showSettings ? '' : (wsTabs.some(tb => tb.id === tab) ? tab : (spaceTabs[0]?.id || 'review'));
2672
+ const activeSpace = activeTab.startsWith('space:') ? allTypes.find(ot => ot.name === activeTab.slice(6)) : null;
2673
+ const pickTab = (id) => guardWsDirty(() => { setShowSettings(false); setTab(id); });
2674
+
2675
+ // Menu items are VERBS (a click copies to the clipboard; the toast confirms). The contract-agent
2676
+ // builder is a different category from the two copy actions, so a divider separates it.
2677
+ const agentMenuItems = [
2678
+ { label: t('organisms.copyChatPrompt') || 'Copy chat prompt', icon: '💬', onClick: () => copyAccessPrompt('human') },
2679
+ { label: t('organisms.copyCodingPrompt') || 'Copy coding agent prompt', icon: '⌨', onClick: () => copyAccessPrompt('agent') },
2680
+ { divider: true },
2681
+ { label: t('organisms.contractPrompt') || 'Create contract agent', icon: '⚙️', onClick: copyContractPrompt },
2682
+ ];
2683
+
2090
2684
  return html`
2091
2685
  <div class="pj-ws">
2092
2686
  <${ConfirmUI} />
2093
2687
  ${back}
2094
2688
  <div class="pj-ws-titlerow">
2095
- <span class="section-title pj-ws-title">${escHtml(ws.manifest?.name || org.name || 'Workspace')}</span>
2096
- <span class="badge badge-success">${escHtml(ws.manifest?.status || 'active')}</span>
2097
- </div>
2098
- ${ws.manifest?.summary ? html`<div class="section-desc">${escHtml(ws.manifest.summary)}</div>` : null}
2099
-
2100
- <div class="pj-divider"></div>
2101
-
2102
- <div class="pj-toolbar">
2103
- <button class="btn-ghost btn-sm" onClick=${() => setShowSettings(s => !s)}>${'⚙ '}${t('organisms.settings') || 'Settings'}</button>
2104
- <button class="btn-ghost btn-sm" onClick=${() => setShowSpaces(s => !s)}>${'+ '}${t('organisms.addSpaceBtn') || 'Document space'}</button>
2105
- ${docTypes.length > 0 ? html`<button class="btn-ghost btn-sm" title=${t('organisms.shareHint') || 'Publish document pages to a public, no-login link'} onClick=${() => { const n = !showShare; setShowShare(n); if (n && share === null) loadShare(); }}>${'🌐 '}${t('organisms.share') || 'Share'}</button>` : null}
2106
- <span class="pj-toolbar-sep"></span>
2107
- <span class="pj-ai-prompt">
2108
- <span class="pj-ai-prompt-label">${'🤖 '}${t('organisms.aiPrompt') || 'AI access prompt:'}</span>
2109
- <button class="btn-ghost btn-sm" onClick=${() => copyAccessPrompt('human')}>${t('organisms.aiPromptHuman') || 'For chat'}</button>
2110
- <button class="btn-ghost btn-sm" onClick=${() => copyAccessPrompt('agent')}>${t('organisms.aiPromptAgent') || 'For coding agent'}</button>
2111
- <button class="btn-ghost btn-sm" title=${t('organisms.contractPromptHint') || 'Copy a prompt to build an agent that processes this workspace (reads requests → writes results)'} onClick=${copyContractPrompt}>${'⚙️ '}${t('organisms.contractPrompt') || 'Create contract agent'}</button>
2689
+ <span class="section-title pj-ws-title">${(ws.manifest?.name || org.name || 'Workspace')}</span>
2690
+ <span class="badge badge-success">${(ws.manifest?.status || 'active')}</span>
2691
+ <span class="pj-ws-head-actions">
2692
+ <${KebabMenu} label=${t('organisms.forAgentsHint') || 'Copy a ready prompt that teaches an AI to use this workspace'}
2693
+ btnClass="btn-outline btn-sm" trigger=${'🤖 ' + (t('organisms.agentAccess') || 'Agent access') + ' ▾'} items=${agentMenuItems} />
2694
+ <button class="btn-outline btn-sm ${showSettings ? 'pj-org-btn-active' : ''}" onClick=${() => guardWsDirty(() => setShowSettings(s => !s))}>${'⚙ '}${t('organisms.settings') || 'Settings'}</button>
2112
2695
  </span>
2113
- <span class="pj-toolbar-sep"></span>
2114
- <label class="pj-gate-label pj-toolbar-gate" title=${t('organisms.publishGateHint') || 'When on, an agent’s publish is held for your review instead of going live'}>
2115
- <input type="checkbox" checked=${gateOn} onChange=${toggleGate} disabled=${busy} />
2116
- ${'🔒 '}${t('organisms.publishGate') || 'Require review before publishing'}
2117
- </label>
2696
+ </div>
2697
+ ${ws.manifest?.summary ? html`<div class="section-desc">${(ws.manifest.summary)}</div>` : null}
2698
+
2699
+ ${approvals.length > 0 && activeTab !== 'review' ? html`
2700
+ <div class="pj-ws-banner" role="status">
2701
+ <span class="pj-ws-banner-text">
2702
+ <b>${(t('organisms.reviewBanner') || '{n} waiting for review').replace('{n}', String(approvals.length))}</b>
2703
+ <span class="pj-ws-banner-sub">${t('organisms.reviewBannerSub') || 'Publishes are gated and need a human decision.'}</span>
2704
+ </span>
2705
+ <button class="btn-outline btn-sm" onClick=${() => setTab('review')}>${t('organisms.reviewQueue') || 'Review queue'}</button>
2706
+ </div>` : null}
2707
+
2708
+ <div class="pj-org-tabs" role="tablist">
2709
+ ${wsTabs.map(tb => html`
2710
+ <button class="pj-org-tab ${activeTab === tb.id ? 'active' : ''}" role="tab" aria-selected=${activeTab === tb.id} key=${tb.id}
2711
+ onClick=${() => pickTab(tb.id)}>
2712
+ ${(tb.label)}${tb.count !== null && tb.count !== undefined ? html`<span class="pj-org-tab-count">${tb.count}</span>` : null}
2713
+ </button>`)}
2714
+ <button class="pj-org-tab pj-ws-tab-add" title=${t('organisms.addDocSpaceTitle') || 'Add a document space'} onClick=${() => guardWsDirty(() => { setShowSettings(false); setShowSpaces(s => !s); })}>+</button>
2118
2715
  </div>
2119
2716
 
2120
2717
  ${showSpaces && html`
2121
2718
  <div class="pj-inbox pj-spaces-add">
2122
- <div class="card-h3">${t('organisms.addSpaceTitle') || 'Add a space'}</div>
2123
- <div class="section-desc">${t('organisms.addSpaceDesc') || 'A document space is a free-form wiki (sections + markdown pages). A record space is a schema-locked list (forms). You can remove spaces in Settings.'}</div>
2719
+ <div class="card-h3">${t('organisms.addDocSpaceTitle') || 'Add a document space'}</div>
2720
+ <div class="section-desc">${t('organisms.addDocSpaceDesc') || 'A document space is a free-form wiki (sections + markdown pages). Record types need a schema, so they are designed with AI in Settings → Process (restructure).'}</div>
2124
2721
  <div class="pj-space-row">
2125
2722
  <input type="text" class="input-field input-sm" placeholder=${t('organisms.spaceName') || 'New space name'} value=${newSpaceName} onInput=${e => setNewSpaceName(e.target.value)} onKeyDown=${e => { if (e.key === 'Enter') addSpaceHandler(); }} />
2126
- <select class="input-field input-sm" value=${newSpaceMode} onChange=${e => setNewSpaceMode(e.target.value)}>
2127
- <option value="document">${t('organisms.modeDocument') || 'Document (wiki)'}</option>
2128
- <option value="records">${t('organisms.modeRecords') || 'Records (form)'}</option>
2129
- </select>
2130
2723
  <button class="btn-primary btn-sm" onClick=${addSpaceHandler} disabled=${busy || !newSpaceName.trim()}>${t('organisms.addSpace') || '+ Add'}</button>
2131
2724
  <button class="btn-ghost btn-sm" onClick=${() => setShowSpaces(false)}>${t('organisms.cancel') || 'Cancel'}</button>
2132
2725
  </div>
2133
2726
  </div>`}
2134
2727
 
2135
- ${showShare && html`
2136
- <div class="pj-inbox">
2137
- <div class="card-h3">${'🌐 '}${t('organisms.sharePublicTitle') || 'Public sharing'}</div>
2138
- <div class="section-desc">${t('organisms.sharePublicDesc') || 'Make published document-space pages readable by anyone with the link — no login required. Drafts are never shared.'}</div>
2139
- ${!share && shareBusy ? html`<div class="pj-empty">${t('organisms.loading') || 'Loading…'}</div>` : null}
2140
- ${share && docTypes.length === 0 ? html`<div class="pj-empty">${t('organisms.noDocSpaces') || 'This workspace has no document spaces to share.'}</div>` : null}
2141
- ${share ? docTypes.map(ot => {
2142
- const docs = objectsFor(ot.name);
2143
- const spaceOn = !!(share.spaces && share.spaces[ot.name]);
2144
- return html`
2145
- <div class="pj-share-space" key=${'sh' + ot.name}>
2146
- <label class="pj-share-row">
2147
- <input type="checkbox" checked=${spaceOn} disabled=${shareBusy} onChange=${e => patchShare({ spaces: { [ot.name]: e.target.checked } })} />
2148
- <span class="pj-space-name">${escHtml(ot.name)}</span>
2149
- <span class="pj-doc-tag">${docs.length} ${t('organisms.docs') || 'docs'}</span>
2150
- </label>
2151
- ${docs.length === 0
2152
- ? html`<div class="pj-empty pj-share-empty">${t('organisms.noPublishedDocs') || 'No published documents yet — publish a page to share it.'}</div>`
2153
- : html`<div class="pj-share-docs">
2154
- ${docs.map(d => {
2155
- const on = isDocPublic(ot.name, d.id);
2156
- return html`
2157
- <label class="pj-share-doc" key=${'shd' + d.id}>
2158
- <input type="checkbox" checked=${on} disabled=${shareBusy} onChange=${e => patchShare({ docs: { [`${ot.name}/${d.id}`]: e.target.checked } })} />
2159
- <span class="pj-share-doc-title">${escHtml(d.title || d.id)}</span>
2160
- ${on ? html`<a class="pj-share-link" href=${orgService.publicViewerUrl(orgId, wsId, { type: ot.name, id: d.id })} target="_blank" rel="noopener">${t('organisms.openLink') || 'open ↗'}</a>` : null}
2161
- </label>`;
2162
- })}
2163
- </div>`}
2164
- </div>`;
2165
- }) : null}
2166
- ${anythingPublic() ? html`
2167
- <div class="pj-divider"></div>
2168
- <div class="pj-share-actions">
2169
- <a class="btn-outline btn-sm" href=${orgService.publicViewerUrl(orgId, wsId)} target="_blank" rel="noopener">${'🔗 '}${t('organisms.openPublicViewer') || 'Open public viewer'}</a>
2170
- <button class="btn-ghost btn-sm" onClick=${() => copyShareLink(orgService.publicViewerUrl(orgId, wsId))}>${t('organisms.copyLink') || 'Copy link'}</button>
2171
- </div>` : null}
2172
- </div>`}
2173
-
2174
2728
  ${showSettings && html`
2175
2729
  <div class="pj-inbox">
2176
- <div class="card-h3">${t('organisms.settings') || 'Workspace settings'}</div>
2177
- <label class="pj-field"><span>${t('organisms.wsName') || 'Name'}</span>
2178
- <input type="text" class="input-field input-sm" value=${sName} onInput=${e => setSName(e.target.value)} /></label>
2179
- <label class="pj-field"><span>${t('organisms.wsSummary') || 'Summary'}</span>
2180
- <textarea class="input-field input-sm" rows="2" value=${sSummary} onInput=${e => setSSummary(e.target.value)}></textarea></label>
2181
- <label class="pj-field"><span>${t('organisms.autonomy') || 'AI autonomy (L1 cautious → L5 free)'}</span>
2182
- <select class="input-field input-sm" value=${sAutonomy} onChange=${e => setSAutonomy(e.target.value)}>
2183
- ${['L1', 'L2', 'L3', 'L4', 'L5'].map(l => html`<option value=${l} key=${l}>${l}</option>`)}
2184
- </select></label>
2185
- <div class="pj-empty">${t('organisms.template') || 'Template'}: ${escHtml(ws.manifest?.kind || '')}</div>
2186
- <div class="form-actions">
2187
- <button class="btn-primary btn-sm" onClick=${saveSettings} disabled=${busy}>${t('organisms.save') || 'Save'}</button>
2188
- <button class="btn-ghost btn-sm" onClick=${() => setShowSettings(false)}>${t('organisms.cancel') || 'Cancel'}</button>
2730
+ <div class="pj-meta-line">
2731
+ <span>${t('organisms.template') || 'Template'} ${(ws.manifest?.kind || '-')}</span>
2732
+ ${ws.manifest?.updatedAt ? html`<span>${t('organisms.lastSaved') || 'Last saved'} ${fmtDate(ws.manifest.updatedAt)}</span>` : null}
2733
+ </div>
2734
+
2735
+ <div class="pj-form-card">
2736
+ <div class="pj-form-group">${t('organisms.formIdentity') || 'Identity'}</div>
2737
+ <label class="pj-field"><span>${t('organisms.wsName') || 'Name'}</span>
2738
+ <input type="text" class="input-field input-sm" value=${sName} onInput=${e => setSName(e.target.value)} /></label>
2739
+ <label class="pj-field"><span>${t('organisms.wsSummary') || 'Summary'}</span>
2740
+ <textarea class="input-field input-sm" rows="2" value=${sSummary} onInput=${e => setSSummary(e.target.value)}></textarea></label>
2741
+
2742
+ <div class="pj-form-group">${t('organisms.formAgentPolicy') || 'Agent policy'}</div>
2743
+ <label class="pj-field"><span>${t('organisms.autonomy') || 'AI autonomy (L1 cautious → L5 free)'}</span>
2744
+ <select class="input-field input-sm" value=${sAutonomy} onChange=${e => setSAutonomy(e.target.value)}>
2745
+ ${['L1', 'L2', 'L3', 'L4', 'L5'].map(l => html`<option value=${l} key=${l}>${l} — ${t(`organisms.autonomyLevels.${l}`) || ''}</option>`)}
2746
+ </select></label>
2747
+ <div class="pj-form-hint">${t('organisms.autonomyHint') || 'Guidance for agents working here — L1 asks before nearly everything, L5 acts freely. The publish gate (Review tab) still applies regardless.'}</div>
2748
+
2749
+ <div class="form-actions">
2750
+ <button class="btn-primary btn-sm" onClick=${saveSettings} disabled=${busy || !wsDirty}>${t('organisms.saveChanges') || 'Save changes'}</button>
2751
+ <button class="btn-ghost btn-sm" onClick=${() => { resetSettingsForm(); setShowSettings(false); }}>${t('organisms.cancel') || 'Cancel'}</button>
2752
+ </div>
2189
2753
  </div>
2190
2754
 
2191
2755
  <div class="pj-divider"></div>
2192
- <div class="card-h3">${t('organisms.spaces') || 'Spaces'}</div>
2756
+ <div class="pj-form-group">${t('organisms.spaces') || 'Spaces'}</div>
2757
+ <div class="pj-form-hint">${t('organisms.spacesRemoveHint') || 'These actions apply immediately. Removing a space hides its section — the data stays in memory and comes back if a space with the same name is added again.'}</div>
2193
2758
  ${(ws.manifest?.objectTypes || []).map(ot => html`
2194
2759
  <div class="pj-doc-row" key=${'sp' + ot.name}>
2195
- <span class="pj-space-name">${escHtml(ot.name)}<span class="pj-doc-tag">${isDocSpace(ot) ? (t('organisms.docs') || 'docs') : (t('organisms.recordsMode') || 'records')}</span></span>
2760
+ <span class="pj-space-name">${(ot.name)}<span class="pj-doc-tag">${isDocSpace(ot) ? (t('organisms.docs') || 'docs') : (t('organisms.recordsMode') || 'records')}</span></span>
2196
2761
  <button class="btn-ghost btn-sm" onClick=${() => removeSpaceHandler(ot.name)} disabled=${busy}>${t('organisms.remove') || 'Remove'}</button>
2197
2762
  </div>
2198
2763
  `)}
2199
2764
  <div class="form-actions">
2200
- <input type="text" class="input-field input-sm" placeholder=${t('organisms.spaceName') || 'New space name'} value=${newSpaceName} onInput=${e => setNewSpaceName(e.target.value)} />
2201
- <select class="input-field input-sm" value=${newSpaceMode} onChange=${e => setNewSpaceMode(e.target.value)}>
2202
- <option value="document">${t('organisms.docsSpace') || 'document space'}</option>
2203
- <option value="records">${t('organisms.recordsSpace') || 'records type'}</option>
2204
- </select>
2765
+ <input type="text" class="input-field input-sm" placeholder=${t('organisms.docSpaceNamePlaceholder') || 'New document space name'} value=${newSpaceName} onInput=${e => setNewSpaceName(e.target.value)} />
2205
2766
  <button class="btn-outline btn-sm" onClick=${addSpaceHandler} disabled=${busy || !newSpaceName.trim()}>${t('organisms.addSpace') || '+ Add'}</button>
2206
2767
  </div>
2207
2768
 
2208
2769
  <div class="pj-divider"></div>
2770
+ <div class="pj-form-group">${t('organisms.formProcess') || 'Process'}</div>
2771
+ ${ws.manifest ? html`
2772
+ <div class="pj-chart">
2773
+ <div class="pj-chart-head">
2774
+ <span class="pj-chart-title">${'🔄 '}${t('organisms.editFlow') || 'How editing works here'}</span>
2775
+ <button class="btn-ghost btn-sm" onClick=${() => setShowFlow(s => !s)}>${showFlow ? (t('organisms.hide') || 'Hide') : (t('organisms.show') || 'Show')}</button>
2776
+ </div>
2777
+ ${showFlow ? html`<${Mermaid} chart=${orgService.buildEditFlowMermaid(ws.manifest, gateOn)} />` : null}
2778
+ </div>` : null}
2209
2779
  <button class="btn-outline btn-sm" onClick=${() => setShowRegenerate(s => !s)}>
2210
2780
  ${showRegenerate ? (t('organisms.cancel') || 'Cancel') : (t('organisms.restructure') || '✨ Restructure / add types with AI')}
2211
2781
  </button>
2212
2782
  ${showRegenerate && renderGenerator()}
2213
2783
 
2784
+ <div class="pj-divider"></div>
2214
2785
  <div class="pj-danger">
2215
2786
  <div class="pj-danger-title">${t('organisms.dangerZone') || 'Danger zone'}</div>
2216
2787
  <div class="section-desc">${t('organisms.deleteWarn') || 'Deleting the workspace removes the manifest and ALL its data — drafts, published records, version history — and its schemas. The organism stays. This cannot be undone.'}</div>
@@ -2222,101 +2793,84 @@ function Workspace({ org, wsId, showToast, onBack }) {
2222
2793
  </div>
2223
2794
  `}
2224
2795
 
2225
- ${approvals.length > 0 && html`
2226
- <div class="pj-inbox">
2227
- <div class="card-h3">${t('organisms.needsDecision') || 'Needs your decision'} (${approvals.length})</div>
2228
- ${approvals.map(a => html`
2229
- <div class="pj-approval" key=${a.id}>
2230
- <div class="pj-approval-text">${escHtml(a.prompt || a.action)}</div>
2231
- <div class="card-actions">
2232
- <button class="btn-success btn-sm" onClick=${() => resolve(a.id, 'approve')} disabled=${busy}>${t('organisms.approve') || 'Approve'}</button>
2233
- <button class="btn-danger btn-sm" onClick=${() => resolve(a.id, 'reject')} disabled=${busy}>${t('organisms.reject') || 'Reject'}</button>
2234
- </div>
2235
- </div>
2236
- `)}
2237
- </div>
2238
- `}
2239
-
2240
- <div class="pj-divider"></div>
2796
+ ${activeSpace
2797
+ ? (!orgService.isMemorySpace(activeSpace)
2798
+ ? renderSpaceNotice(activeSpace)
2799
+ : (isDocSpace(activeSpace) ? renderDocSpace(activeSpace) : renderRecordSpace(activeSpace)))
2800
+ : null}
2241
2801
 
2242
- <${ParticipantsPanel} orgId=${orgId} wsId=${wsId} />
2802
+ ${activeTab === 'sources' ? html`<${SourcesPanel} orgId=${orgId} wsId=${wsId} showToast=${showToast} />` : null}
2243
2803
 
2244
- ${ws.manifest ? html`
2245
- <div class="pj-chart">
2246
- <div class="pj-chart-head">
2247
- <span class="pj-chart-title">${'🔄 '}${t('organisms.editFlow') || 'How editing works here'}</span>
2248
- <button class="btn-ghost btn-sm" onClick=${() => setShowFlow(s => !s)}>${showFlow ? (t('organisms.hide') || 'Hide') : (t('organisms.show') || 'Show')}</button>
2249
- </div>
2250
- ${showFlow ? html`<${Mermaid} chart=${orgService.buildEditFlowMermaid(ws.manifest, gateOn)} />` : null}
2804
+ ${activeTab === 'share' ? html`
2805
+ <div class="pj-section">
2806
+ <div class="section-desc">${t('organisms.sharePublicDesc') || 'Make published document-space pages readable by anyone with the link — no login required. Drafts are never shared.'}</div>
2807
+ ${docTypes.length === 0 ? html`<${EmptyState} icon="🌐" text=${t('organisms.noDocSpaces') || 'This workspace has no document spaces to share.'} />` : html`
2808
+ ${!share && shareBusy ? html`<div class="pj-empty">${t('organisms.loading') || 'Loading…'}</div>` : null}
2809
+ ${share ? docTypes.map(ot => {
2810
+ const docs = objectsFor(ot.name);
2811
+ const spaceOn = !!(share.spaces && share.spaces[ot.name]);
2812
+ return html`
2813
+ <div class="pj-share-space" key=${'sh' + ot.name}>
2814
+ <label class="pj-share-row">
2815
+ <input type="checkbox" checked=${spaceOn} disabled=${shareBusy} onChange=${e => patchShare({ spaces: { [ot.name]: e.target.checked } })} />
2816
+ <span class="pj-space-name">${wsT('type.' + ot.name) || ot.name}</span>
2817
+ <span class="pj-doc-tag">${docs.length} ${t('organisms.docs') || 'docs'}</span>
2818
+ </label>
2819
+ ${docs.length === 0
2820
+ ? html`<div class="pj-empty pj-share-empty">${t('organisms.noPublishedDocs') || 'No published documents yet — publish a page to share it.'}</div>`
2821
+ : html`<div class="pj-share-docs">
2822
+ ${docs.map(d => {
2823
+ const on = isDocPublic(ot.name, d.id);
2824
+ return html`
2825
+ <label class="pj-share-doc" key=${'shd' + d.id}>
2826
+ <input type="checkbox" checked=${on} disabled=${shareBusy} onChange=${e => patchShare({ docs: { [`${ot.name}/${d.id}`]: e.target.checked } })} />
2827
+ <span class="pj-share-doc-title">${d.title || d.id}</span>
2828
+ ${on ? html`<a class="pj-share-link" href=${orgService.publicViewerUrl(orgId, wsId, { type: ot.name, id: d.id })} target="_blank" rel="noopener">${t('organisms.openLink') || 'open ↗'}</a>` : null}
2829
+ </label>`;
2830
+ })}
2831
+ </div>`}
2832
+ </div>`;
2833
+ }) : null}
2834
+ ${anythingPublic() ? html`
2835
+ <div class="pj-share-actions">
2836
+ <a class="btn-outline btn-sm" href=${orgService.publicViewerUrl(orgId, wsId)} target="_blank" rel="noopener">${'🔗 '}${t('organisms.openPublicViewer') || 'Open public viewer'}</a>
2837
+ <button class="btn-ghost btn-sm" onClick=${() => copyShareLink(orgService.publicViewerUrl(orgId, wsId))}>${t('organisms.copyLink') || 'Copy link'}</button>
2838
+ </div>` : null}
2839
+ `}
2251
2840
  </div>` : null}
2252
2841
 
2253
- <${ActivityPanel} orgId=${orgId} wsId=${wsId} />
2254
-
2255
- ${types.length > 0 ? html`<div class="pj-divider"></div>` : null}
2256
-
2257
- ${types.map(ot => isDocSpace(ot) ? renderDocSpace(ot) : html`
2258
- <div class="pj-section" key=${ot.name}>
2259
- <div class="pj-section-head">
2260
- <span class="pj-section-title">${escHtml(ot.name)}</span>
2261
- ${ot.append ? null : html`<button class="btn-outline btn-sm" onClick=${() => startAdd(ot)}>${'+ '}${t('organisms.addDraft') || 'Add draft'}</button>`}
2262
- </div>
2263
-
2264
- ${adding === ot.name && (addingSchema
2265
- ? html`<${SchemaForm} key=${'sf-' + (addingId || 'new')} schema=${addingSchema} busy=${busy} initial=${addingInitial}
2266
- onSave=${(v) => saveDraft(ot, addingId ? { ...v, id: addingId } : v)} onCancel=${cancelForm} />`
2267
- : html`<${Spinner} />`)}
2268
-
2269
- ${draftsFor(ot.name).map((d, i) => html`
2270
- <div class="pj-rec" key=${'d' + i}>
2271
- <div class="pj-item pj-item-draft">
2272
- <span class="badge badge-warn">${t('organisms.draft') || 'draft'}</span>
2273
- <button class="pj-rec-title" onClick=${() => toggleExpand(ot, d.id)}>${String(d[PRIMARY_FIELD[ot.name] || 'title'] || d.id || '')}</button>
2274
- <button class="btn-ghost btn-sm" onClick=${() => startEdit(ot, d)} disabled=${busy}>${t('organisms.edit') || 'Edit'}</button>
2275
- <button class="btn-primary btn-sm" onClick=${() => publish(ot, d.id)} disabled=${busy}>${t('organisms.publish') || 'Publish'}</button>
2276
- <button class="pj-icon-btn" title=${t('organisms.delete') || 'Delete'} disabled=${busy} onClick=${() => removeObject(ot.namespace, d.id, String(d[PRIMARY_FIELD[ot.name] || 'title'] || d.id))}>🗑</button>
2277
- </div>
2278
- ${expandedRec[ot.name + ':' + d.id] ? html`<div class="pj-rec-fields">${recordFields(d)}</div><${WorkspaceComments} orgId=${orgId} ws=${wsId} space=${ot.name} instanceId=${d.id} showToast=${showToast} />` : null}
2279
- </div>
2280
- `)}
2281
-
2282
- ${objectsFor(ot.name).length === 0 && draftsFor(ot.name).length === 0
2283
- ? html`<${EmptyState} text=${t('organisms.noneYet') || 'none yet'} />`
2284
- : objectsFor(ot.name).map((o, i) => html`
2285
- <div class="pj-rec" key=${'o' + i}>
2286
- <div class="pj-item">
2287
- <button class="pj-rec-title" onClick=${() => toggleExpand(ot, o.id)}>${String(o[PRIMARY_FIELD[ot.name] || 'title'] || o.summary || o.id || '')}</button>
2288
- ${o.status ? html`<span class="badge badge-info">${escHtml(o.status)}</span>` : null}
2289
- <button class="pj-icon-btn" title=${t('organisms.delete') || 'Delete'} disabled=${busy} onClick=${() => removeObject(ot.namespace, o.id, String(o[PRIMARY_FIELD[ot.name] || 'title'] || o.id))}>🗑</button>
2842
+ ${activeTab === 'review' ? html`
2843
+ <div class="pj-section">
2844
+ <label class="pj-gate-label" title=${t('organisms.publishGateHint') || 'When on, an agent’s publish is held for your review instead of going live'}>
2845
+ <input type="checkbox" checked=${gateOn} onChange=${toggleGate} disabled=${busy} />
2846
+ ${'🔒 '}${t('organisms.publishGate') || 'Require review before publishing'}
2847
+ </label>
2848
+ ${approvals.length === 0
2849
+ ? html`<${EmptyState} icon="📭" text=${t('organisms.reviewEmpty') || 'Nothing waiting for review.'} />`
2850
+ : html`
2851
+ <div class="card-h3">${t('organisms.needsDecision') || 'Needs your decision'} (${approvals.length})</div>
2852
+ ${approvals.map(a => html`
2853
+ <div class="pj-approval" key=${a.id}>
2854
+ <div class="pj-approval-text">${(a.prompt || a.action)}</div>
2855
+ <div class="card-actions">
2856
+ <button class="btn-success btn-sm" onClick=${() => resolve(a.id, 'approve')} disabled=${busy}>${t('organisms.approve') || 'Approve'}</button>
2857
+ <button class="btn-danger btn-sm" onClick=${() => resolve(a.id, 'reject')} disabled=${busy}>${t('organisms.reject') || 'Reject'}</button>
2858
+ </div>
2290
2859
  </div>
2291
- ${expandedRec[ot.name + ':' + o.id] ? html`<div class="pj-rec-fields">${recordFields(o)}</div><${WorkspaceComments} orgId=${orgId} ws=${wsId} space=${ot.name} instanceId=${o.id} showToast=${showToast} />` : null}
2292
- </div>
2293
- `)
2294
- }
2295
- </div>
2296
- `)}
2297
-
2298
- ${otherTypes.map(ot => html`
2299
- <div class="pj-section" key=${ot.name}>
2300
- <div class="pj-section-head">
2301
- <span class="pj-section-title">${escHtml(ot.name)}</span>
2302
- <span class="badge badge-warn">${escHtml(String(ot.backing))}</span>
2303
- </div>
2304
- <div class="pj-space-notice">${ot.backing === 'tasks'
2305
- ? (t('organisms.spaceTasksBacked') || 'This space points at the task system — its items are tasks, not workspace records. Manage them in the Tasks views.')
2306
- : (t('organisms.spaceBackingUnsupported') || 'This space’s backing is not supported, so its content is not shown here. Edit the workspace (manifest) and set this space’s backing to "memory" to restore it — files and knowledge packages attach via Sources or document images instead.')}</div>
2307
- </div>
2308
- `)}
2860
+ `)}`}
2861
+ </div>` : null}
2309
2862
 
2310
- <${SourcesPanel} orgId=${orgId} wsId=${wsId} showToast=${showToast} />
2863
+ ${activeTab === 'activity' ? html`
2864
+ <${ActivityPanel} orgId=${orgId} wsId=${wsId} />
2865
+ ${(ws.decisions || []).length > 0 ? html`
2866
+ <div class="pj-section">
2867
+ <div class="pj-section-title">${t('organisms.decisions') || 'Recent decisions'}</div>
2868
+ ${ws.decisions.slice(-8).reverse().map((d, i) => html`
2869
+ <div class="pj-item pj-decision" key=${'dec' + i}><span class="pj-item-text">${(String(d.summary || ''))}</span></div>
2870
+ `)}
2871
+ </div>` : null}` : null}
2311
2872
 
2312
- ${(ws.decisions || []).length > 0 && html`
2313
- <div class="pj-section">
2314
- <div class="pj-section-title">${t('organisms.decisions') || 'Recent decisions'}</div>
2315
- ${ws.decisions.slice(-8).reverse().map((d, i) => html`
2316
- <div class="pj-item pj-decision" key=${'dec' + i}><span class="pj-item-text">${escHtml(String(d.summary || ''))}</span></div>
2317
- `)}
2318
- </div>
2319
- `}
2873
+ ${activeTab === 'people' ? html`<${ParticipantsPanel} orgId=${orgId} wsId=${wsId} />` : null}
2320
2874
  </div>
2321
2875
  `;
2322
2876
  }
@@ -2411,8 +2965,8 @@ function SourcesPanel({ orgId, wsId, showToast }) {
2411
2965
  else { label = scope === 'mine' ? (item.value?.name || item.key) : (item.name || item.package_id); meta = (scope === 'mine' ? (item.value?.entries?.length || 0) : (item.entries_count || 0)) + ' ' + (t('organisms.entries') || 'entries'); }
2412
2966
  return html`
2413
2967
  <div class="pj-src-result" key=${'r' + i}>
2414
- <span class="pj-src-result-label" title=${String(label)}>${escHtml(String(label))}</span>
2415
- <span class="pj-src-result-meta">${escHtml(String(meta))}</span>
2968
+ <span class="pj-src-result-label" title=${String(label)}>${(String(label))}</span>
2969
+ <span class="pj-src-result-meta">${(String(meta))}</span>
2416
2970
  <button class="btn-ghost btn-sm" disabled=${busy} onClick=${() => attach(item)}>${t('organisms.attach') || 'Attach'}</button>
2417
2971
  </div>`;
2418
2972
  };
@@ -2453,7 +3007,7 @@ function SourcesPanel({ orgId, wsId, showToast }) {
2453
3007
  ${sources.map(s => html`
2454
3008
  <div class="pj-src-item" key=${s.id}>
2455
3009
  <span class="pj-src-icon">${SRC_ICON[s.type] || '•'}</span>
2456
- <span class="pj-src-label" title=${s.key || s.packageId || ''}>${escHtml(String(s.label || s.key || s.packageId || ''))}</span>
3010
+ <span class="pj-src-label" title=${s.key || s.packageId || ''}>${(String(s.label || s.key || s.packageId || ''))}</span>
2457
3011
  ${s.external ? html`<span class="badge badge-muted pj-mini">${t('organisms.external') || 'external'}</span>` : null}
2458
3012
  <span class="badge badge-info pj-mini">${t('organisms.src_' + s.type) || s.type}</span>
2459
3013
  <button class="pj-icon-btn" title=${t('organisms.remove') || 'Remove'} onClick=${() => removeSource(s.id)}>✕</button>
@@ -2464,11 +3018,17 @@ function SourcesPanel({ orgId, wsId, showToast }) {
2464
3018
 
2465
3019
  /* A form rendered from a JSON Schema — typed inputs (enum→select, integer→number,
2466
3020
  * array→lines, boolean→checkbox, else text). Works for any objectType, including ones a
2467
- * generated manifest declares. `id` is auto-generated when blank, so it's never required here. */
2468
- function SchemaForm({ schema, busy, onSave, onCancel, initial }) {
3021
+ * generated manifest declares. New records pre-fill `id` (editable) and requester-style
3022
+ * fields ("x-default": "currentUser", or a requested_by/created_by-shaped name) with the
3023
+ * signed-in GHII. readOnly properties are agent-filled results — hidden here, shown only in
3024
+ * the record view. Labels/hints resolve manifest i18n (wsT) → schema description → raw key.
3025
+ * Save is always clickable: a failed attempt outlines the still-empty required fields. */
3026
+ function SchemaForm({ schema, busy, onSave, onCancel, initial, idPrefix, namespace, wsT }) {
2469
3027
  const props = (schema && schema.properties) || {};
2470
- const required = new Set(((schema && schema.required) || []).filter(k => k !== 'id'));
2471
- const fieldNames = Object.keys(props);
3028
+ const editable = Object.entries(props).filter(([, def]) => !(def && def.readOnly));
3029
+ const required = new Set(((schema && schema.required) || []).filter(k => k !== 'id' && !(props[k] && props[k].readOnly)));
3030
+ const fieldNames = editable.map(([k]) => k);
3031
+ const [missing, setMissing] = useState(null); // required keys empty at the last save attempt
2472
3032
  // Seed from an existing record when editing (arrays → newline text for the textarea inputs).
2473
3033
  const [vals, setVals] = useState(() => {
2474
3034
  const out = {};
@@ -2477,6 +3037,14 @@ function SchemaForm({ schema, busy, onSave, onCancel, initial }) {
2477
3037
  if (v === undefined || v === null) continue;
2478
3038
  out[k] = Array.isArray(v) ? v.join('\n') : (def.type === 'boolean' ? !!v : String(v));
2479
3039
  }
3040
+ if (!initial) {
3041
+ if (props.id && out.id === undefined) out.id = `${idPrefix || 'rec'}-${Date.now().toString(36)}`;
3042
+ const me = getGhii() || '';
3043
+ for (const [k, def] of editable) {
3044
+ if (out[k] !== undefined || def.type !== 'string' || def.enum) continue;
3045
+ if (def['x-default'] === 'currentUser' || /^(requested_by|created_by|requester|author)$/i.test(k)) out[k] = me;
3046
+ }
3047
+ }
2480
3048
  return out;
2481
3049
  });
2482
3050
  const set = (k, v) => setVals(s => ({ ...s, [k]: v }));
@@ -2494,38 +3062,52 @@ function SchemaForm({ schema, busy, onSave, onCancel, initial }) {
2494
3062
  return out;
2495
3063
  };
2496
3064
 
2497
- const canSave = [...required].every(k => vals[k] !== undefined && String(vals[k]).trim() !== '');
3065
+ const isEmpty = (k) => vals[k] === undefined || String(vals[k]).trim() === '';
3066
+ // Of the fields flagged at the last attempt, only the ones STILL empty stay outlined.
3067
+ const stillMissing = (missing || []).filter(isEmpty);
3068
+ const trySave = () => {
3069
+ const miss = [...required].filter(isEmpty);
3070
+ setMissing(miss.length ? miss : null);
3071
+ if (!miss.length) onSave(buildValue());
3072
+ };
3073
+
3074
+ const labelOf = (k) => (wsT && wsT(`${namespace}.${k}`)) || k;
3075
+ const hintOf = (k, def) => (wsT && wsT(`${namespace}.${k}.hint`)) || def.description
3076
+ || (k === 'id' && !initial ? (t('organisms.autoIdHint') || 'Pre-filled automatically — change it if you want a memorable id.') : '');
2498
3077
 
2499
- const field = (k, def) => {
2500
- const label = k + (required.has(k) ? ' *' : '');
3078
+ const control = (k, def) => {
2501
3079
  if (def.type === 'string' && Array.isArray(def.enum)) {
2502
- return html`<label class="pj-field" key=${k}><span>${label}</span>
2503
- <select class="input-field input-sm" value=${vals[k] ?? ''} onChange=${e => set(k, e.target.value)}>
3080
+ return html`<select class="input-field input-sm" value=${vals[k] ?? ''} onChange=${e => set(k, e.target.value)}>
2504
3081
  <option value="">—</option>${def.enum.map(o => html`<option value=${o} key=${o}>${o}</option>`)}
2505
- </select></label>`;
3082
+ </select>`;
2506
3083
  }
2507
3084
  // Date / datetime fields → native pickers (by schema format, or a name ending in _date).
2508
3085
  if (def.format === 'date-time') {
2509
- return html`<label class="pj-field" key=${k}><span>${label}</span>
2510
- <input type="datetime-local" class="input-field input-sm" value=${vals[k] ?? ''} onInput=${e => set(k, e.target.value)} /></label>`;
3086
+ return html`<input type="datetime-local" class="input-field input-sm" value=${vals[k] ?? ''} onInput=${e => set(k, e.target.value)} />`;
2511
3087
  }
2512
3088
  if (def.format === 'date' || (def.type === 'string' && !def.enum && /(_date$|^date$)/i.test(k))) {
2513
- return html`<label class="pj-field" key=${k}><span>${label}</span>
2514
- <input type="date" class="input-field input-sm" value=${vals[k] ?? ''} onInput=${e => set(k, e.target.value)} /></label>`;
3089
+ return html`<input type="date" class="input-field input-sm" value=${vals[k] ?? ''} onInput=${e => set(k, e.target.value)} />`;
2515
3090
  }
2516
3091
  if (def.type === 'integer' || def.type === 'number') {
2517
- return html`<label class="pj-field" key=${k}><span>${label}</span>
2518
- <input type="number" class="input-field input-sm" value=${vals[k] ?? ''} onInput=${e => set(k, e.target.value)} /></label>`;
3092
+ return html`<input type="number" class="input-field input-sm" value=${vals[k] ?? ''} onInput=${e => set(k, e.target.value)} />`;
2519
3093
  }
3094
+ if (def.type === 'array') {
3095
+ return html`<textarea class="input-field input-sm" rows="2" value=${vals[k] ?? ''} onInput=${e => set(k, e.target.value)}></textarea>`;
3096
+ }
3097
+ return html`<input type="text" class="input-field input-sm" value=${vals[k] ?? ''} onInput=${e => set(k, e.target.value)} />`;
3098
+ };
3099
+
3100
+ const field = (k, def) => {
3101
+ const label = labelOf(k) + (required.has(k) ? ' *' : '');
2520
3102
  if (def.type === 'boolean') {
2521
3103
  return html`<label class="pj-field pj-field-inline" key=${k}><input type="checkbox" checked=${!!vals[k]} onChange=${e => set(k, e.target.checked)} /><span>${label}</span></label>`;
2522
3104
  }
2523
- if (def.type === 'array') {
2524
- return html`<label class="pj-field" key=${k}><span>${label} ${'(' + (t('organisms.onePerLine') || 'one per line') + ')'}</span>
2525
- <textarea class="input-field input-sm" rows="2" value=${vals[k] ?? ''} onInput=${e => set(k, e.target.value)}></textarea></label>`;
2526
- }
2527
- return html`<label class="pj-field" key=${k}><span>${label}</span>
2528
- <input type="text" class="input-field input-sm" value=${vals[k] ?? ''} onInput=${e => set(k, e.target.value)} /></label>`;
3105
+ const hint = hintOf(k, def);
3106
+ const suffix = def.type === 'array' ? ' (' + (t('organisms.onePerLine') || 'one per line') + ')' : '';
3107
+ return html`<label class="pj-field ${stillMissing.includes(k) ? 'pj-field-invalid' : ''}" key=${k}>
3108
+ <span>${label}${suffix}</span>
3109
+ ${hint ? html`<span class="pj-field-hint">${hint}</span>` : null}
3110
+ ${control(k, def)}</label>`;
2529
3111
  };
2530
3112
 
2531
3113
  return html`
@@ -2534,8 +3116,10 @@ function SchemaForm({ schema, busy, onSave, onCancel, initial }) {
2534
3116
  ${fieldNames.length === 0
2535
3117
  ? html`<div class="pj-empty">${t('organisms.loading') || 'Loading...'}</div>`
2536
3118
  : fieldNames.map(k => field(k, props[k]))}
3119
+ ${stillMissing.length ? html`<div class="pj-form-error">${(t('organisms.fillRequired') || 'Please fill in the required fields: {fields}')
3120
+ .replace('{fields}', stillMissing.map(labelOf).join(', '))}</div>` : null}
2537
3121
  <div class="form-actions">
2538
- <button class="btn-primary btn-sm" onClick=${() => onSave(buildValue())} disabled=${busy || !canSave}>${t('organisms.saveDraft') || 'Save draft'}</button>
3122
+ <button class="btn-primary btn-sm" onClick=${trySave} disabled=${busy}>${t('organisms.saveDraft') || 'Save draft'}</button>
2539
3123
  <button class="btn-ghost btn-sm" onClick=${onCancel}>${t('organisms.cancel') || 'Cancel'}</button>
2540
3124
  </div>
2541
3125
  </div>
@@ -2777,7 +3361,7 @@ export function DocumentEditor({ orgId, page, busy, onSave, onCancel }) {
2777
3361
  </div>
2778
3362
  ${images.map(i => html`
2779
3363
  <div class="pj-img-vis-row" key=${i.key}>
2780
- <span class="pj-img-vis-name" title=${i.key}>${escHtml(i.alt)}</span>
3364
+ <span class="pj-img-vis-name" title=${i.key}>${(i.alt)}</span>
2781
3365
  <${VisibilityPill} visibility=${i.visibility} onClick=${() => { if (!imgBusy) changeImageVisibility(i.key, i.visibility === 'public' ? 'private' : 'public'); }} />
2782
3366
  </div>`)}
2783
3367
  </div>` : null}