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
@@ -1,10 +1,10 @@
import { useContext, useEffect, useState } from "react";
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { AppContext } from "@/src/pages/__root";
import type { BotFileScopesPayload, MediaType, PublicBotFile } from "@/src/types";
import FilePreviewLink from "@/src/components/general/file-preview-link";
import AttachmentIcon from "./attachment-icon";
type Props = {
@@ -50,8 +50,8 @@ function fetchFile({
/**
* A file a bot named in a message (`fileRef`): the name it is saved under,
* linked to its bytes at `/media/<id>` (private: only someone who reaches
* the file's folder can load it). A folder the bot named is a `folderRef`
* opening it in the file preview (its bytes at `/media/<id>`, private: only
* someone who reaches the file's folder can load them). A folder the bot named is a `folderRef`
* instead, and its chip opens the files browser. The conversation's files come with the page, so most
* chips need no request; one that isn't among them (older than the page's
* first page) is fetched by id. A file the user doesn't reach, or one
@@ -85,15 +85,15 @@ export default function FileRefChip({ file_id }: Props) {
}
return (
<Link
href={file.url}
target="_blank"
rel="noopener noreferrer"
title={`Open ${file.name}`}
<FilePreviewLink
name={file.name}
url={file.url}
media_type={file.media_type}
size={file.size}
className="inline-flex h-6 max-w-full gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink hover:opacity-80"
>
<AttachmentIcon media_type={file.media_type as MediaType} />
<Span className="truncate">{file.name}</Span>
</Link>
</FilePreviewLink>
);
}
@@ -1,4 +1,3 @@
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";
@@ -6,6 +5,7 @@ import Button from "@moduletrace/twui/layout/Button";
import { Trash2 } from "lucide-react";
import type { MediaType, PublicBotFile } from "@/src/types";
import relativeTime from "@/src/utils/relative-time";
import FilePreviewLink from "@/src/components/general/file-preview-link";
import AttachmentIcon from "./attachment-icon";
type Props = {
@@ -29,15 +29,15 @@ export default function FileRow({ file, can_manage, busy, onDelete }: Props) {
<AttachmentIcon media_type={file.media_type as MediaType} size={16} />
</Row>
<Stack className="min-w-0 flex-1 gap-0.5!">
<Link
href={file.url}
target="_blank"
rel="noopener noreferrer"
title={`Open ${file.name}`}
<FilePreviewLink
name={file.path || file.name}
url={file.url}
media_type={file.media_type}
size={file.size}
className="text-[13px] font-bold leading-5 text-ink break-words hover:opacity-80"
>
{file.path || file.name}
</Link>
</FilePreviewLink>
<Span className="text-[12px] leading-4 text-ink-secondary">
{[
file.scope == "workspace" ? "Workspace" : "Bot files",
@@ -34,7 +34,7 @@ import WorkspaceRefChip from "./workspace-ref-chip";
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
* becomes its switch button, a take-over request a button that opens the
* computer, a bot it named a link to that bot's conversation, a workspace
* a link to that workspace, a file it saved a chip that downloads it, a
* a link to that workspace, a file it saved a chip that previews it, a
* folder it named a chip that opens it in the files browser, an MCP server,
* tool, skill or hook it proposed a chip that opens the confirm card, a
* secret it showed a masked chip, a secret it asked for a chip that opens
@@ -1,12 +1,12 @@
import Img from "@moduletrace/twui/layout/Img";
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import type { BotMessageAttachment } from "@/src/types";
import FilePreviewLink from "@/src/components/general/file-preview-link";
import AttachmentIcon from "./attachment-icon";
/**
* One file on a message: an image as a thumbnail, anything else as a
* named chip. Both open the file in a new tab.
* named chip. Both open the file in the preview.
*/
export default function MessageAttachment({
name,
@@ -16,25 +16,26 @@ export default function MessageAttachment({
}: BotMessageAttachment) {
if (media_type == "image") {
return (
<Link href={url} target="_blank" title={name} className="block">
<FilePreviewLink name={name} url={url} media_type={media_type} title={name} className="block">
<Img
src={thumbnail_url || url}
alt={name}
className="max-h-48 max-w-64 rounded-default-lg border border-solid border-hairline object-cover"
/>
</Link>
</FilePreviewLink>
);
}
return (
<Link
href={url}
target="_blank"
<FilePreviewLink
name={name}
url={url}
media_type={media_type}
title={name}
className="flex max-w-64 items-center gap-2 rounded-default-lg border border-solid border-hairline bg-surface px-3 py-2 text-[13px] leading-[18px] text-ink no-underline hover:border-hairline-strong"
>
<AttachmentIcon media_type={media_type} />
<Span className="truncate">{name}</Span>
</Link>
</FilePreviewLink>
);
}
@@ -8,6 +8,7 @@ import type { MediaType, StorageEntry } from "@/src/types";
import formatBytes from "@/src/utils/format-bytes";
import relativeTime from "@/src/utils/relative-time";
import AttachmentIcon from "@/src/pages/admin/bots/[bot_id]/(partials)/attachment-icon";
import FilePreviewLink from "@/src/components/general/file-preview-link";
type Props = {
entry: StorageEntry;
@@ -16,8 +17,8 @@ type Props = {
};
/**
* One entry in the open folder: a folder opens in place, a file opens in a
* new tab (its own media record when it is one of the bot's files, else
* One entry in the open folder: a folder opens in place, a file opens in the
* file preview (its own media record when it is one of the bot's files, else
* the browser's streaming route).
*/
export default function StorageEntryRow({ entry, selected, onOpenFolder }: Props) {
@@ -51,15 +52,15 @@ export default function StorageEntryRow({ entry, selected, onOpenFolder }: Props
{entry.name}
</Link>
) : (
<Link
href={entry.url}
target="_blank"
rel="noopener noreferrer"
title={`Open ${entry.name}`}
<FilePreviewLink
name={entry.name}
url={entry.url || ""}
media_type={entry.media_type}
size={entry.size}
className="text-[13px] font-bold leading-5 text-ink break-words hover:opacity-80"
>
{entry.name}
</Link>
</FilePreviewLink>
)}
<Span className="text-[12px] leading-4 text-ink-secondary">
{[