Files
dataroom/components/view/viewer/dataroom-viewer.tsx
zeekay caad90bc04 rip: enterprise dirs + paywalls — full AGPL, no commercial-license code
Drop the dual-licensed `ee/` tree entirely. All features previously
locked behind the Hanzo Dataroom Commercial License are now part of the
AGPL-3.0 core and freely available — custom domains, branded viewing,
advanced analytics, watermarks, access controls, Q&A, AI vector stores,
workflows, SAML SSO, dataroom invitations, conversations, templates.

Layout changes:
* `ee/features/{ai,conversations,workflows,permissions,storage,templates,
  dataroom-invitations,access-notifications,security,conversions}/` →
  `features/<name>/` (AGPL).
* `ee/limits/` → `lib/billing/limits/` with every plan resolving to an
  unlimited profile (no users/links/documents/domains/datarooms cap,
  conversations and watermarks always on).
* `ee/stripe/` → `lib/billing/legacy/` (already a compat shim over
  `@hanzoai/commerce`; renewal-reminder webhook is now a no-op).
* `ee/features/billing/cancellation/` deleted. Pause/unpause/cancel/
  retention routes now compile against `lib/billing/cancellation.ts`
  stubs that return 410 Gone; `isTeamPaused`/`isTeamPausedById` in
  `lib/billing/paused.ts` always report active. `CancellationModal` is
  a no-op shim in `components/billing/`.
* `app/(ee)/api/*` route group folded into `app/api/*` (SAML auth,
  SCIM, AI chat, workflows, FAQ, link-upload).

Build: `npm run build` green.
2026-06-07 11:46:48 -07:00

792 lines
28 KiB
TypeScript

import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import React from "react";
import { ViewerChatPanel } from "@/features/ai/components/viewer-chat-panel";
import {
ViewerChatLayout,
ViewerChatProvider,
} from "@/features/ai/components/viewer-chat-provider";
import { ViewerChatToggle } from "@/features/ai/components/viewer-chat-toggle";
import {
DataroomBrand,
DataroomFolder,
PermissionGroupAccessControls,
ViewerGroupAccessControls,
} from "@prisma/client";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { PanelLeftIcon, UploadIcon, XIcon } from "lucide-react";
import { usePendingUploads } from "@/context/pending-uploads-context";
import { cn } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
getHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import { sortByIndexThenName } from "@/lib/utils/sort-items-by-index-name";
import { ViewFolderTree } from "@/components/datarooms/folders";
import { SearchBoxPersisted } from "@/components/search-box";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import {
Sheet,
SheetOverlay,
SheetPortal,
SheetTrigger,
} from "@/components/ui/sheet";
import { DEFAULT_DATAROOM_VIEW_TYPE } from "../dataroom/dataroom-view";
import DocumentCard from "../dataroom/document-card";
import { DocumentUploadModal } from "../dataroom/document-upload-modal";
import FolderCard from "../dataroom/folder-card";
import IndexFileDialog from "../dataroom/index-file-dialog";
import {
IntroductionInfoButton,
IntroductionProvider,
} from "../dataroom/introduction-modal";
import DataroomNav from "../dataroom/nav-dataroom";
import PendingDocumentCard from "../dataroom/pending-document-card";
import {
ViewerSurfaceThemeProvider,
createViewerSurfaceTheme,
} from "./viewer-surface-theme";
const ViewerBreadcrumbItem = ({
folder,
setFolderId,
isLast,
dataroomIndexEnabled,
}: {
folder: any;
setFolderId: (id: string | null) => void;
isLast: boolean;
dataroomIndexEnabled?: boolean;
}) => {
const displayName = getHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
dataroomIndexEnabled || false,
);
if (isLast) {
return (
<BreadcrumbPage
className="capitalize text-[var(--viewer-text)]"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</BreadcrumbPage>
);
}
return (
<BreadcrumbLink
onClick={() => setFolderId(folder.id)}
className="cursor-pointer capitalize text-[var(--viewer-muted-text)] hover:text-[var(--viewer-text)]"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</BreadcrumbLink>
);
};
type FolderOrDocument =
| (DataroomFolder & { allowDownload: boolean })
| DataroomDocument;
export type DocumentVersion = {
id: string;
type: string;
versionNumber: number;
hasPages: boolean;
isVertical: boolean;
updatedAt: Date;
};
type DataroomDocument = {
dataroomDocumentId: string;
folderId: string | null;
id: string;
name: string;
orderIndex: number | null;
downloadOnly: boolean;
versions: DocumentVersion[];
canDownload: boolean;
canView: boolean;
hierarchicalIndex: string | null;
};
const getParentFolders = (
folderId: string | null,
folders: DataroomFolder[],
): DataroomFolder[] => {
const breadcrumbFolders: DataroomFolder[] = [];
let currentFolder = folders.find((folder) => folder.id === folderId);
while (currentFolder) {
breadcrumbFolders.unshift(currentFolder);
currentFolder = folders.find(
(folder) => folder.id === currentFolder!.parentId,
);
}
return breadcrumbFolders;
};
export default function DataroomViewer({
brand,
viewId,
linkId,
dataroom,
allowDownload,
isPreview,
folderId,
setFolderId,
accessControls,
viewerId,
viewData,
enableIndexFile,
isEmbedded,
viewerEmail,
dataroomIndexEnabled,
}: {
brand: Partial<DataroomBrand>;
viewId?: string;
linkId: string;
dataroom: any;
allowDownload: boolean;
isPreview?: boolean;
folderId: string | null;
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
accessControls: ViewerGroupAccessControls[] | PermissionGroupAccessControls[];
viewerId?: string;
viewData: DEFAULT_DATAROOM_VIEW_TYPE;
enableIndexFile?: boolean;
isEmbedded?: boolean;
viewerEmail?: string;
dataroomIndexEnabled?: boolean;
}) {
const { documents, folders, allowBulkDownload } = dataroom as {
documents: DataroomDocument[];
folders: DataroomFolder[];
allowBulkDownload: boolean;
};
const router = useRouter();
const searchQuery = (router.query.search as string)?.toLowerCase() || "";
// Tab state: "documents" (normal view) or "my-uploads" (visitor's uploads)
const [activeTab, setActiveTab] = useState<"documents" | "my-uploads">(
"documents",
);
// Get pending uploads (in-flight + persisted from server)
const {
getPendingUploadsForFolder,
getAllUploads,
hasUploads,
updatePendingUpload,
} = usePendingUploads();
const pendingUploadsForFolder = getPendingUploadsForFolder(folderId);
const allUploads = getAllUploads();
const breadcrumbFolders = useMemo(
() => getParentFolders(folderId, folders),
[folderId, folders],
);
const allDocumentsCanDownload = useMemo(() => {
if (!allowDownload) return false;
if (!documents || documents.length === 0) return false;
return documents.some((doc) => {
if (doc.versions[0].type === "notion") return false;
const accessControl = accessControls.find(
(access) => access.itemId === doc.dataroomDocumentId,
);
return accessControl?.canDownload ?? true;
});
}, [documents, accessControls, allowDownload]);
// Efficiently calculate effective updatedAt for all folders in a single pass
const folderEffectiveUpdatedAt = useMemo(() => {
const effectiveUpdatedAt = new Map<string, Date>();
// Create maps for fast lookups
const folderChildren = new Map<string, string[]>();
const folderDocuments = new Map<string, DataroomDocument[]>();
// Build folder hierarchy map
folders.forEach((folder) => {
const parentId = folder.parentId || "root";
if (!folderChildren.has(parentId)) {
folderChildren.set(parentId, []);
}
folderChildren.get(parentId)!.push(folder.id);
});
// Build document map
documents.forEach((doc) => {
const folderId = doc.folderId || "root";
if (!folderDocuments.has(folderId)) {
folderDocuments.set(folderId, []);
}
folderDocuments.get(folderId)!.push(doc);
});
// Calculate effective updatedAt bottom-up (post-order traversal)
const calculateEffectiveUpdatedAt = (folderId: string): Date => {
// Return cached result if already calculated
if (effectiveUpdatedAt.has(folderId)) {
return effectiveUpdatedAt.get(folderId)!;
}
const folder = folders.find((f) => f.id === folderId);
if (!folder) return new Date(0);
let maxDate = new Date(folder.updatedAt);
// Check documents in this folder
const docsInFolder = folderDocuments.get(folderId) || [];
docsInFolder.forEach((doc) => {
if (doc.versions && doc.versions.length > 0) {
const docDate = new Date(doc.versions[0].updatedAt);
if (docDate > maxDate) maxDate = docDate;
}
});
// Check child folders recursively
const childFolderIds = folderChildren.get(folderId) || [];
childFolderIds.forEach((childId) => {
const childDate = calculateEffectiveUpdatedAt(childId);
if (childDate > maxDate) maxDate = childDate;
});
// Cache and return result
effectiveUpdatedAt.set(folderId, maxDate);
return maxDate;
};
// Calculate for all folders
folders.forEach((folder) => {
calculateEffectiveUpdatedAt(folder.id);
});
return effectiveUpdatedAt;
}, [folders, documents]);
// create a mixedItems array with folders and documents of the current folder and memoize it
const mixedItems = useMemo(() => {
// If there's a search query, filter documents by name across all folders
if (searchQuery) {
return (documents || [])
.filter((doc) => doc.name.toLowerCase().includes(searchQuery))
.map((doc) => {
const accessControl = accessControls.find(
(access) => access.itemId === doc.dataroomDocumentId,
);
return {
...doc,
itemType: "document",
canDownload:
(accessControl?.canDownload ?? true) &&
doc.versions[0].type !== "notion",
};
})
.sort((a, b) => (a.orderIndex || 0) - (b.orderIndex || 0));
}
const mixedItems: FolderOrDocument[] = [
...(folders || [])
.filter((folder) => folder.parentId === folderId)
.map((folder) => {
const folderDocuments = documents.filter(
(doc) => doc.folderId === folder.id,
);
// Get pre-calculated effective updatedAt
const effectiveUpdatedAt =
folderEffectiveUpdatedAt.get(folder.id) ||
new Date(folder.updatedAt);
const allDocumentsCanDownload =
folderDocuments.length === 0 || // Allow download for empty folders
folderDocuments.every((doc) => {
const accessControl = accessControls.find(
(access) => access.itemId === doc.dataroomDocumentId,
);
return (
(accessControl?.canDownload ?? true) &&
doc.versions[0].type !== "notion"
);
});
return {
...folder,
updatedAt: effectiveUpdatedAt,
itemType: "folder",
allowDownload: allowDownload && allDocumentsCanDownload,
};
}),
...(documents || [])
.filter((doc) => doc.folderId === folderId)
.map((doc) => {
const accessControl = accessControls.find(
(access) => access.itemId === doc.dataroomDocumentId,
);
return {
...doc,
itemType: "document",
canDownload:
(accessControl?.canDownload ?? true) &&
doc.versions[0].type !== "notion",
};
}),
];
return sortByIndexThenName(mixedItems);
}, [
folders,
documents,
folderId,
accessControls,
allowDownload,
folderEffectiveUpdatedAt,
searchQuery,
]);
const filteredPendingUploads = useMemo(
() =>
pendingUploadsForFolder.filter((u) => {
if (!u.documentId) return true;
return !mixedItems.some(
(item) => "versions" in item && item.id === u.documentId,
);
}),
[pendingUploadsForFolder, mixedItems],
);
// Fallback reconciliation: if the document is already visible and ready,
// mark its pending upload as complete even if realtime status was missed.
useEffect(() => {
allUploads.forEach((upload) => {
if (upload.status !== "processing" || !upload.documentId) return;
const matchingDocument = documents.find((doc) => doc.id === upload.documentId);
if (!matchingDocument) return;
const primaryVersion = matchingDocument.versions[0];
if (!primaryVersion) return;
const needsProcessing = ["pdf", "docs", "slides"].includes(
primaryVersion.type,
);
const isReady = !needsProcessing || primaryVersion.hasPages;
if (isReady) {
updatePendingUpload(upload.id, { status: "complete" });
}
});
}, [allUploads, documents, updatePendingUpload]);
const renderItem = (item: FolderOrDocument) => {
if ("versions" in item) {
const isProcessing =
["docs", "slides", "pdf"].includes(item.versions[0].type) &&
!item.versions[0].hasPages;
return (
<DocumentCard
key={item.id}
document={item}
linkId={linkId}
viewId={viewId}
isPreview={!!isPreview}
allowDownload={allowDownload && item.canDownload}
isProcessing={isProcessing}
dataroomIndexEnabled={dataroomIndexEnabled}
showLastUpdated={dataroom?.showLastUpdated ?? true}
/>
);
}
return (
<FolderCard
key={item.id}
folder={item}
dataroomId={dataroom?.id}
setFolderId={setFolderId}
isPreview={!!isPreview}
linkId={linkId}
viewId={viewId}
allowDownload={item.allowDownload}
dataroomIndexEnabled={dataroomIndexEnabled}
showLastUpdated={dataroom?.showLastUpdated ?? true}
/>
);
};
const viewerSurfaceTheme = useMemo(
() =>
createViewerSurfaceTheme(
(brand as any)?.applyAccentColorToDataroomView
? brand?.accentColor
: "#ffffff",
),
[brand],
);
const mobileTreeTheme = useMemo(
() => ({
...viewerSurfaceTheme,
textTone: "dark" as const,
usesLightText: false,
}),
[viewerSurfaceTheme],
);
// Prepare documents for chat context
const documentsForChat = documents.map((doc) => ({
dataroomDocumentId: doc.dataroomDocumentId,
id: doc.id,
name: doc.name,
folderId: doc.folderId,
}));
return (
<IntroductionProvider dataroom={dataroom} viewerId={viewerId}>
<ViewerChatProvider
enabled={viewData.agentsEnabled}
dataroomId={dataroom?.id}
dataroomName={viewData.dataroomName}
linkId={linkId}
viewId={viewId}
viewerId={viewerId}
documents={documentsForChat}
folders={folders}
>
<DataroomNav
brand={brand}
linkId={linkId}
viewId={viewId}
dataroom={dataroom}
allowDownload={allDocumentsCanDownload}
allowBulkDownload={allowBulkDownload}
isPreview={isPreview}
dataroomId={dataroom?.id}
viewerId={viewerId}
viewerEmail={viewerEmail}
conversationsEnabled={viewData.conversationsEnabled}
isTeamMember={viewData.isTeamMember}
/>
<ViewerSurfaceThemeProvider value={viewerSurfaceTheme}>
<ViewerChatLayout>
<div
className="relative flex flex-1 items-center overflow-hidden bg-white dark:bg-black"
style={
viewerSurfaceTheme.palette.backgroundColor
? { backgroundColor: viewerSurfaceTheme.palette.backgroundColor }
: undefined
}
>
<div
className="relative mx-auto flex h-full w-full items-start justify-center"
style={
{
"--viewer-text": viewerSurfaceTheme.palette.textColor,
"--viewer-muted-text": viewerSurfaceTheme.palette.mutedTextColor,
"--viewer-subtle-text":
viewerSurfaceTheme.palette.subtleTextColor,
"--viewer-panel-bg": viewerSurfaceTheme.palette.panelBgColor,
"--viewer-panel-bg-hover":
viewerSurfaceTheme.palette.panelHoverBgColor,
"--viewer-panel-border":
viewerSurfaceTheme.palette.panelBorderColor,
"--viewer-panel-border-hover":
viewerSurfaceTheme.palette.panelBorderHoverColor,
"--viewer-control-bg": viewerSurfaceTheme.palette.controlBgColor,
"--viewer-control-border":
viewerSurfaceTheme.palette.controlBorderColor,
"--viewer-control-border-strong":
viewerSurfaceTheme.palette.controlBorderStrongColor,
"--viewer-control-icon":
viewerSurfaceTheme.palette.controlIconColor,
"--viewer-placeholder":
viewerSurfaceTheme.palette.controlPlaceholderColor,
} as React.CSSProperties
}
>
{/* Tree view */}
<div
className="hidden h-full w-1/4 space-y-8 overflow-auto px-3 pb-4 pt-4 md:flex md:px-6 md:pt-6 lg:px-8 lg:pt-9 xl:px-14"
>
<ScrollArea showScrollbar className="w-full">
<ViewFolderTree
folders={folders}
documents={documents}
setFolderId={setFolderId}
folderId={folderId}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
<ScrollBar orientation="horizontal" />
<ScrollBar orientation="vertical" />
</ScrollArea>
</div>
{/* Detail view */}
<ScrollArea
showScrollbar
className="h-full flex-grow overflow-auto"
>
<div
className="h-full px-3 pb-4 pt-4 md:px-6 md:pt-6 lg:px-8 lg:pt-9 xl:px-14"
>
<div className="flex items-center gap-x-2">
{/* sidebar for mobile */}
<div className="flex md:hidden">
<Sheet>
<SheetTrigger asChild>
<button className={cn(
"lg:hidden",
"text-[var(--viewer-subtle-text)]",
)}>
<PanelLeftIcon
className="h-5 w-5"
aria-hidden="true"
/>
</button>
</SheetTrigger>
<SheetPortal>
<SheetOverlay className="fixed top-[35dvh] z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<SheetPrimitive.Content
className={cn(
"fixed top-[35dvh] z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
"left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-lg",
"m-0 w-[280px] p-0 sm:w-[300px] lg:hidden",
)}
>
<div className="mt-8 h-full space-y-8 overflow-auto px-2 py-3">
<ViewerSurfaceThemeProvider value={mobileTreeTheme}>
<ViewFolderTree
folders={folders}
documents={documents}
setFolderId={setFolderId}
folderId={folderId}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
</ViewerSurfaceThemeProvider>
</div>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
</Sheet>
</div>
<div className="flex flex-1 items-center justify-between gap-x-2">
<Breadcrumb>
<BreadcrumbList className="text-[var(--viewer-muted-text)]">
<BreadcrumbItem key={"root"}>
<BreadcrumbLink
onClick={() => setFolderId(null)}
className="cursor-pointer text-[var(--viewer-muted-text)] hover:text-[var(--viewer-text)]"
>
Home
</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbFolders.map((folder, index) => (
<React.Fragment key={folder.id}>
<BreadcrumbSeparator
className="text-[var(--viewer-subtle-text)]"
/>
<BreadcrumbItem>
<ViewerBreadcrumbItem
folder={folder}
setFolderId={setFolderId}
isLast={index === breadcrumbFolders.length - 1}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
</BreadcrumbItem>
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
<div className="flex items-center gap-x-2">
<IntroductionInfoButton />
<SearchBoxPersisted
inputClassName="h-9 border-[var(--viewer-control-border)] bg-[var(--viewer-control-bg)] text-[var(--viewer-text)] placeholder:text-[var(--viewer-placeholder)] shadow-sm hover:border-[var(--viewer-control-border-strong)] focus:border-[var(--viewer-control-border-strong)]"
leftIconClassName="text-[var(--viewer-control-icon)]"
clearIconClassName="text-[var(--viewer-control-icon)] hover:text-[var(--viewer-text)]"
/>
{enableIndexFile && viewId && viewerId && (
<IndexFileDialog
linkId={linkId}
viewId={viewId}
dataroomId={dataroom?.id}
viewerId={viewerId}
viewerEmail={viewerEmail}
/>
)}
{viewData?.enableVisitorUpload && viewerId && (
<DocumentUploadModal
linkId={linkId}
dataroomId={dataroom?.id}
viewerId={viewerId}
folderId={folderId ?? undefined}
folderName={
folderId
? folders.find((f) => f.id === folderId)?.name
: undefined
}
/>
)}
</div>
</div>
</div>
{/* Tabs: Documents / My Uploads */}
{viewData?.enableVisitorUpload && hasUploads && (
<div
className="mt-4 flex items-center gap-1 border-b"
style={{ borderColor: viewerSurfaceTheme.palette.panelBorderColor }}
>
<button
onClick={() => setActiveTab("documents")}
className={cn(
"-mb-px border-b-2 px-3 py-2 text-sm font-medium transition-colors",
activeTab === "documents"
? "border-[var(--viewer-text)] text-[var(--viewer-text)]"
: "border-transparent text-[var(--viewer-subtle-text)] hover:border-[var(--viewer-panel-border-hover)] hover:text-[var(--viewer-text)]",
)}
>
Documents
</button>
<button
onClick={() => setActiveTab("my-uploads")}
className={cn(
"-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors",
activeTab === "my-uploads"
? "border-[var(--viewer-text)] text-[var(--viewer-text)]"
: "border-transparent text-[var(--viewer-subtle-text)] hover:border-[var(--viewer-panel-border-hover)] hover:text-[var(--viewer-text)]",
)}
>
<UploadIcon className="h-3.5 w-3.5" />
My Uploads
<span
className="inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-medium"
style={{
backgroundColor: viewerSurfaceTheme.palette.controlBgColor,
color: viewerSurfaceTheme.palette.mutedTextColor,
}}
>
{allUploads.length}
</span>
</button>
</div>
)}
{/* Search results banner */}
{searchQuery && activeTab === "documents" && (
<div className="mt-4 rounded-md border border-[var(--viewer-panel-border)] bg-[var(--viewer-control-bg)] px-4 py-3">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-[var(--viewer-muted-text)]">
Search results for &quot;{searchQuery}&quot;
</div>
<div className="text-xs text-[var(--viewer-subtle-text)]">
({mixedItems.length} result
{mixedItems.length !== 1 ? "s" : ""} across all folders)
</div>
</div>
</div>
)}
{activeTab === "my-uploads" ? (
/* My Uploads tab - show all uploads across all folders */
<ul
role="list"
className="-mx-4 space-y-4 overflow-auto p-4"
>
{allUploads.length === 0 ? (
<li className="py-6 text-center text-[var(--viewer-subtle-text)]">
No uploads yet. Upload documents using the &quot;Add
Document&quot; button.
</li>
) : (
allUploads.map((pendingUpload) => (
<li key={pendingUpload.id}>
<PendingDocumentCard
pendingUpload={pendingUpload}
folders={folders}
linkId={linkId}
showFolderPath
onNavigateToFolder={(id) => {
setFolderId(id);
setActiveTab("documents");
}}
/>
</li>
))
)}
</ul>
) : (
/* Documents tab - normal folder view */
<ul
role="list"
className="-mx-4 space-y-4 overflow-auto p-4"
>
{!searchQuery &&
filteredPendingUploads.map((pendingUpload) => (
<li key={pendingUpload.id}>
<PendingDocumentCard
pendingUpload={pendingUpload}
linkId={linkId}
/>
</li>
))}
{mixedItems.length === 0 &&
filteredPendingUploads.length === 0 ? (
<li className="py-6 text-center text-[var(--viewer-subtle-text)]">
{searchQuery
? "No documents match your search."
: "No items available."}
</li>
) : (
mixedItems.map((item) => (
<li key={item.id}>{renderItem(item)}</li>
))
)}
</ul>
)}
</div>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
</div>
</ViewerChatLayout>
</ViewerSurfaceThemeProvider>
{/* AI Chat Components */}
<ViewerChatPanel />
<ViewerChatToggle />
</ViewerChatProvider>
</IntroductionProvider>
);
}