import { Link, Outlet, useLocation, useParams } from "@tanstack/react-router"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { BookOpen, ExternalLink } from "lucide-react"; import type { Integration } from "@executor-js/sdk/shared"; import { integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { Button } from "../components/button"; import { Skeleton } from "../components/skeleton"; import { SidebarUpdateCard } from "../components/update-card"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from "../components/dropdown-menu"; import { IntegrationFavicon, integrationInferredUrl, integrationPresetIconUrl, } from "../components/integration-favicon"; import { CommandPalette } from "../components/command-palette"; import { Wordmark } from "../components/wordmark"; import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; import { useAuth } from "./auth-context"; // --------------------------------------------------------------------------- // Shared multiplayer shell (cloud + self-host). // // Provider-neutral: identity comes from the shared `useAuth()` seam. The bits // that genuinely differ per product are injected: // - `onSignOut` how the session is ended (WorkOS logout vs Better Auth) // - `orgMenuSlot` org switcher / create-org (cloud only) // - `supportSlot` support dialog button (cloud only) // - `navItems` which sections show (e.g. cloud adds Billing) // Everything visual is identical so both products look the same. // --------------------------------------------------------------------------- /** Nav targets are ORG-SCOPE-RELATIVE paths ("/", "/policies", "/admin") — the * shell prefixes the optional `{-$orgSlug}` segment when rendering, and the * router's param inheritance keeps the active org's slug in every href. */ export type ShellNavItem = { readonly to: string; readonly label: string }; /** Integrations lives at "/", plus the standard tool-management sections. Hosts * spread this and append their own (e.g. API keys, Organization, Billing). A * host only adds API keys if it actually serves them in-app (Cloudflare manages * them in Access, so it omits the link). */ export const defaultShellNavItems: ReadonlyArray = [ { to: "/", label: "Integrations" }, { to: "/secrets", label: "Providers" }, { to: "/policies", label: "Policies" }, { to: "/toolkits", label: "Toolkits" }, ]; /** Canonical public docs (Mintlify). Same-origin on cloud (executor.sh proxies * `/docs`); the correct external target for self-host / Cloudflare / local, * whose `/docs` is Swagger UI rather than the product docs. Hosts can override * via {@link ShellProps.docsUrl}. */ export const DEFAULT_DOCS_URL = "https://executor.sh/docs"; // The build version, surfaced in the sidebar footer so self-hosters can see // what they're running (and sanity-check the update card). Undefined on builds // that don't inject it (e.g. cloud) — then the line is hidden. const APP_VERSION = ( import.meta as ImportMeta & { readonly env?: { readonly VITE_APP_VERSION?: string } } ).env?.VITE_APP_VERSION; // Scope-relative path -> the route id under the optional org segment // ("/" -> "/{-$orgSlug}", "/policies" -> "/{-$orgSlug}/policies"). Loosely // typed on purpose: host-specific items ("/admin", "/billing") resolve against // the host's own route tree, which this package can't see. const orgScopedTo = (to: string): string => (to === "/" ? "/{-$orgSlug}" : `/{-$orgSlug}${to}`); const normalizePluginPagePath = (path: string): string => { if (!path || path === "/") return "/"; return path.startsWith("/") ? path : `/${path}`; }; const pluginPageNavPath = (pluginId: string, path: string): string => { const normalized = normalizePluginPagePath(path); return normalized === "/" ? `/plugins/${pluginId}/` : `/plugins/${pluginId}${normalized}`; }; /** The pathname with the active org-slug prefix stripped, for active-state * comparisons against scope-relative paths. */ function useScopeRelativePathname(): string { const pathname = useLocation({ select: (location) => location.pathname }); const params = useParams({ strict: false }) as { orgSlug?: string }; const orgSlug = params.orgSlug; if (orgSlug && (pathname === `/${orgSlug}` || pathname.startsWith(`/${orgSlug}/`))) { return pathname.slice(orgSlug.length + 1) || "/"; } return pathname; } export interface ShellProps { /** End the session. Cloud POSTs its logout path; self-host calls Better Auth. */ readonly onSignOut: () => void | Promise; /** Nav sections; defaults to {@link defaultShellNavItems}. */ readonly navItems?: ReadonlyArray; /** Injected into the account dropdown — cloud's org switcher / create-org. */ readonly orgMenuSlot?: ReactNode; /** Injected support button above the account footer (cloud). */ readonly supportSlot?: ReactNode; /** Docs link target; defaults to {@link DEFAULT_DOCS_URL}. Opens in a new tab. */ readonly docsUrl?: string; /** Replaces the routed `` (the org-slug gate's in-shell 404). */ readonly content?: ReactNode; } // ── Brand ──────────────────────────────────────────────────────────────── function Brand(props: { onNavigate?: () => void }) { return ( ); } // ── NavItem ────────────────────────────────────────────────────────────── function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) { return ( {props.label} ); } // ── DocsLink ─────────────────────────────────────────────────────────────── /** External link to the documentation. Lives in the sidebar footer (always * visible, outside the scrollable nav) so docs are reachable from inside the * app, not only the marketing site. */ function DocsLink(props: { href: string; onNavigate?: () => void }) { return ( { trackEvent("docs_opened", { surface: "sidebar" }); props.onNavigate?.(); }} className="group flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm text-sidebar-foreground transition-colors hover:bg-sidebar-active/60 hover:text-foreground" > Docs ); } // ── IntegrationList ─────────────────────────────────────────────────────────── // `pathname` is scope-relative (org-slug prefix already stripped). function IntegrationList(props: { pathname: string; onNavigate?: () => void }) { const integrations = useAtomValue(integrationsOptimisticAtom); const integrationPlugins = useIntegrationPlugins(); return AsyncResult.match(integrations, { onInitial: () => (
{[80, 65, 72, 58, 68].map((w, i) => (
))}
), onFailure: () => (
No integrations yet
), onSuccess: ({ value }) => value.length === 0 ? (
No integrations yet
) : (
{value.map((integration: Integration) => { const slug = String(integration.slug); const name = integration.name || slug; const detailPath = `/integrations/${slug}`; const active = props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); return ( {name} ); })}
), }); } // ── Avatar / initials ────────────────────────────────────────────────────── function initialsFor(name: string | null, email: string) { if (name) { return name .split(" ") .map((n) => n[0]) .join("") .slice(0, 2) .toUpperCase(); } return email[0]!.toUpperCase(); } function Avatar(props: { url: string | null; name: string | null; email: string }) { if (props.url) { return ; } return (
{initialsFor(props.name, props.email)}
); } // ── UserFooter ────────────────────────────────────────────────────────── function UserFooter(props: Pick) { const auth = useAuth(); if (auth.status !== "authenticated") return null; return (
{props.orgMenuSlot} Signed in as

{auth.user.name ?? auth.user.email}

{auth.user.name && (

{auth.user.email}

)}
void props.onSignOut()} > Sign out
); } // ── SidebarContent ─────────────────────────────────────────────────────── function SidebarContent( props: ShellProps & { pathname: string; onNavigate?: () => void; showBrand?: boolean }, ) { const plugins = useClientPlugins(); const pluginNavItems = plugins.flatMap((plugin) => (plugin.pages ?? []).flatMap((page) => page.nav ? [ { to: pluginPageNavPath(plugin.id, page.path), label: page.nav.label, }, ] : [], ), ); const navItems = [...(props.navItems ?? defaultShellNavItems), ...pluginNavItems]; return ( <> {props.showBrand !== false && (
)}
{APP_VERSION && (

v{APP_VERSION}

)}
{props.supportSlot &&
{props.supportSlot}
} ); } // ── Shell ───────────────────────────────────────────────────────────────── export function Shell(props: ShellProps) { const pathname = useScopeRelativePathname(); const lastPathname = useRef(pathname); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); if (lastPathname.current !== pathname) { lastPathname.current = pathname; if (mobileSidebarOpen) setMobileSidebarOpen(false); } useEffect(() => { if (!mobileSidebarOpen) return; const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; }, [mobileSidebarOpen]); return (
{/* Desktop sidebar */} {/* Mobile sidebar overlay */} {mobileSidebarOpen && (
{/* oxlint-disable-next-line react/forbid-elements */}
setMobileSidebarOpen(false)} showBrand={false} />
)} {/* Main content */}
{/* Mobile top bar */}
{props.content ?? }
); }