Compare commits

..

2 Commits

Author SHA1 Message Date
Dane Urban
0604d9ca83 md formatting 2026-02-22 18:17:06 -08:00
Dane Urban
921f5d9e96 preview modal 2026-02-22 17:42:30 -08:00
11 changed files with 850 additions and 447 deletions

View File

@@ -243,12 +243,12 @@ USAGE_LIMIT_CHUNKS_INDEXED_PAID = int(
)
# Per-week API calls using API keys or Personal Access Tokens
USAGE_LIMIT_API_CALLS_TRIAL = int(os.environ.get("USAGE_LIMIT_API_CALLS_TRIAL", "0"))
USAGE_LIMIT_API_CALLS_TRIAL = int(os.environ.get("USAGE_LIMIT_API_CALLS_TRIAL", "400"))
USAGE_LIMIT_API_CALLS_PAID = int(os.environ.get("USAGE_LIMIT_API_CALLS_PAID", "40000"))
# Per-week non-streaming API calls (more expensive, so lower limits)
USAGE_LIMIT_NON_STREAMING_CALLS_TRIAL = int(
os.environ.get("USAGE_LIMIT_NON_STREAMING_CALLS_TRIAL", "0")
os.environ.get("USAGE_LIMIT_NON_STREAMING_CALLS_TRIAL", "80")
)
USAGE_LIMIT_NON_STREAMING_CALLS_PAID = int(
os.environ.get("USAGE_LIMIT_NON_STREAMING_CALLS_PAID", "160")

View File

@@ -31,7 +31,6 @@ import Button from "@/refresh-components/buttons/Button";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import Text from "@/refresh-components/texts/Text";
import { SvgEdit, SvgKey, SvgRefreshCw } from "@opal/icons";
import { useCloudSubscription } from "@/hooks/useCloudSubscription";
function Main() {
const {
@@ -40,8 +39,6 @@ function Main() {
error,
} = useSWR<APIKey[]>("/api/admin/api-key", errorHandlingFetcher);
const canCreateKeys = useCloudSubscription();
const [fullApiKey, setFullApiKey] = useState<string | null>(null);
const [keyIsGenerating, setKeyIsGenerating] = useState(false);
const [showCreateUpdateForm, setShowCreateUpdateForm] = useState(false);
@@ -73,23 +70,12 @@ function Main() {
const introSection = (
<div className="flex flex-col items-start gap-4">
<Text as="p">
API Keys allow you to access Onyx APIs programmatically.
{canCreateKeys
? " Click the button below to generate a new API Key."
: ""}
API Keys allow you to access Onyx APIs programmatically. Click the
button below to generate a new API Key.
</Text>
{canCreateKeys ? (
<CreateButton onClick={() => setShowCreateUpdateForm(true)}>
Create API Key
</CreateButton>
) : (
<div className="flex flex-col gap-2 rounded-lg bg-background-tint-02 p-4">
<Text as="p" text04>
This feature requires an active paid subscription.
</Text>
<Button href="/admin/billing">Upgrade Plan</Button>
</div>
)}
<CreateButton onClick={() => setShowCreateUpdateForm(true)}>
Create API Key
</CreateButton>
</div>
);
@@ -123,7 +109,7 @@ function Main() {
title="New API Key"
icon={SvgKey}
onClose={() => setFullApiKey(null)}
description="Make sure you copy your new API key. You won't be able to see this key again."
description="Make sure you copy your new API key. You wont be able to see this key again."
/>
<Modal.Body>
<Text as="p" className="break-all flex-1">
@@ -138,94 +124,88 @@ function Main() {
{introSection}
{canCreateKeys && (
<>
<Separator />
<Separator />
<Title className="mt-6">Existing API Keys</Title>
<Table className="overflow-visible">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>API Key</TableHead>
<TableHead>Role</TableHead>
<TableHead>Regenerate</TableHead>
<TableHead>Delete</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredApiKeys.map((apiKey) => (
<TableRow key={apiKey.api_key_id}>
<TableCell>
<Button
internal
onClick={() => handleEdit(apiKey)}
leftIcon={SvgEdit}
>
{apiKey.api_key_name || <i>null</i>}
</Button>
</TableCell>
<TableCell className="max-w-64">
{apiKey.api_key_display}
</TableCell>
<TableCell className="max-w-64">
{apiKey.api_key_role.toUpperCase()}
</TableCell>
<TableCell>
<Button
internal
leftIcon={SvgRefreshCw}
onClick={async () => {
setKeyIsGenerating(true);
const response = await regenerateApiKey(apiKey);
setKeyIsGenerating(false);
if (!response.ok) {
const errorMsg = await response.text();
toast.error(
`Failed to regenerate API Key: ${errorMsg}`
);
return;
}
const newKey = (await response.json()) as APIKey;
setFullApiKey(newKey.api_key);
mutate("/api/admin/api-key");
}}
>
Refresh
</Button>
</TableCell>
<TableCell>
<DeleteButton
onClick={async () => {
const response = await deleteApiKey(apiKey.api_key_id);
if (!response.ok) {
const errorMsg = await response.text();
toast.error(`Failed to delete API Key: ${errorMsg}`);
return;
}
mutate("/api/admin/api-key");
}}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Title className="mt-6">Existing API Keys</Title>
<Table className="overflow-visible">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>API Key</TableHead>
<TableHead>Role</TableHead>
<TableHead>Regenerate</TableHead>
<TableHead>Delete</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredApiKeys.map((apiKey) => (
<TableRow key={apiKey.api_key_id}>
<TableCell>
<Button
internal
onClick={() => handleEdit(apiKey)}
leftIcon={SvgEdit}
>
{apiKey.api_key_name || <i>null</i>}
</Button>
</TableCell>
<TableCell className="max-w-64">
{apiKey.api_key_display}
</TableCell>
<TableCell className="max-w-64">
{apiKey.api_key_role.toUpperCase()}
</TableCell>
<TableCell>
<Button
internal
leftIcon={SvgRefreshCw}
onClick={async () => {
setKeyIsGenerating(true);
const response = await regenerateApiKey(apiKey);
setKeyIsGenerating(false);
if (!response.ok) {
const errorMsg = await response.text();
toast.error(`Failed to regenerate API Key: ${errorMsg}`);
return;
}
const newKey = (await response.json()) as APIKey;
setFullApiKey(newKey.api_key);
mutate("/api/admin/api-key");
}}
>
Refresh
</Button>
</TableCell>
<TableCell>
<DeleteButton
onClick={async () => {
const response = await deleteApiKey(apiKey.api_key_id);
if (!response.ok) {
const errorMsg = await response.text();
toast.error(`Failed to delete API Key: ${errorMsg}`);
return;
}
mutate("/api/admin/api-key");
}}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{showCreateUpdateForm && (
<OnyxApiKeyForm
onCreateApiKey={(apiKey) => {
setFullApiKey(apiKey.api_key);
}}
onClose={() => {
setShowCreateUpdateForm(false);
setSelectedApiKey(undefined);
mutate("/api/admin/api-key");
}}
apiKey={selectedApiKey}
/>
)}
</>
{showCreateUpdateForm && (
<OnyxApiKeyForm
onCreateApiKey={(apiKey) => {
setFullApiKey(apiKey.api_key);
}}
onClose={() => {
setShowCreateUpdateForm(false);
setSelectedApiKey(undefined);
mutate("/api/admin/api-key");
}}
apiKey={selectedApiKey}
/>
)}
</>
);

View File

@@ -1,25 +0,0 @@
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import { hasPaidSubscription } from "@/lib/billing/interfaces";
import { useBillingInformation } from "@/hooks/useBillingInformation";
/**
* Returns whether the current tenant has an active paid subscription on cloud.
*
* Self-hosted deployments always return true (no billing gate).
* Cloud deployments check billing status via the billing API.
* Returns true while loading to avoid flashing the upgrade prompt.
*/
export function useCloudSubscription(): boolean {
const { data: billingData, isLoading } = useBillingInformation();
if (!NEXT_PUBLIC_CLOUD_ENABLED) {
return true;
}
// Treat loading as subscribed to avoid UI flash
if (isLoading || billingData == null) {
return true;
}
return hasPaidSubscription(billingData);
}

View File

@@ -133,19 +133,6 @@ export function hasActiveSubscription(
return data.status !== null;
}
/**
* Check if the response indicates an active *paid* subscription.
* Returns true only for status === "active" (excludes trialing, past_due, etc.).
*/
export function hasPaidSubscription(
data: BillingInformation | SubscriptionStatus
): data is BillingInformation {
if ("subscribed" in data) {
return false;
}
return data.status === BillingStatus.ACTIVE;
}
/**
* Check if a license is valid and active.
*/

View File

@@ -7,15 +7,27 @@ interface LinguistLanguage {
filenames?: string[];
}
const allLanguages = Object.values(languages) as LinguistLanguage[];
// Collect extensions that linguist-languages assigns to "Markdown" so we can
// exclude them from the code-language map (some programming languages also
// claim `.md`).
const markdownExtensions = new Set(
allLanguages
.find((lang) => lang.name === "Markdown")
?.extensions?.map((ext) => ext.toLowerCase()) ?? []
);
// Build extension → language name and filename → language name maps at module load
const extensionMap = new Map<string, string>();
const filenameMap = new Map<string, string>();
for (const lang of Object.values(languages) as LinguistLanguage[]) {
for (const lang of allLanguages) {
if (lang.type !== "programming") continue;
const name = lang.name.toLowerCase();
for (const ext of lang.extensions ?? []) {
if (markdownExtensions.has(ext)) continue;
// First language to claim an extension wins
if (!extensionMap.has(ext)) {
extensionMap.set(ext, name);
@@ -38,3 +50,12 @@ export function getCodeLanguage(name: string): string | null {
const ext = lower.match(/\.[^.]+$/)?.[0];
return (ext && extensionMap.get(ext)) ?? filenameMap.get(lower) ?? null;
}
/**
* Returns true if the file name has a Markdown extension (as defined by
* linguist-languages) and should be rendered as rich text rather than code.
*/
export function isMarkdownFile(name: string): boolean {
const ext = name.toLowerCase().match(/\.[^.]+$/)?.[0];
return !!ext && markdownExtensions.has(ext);
}

View File

@@ -25,9 +25,7 @@ import { AppPopup } from "@/app/app/components/AppPopup";
import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal";
import { useUser } from "@/providers/UserProvider";
import NoAssistantModal from "@/components/modals/NoAssistantModal";
import TextViewModal from "@/sections/modals/TextViewModal";
import CodeViewModal from "@/sections/modals/CodeViewModal";
import { getCodeLanguage } from "@/lib/languages";
import PreviewModal from "@/sections/modals/PreviewModal";
import Modal from "@/refresh-components/Modal";
import { useSendMessageToParent } from "@/lib/extension/utils";
import { SUBMIT_MESSAGE_TYPES } from "@/lib/extension/constants";
@@ -686,18 +684,12 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
</div>
)}
{presentingDocument &&
(getCodeLanguage(presentingDocument.semantic_identifier || "") ? (
<CodeViewModal
presentingDocument={presentingDocument}
onClose={() => setPresentingDocument(null)}
/>
) : (
<TextViewModal
presentingDocument={presentingDocument}
onClose={() => setPresentingDocument(null)}
/>
))}
{presentingDocument && (
<PreviewModal
presentingDocument={presentingDocument}
onClose={() => setPresentingDocument(null)}
/>
)}
{stackTraceModalContent && (
<ExceptionTraceModal

View File

@@ -65,7 +65,6 @@ import { Interactive } from "@opal/core";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { useSettingsContext } from "@/providers/SettingsProvider";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import { useCloudSubscription } from "@/hooks/useCloudSubscription";
interface PAT {
id: number;
@@ -938,8 +937,6 @@ function AccountsAccessSettings() {
useState<CreatedTokenState | null>(null);
const [tokenToDelete, setTokenToDelete] = useState<PAT | null>(null);
const canCreateTokens = useCloudSubscription();
const showPasswordSection = Boolean(user?.password_configured);
const showTokensSection = authType !== null;
@@ -1248,104 +1245,93 @@ function AccountsAccessSettings() {
{showTokensSection && (
<Section gap={0.75}>
<InputLayouts.Title title="Access Tokens" />
{canCreateTokens ? (
<Card padding={0.25}>
<Section gap={0}>
<Section flexDirection="row" padding={0.25} gap={0.5}>
{pats.length === 0 ? (
<Section padding={0.5} alignItems="start">
<Text text03 secondaryBody>
{isLoading
? "Loading tokens..."
: "No access tokens created."}
</Text>
</Section>
) : (
<InputTypeIn
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
leftSearchIcon
variant="internal"
/>
)}
<CreateButton
onClick={() => setShowCreateModal(true)}
secondary={false}
internal
transient={showCreateModal}
rightIcon
>
New Access Token
</CreateButton>
</Section>
<Card padding={0.25}>
<Section gap={0}>
{/* Header with search/empty state and create button */}
<Section flexDirection="row" padding={0.25} gap={0.5}>
{pats.length === 0 ? (
<Section padding={0.5} alignItems="start">
<Text as="span" text03 secondaryBody>
{isLoading
? "Loading tokens..."
: "No access tokens created."}
</Text>
</Section>
) : (
<InputTypeIn
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
leftSearchIcon
variant="internal"
/>
)}
<CreateButton
onClick={() => setShowCreateModal(true)}
secondary={false}
internal
transient={showCreateModal}
rightIcon
>
New Access Token
</CreateButton>
</Section>
<Section gap={0.25}>
{filteredPats.map((pat) => {
const now = new Date();
const createdDate = new Date(pat.created_at);
const daysSinceCreation = Math.floor(
(now.getTime() - createdDate.getTime()) /
{/* Token List */}
<Section gap={0.25}>
{filteredPats.map((pat) => {
const now = new Date();
const createdDate = new Date(pat.created_at);
const daysSinceCreation = Math.floor(
(now.getTime() - createdDate.getTime()) /
(1000 * 60 * 60 * 24)
);
let expiryText = "Never expires";
if (pat.expires_at) {
const expiresDate = new Date(pat.expires_at);
const daysUntilExpiry = Math.ceil(
(expiresDate.getTime() - now.getTime()) /
(1000 * 60 * 60 * 24)
);
expiryText = `Expires in ${daysUntilExpiry} day${
daysUntilExpiry === 1 ? "" : "s"
}`;
}
let expiryText = "Never expires";
if (pat.expires_at) {
const expiresDate = new Date(pat.expires_at);
const daysUntilExpiry = Math.ceil(
(expiresDate.getTime() - now.getTime()) /
(1000 * 60 * 60 * 24)
);
expiryText = `Expires in ${daysUntilExpiry} day${
daysUntilExpiry === 1 ? "" : "s"
}`;
}
const middleText = `Created ${daysSinceCreation} day${
daysSinceCreation === 1 ? "" : "s"
} ago - ${expiryText}`;
const middleText = `Created ${daysSinceCreation} day${
daysSinceCreation === 1 ? "" : "s"
} ago - ${expiryText}`;
return (
<Interactive.Container
key={pat.id}
heightVariant="fit"
widthVariant="full"
>
<div className="w-full bg-background-tint-01">
<AttachmentItemLayout
icon={SvgKey}
title={pat.name}
description={pat.token_display}
middleText={middleText}
rightChildren={
<OpalButton
icon={SvgTrash}
onClick={() => setTokenToDelete(pat)}
prominence="tertiary"
size="sm"
aria-label={`Delete token ${pat.name}`}
/>
}
/>
</div>
</Interactive.Container>
);
})}
</Section>
return (
<Interactive.Container
key={pat.id}
heightVariant="fit"
widthVariant="full"
>
<div className="w-full bg-background-tint-01">
<AttachmentItemLayout
icon={SvgKey}
title={pat.name}
description={pat.token_display}
middleText={middleText}
rightChildren={
<OpalButton
icon={SvgTrash}
onClick={() => setTokenToDelete(pat)}
prominence="tertiary"
size="sm"
aria-label={`Delete token ${pat.name}`}
/>
}
/>
</div>
</Interactive.Container>
);
})}
</Section>
</Card>
) : (
<Card>
<Section flexDirection="row" justifyContent="between">
<Text text03 secondaryBody>
Access tokens require an active paid subscription.
</Text>
<Button secondary href="/admin/billing">
Upgrade Plan
</Button>
</Section>
</Card>
)}
</Section>
</Card>
</Section>
)}
</Section>

View File

@@ -1,188 +0,0 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import Modal from "@/refresh-components/Modal";
import Text from "@/refresh-components/texts/Text";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { Button } from "@opal/components";
import { SvgDownload } from "@opal/icons";
import { Section } from "@/layouts/general-layouts";
import { getCodeLanguage } from "@/lib/languages";
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import { CodeBlock } from "@/app/app/message/CodeBlock";
import { extractCodeText } from "@/app/app/message/codeUtils";
import { fetchChatFile } from "@/lib/chat/svc";
export interface CodeViewProps {
presentingDocument: MinimalOnyxDocument;
onClose: () => void;
}
export default function CodeViewModal({
presentingDocument,
onClose,
}: CodeViewProps) {
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 language =
getCodeLanguage(presentingDocument.semantic_identifier || "") ||
"plaintext";
const lineCount = useMemo(() => {
if (!fileContent) return 0;
return fileContent.split("\n").length;
}, [fileContent]);
const fileSize = useMemo(() => {
if (!fileContent) return "";
const bytes = new TextEncoder().encode(fileContent).length;
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(2)} KB`;
const mb = kb / 1024;
return `${mb.toFixed(2)} MB`;
}, [fileContent]);
const headerDescription = useMemo(() => {
if (!fileContent) return "";
return `${language} - ${lineCount} ${
lineCount === 1 ? "line" : "lines"
} · ${fileSize}`;
}, [fileContent, language, lineCount, fileSize]);
useEffect(() => {
(async () => {
setIsLoading(true);
setLoadError(null);
setFileContent("");
const fileIdLocal =
presentingDocument.document_id.split("__")[1] ||
presentingDocument.document_id;
try {
const response = await fetchChatFile(fileIdLocal);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
setFileUrl((prev) => {
if (prev) {
window.URL.revokeObjectURL(prev);
}
return url;
});
setFileName(presentingDocument.semantic_identifier || "document");
setFileContent(await blob.text());
} catch {
setLoadError("Failed to load document.");
} finally {
setIsLoading(false);
}
})();
}, [presentingDocument]);
useEffect(() => {
return () => {
if (fileUrl) {
window.URL.revokeObjectURL(fileUrl);
}
};
}, [fileUrl]);
return (
<Modal
open
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<Modal.Content
width="md"
height="lg"
preventAccidentalClose={false}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Modal.Header
title={fileName || "Code"}
description={headerDescription}
onClose={onClose}
/>
<Modal.Body padding={0} gap={0}>
<Section padding={0} gap={0}>
{isLoading ? (
<Section>
<SimpleLoader className="h-8 w-8" />
</Section>
) : loadError ? (
<Section padding={1}>
<Text text03 mainUiBody>
{loadError}
</Text>
</Section>
) : (
<MinimalMarkdown
content={`\`\`\`${language}\n${fileContent}\n\`\`\``}
className="w-full h-full break-words"
components={{
code: ({
node,
className: codeClassName,
children,
...props
}: any) => {
const codeText = extractCodeText(
node,
fileContent,
children
);
return (
<CodeBlock className="" codeText={codeText}>
{children}
</CodeBlock>
);
},
}}
/>
)}
</Section>
</Modal.Body>
<Modal.Footer>
<Section
flexDirection="row"
justifyContent="between"
alignItems="center"
>
<Text text03 mainContentMuted>
{lineCount} {lineCount === 1 ? "line" : "lines"}
</Text>
<Section flexDirection="row" gap={0.5} width="fit">
<CopyIconButton
getCopyText={() => fileContent}
tooltip="Copy code"
size="sm"
/>
<a
href={fileUrl}
download={fileName || presentingDocument.document_id}
>
<Button
icon={SvgDownload}
tooltip="Download"
size="sm"
prominence="tertiary"
/>
</a>
</Section>
</Section>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}

View File

@@ -0,0 +1,252 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import Modal from "@/refresh-components/Modal";
import Text from "@/refresh-components/texts/Text";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { cn } from "@/lib/utils";
import { Section } from "@/layouts/general-layouts";
import { getCodeLanguage } from "@/lib/languages";
import { fetchChatFile } from "@/lib/chat/svc";
import {
PreviewContext,
resolveVariant,
} from "@/sections/modals/PreviewModalVariants";
// ---------------------------------------------------------------------------
// MIME resolution helper
// ---------------------------------------------------------------------------
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;
}
// ---------------------------------------------------------------------------
// PreviewModal
// ---------------------------------------------------------------------------
interface PreviewModalProps {
presentingDocument: MinimalOnyxDocument;
onClose: () => void;
}
export default function PreviewModal({
presentingDocument,
onClose,
}: PreviewModalProps) {
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 [mimeType, setMimeType] = useState("application/octet-stream");
const [zoom, setZoom] = useState(100);
// Resolve variant ----------------------------------------------------------
const variant = useMemo(
() => resolveVariant(presentingDocument.semantic_identifier, mimeType),
[presentingDocument.semantic_identifier, mimeType]
);
// Derived values -----------------------------------------------------------
const language = useMemo(
() =>
getCodeLanguage(presentingDocument.semantic_identifier || "") ||
"plaintext",
[presentingDocument.semantic_identifier]
);
const lineCount = useMemo(() => {
if (!fileContent) return 0;
return fileContent.split("\n").length;
}, [fileContent]);
const fileSize = useMemo(() => {
if (!fileContent) return "";
const bytes = new TextEncoder().encode(fileContent).length;
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(2)} KB`;
const mb = kb / 1024;
return `${mb.toFixed(2)} MB`;
}, [fileContent]);
// File fetching ------------------------------------------------------------
const fetchFile = useCallback(async () => {
setIsLoading(true);
setLoadError(null);
setFileContent("");
const fileIdLocal =
presentingDocument.document_id.split("__")[1] ||
presentingDocument.document_id;
try {
const response = await fetchChatFile(fileIdLocal);
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);
const rawContentType =
response.headers.get("Content-Type") || "application/octet-stream";
const resolvedMime = resolveMimeType(rawContentType, originalFileName);
setMimeType(resolvedMime);
const resolved = resolveVariant(
presentingDocument.semantic_identifier,
resolvedMime
);
if (resolved.needsTextContent) {
setFileContent(await blob.text());
}
} catch {
setLoadError("Failed to load document.");
} finally {
setIsLoading(false);
}
}, [presentingDocument]);
useEffect(() => {
fetchFile();
}, [fetchFile]);
useEffect(() => {
return () => {
if (fileUrl) window.URL.revokeObjectURL(fileUrl);
};
}, [fileUrl]);
// Actions ------------------------------------------------------------------
const handleDownload = useCallback(() => {
const link = document.createElement("a");
link.href = fileUrl;
link.download = fileName || presentingDocument.document_id;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [fileUrl, fileName, presentingDocument.document_id]);
const handleZoomIn = useCallback(
() => setZoom((prev) => Math.min(prev + 25, 200)),
[]
);
const handleZoomOut = useCallback(
() => setZoom((prev) => Math.max(prev - 25, 25)),
[]
);
// Build context ------------------------------------------------------------
const ctx: PreviewContext = useMemo(
() => ({
fileContent,
fileUrl,
fileName,
language,
lineCount,
fileSize,
zoom,
onZoomIn: handleZoomIn,
onZoomOut: handleZoomOut,
onDownload: handleDownload,
}),
[
fileContent,
fileUrl,
fileName,
language,
lineCount,
fileSize,
zoom,
handleZoomIn,
handleZoomOut,
handleDownload,
]
);
// Render -------------------------------------------------------------------
return (
<Modal
open
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<Modal.Content
width={variant.width}
height={variant.height}
preventAccidentalClose={false}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Modal.Header
title={fileName || "Document"}
description={variant.headerDescription(ctx)}
onClose={onClose}
/>
{/* Body + floating footer wrapper */}
<Modal.Body padding={0} gap={0}>
<Section padding={0} gap={0}>
{isLoading ? (
<Section>
<SimpleLoader className="h-8 w-8" />
</Section>
) : loadError ? (
<Section padding={1}>
<Text text03 mainUiBody>
{loadError}
</Text>
</Section>
) : (
variant.renderContent(ctx)
)}
</Section>
</Modal.Body>
{/* Floating footer */}
{!isLoading && !loadError && (
<div
className={cn(
"absolute bottom-0 left-0 right-0",
"flex items-center justify-between",
"p-4 pointer-events-none w-full"
)}
style={{
background:
"linear-gradient(to top, var(--background-tint-01) 40%, transparent)",
}}
>
{/* Left slot */}
<div className="pointer-events-auto z-10">
{variant.renderFooterLeft(ctx)}
</div>
{/* Right slot */}
<div className="pointer-events-auto z-10 rounded-12 bg-background-tint-00 p-1 shadow-lg">
{variant.renderFooterRight(ctx)}
</div>
</div>
)}
</Modal.Content>
</Modal>
);
}

View File

@@ -0,0 +1,398 @@
import React from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import { Button } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import { SvgDownload, SvgZoomIn, SvgZoomOut } from "@opal/icons";
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import { cn } from "@/lib/utils";
import { Section } from "@/layouts/general-layouts";
import { getCodeLanguage, isMarkdownFile } from "@/lib/languages";
import { CodeBlock } from "@/app/app/message/CodeBlock";
import { extractCodeText } from "@/app/app/message/codeUtils";
// ---------------------------------------------------------------------------
// PreviewContext — shared data bag passed into every variant
// ---------------------------------------------------------------------------
export interface PreviewContext {
fileContent: string;
fileUrl: string;
fileName: string;
language: string;
lineCount: number;
fileSize: string;
zoom: number;
onZoomIn: () => void;
onZoomOut: () => void;
onDownload: () => void;
}
// ---------------------------------------------------------------------------
// PreviewVariant — self-contained definition for a file-type view
// ---------------------------------------------------------------------------
export interface PreviewVariant {
/** Return true if this variant should handle the given file. */
matches: (semanticIdentifier: string | null, mimeType: string) => boolean;
/** Modal width. */
width: "lg" | "md" | "md-sm" | "sm";
/** Modal height. */
height: "fit" | "sm" | "lg" | "full";
/** Whether the fetcher should read the blob as text. */
needsTextContent: boolean;
/** String shown below the title in the modal header. */
headerDescription: (ctx: PreviewContext) => string;
/** Body content. */
renderContent: (ctx: PreviewContext) => React.ReactNode;
/** Left side of the floating footer (e.g. line count text, zoom controls). Return null for nothing. */
renderFooterLeft: (ctx: PreviewContext) => React.ReactNode;
/** Right side of the floating footer (e.g. copy + download buttons). */
renderFooterRight: (ctx: PreviewContext) => React.ReactNode;
}
// ---------------------------------------------------------------------------
// Shared footer building blocks
// ---------------------------------------------------------------------------
function DownloadButton({ onDownload }: { onDownload: () => void }) {
return (
<Button
prominence="tertiary"
size="sm"
icon={SvgDownload}
onClick={onDownload}
tooltip="Download"
/>
);
}
function CopyButton({ getText }: { getText: () => string }) {
return (
<CopyIconButton getCopyText={getText} tooltip="Copy content" size="sm" />
);
}
function ZoomControls({
zoom,
onZoomIn,
onZoomOut,
}: {
zoom: number;
onZoomIn: () => void;
onZoomOut: () => void;
}) {
return (
<div className="rounded-12 bg-background-tint-00 p-1 shadow-lg">
<Section flexDirection="row" width="fit">
<Button
prominence="tertiary"
size="sm"
icon={SvgZoomOut}
onClick={onZoomOut}
tooltip="Zoom Out"
/>
<Text mainUiMono text03>
{zoom}%
</Text>
<Button
prominence="tertiary"
size="sm"
icon={SvgZoomIn}
onClick={onZoomIn}
tooltip="Zoom In"
/>
</Section>
</div>
);
}
// ---------------------------------------------------------------------------
// Variants
// ---------------------------------------------------------------------------
const MARKDOWN_MIMES = [
"text/markdown",
"text/x-markdown",
"text/plain",
"text/x-rst",
"text/x-org",
];
const codeVariant: PreviewVariant = {
matches: (name) => !!getCodeLanguage(name || ""),
width: "md",
height: "lg",
needsTextContent: true,
headerDescription: (ctx) =>
ctx.fileContent
? `${ctx.language} - ${ctx.lineCount} ${
ctx.lineCount === 1 ? "line" : "lines"
} · ${ctx.fileSize}`
: "",
renderContent: (ctx) => (
<MinimalMarkdown
content={`\`\`\`${ctx.language}${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>
);
},
}}
/>
),
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 onDownload={ctx.onDownload} />
</Section>
),
};
const imageVariant: PreviewVariant = {
matches: (_name, mime) => mime.startsWith("image/"),
width: "lg",
height: "full",
needsTextContent: false,
headerDescription: () => "",
renderContent: (ctx) => (
<div
className="flex flex-1 min-h-0 items-center justify-center p-4 transition-transform duration-300 ease-in-out"
style={{
transform: `scale(${ctx.zoom / 100})`,
transformOrigin: "center",
}}
>
<img
src={ctx.fileUrl}
alt={ctx.fileName}
className="max-w-full max-h-full object-contain"
/>
</div>
),
renderFooterLeft: (ctx) => (
<ZoomControls
zoom={ctx.zoom}
onZoomIn={ctx.onZoomIn}
onZoomOut={ctx.onZoomOut}
/>
),
renderFooterRight: (ctx) => (
<Section flexDirection="row" width="fit">
<CopyButton getText={() => ctx.fileContent} />
<DownloadButton onDownload={ctx.onDownload} />
</Section>
),
};
const pdfVariant: PreviewVariant = {
matches: (_name, mime) => mime === "application/pdf",
width: "lg",
height: "full",
needsTextContent: false,
headerDescription: () => "",
renderContent: (ctx) => (
<iframe
src={`${ctx.fileUrl}#toolbar=0`}
className="w-full h-full flex-1 min-h-0 border-none"
title="PDF Viewer"
/>
),
renderFooterLeft: (ctx) => (
<ZoomControls
zoom={ctx.zoom}
onZoomIn={ctx.onZoomIn}
onZoomOut={ctx.onZoomOut}
/>
),
renderFooterRight: (ctx) => (
<Section flexDirection="row" width="fit">
<CopyButton getText={() => ctx.fileContent} />
<DownloadButton onDownload={ctx.onDownload} />
</Section>
),
};
interface CsvData {
headers: string[];
rows: string[][];
}
function parseCsv(content: string): CsvData {
const lines = content.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 };
}
const csvVariant: PreviewVariant = {
matches: (name, mime) =>
mime.startsWith("text/csv") || (name || "").toLowerCase().endsWith(".csv"),
width: "lg",
height: "full",
needsTextContent: true,
headerDescription: (ctx) => {
if (!ctx.fileContent) return "";
const { rows } = parseCsv(ctx.fileContent);
return `CSV - ${rows.length} rows · ${ctx.fileSize}`;
},
renderContent: (ctx) => {
if (!ctx.fileContent) return null;
const { headers, rows } = parseCsv(ctx.fileContent);
return (
<Section justifyContent="start" alignItems="start" padding={1}>
<Table>
<TableHeader className="sticky top-0 z-sticky">
<TableRow className="bg-background-tint-02">
{headers.map((h: string, i: number) => (
<TableHead key={i}>
<Text
as="p"
className="line-clamp-2 font-medium"
text03
mainUiBody
>
{h}
</Text>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row: string[], rIdx: number) => (
<TableRow key={rIdx}>
{headers.map((_: string, cIdx: number) => (
<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>
</Section>
);
},
renderFooterLeft: (ctx) => {
if (!ctx.fileContent) return null;
const { headers, rows } = parseCsv(ctx.fileContent);
return (
<Text text03 mainUiBody className="select-none">
{headers.length} {headers.length === 1 ? "column" : "columns"} ·{" "}
{rows.length} {rows.length === 1 ? "row" : "rows"}
</Text>
);
},
renderFooterRight: (ctx) => (
<Section flexDirection="row" width="fit">
<CopyButton getText={() => ctx.fileContent} />
<DownloadButton onDownload={ctx.onDownload} />
</Section>
),
};
const markdownVariant: PreviewVariant = {
matches: (name, mime) => {
if (MARKDOWN_MIMES.some((m) => mime.startsWith(m))) return true;
return isMarkdownFile(name || "");
},
width: "lg",
height: "full",
needsTextContent: true,
headerDescription: () => "",
renderContent: (ctx) => (
<ScrollIndicatorDiv className="flex-1 min-h-0 p-4" variant="shadow">
<MinimalMarkdown
content={ctx.fileContent}
className="w-full pb-4 h-full text-lg break-words"
/>
</ScrollIndicatorDiv>
),
renderFooterLeft: () => null,
renderFooterRight: (ctx) => (
<Section flexDirection="row" width="fit">
<CopyButton getText={() => ctx.fileContent} />
<DownloadButton onDownload={ctx.onDownload} />
</Section>
),
};
const unsupportedVariant: PreviewVariant = {
matches: () => true,
width: "lg",
height: "full",
needsTextContent: false,
headerDescription: () => "",
renderContent: (ctx) => (
<div className="flex flex-col items-center justify-center flex-1 min-h-0 gap-4 p-6">
<Text as="p" text03 mainUiBody>
This file format is not supported for preview.
</Text>
<Button onClick={ctx.onDownload}>Download File</Button>
</div>
),
renderFooterLeft: () => null,
renderFooterRight: (ctx) => <DownloadButton onDownload={ctx.onDownload} />,
};
// ---------------------------------------------------------------------------
// Variant registry — first match wins
// ---------------------------------------------------------------------------
const PREVIEW_VARIANTS: PreviewVariant[] = [
codeVariant,
imageVariant,
pdfVariant,
csvVariant,
markdownVariant,
];
export function resolveVariant(
semanticIdentifier: string | null,
mimeType: string
): PreviewVariant {
return (
PREVIEW_VARIANTS.find((v) => v.matches(semanticIdentifier, mimeType)) ??
unsupportedVariant
);
}

View File

@@ -150,7 +150,7 @@ test.describe("File preview modal from chat file links", () => {
await expect(modal.getByText("Hello from the mock file!")).toBeVisible();
});
test("clicking a code file link opens the CodeViewModal with syntax highlighting", async ({
test("clicking a code file link opens the PreviewModal with syntax highlighting", async ({
page,
}) => {
const mockContent = `Here is your script: [app.py](/api/chat/file/${MOCK_FILE_ID})`;
@@ -173,7 +173,7 @@ test.describe("File preview modal from chat file links", () => {
await expect(fileLink).toBeVisible({ timeout: 5000 });
await fileLink.click();
// Verify the CodeViewModal opens
// Verify the PreviewModal opens
const modal = page.getByRole("dialog");
await expect(modal).toBeVisible({ timeout: 5000 });