SurfSense/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
Anish Sarkar 4088289f95 refactor(documents): remove unused document editing functionality and streamline components
- Eliminated references to document editing in DocumentNode, FolderTreeView, and DocumentsSidebar for a cleaner interface.
- Updated DocumentNode to enhance accessibility with keyboard interactions and improved click handling.
- Adjusted layout in AllChatsSidebar and MobileSidebar for better user experience and consistency across components.
2026-07-06 16:02:22 +05:30

1259 lines
39 KiB
TypeScript

"use client";
import { useQuery } from "@rocicorp/zero/react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { FolderPlus, ListFilter, SlidersVertical, Trash2 } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { makeFolderMention, mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
import { getDocumentTypeIcon } from "@/components/documents/DocumentTypeIcon";
import type { FolderDisplay } from "@/components/documents/FolderNode";
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
import { FolderTreeView } from "@/components/documents/FolderTreeView";
import { VersionHistoryDialog } from "@/components/documents/version-history";
import { useRuntimeConfig } from "@/components/providers/runtime-config";
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service";
import { authenticatedFetch } from "@/lib/auth-fetch";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
import { buildBackendUrl } from "@/lib/env-config";
import { uploadFolderScan } from "@/lib/folder-sync-upload";
import { getWorkspaceIdNumber } from "@/lib/route-params";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
import { queries } from "@/zero/queries/index";
import { SidebarSection } from "./SidebarSection";
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["USER_MEMORY", "TEAM_MEMORY"];
const MEMORY_DOCUMENTS: DocumentNodeDoc[] = [
{
id: -1001,
title: "MEMORY.md",
document_type: "USER_MEMORY",
folderId: null,
status: { state: "ready" },
},
{
id: -1002,
title: "TEAM_MEMORY.md",
document_type: "TEAM_MEMORY",
folderId: null,
status: { state: "ready" },
},
];
function isMemoryDocument(doc: { document_type: string }) {
return doc.document_type === "USER_MEMORY" || doc.document_type === "TEAM_MEMORY";
}
function downloadTextFile(content: string, fileName: string, type = "text/markdown;charset=utf-8") {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function EmbeddedDocumentsMenu({
typeCounts,
activeTypes,
onToggleType,
onCreateFolder,
}: {
typeCounts: Partial<Record<string, number>>;
activeTypes: DocumentTypeEnum[];
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
onCreateFolder: () => void;
}) {
const documentTypes = useMemo(
() => Object.keys(typeCounts).sort() as DocumentTypeEnum[],
[typeCounts]
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="relative h-7 w-7 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Document actions"
>
<SlidersVertical className="h-3.5 w-3.5" />
{activeTypes.length > 0 ? (
<span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
) : null}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={onCreateFolder}>
<FolderPlus className="h-4 w-4" />
New folder
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ListFilter className="h-4 w-4" />
Filter by type
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52 max-h-72 overflow-y-auto">
{documentTypes.length > 0 ? (
documentTypes.map((type) => (
<DropdownMenuCheckboxItem
key={type}
checked={activeTypes.includes(type)}
onCheckedChange={(checked) => onToggleType(type, checked === true)}
onSelect={(event) => event.preventDefault()}
>
{getDocumentTypeIcon(type, "h-4 w-4")}
<span className="min-w-0 flex-1 truncate">{getDocumentTypeLabel(type)}</span>
<span className="ml-auto text-xs text-muted-foreground">
{typeCounts[type] ?? 0}
</span>
</DropdownMenuCheckboxItem>
))
) : (
<DropdownMenuItem disabled>No document types</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
);
}
function CloudDocumentsSkeleton() {
const rows = [
{ id: "row-1", widthClass: "w-44" },
{ id: "row-2", widthClass: "w-32" },
{ id: "row-3", widthClass: "w-32" },
{ id: "row-4", widthClass: "w-44" },
{ id: "row-5", widthClass: "w-32" },
{ id: "row-6", widthClass: "w-32" },
{ id: "row-7", widthClass: "w-44" },
{ id: "row-8", widthClass: "w-32" },
];
return (
<div className="px-2 py-1">
<div className="space-y-1">
{rows.map((row) => (
<div key={row.id} className="flex h-8 items-center gap-2 px-2">
<Skeleton className="h-4 w-4 rounded-sm" />
<Skeleton className={`h-4 ${row.widthClass}`} />
</div>
))}
</div>
</div>
);
}
interface WatchedFolderEntry {
path: string;
name: string;
excludePatterns: string[];
fileExtensions: string[] | null;
rootFolderId: number | null;
workspaceId: number;
active: boolean;
}
interface DocumentsSidebarProps {
/** When true, renders content without any wrapper — parent provides the container */
embedded?: boolean;
}
export function DocumentsSidebar(props: DocumentsSidebarProps) {
const isAnonymous = useIsAnonymous();
const { isDesktop } = usePlatform();
if (isAnonymous) {
return <AnonymousDocumentsSidebar {...props} />;
}
return isDesktop ? (
<AuthenticatedDesktopDocumentsSidebar {...props} />
) : (
<AuthenticatedWebDocumentsSidebar {...props} />
);
}
function AuthenticatedDesktopDocumentsSidebar(props: DocumentsSidebarProps) {
return <AuthenticatedDocumentsSidebarBase {...props} desktopFeaturesEnabled />;
}
function AuthenticatedWebDocumentsSidebar(props: DocumentsSidebarProps) {
return <AuthenticatedDocumentsSidebarBase {...props} desktopFeaturesEnabled={false} />;
}
function AuthenticatedDocumentsSidebarBase({
embedded = false,
desktopFeaturesEnabled,
}: DocumentsSidebarProps & { desktopFeaturesEnabled: boolean }) {
const t = useTranslations("documents");
const params = useParams();
const platformElectronAPI = useElectronAPI();
const electronAPI = desktopFeaturesEnabled ? platformElectronAPI : null;
const { etlService } = useRuntimeConfig();
const workspaceId = getWorkspaceIdNumber(params) ?? 0;
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
const refreshWatchedIds = useCallback(async () => {
if (!electronAPI?.getWatchedFolders) return;
const api = electronAPI;
const folders = (await api.getWatchedFolders()) as WatchedFolderEntry[];
if (folders.length === 0) {
try {
const backendFolders = await documentsApiService.getWatchedFolders(workspaceId);
for (const bf of backendFolders) {
const meta = bf.metadata as Record<string, unknown> | null;
if (!meta?.watched || !meta.folder_path) continue;
await api.addWatchedFolder({
path: meta.folder_path as string,
name: bf.name,
rootFolderId: bf.id,
workspaceId: bf.workspace_id,
excludePatterns: (meta.exclude_patterns as string[]) ?? [],
fileExtensions: (meta.file_extensions as string[] | null) ?? null,
active: true,
});
}
const recovered = (await api.getWatchedFolders()) as WatchedFolderEntry[];
const ids = new Set(
recovered
.filter((f: WatchedFolderEntry) => f.rootFolderId != null)
.map((f: WatchedFolderEntry) => f.rootFolderId as number)
);
setWatchedFolderIds(ids);
return;
} catch (err) {
console.error("[DocumentsSidebar] Recovery from backend failed:", err);
}
}
const ids = new Set(
folders
.filter((f: WatchedFolderEntry) => f.rootFolderId != null)
.map((f: WatchedFolderEntry) => f.rootFolderId as number)
);
setWatchedFolderIds(ids);
}, [workspaceId, electronAPI]);
useEffect(() => {
refreshWatchedIds();
}, [refreshWatchedIds]);
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
const [sidebarDocs, setSidebarDocs] = useAtom(mentionedDocumentsAtom);
const mentionedDocKeys = useMemo(
() => new Set(sidebarDocs.map((d) => getMentionDocKey(d))),
[sidebarDocs]
);
// Folder state
const [expandedFolderMap, setExpandedFolderMap] = useAtom(expandedFolderIdsAtom);
const expandedIds = useMemo(
() => new Set(expandedFolderMap[workspaceId] ?? []),
[expandedFolderMap, workspaceId]
);
const toggleFolderExpand = useCallback(
(folderId: number) => {
setExpandedFolderMap((prev) => {
const current = new Set(prev[workspaceId] ?? []);
if (current.has(folderId)) current.delete(folderId);
else current.add(folderId);
return { ...prev, [workspaceId]: [...current] };
});
},
[workspaceId, setExpandedFolderMap]
);
// Zero queries for tree data
const [zeroFolders, zeroFoldersResult] = useQuery(
queries.folders.bySpace({ workspaceId: workspaceId })
);
const [zeroAllDocs, zeroAllDocsResult] = useQuery(
queries.documents.bySpace({ workspaceId: workspaceId })
);
const [agentCreatedDocs, setAgentCreatedDocs] = useAtom(agentCreatedDocumentsAtom);
const treeFolders: FolderDisplay[] = useMemo(
() =>
(zeroFolders ?? []).map((f) => ({
id: f.id,
name: f.name,
position: f.position,
parentId: f.parentId ?? null,
workspaceId: f.workspaceId,
metadata: f.metadata as Record<string, unknown> | null | undefined,
})),
[zeroFolders]
);
const treeDocuments: DocumentNodeDoc[] = useMemo(() => {
const zeroDocs = (zeroAllDocs ?? [])
.filter((d) => {
if (!d.title || d.title.trim() === "") return false;
const state = (d.status as { state?: string } | undefined)?.state;
if (state === "deleting") return false;
return true;
})
.map((d) => ({
id: d.id,
title: d.title,
document_type: d.documentType,
folderId: (d as { folderId?: number | null }).folderId ?? null,
status: d.status as { state: string; reason?: string | null } | undefined,
}));
const zeroIds = new Set(zeroDocs.map((d) => d.id));
const pendingAgentDocs = agentCreatedDocs
.filter((d) => d.workspaceId === workspaceId && !zeroIds.has(d.id))
.map((d) => ({
id: d.id,
title: d.title,
document_type: d.documentType,
folderId: d.folderId ?? null,
status: { state: "ready" } as { state: string; reason?: string | null },
}));
return [...pendingAgentDocs, ...zeroDocs];
}, [zeroAllDocs, agentCreatedDocs, workspaceId]);
// Prune agent-created docs once Zero has caught up
useEffect(() => {
if (!zeroAllDocs?.length || !agentCreatedDocs.length) return;
const zeroIds = new Set(zeroAllDocs.map((d) => d.id));
const remaining = agentCreatedDocs.filter((d) => !zeroIds.has(d.id));
if (remaining.length < agentCreatedDocs.length) {
setAgentCreatedDocs(remaining);
}
}, [zeroAllDocs, agentCreatedDocs, setAgentCreatedDocs]);
const foldersByParent = useMemo(() => {
const map: Record<string, FolderDisplay[]> = {};
for (const f of treeFolders) {
const key = String(f.parentId ?? "root");
if (!map[key]) map[key] = [];
map[key].push(f);
}
return map;
}, [treeFolders]);
// Folder actions
const [folderPickerOpen, setFolderPickerOpen] = useState(false);
const [folderPickerTarget, setFolderPickerTarget] = useState<{
type: "folder" | "document";
id: number;
disabledIds?: Set<number>;
} | null>(null);
// Create-folder dialog state
const [createFolderOpen, setCreateFolderOpen] = useState(false);
const [createFolderParentId, setCreateFolderParentId] = useState<number | null>(null);
const createFolderParentName = useMemo(() => {
if (createFolderParentId === null) return null;
return treeFolders.find((f) => f.id === createFolderParentId)?.name ?? null;
}, [createFolderParentId, treeFolders]);
const handleCreateFolder = useCallback((parentId: number | null) => {
setCreateFolderParentId(parentId);
setCreateFolderOpen(true);
}, []);
const handleCreateFolderConfirm = useCallback(
async (name: string) => {
try {
await foldersApiService.createFolder({
name,
parent_id: createFolderParentId,
workspace_id: workspaceId,
});
toast.success("Folder created");
if (createFolderParentId !== null) {
setExpandedFolderMap((prev) => {
const current = new Set(prev[workspaceId] ?? []);
current.add(createFolderParentId);
return { ...prev, [workspaceId]: [...current] };
});
}
} catch (e: unknown) {
toast.error((e as Error)?.message || "Failed to create folder");
}
},
[createFolderParentId, workspaceId, setExpandedFolderMap]
);
const handleRescanFolder = useCallback(
async (folder: FolderDisplay) => {
if (!electronAPI) return;
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
const matched = watchedFolders.find(
(wf: WatchedFolderEntry) => wf.rootFolderId === folder.id
);
if (!matched) {
toast.error("This folder is not being watched");
return;
}
try {
toast.info(`Re-scanning folder: ${matched.name}`);
await uploadFolderScan({
folderPath: matched.path,
folderName: matched.name,
workspaceId: workspaceId,
excludePatterns: matched.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS,
fileExtensions:
matched.fileExtensions ?? Array.from(getSupportedExtensionsSet(undefined, etlService)),
rootFolderId: folder.id,
});
toast.success(`Re-scan complete: ${matched.name}`);
} catch (err) {
toast.error((err as Error)?.message || "Failed to re-scan folder");
}
},
[workspaceId, electronAPI, etlService]
);
const handleStopWatching = useCallback(
async (folder: FolderDisplay) => {
if (!electronAPI) return;
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
const matched = watchedFolders.find(
(wf: WatchedFolderEntry) => wf.rootFolderId === folder.id
);
if (!matched) {
toast.error("This folder is not being watched");
return;
}
await electronAPI.removeWatchedFolder(matched.path);
try {
await foldersApiService.stopWatching(folder.id);
} catch (err) {
console.error("[DocumentsSidebar] Failed to clear watched metadata:", err);
}
toast.success(`Stopped watching: ${matched.name}`);
refreshWatchedIds();
},
[electronAPI, refreshWatchedIds]
);
const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => {
try {
await foldersApiService.updateFolder(folder.id, { name: newName });
toast.success("Folder renamed");
} catch (e: unknown) {
toast.error((e as Error)?.message || "Failed to rename folder");
}
}, []);
const handleDeleteFolder = useCallback(
async (folder: FolderDisplay) => {
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
try {
if (electronAPI) {
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
const matched = watchedFolders.find(
(wf: WatchedFolderEntry) => wf.rootFolderId === folder.id
);
if (matched) {
await electronAPI.removeWatchedFolder(matched.path);
}
}
await foldersApiService.deleteFolder(folder.id);
toast.success("Folder deleted");
} catch (e: unknown) {
toast.error((e as Error)?.message || "Failed to delete folder");
}
},
[electronAPI]
);
const handleMoveFolder = useCallback(
(folder: FolderDisplay) => {
const subtreeIds = new Set<number>();
function collectSubtree(id: number) {
subtreeIds.add(id);
for (const child of foldersByParent[String(id)] ?? []) {
collectSubtree(child.id);
}
}
collectSubtree(folder.id);
setFolderPickerTarget({
type: "folder",
id: folder.id,
disabledIds: subtreeIds,
});
setFolderPickerOpen(true);
},
[foldersByParent]
);
const handleMoveDocument = useCallback((doc: DocumentNodeDoc) => {
setFolderPickerTarget({ type: "document", id: doc.id });
setFolderPickerOpen(true);
}, []);
const isExportingKBRef = useRef(false);
const [exportWarningOpen, setExportWarningOpen] = useState(false);
const [exportWarningContext, setExportWarningContext] = useState<{
folder: FolderDisplay;
pendingCount: number;
} | null>(null);
const doExport = useCallback(async (url: string, downloadName: string) => {
const response = await authenticatedFetch(url, { 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 blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = downloadName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
}, []);
const handleExportWarningConfirm = useCallback(async () => {
setExportWarningOpen(false);
const ctx = exportWarningContext;
if (!ctx?.folder) return;
isExportingKBRef.current = true;
try {
const safeName =
ctx.folder.name
.replace(/[^a-zA-Z0-9 _-]/g, "_")
.trim()
.slice(0, 80) || "folder";
await doExport(
buildBackendUrl(`/api/v1/workspaces/${workspaceId}/export`, {
folder_id: ctx.folder.id,
}),
`${safeName}.zip`
);
toast.success(`Folder "${ctx.folder.name}" exported`);
} catch (err) {
console.error("Folder export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
isExportingKBRef.current = false;
}
setExportWarningContext(null);
}, [exportWarningContext, workspaceId, doExport]);
const getPendingCountInSubtree = useCallback(
(folderId: number): number => {
const subtreeIds = new Set<number>();
function collect(id: number) {
subtreeIds.add(id);
for (const child of foldersByParent[String(id)] ?? []) {
collect(child.id);
}
}
collect(folderId);
return treeDocuments.filter(
(d) =>
subtreeIds.has(d.folderId ?? -1) &&
(d.status?.state === "pending" || d.status?.state === "processing")
).length;
},
[foldersByParent, treeDocuments]
);
const handleExportFolder = useCallback(
async (folder: FolderDisplay) => {
const folderPendingCount = getPendingCountInSubtree(folder.id);
if (folderPendingCount > 0) {
setExportWarningContext({
folder,
pendingCount: folderPendingCount,
});
setExportWarningOpen(true);
return;
}
isExportingKBRef.current = true;
try {
const safeName =
folder.name
.replace(/[^a-zA-Z0-9 _-]/g, "_")
.trim()
.slice(0, 80) || "folder";
await doExport(
buildBackendUrl(`/api/v1/workspaces/${workspaceId}/export`, {
folder_id: folder.id,
}),
`${safeName}.zip`
);
toast.success(`Folder "${folder.name}" exported`);
} catch (err) {
console.error("Folder export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
isExportingKBRef.current = false;
}
},
[workspaceId, getPendingCountInSubtree, doExport]
);
const handleExportDocument = useCallback(
async (doc: DocumentNodeDoc, format: string) => {
if (isMemoryDocument(doc)) {
try {
const endpoint =
doc.document_type === "USER_MEMORY"
? buildBackendUrl("/api/v1/users/me/memory")
: buildBackendUrl(`/api/v1/workspaces/${workspaceId}/memory`);
const response = await authenticatedFetch(endpoint, { method: "GET" });
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
throw new Error(errorData.detail || "Export failed");
}
const data = (await response.json()) as { memory_md?: string };
downloadTextFile(
data.memory_md ?? "",
doc.title.endsWith(".md") ? doc.title : `${doc.title}.md`
);
return;
} catch (err) {
console.error("Memory export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed");
return;
}
}
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(
buildBackendUrl(`/api/v1/workspaces/${workspaceId}/documents/${doc.id}/export`, {
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`);
}
},
[workspaceId]
);
const handleFolderPickerSelect = useCallback(
async (targetFolderId: number | null) => {
if (!folderPickerTarget) return;
try {
if (folderPickerTarget.type === "folder") {
await foldersApiService.moveFolder(folderPickerTarget.id, {
new_parent_id: targetFolderId,
});
toast.success("Folder moved");
} else {
await foldersApiService.moveDocument(folderPickerTarget.id, {
folder_id: targetFolderId,
});
toast.success("Document moved");
}
} catch (e: unknown) {
toast.error((e as Error)?.message || "Failed to move item");
}
setFolderPickerTarget(null);
},
[folderPickerTarget]
);
const handleDropIntoFolder = useCallback(
async (itemType: "folder" | "document", itemId: number, targetFolderId: number | null) => {
try {
if (itemType === "folder") {
await foldersApiService.moveFolder(itemId, {
new_parent_id: targetFolderId,
});
toast.success("Folder moved");
} else {
await foldersApiService.moveDocument(itemId, {
folder_id: targetFolderId,
});
toast.success("Document moved");
}
} catch (e: unknown) {
toast.error((e as Error)?.message || "Failed to move item");
}
},
[]
);
const handleReorderFolder = useCallback(
async (folderId: number, beforePos: string | null, afterPos: string | null) => {
try {
await foldersApiService.reorderFolder(folderId, {
before_position: beforePos,
after_position: afterPos,
});
} catch (e: unknown) {
toast.error((e as Error)?.message || "Failed to reorder folder");
}
},
[]
);
const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
if (isMemoryDocument(doc)) return;
const key = getMentionDocKey({ ...doc, kind: "doc" });
if (isMentioned) {
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
} else {
setSidebarDocs((prev) => {
if (prev.some((d) => getMentionDocKey(d) === key)) return prev;
return [
...prev,
{
id: doc.id,
title: doc.title,
document_type: doc.document_type as DocumentTypeEnum,
kind: "doc",
},
];
});
}
},
[setSidebarDocs]
);
const handleToggleFolderSelect = useCallback(
(folderId: number, selectAll: boolean) => {
// One folder click = one folder-mention chip. The agent
// resolves the chip to its virtual path
// (``/documents/MyFolder/``) and walks it itself with
// ``ls`` / ``find_documents``. We deliberately don't
// fan out to per-doc chips anymore — the previous
// behaviour created N chips for one click and dropped
// nested folders entirely once selected, which the
// agent had no way to recover.
const folder = treeFolders.find((f) => f.id === folderId);
if (!folder) return;
const chip = makeFolderMention({ id: folder.id, name: folder.name });
const chipKey = getMentionDocKey(chip);
if (selectAll) {
setSidebarDocs((prev) => {
const exists = prev.some((d) => getMentionDocKey(d) === chipKey);
return exists ? prev : [...prev, chip];
});
} else {
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== chipKey));
}
},
[treeFolders, setSidebarDocs]
);
const treeDocumentsWithMemory = useMemo(
() => [...MEMORY_DOCUMENTS, ...treeDocuments],
[treeDocuments]
);
const openMemoryDocument = useCallback(
(doc: DocumentNodeDoc) => {
if (doc.document_type === "USER_MEMORY") {
openEditorPanel({
kind: "memory",
memoryScope: "user",
workspaceId: workspaceId,
title: doc.title,
});
return true;
}
if (doc.document_type === "TEAM_MEMORY") {
openEditorPanel({
kind: "memory",
memoryScope: "team",
workspaceId: workspaceId,
title: doc.title,
});
return true;
}
return false;
},
[openEditorPanel, workspaceId]
);
const handleResetMemoryDocument = useCallback(
async (doc: DocumentNodeDoc) => {
if (!isMemoryDocument(doc)) return;
if (!window.confirm(`Reset ${doc.title.toLowerCase()}? This clears the memory document.`)) {
return;
}
const endpoint =
doc.document_type === "USER_MEMORY"
? buildBackendUrl("/api/v1/users/me/memory/reset")
: buildBackendUrl(`/api/v1/workspaces/${workspaceId}/memory/reset`);
try {
const response = await authenticatedFetch(endpoint, { method: "POST" });
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Reset failed" }));
throw new Error(errorData.detail || "Reset failed");
}
toast.success(`${doc.title} reset`);
openMemoryDocument(doc);
} catch (error) {
toast.error((error as Error)?.message || `Failed to reset ${doc.title.toLowerCase()}`);
}
},
[openMemoryDocument, workspaceId]
);
const typeCounts = useMemo(() => {
const counts: Partial<Record<string, number>> = {};
for (const d of treeDocuments) {
const displayType = d.document_type === "LOCAL_FOLDER_FILE" ? "FILE" : d.document_type;
counts[displayType] = (counts[displayType] || 0) + 1;
}
return counts;
}, [treeDocuments]);
const deletableSelectedIds = useMemo(() => {
const treeDocMap = new Map(treeDocuments.map((d) => [d.id, d]));
return sidebarDocs
.filter((doc) => {
if (doc.kind !== "doc") return false;
const fullDoc = treeDocMap.get(doc.id);
if (!fullDoc) return false;
const state = fullDoc.status?.state ?? "ready";
return (
state !== "pending" &&
state !== "processing" &&
!NON_DELETABLE_DOCUMENT_TYPES.includes(doc.document_type)
);
})
.map((doc) => doc.id);
}, [sidebarDocs, treeDocuments]);
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [versionDocId, setVersionDocId] = useState<number | null>(null);
const handleBulkDeleteSelected = useCallback(async () => {
if (deletableSelectedIds.length === 0) return;
setIsBulkDeleting(true);
try {
const results = await Promise.allSettled(
deletableSelectedIds.map(async (id) => {
await deleteDocumentMutation({ id });
return id;
})
);
const successIds = results
.filter((r): r is PromiseFulfilledResult<number> => r.status === "fulfilled")
.map((r) => r.value);
const failed = results.length - successIds.length;
if (successIds.length > 0) {
setSidebarDocs((prev) => {
const idSet = new Set(successIds);
return prev.filter((d) => !idSet.has(d.id));
});
toast.success(`Deleted ${successIds.length} document${successIds.length !== 1 ? "s" : ""}`);
}
if (failed > 0) {
toast.error(`Failed to delete ${failed} document${failed !== 1 ? "s" : ""}`);
}
} catch {
toast.error("Failed to delete documents");
}
setIsBulkDeleting(false);
setBulkDeleteConfirmOpen(false);
}, [deletableSelectedIds, deleteDocumentMutation, setSidebarDocs]);
const onToggleType = useCallback((type: DocumentTypeEnum, checked: boolean) => {
setActiveTypes((prev) => {
if (checked) {
return prev.includes(type) ? prev : [...prev, type];
}
return prev.filter((t) => t !== type);
});
}, []);
const handleDeleteDocument = useCallback(
async (id: number): Promise<boolean> => {
try {
await deleteDocumentMutation({ id });
toast.success(t("delete_success") || "Document deleted");
setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== id));
return true;
} catch (e) {
console.error("Error deleting document:", e);
return false;
}
},
[deleteDocumentMutation, t, setSidebarDocs]
);
const showCloudSkeleton =
zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete";
const renderDocumentTree = ({
documents = treeDocumentsWithMemory,
activeTypesForTree = activeTypes,
searchQuery,
}: {
documents?: DocumentNodeDoc[];
activeTypesForTree?: DocumentTypeEnum[];
searchQuery?: string;
} = {}) => (
<div className="relative">
{deletableSelectedIds.length > 0 && (
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-center px-4 py-1.5 animate-in fade-in duration-150 pointer-events-none">
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setBulkDeleteConfirmOpen(true)}
className="pointer-events-auto h-auto gap-1.5 px-3 py-1 text-xs shadow-lg"
>
<Trash2 size={12} />
Delete {deletableSelectedIds.length}{" "}
{deletableSelectedIds.length === 1 ? "item" : "items"}
</Button>
</div>
)}
{showCloudSkeleton ? (
<CloudDocumentsSkeleton />
) : (
<FolderTreeView
folders={treeFolders}
documents={documents}
expandedIds={expandedIds}
onToggleExpand={toggleFolderExpand}
mentionedDocKeys={mentionedDocKeys}
onToggleChatMention={handleToggleChatMention}
onToggleFolderSelect={handleToggleFolderSelect}
onRenameFolder={handleRenameFolder}
onDeleteFolder={handleDeleteFolder}
onMoveFolder={handleMoveFolder}
onCreateFolder={handleCreateFolder}
searchQuery={searchQuery}
onPreviewDocument={(doc) => {
if (openMemoryDocument(doc)) return;
openEditorPanel({
documentId: doc.id,
workspaceId: workspaceId,
title: doc.title,
});
}}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument}
onResetDocument={handleResetMemoryDocument}
onExportDocument={handleExportDocument}
onVersionHistory={(doc) => setVersionDocId(doc.id)}
activeTypes={activeTypesForTree}
onDropIntoFolder={handleDropIntoFolder}
onReorderFolder={handleReorderFolder}
watchedFolderIds={watchedFolderIds}
onRescanFolder={handleRescanFolder}
onStopWatchingFolder={handleStopWatching}
onExportFolder={handleExportFolder}
/>
)}
</div>
);
if (embedded) {
return (
<>
<SidebarSection
title={t("title") || "Documents"}
defaultOpen={true}
contentClassName="px-0"
persistentAction={
<EmbeddedDocumentsMenu
typeCounts={typeCounts}
activeTypes={activeTypes}
onToggleType={onToggleType}
onCreateFolder={() => handleCreateFolder(null)}
/>
}
>
{renderDocumentTree()}
</SidebarSection>
{versionDocId !== null && (
<VersionHistoryDialog
open
onOpenChange={(open) => {
if (!open) setVersionDocId(null);
}}
documentId={versionDocId}
/>
)}
<FolderPickerDialog
open={folderPickerOpen}
onOpenChange={setFolderPickerOpen}
folders={treeFolders}
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}
/>
<CreateFolderDialog
open={createFolderOpen}
onOpenChange={setCreateFolderOpen}
parentFolderName={createFolderParentName}
onConfirm={handleCreateFolderConfirm}
/>
<AlertDialog
open={bulkDeleteConfirmOpen}
onOpenChange={(open) => !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete {deletableSelectedIds.length} document
{deletableSelectedIds.length !== 1 ? "s" : ""}?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone.{" "}
{deletableSelectedIds.length === 1
? "This document"
: `These ${deletableSelectedIds.length} documents`}{" "}
will be permanently deleted from your workspace.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBulkDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleBulkDeleteSelected();
}}
disabled={isBulkDeleting}
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<span className={isBulkDeleting ? "opacity-0" : ""}>Delete</span>
{isBulkDeleting && <Spinner size="sm" className="absolute" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={exportWarningOpen}
onOpenChange={(open) => {
if (!open) {
setExportWarningOpen(false);
setExportWarningContext(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Some documents are still processing</AlertDialogTitle>
<AlertDialogDescription>
{exportWarningContext?.pendingCount} document
{exportWarningContext?.pendingCount !== 1 ? "s are" : " is"} currently being
processed and will be excluded from the export. Do you want to continue?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleExportWarningConfirm}>
Export anyway
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
return null;
}
// ---------------------------------------------------------------------------
// Anonymous Documents Sidebar
// ---------------------------------------------------------------------------
function AnonymousDocumentsSidebar({ embedded = false }: DocumentsSidebarProps) {
const t = useTranslations("documents");
const anonMode = useAnonymousMode();
const { gate } = useLoginGate();
const [sidebarDocs, setSidebarDocs] = useAtom(mentionedDocumentsAtom);
const mentionedDocKeys = useMemo(
() => new Set(sidebarDocs.map((d) => getMentionDocKey(d))),
[sidebarDocs]
);
const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
const key = getMentionDocKey({ ...doc, kind: "doc" });
if (isMentioned) {
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
} else {
setSidebarDocs((prev) => {
if (prev.some((d) => getMentionDocKey(d) === key)) return prev;
return [
...prev,
{
id: doc.id,
title: doc.title,
document_type: doc.document_type as DocumentTypeEnum,
kind: "doc",
},
];
});
}
},
[setSidebarDocs]
);
const uploadedDoc = anonMode.isAnonymous ? anonMode.uploadedDoc : null;
const hasDoc = uploadedDoc !== null;
const handleRemoveDoc = useCallback(() => {
if (anonMode.isAnonymous) {
anonMode.setUploadedDoc(null);
}
}, [anonMode]);
const treeDocuments: DocumentNodeDoc[] = useMemo(() => {
if (!anonMode.isAnonymous || !anonMode.uploadedDoc) return [];
return [
{
id: -1,
title: anonMode.uploadedDoc.filename,
document_type: "FILE",
folderId: null,
status: { state: "ready" } as { state: string; reason?: string | null },
},
];
}, [anonMode]);
if (embedded) {
return (
<SidebarSection
title={t("title") || "Documents"}
defaultOpen={true}
contentClassName="px-0"
persistentAction={
<EmbeddedDocumentsMenu
typeCounts={hasDoc ? { FILE: 1 } : {}}
activeTypes={[]}
onToggleType={() => {}}
onCreateFolder={() => gate("create folders")}
/>
}
>
<FolderTreeView
folders={[]}
documents={treeDocuments}
expandedIds={new Set()}
onToggleExpand={() => {}}
mentionedDocKeys={mentionedDocKeys}
onToggleChatMention={handleToggleChatMention}
onToggleFolderSelect={() => {}}
onRenameFolder={() => gate("rename folders")}
onDeleteFolder={() => gate("delete folders")}
onMoveFolder={() => gate("organize folders")}
onCreateFolder={() => gate("create folders")}
onPreviewDocument={() => gate("preview documents")}
onDeleteDocument={async () => {
handleRemoveDoc();
setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== -1));
return true;
}}
onMoveDocument={() => gate("organize documents")}
onExportDocument={() => gate("export documents")}
onVersionHistory={() => gate("view version history")}
activeTypes={[]}
onDropIntoFolder={async () => gate("organize documents")}
onReorderFolder={async () => gate("organize folders")}
watchedFolderIds={new Set()}
onRescanFolder={() => gate("watch local folders")}
onStopWatchingFolder={() => gate("watch local folders")}
onExportFolder={() => gate("export folders")}
/>
</SidebarSection>
);
}
return null;
}