ltcai 1.0.1 → 1.2.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.
@@ -0,0 +1,6 @@
1
+ """Service layer extracted from server_app.py.
2
+
3
+ Services wrap stores and business logic with no dependency on the FastAPI app
4
+ object, so API routers (latticeai.api.*) and the app assembly
5
+ (latticeai.server_app) can import them without creating import cycles.
6
+ """
@@ -0,0 +1,53 @@
1
+ """Chat / session service seam.
2
+
3
+ The streaming chat path in ``server_app`` is intentionally left in place — its
4
+ generator and SSE behaviour are sensitive. This service provides a stable seam
5
+ for the *bookkeeping* around answers (conversation history access and
6
+ Workspace-OS answer-trace recording) so those concerns are named and wrapped
7
+ rather than reaching into the store directly from the streaming handler.
8
+
9
+ It is a behaviour-preserving façade: methods forward to the injected history
10
+ accessor and :class:`WorkspaceOSStore`, so wiring the chat path through it
11
+ cannot change streaming output.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Callable, Dict, List, Optional
17
+
18
+ from latticeai.core.workspace_os import WorkspaceOSStore
19
+
20
+
21
+ class ChatService:
22
+ def __init__(self, *, store: WorkspaceOSStore, get_history: Callable[[], List[Dict[str, Any]]]):
23
+ self._store = store
24
+ self._get_history = get_history
25
+
26
+ # ── conversation history ─────────────────────────────────────────────
27
+
28
+ def history(self) -> List[Dict[str, Any]]:
29
+ return self._get_history()
30
+
31
+ # ── answer-trace recording (Graph RAG) ───────────────────────────────
32
+
33
+ def build_graph_trace(self, question: str, graph: Any, context: str = "", *, limit: int = 8) -> Dict[str, Any]:
34
+ return self._store.build_graph_trace(question, graph, context, limit=limit)
35
+
36
+ def record_trace(
37
+ self,
38
+ *,
39
+ question: str,
40
+ response: str,
41
+ conversation_id: Optional[str],
42
+ user_email: Optional[str],
43
+ trace: Dict[str, Any],
44
+ workspace_id: Optional[str] = None,
45
+ ) -> Dict[str, Any]:
46
+ return self._store.record_trace(
47
+ question=question,
48
+ response=response,
49
+ conversation_id=conversation_id,
50
+ user_email=user_email,
51
+ trace=trace,
52
+ workspace_id=workspace_id,
53
+ )
@@ -0,0 +1,51 @@
1
+ """Model / engine state helpers used by the health and model routers.
2
+
3
+ Pure payload builders with no FastAPI dependency. The model runtime (LLMRouter),
4
+ ``engine_status``, and ``runtime_features`` remain owned by ``server_app``; this
5
+ module just assembles the response shapes so the health/model routers stay thin
6
+ and the summaries live in one place.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Callable, Dict, List
12
+
13
+
14
+ class ModelService:
15
+ """Assembles health/engine summary payloads from injected runtime pieces."""
16
+
17
+ def __init__(
18
+ self,
19
+ *,
20
+ model_router: Any,
21
+ runtime_features: Callable[[], Dict[str, Any]],
22
+ is_public: bool,
23
+ ):
24
+ self._router = model_router
25
+ self._runtime_features = runtime_features
26
+ self._is_public = is_public
27
+
28
+ def runtime(self) -> Dict[str, Any]:
29
+ return self._runtime_features()
30
+
31
+ def health_base(self, *, version: str, mode: str) -> Dict[str, Any]:
32
+ return {
33
+ "status": "ok",
34
+ "version": version,
35
+ "mode": mode,
36
+ "platform": "AI Workspace OS",
37
+ }
38
+
39
+ def health_full(self, base: Dict[str, Any], engines: List[Dict[str, Any]]) -> Dict[str, Any]:
40
+ return {
41
+ **base,
42
+ "current_model": self._router.current_model_id,
43
+ "loaded_models": self._router.loaded_model_ids,
44
+ "device": "Apple Silicon MLX" if not self._is_public else "Public cloud/API runtime",
45
+ "features": self._runtime_features(),
46
+ "providers": self._router.detected_cloud_models(),
47
+ "engines": engines,
48
+ }
49
+
50
+ def engines_payload(self, engines: List[Dict[str, Any]]) -> Dict[str, Any]:
51
+ return {"engines": engines, "current": self._router.current_model_id}
@@ -0,0 +1,117 @@
1
+ """Workspace service layer: scope resolution, permission guardrails, and a
2
+ single seam in front of :class:`WorkspaceOSStore`.
3
+
4
+ This module centralizes the workspace_id resolution and role/permission checks
5
+ that were previously scattered across the FastAPI handlers. It depends only on
6
+ ``workspace_os`` (and indirectly ``enterprise``), never on the FastAPI app, so
7
+ both the app assembly and the API routers can import it freely.
8
+
9
+ Guardrail summary (v1.2.0):
10
+
11
+ * **Explicit workspace targeting is gated.** When a caller names a workspace
12
+ (``X-Workspace-Id`` header / ``workspace_id``), reads require ``read`` and
13
+ writes require ``write`` on that workspace; non-members are rejected.
14
+ * **Backward compatible default.** With no workspace named, the scope resolves
15
+ to the *active* workspace (Personal by default). Pre-1.1 clients that never
16
+ send a header keep operating on Personal data exactly as before.
17
+ * **Personal workspace** always grants its single local user owner rights.
18
+ * **No-auth local mode** keeps the owner fallback for *ownerless* org
19
+ workspaces (the anonymous local user owns what they create), but a *named*
20
+ stranger never bypasses membership.
21
+ * **Graph and installed Skills are intentionally machine-global shared state**
22
+ (the local knowledge graph and on-disk skills are not partitioned per
23
+ workspace). Skill enable/disable and other actions still record
24
+ workspace-scoped timeline events via the active workspace.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import Any, Dict, Optional
30
+
31
+ from latticeai.core.workspace_os import WorkspaceOSStore
32
+
33
+
34
+ class WorkspaceService:
35
+ """Permission-aware façade over :class:`WorkspaceOSStore`."""
36
+
37
+ # Graph / installed-skill state that is shared machine-wide rather than
38
+ # partitioned per workspace. Surfaced so the UI / docs can be explicit.
39
+ SHARED_GLOBAL_AREAS = ("graph", "skills")
40
+
41
+ def __init__(self, store: WorkspaceOSStore):
42
+ self.store = store
43
+
44
+ # ── scope resolution + gating ────────────────────────────────────────
45
+
46
+ def _ensure_permission(self, workspace_id: str, user_id: Optional[str], permission: str) -> None:
47
+ if not self.store.has_permission(workspace_id, user_id, permission):
48
+ raise PermissionError(
49
+ f"'{user_id or 'anonymous'}' lacks '{permission}' on workspace '{workspace_id}'"
50
+ )
51
+
52
+ def resolve_read_scope(self, requested: Optional[str], user_id: Optional[str]) -> str:
53
+ """Resolve + authorize the workspace a read should target.
54
+
55
+ ``None`` falls back to the active workspace (Personal by default), which
56
+ preserves pre-1.1 behaviour. An explicitly named workspace is gated on
57
+ ``read`` so non-members cannot read organization data.
58
+ """
59
+ workspace_id = requested or self.store._active_workspace_id()
60
+ self._ensure_permission(workspace_id, user_id, "read")
61
+ return workspace_id
62
+
63
+ def resolve_write_scope(self, requested: Optional[str], user_id: Optional[str]) -> str:
64
+ """Resolve + authorize the workspace a write should target (gated on ``write``)."""
65
+ workspace_id = requested or self.store._active_workspace_id()
66
+ self._ensure_permission(workspace_id, user_id, "write")
67
+ return workspace_id
68
+
69
+ def can_read(self, workspace_id: str, user_id: Optional[str]) -> bool:
70
+ return self.store.has_permission(workspace_id, user_id, "read")
71
+
72
+ def can_write(self, workspace_id: str, user_id: Optional[str]) -> bool:
73
+ return self.store.has_permission(workspace_id, user_id, "write")
74
+
75
+ # ── workspace registry / summary ─────────────────────────────────────
76
+
77
+ def summary(self, user_id: Optional[str]) -> Dict[str, Any]:
78
+ data = self.store.summary()
79
+ data["workspace_registry"] = self.store.list_workspaces(user_id=user_id)
80
+ data["shared_global_areas"] = list(self.SHARED_GLOBAL_AREAS)
81
+ return data
82
+
83
+ def list_workspaces(self, user_id: Optional[str]) -> Dict[str, Any]:
84
+ return self.store.list_workspaces(user_id=user_id)
85
+
86
+ def get_workspace(self, workspace_id: str, user_id: Optional[str]) -> Dict[str, Any]:
87
+ # Reading workspace metadata requires read access to that workspace.
88
+ self._ensure_permission(workspace_id, user_id, "read")
89
+ return self.store.get_workspace(workspace_id, user_id=user_id)
90
+
91
+ def workspace_summary(self, workspace_id: str, user_id: Optional[str]) -> Dict[str, Any]:
92
+ self._ensure_permission(workspace_id, user_id, "read")
93
+ return self.store.workspace_summary(workspace_id, user_id=user_id)
94
+
95
+ # ── organization workspace management (delegates with actor) ─────────
96
+
97
+ def create_organization_workspace(self, *, name: str, owner_user_id: Optional[str], settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
98
+ return self.store.create_organization_workspace(name=name, owner_user_id=owner_user_id, settings=settings)
99
+
100
+ def update_workspace(self, workspace_id: str, *, name=None, settings=None, actor=None) -> Dict[str, Any]:
101
+ return self.store.update_workspace(workspace_id, name=name, settings=settings, actor=actor)
102
+
103
+ def archive_workspace(self, workspace_id: str, *, actor=None) -> Dict[str, Any]:
104
+ return self.store.archive_workspace(workspace_id, actor=actor)
105
+
106
+ def add_member(self, workspace_id: str, *, user_id: str, role: str = "member", actor=None) -> Dict[str, Any]:
107
+ return self.store.add_member(workspace_id, user_id=user_id, role=role, actor=actor)
108
+
109
+ def update_member_role(self, workspace_id: str, *, user_id: str, role: str, actor=None) -> Dict[str, Any]:
110
+ return self.store.update_member_role(workspace_id, user_id=user_id, role=role, actor=actor)
111
+
112
+ def remove_member(self, workspace_id: str, *, user_id: str, actor=None) -> Dict[str, Any]:
113
+ return self.store.remove_member(workspace_id, user_id=user_id, actor=actor)
114
+
115
+ def set_active_workspace(self, workspace_id: str, user_id: Optional[str]) -> Dict[str, Any]:
116
+ # Membership is enforced inside the store for organization workspaces.
117
+ return self.store.set_active_workspace(workspace_id, user_id=user_id)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Lattice AI Workspace OS for local-first graph, memory, agent, workflow, and skill operations",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -3,6 +3,9 @@ const API_BASE = window.location.protocol === "file:" ? "http://localhost:4825"
3
3
  const state = {
4
4
  os: null,
5
5
  snapshots: [],
6
+ activeWorkspace: null,
7
+ registry: null,
8
+ managingWorkspace: null,
6
9
  };
7
10
 
8
11
  function $(id) {
@@ -21,6 +24,7 @@ function escapeHtml(value) {
21
24
  async function api(path, options = {}) {
22
25
  const headers = { ...(options.headers || {}) };
23
26
  if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
27
+ if (state.activeWorkspace && !headers["X-Workspace-Id"]) headers["X-Workspace-Id"] = state.activeWorkspace;
24
28
  const response = await fetch(API_BASE + path, { credentials: "include", ...options, headers });
25
29
  const text = await response.text();
26
30
  let data = {};
@@ -226,6 +230,103 @@ function renderTimeline(payload) {
226
230
  `).join("") : `<div class="timeline-item"><div class="meta-line">No timeline events.</div></div>`;
227
231
  }
228
232
 
233
+ function renderWorkspaceRegistry(registry, edition) {
234
+ state.registry = registry;
235
+ if (!state.activeWorkspace) state.activeWorkspace = registry.active_workspace || "personal";
236
+ const workspaces = registry.workspaces || [];
237
+ const select = $("workspace-select");
238
+ if (select) {
239
+ select.innerHTML = workspaces.map((ws) => `
240
+ <option value="${escapeHtml(ws.workspace_id)}" ${ws.workspace_id === state.activeWorkspace ? "selected" : ""}>
241
+ ${escapeHtml(ws.name)}${ws.type === "organization" ? " (org)" : ""}${ws.status === "archived" ? " · archived" : ""}
242
+ </option>
243
+ `).join("");
244
+ }
245
+ const active = workspaces.find((ws) => ws.workspace_id === state.activeWorkspace);
246
+ const rolePill = $("workspace-role");
247
+ if (rolePill) rolePill.textContent = active ? (active.your_role || "—") : "";
248
+ if (edition) {
249
+ const pill = $("edition-pill");
250
+ if (pill) pill.textContent = edition.edition || "community";
251
+ }
252
+ const list = $("workspace-list");
253
+ if (list) {
254
+ list.innerHTML = workspaces.length ? workspaces.map((ws) => `
255
+ <div class="list-item">
256
+ <div class="list-title">
257
+ <span>${escapeHtml(ws.name)}</span>
258
+ <span class="status-pill">${escapeHtml(ws.type)}</span>
259
+ </div>
260
+ <div class="meta-line">id: ${escapeHtml(ws.workspace_id)} · members: ${escapeHtml(ws.member_count)} · role: ${escapeHtml(ws.your_role || "—")} · ${escapeHtml(ws.status || "active")}</div>
261
+ <div class="item-actions">
262
+ ${ws.workspace_id === state.activeWorkspace ? `<span class="status-pill">active</span>` : `<button class="small-action" data-ws-action="activate" data-ws="${escapeHtml(ws.workspace_id)}"><i class="ti ti-switch-horizontal"></i>Switch</button>`}
263
+ ${ws.type === "organization" ? `<button class="small-action" data-ws-action="members" data-ws="${escapeHtml(ws.workspace_id)}"><i class="ti ti-users"></i>Members</button>` : ""}
264
+ ${ws.type === "organization" && ws.status !== "archived" ? `<button class="small-action" data-ws-action="archive" data-ws="${escapeHtml(ws.workspace_id)}"><i class="ti ti-archive"></i>Archive</button>` : ""}
265
+ </div>
266
+ </div>
267
+ `).join("") : `<div class="list-item"><div class="meta-line">No workspaces.</div></div>`;
268
+ }
269
+ renderMembers(workspaces);
270
+ }
271
+
272
+ function renderMembers(workspaces) {
273
+ const panel = $("member-panel");
274
+ if (!panel) return;
275
+ if (!state.managingWorkspace) {
276
+ panel.hidden = true;
277
+ return;
278
+ }
279
+ const ws = (workspaces || []).find((item) => item.workspace_id === state.managingWorkspace);
280
+ if (!ws || ws.type !== "organization") {
281
+ panel.hidden = true;
282
+ return;
283
+ }
284
+ panel.hidden = false;
285
+ const title = $("member-panel-title");
286
+ if (title) title.textContent = `Members — ${ws.name}`;
287
+ const members = ws.members || [];
288
+ $("member-list").innerHTML = members.length ? members.map((m) => `
289
+ <div class="list-item">
290
+ <div class="list-title"><span>${escapeHtml(m.user_id)}</span><span class="status-pill">${escapeHtml(m.role)}</span></div>
291
+ <div class="item-actions">
292
+ <select class="small-action" data-member-role="${escapeHtml(m.user_id)}" data-ws="${escapeHtml(ws.workspace_id)}">
293
+ ${["owner", "admin", "member", "viewer"].map((r) => `<option value="${r}" ${m.role === r ? "selected" : ""}>${r}</option>`).join("")}
294
+ </select>
295
+ <button class="small-action" data-member-remove="${escapeHtml(m.user_id)}" data-ws="${escapeHtml(ws.workspace_id)}"><i class="ti ti-user-minus"></i>Remove</button>
296
+ </div>
297
+ </div>
298
+ `).join("") : `<div class="list-item"><div class="meta-line">No members yet.</div></div>`;
299
+ }
300
+
301
+ async function activateWorkspace(workspaceId) {
302
+ await api("/workspace/activate", { method: "POST", body: JSON.stringify({ workspace_id: workspaceId }) });
303
+ state.activeWorkspace = workspaceId;
304
+ toast(`Switched to ${workspaceId}`);
305
+ await refreshAll();
306
+ }
307
+
308
+ async function createOrg() {
309
+ const name = ($("org-name").value || "").trim();
310
+ if (!name) return;
311
+ const result = await api("/workspace/orgs", { method: "POST", body: JSON.stringify({ name, settings: {} }) });
312
+ $("org-name").value = "";
313
+ toast(`Created ${result.workspace.workspace_id}`);
314
+ state.managingWorkspace = result.workspace.workspace_id;
315
+ await refreshAll();
316
+ }
317
+
318
+ async function addMember(workspaceId) {
319
+ const userId = ($("member-user").value || "").trim();
320
+ if (!userId) return;
321
+ await api(`/workspace/orgs/${encodeURIComponent(workspaceId)}/members`, {
322
+ method: "POST",
323
+ body: JSON.stringify({ user_id: userId, role: $("member-role").value }),
324
+ });
325
+ $("member-user").value = "";
326
+ toast(`Added ${userId}`);
327
+ await refreshAll();
328
+ }
329
+
229
330
  async function refreshAll() {
230
331
  const [os, onboarding, traces, indexing, snapshots, memories, computerMemory, agents, workflows, skills, timeline] = await Promise.all([
231
332
  api("/workspace/os"),
@@ -242,6 +343,7 @@ async function refreshAll() {
242
343
  ]);
243
344
  state.os = os;
244
345
  renderMetrics(os);
346
+ if (os.workspace_registry) renderWorkspaceRegistry(os.workspace_registry, os.edition);
245
347
  renderOnboarding(onboarding);
246
348
  renderTraces(traces);
247
349
  renderIndexing(indexing);
@@ -357,6 +459,47 @@ document.addEventListener("click", async (event) => {
357
459
  });
358
460
  toast(`Skill ${skillBtn.dataset.skillAction}`);
359
461
  await refreshAll();
462
+ return;
463
+ }
464
+
465
+ const wsBtn = event.target.closest("[data-ws-action]");
466
+ if (wsBtn) {
467
+ const action = wsBtn.dataset.wsAction;
468
+ const ws = wsBtn.dataset.ws;
469
+ if (action === "activate") {
470
+ await activateWorkspace(ws);
471
+ } else if (action === "members") {
472
+ state.managingWorkspace = ws;
473
+ if (state.registry) renderMembers(state.registry.workspaces);
474
+ } else if (action === "archive") {
475
+ await api(`/workspace/orgs/${encodeURIComponent(ws)}/archive`, { method: "POST" });
476
+ toast(`Archived ${ws}`);
477
+ await refreshAll();
478
+ }
479
+ return;
480
+ }
481
+
482
+ const removeBtn = event.target.closest("[data-member-remove]");
483
+ if (removeBtn) {
484
+ await api(`/workspace/orgs/${encodeURIComponent(removeBtn.dataset.ws)}/members/${encodeURIComponent(removeBtn.dataset.memberRemove)}`, { method: "DELETE" });
485
+ toast("Member removed");
486
+ await refreshAll();
487
+ }
488
+ });
489
+
490
+ document.addEventListener("change", async (event) => {
491
+ const roleSelect = event.target.closest("[data-member-role]");
492
+ if (roleSelect) {
493
+ try {
494
+ await api(`/workspace/orgs/${encodeURIComponent(roleSelect.dataset.ws)}/members/${encodeURIComponent(roleSelect.dataset.memberRole)}`, {
495
+ method: "PATCH",
496
+ body: JSON.stringify({ role: roleSelect.value }),
497
+ });
498
+ toast("Role updated");
499
+ await refreshAll();
500
+ } catch (err) {
501
+ toast(err.message);
502
+ }
360
503
  }
361
504
  });
362
505
 
@@ -378,5 +521,11 @@ document.addEventListener("DOMContentLoaded", () => {
378
521
  }));
379
522
  $("create-demo-workflow").addEventListener("click", () => createDemoWorkflow().catch((err) => toast(err.message)));
380
523
  $("reload-skills").addEventListener("click", () => refreshAll().catch((err) => toast(err.message)));
524
+ $("workspace-select").addEventListener("change", (event) => activateWorkspace(event.target.value).catch((err) => toast(err.message)));
525
+ $("create-org").addEventListener("click", () => createOrg().catch((err) => toast(err.message)));
526
+ $("new-org-btn").addEventListener("click", () => $("org-name").focus());
527
+ $("add-member").addEventListener("click", () => {
528
+ if (state.managingWorkspace) addMember(state.managingWorkspace).catch((err) => toast(err.message));
529
+ });
381
530
  refreshAll().catch((err) => toast(err.message));
382
531
  });
package/static/sw.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Lattice AI Service Worker — enables PWA install on Android/iOS
2
2
  // Strategy: network-first for API, cache-first for static assets.
3
- const CACHE = "ltcai-v100";
3
+ const CACHE = "ltcai-v110";
4
4
  const STATIC = [
5
5
  "/",
6
6
  "/workspace",
@@ -513,3 +513,34 @@ textarea {
513
513
  grid-template-columns: 1fr;
514
514
  }
515
515
  }
516
+
517
+ /* Workspace switcher + organization management (v1.1.0) */
518
+ .workspace-switcher {
519
+ display: inline-flex;
520
+ align-items: center;
521
+ gap: 8px;
522
+ padding: 6px 10px;
523
+ border-radius: 10px;
524
+ background: rgba(255, 255, 255, 0.06);
525
+ border: 1px solid rgba(255, 255, 255, 0.12);
526
+ }
527
+ .workspace-switcher select {
528
+ background: transparent;
529
+ border: none;
530
+ color: inherit;
531
+ font: inherit;
532
+ font-weight: 600;
533
+ max-width: 220px;
534
+ }
535
+ .workspace-role-pill {
536
+ font-size: 11px;
537
+ text-transform: uppercase;
538
+ letter-spacing: 0.04em;
539
+ opacity: 0.75;
540
+ }
541
+ #member-panel {
542
+ margin-top: 16px;
543
+ }
544
+ #org-create-form {
545
+ margin-bottom: 12px;
546
+ }
@@ -8,7 +8,7 @@
8
8
  <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
9
9
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap">
10
10
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
11
- <link rel="stylesheet" href="/static/workspace.css?v=1.0.0">
11
+ <link rel="stylesheet" href="/static/workspace.css?v=1.1.0">
12
12
  </head>
13
13
  <body>
14
14
  <div class="workspace-shell">
@@ -41,6 +41,12 @@
41
41
  <h1>Workspace Command Center</h1>
42
42
  </div>
43
43
  <div class="top-actions">
44
+ <div class="workspace-switcher" title="Active workspace">
45
+ <i class="ti ti-building-community"></i>
46
+ <select id="workspace-select" aria-label="Active workspace"></select>
47
+ <span class="workspace-role-pill" id="workspace-role"></span>
48
+ </div>
49
+ <button class="icon-action" id="new-org-btn" title="New organization workspace"><i class="ti ti-plus"></i></button>
44
50
  <button class="icon-action" id="refresh-btn" title="Refresh"><i class="ti ti-refresh"></i></button>
45
51
  <button class="primary-action" id="snapshot-now"><i class="ti ti-device-floppy"></i><span>Snapshot</span></button>
46
52
  </div>
@@ -48,6 +54,39 @@
48
54
 
49
55
  <section class="metric-grid" id="metric-grid"></section>
50
56
 
57
+ <section class="workspace-band" id="organization">
58
+ <div class="section-head">
59
+ <div>
60
+ <div class="eyebrow">Workspaces</div>
61
+ <h2>Personal &amp; Organization</h2>
62
+ </div>
63
+ <span class="status-pill" id="edition-pill">community</span>
64
+ </div>
65
+ <div class="inline-form" id="org-create-form">
66
+ <input id="org-name" placeholder="New organization workspace name">
67
+ <button class="secondary-action" id="create-org"><i class="ti ti-building-plus"></i><span>Create Org</span></button>
68
+ </div>
69
+ <div id="workspace-list" class="list-stack"></div>
70
+ <div class="workspace-panel" id="member-panel" hidden>
71
+ <div class="section-head">
72
+ <div>
73
+ <div class="eyebrow">Members</div>
74
+ <h2 id="member-panel-title">Manage Members</h2>
75
+ </div>
76
+ </div>
77
+ <div class="inline-form split">
78
+ <input id="member-user" placeholder="user@example.com">
79
+ <select id="member-role">
80
+ <option value="admin">admin</option>
81
+ <option value="member" selected>member</option>
82
+ <option value="viewer">viewer</option>
83
+ </select>
84
+ <button class="secondary-action" id="add-member"><i class="ti ti-user-plus"></i><span>Add</span></button>
85
+ </div>
86
+ <div id="member-list" class="list-stack"></div>
87
+ </div>
88
+ </section>
89
+
51
90
  <section class="workspace-band onboarding-band" id="onboarding">
52
91
  <div class="section-head">
53
92
  <div>
@@ -194,6 +233,6 @@
194
233
  </div>
195
234
 
196
235
  <div class="toast" id="toast"></div>
197
- <script src="/static/scripts/workspace.js?v=1.0.0"></script>
236
+ <script src="/static/scripts/workspace.js?v=1.1.0"></script>
198
237
  </body>
199
238
  </html>