diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index 4ad3bc5fd..0a5941572 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -8,6 +8,7 @@ import { } from "@corbits/command-palette"; import { useCallback, useMemo, useState } from "react"; +import { listAgentDefinitions } from "./agents-api"; import { NAV_ROUTES } from "./routes"; import { RunsSchema, useAPIQuery } from "./api"; import { useBench } from "./bench-context"; @@ -21,13 +22,12 @@ const STATIC_COMMANDS = buildStaticCommands( * 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. + * results come from the same listChannels / workflow-runs / agent-definitions + * calls the product pages already use — this file adds no new fetch of its + * own beyond those. Sources are free-form labels the package carries through + * so this provider can group results and map a selection to a real route. + * Ranking, matching, and the "no raw identifier on screen" floor all live in + * `@corbits/command-palette` and `@corbits/react-ui`. */ export function CommandPaletteProvider({ navigate, @@ -45,6 +45,9 @@ export function CommandPaletteProvider({ return result.map((channel) => ({ id: channel.id, name: channel.title })); }, [selectedTenantId]); + // Workflow runs are what the Routines page lists today. The group is labeled + // "Runs" (truthful source) and navigates to `/routines/:id` — never the dead + // `/workflows` path the previous palette hard-coded. const listRunsForSearch = useCallback(async () => { if (runsQuery.kind !== "ready") return []; return runsQuery.data.data.map((run) => ({ @@ -53,11 +56,28 @@ export function CommandPaletteProvider({ })); }, [runsQuery]); + const listAgentsForSearch = useCallback(async () => { + if (selectedTenantId === null) return []; + const definitions = await listAgentDefinitions(selectedTenantId); + return definitions.map((definition) => ({ + id: definition.id, + name: definition.name, + })); + }, [selectedTenantId]); + + const sources = useMemo( + () => [ + { category: "channels", fetch: listChannelsForSearch }, + { category: "runs", fetch: listRunsForSearch }, + { category: "agents", fetch: listAgentsForSearch }, + ], + [listChannelsForSearch, listRunsForSearch, listAgentsForSearch], + ); + const { results, loading, error, hasMore, loadMore } = useEntitySearch({ query, enabled: open, - listChannels: listChannelsForSearch, - listRuns: listRunsForSearch, + sources, }); useCommandShortcut(() => setOpen((current) => !current)); @@ -70,7 +90,8 @@ export function CommandPaletteProvider({ matchesQuery(command.title, query), ); const channels = results.filter((result) => result.category === "channels"); - const routines = results.filter((result) => result.category === "routines"); + const runs = results.filter((result) => result.category === "runs"); + const agents = results.filter((result) => result.category === "agents"); const groups: CommandPaletteGroup[] = []; if (pages.length > 0) { @@ -93,16 +114,26 @@ export function CommandPaletteProvider({ })), }); } - if (routines.length > 0) { + if (runs.length > 0) { groups.push({ - id: "routines", - heading: "Routines", - items: routines.map((run) => ({ - id: `entity:routines:${run.id}`, + id: "runs", + heading: "Runs", + items: runs.map((run) => ({ + id: `entity:runs:${run.id}`, title: run.title, })), }); } + if (agents.length > 0) { + groups.push({ + id: "agents", + heading: "Agents", + items: agents.map((agent) => ({ + id: `entity:agents:${agent.id}`, + title: agent.title, + })), + }); + } return groups; }, [results, query]); @@ -112,8 +143,11 @@ export function CommandPaletteProvider({ 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"); + } else if (id.startsWith("entity:runs:")) { + // Routines page owns the /routines prefix (including detail segments). + navigate(`/routines/${id.slice("entity:runs:".length)}`); + } else if (id.startsWith("entity:agents:")) { + navigate("/agents"); } setOpen(false); }, diff --git a/packages/command-palette/src/entity-search.ts b/packages/command-palette/src/entity-search.ts index 4367ee87a..e95bdca09 100644 --- a/packages/command-palette/src/entity-search.ts +++ b/packages/command-palette/src/entity-search.ts @@ -6,7 +6,11 @@ import { matchesQuery } from "./static-commands"; export type EntitySearchResult = { readonly id: string; readonly title: string; - readonly category: "channels" | "routines"; + /** Which source the result came from — a free-form label the consumer + * defines (e.g. `"channels"`, `"routines"`, `"agents"`). The package + * never interprets it; it only carries it through so the app shell can + * group results and map a selection back to the right route. */ + readonly category: string; }; export type EntitySearchPage = { @@ -15,58 +19,63 @@ export type EntitySearchPage = { }; /** 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. */ + * list — channels, routines, agents, etc. all already come off + * arktype-validated API responses before they reach here, so this module + * trusts the shape it is handed. */ export type SearchableEntity = { readonly id: string; readonly name: string; }; +/** A named bundle of entities the search core matches against. The + * `category` label flows through to every result so the consumer can group + * and route them without re-deriving provenance. */ +export type EntitySource = { + readonly category: string; + readonly entities: readonly SearchableEntity[]; +}; + export type SearchEntitiesInput = { readonly query: string; - readonly channels: readonly SearchableEntity[]; - readonly runs: readonly SearchableEntity[]; + readonly sources: readonly EntitySource[]; 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). + * pages — channels, routines, agents, whatever the consumer hands in. There + * is no cross-tenant search endpoint yet, so every source is an + * already-fetched list matched here. * * 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. + * "type to search" state covers that case, and dumping every entity into + * the list the instant the palette opens would make the static commands + * compete with noise for the first keystroke. + * + * Results preserve source order: if the consumer passes channels before + * routines, channel matches appear first within a page. */ export function searchEntities({ query, - channels, - runs, + sources, 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 matched: EntitySearchResult[] = []; + for (const source of sources) { + for (const entity of source.entities) { + if (matchesQuery(entity.name, query)) { + matched.push({ + id: entity.id, + title: entity.name, + category: source.category, + }); + } + } + } 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 index dff125291..db52aa3db 100644 --- a/packages/command-palette/src/index.ts +++ b/packages/command-palette/src/index.ts @@ -1,11 +1,12 @@ // `@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. +// entity lists (channels, routines, agents — any source the consumer wires); +// `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"; @@ -13,12 +14,14 @@ export { searchEntities } from "./entity-search"; export type { EntitySearchPage, EntitySearchResult, + EntitySource, SearchableEntity, SearchEntitiesInput, } from "./entity-search"; export { useEntitySearch } from "./use-entity-search"; export type { + EntitySourceFetcher, UseEntitySearchOptions, UseEntitySearchResult, } from "./use-entity-search"; diff --git a/packages/command-palette/src/use-entity-search.ts b/packages/command-palette/src/use-entity-search.ts index 996b68544..b3728ef1d 100644 --- a/packages/command-palette/src/use-entity-search.ts +++ b/packages/command-palette/src/use-entity-search.ts @@ -6,14 +6,20 @@ import type { EntitySearchResult, SearchableEntity } from "./entity-search"; const DEFAULT_PAGE_SIZE = 20; const DEFAULT_DEBOUNCE_MS = 200; +/** A named fetcher the hook calls once per search. The `category` label + * flows through to every result so the consumer can group and route them. */ +export type EntitySourceFetcher = { + readonly category: string; + readonly fetch: () => Promise; +}; + 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; + readonly sources: readonly EntitySourceFetcher[]; }; export type UseEntitySearchResult = { @@ -25,13 +31,13 @@ export type UseEntitySearchResult = { }; /** - * 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. + * Debounces a typed query, fetches every source 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 @@ -39,32 +45,30 @@ export type UseEntitySearchResult = { * 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. + * All sources are fetched in parallel via `Promise.all`; a failure in any + * one surfaces as `error: true` rather than a partial result set. */ export function useEntitySearch({ query, enabled, pageSize = DEFAULT_PAGE_SIZE, debounceMs = DEFAULT_DEBOUNCE_MS, - listChannels, - listRuns, + sources, }: 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 [fetched, setFetched] = useState | 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 }; + // 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 fetchersRef = useRef(sources); + fetchersRef.current = sources; // 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 @@ -84,7 +88,7 @@ export function useEntitySearch({ useEffect(() => { if (debouncedQuery.trim().length === 0) { - setEntities(null); + setFetched(null); setFetching(false); setError(false); return; @@ -92,13 +96,17 @@ export function useEntitySearch({ const token = ++fetchToken.current; setFetching(true); setError(false); - void Promise.all([ - fetchers.current.listChannels(), - fetchers.current.listRuns(), - ]) - .then(([channels, runs]) => { + const current = fetchersRef.current; + void Promise.all(current.map((source) => source.fetch())) + .then((results) => { if (token !== fetchToken.current) return; - setEntities({ channels, runs }); + const map = new Map(); + for (let i = 0; i < current.length; i++) { + const source = current[i]; + if (!source) continue; + map.set(source.category, results[i] ?? []); + } + setFetched(map); setFetching(false); }) .catch(() => { @@ -110,7 +118,7 @@ export function useEntitySearch({ const loading = pending || fetching; - if (entities === null || debouncedQuery.trim().length === 0) { + if (fetched === null || debouncedQuery.trim().length === 0) { return { results: [], loading, @@ -120,10 +128,14 @@ export function useEntitySearch({ }; } + const resolvedSources = fetchersRef.current.map((source) => ({ + category: source.category, + entities: fetched.get(source.category) ?? [], + })); + const page = searchEntities({ query: debouncedQuery, - channels: entities.channels, - runs: entities.runs, + sources: resolvedSources, pageSize: offset + pageSize, offset: 0, }); diff --git a/packages/command-palette/test/entity-search.test.ts b/packages/command-palette/test/entity-search.test.ts index 6cabfa7d1..1a62fa74d 100644 --- a/packages/command-palette/test/entity-search.test.ts +++ b/packages/command-palette/test/entity-search.test.ts @@ -3,29 +3,45 @@ 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" }, + const sources = [ + { + category: "channels", + entities: [ + { id: "chan-1", name: "Launch Planning" }, + { id: "chan-2", name: "Support Triage" }, + ], + }, + { + category: "routines", + entities: [ + { id: "rt-1", name: "Nightly Digest" }, + { id: "rt-2", name: "Launch Retro" }, + ], + }, + { + category: "agents", + entities: [ + { id: "agent-1", name: "Launch Agent" }, + { id: "agent-2", name: "Research Helper" }, + ], + }, ]; - test("matches by title across every category and never surfaces a raw id", () => { + test("matches by title across every source and never surfaces a raw id", () => { const page = searchEntities({ query: "launch", - channels, - runs, + sources, pageSize: 10, offset: 0, }); const titles = page.results.map((result) => result.title); - expect(titles).toEqual(["Launch Planning", "Launch Retro"]); + expect(titles).toEqual(["Launch Planning", "Launch Retro", "Launch Agent"]); expect( page.results.every( (result) => - !result.title.startsWith("chan-") && !result.title.startsWith("run-"), + !result.title.startsWith("chan-") && + !result.title.startsWith("rt-") && + !result.title.startsWith("agent-"), ), ).toBe(true); }); @@ -33,8 +49,7 @@ describe("searchEntities", () => { test("an empty query returns no results — the palette shows its own empty state", () => { const page = searchEntities({ query: "", - channels, - runs, + sources, pageSize: 10, offset: 0, }); @@ -44,8 +59,7 @@ describe("searchEntities", () => { test("paginates with a page size and reports whether more results remain", () => { const first = searchEntities({ query: "a", - channels, - runs, + sources, pageSize: 1, offset: 0, }); @@ -54,23 +68,44 @@ describe("searchEntities", () => { const second = searchEntities({ query: "a", - channels, - runs, + sources, pageSize: 1, offset: 1, }); expect(second.results).toHaveLength(1); }); - test("categorizes channel and routine results distinctly", () => { + test("preserves source order and categorizes results by their source", () => { const page = searchEntities({ query: "launch", - channels, - runs, + sources, pageSize: 10, offset: 0, }); const categories = page.results.map((result) => result.category); - expect(categories).toEqual(["channels", "routines"]); + expect(categories).toEqual(["channels", "routines", "agents"]); + }); + + test("returns nothing for a source with no matching entities", () => { + const page = searchEntities({ + query: "research", + sources, + pageSize: 10, + offset: 0, + }); + const titles = page.results.map((result) => result.title); + expect(titles).toEqual(["Research Helper"]); + expect(page.results[0]?.category).toBe("agents"); + }); + + test("handles an empty sources list without error", () => { + const page = searchEntities({ + query: "anything", + sources: [], + pageSize: 10, + offset: 0, + }); + expect(page.results).toEqual([]); + expect(page.hasMore).toBe(false); }); }); diff --git a/packages/command-palette/test/use-entity-search.test.tsx b/packages/command-palette/test/use-entity-search.test.tsx index 37142a021..c5f1ae11f 100644 --- a/packages/command-palette/test/use-entity-search.test.tsx +++ b/packages/command-palette/test/use-entity-search.test.tsx @@ -11,9 +11,19 @@ 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" }, +const ROUTINES = [ + { id: "rt-1", name: "Nightly Digest" }, + { id: "rt-2", name: "Launch Retro" }, +]; +const AGENTS = [ + { id: "agent-1", name: "Launch Agent" }, + { id: "agent-2", name: "Research Helper" }, +]; + +const SOURCES = [ + { category: "channels", fetch: () => Promise.resolve(CHANNELS) }, + { category: "routines", fetch: () => Promise.resolve(ROUTINES) }, + { category: "agents", fetch: () => Promise.resolve(AGENTS) }, ]; function mount(initialQuery: string) { @@ -31,8 +41,7 @@ function mount(initialQuery: string) { enabled: true, pageSize: 1, debounceMs: 5, - listChannels: () => Promise.resolve(CHANNELS), - listRuns: () => Promise.resolve(RUNS), + sources: SOURCES, }); return null; } @@ -72,7 +81,10 @@ describe("useEntitySearch", () => { expect(titles).toEqual(["Launch Planning"]); expect( titles.every( - (title) => !title.startsWith("chan-") && !title.startsWith("run-"), + (title) => + !title.startsWith("chan-") && + !title.startsWith("rt-") && + !title.startsWith("agent-"), ), ).toBe(true); expect(harness.get().hasMore).toBe(true); @@ -90,7 +102,7 @@ describe("useEntitySearch", () => { 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); + expect(harness.get().hasMore).toBe(true); harness.unmount(); }); @@ -113,7 +125,7 @@ describe("useEntitySearch", () => { harness.unmount(); }); - test("a fetch failure is reported as an error rather than an empty result", async () => { + test("a fetch failure in any source is reported as an error rather than a partial result", async () => { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -125,8 +137,13 @@ describe("useEntitySearch", () => { enabled: true, pageSize: 10, debounceMs: 5, - listChannels: () => Promise.reject(new Error("boom")), - listRuns: () => Promise.resolve(RUNS), + sources: [ + { category: "channels", fetch: () => Promise.resolve(CHANNELS) }, + { + category: "routines", + fetch: () => Promise.reject(new Error("boom")), + }, + ], }); return null; } @@ -139,4 +156,22 @@ describe("useEntitySearch", () => { expect(latest?.loading).toBe(false); root.unmount(); }); + + test("searches across all sources and preserves source order in results", async () => { + const harness = mount(""); + await harness.settle(); + await harness.setQuery("launch"); + await harness.settle(); + // Load all three "launch" matches across channels/routines/agents + act(() => harness.get().loadMore()); + await harness.settle(); + act(() => harness.get().loadMore()); + await harness.settle(); + const results = harness.get().results; + const titles = results.map((r) => r.title); + expect(titles).toEqual(["Launch Planning", "Launch Retro", "Launch Agent"]); + const categories = results.map((r) => r.category); + expect(categories).toEqual(["channels", "routines", "agents"]); + harness.unmount(); + }); });