branch:
Sidebar.tsx
8817 bytesRaw
import {
  CaretDownIcon,
  CaretRightIcon,
  CubeIcon,
  ChatDotsIcon,
  HardDrivesIcon,
  GitBranchIcon,
  EnvelopeIcon,
  DatabaseIcon,
  LightningIcon,
  ClockIcon,
  UsersIcon,
  CpuIcon,
  WrenchIcon,
  KeyIcon,
  PlayCircleIcon,
  CheckCircleIcon,
  SunIcon,
  MoonIcon,
  MonitorIcon,
  SignpostIcon,
  TreeStructureIcon,
  ChatCircleIcon,
  StackIcon,
  GitMergeIcon,
  MicrophoneIcon,
  ShieldIcon,
  ArrowsClockwiseIcon,
  XIcon
} from "@phosphor-icons/react";
import { useState, useEffect } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { Button, Link } from "@cloudflare/kumo";
import { PoweredByAgents } from "@cloudflare/agents-ui";
import { useTheme } from "../hooks/useTheme";

interface NavItem {
  label: string;
  path: string;
  icon: React.ReactNode;
}

interface NavCategory {
  label: string;
  icon: React.ReactNode;
  items: NavItem[];
}

const navigation: NavCategory[] = [
  {
    label: "Core",
    icon: <CubeIcon size={16} />,
    items: [
      {
        label: "State",
        path: "/core/state",
        icon: <DatabaseIcon size={16} />
      },
      {
        label: "Callable",
        path: "/core/callable",
        icon: <LightningIcon size={16} />
      },
      {
        label: "Streaming",
        path: "/core/streaming",
        icon: <PlayCircleIcon size={16} />
      },
      {
        label: "Schedule",
        path: "/core/schedule",
        icon: <ClockIcon size={16} />
      },
      {
        label: "Connections",
        path: "/core/connections",
        icon: <UsersIcon size={16} />
      },
      {
        label: "SQL",
        path: "/core/sql",
        icon: <DatabaseIcon size={16} />
      },
      {
        label: "Routing",
        path: "/core/routing",
        icon: <SignpostIcon size={16} />
      },
      {
        label: "Readonly",
        path: "/core/readonly",
        icon: <ShieldIcon size={16} />
      },
      {
        label: "Retry",
        path: "/core/retry",
        icon: <ArrowsClockwiseIcon size={16} />
      }
    ]
  },
  {
    label: "AI",
    icon: <CpuIcon size={16} />,
    items: [
      {
        label: "Chat",
        path: "/ai/chat",
        icon: <ChatDotsIcon size={16} />
      },
      {
        label: "Tools",
        path: "/ai/tools",
        icon: <WrenchIcon size={16} />
      },
      {
        label: "Codemode",
        path: "/ai/codemode",
        icon: <LightningIcon size={16} />
      }
    ]
  },
  {
    label: "MCP",
    icon: <HardDrivesIcon size={16} />,
    items: [
      {
        label: "Server",
        path: "/mcp/server",
        icon: <HardDrivesIcon size={16} />
      },
      {
        label: "Client",
        path: "/mcp/client",
        icon: <CpuIcon size={16} />
      },
      {
        label: "OAuth",
        path: "/mcp/oauth",
        icon: <KeyIcon size={16} />
      }
    ]
  },
  {
    label: "Workflows",
    icon: <GitBranchIcon size={16} />,
    items: [
      {
        label: "Basic",
        path: "/workflow/basic",
        icon: <PlayCircleIcon size={16} />
      },
      {
        label: "Approval",
        path: "/workflow/approval",
        icon: <CheckCircleIcon size={16} />
      }
    ]
  },
  {
    label: "Multi-Agent",
    icon: <TreeStructureIcon size={16} />,
    items: [
      {
        label: "Supervisor",
        path: "/multi-agent/supervisor",
        icon: <UsersIcon size={16} />
      },
      {
        label: "Chat Rooms",
        path: "/multi-agent/rooms",
        icon: <ChatCircleIcon size={16} />
      },
      {
        label: "Workers",
        path: "/multi-agent/workers",
        icon: <StackIcon size={16} />
      },
      {
        label: "Pipeline",
        path: "/multi-agent/pipeline",
        icon: <GitMergeIcon size={16} />
      }
    ]
  },
  {
    label: "Voice",
    icon: <MicrophoneIcon size={16} />,
    items: [
      {
        label: "Voice Chat",
        path: "/voice/chat",
        icon: <MicrophoneIcon size={16} />
      }
    ]
  },
  {
    label: "Email",
    icon: <EnvelopeIcon size={16} />,
    items: [
      {
        label: "Receive",
        path: "/email/receive",
        icon: <EnvelopeIcon size={16} />
      },
      {
        label: "Secure Replies",
        path: "/email/secure",
        icon: <ShieldIcon size={16} />
      }
    ]
  }
];

function CategorySection({
  category,
  onNavigate
}: {
  category: NavCategory;
  onNavigate?: () => void;
}) {
  const [isOpen, setIsOpen] = useState(true);

  return (
    <div className="mb-1">
      <button
        type="button"
        onClick={() => setIsOpen(!isOpen)}
        aria-expanded={isOpen}
        aria-controls={`nav-category-${category.label.toLowerCase().replace(/\s+/g, "-")}`}
        className="w-full flex items-center gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wider text-kumo-subtle hover:text-kumo-default bg-kumo-control rounded-md transition-colors"
      >
        {isOpen ? <CaretDownIcon size={12} /> : <CaretRightIcon size={12} />}
        {category.icon}
        {category.label}
      </button>

      {isOpen && (
        <div
          id={`nav-category-${category.label.toLowerCase().replace(/\s+/g, "-")}`}
          role="region"
          aria-label={`${category.label} navigation`}
          className="ml-5 mt-1 space-y-0.5"
        >
          {category.items.map((item) => (
            <NavLink
              key={item.path}
              to={item.path}
              onClick={onNavigate}
              className={({ isActive }) =>
                `flex items-center gap-2 px-3 py-2 text-sm rounded-md transition-colors ${
                  isActive
                    ? "bg-kumo-control text-kumo-default font-medium"
                    : "text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default"
                }`
              }
            >
              {item.icon}
              {item.label}
            </NavLink>
          ))}
        </div>
      )}
    </div>
  );
}

function ModeToggle() {
  const { mode, setMode } = useTheme();

  const cycleMode = () => {
    if (mode === "system") setMode("light");
    else if (mode === "light") setMode("dark");
    else setMode("system");
  };

  const icon =
    mode === "system" ? (
      <MonitorIcon size={16} />
    ) : mode === "light" ? (
      <SunIcon size={16} />
    ) : (
      <MoonIcon size={16} />
    );

  return (
    <Button
      variant="ghost"
      size="sm"
      icon={icon}
      onClick={cycleMode}
      title={`Mode: ${mode}`}
    >
      <span className="text-xs capitalize">{mode}</span>
    </Button>
  );
}

function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
  return (
    <>
      <div className="p-4 border-b border-kumo-line flex items-center justify-between">
        <PoweredByAgents />
        {onNavigate && (
          <Button
            variant="ghost"
            shape="square"
            size="sm"
            icon={<XIcon size={18} />}
            onClick={onNavigate}
            aria-label="Close navigation"
            className="md:hidden"
          />
        )}
      </div>

      <nav className="flex-1 overflow-y-auto p-2">
        {navigation.map((category) => (
          <CategorySection
            key={category.label}
            category={category}
            onNavigate={onNavigate}
          />
        ))}
      </nav>

      <div className="p-4 border-t border-kumo-line space-y-2">
        <ModeToggle />
        <div className="text-xs text-kumo-subtle">
          <Link href="https://github.com/cloudflare/agents" variant="inline">
            GitHub
          </Link>
          {" · "}
          <Link
            href="https://developers.cloudflare.com/agents"
            variant="inline"
          >
            Docs
          </Link>
        </div>
      </div>
    </>
  );
}

interface SidebarProps {
  open: boolean;
  onClose: () => void;
}

export function Sidebar({ open, onClose }: SidebarProps) {
  const location = useLocation();

  useEffect(() => {
    onClose();
  }, [location.pathname, onClose]);

  return (
    <>
      {/* Desktop: static sidebar */}
      <aside className="hidden md:flex w-56 h-full border-r border-kumo-line bg-kumo-base flex-col shrink-0">
        <SidebarContent />
      </aside>

      {/* Mobile: overlay drawer */}
      {open && (
        <div className="fixed inset-0 z-40 md:hidden">
          {/* Backdrop */}
          <button
            type="button"
            className="absolute inset-0 bg-black/40"
            onClick={onClose}
            aria-label="Close navigation"
          />
          {/* Panel */}
          <aside className="relative w-72 max-w-[85vw] h-full bg-kumo-base flex flex-col shadow-xl">
            <SidebarContent onNavigate={onClose} />
          </aside>
        </div>
      )}
    </>
  );
}