Compare commits

...
2 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 f2710eb640 Release v0.11.0
Release image / image (release) Canceled after 0s
CI / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 19:25:49 +01:00
tbenandClaude Opus 5.5 faa554d5e3 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]>
2026-10-28 19:25:49 +01:00
27 changed files with 961 additions and 43 deletions
+1
View File
@@ -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:<volume>:<owner>:<path>]` (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`.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cobble",
"version": "0.10.0",
"version": "0.11.0",
"module": "index.ts",
"type": "module",
"private": true,
@@ -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}
+9
View File
@@ -597,6 +597,15 @@ export const AppData = {
* inside its frame.
*/
BotGraphicInlineMaxHeight: 640,
/**
* Largest text file (markdown, CSV, plain text) the file preview reads
* into the page (bytes); a bigger one is only offered to download.
*/
FilePreviewMaxTextBytes: 2 * 1024 * 1024,
/**
* Most rows of a CSV the file preview draws.
*/
FilePreviewMaxTableRows: 1000,
/**
* Most questions one `ask_user` card holds.
*/
+89
View File
@@ -0,0 +1,89 @@
import { useEffect, useState } from "react";
import { AppData } from "@/src/data/app-data";
type Params = {
url: string;
/**
* Read nothing (a preview that isn't text).
*/
skip?: boolean;
/**
* The file's size when known, so a big one isn't fetched at all.
*/
size?: number;
};
type FileText = {
text: string | null;
loading: boolean;
error: string | null;
/**
* Bigger than `AppData.FilePreviewMaxTextBytes`: offered to download only.
*/
too_large: boolean;
};
/**
* A file's bytes read as text for the preview, once per URL, up to
* `AppData.FilePreviewMaxTextBytes`.
*/
export default function useFileText({ url, skip, size }: Params): FileText {
const max = AppData.FilePreviewMaxTextBytes;
const known_too_large = Boolean(size && size > max);
const [state, setState] = useState<FileText>({
text: null,
loading: !skip && !known_too_large,
error: null,
too_large: known_too_large,
});
useEffect(() => {
if (skip || known_too_large) return;
let live = true;
setState({ text: null, loading: true, error: null, too_large: false });
(async () => {
try {
const res = await fetch(url, { credentials: "same-origin" });
if (!res.ok) throw new Error(`The file couldn't be read (${res.status}).`);
const length = Number(res.headers.get("content-length")) || 0;
if (length > max) {
res.body?.cancel();
if (live) setState({ text: null, loading: false, error: null, too_large: true });
return;
}
const blob = await res.blob();
if (blob.size > max) {
if (live) setState({ text: null, loading: false, error: null, too_large: true });
return;
}
const text = await blob.text();
if (live) setState({ text, loading: false, error: null, too_large: false });
} catch (error: any) {
if (live) {
setState({
text: null,
loading: false,
error: error?.message || "The file couldn't be read.",
too_large: false,
});
}
}
})();
return () => {
live = false;
};
}, [url, skip, known_too_large, max]);
return state;
}
@@ -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">
{[
+23
View File
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test";
import filePreviewKind from "./file-preview-kind";
test("filePreviewKind reads the extension first", () => {
expect(filePreviewKind({ name: "report.md", media_type: "document" })).toBe("markdown");
expect(filePreviewKind({ name: "seo/raw/Ranks.CSV" })).toBe("csv");
expect(filePreviewKind({ name: "data.tsv" })).toBe("tsv");
expect(filePreviewKind({ name: "chart.svg", media_type: "document" })).toBe("image");
expect(filePreviewKind({ name: "paper.pdf" })).toBe("pdf");
expect(filePreviewKind({ name: "page.html" })).toBe("html");
expect(filePreviewKind({ name: "run.xml.out" })).toBe("text");
expect(filePreviewKind({ name: "notes.json" })).toBe("text");
expect(filePreviewKind({ name: "README" })).toBe("text");
});
test("filePreviewKind falls back to the media type", () => {
expect(filePreviewKind({ name: "photo", media_type: "image" })).toBe("image");
expect(filePreviewKind({ name: "clip", media_type: "video" })).toBe("video");
expect(filePreviewKind({ name: "voice", media_type: "audio" })).toBe("audio");
expect(filePreviewKind({ name: "archive.zip", media_type: "document" })).toBe("none");
expect(filePreviewKind({ name: ".md" })).toBe("none");
expect(filePreviewKind({})).toBe("none");
});
+134
View File
@@ -0,0 +1,134 @@
/**
* How the file preview shows a file.
*/
export type FilePreviewKind =
| "markdown"
| "csv"
| "tsv"
| "image"
| "pdf"
| "html"
| "audio"
| "video"
| "text"
| "none";
type Params = {
name?: string | null;
/**
* The `media` type (`image`, `video`, `document`, `audio`), when known.
*/
media_type?: string | null;
};
const ExtensionKinds: Record<string, FilePreviewKind> = {
md: "markdown",
markdown: "markdown",
mdx: "markdown",
csv: "csv",
tsv: "tsv",
tab: "tsv",
png: "image",
jpg: "image",
jpeg: "image",
gif: "image",
webp: "image",
avif: "image",
bmp: "image",
ico: "image",
svg: "image",
pdf: "pdf",
html: "html",
htm: "html",
mp3: "audio",
wav: "audio",
ogg: "audio",
oga: "audio",
m4a: "audio",
flac: "audio",
aac: "audio",
opus: "audio",
mp4: "video",
webm: "video",
mov: "video",
m4v: "video",
ogv: "video",
};
/**
* Text read as it is (shown as code, never run: XML sources too).
*/
const TextExtensions = [
"txt",
"text",
"log",
"out",
"json",
"jsonl",
"ndjson",
"yaml",
"yml",
"toml",
"ini",
"cfg",
"conf",
"env",
"xml",
"css",
"scss",
"js",
"mjs",
"cjs",
"ts",
"tsx",
"jsx",
"py",
"rb",
"php",
"go",
"rs",
"java",
"kt",
"swift",
"c",
"h",
"cpp",
"hpp",
"cs",
"sh",
"bash",
"zsh",
"sql",
"diff",
"patch",
"rst",
"tex",
"ics",
"vtt",
"srt",
];
/**
* Files often named without an extension that hold text.
*/
const TextNames = ["readme", "license", "makefile", "dockerfile", "changelog", "notes"];
/**
* What the file preview does with a file, from its name first (a bot's
* `report.md` is a document to `media`) and its media type after.
*/
export default function filePreviewKind({ name, media_type }: Params): FilePreviewKind {
const base = (name || "").trim().toLowerCase().split("/").pop() || "";
const dot = base.lastIndexOf(".");
const extension = dot > 0 ? base.slice(dot + 1) : "";
if (extension && ExtensionKinds[extension]) return ExtensionKinds[extension]!;
if (extension && TextExtensions.includes(extension)) return "text";
if (!extension && TextNames.includes(base)) return "text";
if (media_type == "image") return "image";
if (media_type == "audio") return "audio";
if (media_type == "video") return "video";
return "none";
}
+12
View File
@@ -0,0 +1,12 @@
import { expect, test } from "bun:test";
import isPlainClick from "./is-plain-click";
const click = { button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false };
test("isPlainClick takes only an unmodified left click", () => {
expect(isPlainClick({ event: click })).toBe(true);
expect(isPlainClick({ event: { ...click, button: 1 } })).toBe(false);
expect(isPlainClick({ event: { ...click, metaKey: true } })).toBe(false);
expect(isPlainClick({ event: { ...click, ctrlKey: true } })).toBe(false);
expect(isPlainClick({ event: { ...click, shiftKey: true } })).toBe(false);
});
+18
View File
@@ -0,0 +1,18 @@
type Params = {
event: {
button: number;
metaKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
};
};
/**
* A plain left click, with no key held: the one a link may take over (to
* open a preview); a middle click or Ctrl/Cmd/Shift click still does what
* the browser does with a link (a new tab or window).
*/
export default function isPlainClick({ event }: Params) {
return event.button == 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
}
+43
View File
@@ -0,0 +1,43 @@
import { expect, test } from "bun:test";
import parseCsv from "./parse-csv";
test("parseCsv splits rows and cells", () => {
expect(parseCsv({ text: "a,b\r\n1,2\n3,4\n" })).toEqual({
rows: [
["a", "b"],
["1", "2"],
["3", "4"],
],
truncated: false,
columns: 2,
});
});
test("parseCsv keeps quoted delimiters, line breaks and quotes", () => {
const { rows } = parseCsv({ text: 'name,note\n"Smith, J","said ""hi""\nthen left"\n' });
expect(rows).toEqual([
["name", "note"],
["Smith, J", 'said "hi"\nthen left'],
]);
});
test("parseCsv pads short rows and reads tabs", () => {
const { rows, columns } = parseCsv({ text: "a\tb\tc\n1", delimiter: "\t" });
expect(columns).toBe(3);
expect(rows).toEqual([
["a", "b", "c"],
["1", "", ""],
]);
});
test("parseCsv stops at max_rows", () => {
expect(parseCsv({ text: "h\n1\n2\n3\n", max_rows: 2 })).toEqual({
rows: [["h"], ["1"]],
truncated: true,
columns: 1,
});
expect(parseCsv({ text: "h\n1\n", max_rows: 2 }).truncated).toBe(false);
expect(parseCsv({ text: "" }).rows).toEqual([]);
});
+97
View File
@@ -0,0 +1,97 @@
type Params = {
text: string;
/**
* `,` for CSV, a tab for TSV.
*/
delimiter?: string;
/**
* Stop after this many rows (the header included).
*/
max_rows?: number;
};
export type ParsedCsv = {
rows: string[][];
/**
* Rows were left out past `max_rows`.
*/
truncated: boolean;
/**
* The widest row's cell count; every row is padded to it.
*/
columns: number;
};
/**
* Splits CSV text (RFC 4180: quoted cells may hold delimiters, line breaks
* and doubled quotes) into rows of cells. A leading BOM is dropped, `\r\n`
* and `\n` both end a row, a trailing empty line adds no row, and short
* rows are padded so every row has the same number of cells.
*/
export default function parseCsv({ text, delimiter = ",", max_rows }: Params): ParsedCsv {
const source = text.charCodeAt(0) == 0xfeff ? text.slice(1) : text;
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let quoted = false;
let truncated = false;
let index = 0;
const endRow = () => {
row.push(cell);
rows.push(row);
row = [];
cell = "";
};
while (index < source.length) {
const char = source[index]!;
if (quoted) {
if (char == '"') {
if (source[index + 1] == '"') {
cell += '"';
index += 2;
continue;
}
quoted = false;
} else {
cell += char;
}
index++;
continue;
}
if (char == '"' && cell == "") {
quoted = true;
} else if (char == delimiter) {
row.push(cell);
cell = "";
} else if (char == "\n" || char == "\r") {
if (char == "\r" && source[index + 1] == "\n") index++;
endRow();
if (max_rows && rows.length >= max_rows) {
truncated = index + 1 < source.length;
break;
}
} else {
cell += char;
}
index++;
}
if (!truncated && (cell != "" || row.length)) endRow();
const columns = rows.reduce((widest, it) => Math.max(widest, it.length), 0);
for (const it of rows) {
while (it.length < columns) it.push("");
}
return { rows, truncated, columns };
}