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:
@@ -688,6 +688,11 @@ export const AppData = {
|
|||||||
RagTopChunks: 8,
|
RagTopChunks: 8,
|
||||||
RagOverFetch: 4,
|
RagOverFetch: 4,
|
||||||
RagMaxK: 60,
|
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
|
* 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
|
* at least this many bytes, sit free (after a big delete, such as the
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
|
||||||
|
type Params<T> = {
|
||||||
|
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<T>({ value, delay_ms = AppData.SearchDebounceMs }: Params<T>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
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 { AppContext } from "@/src/pages/__root";
|
||||||
import type { BotFileScopesPayload } from "@/src/types";
|
import type { BotFileScopesPayload } from "@/src/types";
|
||||||
|
|
||||||
@@ -12,22 +12,30 @@ type Params = {
|
|||||||
* the first page of files).
|
* the first page of files).
|
||||||
*/
|
*/
|
||||||
enabled?: boolean;
|
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
|
* 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 { pageProps } = useContext(AppContext);
|
||||||
const [data, setData] = useState<BotFileScopesPayload | undefined>(
|
const [data, setData] = useState<BotFileScopesPayload | undefined>(
|
||||||
pageProps?.bot_files || undefined,
|
pageProps?.bot_files || undefined,
|
||||||
);
|
);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
const latest = useRef(0);
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
async (params?: { folder?: string; search?: string }) => {
|
async (params?: { folder?: string; search?: string }) => {
|
||||||
|
const request = ++latest.current;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
|
||||||
@@ -44,6 +52,8 @@ export default function useBotFiles({ bot_id, workspace_id, enabled = true }: Pa
|
|||||||
{ method: "GET" },
|
{ method: "GET" },
|
||||||
).catch(() => null);
|
).catch(() => null);
|
||||||
|
|
||||||
|
if (request != latest.current) return;
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
if (!res?.success || !res.singleRes) {
|
if (!res?.success || !res.singleRes) {
|
||||||
@@ -57,8 +67,8 @@ export default function useBotFiles({ bot_id, workspace_id, enabled = true }: Pa
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (enabled) load().catch(() => {});
|
if (enabled) load({ search }).catch(() => {});
|
||||||
}, [enabled, load]);
|
}, [enabled, load, search]);
|
||||||
|
|
||||||
return { data, loading, error, reload: load, setData };
|
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 type { BotWithLastMessage } from "@/src/types";
|
||||||
import HeaderIconButton from "@/src/components/general/header-icon-button";
|
import HeaderIconButton from "@/src/components/general/header-icon-button";
|
||||||
import { AppContext } from "@/src/pages/__root";
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import useDebouncedValue from "@/src/hooks/use-debounced-value";
|
||||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
import useBotFiles from "../(hooks)/use-bot-files";
|
import useBotFiles from "../(hooks)/use-bot-files";
|
||||||
import FileRow from "./file-row";
|
import FileRow from "./file-row";
|
||||||
@@ -30,19 +31,22 @@ type Props = {
|
|||||||
/**
|
/**
|
||||||
* The files a conversation works with: a bot's own folder, or the
|
* 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
|
* 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)
|
* 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.
|
* 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) {
|
export default function BotFilesModal({ bot, workspace, menu, on_open }: Props) {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const query = useDebouncedValue({ value: search.trim() });
|
||||||
const [busy_id, setBusyId] = useState<number>();
|
const [busy_id, setBusyId] = useState<number>();
|
||||||
const [status, setStatus] = useState<string>();
|
const [status, setStatus] = useState<string>();
|
||||||
const { data, loading, error, reload } = useBotFiles({
|
const { data, loading, error, reload } = useBotFiles({
|
||||||
bot_id: Number(bot.id),
|
bot_id: Number(bot.id),
|
||||||
workspace_id: workspace?.id ? Number(workspace.id) : null,
|
workspace_id: workspace?.id ? Number(workspace.id) : null,
|
||||||
enabled: open,
|
enabled: open,
|
||||||
|
search: query,
|
||||||
});
|
});
|
||||||
|
|
||||||
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
|
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);
|
setBusyId(undefined);
|
||||||
setStatus(res?.success ? done : res?.msg || `That didn't work.`);
|
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 || [];
|
const files = data?.files || [];
|
||||||
@@ -130,10 +134,7 @@ export default function BotFilesModal({ bot, workspace, menu, on_open }: Props)
|
|||||||
placeholder="Search by name or path"
|
placeholder="Search by name or path"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
clearInputProps={{ onClick: () => setSearch("") }}
|
||||||
if (event.key == "Enter") reload({ search }).catch(() => {});
|
|
||||||
}}
|
|
||||||
clearInputProps={{ onClick: () => reload({}).catch(() => {}) }}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</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">
|
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">
|
||||||
{loading
|
{loading
|
||||||
? "Loading files..."
|
? "Loading files..."
|
||||||
: error || (search ? `Nothing matches "${search}".` : "No files yet.")}
|
: error || (query ? `Nothing matches "${query}".` : "No files yet.")}
|
||||||
</Span>
|
</Span>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -2,26 +2,20 @@ import Input from "@moduletrace/twui/form/Input";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
loading?: boolean;
|
|
||||||
onChange: (search: string) => void;
|
onChange: (search: string) => void;
|
||||||
onSubmit: (search: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Narrows the open folder by name or path. Enter runs the search; clearing
|
* Narrows the open folder by name or path as the user types (the pane
|
||||||
* the field runs it again empty.
|
* debounces the search).
|
||||||
*/
|
*/
|
||||||
export default function FilesSearchInput({ value, loading, onChange, onSubmit }: Props) {
|
export default function FilesSearchInput({ value, onChange }: Props) {
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search this folder by name"
|
placeholder="Search this folder by name"
|
||||||
value={value}
|
value={value}
|
||||||
disabled={loading}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
clearInputProps={{ onClick: () => onChange("") }}
|
||||||
if (event.key == "Enter") onSubmit(event.currentTarget.value);
|
|
||||||
}}
|
|
||||||
clearInputProps={{ onClick: () => onSubmit("") }}
|
|
||||||
wrapperWrapperProps={{ className: "w-full mt-3" }}
|
wrapperWrapperProps={{ className: "w-full mt-3" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ type Props = {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
onSearch: (search: string) => void;
|
onSearch: (search: string) => void;
|
||||||
onSubmitSearch: () => void;
|
|
||||||
onOpenFolder: (path: string) => void;
|
onOpenFolder: (path: string) => void;
|
||||||
onOpenVolume: (option: StorageSpecOption) => void;
|
onOpenVolume: (option: StorageSpecOption) => void;
|
||||||
};
|
};
|
||||||
@@ -40,7 +39,6 @@ export default function FilesBrowser({
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
onSearch,
|
onSearch,
|
||||||
onSubmitSearch,
|
|
||||||
onOpenFolder,
|
onOpenFolder,
|
||||||
onOpenVolume,
|
onOpenVolume,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -89,12 +87,7 @@ export default function FilesBrowser({
|
|||||||
crumbs={folder?.breadcrumbs || []}
|
crumbs={folder?.breadcrumbs || []}
|
||||||
onOpenFolder={onOpenFolder}
|
onOpenFolder={onOpenFolder}
|
||||||
/>
|
/>
|
||||||
<FilesSearchInput
|
<FilesSearchInput value={search} onChange={onSearch} />
|
||||||
value={search}
|
|
||||||
loading={loading}
|
|
||||||
onChange={onSearch}
|
|
||||||
onSubmit={onSubmitSearch}
|
|
||||||
/>
|
|
||||||
<Stack className="w-full mt-2 gap-0! items-stretch">
|
<Stack className="w-full mt-2 gap-0! items-stretch">
|
||||||
{folder?.entries.length ? (
|
{folder?.entries.length ? (
|
||||||
folder.entries.map((entry) => (
|
folder.entries.map((entry) => (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
|
import useDebouncedValue from "@/src/hooks/use-debounced-value";
|
||||||
import { AppContext } from "@/src/pages/__root";
|
import { AppContext } from "@/src/pages/__root";
|
||||||
import type { StorageSpecOption } from "@/src/types";
|
import type { StorageSpecOption } from "@/src/types";
|
||||||
import { parseStorageSpec, type StorageSpec } from "@/src/utils/storage-volumes";
|
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 [path, set_path] = useState(page_folder?.path || "");
|
||||||
const [search, set_search] = useState("");
|
const [search, set_search] = useState("");
|
||||||
const [query, set_query] = useState("");
|
const query = useDebouncedValue({ value: search.trim() });
|
||||||
const { folder, loading, error } = useStorageFolder({
|
const { folder, loading, error } = useStorageFolder({
|
||||||
spec,
|
spec,
|
||||||
path,
|
path,
|
||||||
@@ -38,7 +39,6 @@ export default function FilesPane() {
|
|||||||
set_spec(next_spec);
|
set_spec(next_spec);
|
||||||
set_path(next_path);
|
set_path(next_path);
|
||||||
set_search("");
|
set_search("");
|
||||||
set_query("");
|
|
||||||
|
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
|
|
||||||
@@ -63,7 +63,6 @@ export default function FilesPane() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
onSearch={set_search}
|
onSearch={set_search}
|
||||||
onSubmitSearch={() => set_query(search)}
|
|
||||||
onOpenFolder={(next_path: string) => navigate({ next_spec: spec, next_path })}
|
onOpenFolder={(next_path: string) => navigate({ next_spec: spec, next_path })}
|
||||||
onOpenVolume={(option: StorageSpecOption) =>
|
onOpenVolume={(option: StorageSpecOption) =>
|
||||||
navigate({ next_spec: option.spec, next_path: "" })
|
navigate({ next_spec: option.spec, next_path: "" })
|
||||||
|
|||||||
Reference in New Issue
Block a user