Search files as the user types

The Files page and the conversation's files modal searched only on
Enter. Both now search by name or path a moment after the last
keystroke (useDebouncedValue, AppData.SearchDebounceMs), clearing shows
the whole list at once, and the modal drops an answer a newer request
overtook. The Files search box no longer locks while a folder loads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-28 04:46:39 +01:00
co-authored by Claude Opus 5.5
parent ac3ebc43d4
commit 5f96004f13
7 changed files with 66 additions and 34 deletions
@@ -1,6 +1,6 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { useCallback, useContext, useEffect, useState } from "react";
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { AppContext } from "@/src/pages/__root";
import type { BotFileScopesPayload } from "@/src/types";
@@ -12,22 +12,30 @@ type Params = {
* the first page of files).
*/
enabled?: boolean;
/**
* Only files whose name or path has this.
*/
search?: string;
};
/**
* A conversation's files: the page's own copy first, then the folder and
* search the modal asks for. Saves and deletes refresh it.
* search the modal asks for (reloaded when the search changes; an answer
* that a newer request overtook is dropped). Saves and deletes refresh it.
*/
export default function useBotFiles({ bot_id, workspace_id, enabled = true }: Params) {
export default function useBotFiles({ bot_id, workspace_id, enabled = true, search }: Params) {
const { pageProps } = useContext(AppContext);
const [data, setData] = useState<BotFileScopesPayload | undefined>(
pageProps?.bot_files || undefined,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const latest = useRef(0);
const load = useCallback(
async (params?: { folder?: string; search?: string }) => {
const request = ++latest.current;
setLoading(true);
setError(undefined);
@@ -44,6 +52,8 @@ export default function useBotFiles({ bot_id, workspace_id, enabled = true }: Pa
{ method: "GET" },
).catch(() => null);
if (request != latest.current) return;
setLoading(false);
if (!res?.success || !res.singleRes) {
@@ -57,8 +67,8 @@ export default function useBotFiles({ bot_id, workspace_id, enabled = true }: Pa
);
useEffect(() => {
if (enabled) load().catch(() => {});
}, [enabled, load]);
if (enabled) load({ search }).catch(() => {});
}, [enabled, load, search]);
return { data, loading, error, reload: load, setData };
}
@@ -12,6 +12,7 @@ import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types";
import HeaderIconButton from "@/src/components/general/header-icon-button";
import { AppContext } from "@/src/pages/__root";
import useDebouncedValue from "@/src/hooks/use-debounced-value";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import useBotFiles from "../(hooks)/use-bot-files";
import FileRow from "./file-row";
@@ -30,19 +31,22 @@ type Props = {
/**
* The files a conversation works with: a bot's own folder, or the
* workspace's shared one. Each can be opened or deleted (a crew manager
* only). The list loads when the modal opens, and search (by name or path)
* goes through the same endpoint, so the page props stay small.
* only). The list loads when the modal opens, and search (by name or path,
* as the user types) goes through the same endpoint, so the page props
* stay small.
*/
export default function BotFilesModal({ bot, workspace, menu, on_open }: Props) {
const { pageProps } = useContext(AppContext);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const query = useDebouncedValue({ value: search.trim() });
const [busy_id, setBusyId] = useState<number>();
const [status, setStatus] = useState<string>();
const { data, loading, error, reload } = useBotFiles({
bot_id: Number(bot.id),
workspace_id: workspace?.id ? Number(workspace.id) : null,
enabled: open,
search: query,
});
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
@@ -75,7 +79,7 @@ export default function BotFilesModal({ bot, workspace, menu, on_open }: Props)
setBusyId(undefined);
setStatus(res?.success ? done : res?.msg || `That didn't work.`);
if (res?.success) await reload({ search });
if (res?.success) await reload({ search: query });
};
const files = data?.files || [];
@@ -130,10 +134,7 @@ export default function BotFilesModal({ bot, workspace, menu, on_open }: Props)
placeholder="Search by name or path"
value={search}
onChange={(event) => setSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key == "Enter") reload({ search }).catch(() => {});
}}
clearInputProps={{ onClick: () => reload({}).catch(() => {}) }}
clearInputProps={{ onClick: () => setSearch("") }}
/>
</Stack>
@@ -159,7 +160,7 @@ export default function BotFilesModal({ bot, workspace, menu, on_open }: Props)
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">
{loading
? "Loading files..."
: error || (search ? `Nothing matches "${search}".` : "No files yet.")}
: error || (query ? `Nothing matches "${query}".` : "No files yet.")}
</Span>
)}
</Stack>