Compare commits

...

1 Commits

Author SHA1 Message Date
Jamison Lahman
8183193583 feat(fe): increase preview file type support & replace TextViewModal with PreviewModal variant (#9212)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-19 15:48:56 -07:00
16 changed files with 163 additions and 401 deletions

View File

@@ -19,12 +19,16 @@ class OnyxMimeTypes:
PLAIN_TEXT_MIME_TYPE,
"text/markdown",
"text/x-markdown",
"text/x-log",
"text/x-config",
"text/tab-separated-values",
"application/json",
"application/xml",
"text/xml",
"application/x-yaml",
"application/yaml",
"text/yaml",
"text/x-yaml",
}
DOCUMENT_MIME_TYPES = {
PDF_MIME_TYPE,

View File

@@ -8,7 +8,6 @@ interface CodeBlockProps {
children?: ReactNode;
codeText: string;
showHeader?: boolean;
noPadding?: boolean;
}
const MemoizedCodeLine = memo(({ content }: { content: ReactNode }) => (
@@ -20,7 +19,6 @@ export const CodeBlock = memo(function CodeBlock({
children,
codeText,
showHeader = true,
noPadding = false,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
@@ -117,12 +115,7 @@ export const CodeBlock = memo(function CodeBlock({
return (
<>
{showHeader ? (
<div
className={cn(
"bg-background-tint-00 rounded-12 max-w-full min-w-0",
!noPadding && "px-1 pb-1"
)}
>
<div className="bg-background-tint-00 px-1 pb-1 rounded-12 max-w-full min-w-0">
{language && (
<div className="flex items-center px-2 py-1 text-sm text-text-04 gap-x-2">
<SvgCode

View File

@@ -6,7 +6,7 @@ import { ChatFileType, FileDescriptor } from "@/app/app/interfaces";
import Attachment from "@/refresh-components/Attachment";
import { InMessageImage } from "@/app/app/components/files/images/InMessageImage";
import CsvContent from "@/components/tools/CSVContent";
import TextViewModal from "@/sections/modals/TextViewModal";
import PreviewModal from "@/sections/modals/PreviewModal";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import ExpandableContentWrapper from "@/components/tools/ExpandableContentWrapper";
@@ -52,7 +52,7 @@ export default function FileDisplay({ files }: FileDisplayProps) {
return (
<>
{previewingFile && (
<TextViewModal
<PreviewModal
presentingDocument={presentingDocument}
onClose={() => setPreviewingFile(null)}
/>

View File

@@ -11,7 +11,7 @@ import { Callout } from "@/components/ui/callout";
import OnyxInitializingLoader from "@/components/OnyxInitializingLoader";
import { Persona } from "@/app/admin/agents/interfaces";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import TextViewModal from "@/sections/modals/TextViewModal";
import PreviewModal from "@/sections/modals/PreviewModal";
import { UNNAMED_CHAT } from "@/lib/constants";
import Text from "@/refresh-components/texts/Text";
import useOnMount from "@/hooks/useOnMount";
@@ -64,7 +64,7 @@ export default function SharedChatDisplay({
return (
<>
{presentingDocument && (
<TextViewModal
<PreviewModal
presentingDocument={presentingDocument}
onClose={() => setPresentingDocument(null)}
/>

View File

@@ -40,7 +40,7 @@ import { SvgUser, SvgMenu, SvgAlertTriangle } from "@opal/icons";
import { useAppBackground } from "@/providers/AppBackgroundProvider";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import DocumentsSidebar from "@/sections/document-sidebar/DocumentsSidebar";
import TextViewModal from "@/sections/modals/TextViewModal";
import PreviewModal from "@/sections/modals/PreviewModal";
import { personaIncludesRetrieval } from "@/app/app/services/lib";
import { useQueryController } from "@/providers/QueryControllerProvider";
import { eeGated } from "@/ce";
@@ -537,7 +537,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
{/* Text/document preview modal */}
{presentingDocument && (
<TextViewModal
<PreviewModal
presentingDocument={presentingDocument}
onClose={() => setPresentingDocument(null)}
/>

View File

@@ -4,7 +4,7 @@ import {
MemoizedLink,
MemoizedParagraph,
} from "@/app/app/message/MemoizedTextComponents";
import React, { useMemo, CSSProperties } from "react";
import { useMemo, CSSProperties } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
@@ -19,6 +19,7 @@ interface MinimalMarkdownProps {
content: string;
className?: string;
style?: CSSProperties;
showHeader?: boolean;
/**
* Override specific markdown renderers.
* Any renderer not provided will fall back to this component's defaults.
@@ -30,6 +31,7 @@ export default function MinimalMarkdown({
content,
className = "",
style,
showHeader = true,
components,
}: MinimalMarkdownProps) {
const markdownComponents = useMemo(() => {
@@ -43,7 +45,11 @@ export default function MinimalMarkdown({
code: ({ node, inline, className, children, ...props }: any) => {
const codeText = extractCodeText(node, content, children);
return (
<CodeBlock className={className} codeText={codeText}>
<CodeBlock
className={className}
codeText={codeText}
showHeader={showHeader}
>
{children}
</CodeBlock>
);
@@ -54,7 +60,7 @@ export default function MinimalMarkdown({
...defaults,
...(components ?? {}),
} satisfies Components;
}, [content, components]);
}, [content, components, showHeader]);
return (
<div style={style || {}} className={`${className}`}>

View File

@@ -6,7 +6,7 @@ import { Button } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import { FileDescriptor } from "@/app/app/interfaces";
import { cn } from "@/lib/utils";
import TextViewModal from "@/sections/modals/TextViewModal";
import PreviewModal from "@/sections/modals/PreviewModal";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
export interface ExpandableContentWrapperProps {
@@ -95,7 +95,7 @@ export default function ExpandableContentWrapper({
return (
<>
{expanded && (
<TextViewModal
<PreviewModal
presentingDocument={presentingDocument}
onClose={() => setExpanded(false)}
/>

View File

@@ -10,18 +10,12 @@ import { Section } from "@/layouts/general-layouts";
import { getCodeLanguage, getDataLanguage } from "@/lib/languages";
import { fetchChatFile } from "@/lib/chat/svc";
import { PreviewContext } from "@/sections/modals/PreviewModal/interfaces";
import {
getMimeLanguage,
resolveMimeType,
} from "@/sections/modals/PreviewModal/mimeUtils";
import { resolveVariant } from "@/sections/modals/PreviewModal/variants";
function resolveMimeType(mimeType: string, fileName: string): string {
if (mimeType !== "application/octet-stream") return mimeType;
const lower = fileName.toLowerCase();
if (lower.endsWith(".md") || lower.endsWith(".markdown"))
return "text/markdown";
if (lower.endsWith(".txt")) return "text/plain";
if (lower.endsWith(".csv")) return "text/csv";
return mimeType;
}
interface PreviewModalProps {
presentingDocument: MinimalOnyxDocument;
onClose: () => void;
@@ -47,9 +41,10 @@ export default function PreviewModal({
const language = useMemo(
() =>
getCodeLanguage(presentingDocument.semantic_identifier || "") ||
getMimeLanguage(mimeType) ||
getDataLanguage(presentingDocument.semantic_identifier || "") ||
"plaintext",
[presentingDocument.semantic_identifier]
[mimeType, presentingDocument.semantic_identifier]
);
const lineCount = useMemo(() => {

View File

@@ -0,0 +1,50 @@
const MIME_LANGUAGE_PREFIXES: Array<[prefix: string, language: string]> = [
["application/json", "json"],
["application/xml", "xml"],
["text/xml", "xml"],
["application/x-yaml", "yaml"],
["application/yaml", "yaml"],
["text/yaml", "yaml"],
["text/x-yaml", "yaml"],
];
const OCTET_STREAM_EXTENSION_TO_MIME: Record<string, string> = {
".md": "text/markdown",
".markdown": "text/markdown",
".txt": "text/plain",
".log": "text/plain",
".conf": "text/plain",
".sql": "text/plain",
".csv": "text/csv",
".tsv": "text/tab-separated-values",
".json": "application/json",
".xml": "application/xml",
".yml": "application/x-yaml",
".yaml": "application/x-yaml",
};
export function getMimeLanguage(mimeType: string): string | null {
return (
MIME_LANGUAGE_PREFIXES.find(([prefix]) =>
mimeType.startsWith(prefix)
)?.[1] ?? null
);
}
export function resolveMimeType(mimeType: string, fileName: string): string {
if (mimeType !== "application/octet-stream") {
return mimeType;
}
const lowerFileName = fileName.toLowerCase();
for (const [extension, resolvedMime] of Object.entries(
OCTET_STREAM_EXTENSION_TO_MIME
)) {
if (lowerFileName.endsWith(extension)) {
return resolvedMime;
}
}
return mimeType;
}

View File

@@ -0,0 +1,22 @@
"use client";
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import "@/app/app/message/custom-code-styles.css";
interface CodePreviewProps {
content: string;
language?: string | null;
}
export function CodePreview({ content, language }: CodePreviewProps) {
const normalizedContent = content.replace(/~~~/g, "\\~\\~\\~");
const fenceHeader = language ? `~~~${language}` : "~~~";
return (
<MinimalMarkdown
content={`${fenceHeader}\n${normalizedContent}\n\n~~~`}
className="w-full h-full"
showHeader={false}
/>
);
}

View File

@@ -1,10 +1,8 @@
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
import { getCodeLanguage } from "@/lib/languages";
import { CodeBlock } from "@/app/app/message/CodeBlock";
import { extractCodeText } from "@/app/app/message/codeUtils";
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
import { CodePreview } from "@/sections/modals/PreviewModal/variants/CodePreview";
import {
CopyButton,
DownloadButton,
@@ -24,20 +22,7 @@ export const codeVariant: PreviewVariant = {
: "",
renderContent: (ctx) => (
<MinimalMarkdown
content={`\`\`\`${ctx.language}\n${ctx.fileContent}\n\n\`\`\``}
className="w-full break-words h-full"
components={{
code: ({ node, children }: any) => {
const codeText = extractCodeText(node, ctx.fileContent, children);
return (
<CodeBlock className="" codeText={codeText}>
{children}
</CodeBlock>
);
},
}}
/>
<CodePreview content={ctx.fileContent} language={ctx.language} />
),
renderFooterLeft: (ctx) => (

View File

@@ -1,10 +1,9 @@
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
import { getDataLanguage } from "@/lib/languages";
import { CodeBlock } from "@/app/app/message/CodeBlock";
import { extractCodeText } from "@/app/app/message/codeUtils";
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
import { getMimeLanguage } from "@/sections/modals/PreviewModal/mimeUtils";
import { CodePreview } from "@/sections/modals/PreviewModal/variants/CodePreview";
import {
CopyButton,
DownloadButton,
@@ -22,7 +21,8 @@ function formatContent(language: string, content: string): string {
}
export const dataVariant: PreviewVariant = {
matches: (name) => !!getDataLanguage(name || ""),
matches: (name, mime) =>
!!getDataLanguage(name || "") || !!getMimeLanguage(mime),
width: "md",
height: "lg",
needsTextContent: true,
@@ -36,22 +36,7 @@ export const dataVariant: PreviewVariant = {
renderContent: (ctx) => {
const formatted = formatContent(ctx.language, ctx.fileContent);
return (
<MinimalMarkdown
content={`\`\`\`${ctx.language}\n${formatted}\n\n\`\`\``}
className="w-full break-words h-full"
components={{
code: ({ node, children }: any) => {
const codeText = extractCodeText(node, formatted, children);
return (
<CodeBlock className="" codeText={codeText}>
{children}
</CodeBlock>
);
},
}}
/>
);
return <CodePreview content={formatted} language={ctx.language} />;
},
renderFooterLeft: (ctx) => (

View File

@@ -5,6 +5,7 @@ import { pdfVariant } from "@/sections/modals/PreviewModal/variants/pdfVariant";
import { csvVariant } from "@/sections/modals/PreviewModal/variants/csvVariant";
import { markdownVariant } from "@/sections/modals/PreviewModal/variants/markdownVariant";
import { dataVariant } from "@/sections/modals/PreviewModal/variants/dataVariant";
import { textVariant } from "@/sections/modals/PreviewModal/variants/textVariant";
import { unsupportedVariant } from "@/sections/modals/PreviewModal/variants/unsupportedVariant";
import { docxVariant } from "@/sections/modals/PreviewModal/variants/docxVariant";
@@ -15,6 +16,7 @@ const PREVIEW_VARIANTS: PreviewVariant[] = [
pdfVariant,
csvVariant,
dataVariant,
textVariant,
markdownVariant,
docxVariant,
];

View File

@@ -11,7 +11,6 @@ import {
const MARKDOWN_MIMES = [
"text/markdown",
"text/x-markdown",
"text/plain",
"text/x-rst",
"text/x-org",
];

View File

@@ -0,0 +1,54 @@
import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
import { CodePreview } from "@/sections/modals/PreviewModal/variants/CodePreview";
import {
CopyButton,
DownloadButton,
} from "@/sections/modals/PreviewModal/variants/shared";
const TEXT_MIMES = [
"text/plain",
"text/x-log",
"text/x-config",
"text/tab-separated-values",
];
const TEXT_EXTENSIONS = [".txt", ".log", ".conf", ".tsv"];
export const textVariant: PreviewVariant = {
matches: (name, mime) => {
if (TEXT_MIMES.some((supportedMime) => mime.startsWith(supportedMime))) {
return true;
}
const lowerName = (name || "").toLowerCase();
return TEXT_EXTENSIONS.some((extension) => lowerName.endsWith(extension));
},
width: "md",
height: "lg",
needsTextContent: true,
headerDescription: (ctx) =>
ctx.fileContent
? `${ctx.lineCount} ${ctx.lineCount === 1 ? "line" : "lines"} · ${
ctx.fileSize
}`
: "",
renderContent: (ctx) => (
<CodePreview content={ctx.fileContent} language={ctx.language} />
),
renderFooterLeft: (ctx) => (
<Text text03 mainUiBody className="select-none">
{ctx.lineCount} {ctx.lineCount === 1 ? "line" : "lines"}
</Text>
),
renderFooterRight: (ctx) => (
<Section flexDirection="row" width="fit">
<CopyButton getText={() => ctx.fileContent} />
<DownloadButton fileUrl={ctx.fileUrl} fileName={ctx.fileName} />
</Section>
),
};

View File

@@ -1,333 +0,0 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import { Button } from "@opal/components";
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
import Text from "@/refresh-components/texts/Text";
import {
SvgDownloadCloud,
SvgFileText,
SvgZoomIn,
SvgZoomOut,
} from "@opal/icons";
import PreviewImage from "@/refresh-components/PreviewImage";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
import { cn } from "@/lib/utils";
import { Section } from "@/layouts/general-layouts";
export interface TextViewProps {
presentingDocument: MinimalOnyxDocument;
onClose: () => void;
}
export default function TextViewModal({
presentingDocument,
onClose,
}: TextViewProps) {
const [zoom, setZoom] = useState(100);
const [fileContent, setFileContent] = useState("");
const [fileUrl, setFileUrl] = useState("");
const [fileName, setFileName] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [fileType, setFileType] = useState("application/octet-stream");
const csvData = useMemo(() => {
if (!fileType.startsWith("text/csv")) {
return null;
}
const lines = fileContent.split(/\r?\n/).filter((l) => l.length > 0);
const headers = lines.length > 0 ? lines[0]?.split(",") ?? [] : [];
const rows = lines.slice(1).map((line) => line.split(","));
return { headers, rows } as { headers: string[]; rows: string[][] };
}, [fileContent, fileType]);
// Detect if a given MIME type is one of the recognized markdown formats
const isMarkdownFormat = (mimeType: string): boolean => {
const markdownFormats = [
"text/markdown",
"text/x-markdown",
"text/plain",
"text/csv",
"text/x-rst",
"text/x-org",
"txt",
];
return markdownFormats.some((format) => mimeType.startsWith(format));
};
const isImageFormat = (mimeType: string) => {
const imageFormats = [
"image/png",
"image/jpeg",
"image/gif",
"image/svg+xml",
];
return imageFormats.some((format) => mimeType.startsWith(format));
};
// Detect if a given MIME type can be rendered in an <iframe>
const isSupportedIframeFormat = (mimeType: string): boolean => {
const supportedFormats = [
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/svg+xml",
];
return supportedFormats.some((format) => mimeType.startsWith(format));
};
const fetchFile = useCallback(
async (signal?: AbortSignal) => {
setIsLoading(true);
setLoadError(null);
setFileContent("");
const fileIdLocal =
presentingDocument.document_id.split("__")[1] ||
presentingDocument.document_id;
try {
const response = await fetch(
`/api/chat/file/${encodeURIComponent(fileIdLocal)}`,
{
method: "GET",
signal,
cache: "force-cache",
}
);
if (!response.ok) {
setLoadError("Failed to load document.");
return;
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
setFileUrl((prev) => {
if (prev) {
window.URL.revokeObjectURL(prev);
}
return url;
});
const originalFileName =
presentingDocument.semantic_identifier || "document";
setFileName(originalFileName);
let contentType =
response.headers.get("Content-Type") || "application/octet-stream";
// If it's octet-stream but file name suggests a text-based extension, override accordingly
if (contentType === "application/octet-stream") {
const lowerName = originalFileName.toLowerCase();
if (lowerName.endsWith(".md") || lowerName.endsWith(".markdown")) {
contentType = "text/markdown";
} else if (lowerName.endsWith(".txt")) {
contentType = "text/plain";
} else if (lowerName.endsWith(".csv")) {
contentType = "text/csv";
}
}
setFileType(contentType);
// If the final content type looks like markdown, read its text
if (isMarkdownFormat(contentType)) {
const text = await blob.text();
setFileContent(text);
}
} catch (error) {
// Abort is expected on unmount / doc change
if (signal?.aborted) {
return;
}
setLoadError("Failed to load document.");
} finally {
// Prevent stale/aborted requests from clobbering the loading state.
// This is especially important in React StrictMode where effects can run twice.
if (!signal?.aborted) {
setIsLoading(false);
}
}
},
[presentingDocument]
);
useEffect(() => {
const controller = new AbortController();
fetchFile(controller.signal);
return () => {
controller.abort();
};
}, [fetchFile]);
useEffect(() => {
return () => {
if (fileUrl) {
window.URL.revokeObjectURL(fileUrl);
}
};
}, [fileUrl]);
const handleDownload = () => {
const link = document.createElement("a");
link.href = fileUrl;
link.download = fileName || presentingDocument.document_id;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleZoomIn = () => setZoom((prev) => Math.min(prev + 25, 200));
const handleZoomOut = () => setZoom((prev) => Math.max(prev - 25, 100));
return (
<Modal
open
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<Modal.Content
width="lg"
height="full"
preventAccidentalClose={false}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Modal.Header
icon={SvgFileText}
title={fileName || "Document"}
onClose={onClose}
>
<Section flexDirection="row" justifyContent="start" gap={0.25}>
<Button
prominence="tertiary"
onClick={handleZoomOut}
icon={SvgZoomOut}
tooltip="Zoom Out"
/>
<Text mainUiBody>{zoom}%</Text>
<Button
prominence="tertiary"
onClick={handleZoomIn}
icon={SvgZoomIn}
tooltip="Zoom In"
/>
<Button
prominence="tertiary"
onClick={handleDownload}
icon={SvgDownloadCloud}
tooltip="Download"
/>
</Section>
</Modal.Header>
<Modal.Body>
<Section>
{isLoading ? (
<SimpleLoader className="h-8 w-8" />
) : loadError ? (
<Text text03 mainUiBody>
{loadError}
</Text>
) : (
<div
className="flex flex-col flex-1 min-h-0 min-w-0 w-full transform origin-center transition-transform duration-300 ease-in-out"
style={{ transform: `scale(${zoom / 100})` }}
>
{isImageFormat(fileType) ? (
<PreviewImage
src={fileUrl}
alt={fileName}
className="w-full flex-1 min-h-0"
/>
) : isSupportedIframeFormat(fileType) ? (
<iframe
src={`${fileUrl}#toolbar=0`}
className="w-full h-full flex-1 min-h-0 border-none"
title="File Viewer"
/>
) : isMarkdownFormat(fileType) ? (
<ScrollIndicatorDiv
className="flex-1 min-h-0 p-4"
variant="shadow"
>
{csvData ? (
<Table>
<TableHeader className="sticky top-0 z-sticky">
<TableRow className="bg-background-tint-02">
{csvData.headers.map((h, i) => (
<TableHead key={i}>
<Text
as="p"
className="line-clamp-2 font-medium"
text03
mainUiBody
>
{h}
</Text>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{csvData.rows.map((row, rIdx) => (
<TableRow key={rIdx}>
{csvData.headers.map((_, cIdx) => (
<TableCell
key={cIdx}
className={cn(
cIdx === 0 &&
"sticky left-0 bg-background-tint-01",
"py-0 px-4 whitespace-normal break-words"
)}
>
{row?.[cIdx] ?? ""}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
) : (
<MinimalMarkdown
content={fileContent}
className="w-full pb-4 h-full text-lg break-words"
/>
)}
</ScrollIndicatorDiv>
) : (
<div className="flex flex-col items-center justify-center flex-1 min-h-0 p-6 gap-4">
<Text as="p" text03 mainUiBody>
This file format is not supported for preview.
</Text>
<Button onClick={handleDownload}>Download File</Button>
</div>
)}
</div>
)}
</Section>
</Modal.Body>
<Modal.Footer>
<BasicModalFooter
submit={<Button onClick={handleDownload}>Download File</Button>}
/>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}