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 dynamic from "next/dynamic";
import type { FC } from "react";
import { 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 {
Drawer,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useMediaQuery } from "@/hooks/use-media-query";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
@ -51,7 +60,7 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
<Tooltip>
<TooltipTrigger asChild>
<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"
>
<FileText className="size-3" />
@ -78,7 +87,7 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
type="button"
variant="ghost"
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}`}
aria-label={`View cited chunk ${chunkId}`}
>
@ -88,36 +97,77 @@ const NumericChunkCitation: 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 (
<CitationHoverPopover
id={`doc-${chunkId}`}
contentClassName="w-96 max-w-[calc(100vw-2rem)] p-0"
align="start"
trigger={(hoverProps) => (
<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"
aria-label={`Show Surfsense documentation chunk ${chunkId}`}
title="Surfsense documentation"
{...hoverProps}
<>
<CitationHoverPopover
id={`doc-${chunkId}`}
contentClassName="w-96 max-w-[calc(100vw-2rem)] p-0"
align="start"
trigger={(hoverProps) => (
<Button
type="button"
variant="ghost"
size={null}
onClick={isTouchLike ? handleMobileClick : undefined}
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" />
doc
</Button>
)}
>
<SurfsenseDocPreview chunkId={chunkId} />
</CitationHoverPopover>
<DrawerHandle />
<DrawerHeader className="pb-0">
<DrawerTitle>Surfsense documentation</DrawerTitle>
</DrawerHeader>
<SurfsenseDocPreviewContent chunkId={chunkId} query={docQuery} contentClassName="max-h-[60vh]" />
</DrawerContent>
</Drawer>
</>
);
};
const SurfsenseDocPreview: FC<{ chunkId: number }> = ({ chunkId }) => {
const { data, isLoading, error } = useQuery({
function useSurfsenseDocPreviewQuery(chunkId: number, enabled = true) {
return useQuery({
queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`),
queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId),
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];
@ -142,7 +192,7 @@ const SurfsenseDocPreview: FC<{ chunkId: number }> = ({ chunkId }) => {
</a>
)}
</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 && (
<div className="flex items-center gap-2 py-4 text-muted-foreground">
<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 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 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}`)}
</p>
</div>
<div className="flex items-center gap-2 shrink-0 text-[11px] text-muted-foreground">
<span>Chunk #{chunkId}</span>
{totalChunks > 0 && <span>· {totalChunks} chunks</span>}
<div className="flex items-center gap-1 shrink-0 text-[11px] text-muted-foreground">
{totalChunks > 0 && <span>{totalChunks} chunks</span>}
</div>
</div>
</div>
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto px-5 py-4">
{isLoading && (
<div className="flex items-center gap-2 py-8 text-muted-foreground">
<Spinner size="sm" />
<span className="text-sm">Loading citation</span>
<div className="flex min-h-full items-center justify-center text-muted-foreground">
<Spinner size="md" />
</div>
)}
@ -163,14 +153,14 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
<span
className={
isCited
? "text-[11px] font-semibold text-primary"
? "text-[11px] text-muted-foreground"
: "text-[11px] font-medium text-muted-foreground"
}
>
{isCited ? "Cited chunk" : `Chunk #${chunk.id}`}
Chunk #{chunk.id}
</span>
{isCited && (
<span className="text-[11px] text-muted-foreground">#{chunk.id}</span>
<span className="text-[11px] font-semibold text-primary">Cited chunk</span>
)}
</div>
<div className="text-sm">
@ -191,10 +181,7 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
</div>
{!isLoading && !error && data && (
<div className="shrink-0 flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3">
<div className="text-[11px] text-muted-foreground">
Showing {shownAbove} above · cited · {shownBelow} below
</div>
<div className="shrink-0 flex flex-wrap items-center justify-end gap-2 border-t px-4 py-3">
<div className="flex items-center gap-2">
{(hasMoreAbove || hasMoreBelow) && !expanded && (
<Button

View file

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

View file

@ -95,7 +95,7 @@ export function Citation(props: CitationProps) {
return (
<CitationHoverPopover
id={id}
contentClassName="w-72 cursor-pointer p-0"
contentClassName="w-72 cursor-pointer overflow-hidden p-0"
onContentClick={handleClick}
trigger={(hoverProps) => (
<Button
@ -107,20 +107,16 @@ export function Citation(props: CitationProps) {
onClick={handleClick}
{...hoverProps}
className={cn(
"h-auto cursor-pointer gap-1.5 rounded-md px-2 py-1",
"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",
"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",
className
)}
>
{iconElement}
<span className="text-muted-foreground">{domain}</span>
<span>{domain}</span>
</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">
{iconElement}
<span className="text-muted-foreground text-xs">{domain}</span>