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.
- package/dist/locales/en.json +138 -104
- package/dist/locales/fi.json +164 -130
- package/dist/public/css/theme.css +25 -10
- package/dist/public/css/views/profile.css +67 -3
- package/dist/public/js/services/organisms.js +28 -5
- package/dist/public/views/profile/organisms-tab.js +1057 -473
- package/dist/src/cli/connect/mcp/tools/organisms.d.ts +3 -0
- package/dist/src/cli/connect/mcp/tools/organisms.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/organisms.js +20 -3
- package/dist/src/cli/connect/mcp/tools/organisms.js.map +1 -1
- package/dist/src/cli/connect/tool-call.d.ts.map +1 -1
- package/dist/src/cli/connect/tool-call.js +20 -1
- package/dist/src/cli/connect/tool-call.js.map +1 -1
- package/dist/src/mcp/core.d.ts +4 -0
- package/dist/src/mcp/core.d.ts.map +1 -1
- package/dist/src/mcp/core.js +24 -0
- package/dist/src/mcp/core.js.map +1 -1
- package/dist/src/mcp/organisms.d.ts +3 -0
- package/dist/src/mcp/organisms.d.ts.map +1 -1
- package/dist/src/mcp/organisms.js +18 -0
- package/dist/src/mcp/organisms.js.map +1 -1
- package/dist/src/routes/organisms.d.ts.map +1 -1
- package/dist/src/routes/organisms.js +27 -5
- package/dist/src/routes/organisms.js.map +1 -1
- package/dist/src/services/schema-validator.d.ts.map +1 -1
- package/dist/src/services/schema-validator.js +12 -0
- package/dist/src/services/schema-validator.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
111
|
-
|
|
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
|
|
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 =>
|
|
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="
|
|
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>${
|
|
183
|
-
<span class="pj-mini"> — ${
|
|
184
|
-
<div class="pj-mini">${
|
|
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>${
|
|
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
|
-
*
|
|
247
|
-
*
|
|
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 [
|
|
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
|
-
|
|
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="
|
|
338
|
-
|
|
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="
|
|
342
|
-
|
|
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-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
<
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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>${
|
|
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">${
|
|
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}>${
|
|
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">${
|
|
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">${
|
|
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
|
|
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
|
-
|
|
849
|
-
|
|
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
|
-
//
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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 (!
|
|
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:
|
|
864
|
-
type:
|
|
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');
|
|
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="
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
<
|
|
901
|
-
<
|
|
902
|
-
|
|
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 ?
|
|
913
|
-
<div class="
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
`
|
|
1316
|
+
` : null}
|
|
1317
|
+
</div>
|
|
949
1318
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
<
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
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">${
|
|
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">${
|
|
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 ${
|
|
997
|
-
onClick=${() =>
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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
|
-
${
|
|
1012
|
-
<${
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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(
|
|
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-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
<
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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-
|
|
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">${
|
|
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">${
|
|
1324
|
-
${e.agent ? html`<span class="pj-act-agent" title=${t('organisms.viaAgent') || 'via this agent'}>${'🤖 '}${
|
|
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">${
|
|
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>${
|
|
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">${'🌐 '}${
|
|
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
|
-
${'🤖 '}${
|
|
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>${
|
|
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>${
|
|
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>${
|
|
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"> · “${
|
|
1489
|
-
${c.anchor?.section ? html`<span class="pj-mini"> · §${
|
|
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">${
|
|
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'}: “${
|
|
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 [
|
|
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
|
-
|
|
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
|
|
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
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
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
|
-
|
|
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,
|
|
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">${
|
|
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}>${
|
|
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-
|
|
1932
|
-
<button class="
|
|
1933
|
-
<span class="pj-crumb-sep">/</span>
|
|
1934
|
-
<
|
|
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">${
|
|
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)}>${
|
|
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">${
|
|
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">${
|
|
2096
|
-
<span class="badge badge-success">${
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
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
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
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.
|
|
2123
|
-
<div class="section-desc">${t('organisms.
|
|
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="
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
<
|
|
2182
|
-
<
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
<
|
|
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="
|
|
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">${
|
|
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.
|
|
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
|
-
${
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
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
|
-
|
|
2802
|
+
${activeTab === 'sources' ? html`<${SourcesPanel} orgId=${orgId} wsId=${wsId} showToast=${showToast} />` : null}
|
|
2243
2803
|
|
|
2244
|
-
${
|
|
2245
|
-
<div class="pj-
|
|
2246
|
-
<div class="
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
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
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
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
|
-
|
|
2292
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
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)}>${
|
|
2415
|
-
<span class="pj-src-result-meta">${
|
|
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 || ''}>${
|
|
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.
|
|
2468
|
-
|
|
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
|
|
2471
|
-
const
|
|
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
|
|
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
|
|
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`<
|
|
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
|
|
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`<
|
|
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`<
|
|
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`<
|
|
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
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
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=${
|
|
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}>${
|
|
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}
|