diff --git a/src/data/app-data.ts b/src/data/app-data.ts index c53f33e..354f4b2 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -688,6 +688,11 @@ export const AppData = { RagTopChunks: 8, RagOverFetch: 4, RagMaxK: 60, + /** + * How long a search box waits after the last keystroke before it + * searches. + */ + SearchDebounceMs: 250, /** * The database is vacuumed at boot once this share of its pages, and * at least this many bytes, sit free (after a big delete, such as the diff --git a/src/hooks/use-debounced-value.ts b/src/hooks/use-debounced-value.ts new file mode 100644 index 0000000..2bc9088 --- /dev/null +++ b/src/hooks/use-debounced-value.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react"; +import { AppData } from "@/src/data/app-data"; + +type Params = { + value: T; + delay_ms?: number; +}; + +/** + * `value` once it has stopped changing for `delay_ms` + * (`AppData.SearchDebounceMs` by default), so a search box searches as the + * user types without a request per keystroke. An emptied value comes + * through at once. + */ +export default function useDebouncedValue({ value, delay_ms = AppData.SearchDebounceMs }: Params) { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + if (!value) { + setDebounced(value); + return; + } + + const timer = setTimeout(() => setDebounced(value), delay_ms); + + return () => clearTimeout(timer); + }, [value, delay_ms]); + + return debounced; +} diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-bot-files.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-bot-files.ts index f590c96..6bb6e39 100644 --- a/src/pages/admin/bots/[bot_id]/(hooks)/use-bot-files.ts +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-bot-files.ts @@ -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( pageProps?.bot_files || undefined, ); const [loading, setLoading] = useState(false); const [error, setError] = useState(); + 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 }; } 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 04b9fa7..439aa9d 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 @@ -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(); const [status, setStatus] = useState(); 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("") }} /> @@ -159,7 +160,7 @@ export default function BotFilesModal({ bot, workspace, menu, on_open }: Props) {loading ? "Loading files..." - : error || (search ? `Nothing matches "${search}".` : "No files yet.")} + : error || (query ? `Nothing matches "${query}".` : "No files yet.")} )} diff --git a/src/pages/admin/files/(partials)/files-search-input.tsx b/src/pages/admin/files/(partials)/files-search-input.tsx index 8e69d26..04f16d7 100644 --- a/src/pages/admin/files/(partials)/files-search-input.tsx +++ b/src/pages/admin/files/(partials)/files-search-input.tsx @@ -2,26 +2,20 @@ import Input from "@moduletrace/twui/form/Input"; type Props = { value: string; - loading?: boolean; onChange: (search: string) => void; - onSubmit: (search: string) => void; }; /** - * Narrows the open folder by name or path. Enter runs the search; clearing - * the field runs it again empty. + * Narrows the open folder by name or path as the user types (the pane + * debounces the search). */ -export default function FilesSearchInput({ value, loading, onChange, onSubmit }: Props) { +export default function FilesSearchInput({ value, onChange }: Props) { return ( onChange(event.target.value)} - onKeyDown={(event) => { - if (event.key == "Enter") onSubmit(event.currentTarget.value); - }} - clearInputProps={{ onClick: () => onSubmit("") }} + clearInputProps={{ onClick: () => onChange("") }} wrapperWrapperProps={{ className: "w-full mt-3" }} /> ); diff --git a/src/pages/admin/files/(sections)/files-browser.tsx b/src/pages/admin/files/(sections)/files-browser.tsx index 8b9f2f5..4506242 100644 --- a/src/pages/admin/files/(sections)/files-browser.tsx +++ b/src/pages/admin/files/(sections)/files-browser.tsx @@ -21,7 +21,6 @@ type Props = { loading?: boolean; error?: string; onSearch: (search: string) => void; - onSubmitSearch: () => void; onOpenFolder: (path: string) => void; onOpenVolume: (option: StorageSpecOption) => void; }; @@ -40,7 +39,6 @@ export default function FilesBrowser({ loading, error, onSearch, - onSubmitSearch, onOpenFolder, onOpenVolume, }: Props) { @@ -89,12 +87,7 @@ export default function FilesBrowser({ crumbs={folder?.breadcrumbs || []} onOpenFolder={onOpenFolder} /> - + {folder?.entries.length ? ( folder.entries.map((entry) => ( diff --git a/src/pages/admin/files/(sections)/files-pane.tsx b/src/pages/admin/files/(sections)/files-pane.tsx index 6005484..f80bfa4 100644 --- a/src/pages/admin/files/(sections)/files-pane.tsx +++ b/src/pages/admin/files/(sections)/files-pane.tsx @@ -1,4 +1,5 @@ import { useContext, useState } from "react"; +import useDebouncedValue from "@/src/hooks/use-debounced-value"; import { AppContext } from "@/src/pages/__root"; import type { StorageSpecOption } from "@/src/types"; import { parseStorageSpec, type StorageSpec } from "@/src/utils/storage-volumes"; @@ -20,7 +21,7 @@ export default function FilesPane() { ); const [path, set_path] = useState(page_folder?.path || ""); const [search, set_search] = useState(""); - const [query, set_query] = useState(""); + const query = useDebouncedValue({ value: search.trim() }); const { folder, loading, error } = useStorageFolder({ spec, path, @@ -38,7 +39,6 @@ export default function FilesPane() { set_spec(next_spec); set_path(next_path); set_search(""); - set_query(""); const url = new URL(window.location.href); @@ -63,7 +63,6 @@ export default function FilesPane() { loading={loading} error={error} onSearch={set_search} - onSubmitSearch={() => set_query(search)} onOpenFolder={(next_path: string) => navigate({ next_spec: spec, next_path })} onOpenVolume={(option: StorageSpecOption) => navigate({ next_spec: option.spec, next_path: "" })