diff --git a/frontend/src/features/resources/resources-constants.ts b/frontend/src/features/resources/resources-constants.ts
index 74d7fb1df..4d4af40c4 100644
--- a/frontend/src/features/resources/resources-constants.ts
+++ b/frontend/src/features/resources/resources-constants.ts
@@ -20,3 +20,13 @@ export const MAX_UPLOAD_TOTAL_SIZE_MB = 2 * 1024;
/** Mirrors `resources.MaxUploadFiles`. */
export const MAX_UPLOAD_FILES_PER_REQUEST = 1000;
+
+/**
+ * Above this many entries, mounting the FileManager tree unfiltered risks
+ * freezing the page: the tree has no row virtualization, so building and
+ * rendering tens of thousands of DOM nodes on first paint blocks the main
+ * thread (see https://github.com/vxcontrol/pentagi/issues/403). Past this
+ * size, the Resources page prompts the user to search instead of eagerly
+ * rendering the whole library.
+ */
+export const LARGE_LIBRARY_PROMPT_THRESHOLD = 5000;
diff --git a/frontend/src/features/resources/resources-utils.test.ts b/frontend/src/features/resources/resources-utils.test.ts
new file mode 100644
index 000000000..038fcd1c8
--- /dev/null
+++ b/frontend/src/features/resources/resources-utils.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest';
+
+import { shouldPromptToSearchLargeLibrary } from './resources-utils';
+
+describe('shouldPromptToSearchLargeLibrary', () => {
+ it('returns false when the count is at or below the threshold', () => {
+ expect(shouldPromptToSearchLargeLibrary(5000, false, 5000)).toBe(false);
+ expect(shouldPromptToSearchLargeLibrary(100, false, 5000)).toBe(false);
+ });
+
+ it('returns true when the count exceeds the threshold and no query is active', () => {
+ expect(shouldPromptToSearchLargeLibrary(5001, false, 5000)).toBe(true);
+ expect(shouldPromptToSearchLargeLibrary(100000, false, 5000)).toBe(true);
+ });
+
+ it('returns false once a search query is active, regardless of count', () => {
+ expect(shouldPromptToSearchLargeLibrary(100000, true, 5000)).toBe(false);
+ });
+});
diff --git a/frontend/src/features/resources/resources-utils.ts b/frontend/src/features/resources/resources-utils.ts
index c4510eede..e037373c5 100644
--- a/frontend/src/features/resources/resources-utils.ts
+++ b/frontend/src/features/resources/resources-utils.ts
@@ -48,3 +48,14 @@ export const buildResourcesDownloadHref = (files: readonly FileNode[]): string =
};
export const pluralizeItems = (count: number): string => (count === 1 ? 'item' : 'items');
+
+/**
+ * True when the library is large enough that rendering it unfiltered risks
+ * freezing the page (see `LARGE_LIBRARY_PROMPT_THRESHOLD`), and no search
+ * query is active yet to narrow the result set down.
+ */
+export const shouldPromptToSearchLargeLibrary = (
+ resourceCount: number,
+ hasActiveQuery: boolean,
+ threshold: number,
+): boolean => !hasActiveQuery && resourceCount > threshold;
diff --git a/frontend/src/pages/resources/resources.tsx b/frontend/src/pages/resources/resources.tsx
index 7f0cc957f..b32b5c60d 100644
--- a/frontend/src/pages/resources/resources.tsx
+++ b/frontend/src/pages/resources/resources.tsx
@@ -44,10 +44,16 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/
import { FileDropZone } from '@/components/ui/file-drop-zone';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { Spinner } from '@/components/ui/spinner';
+import { LARGE_LIBRARY_PROMPT_THRESHOLD } from '@/features/resources/resources-constants';
import { ResourcesCopyDialog } from '@/features/resources/resources-copy-dialog';
import { ResourcesMkdirDialog } from '@/features/resources/resources-mkdir-dialog';
import { ResourcesMoveDialog } from '@/features/resources/resources-move-dialog';
-import { buildResourcesDownloadHref, pluralizeItems, toFileNode } from '@/features/resources/resources-utils';
+import {
+ buildResourcesDownloadHref,
+ pluralizeItems,
+ shouldPromptToSearchLargeLibrary,
+ toFileNode,
+} from '@/features/resources/resources-utils';
import { useResourcesDelete } from '@/features/resources/use-resources-delete';
import { useResourcesMove } from '@/features/resources/use-resources-move';
import { useResourcesSearch } from '@/features/resources/use-resources-search';
@@ -433,6 +439,30 @@ function Resources() {
);
+ // Above LARGE_LIBRARY_PROMPT_THRESHOLD entries, the FileManager tree (which
+ // isn't virtualized) can freeze the page on first paint. Skip mounting it
+ // until the user narrows the list down with a search query.
+ const isLibraryTooLargeToBrowse = shouldPromptToSearchLargeLibrary(
+ resources.length,
+ Boolean(search.debouncedQuery.trim()),
+ LARGE_LIBRARY_PROMPT_THRESHOLD,
+ );
+
+ const largeLibraryState = (
+
+
+
+
+
+ Large resource library
+
+ This library has {resources.length.toLocaleString()} entries — too many to browse at once. Search
+ above to narrow it down.
+
+
+
+ );
+
// Error surface only when there's no data — a failed background refetch must not blank a working list.
if (error && !hasResources) {
return (
@@ -544,25 +574,29 @@ function Resources() {
-
+ {isLibraryTooLargeToBrowse ? (
+ largeLibraryState
+ ) : (
+
+ )}