feat(canvas): functional org switcher in the concierge topbar #2497
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
// <slug>.<apex>. 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<Array<{ slug: string; name?: string; id?: string }> | 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<string, typeof nodes>();
|
||||
@@ -330,10 +377,51 @@ export function ConciergeShell() {
|
||||
<div className={s.main}>
|
||||
{/* TOPBAR */}
|
||||
<header className={s.topbar}>
|
||||
<div className={s.org}>
|
||||
<div
|
||||
className={s.org}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={orgMenuOpen}
|
||||
data-testid="topbar-org-switcher"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleOrgMenu();
|
||||
}}
|
||||
>
|
||||
<div className={s.orgBadge}>{initials(orgName).slice(0, 1)}</div>
|
||||
<span data-testid="topbar-org-name" className={s.orgName}>{orgName}</span>
|
||||
<span className={s.chev}><IcChevDown /></span>
|
||||
{orgMenuOpen && (
|
||||
<div
|
||||
className={s.orgMenu}
|
||||
role="menu"
|
||||
data-testid="topbar-org-menu"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{orgs === null ? (
|
||||
<div className={s.orgMenuEmpty}>Loading…</div>
|
||||
) : orgs.length === 0 ? (
|
||||
<div className={s.orgMenuEmpty}>No other organizations</div>
|
||||
) : (
|
||||
orgs.map((o) => (
|
||||
<button
|
||||
key={o.id || o.slug}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${s.orgMenuItem} ${o.slug === orgSlug ? s.orgMenuCurrent : ""}`}
|
||||
onClick={() => switchOrg(o.slug)}
|
||||
>
|
||||
<span className={s.orgMenuBadge}>{initials(o.name || o.slug).slice(0, 1)}</span>
|
||||
<span className={s.orgMenuName}>{o.name || o.slug}</span>
|
||||
{o.slug === orgSlug && (
|
||||
<span className={s.orgMenuTick}><IcCheck /></span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={s.topbarRight}>
|
||||
<button className={s.iconPill} title="Search"><IcSearch /></button>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// Org switching across tenant subdomains.
|
||||
//
|
||||
// Each org is its own tenant at <slug>.<apex> (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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user