Skip to content

Commit 7deb579

Browse files
committed
feat: long file scrollable & PDF viewer (#1374)
- Replace broken <iframe> PDF preview with a react-pdf PdfViewer component that renders pages lazily via IntersectionObserver, so large PDFs don't block the renderer process. ResizeObserver for container width uses useRef + useEffect so it is always properly cleaned up on unmount. An onLoadError handler shows a friendly error message instead of a blank screen. The component is React.lazy()-loaded so the ~464 kB pdfjs-dist bundle is only fetched when the user actually opens a PDF file. - Fix long-file scrolling in the Agent Folder view: the outer content container now uses 'scrollbar overflow-y-auto' (not overflow-hidden), letting content grow naturally and trigger the scrollbar. The inner wrapper carries h-full only for the HTML iframe case. The markdown prose wrapper gets its own overflow-hidden so the global MarkDown component (used elsewhere) is not affected. - Replace scrollbar-always-visible with scrollbar in the Folder content container so the scrollbar follows the project-wide hover-to-show behaviour and does not look odd on macOS overlay-scrollbar systems. - Rewrite LongFileScroll tests: the previous tests hardcoded their own JSX that never contained the production classes being checked, so they always passed regardless of regressions. The new tests mirror the exact className expressions from Folder/index.tsx so any future regression breaks them. FileTree mock comment updated to reflect lazy-loading. Closes #1374
1 parent ea44f1e commit 7deb579

5 files changed

Lines changed: 380 additions & 8 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@
106106
"postprocessing": "^6.37.8",
107107
"react-day-picker": "^9.13.0",
108108
"react-markdown": "^10.1.0",
109+
"react-pdf": "^10.4.1",
109110
"react-resizable-panels": "^3.0.4",
110111
"react-router-dom": "^7.6.0",
111112
"remark-gfm": "^4.0.1",
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
14+
15+
import { memo, useCallback, useEffect, useRef, useState } from 'react';
16+
import { Document, Page, pdfjs } from 'react-pdf';
17+
import 'react-pdf/dist/Page/AnnotationLayer.css';
18+
import 'react-pdf/dist/Page/TextLayer.css';
19+
20+
// Configure PDF.js worker in the same module where we use Document/Page
21+
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
22+
'pdfjs-dist/build/pdf.worker.min.mjs',
23+
import.meta.url
24+
).toString();
25+
26+
const DEFAULT_PAGE_HEIGHT = 1056;
27+
28+
interface LazyPageProps {
29+
pageNumber: number;
30+
width: number | undefined;
31+
}
32+
33+
const LazyPage = memo(function LazyPage({ pageNumber, width }: LazyPageProps) {
34+
const [visible, setVisible] = useState(false);
35+
const ref = useRef<HTMLDivElement>(null);
36+
37+
useEffect(() => {
38+
const node = ref.current;
39+
if (!node) return;
40+
const observer = new IntersectionObserver(
41+
([entry]) => {
42+
if (entry.isIntersecting) {
43+
setVisible(true);
44+
observer.disconnect();
45+
}
46+
},
47+
{ rootMargin: '200px' }
48+
);
49+
observer.observe(node);
50+
return () => observer.disconnect();
51+
}, []);
52+
53+
return (
54+
<div ref={ref}>
55+
{visible ? (
56+
<Page
57+
pageNumber={pageNumber}
58+
width={width}
59+
className="mb-4 shadow-md"
60+
/>
61+
) : (
62+
<div
63+
className="bg-background-secondary mb-4"
64+
style={{ height: width ? width * 1.414 : DEFAULT_PAGE_HEIGHT }}
65+
/>
66+
)}
67+
</div>
68+
);
69+
});
70+
71+
interface PdfViewerProps {
72+
/** data URL (data:application/pdf;base64,...) or file path */
73+
content: string;
74+
}
75+
76+
export default function PdfViewer({ content }: PdfViewerProps) {
77+
const [numPages, setNumPages] = useState<number>(0);
78+
const [containerWidth, setContainerWidth] = useState<number>(0);
79+
const [loadError, setLoadError] = useState<string | null>(null);
80+
const containerRef = useRef<HTMLDivElement>(null);
81+
82+
const onDocumentLoadSuccess = useCallback(
83+
({ numPages }: { numPages: number }) => {
84+
setNumPages(numPages);
85+
setLoadError(null);
86+
},
87+
[]
88+
);
89+
90+
const onDocumentLoadError = useCallback((error: Error) => {
91+
console.error('Failed to load PDF document:', error);
92+
setLoadError(error.message ?? 'Failed to load PDF.');
93+
}, []);
94+
95+
useEffect(() => {
96+
const node = containerRef.current;
97+
if (!node) return;
98+
const observer = new ResizeObserver((entries) => {
99+
for (const entry of entries) {
100+
setContainerWidth(entry.contentRect.width);
101+
}
102+
});
103+
observer.observe(node);
104+
return () => observer.disconnect();
105+
}, []);
106+
107+
return (
108+
<div ref={containerRef} className="flex flex-col items-center gap-4">
109+
{loadError ? (
110+
<div className="flex flex-col items-center justify-center gap-2 py-12 text-text-secondary">
111+
<p className="text-sm font-medium">Failed to load PDF</p>
112+
<p className="max-w-xs text-center text-xs text-text-tertiary">
113+
{loadError}
114+
</p>
115+
</div>
116+
) : (
117+
<Document
118+
file={content}
119+
onLoadSuccess={onDocumentLoadSuccess}
120+
onLoadError={onDocumentLoadError}
121+
>
122+
{Array.from({ length: numPages }, (_, index) => (
123+
<LazyPage
124+
key={`page_${index + 1}`}
125+
pageNumber={index + 1}
126+
width={containerWidth || undefined}
127+
/>
128+
))}
129+
</Document>
130+
)}
131+
</div>
132+
);
133+
}

src/components/Folder/index.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ import {
3030
Search,
3131
SquareTerminal,
3232
} from 'lucide-react';
33-
import { useEffect, useRef, useState } from 'react';
33+
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
3434
import FolderComponent from './FolderComponent';
3535

36+
const PdfViewer = lazy(() => import('./PdfViewer'));
37+
3638
import { proxyFetchGet } from '@/api/http';
3739
import { MarkDown } from '@/components/ChatBox/MessageItem/MarkDown';
3840
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
@@ -709,12 +711,12 @@ export default function Folder({ data: _data }: { data?: Agent }) {
709711
className={`flex min-h-0 flex-1 flex-col ${selectedFile?.type === 'html' && !isShowSourceCode ? 'overflow-hidden' : 'scrollbar overflow-y-auto'}`}
710712
>
711713
<div
712-
className={`flex min-h-full flex-col ${selectedFile?.type === 'html' && !isShowSourceCode ? '' : 'p-6'} file-viewer-content`}
714+
className={`flex min-h-full flex-col ${selectedFile?.type === 'html' && !isShowSourceCode ? 'h-full' : ''} ${selectedFile?.type === 'html' && !isShowSourceCode ? '' : 'p-6'} file-viewer-content`}
713715
>
714716
{selectedFile ? (
715717
!loading ? (
716718
selectedFile.type === 'md' && !isShowSourceCode ? (
717-
<div className="prose prose-sm max-w-none">
719+
<div className="prose prose-sm max-w-none overflow-hidden">
718720
<MarkDown
719721
content={selectedFile.content || ''}
720722
enableTypewriter={false}
@@ -726,11 +728,15 @@ export default function Folder({ data: _data }: { data?: Agent }) {
726728
/>
727729
</div>
728730
) : selectedFile.type === 'pdf' ? (
729-
<iframe
730-
src={selectedFile.content as string}
731-
className="h-full w-full border-0"
732-
title={selectedFile.name}
733-
/>
731+
<Suspense
732+
fallback={
733+
<div className="flex h-full items-center justify-center">
734+
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600" />
735+
</div>
736+
}
737+
>
738+
<PdfViewer content={selectedFile.content as string} />
739+
</Suspense>
734740
) : ['csv', 'doc', 'docx', 'pptx', 'xlsx'].includes(
735741
selectedFile.type
736742
) ? (

test/unit/components/Folder/FileTree.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@
1515
import { render, screen } from '@testing-library/react';
1616
import userEvent from '@testing-library/user-event';
1717
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
18+
19+
// Mock PdfViewer to avoid pdfjs-dist requiring DOMMatrix in jsdom.
20+
// PdfViewer is lazy-loaded (React.lazy) in index.tsx, but vi.mock still
21+
// intercepts the dynamic import so the real pdfjs worker is never loaded
22+
// during unit tests.
23+
vi.mock('../../../../src/components/Folder/PdfViewer', () => ({
24+
default: () => <div data-testid="pdf-viewer-mock" />,
25+
}));
26+
1827
import { FileTree } from '../../../../src/components/Folder/index';
1928

2029
describe('FileTree', () => {

0 commit comments

Comments
 (0)