refactor: enhance document mention functionality in chat by introducing pending mentions and removals atoms, and updating DocumentsSidebar and Composer components for improved document management

This commit is contained in:
Anish Sarkar 2026-03-06 15:35:58 +05:30
parent 95a0e35393
commit f0e4aa6539
7 changed files with 261 additions and 237 deletions

View file

@ -1,20 +1,9 @@
"use client";
import { CircleAlert, ListFilter, Search, Trash, Upload, X } from "lucide-react";
import { ListFilter, Search, Upload, X } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { useMemo, useRef, useState } from "react";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@ -24,18 +13,14 @@ import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon";
export function DocumentsFilters({
typeCounts: typeCountsRecord,
selectedIds,
onSearch,
searchValue,
onBulkDelete,
onToggleType,
activeTypes,
}: {
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
selectedIds: Set<number>;
onSearch: (v: string) => void;
searchValue: string;
onBulkDelete: () => Promise<void>;
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
activeTypes: DocumentTypeEnum[];
}) {
@ -189,48 +174,6 @@ export function DocumentsFilters({
)}
</div>
{/* Bulk Delete Button */}
{selectedIds.size > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" className="h-9 shrink-0 gap-1.5 px-2.5">
<Trash size={14} />
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-destructive-foreground/20 text-[10px] font-medium">
{selectedIds.size}
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="max-w-md">
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive"
aria-hidden="true"
>
<CircleAlert size={18} strokeWidth={2} />
</div>
<AlertDialogHeader className="flex-1">
<AlertDialogTitle>
Delete {selectedIds.size} document{selectedIds.size !== 1 ? "s" : ""}?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the selected{" "}
{selectedIds.size === 1 ? "document" : "documents"} from your search space.
</AlertDialogDescription>
</AlertDialogHeader>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onBulkDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{/* Upload Button */}
<Button
onClick={openUploadDialog}

View file

@ -275,8 +275,6 @@ export function DocumentsTableShell({
documents,
loading,
error,
selectedIds,
setSelectedIds,
sortKey,
sortDesc,
onSortChange,
@ -286,12 +284,12 @@ export function DocumentsTableShell({
loadingMore = false,
onLoadMore,
isSearchMode = false,
mentionedDocIds,
onToggleChatMention,
}: {
documents: Document[];
loading: boolean;
error: boolean;
selectedIds: Set<number>;
setSelectedIds: (update: Set<number>) => void;
sortKey: SortKey;
sortDesc: boolean;
onSortChange: (key: SortKey) => void;
@ -301,6 +299,10 @@ export function DocumentsTableShell({
loadingMore?: boolean;
onLoadMore?: () => void;
isSearchMode?: boolean;
/** IDs of documents currently mentioned as chips in the chat composer */
mentionedDocIds?: Set<number>;
/** Toggle a document's mention in the chat (add if not mentioned, remove if mentioned) */
onToggleChatMention?: (doc: Document, mentioned: boolean) => void;
}) {
const t = useTranslations("documents");
const { openDialog } = useDocumentUploadDialog();
@ -398,30 +400,28 @@ export function DocumentsTableShell({
return state !== "pending" && state !== "processing";
};
const hasChatMode = !!onToggleChatMention && !!mentionedDocIds;
const selectableDocs = sorted.filter(isSelectable);
const allSelectedOnPage =
selectableDocs.length > 0 && selectableDocs.every((d) => selectedIds.has(d.id));
const someSelectedOnPage =
selectableDocs.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage;
const allMentionedOnPage =
hasChatMode &&
selectableDocs.length > 0 &&
selectableDocs.every((d) => mentionedDocIds.has(d.id));
const someMentionedOnPage =
hasChatMode &&
selectableDocs.some((d) => mentionedDocIds.has(d.id)) &&
!allMentionedOnPage;
const toggleAll = (checked: boolean) => {
const next = new Set(selectedIds);
if (checked)
selectableDocs.forEach((d) => {
next.add(d.id);
});
else
sorted.forEach((d) => {
next.delete(d.id);
});
setSelectedIds(next);
};
const toggleOne = (id: number, checked: boolean) => {
const next = new Set(selectedIds);
if (checked) next.add(id);
else next.delete(id);
setSelectedIds(next);
if (!onToggleChatMention) return;
for (const doc of selectableDocs) {
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
if (checked && !isMentioned) {
onToggleChatMention(doc, false);
} else if (!checked && isMentioned) {
onToggleChatMention(doc, true);
}
}
};
const onSortHeader = (key: SortKey) => onSortChange(key);
@ -438,9 +438,9 @@ export function DocumentsTableShell({
<TableHead className="w-10 pl-3 pr-0 text-center h-8">
<div className="flex items-center justify-center h-full">
<Checkbox
checked={allSelectedOnPage || (someSelectedOnPage && "indeterminate")}
checked={allMentionedOnPage || (someMentionedOnPage && "indeterminate")}
onCheckedChange={(v) => toggleAll(!!v)}
aria-label="Select all"
aria-label={hasChatMode ? "Toggle all for chat" : "Select all"}
className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
</div>
@ -526,58 +526,69 @@ export function DocumentsTableShell({
<div ref={desktopScrollRef} className="flex-1 overflow-auto">
<Table className="table-fixed w-full">
<TableBody>
{sorted.map((doc, index) => {
const isSelected = selectedIds.has(doc.id);
const canSelect = isSelectable(doc);
return (
<RowContextMenu
key={doc.id}
doc={doc}
onPreview={handleViewDocument}
onDelete={setDeleteDoc}
searchSpaceId={searchSpaceId}
{sorted.map((doc) => {
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
const canInteract = isSelectable(doc);
const handleRowClick = () => {
if (canInteract && onToggleChatMention) {
onToggleChatMention(doc, isMentioned);
}
};
return (
<RowContextMenu
key={doc.id}
doc={doc}
onPreview={handleViewDocument}
onDelete={setDeleteDoc}
searchSpaceId={searchSpaceId}
>
<tr
className={`border-b border-border/50 transition-colors ${
isMentioned
? "bg-primary/5 hover:bg-primary/8"
: "hover:bg-muted/30"
} ${canInteract && hasChatMode ? "cursor-pointer" : ""}`}
onClick={handleRowClick}
>
<tr
className={`border-b border-border/50 transition-colors ${
isSelected ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30"
}`}
<TableCell
className="w-10 pl-3 pr-0 py-1.5 text-center"
onClick={(e) => e.stopPropagation()}
>
<TableCell className="w-10 pl-3 pr-0 py-1.5 text-center">
<div className="flex items-center justify-center h-full">
<Checkbox
checked={isSelected}
onCheckedChange={(v) => canSelect && toggleOne(doc.id, !!v)}
disabled={!canSelect}
aria-label={
canSelect ? "Select row" : "Cannot select while processing"
}
className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canSelect ? "opacity-40 cursor-not-allowed" : ""}`}
/>
</div>
</TableCell>
<TableCell className="px-2 py-1.5 max-w-0">
<DocumentNameTooltip
doc={doc}
className="truncate block text-sm text-foreground cursor-default"
<div className="flex items-center justify-center h-full">
<Checkbox
checked={isMentioned}
onCheckedChange={() => handleRowClick()}
disabled={!canInteract}
aria-label={
isMentioned ? "Remove from chat" : "Add to chat"
}
className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`}
/>
</TableCell>
<TableCell className="w-10 px-0 py-1.5 text-center">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center justify-center">
{getDocumentTypeIcon(doc.document_type, "h-4 w-4")}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{getDocumentTypeLabel(doc.document_type)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell className="w-12 pl-0 pr-3 py-1.5 text-center">
<StatusIndicator status={doc.status} />
</TableCell>
</tr>
</RowContextMenu>
</div>
</TableCell>
<TableCell className="px-2 py-1.5 max-w-0">
<DocumentNameTooltip
doc={doc}
className="truncate block text-sm text-foreground cursor-default"
/>
</TableCell>
<TableCell className="w-10 px-0 py-1.5 text-center">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center justify-center">
{getDocumentTypeIcon(doc.document_type, "h-4 w-4")}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{getDocumentTypeLabel(doc.document_type)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell className="w-12 pl-0 pr-3 py-1.5 text-center">
<StatusIndicator status={doc.status} />
</TableCell>
</tr>
</RowContextMenu>
);
})}
</TableBody>
@ -637,50 +648,67 @@ export function DocumentsTableShell({
ref={mobileScrollRef}
className="md:hidden divide-y divide-border/50 flex-1 overflow-auto"
>
{sorted.map((doc, index) => {
const isSelected = selectedIds.has(doc.id);
const canSelect = isSelectable(doc);
return (
<RowContextMenu
key={doc.id}
doc={doc}
onPreview={handleViewDocument}
onDelete={setDeleteDoc}
searchSpaceId={searchSpaceId}
>
<div
className={`px-3 py-2 transition-colors ${
isSelected ? "bg-primary/5" : "hover:bg-muted/20"
}`}
>
<div className="flex items-center gap-3">
{sorted.map((doc) => {
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
const canInteract = isSelectable(doc);
const handleCardClick = () => {
if (canInteract && onToggleChatMention) {
onToggleChatMention(doc, isMentioned);
}
};
return (
<RowContextMenu
key={doc.id}
doc={doc}
onPreview={handleViewDocument}
onDelete={setDeleteDoc}
searchSpaceId={searchSpaceId}
>
<div
className={`relative px-3 py-2 transition-colors ${
isMentioned
? "bg-primary/5"
: "hover:bg-muted/20"
} ${canInteract && hasChatMode ? "cursor-pointer" : ""}`}
>
{canInteract && hasChatMode && (
<button
type="button"
className="absolute inset-0 z-0"
aria-label={isMentioned ? `Remove ${doc.title} from chat` : `Add ${doc.title} to chat`}
onClick={handleCardClick}
/>
)}
<div className="relative z-10 flex items-center gap-3 pointer-events-none">
<span className="pointer-events-auto">
<Checkbox
checked={isSelected}
onCheckedChange={(v) => canSelect && toggleOne(doc.id, !!v)}
disabled={!canSelect}
aria-label={canSelect ? "Select row" : "Cannot select while processing"}
className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary shrink-0 ${!canSelect ? "opacity-40 cursor-not-allowed" : ""}`}
checked={isMentioned}
onCheckedChange={() => handleCardClick()}
disabled={!canInteract}
aria-label={isMentioned ? "Remove from chat" : "Add to chat"}
className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary shrink-0 ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`}
/>
</span>
<div className="flex-1 min-w-0">
<DocumentNameTooltip
doc={doc}
className="truncate block text-sm text-foreground cursor-default"
/>
<div className="flex-1 min-w-0">
<DocumentNameTooltip
doc={doc}
className="truncate block text-sm text-foreground cursor-default"
/>
</div>
<div className="flex items-center gap-2 shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center justify-center">
{getDocumentTypeIcon(doc.document_type, "h-4 w-4")}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{getDocumentTypeLabel(doc.document_type)}
</TooltipContent>
</Tooltip>
<StatusIndicator status={doc.status} />
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center justify-center">
{getDocumentTypeIcon(doc.document_type, "h-4 w-4")}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{getDocumentTypeLabel(doc.document_type)}
</TooltipContent>
</Tooltip>
<StatusIndicator status={doc.status} />
</div>
</div>
</div>
</RowContextMenu>
);

View file

@ -1,7 +1,7 @@
"use client";
import { atom } from "jotai";
import type { Document, SurfsenseDocsDocument } from "@/contracts/types/document.types";
import type { Document } from "@/contracts/types/document.types";
/**
* Atom to store the IDs of documents mentioned in the current chat composer.
@ -30,6 +30,22 @@ export interface MentionedDocumentInfo {
document_type: string;
}
/**
* Queue atom for sidebar composer communication (additions).
* The sidebar writes documents here; the Composer picks them up,
* inserts chips, and clears the queue.
*/
export const pendingDocumentMentionsAtom = atom<
Pick<Document, "id" | "title" | "document_type">[]
>([]);
/**
* Queue atom for sidebar composer communication (removals).
* The sidebar writes { id, document_type } here; the Composer removes
* the matching chips and clears the queue.
*/
export const pendingDocumentRemovalsAtom = atom<{ id: number; document_type?: string }[]>([]);
/**
* Atom to store mentioned documents per message ID.
* This allows displaying which documents were mentioned with each user message.

View file

@ -27,6 +27,7 @@ export interface InlineMentionEditorRef {
getText: () => string;
getMentionedDocuments: () => MentionedDocument[];
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
removeDocumentChip: (docId: number, docType?: string) => void;
setDocumentChipStatus: (
docId: number,
docType: string | undefined,
@ -388,6 +389,33 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[]
);
const removeDocumentChip = useCallback(
(docId: number, docType?: string) => {
if (!editorRef.current) return;
const chipKey = `${docType ?? "UNKNOWN"}:${docId}`;
const chips = editorRef.current.querySelectorAll<HTMLSpanElement>(
`span[${CHIP_DATA_ATTR}="true"]`
);
for (const chip of chips) {
if (getChipId(chip) === docId && getChipDocType(chip) === (docType ?? "UNKNOWN")) {
chip.remove();
break;
}
}
setMentionedDocs((prev) => {
const next = new Map(prev);
next.delete(chipKey);
return next;
});
onDocumentRemove?.(docId, docType);
const text = getText();
const empty = text.length === 0 && mentionedDocs.size <= 1;
setIsEmpty(empty);
},
[getText, mentionedDocs.size, onDocumentRemove]
);
// Expose methods via ref
useImperativeHandle(ref, () => ({
focus: () => editorRef.current?.focus(),
@ -395,6 +423,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
getText,
getMentionedDocuments,
insertDocumentChip,
removeDocumentChip,
setDocumentChipStatus,
}));

View file

@ -32,6 +32,8 @@ import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import {
mentionedDocumentIdsAtom,
mentionedDocumentsAtom,
pendingDocumentMentionsAtom,
pendingDocumentRemovalsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import {
@ -229,6 +231,7 @@ const ThreadWelcome: FC = () => {
const Composer: FC = () => {
// Document mention state (atoms persist across component remounts)
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const [pendingMentions, setPendingMentions] = useAtom(pendingDocumentMentionsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const editorRef = useRef<InlineMentionEditorRef>(null);
@ -450,6 +453,40 @@ const Composer: FC = () => {
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
);
// Process documents queued from the sidebar (additions)
useEffect(() => {
if (pendingMentions.length === 0) return;
handleDocumentsMention(pendingMentions);
setPendingMentions([]);
}, [pendingMentions, handleDocumentsMention, setPendingMentions]);
// Process documents queued from the sidebar (removals)
const [pendingRemovals, setPendingRemovals] = useAtom(pendingDocumentRemovalsAtom);
useEffect(() => {
if (pendingRemovals.length === 0) return;
for (const { id, document_type } of pendingRemovals) {
editorRef.current?.removeDocumentChip(id, document_type);
}
setMentionedDocuments((prev) => {
const removalKeys = new Set(
pendingRemovals.map((r) => `${r.document_type ?? "UNKNOWN"}:${r.id}`)
);
const updated = prev.filter(
(doc) => !removalKeys.has(`${doc.document_type ?? "UNKNOWN"}:${doc.id}`)
);
setMentionedDocumentIds({
surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated;
});
setPendingRemovals([]);
}, [pendingRemovals, setPendingRemovals, setMentionedDocuments, setMentionedDocumentIds]);
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
<ChatSessionStatus

View file

@ -1,11 +1,16 @@
"use client";
import { useAtomValue } from "jotai";
import { useAtomValue, useSetAtom } from "jotai";
import { ChevronLeft } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
mentionedDocumentsAtom,
pendingDocumentMentionsAtom,
pendingDocumentRemovalsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { Button } from "@/components/ui/button";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
@ -37,9 +42,30 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
const [sortKey, setSortKey] = useState<SortKey>("created_at");
const [sortDesc, setSortDesc] = useState(true);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
const setPendingMentions = useSetAtom(pendingDocumentMentionsAtom);
const setPendingRemovals = useSetAtom(pendingDocumentRemovalsAtom);
const mentionedDocIds = useMemo(
() => new Set(mentionedDocuments.map((d) => d.id)),
[mentionedDocuments]
);
const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
if (isMentioned) {
setPendingRemovals((prev) => [...prev, { id: doc.id, document_type: doc.document_type }]);
} else {
setPendingMentions((prev) => [
...prev,
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
]);
}
},
[setPendingMentions, setPendingRemovals]
);
const isSearchMode = !!debouncedSearch.trim();
const {
@ -76,59 +102,6 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
}
return prev.filter((t) => t !== type);
});
setSelectedIds(new Set());
};
const onBulkDelete = async () => {
if (selectedIds.size === 0) {
toast.error(t("no_rows_selected"));
return;
}
const selectedDocs = displayDocs.filter((doc) => selectedIds.has(doc.id));
const deletableIds = selectedDocs
.filter((doc) => doc.status?.state !== "pending" && doc.status?.state !== "processing")
.map((doc) => doc.id);
const inProgressCount = selectedIds.size - deletableIds.length;
if (inProgressCount > 0) {
toast.warning(t("delete_in_progress_warning", { count: inProgressCount }));
}
if (deletableIds.length === 0) return;
try {
let conflictCount = 0;
const results = await Promise.all(
deletableIds.map(async (id) => {
try {
await deleteDocumentMutation({ id });
return true;
} catch (error: unknown) {
const status =
(error as { response?: { status?: number } })?.response?.status ??
(error as { status?: number })?.status;
if (status === 409) conflictCount++;
return false;
}
})
);
const okCount = results.filter((r) => r === true).length;
if (okCount === deletableIds.length) {
toast.success(t("delete_success_count", { count: okCount }));
} else if (conflictCount > 0) {
toast.error(t("delete_conflict_error", { count: conflictCount }));
} else {
toast.error(t("delete_partial_failed"));
}
if (isSearchMode) {
searchRemoveItems(deletableIds);
}
setSelectedIds(new Set());
} catch (e) {
console.error(e);
toast.error(t("delete_error"));
}
};
const handleDeleteDocument = useCallback(
@ -203,10 +176,8 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
<div className="px-4 pb-2">
<DocumentsFilters
typeCounts={realtimeTypeCounts}
selectedIds={selectedIds}
onSearch={setSearch}
searchValue={search}
onBulkDelete={onBulkDelete}
onToggleType={onToggleType}
activeTypes={activeTypes}
/>
@ -216,8 +187,6 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
documents={displayDocs}
loading={!!loading}
error={!!error}
selectedIds={selectedIds}
setSelectedIds={setSelectedIds}
sortKey={sortKey}
sortDesc={sortDesc}
onSortChange={handleSortChange}
@ -227,6 +196,8 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
loadingMore={loadingMore}
onLoadMore={onLoadMore}
isSearchMode={isSearchMode}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
/>
</div>
</>

View file

@ -44,7 +44,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none",
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none select-none",
className
)}
{...props}