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(
+ ,
+ );
+ 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 (
+
+ );
+}
+
+/** One action in `PromptInputAddMenu`. */
+export type PromptInputAddMenuItem = {
+ readonly id: string;
+ readonly label: string;
+ /** Decorative; hidden from assistive technology. */
+ readonly icon?: React.ReactNode;
+ readonly detail?: string;
+ /** Listed but not choosable; the arrow keys pass over it. */
+ readonly disabled?: boolean;
+ /** Runs after the menu has closed and focus is back on its trigger, so an
+ * action that moves focus (into the textarea, a file picker) keeps it. */
+ readonly onSelect: () => void;
+};
+
+export type PromptInputAddMenuProps = Omit<
+ React.ComponentProps<"div">,
+ "children"
+> &
+ PopupStateProps & {
+ readonly items: readonly PromptInputAddMenuItem[];
+ readonly disabled?: boolean;
+ /** The trigger's and the menu's accessible name. */
+ readonly label?: string;
+ readonly align?: PromptInputPopupAlign;
+ };
+
+/**
+ * The `+` button and its menu of ways to add to the message: files, a
+ * mention, a command. What each one does is the host's, through `onSelect`.
+ *
+ * A menu button: Enter, Space or ArrowDown opens it on the first item,
+ * ArrowUp on the last; the arrows move round the items, Home and End jump,
+ * Escape closes and returns focus to the trigger, Tab closes and moves on.
+ * A press outside closes it without taking focus back.
+ */
+export function PromptInputAddMenu({
+ items,
+ disabled = false,
+ label = "Add",
+ align = "start",
+ open: openProp,
+ defaultOpen = false,
+ onOpenChange,
+ className,
+ ref,
+ ...props
+}: PromptInputAddMenuProps) {
+ const popover = useDismissablePopover({
+ open: openProp,
+ defaultOpen,
+ onOpenChange,
+ closeOnEscape: false,
+ });
+ const { open, setOpen, rootRef, triggerRef, close } = popover;
+ const menuRef = useRef(null);
+ const menuId = useId();
+ // Set only when the person opens the menu from its trigger, so a menu that
+ // is open from the start (or opened by the host) does not take focus.
+ const focusOnOpen = useRef<"first" | "last" | null>(null);
+
+ function enabledItems(): HTMLElement[] {
+ const menu = menuRef.current;
+ if (menu === null) return [];
+ return [
+ ...menu.querySelectorAll(
+ "[role='menuitem']:not([aria-disabled='true'])",
+ ),
+ ];
+ }
+
+ function focusItem(which: "first" | "last") {
+ const enabled = enabledItems();
+ (which === "first" ? enabled[0] : enabled.at(-1))?.focus();
+ }
+
+ useEffect(() => {
+ const which = focusOnOpen.current;
+ if (!open || which === null) return;
+ focusOnOpen.current = null;
+ focusItem(which);
+ }, [open]);
+
+ function openOn(which: "first" | "last") {
+ if (open) {
+ focusItem(which);
+ return;
+ }
+ focusOnOpen.current = which;
+ setOpen(true);
+ }
+
+ if (items.length === 0) return null;
+ return (
+ {
+ rootRef.current = node;
+ assignRef(ref, node);
+ }}
+ data-slot="prompt-input-add-menu"
+ className={cn("relative inline-flex", className)}
+ >
+
+ {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 = () => (
+
+);