Files
copilotkit__copilotkit/docs/components/ui/integrations-sidebar/integration-selector.tsx

326 lines
11 KiB
TypeScript

import { useState, useEffect, ComponentType, useRef } from "react";
import { flushSync } from "react-dom";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import ChevronDownIcon from "../icons/chevron";
import AdkIcon from "../icons/adk";
import Ag2Icon from "../icons/ag2";
import AgnoIcon from "../icons/agno";
import CrewaiIcon from "../icons/crewai";
import CopilotKitMarkIcon from "../icons/copilotkit-mark";
import LanggraphIcon from "../icons/langgraph";
import LlamaIndexIcon from "../icons/llama-index";
import MastraIcon from "../icons/mastra";
import PydanticAiIcon from "../icons/pydantic-ai";
import IntegrationPuzzleIcon from "../icons/integration-puzzle";
import CheckIcon from "../icons/check";
import { MicrosoftIcon } from "../icons/microsoft";
import { AwsStrandsIcon } from "../icons/aws-strands";
import { AgentSpecMarkIcon, A2AIcon } from "@/lib/icons/custom-icons";
import {
INTEGRATION_ORDER,
IntegrationId,
getIntegration,
} from "@/lib/integrations";
import { normalizeUrl } from "@/lib/analytics-utils";
export type Integration = IntegrationId;
interface IntegrationOption {
label: string;
Icon: ComponentType<{ className?: string }>;
href: string;
}
// Icon mapping - component-specific
const INTEGRATION_ICONS: Record<
IntegrationId,
ComponentType<{ className?: string }>
> = {
"built-in-agent": CopilotKitMarkIcon,
langgraph: LanggraphIcon,
adk: AdkIcon,
"microsoft-agent-framework": MicrosoftIcon,
"aws-strands": AwsStrandsIcon,
mastra: MastraIcon,
"pydantic-ai": PydanticAiIcon,
"crewai-flows": CrewaiIcon,
agno: AgnoIcon,
ag2: Ag2Icon,
"agent-spec": AgentSpecMarkIcon,
llamaindex: LlamaIndexIcon,
a2a: A2AIcon,
};
// Build integration options from canonical order
const INTEGRATION_OPTIONS: Record<Integration, IntegrationOption> =
Object.fromEntries(
INTEGRATION_ORDER.map((id) => {
const meta = getIntegration(id);
return [
id,
{
label: meta.label,
Icon: INTEGRATION_ICONS[id],
href: meta.href,
},
];
}),
) as Record<Integration, IntegrationOption>;
const DEFAULT_INTEGRATION: IntegrationOption = {
label: "Select integration...",
Icon: IntegrationPuzzleIcon,
href: "/integrations",
};
interface IntegrationSelectorProps {
selectedIntegration: Integration | null;
setSelectedIntegration: (integration: Integration | null) => void;
onNavigate?: () => void;
}
const IntegrationSelector = ({
selectedIntegration,
setSelectedIntegration,
onNavigate,
}: IntegrationSelectorProps) => {
const [isOpen, setIsOpen] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const [showTooltip, setShowTooltip] = useState(false);
const pathname = usePathname();
const router = useRouter();
const isClearing = useRef(false);
// Load persisted selection on mount (using sessionStorage for tab-specific state)
useEffect(() => {
const persistedSelection = sessionStorage.getItem("selectedIntegration");
if (persistedSelection && persistedSelection !== "null") {
// Validate against current integration list to avoid stale values
if (INTEGRATION_ORDER.includes(persistedSelection as IntegrationId)) {
setSelectedIntegration(persistedSelection as Integration);
} else {
sessionStorage.removeItem("selectedIntegration");
}
}
}, [setSelectedIntegration]);
// Listen for logo click to clear selection
useEffect(() => {
const handleClearSelection = () => {
// Set flag to prevent pathname effect from re-selecting
isClearing.current = true;
// Clear sessionStorage immediately to prevent race condition
sessionStorage.removeItem("selectedIntegration");
flushSync(() => {
setSelectedIntegration(null);
});
// Reset flag after a brief delay
setTimeout(() => {
isClearing.current = false;
}, 100);
};
window.addEventListener("clearIntegrationSelection", handleClearSelection);
return () => {
window.removeEventListener(
"clearIntegrationSelection",
handleClearSelection,
);
};
}, [setSelectedIntegration]);
// Persist selection to sessionStorage when it changes (tab-specific)
useEffect(() => {
if (selectedIntegration) {
sessionStorage.setItem("selectedIntegration", selectedIntegration);
} else {
sessionStorage.removeItem("selectedIntegration");
}
}, [selectedIntegration]);
const integration = selectedIntegration
? (INTEGRATION_OPTIONS[selectedIntegration] ?? DEFAULT_INTEGRATION)
: DEFAULT_INTEGRATION;
const { Icon } = integration;
const handleIntegrationClick = (
e: React.MouseEvent,
integrationKey: Integration,
href: string,
) => {
e.preventDefault(); // Prevent Link's default navigation
setIsOpen(false);
// If clicking the already selected integration, dismiss it
if (selectedIntegration === integrationKey) {
// Set flag to prevent pathname effect from re-selecting
isClearing.current = true;
sessionStorage.removeItem("selectedIntegration");
sessionStorage.setItem("lastDocsPath", "/");
flushSync(() => {
setSelectedIntegration(null);
});
router.push("/");
onNavigate?.();
// Reset flag after a brief delay
setTimeout(() => {
isClearing.current = false;
}, 100);
return;
}
// Update selection immediately
flushSync(() => {
setSelectedIntegration(integrationKey);
});
// Close the mobile sidebar when navigating, then navigate
onNavigate?.();
router.push(href);
};
useEffect(() => {
// Don't update selection if we're in the middle of clearing (logo click)
if (isClearing.current) {
return;
}
// Normalize the pathname to handle /integrations/... paths
const normalizedPathname = normalizeUrl(pathname);
// Get the first segment after the leading slash
const firstSegment = normalizedPathname.replace(/^\//, "").split("/")[0];
// If we're on an integration page, update the selection to match the URL
if (
firstSegment &&
INTEGRATION_ORDER.includes(firstSegment as IntegrationId)
) {
setSelectedIntegration(firstSegment as Integration);
return;
}
// Clear selection on any non-integration route so the picker returns to
// its default "Select integration..." state on the homepage and other
// docs sections.
if (selectedIntegration) {
setSelectedIntegration(null);
}
}, [pathname, selectedIntegration, setSelectedIntegration]);
// Track last visited docs page (not reference) - tab-specific
useEffect(() => {
if (!pathname.startsWith("/reference")) {
sessionStorage.setItem("lastDocsPath", pathname);
}
}, [pathname]);
useEffect(() => {
if (isHovering && !selectedIntegration && !isOpen) {
const timer = setTimeout(() => {
setShowTooltip(true);
}, 3000);
return () => {
clearTimeout(timer);
setShowTooltip(false);
};
} else {
setShowTooltip(false);
}
}, [isHovering, selectedIntegration, isOpen]);
const visibleIntegrations = Object.entries(INTEGRATION_OPTIONS);
return (
<div className="relative w-full">
<div
className={`flex justify-between items-center p-2 mt-3 mb-3 w-full h-14 rounded-lg border cursor-pointer ${
selectedIntegration || !pathname.startsWith("/reference")
? "bg-[#BEC2FF33] dark:bg-[#7076D533] border-[#7076D5] dark:border-[#BEC2FF] [box-shadow:0px_17px_12px_-10px_rgba(112,118,213,0.3)]"
: "bg-white/50 dark:bg-foreground/5 border-[#0C1112]/10 dark:border-border"
}`}
onClick={() => setIsOpen(!isOpen)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setIsOpen(!isOpen);
}
}}
tabIndex={0}
role="button"
aria-label="Toggle integration selector"
aria-expanded={isOpen}
>
<div className="flex gap-2 items-center">
<div
className={`flex justify-center items-center w-10 h-10 shrink-0 rounded-md ${
selectedIntegration || !pathname.startsWith("/reference")
? "bg-[#BEC2FF] dark:bg-[#7076D5]"
: "bg-[#0C1112]/5 dark:bg-white/5"
}`}
>
<Icon className="text-[#0C1112] dark:text-white" />
</div>
<span
className={`text-sm font-medium opacity-60 ${
(selectedIntegration || !pathname.startsWith("/reference")) &&
"text-foreground"
}`}
>
{integration.label}
</span>
</div>
<div className="flex items-center gap-1">
<ChevronDownIcon className="mr-1 w-4 h-4" />
</div>
</div>
{isOpen && (
<div className="absolute top-full left-0 w-full max-w-[240px] bg-[#F7F7FA] shadow-2xl dark:bg-[#0C1112] border border-border rounded-lg p-1 z-30 max-h-[calc(100vh-200px)] overflow-y-auto custom-scrollbar">
{visibleIntegrations.map(
([key, { label, Icon: OptionIcon, href }]) => (
<Link
key={href}
href={href}
className={`flex gap-2 justify-between items-center px-2 py-1.5 rounded-md cursor-pointer group ${
integration.href === href
? "bg-[#BEC2FF33] dark:bg-[#7076D533]"
: "hover:bg-[#0C1112]/5 dark:hover:bg-white/5"
}`}
onClick={(e) =>
handleIntegrationClick(e, key as Integration, href)
}
>
<div className="flex gap-2.5 items-center">
<div
className={`flex justify-center items-center w-7 h-7 shrink-0 rounded transition-all duration-200 ${
integration.href === href
? "bg-[#BEC2FF] dark:bg-[#7076D5]"
: "bg-[#0C1112]/5 dark:bg-white/5 group-hover:bg-[#0C1112]/10 dark:group-hover:bg-white/5"
}`}
>
<OptionIcon className="w-4 h-4 text-[#5C64DA] dark:text-[#BEC2FF] transition-all duration-200" />
</div>
<span className="text-xs font-medium">{label}</span>
</div>
{integration.href === href && (
<CheckIcon className="w-3.5 h-3.5 text-[#5C64DA] dark:text-[#7076D5]" />
)}
</Link>
),
)}
</div>
)}
{showTooltip && (
<div className="absolute top-full left-0 mt-2 w-full px-3 py-2 rounded-lg bg-[#0C1112] dark:bg-white text-white dark:text-[#0C1112] text-xs font-medium shadow-lg z-30">
See what CopilotKit and your chosen agentic backend can do.
</div>
)}
</div>
);
};
export default IntegrationSelector;