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<ShellNavItem> = [ { 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<void>; /** Nav sections; defaults to {@link defaultShellNavItems}. */ readonly navItems?: ReadonlyArray<ShellNavItem>; /** 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 `<Outlet />` (the org-slug gate's in-shell 404). */ readonly content?: ReactNode;} // ── Brand ──────────────────────────────────────────────────────────────── function Brand(props: { onNavigate?: () => void }) { return ( <Link to="/{-$orgSlug}" onClick={props.onNavigate} className="flex items-center"> <Wordmark /> </Link> );} // ── NavItem ────────────────────────────────────────────────────────────── function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) { return ( <Link to={orgScopedTo(props.to)} onClick={props.onNavigate} className={[ "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors", props.active ? "bg-sidebar-active text-foreground font-medium" : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", ].join(" ")} > {props.label} </Link> );} // ── 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 ( <a href={props.href} target="_blank" rel="noopener noreferrer" onClick={() => { 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" > <BookOpen className="size-4 shrink-0" /> <span className="flex-1">Docs</span> <ExternalLink className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" /> </a> );} // ── 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: () => ( <div className="flex flex-col gap-1 px-2.5 py-1"> {[80, 65, 72, 58, 68].map((w, i) => ( <div key={i} className="flex items-center gap-2 rounded-md py-1.5"> <Skeleton className="size-3.5 shrink-0 rounded" /> <Skeleton className="h-3" style={{ width: `${w}%` }} /> </div> ))} </div> ), onFailure: () => ( <div className="px-2.5 py-2 text-xs text-muted-foreground">No integrations yet</div> ), onSuccess: ({ value }) => value.length === 0 ? ( <div className="px-2.5 py-2 text-sm leading-relaxed text-muted-foreground"> No integrations yet </div> ) : ( <div className="flex flex-col gap-px"> {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 ( <Link key={slug} to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: slug }} onClick={props.onNavigate} className={[ "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors", active ? "bg-sidebar-active text-foreground font-medium" : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", ].join(" ")} > <IntegrationFavicon icon={integrationPresetIconUrl( { id: slug, kind: integration.kind, name, url: integration.displayUrl }, integrationPlugins, )} sourceId={slug} url={ integration.displayUrl ?? integrationInferredUrl({ id: slug, name }) ?? undefined } /> <span className="flex-1 truncate">{name}</span> </Link> ); })} </div> ), });} // ── 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 <img src={props.url} alt="" className="size-7 shrink-0 rounded-full" />; } return ( <div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary"> {initialsFor(props.name, props.email)} </div> );} // ── UserFooter ────────────────────────────────────────────────────────── function UserFooter(props: Pick<ShellProps, "onSignOut" | "orgMenuSlot">) { const auth = useAuth(); if (auth.status !== "authenticated") return null; return ( <div className="shrink-0 border-t border-sidebar-border px-3 py-2.5"> <DropdownMenu> <DropdownMenuTrigger asChild> <Button type="button" variant="ghost" className="flex h-auto w-full items-center justify-start gap-2.5 rounded-md px-1 py-1 text-left hover:bg-sidebar-active/60" > <Avatar url={auth.user.avatarUrl} name={auth.user.name} email={auth.user.email} /> <div className="min-w-0 flex-1"> <p className="truncate text-xs font-medium text-foreground"> {auth.user.name ?? auth.user.email} </p> {auth.organization && ( <p className="truncate text-xs text-muted-foreground">{auth.organization.name}</p> )} </div> <svg viewBox="0 0 16 16" fill="none" className="size-3.5 shrink-0 text-muted-foreground" > <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /> </svg> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start" side="top" className="w-64"> {props.orgMenuSlot} <DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> Signed in as </DropdownMenuLabel> <DropdownMenuItem disabled className="gap-2 text-xs opacity-100"> <Avatar url={auth.user.avatarUrl} name={auth.user.name} email={auth.user.email} /> <div className="min-w-0 flex-1"> <p className="truncate font-medium text-foreground"> {auth.user.name ?? auth.user.email} </p> {auth.user.name && ( <p className="truncate text-muted-foreground">{auth.user.email}</p> )} </div> </DropdownMenuItem> <DropdownMenuItem className="text-xs text-destructive focus:text-destructive" onClick={() => void props.onSignOut()} > Sign out </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> );} // ── 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 && ( <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4"> <Brand onNavigate={props.onNavigate} /> </div> )} <nav className="flex flex-1 flex-col overflow-y-auto p-2"> {navItems.map((item) => ( <NavItem key={item.to} to={item.to} label={item.label} active={item.to === "/" ? props.pathname === "/" : props.pathname.startsWith(item.to)} onNavigate={props.onNavigate} /> ))} <div className="mt-5 mb-1 px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground"> <span>Integrations</span> </div> <IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} /> </nav> <SidebarUpdateCard /> <div className="shrink-0 border-t border-sidebar-border p-2"> <DocsLink href={props.docsUrl ?? DEFAULT_DOCS_URL} onNavigate={props.onNavigate} /> {APP_VERSION && ( <p className="px-2.5 pt-1.5 font-mono text-[11px] text-muted-foreground tabular-nums"> v{APP_VERSION} </p> )} </div> {props.supportSlot && <div className="shrink-0 px-2 pb-2">{props.supportSlot}</div>} <UserFooter onSignOut={props.onSignOut} orgMenuSlot={props.orgMenuSlot} /> </> );} // ── 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 ( <div className="flex h-screen overflow-hidden"> <CommandPalette /> {/* Desktop sidebar */} <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56"> <SidebarContent {...props} pathname={pathname} /> </aside> {/* Mobile sidebar overlay */} {mobileSidebarOpen && ( <div className="fixed inset-0 z-50 flex md:hidden"> {/* oxlint-disable-next-line react/forbid-elements */} <button type="button" aria-label="Close navigation" className="absolute inset-0 bg-black/45 backdrop-blur-[1px]" onClick={() => setMobileSidebarOpen(false)} /> <div className="relative flex h-full w-[84vw] max-w-xs flex-col border-r border-sidebar-border bg-sidebar shadow-2xl"> <div className="flex h-12 shrink-0 items-center justify-between border-b border-sidebar-border px-4"> <Brand onNavigate={() => setMobileSidebarOpen(false)} /> <Button variant="ghost" size="icon-sm" type="button" aria-label="Close navigation" onClick={() => setMobileSidebarOpen(false)} className="text-sidebar-foreground hover:bg-sidebar-active hover:text-foreground" > <svg viewBox="0 0 16 16" className="size-3.5"> <path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /> </svg> </Button> </div> <SidebarContent {...props} pathname={pathname} onNavigate={() => setMobileSidebarOpen(false)} showBrand={false} /> </div> </div> )} {/* Main content */} <main className="flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden"> {/* Mobile top bar */} <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4 md:hidden"> <Button variant="outline" size="icon-sm" type="button" aria-label="Open navigation" onClick={() => setMobileSidebarOpen(true)} className="bg-card hover:bg-accent/50" > <svg viewBox="0 0 16 16" className="size-4"> <path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> </svg> </Button> <Brand /> <div className="w-8 shrink-0" /> </div> {props.content ?? <Outlet />} </main> </div> );}
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<ShellNavItem> = [
{ 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<void>;
/** Nav sections; defaults to {@link defaultShellNavItems}. */
readonly navItems?: ReadonlyArray<ShellNavItem>;
/** 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 `<Outlet />` (the org-slug gate's in-shell 404). */
readonly content?: ReactNode;
}
// ── Brand ────────────────────────────────────────────────────────────────
function Brand(props: { onNavigate?: () => void }) {
return (
<Link to="/{-$orgSlug}" onClick={props.onNavigate} className="flex items-center">
<Wordmark />
</Link>
);
}
// ── NavItem ──────────────────────────────────────────────────────────────
function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) {
return (
<Link
to={orgScopedTo(props.to)}
onClick={props.onNavigate}
className={[
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors",
props.active
? "bg-sidebar-active text-foreground font-medium"
: "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
].join(" ")}
>
{props.label}
</Link>
);
}
// ── 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 (
<a
href={props.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => {
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"
>
<BookOpen className="size-4 shrink-0" />
<span className="flex-1">Docs</span>
<ExternalLink className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</a>
);
}
// ── 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: () => (
<div className="flex flex-col gap-1 px-2.5 py-1">
{[80, 65, 72, 58, 68].map((w, i) => (
<div key={i} className="flex items-center gap-2 rounded-md py-1.5">
<Skeleton className="size-3.5 shrink-0 rounded" />
<Skeleton className="h-3" style={{ width: `${w}%` }} />
</div>
))}
</div>
),
onFailure: () => (
<div className="px-2.5 py-2 text-xs text-muted-foreground">No integrations yet</div>
),
onSuccess: ({ value }) =>
value.length === 0 ? (
<div className="px-2.5 py-2 text-sm leading-relaxed text-muted-foreground">
No integrations yet
</div>
) : (
<div className="flex flex-col gap-px">
{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 (
<Link
key={slug}
to="/{-$orgSlug}/integrations/$namespace"
params={{ namespace: slug }}
onClick={props.onNavigate}
className={[
"group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors",
active
? "bg-sidebar-active text-foreground font-medium"
: "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
].join(" ")}
>
<IntegrationFavicon
icon={integrationPresetIconUrl(
{ id: slug, kind: integration.kind, name, url: integration.displayUrl },
integrationPlugins,
)}
sourceId={slug}
url={
integration.displayUrl ??
integrationInferredUrl({ id: slug, name }) ??
undefined
}
/>
<span className="flex-1 truncate">{name}</span>
</Link>
);
})}
</div>
),
});
}
// ── 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 <img src={props.url} alt="" className="size-7 shrink-0 rounded-full" />;
}
return (
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{initialsFor(props.name, props.email)}
</div>
);
}
// ── UserFooter ──────────────────────────────────────────────────────────
function UserFooter(props: Pick<ShellProps, "onSignOut" | "orgMenuSlot">) {
const auth = useAuth();
if (auth.status !== "authenticated") return null;
return (
<div className="shrink-0 border-t border-sidebar-border px-3 py-2.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="flex h-auto w-full items-center justify-start gap-2.5 rounded-md px-1 py-1 text-left hover:bg-sidebar-active/60"
>
<Avatar url={auth.user.avatarUrl} name={auth.user.name} email={auth.user.email} />
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-foreground">
{auth.user.name ?? auth.user.email}
</p>
{auth.organization && (
<p className="truncate text-xs text-muted-foreground">{auth.organization.name}</p>
)}
</div>
<svg
viewBox="0 0 16 16"
fill="none"
className="size-3.5 shrink-0 text-muted-foreground"
>
<path
d="M4 6l4 4 4-4"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-64">
{props.orgMenuSlot}
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Signed in as
</DropdownMenuLabel>
<DropdownMenuItem disabled className="gap-2 text-xs opacity-100">
<Avatar url={auth.user.avatarUrl} name={auth.user.name} email={auth.user.email} />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
{auth.user.name ?? auth.user.email}
</p>
{auth.user.name && (
<p className="truncate text-muted-foreground">{auth.user.email}</p>
)}
</div>
</DropdownMenuItem>
<DropdownMenuItem
className="text-xs text-destructive focus:text-destructive"
onClick={() => void props.onSignOut()}
>
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
// ── 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 && (
<div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">
<Brand onNavigate={props.onNavigate} />
</div>
)}
<nav className="flex flex-1 flex-col overflow-y-auto p-2">
{navItems.map((item) => (
<NavItem
key={item.to}
to={item.to}
label={item.label}
active={item.to === "/" ? props.pathname === "/" : props.pathname.startsWith(item.to)}
onNavigate={props.onNavigate}
/>
))}
<div className="mt-5 mb-1 px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">
<span>Integrations</span>
</div>
<IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} />
</nav>
<SidebarUpdateCard />
<div className="shrink-0 border-t border-sidebar-border p-2">
<DocsLink href={props.docsUrl ?? DEFAULT_DOCS_URL} onNavigate={props.onNavigate} />
{APP_VERSION && (
<p className="px-2.5 pt-1.5 font-mono text-[11px] text-muted-foreground tabular-nums">
v{APP_VERSION}
</p>
)}
</div>
{props.supportSlot && <div className="shrink-0 px-2 pb-2">{props.supportSlot}</div>}
<UserFooter onSignOut={props.onSignOut} orgMenuSlot={props.orgMenuSlot} />
</>
);
}
// ── 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 (
<div className="flex h-screen overflow-hidden">
<CommandPalette />
{/* Desktop sidebar */}
<aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
<SidebarContent {...props} pathname={pathname} />
</aside>
{/* Mobile sidebar overlay */}
{mobileSidebarOpen && (
<div className="fixed inset-0 z-50 flex md:hidden">
{/* oxlint-disable-next-line react/forbid-elements */}
<button
type="button"
aria-label="Close navigation"
className="absolute inset-0 bg-black/45 backdrop-blur-[1px]"
onClick={() => setMobileSidebarOpen(false)}
/>
<div className="relative flex h-full w-[84vw] max-w-xs flex-col border-r border-sidebar-border bg-sidebar shadow-2xl">
<div className="flex h-12 shrink-0 items-center justify-between border-b border-sidebar-border px-4">
<Brand onNavigate={() => setMobileSidebarOpen(false)} />
<Button
variant="ghost"
size="icon-sm"
type="button"
aria-label="Close navigation"
onClick={() => setMobileSidebarOpen(false)}
className="text-sidebar-foreground hover:bg-sidebar-active hover:text-foreground"
>
<svg viewBox="0 0 16 16" className="size-3.5">
<path
d="M3 3l10 10M13 3L3 13"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
</svg>
</Button>
</div>
<SidebarContent
{...props}
pathname={pathname}
onNavigate={() => setMobileSidebarOpen(false)}
showBrand={false}
/>
</div>
</div>
)}
{/* Main content */}
<main className="flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden">
{/* Mobile top bar */}
<div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4 md:hidden">
<Button
variant="outline"
size="icon-sm"
type="button"
aria-label="Open navigation"
onClick={() => setMobileSidebarOpen(true)}
className="bg-card hover:bg-accent/50"
>
<svg viewBox="0 0 16 16" className="size-4">
<path
d="M2 4h12M2 8h12M2 12h12"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
</Button>
<Brand />
<div className="w-8 shrink-0" />
</div>
{props.content ?? <Outlet />}
</main>
</div>
);
}