chore: ran linting

This commit is contained in:
Anish Sarkar 2026-03-30 01:50:41 +05:30
parent 74826b3714
commit 04691d572b
61 changed files with 1962 additions and 1516 deletions

View file

@ -1,8 +1,8 @@
"use client";
export { cn } from "@/lib/utils";
export {
Popover,
PopoverContent,
PopoverTrigger,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export { cn } from "@/lib/utils";

View file

@ -1,463 +1,395 @@
"use client";
import * as React from "react";
import type { LucideIcon } from "lucide-react";
import {
FileText,
Globe,
Code2,
Newspaper,
Database,
File,
ExternalLink,
} from "lucide-react";
import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react";
import * as React from "react";
import { openSafeNavigationHref, resolveSafeNavigationHref } from "../shared/media";
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
import { Citation } from "./citation";
import type {
SerializableCitation,
CitationType,
CitationVariant,
} from "./schema";
import {
openSafeNavigationHref,
resolveSafeNavigationHref,
} from "../shared/media";
import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
const TYPE_ICONS: Record<CitationType, LucideIcon> = {
webpage: Globe,
document: FileText,
article: Newspaper,
api: Database,
code: Code2,
other: File,
webpage: Globe,
document: FileText,
article: Newspaper,
api: Database,
code: Code2,
other: File,
};
function useHoverPopover(delay = 100) {
const [open, setOpen] = React.useState(false);
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const [open, setOpen] = React.useState(false);
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const handleMouseEnter = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(true), delay);
}, [delay]);
const handleMouseEnter = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(true), delay);
}, [delay]);
const handleMouseLeave = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(false), delay);
}, [delay]);
const handleMouseLeave = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(false), delay);
}, [delay]);
const handleFocus = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setOpen(true);
}, []);
const handleFocus = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setOpen(true);
}, []);
const handleBlur = React.useCallback(
(e: React.FocusEvent) => {
const relatedTarget = e.relatedTarget as HTMLElement | null;
if (containerRef.current?.contains(relatedTarget)) {
return;
}
if (relatedTarget?.closest("[data-radix-popper-content-wrapper]")) {
return;
}
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(false), delay);
},
[delay],
);
const handleBlur = React.useCallback(
(e: React.FocusEvent) => {
const relatedTarget = e.relatedTarget as HTMLElement | null;
if (containerRef.current?.contains(relatedTarget)) {
return;
}
if (relatedTarget?.closest("[data-radix-popper-content-wrapper]")) {
return;
}
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(false), delay);
},
[delay]
);
React.useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
React.useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
return {
open,
setOpen,
containerRef,
handleMouseEnter,
handleMouseLeave,
handleFocus,
handleBlur,
};
return {
open,
setOpen,
containerRef,
handleMouseEnter,
handleMouseLeave,
handleFocus,
handleBlur,
};
}
export interface CitationListProps {
id: string;
citations: SerializableCitation[];
variant?: CitationVariant;
maxVisible?: number;
className?: string;
onNavigate?: (href: string, citation: SerializableCitation) => void;
id: string;
citations: SerializableCitation[];
variant?: CitationVariant;
maxVisible?: number;
className?: string;
onNavigate?: (href: string, citation: SerializableCitation) => void;
}
export function CitationList(props: CitationListProps) {
const {
id,
citations,
variant = "default",
maxVisible,
className,
onNavigate,
} = props;
const { id, citations, variant = "default", maxVisible, className, onNavigate } = props;
const shouldTruncate =
maxVisible !== undefined && citations.length > maxVisible;
const visibleCitations = shouldTruncate
? citations.slice(0, maxVisible)
: citations;
const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : [];
const overflowCount = overflowCitations.length;
const shouldTruncate = maxVisible !== undefined && citations.length > maxVisible;
const visibleCitations = shouldTruncate ? citations.slice(0, maxVisible) : citations;
const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : [];
const overflowCount = overflowCitations.length;
const wrapperClass =
variant === "inline"
? "flex flex-wrap items-center gap-1.5"
: "flex flex-col gap-2";
const wrapperClass =
variant === "inline" ? "flex flex-wrap items-center gap-1.5" : "flex flex-col gap-2";
// Stacked variant: overlapping favicons with popover
if (variant === "stacked") {
return (
<StackedCitations
id={id}
citations={citations}
className={className}
onNavigate={onNavigate}
/>
);
}
// Stacked variant: overlapping favicons with popover
if (variant === "stacked") {
return (
<StackedCitations
id={id}
citations={citations}
className={className}
onNavigate={onNavigate}
/>
);
}
if (variant === "default") {
return (
<div
className={cn("isolate flex flex-col gap-4", className)}
data-tool-ui-id={id}
data-slot="citation-list"
>
{visibleCitations.map((citation) => (
<Citation
key={citation.id}
{...citation}
variant="default"
onNavigate={onNavigate}
/>
))}
{shouldTruncate && (
<OverflowIndicator
citations={overflowCitations}
count={overflowCount}
variant="default"
onNavigate={onNavigate}
/>
)}
</div>
);
}
if (variant === "default") {
return (
<div
className={cn("isolate flex flex-col gap-4", className)}
data-tool-ui-id={id}
data-slot="citation-list"
>
{visibleCitations.map((citation) => (
<Citation key={citation.id} {...citation} variant="default" onNavigate={onNavigate} />
))}
{shouldTruncate && (
<OverflowIndicator
citations={overflowCitations}
count={overflowCount}
variant="default"
onNavigate={onNavigate}
/>
)}
</div>
);
}
return (
<div
className={cn("isolate", wrapperClass, className)}
data-tool-ui-id={id}
data-slot="citation-list"
>
{visibleCitations.map((citation) => (
<Citation
key={citation.id}
{...citation}
variant={variant}
onNavigate={onNavigate}
/>
))}
{shouldTruncate && (
<OverflowIndicator
citations={overflowCitations}
count={overflowCount}
variant={variant}
onNavigate={onNavigate}
/>
)}
</div>
);
return (
<div
className={cn("isolate", wrapperClass, className)}
data-tool-ui-id={id}
data-slot="citation-list"
>
{visibleCitations.map((citation) => (
<Citation key={citation.id} {...citation} variant={variant} onNavigate={onNavigate} />
))}
{shouldTruncate && (
<OverflowIndicator
citations={overflowCitations}
count={overflowCount}
variant={variant}
onNavigate={onNavigate}
/>
)}
</div>
);
}
interface OverflowIndicatorProps {
citations: SerializableCitation[];
count: number;
variant: CitationVariant;
onNavigate?: (href: string, citation: SerializableCitation) => void;
citations: SerializableCitation[];
count: number;
variant: CitationVariant;
onNavigate?: (href: string, citation: SerializableCitation) => void;
}
function OverflowIndicator({
citations,
count,
variant,
onNavigate,
}: OverflowIndicatorProps) {
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
function OverflowIndicator({ citations, count, variant, onNavigate }: OverflowIndicatorProps) {
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
const handleClick = (citation: SerializableCitation) => {
const href = resolveSafeNavigationHref(citation.href);
if (!href) return;
if (onNavigate) {
onNavigate(href, citation);
} else {
openSafeNavigationHref(href);
}
};
const handleClick = (citation: SerializableCitation) => {
const href = resolveSafeNavigationHref(citation.href);
if (!href) return;
if (onNavigate) {
onNavigate(href, citation);
} else {
openSafeNavigationHref(href);
}
};
const popoverContent = (
<div className="flex max-h-72 flex-col overflow-y-auto">
{citations.map((citation) => (
<OverflowItem
key={citation.id}
citation={citation}
onClick={() => handleClick(citation)}
/>
))}
</div>
);
const popoverContent = (
<div className="flex max-h-72 flex-col overflow-y-auto">
{citations.map((citation) => (
<OverflowItem key={citation.id} citation={citation} onClick={() => handleClick(citation)} />
))}
</div>
);
if (variant === "inline") {
return (
<Popover open={open}>
<PopoverTrigger asChild>
<button
type="button"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(
"inline-flex items-center gap-1 rounded-md px-2 py-1",
"bg-muted/60 text-sm tabular-nums",
"transition-colors duration-150",
"hover:bg-muted",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
)}
>
<span className="text-muted-foreground">+{count} more</span>
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-80 p-1"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{popoverContent}
</PopoverContent>
</Popover>
);
}
if (variant === "inline") {
return (
<Popover open={open}>
<PopoverTrigger asChild>
<button
type="button"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(
"inline-flex items-center gap-1 rounded-md px-2 py-1",
"bg-muted/60 text-sm tabular-nums",
"transition-colors duration-150",
"hover:bg-muted",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
)}
>
<span className="text-muted-foreground">+{count} more</span>
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-80 p-1"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{popoverContent}
</PopoverContent>
</Popover>
);
}
// Default variant
return (
<Popover open={open}>
<PopoverTrigger asChild>
<button
type="button"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(
"flex items-center justify-center rounded-xl px-4 py-3",
"border-border bg-card border border-dashed",
"transition-colors duration-150",
"hover:border-foreground/25 hover:bg-muted/50",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
)}
>
<span className="text-muted-foreground text-sm tabular-nums">
+{count} more sources
</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
className="w-80 p-1"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{popoverContent}
</PopoverContent>
</Popover>
);
// Default variant
return (
<Popover open={open}>
<PopoverTrigger asChild>
<button
type="button"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(
"flex items-center justify-center rounded-xl px-4 py-3",
"border-border bg-card border border-dashed",
"transition-colors duration-150",
"hover:border-foreground/25 hover:bg-muted/50",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
)}
>
<span className="text-muted-foreground text-sm tabular-nums">+{count} more sources</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
className="w-80 p-1"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{popoverContent}
</PopoverContent>
</Popover>
);
}
interface OverflowItemProps {
citation: SerializableCitation;
onClick: () => void;
citation: SerializableCitation;
onClick: () => void;
}
function OverflowItem({ citation, onClick }: OverflowItemProps) {
const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
return (
<button
type="button"
onClick={onClick}
className="group hover:bg-muted focus-visible:bg-muted flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none"
>
{citation.favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
<img
src={citation.favicon}
alt=""
aria-hidden="true"
width={16}
height={16}
className="bg-muted size-4 shrink-0 rounded object-cover"
/>
) : (
<TypeIcon
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
)}
<div className="min-w-0 flex-1">
<p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2">
{citation.title}
</p>
<p className="text-muted-foreground truncate text-xs">
{citation.domain}
</p>
</div>
<ExternalLink className="text-muted-foreground mt-0.5 size-3.5 shrink-0 self-start opacity-0 transition-opacity group-hover:opacity-100" />
</button>
);
return (
<button
type="button"
onClick={onClick}
className="group hover:bg-muted focus-visible:bg-muted flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none"
>
{citation.favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
<img
src={citation.favicon}
alt=""
aria-hidden="true"
width={16}
height={16}
className="bg-muted size-4 shrink-0 rounded object-cover"
/>
) : (
<TypeIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
)}
<div className="min-w-0 flex-1">
<p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2">
{citation.title}
</p>
<p className="text-muted-foreground truncate text-xs">{citation.domain}</p>
</div>
<ExternalLink className="text-muted-foreground mt-0.5 size-3.5 shrink-0 self-start opacity-0 transition-opacity group-hover:opacity-100" />
</button>
);
}
interface StackedCitationsProps {
id: string;
citations: SerializableCitation[];
className?: string;
onNavigate?: (href: string, citation: SerializableCitation) => void;
id: string;
citations: SerializableCitation[];
className?: string;
onNavigate?: (href: string, citation: SerializableCitation) => void;
}
function StackedCitations({
id,
citations,
className,
onNavigate,
}: StackedCitationsProps) {
const {
open,
setOpen,
containerRef,
handleMouseEnter,
handleMouseLeave,
handleBlur,
} = useHoverPopover();
const maxIcons = 4;
const visibleCitations = citations.slice(0, maxIcons);
const remainingCount = Math.max(0, citations.length - maxIcons);
function StackedCitations({ id, citations, className, onNavigate }: StackedCitationsProps) {
const { open, setOpen, containerRef, handleMouseEnter, handleMouseLeave, handleBlur } =
useHoverPopover();
const maxIcons = 4;
const visibleCitations = citations.slice(0, maxIcons);
const remainingCount = Math.max(0, citations.length - maxIcons);
const handleClick = (citation: SerializableCitation) => {
const href = resolveSafeNavigationHref(citation.href);
if (!href) return;
if (onNavigate) {
onNavigate(href, citation);
} else {
openSafeNavigationHref(href);
}
};
const handleClick = (citation: SerializableCitation) => {
const href = resolveSafeNavigationHref(citation.href);
if (!href) return;
if (onNavigate) {
onNavigate(href, citation);
} else {
openSafeNavigationHref(href);
}
};
return (
// biome-ignore lint/a11y/noStaticElementInteractions: blur boundary for popover focus management
<div ref={containerRef} onBlur={handleBlur} className="inline-flex">
<Popover open={open}>
<PopoverTrigger asChild>
<button
type="button"
data-tool-ui-id={id}
data-slot="citation-list"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
className={cn(
"isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
"bg-muted/40 outline-none",
"transition-colors duration-150",
"hover:bg-muted/70",
"focus-visible:ring-ring focus-visible:ring-2",
className,
)}
>
<div className="flex items-center">
{visibleCitations.map((citation, index) => {
const TypeIcon =
TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
return (
<div
key={citation.id}
className={cn(
"border-border bg-background dark:border-foreground/20 relative flex size-6 items-center justify-center rounded-full border shadow-xs",
index > 0 && "-ml-2",
)}
style={{ zIndex: maxIcons - index }}
>
{citation.favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
<img
src={citation.favicon}
alt=""
aria-hidden="true"
width={18}
height={18}
className="size-4.5 rounded-full object-cover"
/>
) : (
<TypeIcon
className="text-muted-foreground size-3"
aria-hidden="true"
/>
)}
</div>
);
})}
{remainingCount > 0 && (
<div
className="border-border bg-background dark:border-foreground/20 relative -ml-2 flex size-6 items-center justify-center rounded-full border shadow-xs"
style={{ zIndex: 0 }}
>
<span className="text-muted-foreground text-[10px] font-medium tracking-tight">
</span>
</div>
)}
</div>
<span className="text-muted-foreground text-sm tabular-nums">
{citations.length} source{citations.length !== 1 && "s"}
</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
className="w-80 p-1"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onBlur={handleBlur}
onEscapeKeyDown={() => setOpen(false)}
>
<div className="flex max-h-72 flex-col overflow-y-auto">
{citations.map((citation) => (
<OverflowItem
key={citation.id}
citation={citation}
onClick={() => handleClick(citation)}
/>
))}
</div>
</PopoverContent>
</Popover>
</div>
);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: blur boundary for popover focus management
<div ref={containerRef} onBlur={handleBlur} className="inline-flex">
<Popover open={open}>
<PopoverTrigger asChild>
<button
type="button"
data-tool-ui-id={id}
data-slot="citation-list"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
className={cn(
"isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
"bg-muted/40 outline-none",
"transition-colors duration-150",
"hover:bg-muted/70",
"focus-visible:ring-ring focus-visible:ring-2",
className
)}
>
<div className="flex items-center">
{visibleCitations.map((citation, index) => {
const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
return (
<div
key={citation.id}
className={cn(
"border-border bg-background dark:border-foreground/20 relative flex size-6 items-center justify-center rounded-full border shadow-xs",
index > 0 && "-ml-2"
)}
style={{ zIndex: maxIcons - index }}
>
{citation.favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
<img
src={citation.favicon}
alt=""
aria-hidden="true"
width={18}
height={18}
className="size-4.5 rounded-full object-cover"
/>
) : (
<TypeIcon className="text-muted-foreground size-3" aria-hidden="true" />
)}
</div>
);
})}
{remainingCount > 0 && (
<div
className="border-border bg-background dark:border-foreground/20 relative -ml-2 flex size-6 items-center justify-center rounded-full border shadow-xs"
style={{ zIndex: 0 }}
>
<span className="text-muted-foreground text-[10px] font-medium tracking-tight">
</span>
</div>
)}
</div>
<span className="text-muted-foreground text-sm tabular-nums">
{citations.length} source{citations.length !== 1 && "s"}
</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
className="w-80 p-1"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onBlur={handleBlur}
onEscapeKeyDown={() => setOpen(false)}
>
<div className="flex max-h-72 flex-col overflow-y-auto">
{citations.map((citation) => (
<OverflowItem
key={citation.id}
citation={citation}
onClick={() => handleClick(citation)}
/>
))}
</div>
</PopoverContent>
</Popover>
</div>
);
}

View file

@ -1,261 +1,248 @@
"use client";
import * as React from "react";
import type { LucideIcon } from "lucide-react";
import {
FileText,
Globe,
Code2,
Newspaper,
Database,
File,
ExternalLink,
} from "lucide-react";
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react";
import * as React from "react";
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
import type {
SerializableCitation,
CitationType,
CitationVariant,
} from "./schema";
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
const FALLBACK_LOCALE = "en-US";
const TYPE_ICONS: Record<CitationType, LucideIcon> = {
webpage: Globe,
document: FileText,
article: Newspaper,
api: Database,
code: Code2,
other: File,
webpage: Globe,
document: FileText,
article: Newspaper,
api: Database,
code: Code2,
other: File,
};
function extractDomain(url: string): string | undefined {
try {
const urlObj = new URL(url);
return urlObj.hostname.replace(/^www\./, "");
} catch {
return undefined;
}
try {
const urlObj = new URL(url);
return urlObj.hostname.replace(/^www\./, "");
} catch {
return undefined;
}
}
function formatDate(isoString: string, locale: string): string {
try {
const date = new Date(isoString);
return date.toLocaleDateString(locale, {
year: "numeric",
month: "short",
});
} catch {
return isoString;
}
try {
const date = new Date(isoString);
return date.toLocaleDateString(locale, {
year: "numeric",
month: "short",
});
} catch {
return isoString;
}
}
function useHoverPopover(delay = 100) {
const [open, setOpen] = React.useState(false);
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const [open, setOpen] = React.useState(false);
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const handleMouseEnter = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(true), delay);
}, [delay]);
const handleMouseEnter = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(true), delay);
}, [delay]);
const handleMouseLeave = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(false), delay);
}, [delay]);
const handleMouseLeave = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(false), delay);
}, [delay]);
React.useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
React.useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
return { open, setOpen, handleMouseEnter, handleMouseLeave };
return { open, setOpen, handleMouseEnter, handleMouseLeave };
}
export interface CitationProps extends SerializableCitation {
variant?: CitationVariant;
className?: string;
onNavigate?: (href: string, citation: SerializableCitation) => void;
variant?: CitationVariant;
className?: string;
onNavigate?: (href: string, citation: SerializableCitation) => void;
}
export function Citation(props: CitationProps) {
const { variant = "default", className, onNavigate, ...serializable } = props;
const { variant = "default", className, onNavigate, ...serializable } = props;
const {
id,
href: rawHref,
title,
snippet,
domain: providedDomain,
favicon,
author,
publishedAt,
type = "webpage",
locale: providedLocale,
} = serializable;
const {
id,
href: rawHref,
title,
snippet,
domain: providedDomain,
favicon,
author,
publishedAt,
type = "webpage",
locale: providedLocale,
} = serializable;
const locale = providedLocale ?? FALLBACK_LOCALE;
const sanitizedHref = sanitizeHref(rawHref);
const domain = providedDomain ?? extractDomain(rawHref);
const locale = providedLocale ?? FALLBACK_LOCALE;
const sanitizedHref = sanitizeHref(rawHref);
const domain = providedDomain ?? extractDomain(rawHref);
const citationData: SerializableCitation = {
...serializable,
href: sanitizedHref ?? rawHref,
domain,
locale,
};
const citationData: SerializableCitation = {
...serializable,
href: sanitizedHref ?? rawHref,
domain,
locale,
};
const TypeIcon = TYPE_ICONS[type] ?? Globe;
const TypeIcon = TYPE_ICONS[type] ?? Globe;
const handleClick = () => {
if (!sanitizedHref) return;
if (onNavigate) {
onNavigate(sanitizedHref, citationData);
} else {
openSafeNavigationHref(sanitizedHref);
}
};
const handleClick = () => {
if (!sanitizedHref) return;
if (onNavigate) {
onNavigate(sanitizedHref, citationData);
} else {
openSafeNavigationHref(sanitizedHref);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (sanitizedHref && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
handleClick();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (sanitizedHref && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
handleClick();
}
};
const iconElement = favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
<img
src={favicon}
alt=""
aria-hidden="true"
width={14}
height={14}
className="bg-muted size-3.5 shrink-0 rounded object-cover"
/>
) : (
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
);
const iconElement = favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
<img
src={favicon}
alt=""
aria-hidden="true"
width={14}
height={14}
className="bg-muted size-3.5 shrink-0 rounded object-cover"
/>
) : (
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
);
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
// Inline variant: compact chip with hover popover
if (variant === "inline") {
return (
<Popover open={open}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={title}
data-tool-ui-id={id}
data-slot="citation"
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(
"inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1",
"bg-muted/60 text-sm outline-none",
"transition-colors duration-150",
"hover:bg-muted",
"focus-visible:ring-ring focus-visible:ring-2",
className,
)}
>
{iconElement}
<span className="text-muted-foreground">{domain}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-72 cursor-pointer p-0"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onClick={handleClick}
>
<div className="hover:bg-muted/50 flex flex-col gap-2 p-3 transition-colors">
<div className="flex items-start gap-2">
{iconElement}
<span className="text-muted-foreground text-xs">{domain}</span>
</div>
<p className="text-sm leading-snug font-medium">{title}</p>
{snippet && (
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{snippet}
</p>
)}
</div>
</PopoverContent>
</Popover>
);
}
// Inline variant: compact chip with hover popover
if (variant === "inline") {
return (
<Popover open={open}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={title}
data-tool-ui-id={id}
data-slot="citation"
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(
"inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1",
"bg-muted/60 text-sm outline-none",
"transition-colors duration-150",
"hover:bg-muted",
"focus-visible:ring-ring focus-visible:ring-2",
className
)}
>
{iconElement}
<span className="text-muted-foreground">{domain}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-72 cursor-pointer p-0"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onClick={handleClick}
>
<div className="hover:bg-muted/50 flex flex-col gap-2 p-3 transition-colors">
<div className="flex items-start gap-2">
{iconElement}
<span className="text-muted-foreground text-xs">{domain}</span>
</div>
<p className="text-sm leading-snug font-medium">{title}</p>
{snippet && (
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{snippet}
</p>
)}
</div>
</PopoverContent>
</Popover>
);
}
// Default variant: full card
return (
<article
className={cn("relative w-full max-w-md min-w-72", className)}
lang={locale}
data-tool-ui-id={id}
data-slot="citation"
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: div receives role="link" conditionally when href is present */}
<div
className={cn(
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
"border-border bg-card border text-sm shadow-xs",
"transition-colors duration-150",
sanitizedHref && [
"cursor-pointer",
"hover:border-foreground/25",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
],
)}
onClick={sanitizedHref ? handleClick : undefined}
role={sanitizedHref ? "link" : undefined}
tabIndex={sanitizedHref ? 0 : undefined}
onKeyDown={sanitizedHref ? handleKeyDown : undefined}
>
<div className="flex flex-col gap-2 p-4">
<div className="text-muted-foreground flex min-w-0 items-center justify-between gap-1.5 text-xs">
<div className="flex min-w-0 items-center gap-1.5">
{iconElement}
<span className="truncate font-medium">{domain}</span>
{(author || publishedAt) && (
<span className="opacity-70">
<span className="opacity-60"> </span>
{author}
{author && publishedAt && ", "}
{publishedAt && (
<time dateTime={publishedAt} className="tabular-nums">
{formatDate(publishedAt, locale)}
</time>
)}
</span>
)}
</div>
{sanitizedHref && (
<ExternalLink className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
)}
</div>
// Default variant: full card
return (
<article
className={cn("relative w-full max-w-md min-w-72", className)}
lang={locale}
data-tool-ui-id={id}
data-slot="citation"
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: div receives role="link" conditionally when href is present */}
<div
className={cn(
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
"border-border bg-card border text-sm shadow-xs",
"transition-colors duration-150",
sanitizedHref && [
"cursor-pointer",
"hover:border-foreground/25",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
]
)}
onClick={sanitizedHref ? handleClick : undefined}
role={sanitizedHref ? "link" : undefined}
tabIndex={sanitizedHref ? 0 : undefined}
onKeyDown={sanitizedHref ? handleKeyDown : undefined}
>
<div className="flex flex-col gap-2 p-4">
<div className="text-muted-foreground flex min-w-0 items-center justify-between gap-1.5 text-xs">
<div className="flex min-w-0 items-center gap-1.5">
{iconElement}
<span className="truncate font-medium">{domain}</span>
{(author || publishedAt) && (
<span className="opacity-70">
<span className="opacity-60"> </span>
{author}
{author && publishedAt && ", "}
{publishedAt && (
<time dateTime={publishedAt} className="tabular-nums">
{formatDate(publishedAt, locale)}
</time>
)}
</span>
)}
</div>
{sanitizedHref && (
<ExternalLink className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
)}
</div>
<h3 className="text-foreground text-[15px] leading-snug font-medium text-pretty">
<span className="group-hover:decoration-foreground/30 line-clamp-2 group-hover:underline group-hover:underline-offset-2">
{title}
</span>
</h3>
<h3 className="text-foreground text-[15px] leading-snug font-medium text-pretty">
<span className="group-hover:decoration-foreground/30 line-clamp-2 group-hover:underline group-hover:underline-offset-2">
{title}
</span>
</h3>
{snippet && (
<p className="text-muted-foreground text-[13px] leading-relaxed text-pretty">
<span className="line-clamp-3">{snippet}</span>
</p>
)}
</div>
</div>
</article>
);
{snippet && (
<p className="text-muted-foreground text-[13px] leading-relaxed text-pretty">
<span className="line-clamp-3">{snippet}</span>
</p>
)}
</div>
</div>
</article>
);
}

View file

@ -1,9 +1,9 @@
export { Citation } from "./citation";
export type { CitationProps } from "./citation";
export { CitationList } from "./citation-list";
export { Citation } from "./citation";
export type { CitationListProps } from "./citation-list";
export { CitationList } from "./citation-list";
export type {
SerializableCitation,
CitationType,
CitationVariant,
CitationType,
CitationVariant,
SerializableCitation,
} from "./schema";

View file

@ -1,17 +1,13 @@
import { z } from "zod";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
import { ToolUIIdSchema, ToolUIReceiptSchema, ToolUIRoleSchema } from "../shared/schema";
export const CitationTypeSchema = z.enum([
"webpage",
"document",
"article",
"api",
"code",
"other",
"webpage",
"document",
"article",
"api",
"code",
"other",
]);
export type CitationType = z.infer<typeof CitationTypeSchema>;
@ -21,18 +17,18 @@ export const CitationVariantSchema = z.enum(["default", "inline", "stacked"]);
export type CitationVariant = z.infer<typeof CitationVariantSchema>;
export const SerializableCitationSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
href: z.string().url(),
title: z.string(),
snippet: z.string().optional(),
domain: z.string().optional(),
favicon: z.string().url().optional(),
author: z.string().optional(),
publishedAt: z.string().datetime().optional(),
type: CitationTypeSchema.optional(),
locale: z.string().optional(),
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
href: z.string().url(),
title: z.string(),
snippet: z.string().optional(),
domain: z.string().optional(),
favicon: z.string().url().optional(),
author: z.string().optional(),
publishedAt: z.string().datetime().optional(),
type: CitationTypeSchema.optional(),
locale: z.string().optional(),
});
export type SerializableCitation = z.infer<typeof SerializableCitationSchema>;