// ~/src/siteplane/components/prompt-input/prompt-input.tsx "use client"; import { ArrowUpIcon, PaperclipIcon, SquareIcon } from "lucide-react"; import * as React from "react"; import { useRelativeControlSize } from "../../system/provider/siteplane-ui-provider"; import { cn } from "../../system/utils/cn"; import { slotClass } from "../../system/utils/slot-class"; import { Button, type ButtonBaseProps, type ButtonProps } from "../button"; import { Form } from "../form"; import { Input } from "../input"; import { InputGroup, InputGroupAddon, InputGroupTextarea, } from "../input-group"; import { MotionSwap } from "../motion"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../tooltip"; import { sendDraft, submitOnEnterKey, submitState, type PromptInputStatus, type PromptInputSubmitOnEnter, } from "./prompt-input-keys"; import "./prompt-input.css"; export type { PromptInputStatus, PromptInputSubmitOnEnter }; /** The words the Prompt Input speaks. Each can be replaced through `labels`. */ export type PromptInputLabels = { /** The name of the field for assistive technology. */ textarea: string; /** The name of the button that sends the draft. */ send: string; /** The name of the button that stops a reply that is running. */ stop: string; /** The name and the tooltip of the button that picks files. */ attach: string; }; const defaultPromptInputLabels: PromptInputLabels = { attach: "Attach files", send: "Send message", stop: "Stop", textarea: "Message", }; type PromptInputContextValue = { allowEmpty: boolean; disabled: boolean; labels: PromptInputLabels; onStop: (() => void) | undefined; setValue: (value: string) => void; status: PromptInputStatus; submitOnEnter: PromptInputSubmitOnEnter; textareaRef: React.RefObject; value: string; }; const PromptInputContext = React.createContext( null, ); function usePromptInput(part: string): PromptInputContextValue { const context = React.useContext(PromptInputContext); if (!context) throw new Error(`${part} must be rendered inside PromptInput.`); return context; } export type PromptInputProps = Omit< React.ComponentProps<"form">, "defaultValue" | "onSubmit" > & { /** The draft, for a controlled field. The app clears it when it sends. */ value?: string | undefined; /** The draft of an uncontrolled field, which clears itself when it sends. */ defaultValue?: string | undefined; onValueChange?: ((value: string) => void) | undefined; /** * Called when the draft is sent, by Enter or by the button, with the draft * trimmed of the white space around it. Never called while a reply runs or * for a draft that has nothing in it, unless `allowEmpty` is set. */ onSubmit: (message: { text: string }) => void; /** Where the chat is in a turn (the AI SDK's `status`). A running reply turns Send into Stop. */ status?: PromptInputStatus | undefined; /** Called when Stop is pressed. */ onStop?: (() => void) | undefined; /** Which Enter sends. Shift+Enter always adds a line. */ submitOnEnter?: PromptInputSubmitOnEnter | undefined; /** Lets a draft with no text go, for a message that holds only attachments. */ allowEmpty?: boolean | undefined; /** Disables the field, the attach button and Send. */ disabled?: boolean | undefined; /** The surface of the field (the InputGroup's). */ variant?: React.ComponentProps["variant"]; labels?: Partial | undefined; }; /** * The message field of a chat: a form around an InputGroup with a textarea * that grows with the draft, an attachments row above it and a toolbar below. * It sends on Enter, adds a line on Shift+Enter, never sends while an IME * composition is open, and shows Stop while a reply runs. The field stays * editable while a reply runs; only sending is held back. The app owns the * messages, the files and the transport: `onSubmit` hands over the text. */ export function PromptInput({ allowEmpty = false, children, className, defaultValue = "", disabled = false, labels, onStop, onSubmit, onValueChange, status = "ready", submitOnEnter = "always", value, variant, ...props }: PromptInputProps): React.ReactElement { const [uncontrolledDraft, setUncontrolledDraft] = React.useState(defaultValue); const controlled = value !== undefined; const draft = controlled ? value : uncontrolledDraft; const textareaRef = React.useRef(null); const text = { ...defaultPromptInputLabels, ...labels }; const state = submitState(status, draft, allowEmpty); const setValue = (next: string) => { if (!controlled) setUncontrolledDraft(next); onValueChange?.(next); }; const context: PromptInputContextValue = { allowEmpty, disabled, labels: text, onStop, setValue, status, submitOnEnter, textareaRef, value: draft, }; return (
{ // Enter and the button both end here. The form never navigates. event.preventDefault(); sendDraft({ controlled, disabled, draft, // The field keeps focus, so the next message can be typed at once. focus: () => textareaRef.current?.focus({ preventScroll: true }), onSubmit, setValue, state, }); }} > {children}
); } /** The row above the field that holds the attachments: an AttachmentGroup. Render it only while there are files. */ export function PromptInputAttachments({ className, ...props }: Omit< React.ComponentProps, "align" >): React.ReactElement { return ( ); } function assignRef(ref: React.Ref | undefined, value: T | null): void { if (typeof ref === "function") ref(value); else if (ref) (ref as React.RefObject).current = value; } export type PromptInputTextareaProps = Omit< React.ComponentProps, "defaultValue" | "value" >; /** * The field. It grows from one line to eight, then scrolls, and is named * "Message". Enter sends as `submitOnEnter` says, and the draft is the * PromptInput's, so the field takes no `value`. */ export function PromptInputTextarea({ className, disabled, maxLines = 8, minLines = 1, onChange, onKeyDown, ref, ...props }: PromptInputTextareaProps): React.ReactElement { const prompt = usePromptInput("PromptInputTextarea"); const { textareaRef } = prompt; const mergedRef = React.useCallback( (node: HTMLTextAreaElement | null) => { textareaRef.current = node; assignRef(ref, node); }, [ref, textareaRef], ); return ( { onChange?.(event); prompt.setValue(event.target.value); }} onKeyDown={(event) => { onKeyDown?.(event); // The form decides whether this Enter sends, so a draft that cannot // be sent, or a reply that runs, adds no line either. submitOnEnterKey( event, prompt.submitOnEnter, () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(pointer: fine)").matches, ); }} ref={mergedRef} value={prompt.value} /> ); } /** The row below the field. Put two PromptInputToolbarGroups in it: the controls that start the row and the ones that end it. */ export function PromptInputToolbar({ className, ...props }: Omit< React.ComponentProps, "align" >): React.ReactElement { return ( ); } export type PromptInputToolbarGroupProps = React.ComponentProps<"div"> & { /** `end` pushes the group to the end of the row. */ align?: "start" | "end" | undefined; }; export function PromptInputToolbarGroup({ align = "start", className, ...props }: PromptInputToolbarGroupProps): React.ReactElement { return (
); } export type PromptInputAttachButtonProps = Omit< ButtonBaseProps, "onChange" | "type" > & { /** The file types the picker offers, as the `accept` of a file input. */ accept?: string | undefined; /** Whether the picker takes several files. */ multiple?: boolean | undefined; /** Called with the picked files. The app owns them from here. */ onFilesSelected: (files: File[]) => void; }; /** * The button that opens the file picker, with a Paperclip icon and a tooltip. * It is the field action of rulebook I4, the bare glyph in the 24 px target. * The picker is the kit Input of type file, hidden beside it. */ export function PromptInputAttachButton({ accept, children, disabled, multiple = true, onClick, onFilesSelected, ...props }: PromptInputAttachButtonProps): React.ReactElement { const prompt = usePromptInput("PromptInputAttachButton"); const picker = React.useRef(null); return ( <> { onClick?.(event); if (!event.defaultPrevented) picker.current?.click(); }} type="button" /> } > {children ?? } {prompt.labels.attach} {/* The picker waits outside the layout; the button opens it. It stays enabled when the button is not, because a disabled input would dim the whole field. */} ); } type DistributiveOmit = T extends unknown ? Omit : never; export type PromptInputSubmitProps = DistributiveOmit< ButtonProps, "children" | "type" >; /** * Send, or Stop while a reply runs. Send is the filled round button with the * ArrowUp icon and submits the form; it can be pressed once the draft has * something in it. Stop shows a Square, stays pressable and calls `onStop`. * The icons fade through each other (rulebook M8), and the button is never * the Button's `loading`, which would disable it. Any Button prop, such as * `variant`, `shape` or `size`, replaces the default look. */ export function PromptInputSubmit({ className, disabled, onClick, ...props }: PromptInputSubmitProps): React.ReactElement { const prompt = usePromptInput("PromptInputSubmit"); const size = useRelativeControlSize("md"); const state = submitState(prompt.status, prompt.value, prompt.allowEmpty); const stop = state.kind === "stop"; return ( ); } // ~/src/siteplane/components/prompt-input/prompt-input-keys.ts /* * The rules of the Prompt Input that need no DOM: which Enter sends, and what * the submit button is for. Kept apart from the component so they are checked * without a browser; the component calls them from its handlers. */ /** * Where the chat is in a turn, in the words of the AI SDK's chat `status`: * `ready` waits for a message, `submitted` has sent one and waits for the * reply to start, `streaming` receives it and `error` failed, back to a * field that sends again. */ export type PromptInputStatus = "ready" | "submitted" | "streaming" | "error"; /** * Which Enter sends: `always`, `desktop` only with a fine pointer (a mouse or * a trackpad, so a phone's Enter adds a line), `never` (Enter adds a line). */ export type PromptInputSubmitOnEnter = "always" | "desktop" | "never"; /** What the Prompt Input reads of a key press. */ export type PromptInputEnterEvent = { /** True while an IME composition is open (a browser sets it on the keydown). */ isComposing: boolean; key: string; /** Safari reports the key that ends an IME composition as 229, with isComposing already false. */ keyCode: number; shiftKey: boolean; }; /** * Whether this key press sends the message. Shift+Enter adds a line. Nothing * is sent while an IME composition is open, because that Enter confirms a * candidate: `isComposing`, or keyCode 229 for the Enter that ends it in * Safari. `pointerFine` is whether the primary pointer is a mouse or a * trackpad; only `desktop` reads it. */ export function shouldSubmitOnEnter( event: PromptInputEnterEvent, mode: PromptInputSubmitOnEnter, pointerFine: boolean, ): boolean { if (event.key !== "Enter" || event.shiftKey) return false; if (event.isComposing || event.keyCode === 229) return false; if (mode === "never") return false; return mode === "always" || pointerFine; } /** What the submit button does now, and whether it can be pressed. */ export type PromptInputSubmitState = { disabled: boolean; kind: "send" | "stop"; }; /** * Send, or Stop while a reply is on its way (`submitted` or `streaming`). Stop * can always be pressed. Send needs a draft with something in it, white space * not counting, unless `allowEmpty` lets a draft that holds only attachments * go. After an `error` the field sends again. */ export function submitState( status: PromptInputStatus, draft: string, allowEmpty: boolean, ): PromptInputSubmitState { if (status === "submitted" || status === "streaming") return { disabled: false, kind: "stop" }; return { disabled: !allowEmpty && draft.trim() === "", kind: "send" }; } /** * The key event the Prompt Input reads, as a React keyboard event on the * textarea has it: the composition flags live on the native event. */ export type PromptInputKeyDownEvent = { currentTarget: { form: { requestSubmit(): void } | null }; defaultPrevented: boolean; key: string; nativeEvent: { isComposing: boolean; keyCode: number }; preventDefault(): void; shiftKey: boolean; }; /** * What the textarea does on a key press. An Enter that sends is cancelled, so * it adds no line, and the form is asked to submit; the form decides whether * the draft can go. A key that something else already handled is left alone. * `pointerFine` is read only when the mode asks for it. Returns whether the * key was taken. */ export function submitOnEnterKey( event: PromptInputKeyDownEvent, mode: PromptInputSubmitOnEnter, pointerFine: () => boolean, ): boolean { if (event.defaultPrevented) return false; if ( !shouldSubmitOnEnter( { isComposing: event.nativeEvent.isComposing, key: event.key, keyCode: event.nativeEvent.keyCode, shiftKey: event.shiftKey, }, mode, mode === "desktop" && pointerFine(), ) ) return false; event.preventDefault(); event.currentTarget.form?.requestSubmit(); return true; } /** What the form needs to send the draft. */ export type SendDraftOptions = { /** A controlled draft is the app's to clear. */ controlled: boolean; disabled: boolean; draft: string; /** Puts focus back in the field. */ focus: () => void; onSubmit: (message: { text: string }) => void; setValue: (value: string) => void; state: PromptInputSubmitState; }; /** * Sends the draft when it can go: not while the field is disabled, not while a * reply runs and not for a draft that has nothing in it (unless `allowEmpty` * made the state enabled). The text goes out without the white space around * it, an uncontrolled field clears itself, and focus stays in the field. * Returns whether it sent. */ export function sendDraft(options: SendDraftOptions): boolean { const { controlled, disabled, draft, focus, onSubmit, setValue, state } = options; if (disabled || state.kind === "stop" || state.disabled) return false; onSubmit({ text: draft.trim() }); if (!controlled) setValue(""); focus(); return true; } // ~/src/siteplane/components/prompt-input/prompt-input.css @layer theme, base, components, siteplane, utilities; @layer siteplane { /* The toolbar under the field holds two groups: the controls that start the * row and the ones that end it. A group may shrink below its controls, and * the end group sits at the end of the row also when the start group is * absent or the row wraps. The InputGroup's block addon owns the row's * padding and gap. */ .sp-prompt-input-toolbar-group { display: flex; min-width: 0; align-items: center; gap: var(--sp-rw-space-2); } .sp-prompt-input-toolbar-group[data-align="end"] { margin-inline-start: auto; } /* The attachments row shrinks with the field, so a row of cards scrolls * inside it instead of widening the field. */ .sp-prompt-input-attachments { min-width: 0; } } // ~/src/siteplane/components/prompt-input/index.ts export * from "./prompt-input";