Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 51 additions & 17 deletions apps/web/src/command-palette-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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) => ({
Expand All @@ -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));
Expand All @@ -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) {
Expand All @@ -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]);

Expand All @@ -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);
},
Expand Down
69 changes: 39 additions & 30 deletions packages/command-palette/src/entity-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 };
Expand Down
13 changes: 8 additions & 5 deletions packages/command-palette/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
// `@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";

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";
Loading
Loading