From 1df2eadd6f29a3cfb50efa13b14c214e48a3b5d3 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 21 Oct 2026 23:43:19 +0530 Subject: [PATCH 1/9] feat(prompt-input): add PromptInput with token highlighting PromptInput is a fully controlled form. PromptInputTextarea draws @mention and /command tokens on an aria-hidden mirror beneath transparent native text, grows through field-sizing with a measured fallback, and never submits during IME composition. usePromptInput drives PromptInputSuggestions from the keyboard, and PromptInputSubmit turns Send into Stop while a reply is running. --- e2e/fixtures.tsx | 71 ++++ e2e/prompt-input.spec.ts | 339 ++++++++++++++++ src/hooks/use-prompt-input.test.ts | 271 +++++++++++++ src/hooks/use-prompt-input.ts | 223 +++++++++++ src/index.ts | 3 + src/lib/prompt-tokens.test.ts | 123 ++++++ src/lib/prompt-tokens.ts | 143 +++++++ src/ui/prompt-input.test.tsx | 308 +++++++++++++++ src/ui/prompt-input.tsx | 534 ++++++++++++++++++++++++++ stories/chat/prompt-input.stories.tsx | 117 ++++++ 10 files changed, 2132 insertions(+) create mode 100644 e2e/prompt-input.spec.ts create mode 100644 src/hooks/use-prompt-input.test.ts create mode 100644 src/hooks/use-prompt-input.ts create mode 100644 src/lib/prompt-tokens.test.ts create mode 100644 src/lib/prompt-tokens.ts create mode 100644 src/ui/prompt-input.test.tsx create mode 100644 src/ui/prompt-input.tsx create mode 100644 stories/chat/prompt-input.stories.tsx diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index efdddd17..b97fbae4 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -22,6 +22,12 @@ import { type ChatMessage, type ConversationScrollPolicy, useConversation, + PromptInput, + PromptInputSubmit, + PromptInputSuggestions, + PromptInputTextarea, + type PromptInputStatus, + type PromptToken, } from "@corbits/react-ui"; import { CommandPalette, @@ -459,6 +465,69 @@ 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} +
+ ); +} + +/** 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 +561,6 @@ export const scenarios: Record ReactNode> = { "conversation-stick": () => , "conversation-glide": () => , "conversation-empty": () => , + "prompt-input": () => , + "prompt-input-attachment": () => , }; diff --git a/e2e/prompt-input.spec.ts b/e2e/prompt-input.spec.ts new file mode 100644 index 00000000..c2929220 --- /dev/null +++ b/e2e/prompt-input.spec.ts @@ -0,0 +1,339 @@ +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("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"); +}); + +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..88d78f4b --- /dev/null +++ b/src/hooks/use-prompt-input.test.ts @@ -0,0 +1,271 @@ +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" }, +]; + +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 = ""; + + function Host() { + const [value, setValue] = useState(""); + const textareaRef = useRef(null); + text = value; + 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"); + const setNativeValue = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + + return { + textarea, + /** Replaces the text as if typed, leaving the caret at its end. */ + type(next: string) { + act(() => { + textarea.focus(); + setNativeValue?.call(textarea, next); + textarea.setSelectionRange(next.length, next.length); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + }, + /** Dispatches a keydown; true when the textarea's default action ran. */ + press(key: string, init: KeyboardEventInit = {}): boolean { + let proceeded = true; + act(() => { + proceeded = textarea.dispatchEvent( + new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...init, + }), + ); + }); + 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("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("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("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("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..01269eab --- /dev/null +++ b/src/hooks/use-prompt-input.ts @@ -0,0 +1,223 @@ +import { 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. */ + 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. Otherwise Enter submits and Shift+Enter inserts a + * newline. Nothing happens during IME composition, where Enter commits a + * candidate rather than the message. + * + * 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]; + + // 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. + useLayoutEffect(() => { + const pending = pendingCaret.current; + if (pending === null) return; + pendingCaret.current = null; + const element = textareaRef.current; + if (element === null || pending.value !== value) return; + element.setSelectionRange(pending.caret, pending.caret); + setCaret(pending.caret); + }, [value, textareaRef]); + + 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)) 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 an enclosing dialog, which would otherwise close too. + 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) => { + setCaret(caretOf(event.currentTarget)); + onValueChange(event.currentTarget.value); + }, + onSelect: (event) => setCaret(caretOf(event.currentTarget)), + onKeyDown, + onBlur: () => setCaret(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..955840e4 --- /dev/null +++ b/src/lib/prompt-tokens.test.ts @@ -0,0 +1,123 @@ +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, + }); + }); +}); + +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..5f070f7f --- /dev/null +++ b/src/lib/prompt-tokens.ts @@ -0,0 +1,143 @@ +/** 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; + +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. A space that already follows is + * reused rather than doubled. + */ +export function insertPromptToken( + value: string, + active: ActivePromptToken, + token: PromptToken, +): { readonly value: string; readonly caret: number } { + const after = value.slice(active.end); + const spaced = WHITESPACE.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..370847e4 --- /dev/null +++ b/src/ui/prompt-input.test.tsx @@ -0,0 +1,308 @@ +import { describe, expect, test } from "bun:test"; +import { act, 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 events: string[]; +}; + +function Host({ + initial = "", + status = "ready", + suggestions = true, + renderSuggestion, + submitLabel, + listId, + canSubmit, + events, +}: HostProps) { + const [value, setValue] = useState(initial); + return ( + events.push(`submit:${value}`)} + tokens={TOKENS} + status={status} + {...(canSubmit === undefined ? {} : { canSubmit })} + > + {!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("Send is 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.disabled).toBe(true); + view.type("hello"); + expect(view.button.disabled).toBe(false); + act(() => view.button.click()); + view.press("Enter"); + expect(events).toEqual(["submit:hello", "submit:hello"]); + 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.disabled).toBe(false); + view.press("Enter"); + act(() => view.button.click()); + expect(events).toEqual(["submit:", "submit:"]); + view.unmount(); + }); + + test("canSubmit false keeps Send disabled and Enter inert despite text", () => { + const events: string[] = []; + const view = render({ initial: "hello", canSubmit: false, events }); + expect(view.button.disabled).toBe(true); + view.press("Enter"); + 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("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 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..6d4d8f3f --- /dev/null +++ b/src/ui/prompt-input.tsx @@ -0,0 +1,534 @@ +"use client"; + +import { ArrowUp, Square } from "lucide-react"; +import { + createContext, + 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; + readonly registerSuggestions: () => () => void; +}; + +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 prompt = usePromptInput({ + value, + onValueChange, + onSubmit, + tokens, + suggest: suggestionParts > 0, + busy: status !== "ready", + disabled, + canSubmit, + textareaRef, + }); + + const context: PromptInputContextValue = { + value, + status, + disabled, + tokens, + textareaRef, + listId, + prompt, + 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 function PromptInputTextarea({ + className, + ref, + placeholder = "Send a message…", + "aria-label": ariaLabel = "Message", + rows = 1, + onKeyDown, + onSelect, + onBlur, + onScroll, + ...props +}: PromptInputTextareaProps) { + const { value, disabled, tokens, textareaRef, listId, prompt } = + 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 ( +
+ +