Compare commits

..
6 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 4674b37b85 Release v0.11.1
CI / check (push) Waiting to run
Release image / image (release) Waiting to run
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 19:48:58 +01:00
tbenandClaude Opus 5.5 d352ac924a List the last visited workspace first in the rail
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 19:48:38 +01:00
tbenandClaude Opus 5.5 c1c866876e Show bot graphics at the conversation's full width
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 19:45:27 +01:00
tbenandClaude Opus 5.5 913a87d23c Add a settings icon beside the rail's user row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 19:44:06 +01:00
tbenandClaude Opus 5.5 f2710eb640 Release v0.11.0
CI / check (push) Canceled after 0s
Release image / image (release) 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
45 changed files with 1227 additions and 112 deletions
+2 -1
View File
@@ -90,12 +90,13 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
- **Tables:** `bots` (`kind`, `color` `#rrggbb`, `stone`, `role`, `system_prompt`; `hue` is a legacy fallback), `bot_user_faces`, `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps` (unused yet). Enums in `src/dict/bot-dict.ts`.
- **Sessions:** one thread per user per bot in its own chat plus one per workspace; split into `bot_sessions` (rollover at the app setting `bot_session_max_messages`, default `AppData.BotSessionMaxMessages`, via `grabBotSessionMaxMessages`; titled and summarised by `name-bot-session.ts` from the session's opening after a reply or a task (a session with no user message, work handed to the bot, is named from the bot's own messages; `grabSessionOpening`; `grabBotMessages` reads the latest), summary carried into the next session only when they're linked by `previous_session_id`, which only a carry-over sets; older implicit links are cleared at boot by `clearImplicitSessionLinks`). New session (`startBotSession`) and a conversation with no active session always start fresh. The rollover carries over like the bot does (`rollOverBotSession` -> `continueBotSession`, falling back to the stored summary; kept for the next message while a job is open in the full session or there's nothing to carry), inside send-message, so the user's message lands under the summary. Helpers in `src/functions/backend/db/bot-sessions/`. Job results land in the job's own session, even after it closes, except a reply that called `continue_in_new_session` (`carryOverBotSession`; the menu's and sessions modal's Continue in new session does the same through `continueBotSession`, the model writing title and summary, refused while a job is open in the session): its summary is kept on the closed session and opens the new one as a bot message (`carried_from_session_id`), the job's `session_id` moves so the answer follows, the new session is titled by the bot then (it has no first exchange to be named from), and the old one can never be reopened (`reopenBotSession` refuses; a thread showing it swaps its composer for a link to the new one). Session lists (rail, sessions modal) reopen a session on click, as before; a carried-over one can't be, so it opens to read (`?session_id=`, `conversationHref`). The page tells them which session it shows (`announceShownSession` -> `useShownSession`): they reload, mark it Current (greyed out; without `?session_id=` the active one is), list it even when it's older than the rail's latest, and the active row then leads back. From half the session length the reply prompt suggests moving on (`longSessionNote`). Earlier sessions never enter the prompt (only the last one's summary): the thread can open them above a session (`useOlderMessages.loadPrevious`, a divider per session, quote and reply as usual), and replies get `search_past_sessions`/`read_past_session` (scoped to that user, bot and place, never the current session) when the conversation has any.
- **Coordinator:** exactly one, the super admin's alone, created idempotently by `ensureCoordinatorBot`. Default prompt `src/data/coordinator-system-prompt.ts`, backfilled only when empty.
- **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel; `?session_id=` opens another of the user's sessions there: a closed one is reopened, except a carried-over one, which shows with `SessionMovedBar` instead of the composer, pointing where the conversation went on); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/chatrooms` that workspace's chatrooms; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace.
- **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. The workspace pages set `cobble_last_visited_workspace_id`, read in the root (`last_visited_workspace_id`), and the rail lists that workspace first (`orderRailWorkspaces`); it never decides the landing. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel; `?session_id=` opens another of the user's sessions there: a closed one is reopened, except a carried-over one, which shows with `SessionMovedBar` instead of the composer, pointing where the conversation went on); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/chatrooms` that workspace's chatrooms; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace.
- **No model:** while the bot has no text model there (`bot_text_model` null, kept live by the models modal), the thread shows `NoModelNotice` (centred when empty): super admins get Connect a provider / Guided setup, others are told the super admin sets it up.
- **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due (read with the live face, never the page's) the page polls `session-messages` (`use-reply-catch-up`, also at once when the page comes back into view or online) in case a push was missed, replaying the messages and the bot's face and task as their pushes.
- **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.1",
"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}
+14
View File
@@ -61,6 +61,11 @@ export const AppData = {
* reads it.
*/
LastVisitedBotCookieName: `${SiteData["SiteSlug"]}_last_visited_bot_id`,
/**
* The workspace this browser was last in: the rail lists it first. Never
* where `/admin/bots` lands. `HttpOnly`: only the server reads it.
*/
LastVisitedWorkspaceCookieName: `${SiteData["SiteSlug"]}_last_visited_workspace_id`,
LastVisitedCookieMaxAgeSeconds: 365 * 24 * 60 * 60,
AuthExpiryDays: 60,
APICrudBasePath: "/api/admin/crud",
@@ -597,6 +602,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.
*/
@@ -0,0 +1,28 @@
import { setCookies } from "@/src/utils/cookies";
import isSecureRequest from "@/src/utils/is-secure-request";
import lastVisitedWorkspaceCookies from "@/src/utils/last-visited-workspace-cookies";
type Params = {
req: Request;
res: Response;
workspace_id: number | string;
};
/**
* The page's response with the last-visited workspace cookie set, so the
* rail lists this workspace first from the next page on. Called from the
* workspace pages' `res_transform`.
*/
export default function withLastVisitedWorkspaceCookies({ req, res, workspace_id }: Params): Response {
const new_res = res.clone();
setCookies(
new_res,
lastVisitedWorkspaceCookies({
workspace_id,
secure: isSecureRequest({ req }),
}),
);
return new_res;
}
+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;
}
@@ -0,0 +1,22 @@
import Button from "@moduletrace/twui/layout/Button";
import { Settings } from "lucide-react";
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
/**
* The settings icon beside the user row: a shortcut to the same place as
* the popover's Settings item. Hidden when the rail collapses.
*/
export default function RailSettingsButton() {
return (
<Button
href={SettingsDefaultPath}
title="Settings"
aria-label="Settings"
variant="ghost"
color="text"
className="hidden @rail:flex shrink-0 p-1.5! rounded-default-sm text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<Settings size={18} aria-hidden />
</Button>
);
}
@@ -8,10 +8,12 @@ import UserAvatar from "@/src/components/general/user-avatar";
import { AppContext } from "@/src/pages/__root";
import RailUserMenu from "./rail-user-menu";
import Tag from "@moduletrace/twui/elements/Tag";
import RailSettingsButton from "./rail-settings-button";
/**
* The signed-in user, opening the popover above. Its top rule sits at the
* same height as the composer rule, so the two read as one line.
* same height as the composer rule, so the two read as one line. A
* settings icon sits beside it.
*/
export default function RailUserRow() {
const { user, user_types } = useContext(AppContext);
@@ -24,9 +26,10 @@ export default function RailUserRow() {
return (
<Stack className="h-[88px] shrink-0 gap-0! items-stretch justify-center px-2 @rail:px-3 mx-0 @rail:mx-3 border-0 border-t border-solid border-hairline">
<Row className="w-full gap-1! flex-nowrap items-center">
<Dropdown
position="top-left"
className="w-full"
className="grow min-w-0"
targetWrapperProps={{ className: "w-full" }}
target={
<Row
@@ -71,6 +74,8 @@ export default function RailUserRow() {
>
<RailUserMenu />
</Dropdown>
<RailSettingsButton />
</Row>
</Stack>
);
}
@@ -1,6 +1,8 @@
import { useContext, useState } from "react";
import Span from "@moduletrace/twui/layout/Span";
import { AppData } from "@/src/data/app-data";
import { AppContext } from "@/src/pages/__root";
import orderRailWorkspaces from "@/src/utils/order-rail-workspaces";
import type { WorkspaceWithActivity } from "@/src/types";
import { AddFlowContext } from "../(data)/add-flow-context";
import AllWorkspacesModal from "./all-workspaces-modal";
@@ -16,12 +18,17 @@ type Props = {
};
/**
* The user's workspaces, with a "+" to create one: the latest
* `AppData.RailRecentWorkspaces`, then "See all workspaces" for the rest,
* which lists them all in a modal.
* The user's workspaces, with a "+" to create one: the last visited one
* first, then the rest up to `AppData.RailRecentWorkspaces`, then "See all
* workspaces" for the others, which lists them all in a modal.
*/
export default function RailWorkspacesSection({ workspaces, selected_id }: Props) {
export default function RailWorkspacesSection({ workspaces: listed, selected_id }: Props) {
const { openForm } = useContext(AddFlowContext);
const { pageProps } = useContext(AppContext);
const workspaces = orderRailWorkspaces({
workspaces: listed,
first_id: pageProps?.last_visited_workspace_id,
});
const [open, setOpen] = useState(false);
const shown = workspaces.slice(0, AppData.RailRecentWorkspaces);
const hidden = workspaces.slice(AppData.RailRecentWorkspaces);
+2
View File
@@ -8,6 +8,7 @@ import isUnknownSettingsPath from "../utils/is-unknown-settings-path";
import { SettingsDefaultPath } from "../dict/settings-pages-dict";
import grabInstallName from "../functions/backend/db/app-settings/grab-install-name";
import grabInstallIcon from "../functions/backend/db/app-settings/grab-install-icon";
import readLastVisitedWorkspace from "../utils/read-last-visited-workspace";
/**
* Routing for a local install. There are no public pages:
@@ -84,6 +85,7 @@ const server: BunextPageServerFn<PagePropsType> = async ({ req, url }) => {
user_types,
install_name: await grabInstallName().catch(() => ""),
install_icon: await grabInstallIcon().catch(() => null),
last_visited_workspace_id: readLastVisitedWorkspace({ req }),
},
};
};
@@ -21,7 +21,7 @@ const HeadingClassName = "my-0! text-[15px]! leading-6! tracking-normal! font-bo
*/
export const MessageMarkdownComponents: Components = {
p: ({ children }) => (
<P noMargin className="text-sm leading-5 text-ink">
<P noMargin className="w-full text-sm leading-5 text-ink">
{children}
</P>
),
@@ -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">
{[
@@ -1,6 +1,7 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabConversationProps from "@/src/functions/backend/db/bot-sessions/grab-conversation-props";
import withLastVisitedBotCookies from "@/src/functions/server/with-last-visited-bot-cookies";
import withLastVisitedWorkspaceCookies from "@/src/functions/server/with-last-visited-workspace-cookies";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
@@ -37,11 +38,16 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
}
const bot_id = props.bot.id;
const workspace_id = props.workspace?.id;
return {
props,
res_transform(res) {
return withLastVisitedBotCookies({ req: params.req, res, bot_id });
const with_bot = withLastVisitedBotCookies({ req: params.req, res, bot_id });
return workspace_id
? withLastVisitedWorkspaceCookies({ req: params.req, res: with_bot, workspace_id })
: with_bot;
},
};
};
@@ -1,3 +1,4 @@
import withLastVisitedWorkspaceCookies from "@/src/functions/server/with-last-visited-workspace-cookies";
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabChatroomMessages from "@/src/functions/backend/db/chatrooms/grab-chatroom-messages";
import grabUserChatroom from "@/src/functions/backend/db/chatrooms/grab-user-chatroom";
@@ -44,5 +45,8 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
chatroom: { ...chatroom, unread: 0 },
chatroom_messages: await grabChatroomMessages({ chatroom_id: chatroom.id }),
},
res_transform(res) {
return withLastVisitedWorkspaceCookies({ req: params.req, res, workspace_id: Number(rail.workspace.id) });
},
};
};
@@ -1,3 +1,4 @@
import withLastVisitedWorkspaceCookies from "@/src/functions/server/with-last-visited-workspace-cookies";
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserChatrooms from "@/src/functions/backend/db/chatrooms/grab-user-chatrooms";
import grabWorkspaceRailProps from "@/src/functions/backend/db/workspaces/grab-workspace-rail-props";
@@ -24,5 +25,10 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const chatrooms = await grabUserChatrooms({ user_id: user.id, workspace_id: Number(rail.workspace.id) });
return { props: { ...rail, chatrooms } };
return {
props: { ...rail, chatrooms },
res_transform(res) {
return withLastVisitedWorkspaceCookies({ req: params.req, res, workspace_id: Number(rail.workspace.id) });
},
};
};
@@ -1,3 +1,4 @@
import withLastVisitedWorkspaceCookies from "@/src/functions/server/with-last-visited-workspace-cookies";
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import grabWorkspaceActivity from "@/src/functions/backend/db/workspaces/grab-workspace-activity";
@@ -54,5 +55,8 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
workspace_activity,
workspace_tasks,
},
res_transform(res) {
return withLastVisitedWorkspaceCookies({ req: params.req, res, workspace_id });
},
};
};
+4
View File
@@ -36,6 +36,10 @@ export const server: BunextPageServerFn = async ({ req }) => {
name: AppData["LastVisitedBotCookieName"],
httpOnly: true,
},
{
name: AppData["LastVisitedWorkspaceCookieName"],
httpOnly: true,
},
]);
return new_res;
},
+5
View File
@@ -1998,6 +1998,11 @@ export type PagePropsType = {
* General, and the root's icon links).
*/
install_icon?: PwaInstallIcon | null;
/**
* The workspace this browser was last in (a cookie read in the root):
* the rail lists it first.
*/
last_visited_workspace_id?: number | null;
/**
* The release the server is running, for the General settings page.
*/
+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;
}
@@ -0,0 +1,27 @@
import { AppData } from "@/src/data/app-data";
import type { CookieOptions } from "./cookies";
type Params = {
workspace_id: number | string;
/**
* From `isSecureRequest`, so plain HTTP over a VPN still keeps it.
*/
secure: boolean;
};
/**
* The cookie that remembers the workspace this browser was last in, for
* `setCookies`: read back by `readLastVisitedWorkspace` in the root, so the
* rail lists that workspace first. It never decides where `/admin/bots`
* lands. `HttpOnly`, since only the server reads it.
*/
export default function lastVisitedWorkspaceCookies({ workspace_id, secure }: Params): CookieOptions[] {
return [
{
name: AppData["LastVisitedWorkspaceCookieName"],
value: String(workspace_id),
secure,
maxAge: AppData["LastVisitedCookieMaxAgeSeconds"],
},
];
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import type { WorkspaceWithActivity } from "@/src/types";
import orderRailWorkspaces from "./order-rail-workspaces";
const workspaces = [1, 2, 3, 4].map((id) => ({ id, name: `W${id}` }) as WorkspaceWithActivity);
function ids(list: WorkspaceWithActivity[]) {
return list.map((workspace) => workspace.id);
}
describe("orderRailWorkspaces", () => {
test("the last visited workspace leads, the rest keep their order", () => {
expect(ids(orderRailWorkspaces({ workspaces, first_id: 3 }))).toEqual([3, 1, 2, 4]);
});
test("unchanged without one, or when it isn't listed", () => {
expect(ids(orderRailWorkspaces({ workspaces }))).toEqual([1, 2, 3, 4]);
expect(ids(orderRailWorkspaces({ workspaces, first_id: null }))).toEqual([1, 2, 3, 4]);
expect(ids(orderRailWorkspaces({ workspaces, first_id: 9 }))).toEqual([1, 2, 3, 4]);
});
});
+23
View File
@@ -0,0 +1,23 @@
import type { WorkspaceWithActivity } from "@/src/types";
type Params = {
workspaces: WorkspaceWithActivity[];
/**
* The workspace this browser was last in (`readLastVisitedWorkspace`).
*/
first_id?: number | null;
};
/**
* The rail's workspaces with the last visited one first, the rest in
* their order. Unchanged when it isn't among them.
*/
export default function orderRailWorkspaces({ workspaces, first_id }: Params): WorkspaceWithActivity[] {
if (!first_id) return workspaces;
const first = workspaces.find((workspace) => Number(workspace.id) === Number(first_id));
if (!first) return workspaces;
return [first, ...workspaces.filter((workspace) => workspace !== first)];
}
+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 };
}
+3 -13
View File
@@ -1,3 +1,5 @@
import parsePositiveId from "./parse-positive-id";
export type LastVisitedBot = {
bot_id: number;
};
@@ -13,21 +15,9 @@ type Params = {
* is the caller's check.
*/
export default function parseLastVisitedBot({ bot_id }: Params): LastVisitedBot | null {
const bot = parseId(bot_id);
const bot = parsePositiveId({ value: bot_id });
if (bot == null) return null;
return { bot_id: bot };
}
function parseId(value?: string | number | null): number | null {
if (value === undefined || value === null) return null;
const raw = String(value).trim();
if (!/^\d+$/.test(raw)) return null;
const id = Number(raw);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
+20
View File
@@ -0,0 +1,20 @@
type Params = {
value?: string | number | null;
};
/**
* A positive whole number from a cookie or query value, or null when it
* isn't one, so a hand-edited value never reaches a query. Surrounding
* whitespace is trimmed.
*/
export default function parsePositiveId({ value }: Params): number | null {
if (value === undefined || value === null) return null;
const raw = String(value).trim();
if (!/^\d+$/.test(raw)) return null;
const id = Number(raw);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
+18
View File
@@ -0,0 +1,18 @@
import { AppData } from "@/src/data/app-data";
import { getCookie } from "./cookies";
import parsePositiveId from "./parse-positive-id";
type Params = {
req: Request;
};
/**
* The workspace this browser was last in, from the last-visited cookie
* (see `lastVisitedWorkspaceCookies`), or null. Only orders the rail, so a
* workspace the user no longer reaches just isn't in the list.
*/
export default function readLastVisitedWorkspace({ req }: Params): number | null {
return parsePositiveId({
value: getCookie(req, AppData["LastVisitedWorkspaceCookieName"]),
});
}