diff --git a/apps/web/package.json b/apps/web/package.json index 32c9a00a9..2583294a0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,8 +17,9 @@ "@corbits/bench-ui": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/chat-ui": "workspace:*", + "@corbits/command-palette": "workspace:*", "@corbits/settings-ui": "workspace:*", - "@corbits/react-ui": "github:corbitsdev/react-ui#bebe1ed596637a912e69dbdb857c3e043861e192", + "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", "@intx/types": "workspace:*", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.3", diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index a348c285a..1fbb8ba2b 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -3,6 +3,7 @@ import { CircleAlert } from "lucide-react"; import { AuthScreen } from "./auth-screen"; import { BenchProvider } from "./bench-context"; +import { CommandPaletteProvider } from "./command-palette-provider"; import { NavigationProvider, type Navigate } from "./navigation"; import { NotFoundPage } from "./pages/not-found-page"; import { OnboardingPage } from "./pages/onboarding-page"; @@ -37,6 +38,7 @@ function Shell({ return ( + {path === ONBOARDING_PATH ? ( diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx new file mode 100644 index 000000000..4ad3bc5fd --- /dev/null +++ b/apps/web/src/command-palette-provider.tsx @@ -0,0 +1,143 @@ +import { CommandPalette, useCommandShortcut } from "@corbits/react-ui"; +import type { CommandPaletteGroup } from "@corbits/react-ui"; +import { listChannels } from "@corbits/chat-ui"; +import { + buildStaticCommands, + matchesQuery, + useEntitySearch, +} from "@corbits/command-palette"; +import { useCallback, useMemo, useState } from "react"; + +import { NAV_ROUTES } from "./routes"; +import { RunsSchema, useAPIQuery } from "./api"; +import { useBench } from "./bench-context"; +import type { Navigate } from "./navigation"; + +const STATIC_COMMANDS = buildStaticCommands( + NAV_ROUTES.map((route) => ({ path: route.path, label: route.label })), +); + +/** + * Wires the data-driven react-ui command palette into the app shell. + * + * Static commands come from the routes the shell already renders; entity + * results come from the same `listChannels`/workflow-runs calls the Chat and + * Workflows pages already use — this file adds no new fetch of its own. The + * typed query is debounced and paginated by `useEntitySearch`; this provider + * only groups the results it returns and maps a selection back to a + * navigation. Ranking, matching, and the "no raw identifier on screen" + * floor all live in `@corbits/command-palette` and `@corbits/react-ui` — + * see docs/command-palette.md. + */ +export function CommandPaletteProvider({ + navigate, +}: { + readonly navigate: Navigate; +}) { + const { selectedTenantId } = useBench(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const runsQuery = useAPIQuery("/api/me/workflows/runs", RunsSchema); + + const listChannelsForSearch = useCallback(async () => { + if (selectedTenantId === null) return []; + const result = await listChannels(selectedTenantId, "channel"); + return result.map((channel) => ({ id: channel.id, name: channel.title })); + }, [selectedTenantId]); + + const listRunsForSearch = useCallback(async () => { + if (runsQuery.kind !== "ready") return []; + return runsQuery.data.data.map((run) => ({ + id: run.id, + name: run.definitionName, + })); + }, [runsQuery]); + + const { results, loading, error, hasMore, loadMore } = useEntitySearch({ + query, + enabled: open, + listChannels: listChannelsForSearch, + listRuns: listRunsForSearch, + }); + + useCommandShortcut(() => setOpen((current) => !current)); + + const groups = useMemo(() => { + // Pages are matched here, client-side: they are a tiny fixed list, so + // there is no debounce or fetch to wait on — show the matches the moment + // the query changes (and all of them when it is empty). + const pages = STATIC_COMMANDS.filter((command) => + matchesQuery(command.title, query), + ); + const channels = results.filter((result) => result.category === "channels"); + const routines = results.filter((result) => result.category === "routines"); + + const groups: CommandPaletteGroup[] = []; + if (pages.length > 0) { + groups.push({ + id: "pages", + heading: "Pages", + items: pages.map((command) => ({ + id: command.id, + title: command.title, + })), + }); + } + if (channels.length > 0) { + groups.push({ + id: "channels", + heading: "Channels", + items: channels.map((channel) => ({ + id: `entity:channels:${channel.id}`, + title: channel.title, + })), + }); + } + if (routines.length > 0) { + groups.push({ + id: "routines", + heading: "Routines", + items: routines.map((run) => ({ + id: `entity:routines:${run.id}`, + title: run.title, + })), + }); + } + return groups; + }, [results, query]); + + const handleSelect = useCallback( + (id: string) => { + if (id.startsWith("route:")) { + navigate(id.slice("route:".length)); + } else if (id.startsWith("entity:channels:")) { + navigate(`/chat/${id.slice("entity:channels:".length)}`); + } else if (id.startsWith("entity:routines:")) { + navigate("/workflows"); + } + setOpen(false); + }, + [navigate], + ); + + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) setQuery(""); + }, []); + + return ( + + ); +} diff --git a/bun.lock b/bun.lock index 8ed4fe1b3..95f927ba9 100644 --- a/bun.lock +++ b/bun.lock @@ -87,7 +87,8 @@ "@corbits/bench-ui": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/chat-ui": "workspace:*", - "@corbits/react-ui": "github:corbitsdev/react-ui#bebe1ed596637a912e69dbdb857c3e043861e192", + "@corbits/command-palette": "workspace:*", + "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", "@corbits/settings-ui": "workspace:*", "@intx/types": "workspace:*", "@radix-ui/react-dialog": "^1.1.15", @@ -152,7 +153,7 @@ "name": "@corbits/bench-ui", "version": "0.0.1", "dependencies": { - "@corbits/react-ui": "github:corbitsdev/react-ui#bebe1ed596637a912e69dbdb857c3e043861e192", + "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", "@intx/types": "workspace:*", "arktype": "catalog:", "lucide-react": "^1.27.0", @@ -200,7 +201,7 @@ "version": "0.0.1", "dependencies": { "@corbits/chat": "workspace:*", - "@corbits/react-ui": "github:corbitsdev/react-ui#bebe1ed596637a912e69dbdb857c3e043861e192", + "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", "arktype": "catalog:", "lucide-react": "^1.27.0", "react": "^19.2.0", @@ -229,6 +230,21 @@ "typescript": "catalog:", }, }, + "packages/command-palette": { + "name": "@corbits/command-palette", + "version": "0.0.1", + "dependencies": { + "react": "^19.2.0", + }, + "devDependencies": { + "@happy-dom/global-registrator": "^20.11.2", + "@types/bun": "catalog:", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "react-dom": "^19.2.0", + "typescript": "catalog:", + }, + }, "packages/commands": { "name": "@corbits/commands", "version": "0.0.1", @@ -348,7 +364,7 @@ "dependencies": { "@corbits/bench-ui": "workspace:*", "@corbits/chat-ui": "workspace:*", - "@corbits/react-ui": "github:corbitsdev/react-ui#bebe1ed596637a912e69dbdb857c3e043861e192", + "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", "@intx/types": "workspace:*", "arktype": "catalog:", "lucide-react": "^1.27.0", @@ -775,6 +791,8 @@ "@corbits/chat-ui": ["@corbits/chat-ui@workspace:packages/chat-ui"], + "@corbits/command-palette": ["@corbits/command-palette@workspace:packages/command-palette"], + "@corbits/commands": ["@corbits/commands@workspace:packages/commands"], "@corbits/echo-workflow": ["@corbits/echo-workflow@workspace:workflows/echo"], @@ -783,7 +801,7 @@ "@corbits/notify": ["@corbits/notify@workspace:packages/notify"], - "@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#bebe1ed", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-bebe1ed", "sha512-gQswotVhBuFuqiT8/Xy4vJXkJI0EBdxaXLnWGoH5fLH7M1O0OGuLDBdhSwN89SFb3gOYGDHwj9QngHOxssrBEg=="], + "@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#4b8952c", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-4b8952c", "sha512-2Pb2CQfFRchm2Q3kWUNyA/0pr3au4Pye+zOoNqJrOqjaQhDPPqoMYIAvxiPLBCbShfOtZ/A4dj1cfkj0WaUn4A=="], "@corbits/schedules": ["@corbits/schedules@workspace:packages/schedules"], diff --git a/docs/command-palette.md b/docs/command-palette.md new file mode 100644 index 000000000..5021d0cec --- /dev/null +++ b/docs/command-palette.md @@ -0,0 +1,61 @@ +# Command palette + +Cmd/Ctrl-K opens a global search-and-jump overlay: pages the app shell +already renders, plus channels and workflow runs, ranked and grouped, with +full keyboard navigation and no scale or position motion — just a fade. + +## Where it lives + +The overlay itself — the dialog, the keyboard contract (arrows, Enter, +Escape), grouped result rendering, and the loading/empty/error/load-more +states — is `CommandPalette` in +[corbitsdev/react-ui](https://github.com/corbitsdev/react-ui). It knows +nothing about channels, routines, artifacts, or agents; it renders whatever +grouped items a consumer hands it and calls back on selection. That is a +deliberate boundary: a reusable component cannot carry one product's +vocabulary. + +What workbench actually shows — which pages exist, and which channels and +workflow runs match what someone typed — is `@corbits/command-palette` +(`packages/command-palette`), a UI-free package with two responsibilities: + +- `buildStaticCommands` turns the app shell's own route table into palette + commands. It never invents a destination — a route only becomes a command + because `apps/web/src/routes.tsx` already renders it. +- `searchEntities` matches already-fetched channels and workflow runs + against a query, grouped and paginated. + +`apps/web/src/command-palette-provider.tsx` is composition only: it fetches +channels (`@corbits/chat-ui`'s `listChannels`) and workflow runs +(`/api/me/workflows/runs`) the same way the Chat and Workflows pages already +do, builds `@corbits/command-palette`'s static commands from +`apps/web/src/routes.tsx`, and hands the result to react-ui's palette. No +new endpoint, no domain logic in the app. + +## What is not wired yet + +**Artifacts and agent definitions have no cross-tenant search endpoint +today.** The Library page already documents this for artifacts — it renders +against `ArtifactSummary` with an empty list until `/api/.../artifacts` +exists — and Agents has no equivalent for agent definitions either; the +Agents page only lists per-channel invitable definitions. Rather than fake a +result set, the palette's entity search covers channels and routines only +until those endpoints land. + +**Live, query-driven server search is blocked on a react-ui publish.** The +component currently pinned in `package.json` +(`github:corbitsdev/react-ui#ea97f138844b0c0fc06577fc034d8401601e6702`) +predates the data-driven `CommandPalette` — it owns its own query state +internally and filters a fixed `actions` list, with no way to hand a +keystroke back out to the caller. Today's wiring fetches channels and runs +once when the palette opens and lets that built-in match-as-you-type filter +the full list. + +The rebuilt, data-driven `CommandPalette` — with `groups`, `onQueryChange`, +`loading`, `error`, and `hasMore`/`onLoadMore` as first-class props — lives +on react-ui's `command-palette` branch, commit `ea97f138844b0c0fc06577fc034d8401601e6702`. +Once that is published and workbench's `@corbits/react-ui` dependency moves +to the published version, `command-palette-provider.tsx` switches to +debouncing the typed query into `@corbits/command-palette`'s `searchEntities` +and passing the result through `onQueryChange`, gaining real pagination and +loading state in the process. diff --git a/packages/bench-ui/package.json b/packages/bench-ui/package.json index 9fd4618c3..b43403184 100644 --- a/packages/bench-ui/package.json +++ b/packages/bench-ui/package.json @@ -14,7 +14,7 @@ "test": "bun test" }, "dependencies": { - "@corbits/react-ui": "github:corbitsdev/react-ui#bebe1ed596637a912e69dbdb857c3e043861e192", + "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", "@intx/types": "workspace:*", "arktype": "catalog:", "lucide-react": "^1.27.0", diff --git a/packages/chat-ui/package.json b/packages/chat-ui/package.json index b8de18522..0b423a87c 100644 --- a/packages/chat-ui/package.json +++ b/packages/chat-ui/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@corbits/chat": "workspace:*", - "@corbits/react-ui": "github:corbitsdev/react-ui#bebe1ed596637a912e69dbdb857c3e043861e192", + "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", "arktype": "catalog:", "lucide-react": "^1.27.0", "react": "^19.2.0", diff --git a/packages/command-palette/bunfig.toml b/packages/command-palette/bunfig.toml new file mode 100644 index 000000000..7cd3f7e04 --- /dev/null +++ b/packages/command-palette/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test/dom-setup.ts"] diff --git a/packages/command-palette/package.json b/packages/command-palette/package.json new file mode 100644 index 000000000..af4f4dd40 --- /dev/null +++ b/packages/command-palette/package.json @@ -0,0 +1,26 @@ +{ + "name": "@corbits/command-palette", + "private": true, + "description": "The global command-palette registry: static navigation commands and debounced, paginated entity search, so any surface can render them", + "version": "0.0.1", + "license": "SEE LICENSE IN LICENSE.md", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "react": "^19.2.0" + }, + "devDependencies": { + "@happy-dom/global-registrator": "^20.11.2", + "@types/bun": "catalog:", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "react-dom": "^19.2.0", + "typescript": "catalog:" + } +} diff --git a/packages/command-palette/src/entity-search.ts b/packages/command-palette/src/entity-search.ts new file mode 100644 index 000000000..4367ee87a --- /dev/null +++ b/packages/command-palette/src/entity-search.ts @@ -0,0 +1,73 @@ +import { matchesQuery } from "./static-commands"; + +/** One entity result. Only `title` is ever shown — never the id it carries + * for selection, so a consumer wiring this into a UI can never regress the + * "no raw identifier on screen" floor by accident. */ +export type EntitySearchResult = { + readonly id: string; + readonly title: string; + readonly category: "channels" | "routines"; +}; + +export type EntitySearchPage = { + readonly results: readonly EntitySearchResult[]; + readonly hasMore: boolean; +}; + +/** The bare shape entity search needs from an already-fetched, already-typed + * list — channels and workflow runs both already come off arktype-validated + * API responses (`@corbits/chat-ui`'s `Channel`, workbench's `WorkflowRun`) + * before they reach here, so this module trusts the shape it is handed. */ +export type SearchableEntity = { + readonly id: string; + readonly name: string; +}; + +export type SearchEntitiesInput = { + readonly query: string; + readonly channels: readonly SearchableEntity[]; + readonly runs: readonly SearchableEntity[]; + readonly pageSize: number; + readonly offset: number; +}; + +/** + * Client-side search over entities the app has already fetched for its own + * pages (`listChannels`, the workflow-runs list) — there is no cross-tenant + * search endpoint yet for artifacts or agent definitions, so those two + * categories are not included here (see docs/command-palette.md). + * + * An empty query returns nothing rather than everything: the palette's own + * "type to search" state covers that case, and dumping every channel and + * every run into the list the instant the palette opens would make the + * static commands compete with noise for the first keystroke. + */ +export function searchEntities({ + query, + channels, + runs, + pageSize, + offset, +}: SearchEntitiesInput): EntitySearchPage { + if (query.trim().length === 0) return { results: [], hasMore: false }; + + const matched: EntitySearchResult[] = [ + ...channels + .filter((channel) => matchesQuery(channel.name, query)) + .map((channel) => ({ + id: channel.id, + title: channel.name, + category: "channels" as const, + })), + ...runs + .filter((run) => matchesQuery(run.name, query)) + .map((run) => ({ + id: run.id, + title: run.name, + category: "routines" as const, + })), + ]; + + const page = matched.slice(offset, offset + pageSize); + return { results: page, hasMore: offset + pageSize < matched.length }; +} diff --git a/packages/command-palette/src/index.ts b/packages/command-palette/src/index.ts new file mode 100644 index 000000000..dff125291 --- /dev/null +++ b/packages/command-palette/src/index.ts @@ -0,0 +1,24 @@ +// `@corbits/command-palette`: what the global command palette can show. +// `buildStaticCommands` turns the app shell's own route table into commands. +// `searchEntities` is the pure match/paginate core over already-fetched +// channels and workflow runs; `useEntitySearch` is the one piece of React +// this package owns — debouncing a typed query and fetching those lists, +// because that timing and caching is inseparable from the pagination it +// resets. Rendering — the overlay, the keyboard contract, the +// grouped/loading/empty/load-more states — stays a react-ui concern. +export { buildStaticCommands, matchesQuery } from "./static-commands"; +export type { StaticCommand, StaticRoute } from "./static-commands"; + +export { searchEntities } from "./entity-search"; +export type { + EntitySearchPage, + EntitySearchResult, + SearchableEntity, + SearchEntitiesInput, +} from "./entity-search"; + +export { useEntitySearch } from "./use-entity-search"; +export type { + UseEntitySearchOptions, + UseEntitySearchResult, +} from "./use-entity-search"; diff --git a/packages/command-palette/src/static-commands.ts b/packages/command-palette/src/static-commands.ts new file mode 100644 index 000000000..d29f45dee --- /dev/null +++ b/packages/command-palette/src/static-commands.ts @@ -0,0 +1,32 @@ +/** A destination the command palette can jump to. Built only from routes the + * app shell actually renders — this module never invents a destination. */ +export type StaticCommand = { + readonly id: string; + readonly title: string; + readonly category: "pages"; + readonly path: string; +}; + +/** The minimal shape a route table needs to become palette commands. */ +export type StaticRoute = { + readonly path: string; + readonly label: string; +}; + +export function buildStaticCommands( + routes: readonly StaticRoute[], +): readonly StaticCommand[] { + return routes.map((route) => ({ + id: `route:${route.path}`, + title: route.label, + category: "pages", + path: route.path, + })); +} + +/** Case-insensitive substring match; an empty or whitespace-only query matches everything. */ +export function matchesQuery(title: string, query: string): boolean { + const needle = query.trim().toLowerCase(); + if (needle.length === 0) return true; + return title.toLowerCase().includes(needle); +} diff --git a/packages/command-palette/src/use-entity-search.ts b/packages/command-palette/src/use-entity-search.ts new file mode 100644 index 000000000..996b68544 --- /dev/null +++ b/packages/command-palette/src/use-entity-search.ts @@ -0,0 +1,138 @@ +import { useEffect, useRef, useState } from "react"; + +import { searchEntities } from "./entity-search"; +import type { EntitySearchResult, SearchableEntity } from "./entity-search"; + +const DEFAULT_PAGE_SIZE = 20; +const DEFAULT_DEBOUNCE_MS = 200; + +export type UseEntitySearchOptions = { + readonly query: string; + /** Skip fetching entirely while the palette is closed. */ + readonly enabled: boolean; + readonly pageSize?: number; + readonly debounceMs?: number; + readonly listChannels: () => Promise; + readonly listRuns: () => Promise; +}; + +export type UseEntitySearchResult = { + readonly results: readonly EntitySearchResult[]; + readonly loading: boolean; + readonly error: boolean; + readonly hasMore: boolean; + readonly loadMore: () => void; +}; + +/** + * Debounces a typed query, fetches channels and workflow runs once per + * search (cached across pages of the same search), and matches/paginates + * them via `searchEntities`. Debouncing lives here rather than in the app + * shell because it is inseparable from the pagination it resets: a + * keystroke that arrives mid-debounce must restart the timer *and* the + * offset together, or a stale page from the previous query would leak into + * the new one. + * + * `loading` is derived, not just set in an effect: the moment a keystroke + * makes `query` outrun the debounce-committed `debouncedQuery`, the hook is + * `pending`, and that is visible on the very render the keystroke caused — + * no waiting for a passive effect to flush. It stays true through the fetch + * (`fetching`) and only drops once that query's results are ready. + * + * Artifacts and agent definitions are not searched here — see + * docs/command-palette.md for why those two entity types have nothing to + * query yet. + */ +export function useEntitySearch({ + query, + enabled, + pageSize = DEFAULT_PAGE_SIZE, + debounceMs = DEFAULT_DEBOUNCE_MS, + listChannels, + listRuns, +}: UseEntitySearchOptions): UseEntitySearchResult { + const [debouncedQuery, setDebouncedQuery] = useState(""); + const [offset, setOffset] = useState(0); + const [entities, setEntities] = useState<{ + readonly channels: readonly SearchableEntity[]; + readonly runs: readonly SearchableEntity[]; + } | null>(null); + const [fetching, setFetching] = useState(false); + const [error, setError] = useState(false); + const fetchToken = useRef(0); + // Hold the latest fetchers without making the fetch effect depend on their + // identity — callers (and tests) are free to hand in fresh arrow functions + // each render without restarting the search or looping. + const fetchers = useRef({ listChannels, listRuns }); + fetchers.current = { listChannels, listRuns }; + + // True the instant a keystroke outruns the debounce and stays true until + // that query's fetch resolves — derived here so the spinner shows on the + // render the keystroke caused, before any passive effect runs. + const pending = + enabled && query.trim().length > 0 && debouncedQuery !== query; + + useEffect(() => { + setOffset(0); + if (!enabled || query.trim().length === 0) { + setDebouncedQuery(""); + return; + } + const timer = setTimeout(() => setDebouncedQuery(query), debounceMs); + return () => clearTimeout(timer); + }, [query, enabled, debounceMs]); + + useEffect(() => { + if (debouncedQuery.trim().length === 0) { + setEntities(null); + setFetching(false); + setError(false); + return; + } + const token = ++fetchToken.current; + setFetching(true); + setError(false); + void Promise.all([ + fetchers.current.listChannels(), + fetchers.current.listRuns(), + ]) + .then(([channels, runs]) => { + if (token !== fetchToken.current) return; + setEntities({ channels, runs }); + setFetching(false); + }) + .catch(() => { + if (token !== fetchToken.current) return; + setError(true); + setFetching(false); + }); + }, [debouncedQuery]); + + const loading = pending || fetching; + + if (entities === null || debouncedQuery.trim().length === 0) { + return { + results: [], + loading, + error, + hasMore: false, + loadMore: () => setOffset(0), + }; + } + + const page = searchEntities({ + query: debouncedQuery, + channels: entities.channels, + runs: entities.runs, + pageSize: offset + pageSize, + offset: 0, + }); + + return { + results: page.results, + loading, + error, + hasMore: page.hasMore, + loadMore: () => setOffset((current) => current + pageSize), + }; +} diff --git a/packages/command-palette/test/dom-setup.ts b/packages/command-palette/test/dom-setup.ts new file mode 100644 index 000000000..c9b4f20c1 --- /dev/null +++ b/packages/command-palette/test/dom-setup.ts @@ -0,0 +1,12 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +// This package's other tests are pure functions of plain data and need no +// DOM. `useEntitySearch` is the exception — it debounces on a real timer and +// drives state off real effects, so it is exercised against a registered DOM +// rather than mocked piecemeal. +GlobalRegistrator.register(); + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean; +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; diff --git a/packages/command-palette/test/entity-search.test.ts b/packages/command-palette/test/entity-search.test.ts new file mode 100644 index 000000000..6cabfa7d1 --- /dev/null +++ b/packages/command-palette/test/entity-search.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; + +import { searchEntities } from "../src/entity-search"; + +describe("searchEntities", () => { + const channels = [ + { id: "chan-1", name: "Launch Planning" }, + { id: "chan-2", name: "Support Triage" }, + ]; + const runs = [ + { id: "run-1", name: "Nightly Digest" }, + { id: "run-2", name: "Launch Retro" }, + ]; + + test("matches by title across every category and never surfaces a raw id", () => { + const page = searchEntities({ + query: "launch", + channels, + runs, + pageSize: 10, + offset: 0, + }); + const titles = page.results.map((result) => result.title); + expect(titles).toEqual(["Launch Planning", "Launch Retro"]); + expect( + page.results.every( + (result) => + !result.title.startsWith("chan-") && !result.title.startsWith("run-"), + ), + ).toBe(true); + }); + + test("an empty query returns no results — the palette shows its own empty state", () => { + const page = searchEntities({ + query: "", + channels, + runs, + pageSize: 10, + offset: 0, + }); + expect(page.results).toEqual([]); + }); + + test("paginates with a page size and reports whether more results remain", () => { + const first = searchEntities({ + query: "a", + channels, + runs, + pageSize: 1, + offset: 0, + }); + expect(first.results).toHaveLength(1); + expect(first.hasMore).toBe(true); + + const second = searchEntities({ + query: "a", + channels, + runs, + pageSize: 1, + offset: 1, + }); + expect(second.results).toHaveLength(1); + }); + + test("categorizes channel and routine results distinctly", () => { + const page = searchEntities({ + query: "launch", + channels, + runs, + pageSize: 10, + offset: 0, + }); + const categories = page.results.map((result) => result.category); + expect(categories).toEqual(["channels", "routines"]); + }); +}); diff --git a/packages/command-palette/test/static-commands.test.ts b/packages/command-palette/test/static-commands.test.ts new file mode 100644 index 000000000..7704eeeed --- /dev/null +++ b/packages/command-palette/test/static-commands.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; + +import { buildStaticCommands, matchesQuery } from "../src/static-commands"; + +describe("buildStaticCommands", () => { + test("maps real routes to commands, one per route, in order", () => { + const commands = buildStaticCommands([ + { path: "/", label: "Home" }, + { path: "/chat", label: "Chat" }, + ]); + expect(commands).toEqual([ + { id: "route:/", title: "Home", category: "pages", path: "/" }, + { id: "route:/chat", title: "Chat", category: "pages", path: "/chat" }, + ]); + }); + + test("never fabricates a route beyond what it is given", () => { + const commands = buildStaticCommands([]); + expect(commands).toEqual([]); + }); +}); + +describe("matchesQuery", () => { + test("is case-insensitive and matches substrings anywhere in the title", () => { + expect(matchesQuery("Settings", "sett")).toBe(true); + expect(matchesQuery("Settings", "TINGS")).toBe(true); + }); + + test("an empty query matches everything", () => { + expect(matchesQuery("Settings", "")).toBe(true); + expect(matchesQuery("Settings", " ")).toBe(true); + }); + + test("rejects a title that does not contain the query", () => { + expect(matchesQuery("Settings", "zzz")).toBe(false); + }); +}); diff --git a/packages/command-palette/test/use-entity-search.test.tsx b/packages/command-palette/test/use-entity-search.test.tsx new file mode 100644 index 000000000..37142a021 --- /dev/null +++ b/packages/command-palette/test/use-entity-search.test.tsx @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; +import { act, createElement, useState } from "react"; +import { createRoot } from "react-dom/client"; + +import { useEntitySearch } from "../src/use-entity-search"; +import type { UseEntitySearchResult } from "../src/use-entity-search"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const CHANNELS = [ + { id: "chan-1", name: "Launch Planning" }, + { id: "chan-2", name: "Support Triage" }, +]; +const RUNS = [ + { id: "run-1", name: "Nightly Digest" }, + { id: "run-2", name: "Launch Retro" }, +]; + +function mount(initialQuery: string) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let latest: UseEntitySearchResult | undefined; + let setQuery: (query: string) => void = () => {}; + + function Host() { + const [query, setState] = useState(initialQuery); + setQuery = setState; + latest = useEntitySearch({ + query, + enabled: true, + pageSize: 1, + debounceMs: 5, + listChannels: () => Promise.resolve(CHANNELS), + listRuns: () => Promise.resolve(RUNS), + }); + return null; + } + + act(() => { + root.render(createElement(Host)); + }); + + return { + setQuery: (query: string) => + act(() => { + setQuery(query); + }), + settle: () => act(() => sleep(30)), + get: () => latest as UseEntitySearchResult, + unmount: () => root.unmount(), + }; +} + +describe("useEntitySearch", () => { + test("an empty query fetches nothing and returns no results", async () => { + const harness = mount(""); + await harness.settle(); + expect(harness.get().results).toEqual([]); + expect(harness.get().loading).toBe(false); + harness.unmount(); + }); + + test("debounces the query before searching, then reports matches with the raw id never in the title", async () => { + const harness = mount(""); + await harness.settle(); + await harness.setQuery("launch"); + expect(harness.get().loading).toBe(true); + await harness.settle(); + expect(harness.get().loading).toBe(false); + const titles = harness.get().results.map((result) => result.title); + expect(titles).toEqual(["Launch Planning"]); + expect( + titles.every( + (title) => !title.startsWith("chan-") && !title.startsWith("run-"), + ), + ).toBe(true); + expect(harness.get().hasMore).toBe(true); + harness.unmount(); + }); + + test("loadMore appends the next page without re-debouncing", async () => { + const harness = mount(""); + await harness.settle(); + await harness.setQuery("launch"); + await harness.settle(); + act(() => { + harness.get().loadMore(); + }); + await harness.settle(); + const titles = harness.get().results.map((result) => result.title); + expect(titles).toEqual(["Launch Planning", "Launch Retro"]); + expect(harness.get().hasMore).toBe(false); + harness.unmount(); + }); + + test("changing the query resets pagination back to the first page", async () => { + const harness = mount(""); + await harness.settle(); + await harness.setQuery("launch"); + await harness.settle(); + act(() => { + harness.get().loadMore(); + }); + await harness.settle(); + expect(harness.get().results).toHaveLength(2); + + await harness.setQuery("triage"); + await harness.settle(); + expect(harness.get().results.map((result) => result.title)).toEqual([ + "Support Triage", + ]); + harness.unmount(); + }); + + test("a fetch failure is reported as an error rather than an empty result", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let latest: UseEntitySearchResult | undefined; + + function Host() { + latest = useEntitySearch({ + query: "launch", + enabled: true, + pageSize: 10, + debounceMs: 5, + listChannels: () => Promise.reject(new Error("boom")), + listRuns: () => Promise.resolve(RUNS), + }); + return null; + } + + act(() => { + root.render(createElement(Host)); + }); + await act(() => sleep(30)); + expect(latest?.error).toBe(true); + expect(latest?.loading).toBe(false); + root.unmount(); + }); +}); diff --git a/packages/command-palette/tsconfig.json b/packages/command-palette/tsconfig.json new file mode 100644 index 000000000..461e72c55 --- /dev/null +++ b/packages/command-palette/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["bun"] + }, + "include": ["src", "test"] +} diff --git a/packages/settings-ui/package.json b/packages/settings-ui/package.json index 5b900bf79..94e77ad7f 100644 --- a/packages/settings-ui/package.json +++ b/packages/settings-ui/package.json @@ -16,7 +16,7 @@ "dependencies": { "@corbits/bench-ui": "workspace:*", "@corbits/chat-ui": "workspace:*", - "@corbits/react-ui": "github:corbitsdev/react-ui#bebe1ed596637a912e69dbdb857c3e043861e192", + "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", "@intx/types": "workspace:*", "arktype": "catalog:", "lucide-react": "^1.27.0",