diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index efdddd17..f4b578db 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -22,11 +22,22 @@ import { type ChatMessage, type ConversationScrollPolicy, useConversation, + PromptInput, + PromptInputSubmit, + PromptInputSuggestions, + PromptInputTextarea, + type PromptInputStatus, + type PromptToken, } from "@corbits/react-ui"; import { CommandPalette, type CommandPaletteGroup, } from "@corbits/react-ui/ui/command-palette"; +import { + Dialog, + DialogContent, + DialogTitle, +} from "@corbits/react-ui/ui/dialog"; const REQUEST: ApprovalRequest = { id: "req-1", @@ -459,6 +470,145 @@ function ConversationEmptyScenario() { ); } +const PROMPT_TOKENS: PromptToken[] = [ + { id: "ada", kind: "mention", token: "@Ada", detail: "Research agent" }, + { id: "adam", kind: "mention", token: "@Adam", detail: "Review agent" }, + { id: "brief", kind: "command", token: "/brief", detail: "Summarize" }, +]; + +/** Records every submit with its text, and every stop. "Reply" puts the + * prompt into the streaming state a host is in while an agent answers. */ +function Prompt() { + const [value, setValue] = useState(""); + const [status, setStatus] = useState("ready"); + const { log, record } = useEventLog(); + return ( +
+ record(`submit:${value}`)} + tokens={PROMPT_TOKENS} + status={status} + > + + + { + record("stop"); + setStatus("ready"); + }} + /> + + + {log} +
+ ); +} + +/** The prompt inside an open Radix dialog, which records when it closes. */ +function PromptInDialog() { + const [value, setValue] = useState(""); + const [open, setOpen] = useState(true); + const { log, record } = useEventLog(); + return ( + <> + { + setOpen(next); + if (!next) record("dialog-closed"); + }} + > + + Ask + record(`submit:${value}`)} + tokens={PROMPT_TOKENS} + > + + + + + + {log} + + ); +} + +/** The usual chat host: a submit clears the draft and starts a reply, which + * "Finish reply" ends. That button keeps focus where it is, as a reply + * arriving would. Without `stoppable` there is no `onStop`, so Send stays + * Send while the reply runs. */ +function PromptChat({ stoppable }: { readonly stoppable: boolean }) { + const [value, setValue] = useState(""); + const [status, setStatus] = useState("ready"); + const { log, record } = useEventLog(); + return ( +
+ { + record(`submit:${value}`); + setValue(""); + setStatus("submitted"); + }} + status={status} + > + + { + record("stop"); + setStatus("ready"); + }, + } + : {})} + /> + + + {log} +
+ ); +} + +/** A host that sends attachments with the text: "Attach file" toggles one, + * and with it attached there is something to send even with no text. */ +function PromptWithAttachment() { + const [value, setValue] = useState(""); + const [attached, setAttached] = useState(false); + const { log, record } = useEventLog(); + return ( +
+ + record(`submit:${value}${attached ? "+attachment" : ""}`) + } + canSubmit={value.trim() !== "" || attached} + > + + + + + {log} +
+ ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -492,4 +642,9 @@ export const scenarios: Record ReactNode> = { "conversation-stick": () => , "conversation-glide": () => , "conversation-empty": () => , + "prompt-input": () => , + "prompt-input-attachment": () => , + "prompt-input-dialog": () => , + "prompt-input-chat": () => , + "prompt-input-chat-no-stop": () => , }; diff --git a/e2e/prompt-input.spec.ts b/e2e/prompt-input.spec.ts new file mode 100644 index 00000000..95830a48 --- /dev/null +++ b/e2e/prompt-input.spec.ts @@ -0,0 +1,498 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test, type Page } from "@playwright/test"; + +type Box = { + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +}; + +/** + * Where `token` lands inside the textarea, worked out independently of the + * component's mirror: a hidden clone that copies the textarea's computed text + * and box styles, scrolled the same, with the token in a span. If the mirror's + * highlight drifts from the typed letters, it drifts from this too. + */ +async function typedTokenBox(page: Page, token: string): Promise { + return page + .getByRole("textbox", { name: "Message" }) + .evaluate((textarea: HTMLTextAreaElement, token) => { + const style = getComputedStyle(textarea); + const clone = document.createElement("div"); + const copied = [ + "boxSizing", + "width", + "height", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + "borderTopWidth", + "borderRightWidth", + "borderBottomWidth", + "borderLeftWidth", + "borderStyle", + "fontFamily", + "fontSize", + "fontWeight", + "fontStyle", + "fontVariantLigatures", + "letterSpacing", + "lineHeight", + "tabSize", + "textIndent", + "whiteSpace", + "overflowWrap", + "wordBreak", + "overflowX", + "overflowY", + "scrollbarGutter", + ] as const; + for (const property of copied) clone.style[property] = style[property]; + const box = textarea.getBoundingClientRect(); + clone.style.position = "fixed"; + clone.style.left = `${box.left}px`; + clone.style.top = `${box.top}px`; + clone.style.margin = "0"; + clone.style.visibility = "hidden"; + const at = textarea.value.lastIndexOf(token); + const span = document.createElement("span"); + span.textContent = token; + clone.append( + textarea.value.slice(0, at), + span, + textarea.value.slice(at + token.length), + ); + document.body.append(clone); + clone.scrollTop = textarea.scrollTop; + const rect = span.getClientRects()[0]; + clone.remove(); + if (rect === undefined) throw new Error("token did not lay out"); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + }, token); +} + +async function highlightBox(page: Page): Promise { + return page + .locator("[data-slot='prompt-input-token']") + .last() + .evaluate((mark) => { + const rect = mark.getClientRects()[0]; + if (rect === undefined) throw new Error("highlight did not lay out"); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + }); +} + +async function expectHighlightUnderToken(page: Page, token: string) { + const typed = await typedTokenBox(page, token); + const drawn = await highlightBox(page); + expect(Math.abs(drawn.left - typed.left)).toBeLessThanOrEqual(1); + expect(Math.abs(drawn.top - typed.top)).toBeLessThanOrEqual(1); + expect(Math.abs(drawn.width - typed.width)).toBeLessThanOrEqual(1); + expect(Math.abs(drawn.height - typed.height)).toBeLessThanOrEqual(1); +} + +/** Two frames: long enough for a scroll event and the layout after it. */ +async function settle(page: Page) { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); +} + +const events = (page: Page) => page.getByRole("status", { name: "Events" }); +const message = (page: Page) => page.getByRole("textbox", { name: "Message" }); + +const LONG_DRAFT = Array.from( + { length: 24 }, + (_, index) => `Line ${index + 1} of the draft, wrapping the text a little.`, +).join("\n"); + +test("the highlight sits under the typed token after typing", async ({ + page, +}) => { + await page.goto("/prompt-input"); + await message(page).pressSequentially("Ask @Ada about the numbers"); + await expect(page.locator("[data-slot='prompt-input-token']")).toHaveText( + "@Ada", + ); + await expectHighlightUnderToken(page, "@Ada"); +}); + +test("the highlight stays under the token in wide and wrapped text", async ({ + page, +}) => { + await page.goto("/prompt-input"); + await message(page).fill( + `日本語のテキストと絵文字 👋🏽🎉 ${"wrapping words ".repeat(6)}@Ada`, + ); + await settle(page); + await expectHighlightUnderToken(page, "@Ada"); +}); + +test("the highlight follows a long draft as it scrolls", async ({ page }) => { + await page.goto("/prompt-input"); + const textarea = message(page); + await textarea.fill(`${LONG_DRAFT}\nThen ask @Ada to check it.`); + await textarea.evaluate((element: HTMLTextAreaElement) => { + element.scrollTop = element.scrollHeight; + }); + await settle(page); + expect( + await textarea.evaluate( + (element: HTMLTextAreaElement) => element.scrollTop, + ), + ).toBeGreaterThan(0); + await expectHighlightUnderToken(page, "@Ada"); + + await textarea.evaluate((element: HTMLTextAreaElement) => { + element.scrollTop = 37; + }); + await settle(page); + await expectHighlightUnderToken(page, "@Ada"); +}); + +test("Enter during IME composition does not submit", async ({ page }) => { + await page.goto("/prompt-input"); + const textarea = message(page); + await textarea.fill("にほん"); + await textarea.evaluate((element) => { + element.dispatchEvent(new CompositionEvent("compositionstart")); + element.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + isComposing: true, + bubbles: true, + cancelable: true, + }), + ); + element.dispatchEvent(new CompositionEvent("compositionend")); + }); + await settle(page); + await expect(events(page)).toHaveText(""); + await expect(textarea).toHaveValue("にほん"); + + await textarea.press("Enter"); + await expect(events(page)).toHaveText("submit:にほん"); +}); + +test("Shift+Enter breaks the line and Enter submits", async ({ page }) => { + await page.goto("/prompt-input"); + const textarea = message(page); + await textarea.pressSequentially("one"); + await textarea.press("Shift+Enter"); + await textarea.pressSequentially("two"); + await expect(textarea).toHaveValue("one\ntwo"); + await textarea.press("Enter"); + await expect(events(page)).toHaveText("submit:one\ntwo"); +}); + +test("suggestions follow the keyboard and a pick can be undone", async ({ + page, +}) => { + await page.goto("/prompt-input"); + const textarea = message(page); + await textarea.pressSequentially("Ask @a"); + const list = page.getByRole("listbox", { name: "Mentions" }); + await expect(list.getByRole("option")).toHaveCount(2); + await textarea.press("ArrowDown"); + await expect( + list.getByRole("option", { name: /@Adam/, selected: true }), + ).toBeVisible(); + await textarea.press("Enter"); + await expect(textarea).toHaveValue("Ask @Adam "); + await expect(list).toBeHidden(); + await expect(events(page)).toHaveText(""); + + await textarea.press("ControlOrMeta+z"); + await expect(textarea).toHaveValue("Ask @a"); + + await textarea.press("End"); + await textarea.pressSequentially("da and @"); + await expect(list).toBeVisible(); + await textarea.press("Escape"); + await expect(list).toBeHidden(); + await expect(textarea).toHaveValue("Ask @ada and @"); +}); + +test("a press on the list's padding keeps focus and the list open", async ({ + page, +}) => { + await page.goto("/prompt-input"); + const textarea = message(page); + const list = page.getByRole("listbox", { name: "Mentions" }); + await textarea.pressSequentially("Ask @a"); + await expect(list).toBeVisible(); + + // Inside the border, in the padding round the rows. + await list.click({ position: { x: 2, y: 2 } }); + await expect(textarea).toBeFocused(); + await expect(list).toBeVisible(); + + await list.getByRole("option", { name: /@Adam/ }).click(); + await expect(textarea).toHaveValue("Ask @Adam "); + await expect(textarea).toBeFocused(); +}); + +test("a pick mid-draft leaves the caret past the token on its own line", async ({ + page, +}) => { + await page.goto("/prompt-input"); + const textarea = message(page); + const list = page.getByRole("listbox", { name: "Mentions" }); + const caret = () => + textarea.evaluate((element: HTMLTextAreaElement) => element.selectionStart); + + await textarea.pressSequentially("Ask @ad now"); + await textarea.press("ArrowLeft"); + await textarea.press("ArrowLeft"); + await textarea.press("ArrowLeft"); + await textarea.press("ArrowLeft"); + await expect(list).toBeVisible(); + await textarea.press("Enter"); + await expect(textarea).toHaveValue("Ask @Ada now"); + expect(await caret()).toBe("Ask @Ada ".length); + await expect(list).toBeHidden(); + await textarea.press("Enter"); + await expect(events(page)).toHaveText("submit:Ask @Ada now"); + + await textarea.fill(""); + await textarea.pressSequentially("Ask @ad"); + await textarea.press("Shift+Enter"); + await textarea.pressSequentially("next"); + await textarea.press("Home"); + await textarea.press("ArrowLeft"); + await expect(list).toBeVisible(); + await textarea.press("Enter"); + await expect(textarea).toHaveValue("Ask @Ada \nnext"); + expect(await caret()).toBe("Ask @Ada ".length); + await expect(list).toBeHidden(); +}); + +test("Escape closes the suggestion list, not the dialog around it", async ({ + page, +}) => { + await page.goto("/prompt-input-dialog"); + const dialog = page.getByRole("dialog", { name: "Ask" }); + const textarea = message(page); + const list = page.getByRole("listbox", { name: "Mentions" }); + await textarea.pressSequentially("@a"); + await expect(list).toBeVisible(); + + await textarea.press("Escape"); + await expect(list).toBeHidden(); + await expect(dialog).toBeVisible(); + await expect(textarea).toBeFocused(); + + await textarea.press("Escape"); + await expect(dialog).toBeHidden(); + await expect(events(page)).toHaveText("dialog-closed"); +}); + +test("Send becomes Stop while the agent replies and calls onStop", async ({ + page, +}) => { + await page.goto("/prompt-input"); + const textarea = message(page); + await textarea.pressSequentially("Next step"); + await page.getByRole("button", { name: "Reply" }).click(); + const stop = page.getByRole("button", { name: "Stop" }); + await expect(stop).toBeEnabled(); + await expect(page.getByRole("button", { name: "Send" })).toHaveCount(0); + + await textarea.press("Enter"); + await expect(events(page)).toHaveText(""); + + await stop.click(); + await expect(events(page)).toHaveText("stop"); + const send = page.getByRole("button", { name: "Send" }); + await expect(send).toBeFocused(); + await send.click(); + await expect(events(page)).toHaveText("stop,submit:Next step"); +}); + +/** Settles, then asserts focus is on the submit button and not on the page: + * a focused button that turns `disabled` loses focus to `` at the + * next rendering update, after which `toBeFocused` alone could pass early. */ +async function expectSubmitFocused(page: Page) { + await settle(page); + expect( + await page.evaluate(() => + document.activeElement?.getAttribute("data-slot"), + ), + ).toBe("prompt-input-submit"); +} + +test("Send keeps focus as a cleared draft makes it unsendable", async ({ + page, +}) => { + await page.goto("/prompt-input-chat"); + const textarea = message(page); + const submit = page.locator("[data-slot='prompt-input-submit']"); + const finish = page.getByRole("button", { name: "Finish reply" }); + + await textarea.fill("hello"); + await submit.focus(); + await page.keyboard.press("Enter"); + await expect(events(page)).toHaveText("submit:hello"); + await expect(textarea).toHaveValue(""); + await expect(submit).toHaveAccessibleName("Stop"); + await expectSubmitFocused(page); + + // The reply ends: Send again, with nothing to send. + await finish.click(); + await expect(submit).toHaveAccessibleName("Send"); + await expect(submit).toBeDisabled(); + await expectSubmitFocused(page); + + // Stop ends it too. + await textarea.fill("again"); + await submit.click(); + await expect(submit).toHaveAccessibleName("Stop"); + await submit.click(); + await expect(events(page)).toHaveText("submit:hello,submit:again,stop"); + await expect(submit).toHaveAccessibleName("Send"); + await expectSubmitFocused(page); + + // Enter on the unsendable Send sends nothing. + await page.keyboard.press("Enter"); + await settle(page); + await expect(events(page)).toHaveText("submit:hello,submit:again,stop"); +}); + +test("Send keeps focus while a reply runs with no onStop", async ({ page }) => { + await page.goto("/prompt-input-chat-no-stop"); + const submit = page.getByRole("button", { name: "Send" }); + await message(page).fill("hello"); + await submit.click(); + await expect(events(page)).toHaveText("submit:hello"); + await expect(submit).toBeDisabled(); + await expectSubmitFocused(page); + + await page.getByRole("button", { name: "Finish reply" }).click(); + await expectSubmitFocused(page); +}); + +test("an open suggestion list adds no axe violations and is announced", async ({ + page, +}) => { + await page.goto("/prompt-input"); + const textarea = message(page); + await expect(textarea).toHaveAttribute("aria-haspopup", "listbox"); + await textarea.pressSequentially("Ask @a"); + await expect(page.getByRole("listbox", { name: "Mentions" })).toBeVisible(); + await expect( + page.locator("[data-slot='prompt-input'] [role='status']"), + ).toHaveText("2 suggestions"); + const { violations } = await new AxeBuilder({ page }) + .include("[data-slot='prompt-input']") + .analyze(); + expect(violations.map((violation) => violation.id)).toEqual([]); + + await textarea.press("Escape"); + await expect( + page.locator("[data-slot='prompt-input'] [role='status']"), + ).toHaveText(""); +}); + +test("an attachment alone can be sent with Enter or Send", async ({ page }) => { + await page.goto("/prompt-input-attachment"); + const textarea = message(page); + const send = page.getByRole("button", { name: "Send" }); + await expect(send).toBeDisabled(); + await textarea.press("Enter"); + await expect(events(page)).toHaveText(""); + + await page.getByRole("button", { name: "Attach file" }).click(); + await expect(send).toBeEnabled(); + await textarea.press("Enter"); + await expect(events(page)).toHaveText("submit:+attachment"); + await send.click(); + await expect(events(page)).toHaveText( + "submit:+attachment,submit:+attachment", + ); +}); + +async function heights(page: Page) { + return message(page).evaluate((element: HTMLTextAreaElement) => ({ + offset: element.offsetHeight, + client: element.clientHeight, + scroll: element.scrollHeight, + inline: element.style.height, + })); +} + +test("field-sizing grows the textarea to its max, then it scrolls", async ({ + page, +}) => { + await page.goto("/prompt-input"); + const empty = await heights(page); + await message(page).fill("one\ntwo\nthree"); + const three = await heights(page); + await message(page).fill(LONG_DRAFT); + const long = await heights(page); + expect(three.inline).toBe(""); + expect(three.offset).toBeGreaterThan(empty.offset); + expect(long.offset).toBe(200); + expect(long.scroll).toBeGreaterThan(long.client); +}); + +test.describe("without field-sizing", () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + const supports = CSS.supports.bind(CSS); + CSS.supports = (...args: [string, string?]) => + args[0].includes("field-sizing") + ? false + : args[1] === undefined + ? supports(args[0]) + : supports(args[0], args[1]); + document.addEventListener("DOMContentLoaded", () => { + const style = document.createElement("style"); + style.textContent = "textarea { field-sizing: fixed !important; }"; + document.head.append(style); + }); + }); + }); + + test("the measured fallback grows to the max, then scrolls", async ({ + page, + }) => { + await page.goto("/prompt-input"); + const empty = await heights(page); + await message(page).fill("one\ntwo\nthree"); + const three = await heights(page); + await message(page).fill(LONG_DRAFT); + const long = await heights(page); + await message(page).fill(""); + const cleared = await heights(page); + expect(three.inline).toMatch(/px$/); + expect(three.offset).toBeGreaterThan(empty.offset); + expect(long.offset).toBe(200); + expect(long.scroll).toBeGreaterThan(long.client); + expect(cleared.offset).toBe(empty.offset); + }); + + test("the highlight still sits under the token", async ({ page }) => { + await page.goto("/prompt-input"); + await message(page).fill(`${LONG_DRAFT}\nThen ask @Ada to check it.`); + await message(page).evaluate((element: HTMLTextAreaElement) => { + element.scrollTop = element.scrollHeight; + }); + await settle(page); + await expectHighlightUnderToken(page, "@Ada"); + }); +}); diff --git a/src/hooks/use-prompt-input.test.ts b/src/hooks/use-prompt-input.test.ts new file mode 100644 index 00000000..51dfb4db --- /dev/null +++ b/src/hooks/use-prompt-input.test.ts @@ -0,0 +1,437 @@ +import { describe, expect, test } from "bun:test"; +import { act, createElement, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; + +import type { PromptToken } from "../lib/prompt-tokens.js"; +import { + usePromptInput, + type UsePromptInputResult, +} from "./use-prompt-input.js"; + +const TOKENS: readonly PromptToken[] = [ + { id: "ada", kind: "mention", token: "@Ada" }, + { id: "adam", kind: "mention", token: "@Adam" }, + { id: "alan", kind: "mention", token: "@Alan" }, + { id: "brief", kind: "command", token: "/brief" }, +]; + +const setNativeValue = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", +)?.set; + +/** + * Stands in for the browser's `insertText`, which the test DOM lacks, so a + * pick takes the path it takes in a browser: the selection is replaced, the + * caret is left after the inserted text and `input` fires. Returns the undo. + */ +function installInsertText(): () => void { + const execCommand = (command: string, _showUi?: boolean, text = "") => { + const element = document.activeElement; + if (command !== "insertText" || !(element instanceof HTMLTextAreaElement)) { + return false; + } + const { selectionStart, selectionEnd, value } = element; + setNativeValue?.call( + element, + `${value.slice(0, selectionStart)}${text}${value.slice(selectionEnd)}`, + ); + const caret = selectionStart + text.length; + element.setSelectionRange(caret, caret); + element.dispatchEvent(new Event("input", { bubbles: true })); + return true; + }; + Object.defineProperty(document, "execCommand", { + value: execCommand, + configurable: true, + }); + return () => Reflect.deleteProperty(document, "execCommand"); +} + +type Options = { + readonly suggest?: boolean; + readonly busy?: boolean; + readonly canSubmit?: boolean; +}; + +/** + * Mounts the hook over a real textarea whose text a host owns, so typing and + * keys travel the same path a consumer's do: native events into React's + * handlers, then `onValueChange` back into the host's state. + */ +function mountPrompt({ + suggest = true, + busy = false, + canSubmit, +}: Options = {}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const submitted: string[] = []; + let prompt: UsePromptInputResult | undefined; + let text = ""; + let setText: (next: string) => void = () => undefined; + + function Host() { + const [value, setValue] = useState(""); + const textareaRef = useRef(null); + text = value; + setText = setValue; + prompt = usePromptInput({ + value, + onValueChange: setValue, + onSubmit: () => submitted.push(value), + tokens: TOKENS, + suggest, + busy, + disabled: false, + canSubmit, + textareaRef, + }); + return createElement("textarea", { + ref: textareaRef, + value, + ...prompt.textareaProps, + }); + } + + act(() => { + root.render(createElement(Host)); + }); + const textarea = container.querySelector("textarea"); + if (textarea === null) throw new Error("expected a textarea"); + + return { + textarea, + /** Replaces the text as if typed, leaving the caret at `caret`, or at + * the end. */ + type(next: string, caret = next.length) { + act(() => { + textarea.focus(); + setNativeValue?.call(textarea, next); + textarea.setSelectionRange(caret, caret); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + }, + /** Sets the text from the host's side, as a toolbar button would. */ + hostSet(next: string) { + act(() => setText(next)); + }, + /** Dispatches a keydown; true when the textarea's default action ran. + * `keyCode` is set on the event itself: `KeyboardEventInit` drops it. */ + press( + key: string, + { keyCode, ...init }: KeyboardEventInit & { keyCode?: number } = {}, + ): boolean { + const event = new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...init, + }); + if (keyCode !== undefined) { + Object.defineProperty(event, "keyCode", { value: keyCode }); + } + let proceeded = true; + act(() => { + proceeded = textarea.dispatchEvent(event); + }); + return proceeded; + }, + get prompt(): UsePromptInputResult { + if (prompt === undefined) throw new Error("hook did not render"); + return prompt; + }, + get text() { + return text; + }, + submitted, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +const ids = (tokens: readonly PromptToken[]) => tokens.map((token) => token.id); + +describe("usePromptInput", () => { + test("typing a trigger lists the matching tokens with the first current", () => { + const view = mountPrompt(); + view.type("ask @a"); + expect(ids(view.prompt.suggestions)).toEqual(["ada", "adam", "alan"]); + expect(view.prompt.activeSuggestion?.id).toBe("ada"); + view.type("ask @ad"); + expect(ids(view.prompt.suggestions)).toEqual(["ada", "adam"]); + view.unmount(); + }); + + test("ArrowDown and ArrowUp move through the list and wrap", () => { + const view = mountPrompt(); + view.type("@a"); + expect(view.press("ArrowDown")).toBe(false); + expect(view.prompt.activeSuggestion?.id).toBe("adam"); + view.press("ArrowDown"); + view.press("ArrowDown"); + expect(view.prompt.activeSuggestion?.id).toBe("ada"); + view.press("ArrowUp"); + expect(view.prompt.activeSuggestion?.id).toBe("alan"); + view.unmount(); + }); + + test("the current suggestion keeps its row as the list narrows", () => { + const view = mountPrompt(); + view.type("@a"); + view.press("ArrowDown"); + expect(view.prompt.activeSuggestion?.id).toBe("adam"); + view.type("@ad"); + expect(view.prompt.activeSuggestion?.id).toBe("adam"); + view.unmount(); + }); + + test("Enter picks the current suggestion instead of submitting", () => { + const view = mountPrompt(); + view.type("ask @a"); + view.press("ArrowDown"); + expect(view.press("Enter")).toBe(false); + expect(view.text).toBe("ask @Adam "); + expect(view.textarea.selectionStart).toBe("ask @Adam ".length); + expect(view.prompt.suggestions).toEqual([]); + expect(view.submitted).toEqual([]); + view.unmount(); + }); + + test.each([ + ["the browser's editing", true], + ["a directly set value", false], + ])( + "a pick through %s leaves the caret past the token, so Enter then sends", + (_path, browserEditing) => { + const uninstall = browserEditing ? installInsertText() : undefined; + const view = mountPrompt(); + try { + view.type("ask @ad now", "ask @ad".length); + view.press("Enter"); + expect(view.text).toBe("ask @Ada now"); + expect(view.textarea.selectionStart).toBe("ask @Ada ".length); + expect(view.prompt.suggestions).toEqual([]); + view.press("Enter"); + expect(view.submitted).toEqual(["ask @Ada now"]); + + view.type("ask @ad\nnext", "ask @ad".length); + view.press("Enter"); + expect(view.text).toBe("ask @Ada \nnext"); + expect(view.textarea.selectionStart).toBe("ask @Ada ".length); + expect(view.prompt.suggestions).toEqual([]); + } finally { + view.unmount(); + uninstall?.(); + } + }, + ); + + test("Tab picks too", () => { + const view = mountPrompt(); + view.type("/b"); + view.press("Tab"); + expect(view.text).toBe("/brief "); + view.unmount(); + }); + + test("Escape closes the list until the token changes", () => { + const view = mountPrompt(); + view.type("@a"); + expect(view.press("Escape")).toBe(false); + expect(view.prompt.suggestions).toEqual([]); + view.press("ArrowDown"); + expect(view.prompt.suggestions).toEqual([]); + view.type("@ad"); + expect(ids(view.prompt.suggestions)).toEqual(["ada", "adam"]); + view.unmount(); + }); + + test("Escape holds only for the token it closed", () => { + const view = mountPrompt(); + view.type("@a"); + view.press("Escape"); + view.type("@"); + view.type(""); + view.type("@"); + view.type("@a"); + expect(ids(view.prompt.suggestions)).toEqual(["ada", "adam", "alan"]); + + // With the list closed, Enter sends; the host then clears the draft. + view.press("Escape"); + view.press("Enter"); + expect(view.submitted).toEqual(["@a"]); + view.hostSet(""); + view.type("@a"); + expect(ids(view.prompt.suggestions)).toEqual(["ada", "adam", "alan"]); + view.unmount(); + }); + + test("the row the arrows chose does not carry into a later token", () => { + const view = mountPrompt(); + view.type("@a"); + view.press("ArrowDown"); + view.press("ArrowDown"); + expect(view.prompt.activeSuggestion?.id).toBe("alan"); + view.type("@a "); + view.type("@a hi @a"); + expect(view.prompt.activeSuggestion?.id).toBe("ada"); + view.unmount(); + }); + + test("an open list marks Escape handled before a Radix layer sees it", () => { + // Radix dialogs and popovers listen here and dismiss unless the Escape + // is already marked handled. + const seen: boolean[] = []; + const layer = (event: KeyboardEvent) => { + if (event.key === "Escape") seen.push(event.defaultPrevented); + }; + document.addEventListener("keydown", layer, { capture: true }); + const view = mountPrompt(); + try { + view.type("@a"); + view.press("Escape", { isComposing: true }); + view.press("Escape"); + expect(view.prompt.suggestions).toEqual([]); + view.press("Escape"); + expect(seen).toEqual([false, true, false]); + } finally { + view.unmount(); + document.removeEventListener("keydown", layer, { capture: true }); + } + }); + + test("Enter with no list submits and keeps the newline out", () => { + const view = mountPrompt(); + view.type("hello"); + expect(view.press("Enter")).toBe(false); + expect(view.submitted).toEqual(["hello"]); + view.unmount(); + }); + + test("Shift+Enter leaves the newline to the textarea", () => { + const view = mountPrompt(); + view.type("hello"); + expect(view.press("Enter", { shiftKey: true })).toBe(true); + expect(view.submitted).toEqual([]); + view.unmount(); + }); + + test("Enter during IME composition neither submits nor picks", () => { + const view = mountPrompt(); + view.type("にほん"); + expect(view.press("Enter", { isComposing: true })).toBe(true); + view.type("@a"); + view.press("Enter", { isComposing: true }); + expect(view.text).toBe("@a"); + expect(view.submitted).toEqual([]); + view.unmount(); + }); + + test("the Enter Safari sends as a composition ends (keyCode 229) does not submit", () => { + const view = mountPrompt(); + view.type("にほん"); + act(() => { + view.textarea.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true }), + ); + }); + expect(view.press("Enter", { keyCode: 229 })).toBe(true); + expect(view.submitted).toEqual([]); + view.press("Enter", { keyCode: 13 }); + expect(view.submitted).toEqual(["にほん"]); + view.unmount(); + }); + + test("blank text, a held-down Enter or a busy prompt does not submit", () => { + const view = mountPrompt(); + view.type(" "); + view.press("Enter"); + view.type("hi"); + view.press("Enter", { repeat: true }); + expect(view.submitted).toEqual([]); + view.unmount(); + + const busy = mountPrompt({ busy: true }); + busy.type("hi"); + busy.press("Enter"); + expect(busy.prompt.canSubmit).toBe(false); + expect(busy.submitted).toEqual([]); + busy.unmount(); + }); + + test("a host's canSubmit decides whether there is something to send", () => { + const empty = mountPrompt({ canSubmit: true }); + expect(empty.prompt.canSubmit).toBe(true); + expect(empty.press("Enter")).toBe(false); + expect(empty.submitted).toEqual([""]); + empty.unmount(); + + const blocked = mountPrompt({ canSubmit: false }); + blocked.type("hi"); + expect(blocked.prompt.canSubmit).toBe(false); + blocked.press("Enter"); + expect(blocked.submitted).toEqual([]); + blocked.unmount(); + }); + + test("canSubmit does not override busy or IME composition", () => { + const busy = mountPrompt({ busy: true, canSubmit: true }); + expect(busy.prompt.canSubmit).toBe(false); + busy.press("Enter"); + expect(busy.submitted).toEqual([]); + busy.unmount(); + + const composing = mountPrompt({ canSubmit: true }); + composing.press("Enter", { isComposing: true }); + expect(composing.submitted).toEqual([]); + composing.unmount(); + }); + + test("without a suggestion list the keys keep their usual meaning", () => { + const view = mountPrompt({ suggest: false }); + view.type("@a"); + expect(view.prompt.suggestions).toEqual([]); + expect(view.press("ArrowDown")).toBe(true); + view.press("Enter"); + expect(view.submitted).toEqual(["@a"]); + view.unmount(); + }); + + test("a host-set value is read at the caret it leaves, not the last typed one", () => { + const view = mountPrompt(); + view.type("hi"); + view.hostSet("hi @"); + expect(view.textarea.selectionStart).toBe(4); + expect(ids(view.prompt.suggestions)).toEqual(["ada", "adam", "alan"]); + view.unmount(); + + // The caret typed last (6) sits right after "@ad" in the host's text, + // but the textarea's caret is at the end, past any token. + const stale = mountPrompt(); + stale.type("abcdef"); + stale.hostSet("xy @ad z"); + expect(stale.prompt.suggestions).toEqual([]); + stale.unmount(); + }); + + test("a host-set value does not open the list while the textarea is blurred", () => { + const view = mountPrompt(); + view.type("hi"); + act(() => view.textarea.blur()); + view.hostSet("hi @"); + expect(view.prompt.suggestions).toEqual([]); + view.unmount(); + }); + + test("leaving the textarea closes the list", () => { + const view = mountPrompt(); + view.type("@a"); + act(() => view.textarea.blur()); + expect(view.prompt.suggestions).toEqual([]); + view.unmount(); + }); +}); diff --git a/src/hooks/use-prompt-input.ts b/src/hooks/use-prompt-input.ts new file mode 100644 index 00000000..10039ef7 --- /dev/null +++ b/src/hooks/use-prompt-input.ts @@ -0,0 +1,291 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { + ChangeEvent, + KeyboardEvent as ReactKeyboardEvent, + RefObject, + SyntheticEvent, +} from "react"; + +import { + findActivePromptToken, + insertPromptToken, + matchPromptTokens, + type ActivePromptToken, + type PromptToken, +} from "../lib/prompt-tokens.js"; + +export type UsePromptInputOptions = { + readonly value: string; + readonly onValueChange: (value: string) => void; + /** Called by Enter, or by `submit`, when there is something to send. */ + readonly onSubmit: () => void; + /** Every token that can be suggested. */ + readonly tokens: readonly PromptToken[]; + /** Whether a suggestion list is shown at all. Without one, arrows, Tab and + * Escape keep their usual meaning. */ + readonly suggest: boolean; + /** True while a reply is in flight: Enter does not send. */ + readonly busy: boolean; + readonly disabled: boolean; + /** Whether there is something to send. Defaults to `value` holding more + * than whitespace; a host that sends more than the text, such as + * attachments, says so here. `busy`, `disabled` and IME composition still + * stop a submit. */ + readonly canSubmit?: boolean | undefined; + readonly textareaRef: RefObject; +}; + +export type UsePromptInputResult = { + /** The `@` or `/` token the caret is in, or null. */ + readonly activeToken: ActivePromptToken | null; + /** What the active token could become; empty while the list is closed. */ + readonly suggestions: readonly PromptToken[]; + readonly activeSuggestion: PromptToken | undefined; + readonly setActiveSuggestion: (id: string) => void; + /** Replaces the active token with `token` and puts the caret after it. */ + readonly pick: (token: PromptToken) => void; + /** Whether there is something to send and nothing stops sending it. */ + readonly canSubmit: boolean; + readonly submit: () => void; + /** Spread onto the textarea. While the list is open, Escape reaches + * `onKeyDown` with `defaultPrevented` already set (see `usePromptInput`). + * A host that wraps `onKeyDown` and skips this one for a prevented key + * must still pass that Escape on, or the list never closes. */ + readonly textareaProps: { + readonly onChange: (event: ChangeEvent) => void; + readonly onSelect: (event: SyntheticEvent) => void; + readonly onKeyDown: ( + event: ReactKeyboardEvent, + ) => void; + readonly onBlur: () => void; + }; +}; + +/** Where a collapsed caret sits; a selected range has no caret to complete at. */ +function caretOf(element: HTMLTextAreaElement): number | null { + return element.selectionStart === element.selectionEnd + ? element.selectionStart + : null; +} + +function keyOf(token: ActivePromptToken): string { + return `${token.start}:${token.kind}:${token.query}`; +} + +/** + * The prompt's keyboard and suggestion logic, headless. + * + * The text stays the host's: this hook only reads `value` and asks for + * changes through `onValueChange`. What it owns is where the caret is (so it + * knows which `@` or `/` token is being typed), which suggestion is current, + * and whether Escape closed the list. + * + * Keys: while suggestions show, ArrowDown and ArrowUp move through them and + * wrap, Enter or Tab picks the current one and Escape closes the list until + * the token changes or the caret leaves it. Otherwise Enter submits and + * Shift+Enter inserts a newline. Nothing happens during IME composition, + * where Enter commits a candidate rather than the message. + * + * While the list is open, an Escape in the textarea is prevented from the + * window's capture phase, before any React handler runs, so an enclosing + * dialog or popover leaves it to the list. It still reaches + * `textareaProps.onKeyDown`, which closes the list. + * + * The current suggestion is held by id, not index, so it stays on the same + * row while typing narrows the list. + */ +export function usePromptInput({ + value, + onValueChange, + onSubmit, + tokens, + suggest, + busy, + disabled, + canSubmit: hasSomethingToSend = value.trim() !== "", + textareaRef, +}: UsePromptInputOptions): UsePromptInputResult { + const [caret, setCaret] = useState(null); + const [dismissedKey, setDismissedKey] = useState(null); + const [requestedId, setRequestedId] = useState(); + const pendingCaret = useRef<{ value: string; caret: number } | null>(null); + + const activeToken = useMemo( + () => + caret === null || caret > value.length + ? null + : findActivePromptToken(value, caret), + [value, caret], + ); + const suggestions = useMemo( + () => + !suggest || + disabled || + activeToken === null || + keyOf(activeToken) === dismissedKey + ? [] + : matchPromptTokens(tokens, activeToken), + [suggest, disabled, activeToken, dismissedKey, tokens], + ); + const activeSuggestion = + suggestions.find((token) => token.id === requestedId) ?? suggestions[0]; + const listOpen = activeSuggestion !== undefined; + + // What Escape closed and which row the arrows chose belong to the token + // they were pressed in. Once the caret is in no token, drop both, so the + // next token, even the same text at the same place in a new draft, opens + // with its first suggestion current. + const placeCaret = useCallback((text: string, at: number | null) => { + setCaret(at); + if (at === null || findActivePromptToken(text, at) === null) { + setDismissedKey(null); + setRequestedId(undefined); + } + }, []); + + // A Radix dialog or popover around the prompt dismisses on an Escape it + // sees in the document's capture phase, before React's handlers run, + // unless the event is already marked handled. While the list is open, mark + // it first from the window's capture phase; `onKeyDown` still closes the + // list, so propagation is left alone. + useEffect(() => { + const element = textareaRef.current; + if (!listOpen || element === null) return; + const view = element.ownerDocument.defaultView; + if (view === null) return; + const claimEscape = (event: KeyboardEvent) => { + if ( + event.key === "Escape" && + event.target === element && + !event.isComposing + ) { + event.preventDefault(); + } + }; + view.addEventListener("keydown", claimEscape, { capture: true }); + return () => + view.removeEventListener("keydown", claimEscape, { capture: true }); + }, [listOpen, textareaRef]); + + // A pick that could not go through the browser's editing (see `pick`) + // set the value directly, which leaves the caret at the end. Put it back + // after the token, unless the host settled on some other text. + // + // Any other value the host sets, such as an inserted "@", moves the caret + // without a select event, so read it again while the textarea has focus; + // the caret held from the last event would point into the old text. + useLayoutEffect(() => { + const pending = pendingCaret.current; + pendingCaret.current = null; + const element = textareaRef.current; + if (element === null) return; + if (pending !== null && pending.value === value) { + element.setSelectionRange(pending.caret, pending.caret); + placeCaret(value, pending.caret); + } else if (element.ownerDocument.activeElement === element) { + placeCaret(value, caretOf(element)); + } + }, [value, textareaRef, placeCaret]); + + const canSubmit = !disabled && !busy && hasSomethingToSend; + const submit = () => { + if (canSubmit) onSubmit(); + }; + + const pick = (token: PromptToken) => { + if (activeToken === null) return; + const next = insertPromptToken(value, activeToken, token); + setRequestedId(undefined); + const element = textareaRef.current; + // Inserting through the browser keeps the pick on its undo stack, so + // Cmd+Z takes it back; setting the value directly would clear that + // stack. `execCommand` is deprecated but remains the only way to do it. + if (element !== null && typeof document.execCommand === "function") { + const inserted = next.value.slice( + activeToken.start, + next.value.length - (value.length - activeToken.end), + ); + element.focus(); + element.setSelectionRange(activeToken.start, activeToken.end); + if (document.execCommand("insertText", false, inserted)) { + // The browser leaves the caret after the inserted text, which is + // still inside the token when a following space was reused. + element.setSelectionRange(next.caret, next.caret); + placeCaret(next.value, next.caret); + return; + } + } + pendingCaret.current = next; + onValueChange(next.value); + }; + + const move = (step: number) => { + const index = + activeSuggestion === undefined + ? -1 + : suggestions.indexOf(activeSuggestion); + const next = (index + step + suggestions.length) % suggestions.length; + setRequestedId(suggestions[next]?.id); + }; + + const onKeyDown = (event: ReactKeyboardEvent) => { + // 229 is what Safari reports for the Enter that ends a composition, + // after `isComposing` has already turned false. + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (activeSuggestion !== undefined && activeToken !== null) { + switch (event.key) { + case "ArrowDown": + case "ArrowUp": + event.preventDefault(); + move(event.key === "ArrowDown" ? 1 : -1); + return; + case "Enter": + case "Tab": + if (event.shiftKey) break; + event.preventDefault(); + pick(activeSuggestion); + return; + case "Escape": + // Kept from handlers further out, which would otherwise act on it + // too; Radix layers listen earlier and are held off by the effect + // above. + event.preventDefault(); + event.stopPropagation(); + setDismissedKey(keyOf(activeToken)); + return; + default: + break; + } + } + if (event.key !== "Enter" || event.shiftKey) return; + event.preventDefault(); + if (!event.repeat) submit(); + }; + + return { + activeToken, + suggestions, + activeSuggestion, + setActiveSuggestion: setRequestedId, + pick, + canSubmit, + submit, + textareaProps: { + onChange: (event) => { + placeCaret(event.currentTarget.value, caretOf(event.currentTarget)); + onValueChange(event.currentTarget.value); + }, + onSelect: (event) => + placeCaret(event.currentTarget.value, caretOf(event.currentTarget)), + onKeyDown, + onBlur: () => placeCaret(value, null), + }, + }; +} diff --git a/src/index.ts b/src/index.ts index 3accd3f7..43bb94be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ export * from "./hooks/use-dismissable-popover.js"; export * from "./hooks/use-list-selection.js"; export * from "./hooks/use-prefers-reduced-motion.js"; export * from "./hooks/use-scroll-current-into-view.js"; +export * from "./hooks/use-prompt-input.js"; export * from "./lib/artifact.js"; export * from "./lib/chart-palette.js"; export * from "./lib/chat-message.js"; @@ -36,6 +37,7 @@ export * from "./lib/conversation.js"; export * from "./lib/conversation-activity.js"; export * from "./lib/media-kind.js"; export * from "./lib/attachment-limits.js"; +export * from "./lib/prompt-tokens.js"; export * from "./ui/agent-turn.js"; export * from "./ui/animated-number.js"; export * from "./ui/approval-card.js"; @@ -128,3 +130,4 @@ export * from "./ui/view-toggle.js"; export * from "./ui/voice-waveform.js"; export * from "./ui/conversation.js"; export * from "./ui/conversation-divider.js"; +export * from "./ui/prompt-input.js"; diff --git a/src/lib/prompt-tokens.test.ts b/src/lib/prompt-tokens.test.ts new file mode 100644 index 00000000..caef150b --- /dev/null +++ b/src/lib/prompt-tokens.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "bun:test"; + +import { + findActivePromptToken, + insertPromptToken, + matchPromptTokens, + splitPromptSegments, + type PromptToken, +} from "./prompt-tokens.js"; + +const ADA: PromptToken = { id: "ada", kind: "mention", token: "@Ada" }; +const ADAM: PromptToken = { id: "adam", kind: "mention", token: "@Adam" }; +const BRIEF: PromptToken = { id: "brief", kind: "command", token: "/brief" }; +const TOKENS = [ADA, ADAM, BRIEF]; + +describe("findActivePromptToken", () => { + test("a caret inside @ad finds the mention and its full extent", () => { + expect(findActivePromptToken("hi @ad there", 5)).toEqual({ + kind: "mention", + query: "ad", + start: 3, + end: 6, + }); + }); + + test("a caret right after a bare @ finds an empty query", () => { + expect(findActivePromptToken("ask @", 5)).toEqual({ + kind: "mention", + query: "", + start: 4, + end: 5, + }); + }); + + test("an email address is not a mention", () => { + expect(findActivePromptToken("mail ada@example", 16)).toBeNull(); + }); + + test("a slash counts only at the start of a line", () => { + expect(findActivePromptToken("/bri", 4)).toEqual({ + kind: "command", + query: "bri", + start: 0, + end: 4, + }); + expect(findActivePromptToken("first\n/bri", 10)?.kind).toBe("command"); + expect(findActivePromptToken("and /bri", 8)).toBeNull(); + expect(findActivePromptToken("and/or", 6)).toBeNull(); + }); + + test("a caret outside any token finds nothing", () => { + expect(findActivePromptToken("@Ada hello", 10)).toBeNull(); + expect(findActivePromptToken("", 0)).toBeNull(); + }); + + test("letters beyond ASCII are part of the token", () => { + expect(findActivePromptToken("@Zoë", 4)?.query).toBe("Zoë"); + }); +}); + +describe("matchPromptTokens", () => { + test("keeps tokens of the same kind that start with what is typed", () => { + const active = findActivePromptToken("@ad", 3); + if (active === null) throw new Error("expected an active token"); + expect(matchPromptTokens(TOKENS, active)).toEqual([ADA, ADAM]); + }); + + test("ignores case and other kinds", () => { + const active = findActivePromptToken("/BR", 3); + if (active === null) throw new Error("expected an active token"); + expect(matchPromptTokens(TOKENS, active)).toEqual([BRIEF]); + }); +}); + +describe("insertPromptToken", () => { + test("replaces the token and adds a space with the caret after it", () => { + const active = findActivePromptToken("ask @ad", 7); + if (active === null) throw new Error("expected an active token"); + expect(insertPromptToken("ask @ad", active, ADA)).toEqual({ + value: "ask @Ada ", + caret: 9, + }); + }); + + test("reuses a space that already follows", () => { + const active = findActivePromptToken("ask @ad now", 6); + if (active === null) throw new Error("expected an active token"); + expect(insertPromptToken("ask @ad now", active, ADA)).toEqual({ + value: "ask @Ada now", + caret: 9, + }); + }); + + test("adds a space before a line break and keeps the caret on its line", () => { + const active = findActivePromptToken("ask @ad\nnext", 7); + if (active === null) throw new Error("expected an active token"); + expect(insertPromptToken("ask @ad\nnext", active, ADA)).toEqual({ + value: "ask @Ada \nnext", + caret: 9, + }); + }); +}); + +describe("splitPromptSegments", () => { + test("splits known tokens out of plain text", () => { + expect(splitPromptSegments("/brief for @Ada, please", TOKENS)).toEqual([ + { text: "/brief", token: BRIEF }, + { text: " for " }, + { text: "@Ada", token: ADA }, + { text: ", please" }, + ]); + }); + + test("matches whole words only and prefers the longest token", () => { + expect(splitPromptSegments("@Adam x@Ada @Adah", TOKENS)).toEqual([ + { text: "@Adam", token: ADAM }, + { text: " x@Ada @Adah" }, + ]); + }); + + test("a command highlights only at the start of a line", () => { + expect(splitPromptSegments("run /brief\n/brief", TOKENS)).toEqual([ + { text: "run /brief\n" }, + { text: "/brief", token: BRIEF }, + ]); + }); + + test("text with no tokens is one plain run, and empty text none", () => { + expect(splitPromptSegments("hello", TOKENS)).toEqual([{ text: "hello" }]); + expect(splitPromptSegments("", TOKENS)).toEqual([]); + }); +}); diff --git a/src/lib/prompt-tokens.ts b/src/lib/prompt-tokens.ts new file mode 100644 index 00000000..e9b1b50e --- /dev/null +++ b/src/lib/prompt-tokens.ts @@ -0,0 +1,145 @@ +/** A mention names someone to address; a command names something to run. */ +export type PromptTokenKind = "mention" | "command"; + +/** Something a prompt completes and highlights: an agent to mention or a + * command to run. */ +export type PromptToken = { + readonly id: string; + readonly kind: PromptTokenKind; + /** Exactly as it lands in the text: "@Ada", "/summarize". */ + readonly token: string; + /** Shown beside the token in a suggestion list. */ + readonly detail?: string; +}; + +/** The `@` or `/` token the caret is in, as typed so far. */ +export type ActivePromptToken = { + readonly kind: PromptTokenKind; + /** The characters after the trigger, without it: "ad" for "@ad". */ + readonly query: string; + /** Index of the trigger character. */ + readonly start: number; + /** Index just past the token's last character. */ + readonly end: number; +}; + +/** A run of prompt text: plain, or one known token. */ +export type PromptSegment = { + readonly text: string; + readonly token?: PromptToken; +}; + +const WORD_CHARACTER = /[\p{L}\p{N}_-]/u; +const WHITESPACE = /\s/u; +const SPACE_IN_LINE = /[^\S\n\r]/u; + +function isWordCharacter(character: string): boolean { + return character !== "" && WORD_CHARACTER.test(character); +} + +/** + * Whether a token of `kind` may begin at `index`. A mention starts a word, so + * the `@` in "name@example.com" is not one; a command starts a line, so a + * `/` mid-sentence ("and/or") is not one either. + */ +function canStartAt(value: string, index: number, kind: PromptTokenKind) { + if (index === 0) return true; + const before = value.charAt(index - 1); + return kind === "command" ? before === "\n" : WHITESPACE.test(before); +} + +/** + * Finds the `@mention` or `/command` the caret is in. The token runs from its + * trigger through the word characters on both sides of the caret, so a caret + * in the middle of "@ad" still finds it. Returns `null` when the caret is not + * in a token. + */ +export function findActivePromptToken( + value: string, + caret: number, +): ActivePromptToken | null { + if (caret < 0 || caret > value.length) return null; + let wordStart = caret; + while (wordStart > 0 && isWordCharacter(value.charAt(wordStart - 1))) { + wordStart -= 1; + } + const start = wordStart - 1; + if (start < 0) return null; + const trigger = value.charAt(start); + const kind = + trigger === "@" ? "mention" : trigger === "/" ? "command" : undefined; + if (kind === undefined || !canStartAt(value, start, kind)) return null; + let end = caret; + while (end < value.length && isWordCharacter(value.charAt(end))) end += 1; + return { kind, query: value.slice(wordStart, end), start, end }; +} + +/** The tokens of the active token's kind that begin with what is typed, + * ignoring case, in their given order. */ +export function matchPromptTokens( + tokens: readonly PromptToken[], + active: ActivePromptToken, +): readonly PromptToken[] { + const trigger = active.kind === "mention" ? "@" : "/"; + const typed = `${trigger}${active.query}`.toLowerCase(); + return tokens.filter( + (token) => + token.kind === active.kind && token.token.toLowerCase().startsWith(typed), + ); +} + +/** + * The text with the active token replaced by `token` and a space after it, + * and where the caret goes: past that space, so it leaves the token and the + * list closes. A space that already follows is reused rather than doubled; a + * line break is not, or the caret would land on the next line. + */ +export function insertPromptToken( + value: string, + active: ActivePromptToken, + token: PromptToken, +): { readonly value: string; readonly caret: number } { + const after = value.slice(active.end); + const spaced = SPACE_IN_LINE.test(after.charAt(0)); + const inserted = spaced ? token.token : `${token.token} `; + return { + value: `${value.slice(0, active.start)}${inserted}${after}`, + caret: active.start + token.token.length + 1, + }; +} + +/** + * Splits prompt text into plain runs and known tokens. A token counts only as + * a whole word where its kind may start (see `findActivePromptToken`): "@Ada" + * in "@Ada, can you", not in "@Adam" or "x@Ada". Longer tokens win, so + * "@Ada Lovelace" is found before "@Ada". + */ +export function splitPromptSegments( + value: string, + tokens: readonly PromptToken[], +): readonly PromptSegment[] { + const longestFirst = [...tokens].sort( + (a, b) => b.token.length - a.token.length, + ); + const segments: PromptSegment[] = []; + let runStart = 0; + let index = 0; + while (index < value.length) { + const hit = longestFirst.find( + (token) => + value.startsWith(token.token, index) && + canStartAt(value, index, token.kind) && + !isWordCharacter(value.charAt(index + token.token.length)), + ); + if (hit === undefined) { + index += 1; + continue; + } + if (index > runStart) segments.push({ text: value.slice(runStart, index) }); + segments.push({ text: hit.token, token: hit }); + index += hit.token.length; + runStart = index; + } + if (runStart < value.length) segments.push({ text: value.slice(runStart) }); + return segments; +} diff --git a/src/ui/prompt-input.test.tsx b/src/ui/prompt-input.test.tsx new file mode 100644 index 00000000..24d9c27f --- /dev/null +++ b/src/ui/prompt-input.test.tsx @@ -0,0 +1,432 @@ +import { describe, expect, 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 { + PromptInput, + PromptInputSubmit, + PromptInputSuggestion, + PromptInputSuggestions, + PromptInputTextarea, + type PromptInputStatus, +} from "./prompt-input.js"; + +const TOKENS: readonly PromptToken[] = [ + { id: "ada", kind: "mention", token: "@Ada", detail: "Research agent" }, + { id: "alan", kind: "mention", token: "@Alan" }, + { id: "brief", kind: "command", token: "/brief" }, +]; + +type HostProps = { + readonly initial?: string; + readonly status?: PromptInputStatus; + readonly suggestions?: boolean; + readonly renderSuggestion?: (token: PromptToken) => ReactNode; + readonly submitLabel?: string; + readonly listId?: string; + readonly canSubmit?: boolean; + readonly disabled?: boolean; + readonly events: string[]; +}; + +function Host({ + initial = "", + status = "ready", + suggestions = true, + renderSuggestion, + submitLabel, + listId, + canSubmit, + disabled, + events, +}: HostProps) { + const [value, setValue] = useState(initial); + return ( + events.push(`submit:${value}`)} + tokens={TOKENS} + status={status} + {...(canSubmit === undefined ? {} : { canSubmit })} + {...(disabled === undefined ? {} : { disabled })} + > + {!suggestions ? null : renderSuggestion === undefined ? ( + // A plain-JS host can still pass `id`; the listbox keeps its own. + + ) : ( + {renderSuggestion} + )} + + events.push("stop")} + /> + + ); +} + +function render(props: HostProps) { + const container = document.createElement("div"); + document.body.appendChild(container); + let root!: Root; + act(() => { + root = createRoot(container); + root.render(); + }); + const textarea = container.querySelector("textarea"); + const button = container.querySelector("button"); + if (textarea === null || button === null) throw new Error("expected parts"); + const setNativeValue = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + return { + container, + textarea, + button, + type(next: string) { + act(() => { + textarea.focus(); + setNativeValue?.call(textarea, next); + textarea.setSelectionRange(next.length, next.length); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + }, + press(key: string) { + act(() => { + textarea.dispatchEvent( + new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }), + ); + }); + }, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +describe("PromptInput", () => { + test("the mirror is hidden from assistive tech and highlights known tokens", () => { + const view = render({ initial: "/brief for @Ada and @Adam", events: [] }); + const mirror = view.container.querySelector( + "[data-slot='prompt-input-mirror']", + ); + expect(mirror?.getAttribute("aria-hidden")).toBe("true"); + const marks = [...view.container.querySelectorAll("mark")].map( + (mark) => mark.textContent, + ); + expect(marks).toEqual(["/brief", "@Ada"]); + expect(mirror?.textContent).toBe("/brief for @Ada and @Adam" + "\u200B"); + view.unmount(); + }); + + test("the textarea forwards a host ref", () => { + // React 18 drops `ref` from a function component's props: only a + // forwardRef component receives it there. + expect(PromptInputTextarea.$$typeof).toBe(Symbol.for("react.forward_ref")); + const ref = createRef(); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + undefined} + onSubmit={() => undefined} + > + + , + ); + }); + expect(ref.current).toBe(container.querySelector("textarea")); + act(() => root.unmount()); + container.remove(); + }); + + test("Send is aria-disabled until there is text, then submits", () => { + const events: string[] = []; + const view = render({ events }); + expect(view.button.getAttribute("aria-label")).toBe("Send"); + expect(view.button.getAttribute("aria-disabled")).toBe("true"); + view.type("hello"); + expect(view.button.hasAttribute("aria-disabled")).toBe(false); + act(() => view.button.click()); + view.press("Enter"); + expect(events).toEqual(["submit:hello", "submit:hello"]); + view.unmount(); + }); + + test("with nothing to send, Send keeps focus and a click sends nothing", () => { + const events: string[] = []; + const view = render({ events }); + // `disabled` would drop focus to the page as Send turns unsendable. + expect(view.button.disabled).toBe(false); + act(() => view.button.focus()); + act(() => view.button.click()); + expect(events).toEqual([]); + expect(document.activeElement).toBe(view.button); + view.unmount(); + }); + + test("a disabled PromptInput disables Send for real", () => { + const view = render({ initial: "hello", disabled: true, events: [] }); + expect(view.button.disabled).toBe(true); + view.unmount(); + }); + + test("canSubmit lets an empty prompt send, as with only attachments", () => { + const events: string[] = []; + const view = render({ canSubmit: true, events }); + expect(view.button.hasAttribute("aria-disabled")).toBe(false); + view.press("Enter"); + act(() => view.button.click()); + expect(events).toEqual(["submit:", "submit:"]); + view.unmount(); + }); + + test("canSubmit false keeps Send aria-disabled and Enter inert despite text", () => { + const events: string[] = []; + const view = render({ initial: "hello", canSubmit: false, events }); + expect(view.button.getAttribute("aria-disabled")).toBe("true"); + view.press("Enter"); + act(() => view.button.click()); + expect(events).toEqual([]); + view.unmount(); + }); + + test("canSubmit does not send while a reply is in flight", () => { + const events: string[] = []; + const view = render({ status: "submitted", canSubmit: true, events }); + view.press("Enter"); + expect(events).toEqual([]); + view.unmount(); + }); + + test("while streaming, Send becomes Stop and Enter does not submit", () => { + const events: string[] = []; + const view = render({ initial: "next", status: "streaming", events }); + expect(view.button.getAttribute("aria-label")).toBe("Stop"); + expect(view.button.type).toBe("button"); + act(() => view.button.click()); + view.press("Enter"); + expect(events).toEqual(["stop"]); + view.unmount(); + }); + + test("suggestions list the matches and the textarea points at the current one", () => { + const view = render({ events: [] }); + view.type("@a"); + const list = view.container.querySelector("[role='listbox']"); + expect(list?.getAttribute("aria-label")).toBe("Mentions"); + const options = [...view.container.querySelectorAll("[role='option']")]; + expect(options.map((option) => option.textContent)).toEqual([ + "@AdaResearch agent", + "@Alan", + ]); + expect(view.textarea.getAttribute("aria-controls")).toBe(list?.id ?? ""); + expect(view.textarea.getAttribute("aria-activedescendant")).toBe( + options[0]?.id ?? "", + ); + view.press("ArrowDown"); + expect(options[1]?.getAttribute("aria-selected")).toBe("true"); + expect(view.textarea.getAttribute("aria-activedescendant")).toBe( + options[1]?.id ?? "", + ); + view.unmount(); + }); + + test("the textarea offers list completion only with a suggestions part", () => { + const without = render({ suggestions: false, events: [] }); + expect(without.textarea.hasAttribute("aria-autocomplete")).toBe(false); + expect(without.textarea.hasAttribute("aria-haspopup")).toBe(false); + without.unmount(); + + // A capability, so set while the list is closed too. + const view = render({ events: [] }); + expect(view.container.querySelector("[role='listbox']")).toBeNull(); + expect(view.textarea.getAttribute("aria-autocomplete")).toBe("list"); + expect(view.textarea.getAttribute("aria-haspopup")).toBe("listbox"); + view.unmount(); + }); + + test("a status announces the list opening, narrowing and closing", () => { + const view = render({ events: [] }); + const status = view.container.querySelector("[role='status']"); + expect(status?.textContent).toBe(""); + view.type("@a"); + expect(status?.textContent).toBe("2 suggestions"); + view.type("@al"); + expect(status?.textContent).toBe("1 suggestion"); + view.press("Escape"); + expect(view.container.querySelector("[role='listbox']")).toBeNull(); + // The same node throughout: a live region mounted with its text is not + // reliably read. + expect(view.container.querySelector("[role='status']")).toBe(status); + expect(status?.textContent).toBe(""); + view.unmount(); + }); + + test("a render callback that skips PromptInputSuggestion is reported in development", () => { + const errors: unknown[] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => errors.push(args[0]); + try { + const fine = render({ + events: [], + renderSuggestion: (token) => , + }); + fine.type("@a"); + fine.unmount(); + expect(errors).toEqual([]); + + const bare = render({ + events: [], + renderSuggestion: (token) => ( +
  • + {token.token} +
  • + ), + }); + bare.type("@a"); + bare.unmount(); + } finally { + console.error = originalError; + } + expect(errors).toEqual([ + expect.stringContaining("must return a PromptInputSuggestion"), + ]); + }); + + test("clicking a suggestion picks it", () => { + const view = render({ events: [] }); + view.type("@a"); + const option = + view.container.querySelectorAll("[role='option']")[1]; + act(() => option?.click()); + expect(view.textarea.value).toBe("@Alan "); + expect(view.container.querySelector("[role='listbox']")).toBeNull(); + expect(view.textarea.hasAttribute("aria-controls")).toBe(false); + view.unmount(); + }); + + test("a press anywhere in the list leaves focus in the textarea", () => { + // Its padding, the gaps between rows and its scrollbar belong to the + // list, not to an option; a press there must not blur the textarea. + const view = render({ events: [] }); + view.type("@a"); + const list = view.container.querySelector("[role='listbox']"); + const option = view.container.querySelector("[role='option']"); + const press = (target: HTMLElement | null) => + target?.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, cancelable: true }), + ); + expect(press(list)).toBe(false); + expect(press(option)).toBe(false); + view.unmount(); + }); + + test("a host aria-label names the submit button in either state", () => { + const send = render({ submitLabel: "Envoyer", events: [] }); + expect(send.button.getAttribute("aria-label")).toBe("Envoyer"); + send.unmount(); + const stop = render({ + initial: "next", + status: "streaming", + submitLabel: "Arrêter", + events: [], + }); + expect(stop.button.getAttribute("aria-label")).toBe("Arrêter"); + stop.unmount(); + }); + + test("a host onClick on a suggestion runs and the suggestion is still picked", () => { + const events: string[] = []; + const view = render({ + events, + renderSuggestion: (token) => ( + events.push(`click:${token.id}`)} + /> + ), + }); + view.type("@a"); + const option = + view.container.querySelectorAll("[role='option']")[1]; + act(() => option?.click()); + expect(events).toEqual(["click:alan"]); + expect(view.textarea.value).toBe("@Alan "); + view.unmount(); + }); + + test("a host onClick that prevents default keeps the suggestion unpicked", () => { + const view = render({ + events: [], + renderSuggestion: (token) => ( + event.preventDefault()} + /> + ), + }); + view.type("@a"); + const option = + view.container.querySelectorAll("[role='option']")[1]; + act(() => option?.click()); + expect(view.textarea.value).toBe("@a"); + view.unmount(); + }); + + test("a host id on a suggestion does not break aria-activedescendant", () => { + const view = render({ + events: [], + renderSuggestion: (token) => ( + + ), + }); + view.type("@a"); + const options = [ + ...view.container.querySelectorAll("[role='option']"), + ]; + expect(options[0]?.id.startsWith("host-")).toBe(false); + const current = view.textarea.getAttribute("aria-activedescendant") ?? ""; + expect(current).toBe(options[0]?.id ?? ""); + expect(document.getElementById(current)).toBe(options[0] ?? null); + view.unmount(); + }); + + test("a host id on the suggestions list does not break aria-controls", () => { + const view = render({ listId: "host-list", events: [] }); + view.type("@a"); + const list = view.container.querySelector("[role='listbox']"); + expect(list?.id).not.toBe("host-list"); + const controls = view.textarea.getAttribute("aria-controls") ?? ""; + expect(controls).toBe(list?.id ?? ""); + expect(document.getElementById(controls)).toBe(list); + view.unmount(); + }); + + test("without the suggestions part there is no list and arrows are left alone", () => { + const events: string[] = []; + const view = render({ suggestions: false, events }); + view.type("@a"); + expect(view.container.querySelector("[role='listbox']")).toBeNull(); + view.press("Enter"); + expect(events).toEqual(["submit:@a"]); + view.unmount(); + }); +}); diff --git a/src/ui/prompt-input.tsx b/src/ui/prompt-input.tsx new file mode 100644 index 00000000..cd2cd1e6 --- /dev/null +++ b/src/ui/prompt-input.tsx @@ -0,0 +1,604 @@ +"use client"; + +import { ArrowUp, Square } from "lucide-react"; +import { + createContext, + forwardRef, + Fragment, + useCallback, + useContext, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import type * as React from "react"; + +import { + usePromptInput, + type UsePromptInputResult, +} from "../hooks/use-prompt-input.js"; +import { useScrollCurrentIntoView } from "../hooks/use-scroll-current-into-view.js"; +import { splitPromptSegments, type PromptToken } from "../lib/prompt-tokens.js"; +import { cn } from "../lib/utils.js"; + +/** Where the reply to the last message stands: `submitted` until it starts + * to arrive, `streaming` while it does. */ +export type PromptInputStatus = "ready" | "submitted" | "streaming"; + +type PromptInputContextValue = { + readonly value: string; + readonly status: PromptInputStatus; + readonly disabled: boolean; + readonly tokens: readonly PromptToken[]; + readonly textareaRef: React.RefObject; + readonly listId: string; + readonly prompt: UsePromptInputResult; + /** Whether a `PromptInputSuggestions` part is mounted. */ + readonly suggest: boolean; + readonly registerSuggestions: () => () => void; +}; + +const isProduction = + typeof process !== "undefined" && process.env?.NODE_ENV === "production"; + +const PromptInputContext = createContext(null); + +function usePromptInputContext(part: string): PromptInputContextValue { + const context = useContext(PromptInputContext); + if (context === null) { + throw new Error(`${part} must be rendered inside a PromptInput`); + } + return context; +} + +function optionId(listId: string, token: PromptToken): string { + return `${listId}-${token.id}`; +} + +export type PromptInputProps = Omit< + React.ComponentProps<"form">, + "onSubmit" +> & { + readonly value: string; + readonly onValueChange: (value: string) => void; + /** Enter or the send button, with something to send (see `canSubmit`) + * while `status` is ready. */ + readonly onSubmit: () => void; + /** Whether there is something to send. Defaults to `value` holding more + * than whitespace. A host that sends more than the text, such as + * attachments, passes its own answer, e.g. `value.trim() !== "" || + * files.length > 0`. Enter and `PromptInputSubmit` both follow it; a + * `status` other than ready, `disabled` and IME composition still stop a + * submit. */ + readonly canSubmit?: boolean; + /** Mentions and commands to highlight and, with `PromptInputSuggestions`, + * to suggest. */ + readonly tokens?: readonly PromptToken[]; + /** While not `ready`, neither Enter nor the send button submits. */ + readonly status?: PromptInputStatus; + readonly disabled?: boolean; +}; + +/** + * The message box. Fully controlled: the text is the host's, and every + * change, submit and stop is a callback. Each feature is a part, present + * only when included: `PromptInputTextarea` for the text, + * `PromptInputSuggestions` for the `@`/`/` list, `PromptInputSubmit` for + * send and stop. + * + * `status` lives here rather than on the button so Enter and the button + * follow the same rule: nothing is sent while a reply is in flight. + */ +export function PromptInput({ + value, + onValueChange, + onSubmit, + tokens = [], + status = "ready", + disabled = false, + canSubmit, + className, + children, + ...props +}: PromptInputProps) { + const textareaRef = useRef(null); + const listId = useId(); + // A count, not a flag: StrictMode mounts parts twice, and a host may + // render a list conditionally. + const [suggestionParts, setSuggestionParts] = useState(0); + const registerSuggestions = useCallback(() => { + setSuggestionParts((count) => count + 1); + return () => setSuggestionParts((count) => count - 1); + }, []); + const suggest = suggestionParts > 0; + + const prompt = usePromptInput({ + value, + onValueChange, + onSubmit, + tokens, + suggest, + busy: status !== "ready", + disabled, + canSubmit, + textareaRef, + }); + + const context: PromptInputContextValue = { + value, + status, + disabled, + tokens, + textareaRef, + listId, + prompt, + suggest, + registerSuggestions, + }; + + return ( + +
    { + event.preventDefault(); + prompt.submit(); + }} + > + {children} +
    +
    + ); +} + +/** Whether the browser sizes a textarea to its text by itself. Read once, at + * load; where it can't, `useMeasuredHeight` does the job. */ +const NATIVE_FIELD_SIZING = + typeof CSS !== "undefined" && + typeof CSS.supports === "function" && + CSS.supports("field-sizing", "content"); + +/** + * The fallback for browsers without `field-sizing: content`: after every + * change, and whenever the width changes the wrapping, collapse the textarea + * and set its height to what its text needs. The CSS max-height caps it, past + * which it scrolls. Collapsing forces a layout of the page, which is why this + * only runs where the browser cannot size the field itself. + */ +function useMeasuredHeight( + textareaRef: React.RefObject, + value: string, + enabled: boolean, +) { + useLayoutEffect(() => { + const element = textareaRef.current; + if (!enabled || element === null) return; + element.style.height = "auto"; + element.style.height = `${element.scrollHeight}px`; + }, [textareaRef, value, enabled]); + + useEffect(() => { + const element = textareaRef.current; + if (!enabled || element === null || typeof ResizeObserver === "undefined") { + return; + } + let width = element.clientWidth; + const observer = new ResizeObserver(() => { + if (element.clientWidth === width) return; + width = element.clientWidth; + element.style.height = "auto"; + element.style.height = `${element.scrollHeight}px`; + }); + observer.observe(element); + return () => observer.disconnect(); + }, [textareaRef, enabled]); +} + +// Everything that decides where a letter lands, shared by the textarea and +// the mirror under it so the two wrap identically. Nothing here may differ +// between them, and the highlight adds nothing that takes up width. +const FIELD_TEXT = + "m-0 border-0 px-2.5 py-2 [font:inherit] [letter-spacing:inherit] [tab-size:inherit] whitespace-pre-wrap wrap-break-word [scrollbar-gutter:stable]"; + +const ZERO_WIDTH_SPACE = "\u200B"; + +function assignRef(ref: React.Ref | undefined, node: T | null) { + if (typeof ref === "function") ref(node); + else if (ref !== null && ref !== undefined) ref.current = node; +} + +export type PromptInputTextareaProps = Omit< + React.ComponentProps<"textarea">, + "value" | "defaultValue" | "onChange" | "disabled" +> & { + /** Classes for the box holding the textarea and its mirror. Text styles + * belong here too: both layers inherit them. */ + readonly className?: string; +}; + +/** + * The text. A native textarea, so editing, undo, IME and autofill stay the + * browser's, with its text made transparent over an `aria-hidden` mirror that + * renders the same text, wrapped the same way, with known tokens + * highlighted. Caret and selection are the textarea's; the visible letters + * are the mirror's, which follows the textarea's scroll. + * + * It grows with its text up to its max height, then scrolls: through CSS + * `field-sizing` where the browser has it, by measuring elsewhere. + * + * In forced-colors mode the browser paints the textarea's own text, so the + * mirror steps aside rather than doubling every letter. + */ +export const PromptInputTextarea = forwardRef< + HTMLTextAreaElement, + PromptInputTextareaProps +>(function PromptInputTextarea( + { + className, + placeholder = "Send a message…", + "aria-label": ariaLabel = "Message", + rows = 1, + onKeyDown, + onSelect, + onBlur, + onScroll, + ...props + }, + ref, +) { + const { value, disabled, tokens, textareaRef, listId, prompt, suggest } = + usePromptInputContext("PromptInputTextarea"); + const mirrorTextRef = useRef(null); + const segments = useMemo( + () => splitPromptSegments(value, tokens), + [value, tokens], + ); + + useMeasuredHeight(textareaRef, value, !NATIVE_FIELD_SIZING); + + // Translating the mirror's text rather than scrolling the mirror: an + // `overflow: hidden` box clamps `scrollTop` against its own bottom padding, + // which engines treat differently from a textarea's. + const syncScroll = useCallback(() => { + const element = textareaRef.current; + const mirrorText = mirrorTextRef.current; + if (element === null || mirrorText === null) return; + mirrorText.style.transform = `translateY(${-element.scrollTop}px)`; + }, [textareaRef]); + useLayoutEffect(syncScroll, [syncScroll, value]); + + const active = prompt.activeSuggestion; + const listOpen = active !== undefined; + + return ( +
    + +