diff --git a/canvas/src/components/concierge/Concierge.module.css b/canvas/src/components/concierge/Concierge.module.css index 59c377541..a10bba94e 100644 --- a/canvas/src/components/concierge/Concierge.module.css +++ b/canvas/src/components/concierge/Concierge.module.css @@ -82,8 +82,19 @@ /* ===== MAIN ===== */ .main { flex: 1; display: flex; flex-direction: column; min-width: 0; } .topbar { height: 56px; flex: 0 0 56px; border-bottom: 1px solid var(--hair); background: var(--panel); display: flex; align-items: center; justify-content: space-between; padding: 0 18px 0 20px; } -.org { display: flex; align-items: center; gap: 10px; cursor: pointer; padding: 6px 10px; border-radius: 9px; transition: .16s; margin-left: -6px; } +.org { position: relative; display: flex; align-items: center; gap: 10px; cursor: pointer; padding: 6px 10px; border-radius: 9px; transition: .16s; margin-left: -6px; } .org:hover { background: var(--hair); } +/* Org switcher dropdown */ +.orgMenu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 220px; max-height: 320px; overflow-y: auto; padding: 5px; background: var(--bg-1, #1a1a22); border: 1px solid var(--hair-2); border-radius: 11px; box-shadow: 0 12px 32px rgba(0,0,0,.4); z-index: 50; } +.orgMenuItem { width: 100%; display: flex; align-items: center; gap: 9px; padding: 7px 9px; border: none; background: transparent; border-radius: 8px; cursor: pointer; color: var(--tx-1); font-size: 13.5px; font-weight: 500; text-align: left; transition: .12s; } +.orgMenuItem:hover { background: var(--hair); } +.orgMenuCurrent { font-weight: 700; } +.orgMenuBadge { width: 20px; height: 20px; border-radius: 6px; display: grid; place-items: center; background: linear-gradient(150deg,#2d2d36,#3a3a46); font-size: 11px; font-weight: 700; color: #d8d8e2; border: 1px solid var(--hair-2); flex: 0 0 auto; } +:global([data-theme="light"]) .orgMenuBadge { background: linear-gradient(150deg,#7c3aed,#a78bfa); color: #fff; border: none; } +.orgMenuName { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.orgMenuTick { color: var(--accent, #a78bfa); display: flex; flex: 0 0 auto; } +.orgMenuTick svg { width: 14px; height: 14px; } +.orgMenuEmpty { padding: 9px 11px; color: var(--tx-3); font-size: 13px; } .orgBadge { width: 24px; height: 24px; border-radius: 7px; display: grid; place-items: center; background: linear-gradient(150deg,#2d2d36,#3a3a46); font-size: 12px; font-weight: 700; color: #d8d8e2; border: 1px solid var(--hair-2); } :global([data-theme="light"]) .orgBadge { background: linear-gradient(150deg,#7c3aed,#a78bfa); color: #fff; border: none; } .orgName { font-weight: 600; font-size: 14.5px; letter-spacing: -.01em; } diff --git a/canvas/src/components/concierge/ConciergeShell.tsx b/canvas/src/components/concierge/ConciergeShell.tsx index 5cefd8dc8..cc886e329 100644 --- a/canvas/src/components/concierge/ConciergeShell.tsx +++ b/canvas/src/components/concierge/ConciergeShell.tsx @@ -4,7 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useCanvasStore, type TopView } from "@/store/canvas"; import { WORKSPACE_KIND } from "@/lib/workspace-kind"; import { useTheme } from "@/lib/theme-provider"; -import { api } from "@/lib/api"; +import { api, PLATFORM_URL } from "@/lib/api"; +import { switchOrgUrl } from "@/lib/org-switch"; import { showToast } from "@/components/Toaster"; import type { ActivityEntry } from "@/types/activity"; import { Canvas } from "@/components/Canvas"; @@ -108,13 +109,18 @@ export function ConciergeShell() { // returns an empty name, so the topbar never breaks before the backend // lands. const [orgName, setOrgName] = useState("Molecule AI"); + // Current org slug (from GET /org/identity) — used to highlight the active + // org in the switcher and to derive the apex domain for cross-org navigation. + const [orgSlug, setOrgSlug] = useState(""); useEffect(() => { let cancelled = false; api - .get<{ name?: string }>("/org/identity") + .get<{ name?: string; slug?: string }>("/org/identity") .then((r) => { const name = (r?.name || "").trim(); if (!cancelled && name) setOrgName(name); + const slug = (r?.slug || "").trim(); + if (!cancelled && slug) setOrgSlug(slug); }) .catch(() => { // No endpoint / not reachable — keep the "Molecule AI" fallback. @@ -124,6 +130,47 @@ export function ConciergeShell() { }; }, []); + // --- Org switcher (topbar dropdown) --- + // Each org is its own tenant subdomain, so "switch" = navigate to + // .. The org list comes from the control plane (cross-origin, + // cookie-auth), fetched lazily the first time the menu opens. + const [orgMenuOpen, setOrgMenuOpen] = useState(false); + const [orgs, setOrgs] = useState | null>(null); + const toggleOrgMenu = useCallback(() => { + setOrgMenuOpen((open) => { + const next = !open; + if (next && orgs === null) { + fetch(`${PLATFORM_URL}/cp/orgs`, { + credentials: "include", + signal: AbortSignal.timeout(15_000), + }) + .then((res) => (res.ok ? res.json() : Promise.reject(new Error(String(res.status))))) + .then((body: { orgs?: Array<{ slug: string; name?: string; id?: string }> } | Array<{ slug: string; name?: string; id?: string }>) => { + const list = Array.isArray(body) ? body : body.orgs ?? []; + setOrgs(list.filter((o) => o && o.slug)); + }) + .catch(() => setOrgs([])); // no list / not reachable → render "no other orgs" + } + return next; + }); + }, [orgs]); + const switchOrg = useCallback( + (slug: string) => { + setOrgMenuOpen(false); + if (typeof window === "undefined") return; + const url = switchOrgUrl(window.location.hostname, window.location.protocol, orgSlug, slug); + if (url) window.location.href = url; + }, + [orgSlug] + ); + // Close the menu on any outside click. + useEffect(() => { + if (!orgMenuOpen) return; + const onDoc = () => setOrgMenuOpen(false); + document.addEventListener("click", onDoc); + return () => document.removeEventListener("click", onDoc); + }, [orgMenuOpen]); + // Build the agent hierarchy from live nodes. const { roots, childrenOf } = useMemo(() => { const childrenOf = new Map(); @@ -330,10 +377,51 @@ export function ConciergeShell() {
{/* TOPBAR */}
-
+
{ + e.stopPropagation(); + toggleOrgMenu(); + }} + >
{initials(orgName).slice(0, 1)}
{orgName} + {orgMenuOpen && ( +
e.stopPropagation()} + > + {orgs === null ? ( +
Loading…
+ ) : orgs.length === 0 ? ( +
No other organizations
+ ) : ( + orgs.map((o) => ( + + )) + )} +
+ )}
diff --git a/canvas/src/lib/__tests__/org-switch.test.ts b/canvas/src/lib/__tests__/org-switch.test.ts new file mode 100644 index 000000000..ec96133b4 --- /dev/null +++ b/canvas/src/lib/__tests__/org-switch.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { switchOrgUrl } from "../org-switch"; + +describe("switchOrgUrl", () => { + it("builds the target org's subdomain URL from the current host", () => { + expect( + switchOrgUrl("agents-team.moleculesai.app", "https:", "agents-team", "reno-stars"), + ).toBe("https://reno-stars.moleculesai.app"); + }); + + it("returns null for a no-op (switching to the current org)", () => { + expect( + switchOrgUrl("agents-team.moleculesai.app", "https:", "agents-team", "agents-team"), + ).toBeNull(); + }); + + it("returns null when the target slug is empty", () => { + expect(switchOrgUrl("a.example.com", "https:", "a", "")).toBeNull(); + }); + + it("falls back to dropping the first label when currentSlug doesn't prefix the host", () => { + expect(switchOrgUrl("foo.example.com", "https:", "", "bar")).toBe( + "https://bar.example.com", + ); + }); + + it("returns null when there is no apex to derive (single-label host)", () => { + expect(switchOrgUrl("localhost", "http:", "", "bar")).toBeNull(); + }); +}); diff --git a/canvas/src/lib/org-switch.ts b/canvas/src/lib/org-switch.ts new file mode 100644 index 000000000..bcce51fa2 --- /dev/null +++ b/canvas/src/lib/org-switch.ts @@ -0,0 +1,23 @@ +// Org switching across tenant subdomains. +// +// Each org is its own tenant at . (e.g. agents-team.moleculesai.app), +// so switching orgs from the canvas topbar means navigating to the target org's +// subdomain. switchOrgUrl derives that URL from the current location, or returns +// null when it's a no-op (same org / empty target) or the apex can't be resolved. + +export function switchOrgUrl( + hostname: string, + protocol: string, + currentSlug: string, + targetSlug: string, +): string | null { + if (!targetSlug || targetSlug === currentSlug) return null; + // Prefer stripping the known current-org label; otherwise drop the first + // label as a best-effort apex (covers hosts we didn't seed a slug for). + const apex = + currentSlug && hostname.startsWith(`${currentSlug}.`) + ? hostname.slice(currentSlug.length + 1) + : hostname.split(".").slice(1).join("."); + if (!apex) return null; + return `${protocol}//${targetSlug}.${apex}`; +}