diff --git a/DESIGN.md b/DESIGN.md index f95c85e4f..943bd2388 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -99,8 +99,9 @@ as a document, and these are working surfaces. There is exactly one search surface in the product: the command-palette scope. It is reachable two ways that resolve to the same UI — cmd+K anywhere, or clicking the magnifier in the top nav, which morphs in place -into an inline search bar over about 200ms with the spring easing (see -Motion). Esc collapses it back to the magnifier. There is no page-local +into an inline search bar over about 200ms with the in-place morph easing +(see Motion). Esc collapses it back to the magnifier, with focus returning +to the magnifier itself. There is no page-local search input that duplicates palette scope; a page that needs scoped filtering builds it as a filter control, not a second "search." See `docs/command-palette.md` for the palette's scoring and result-group @@ -139,10 +140,23 @@ Durations run 150–300ms; entrances ease out, never linear or bouncy-in. Two named easings cover the system: - `spring` — `cubic-bezier(.2, .9, .3, 1.15)` — for things that pop into - place with a little overshoot (the search bar's morph). + place with a little overshoot. - `out` — `cubic-bezier(.2, .8, .3, 1)` — for straightforward entrances and exits with no overshoot. +Something that grows or shrinks _in place_ — the search bar's morph, a rail +resizing — takes `--ease-in-out` instead: an overshoot there does not read as +liveliness, it drags every neighbour in the row along with it. This +supersedes the earlier reading of `spring` as the search morph's curve +(CL-6410 review); the curves themselves are react-ui's, and its `theme.css` +documents `--ease-in-out` as the morph curve. + +These are tokens on `@corbits/react-ui`'s theme, not Tailwind utilities the +product can name: the app imports react-ui's _prebuilt_ stylesheet, so a +`duration-standard` or `ease-spring` class compiles to nothing here. Product +motion is authored as a real `transition` declaration reading +`var(--duration-*)` / `var(--ease-*)`. + Motion always encodes a state change — something entering, something transforming, focus moving — never plain decoration. If removing an animation wouldn't remove any information, it doesn't belong. Every diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 806a67256..f74f2768c 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -699,6 +699,63 @@ select:disabled, margin-left: auto; } +/* The one search entry point: a magnifier that morphs in place into the + palette's inline bar. Width is the animated property, so both states have + to be the same element — never a swap between two boxes. The transition is + authored here rather than as Tailwind utilities: react-ui ships a prebuilt + stylesheet, and `duration-standard`/`ease-*` compile to classes only in + react-ui's own build, so a utility class would be inert here. `--ease-in-out` + is react-ui's documented curve for something growing in place — a spring's + overshoot would jitter the whole top bar. Reduced motion is already handled + by that stylesheet's global transition-duration collapse. */ +.stage-search { + display: flex; + flex-shrink: 0; + align-items: center; + width: 1.9rem; + overflow: hidden; + border: 1px solid transparent; + transition: width var(--duration-standard) var(--ease-in-out); +} + +.stage-search[data-expanded="true"] { + width: 15rem; + max-width: 40vw; + border-color: var(--border); + background: var(--card); + padding-right: 0.4rem; +} + +.stage-search-button { + display: grid; + place-items: center; + flex-shrink: 0; + width: 1.9rem; + height: 1.9rem; + border: 0; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; +} + +.stage-search-button:hover { + color: var(--foreground); +} + +.stage-search-field { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.78rem; + color: var(--foreground); +} + +.stage-search-field[data-placeholder="true"] { + color: var(--muted-foreground); +} + /* Breadcrumb trails live in the title slot, always top-left. */ .stage-crumbs { display: flex; diff --git a/apps/web/src/command-palette-events.ts b/apps/web/src/command-palette-events.ts deleted file mode 100644 index 751f43962..000000000 --- a/apps/web/src/command-palette-events.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** App-local event so the rail Search control can open the command palette - * without coupling rail.tsx to CommandPaletteProvider state. */ - -export const OPEN_COMMAND_PALETTE_EVENT = "workbench:open-command-palette"; - -export function requestOpenCommandPalette(): void { - if (typeof window === "undefined") return; - window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT)); -} diff --git a/apps/web/src/command-palette-open-store.ts b/apps/web/src/command-palette-open-store.ts new file mode 100644 index 000000000..719a10c59 --- /dev/null +++ b/apps/web/src/command-palette-open-store.ts @@ -0,0 +1,66 @@ +// The state of the product's single search surface (DESIGN.md → Search), +// held outside the React tree because the surfaces that read and write it are +// siblings, not ancestors: `CommandPaletteProvider` renders the palette, +// `StageTopBar`'s magnifier morphs into it, and a context menu item opens it, +// and app.tsx's Shell mounts the first two side by side. One store, so the +// morph and the palette can never disagree about whether search is open, and +// so cmd+K, the magnifier, and a menu item all drive the same surface. +// +// Module state outlives a React remount, so search is scoped explicitly: +// `CommandPaletteProvider` closes it on a route change (a Back out of a +// result must not leave the overlay standing) and on a bench switch (whose +// results and query belonged to the bench being left). + +import { useSyncExternalStore } from "react"; + +let open = false; +let query = ""; +const listeners = new Set<() => void>(); + +function emit(): void { + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function setCommandPaletteOpen(next: boolean): void { + if (open === next) return; + open = next; + // A closed palette keeps no query: reopening starts from the default view, + // never from a stale search someone abandoned. + if (!next) query = ""; + emit(); +} + +export function openCommandPalette(): void { + setCommandPaletteOpen(true); +} + +export function closeCommandPalette(): void { + setCommandPaletteOpen(false); +} + +export function setCommandPaletteQuery(next: string): void { + if (query === next) return; + query = next; + emit(); +} + +export function useCommandPaletteOpen(): boolean { + return useSyncExternalStore( + subscribe, + () => open, + () => false, + ); +} + +export function useCommandPaletteQuery(): string { + return useSyncExternalStore( + subscribe, + () => query, + () => "", + ); +} diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index b8d50e8d3..76625c934 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -15,6 +15,7 @@ import { useQuery } from "@tanstack/react-query"; import { buildCommandPaletteGroups, buildStaticCommands, + detailPath, isBareScopeQuery, parsePaletteQuery, useEntitySearch, @@ -23,7 +24,7 @@ import { type RecentEntry, } from "@corbits/command-palette"; import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { listAgentDefinitions } from "./agents-api"; import { @@ -31,7 +32,13 @@ import { runActionCommand, type ActionCommandId, } from "./command-palette-actions"; -import { OPEN_COMMAND_PALETTE_EVENT } from "./command-palette-events"; +import { + openCommandPalette, + setCommandPaletteOpen, + setCommandPaletteQuery, + useCommandPaletteOpen, + useCommandPaletteQuery, +} from "./command-palette-open-store"; import { WORKBENCH_NOT_FOUND_EVENT } from "./workbench-not-found-event"; import { recentsStoreForBench } from "./command-palette-recents"; import { NAV_ROUTES } from "./routes"; @@ -41,6 +48,12 @@ import { useCloseCanvas, useOpenRoutineInCanvas, } from "./shell/canvas-availability"; +import { listMcpServers } from "@corbits/plugins-ui"; +import { + AGENTS_PATH_PREFIX, + PLUGINS_PATH_PREFIX, + SKILLS_PATH_PREFIX, +} from "./path-ids"; import { listRoutines, runRoutineNow, useTenantQuery } from "./routines-api"; import { listSkills } from "./skills-api"; import { meKeys, tenantKeys } from "./query-client"; @@ -76,8 +89,11 @@ export function CommandPaletteProvider({ }) { const { memberships, selectedTenantId, selectTenant } = useBench(); const queryClient = useQueryClient(); - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); + // Open state and query live in the shared store, not in this component: + // the top nav's magnifier morphs into this very surface and has to read + // the same state (`command-palette-open-store`). + const open = useCommandPaletteOpen(); + const query = useCommandPaletteQuery(); const [recents, setRecents] = useState([]); const { cycleMode } = useTheme(); const closeCanvas = useCloseCanvas(); @@ -148,9 +164,19 @@ export function CommandPaletteProvider({ })); }, [selectedTenantId, queryClient]); + // `useEntitySearch` carries an id and a title per result and nothing else, + // but `/agents/` needs the definition's own minted handle — which is + // exactly what the fetch just read. Recorded here as the list arrives so a + // selection resolves the real slug instead of guessing one back out of a + // display title. + const agentHandleById = useRef(new Map()); + const listAgentsForSearch = useCallback(async () => { if (selectedTenantId === null) return []; const definitions = await listAgentDefinitions(selectedTenantId); + for (const definition of definitions) { + agentHandleById.current.set(definition.id, definition.name); + } return definitions.map((definition) => ({ id: definition.id, name: definition.name, @@ -239,6 +265,16 @@ export function CommandPaletteProvider({ open && selectedTenantId !== null, () => listSkills(selectedTenantId ?? ""), ); + // Plugins, as far as this bench has any: its connected MCP servers, each + // already carrying the immutable slug `/plugins/` is addressed by. + // The gallery's presets and catalog entries are not connected things and + // have no detail route of their own yet — a follow-up, not a second + // search. + const mcpServersQuery = useTenantQuery( + tenantKeys.mcpServers(selectedTenantId ?? ""), + open && selectedTenantId !== null, + () => listMcpServers(selectedTenantId ?? ""), + ); const artifactsQuery = useAPIQuery( selectedTenantId === null || !open ? "" @@ -280,17 +316,25 @@ export function CommandPaletteProvider({ ] : undefined; - useCommandShortcut(() => setOpen((current) => !current)); - + // cmd+K opens; it cannot also close, because react-ui's shortcut yields to + // text fields and an open palette holds focus in its own input. Escape and + // the overlay are the ways back out. + useCommandShortcut(openCommandPalette); + + // Search is scoped to where it was opened from. A route change (including + // browser Back out of a result) closes it, so the overlay never stands over + // content it was not opened from; a bench switch closes it too, dropping a + // query whose results belonged to the bench being left. A tenant resolving + // for the first time (null → a real bench, at boot) is not a switch. + const searchScope = useRef({ path, tenantId: selectedTenantId }); useEffect(() => { - function onOpenRequest() { - setOpen(true); - } - window.addEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpenRequest); - return () => { - window.removeEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpenRequest); - }; - }, []); + const previous = searchScope.current; + const routeChanged = previous.path !== path; + const benchSwitched = + previous.tenantId !== null && previous.tenantId !== selectedTenantId; + searchScope.current = { path, tenantId: selectedTenantId }; + if (routeChanged || benchSwitched) setCommandPaletteOpen(false); + }, [path, selectedTenantId]); const pageItems = useMemo( () => @@ -376,6 +420,18 @@ export function CommandPaletteProvider({ [skillsQuery], ); + const pluginItems = useMemo( + () => + mcpServersQuery.kind === "ready" + ? mcpServersQuery.data.map((server) => ({ + id: `entity:plugins:${server.slug}`, + title: server.name, + subtitle: "Connected plugin", + })) + : [], + [mcpServersQuery], + ); + const libraryItems = useMemo( () => artifactsQuery.kind === "ready" @@ -408,6 +464,7 @@ export function CommandPaletteProvider({ { id: "pages", heading: "Pages", kind: "pages", items: pageItems }, { id: "routines", heading: "Routines", items: routineItems }, { id: "skills", heading: "Skills", items: skillItems }, + { id: "plugins", heading: "Plugins", items: pluginItems }, { id: "library", heading: "Files", items: libraryItems }, { id: "people", @@ -422,6 +479,7 @@ export function CommandPaletteProvider({ pageItems, routineItems, skillItems, + pluginItems, libraryItems, agentItems, ], @@ -486,7 +544,12 @@ export function CommandPaletteProvider({ const agentId = id.slice("entity:agents:".length); const title = agentItems.find((item) => item.id === id)?.title ?? agentId; - navigate(`/agents/${encodeURIComponent(agentId)}`); + navigate( + detailPath(AGENTS_PATH_PREFIX, { + slug: agentHandleById.current.get(agentId) ?? "", + id: agentId, + }), + ); pushRecent({ kind: "agents", id, title, subtitle: "Agent" }); } else if (id.startsWith("entity:routines:")) { const routineId = id.slice("entity:routines:".length); @@ -498,8 +561,16 @@ export function CommandPaletteProvider({ const skillId = id.slice("entity:skills:".length); const title = skillItems.find((item) => item.id === id)?.title ?? skillId; - navigate(`/skills/${encodeURIComponent(skillId)}`); + // A skill's name is its slug: the Skills API keys every route on it. + navigate( + detailPath(SKILLS_PATH_PREFIX, { slug: skillId, id: skillId }), + ); pushRecent({ kind: "skills", id, title, subtitle: "Skill" }); + } else if (id.startsWith("entity:plugins:")) { + const slug = id.slice("entity:plugins:".length); + const title = pluginItems.find((item) => item.id === id)?.title ?? slug; + navigate(detailPath(PLUGINS_PATH_PREFIX, { slug, id: slug })); + pushRecent({ kind: "plugins", id, title, subtitle: "Plugin" }); } else if (id.startsWith("entity:library:")) { const artifactId = id.slice("entity:library:".length); const title = @@ -507,7 +578,7 @@ export function CommandPaletteProvider({ navigate(libraryArtifactPath(artifactId)); pushRecent({ kind: "library", id, title, subtitle: "Files" }); } - setOpen(false); + setCommandPaletteOpen(false); }, [ navigate, @@ -521,6 +592,7 @@ export function CommandPaletteProvider({ agentItems, routineItems, skillItems, + pluginItems, libraryItems, nextWorkbench, selectTenant, @@ -528,8 +600,7 @@ export function CommandPaletteProvider({ ); const handleOpenChange = useCallback((nextOpen: boolean) => { - setOpen(nextOpen); - if (!nextOpen) setQuery(""); + setCommandPaletteOpen(nextOpen); }, []); return ( @@ -537,7 +608,7 @@ export function CommandPaletteProvider({ open={open} onOpenChange={handleOpenChange} query={query} - onQueryChange={setQuery} + onQueryChange={setCommandPaletteQuery} groups={groups} onSelect={handleSelect} loading={loading} diff --git a/apps/web/src/pages/create-agent-panel.tsx b/apps/web/src/pages/create-agent-panel.tsx index ca8f50480..79784627e 100644 --- a/apps/web/src/pages/create-agent-panel.tsx +++ b/apps/web/src/pages/create-agent-panel.tsx @@ -61,16 +61,15 @@ import { draftAgentDefinition, listCatalogModels, } from "../agents-api"; -import { AgentSkillsPicker } from "./agent-skills-picker"; +import { isValidSlug, slugify } from "@corbits/slug"; -const HANDLE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +import { AgentSkillsPicker } from "./agent-skills-picker"; -export function slugify(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); -} +// A handle IS the agent's slug — the immutable name `/agents/` is +// addressed by (DESIGN.md → Detail Pages). Minting and validating it through +// `@corbits/slug` rather than a local regex and a local slugify is what keeps +// that true: one implementation, so a handle suggested or accepted here can +// never be a shape the router refuses to resolve. function initialsFromName(name: string): string { const [first, second] = name.trim().split(/\s+/).filter(Boolean); @@ -207,7 +206,7 @@ function blockedReason( draftFailed: boolean, ): string | null { if (values.name.trim() === "") return "Add a name to continue."; - if (values.handle.trim() === "" || !HANDLE_PATTERN.test(values.handle)) { + if (!isValidSlug(values.handle.trim())) { return "Fix the handle below — lowercase letters, digits, and hyphens only."; } if (draftFailed && values.manualSystemPrompt.trim() === "") { diff --git a/apps/web/src/query-client.ts b/apps/web/src/query-client.ts index 1057bc2fb..4d62c6bc6 100644 --- a/apps/web/src/query-client.ts +++ b/apps/web/src/query-client.ts @@ -88,6 +88,8 @@ export const tenantKeys = { ["tenant", tenantId, "approvals", "needs-you"] as const, routines: (tenantId: string) => ["tenant", tenantId, "routines"] as const, skills: (tenantId: string) => ["tenant", tenantId, "skills"] as const, + mcpServers: (tenantId: string) => + ["tenant", tenantId, "mcp-servers"] as const, routineRuns: (tenantId: string, routineId: string) => ["tenant", tenantId, "routines", routineId, "runs"] as const, routineRunHistories: (tenantId: string) => diff --git a/apps/web/src/shell/context-menu/items.tsx b/apps/web/src/shell/context-menu/items.tsx index c71fbb832..940efe567 100644 --- a/apps/web/src/shell/context-menu/items.tsx +++ b/apps/web/src/shell/context-menu/items.tsx @@ -33,7 +33,7 @@ import { } from "../library-artifacts"; import { workbenchPath } from "../../workbench-path"; import { requestWorkbenchRename } from "../../workbench-rename-events"; -import { requestOpenCommandPalette } from "../../command-palette-events"; +import { openCommandPalette } from "../../command-palette-open-store"; import { runRoutineNow } from "../../routines-api"; import { SETTINGS_PATH } from "../../routes"; import type { ShellContextMenuTarget } from "./targets"; @@ -240,7 +240,7 @@ function shellMenu(actions: ShellContextMenuActions): ContextMenu { id: "search", label: "Search…", icon: , - onSelect: () => requestOpenCommandPalette(), + onSelect: () => openCommandPalette(), }), contextMenuItem({ id: "workbenches", diff --git a/apps/web/src/shell/stage-search.tsx b/apps/web/src/shell/stage-search.tsx new file mode 100644 index 000000000..766149db8 --- /dev/null +++ b/apps/web/src/shell/stage-search.tsx @@ -0,0 +1,72 @@ +// The one way into search from chrome (DESIGN.md → Search): a magnifier that +// morphs in place into an inline bar and hands the query to the command +// palette — the product's single search surface. There is no second search +// implementation behind this control; it opens the same palette cmd+K does, +// and its expanded/collapsed state IS the palette's open state +// (`command-palette-open-store`), so the two can never disagree. +// +// The palette itself is react-ui's modal `CommandPalette`, which owns the +// editable input once open. The bar this expands into therefore *shows* the +// live query rather than pretending to accept one — a span styled as a +// field, never a second input a click could land in and a screen reader +// would have to explain. An anchored, non-modal palette in react-ui would +// let this bar be the input itself; until then, showing is the honest shape. +// +// Motion is the width transition authored on `.stage-search` in app.css +// (react-ui's `--duration-standard` and `--ease-in-out`, the curve its +// theme documents for something growing in place). Reduced motion needs +// nothing here: react-ui's stylesheet already collapses every transition +// duration under `prefers-reduced-motion`, which makes the swap instant. + +import { MagnifyingGlass } from "@corbits/icons"; +import { useEffect, useRef } from "react"; + +import { + openCommandPalette, + useCommandPaletteOpen, + useCommandPaletteQuery, +} from "../command-palette-open-store"; + +export function StageSearch() { + const expanded = useCommandPaletteOpen(); + const query = useCommandPaletteQuery(); + const buttonRef = useRef(null); + const wasExpanded = useRef(false); + + // Whichever way the palette closed — Escape inside its dialog, a click on + // its overlay, the store — focus comes back to the control the morph came + // out of, instead of being dropped on the document. + useEffect(() => { + if (wasExpanded.current && !expanded) buttonRef.current?.focus(); + wasExpanded.current = expanded; + }, [expanded]); + + return ( +
+ + {expanded ? ( + + {query === "" ? "Search or jump to…" : query} + + ) : null} +
+ ); +} diff --git a/apps/web/src/shell/stage-top-bar.tsx b/apps/web/src/shell/stage-top-bar.tsx index b801310cc..3ac1a9828 100644 --- a/apps/web/src/shell/stage-top-bar.tsx +++ b/apps/web/src/shell/stage-top-bar.tsx @@ -8,6 +8,11 @@ // carries an `href`, so the trail is deep-linkable and a plain click // navigates through the app's own `Link` instead of reloading the shell. // +// The bar also carries the product's one search entry point (`StageSearch`, +// DESIGN.md → Search) ahead of the page's own controls. It is shell chrome, +// not a page action: no page passes it, and no page can opt out — that is +// what makes "exactly one search surface" true of every route at once. +// // `@corbits/react-ui`'s `TopBarBreadcrumbs` renders bare ``, which // would drop the SPA out from under the click, so the trail lives here // until react-ui takes a link-render slot. @@ -16,6 +21,7 @@ import { Fragment, type ReactNode } from "react"; import { Link } from "../navigation"; import { Chip, type ChipTone } from "./chip"; +import { StageSearch } from "./stage-search"; export type StageCrumb = { readonly label: string; @@ -55,6 +61,7 @@ export function StageTopBar({ className="stage-top-bar-actions" data-testid="stage-top-bar-actions" > + {chip !== undefined ? {chip.label} : null} {actions} diff --git a/apps/web/src/workbench-not-found-event.ts b/apps/web/src/workbench-not-found-event.ts index 0098df871..3d384d212 100644 --- a/apps/web/src/workbench-not-found-event.ts +++ b/apps/web/src/workbench-not-found-event.ts @@ -1,8 +1,8 @@ /** App-local event so a workbench-level 404 (`chat-page.tsx`, driven by * `ChatWorkspace`'s `onWorkbenchNotFound`) can tell the command palette to * drop a stale Recents entry, without coupling the chat route to - * `CommandPaletteProvider` state — same pattern as - * `command-palette-events.ts`'s open-palette event. */ + * `CommandPaletteProvider` state — the chat route and that provider are + * siblings in the Shell. */ export const WORKBENCH_NOT_FOUND_EVENT = "workbench:workbench-not-found"; diff --git a/apps/web/src/workbench-rename-events.ts b/apps/web/src/workbench-rename-events.ts index 43a35a7cf..9defabcac 100644 --- a/apps/web/src/workbench-rename-events.ts +++ b/apps/web/src/workbench-rename-events.ts @@ -1,6 +1,6 @@ /** App-local event so the global context menu can trigger a workbench panel * row's own inline-rename input without either side owning the other's - * state, mirroring `command-palette-events.ts`. */ + * state. */ export const REQUEST_WORKBENCH_RENAME_EVENT = "workbench:request-workbench-rename"; diff --git a/apps/web/test/global-search-morph.test.tsx b/apps/web/test/global-search-morph.test.tsx new file mode 100644 index 000000000..996c1a3bc --- /dev/null +++ b/apps/web/test/global-search-morph.test.tsx @@ -0,0 +1,420 @@ +// CL-6410: the product's one search surface. DESIGN.md's Search section fixes +// how it is invoked from chrome: the top-nav magnifier morphs in place into an +// inline bar, Esc collapses it, and cmd+K reaches the identical palette — +// never a second search implementation. +// +// The motion assertions deliberately check the authored stylesheet and the +// tokens it consumes, not class names on the element: react-ui ships a +// prebuilt stylesheet, so a Tailwind motion utility (`duration-standard`, +// `ease-spring`) compiles to nothing here and a className assertion would +// green-light a morph that never runs. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { ThemeProvider } from "@corbits/react-ui"; + +import { BenchProvider } from "../src/bench-context"; +import { CommandPaletteProvider } from "../src/command-palette-provider"; +import { setCommandPaletteOpen } from "../src/command-palette-open-store"; +import { NavigationProvider } from "../src/navigation"; +import { StageTopBar } from "../src/shell/stage-top-bar"; +import { TestQueryProvider } from "./test-query-provider"; + +const noop = () => undefined; +const realFetch = globalThis.fetch; +const realMatchMedia = window.matchMedia; + +const TENANT = "tnt_1"; + +const appCss = readFileSync(new URL("../src/app.css", import.meta.url), "utf8"); +const reactUiCss = readFileSync( + new URL("../node_modules/@corbits/react-ui/dist/styles.css", import.meta.url), + "utf8", +); + +/** The declaration block of the rule that names `className`. */ +function ruleFor(css: string, className: string): string { + const selector = new RegExp(`\\.${className}\\s*[,{]`); + const block = css.split("}").find((candidate) => selector.test(candidate)); + if (block === undefined) throw new Error(`no rule for .${className}`); + return block.slice(block.indexOf("{")); +} + +function stubMatchMedia(matching: Record): void { + window.matchMedia = ((media: string) => + ({ + media, + matches: matching[media] ?? false, + addEventListener: noop, + removeEventListener: noop, + }) as unknown as MediaQueryList) as typeof window.matchMedia; +} + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +const slugHandled = { + id: "wfd_1", + tenantId: TENANT, + name: "research-analyst", + description: "Answers research questions", + currentVersion: "1", + status: "deployed" as const, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +/** A handle that is not a slug — minted before the rule tightened, or + * imported. Its detail route cannot be guessed at. */ +const unsluggedHandle = { + ...slugHandled, + id: "wfd_2", + name: "Café Crème Bot", +}; + +function stubShellFetch(): void { + globalThis.fetch = ((input: RequestInfo | URL) => { + const path = String(input); + if (path.includes("/api/me/principals")) + return Promise.resolve( + json({ + data: [ + { + principalId: "prn_1", + tenantId: TENANT, + tenantName: "Corbits Bench", + tenantSlug: "corbits-bench", + kind: "user", + status: "active", + roles: [], + }, + ], + nextCursor: null, + }), + ); + if (path.includes("/api/workbench-tenancies/kinds")) + return Promise.resolve(json({ workbenchTenantIds: [] })); + if (path.includes("/workflows/definitions")) + return Promise.resolve( + json({ data: [slugHandled, unsluggedHandle], nextCursor: null }), + ); + if (path.includes("/mcp-servers")) + return Promise.resolve(json({ data: [] })); + if (path.includes("/skills")) return Promise.resolve(json({ skills: [] })); + if (path.includes("/routines")) return Promise.resolve(json({ data: [] })); + return Promise.resolve(json({ data: [], nextCursor: null })); + }) as typeof fetch; +} + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + stubMatchMedia({}); + stubShellFetch(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + setCommandPaletteOpen(false); + globalThis.fetch = realFetch; + window.matchMedia = realMatchMedia; +}); + +async function settle(): Promise { + for (let i = 0; i < 25; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } +} + +async function render(node: React.ReactElement): Promise { + await act(async () => { + root.render(node); + }); + await settle(); +} + +function searchShell(): HTMLElement { + const shell = container.querySelector( + '[data-testid="stage-search"]', + ); + if (shell === null) throw new Error("the top nav renders no search control"); + return shell; +} + +function magnifier(): HTMLButtonElement { + const button = searchShell().querySelector( + 'button[aria-label="Search"]', + ); + if (button === null) throw new Error("no magnifier in the top nav"); + return button; +} + +function morphField(): HTMLElement | null { + return container.querySelector( + '[data-testid="stage-search-field"]', + ); +} + +function paletteInputs(): readonly HTMLInputElement[] { + return [...document.querySelectorAll('[role="combobox"]')]; +} + +function paletteInput(): HTMLInputElement { + const input = paletteInputs()[0]; + if (input === undefined) throw new Error("the palette rendered no input"); + return input; +} + +async function typeInPalette(value: string): Promise { + const setValue = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + if (setValue === undefined) throw new Error("no native value setter"); + const input = paletteInput(); + await act(async () => { + setValue.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await settle(); +} + +async function pressEscapeInPalette(): Promise { + await act(async () => { + paletteInput().dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }), + ); + }); + await settle(); +} + +function resultRow(text: string): HTMLElement { + const row = [ + ...document.querySelectorAll('[role="option"]'), + ].find((option) => option.textContent?.includes(text)); + if (row === undefined) throw new Error(`no result row for ${text}`); + return row; +} + +function Harness({ + navigate = noop, + path = "/agents", +}: { + readonly navigate?: (to: string) => void; + readonly path?: string; +}) { + return ( + + + + + + + + + + + ); +} + +describe("the top-nav search morph", () => { + test("the collapsed control is a magnifier and nothing else", async () => { + await render(); + expect(magnifier().getAttribute("aria-expanded")).toBe("false"); + expect(morphField()).toBeNull(); + }); + + test("clicking the magnifier morphs it in place into the inline bar", async () => { + await render(); + await act(async () => { + magnifier().click(); + }); + await settle(); + + expect(morphField()).not.toBeNull(); + expect(magnifier().getAttribute("aria-expanded")).toBe("true"); + expect(searchShell().dataset.expanded).toBe("true"); + }); + + test("the morph is a real transition: authored on the element, on tokens the shipped stylesheet defines", () => { + const rule = ruleFor(appCss, "stage-search"); + // One element whose width animates — a swap between two boxes could not + // transition at all. + expect(rule).toContain("transition: width var(--duration-standard)"); + // react-ui's documented curve for something growing in place; a spring's + // overshoot would jitter the whole top bar. + expect(rule).toContain("var(--ease-in-out)"); + // Both tokens have to exist in the prebuilt sheet the app actually + // imports, or the declaration silently resolves to nothing. + expect(reactUiCss).toContain("--duration-standard:"); + expect(reactUiCss).toContain("--ease-in-out:"); + }); + + test("the morph carries no Tailwind motion utility, which would be inert against the prebuilt stylesheet", async () => { + await render(); + await act(async () => { + magnifier().click(); + }); + expect(searchShell().className).not.toContain("duration-"); + expect(searchShell().className).not.toContain("ease-"); + expect(searchShell().className).not.toContain("transition-"); + }); + + test("reduced motion needs no per-element handling: the shipped stylesheet collapses every transition", () => { + const reducedMotionBlock = reactUiCss.slice( + reactUiCss.lastIndexOf("prefers-reduced-motion: reduce"), + ); + expect(reducedMotionBlock).toContain("transition-duration: 0.01ms"); + }); + + test("the inline bar shows the query instead of impersonating an input", async () => { + await render(); + await act(async () => { + magnifier().click(); + }); + await settle(); + await typeInPalette("resea"); + + const field = morphField(); + expect(field?.tagName).toBe("SPAN"); + expect(field?.textContent).toBe("resea"); + // The palette owns the one editable search field in the product. + expect(paletteInputs()).toHaveLength(1); + }); +}); + +describe("collapsing back to the magnifier", () => { + test("Escape inside the palette collapses the morph and returns focus to the magnifier", async () => { + await render(); + await act(async () => { + magnifier().click(); + }); + await settle(); + expect(morphField()).not.toBeNull(); + + await pressEscapeInPalette(); + + expect(morphField()).toBeNull(); + expect(magnifier().getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(magnifier()); + }); + + test("focus lands on the magnifier even when the palette was opened by cmd+K, which never focused it", async () => { + await render(); + await act(async () => { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: true, + bubbles: true, + }), + ); + }); + await settle(); + expect(document.activeElement).not.toBe(magnifier()); + + await pressEscapeInPalette(); + + expect(document.activeElement).toBe(magnifier()); + }); + + test("a route change closes the surface, so Back never leaves it standing", async () => { + await render(); + await act(async () => { + magnifier().click(); + }); + await settle(); + expect(paletteInputs()).toHaveLength(1); + + await render(); + + expect(paletteInputs()).toHaveLength(0); + expect(morphField()).toBeNull(); + }); +}); + +describe("one search surface, two doors", () => { + test("cmd+K and the magnifier open the identical palette", async () => { + await render(); + expect(paletteInputs()).toHaveLength(0); + + await act(async () => { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: true, + bubbles: true, + }), + ); + }); + await settle(); + expect(paletteInputs()).toHaveLength(1); + const fromShortcut = paletteInput().getAttribute("aria-label"); + expect(searchShell().dataset.expanded).toBe("true"); + + await pressEscapeInPalette(); + expect(paletteInputs()).toHaveLength(0); + + await act(async () => { + magnifier().click(); + }); + await settle(); + expect(paletteInputs()).toHaveLength(1); + expect(paletteInput().getAttribute("aria-label")).toBe(fromShortcut); + }); + + test("selecting a result navigates to that entity's own slug detail route", async () => { + const navigated: string[] = []; + await render( navigated.push(to)} />); + + await act(async () => { + magnifier().click(); + }); + await settle(); + await typeInPalette("@"); + + await act(async () => { + resultRow("research-analyst").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + expect(navigated).toContain("/agents/research-analyst"); + }); + + test("an entity whose handle is not a slug keeps its id deep link, never a guessed slug", async () => { + const navigated: string[] = []; + await render( navigated.push(to)} />); + + await act(async () => { + magnifier().click(); + }); + await settle(); + await typeInPalette("@"); + + await act(async () => { + resultRow("Café Crème Bot").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + expect(navigated).toContain("/agents/wfd_2"); + expect(navigated).not.toContain("/agents/cafe-creme-bot"); + }); +}); diff --git a/bun.lock b/bun.lock index 1824bac53..315f96366 100644 --- a/bun.lock +++ b/bun.lock @@ -498,6 +498,7 @@ "name": "@corbits/command-palette", "version": "0.0.1", "dependencies": { + "@corbits/slug": "workspace:*", "react": "^19.2.0", }, "devDependencies": { @@ -546,7 +547,7 @@ }, "packages/connections-tools": { "name": "@corbits/connections-tools", - "version": "0.0.4", + "version": "0.0.5", "dependencies": { "@intx/agent": "0.3.0", "@intx/types": "0.3.0", @@ -3269,8 +3270,6 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], - "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3293,10 +3292,6 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#caa5214", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-caa5214", "sha512-z8DRBFgA4ukM8p29COeaMjfKZYe5jAUF4OBMiaIQFuW592+DGD/y6Ws6SjGlXmR9azkHNWh8oTzjlWlRP24vsQ=="], - - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], diff --git a/docs/command-palette.md b/docs/command-palette.md index 0d14d2653..2ad8abf84 100644 --- a/docs/command-palette.md +++ b/docs/command-palette.md @@ -1,8 +1,39 @@ # Command palette Cmd/Ctrl-K opens a global search-and-jump overlay: pages the app shell -already renders, plus workbenches, agents, workflow runs, routines, skills, and -Library artifacts, ranked and grouped, with full keyboard navigation. +already renders, plus workbenches, agents, workflow runs, routines, skills, +plugins, and Library artifacts, ranked and grouped, with full keyboard +navigation. + +## How it's invoked + +This palette is the product's only search surface (DESIGN.md → Search), and +it has exactly two doors: Cmd/Ctrl-K anywhere, and the magnifier the shell's +top bar carries on every route (`StageSearch` in +`apps/web/src/shell/stage-search.tsx`). Clicking the magnifier morphs it in +place into an inline bar — a width transition authored in `app.css` on +react-ui's `--duration-standard` and `--ease-in-out` (the curve its theme +documents for something growing in place; the app imports react-ui's +prebuilt stylesheet, where Tailwind motion utilities do not exist) — and +opens this same overlay. Escape collapses the bar back to the magnifier and +returns focus to it. Reduced motion needs nothing here: that stylesheet +already collapses every transition duration under +`prefers-reduced-motion`. + +Both doors read and write one state, `command-palette-open-store.ts`: an +external store rather than component state, because the palette provider and +the top bar are siblings in `app.tsx`'s Shell, and a context-menu item opens +the palette too. That state outlives a remount, so it is scoped explicitly — +the provider closes search on a route change (a Back out of a result never +leaves the overlay standing) and on a bench switch. cmd+K opens and does not +toggle: react-ui's shortcut yields to text fields, and an open palette holds +focus in its own input, so Escape and the overlay are the ways back out. + +Because react-ui's `CommandPalette` is a modal dialog that owns the editable +input once open, the morphed bar _shows_ the live query as text rather than +rendering a second input a click could land in — one editable search field in +the product, with the morph showing where the overlay came from. An anchored, +non-modal palette in react-ui would let that bar be the input itself. ## Where it lives @@ -35,19 +66,31 @@ with three responsibilities: `listWorkbenches`), agents (`listAgentDefinitions`), and workflow runs (`/api/me/workflows/runs`), fetches routines, skills, and Library artifacts as small per-bench catalogs (filtered client-side, the same way the static -route list already is), builds `@corbits/command-palette`'s static commands +route list already is), lists the bench's connected MCP servers as Plugins, +builds `@corbits/command-palette`'s static commands from `apps/web/src/routes.tsx`, and hands the assembled groups to react-ui's data-driven palette. No new endpoint beyond the ones the Routines, Skills, and Library pages already use, no domain logic in the app. ## Groups, in display order -Commands, Workbenches, Pages, Runs, Routines, Skills, Library, then -People & agents — matching the mock's `buildCmdkEntries` ordering. Selecting -a workbench, run, agent, routine, or skill result navigates to its real route -and records a recent entry (`recentsStoreForBench`, per-bench, local to the -browser); selecting a Library result opens the Library list, since a Library -item has no dedicated route of its own yet. +Commands, Workbenches, Pages, Runs, Routines, Skills, Plugins, Files, then +People & agents — the mock's `buildCmdkEntries` ordering, with Plugins (this +bench's connected MCP servers) added where the gallery sits in the rail. +Selecting a workbench, run, agent, routine, skill, or plugin result navigates +to its real route and records a recent entry (`recentsStoreForBench`, +per-bench, local to the browser); selecting a Library result opens the +Library list, since a Library item has no dedicated route of its own yet. + +Agents, skills, and plugins resolve to the slug-addressed detail routes +DESIGN.md's Detail Pages section defines (`/agents/`), through +`detailPath`, which is handed the entity's _own_ minted slug — an agent's +handle, a skill's name, an MCP server's slug. A slug is never derived from a +display title: slugs are immutable and titles are not, so a guess 404s the +moment the two disagree. An entity whose slug is not a slug falls back to +its opaque id, which every roster still resolves as a deep link. Routines and Library results still open their +roster deep links, which carry real content the slug placeholders do not +yet; moving them over belongs with those detail pages. ## What is not wired yet @@ -56,3 +99,7 @@ small badge for the active `#`/`@`/`>`/`/` scope and a footer legend naming each prefix; react-ui's `CommandPalette` has no slot for either today. The placeholder text (`Search or jump to… (# workbenches · @ people · > actions · / pages)`) carries that information as plain text instead. + +**Plugins covers connected servers only.** The gallery's presets and catalog +entries are not connected things and have no detail route yet, so they are +not searchable — a follow-up on this group, never a second search surface. diff --git a/packages/command-palette/package.json b/packages/command-palette/package.json index 920178f1d..047e156c8 100644 --- a/packages/command-palette/package.json +++ b/packages/command-palette/package.json @@ -13,6 +13,7 @@ "test": "bun test" }, "dependencies": { + "@corbits/slug": "workspace:*", "react": "^19.2.0" }, "devDependencies": { diff --git a/packages/command-palette/src/detail-paths.test.ts b/packages/command-palette/src/detail-paths.test.ts new file mode 100644 index 000000000..914d085ad --- /dev/null +++ b/packages/command-palette/src/detail-paths.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; + +import { detailPath } from "./detail-paths"; + +describe("detailPath", () => { + test("an entity's own slug addresses its detail route", () => { + expect(detailPath("/agents", { slug: "weekly-digest", id: "wfd_1" })).toBe( + "/agents/weekly-digest", + ); + }); + + test("a slug is never derived from a display name", () => { + expect(detailPath("/agents", { slug: "Café Crème Bot", id: "wfd_2" })).toBe( + "/agents/wfd_2", + ); + expect(detailPath("/skills", { slug: "", id: "skill_1" })).toBe( + "/skills/skill_1", + ); + }); + + test("the id fallback survives a segment that needs escaping", () => { + expect(detailPath("/plugins", { slug: "Not A Slug", id: "a/b" })).toBe( + "/plugins/a%2Fb", + ); + }); +}); diff --git a/packages/command-palette/src/detail-paths.ts b/packages/command-palette/src/detail-paths.ts new file mode 100644 index 000000000..420e9f2d6 --- /dev/null +++ b/packages/command-palette/src/detail-paths.ts @@ -0,0 +1,28 @@ +// Where a palette result lands. DESIGN.md's Detail Pages section addresses +// every browsable entity by slug (`/agents/`), so a result row has to +// resolve to that route when the entity carries a real slug. +// +// The slug is never derived here. A slug is minted once, at creation, and is +// immutable; guessing one from a display name produces a URL that 404s the +// moment the two disagree (an accent folded differently, a rename, a name +// that was never sluggable). An entity whose slug is not a slug — an import +// race, an external id, a handle minted before the rule tightened — falls +// back to its own opaque id, which every roster still resolves as a deep +// link into that row. + +import { isValidSlug } from "@corbits/slug"; + +export type DetailAddressable = { + /** The entity's own minted slug, as the server returned it. */ + readonly slug: string; + /** The opaque id its roster deep link accepts. */ + readonly id: string; +}; + +export function detailPath( + rosterPath: string, + entity: DetailAddressable, +): string { + if (isValidSlug(entity.slug)) return `${rosterPath}/${entity.slug}`; + return `${rosterPath}/${encodeURIComponent(entity.id)}`; +} diff --git a/packages/command-palette/src/index.ts b/packages/command-palette/src/index.ts index 5f0c5d712..fec52026e 100644 --- a/packages/command-palette/src/index.ts +++ b/packages/command-palette/src/index.ts @@ -43,6 +43,9 @@ export type { PaletteSource, } from "./command-groups"; +export { detailPath } from "./detail-paths"; +export type { DetailAddressable } from "./detail-paths"; + export { addRecentEntry, createRecentsStore,