Merge pull request #996 from AnishSarkar22/refactor/indexing-pipelines

feat: refactor indexing pipelines for some connectors
This commit is contained in:
Rohan Verma 2026-03-27 15:05:01 -07:00 committed by GitHub
commit 30034d68c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 10230 additions and 7585 deletions

View file

@ -4,7 +4,7 @@ import { useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Cable, Settings } from "lucide-react";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import {
globalNewLLMConfigsAtom,
@ -72,9 +72,9 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
const llmConfigLoading = preferencesLoading || globalConfigsLoading;
// Fetch document type counts via the lightweight /type-counts endpoint (cached 10 min)
const { data: documentTypeCounts, isFetching: documentTypesLoading } =
useAtomValue(documentTypeCountsAtom);
// Real-time document type counts via Zero (updates instantly as docs are indexed)
const documentTypeCounts = useZeroDocumentTypeCounts(searchSpaceId);
const documentTypesLoading = documentTypeCounts === undefined;
// Read status inbox items from shared atom (populated by LayoutDataProvider)
// instead of creating a duplicate useInbox("status") hook.

View file

@ -867,6 +867,9 @@ export const useConnectorDialog = () => {
setIsOpen(false);
setIsFromOAuth(false);
setIndexingConfig(null);
setIndexingConnector(null);
setIndexingConnectorConfig(null);
refreshConnectors();
queryClient.invalidateQueries({
@ -898,6 +901,9 @@ export const useConnectorDialog = () => {
const handleSkipIndexing = useCallback(() => {
setIsOpen(false);
setIsFromOAuth(false);
setIndexingConfig(null);
setIndexingConnector(null);
setIndexingConnectorConfig(null);
}, [setIsOpen]);
// Handle starting edit mode

View file

@ -1,6 +1,5 @@
"use client";
import { FolderPlus } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
@ -52,22 +51,25 @@ export function CreateFolderDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderPlus className="size-5 text-muted-foreground" />
{isSubfolder ? "New subfolder" : "New folder"}
</DialogTitle>
<DialogDescription>
{isSubfolder
? `Create a new folder inside "${parentFolderName}".`
: "Create a new folder at the root level."}
</DialogDescription>
<DialogContent className="select-none max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
<DialogHeader className="space-y-2 pb-2">
<div className="flex items-center gap-2 sm:gap-3">
<div className="flex-1 min-w-0">
<DialogTitle className="text-base sm:text-lg">
{isSubfolder ? "New subfolder" : "New folder"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm mt-0.5">
{isSubfolder
? `Create a new folder inside "${parentFolderName}".`
: "Create a new folder at the root level."}
</DialogDescription>
</div>
</div>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<form onSubmit={handleSubmit} className="flex flex-col gap-3 sm:gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="folder-name">Folder name</Label>
<Label htmlFor="folder-name" className="text-sm">Folder name</Label>
<Input
ref={inputRef}
id="folder-name"
@ -76,14 +78,24 @@ export function CreateFolderDialog({
onChange={(e) => setName(e.target.value)}
maxLength={255}
autoComplete="off"
className="text-sm h-9 sm:h-10"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
<DialogFooter className="flex-row justify-end gap-2 pt-2 sm:pt-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="h-8 sm:h-9 text-xs sm:text-sm"
>
Cancel
</Button>
<Button type="submit" disabled={!name.trim()}>
<Button
type="submit"
disabled={!name.trim()}
className="h-8 sm:h-9 text-xs sm:text-sm"
>
Create
</Button>
</DialogFooter>

View file

@ -1,25 +1,32 @@
"use client";
import { Eye, MoreHorizontal, Move, Pencil, Trash2 } from "lucide-react";
import React, { useCallback } from "react";
import { AlertCircle, Clock, Download, Eye, MoreHorizontal, Move, PenLine, Trash2 } from "lucide-react";
import React, { useCallback, useRef, useState } from "react";
import { useDrag } from "react-dnd";
import { getDocumentTypeIcon } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { ExportContextItems, ExportDropdownItems } from "@/components/shared/ExportMenuItems";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { cn } from "@/lib/utils";
import { DND_TYPES } from "./FolderNode";
@ -41,6 +48,9 @@ interface DocumentNodeProps {
onEdit: (doc: DocumentNodeDoc) => void;
onDelete: (doc: DocumentNodeDoc) => void;
onMove: (doc: DocumentNodeDoc) => void;
onExport?: (doc: DocumentNodeDoc, format: string) => void;
contextMenuOpen?: boolean;
onContextMenuOpenChange?: (open: boolean) => void;
}
export const DocumentNode = React.memo(function DocumentNode({
@ -52,6 +62,9 @@ export const DocumentNode = React.memo(function DocumentNode({
onEdit,
onDelete,
onMove,
onExport,
contextMenuOpen,
onContextMenuOpenChange,
}: DocumentNodeProps) {
const statusState = doc.status?.state ?? "ready";
const isSelectable = statusState !== "pending" && statusState !== "processing";
@ -74,48 +87,90 @@ export const DocumentNode = React.memo(function DocumentNode({
);
const isProcessing = statusState === "pending" || statusState === "processing";
const [dropdownOpen, setDropdownOpen] = useState(false);
const [exporting, setExporting] = useState<string | null>(null);
const rowRef = useRef<HTMLButtonElement>(null);
const handleExport = useCallback(
(format: string) => {
if (!onExport) return;
setExporting(format);
onExport(doc, format);
setTimeout(() => setExporting(null), 2000);
},
[doc, onExport]
);
const attachRef = useCallback(
(node: HTMLButtonElement | null) => {
(rowRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
drag(node);
},
[drag]
);
return (
<ContextMenu>
<ContextMenu onOpenChange={onContextMenuOpenChange}>
<ContextMenuTrigger asChild>
{/* biome-ignore lint/a11y/useSemanticElements: div required for drag ref */}
<div
ref={drag}
role="button"
tabIndex={0}
<button
type="button"
ref={attachRef}
className={cn(
"group flex h-8 items-center gap-1.5 rounded-md px-1 text-sm hover:bg-accent/50 cursor-pointer select-none",
isMentioned && "bg-accent/30",
isDragging && "opacity-40"
"group flex h-8 w-full items-center gap-2.5 rounded-md px-1 text-sm hover:bg-accent/50 cursor-pointer select-none text-left",
isMentioned && "bg-accent/30",
isDragging && "opacity-40"
)}
style={{ paddingLeft: `${depth * 16 + 4}px` }}
onClick={handleCheckChange}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleCheckChange();
}
}}
>
{isSelectable ? (
{(() => {
if (statusState === "pending") {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<Clock className="h-3.5 w-3.5 text-muted-foreground/60" />
</span>
</TooltipTrigger>
<TooltipContent side="top">Pending - waiting to be synced</TooltipContent>
</Tooltip>
);
}
if (statusState === "processing") {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<Spinner size="xs" className="text-primary" />
</span>
</TooltipTrigger>
<TooltipContent side="top">Syncing</TooltipContent>
</Tooltip>
);
}
if (statusState === "failed") {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{doc.status?.reason || "Processing failed"}
</TooltipContent>
</Tooltip>
);
}
return (
<Checkbox
checked={isMentioned}
onCheckedChange={handleCheckChange}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5 shrink-0"
/>
) : (
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<span
className={cn(
"h-2 w-2 rounded-full",
statusState === "processing" && "animate-pulse bg-amber-500",
statusState === "pending" && "bg-muted-foreground/40",
statusState === "failed" && "bg-destructive"
)}
/>
</span>
)}
);
})()}
<span className="flex-1 min-w-0 truncate">{doc.title}</span>
@ -126,25 +181,28 @@ export const DocumentNode = React.memo(function DocumentNode({
)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent",
dropdownOpen ? "opacity-100 bg-accent hover:bg-accent" : "opacity-0 group-hover:opacity-100"
)}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => onPreview(doc)}>
<Eye className="mr-2 h-4 w-4" />
Open
</DropdownMenuItem>
{isEditable && (
<DropdownMenuItem onClick={() => onEdit(doc)}>
<Pencil className="mr-2 h-4 w-4" />
<PenLine className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
)}
@ -152,7 +210,17 @@ export const DocumentNode = React.memo(function DocumentNode({
<Move className="mr-2 h-4 w-4" />
Move to...
</DropdownMenuItem>
<DropdownMenuSeparator />
{onExport && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="mr-2 h-4 w-4" />
Export
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isProcessing}
@ -163,34 +231,46 @@ export const DocumentNode = React.memo(function DocumentNode({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</button>
</ContextMenuTrigger>
<ContextMenuContent className="w-44">
<ContextMenuItem onClick={() => onPreview(doc)}>
<Eye className="mr-2 h-4 w-4" />
Open
</ContextMenuItem>
{isEditable && (
<ContextMenuItem onClick={() => onEdit(doc)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
{contextMenuOpen && (
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => onPreview(doc)}>
<Eye className="mr-2 h-4 w-4" />
Open
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onMove(doc)}>
<Move className="mr-2 h-4 w-4" />
Move to...
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive focus:text-destructive"
disabled={isProcessing}
onClick={() => onDelete(doc)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
{isEditable && (
<ContextMenuItem onClick={() => onEdit(doc)}>
<PenLine className="mr-2 h-4 w-4" />
Edit
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onMove(doc)}>
<Move className="mr-2 h-4 w-4" />
Move to...
</ContextMenuItem>
{onExport && (
<ContextMenuSub>
<ContextMenuSubTrigger>
<Download className="mr-2 h-4 w-4" />
Export
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[180px]">
<ExportContextItems onExport={handleExport} exporting={exporting} />
</ContextMenuSubContent>
</ContextMenuSub>
)}
<ContextMenuItem
className="text-destructive focus:text-destructive"
disabled={isProcessing}
onClick={() => onDelete(doc)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
);
});

View file

@ -8,7 +8,7 @@ import {
FolderPlus,
MoreHorizontal,
Move,
Pencil,
PenLine,
Trash2,
} from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
@ -18,14 +18,12 @@ import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
@ -66,6 +64,8 @@ interface FolderNodeProps {
onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void;
siblingPositions?: { before: string | null; after: string | null };
disabledDropIds?: Set<number>;
contextMenuOpen?: boolean;
onContextMenuOpenChange?: (open: boolean) => void;
}
function getDropZone(
@ -99,6 +99,8 @@ export const FolderNode = React.memo(function FolderNode({
onReorderFolder,
siblingPositions,
disabledDropIds,
contextMenuOpen,
onContextMenuOpenChange,
}: FolderNodeProps) {
const [renameValue, setRenameValue] = useState(folder.name);
const inputRef = useRef<HTMLInputElement>(null);
@ -213,7 +215,7 @@ export const FolderNode = React.memo(function FolderNode({
const FolderIcon = isExpanded ? FolderOpen : Folder;
return (
<ContextMenu>
<ContextMenu onOpenChange={onContextMenuOpenChange}>
<ContextMenuTrigger asChild disabled={isRenaming}>
{/* biome-ignore lint/a11y/useSemanticElements: div required for drag/drop refs */}
<div
@ -261,7 +263,8 @@ export const FolderNode = React.memo(function FolderNode({
onBlur={handleRenameSubmit}
onKeyDown={handleRenameKeyDown}
onClick={(e) => e.stopPropagation()}
className="flex-1 min-w-0 rounded border border-primary bg-background px-1 py-0.5 text-sm outline-none"
placeholder="Enter folder name"
className="flex-1 min-w-0 bg-transparent px-1 py-0.5 text-sm outline-none caret-primary placeholder:text-muted-foreground/50"
/>
) : (
<span className="flex-1 min-w-0 truncate">{folder.name}</span>
@ -279,13 +282,13 @@ export const FolderNode = React.memo(function FolderNode({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
className="hidden sm:inline-flex h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@ -301,7 +304,7 @@ export const FolderNode = React.memo(function FolderNode({
startRename();
}}
>
<Pencil className="mr-2 h-4 w-4" />
<PenLine className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
@ -313,7 +316,6 @@ export const FolderNode = React.memo(function FolderNode({
<Move className="mr-2 h-4 w-4" />
Move to...
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
@ -330,21 +332,20 @@ export const FolderNode = React.memo(function FolderNode({
</div>
</ContextMenuTrigger>
{!isRenaming && (
<ContextMenuContent className="w-48">
{!isRenaming && contextMenuOpen && (
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => onCreateSubfolder(folder.id)}>
<FolderPlus className="mr-2 h-4 w-4" />
New subfolder
</ContextMenuItem>
<ContextMenuItem onClick={() => startRename()}>
<Pencil className="mr-2 h-4 w-4" />
<PenLine className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={() => onMove(folder)}>
<Move className="mr-2 h-4 w-4" />
Move to...
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(folder)}

View file

@ -124,10 +124,18 @@ export function FolderPickerDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
<DialogContent className="select-none max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
<DialogHeader className="space-y-2 pb-2">
<div className="flex items-center gap-2 sm:gap-3">
<div className="flex-1 min-w-0">
<DialogTitle className="text-base sm:text-lg">{title}</DialogTitle>
{description && (
<DialogDescription className="text-xs sm:text-sm mt-0.5">
{description}
</DialogDescription>
)}
</div>
</div>
</DialogHeader>
<div className="max-h-[300px] overflow-y-auto rounded-md border p-1">
@ -147,11 +155,17 @@ export function FolderPickerDialog({
{renderPickerLevel(null, 1)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<DialogFooter className="flex-row justify-end gap-2 pt-2 sm:pt-3">
<Button
variant="secondary"
onClick={() => onOpenChange(false)}
className="h-8 sm:h-9 text-xs sm:text-sm"
>
Cancel
</Button>
<Button onClick={handleConfirm}>Move here</Button>
<Button onClick={handleConfirm} className="h-8 sm:h-9 text-xs sm:text-sm">
Move here
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -1,8 +1,8 @@
"use client";
import { useAtom } from "jotai";
import { TreePine } from "lucide-react";
import { useCallback, useMemo } from "react";
import { CirclePlus } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { renamingFolderIdAtom } from "@/atoms/documents/folder.atoms";
@ -28,6 +28,7 @@ interface FolderTreeViewProps {
onEditDocument: (doc: DocumentNodeDoc) => void;
onDeleteDocument: (doc: DocumentNodeDoc) => void;
onMoveDocument: (doc: DocumentNodeDoc) => void;
onExportDocument?: (doc: DocumentNodeDoc, format: string) => void;
activeTypes: DocumentTypeEnum[];
onDropIntoFolder?: (
itemType: "folder" | "document",
@ -62,6 +63,7 @@ export function FolderTreeView({
onEditDocument,
onDeleteDocument,
onMoveDocument,
onExportDocument,
activeTypes,
onDropIntoFolder,
onReorderFolder,
@ -80,6 +82,8 @@ export function FolderTreeView({
return counts;
}, [folders, foldersByParent, docsByFolder]);
const [openContextMenuId, setOpenContextMenuId] = useState<string | null>(null);
// Single subscription for rename state — derived boolean passed to each FolderNode
const [renamingFolderId, setRenamingFolderId] = useAtom(renamingFolderIdAtom);
const handleStartRename = useCallback(
@ -157,6 +161,8 @@ export function FolderTreeView({
onDropIntoFolder={onDropIntoFolder}
onReorderFolder={onReorderFolder}
siblingPositions={siblingPositions}
contextMenuOpen={openContextMenuId === `folder-${f.id}`}
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `folder-${f.id}` : null)}
/>
);
@ -177,6 +183,9 @@ export function FolderTreeView({
onEdit={onEditDocument}
onDelete={onDeleteDocument}
onMove={onMoveDocument}
onExport={onExportDocument}
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
/>
);
}
@ -189,7 +198,7 @@ export function FolderTreeView({
if (treeNodes.length === 0 && folders.length === 0 && documents.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
<TreePine className="h-10 w-10" />
<CirclePlus className="h-10 w-10 rotate-45" />
<p className="text-sm">No documents yet</p>
</div>
);
@ -198,7 +207,7 @@ export function FolderTreeView({
if (treeNodes.length === 0 && activeTypes.length > 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
<TreePine className="h-10 w-10" />
<CirclePlus className="h-10 w-10 rotate-45" />
<p className="text-sm">No matching documents</p>
</div>
);

View file

@ -185,7 +185,7 @@ function DateTimePickerField({
type="time"
value={time}
onChange={handleTimeChange}
className="w-[120px] text-sm shrink-0 pl-1.5 [&::-webkit-calendar-picker-indicator]:order-first [&::-webkit-calendar-picker-indicator]:mr-1"
className="w-[120px] text-sm shrink-0 appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
);

View file

@ -396,10 +396,13 @@ export function AllPrivateChatsSidebarContent({
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0",
"h-6 w-6 shrink-0 hover:bg-transparent",
isMobile
? "opacity-0 pointer-events-none absolute"
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
: openDropdownId === thread.id
? "opacity-100"
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
openDropdownId === thread.id && "bg-accent hover:bg-accent",
"transition-opacity"
)}
disabled={isBusy}

View file

@ -396,10 +396,13 @@ export function AllSharedChatsSidebarContent({
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0",
"h-6 w-6 shrink-0 hover:bg-transparent",
isMobile
? "opacity-0 pointer-events-none absolute"
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
: openDropdownId === thread.id
? "opacity-100"
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
openDropdownId === thread.id && "bg-accent hover:bg-accent",
"transition-opacity"
)}
disabled={isBusy}

View file

@ -79,14 +79,21 @@ export function ChatListItem({
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
isMobile
? "opacity-0"
: isActive
: isActive || dropdownOpen
? "opacity-100"
: "opacity-0 group-hover/item:opacity-100"
)}
>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="pointer-events-auto h-6 w-6">
<Button
variant="ghost"
size="icon"
className={cn(
"pointer-events-auto h-6 w-6 hover:bg-transparent",
dropdownOpen && "bg-accent hover:bg-accent"
)}
>
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
<span className="sr-only">{t("more_options")}</span>
</Button>

View file

@ -7,6 +7,7 @@ import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
import {
DocumentsTableShell,
@ -33,6 +34,7 @@ import { useDocumentSearch } from "@/hooks/use-document-search";
import { useDocuments } from "@/hooks/use-documents";
import { useMediaQuery } from "@/hooks/use-media-query";
import { foldersApiService } from "@/lib/apis/folders-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { queries } from "@/zero/queries/index";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
@ -234,6 +236,43 @@ export function DocumentsSidebar({
setFolderPickerOpen(true);
}, []);
const handleExportDocument = useCallback(
async (doc: DocumentNodeDoc, format: string) => {
const safeTitle =
doc.title
.replace(/[^a-zA-Z0-9 _-]/g, "_")
.trim()
.slice(0, 80) || "document";
const ext = EXPORT_FILE_EXTENSIONS[format] ?? format;
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${doc.id}/export?format=${format}`,
{ method: "GET" }
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
throw new Error(errorData.detail || "Export failed");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${safeTitle}.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error(`Export ${format} failed:`, err);
toast.error(err instanceof Error ? err.message : `Export failed`);
}
},
[searchSpaceId]
);
const handleFolderPickerSelect = useCallback(
async (targetFolderId: number | null) => {
if (!folderPickerTarget) return;
@ -606,6 +645,7 @@ export function DocumentsSidebar({
}}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument}
onExportDocument={handleExportDocument}
activeTypes={activeTypes}
onDropIntoFolder={handleDropIntoFolder}
onReorderFolder={handleReorderFolder}
@ -617,7 +657,7 @@ export function DocumentsSidebar({
open={folderPickerOpen}
onOpenChange={setFolderPickerOpen}
folders={treeFolders}
title={folderPickerTarget?.type === "folder" ? "Move folder to..." : "Move document to..."}
title={folderPickerTarget?.type === "folder" ? "Move folder to" : "Move document to"}
description="Select a destination folder, or choose Root to move to the top level."
disabledFolderIds={folderPickerTarget?.disabledIds}
onSelect={handleFolderPickerSelect}

View file

@ -7,7 +7,7 @@ import { useTheme } from "next-themes";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { useIsMobile } from "@/hooks/use-mobile";
@ -452,8 +452,8 @@ export function OnboardingTour() {
enabled: !!searchSpaceId,
});
// Get document type counts
const { data: documentTypeCounts } = useAtomValue(documentTypeCountsAtom);
// Real-time document type counts via Zero
const documentTypeCounts = useZeroDocumentTypeCounts(searchSpaceId);
// Get connectors
const { data: connectors = [] } = useAtomValue(connectorsAtom);

View file

@ -15,10 +15,9 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ExportDropdownItems, EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
@ -198,19 +197,6 @@ export function ReportPanelContent({
}
}, [currentMarkdown]);
// Maps backend format values to download file extensions
const FILE_EXTENSIONS: Record<string, string> = {
pdf: "pdf",
docx: "docx",
html: "html",
latex: "tex",
epub: "epub",
odt: "odt",
plain: "txt",
md: "md",
};
// Export report
const handleExport = useCallback(
async (format: string) => {
setExporting(format);
@ -219,7 +205,7 @@ export function ReportPanelContent({
.replace(/[^a-zA-Z0-9 _-]/g, "_")
.trim()
.slice(0, 80) || "report";
const ext = FILE_EXTENSIONS[format] ?? format;
const ext = EXPORT_FILE_EXTENSIONS[format] ?? format;
try {
if (format === "md") {
if (!currentMarkdown) return;
@ -329,68 +315,11 @@ export function ReportPanelContent({
align="start"
className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
>
{!shareToken && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Documents
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleExport("pdf")}
disabled={exporting !== null}
>
PDF (.pdf)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport("docx")}
disabled={exporting !== null}
>
Word (.docx)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport("odt")}
disabled={exporting !== null}
>
OpenDocument (.odt)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Web &amp; E-Book
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleExport("html")}
disabled={exporting !== null}
>
HTML (.html)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport("epub")}
disabled={exporting !== null}
>
EPUB (.epub)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Source &amp; Plain
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleExport("latex")}
disabled={exporting !== null}
>
LaTeX (.tex)
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={() => handleExport("md")} disabled={exporting !== null}>
Markdown (.md)
</DropdownMenuItem>
{!shareToken && (
<DropdownMenuItem
onClick={() => handleExport("plain")}
disabled={exporting !== null}
>
Plain Text (.txt)
</DropdownMenuItem>
)}
<ExportDropdownItems
onExport={handleExport}
exporting={exporting}
showAllFormats={!shareToken}
/>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -0,0 +1,142 @@
"use client";
import { Loader2 } from "lucide-react";
import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { ContextMenuItem } from "@/components/ui/context-menu";
export const EXPORT_FILE_EXTENSIONS: Record<string, string> = {
pdf: "pdf",
docx: "docx",
html: "html",
latex: "tex",
epub: "epub",
odt: "odt",
plain: "txt",
md: "md",
};
interface ExportMenuItemsProps {
onExport: (format: string) => void;
exporting: string | null;
/** Hide server-side formats (PDF, DOCX, etc.) — only show md */
showAllFormats?: boolean;
}
export function ExportDropdownItems({
onExport,
exporting,
showAllFormats = true,
}: ExportMenuItemsProps) {
const handle = (format: string) => (e: React.MouseEvent) => {
e.stopPropagation();
onExport(format);
};
return (
<>
{showAllFormats && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Documents
</DropdownMenuLabel>
<DropdownMenuItem onClick={handle("pdf")} disabled={exporting !== null}>
{exporting === "pdf" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
PDF (.pdf)
</DropdownMenuItem>
<DropdownMenuItem onClick={handle("docx")} disabled={exporting !== null}>
{exporting === "docx" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Word (.docx)
</DropdownMenuItem>
<DropdownMenuItem onClick={handle("odt")} disabled={exporting !== null}>
{exporting === "odt" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
OpenDocument (.odt)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Web &amp; E-Book
</DropdownMenuLabel>
<DropdownMenuItem onClick={handle("html")} disabled={exporting !== null}>
{exporting === "html" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
HTML (.html)
</DropdownMenuItem>
<DropdownMenuItem onClick={handle("epub")} disabled={exporting !== null}>
{exporting === "epub" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
EPUB (.epub)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Source &amp; Plain
</DropdownMenuLabel>
<DropdownMenuItem onClick={handle("latex")} disabled={exporting !== null}>
{exporting === "latex" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
LaTeX (.tex)
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={handle("md")} disabled={exporting !== null}>
{exporting === "md" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Markdown (.md)
</DropdownMenuItem>
{showAllFormats && (
<DropdownMenuItem onClick={handle("plain")} disabled={exporting !== null}>
{exporting === "plain" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Plain Text (.txt)
</DropdownMenuItem>
)}
</>
);
}
export function ExportContextItems({
onExport,
exporting,
showAllFormats = true,
}: ExportMenuItemsProps) {
const handle = (format: string) => (e: React.MouseEvent) => {
e.stopPropagation();
onExport(format);
};
return (
<>
{showAllFormats && (
<>
<ContextMenuItem onClick={handle("pdf")} disabled={exporting !== null}>
{exporting === "pdf" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
PDF (.pdf)
</ContextMenuItem>
<ContextMenuItem onClick={handle("docx")} disabled={exporting !== null}>
{exporting === "docx" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Word (.docx)
</ContextMenuItem>
<ContextMenuItem onClick={handle("odt")} disabled={exporting !== null}>
{exporting === "odt" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
OpenDocument (.odt)
</ContextMenuItem>
<ContextMenuItem onClick={handle("html")} disabled={exporting !== null}>
{exporting === "html" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
HTML (.html)
</ContextMenuItem>
<ContextMenuItem onClick={handle("epub")} disabled={exporting !== null}>
{exporting === "epub" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
EPUB (.epub)
</ContextMenuItem>
<ContextMenuItem onClick={handle("latex")} disabled={exporting !== null}>
{exporting === "latex" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
LaTeX (.tex)
</ContextMenuItem>
</>
)}
<ContextMenuItem onClick={handle("md")} disabled={exporting !== null}>
{exporting === "md" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Markdown (.md)
</ContextMenuItem>
{showAllFormats && (
<ContextMenuItem onClick={handle("plain")} disabled={exporting !== null}>
{exporting === "plain" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Plain Text (.txt)
</ContextMenuItem>
)}
</>
);
}

View file

@ -253,6 +253,12 @@ function ApprovalCard({
String(effectiveNewDescription ?? "") !== (event?.description ?? "");
const buildFinalArgs = useCallback(() => {
const base = {
event_id: event?.event_id,
document_id: event?.document_id,
connector_id: account?.id,
};
if (pendingEdits) {
const attendeesArr = pendingEdits.attendees
? pendingEdits.attendees
@ -260,22 +266,38 @@ function ApprovalCard({
.map((e) => e.trim())
.filter(Boolean)
: null;
const origAttendees = event?.attendees?.map((a) => a.email) ?? [];
return {
event_id: event?.event_id,
document_id: event?.document_id,
connector_id: account?.id,
new_summary: pendingEdits.summary || null,
new_description: pendingEdits.description || null,
new_start_datetime: pendingEdits.start_datetime || null,
new_end_datetime: pendingEdits.end_datetime || null,
new_location: pendingEdits.location || null,
new_attendees: attendeesArr,
...base,
new_summary:
pendingEdits.summary && pendingEdits.summary !== (event?.summary ?? "")
? pendingEdits.summary
: null,
new_description:
pendingEdits.description !== (event?.description ?? "")
? pendingEdits.description || null
: null,
new_start_datetime:
pendingEdits.start_datetime && pendingEdits.start_datetime !== (event?.start ?? "")
? pendingEdits.start_datetime
: null,
new_end_datetime:
pendingEdits.end_datetime && pendingEdits.end_datetime !== (event?.end ?? "")
? pendingEdits.end_datetime
: null,
new_location:
pendingEdits.location !== (event?.location ?? "")
? pendingEdits.location || null
: null,
new_attendees:
attendeesArr && attendeesArr.join(",") !== origAttendees.join(",")
? attendeesArr
: null,
};
}
return {
event_id: event?.event_id,
document_id: event?.document_id,
connector_id: account?.id,
...base,
new_summary: actionArgs.new_summary ?? null,
new_description: actionArgs.new_description ?? null,
new_start_datetime: actionArgs.new_start_datetime ?? null,