aimeat 1.20.6 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/.env.example +9 -0
  2. package/dist/locales/en.json +17 -1
  3. package/dist/locales/fi.json +17 -3
  4. package/dist/public/css/views/profile.css +55 -0
  5. package/dist/public/js/services/memory.js +9 -2
  6. package/dist/public/views/profile/landing-page.js +16 -1
  7. package/dist/public/views/profile/organisms-tab.js +456 -220
  8. package/dist/src/cli/connect/api-client.d.ts +24 -0
  9. package/dist/src/cli/connect/api-client.d.ts.map +1 -1
  10. package/dist/src/cli/connect/api-client.js +29 -33
  11. package/dist/src/cli/connect/api-client.js.map +1 -1
  12. package/dist/src/cli/connect/config.d.ts.map +1 -1
  13. package/dist/src/cli/connect/config.js +4 -1
  14. package/dist/src/cli/connect/config.js.map +1 -1
  15. package/dist/src/cli/connect/mcp/local-server.d.ts +25 -0
  16. package/dist/src/cli/connect/mcp/local-server.d.ts.map +1 -0
  17. package/dist/src/cli/connect/mcp/local-server.js +411 -0
  18. package/dist/src/cli/connect/mcp/local-server.js.map +1 -0
  19. package/dist/src/cli/connect/mcp/poller.d.ts +13 -0
  20. package/dist/src/cli/connect/mcp/poller.d.ts.map +1 -1
  21. package/dist/src/cli/connect/mcp/poller.js +3 -2
  22. package/dist/src/cli/connect/mcp/poller.js.map +1 -1
  23. package/dist/src/cli/connect/mcp/server.d.ts +41 -0
  24. package/dist/src/cli/connect/mcp/server.d.ts.map +1 -1
  25. package/dist/src/cli/connect/mcp/server.js +47 -21
  26. package/dist/src/cli/connect/mcp/server.js.map +1 -1
  27. package/dist/src/cli/connect/tunnel-client.d.ts +109 -0
  28. package/dist/src/cli/connect/tunnel-client.d.ts.map +1 -0
  29. package/dist/src/cli/connect/tunnel-client.js +462 -0
  30. package/dist/src/cli/connect/tunnel-client.js.map +1 -0
  31. package/dist/src/config.d.ts +4 -0
  32. package/dist/src/config.d.ts.map +1 -1
  33. package/dist/src/config.js +9 -0
  34. package/dist/src/config.js.map +1 -1
  35. package/dist/src/generated/api-types.d.ts +139 -0
  36. package/dist/src/generated/api-types.d.ts.map +1 -1
  37. package/dist/src/index.js +72 -7
  38. package/dist/src/index.js.map +1 -1
  39. package/dist/src/models/schemas.d.ts +1 -1
  40. package/dist/src/routes/agent-tasks.d.ts.map +1 -1
  41. package/dist/src/routes/agent-tasks.js +6 -1
  42. package/dist/src/routes/agent-tasks.js.map +1 -1
  43. package/dist/src/routes/connect-tunnel.d.ts +17 -0
  44. package/dist/src/routes/connect-tunnel.d.ts.map +1 -0
  45. package/dist/src/routes/connect-tunnel.js +31 -0
  46. package/dist/src/routes/connect-tunnel.js.map +1 -0
  47. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  48. package/dist/src/server-bootstrap/routes-loader.js +5 -0
  49. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  50. package/dist/src/server-bootstrap/service-init.d.ts +2 -0
  51. package/dist/src/server-bootstrap/service-init.d.ts.map +1 -1
  52. package/dist/src/server-bootstrap/service-init.js +11 -0
  53. package/dist/src/server-bootstrap/service-init.js.map +1 -1
  54. package/dist/src/server.d.ts +2 -0
  55. package/dist/src/server.d.ts.map +1 -1
  56. package/dist/src/server.js +1 -0
  57. package/dist/src/server.js.map +1 -1
  58. package/dist/src/services/config-schema.d.ts.map +1 -1
  59. package/dist/src/services/config-schema.js +5 -0
  60. package/dist/src/services/config-schema.js.map +1 -1
  61. package/dist/src/services/connect-tunnel.d.ts +123 -0
  62. package/dist/src/services/connect-tunnel.d.ts.map +1 -0
  63. package/dist/src/services/connect-tunnel.js +403 -0
  64. package/dist/src/services/connect-tunnel.js.map +1 -0
  65. package/dist/src/services/event-bus.d.ts +14 -0
  66. package/dist/src/services/event-bus.d.ts.map +1 -1
  67. package/dist/src/services/event-bus.js +25 -0
  68. package/dist/src/services/event-bus.js.map +1 -1
  69. package/package.json +1 -1
@@ -60,12 +60,21 @@
60
60
  * name with a role (viewer/contributor), switch roles, remove; approve/deny pending requests. New
61
61
  * "Create contract agent" AI-prompt button (orgService.buildContractAgentPrompt) copies a full
62
62
  * contract-agent build prompt for THIS workspace. See docs/agent-workspace-contracts.md.
63
+ * v1.15.0 — 2026-06-10 — Organisms list renew, phase 1: compact one-line rows (avatar, name, type,
64
+ * members/agents/date) with drag-and-drop manual ordering + a sort menu (My order / Name / Newest,
65
+ * persisted to user memory key organisms.ui); management actions move into a "…" kebab menu. New
66
+ * OrganismHome page (breadcrumb header + Export/Settings + Workspaces/Members/Agents/Board tabs)
67
+ * between the list and a workspace — Members/Agents tabs host the former in-card OrgMemberManager
68
+ * sections, Settings hosts the edit form + metadata + danger zone, Board links to the Boards tab.
69
+ * Nothing removed: every former card action has a new home (list "…" menu, home header, or a tab).
70
+ * v1.16.0 — 2026-06-10 — Row button "Open workspace" → "Open"; new 📁 workspace-count stat next to
71
+ * the members counter (lazy discoverWorkspaces per own org) so empty organisms are obvious.
63
72
  */
64
73
  import { h } from 'preact';
65
- import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
74
+ import { useState, useEffect, useCallback, useRef, useMemo } from 'preact/hooks';
66
75
  import htm from 'htm';
67
76
  const html = htm.bind(h);
68
- import { t } from '/js/i18n.js';
77
+ import { t, getLocale } from '/js/i18n.js';
69
78
  import { escHtml } from '/js/utils.js';
70
79
  import { Spinner, VisibilityPill } from './shared.js';
71
80
  import { useConfirm } from '/components/Modal.js';
@@ -81,6 +90,58 @@ import { dt, fmtBytes } from '/js/format.js';
81
90
  import { Markdown, slugifyHeading } from '/components/Markdown.js';
82
91
  import { Mermaid } from '/components/Mermaid.js';
83
92
 
93
+ /** Date-only, formatted in the APP locale (browser locale may differ — fi must show 10.6.2026, not 6/10/2026). */
94
+ function fmtDate(s) {
95
+ return new Date(s).toLocaleDateString(getLocale() === 'fi' ? 'fi-FI' : undefined);
96
+ }
97
+
98
+ /** Two-letter monogram for the list/home avatar (initials of the first two words, else first two chars). */
99
+ function orgInitials(name) {
100
+ const s = String(name || '?').trim();
101
+ const words = s.split(/\s+/).filter(Boolean);
102
+ const ini = words.length >= 2 ? words[0][0] + words[1][0] : s.slice(0, 2);
103
+ return ini.charAt(0).toUpperCase() + (ini.charAt(1) || '').toLowerCase();
104
+ }
105
+
106
+ /** "…" actions menu — items: { label, icon?, danger?, onClick } (falsy items are skipped). Closes on outside click. */
107
+ function KebabMenu({ items, label }) {
108
+ const [open, setOpen] = useState(false);
109
+ const ref = useRef(null);
110
+ useEffect(() => {
111
+ if (!open) return undefined;
112
+ const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
113
+ document.addEventListener('click', close);
114
+ return () => document.removeEventListener('click', close);
115
+ }, [open]);
116
+ const visible = (items || []).filter(Boolean);
117
+ if (visible.length === 0) return null;
118
+ return html`
119
+ <div class="pj-menu" ref=${ref}>
120
+ <button class="pj-icon-btn pj-menu-btn" title=${label} aria-haspopup="menu" aria-expanded=${open}
121
+ onClick=${(e) => { e.stopPropagation(); setOpen(o => !o); }}>${'⋮'}</button>
122
+ ${open ? html`
123
+ <div class="pj-menu-pop" role="menu" onClick=${(e) => e.stopPropagation()}>
124
+ ${visible.map(it => html`
125
+ <button class="pj-menu-item ${it.danger ? 'pj-menu-item-danger' : ''}" role="menuitem" key=${it.label}
126
+ onClick=${() => { setOpen(false); it.onClick?.(); }}>${it.icon ? `${it.icon} ` : ''}${it.label}</button>`)}
127
+ </div>` : null}
128
+ </div>`;
129
+ }
130
+
131
+ /** Download a whole-organism ZIP backup (used from the list "…" menu and the home header). */
132
+ async function exportOrganismZip(org, showToast) {
133
+ try {
134
+ const jwt = window.AIMEAT?.auth?.getSession?.()?.jwt || '';
135
+ const res = await fetch(`/v1/organisms/${encodeURIComponent(org.id)}/export`, { headers: { Authorization: 'Bearer ' + jwt } });
136
+ if (!res.ok) throw new Error('Export failed');
137
+ const blob = await res.blob();
138
+ const url = URL.createObjectURL(blob);
139
+ const a = document.createElement('a');
140
+ a.href = url; a.download = `organism-${String(org.name || org.id).replace(/[^a-z0-9_-]+/gi, '-').slice(0, 40)}.zip`;
141
+ document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
142
+ } catch (e) { showToast((e && e.message) || 'Export failed'); }
143
+ }
144
+
84
145
  /**
85
146
  * Content search inside an organism — case-insensitive substring over the records + documents of
86
147
  * every workspace the member can read. Backend: GET /v1/organisms/:id/search. Rendered in the
@@ -177,7 +238,11 @@ function IncomingInvitations({ showToast, onChanged }) {
177
238
  * list (#6). Backend: /:id/{join-requests,invitations,members,transfer,agents}. Refreshes the
178
239
  * parent list via onChanged so member counts stay current.
179
240
  */
180
- function OrgMemberManager({ org, ghii, canManage, isCreator, showToast, confirm, onChanged }) {
241
+ /**
242
+ * Member + agent management. `show` limits the rendered sections: 'members' (invite, pending
243
+ * requests, roster, blocked) or 'agents' (attached agents); omitted = both (legacy layout).
244
+ */
245
+ function OrgMemberManager({ org, ghii, canManage, isCreator, showToast, confirm, onChanged, show }) {
181
246
  const orgId = org.id;
182
247
  const [requests, setRequests] = useState(null);
183
248
  const [members, setMembers] = useState(null);
@@ -259,8 +324,12 @@ function OrgMemberManager({ org, ghii, canManage, isCreator, showToast, confirm,
259
324
  const pending = (requests || []).filter(r => r.status === 'pending');
260
325
  const agents = org.agentGaiis || [];
261
326
 
327
+ const showMembers = show !== 'agents';
328
+ const showAgents = show !== 'members';
329
+
262
330
  return html`
263
331
  <div class="card-detail">
332
+ ${showMembers ? html`
264
333
  <div class="section-title">${canManage ? (t('organisms.manageMembers') || 'Manage members') : (t('organisms.membersRoster') || 'Members')}</div>
265
334
  ${canManage ? html`<div class="section-desc">${t('organisms.manageMembersDesc') || 'Invite, approve, block and revoke member access.'}</div>` : null}
266
335
 
@@ -314,7 +383,9 @@ function OrgMemberManager({ org, ghii, canManage, isCreator, showToast, confirm,
314
383
  </div>
315
384
  `)}
316
385
  ` : null}
386
+ ` : null}
317
387
 
388
+ ${showAgents ? html`
318
389
  <div class="detail-label">${t('organisms.attachedAgents') || 'Attached agents'}</div>
319
390
  ${agents.length === 0 ? html`<div class="section-desc">${t('organisms.noAgents') || 'No agents attached.'}</div>` : null}
320
391
  ${agents.map(g => html`
@@ -330,6 +401,7 @@ function OrgMemberManager({ org, ghii, canManage, isCreator, showToast, confirm,
330
401
  onInput=${(e) => setAgentName(e.target.value)} onKeyDown=${(e) => { if (e.key === 'Enter') attachAgent(); }} />
331
402
  <button class="btn-outline btn-sm" disabled=${busy || !agentName.trim()} onClick=${attachAgent}>${t('organisms.attachAgent') || 'Attach agent'}</button>
332
403
  </div>
404
+ ` : null}
333
405
  </div>
334
406
  `;
335
407
  }
@@ -345,9 +417,13 @@ export default function OrganismsTab({ session, showToast, onStats }) {
345
417
  const [openWs, setOpenWs] = useState(() => { try { return sessionStorage.getItem('aimeat.ws.openWs') || null; } catch (e) { return null; } });
346
418
  const [creating, setCreating] = useState(false);
347
419
  const [showCreate, setShowCreate] = useState(false);
348
- const [editing, setEditing] = useState(null);
349
- const [saving, setSaving] = useState(false);
350
- const [editForm, setEditForm] = useState({});
420
+ // List ordering: 'custom' (drag-and-drop, persisted) | 'name' | 'newest'. Persisted together with
421
+ // the manual order in the user-memory key `organisms.ui` so it follows the user across devices.
422
+ const [sortMode, setSortMode] = useState('custom');
423
+ const [customOrder, setCustomOrder] = useState([]);
424
+ const [openSettings, setOpenSettings] = useState(false); // open OrganismHome with the Settings panel showing
425
+ const dragIdRef = useRef(null);
426
+ const [dragOverId, setDragOverId] = useState(null);
351
427
 
352
428
  /* ── Create form state ── */
353
429
  const [formName, setFormName] = useState('');
@@ -358,6 +434,7 @@ export default function OrganismsTab({ session, showToast, onStats }) {
358
434
  const [formInterests, setFormInterests] = useState('');
359
435
 
360
436
  const ghii = session?.owner || '';
437
+ const [wsCounts, setWsCounts] = useState({}); // orgId → workspace count (own orgs)
361
438
 
362
439
  const loadData = useCallback(async () => {
363
440
  try {
@@ -371,6 +448,10 @@ export default function OrganismsTab({ session, showToast, onStats }) {
371
448
  setMyOrganisms(mine);
372
449
  setPublicOrganisms(discover);
373
450
  onStats?.({ organisms: mine.length });
451
+ // Workspace counts for the 📁 row stat (member-gated endpoint, so own orgs only).
452
+ Promise.all(mine.map(async (o) => [o.id, (await orgService.discoverWorkspaces(o.id)).length]))
453
+ .then(entries => setWsCounts(Object.fromEntries(entries)))
454
+ .catch(() => {});
374
455
  } catch {
375
456
  setMyOrganisms([]);
376
457
  setPublicOrganisms([]);
@@ -381,6 +462,53 @@ export default function OrganismsTab({ session, showToast, onStats }) {
381
462
  if (session) loadData();
382
463
  }, [session, loadData]);
383
464
 
465
+ // ── List-order preferences (user memory key `organisms.ui`: { order: [ids], sort: mode }) ──
466
+ const UI_PREFS_KEY = 'organisms.ui';
467
+ useEffect(() => {
468
+ if (!session) return;
469
+ (async () => {
470
+ try {
471
+ const r = await memoryService.getMemory(UI_PREFS_KEY);
472
+ const v = r?.data?.value;
473
+ if (v && typeof v === 'object') {
474
+ if (Array.isArray(v.order)) setCustomOrder(v.order.filter(x => typeof x === 'string'));
475
+ if (['custom', 'name', 'newest'].includes(v.sort)) setSortMode(v.sort);
476
+ }
477
+ } catch { /* no saved prefs yet */ }
478
+ })();
479
+ }, [session]);
480
+ const savePrefs = useCallback((order, sort) => {
481
+ memoryService.createMemory(UI_PREFS_KEY, { order, sort }, 'private').catch(() => {});
482
+ }, []);
483
+
484
+ // Stable sort keeps server order for ids missing from the saved manual order (appended at the end).
485
+ const sortedMine = useMemo(() => {
486
+ const arr = [...(myOrganisms || [])];
487
+ if (sortMode === 'name') arr.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
488
+ else if (sortMode === 'newest') arr.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
489
+ else if (customOrder.length) {
490
+ const pos = new Map(customOrder.map((id, i) => [id, i]));
491
+ arr.sort((a, b) => (pos.has(a.id) ? pos.get(a.id) : Infinity) - (pos.has(b.id) ? pos.get(b.id) : Infinity));
492
+ }
493
+ return arr;
494
+ }, [myOrganisms, sortMode, customOrder]);
495
+
496
+ // Drop on a row: move the dragged row to the target's position; switches to manual order and saves.
497
+ const onDropRow = (targetId) => {
498
+ const fromId = dragIdRef.current;
499
+ dragIdRef.current = null;
500
+ setDragOverId(null);
501
+ if (!fromId || fromId === targetId) return;
502
+ const ids = sortedMine.map(o => o.id);
503
+ const from = ids.indexOf(fromId);
504
+ const to = ids.indexOf(targetId);
505
+ if (from < 0 || to < 0) return;
506
+ ids.splice(to, 0, ids.splice(from, 1)[0]);
507
+ setCustomOrder(ids);
508
+ setSortMode('custom');
509
+ savePrefs(ids, 'custom');
510
+ };
511
+
384
512
  // Import a whole organism from a ZIP backup → creates a new organism + opens it.
385
513
  const orgFileRef = useRef(null);
386
514
  const [importingOrg, setImportingOrg] = useState(false);
@@ -469,6 +597,7 @@ export default function OrganismsTab({ session, showToast, onStats }) {
469
597
  try {
470
598
  await orgService.leaveOrganism(id);
471
599
  showToast(t('organisms.left') || 'Left organism');
600
+ setOpenId(null); setOpenWs(null);
472
601
  loadData();
473
602
  } catch {
474
603
  showToast(t('organisms.leaveError') || 'Failed to leave');
@@ -482,6 +611,7 @@ export default function OrganismsTab({ session, showToast, onStats }) {
482
611
  await orgService.deleteOrganism(id);
483
612
  showToast(t('organisms.deleted') || 'Organism deleted');
484
613
  setExpanded(null);
614
+ setOpenId(null); setOpenWs(null);
485
615
  loadData();
486
616
  } catch {
487
617
  showToast(t('organisms.deleteError') || 'Failed to delete');
@@ -493,212 +623,113 @@ export default function OrganismsTab({ session, showToast, onStats }) {
493
623
  setExpanded(prev => prev === id ? null : id);
494
624
  }, []);
495
625
 
496
- const startEdit = useCallback((org) => {
497
- setEditing(org.id);
498
- setEditForm({
499
- name: org.name,
500
- description: org.description || '',
501
- type: org.type,
502
- join_policy: org.joinPolicy,
503
- visibility: org.visibility,
504
- interests: (org.interests || []).join(', '),
505
- });
506
- }, []);
507
-
508
- const cancelEdit = useCallback(() => {
509
- setEditing(null);
510
- setEditForm({});
511
- }, []);
512
-
513
- const handleUpdate = useCallback(async (id) => {
514
- if (!editForm.name?.trim()) {
515
- showToast(t('organisms.nameRequired') || 'Name is required');
516
- return;
517
- }
518
- setSaving(true);
519
- try {
520
- const interests = editForm.interests.split(',').map(s => s.trim()).filter(Boolean);
521
- const result = await orgService.updateOrganism(id, {
522
- name: editForm.name.trim(),
523
- description: editForm.description.trim(),
524
- type: editForm.type,
525
- join_policy: editForm.join_policy,
526
- visibility: editForm.visibility,
527
- interests,
528
- });
529
- if (result?.ok !== false) {
530
- showToast(t('organisms.updated') || 'Organism updated');
531
- setEditing(null);
532
- setEditForm({});
533
- loadData();
534
- } else {
535
- showToast(result?.error?.message || (t('organisms.updateError') || 'Failed to update'));
536
- }
537
- } catch {
538
- showToast(t('organisms.updateError') || 'Failed to update');
539
- } finally { setSaving(false); }
540
- }, [editForm, showToast, loadData]);
541
-
542
- const renderEditForm = (org) => html`
543
- <div class="card-detail" onClick=${(e) => e.stopPropagation()}>
544
- <div class="flex-col">
545
- <input type="text" value=${editForm.name} onInput=${(e) => setEditForm(f => ({ ...f, name: e.target.value }))}
546
- placeholder=${t('organisms.namePlaceholder') || 'Name'} class="input-field input-sm" />
547
- <textarea value=${editForm.description} onInput=${(e) => setEditForm(f => ({ ...f, description: e.target.value }))} rows="2"
548
- placeholder=${t('organisms.descPlaceholder') || 'Description'} class="input-field input-sm" />
549
- <input type="text" value=${editForm.interests} onInput=${(e) => setEditForm(f => ({ ...f, interests: e.target.value }))}
550
- placeholder=${t('organisms.interestsPlaceholder') || 'Interests (comma separated)'} class="input-field input-sm" />
551
- <div class="flex-row-wrap">
552
- <select value=${editForm.type} onChange=${(e) => setEditForm(f => ({ ...f, type: e.target.value }))} class="input-field input-sm">
553
- <option value="community">${t('organisms.types.community') || 'Community'}</option>
554
- <option value="team">${t('organisms.types.team') || 'Team'}</option>
555
- <option value="club">${t('organisms.types.club') || 'Club'}</option>
556
- <option value="cooperative">${t('organisms.types.cooperative') || 'Cooperative'}</option>
557
- <option value="project">${t('organisms.types.project') || 'Project'}</option>
558
- </select>
559
- <select value=${editForm.join_policy} onChange=${(e) => setEditForm(f => ({ ...f, join_policy: e.target.value }))} class="input-field input-sm">
560
- <option value="open">${t('organisms.policyOpen') || 'Open (anyone can join)'}</option>
561
- <option value="approval_required">${t('organisms.policyApproval') || 'Approval required'}</option>
562
- <option value="invite_only">${t('organisms.policyInvite') || 'Invite only'}</option>
563
- </select>
564
- <select value=${editForm.visibility} onChange=${(e) => setEditForm(f => ({ ...f, visibility: e.target.value }))} class="input-field input-sm">
565
- <option value="public">${t('organisms.visPublic') || 'Public'}</option>
566
- <option value="listed">${t('organisms.visListed') || 'Listed'}</option>
567
- <option value="private">${t('organisms.visPrivate') || 'Private'}</option>
568
- </select>
569
- </div>
570
- <div class="flex-row-wrap">
571
- <button class="btn-primary btn-sm" onClick=${() => handleUpdate(org.id)} disabled=${saving}>
572
- ${saving ? '...' : (t('organisms.save') || 'Save')}
573
- </button>
574
- <button class="btn-ghost btn-sm" onClick=${cancelEdit}>
575
- ${t('organisms.cancel') || 'Cancel'}
576
- </button>
577
- </div>
578
- </div>
579
- </div>
580
- `;
581
-
582
- const renderOrgCard = (org, isMine) => {
583
- const isExpanded = expanded === org.id;
584
- const isEditing = editing === org.id;
585
- const isCreator = org.creatorGhii === ghii;
586
- const isAdmin = org.admins?.includes(ghii);
587
- const isMember = org.members?.includes(ghii);
588
- const canEdit = isCreator || isAdmin;
626
+ // Discover rows expand in place (non-members can't open the home page, so the detail
627
+ // grid + interests they could previously see in the expanded card stay reachable here).
628
+ const renderDiscoverDetail = (org) => {
589
629
  const policyLabel = {
590
630
  open: t('organisms.policyOpen') || 'Open',
591
631
  approval_required: t('organisms.policyApproval') || 'Approval',
592
632
  invite_only: t('organisms.policyInvite') || 'Invite Only',
593
633
  }[org.joinPolicy] || org.joinPolicy;
594
- const typeLabel = t(`organisms.types.${org.type}`) || org.type;
595
-
596
634
  return html`
597
- <div class="card ${isExpanded ? 'card-expanded' : ''}" key=${org.id}>
598
- <div class="card-header card-clickable" onClick=${() => toggleExpand(org.id)}>
599
- <span class="expand-icon">${isExpanded ? '\u25BC' : '\u25B6'}</span>
600
- <div class="card-title">${escHtml(org.name)}</div>
601
- <span class="badge badge-info">${typeLabel}</span>
602
- <span class="badge ${org.visibility === 'public' ? 'badge-success' : 'badge-warn'}">${org.visibility}</span>
603
- </div>
604
- <div class="card-subtitle">
605
- ${org.description ? escHtml(org.description.slice(0, 100)) : ''}
606
- ${' \u2014 '}${(org.members || []).length} ${t('organisms.members') || 'members'}
607
- ${' \u2014 '}${policyLabel}
608
- </div>
609
-
610
- ${(org.interests || []).length > 0 && html`
611
- <div class="flex-row-wrap" >
612
- ${org.interests.map(tag => html`<span class="file-tag" key=${tag}>${escHtml(tag)}</span>`)}
635
+ <div class="pj-org-detail" onClick=${(e) => e.stopPropagation()}>
636
+ <div class="detail-grid">
637
+ <div class="detail-item">
638
+ <span class="detail-label">${t('organisms.creator') || 'Creator'}</span>
639
+ <span class="detail-value">${escHtml(org.creatorGhii)}</span>
613
640
  </div>
614
- `}
615
-
616
- ${isExpanded && !isEditing && html`
617
- <div class="card-detail">
618
- <div class="detail-grid">
619
- <div class="detail-item">
620
- <span class="detail-label">${t('organisms.creator') || 'Creator'}</span>
621
- <span class="detail-value">${escHtml(org.creatorGhii)}</span>
622
- </div>
623
- <div class="detail-item">
624
- <span class="detail-label">${t('organisms.admins') || 'Admins'}</span>
625
- <span class="detail-value">${(org.admins || []).join(', ') || '-'}</span>
626
- </div>
627
- <div class="detail-item">
628
- <span class="detail-label">${t('organisms.memberCount') || 'Members'}</span>
629
- <span class="detail-value">${(org.members || []).length} / ${org.maxMembers || 500}</span>
630
- </div>
631
- ${org.boardId ? html`
632
- <div class="detail-item">
633
- <span class="detail-label">${t('organisms.board') || 'Board'}</span>
634
- <span class="detail-value mono">${escHtml(org.boardId)}</span>
635
- </div>
636
- ` : null}
637
- ${org.createdAt ? html`
638
- <div class="detail-item">
639
- <span class="detail-label">${t('organisms.createdAt') || 'Created'}</span>
640
- <span class="detail-value">${new Date(org.createdAt).toLocaleDateString()}</span>
641
- </div>
642
- ` : null}
641
+ <div class="detail-item">
642
+ <span class="detail-label">${t('organisms.memberCount') || 'Members'}</span>
643
+ <span class="detail-value">${(org.members || []).length} / ${org.maxMembers || 500}</span>
644
+ </div>
645
+ <div class="detail-item">
646
+ <span class="detail-label">${t('organisms.policyLabel') || 'Join policy'}</span>
647
+ <span class="detail-value">${policyLabel}</span>
648
+ </div>
649
+ ${org.createdAt ? html`
650
+ <div class="detail-item">
651
+ <span class="detail-label">${t('organisms.createdAt') || 'Created'}</span>
652
+ <span class="detail-value">${fmtDate(org.createdAt)}</span>
643
653
  </div>
654
+ ` : null}
655
+ </div>
656
+ ${(org.interests || []).length > 0 ? html`
657
+ <div class="flex-row-wrap">
658
+ ${org.interests.map(tag => html`<span class="file-tag" key=${tag}>${escHtml(tag)}</span>`)}
659
+ </div>` : null}
660
+ </div>`;
661
+ };
644
662
 
645
- <div class="card-actions">
646
- ${(isMine || isMember) ? html`
647
- <button class="btn-primary btn-sm" onClick=${(e) => { e.stopPropagation(); setOpenWs(null); setOpenId(org.id); }}>
648
- ${t('organisms.openWorkspace') || 'Open workspace'}
649
- </button>
650
- ` : null}
651
- ${canEdit ? html`
652
- <button class="btn-outline btn-sm" onClick=${(e) => { e.stopPropagation(); startEdit(org); }}>
653
- ${t('organisms.edit') || 'Edit'}
654
- </button>
655
- ` : null}
656
- ${isMine && !isCreator ? html`
657
- <button class="btn-danger-solid btn-sm" onClick=${(e) => { e.stopPropagation(); handleLeave(org.id, org.name); }}>
658
- ${t('organisms.leave') || 'Leave'}
659
- </button>
660
- ` : null}
661
- ${isCreator ? html`
662
- <button class="btn-danger-solid btn-sm" onClick=${(e) => { e.stopPropagation(); handleDelete(org.id, org.name); }}>
663
- ${t('organisms.delete') || 'Delete'}
664
- </button>
665
- ` : null}
666
- ${!isMember ? html`
667
- <button class="btn-primary btn-sm" onClick=${(e) => { e.stopPropagation(); handleJoin(org.id); }}>
668
- ${t('organisms.join') || 'Join'}
669
- </button>
670
- ` : null}
671
- </div>
663
+ // Compact one-line row: avatar \u00B7 name + type + lock \u00B7 description \u00B7 members/agents/date \u00B7
664
+ // primary action \u00B7 "\u2026" menu. Management (settings, leave, delete) lives in the menu / home page.
665
+ const renderOrgRow = (org, isMine) => {
666
+ const isCreator = org.creatorGhii === ghii;
667
+ const isAdmin = org.admins?.includes(ghii);
668
+ const isMember = org.members?.includes(ghii);
669
+ const canEdit = isCreator || isAdmin;
670
+ const typeLabel = t(`organisms.types.${org.type}`) || org.type;
671
+ const isExpanded = !isMine && expanded === org.id;
672
+ const visLabel = org.visibility === 'private' ? (t('organisms.visPrivate') || 'Private') : (t('organisms.visListed') || 'Listed');
673
+ const openHome = (withSettings) => { setOpenSettings(!!withSettings); setOpenWs(null); setOpenId(org.id); };
674
+ const activate = () => (isMine || isMember) ? openHome(false) : toggleExpand(org.id);
675
+
676
+ const menuItems = isMine ? [
677
+ canEdit && { label: t('organisms.settings') || 'Settings', icon: '\u2699', onClick: () => openHome(true) },
678
+ { label: t('organisms.exportOrg') || 'Export organism', icon: '\u2B07', onClick: () => exportOrganismZip(org, showToast) },
679
+ !isCreator && { label: t('organisms.leave') || 'Leave', danger: true, onClick: () => handleLeave(org.id, org.name) },
680
+ isCreator && { label: t('organisms.delete') || 'Delete', danger: true, onClick: () => handleDelete(org.id, org.name) },
681
+ ] : [];
672
682
 
673
- ${(canEdit || isMember) ? html`<${OrgSearch}
674
- orgId=${org.id}
675
- onOpenWorkspace=${(ws) => { setOpenWs(ws); setOpenId(org.id); }} />` : null}
676
-
677
- ${(canEdit || isMember) ? html`<${OrgMemberManager}
678
- org=${org}
679
- ghii=${ghii}
680
- canManage=${canEdit}
681
- isCreator=${isCreator}
682
- showToast=${showToast}
683
- confirm=${confirm}
684
- onChanged=${loadData} />` : null}
683
+ return html`
684
+ <div class="pj-org-row ${dragOverId === org.id ? 'pj-org-drag-over' : ''}" key=${org.id}
685
+ draggable=${isMine}
686
+ onDragStart=${isMine ? ((e) => { dragIdRef.current = org.id; e.dataTransfer.effectAllowed = 'move'; }) : undefined}
687
+ onDragOver=${isMine ? ((e) => { e.preventDefault(); setDragOverId(org.id); }) : undefined}
688
+ onDragLeave=${isMine ? (() => setDragOverId(d => (d === org.id ? null : d))) : undefined}
689
+ onDrop=${isMine ? (() => onDropRow(org.id)) : undefined}
690
+ onDragEnd=${isMine ? (() => { dragIdRef.current = null; setDragOverId(null); }) : undefined}>
691
+ <div class="pj-org-avatar" aria-hidden="true">${orgInitials(org.name)}</div>
692
+ <div class="pj-org-main" role="button" tabindex="0" onClick=${activate}
693
+ onKeyDown=${(e) => { if (e.key === 'Enter') activate(); }}>
694
+ <div class="pj-org-titlerow">
695
+ <span class="pj-org-name">${escHtml(org.name)}</span>
696
+ <span class="badge badge-info">${typeLabel}</span>
697
+ ${org.visibility !== 'public' ? html`<span class="pj-org-lock" title=${visLabel}>${'\uD83D\uDD12'}</span>` : null}
685
698
  </div>
686
- `}
687
-
688
- ${isExpanded && isEditing && renderEditForm(org)}
699
+ ${org.description ? html`<div class="pj-org-desc">${escHtml(org.description)}</div>` : null}
700
+ </div>
701
+ <div class="pj-org-stats">
702
+ ${isMine && wsCounts[org.id] !== undefined ? html`
703
+ <span class="pj-org-stat" title=${t('organisms.tabWorkspaces') || 'Workspaces'}>${'\uD83D\uDCC1'} ${wsCounts[org.id]}</span>` : null}
704
+ <span class="pj-org-stat" title=${t('organisms.members') || 'Members'}>${'\uD83D\uDC65'} ${(org.members || []).length}</span>
705
+ <span class="pj-org-stat" title=${t('organisms.attachedAgents') || 'Attached agents'}>${'\uD83E\uDD16'} ${(org.agentGaiis || []).length}</span>
706
+ ${org.createdAt ? html`<span class="pj-org-stat pj-org-date" title=${t('organisms.createdAt') || 'Created'}>${fmtDate(org.createdAt)}</span>` : null}
707
+ </div>
708
+ ${(isMine || isMember)
709
+ ? html`<button class="btn-outline btn-sm pj-org-openbtn" onClick=${() => openHome(false)}>${t('organisms.open') || 'Open'}</button>`
710
+ : html`<button class="btn-outline btn-sm pj-org-openbtn" onClick=${() => handleJoin(org.id)}>${t('organisms.join') || 'Join'}</button>`}
711
+ ${isMine ? html`<${KebabMenu} label=${t('organisms.moreActions') || 'More actions'} items=${menuItems} />` : null}
712
+ ${isExpanded ? renderDiscoverDetail(org) : null}
689
713
  </div>
690
714
  `;
691
715
  };
692
716
 
693
717
  if (openId) {
694
718
  const org = [...(myOrganisms || []), ...publicOrganisms].find(o => o.id === openId) || { id: openId };
695
- // openWs chosen → that workspace; otherwise the organism's workspace LIST.
719
+ // openWs chosen → that workspace; otherwise the organism's HOME page (tabs incl. workspaces).
696
720
  if (openWs) {
697
721
  return html`<${Workspace} org=${org} wsId=${openWs} session=${session} showToast=${showToast}
698
722
  onBack=${() => { setOpenWs(null); }} />`;
699
723
  }
700
- return html`<${WorkspaceList} org=${org} showToast=${showToast}
701
- onOpen=${(wsId) => setOpenWs(wsId)} onBack=${() => { setOpenId(null); loadData(); }} />`;
724
+ return html`
725
+ <${OrganismHome} org=${org} ghii=${ghii} showToast=${showToast}
726
+ initialSettings=${openSettings}
727
+ onOpenWs=${(wsId) => setOpenWs(wsId)}
728
+ onBack=${() => { setOpenId(null); setOpenSettings(false); loadData(); }}
729
+ onChanged=${loadData}
730
+ onLeave=${() => handleLeave(org.id, org.name)}
731
+ onDelete=${() => handleDelete(org.id, org.name)} />
732
+ <${ConfirmUI} />`;
702
733
  }
703
734
 
704
735
  if (!myOrganisms) return html`<${Spinner} text=${t('organisms.loading') || 'Loading organisms...'} />`;
@@ -707,13 +738,9 @@ export default function OrganismsTab({ session, showToast, onStats }) {
707
738
  <div class="section-title">${t('organisms.title') || 'Organisms'}</div>
708
739
  <div class="section-desc">${t('organisms.desc') || 'Organisms are groups — communities, teams, clubs, or projects. Create one or join existing ones to share knowledge, coordinate work, and build together.'}</div>
709
740
 
710
- <!-- Create button / form -->
741
+ <!-- Create form (opened from the topbar button) -->
711
742
  <div class="mb-1">
712
- ${!showCreate ? html`
713
- <button class="btn-primary" onClick=${() => setShowCreate(true)}>
714
- ${t('organisms.createNew') || 'Create Organism'}
715
- </button>
716
- ` : html`
743
+ ${!showCreate ? null : html`
717
744
  <div class="create-form">
718
745
  <h4 class="card-h3 mb-half">${t('organisms.createTitle') || 'Create New Organism'}</h4>
719
746
  <div class="flex-col">
@@ -765,22 +792,242 @@ export default function OrganismsTab({ session, showToast, onStats }) {
765
792
  <input type="file" accept=".zip,application/zip" ref=${orgFileRef} style="display:none" onChange=${(e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; doImportOrg(f); }} />
766
793
  <div class="pj-ws-topbar">
767
794
  <div class="section-title">${t('organisms.myOrganisms') || 'My Organisms'}</div>
768
- <button class="btn-outline btn-sm" disabled=${importingOrg} title=${t('organisms.importOrgHint') || 'Restore an organism from a .zip backup'} onClick=${() => orgFileRef.current && orgFileRef.current.click()}>${'⬆ '}${t('organisms.importOrg') || 'Import organism'}</button>
795
+ <div class="pj-org-topbar-actions">
796
+ ${myOrganisms.length > 1 ? html`
797
+ <select class="input-field input-sm pj-org-sort" title=${t('organisms.sortTitle') || 'Sort'} value=${sortMode}
798
+ onChange=${(e) => { const m = e.target.value; setSortMode(m); savePrefs(customOrder, m); }}>
799
+ <option value="custom">${t('organisms.sortCustom') || 'My order'}</option>
800
+ <option value="name">${t('organisms.sortName') || 'Name A–Z'}</option>
801
+ <option value="newest">${t('organisms.sortNewest') || 'Newest first'}</option>
802
+ </select>` : null}
803
+ <button class="btn-outline btn-sm" disabled=${importingOrg} title=${t('organisms.importOrgHint') || 'Restore an organism from a .zip backup'} onClick=${() => orgFileRef.current && orgFileRef.current.click()}>${'⬆ '}${t('organisms.importOrg') || 'Import'}</button>
804
+ <button class="btn-primary btn-sm" onClick=${() => setShowCreate(true)}>${'+ '}${t('organisms.createNew') || 'Create Organism'}</button>
805
+ </div>
769
806
  </div>
770
807
  ${myOrganisms.length === 0
771
808
  ? html`<div class="empty">${t('organisms.empty') || 'You are not part of any organisms yet.'}</div>`
772
- : myOrganisms.map(org => renderOrgCard(org, true))
809
+ : html`
810
+ <div class="pj-org-list">${sortedMine.map(org => renderOrgRow(org, true))}</div>
811
+ ${sortMode === 'custom' && sortedMine.length > 1 ? html`
812
+ <div class="pj-org-hint">${t('organisms.reorderHint') || 'Drag rows to reorder — the order is saved to your profile.'}</div>` : null}
813
+ `
773
814
  }
774
815
 
775
816
  <!-- Discover -->
776
817
  ${publicOrganisms.length > 0 && html`
777
818
  <div class="section-title section-title-spaced">${t('organisms.discover') || 'Discover'}</div>
778
- ${publicOrganisms.map(org => renderOrgCard(org, false))}
819
+ <div class="pj-org-list">${publicOrganisms.map(org => renderOrgRow(org, false))}</div>
779
820
  `}
780
821
  <${ConfirmUI} />
781
822
  `;
782
823
  }
783
824
 
825
+ /* ───────────────── Organism home page ─────────────────
826
+ * One home per organism: breadcrumb header (avatar, name, badges, description,
827
+ * Export + Settings) and tabs — Workspaces (the workspace list + content search),
828
+ * Members (invite/approve/roster/blocked), Agents (attach/detach), Board (link to
829
+ * the Boards tab). The Settings panel hosts the edit form, the metadata that used
830
+ * to live in the expanded list card, and the danger zone (leave / delete). */
831
+ function OrganismHome({ org, ghii, showToast, initialSettings, onOpenWs, onBack, onChanged, onLeave, onDelete }) {
832
+ const { confirm, ConfirmUI } = useConfirm();
833
+ const [tab, setTab] = useState(() => { try { return sessionStorage.getItem('aimeat.org.tab') || 'workspaces'; } catch { return 'workspaces'; } });
834
+ const [showSettings, setShowSettings] = useState(!!initialSettings);
835
+ const [wsCount, setWsCount] = useState(null);
836
+ useEffect(() => { try { sessionStorage.setItem('aimeat.org.tab', tab); } catch { /* noop */ } }, [tab]);
837
+
838
+ const isCreator = org.creatorGhii === ghii;
839
+ const isAdmin = org.admins?.includes(ghii);
840
+ const isMember = org.members?.includes(ghii);
841
+ const canEdit = isCreator || isAdmin;
842
+ const typeLabel = t(`organisms.types.${org.type}`) || org.type;
843
+
844
+ /* ── Settings: edit form (admins), metadata, danger zone ── */
845
+ const [editForm, setEditForm] = useState(null);
846
+ const [saving, setSaving] = useState(false);
847
+ // Re-seed the form when another organism is opened (org.id), not on every reload of the same org.
848
+ useEffect(() => { setEditForm(null); }, [org.id]);
849
+ const seedForm = () => ({
850
+ name: org.name || '', description: org.description || '', type: org.type,
851
+ join_policy: org.joinPolicy, visibility: org.visibility, interests: (org.interests || []).join(', '),
852
+ });
853
+ const saveEdit = async () => {
854
+ if (!editForm?.name?.trim()) { showToast(t('organisms.nameRequired') || 'Name is required'); return; }
855
+ setSaving(true);
856
+ try {
857
+ const interests = editForm.interests.split(',').map(s => s.trim()).filter(Boolean);
858
+ const result = await orgService.updateOrganism(org.id, {
859
+ name: editForm.name.trim(), description: editForm.description.trim(),
860
+ type: editForm.type, join_policy: editForm.join_policy, visibility: editForm.visibility, interests,
861
+ });
862
+ if (result?.ok !== false) { showToast(t('organisms.updated') || 'Organism updated'); setEditForm(null); onChanged?.(); }
863
+ else showToast(result?.error?.message || (t('organisms.updateError') || 'Failed to update'));
864
+ } catch { showToast(t('organisms.updateError') || 'Failed to update'); }
865
+ finally { setSaving(false); }
866
+ };
867
+
868
+ const boardIdShort = org.boardId && org.boardId.length > 20
869
+ ? `${org.boardId.slice(0, 12)}…${org.boardId.slice(-6)}` : (org.boardId || '');
870
+ const copyBoardId = async () => {
871
+ const ok = await copyToClipboard(org.boardId);
872
+ showToast(ok ? (t('organisms.copied') || 'Copied') : (t('organisms.copyFailed') || 'Could not copy'));
873
+ };
874
+
875
+ const renderSettings = () => html`
876
+ <div class="card-detail pj-org-settings">
877
+ <div class="detail-grid">
878
+ <div class="detail-item">
879
+ <span class="detail-label">${t('organisms.creator') || 'Creator'}</span>
880
+ <span class="detail-value">${escHtml(org.creatorGhii || '-')}</span>
881
+ </div>
882
+ <div class="detail-item">
883
+ <span class="detail-label">${t('organisms.admins') || 'Admins'}</span>
884
+ <span class="detail-value">${(org.admins || []).join(', ') || '-'}</span>
885
+ </div>
886
+ <div class="detail-item">
887
+ <span class="detail-label">${t('organisms.memberCount') || 'Members'}</span>
888
+ <span class="detail-value">${(org.members || []).length} / ${org.maxMembers || 500}</span>
889
+ </div>
890
+ ${org.createdAt ? html`
891
+ <div class="detail-item">
892
+ <span class="detail-label">${t('organisms.createdAt') || 'Created'}</span>
893
+ <span class="detail-value">${fmtDate(org.createdAt)}</span>
894
+ </div>` : null}
895
+ ${org.boardId ? html`
896
+ <div class="detail-item">
897
+ <span class="detail-label">${t('organisms.board') || 'Board'}</span>
898
+ <span class="detail-value mono" title=${escHtml(org.boardId)}>${escHtml(boardIdShort)}
899
+ <button class="pj-icon-btn" title=${t('organisms.copyId') || 'Copy ID'} onClick=${copyBoardId}>${'📋'}</button>
900
+ </span>
901
+ </div>` : null}
902
+ </div>
903
+ ${(org.interests || []).length > 0 ? html`
904
+ <div class="flex-row-wrap">
905
+ ${org.interests.map(tag => html`<span class="file-tag" key=${tag}>${escHtml(tag)}</span>`)}
906
+ </div>` : null}
907
+
908
+ ${canEdit ? (editForm ? html`
909
+ <div class="flex-col">
910
+ <input type="text" value=${editForm.name} onInput=${(e) => setEditForm(f => ({ ...f, name: e.target.value }))}
911
+ placeholder=${t('organisms.namePlaceholder') || 'Name'} class="input-field input-sm" />
912
+ <textarea value=${editForm.description} onInput=${(e) => setEditForm(f => ({ ...f, description: e.target.value }))} rows="2"
913
+ placeholder=${t('organisms.descPlaceholder') || 'Description'} class="input-field input-sm" />
914
+ <input type="text" value=${editForm.interests} onInput=${(e) => setEditForm(f => ({ ...f, interests: e.target.value }))}
915
+ placeholder=${t('organisms.interestsPlaceholder') || 'Interests (comma separated)'} class="input-field input-sm" />
916
+ <div class="flex-row-wrap">
917
+ <select value=${editForm.type} onChange=${(e) => setEditForm(f => ({ ...f, type: e.target.value }))} class="input-field input-sm">
918
+ <option value="community">${t('organisms.types.community') || 'Community'}</option>
919
+ <option value="team">${t('organisms.types.team') || 'Team'}</option>
920
+ <option value="club">${t('organisms.types.club') || 'Club'}</option>
921
+ <option value="cooperative">${t('organisms.types.cooperative') || 'Cooperative'}</option>
922
+ <option value="project">${t('organisms.types.project') || 'Project'}</option>
923
+ </select>
924
+ <select value=${editForm.join_policy} onChange=${(e) => setEditForm(f => ({ ...f, join_policy: e.target.value }))} class="input-field input-sm">
925
+ <option value="open">${t('organisms.policyOpen') || 'Open (anyone can join)'}</option>
926
+ <option value="approval_required">${t('organisms.policyApproval') || 'Approval required'}</option>
927
+ <option value="invite_only">${t('organisms.policyInvite') || 'Invite only'}</option>
928
+ </select>
929
+ <select value=${editForm.visibility} onChange=${(e) => setEditForm(f => ({ ...f, visibility: e.target.value }))} class="input-field input-sm">
930
+ <option value="public">${t('organisms.visPublic') || 'Public'}</option>
931
+ <option value="listed">${t('organisms.visListed') || 'Listed'}</option>
932
+ <option value="private">${t('organisms.visPrivate') || 'Private'}</option>
933
+ </select>
934
+ </div>
935
+ <div class="flex-row-wrap">
936
+ <button class="btn-primary btn-sm" onClick=${saveEdit} disabled=${saving}>${saving ? '...' : (t('organisms.save') || 'Save')}</button>
937
+ <button class="btn-ghost btn-sm" onClick=${() => setEditForm(null)}>${t('organisms.cancel') || 'Cancel'}</button>
938
+ </div>
939
+ </div>
940
+ ` : html`
941
+ <div class="flex-row-wrap">
942
+ <button class="btn-outline btn-sm" onClick=${() => setEditForm(seedForm())}>${t('organisms.edit') || 'Edit'}</button>
943
+ </div>
944
+ `) : null}
945
+
946
+ ${(isCreator || isMember) ? html`
947
+ <div class="detail-label">${t('organisms.dangerZone') || 'Danger zone'}</div>
948
+ <div class="flex-row-wrap">
949
+ ${isMember && !isCreator ? html`
950
+ <button class="btn-danger btn-sm" onClick=${onLeave}>${t('organisms.leave') || 'Leave'}</button>` : null}
951
+ ${isCreator ? html`
952
+ <button class="btn-danger btn-sm" onClick=${onDelete}>${t('organisms.delete') || 'Delete'}</button>` : null}
953
+ </div>` : null}
954
+ </div>`;
955
+
956
+ const tabs = [
957
+ { id: 'workspaces', label: t('organisms.tabWorkspaces') || 'Workspaces', count: wsCount },
958
+ { id: 'members', label: t('organisms.tabMembers') || 'Members', count: (org.members || []).length },
959
+ { id: 'agents', label: t('organisms.tabAgents') || 'Agents', count: (org.agentGaiis || []).length },
960
+ { id: 'board', label: t('organisms.tabBoard') || 'Board', count: null },
961
+ ];
962
+
963
+ return html`
964
+ <div class="pj-ws">
965
+ <div class="pj-org-breadcrumb">
966
+ <button class="pj-org-crumb-link" onClick=${onBack}>${t('organisms.title') || 'Organisms'}</button>
967
+ <span class="pj-org-crumb-sep">/</span>
968
+ <span>${escHtml(org.name || org.id)}</span>
969
+ </div>
970
+
971
+ <div class="pj-org-home-head">
972
+ <div class="pj-org-avatar pj-org-avatar-lg" aria-hidden="true">${orgInitials(org.name)}</div>
973
+ <div class="pj-org-home-title">
974
+ <div class="pj-org-titlerow">
975
+ <span class="pj-org-home-name">${escHtml(org.name || org.id)}</span>
976
+ <span class="badge badge-info">${typeLabel}</span>
977
+ <span class="badge ${org.visibility === 'public' ? 'badge-success' : 'badge-warn'}">${org.visibility || ''}</span>
978
+ </div>
979
+ ${org.description ? html`<div class="section-desc pj-org-home-desc">${escHtml(org.description)}</div>` : null}
980
+ </div>
981
+ <div class="pj-org-home-actions">
982
+ <button class="btn-outline btn-sm" title=${t('organisms.exportOrgHint') || 'Download a ZIP backup of the whole organism (all workspaces)'}
983
+ onClick=${() => exportOrganismZip(org, showToast)}>${'⬇ '}${t('organisms.exportOrg') || 'Export'}</button>
984
+ <button class="btn-outline btn-sm ${showSettings ? 'pj-org-btn-active' : ''}" onClick=${() => setShowSettings(s => !s)}>${'⚙ '}${t('organisms.settings') || 'Settings'}</button>
985
+ </div>
986
+ </div>
987
+
988
+ ${showSettings ? renderSettings() : null}
989
+
990
+ <div class="pj-org-tabs" role="tablist">
991
+ ${tabs.map(tb => html`
992
+ <button class="pj-org-tab ${tab === tb.id ? 'active' : ''}" role="tab" aria-selected=${tab === tb.id} key=${tb.id}
993
+ onClick=${() => setTab(tb.id)}>
994
+ ${tb.label}${tb.count !== null && tb.count !== undefined ? html`<span class="pj-org-tab-count">${tb.count}</span>` : null}
995
+ </button>`)}
996
+ </div>
997
+
998
+ ${tab === 'workspaces' ? html`
999
+ <${WorkspaceList} org=${org} showToast=${showToast} onOpen=${onOpenWs} onCount=${setWsCount} />
1000
+ <${OrgSearch} orgId=${org.id} onOpenWorkspace=${(ws) => onOpenWs(ws)} />
1001
+ ` : null}
1002
+
1003
+ ${tab === 'members' ? html`
1004
+ <${OrgMemberManager} org=${org} ghii=${ghii} canManage=${canEdit} isCreator=${isCreator}
1005
+ showToast=${showToast} confirm=${confirm} onChanged=${onChanged} show="members" />` : null}
1006
+
1007
+ ${tab === 'agents' ? html`
1008
+ <${OrgMemberManager} org=${org} ghii=${ghii} canManage=${canEdit} isCreator=${isCreator}
1009
+ showToast=${showToast} confirm=${confirm} onChanged=${onChanged} show="agents" />` : null}
1010
+
1011
+ ${tab === 'board' ? html`
1012
+ <div class="card-detail">
1013
+ <div class="section-title">${t('organisms.tabBoard') || 'Board'}</div>
1014
+ ${org.boardId ? html`
1015
+ <div class="section-desc">${t('organisms.boardDesc') || 'This organism has a discussion board. Open it in the Boards view.'}</div>
1016
+ <div class="pj-org-boardrow">
1017
+ <span class="mono" title=${escHtml(org.boardId)}>${escHtml(boardIdShort)}</span>
1018
+ <button class="pj-icon-btn" title=${t('organisms.copyId') || 'Copy ID'} onClick=${copyBoardId}>${'📋'}</button>
1019
+ <button class="btn-outline btn-sm" onClick=${() => window.dispatchEvent(new CustomEvent('aimeat-open-tab', { detail: { tabId: 'boards' } }))}>
1020
+ ${t('organisms.openBoardsTab') || 'Open in Boards'}</button>
1021
+ </div>
1022
+ ` : html`
1023
+ <div class="section-desc">${t('organisms.noBoard') || 'This organism has no board.'}</div>
1024
+ `}
1025
+ </div>` : null}
1026
+
1027
+ <${ConfirmUI} />
1028
+ </div>`;
1029
+ }
1030
+
784
1031
  /* ───────────────── Organism workspace (manifest-driven) ─────────────────
785
1032
  * Any organism can have a governed workspace. If it has no manifest yet, offer
786
1033
  * "Set up workspace" (applies the project template). Otherwise render the
@@ -792,7 +1039,9 @@ const PRIMARY_FIELD = { goal: 'title', plan: 'approach', deliverable: 'title', r
792
1039
  /* Workspace list — an organism contains many workspaces (each an independent manifest + data set,
793
1040
  * namespaced under organism.{id}.w.{wsId}.*). Lists the registry (organism.{id}.meta.workspaces),
794
1041
  * lets the user create a new one (→ opens its setup/generate screen) or open/delete an existing one. */
795
- function WorkspaceList({ org, showToast, onOpen, onBack }) {
1042
+ /* Rendered embedded in OrganismHome's Workspaces tab (the back/export topbar + organism title
1043
+ * moved to the home header). onCount reports the discovered workspace count for the tab badge. */
1044
+ function WorkspaceList({ org, showToast, onOpen, onCount }) {
796
1045
  const orgId = org.id;
797
1046
  const { confirm, ConfirmUI } = useConfirm();
798
1047
  const [list, setList] = useState(null); // null = loading
@@ -806,7 +1055,11 @@ function WorkspaceList({ org, showToast, onOpen, onBack }) {
806
1055
 
807
1056
  // Discovery: every workspace in the org (membership-gated) with this user's access status. A member
808
1057
  // sees workspaces they can't yet read (access:'none') so they can request access.
809
- const load = useCallback(async () => { setList(await orgService.discoverWorkspaces(orgId)); }, [orgId]);
1058
+ const load = useCallback(async () => {
1059
+ const l = await orgService.discoverWorkspaces(orgId);
1060
+ setList(l);
1061
+ onCount?.(Array.isArray(l) ? l.length : 0);
1062
+ }, [orgId, onCount]);
810
1063
  useEffect(() => { load(); }, [load]);
811
1064
 
812
1065
  const requestAccess = async (w) => {
@@ -890,27 +1143,10 @@ function WorkspaceList({ org, showToast, onOpen, onBack }) {
890
1143
  } catch (e) { showToast((e && e.message) || 'Import failed'); }
891
1144
  finally { setBusy(false); }
892
1145
  };
893
- const doExportOrg = async () => {
894
- try {
895
- const res = await fetch(`/v1/organisms/${encodeURIComponent(orgId)}/export`, { headers: { Authorization: 'Bearer ' + jwt() } });
896
- if (!res.ok) throw new Error('Export failed');
897
- const blob = await res.blob();
898
- const url = URL.createObjectURL(blob);
899
- const a = document.createElement('a');
900
- a.href = url; a.download = `organism-${String(org.name || orgId).replace(/[^a-z0-9_-]+/gi, '-').slice(0, 40)}.zip`;
901
- document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
902
- } catch (e) { showToast((e && e.message) || 'Export failed'); }
903
- };
904
-
905
1146
  return html`
906
- <div class="pj-ws">
1147
+ <div class="pj-ws-embedded">
907
1148
  <${ConfirmUI} />
908
1149
  <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); }} />
909
- <div class="pj-ws-topbar">
910
- <button class="btn-ghost btn-sm" onClick=${onBack}>${'← '}${t('organisms.allOrganisms') || 'All organisms'}</button>
911
- <button class="btn-ghost btn-sm" title=${t('organisms.exportOrgHint') || 'Download a ZIP backup of the whole organism (all workspaces)'} onClick=${doExportOrg}>${'⬇ '}${t('organisms.exportOrg') || 'Export organism'}</button>
912
- </div>
913
- <div class="section-title">${escHtml(org.name || 'Organism')}</div>
914
1150
  <div class="section-desc">${t('organisms.workspacesDesc') || 'Workspaces in this organism — each is an independent space with its own documents, records and history.'}</div>
915
1151
 
916
1152
  ${overview ? html`