From faa554d5e383bd7e754405f27aac6924cd620b86 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Wed, 28 Oct 2026 19:25:49 +0100 Subject: [PATCH] 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 --- AGENTS.md | 1 + src/components/general/file-preview-body.tsx | 61 ++++++++ src/components/general/file-preview-code.tsx | 28 ++++ src/components/general/file-preview-link.tsx | 52 +++++++ .../general/file-preview-markdown.tsx | 47 ++++++ src/components/general/file-preview-modal.tsx | 88 ++++++++++++ .../general/file-preview-notice.tsx | 19 +++ .../general/file-preview-table-cell.tsx | 20 +++ .../general/file-preview-table-row.tsx | 19 +++ src/components/general/file-preview-table.tsx | 54 +++++++ src/components/general/file-preview-text.tsx | 50 +++++++ .../general/file-preview.render.test.tsx | 53 +++++++ src/components/general/task-file-row.tsx | 17 ++- src/data/app-data.ts | 9 ++ src/hooks/use-file-text.ts | 89 ++++++++++++ .../[bot_id]/(partials)/file-ref-chip.tsx | 18 +-- .../bots/[bot_id]/(partials)/file-row.tsx | 14 +- .../[bot_id]/(partials)/markdown-link.tsx | 2 +- .../(partials)/message-attachment.tsx | 17 +-- .../files/(partials)/storage-entry-row.tsx | 17 +-- src/utils/file-preview-kind.test.ts | 23 +++ src/utils/file-preview-kind.ts | 134 ++++++++++++++++++ src/utils/is-plain-click.test.ts | 12 ++ src/utils/is-plain-click.ts | 18 +++ src/utils/parse-csv.test.ts | 43 ++++++ src/utils/parse-csv.ts | 97 +++++++++++++ 26 files changed, 960 insertions(+), 42 deletions(-) create mode 100644 src/components/general/file-preview-body.tsx create mode 100644 src/components/general/file-preview-code.tsx create mode 100644 src/components/general/file-preview-link.tsx create mode 100644 src/components/general/file-preview-markdown.tsx create mode 100644 src/components/general/file-preview-modal.tsx create mode 100644 src/components/general/file-preview-notice.tsx create mode 100644 src/components/general/file-preview-table-cell.tsx create mode 100644 src/components/general/file-preview-table-row.tsx create mode 100644 src/components/general/file-preview-table.tsx create mode 100644 src/components/general/file-preview-text.tsx create mode 100644 src/components/general/file-preview.render.test.tsx create mode 100644 src/hooks/use-file-text.ts create mode 100644 src/utils/file-preview-kind.test.ts create mode 100644 src/utils/file-preview-kind.ts create mode 100644 src/utils/is-plain-click.test.ts create mode 100644 src/utils/is-plain-click.ts create mode 100644 src/utils/parse-csv.test.ts create mode 100644 src/utils/parse-csv.ts diff --git a/AGENTS.md b/AGENTS.md index bc86731..60a989d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker - **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it. - **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`). - **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:::]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`, `[hook-proposal:ID]`, `[secret:ID]` (masked env, Reveal/Copy), `[secret-request:ID]` (masked input card), `[question:ID]` (a bot's questions), `[graphic:ID]` (a bot graphic, framed inline), `[room:ID]` (a chatroom). Parsers in `src/utils/*-ref.ts`. A ref quoted alone in a code span still links; one inside longer code stays literal (`linkComputerRefs` splits out code first). A file chip opens a file from another bot's folder for a user who reaches that folder (`userReachesFileScope`, the same rule as its bytes), so answers passed on from chatrooms keep working. +- **File preview:** every file link (chips, attachments, files modal, task files, `/admin/files`) is a `FilePreviewLink`: a plain click opens `FilePreviewModal` (markdown rendered with the message elements, CSV/TSV as a table via `parseCsv`, images/PDF/audio/video in place, HTML in a frame with `sandbox=""` (plus the media server's `sandbox` CSP), other text as code, the kind from `filePreviewKind`; Download and Open in a new tab), a modified click still opens a tab. Text is read up to `AppData.FilePreviewMaxTextBytes`. Never render a file's HTML on the app's origin or with scripts. - **Copy chat** (header menu): the active session (the one on screen) as one markdown document (`api/admin/bots/conversation-export` -> `conversationMarkdown`, refs as names, message headings demoted). The clipboard write starts on the click with a pending `ClipboardItem` (`copyPendingText`), since Safari drops the gesture after a fetch. - **Asking before acting:** `ask_user` (`QuestionsNote`) puts up to `AppData.BotQuestionMaxQuestions` multiple-choice questions (`single` = radios, `multiple` = checkboxes, optional typed "Other") on one card, once per run -> `bot_questions` -> `[question:ID]` chip opening `BotQuestionCard`; the message needs the user. Answers are checked against the questions (`checkBotQuestionAnswers`) and stored, then sent as the user's chat message (`botQuestionAnswersText`); Skip says so. Replies and user-started tasks only: never routines, hook calls or chatroom sessions (no one sees those). - **Replies/reactions:** `reply_to_message_id` (not an FK); reactions from the fixed `BotMessageReactions`, toggled by `react-message.ts` and the `react_to_message` tool, pushed as `server:bot-reaction`. diff --git a/src/components/general/file-preview-body.tsx b/src/components/general/file-preview-body.tsx new file mode 100644 index 0000000..50ca9be --- /dev/null +++ b/src/components/general/file-preview-body.tsx @@ -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 ``, 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 ( + + {name} + + ); + } + + if (kind == "pdf") { + return