diff --git a/src/functions/backend/db/bot-files/conversation-files-sql.test.ts b/src/functions/backend/db/bot-files/conversation-files-sql.test.ts
new file mode 100644
index 0000000..f8b6dd9
--- /dev/null
+++ b/src/functions/backend/db/bot-files/conversation-files-sql.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, test } from "bun:test";
+import { Database } from "bun:sqlite";
+
+/**
+ * The shape `listBotFiles` uses for a conversation's props. It has to be the
+ * whole folder tree: a bot saves into paths like `reports/` and
+ * `seo/coderank/`, and a `[file:ID]` chip in a message can only resolve
+ * against the files in these props, so a top-level-only list left every
+ * nested file ref as unclickable plain text in the chat.
+ */
+function rows({ db, scopes, recursive }: { db: Database; scopes: { scope: string; owner_id: number }[]; recursive: boolean }) {
+ const owner_sql = scopes.map(() => `(scope = ? AND owner_id = ?)`).join(" OR ");
+ const values = scopes.flatMap((s) => [s.scope, s.owner_id]);
+ const where = [owner_sql];
+
+ if (!recursive) where.push(`(folder = '' OR folder IS NULL)`);
+
+ return db
+ .query(`SELECT id FROM bot_files WHERE ${where.join(" AND ")} ORDER BY id`)
+ .all(...values)
+ .map((row) => (row as { id: number }).id);
+}
+
+const db = new Database(":memory:");
+
+db.run(`CREATE TABLE bot_files (id INTEGER PRIMARY KEY, scope TEXT, owner_id INTEGER, folder TEXT, path TEXT)`);
+db.run(
+ `INSERT INTO bot_files VALUES
+ (1, 'bot', 6, '', 'root.md'),
+ (2, 'bot', 6, 'reports', 'reports/audit.md'),
+ (3, 'bot', 6, 'seo/coderank', 'seo/coderank/crawl.py'),
+ (4, 'bot', 5, 'reports', 'reports/other.md'),
+ (5, 'workspace', 2, 'shared', 'shared/doc.md')`,
+);
+
+const scopes = [{ scope: "bot", owner_id: 6 }];
+
+describe("a conversation's file list", () => {
+ test("the top level alone misses every file a bot saved in a folder", () => {
+ expect(rows({ db, scopes, recursive: false })).toEqual([1]);
+ });
+
+ test("the whole tree has them all, so a nested file ref resolves", () => {
+ expect(rows({ db, scopes, recursive: true })).toEqual([1, 2, 3]);
+ });
+
+ test("another owner's folder is never in it", () => {
+ expect(rows({ db, scopes, recursive: true })).not.toContain(4);
+ });
+});
diff --git a/src/functions/backend/db/bot-files/grab-conversation-files.ts b/src/functions/backend/db/bot-files/grab-conversation-files.ts
index ca10fe8..ca19660 100644
--- a/src/functions/backend/db/bot-files/grab-conversation-files.ts
+++ b/src/functions/backend/db/bot-files/grab-conversation-files.ts
@@ -19,6 +19,11 @@ type Params = {
* (a workspace's shared one, else the bot's own), its latest
* `AppData.BotFilesPageSize` of them, and the usage against the quota.
* The full list (folders, search, older files) loads in the files modal.
+ *
+ * It takes the whole folder tree, not just its top level: a bot saves into
+ * paths like `reports/` or `seo/coderank/`, and a file chip in a message
+ * can only find the file it names among these, so a top-level-only list
+ * left every nested `[file:ID]` unclickable plain text in the chat.
*/
export default async function grabConversationFiles({
bot_id,
@@ -29,7 +34,7 @@ export default async function grabConversationFiles({
: ({ scope: "bot", owner_id: Number(bot_id) } as const);
const [files, usage, quota_bytes, quota_mb] = await Promise.all([
- listBotFiles({ scopes: [scope], limit: AppData.BotFilesPageSize }),
+ listBotFiles({ scopes: [scope], recursive: true, limit: AppData.BotFilesPageSize }),
grabStorageUsage(scope),
grabBotFileQuotaBytes(),
grabBotFileQuotaMb(),
diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-files-modal.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-files-modal.tsx
index 8976ff2..852ed23 100644
--- a/src/pages/admin/bots/[bot_id]/(partials)/bot-files-modal.tsx
+++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-files-modal.tsx
@@ -4,6 +4,7 @@ import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import Input from "@moduletrace/twui/form/Input";
+import Link from "@moduletrace/twui/layout/Link";
import { FolderOpen } from "lucide-react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
@@ -114,6 +115,15 @@ export default function BotFilesModal({ bot, workspace, menu, on_open }: Props)
} MB used in this folder.`}
) : null}
+
+ Browse this folder in Files
+
diff --git a/src/pages/admin/bots/[bot_id]/(partials)/file-ref-chip.tsx b/src/pages/admin/bots/[bot_id]/(partials)/file-ref-chip.tsx
index 5457d6a..bfa7df5 100644
--- a/src/pages/admin/bots/[bot_id]/(partials)/file-ref-chip.tsx
+++ b/src/pages/admin/bots/[bot_id]/(partials)/file-ref-chip.tsx
@@ -1,23 +1,79 @@
-import { useContext } from "react";
+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 { MediaType } from "@/src/types";
+import type { BotFileScopesPayload, MediaType, PublicBotFile } from "@/src/types";
import AttachmentIcon from "./attachment-icon";
type Props = {
file_id: number;
};
+/**
+ * One file, fetched once per id: a message can name a file older than the
+ * page's first page of them, and the chip still has to open it. Failures
+ * are remembered too, so a deleted file doesn't refetch on every render.
+ */
+const fetched = new Map>();
+
+function fetchFile({
+ file_id,
+ bot_id,
+ workspace_id,
+}: Props & { bot_id: number; workspace_id?: number | null }) {
+ const cached = fetched.get(file_id);
+
+ if (cached) return cached;
+
+ const query = new URLSearchParams({ file_id: String(file_id) });
+
+ if (workspace_id) query.set("workspace_id", String(workspace_id));
+ else query.set("bot_id", String(bot_id));
+
+ const pending = fetchApi<{}, APIResponseObject>(
+ `/api/admin/bots/files?${query.toString()}`,
+ { method: "GET" },
+ )
+ .then((res) => (res?.success && res.singleRes?.files?.length ? res.singleRes.files[0] : null))
+ .catch(() => null)
+ .then((file) => file || null);
+
+ fetched.set(file_id, pending);
+
+ return pending;
+}
+
/**
* A file a bot named in a message (`fileRef`): the name it is saved under,
- * linked to its bytes. The conversation's files come with the page, so no
- * request is made per chip; a file the user doesn't reach (or one deleted
- * since) shows as its id.
+ * linked to its bytes. 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
+ * deleted since, shows as its id.
*/
export default function FileRefChip({ file_id }: Props) {
const { pageProps } = useContext(AppContext);
- const file = pageProps?.bot_files?.files?.find((it) => it.id == file_id);
+ const known = pageProps?.bot_files?.files?.find((it) => it.id == file_id);
+ const [fetched_file, setFetchedFile] = useState(null);
+ const bot_id = Number(pageProps?.bot?.id) || 0;
+ const workspace_id = pageProps?.workspace?.id ? Number(pageProps.workspace.id) : null;
+
+ useEffect(() => {
+ if (known || !bot_id) return;
+
+ let live = true;
+
+ fetchFile({ file_id, bot_id, workspace_id }).then((file) => {
+ if (live) setFetchedFile(file);
+ });
+
+ return () => {
+ live = false;
+ };
+ }, [file_id, bot_id, workspace_id, known]);
+
+ const file = known || fetched_file;
if (!file) {
return {`file ${file_id}`};