Open bot files in a preview modal instead of a new tab

Markdown rendered, CSV/TSV as a table, images, PDFs, audio, video and
sandboxed HTML in place, other text as code, with Download and Open in a
new tab. Used by file chips, message attachments, the files modal, task
files and the files browser.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-28 19:25:49 +01:00
co-authored by Claude Opus 5.5
parent 521c2bbac0
commit faa554d5e3
26 changed files with 960 additions and 42 deletions
@@ -0,0 +1,61 @@
import Img from "@moduletrace/twui/layout/Img";
import Stack from "@moduletrace/twui/layout/Stack";
import type { FilePreviewKind } from "@/src/utils/file-preview-kind";
import FilePreviewNotice from "./file-preview-notice";
import FilePreviewText from "./file-preview-text";
type Props = {
kind: FilePreviewKind;
name: string;
url: string;
size?: number;
};
/**
* The preview itself, by kind. Media the browser draws itself (images,
* PDFs, audio, video) load from the file's URL; text kinds are read into
* the page (`FilePreviewText`). Nothing here runs the file: an SVG shows
* through `<img>`, which runs no script, and an HTML page in a frame
* sandboxed twice (the attribute here and the `sandbox` CSP the media
* server sends with it, `servedFileHeaders`): no script, no forms, an
* origin of its own.
*/
export default function FilePreviewBody({ kind, name, url, size }: Props) {
if (kind == "image") {
return (
<Stack className="grow min-h-0 w-full items-center justify-center p-4 overflow-auto bg-sunken">
<Img src={url} alt={name} className="max-w-full max-h-full object-contain" />
</Stack>
);
}
if (kind == "pdf") {
return <iframe src={url} title={name} className="block grow w-full border-0 bg-paper" />;
}
if (kind == "html") {
return <iframe src={url} title={name} sandbox="" referrerPolicy="no-referrer" className="block grow w-full border-0 bg-paper" />;
}
if (kind == "audio") {
return (
<Stack className="grow w-full items-center justify-center p-6">
<audio src={url} controls preload="metadata" className="w-full max-w-xl" />
</Stack>
);
}
if (kind == "video") {
return (
<Stack className="grow min-h-0 w-full items-center justify-center p-4 bg-sunken">
<video src={url} controls preload="metadata" className="max-w-full max-h-full" />
</Stack>
);
}
if (kind == "none") {
return <FilePreviewNotice message="This kind of file can't be previewed here. Download it to open it." />;
}
return <FilePreviewText kind={kind} url={url} size={size} />;
}
@@ -0,0 +1,28 @@
import CodeBlock from "@moduletrace/twui/elements/CodeBlock";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
text: string;
};
/**
* A plain text file in the preview, as it is, on the sunken surface with
* twui's copy button.
*/
export default function FilePreviewCode({ text }: Props) {
return (
<Stack className="grow min-h-0 w-full overflow-auto p-4 items-stretch">
<CodeBlock
singleBlock
backgroundColor="var(--color-sunken)"
wrapperProps={{
className:
"max-w-full! outline-hairline dark:outline-hairline [&_.twui-code-block-header_svg]:stroke-ink-secondary",
}}
className="font-mono text-[13px] leading-5 text-ink py-2 pl-2 pr-10 whitespace-pre-wrap wrap-anywhere"
>
{text}
</CodeBlock>
</Stack>
);
}
@@ -0,0 +1,52 @@
import { useState, type ReactNode } from "react";
import Link from "@moduletrace/twui/layout/Link";
import isPlainClick from "@/src/utils/is-plain-click";
import FilePreviewModal from "./file-preview-modal";
type Props = {
name: string;
url: string;
media_type?: string | null;
size?: number;
title?: string;
className?: string;
children: ReactNode;
};
/**
* A link to a file that opens it in the preview (`FilePreviewModal`) on a
* plain click. It stays a real link to the bytes, so a middle click or
* Ctrl/Cmd click still opens the file in a new tab.
*/
export default function FilePreviewLink({ name, url, media_type, size, title, className, children }: Props) {
const [open, setOpen] = useState(false);
return (
<>
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
title={title || `Preview ${name}`}
onClick={(event) => {
if (!isPlainClick({ event })) return;
event.preventDefault();
setOpen(true);
}}
className={className}
>
{children}
</Link>
{open ? (
<FilePreviewModal
name={name}
url={url}
media_type={media_type}
size={size}
onClose={() => setOpen(false)}
/>
) : null}
</>
);
}
@@ -0,0 +1,47 @@
import Markdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { twMerge } from "tailwind-merge";
import H2 from "@moduletrace/twui/layout/H2";
import H3 from "@moduletrace/twui/layout/H3";
import H4 from "@moduletrace/twui/layout/H4";
import H5 from "@moduletrace/twui/layout/H5";
import Stack from "@moduletrace/twui/layout/Stack";
import { MessageMarkdownComponents } from "@/src/pages/admin/bots/[bot_id]/(data)/message-markdown-components";
type Props = {
text: string;
};
const RemarkPlugins = [remarkGfm];
const HeadingClassName = "my-0! tracking-normal! font-bold text-ink";
/**
* A message's markdown elements, with headings sized for a document
* rather than a chat line.
*/
const DocumentComponents: Components = {
...MessageMarkdownComponents,
h1: ({ children }) => <H2 className={twMerge(HeadingClassName, "text-2xl! leading-8! mt-2!")}>{children}</H2>,
h2: ({ children }) => <H3 className={twMerge(HeadingClassName, "text-xl! leading-7! mt-2!")}>{children}</H3>,
h3: ({ children }) => <H4 className={twMerge(HeadingClassName, "text-[17px]! leading-6! mt-1!")}>{children}</H4>,
h4: ({ children }) => <H5 className={twMerge(HeadingClassName, "text-[15px]! leading-6!")}>{children}</H5>,
h5: ({ children }) => <H5 className={twMerge(HeadingClassName, "text-sm! leading-5!")}>{children}</H5>,
h6: ({ children }) => <H5 className={twMerge(HeadingClassName, "text-sm! leading-5! text-ink-secondary")}>{children}</H5>,
};
/**
* A markdown file rendered in the preview, through the same elements as a
* bot's messages (raw HTML never rendered), in a readable column.
*/
export default function FilePreviewMarkdown({ text }: Props) {
return (
<Stack className="grow min-h-0 w-full overflow-auto items-center">
<Stack className="w-full max-w-[760px] gap-3! items-stretch px-6 py-5">
<Markdown remarkPlugins={RemarkPlugins} components={DocumentComponents}>
{text}
</Markdown>
</Stack>
</Stack>
);
}
@@ -0,0 +1,88 @@
import Modal from "@moduletrace/twui/elements/Modal";
import H2 from "@moduletrace/twui/layout/H2";
import Link from "@moduletrace/twui/layout/Link";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Download, SquareArrowOutUpRight } from "lucide-react";
import filePreviewKind from "@/src/utils/file-preview-kind";
import formatBytes from "@/src/utils/format-bytes";
import FilePreviewBody from "./file-preview-body";
type Props = {
name: string;
url: string;
media_type?: string | null;
size?: number;
onClose: () => void;
};
const ActionClassName =
"shrink-0 inline-flex items-center gap-1.5 px-2 py-1.5 text-[13px] leading-[18px] text-ink-secondary hover:text-ink rounded-default hover:bg-sunken no-underline";
/**
* A file over the page: markdown rendered, a CSV as a table, an image,
* PDF, audio or video in place, other text as code, with ways to download
* it or open it in a new tab. A file it can't show offers those only.
*/
export default function FilePreviewModal({
name,
url,
media_type,
size,
onClose,
}: Props) {
const kind = filePreviewKind({ name, media_type });
const base_name = name.split("/").pop() || name;
return (
<Modal
open
setOpen={(next_open) => {
if (!next_open) onClose();
}}
className="max-w-[95vw]! w-[95vw] h-[90vh] p-0 bg-paper flex flex-col overflow-hidden"
>
<Row className="w-full gap-3! flex-nowrap items-center pl-5 pr-14 py-3 border-0 border-b border-solid border-hairline">
<Stack className="min-w-0 grow gap-0! items-start">
<H2
title={name}
className="text-base! leading-6! tracking-normal! font-bold text-ink truncate w-full"
>
{base_name}
</H2>
{size ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{formatBytes({ bytes: size })}
</Span>
) : null}
</Stack>
<Link
href={url}
download={base_name}
title={`Download ${base_name}`}
className={ActionClassName}
>
<Download size={16} aria-hidden />
<Span className="hidden sm:inline">Download</Span>
</Link>
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
title="Open in a new tab"
aria-label="Open in a new tab"
className={ActionClassName}
>
<SquareArrowOutUpRight size={16} aria-hidden />
</Link>
</Row>
<FilePreviewBody
kind={kind}
name={base_name}
url={url}
size={size}
/>
</Modal>
);
}
@@ -0,0 +1,19 @@
import P from "@moduletrace/twui/layout/P";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
message: string;
};
/**
* What the preview says instead of a file it doesn't show.
*/
export default function FilePreviewNotice({ message }: Props) {
return (
<Stack className="grow w-full items-center justify-center p-6">
<P noMargin className="text-sm leading-5 text-ink-secondary text-center max-w-md">
{message}
</P>
</Stack>
);
}
@@ -0,0 +1,20 @@
import { twMerge } from "tailwind-merge";
type Props = {
value: string;
header?: boolean;
};
/**
* One cell of a previewed CSV, its text kept to one readable width.
*/
export default function FilePreviewTableCell({ value, header }: Props) {
const className = twMerge(
"px-3 py-1.5 max-w-80 text-left align-top whitespace-pre-wrap wrap-break-word border-0 border-b border-r border-solid border-hairline",
header && "font-bold border-hairline-strong",
);
if (header) return <th className={className}>{value}</th>;
return <td className={className}>{value}</td>;
}
@@ -0,0 +1,19 @@
import FilePreviewTableCell from "./file-preview-table-cell";
type Props = {
cells: string[];
header?: boolean;
};
/**
* One row of a previewed CSV.
*/
export default function FilePreviewTableRow({ cells, header }: Props) {
return (
<tr>
{cells.map((value, index) => (
<FilePreviewTableCell key={index} value={value} header={header} />
))}
</tr>
);
}
@@ -0,0 +1,54 @@
import { useMemo } from "react";
import P from "@moduletrace/twui/layout/P";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppData } from "@/src/data/app-data";
import parseCsv from "@/src/utils/parse-csv";
import FilePreviewNotice from "./file-preview-notice";
import FilePreviewTableRow from "./file-preview-table-row";
type Props = {
text: string;
delimiter: string;
};
/**
* A CSV or TSV file as a table: its first row the header (kept in view
* while scrolling), up to `AppData.FilePreviewMaxTableRows` rows. Cells
* are text only. Tables have no children-based twui equivalent, so they're
* styled elements, as in a message.
*/
export default function FilePreviewTable({ text, delimiter }: Props) {
const { rows, truncated } = useMemo(
() => parseCsv({ text, delimiter, max_rows: AppData.FilePreviewMaxTableRows + 1 }),
[text, delimiter],
);
if (!rows.length) return <FilePreviewNotice message="This file is empty." />;
const [header, ...body] = rows;
return (
<Stack className="grow min-h-0 w-full items-stretch gap-0!">
<Stack className="grow min-h-0 w-full overflow-auto items-start">
<table className="text-[13px] leading-5 text-ink border-separate border-spacing-0">
<thead className="sticky top-0 bg-surface">
<FilePreviewTableRow cells={header!} header />
</thead>
<tbody>
{body.map((cells, index) => (
<FilePreviewTableRow key={index} cells={cells} />
))}
</tbody>
</table>
</Stack>
<P
noMargin
className="shrink-0 px-5 py-2 text-[12px] leading-4 text-ink-secondary border-0 border-t border-solid border-hairline"
>
{truncated
? `Showing the first ${body.length} rows. Download the file for the rest.`
: `${body.length} ${body.length == 1 ? "row" : "rows"}`}
</P>
</Stack>
);
}
@@ -0,0 +1,50 @@
import Loading from "@moduletrace/twui/elements/Loading";
import Stack from "@moduletrace/twui/layout/Stack";
import useFileText from "@/src/hooks/use-file-text";
import formatBytes from "@/src/utils/format-bytes";
import { AppData } from "@/src/data/app-data";
import type { FilePreviewKind } from "@/src/utils/file-preview-kind";
import FilePreviewCode from "./file-preview-code";
import FilePreviewMarkdown from "./file-preview-markdown";
import FilePreviewNotice from "./file-preview-notice";
import FilePreviewTable from "./file-preview-table";
type Props = {
kind: FilePreviewKind;
url: string;
size?: number;
};
/**
* A text file read into the preview: markdown rendered, CSV or TSV as a
* table, anything else as code.
*/
export default function FilePreviewText({ kind, url, size }: Props) {
const { text, loading, error, too_large } = useFileText({ url, size });
if (loading) {
return (
<Stack className="grow w-full items-center justify-center">
<Loading />
</Stack>
);
}
if (too_large) {
return (
<FilePreviewNotice
message={`This file is bigger than ${formatBytes({ bytes: AppData.FilePreviewMaxTextBytes })}, too big to preview. Download it to open it.`}
/>
);
}
if (error || text == null) {
return <FilePreviewNotice message={error || "The file couldn't be read."} />;
}
if (kind == "markdown") return <FilePreviewMarkdown text={text} />;
if (kind == "csv") return <FilePreviewTable text={text} delimiter="," />;
if (kind == "tsv") return <FilePreviewTable text={text} delimiter={"\t"} />;
return <FilePreviewCode text={text} />;
}
@@ -0,0 +1,53 @@
import { expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import { AppData } from "@/src/data/app-data";
import FilePreviewBody from "./file-preview-body";
import FilePreviewLink from "./file-preview-link";
import FilePreviewMarkdown from "./file-preview-markdown";
import FilePreviewTable from "./file-preview-table";
test("a markdown file renders as a document, raw HTML left out", () => {
const html = renderToString(
<FilePreviewMarkdown text={"# Report\n\nSome **bold** text.\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n<script>alert(1)</script>"} />,
);
expect(html).toContain("Report");
expect(html).toContain("<h2");
expect(html).toContain("font-bold");
expect(html).toContain("<table");
expect(html).not.toContain("<script");
});
test("a CSV renders as a table with its header row", () => {
const html = renderToString(<FilePreviewTable text={'name,city\n"Smith, J",Lagos\n'} delimiter="," />);
expect(html).toContain("<thead");
expect(html).toContain("<th");
expect(html).toContain("Smith, J");
expect(html).toContain("1 row");
});
test("a long CSV says it was cut", () => {
const rows = Array.from({ length: AppData.FilePreviewMaxTableRows + 5 }, (_, i) => `${i}`).join("\n");
const html = renderToString(<FilePreviewTable text={`n\n${rows}`} delimiter="," />);
expect(html).toContain(`Showing the first ${AppData.FilePreviewMaxTableRows} rows`);
});
test("media previews load from the file's URL, unknown files offer a download", () => {
expect(renderToString(<FilePreviewBody kind="image" name="a.png" url="/media/1" />)).toContain('src="/media/1"');
expect(renderToString(<FilePreviewBody kind="pdf" name="a.pdf" url="/media/2" />)).toContain("<iframe");
expect(renderToString(<FilePreviewBody kind="html" name="a.html" url="/media/4" />)).toContain('sandbox=""');
expect(renderToString(<FilePreviewBody kind="none" name="a.zip" url="/media/3" />)).toContain("can&#x27;t be previewed");
});
test("a file link stays a real link to the bytes until clicked", () => {
const html = renderToString(
<FilePreviewLink name="report.md" url="/media/9">
report.md
</FilePreviewLink>,
);
expect(html).toContain('href="/media/9"');
expect(html).toContain("Preview report.md");
});
+8 -9
View File
@@ -1,9 +1,9 @@
import Row from "@moduletrace/twui/layout/Row";
import Link from "@moduletrace/twui/layout/Link";
import { FileText, Image as ImageIcon } from "lucide-react";
import Span from "@moduletrace/twui/layout/Span";
import type { TaskFile } from "@/src/types";
import fileRef from "@/src/utils/file-ref";
import FilePreviewLink from "./file-preview-link";
type Props = {
file: TaskFile;
@@ -15,8 +15,8 @@ type Props = {
};
/**
* One file on a task: its name, opening the bytes at `/media/<id>` (private,
* so only someone who reaches the task can load it), and its file ref (built by `fileRef`) when it
* One file on a task: its name, opening it in the file preview (its bytes at
* `/media/<id>`, private, so only someone who reaches the task can load them), and its file ref (built by `fileRef`) when it
* lives in a bot's or workspace's folder (so the file's real home is
* reachable too).
*/
@@ -39,15 +39,14 @@ export default function TaskFileRow({ file, onDetach, busy }: Props) {
) : (
<FileText size={16} className="shrink-0 text-ink-secondary" aria-hidden />
)}
<Link
href={`/media/${file.media_id}`}
target="_blank"
rel="noreferrer"
title={file.name || "Open the file"}
<FilePreviewLink
name={file.name || `File ${file.media_id}`}
url={`/media/${file.media_id}`}
media_type={file.media_type}
className="grow min-w-0 text-[13px] leading-[18px] text-ink truncate hover:underline"
>
{file.name || `File ${file.media_id}`}
</Link>
</FilePreviewLink>
{file.kind == "reference" ? (
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">reference</Span>
) : null}