ltcai 1.0.0 → 1.1.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.
@@ -96,6 +96,11 @@ from latticeai.core.workspace_os import (
96
96
  WorkspaceOSStore,
97
97
  remove_skill_directory,
98
98
  )
99
+ from latticeai.core.enterprise import (
100
+ EnterpriseCapability,
101
+ capability_registry,
102
+ detect_edition,
103
+ )
99
104
  from latticeai.core.agent import (
100
105
  AgentState,
101
106
  AgentRunContext,
@@ -1507,6 +1512,29 @@ class WorkspaceVSCodeRequest(BaseModel):
1507
1512
  prompt: str = ""
1508
1513
 
1509
1514
 
1515
+ class WorkspaceCreateRequest(BaseModel):
1516
+ name: str
1517
+ settings: Dict = {}
1518
+
1519
+
1520
+ class WorkspaceUpdateRequest(BaseModel):
1521
+ name: Optional[str] = None
1522
+ settings: Optional[Dict] = None
1523
+
1524
+
1525
+ class WorkspaceMemberRequest(BaseModel):
1526
+ user_id: str
1527
+ role: str = "member"
1528
+
1529
+
1530
+ class WorkspaceMemberRoleRequest(BaseModel):
1531
+ role: str
1532
+
1533
+
1534
+ class WorkspaceActivateRequest(BaseModel):
1535
+ workspace_id: str
1536
+
1537
+
1510
1538
  class GardenRequest(BaseModel):
1511
1539
  raw_data: str
1512
1540
  category: Optional[str] = None # 10_Wiki / 00_Raw / Skills
@@ -1708,12 +1736,27 @@ def _workspace_graph():
1708
1736
  return KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None
1709
1737
 
1710
1738
 
1739
+ def _workspace_scope(request: Request) -> Optional[str]:
1740
+ """Resolve the active workspace for scoping from an optional header/query.
1741
+
1742
+ Returns ``None`` when unset so the store falls back to the active workspace
1743
+ (Personal by default), preserving pre-1.1 behaviour for legacy clients.
1744
+ """
1745
+ header = request.headers.get("X-Workspace-Id")
1746
+ if header and header.strip():
1747
+ return header.strip()
1748
+ query = request.query_params.get("workspace_id")
1749
+ return query.strip() if query and query.strip() else None
1750
+
1751
+
1711
1752
  @app.get("/workspace/os")
1712
1753
  async def workspace_os_summary(request: Request):
1713
- require_user(request)
1754
+ user = require_user(request)
1714
1755
  summary = WORKSPACE_OS.summary()
1715
1756
  summary["graph"] = _graph_stats_safe()
1716
1757
  summary["models"] = _workspace_models_payload()
1758
+ summary["workspace_registry"] = WORKSPACE_OS.list_workspaces(user_id=user or None)
1759
+ summary["edition"] = capability_registry.describe()
1717
1760
  return summary
1718
1761
 
1719
1762
 
@@ -1770,7 +1813,7 @@ async def workspace_onboarding_model_recommendations(request: Request):
1770
1813
  @app.get("/workspace/traces")
1771
1814
  async def workspace_traces(request: Request, conversation_id: Optional[str] = None, limit: int = 50):
1772
1815
  require_user(request)
1773
- return WORKSPACE_OS.list_traces(conversation_id=conversation_id, limit=limit)
1816
+ return WORKSPACE_OS.list_traces(conversation_id=conversation_id, limit=limit, workspace_id=_workspace_scope(request))
1774
1817
 
1775
1818
 
1776
1819
  @app.get("/workspace/indexing")
@@ -1805,7 +1848,7 @@ async def workspace_indexing_remove(source_id: str, request: Request):
1805
1848
  @app.get("/workspace/snapshots")
1806
1849
  async def workspace_snapshots(request: Request):
1807
1850
  require_user(request)
1808
- return WORKSPACE_OS.list_snapshots()
1851
+ return WORKSPACE_OS.list_snapshots(workspace_id=_workspace_scope(request))
1809
1852
 
1810
1853
 
1811
1854
  @app.post("/workspace/snapshots")
@@ -1817,6 +1860,7 @@ async def workspace_snapshot_create(req: WorkspaceSnapshotRequest, request: Requ
1817
1860
  history=get_history(),
1818
1861
  settings=_workspace_settings_payload(),
1819
1862
  models=_workspace_models_payload(),
1863
+ workspace_id=_workspace_scope(request),
1820
1864
  )
1821
1865
  append_audit_event("workspace_snapshot", user_email=current_user, snapshot_id=result["snapshot"]["id"])
1822
1866
  return result
@@ -1863,7 +1907,7 @@ async def workspace_snapshot_export(snapshot_id: str, request: Request):
1863
1907
  @app.get("/workspace/time-machine")
1864
1908
  async def workspace_time_machine(request: Request, limit: int = 100):
1865
1909
  require_user(request)
1866
- return WORKSPACE_OS.timeline(get_audit_log(), limit=limit)
1910
+ return WORKSPACE_OS.timeline(get_audit_log(), limit=limit, workspace_id=_workspace_scope(request))
1867
1911
 
1868
1912
 
1869
1913
  @app.get("/workspace/time-machine/{snapshot_id}/{area}")
@@ -1878,13 +1922,13 @@ async def workspace_time_machine_view(snapshot_id: str, area: str, request: Requ
1878
1922
  @app.get("/workspace/memories")
1879
1923
  async def workspace_memories(request: Request, kind: Optional[str] = None):
1880
1924
  current_user = require_user(request)
1881
- return WORKSPACE_OS.list_memories(user_email=current_user or None, kind=kind)
1925
+ return WORKSPACE_OS.list_memories(user_email=current_user or None, kind=kind, workspace_id=_workspace_scope(request))
1882
1926
 
1883
1927
 
1884
1928
  @app.get("/workspace/memories/search")
1885
1929
  async def workspace_memory_search(q: str, request: Request, limit: int = 20):
1886
1930
  current_user = require_user(request)
1887
- return WORKSPACE_OS.search_memories(q, user_email=current_user or None, limit=limit)
1931
+ return WORKSPACE_OS.search_memories(q, user_email=current_user or None, limit=limit, workspace_id=_workspace_scope(request))
1888
1932
 
1889
1933
 
1890
1934
  @app.post("/workspace/memories")
@@ -1899,6 +1943,7 @@ async def workspace_memory_upsert(req: WorkspaceMemoryRequest, request: Request)
1899
1943
  metadata=req.metadata,
1900
1944
  user_email=current_user or None,
1901
1945
  graph=_workspace_graph(),
1946
+ workspace_id=_workspace_scope(request),
1902
1947
  )
1903
1948
  except ValueError as exc:
1904
1949
  raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -1917,7 +1962,7 @@ async def workspace_memory_delete(memory_id: str, request: Request):
1917
1962
  @app.get("/workspace/agents")
1918
1963
  async def workspace_agents(request: Request):
1919
1964
  require_user(request)
1920
- return WORKSPACE_OS.list_agents()
1965
+ return WORKSPACE_OS.list_agents(workspace_id=_workspace_scope(request))
1921
1966
 
1922
1967
 
1923
1968
  @app.post("/workspace/agents/runs")
@@ -1932,6 +1977,7 @@ async def workspace_agent_run(req: WorkspaceAgentRunRequest, request: Request):
1932
1977
  relationships=req.relationships,
1933
1978
  user_email=current_user or None,
1934
1979
  graph=_workspace_graph(),
1980
+ workspace_id=_workspace_scope(request),
1935
1981
  )
1936
1982
  return {"run": run}
1937
1983
 
@@ -1974,7 +2020,7 @@ async def workspace_computer_memory_activity(req: WorkspaceComputerActivityReque
1974
2020
  @app.get("/workspace/workflows")
1975
2021
  async def workspace_workflows(request: Request, q: str = ""):
1976
2022
  require_user(request)
1977
- return WORKSPACE_OS.list_workflows(query=q)
2023
+ return WORKSPACE_OS.list_workflows(query=q, workspace_id=_workspace_scope(request))
1978
2024
 
1979
2025
 
1980
2026
  @app.post("/workspace/workflows")
@@ -1986,6 +2032,7 @@ async def workspace_workflow_create(req: WorkspaceWorkflowRequest, request: Requ
1986
2032
  metadata=req.metadata,
1987
2033
  user_email=current_user or None,
1988
2034
  graph=_workspace_graph(),
2035
+ workspace_id=_workspace_scope(request),
1989
2036
  )
1990
2037
  return {"workflow": workflow}
1991
2038
 
@@ -2114,6 +2161,139 @@ async def workspace_vscode_send(req: WorkspaceVSCodeRequest, request: Request):
2114
2161
  return {"status": "ok", "workflow": workflow}
2115
2162
 
2116
2163
 
2164
+ # ── Organization Workspaces, membership, roles, and edition seam ─────────────────
2165
+
2166
+ @app.get("/workspace/registry")
2167
+ async def workspace_registry(request: Request):
2168
+ user = require_user(request)
2169
+ return WORKSPACE_OS.list_workspaces(user_id=user or None)
2170
+
2171
+
2172
+ @app.get("/workspace/editions")
2173
+ async def workspace_editions(request: Request):
2174
+ require_user(request)
2175
+ return capability_registry.describe()
2176
+
2177
+
2178
+ @app.post("/workspace/activate")
2179
+ async def workspace_activate(req: WorkspaceActivateRequest, request: Request):
2180
+ user = require_user(request)
2181
+ try:
2182
+ return WORKSPACE_OS.set_active_workspace(req.workspace_id, user_id=user or None)
2183
+ except FileNotFoundError as exc:
2184
+ raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
2185
+ except PermissionError as exc:
2186
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
2187
+
2188
+
2189
+ @app.post("/workspace/orgs")
2190
+ async def workspace_org_create(req: WorkspaceCreateRequest, request: Request):
2191
+ user = require_user(request)
2192
+ try:
2193
+ workspace = WORKSPACE_OS.create_organization_workspace(
2194
+ name=req.name,
2195
+ owner_user_id=user or None,
2196
+ settings=req.settings,
2197
+ )
2198
+ except ValueError as exc:
2199
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
2200
+ append_audit_event("workspace_created", user_email=user, workspace_id=workspace["workspace_id"])
2201
+ return {"workspace": workspace}
2202
+
2203
+
2204
+ @app.get("/workspace/orgs/{workspace_id}")
2205
+ async def workspace_org_get(workspace_id: str, request: Request):
2206
+ user = require_user(request)
2207
+ try:
2208
+ return {"workspace": WORKSPACE_OS.get_workspace(workspace_id, user_id=user or None)}
2209
+ except FileNotFoundError as exc:
2210
+ raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
2211
+
2212
+
2213
+ @app.get("/workspace/orgs/{workspace_id}/summary")
2214
+ async def workspace_org_summary(workspace_id: str, request: Request):
2215
+ user = require_user(request)
2216
+ try:
2217
+ return WORKSPACE_OS.workspace_summary(workspace_id, user_id=user or None)
2218
+ except FileNotFoundError as exc:
2219
+ raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
2220
+
2221
+
2222
+ @app.patch("/workspace/orgs/{workspace_id}")
2223
+ async def workspace_org_update(workspace_id: str, req: WorkspaceUpdateRequest, request: Request):
2224
+ user = require_user(request)
2225
+ try:
2226
+ workspace = WORKSPACE_OS.update_workspace(workspace_id, name=req.name, settings=req.settings, actor=user or None)
2227
+ except FileNotFoundError as exc:
2228
+ raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
2229
+ except PermissionError as exc:
2230
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
2231
+ except ValueError as exc:
2232
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
2233
+ append_audit_event("workspace_updated", user_email=user, workspace_id=workspace_id)
2234
+ return {"workspace": workspace}
2235
+
2236
+
2237
+ @app.post("/workspace/orgs/{workspace_id}/archive")
2238
+ async def workspace_org_archive(workspace_id: str, request: Request):
2239
+ user = require_user(request)
2240
+ try:
2241
+ workspace = WORKSPACE_OS.archive_workspace(workspace_id, actor=user or None)
2242
+ except FileNotFoundError as exc:
2243
+ raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
2244
+ except PermissionError as exc:
2245
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
2246
+ except ValueError as exc:
2247
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
2248
+ append_audit_event("workspace_archived", user_email=user, workspace_id=workspace_id)
2249
+ return {"workspace": workspace}
2250
+
2251
+
2252
+ @app.post("/workspace/orgs/{workspace_id}/members")
2253
+ async def workspace_org_add_member(workspace_id: str, req: WorkspaceMemberRequest, request: Request):
2254
+ user = require_user(request)
2255
+ try:
2256
+ workspace = WORKSPACE_OS.add_member(workspace_id, user_id=req.user_id, role=req.role, actor=user or None)
2257
+ except FileNotFoundError as exc:
2258
+ raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
2259
+ except PermissionError as exc:
2260
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
2261
+ except ValueError as exc:
2262
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
2263
+ append_audit_event("workspace_member_added", user_email=user, workspace_id=workspace_id, member=req.user_id, role=req.role)
2264
+ return {"workspace": workspace}
2265
+
2266
+
2267
+ @app.patch("/workspace/orgs/{workspace_id}/members/{user_id}")
2268
+ async def workspace_org_update_member(workspace_id: str, user_id: str, req: WorkspaceMemberRoleRequest, request: Request):
2269
+ user = require_user(request)
2270
+ try:
2271
+ workspace = WORKSPACE_OS.update_member_role(workspace_id, user_id=user_id, role=req.role, actor=user or None)
2272
+ except FileNotFoundError as exc:
2273
+ raise HTTPException(status_code=404, detail=f"Not found: {exc}") from exc
2274
+ except PermissionError as exc:
2275
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
2276
+ except ValueError as exc:
2277
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
2278
+ append_audit_event("workspace_member_role_updated", user_email=user, workspace_id=workspace_id, member=user_id, role=req.role)
2279
+ return {"workspace": workspace}
2280
+
2281
+
2282
+ @app.delete("/workspace/orgs/{workspace_id}/members/{user_id}")
2283
+ async def workspace_org_remove_member(workspace_id: str, user_id: str, request: Request):
2284
+ user = require_user(request)
2285
+ try:
2286
+ workspace = WORKSPACE_OS.remove_member(workspace_id, user_id=user_id, actor=user or None)
2287
+ except FileNotFoundError as exc:
2288
+ raise HTTPException(status_code=404, detail=f"Not found: {exc}") from exc
2289
+ except PermissionError as exc:
2290
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
2291
+ except ValueError as exc:
2292
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
2293
+ append_audit_event("workspace_member_removed", user_email=user, workspace_id=workspace_id, member=user_id)
2294
+ return {"workspace": workspace}
2295
+
2296
+
2117
2297
  # ── Health & Info ──────────────────────────────────────────────────────────────
2118
2298
 
2119
2299
  ENGINE_INSTALLERS = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "1.0.0",
3
+ "version": "1.1.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>