From 152760f3a277171d91f348f00795c049f785ec7b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 00:10:07 -0700 Subject: [PATCH 1/9] test(tool-picker): add selection/search/status tests (CL-8483) --- src/ui/tool-picker.test.tsx | 123 ++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/ui/tool-picker.test.tsx diff --git a/src/ui/tool-picker.test.tsx b/src/ui/tool-picker.test.tsx new file mode 100644 index 00000000..fe6ff3a6 --- /dev/null +++ b/src/ui/tool-picker.test.tsx @@ -0,0 +1,123 @@ +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" }, +]; + +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", () => { + const { container, unmount } = mount(); + const headings = [...container.querySelectorAll('p[role="presentation"], p')].map((el) => el.textContent); + expect(headings).toContain("slack"); + expect(headings).toContain("linear"); + 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 the empty state", () => { + const { container, unmount } = mount({ items: [] }); + expect(container.textContent).toContain("No tools match"); + unmount(); + }); +}); From cde6c58b27ac2835ead79c1f2026ec93fecb0cd9 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 00:10:07 -0700 Subject: [PATCH 2/9] feat(tool): ToolPicker for browsing, searching and selecting tools (CL-8483) --- package.json | 4 + src/index.ts | 1 + src/ui/tool-picker.tsx | 212 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 src/ui/tool-picker.tsx 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/tool-picker.tsx b/src/ui/tool-picker.tsx new file mode 100644 index 00000000..cd9fe149 --- /dev/null +++ b/src/ui/tool-picker.tsx @@ -0,0 +1,212 @@ +import { useId, useMemo, 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; + readonly empty?: ReactNode; + readonly searchPlaceholder?: 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 })); +} + +/** + * 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…", + className, +}: ToolPickerProps) { + const baseId = useId(); + const [query, setQuery] = useState(""); + + const filtered = useMemo(() => items.filter((item) => matches(item, query)), [items, query]); + const groups = useMemo(() => groupByPackage(filtered), [filtered]); + const selectableIds = useMemo(() => new Set(value), [value]); + + const toggle = (item: ToolPickerItem) => { + if (item.status === "disabled") return; + const isSelected = selectableIds.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 has nothing to close here — the picker is inline, not a popover. */ + }, + }); + + const optionId = (id: string) => `${baseId}-option-${id}`; + + const onKeyDown = (event: ReactKeyboardEvent) => { + navigation.onKeyDown(event); + }; + + return ( +
+ setQuery(event.target.value)} + onKeyDown={onKeyDown} + placeholder={searchPlaceholder} + aria-label="Search tools" + role="combobox" + aria-expanded + aria-controls={`${baseId}-list`} + aria-activedescendant={navigation.activeId === undefined ? undefined : optionId(navigation.activeId)} + autoComplete="off" + /> + +
+ {loading ? ( +

Loading…

+ ) : groups.length === 0 ? ( + (empty ?? ) + ) : ( + groups.map((group) => ( +
+

+ {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} + )} +
+ ); +} From d2357a1fbb2a15e95a2faba5e010ab42c2555788 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 00:10:07 -0700 Subject: [PATCH 3/9] docs(tool-picker): add story covering single/multi select and states (CL-8483) --- stories/primitives/tool-picker.stories.tsx | 47 ++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 stories/primitives/tool-picker.stories.tsx 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 = () => {}} />; From 8b7da953a8972c968b8b154a68b08094062351b8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 22:01:30 -0700 Subject: [PATCH 4/9] fix(tool): split empty catalog vs no matches; Escape clears (CL-8483) Default empty copy matches CommandPalette: no catalog vs failed search. Escape clears the query and does not swallow a second Escape. --- src/ui/tool-picker.test.tsx | 40 ++++++++++++++++++++++++++++++++++++- src/ui/tool-picker.tsx | 19 +++++++++++++++--- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/ui/tool-picker.test.tsx b/src/ui/tool-picker.test.tsx index fe6ff3a6..b7287b25 100644 --- a/src/ui/tool-picker.test.tsx +++ b/src/ui/tool-picker.test.tsx @@ -115,9 +115,47 @@ describe("ToolPicker", () => { unmount(); }); - test("empty catalog shows the empty state", () => { + 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(4); + + 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 index cd9fe149..c33e448b 100644 --- a/src/ui/tool-picker.tsx +++ b/src/ui/tool-picker.tsx @@ -34,6 +34,7 @@ export type ToolPickerProps = { 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; readonly className?: string; @@ -104,16 +105,26 @@ export function ToolPicker({ if (item !== undefined) toggle(item); }, onClose: () => { - /* Escape has nothing to close here — the picker is inline, not a popover. */ + /* Escape is handled below so an empty query does not swallow the key. */ }, }); 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 (
{loading ? (

Loading…

- ) : groups.length === 0 ? ( - (empty ?? ) + ) : emptyCatalog ? ( + (empty ?? ) + ) : noMatches ? ( + ) : ( groups.map((group) => (
From c926bbea57e9f20de3d52392506b6bd6aab51351 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 18:52:33 -0700 Subject: [PATCH 5/9] test(toast): dismiss sonner store between Toaster cases Sonner replays active toasts onto the next Toaster subscriber, so leftover confirmations from a prior case stacked (Expected 1, Received 2). --- src/ui/toast-edge.test.tsx | 7 +++++++ src/ui/toast.test.tsx | 7 +++++++ 2 files changed, 14 insertions(+) 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; }); From d7346f2c6ecee69740281d3c89702457662534b1 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 22:13:43 -0700 Subject: [PATCH 6/9] fix(tool): humanize ToolPicker group headings (CL-8483) Slugs like slack and google-drive render as Slack / Google Drive. A package string that is already a display name is kept. Drop the uppercase shout. Escape and empty-copy split are unchanged. --- src/ui/tool-picker.test.tsx | 21 +++++++++++++++++---- src/ui/tool-picker.tsx | 14 ++++++++++++-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/ui/tool-picker.test.tsx b/src/ui/tool-picker.test.tsx index b7287b25..c4dd8c03 100644 --- a/src/ui/tool-picker.test.tsx +++ b/src/ui/tool-picker.test.tsx @@ -10,6 +10,7 @@ const ITEMS: ComponentProps["items"] = [ { 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> = {}) { @@ -28,11 +29,23 @@ function mount(props: Partial> = {}) { } describe("ToolPicker", () => { - test("groups items by package", () => { + 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("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(); }); @@ -149,7 +162,7 @@ describe("ToolPicker", () => { act(() => { input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); }); - expect(options().length).toBe(4); + expect(options().length).toBe(5); const second = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }); act(() => { diff --git a/src/ui/tool-picker.tsx b/src/ui/tool-picker.tsx index c33e448b..7f83ad61 100644 --- a/src/ui/tool-picker.tsx +++ b/src/ui/tool-picker.tsx @@ -61,6 +61,16 @@ function groupByPackage(items: readonly ToolPickerItem[]): { readonly id: string 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 @@ -157,8 +167,8 @@ export function ToolPicker({ ) : ( groups.map((group) => (
-

- {group.id} +

+ {groupHeading(group.id)}

{group.items.map((item) => ( From 1875e7ca5448f9256a66276a15d8f2e1bf5e504f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 11:30:08 -0700 Subject: [PATCH 7/9] fix(tool): drop dead keydown handler on non-focusable listbox (CL-8483) --- src/ui/tool-picker.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ui/tool-picker.tsx b/src/ui/tool-picker.tsx index 7f83ad61..8ad90269 100644 --- a/src/ui/tool-picker.tsx +++ b/src/ui/tool-picker.tsx @@ -155,7 +155,6 @@ export function ToolPicker({ role="listbox" aria-label="Tools" aria-multiselectable={multiple} - onKeyDown={onKeyDown} className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto" > {loading ? ( From 959c05cfe88143e196dc44ceb613ab5be9bbb6d4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 11:46:19 -0700 Subject: [PATCH 8/9] fix(tool): scroll active option into view; combobox ARIA parity; neutral pending badge (CL-8483) --- ARCHITECTURE.md | 6 ++++++ src/ui/tool-picker.tsx | 37 +++++++++++++++++++++++++------------ 2 files changed, 31 insertions(+), 12 deletions(-) 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/src/ui/tool-picker.tsx b/src/ui/tool-picker.tsx index 8ad90269..a9f96ca4 100644 --- a/src/ui/tool-picker.tsx +++ b/src/ui/tool-picker.tsx @@ -1,4 +1,4 @@ -import { useId, useMemo, useState } from "react"; +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"; @@ -37,6 +37,8 @@ export type ToolPickerProps = { /** 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; }; @@ -89,18 +91,20 @@ export function ToolPicker({ 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 selectableIds = useMemo(() => new Set(value), [value]); + const selectedIds = useMemo(() => new Set(value), [value]); const toggle = (item: ToolPickerItem) => { if (item.status === "disabled") return; - const isSelected = selectableIds.has(item.id); + const isSelected = selectedIds.has(item.id); if (!multiple) { onChange(isSelected ? [] : [item.id]); return; @@ -119,6 +123,10 @@ export function ToolPicker({ }, }); + useEffect(() => { + listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: "nearest" }); + }, [navigation.activeId]); + const optionId = (id: string) => `${baseId}-option-${id}`; const onKeyDown = (event: ReactKeyboardEvent) => { @@ -142,15 +150,19 @@ export function ToolPicker({ onChange={(event) => setQuery(event.target.value)} onKeyDown={onKeyDown} placeholder={searchPlaceholder} - aria-label="Search tools" + 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…

+

Loading…

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

No tools match — try a different search.

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

+

{groupHeading(group.id)}

@@ -175,7 +187,7 @@ export function ToolPicker({ key={item.id} id={optionId(item.id)} item={item} - selected={selectableIds.has(item.id)} + selected={selectedIds.has(item.id)} active={navigation.activeId === item.id} onSelect={() => toggle(item)} onPointerMove={() => navigation.setActiveId(item.id)} @@ -217,14 +229,15 @@ function ToolPickerOption({ onClick={disabled ? undefined : onSelect} className={cn( "flex w-full flex-col gap-1 rounded-lg border px-3.5 py-3 text-left transition-colors ease-out", - disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-muted", + disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-muted active:brightness-95", selected ? "border-primary-emphasis bg-primary/10" : "border-border bg-card", - active && !disabled && "bg-muted", + // The keyboard cursor must stay visible on disabled rows — aria-activedescendant can land there. + active && "bg-muted", )} > {item.name} - {item.status === undefined ? null : {STATUS_LABEL[item.status]}} + {item.status === undefined ? null : {STATUS_LABEL[item.status]}} {item.description === undefined ? null : ( {item.description} From 0c0145e23662e887346280c8005431d42bfab8b0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 16:04:34 -0700 Subject: [PATCH 9/9] test(tool-picker): match compact no-match copy (CL-8483) --- src/ui/tool-picker.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/tool-picker.test.tsx b/src/ui/tool-picker.test.tsx index c4dd8c03..999d1dcd 100644 --- a/src/ui/tool-picker.test.tsx +++ b/src/ui/tool-picker.test.tsx @@ -144,7 +144,7 @@ describe("ToolPicker", () => { 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).toContain("try a different search."); expect(container.textContent).not.toContain("Nothing in the catalog yet."); unmount(); });