feat(canvas): functional org switcher in the concierge topbar #2497

Merged
core-devops merged 1 commits from feat/canvas-org-switcher into main 2026-06-10 04:39:55 +00:00
4 changed files with 156 additions and 4 deletions
@@ -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();
});
});
+23
View File
@@ -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}`;
}