From de863618ba1738b833f5d6e91e0c5ff3fb46b1c3 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Thu, 22 Oct 2026 07:37:21 +0100 Subject: [PATCH] List a conversation's whole file tree, not just its top level grab-conversation-files asked listBotFiles for the top level only, and a bot saves into folders (reports/, seo/coderank/, tmp/...). The page therefore carried none of the files a reply named, so every [file:ID] chip rendered as dead plain text and the files could not be opened at all: the bot had done the work and the user had no way to reach it. The props now take the whole tree, and a chip that still isn't among them (a message older than the page's first page of files) fetches its one file by id, cached per id so a deleted one doesn't refetch on every render. --- .../bot-files/conversation-files-sql.test.ts | 50 ++++++++++++++ .../db/bot-files/grab-conversation-files.ts | 7 +- .../[bot_id]/(partials)/bot-files-modal.tsx | 10 +++ .../[bot_id]/(partials)/file-ref-chip.tsx | 68 +++++++++++++++++-- 4 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 src/functions/backend/db/bot-files/conversation-files-sql.test.ts 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}`};