refactor: implement CitationHoverPopover component to enhance inline citation functionality and improve user interaction

This commit is contained in:
Anish Sarkar 2026-05-15 02:00:17 +05:30
parent 56239548c8
commit ea087d1d23
3 changed files with 293 additions and 210 deletions

View file

@ -5,12 +5,11 @@ import { useSetAtom } from "jotai";
import { ExternalLink, FileText } from "lucide-react"; import { ExternalLink, FileText } from "lucide-react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import type { FC } from "react"; import type { FC } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom"; import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context"; import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
import { Citation } from "@/components/tool-ui/citation"; import { Citation } from "@/components/tool-ui/citation";
import { CitationHoverPopover } from "@/components/tool-ui/citation/citation-hover-popover";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
@ -31,8 +30,6 @@ interface InlineCitationProps {
isDocsChunk?: boolean; isDocsChunk?: boolean;
} }
const POPOVER_HOVER_CLOSE_DELAY_MS = 150;
/** /**
* Inline citation badge for knowledge-base chunks (numeric chunk IDs) and * Inline citation badge for knowledge-base chunks (numeric chunk IDs) and
* Surfsense documentation chunks (`isDocsChunk`). Negative chunk IDs render as * Surfsense documentation chunks (`isDocsChunk`). Negative chunk IDs render as
@ -91,68 +88,41 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
}; };
const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => { const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
const [open, setOpen] = useState(false); return (
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); <CitationHoverPopover
id={`doc-${chunkId}`}
const cancelClose = useCallback(() => { contentClassName="w-96 max-w-[calc(100vw-2rem)] p-0"
if (closeTimerRef.current) { align="start"
clearTimeout(closeTimerRef.current); trigger={(hoverProps) => (
closeTimerRef.current = null; <Button
} type="button"
}, []); variant="ghost"
className="ml-0.5 h-5 min-w-5 cursor-pointer gap-0.5 rounded-md bg-primary/10 px-1.5 text-[11px] font-medium text-primary align-baseline shadow-sm transition-colors hover:bg-primary/15 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
const scheduleClose = useCallback(() => { aria-label={`Show Surfsense documentation chunk ${chunkId}`}
cancelClose(); title="Surfsense documentation"
closeTimerRef.current = setTimeout(() => { {...hoverProps}
setOpen(false); >
closeTimerRef.current = null; <FileText className="size-3" />
}, POPOVER_HOVER_CLOSE_DELAY_MS); doc
}, [cancelClose]); </Button>
)}
useEffect(() => () => cancelClose(), [cancelClose]); >
<SurfsenseDocPreview chunkId={chunkId} />
</CitationHoverPopover>
);
};
const SurfsenseDocPreview: FC<{ chunkId: number }> = ({ chunkId }) => {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`), queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`),
queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId), queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId),
enabled: open,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
const citedChunk = data?.chunks.find((c) => c.id === chunkId) ?? data?.chunks[0]; const citedChunk = data?.chunks.find((c) => c.id === chunkId) ?? data?.chunks[0];
return ( return (
<Popover open={open} onOpenChange={setOpen}> <>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
onClick={() => setOpen((prev) => !prev)}
onMouseEnter={() => {
cancelClose();
setOpen(true);
}}
onMouseLeave={scheduleClose}
onFocus={() => {
cancelClose();
setOpen(true);
}}
onBlur={scheduleClose}
className="ml-0.5 h-5 min-w-5 cursor-pointer gap-0.5 rounded-md bg-primary/10 px-1.5 text-[11px] font-medium text-primary align-baseline shadow-sm transition-colors hover:bg-primary/15 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
aria-label={`Show Surfsense documentation chunk ${chunkId}`}
title="Surfsense documentation"
>
<FileText className="size-3" />
doc
</Button>
</PopoverTrigger>
<PopoverContent
className="w-96 max-w-[calc(100vw-2rem)] p-0"
align="start"
sideOffset={6}
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex items-center justify-between gap-2 border-b px-3 py-2"> <div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium"> <p className="truncate text-sm font-medium">
@ -191,8 +161,7 @@ const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
<p className="py-4 text-xs text-muted-foreground">No content available.</p> <p className="py-4 text-xs text-muted-foreground">No content available.</p>
)} )}
</div> </div>
</PopoverContent> </>
</Popover>
); );
}; };

View file

@ -0,0 +1,142 @@
"use client";
import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react";
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "./_adapter";
type PopoverContentProps = ComponentProps<typeof PopoverContent>;
export type CitationHoverTriggerProps = Pick<
HTMLAttributes<HTMLElement>,
"onBlur" | "onFocus" | "onPointerEnter" | "onPointerLeave"
>;
interface CitationHoverPopoverProps {
id: string;
trigger: (props: CitationHoverTriggerProps) => ReactElement;
children: ReactNode;
contentClassName?: string;
side?: PopoverContentProps["side"];
align?: PopoverContentProps["align"];
sideOffset?: PopoverContentProps["sideOffset"];
onContentClick?: PopoverContentProps["onClick"];
}
const OPEN_DELAY_MS = 80;
const CLOSE_DELAY_MS = 120;
let activeCitationId: string | null = null;
const listeners = new Set<() => void>();
function subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function getSnapshot() {
return activeCitationId;
}
function setActiveCitationId(id: string | null) {
if (activeCitationId === id) return;
activeCitationId = id;
for (const listener of listeners) {
listener();
}
}
function useCitationHoverState(id: string) {
const activeId = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = useCallback(() => {
if (openTimerRef.current) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const open = activeId === id;
const scheduleOpen = useCallback(() => {
clearTimers();
openTimerRef.current = setTimeout(() => {
setActiveCitationId(id);
openTimerRef.current = null;
}, OPEN_DELAY_MS);
}, [clearTimers, id]);
const scheduleClose = useCallback(() => {
clearTimers();
closeTimerRef.current = setTimeout(() => {
if (activeCitationId === id) {
setActiveCitationId(null);
}
closeTimerRef.current = null;
}, CLOSE_DELAY_MS);
}, [clearTimers, id]);
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
clearTimers();
setActiveCitationId(nextOpen ? id : null);
},
[clearTimers, id]
);
useEffect(() => {
return () => {
clearTimers();
if (activeCitationId === id) {
setActiveCitationId(null);
}
};
}, [clearTimers, id]);
return { open, scheduleOpen, scheduleClose, handleOpenChange };
}
export function CitationHoverPopover({
id,
trigger,
children,
contentClassName,
side = "top",
align = "start",
sideOffset = 6,
onContentClick,
}: CitationHoverPopoverProps) {
const { open, scheduleOpen, scheduleClose, handleOpenChange } = useCitationHoverState(id);
const hoverProps = {
onPointerEnter: scheduleOpen,
onPointerLeave: scheduleClose,
onFocus: scheduleOpen,
onBlur: scheduleClose,
} satisfies CitationHoverTriggerProps;
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>{trigger(hoverProps)}</PopoverTrigger>
<PopoverContent
side={side}
align={align}
sideOffset={sideOffset}
className={contentClassName}
onPointerEnter={scheduleOpen}
onPointerLeave={scheduleClose}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onClick={onContentClick}
>
{children}
</PopoverContent>
</Popover>
);
}

View file

@ -2,10 +2,10 @@
import { ExternalLink, Globe } from "lucide-react"; import { ExternalLink, Globe } from "lucide-react";
import NextImage from "next/image"; import NextImage from "next/image";
import * as React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { openSafeNavigationHref, sanitizeHref } from "../shared/media"; import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; import { cn } from "./_adapter";
import { CitationHoverPopover } from "./citation-hover-popover";
import type { CitationVariant, SerializableCitation } from "./schema"; import type { CitationVariant, SerializableCitation } from "./schema";
import { TYPE_ICONS } from "./type-icons"; import { TYPE_ICONS } from "./type-icons";
@ -32,29 +32,6 @@ function formatDate(isoString: string, locale: string): string {
} }
} }
function useHoverPopover(delay = 100) {
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 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);
};
}, []);
return { open, setOpen, handleMouseEnter, handleMouseLeave };
}
export interface CitationProps extends SerializableCitation { export interface CitationProps extends SerializableCitation {
variant?: CitationVariant; variant?: CitationVariant;
className?: string; className?: string;
@ -99,13 +76,6 @@ export function Citation(props: CitationProps) {
} }
}; };
const handleKeyDown = (e: React.KeyboardEvent) => {
if (sanitizedHref && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
handleClick();
}
};
const iconElement = favicon ? ( const iconElement = favicon ? (
<NextImage <NextImage
src={favicon} src={favicon}
@ -120,13 +90,14 @@ export function Citation(props: CitationProps) {
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" /> <TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
); );
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
// Inline variant: compact chip with hover popover // Inline variant: compact chip with hover popover
if (variant === "inline") { if (variant === "inline") {
return ( return (
<Popover open={open}> <CitationHoverPopover
<PopoverTrigger asChild> id={id}
contentClassName="w-72 cursor-pointer p-0"
onContentClick={handleClick}
trigger={(hoverProps) => (
<Button <Button
variant="ghost" variant="ghost"
type="button" type="button"
@ -134,8 +105,7 @@ export function Citation(props: CitationProps) {
data-tool-ui-id={id} data-tool-ui-id={id}
data-slot="citation" data-slot="citation"
onClick={handleClick} onClick={handleClick}
onMouseEnter={handleMouseEnter} {...hoverProps}
onMouseLeave={handleMouseLeave}
className={cn( className={cn(
"h-auto cursor-pointer gap-1.5 rounded-md px-2 py-1", "h-auto cursor-pointer gap-1.5 rounded-md px-2 py-1",
"bg-muted/60 text-sm outline-none", "bg-muted/60 text-sm outline-none",
@ -148,16 +118,7 @@ export function Citation(props: CitationProps) {
{iconElement} {iconElement}
<span className="text-muted-foreground">{domain}</span> <span className="text-muted-foreground">{domain}</span>
</Button> </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-accent hover:text-accent-foreground flex flex-col gap-2 p-3 transition-colors"> <div className="hover:bg-accent hover:text-accent-foreground flex flex-col gap-2 p-3 transition-colors">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
@ -171,36 +132,22 @@ export function Citation(props: CitationProps) {
</p> </p>
)} )}
</div> </div>
</PopoverContent> </CitationHoverPopover>
</Popover>
); );
} }
// Default variant: full card const cardClassName = cn(
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", "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", "border-border bg-card border text-sm shadow-xs",
"transition-colors duration-150", "transition-colors duration-150",
sanitizedHref && [ sanitizedHref && [
"cursor-pointer", "cursor-pointer no-underline",
"hover:border-foreground/25", "hover:border-foreground/25",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none", "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} const cardContent = (
tabIndex={sanitizedHref ? 0 : undefined}
onKeyDown={sanitizedHref ? handleKeyDown : undefined}
>
<div className="flex flex-col gap-2 p-4"> <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="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"> <div className="flex min-w-0 items-center gap-1.5">
@ -236,7 +183,32 @@ export function Citation(props: CitationProps) {
</p> </p>
)} )}
</div> </div>
</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"
>
{sanitizedHref ? (
<a
href={sanitizedHref}
target="_blank"
rel="noopener noreferrer"
className={cardClassName}
onClick={(event) => {
event.preventDefault();
handleClick();
}}
>
{cardContent}
</a>
) : (
<div className={cardClassName}>{cardContent}</div>
)}
</article> </article>
); );
} }