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.
This commit is contained in:
2026-10-22 07:37:21 +01:00
parent f1b7ba535f
commit de863618ba
4 changed files with 128 additions and 7 deletions
@@ -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);
});
});
@@ -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(),