diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index c7134670..caef68d0 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -34,6 +34,14 @@ import { PanelHandle, PanelHeader, PanelPane, + PromptInputToolbar, + PromptInputAddMenu, + PromptInputButton, + PromptInputModelSelect, + PromptInputModeSelect, + PromptInputFloat, + PromptInputContext, + PromptInputNote, } from "@corbits/react-ui"; import { CommandPalette, @@ -570,6 +578,109 @@ function PanelScenario() { ); } +/** Every toolbar part, with each choice and action recorded. "Toggle + * toolbar" takes the toolbar out, to compare layouts; clearing the reply + * chip removes it and "Reply to Ada" brings it back. */ +function PromptToolbarScenario() { + const [value, setValue] = useState(""); + const [model, setModel] = useState("deep"); + const [mode, setMode] = useState("ask"); + const [toolbar, setToolbar] = useState(true); + const [replying, setReplying] = useState(true); + const { log, record } = useEventLog(); + return ( +
+ record(`submit:${value}`)} + tokens={PROMPT_TOKENS} + > + {replying ? ( + + setReplying(false)} + /> + + ) : null} + + + {toolbar ? ( + + record("add:files"), + }, + { + id: "link", + label: "Add a link", + disabled: true, + onSelect: () => record("add:link"), + }, + { + id: "mention", + label: "Mention an agent", + onSelect: () => record("add:mention"), + }, + ]} + /> + { + record(`mode:${id}`); + setMode(id); + }} + /> + { + record(`model:${id}`); + setModel(id); + }} + /> + record("voice")} + > + V + + + + ) : ( + + )} + + Agents can make mistakes. + + + {log} +
+ ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -605,4 +716,5 @@ export const scenarios: Record ReactNode> = { "prompt-input": () => , "prompt-input-attachment": () => , panel: () => , + "prompt-input-toolbar": () => , }; diff --git a/e2e/prompt-input-toolbar.spec.ts b/e2e/prompt-input-toolbar.spec.ts new file mode 100644 index 00000000..caf31785 --- /dev/null +++ b/e2e/prompt-input-toolbar.spec.ts @@ -0,0 +1,181 @@ +import { expect, test, type Page } from "@playwright/test"; + +const events = (page: Page) => page.getByRole("status", { name: "Events" }); +const message = (page: Page) => page.getByRole("textbox", { name: "Message" }); +const model = (page: Page) => page.getByRole("combobox", { name: "Model" }); +const mode = (page: Page) => + page.getByRole("combobox", { name: "Mode", exact: true }); +const add = (page: Page) => + page.getByRole("button", { name: "Add", exact: true }); + +function box(page: Page, selector: string) { + return page.locator(selector).evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom, height: rect.height }; + }); +} + +test.beforeEach(async ({ page }) => { + await page.goto("/prompt-input-toolbar"); +}); + +test("the model picker opens, moves and chooses by keyboard, keeping focus on its trigger", async ({ + page, +}) => { + await model(page).focus(); + await page.keyboard.press("Enter"); + const list = page.getByRole("listbox", { name: "Model" }); + await expect(list).toBeVisible(); + await expect(model(page)).toBeFocused(); + // Opens on the chosen model, then walks the grouped order upward. + const current = page.locator( + "[data-slot='prompt-input-select-option'][data-active]", + ); + await expect(current).toHaveText("Deep 4"); + await page.keyboard.press("ArrowUp"); + await expect(current).toHaveText("Lite 1"); + await expect(model(page)).toHaveAttribute( + "aria-activedescendant", + (await current.getAttribute("id")) ?? "", + ); + await page.keyboard.press("Enter"); + await expect(list).toBeHidden(); + await expect(events(page)).toHaveText("model:lite"); + await expect(model(page)).toHaveText("Lite"); + await expect(model(page)).toBeFocused(); +}); + +test("Escape closes a picker without choosing and leaves focus on its trigger", async ({ + page, +}) => { + await mode(page).focus(); + await page.keyboard.press("ArrowDown"); + await expect( + page.getByRole("listbox", { name: "Mode", exact: true }), + ).toBeVisible(); + await page.keyboard.press("End"); + await page.keyboard.press("Escape"); + await expect( + page.getByRole("listbox", { name: "Mode", exact: true }), + ).toBeHidden(); + await expect(mode(page)).toBeFocused(); + await expect(mode(page)).toHaveText("Ask"); + await expect(events(page)).toHaveText(""); + + // Space opens and chooses too. + await page.keyboard.press(" "); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press(" "); + await expect(events(page)).toHaveText("mode:plan"); + await expect( + page.getByRole("listbox", { name: "Mode", exact: true }), + ).toBeHidden(); + await expect(mode(page)).toBeFocused(); +}); + +test("a picker chooses by pointer and closes on a press outside", async ({ + page, +}) => { + await model(page).click(); + await page.getByRole("option", { name: "Swift 2" }).click(); + await expect(events(page)).toHaveText("model:swift"); + await expect(model(page)).toHaveText("Swift"); + + await model(page).click(); + await expect(page.getByRole("listbox", { name: "Model" })).toBeVisible(); + await page.getByRole("button", { name: "Reply to Ada" }).click(); + await expect(page.getByRole("listbox", { name: "Model" })).toBeHidden(); +}); + +test("the add menu opens, moves past a disabled item and chooses by keyboard", async ({ + page, +}) => { + await add(page).focus(); + await page.keyboard.press("Enter"); + const menu = page.getByRole("menu", { name: "Add" }); + await expect(menu).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Add files" })).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect( + page.getByRole("menuitem", { name: "Mention an agent" }), + ).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(page.getByRole("menuitem", { name: "Add files" })).toBeFocused(); + await page.keyboard.press("ArrowUp"); + await page.keyboard.press("Enter"); + await expect(menu).toBeHidden(); + await expect(events(page)).toHaveText("add:mention"); + await expect(add(page)).toBeFocused(); +}); + +test("Escape closes the add menu and returns focus; Tab closes it and moves on", async ({ + page, +}) => { + await add(page).focus(); + await page.keyboard.press("ArrowUp"); + await expect( + page.getByRole("menuitem", { name: "Mention an agent" }), + ).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menu", { name: "Add" })).toBeHidden(); + await expect(add(page)).toBeFocused(); + + await page.keyboard.press("ArrowDown"); + await expect(page.getByRole("menuitem", { name: "Add files" })).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(page.getByRole("menu", { name: "Add" })).toBeHidden(); + await expect(mode(page)).toBeFocused(); + await expect(events(page)).toHaveText(""); +}); + +test("the toolbar takes its own row, and without it the box is one row", async ({ + page, +}) => { + const field = await box(page, "[data-slot='prompt-input-field']"); + const toolbar = await box(page, "[data-slot='prompt-input-toolbar']"); + expect(toolbar.top).toBeGreaterThanOrEqual(field.bottom); + + await page.getByRole("button", { name: "Toggle toolbar" }).click(); + await expect(page.locator("[data-slot='prompt-input-toolbar']")).toHaveCount( + 0, + ); + const form = await box(page, "[data-slot='prompt-input']"); + const single = await box(page, "[data-slot='prompt-input-field']"); + const submit = await box(page, "[data-slot='prompt-input-submit']"); + // One row: the send button sits beside the text, not under it. + expect(submit.top).toBeLessThan(single.bottom); + expect(form.height).toBeLessThan(field.height + toolbar.height); +}); + +test("the float holds the reply chip above the box and steps aside for a popup", async ({ + page, +}) => { + const float = page.locator("[data-slot='prompt-input-float']"); + const chip = page.locator("[data-slot='prompt-input-context']"); + await expect(chip).toContainText("Replying to Ada"); + const floatBox = await box(page, "[data-slot='prompt-input-float']"); + const form = await box(page, "[data-slot='prompt-input']"); + expect(floatBox.bottom).toBeLessThanOrEqual(form.top); + await expect(float).toHaveCSS("visibility", "visible"); + + await model(page).click(); + await expect(float).toHaveCSS("visibility", "hidden"); + await page.keyboard.press("Escape"); + await expect(float).toHaveCSS("visibility", "visible"); + + await message(page).pressSequentially("@a"); + await expect(page.getByRole("listbox", { name: "Mentions" })).toBeVisible(); + await expect(float).toHaveCSS("visibility", "hidden"); + await page.keyboard.press("Escape"); + await expect(float).toHaveCSS("visibility", "visible"); + + await page.getByRole("button", { name: "Clear" }).click(); + await expect(chip).toHaveCount(0); +}); + +test("toolbar buttons never submit the prompt", async ({ page }) => { + await message(page).fill("draft"); + await page.getByRole("button", { name: "Voice" }).click(); + await expect(events(page)).toHaveText("voice"); + await expect(message(page)).toHaveValue("draft"); +}); diff --git a/src/index.ts b/src/index.ts index 36265287..a5549eac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -132,3 +132,4 @@ export * from "./ui/conversation.js"; export * from "./ui/conversation-divider.js"; export * from "./ui/prompt-input.js"; export * from "./ui/panel.js"; +export * from "./ui/prompt-input-toolbar.js"; diff --git a/src/ui/prompt-input-toolbar.test.tsx b/src/ui/prompt-input-toolbar.test.tsx new file mode 100644 index 00000000..bd9cd7ab --- /dev/null +++ b/src/ui/prompt-input-toolbar.test.tsx @@ -0,0 +1,563 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { act, type ReactNode, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { + PromptInputAddMenu, + PromptInputButton, + PromptInputContext, + PromptInputModeSelect, + PromptInputModelSelect, + type PromptInputAddMenuItem, + type PromptInputModelOption, + type PromptInputModeOption, +} from "./prompt-input-toolbar.js"; + +const MODELS: readonly PromptInputModelOption[] = [ + { id: "swift", name: "Swift 2", short: "Swift", group: "Fast" }, + { id: "deep", name: "Deep 4", short: "Deep", group: "Thorough" }, + { id: "lite", name: "Lite 1", group: "Fast" }, +]; + +const MODES: readonly PromptInputModeOption[] = [ + { id: "ask", label: "Ask", detail: "Answers without acting" }, + { id: "agent", label: "Agent", detail: "Acts on your behalf" }, +]; + +function mount(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + let root!: Root; + act(() => { + root = createRoot(container); + root.render(node); + }); + return { + container, + query(selector: string): T { + const element = container.querySelector(selector); + if (element === null) throw new Error(`no ${selector}`); + return element; + }, + all(selector: string): HTMLElement[] { + return [...container.querySelectorAll(selector)]; + }, + unmount() { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function press(target: Element, key: string): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }); + act(() => { + target.dispatchEvent(event); + }); + return event; +} + +function click(target: HTMLElement) { + act(() => target.click()); +} + +function ControlledModel({ + initial, + events, +}: { + readonly initial: string; + readonly events: string[]; +}) { + const [value, setValue] = useState(initial); + return ( + { + events.push(id); + setValue(id); + }} + /> + ); +} + +describe("PromptInputModelSelect", () => { + test("shows the first model's short name by default and lists models by group", () => { + const view = mount(); + const trigger = view.query("[role='combobox']"); + expect(trigger.getAttribute("aria-label")).toBe("Model"); + expect(trigger.textContent).toBe("Swift"); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(trigger.hasAttribute("aria-controls")).toBe(false); + + click(trigger); + const list = view.query("[role='listbox']"); + expect(trigger.getAttribute("aria-controls")).toBe(list.id); + const groups = view.all("[role='group']"); + expect( + groups.map((group) => { + const labelId = group.getAttribute("aria-labelledby") ?? ""; + return document.getElementById(labelId)?.textContent; + }), + ).toEqual(["Fast", "Thorough"]); + // Grouped order, which is also the order the arrows walk. + expect( + view.all("[role='option']").map((option) => option.textContent), + ).toEqual(["Swift 2", "Lite 1", "Deep 4"]); + view.unmount(); + }); + + test("calls onValueChange with the chosen id and reflects the controlled value", () => { + const events: string[] = []; + const view = mount(); + const trigger = view.query("[role='combobox']"); + expect(trigger.textContent).toBe("Deep"); + + click(trigger); + const chosen = view + .all("[role='option']") + .find((option) => option.getAttribute("aria-selected") === "true"); + expect(chosen?.textContent).toBe("Deep 4"); + const lite = view.all("[role='option']")[1]; + if (lite === undefined) throw new Error("no option"); + click(lite); + expect(events).toEqual(["lite"]); + expect(trigger.textContent).toBe("Lite 1"); + expect(view.container.querySelector("[role='listbox']")).toBeNull(); + view.unmount(); + }); + + test("a controlled value the host does not change stays put", () => { + const events: string[] = []; + const view = mount( + events.push(id)} + />, + ); + const trigger = view.query("[role='combobox']"); + click(trigger); + const deep = view.all("[role='option']")[2]; + if (deep === undefined) throw new Error("no option"); + click(deep); + expect(events).toEqual(["deep"]); + expect(trigger.textContent).toBe("Swift"); + view.unmount(); + }); + + test("works by keyboard with focus kept on the trigger", () => { + const events: string[] = []; + const view = mount(); + const trigger = view.query("[role='combobox']"); + act(() => trigger.focus()); + + const opening = press(trigger, "ArrowDown"); + expect(opening.defaultPrevented).toBe(true); + const options = view.all("[role='option']"); + // Opens on the chosen model. + expect(trigger.getAttribute("aria-activedescendant")).toBe( + options[1]?.id ?? "", + ); + press(trigger, "ArrowDown"); + expect(trigger.getAttribute("aria-activedescendant")).toBe( + options[2]?.id ?? "", + ); + // Stops at the end rather than wrapping. + press(trigger, "ArrowDown"); + expect(trigger.getAttribute("aria-activedescendant")).toBe( + options[2]?.id ?? "", + ); + press(trigger, "Home"); + expect(trigger.getAttribute("aria-activedescendant")).toBe( + options[0]?.id ?? "", + ); + press(trigger, "Enter"); + expect(events).toEqual(["swift"]); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(trigger.hasAttribute("aria-activedescendant")).toBe(false); + expect(document.activeElement).toBe(trigger); + view.unmount(); + }); + + test("Escape closes without choosing and does not reach an enclosing handler", () => { + const events: string[] = []; + const outer: string[] = []; + const view = mount( +
outer.push(event.key)}> + events.push(id)} + /> +
, + ); + const trigger = view.query("[role='combobox']"); + act(() => trigger.focus()); + press(trigger, "Enter"); + press(trigger, "ArrowDown"); + press(trigger, "Escape"); + expect(view.container.querySelector("[role='listbox']")).toBeNull(); + expect(events).toEqual([]); + expect(outer).toEqual(["Enter", "ArrowDown"]); + expect(document.activeElement).toBe(trigger); + view.unmount(); + }); + + test("a value that matches no model shows the label", () => { + const view = mount( + undefined} + />, + ); + expect(view.query("[role='combobox']").textContent).toBe("Model"); + view.unmount(); + }); + + test("renders nothing without models", () => { + const view = mount(); + expect(view.container.innerHTML).toBe(""); + view.unmount(); + }); + + test("an uncontrolled select whose models arrive after mount defaults to the first", () => { + const events: string[] = []; + let setModels!: (models: readonly PromptInputModelOption[]) => void; + function Host() { + const [models, set] = useState([]); + setModels = set; + return ( + events.push(id)} + /> + ); + } + const view = mount(); + act(() => setModels(MODELS)); + const trigger = view.query("[role='combobox']"); + expect(trigger.textContent).toBe("Swift"); + click(trigger); + expect( + view + .all("[role='option']") + .filter((option) => option.getAttribute("aria-selected") === "true") + .map((option) => option.textContent), + ).toEqual(["Swift 2"]); + // The late default is not a choice, so the host hears nothing. + expect(events).toEqual([]); + + // A choice the user made survives a new list. + const deep = view.all("[role='option']")[2]; + if (deep === undefined) throw new Error("no option"); + click(deep); + act(() => setModels([...MODELS].reverse())); + expect(trigger.textContent).toBe("Deep"); + expect(events).toEqual(["deep"]); + view.unmount(); + }); + + test("every open starts on the chosen model, even one the host drives", () => { + let setOpen!: (open: boolean) => void; + function Host() { + const [open, set] = useState(false); + setOpen = set; + return ( + + ); + } + const view = mount(); + const trigger = view.query("[role='combobox']"); + act(() => trigger.focus()); + press(trigger, "ArrowDown"); + press(trigger, "ArrowDown"); + press(trigger, "ArrowDown"); + expect(view.query("[data-active]").textContent).toBe("Deep 4"); + act(() => { + document.body.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true }), + ); + }); + expect(view.container.querySelector("[role='listbox']")).toBeNull(); + + act(() => setOpen(true)); + const active = view.query("[data-active]"); + expect(active.textContent).toBe("Swift 2"); + expect(trigger.getAttribute("aria-activedescendant")).toBe(active.id); + view.unmount(); + }); + + test("ids are unique across instances", () => { + const view = mount( + <> + + + , + ); + const ids = view.all("[id]").map((element) => element.id); + expect(ids.length).toBeGreaterThan(0); + expect(new Set(ids).size).toBe(ids.length); + view.unmount(); + }); +}); + +describe("PromptInputModeSelect", () => { + test("calls onValueChange with the chosen id and reflects the controlled value", () => { + function Host({ events }: { readonly events: string[] }) { + const [mode, setMode] = useState("agent"); + return ( + { + events.push(id); + setMode(id); + }} + /> + ); + } + const events: string[] = []; + const view = mount(); + const trigger = view.query("[role='combobox']"); + expect(trigger.getAttribute("aria-label")).toBe("Mode"); + expect(trigger.textContent).toBe("Agent"); + + act(() => trigger.focus()); + press(trigger, " "); + expect( + view.all("[role='option']").map((option) => option.textContent), + ).toEqual(["AskAnswers without acting", "AgentActs on your behalf"]); + press(trigger, "ArrowUp"); + press(trigger, " "); + expect(events).toEqual(["ask"]); + expect(trigger.textContent).toBe("Ask"); + view.unmount(); + }); + + test("open state can be owned by the host", () => { + const changes: boolean[] = []; + const view = mount( + changes.push(open)} + />, + ); + expect(view.container.querySelector("[role='listbox']")).not.toBeNull(); + const option = view.all("[role='option']")[1]; + if (option === undefined) throw new Error("no option"); + click(option); + expect(changes).toEqual([false]); + // Still open: the host has not closed it. + expect(view.container.querySelector("[role='listbox']")).not.toBeNull(); + view.unmount(); + }); +}); + +function menuItems(events: string[]): PromptInputAddMenuItem[] { + return [ + { + id: "files", + label: "Add files", + onSelect: () => events.push(`files:${document.activeElement?.tagName}`), + }, + { + id: "mention", + label: "Mention an agent", + disabled: true, + onSelect: () => events.push("mention"), + }, + { + id: "command", + label: "Use a command", + detail: "Starts a /", + onSelect: () => events.push("command"), + }, + ]; +} + +describe("PromptInputAddMenu", () => { + test("opens on the first item, moves past disabled items and wraps", () => { + const view = mount(); + const trigger = view.query("[aria-haspopup='menu']"); + expect(trigger.getAttribute("aria-label")).toBe("Add"); + click(trigger); + const menu = view.query("[role='menu']"); + expect(trigger.getAttribute("aria-controls")).toBe(menu.id); + const items = view.all("[role='menuitem']"); + expect(items[1]?.getAttribute("aria-disabled")).toBe("true"); + expect(document.activeElement).toBe(items[0] ?? null); + + press(menu, "ArrowDown"); + expect(document.activeElement).toBe(items[2] ?? null); + press(menu, "ArrowDown"); + expect(document.activeElement).toBe(items[0] ?? null); + press(menu, "ArrowUp"); + expect(document.activeElement).toBe(items[2] ?? null); + press(menu, "Home"); + expect(document.activeElement).toBe(items[0] ?? null); + view.unmount(); + }); + + test("ArrowUp on the trigger opens on the last item", () => { + const view = mount(); + const trigger = view.query("[aria-haspopup='menu']"); + act(() => trigger.focus()); + press(trigger, "ArrowUp"); + expect(document.activeElement?.textContent).toBe("Use a commandStarts a /"); + view.unmount(); + }); + + test("choosing an item closes the menu, returns focus, then runs it", () => { + const events: string[] = []; + const view = mount(); + const trigger = view.query("[aria-haspopup='menu']"); + click(trigger); + const files = view.all("[role='menuitem']")[0]; + if (files === undefined) throw new Error("no item"); + click(files); + // Focus was back on the trigger when the action ran. + expect(events).toEqual(["files:BUTTON"]); + expect(document.activeElement).toBe(trigger); + expect(view.container.querySelector("[role='menu']")).toBeNull(); + view.unmount(); + }); + + test("a disabled item does nothing", () => { + const events: string[] = []; + const view = mount(); + click(view.query("[aria-haspopup='menu']")); + const mention = view.all("[role='menuitem']")[1]; + if (mention === undefined) throw new Error("no item"); + click(mention); + expect(events).toEqual([]); + expect(view.container.querySelector("[role='menu']")).not.toBeNull(); + view.unmount(); + }); + + test("Escape closes, returns focus and stays inside the menu", () => { + const outer: string[] = []; + const view = mount( +
outer.push(event.key)}> + +
, + ); + const trigger = view.query("[aria-haspopup='menu']"); + click(trigger); + press(view.query("[role='menu']"), "Escape"); + expect(view.container.querySelector("[role='menu']")).toBeNull(); + expect(document.activeElement).toBe(trigger); + expect(outer).toEqual([]); + view.unmount(); + }); + + test("a menu open from the start does not take focus", () => { + const outside = document.createElement("button"); + document.body.appendChild(outside); + outside.focus(); + const view = mount( + , + ); + expect(view.container.querySelector("[role='menu']")).not.toBeNull(); + expect(document.activeElement).toBe(outside); + view.unmount(); + outside.remove(); + }); + + test("a menu whose items are all disabled opens, keeps focus and closes on Escape", () => { + const view = mount( + undefined }, + ]} + />, + ); + const trigger = view.query("[aria-haspopup='menu']"); + act(() => trigger.focus()); + press(trigger, "ArrowDown"); + expect(view.container.querySelector("[role='menu']")).not.toBeNull(); + expect(document.activeElement).toBe(trigger); + press(trigger, "Escape"); + expect(view.container.querySelector("[role='menu']")).toBeNull(); + view.unmount(); + }); + + test("unmounting while open removes its document listeners", () => { + const added = spyOn(document, "addEventListener"); + const removed = spyOn(document, "removeEventListener"); + try { + const view = mount( + , + ); + view.unmount(); + const count = (calls: readonly (readonly unknown[])[], type: string) => + calls.filter(([name]) => name === type).length; + expect(count(added.mock.calls, "pointerdown")).toBeGreaterThan(0); + expect(count(removed.mock.calls, "pointerdown")).toBe( + count(added.mock.calls, "pointerdown"), + ); + } finally { + added.mockRestore(); + removed.mockRestore(); + } + }); +}); + +describe("PromptInputButton", () => { + test("never submits the form it sits in", () => { + const submits: string[] = []; + const view = mount( +
{ + event.preventDefault(); + submits.push("submit"); + }} + > + v +
, + ); + const button = view.query( + "[data-slot='prompt-input-button']", + ); + expect(button.type).toBe("button"); + click(button); + expect(submits).toEqual([]); + view.unmount(); + }); +}); + +describe("PromptInputContext", () => { + test("offers a clear button only when it can be cleared", () => { + const cleared: string[] = []; + const plain = mount( + , + ); + expect(plain.container.querySelector("button")).toBeNull(); + expect(plain.container.textContent).toBe("Replying to Ada"); + plain.unmount(); + + const clearable = mount( + cleared.push("clear")} + />, + ); + const button = clearable.query("button"); + expect(button.getAttribute("aria-label")).toBe("Stop replying"); + click(button); + expect(cleared).toEqual(["clear"]); + clearable.unmount(); + }); +}); diff --git a/src/ui/prompt-input-toolbar.tsx b/src/ui/prompt-input-toolbar.tsx new file mode 100644 index 00000000..41e8befb --- /dev/null +++ b/src/ui/prompt-input-toolbar.tsx @@ -0,0 +1,772 @@ +"use client"; + +import { Check, ChevronDown, Plus, Reply, X } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import type * as React from "react"; + +import { useControllableState } from "../hooks/use-controllable-state.js"; +import { useDismissablePopover } from "../hooks/use-dismissable-popover.js"; +import { useScrollCurrentIntoView } from "../hooks/use-scroll-current-into-view.js"; +import { cn } from "../lib/utils.js"; + +function assignRef(ref: React.Ref | undefined, node: T | null) { + if (typeof ref === "function") ref(node); + else if (ref !== null && ref !== undefined) ref.current = node; +} + +/** Where a key moves the current item in a list of `length`: arrows step + * (wrapping when `wrap`, stopping at the ends otherwise), Home and End jump. + * `undefined` for any other key. */ +function stepIndex( + key: string, + current: number, + length: number, + wrap: boolean, +): number | undefined { + if (length === 0) return undefined; + switch (key) { + case "ArrowDown": + return wrap ? (current + 1) % length : Math.min(current + 1, length - 1); + case "ArrowUp": + if (current < 0) return length - 1; + return wrap ? (current - 1 + length) % length : Math.max(current - 1, 0); + case "Home": + return 0; + case "End": + return length - 1; + default: + return undefined; + } +} + +/** Open state a parent may own, as on every popup here. */ +type PopupStateProps = { + readonly open?: boolean; + readonly defaultOpen?: boolean; + readonly onOpenChange?: (open: boolean) => void; +}; + +/** Which edge of its trigger a popup lines up with. */ +export type PromptInputPopupAlign = "start" | "end"; + +// Above the textarea (z-10) and the suggestion list (z-20); the popups open +// upward, over the box, since the box sits at the bottom of the screen. +const POPUP = + "absolute bottom-full z-30 mb-2 flex max-h-72 w-max max-w-80 min-w-48 flex-col gap-0.5 overflow-y-auto rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg"; + +const ICON_BUTTON = + "grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"; + +export type PromptInputToolbarProps = React.ComponentProps<"div">; + +/** + * The row of controls under the text. Rendered last inside `PromptInput`, it + * takes a row of its own; without it the box stays a single row. Put `ms-auto` + * on the first control that belongs at the far end (a model select, say, or + * `PromptInputSubmit`). + */ +export function PromptInputToolbar({ + className, + ...props +}: PromptInputToolbarProps) { + return ( +
+ ); +} + +export type PromptInputButtonProps = Omit< + React.ComponentProps<"button">, + "type" | "aria-label" +> & { + /** Toolbar buttons show only an icon, so the name is required. */ + readonly "aria-label": string; +}; + +/** An icon button sized for the toolbar: voice, attach, anything the host + * adds. Never submits the form it sits in. */ +export function PromptInputButton({ + className, + ...props +}: PromptInputButtonProps) { + return ( + + {open ? ( + + ) : null} +
+ ); +} + +/** An option as the shared select draws it. */ +type SelectOption = { + readonly id: string; + /** What the trigger shows while this option is chosen. */ + readonly short: string; + readonly name: string; + readonly detail?: string | undefined; + readonly group?: string | undefined; +}; + +type SelectGroup = { + readonly name: string | undefined; + readonly options: readonly SelectOption[]; +}; + +/** Options grouped in order of each group's first appearance. */ +function groupOptions(options: readonly SelectOption[]): SelectGroup[] { + const groups: { name: string | undefined; options: SelectOption[] }[] = []; + for (const option of options) { + const group = groups.find((entry) => entry.name === option.group); + if (group === undefined) { + groups.push({ name: option.group, options: [option] }); + } else { + group.options.push(option); + } + } + return groups; +} + +/** The value, open-state and layout props both selects share. */ +type SelectStateProps = Omit< + React.ComponentProps<"div">, + "children" | "defaultValue" | "onChange" +> & + PopupStateProps & { + /** The chosen option's `id`. Supply it with `onValueChange` to own it. */ + readonly value?: string; + /** Seeds the select's own state when `value` is not given. Without it, + * the select shows the first option until one is chosen, including + * options that arrive after mount; that default is not a choice, so it + * does not call `onValueChange`. */ + readonly defaultValue?: string; + readonly onValueChange?: (id: string) => void; + readonly disabled?: boolean; + readonly align?: PromptInputPopupAlign; + }; + +type OptionSelectProps = SelectStateProps & { + readonly slot: string; + /** The trigger's and the list's accessible name; also what the trigger + * shows while the value matches no option. */ + readonly label: string; + readonly options: readonly SelectOption[]; +}; + +/** + * A select-only combobox: focus stays on the trigger, which points at the + * current option with `aria-activedescendant` while the list is open, so + * focus is already home when the list closes. Enter, Space or the arrows + * open it on the chosen option; the arrows move, Home and End jump, Enter + * or Space chooses, Escape and Tab close without choosing. + */ +function OptionSelect({ + slot, + label, + options, + value: valueProp, + defaultValue, + onValueChange, + open: openProp, + defaultOpen = false, + onOpenChange, + disabled = false, + align = "start", + className, + ref, + ...props +}: OptionSelectProps) { + // Undefined until something is chosen: the first option then stands in, + // read from the current list so options that load late still get it. + const [picked, setPicked] = useControllableState({ + value: valueProp, + defaultValue, + onChange: + onValueChange === undefined + ? undefined + : (id) => { + if (id !== undefined) onValueChange(id); + }, + name: slot, + }); + const { open, setOpen, rootRef, triggerRef, close } = useDismissablePopover< + HTMLDivElement, + HTMLButtonElement + >({ open: openProp, defaultOpen, onOpenChange, closeOnEscape: false }); + const listId = useId(); + const [activeId, setActiveId] = useState(undefined); + // However the list closed (a press outside, the host), the next open + // starts on the chosen option, so the highlight is dropped on close. + const [wasOpen, setWasOpen] = useState(open); + if (wasOpen !== open) { + setWasOpen(open); + if (!open) setActiveId(undefined); + } + + const groups = groupOptions(options); + // The order the arrows walk is the order on screen, groups and all. + const ordered = groups.flatMap((group) => group.options); + const value = picked ?? ordered[0]?.id; + const chosen = ordered.find((option) => option.id === value); + const current = + ordered.find((option) => option.id === activeId) ?? chosen ?? ordered[0]; + const currentRef = useScrollCurrentIntoView( + open ? (current?.id ?? "") : "", + ); + + const optionId = (option: SelectOption) => + `${listId}-${ordered.indexOf(option)}`; + + function show(option: SelectOption | undefined) { + setActiveId(option?.id); + setOpen(true); + } + + function hide() { + setActiveId(undefined); + close(); + } + + function choose(option: SelectOption) { + setPicked(option.id); + hide(); + } + + if (ordered.length === 0) return null; + + const renderOption = (option: SelectOption) => { + const active = option.id === current?.id; + const selected = option.id === chosen?.id; + return ( +
event.preventDefault()} + onPointerMove={() => { + if (!active) setActiveId(option.id); + }} + onClick={() => choose(option)} + > + + {option.name} + {option.detail === undefined ? null : ( + + {option.detail} + + )} + + +
+ ); + }; + + return ( +
{ + rootRef.current = node; + assignRef(ref, node); + }} + data-slot={slot} + className={cn("relative inline-flex min-w-0", className)} + > + + {open ? ( +
+ {groups.map((group, groupIndex) => + group.name === undefined ? ( + group.options.map(renderOption) + ) : ( +
+
+ {group.name} +
+ {group.options.map(renderOption)} +
+ ), + )} +
+ ) : null} +
+ ); +} + +/** A model to choose from in `PromptInputModelSelect`. */ +export type PromptInputModelOption = { + readonly id: string; + readonly name: string; + /** What the trigger shows while it is chosen; defaults to `name`. */ + readonly short?: string; + readonly detail?: string; + /** Models with the same group (a provider, say) are listed together + * under it. */ + readonly group?: string; +}; + +export type PromptInputModelSelectProps = SelectStateProps & { + readonly models: readonly PromptInputModelOption[]; + /** The accessible name; also shown when `value` matches no model. */ + readonly label?: string; +}; + +/** + * Which model answers. The trigger shows the chosen model's short name; the + * list groups models by `group`. A model only: anything finer, such as a + * reasoning level, is a separate control, so `onValueChange` stays a single + * model id. + */ +export function PromptInputModelSelect({ + models, + label = "Model", + align = "end", + ...props +}: PromptInputModelSelectProps) { + return ( + ({ + id: model.id, + short: model.short ?? model.name, + name: model.name, + detail: model.detail, + group: model.group, + }))} + /> + ); +} + +/** A way of working, offered by `PromptInputModeSelect`. */ +export type PromptInputModeOption = { + readonly id: string; + readonly label: string; + readonly detail?: string; +}; + +export type PromptInputModeSelectProps = SelectStateProps & { + readonly modes: readonly PromptInputModeOption[]; + /** The accessible name; also shown when `value` matches no mode. */ + readonly label?: string; +}; + +/** How the agent works this turn. The modes' labels and details are the + * host's own strings; the trigger shows the chosen mode's label. */ +export function PromptInputModeSelect({ + modes, + label = "Mode", + ...props +}: PromptInputModeSelectProps) { + return ( + ({ + id: mode.id, + short: mode.label, + name: mode.label, + detail: mode.detail, + }))} + /> + ); +} + +export type PromptInputFloatProps = React.ComponentProps<"div">; + +/** + * What floats above the box: the reply context, attachments, a notice. + * Rendered inside `PromptInput`, it sits over the content above the box + * rather than pushing it. It steps aside while the suggestion list or a + * toolbar popup is open, since those open over the same spot. + */ +export function PromptInputFloat({ + className, + ...props +}: PromptInputFloatProps) { + return ( +
+ ); +} + +export type PromptInputContextProps = Omit< + React.ComponentProps<"div">, + "children" +> & { + /** What the next message answers, e.g. "Replying to". */ + readonly label: string; + readonly detail?: string; + /** Replaces the reply arrow. Decorative. */ + readonly icon?: React.ReactNode; + /** Offered as a clear button when given. */ + readonly onClear?: () => void; + /** The clear button's accessible name. */ + readonly clearLabel?: string; +}; + +/** A chip saying what the next message is about: the message it replies + * to, the item it refers to. Usually inside `PromptInputFloat`. */ +export function PromptInputContext({ + label, + detail, + icon, + onClear, + clearLabel = "Clear", + className, + ...props +}: PromptInputContextProps) { + return ( +
+ + {icon ?? } + + + {label} + {detail === undefined ? null : <> {detail}} + + {onClear === undefined ? null : ( + + )} +
+ ); +} + +export type PromptInputNoteProps = React.ComponentProps<"p">; + +/** Small print under the box, such as a reminder that answers can be + * wrong. Rendered after `PromptInput`, not inside it. */ +export function PromptInputNote({ className, ...props }: PromptInputNoteProps) { + return ( +

+ ); +} diff --git a/src/ui/prompt-input.tsx b/src/ui/prompt-input.tsx index 6d4d8f3f..b527fdf0 100644 --- a/src/ui/prompt-input.tsx +++ b/src/ui/prompt-input.tsx @@ -139,6 +139,8 @@ export function PromptInput({ data-disabled={disabled ? "" : undefined} className={cn( "relative flex items-end gap-2 rounded-lg border border-input bg-background p-1.5", + // A `PromptInputToolbar` placed last takes a row of its own. + "has-[>[data-slot=prompt-input-toolbar]]:flex-wrap", // The textarea gives up its own outline for one round the box. "has-[textarea:focus-visible]:outline-2 has-[textarea:focus-visible]:outline-offset-2 has-[textarea:focus-visible]:outline-ring", className, diff --git a/stories/chat/prompt-input.stories.tsx b/stories/chat/prompt-input.stories.tsx index 13dd10a3..1331d57e 100644 --- a/stories/chat/prompt-input.stories.tsx +++ b/stories/chat/prompt-input.stories.tsx @@ -1,3 +1,4 @@ +import { AtSign, AudioLines, Paperclip, SquareSlash } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; @@ -9,6 +10,18 @@ import { PromptInputTextarea, type PromptInputStatus, } from "../../src/ui/prompt-input.js"; +import { + PromptInputAddMenu, + PromptInputButton, + PromptInputContext, + PromptInputFloat, + PromptInputModeSelect, + PromptInputModelSelect, + PromptInputNote, + PromptInputToolbar, + type PromptInputModelOption, + type PromptInputModeOption, +} from "../../src/ui/prompt-input-toolbar.js"; export default { title: "Chat / Prompt input" }; @@ -115,3 +128,197 @@ export const Replying = () => ( export const LongDraft = () => ; export const Disabled = () => ; + +const MODELS: readonly PromptInputModelOption[] = [ + { + id: "swift", + name: "Swift 2", + short: "Swift", + detail: "Quick answers", + group: "Fast", + }, + { + id: "lite", + name: "Lite 1", + short: "Lite", + detail: "Lowest cost", + group: "Fast", + }, + { + id: "deep", + name: "Deep 4", + short: "Deep", + detail: "Careful, slower reasoning", + group: "Thorough", + }, +]; + +const MODES: readonly PromptInputModeOption[] = [ + { id: "ask", label: "Ask", detail: "Answers without taking action" }, + { id: "plan", label: "Plan", detail: "Drafts steps for you to approve" }, + { id: "agent", label: "Agent", detail: "Acts on your behalf" }, +]; + +type ToolbarPromptProps = { + readonly addMenu?: boolean; + readonly mode?: boolean; + readonly model?: boolean; + readonly voice?: boolean; + readonly replying?: boolean; + readonly note?: boolean; + readonly openPicker?: "model" | "mode" | "add"; + readonly disabled?: boolean; +}; + +/** A prompt with whichever toolbar parts are switched on. */ +function ToolbarPrompt({ + addMenu = false, + mode = false, + model = false, + voice = false, + replying = false, + note = false, + openPicker, + disabled = false, +}: ToolbarPromptProps) { + const [value, setValue] = useState(""); + const [modelId, setModelId] = useState("deep"); + const [modeId, setModeId] = useState("agent"); + const [context, setContext] = useState(replying); + const textareaRef = useRef(null); + const startToken = (prefix: string) => { + setValue( + (draft) => + (draft === "" || draft.endsWith(" ") ? draft : `${draft} `) + prefix, + ); + textareaRef.current?.focus(); + }; + const toolbar = addMenu || mode || model || voice; + return ( + + setValue("")} + tokens={TOKENS} + disabled={disabled} + > + {context ? ( + + setContext(false)} + /> + + ) : null} + + + {toolbar ? ( + + {addMenu ? ( + , + onSelect: () => undefined, + }, + { + id: "mention", + label: "Mention an agent", + icon: , + onSelect: () => startToken("@"), + }, + { + id: "command", + label: "Use a command", + icon: , + onSelect: () => startToken("/"), + }, + ]} + /> + ) : null} + {mode ? ( + + ) : null} +

+ {model ? ( + + ) : null} + {voice ? ( + + + + ) : null} + undefined} /> +
+ + ) : ( + undefined} /> + )} + + {note ? ( + + Agents can make mistakes. Check important work. + + ) : null} + + ); +} + +/** Every optional part on: add menu, mode and model pickers, voice, the + * reply chip floating above and the note below. */ +export const EverythingOn = () => ( + +); + +/** The model picker open, grouped, with the chosen model checked. */ +export const ModelPickerOpen = () => ( + +); + +/** The mode picker open on the current mode. */ +export const ModePickerOpen = () => ( + +); + +/** The `+` menu open: files, a mention, a command. */ +export const AddMenuOpen = () => ( + +); + +/** Small print under the box; nothing else added. */ +export const WithNote = () => ; + +/** The chip says what the next message answers; its button clears it. */ +export const ReplyingChip = () => ; + +/** Disabled: the host passes `disabled` to the toolbar parts as well as + * to the prompt. */ +export const ToolbarDisabled = () => ( + +);