diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 81f58bec..cd596ef8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -72,6 +72,12 @@ the active row by id rather than index, because the row a person is on has usual been resorted by a fresh keystroke, and an index would silently point at a different item than the one they were looking at. +`ToolPicker` is the deliberate exception to caller-owns-matching. The palette searches an +unbounded, possibly server-backed space, so the caller owns the fetch, debounce and cursor; +the picker is a form control over a bounded, already-fetched catalog, and it needs the match +outcome itself to split "empty catalog" from "no matches." Self-filtering is scoped to that +case — anything unbounded still belongs to the caller. + The dialog's fade is the existing `Dialog` opacity transition — no scale, no position — and that transition already collapses under `prefers-reduced-motion` at the theme layer. A palette a person opens dozens of times a day should not also compete with itself for diff --git a/package.json b/package.json index 3129f69a..4fcfb539 100644 --- a/package.json +++ b/package.json @@ -523,6 +523,10 @@ "types": "./dist/ui/tool-narrative.d.ts", "default": "./dist/ui/tool-narrative.js" }, + "./ui/tool-picker": { + "types": "./dist/ui/tool-picker.d.ts", + "default": "./dist/ui/tool-picker.js" + }, "./ui/tooltip": { "types": "./dist/ui/tooltip.d.ts", "default": "./dist/ui/tooltip.js" diff --git a/src/index.ts b/src/index.ts index 2d6a55d8..632ba5fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -116,6 +116,7 @@ export * from "./ui/toast.js"; export * from "./ui/token-mosaic.js"; export * from "./ui/tool-block.js"; export * from "./ui/tool-narrative.js"; +export * from "./ui/tool-picker.js"; export * from "./ui/tooltip.js"; export * from "./ui/top-bar.js"; export * from "./ui/trace-waterfall.js"; diff --git a/src/ui/toast-edge.test.tsx b/src/ui/toast-edge.test.tsx index 350fc716..f4dd354e 100644 --- a/src/ui/toast-edge.test.tsx +++ b/src/ui/toast-edge.test.tsx @@ -1,6 +1,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, test } from "bun:test"; +import { toast as sonnerToast } from "sonner"; import { toast, Toaster } from "./toast.js"; @@ -40,6 +41,12 @@ describe("toast replace-semantics edges", () => { let mounted: Mounted | undefined; afterEach(() => { + // Sonner keeps a process-wide toast store and replays active toasts to + // the next Toaster that subscribes. Dismiss before unmount so a later + // test does not inherit the previous confirmation. + act(() => { + sonnerToast.dismiss(); + }); mounted?.unmount(); mounted = undefined; }); diff --git a/src/ui/toast.test.tsx b/src/ui/toast.test.tsx index b907ebf8..3d850021 100644 --- a/src/ui/toast.test.tsx +++ b/src/ui/toast.test.tsx @@ -1,6 +1,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, test } from "bun:test"; +import { toast as sonnerToast } from "sonner"; import { toast, Toaster } from "./toast.js"; @@ -40,6 +41,12 @@ describe("Toaster", () => { let mounted: Mounted | undefined; afterEach(() => { + // Sonner keeps a process-wide toast store and replays active toasts to + // the next Toaster that subscribes. Dismiss before unmount so a later + // test does not inherit the previous confirmation. + act(() => { + sonnerToast.dismiss(); + }); mounted?.unmount(); mounted = undefined; }); diff --git a/src/ui/tool-picker.test.tsx b/src/ui/tool-picker.test.tsx new file mode 100644 index 00000000..999d1dcd --- /dev/null +++ b/src/ui/tool-picker.test.tsx @@ -0,0 +1,174 @@ +import { describe, expect, test } from "bun:test"; +import { act, createElement } from "react"; +import type { ComponentProps } from "react"; +import { createRoot } from "react-dom/client"; + +import { ToolPicker } from "./tool-picker.js"; + +const ITEMS: ComponentProps["items"] = [ + { id: "slack-post", name: "Post message", package: "slack" }, + { id: "slack-react", name: "Add reaction", package: "slack" }, + { id: "linear-create", name: "Create issue", package: "linear", status: "pending" }, + { id: "linear-close", name: "Close issue", package: "linear", status: "disabled" }, + { id: "gmail-send", name: "Send email", package: "google-drive" }, +]; + +function mount(props: Partial> = {}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(createElement(ToolPicker, { items: ITEMS, value: [], onChange: () => {}, ...props })); + }); + return { + options: () => [...container.querySelectorAll('[role="option"]')] as HTMLDivElement[], + optionByName: (name: string) => [...container.querySelectorAll('[role="option"]')].find((el) => el.textContent?.includes(name)) as HTMLDivElement, + container, + unmount: () => act(() => root.unmount()), + }; +} + +describe("ToolPicker", () => { + test("groups items by package with humanized headings, not raw slugs", () => { + const { container, unmount } = mount(); + const headings = [...container.querySelectorAll('p[role="presentation"], p')].map((el) => el.textContent); + expect(headings).toContain("Slack"); + expect(headings).toContain("Linear"); + expect(headings).toContain("Google Drive"); + expect(headings).not.toContain("slack"); + expect(headings).not.toContain("google-drive"); + unmount(); + }); + + test("keeps a package string that is already a display name", () => { + const { container, unmount } = mount({ + items: [{ id: "x", name: "Post message", package: "Slack" }], + }); + const headings = [...container.querySelectorAll("p")].map((el) => el.textContent); + expect(headings).toContain("Slack"); + unmount(); + }); + + test("single select: clicking an item replaces the current value", () => { + let value: readonly string[] = []; + const { optionByName, unmount } = mount({ + value, + onChange: (next) => { + value = next; + }, + }); + act(() => { + optionByName("Post message").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(value).toEqual(["slack-post"]); + unmount(); + }); + + test("single select: clicking the already-selected item deselects it", () => { + let value: readonly string[] = ["slack-post"]; + const { optionByName, unmount } = mount({ + value, + onChange: (next) => { + value = next; + }, + }); + act(() => { + optionByName("Post message").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(value).toEqual([]); + unmount(); + }); + + test("multi select: accumulates selections across packages", () => { + let value: readonly string[] = ["slack-post"]; + const { optionByName, unmount } = mount({ + multiple: true, + value, + onChange: (next) => { + value = next; + }, + }); + act(() => { + optionByName("Add reaction").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(value).toEqual(["slack-post", "slack-react"]); + unmount(); + }); + + test("a disabled item cannot be selected", () => { + let called = false; + const { optionByName, unmount } = mount({ + onChange: () => { + called = true; + }, + }); + act(() => { + optionByName("Close issue").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(called).toBe(false); + unmount(); + }); + + test("search filters the visible options", () => { + const { container, options, unmount } = mount(); + const input = container.querySelector("input") as HTMLInputElement; + act(() => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + setter?.call(input, "linear"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(options().length).toBe(2); + unmount(); + }); + + test("loading state shows a loading message instead of the catalog", () => { + const { container, unmount } = mount({ loading: true }); + expect(container.textContent).toContain("Loading…"); + expect(container.querySelectorAll('[role="option"]').length).toBe(0); + unmount(); + }); + + test("empty catalog shows a catalog-empty state, not a failed search", () => { + const { container, unmount } = mount({ items: [] }); + expect(container.textContent).toContain("Nothing in the catalog yet."); + expect(container.textContent).not.toContain("Try a different search."); + unmount(); + }); + + test("a search with no hits shows no-matches copy", () => { + const { container, unmount } = mount(); + const input = container.querySelector("input") as HTMLInputElement; + act(() => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + setter?.call(input, "zzzz-no-such-tool"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(container.textContent).toContain("No tools match"); + expect(container.textContent).toContain("try a different search."); + expect(container.textContent).not.toContain("Nothing in the catalog yet."); + unmount(); + }); + + test("Escape clears the search query and does not swallow a second Escape", () => { + const { container, options, unmount } = mount(); + const input = container.querySelector("input") as HTMLInputElement; + act(() => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + setter?.call(input, "linear"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(options().length).toBe(2); + + act(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); + }); + expect(options().length).toBe(5); + + const second = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }); + act(() => { + input.dispatchEvent(second); + }); + expect(second.defaultPrevented).toBe(false); + unmount(); + }); +}); diff --git a/src/ui/tool-picker.tsx b/src/ui/tool-picker.tsx new file mode 100644 index 00000000..a9f96ca4 --- /dev/null +++ b/src/ui/tool-picker.tsx @@ -0,0 +1,247 @@ +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react"; + +import { useCommandPaletteNavigation } from "../hooks/use-command-palette-navigation.js"; +import { cn } from "../lib/utils.js"; +import { Badge } from "./badge.js"; +import { EmptyState } from "./empty-state.js"; +import { Input } from "./input.js"; + +export type ToolPickerStatus = "pending" | "disabled"; + +/** + * One entry in the caller-supplied catalog — a tool the picker can list, + * search and select. Shaped like a `DataTable` row: plain data in, no + * behaviour, so the same catalog can back a table elsewhere without a + * reshape. + */ +export type ToolPickerItem = { + readonly id: string; + readonly name: string; + /** Groups the list — the picker's one piece of structure over the flat catalog. */ + readonly package: string; + readonly description?: string; + /** Absent means available. `disabled` blocks selection; `pending` allows it. */ + readonly status?: ToolPickerStatus; +}; + +export type ToolPickerProps = { + readonly items: readonly ToolPickerItem[]; + /** Selected ids. A single-select picker still carries at most one entry here. */ + readonly value: readonly string[]; + readonly onChange: (value: readonly string[]) => void; + /** Defaults to single-select: a new pick replaces the current one. */ + readonly multiple?: boolean; + /** First load — the catalog has not arrived yet, distinct from an empty catalog. */ + readonly loading?: boolean; + /** Shown when the catalog itself is empty. Failed searches use a separate default. */ + readonly empty?: ReactNode; + readonly searchPlaceholder?: string; + /** Accessible name for the search input — set it if this isn't picking tools. */ + readonly searchLabel?: string; + readonly className?: string; +}; + +const STATUS_LABEL: Record = { + pending: "Pending", + disabled: "Disabled", +}; + +function matches(item: ToolPickerItem, query: string): boolean { + if (query.length === 0) return true; + const haystack = `${item.name} ${item.package} ${item.description ?? ""}`.toLowerCase(); + return haystack.includes(query.toLowerCase()); +} + +function groupByPackage(items: readonly ToolPickerItem[]): { readonly id: string; readonly items: ToolPickerItem[] }[] { + const groups = new Map(); + for (const item of items) { + const group = groups.get(item.package); + if (group === undefined) groups.set(item.package, [item]); + else group.push(item); + } + return [...groups.entries()].map(([id, groupItems]) => ({ id, items: groupItems })); +} + +/** Visible group title: a display name if the catalog already sent one, else a humanized slug. */ +function groupHeading(id: string): string { + if (/[A-Z]/.test(id) || /\s/.test(id)) return id; + return id + .split(/[-_./]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +/** + * Browse, search and select tools from a caller-supplied catalog, grouped by + * package. Controlled like a form field (`value`/`onChange`) rather than + * owning selection itself — a caller wiring this into a bigger form needs + * the same value it would get from any other control. + * + * Disabled items stay in the list rather than disappearing — a tool a + * caller cannot pick right now is still information ("already attached + * elsewhere", "needs a credential") that vanishing would hide. + */ +export function ToolPicker({ + items, + value, + onChange, + multiple = false, + loading = false, + empty, + searchPlaceholder = "Search tools…", + searchLabel = "Search tools", + className, +}: ToolPickerProps) { + const baseId = useId(); + const [query, setQuery] = useState(""); + const listRef = useRef(null); + + const filtered = useMemo(() => items.filter((item) => matches(item, query)), [items, query]); + const groups = useMemo(() => groupByPackage(filtered), [filtered]); + const selectedIds = useMemo(() => new Set(value), [value]); + + const toggle = (item: ToolPickerItem) => { + if (item.status === "disabled") return; + const isSelected = selectedIds.has(item.id); + if (!multiple) { + onChange(isSelected ? [] : [item.id]); + return; + } + onChange(isSelected ? value.filter((id) => id !== item.id) : [...value, item.id]); + }; + + const navigation = useCommandPaletteNavigation({ + items: filtered, + onSelect: (id) => { + const item = filtered.find((candidate) => candidate.id === id); + if (item !== undefined) toggle(item); + }, + onClose: () => { + /* Escape is handled below so an empty query does not swallow the key. */ + }, + }); + + useEffect(() => { + listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: "nearest" }); + }, [navigation.activeId]); + + const optionId = (id: string) => `${baseId}-option-${id}`; + + const onKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === "Escape") { + if (query.length > 0) { + event.preventDefault(); + setQuery(""); + } + return; + } + navigation.onKeyDown(event); + }; + + const emptyCatalog = items.length === 0; + const noMatches = !emptyCatalog && filtered.length === 0; + + return ( +
+ setQuery(event.target.value)} + onKeyDown={onKeyDown} + placeholder={searchPlaceholder} + aria-label={searchLabel} + role="combobox" + aria-expanded + aria-autocomplete="list" + aria-controls={`${baseId}-list`} + aria-activedescendant={navigation.activeId === undefined ? undefined : optionId(navigation.activeId)} + aria-busy={loading} + autoComplete="off" + spellCheck={false} + /> + +
+ {loading ? ( +

Loading…

+ ) : emptyCatalog ? ( +
{empty ?? }
+ ) : noMatches ? ( +

No tools match — try a different search.

+ ) : ( + groups.map((group) => ( +
+

+ {groupHeading(group.id)} +

+
+ {group.items.map((item) => ( + toggle(item)} + onPointerMove={() => navigation.setActiveId(item.id)} + /> + ))} +
+
+ )) + )} +
+
+ ); +} + +function ToolPickerOption({ + id, + item, + selected, + active, + onSelect, + onPointerMove, +}: { + readonly id: string; + readonly item: ToolPickerItem; + readonly selected: boolean; + readonly active: boolean; + readonly onSelect: () => void; + readonly onPointerMove: () => void; +}) { + const disabled = item.status === "disabled"; + return ( +
+ + {item.name} + {item.status === undefined ? null : {STATUS_LABEL[item.status]}} + + {item.description === undefined ? null : ( + {item.description} + )} +
+ ); +} diff --git a/stories/primitives/tool-picker.stories.tsx b/stories/primitives/tool-picker.stories.tsx new file mode 100644 index 00000000..cdd5f187 --- /dev/null +++ b/stories/primitives/tool-picker.stories.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; + +import { ToolPicker, type ToolPickerItem } from "../../src/ui/tool-picker.js"; + +export default { title: "Primitives / Tool picker" }; + +const ITEMS: ToolPickerItem[] = [ + { id: "slack-post", name: "Post message", package: "slack", description: "Sends a message to a channel." }, + { id: "slack-react", name: "Add reaction", package: "slack", description: "Reacts to an existing message." }, + { + id: "linear-create", + name: "Create issue", + package: "linear", + description: "Opens a new issue in a team.", + status: "pending", + }, + { + id: "linear-close", + name: "Close issue", + package: "linear", + description: "Already attached elsewhere in this workflow.", + status: "disabled", + }, + { id: "gmail-send", name: "Send email", package: "gmail", description: "Sends a message from the connected inbox." }, +]; + +export const SingleSelect = () => { + const [value, setValue] = useState(["slack-post"]); + return ( +
+ +
+ ); +}; + +export const MultiSelect = () => { + const [value, setValue] = useState(["slack-post", "gmail-send"]); + return ( +
+ +
+ ); +}; + +export const Loading = () => {}} loading />; + +export const Empty = () => {}} />;