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
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -37,6 +38,7 @@ function Shell({
return (
<NavigationProvider navigate={navigate}>
<BenchProvider>
<CommandPaletteProvider navigate={navigate} />
<AppShell path={path} user={user} onSignOut={onSignOut}>
{path === ONBOARDING_PATH ? (
<OnboardingPage />
Expand Down
143 changes: 143 additions & 0 deletions apps/web/src/command-palette-provider.tsx
Original file line number Diff line number Diff line change
@@ -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<readonly CommandPaletteGroup[]>(() => {
// 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 (
<CommandPalette
open={open}
onOpenChange={handleOpenChange}
query={query}
onQueryChange={setQuery}
groups={groups}
onSelect={handleSelect}
loading={loading}
error={error ? "Search failed. Try again." : undefined}
hasMore={hasMore}
onLoadMore={loadMore}
placeholder="Search or jump to…"
/>
);
}
28 changes: 23 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 61 additions & 0 deletions docs/command-palette.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/bench-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/chat-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/command-palette/bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[test]
preload = ["./test/dom-setup.ts"]
26 changes: 26 additions & 0 deletions packages/command-palette/package.json
Original file line number Diff line number Diff line change
@@ -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:"
}
}
Loading
Loading