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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
7 changes: 7 additions & 0 deletions src/ui/toast-edge.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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;
});
Expand Down
7 changes: 7 additions & 0 deletions src/ui/toast.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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;
});
Expand Down
174 changes: 174 additions & 0 deletions src/ui/tool-picker.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof ToolPicker>["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<ComponentProps<typeof ToolPicker>> = {}) {
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();
});
});
Loading
Loading