diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index f8da0c5b..e1c72e01 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -35,6 +35,14 @@ import { PanelHandle, PanelHeader, PanelPane, + PromptInputToolbar, + PromptInputAddMenu, + PromptInputButton, + PromptInputModelSelect, + PromptInputModeSelect, + PromptInputFloat, + PromptInputContext, + PromptInputNote, } from "@corbits/react-ui"; import { CommandPalette, @@ -683,6 +691,169 @@ 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. "Open models" opens the + * model list the way a host would, without focusing it. */ +function PromptToolbarScenario() { + const [value, setValue] = useState(""); + const [model, setModel] = useState("deep"); + const [modelOpen, setModelOpen] = useState(false); + 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); + }} + open={modelOpen} + onOpenChange={setModelOpen} + /> + record("voice")} + > + V + + + + ) : ( + + )} + + Agents can make mistakes. + + + + {log} +
+ ); +} + +/** The toolbar inside an open Radix dialog, which records when it closes. + * "Open models" opens the model list the way a host would. */ +function PromptToolbarInDialog() { + const [value, setValue] = useState(""); + const [modelOpen, setModelOpen] = useState(false); + const [open, setOpen] = useState(true); + const { log, record } = useEventLog(); + return ( + <> + { + setOpen(next); + if (!next) record("dialog-closed"); + }} + > + + Ask + record(`submit:${value}`)} + tokens={PROMPT_TOKENS} + > + + + + record("add:files"), + }, + ]} + /> + + + + + + + {log} + + ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -722,4 +893,6 @@ export const scenarios: Record ReactNode> = { "prompt-input-chat": () => , "prompt-input-chat-no-stop": () => , panel: () => , + "prompt-input-toolbar": () => , + "prompt-input-toolbar-dialog": () => , }; diff --git a/e2e/prompt-input-toolbar.spec.ts b/e2e/prompt-input-toolbar.spec.ts new file mode 100644 index 00000000..6c5aa241 --- /dev/null +++ b/e2e/prompt-input-toolbar.spec.ts @@ -0,0 +1,290 @@ +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("a press between rows of an open popup leaves its keys working", async ({ + page, +}) => { + await model(page).focus(); + await page.keyboard.press("Enter"); + const current = page.locator( + "[data-slot='prompt-input-select-option'][data-active]", + ); + await expect(current).toHaveText("Deep 4"); + await page.getByText("Fast", { exact: true }).click(); + await expect(model(page)).toBeFocused(); + await page.keyboard.press("ArrowUp"); + await expect(current).toHaveText("Lite 1"); + await page.keyboard.press("Escape"); + await expect(page.getByRole("listbox", { name: "Model" })).toBeHidden(); + await expect(model(page)).toBeFocused(); + + await add(page).focus(); + await page.keyboard.press("Enter"); + const menu = page.getByRole("menu", { name: "Add" }); + await expect(page.getByRole("menuitem", { name: "Add files" })).toBeFocused(); + // The menu's padding, above its first item. + await menu.click({ position: { x: 8, y: 2 } }); + 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 expect(events(page)).toHaveText(""); +}); + +test("Escape in the textarea closes a list the host opened, after the suggestion list", async ({ + page, +}) => { + await page.getByRole("button", { name: "Open models" }).click(); + const models = page.getByRole("listbox", { name: "Model" }); + await expect(models).toBeVisible(); + await message(page).focus(); + await message(page).pressSequentially("@a"); + const mentions = page.getByRole("listbox", { name: "Mentions" }); + await expect(mentions).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(mentions).toBeHidden(); + await expect(models).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(models).toBeHidden(); + await expect(model(page)).toHaveAttribute("aria-expanded", "false"); + await expect(message(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"); +}); + +test.describe("inside a dialog", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/prompt-input-toolbar-dialog"); + }); + + test("Escape in the add menu closes the menu, not the dialog", async ({ + page, + }) => { + const dialog = page.getByRole("dialog", { name: "Ask" }); + await add(page).click(); + const menu = page.getByRole("menu", { name: "Add" }); + await expect( + page.getByRole("menuitem", { name: "Add files" }), + ).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(menu).toBeHidden(); + await expect(add(page)).toBeFocused(); + await expect(dialog).toBeVisible(); + + await model(page).focus(); + await page.keyboard.press("Enter"); + const list = page.getByRole("listbox", { name: "Model" }); + await expect(list).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(list).toBeHidden(); + await expect(model(page)).toBeFocused(); + await expect(dialog).toBeVisible(); + }); + + test("beside the suggestion list, Escape closes the list, then a host-opened popup, then the dialog", async ({ + page, + }) => { + const dialog = page.getByRole("dialog", { name: "Ask" }); + const models = page.getByRole("listbox", { name: "Model" }); + const mentions = page.getByRole("listbox", { name: "Mentions" }); + await page.getByRole("button", { name: "Open models" }).click(); + await expect(models).toBeVisible(); + await message(page).pressSequentially("@a"); + await expect(mentions).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(mentions).toBeHidden(); + await expect(models).toBeVisible(); + await expect(dialog).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(models).toBeHidden(); + await expect(message(page)).toBeFocused(); + await expect(dialog).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + await expect(events(page)).toHaveText("dialog-closed"); + }); +}); diff --git a/src/index.ts b/src/index.ts index dc769c66..283451cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -133,3 +133,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..a1059202 --- /dev/null +++ b/src/ui/prompt-input-toolbar.test.tsx @@ -0,0 +1,1089 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { act, createRef, type ReactNode, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import type { PromptToken } from "../lib/prompt-tokens.js"; +import { Dialog, DialogContent, DialogTitle } from "./dialog.js"; +import { + PromptInput, + PromptInputSubmit, + PromptInputSuggestions, + PromptInputTextarea, +} from "./prompt-input.js"; +import { + PromptInputAddMenu, + PromptInputButton, + PromptInputContext, + PromptInputFloat, + PromptInputModeSelect, + PromptInputModelSelect, + PromptInputNote, + PromptInputToolbar, + 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)]; + }, + rerender(next: ReactNode) { + act(() => root.render(next)); + }, + 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()); +} + +/** Whether a press on `target` keeps the browser from moving focus. */ +function pressKeepsFocus(target: Element): boolean { + const event = new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + }); + act(() => { + target.dispatchEvent(event); + }); + return event.defaultPrevented; +} + +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("a press anywhere in the open list keeps focus on the trigger", () => { + const view = mount(); + click(view.query("[role='combobox']")); + const list = view.query("[role='listbox']"); + const heading = document.getElementById( + view.query("[role='group']").getAttribute("aria-labelledby") ?? "", + ); + if (heading === null) throw new Error("no heading"); + expect(pressKeepsFocus(list)).toBe(true); + expect(pressKeepsFocus(heading)).toBe(true); + expect(pressKeepsFocus(view.query("[role='option']"))).toBe(true); + 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("a press anywhere in the open menu keeps focus where it is", () => { + const view = mount(); + click(view.query("[aria-haspopup='menu']")); + expect(pressKeepsFocus(view.query("[role='menu']"))).toBe(true); + expect(pressKeepsFocus(view.query("[role='menuitem']"))).toBe(true); + 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("an ArrowUp the host declined does not make its later open take focus", () => { + const asks: boolean[] = []; + function Host() { + const [open, setOpen] = useState(false); + return ( + <> + asks.push(next)} + /> + + + ); + } + const view = mount(); + const trigger = view.query("[aria-haspopup='menu']"); + act(() => trigger.focus()); + press(trigger, "ArrowUp"); + expect(asks).toEqual([true]); + expect(view.container.querySelector("[role='menu']")).toBeNull(); + click(view.query("[data-host]")); + expect(view.container.querySelector("[role='menu']")).not.toBeNull(); + expect(document.activeElement?.getAttribute("aria-haspopup")).toBe("menu"); + 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("refs", () => { + test("the add menu and both selects hand their root to a ref", () => { + const add = createRef(); + const model = createRef(); + const mode = createRef(); + const view = mount( + <> + + + + , + ); + expect(add.current).toBe( + view.query("[data-slot='prompt-input-add-menu']"), + ); + expect(model.current).toBe( + view.query("[data-slot='prompt-input-model-select']"), + ); + expect(mode.current).toBe( + view.query("[data-slot='prompt-input-mode-select']"), + ); + view.unmount(); + expect(add.current).toBeNull(); + }); +}); + +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(); + }); +}); + +describe("PromptInputToolbar, PromptInputFloat and PromptInputNote", () => { + test("each renders its slot, keeps the host's props and merges its class", () => { + const view = mount( + <> + + + + + Floating + + Small print + , + ); + const toolbar = view.query("[data-slot='prompt-input-toolbar']"); + expect(toolbar.tagName).toBe("DIV"); + expect(toolbar.getAttribute("aria-label")).toBe("Message tools"); + expect(toolbar.classList.contains("basis-full")).toBe(true); + expect(toolbar.classList.contains("host-a")).toBe(true); + expect(toolbar.textContent).toBe("Tool"); + + const float = view.query("[data-slot='prompt-input-float']"); + expect(float.id).toBe("float"); + expect(float.classList.contains("absolute")).toBe(true); + expect(float.classList.contains("host-b")).toBe(true); + expect(float.textContent).toBe("Floating"); + + const note = view.query("[data-slot='prompt-input-note']"); + expect(note.tagName).toBe("P"); + expect(note.classList.contains("host-c")).toBe(true); + expect(note.textContent).toBe("Small print"); + view.unmount(); + }); +}); + +const TOKENS: readonly PromptToken[] = [ + { id: "ada", kind: "mention", token: "@Ada" }, +]; + +/** A prompt with a toolbar, as a host would put one together. */ +function Prompt({ + open, + disabled = false, + partsDisabled, + events, +}: { + readonly open?: "add" | "model"; + readonly disabled?: boolean; + readonly partsDisabled?: boolean; + /** Records the popups' open changes and choices. */ + readonly events?: string[]; +}) { + const [value, setValue] = useState(""); + const own = partsDisabled === undefined ? {} : { disabled: partsDisabled }; + return ( + undefined} + tokens={TOKENS} + disabled={disabled} + > + + + + events?.push(`add:${next}`)} + items={[ + { id: "f", label: "Add files", onSelect: () => events?.push("f") }, + ]} + /> + + events?.push(`model:${next}`)} + onValueChange={(id) => events?.push(`model=${id}`)} + /> + + v + + + + + ); +} + +/** A boolean rather than the element, so a failure prints a line, not a + * React tree. */ +function shown(view: { readonly container: HTMLElement }, selector: string) { + return view.container.querySelector(selector) !== null; +} + +const POPUPS = { + add: { popup: "[role='menu']", trigger: "[aria-haspopup='menu']" }, + model: { + popup: "[role='listbox']", + trigger: "[data-slot='prompt-input-model-select'] [role='combobox']", + }, +} as const; + +/** `Prompt` in a dialog that records its own open changes. The dialog + * renders in a portal, so its contents are queried from `document`. */ +function InDialog({ + dialog, + open, +}: { + readonly dialog: boolean[]; + readonly open?: "add" | "model"; +}) { + const [isOpen, setOpen] = useState(true); + return ( + { + dialog.push(next); + setOpen(next); + }} + > + + Chat + + + + ); +} + +/** A prompt in a dialog whose model list the host opens with a button. */ +function HostModelInDialog({ dialog }: { readonly dialog: boolean[] }) { + const [value, setValue] = useState(""); + const [modelOpen, setModelOpen] = useState(false); + return ( + dialog.push(next)}> + + Chat + undefined} + tokens={TOKENS} + > + + + + + + + + + + ); +} + +function typeMention(textarea: HTMLTextAreaElement) { + const setValue = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + act(() => { + textarea.focus(); + setValue?.call(textarea, "@"); + textarea.setSelectionRange(1, 1); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +function inDialog(selector: string): T { + const element = document.querySelector(`[role='dialog'] ${selector}`); + if (element === null) throw new Error(`no ${selector}`); + return element; +} + +describe("toolbar parts inside PromptInput", () => { + for (const which of ["add", "model"] as const) { + test(`the ${which} popup, opened by the host, closes on Escape from the textarea and focus stays there`, () => { + const view = mount(); + const textarea = view.query("textarea"); + act(() => textarea.focus()); + expect(shown(view, POPUPS[which].popup)).toBe(true); + press(textarea, "Escape"); + expect(shown(view, POPUPS[which].popup)).toBe(false); + expect( + view.query(POPUPS[which].trigger).getAttribute("aria-expanded"), + ).toBe("false"); + expect(document.activeElement).toBe(textarea); + view.unmount(); + }); + } + + test("with the suggestion list open too, the first Escape closes only the list", () => { + const view = mount(); + const textarea = view.query("textarea"); + const setValue = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + act(() => { + textarea.focus(); + setValue?.call(textarea, "@"); + textarea.setSelectionRange(1, 1); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + const list = "[data-slot='prompt-input-suggestions']"; + expect(shown(view, list)).toBe(true); + expect(shown(view, "[role='menu']")).toBe(true); + + press(textarea, "Escape"); + expect(shown(view, list)).toBe(false); + expect(shown(view, "[role='menu']")).toBe(true); + + press(textarea, "Escape"); + expect(shown(view, "[role='menu']")).toBe(false); + expect(document.activeElement).toBe(textarea); + view.unmount(); + }); + + test("a popup's own Escape is not handled twice", () => { + const changes: boolean[] = []; + const view = mount( + changes.push(open)} + />, + ); + const trigger = view.query("[aria-haspopup='menu']"); + click(trigger); + press(view.query("[role='menu']"), "Escape"); + expect(changes).toEqual([true, false]); + expect(document.activeElement).toBe(trigger); + view.unmount(); + }); + + test("Escape inside the add menu closes the menu, not an enclosing dialog", () => { + const dialog: boolean[] = []; + const view = mount(); + const trigger = inDialog("[aria-haspopup='menu']"); + click(trigger); + const item = inDialog("[role='menuitem']"); + expect(document.activeElement).toBe(item); + press(item, "Escape"); + expect(dialog).toEqual([]); + expect(document.querySelector("[role='dialog'] [role='menu']")).toBeNull(); + expect(document.activeElement).toBe(trigger); + view.unmount(); + }); + + test("Escape on an open select closes the list, not an enclosing dialog", () => { + const dialog: boolean[] = []; + const view = mount(); + const trigger = inDialog(POPUPS.model.trigger); + act(() => trigger.focus()); + click(trigger); + expect( + document.querySelector("[role='dialog'] [role='listbox']"), + ).not.toBeNull(); + press(trigger, "Escape"); + expect(dialog).toEqual([]); + expect( + document.querySelector("[role='dialog'] [role='listbox']"), + ).toBeNull(); + expect(document.activeElement).toBe(trigger); + view.unmount(); + }); + + for (const which of ["add", "model"] as const) { + test(`Escape from the textarea closes the host-opened ${which} popup, not an enclosing dialog`, () => { + const dialog: boolean[] = []; + const view = mount(); + const textarea = inDialog("textarea"); + act(() => textarea.focus()); + const popup = `[role='dialog'] ${POPUPS[which].popup}`; + expect(document.querySelector(popup)).not.toBeNull(); + press(textarea, "Escape"); + expect(dialog).toEqual([]); + expect(document.querySelector(popup)).toBeNull(); + expect(document.activeElement).toBe(textarea); + view.unmount(); + }); + } + + test("Escape in an unrelated dialog closes the dialog, not a host-opened list", () => { + const events: string[] = []; + const dialog: boolean[] = []; + function Page() { + const [isOpen, setOpen] = useState(true); + return ( + <> + + { + dialog.push(next); + setOpen(next); + }} + > + + Other + + + + + ); + } + const view = mount(); + const other = inDialog("[data-other]"); + act(() => other.focus()); + press(other, "Escape"); + expect(dialog).toEqual([false]); + expect(events).toEqual([]); + expect(shown(view, POPUPS.model.popup)).toBe(true); + view.unmount(); + }); + + test("Escape inside the add menu leaves a host-opened list open", () => { + const events: string[] = []; + const view = mount(); + const trigger = view.query(POPUPS.add.trigger); + act(() => trigger.focus()); + press(trigger, "ArrowDown"); + const item = view.query("[role='menuitem']"); + expect(document.activeElement).toBe(item); + press(item, "Escape"); + expect(events).toEqual(["add:true", "add:false"]); + expect(shown(view, POPUPS.add.popup)).toBe(false); + expect(shown(view, POPUPS.model.popup)).toBe(true); + expect(document.activeElement).toBe(trigger); + view.unmount(); + }); + + for (const first of ["popup", "list"] as const) { + test(`in a dialog, with the ${first} opened first, Escape closes the list, then the popup, and never the dialog`, () => { + const dialog: boolean[] = []; + const view = mount(); + const textarea = inDialog("textarea"); + const openPopup = () => click(inDialog("[data-host]")); + if (first === "popup") openPopup(); + typeMention(textarea); + if (first === "list") openPopup(); + const list = "[role='dialog'] [data-slot='prompt-input-suggestions']"; + const popup = "[role='dialog'] [role='listbox'][aria-label='Model']"; + expect(document.querySelector(list)).not.toBeNull(); + expect(document.querySelector(popup)).not.toBeNull(); + + press(textarea, "Escape"); + expect(dialog).toEqual([]); + expect(document.querySelector(list)).toBeNull(); + expect(document.querySelector(popup)).not.toBeNull(); + + press(textarea, "Escape"); + expect(dialog).toEqual([]); + expect(document.querySelector(popup)).toBeNull(); + expect(document.activeElement).toBe(textarea); + view.unmount(); + }); + } + + for (const which of ["add", "model"] as const) { + test(`the ${which} popup, open from the start in a disabled prompt, is not shown`, () => { + const events: string[] = []; + const view = mount(); + expect(shown(view, POPUPS[which].popup)).toBe(false); + expect( + view.query(POPUPS[which].trigger).getAttribute("aria-expanded"), + ).toBe("false"); + expect(events.filter((event) => !event.includes(":"))).toEqual([]); + view.unmount(); + }); + + test(`disabling the prompt closes the open ${which} popup for good`, () => { + const events: string[] = []; + const view = mount(); + expect(shown(view, POPUPS[which].popup)).toBe(true); + view.rerender(); + expect(shown(view, POPUPS[which].popup)).toBe(false); + expect(events).toEqual([`${which}:false`]); + view.rerender(); + expect(shown(view, POPUPS[which].popup)).toBe(false); + view.unmount(); + }); + } + + test("the parts follow PromptInput's disabled", () => { + const view = mount(); + expect(view.query("textarea").disabled).toBe(true); + const parts = view.all( + "[aria-haspopup='menu'], [role='combobox'], [data-slot='prompt-input-button']", + ); + expect(parts).toHaveLength(4); + for (const part of parts) { + expect((part as HTMLButtonElement).disabled).toBe(true); + } + view.unmount(); + }); + + test("a part's own disabled wins over PromptInput's", () => { + const enabled = mount(); + for (const part of enabled.all( + "[aria-haspopup='menu'], [role='combobox'], [data-slot='prompt-input-button']", + )) { + expect((part as HTMLButtonElement).disabled).toBe(false); + } + enabled.unmount(); + + const disabled = mount(); + for (const part of disabled.all( + "[aria-haspopup='menu'], [role='combobox'], [data-slot='prompt-input-button']", + )) { + expect((part as HTMLButtonElement).disabled).toBe(true); + } + disabled.unmount(); + }); +}); diff --git a/src/ui/prompt-input-toolbar.tsx b/src/ui/prompt-input-toolbar.tsx new file mode 100644 index 00000000..316cbe64 --- /dev/null +++ b/src/ui/prompt-input-toolbar.tsx @@ -0,0 +1,906 @@ +"use client"; + +import { Check, ChevronDown, Plus, Reply, X } from "lucide-react"; +import { + forwardRef, + useEffect, + useId, + useLayoutEffect, + 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"; +import { usePromptInputDisabled } from "./prompt-input.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; + } +} + +/** The root of a toolbar popup while it is open; each root carries + * `data-state="open"` only then. */ +const OPEN_POPUP = [ + "prompt-input-add-menu", + "prompt-input-model-select", + "prompt-input-mode-select", +] + .map((slot) => `[data-slot=${slot}][data-state=open]`) + .join(","); + +/** + * While `open`, claims Escape for the popup so that one Escape closes only + * the popup, not an enclosing dialog as well. A dialog dismisses on Escape + * from a capture listener on the document unless the event is already + * prevented, so this prevents it earlier, in the window's capture phase. + * Escape pressed inside `rootRef` is then the popup's own keys' to close. + * Escape pressed elsewhere in the enclosing `PromptInput` (in the textarea, + * say, beside a popup the host opened) closes the popup here and leaves + * focus where it is. Left alone are an Escape from outside the + * `PromptInput`, which belongs to whatever layer it was pressed in, one + * inside another open toolbar popup, which that popup closes, and any while + * the `PromptInput`'s suggestion list is showing, which the list takes. + */ +function useClaimEscape( + open: boolean, + rootRef: React.RefObject, + setOpen: (open: boolean) => void, +) { + useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + const root = rootRef.current; + const { target } = event; + if ( + event.key !== "Escape" || + event.defaultPrevented || + root === null || + !(target instanceof Element) + ) { + return; + } + const inside = root.contains(target); + if (!inside) { + const box = root.closest("[data-slot=prompt-input]"); + if ( + box === null || + target.closest("[data-slot=prompt-input]") !== box + ) { + return; + } + const listShowing = + box.querySelector("[data-slot=prompt-input-suggestions]") !== null; + const inOther = target.closest(OPEN_POPUP) !== null; + if (listShowing || inOther) return; + } + event.preventDefault(); + if (!inside) setOpen(false); + }; + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [open, rootRef, setOpen]); +} + +/** + * What a popup shows of its `open` state: closed while `disabled`, whatever + * its owner holds, so a disabled part offers nothing to choose. Turning + * disabled while open closes it for the owner too, so it stays closed once + * the part is enabled again. + */ +function useClosedWhileDisabled( + open: boolean, + disabled: boolean, + setOpen: (open: boolean) => void, +): boolean { + // The latest `setOpen`, which is new on every render of a popup the owner + // controls, so the owner is asked once per change rather than each render. + const setOpenRef = useRef(setOpen); + useLayoutEffect(() => { + setOpenRef.current = setOpen; + }); + useEffect(() => { + if (disabled && open) setOpenRef.current(false); + }, [disabled, open]); + return open && !disabled; +} + +/** 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-px 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`). + * + * Inside a disabled `PromptInput`, the add menu, the selects and + * `PromptInputButton` are disabled too unless given their own `disabled`. + */ +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. `disabled` defaults to the + * enclosing `PromptInput`'s. */ +export function PromptInputButton({ + disabled, + className, + ...props +}: PromptInputButtonProps) { + const boxDisabled = usePromptInputDisabled(); + 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; + /** Defaults to the enclosing `PromptInput`'s `disabled`. */ + 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, Home and End on the first or last; the + * arrows move, Home and End jump, Enter or Space chooses, Escape and Tab + * close without choosing. Escape from elsewhere in the enclosing + * `PromptInput` (the textarea, beside a list the host opened) closes it too + * and leaves focus where it is. + */ +const OptionSelect = forwardRef(function OptionSelect( + { + slot, + label, + options, + value: valueProp, + defaultValue, + onValueChange, + open: openProp, + defaultOpen = false, + onOpenChange, + disabled: disabledProp, + align = "start", + className, + ...props + }: Omit, + ref: React.ForwardedRef, +) { + // 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 popover = useDismissablePopover({ + open: openProp, + defaultOpen, + onOpenChange, + closeOnEscape: false, + }); + const { setOpen, rootRef, triggerRef, close } = popover; + const boxDisabled = usePromptInputDisabled(); + const disabled = disabledProp ?? boxDisabled; + const open = useClosedWhileDisabled(popover.open, disabled, setOpen); + useClaimEscape(open, rootRef, setOpen); + 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 ( +
{ + 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} + data-state={open ? "open" : undefined} + className={cn("relative inline-flex min-w-0", className)} + > + + {open ? ( +
event.preventDefault()} + > + {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 const PromptInputModelSelect = forwardRef< + HTMLDivElement, + PromptInputModelSelectProps +>(function PromptInputModelSelect( + { models, label = "Model", align = "end", ...props }, + ref, +) { + 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 const PromptInputModeSelect = forwardRef< + HTMLDivElement, + PromptInputModeSelectProps +>(function PromptInputModeSelect({ modes, label = "Mode", ...props }, ref) { + 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 cd2cd1e6..7cb3794f 100644 --- a/src/ui/prompt-input.tsx +++ b/src/ui/prompt-input.tsx @@ -54,6 +54,13 @@ function usePromptInputContext(part: string): PromptInputContextValue { return context; } +/** Whether the enclosing `PromptInput` is `disabled`; `false` outside one. + * The toolbar parts default their own `disabled` to it, and a part the host + * builds can do the same. */ +export function usePromptInputDisabled(): boolean { + return useContext(PromptInputContext)?.disabled ?? false; +} + function optionId(listId: string, token: PromptToken): string { return `${listId}-${token.id}`; } @@ -79,6 +86,8 @@ export type PromptInputProps = Omit< readonly tokens?: readonly PromptToken[]; /** While not `ready`, neither Enter nor the send button submits. */ readonly status?: PromptInputStatus; + /** Disables the textarea and sending, and the toolbar parts inside that do + * not set their own `disabled`. */ readonly disabled?: boolean; }; @@ -147,6 +156,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..e6246578 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,194 @@ 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 toolbar parts follow the prompt's `disabled`; none is + * passed its own. */ +export const ToolbarDisabled = () => ( + +);