diff --git a/surfsense_web/components/assistant-ui/inline-citation.tsx b/surfsense_web/components/assistant-ui/inline-citation.tsx index abb89d5da..a9f9cc076 100644 --- a/surfsense_web/components/assistant-ui/inline-citation.tsx +++ b/surfsense_web/components/assistant-ui/inline-citation.tsx @@ -5,12 +5,11 @@ import { useSetAtom } from "jotai"; import { ExternalLink, FileText } from "lucide-react"; import dynamic from "next/dynamic"; import type { FC } from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom"; import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context"; import { Citation } from "@/components/tool-ui/citation"; +import { CitationHoverPopover } from "@/components/tool-ui/citation/citation-hover-popover"; import { Button } from "@/components/ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { documentsApiService } from "@/lib/apis/documents-api.service"; @@ -31,8 +30,6 @@ interface InlineCitationProps { isDocsChunk?: boolean; } -const POPOVER_HOVER_CLOSE_DELAY_MS = 150; - /** * Inline citation badge for knowledge-base chunks (numeric chunk IDs) and * Surfsense documentation chunks (`isDocsChunk`). Negative chunk IDs render as @@ -91,108 +88,80 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => { }; const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => { - const [open, setOpen] = useState(false); - const closeTimerRef = useRef | null>(null); - - const cancelClose = useCallback(() => { - if (closeTimerRef.current) { - clearTimeout(closeTimerRef.current); - closeTimerRef.current = null; - } - }, []); - - const scheduleClose = useCallback(() => { - cancelClose(); - closeTimerRef.current = setTimeout(() => { - setOpen(false); - closeTimerRef.current = null; - }, POPOVER_HOVER_CLOSE_DELAY_MS); - }, [cancelClose]); - - useEffect(() => () => cancelClose(), [cancelClose]); + return ( + ( + + )} + > + + + ); +}; +const SurfsenseDocPreview: FC<{ chunkId: number }> = ({ chunkId }) => { const { data, isLoading, error } = useQuery({ queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`), queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId), - enabled: open, staleTime: 5 * 60 * 1000, }); const citedChunk = data?.chunks.find((c) => c.id === chunkId) ?? data?.chunks[0]; return ( - - - - - e.preventDefault()} - > -
-
-

- {data?.title ?? "Surfsense documentation"} -

-

Chunk #{chunkId}

+ <> +
+
+

+ {data?.title ?? "Surfsense documentation"} +

+

Chunk #{chunkId}

+
+ {data?.source && ( + + + Open + + )} +
+
+ {isLoading && ( +
+ + Loading…
- {data?.source && ( - - - Open - - )} -
-
- {isLoading && ( -
- - Loading… -
- )} - {error && ( -

- {error instanceof Error ? error.message : "Failed to load chunk"} -

- )} - {!isLoading && !error && citedChunk?.content && ( - - )} - {!isLoading && !error && !citedChunk?.content && ( -

No content available.

- )} -
- - + )} + {error && ( +

+ {error instanceof Error ? error.message : "Failed to load chunk"} +

+ )} + {!isLoading && !error && citedChunk?.content && ( + + )} + {!isLoading && !error && !citedChunk?.content && ( +

No content available.

+ )} +
+ ); }; diff --git a/surfsense_web/components/tool-ui/citation/citation-hover-popover.tsx b/surfsense_web/components/tool-ui/citation/citation-hover-popover.tsx new file mode 100644 index 000000000..0ea1cf54d --- /dev/null +++ b/surfsense_web/components/tool-ui/citation/citation-hover-popover.tsx @@ -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; + +export type CitationHoverTriggerProps = Pick< + HTMLAttributes, + "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 | null>(null); + const closeTimerRef = useRef | 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 ( + + {trigger(hoverProps)} + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + onClick={onContentClick} + > + {children} + + + ); +} diff --git a/surfsense_web/components/tool-ui/citation/citation.tsx b/surfsense_web/components/tool-ui/citation/citation.tsx index 9ecb7c2f2..8f8d872ef 100644 --- a/surfsense_web/components/tool-ui/citation/citation.tsx +++ b/surfsense_web/components/tool-ui/citation/citation.tsx @@ -2,10 +2,10 @@ import { ExternalLink, Globe } from "lucide-react"; import NextImage from "next/image"; -import * as React from "react"; import { Button } from "@/components/ui/button"; 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_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 | 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 { variant?: CitationVariant; 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 ? (