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
@@ -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.`}
</Span>
) : null}
<Link
href={`/admin/files?volume=${workspace?.id ? "workspaces" : "bots"}&owner=${
workspace?.id || bot.id
}`}
title="Browse every folder in Files"
className="text-[12px] font-bold leading-4 text-ink underline underline-offset-2 hover:opacity-80"
>
Browse this folder in Files
</Link>
</Stack>
<Stack className="w-full mt-4 gap-2! items-stretch">
@@ -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<number, Promise<PublicBotFile | null>>();
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<BotFileScopesPayload>>(
`/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<PublicBotFile | null>(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 <Span className="font-bold">{`file ${file_id}`}</Span>;