From 6c91dab6b9c2194f086f12c4a30542cd212cc46a Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 11 Jul 2026 14:35:09 -0300 Subject: [PATCH 01/19] feat(shell): org selector moves up into a toolbar breadcrumb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `deco / / ` breadcrumb in the org-shell toolbar: - **deco** — logo, links to `/` (the cross-org MY-deco home, next commit). - **org** — opens the org switcher popover (reuses the org list UI, now extracted from account-popover into a shared `header/org-switcher`). - **agent**— the active agent, resolved from `?virtualmcpid`, inside a thread. The sidebar-footer AccountPopover keeps account/settings/logout; org switching now lives "up" in the breadcrumb. Replaces the bare Toolbar.LogoLink in the org shell (LogoLink still used by settings + sidebar logo header). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/web/components/account-popover.tsx | 160 +------------ .../web/components/header/org-switcher.tsx | 223 ++++++++++++++++++ .../components/header/shell-breadcrumb.tsx | 114 +++++++++ .../web/layouts/org-shell-layout/index.tsx | 7 +- 4 files changed, 346 insertions(+), 158 deletions(-) create mode 100644 apps/mesh/src/web/components/header/org-switcher.tsx create mode 100644 apps/mesh/src/web/components/header/shell-breadcrumb.tsx diff --git a/apps/mesh/src/web/components/account-popover.tsx b/apps/mesh/src/web/components/account-popover.tsx index 13f496dad0..01703eb15c 100644 --- a/apps/mesh/src/web/components/account-popover.tsx +++ b/apps/mesh/src/web/components/account-popover.tsx @@ -20,7 +20,6 @@ import { import { cn } from "@deco/ui/lib/utils.ts"; import { useIsMobile } from "@deco/ui/hooks/use-mobile.ts"; import { - Check, Copy01, Download01, File06, @@ -28,70 +27,27 @@ import { LogOut01, Monitor01, Moon01, - Plus, - SearchMd, Settings02, Shield01, Sun, Users03, VolumeMax, VolumeX, - XClose, } from "@untitledui/icons"; import { GitHubIcon } from "@daveyplate/better-auth-ui"; import { SidebarMenuButton } from "@deco/ui/components/sidebar.tsx"; -import { authClient, useActiveOrganizations } from "@/web/lib/auth-client"; +import { authClient } from "@/web/lib/auth-client"; import { useProjectContext } from "@decocms/mesh-sdk"; import { track } from "@/web/lib/posthog-client"; import { clearPersistedQueryCache } from "@/web/lib/query-persist"; import { CreateOrganizationDialog } from "@/web/components/create-organization-dialog"; +import { + getOrgColorStyle, + OrganizationsPanel, +} from "@/web/components/header/org-switcher"; import { usePreferences, type ThemeMode } from "@/web/hooks/use-preferences.ts"; import { toast } from "@deco/ui/components/sonner.js"; -function getOrgColorStyle(name: string): { - backgroundColor: string; - color: string; -} { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - } - const h = Math.abs(hash) % 360; - return { - backgroundColor: `hsl(${h} 55% 70%)`, - color: `hsl(${h} 55% 20%)`, - }; -} - -function OrgIcon({ - org, - size = "sm", -}: { - org: { name: string; logo?: string | null }; - size?: "xs" | "sm"; -}) { - const sizeClass = size === "xs" ? "size-5" : "size-6"; - const textClass = size === "xs" ? "text-[9px]" : "text-xs"; - - return ( -
- {org.logo ? ( - - ) : ( - - {org.name.slice(0, 2).toUpperCase()} - - )} -
- ); -} - interface MenuItem { key: string; label: string; @@ -141,112 +97,6 @@ function MenuItemButton({ ); } -function OrganizationsPanel({ - orgParam, - onSelectOrg, - onCreateOrg, -}: { - orgParam?: string; - onSelectOrg: (slug: string) => void; - onCreateOrg: () => void; -}) { - // Fetched here, not in the parent: this panel only mounts inside the open - // popover/drawer, so the (potentially large) organization.list call is - // deferred until the switcher is actually opened — it no longer fires on - // every page load. - const { data: organizations } = useActiveOrganizations(); - const sortedOrgs = [...(organizations ?? [])].sort((a, b) => { - if (a.slug === orgParam) return -1; - if (b.slug === orgParam) return 1; - return a.name.localeCompare(b.name); - }); - - const [searchOpen, setSearchOpen] = useState(false); - const [query, setQuery] = useState(""); - - const q = query.toLowerCase(); - const filtered = q - ? sortedOrgs.filter( - (o) => - o.name.toLowerCase().includes(q) || o.slug.toLowerCase().includes(q), - ) - : sortedOrgs; - - const iconBtnClass = - "flex items-center justify-center size-7 rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"; - - function toggleSearch() { - if (searchOpen) setQuery(""); - setSearchOpen((prev) => !prev); - } - - return ( - <> -
- {searchOpen ? ( - setQuery(e.target.value)} - onKeyDown={(e) => e.key === "Escape" && toggleSearch()} - placeholder="Search organizations..." - className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60" - /> - ) : ( - - Your Organizations - - )} -
- - -
-
-
- {filtered.length === 0 && ( -

- {query - ? `No organizations match "${query}"` - : "No organizations available"} -

- )} - {filtered.map((org) => ( - - ))} -
- - ); -} - /** Shared content rendered inside popover (desktop) or drawer (mobile) */ function AccountPopoverContent({ user, diff --git a/apps/mesh/src/web/components/header/org-switcher.tsx b/apps/mesh/src/web/components/header/org-switcher.tsx new file mode 100644 index 0000000000..4b6d7c7a70 --- /dev/null +++ b/apps/mesh/src/web/components/header/org-switcher.tsx @@ -0,0 +1,223 @@ +/** + * Org switcher — shared building blocks for switching the active organization. + * + * Extracted from `account-popover.tsx` so the same list UI backs both the + * sidebar-footer account popover and the toolbar breadcrumb (see + * `shell-breadcrumb.tsx`). The org list is fetched lazily inside + * `OrganizationsPanel`, so mounting a closed switcher costs nothing. + */ +import { type ReactNode, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@deco/ui/components/popover.tsx"; +import { Check, Plus, SearchMd, XClose } from "@untitledui/icons"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { useActiveOrganizations } from "@/web/lib/auth-client"; +import { CreateOrganizationDialog } from "@/web/components/create-organization-dialog"; + +export function getOrgColorStyle(name: string): { + backgroundColor: string; + color: string; +} { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + const h = Math.abs(hash) % 360; + return { + backgroundColor: `hsl(${h} 55% 70%)`, + color: `hsl(${h} 55% 20%)`, + }; +} + +export function OrgIcon({ + org, + size = "sm", +}: { + org: { name: string; logo?: string | null }; + size?: "xs" | "sm"; +}) { + const sizeClass = size === "xs" ? "size-5" : "size-6"; + const textClass = size === "xs" ? "text-[9px]" : "text-xs"; + + return ( +
+ {org.logo ? ( + + ) : ( + + {org.name.slice(0, 2).toUpperCase()} + + )} +
+ ); +} + +export function OrganizationsPanel({ + orgParam, + onSelectOrg, + onCreateOrg, +}: { + orgParam?: string; + onSelectOrg: (slug: string) => void; + onCreateOrg: () => void; +}) { + // Fetched here, not in the parent: this panel only mounts inside the open + // popover/drawer, so the (potentially large) organization.list call is + // deferred until the switcher is actually opened — it no longer fires on + // every page load. + const { data: organizations } = useActiveOrganizations(); + const sortedOrgs = [...(organizations ?? [])].sort((a, b) => { + if (a.slug === orgParam) return -1; + if (b.slug === orgParam) return 1; + return a.name.localeCompare(b.name); + }); + + const [searchOpen, setSearchOpen] = useState(false); + const [query, setQuery] = useState(""); + + const q = query.toLowerCase(); + const filtered = q + ? sortedOrgs.filter( + (o) => + o.name.toLowerCase().includes(q) || o.slug.toLowerCase().includes(q), + ) + : sortedOrgs; + + const iconBtnClass = + "flex items-center justify-center size-7 rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"; + + function toggleSearch() { + if (searchOpen) setQuery(""); + setSearchOpen((prev) => !prev); + } + + return ( + <> +
+ {searchOpen ? ( + setQuery(e.target.value)} + onKeyDown={(e) => e.key === "Escape" && toggleSearch()} + placeholder="Search organizations..." + className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60" + /> + ) : ( + + Your Organizations + + )} +
+ + +
+
+
+ {filtered.length === 0 && ( +

+ {query + ? `No organizations match "${query}"` + : "No organizations available"} +

+ )} + {filtered.map((org) => ( + + ))} +
+ + ); +} + +/** + * Self-contained org switcher popover: renders `trigger` and, on open, the + * organization list. Selecting an org navigates to `/$org`; the "+" affordance + * opens the create-organization dialog. Used by the toolbar breadcrumb. + */ +export function OrgSwitcherPopover({ + trigger, + orgParam, + align = "start", + side = "bottom", +}: { + trigger: ReactNode; + orgParam?: string; + align?: "start" | "center" | "end"; + side?: "top" | "right" | "bottom" | "left"; +}) { + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [creatingOrg, setCreatingOrg] = useState(false); + + return ( + <> + + {trigger} + e.preventDefault()} + > + { + setOpen(false); + navigate({ to: "/$org", params: { org: slug } }); + }} + onCreateOrg={() => { + setOpen(false); + setCreatingOrg(true); + }} + /> + + + + + ); +} diff --git a/apps/mesh/src/web/components/header/shell-breadcrumb.tsx b/apps/mesh/src/web/components/header/shell-breadcrumb.tsx new file mode 100644 index 0000000000..26cae2fb50 --- /dev/null +++ b/apps/mesh/src/web/components/header/shell-breadcrumb.tsx @@ -0,0 +1,114 @@ +/** + * Shell breadcrumb — primary org navigation in the toolbar: `deco › org › agent`. + * + * - **deco** — the product logo, links to `/` (the cross-org "MY deco" home). + * - **org** — the active organization; clicking opens the org switcher popover. + * - **agent**— the current agent, shown only inside an agent/thread route. + * + * This replaces the sidebar-footer popover as the place you switch orgs (the + * footer popover now carries account/settings only). Renders inside + * `Toolbar.LeftColumn` — see `org-shell-layout`. + */ +import { Suspense } from "react"; +import { Link, useParams, useSearch } from "@tanstack/react-router"; +import { ChevronDown } from "@untitledui/icons"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbSeparator, +} from "@deco/ui/components/breadcrumb.tsx"; +import { useProjectContext, useVirtualMCP } from "@decocms/mesh-sdk"; +import { Toolbar } from "@/web/layouts/agent-shell-layout/toolbar"; +import { AgentAvatar } from "@/web/components/agent-icon"; +import { + OrgIcon, + OrgSwitcherPopover, +} from "@/web/components/header/org-switcher"; + +const crumbBtnClass = + "wco-no-drag inline-flex items-center gap-1.5 min-w-0 rounded-md px-1.5 py-1 text-sm text-foreground hover:bg-accent/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"; + +/** + * Trailing agent crumb — resolves the agent entity from `?virtualmcpid` (the + * same source the agent shell reads). Suspends on the collection fetch, so it's + * wrapped in its own boundary and never blocks the rest of the toolbar. + */ +function AgentCrumb() { + const search = useSearch({ strict: false }) as { virtualmcpid?: string }; + const entity = useVirtualMCP(search.virtualmcpid ?? null); + // No explicit agent selected (default decopilot) → no crumb; the breadcrumb + // stays `deco › org` on a bare chat. + if (!entity) return null; + const title = entity.title ?? "Agent"; + return ( + <> + + + + + {title} + + + + ); +} + +export function ShellBreadcrumb() { + const { org } = useProjectContext(); + const params = useParams({ strict: false }) as { + org?: string; + taskId?: string; + }; + const isAgentRoute = Boolean(params.taskId); + + return ( + + + {/* deco → MY deco home */} + + + + + + + + + {/* org → switcher popover */} + + + + + {org.name} + + + + } + /> + + + {/* agent → only inside a thread */} + {isAgentRoute && ( + + + + )} + + + ); +} diff --git a/apps/mesh/src/web/layouts/org-shell-layout/index.tsx b/apps/mesh/src/web/layouts/org-shell-layout/index.tsx index c6b2fbfb51..0b16904961 100644 --- a/apps/mesh/src/web/layouts/org-shell-layout/index.tsx +++ b/apps/mesh/src/web/layouts/org-shell-layout/index.tsx @@ -30,6 +30,7 @@ import { StudioSidebar, StudioSidebarMobile } from "@/web/components/sidebar"; import { ChatPrefsProvider } from "@/web/components/chat/context"; import { ThreadManagerProvider } from "@/web/components/chat/store/hooks"; import { LinkedDesktopIndicator } from "@/web/components/header/linked-desktop-indicator"; +import { ShellBreadcrumb } from "@/web/components/header/shell-breadcrumb"; import { Toolbar } from "@/web/layouts/agent-shell-layout/toolbar"; import { MobileSidebarSheet, @@ -60,9 +61,9 @@ export default function OrgShellLayout() {
{isMobile ? ( -
- +
+
@@ -74,8 +75,8 @@ export default function OrgShellLayout() { ) : ( - + From 8adfa3b7573ca01c74f86cb0e09b955b8f08c9d1 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 11 Jul 2026 22:25:36 -0300 Subject: [PATCH 02/19] feat(shell): account popover is account-only; org switching lives up top Org switching moved into the toolbar breadcrumb, so the sidebar-footer popover no longer needs the "Your Organizations" panel. Drop it: the desktop popover becomes a single column, the mobile drawer loses its org section, and both triggers now show the signed-in user (avatar + name) instead of the org. OrganizationsPanel is now internal to header/org-switcher (used only by the breadcrumb's OrgSwitcherPopover). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/web/components/account-popover.tsx | 129 ++++-------------- .../web/components/header/org-switcher.tsx | 2 +- 2 files changed, 25 insertions(+), 106 deletions(-) diff --git a/apps/mesh/src/web/components/account-popover.tsx b/apps/mesh/src/web/components/account-popover.tsx index 01703eb15c..3bd91d5c3b 100644 --- a/apps/mesh/src/web/components/account-popover.tsx +++ b/apps/mesh/src/web/components/account-popover.tsx @@ -40,11 +40,6 @@ import { authClient } from "@/web/lib/auth-client"; import { useProjectContext } from "@decocms/mesh-sdk"; import { track } from "@/web/lib/posthog-client"; import { clearPersistedQueryCache } from "@/web/lib/query-persist"; -import { CreateOrganizationDialog } from "@/web/components/create-organization-dialog"; -import { - getOrgColorStyle, - OrganizationsPanel, -} from "@/web/components/header/org-switcher"; import { usePreferences, type ThemeMode } from "@/web/hooks/use-preferences.ts"; import { toast } from "@deco/ui/components/sonner.js"; @@ -106,12 +101,8 @@ function AccountPopoverContent({ themeOptions, preferences, setPreferences, - orgParam, - onSelectOrg, - onCreateOrg, close, isMobile, - open, }: { user: { id?: string; name?: string; email?: string } | undefined; userImage?: string; @@ -120,12 +111,8 @@ function AccountPopoverContent({ themeOptions: { value: ThemeMode; icon: React.ReactNode; label: string }[]; preferences: ReturnType[0]; setPreferences: ReturnType[1]; - orgParam?: string; - onSelectOrg: (slug: string) => void; - onCreateOrg: () => void; close: () => void; isMobile: boolean; - open: boolean; }) { if (isMobile) { // Mobile: single-column scrollable layout @@ -174,16 +161,6 @@ function AccountPopoverContent({ {/* Scrollable content */}
- {/* Org switcher */} -
- -
- {/* Menu items */}