refactor: enhance citation components with mobile support and improved styling for better user experience

This commit is contained in:
Anish Sarkar 2026-05-15 03:56:01 +05:30
parent 01d7379914
commit 4dd5871318
4 changed files with 92 additions and 53 deletions

View file

@ -5,13 +5,22 @@ 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 { 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 { CitationHoverPopover } from "@/components/tool-ui/citation/citation-hover-popover";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
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 { useMediaQuery } from "@/hooks/use-media-query";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
@ -51,7 +60,7 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span <span
className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-primary/10 px-1.5 text-[11px] font-medium text-primary align-baseline shadow-sm" className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline"
role="note" role="note"
> >
<FileText className="size-3" /> <FileText className="size-3" />
@ -78,7 +87,7 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => openCitationPanel({ chunkId })} onClick={() => openCitationPanel({ chunkId })}
className="ml-0.5 h-5 min-w-5 cursor-pointer rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none" className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline"
title={`View source chunk #${chunkId}`} title={`View source chunk #${chunkId}`}
aria-label={`View cited chunk ${chunkId}`} aria-label={`View cited chunk ${chunkId}`}
> >
@ -88,36 +97,77 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
}; };
const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => { const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
const isTouchLike = useMediaQuery("(hover: none), (pointer: coarse)");
const [mobilePreviewOpen, setMobilePreviewOpen] = useState(false);
const docQuery = useSurfsenseDocPreviewQuery(chunkId, mobilePreviewOpen);
const handleMobileClick = () => {
setMobilePreviewOpen(true);
};
return ( return (
<CitationHoverPopover <>
id={`doc-${chunkId}`} <CitationHoverPopover
contentClassName="w-96 max-w-[calc(100vw-2rem)] p-0" id={`doc-${chunkId}`}
align="start" contentClassName="w-96 max-w-[calc(100vw-2rem)] p-0"
trigger={(hoverProps) => ( align="start"
<Button trigger={(hoverProps) => (
type="button" <Button
variant="ghost" type="button"
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" variant="ghost"
aria-label={`Show Surfsense documentation chunk ${chunkId}`} size={null}
title="Surfsense documentation" onClick={isTouchLike ? handleMobileClick : undefined}
{...hoverProps} className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline"
aria-label={`Show Surfsense documentation chunk ${chunkId}`}
title="Surfsense documentation"
{...hoverProps}
>
<FileText className="size-3" />
doc
</Button>
)}
>
<SurfsenseDocPreview chunkId={chunkId} />
</CitationHoverPopover>
<Drawer open={mobilePreviewOpen} onOpenChange={setMobilePreviewOpen} shouldScaleBackground={false}>
<DrawerContent
className="max-h-[85vh] z-80 bg-popover text-popover-foreground"
overlayClassName="z-80"
> >
<FileText className="size-3" /> <DrawerHandle />
doc <DrawerHeader className="pb-0">
</Button> <DrawerTitle>Surfsense documentation</DrawerTitle>
)} </DrawerHeader>
> <SurfsenseDocPreviewContent chunkId={chunkId} query={docQuery} contentClassName="max-h-[60vh]" />
<SurfsenseDocPreview chunkId={chunkId} /> </DrawerContent>
</CitationHoverPopover> </Drawer>
</>
); );
}; };
const SurfsenseDocPreview: FC<{ chunkId: number }> = ({ chunkId }) => { function useSurfsenseDocPreviewQuery(chunkId: number, enabled = true) {
const { data, isLoading, error } = useQuery({ return useQuery({
queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`), queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`),
queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId), queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
enabled,
}); });
}
type SurfsenseDocPreviewQuery = ReturnType<typeof useSurfsenseDocPreviewQuery>;
const SurfsenseDocPreview: FC<{ chunkId: number }> = ({ chunkId }) => {
const query = useSurfsenseDocPreviewQuery(chunkId);
return <SurfsenseDocPreviewContent chunkId={chunkId} query={query} />;
};
const SurfsenseDocPreviewContent: FC<{
chunkId: number;
query: SurfsenseDocPreviewQuery;
contentClassName?: string;
}> = ({ chunkId, query, contentClassName = "max-h-72" }) => {
const { data, isLoading, error } = query;
const citedChunk = data?.chunks.find((c) => c.id === chunkId) ?? data?.chunks[0]; const citedChunk = data?.chunks.find((c) => c.id === chunkId) ?? data?.chunks[0];
@ -142,7 +192,7 @@ const SurfsenseDocPreview: FC<{ chunkId: number }> = ({ chunkId }) => {
</a> </a>
)} )}
</div> </div>
<div className="max-h-72 overflow-auto px-3 py-2 text-sm"> <div className={`${contentClassName} overflow-auto px-3 py-2 text-sm`}>
{isLoading && ( {isLoading && (
<div className="flex items-center gap-2 py-4 text-muted-foreground"> <div className="flex items-center gap-2 py-4 text-muted-foreground">
<Spinner size="xs" /> <Spinner size="xs" />

View file

@ -50,14 +50,6 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
const totalChunks = data?.total_chunks ?? data?.chunks.length ?? 0; const totalChunks = data?.total_chunks ?? data?.chunks.length ?? 0;
const startIndex = data?.chunk_start_index ?? 0; const startIndex = data?.chunk_start_index ?? 0;
const citedIndexInWindow = data
? Math.max(
0,
data.chunks.findIndex((c) => c.id === chunkId)
)
: 0;
const shownAbove = citedIndexInWindow;
const shownBelow = data ? Math.max(0, data.chunks.length - 1 - citedIndexInWindow) : 0;
const hasMoreAbove = startIndex > 0; const hasMoreAbove = startIndex > 0;
const hasMoreBelow = data ? startIndex + data.chunks.length < totalChunks : false; const hasMoreBelow = data ? startIndex + data.chunks.length < totalChunks : false;
@ -117,18 +109,16 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
{data?.title ?? (isLoading ? "Loading…" : `Chunk #${chunkId}`)} {data?.title ?? (isLoading ? "Loading…" : `Chunk #${chunkId}`)}
</p> </p>
</div> </div>
<div className="flex items-center gap-2 shrink-0 text-[11px] text-muted-foreground"> <div className="flex items-center gap-1 shrink-0 text-[11px] text-muted-foreground">
<span>Chunk #{chunkId}</span> {totalChunks > 0 && <span>{totalChunks} chunks</span>}
{totalChunks > 0 && <span>· {totalChunks} chunks</span>}
</div> </div>
</div> </div>
</div> </div>
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto px-5 py-4"> <div ref={scrollContainerRef} className="flex-1 overflow-y-auto px-5 py-4">
{isLoading && ( {isLoading && (
<div className="flex items-center gap-2 py-8 text-muted-foreground"> <div className="flex min-h-full items-center justify-center text-muted-foreground">
<Spinner size="sm" /> <Spinner size="md" />
<span className="text-sm">Loading citation</span>
</div> </div>
)} )}
@ -163,14 +153,14 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
<span <span
className={ className={
isCited isCited
? "text-[11px] font-semibold text-primary" ? "text-[11px] text-muted-foreground"
: "text-[11px] font-medium text-muted-foreground" : "text-[11px] font-medium text-muted-foreground"
} }
> >
{isCited ? "Cited chunk" : `Chunk #${chunk.id}`} Chunk #{chunk.id}
</span> </span>
{isCited && ( {isCited && (
<span className="text-[11px] text-muted-foreground">#{chunk.id}</span> <span className="text-[11px] font-semibold text-primary">Cited chunk</span>
)} )}
</div> </div>
<div className="text-sm"> <div className="text-sm">
@ -191,10 +181,7 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
</div> </div>
{!isLoading && !error && data && ( {!isLoading && !error && data && (
<div className="shrink-0 flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3"> <div className="shrink-0 flex flex-wrap items-center justify-end gap-2 border-t px-4 py-3">
<div className="text-[11px] text-muted-foreground">
Showing {shownAbove} above · cited · {shownBelow} below
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(hasMoreAbove || hasMoreBelow) && !expanded && ( {(hasMoreAbove || hasMoreBelow) && !expanded && (
<Button <Button

View file

@ -2,6 +2,7 @@
import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react"; import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react";
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import { useMediaQuery } from "@/hooks/use-media-query";
import { Popover, PopoverContent, PopoverTrigger } from "./_adapter"; import { Popover, PopoverContent, PopoverTrigger } from "./_adapter";
type PopoverContentProps = ComponentProps<typeof PopoverContent>; type PopoverContentProps = ComponentProps<typeof PopoverContent>;
@ -113,6 +114,7 @@ export function CitationHoverPopover({
sideOffset = 6, sideOffset = 6,
onContentClick, onContentClick,
}: CitationHoverPopoverProps) { }: CitationHoverPopoverProps) {
const isTouchLike = useMediaQuery("(hover: none), (pointer: coarse)");
const { open, scheduleOpen, scheduleClose, handleOpenChange } = useCitationHoverState(id); const { open, scheduleOpen, scheduleClose, handleOpenChange } = useCitationHoverState(id);
const hoverProps = { const hoverProps = {
onPointerEnter: scheduleOpen, onPointerEnter: scheduleOpen,
@ -121,6 +123,10 @@ export function CitationHoverPopover({
onBlur: scheduleClose, onBlur: scheduleClose,
} satisfies CitationHoverTriggerProps; } satisfies CitationHoverTriggerProps;
if (isTouchLike) {
return trigger({});
}
return ( return (
<Popover open={open} onOpenChange={handleOpenChange}> <Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>{trigger(hoverProps)}</PopoverTrigger> <PopoverTrigger asChild>{trigger(hoverProps)}</PopoverTrigger>

View file

@ -95,7 +95,7 @@ export function Citation(props: CitationProps) {
return ( return (
<CitationHoverPopover <CitationHoverPopover
id={id} id={id}
contentClassName="w-72 cursor-pointer p-0" contentClassName="w-72 cursor-pointer overflow-hidden p-0"
onContentClick={handleClick} onContentClick={handleClick}
trigger={(hoverProps) => ( trigger={(hoverProps) => (
<Button <Button
@ -107,20 +107,16 @@ export function Citation(props: CitationProps) {
onClick={handleClick} onClick={handleClick}
{...hoverProps} {...hoverProps}
className={cn( className={cn(
"h-auto cursor-pointer gap-1.5 rounded-md px-2 py-1", "ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center gap-1.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline",
"bg-muted/60 text-sm outline-none",
"transition-colors duration-150",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:ring-ring focus-visible:ring-2",
className className
)} )}
> >
{iconElement} {iconElement}
<span className="text-muted-foreground">{domain}</span> <span>{domain}</span>
</Button> </Button>
)} )}
> >
<div className="hover:bg-accent hover:text-accent-foreground flex flex-col gap-2 p-3 transition-colors"> <div className="flex flex-col gap-2 p-3">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
{iconElement} {iconElement}
<span className="text-muted-foreground text-xs">{domain}</span> <span className="text-muted-foreground text-xs">{domain}</span>