"use client";
import { BookOpenText, ChevronDown, ExternalLink, Globe2 } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useId,
useState,
} from "react";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import { getFaviconUrl } from "@/lib/favicon";
import { cn } from "@/lib/utils";
export interface CitationItem {
id: string;
title: ReactNode;
domain?: ReactNode;
url?: string;
}
export interface CitationsProps {
citations: CitationItem[];
title?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
idPrefix?: string;
className?: string;
}
export interface CitationProps {
citationId: string;
index: number;
/** Must match the related Citations idPrefix. */
idPrefix: string;
className?: string;
}
export interface CitationListProps {
citations: CitationItem[];
idPrefix?: string;
className?: string;
}
export interface CitationStackProps {
citations: CitationItem[];
limit?: number;
className?: string;
}
function citationTargetId(prefix: string, citationId: string) {
return `${prefix}-${citationId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
}
export function Citation({
citationId,
index,
idPrefix,
className,
}: CitationProps) {
return (
{index}
);
}
export function CitationFavicon({
url,
className,
}: {
url?: string;
className?: string;
}) {
const favicon = url ? getFaviconUrl(url) : null;
const [failedUrl, setFailedUrl] = useState(null);
return (
{favicon && failedUrl !== favicon ? (
// biome-ignore lint/performance/noImgElement: Dynamic cross-site favicons keep this framework-agnostic registry component portable.
setFailedUrl(favicon)}
className="size-4 rounded-sm object-contain"
/>
) : (
)}
);
}
export function CitationStack({
citations,
limit = 3,
className,
}: CitationStackProps) {
return (
{citations.slice(0, limit).map((citation) => (
))}
);
}
function CitationRow({
citation,
index,
idPrefix,
}: {
citation: CitationItem;
index: number;
idPrefix: string;
}) {
const content = (
<>
{citation.title}
{citation.domain ? (
{citation.domain}
) : null}
{index}
{citation.url ? (
) : null}
>
);
const className =
"group/citation flex items-center gap-2 rounded-md px-1.5 py-1 outline-none focus-visible:ring-2 focus-visible:ring-ring";
const id = citationTargetId(idPrefix, citation.id);
return citation.url ? (
{content}
) : (
{content}
);
}
export function CitationList({
citations,
idPrefix,
className,
}: CitationListProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const resolvedPrefix =
idPrefix ?? `citation-list-${baseId.replace(/:/g, "")}`;
return (
{citations.map((citation, index) => (
))}
);
}
export function Citations({
citations,
title = "Sources",
open,
defaultOpen = false,
onOpenChange,
idPrefix,
className,
}: CitationsProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const contentId = `${baseId}-content`;
const resolvedPrefix =
idPrefix ?? `citation-${baseId.replace(/:/g, "")}`;
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[onOpenChange, open],
);
return (
);
}