For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
0
Sponsor

Prompt Input

The box a message is written in, assembled from parts: a field that grows and then scrolls, the files riding with the draft as chips above it, the model answering it, and the one button that ends the turn.

raven.jpg
34%

brand-assets.zip

"use client";

import { useEffect, useRef, useState } from "react";

Installation

$ pnpm dlx shadcn@latest add https://motif-ui.vercel.app/r/prompt-input.json

Pulls in attachment for the chips in the header, plus dropdown-menu for the model picker.

Usage

The root owns the state and draws the card; everything in it is a part. This is the whole frame in one place — a header that holds chips, the field, and a footer with an attach button, a model picker and submit.

import { useState } from "react";
 
import {
  ModelPicker,
  PromptInput,
  PromptInputAttach,
  PromptInputAttachment,
  PromptInputFooter,
  PromptInputHeader,
  PromptInputSubmit,
  PromptInputTextarea,
  type PromptAttachment,
  type PromptModel,
} from "@/components/agents/prompt-input";
 
const MODELS: PromptModel[] = [
  { id: "gpt-5.2", name: "GPT-5.2", provider: "openai" },
  { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", provider: "anthropic" },
  { id: "gemini-3-pro", name: "Gemini 3 Pro", provider: "gemini" },
];
 
export function Composer() {
  const [model, setModel] = useState(MODELS[0].id);
  const [attachments, setAttachments] = useState<PromptAttachment[]>([]);
 
  return (
    <PromptInput onSubmit={(message) => send({ message, model })}>
      <PromptInputHeader>
        {attachments.map(({ id, ...chip }) => (
          <PromptInputAttachment
            key={id}
            {...chip}
            onRemove={() =>
              setAttachments((current) =>
                current.filter((file) => file.id !== id)
              )
            }
          />
        ))}
      </PromptInputHeader>
 
      <PromptInputTextarea placeholder="Review the current implementation and suggest the next improvement." />
 
      <PromptInputFooter>
        <PromptInputAttach onFiles={(files) => upload(files, setAttachments)} />
        <ModelPicker models={MODELS} onValueChange={setModel} value={model} />
        <PromptInputSubmit />
      </PromptInputFooter>
    </PromptInput>
  );
}

The draft is the composer's own until you pass value, and a send clears it. A controlled draft is never cleared for you — onValueChange reports every keystroke and onSubmit hands over the trimmed text, so the caller decides where the sentence goes next.

The frame is the frame

A composer is where a surface grows its own conventions. A tools menu, a microphone, a quoted reply, a token counter, a composer in a thread with no attach button — every one of those is an arrangement of these parts, and none of them is a prop the root has to learn about first. So the root takes the message and nothing else.

PartWhat it is
PromptInputThe root: the card, the draft, the submit contract
PromptInputHeaderFlex row above the field; draws nothing while it holds nothing
PromptInputTextareaThe field, with the growth cap, the two-line floor and the Enter binding
PromptInputFooterFlex row under the field
PromptInputAttachmentA chip in the header: the attachment card plus its entrance
PromptInputButtonA round control for the row: the platform Button at size-10 rounded-full
PromptInputAttachThe plus, and the file dialog behind it
PromptInputSubmitSend, or stop while streaming; takes the trailing edge of the row
ModelPickerWhich model answers; standalone, and usable outside a composer

PromptInputHeader, PromptInputFooter and PromptInputAttachment take no composer state — they are ordinary elements named after where they sit. PromptInputTextarea, PromptInputAttach and PromptInputSubmit read the root from context, so they must be inside one.

Two of the parts are other components rather than new ones: the chip is attachment, and the round controls in the row are the platform button at one size — hover, focus ring, disabled treatment and sound/haptic feedback all come from it, so PromptInputButton takes ButtonProps with the geometry already applied. The field is the one piece with no base to build on: the platform ships no textarea, and the growth cap, the two-line floor and the Enter binding are the composer's own.

Nothing here is drawn that cannot act: PromptInputAttach without onFiles renders nothing, a header with no children renders nothing, and a submit button with an empty draft is disabled rather than hidden — the send button is the one fixed point of the row, and a row where it comes and goes is a row you have to re-find.

The field grows, then scrolls

The textarea is measured against its content on every change and capped at maxHeight (200px by default, four lines of text-base and a wrap). Past the cap it scrolls, and it never shrinks below two lines — one line of text in a box this wide reads as a single-line input, which is not what this is.

Both halves of that matter. A composer is anchored to the bottom of the window in every app that has one, so a draft that grows the card without a ceiling ends up being a draft whose beginning is off-screen. And a field that scrolls while it is still short is a field that hides a line for no reason, which is why the size is measured rather than fixed.

Enter sends

Enter submits, Shift+Enter breaks the line. A composer is typed into and then sent far more often than it is asked to hold a second line, and the reverse binding is right for a form, not for this one.

A composition in progress is left alone: an IME's Enter commits the candidate under it, and sending on that key would eat the word the user was still assembling.

Attachments ride in the header

Files sit above the field as the same chips used everywhere else — the same component, the same size, the same ring while an upload is in flight. They are what the message is about, so they go on the near side of the thing that says what the message says. In the footer they would have to share a line with controls that are deliberately one fixed size, and a row of 56px chips would set that size for everything.

PromptInputAttachment is the chip with one thing added: it fades in. Chips land one at a time and the row reflows around them, so a chip that appears whole is a tear in the row. Everything else is the chip's own props — name, src for a preview, progress while an upload is in flight, onRemove to make it removable.

PromptInputAttach is what makes the plus real: the composer opens the file dialog and hands over the Files as onFiles. Whatever you do with them — upload them, read them, keep the last one — ends in the same place: new chips in the header with progress running from 0 to 100.

Models

The picker is one mark, one name and one chevron. The mark is the loud part because it is the part read at a glance; the name sits in the muted tone the rest of the row uses, so the row has one subject instead of two. The menu opens upward — a composer lives at the bottom of the window, and a menu that unfolds over the message you are about to send is a menu covering the thing you are deciding about.

models takes { id, name, provider }. value and onValueChange make the pick controlled; on its own the picker holds it, starting at defaultValue or the first model.

ProviderMark draws LobeHub's icon set (MIT) in its coloured cut, and it lives in Provider Mark rather than here — the marks are the same artwork a browsing picker needs, so they ship once and both components depend on them. This item re-exports ProviderMark and PROVIDER_NAMES for convenience, and shadcn add brings the marks along with it.

import { ProviderMark } from "@/components/ui/provider-mark";
 
<ProviderMark className="size-5 text-foreground" provider="anthropic" />;

The classes still matter for the marks whose brand is monochrome — OpenAI, Anthropic, xAI, Groq and the rest take the tone of the text they sit in; the coloured ones ignore it.

PROVIDER_NAMES is exported beside it for the times the mark is not enough — a menu that has to say "Anthropic" somewhere.

While an answer is arriving

Pass streaming and submit becomes stop — one button with two jobs, because while an answer is streaming the only thing worth doing to the composer is stopping it, and a second button beside send would make the reader choose between two live actions on every frame. Enter cannot send while it is true. onStop is called when it is pressed; flipping streaming back to false is what returns the arrow.

"use client";

import { useEffect, useRef, useState } from "react";

Props

PromptInput

PropDefaultMeaning
value—Controlled draft, never cleared by a send
defaultValue""Draft the composer starts with
onValueChange—Called with the draft on every change
onSubmit—Called with the trimmed draft; Enter or submit
streamingfalseSwaps the submit part's arrow for stop, and blocks Enter
onStop—Called when the stop face is pressed
disabledfalseDisables the field and the submit button
className—Extra classes for the card

Anything else goes to the underlying form.

Parts

PartProps
PromptInputTextareamaxHeight, plus textarea props; onKeyDown runs before the binding
PromptInputAttachmentAttachmentProps — name, src, progress, status, icon, onRemove
PromptInputAttachonFiles, accept, plus ButtonProps
PromptInputSubmitButtonProps; className lands on the button
PromptInputButtonButtonProps — variant, size, sound, haptic; geometry fixed
PromptInputHeader / PromptInputFooterdiv props
ModelPickermodels, value, defaultValue, onValueChange, className

Component source

prompt-input/index.tsx
"use client";

import type { FormEvent } from "react";
import { useCallback, useMemo, useRef, useState } from "react";

import { cn } from "@/lib/utils";

import { PromptInputContext } from "./context";
import type { PromptInputState } from "./context";
import type { PromptInputProps } from "./types";

export {
  PromptInputAttach,
  PromptInputAttachment,
  PromptInputButton,
  PromptInputFooter,
  PromptInputHeader,
  PromptInputSubmit,
  PromptInputTextarea,
} from "./parts";
export { ModelPicker } from "./model-picker";
// Re-exported so a composer that hands its own model list over does not have to reach
// for a second import path to draw the marks on it.
export { PROVIDER_NAMES, ProviderMark } from "@/components/ui/provider-mark";
export type {
  ModelPickerProps,
  PromptAttachment,
  PromptInputAttachProps,
  PromptInputButtonProps,
  PromptInputProps,
  PromptInputTextareaProps,
  PromptModel,
} from "./types";
export type {
  ProviderId,
  ProviderMarkProps,
} from "@/components/ui/provider-mark";

/* -- The composer --------------------------------------------------------------
 * Everything an agent surface asks for passes through one box, so the box is mostly the
 * sentence: the field owns the middle, and the row under it holds only the things a
 * message can carry besides its text — what rides with it, and who answers it — plus the
 * one action that ends the turn.
 *
 * The root owns the state and draws nothing but the card. What goes in it is assembled
 * from parts: `PromptInputHeader`, `PromptInputTextarea`, `PromptInputFooter`, and
 * whatever is dropped into them. A composer is where a surface grows its own
 * conventions — a tools menu, a microphone, a quoted reply, no attach button at all —
 * and every one of those is an arrangement of these pieces rather than a prop this
 * component has to learn about first.
 *
 * Four decisions the parts inherit:
 *
 *  - Enter sends, Shift+Enter breaks the line. A composer is typed into and then sent
 *    far more often than it is made to hold a second line; the reverse is right for a
 *    form, not for this. The composition guard matters as much as the binding: an IME's
 *    Enter commits the candidate under it, and sending on that key would eat the word
 *    the user was still assembling.
 *
 *  - The field grows to its content and then scrolls, instead of pushing the card
 *    taller, and it never shrinks below two lines.
 *
 *  - Nothing is drawn that cannot do anything: no attach button without `onFiles`, no
 *    header without chips. A dead control is worse than a missing one — it costs a click
 *    to find out, and it teaches the user that this surface has parts that lie.
 *
 *  - The draft is the composer's own until `value` is passed, and a send clears it. A
 *    controlled draft belongs to the caller, who knows where the sentence goes next.
 * --------------------------------------------------------------------------- */

export const PromptInput = ({
  children,
  className,
  defaultValue,
  disabled = false,
  onStop,
  onSubmit,
  onValueChange,
  streaming = false,
  value,
  ...props
}: PromptInputProps) => {
  const [draft, setDraft] = useState(defaultValue ?? "");
  const field = useRef<HTMLTextAreaElement>(null);

  // Controlled when the caller passes `value`; otherwise the root keeps the draft and
  // clears it after a send.
  const controlled = value !== undefined;
  const text = controlled ? value : draft;

  const setValue = useCallback(
    (next: string) => {
      if (!controlled) {
        setDraft(next);
      }
      onValueChange?.(next);
    },
    [controlled, onValueChange]
  );

  const submit = useCallback(() => {
    const message = text.trim();
    if (message.length === 0 || disabled || streaming) {
      return;
    }
    onSubmit?.(message);
    if (!controlled) {
      setValue("");
    }
    field.current?.focus();
  }, [controlled, disabled, onSubmit, setValue, streaming, text]);

  const state = useMemo<PromptInputState>(
    () => ({
      canSend: text.trim().length > 0 && !disabled,
      disabled,
      field,
      setValue,
      stop: onStop,
      streaming,
      submit,
      value: text,
    }),
    [disabled, field, onStop, setValue, streaming, submit, text]
  );

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    submit();
  };

  return (
    <PromptInputContext.Provider value={state}>
      <form
        className={cn(
          "flex w-full max-w-2xl flex-col rounded-3xl border border-border bg-card p-2.5 transition-colors duration-150 ease-out focus-within:border-ring/50",
          disabled && "opacity-60",
          className
        )}
        onSubmit={handleSubmit}
        {...props}
      >
        {children}
      </form>
    </PromptInputContext.Provider>
  );
};
parts.tsx
"use client";

import { ArrowUp, Plus, Square } from "lucide-react";
import type { ChangeEvent, ComponentProps, KeyboardEvent } from "react";
import { Children, useLayoutEffect, useRef } from "react";

import type { AttachmentProps } from "@/components/ui/attachment";
import { Attachment } from "@/components/ui/attachment";
import type { ButtonProps } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

import { usePromptInput } from "./context";
import type {
  PromptInputAttachProps,
  PromptInputButtonProps,
  PromptInputTextareaProps,
} from "./types";

/* -- The parts -----------------------------------------------------------------
 * A composer is four decisions stacked — what rides with the message, what it says,
 * what it can be sent with, and how it ends — and each one is a part, so a caller can
 * take the stack apart. A tools menu, a microphone, a quote of the message being
 * answered, a composer with no attach button: all of them are a different arrangement
 * of the same pieces rather than a prop this file has to know about.
 *
 * The parts that need the root's state read it from context and the rest take no
 * context at all, so `PromptInputHeader` and `PromptInputFooter` are ordinary flex rows
 * that happen to be named after where they sit.
 * --------------------------------------------------------------------------- */

/** How tall the field may grow before it scrolls: four lines of `text-base` and a wrap.
 *  Enough for a paragraph, short of half the window. */
const MAX_FIELD_HEIGHT = 200;

/** The field holds at least two lines even when it is empty. One line of text in a box
 *  this wide reads as a single-line input, which is not what this is — the second line is
 *  the room the draft is visibly allowed to grow into. */
const MIN_ROWS = 2;

/** The footer's round controls are the platform button at one size, so the row reads as
 *  one row rather than three buttons that happen to be next to each other. Everything
 *  else — hover, focus ring, the disabled treatment — is the button's own. */
const ROUND_CONTROL = "size-10 shrink-0 rounded-full";

/**
 * The row above the field: what rides with the message. For attachments, quotes of the
 * message being answered, a warning about the draft — anything that is *about* the text
 * rather than part of it.
 *
 * It draws nothing when it holds nothing, so a composer that always renders its header
 * does not pay for one when there is nothing to put in it.
 */
export const PromptInputHeader = ({
  children,
  className,
  ...props
}: ComponentProps<"div">) =>
  Children.toArray(children).length === 0 ? null : (
    <div className={cn("flex flex-wrap gap-2.5 p-1.5", className)} {...props}>
      {children}
    </div>
  );

/**
 * A chip in the header: the attachment card plus the one thing the header adds — it
 * fades in. Chips land one at a time and the row reflows around them, so a chip that
 * appears whole is a tear in the row; the fade is the difference, and it is short enough
 * that a picked file still feels like it landed instantly.
 *
 * The fade is a CSS animation, not a motion one, and that is the point: chips already in
 * the draft when the page loads are in the markup at full opacity, so reopening a thread
 * with five attachments does not replay their arrival, and no inline `opacity: 0` is left
 * waiting on hydration for a script that never runs. It is skipped outright under
 * `prefers-reduced-motion`.
 *
 * Props are the chip's own, so `onRemove` is what makes it removable and `progress`
 * under 100 is what draws the upload ring.
 */
export const PromptInputAttachment = ({
  className,
  ...props
}: AttachmentProps) => (
  <Attachment
    className={cn(
      "animate-in fade-in zoom-in-95 duration-150 motion-reduce:animate-none",
      className
    )}
    {...props}
  />
);

/**
 * The field. It grows to its content and then scrolls instead of pushing the card
 * taller — a composer is anchored to the bottom of the window in every app that has one,
 * and a draft long enough to fill the viewport would otherwise be a draft you cannot see
 * the end of.
 *
 * Enter sends, Shift+Enter breaks the line, and a composition in progress is left alone:
 * an IME's Enter commits the candidate under it, and sending on that key would eat the
 * word the user was still assembling.
 */
export const PromptInputTextarea = ({
  className,
  maxHeight = MAX_FIELD_HEIGHT,
  onKeyDown,
  rows,
  style,
  ...props
}: PromptInputTextareaProps) => {
  const { disabled, field, setValue, submit, value } = usePromptInput();
  // Two empty lines in px, measured once off the field itself so it stays true if the
  // type scale changes.
  const floor = useRef(0);

  useLayoutEffect(() => {
    const node = field.current;
    if (!node) {
      return;
    }
    if (floor.current === 0) {
      const styles = window.getComputedStyle(node);
      const frame =
        Number.parseFloat(styles.paddingTop) +
        Number.parseFloat(styles.paddingBottom);
      floor.current = Number.parseFloat(styles.lineHeight) * MIN_ROWS + frame;
    }
    // `auto` first: a field sitting at its old height cannot report a shorter
    // `scrollHeight`, because its own padding box counts as overflow.
    node.style.height = "auto";
    const wanted = Math.max(node.scrollHeight, floor.current);
    node.style.height = `${Math.min(wanted, maxHeight)}px`;
    node.style.overflowY = node.scrollHeight > maxHeight ? "auto" : "hidden";
  }, [field, maxHeight, value]);

  const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
    onKeyDown?.(event);
    if (
      event.defaultPrevented ||
      event.key !== "Enter" ||
      event.shiftKey ||
      event.nativeEvent.isComposing
    ) {
      return;
    }
    event.preventDefault();
    submit();
  };

  return (
    <textarea
      {...props}
      className={cn(
        "w-full resize-none bg-transparent px-2 py-2.5 text-base leading-relaxed outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
        className
      )}
      disabled={disabled}
      onChange={(event) => setValue(event.target.value)}
      onKeyDown={handleKeyDown}
      ref={field}
      rows={rows ?? MIN_ROWS}
      style={{ maxHeight, ...style }}
      value={value}
    />
  );
};

/** The row under the field: what the message can be sent with, and the button that sends
 *  it. */
export const PromptInputFooter = ({
  className,
  ...props
}: ComponentProps<"div">) => (
  <div className={cn("flex items-center gap-1", className)} {...props} />
);

/** A round control in the footer: the platform `Button` at 40px round, with every
 *  variant, size and feedback prop it already has left intact. */
export const PromptInputButton = ({
  className,
  size = "icon",
  variant = "ghost",
  ...props
}: PromptInputButtonProps) => (
  <Button
    className={cn(ROUND_CONTROL, className)}
    size={size}
    variant={variant}
    {...props}
  />
);

/**
 * The plus: the composer's own file input, opened by a button rather than drawn. It
 * resets the input after a pick, so choosing the same file twice in a row counts twice,
 * and hands focus back to the field, because the next thing the user does is type.
 */
export const PromptInputAttach = ({
  accept,
  onFiles,
  ...props
}: PromptInputAttachProps) => {
  const { field } = usePromptInput();
  const input = useRef<HTMLInputElement>(null);

  if (!onFiles) {
    return null;
  }

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    const chosen = [...(event.target.files ?? [])];
    if (chosen.length > 0) {
      onFiles(chosen);
    }
    event.target.value = "";
    field.current?.focus();
  };

  return (
    <>
      <PromptInputButton
        aria-label="Attach files"
        onClick={() => input.current?.click()}
        type="button"
        {...props}
      >
        <Plus className="size-5" strokeWidth={1.75} />
      </PromptInputButton>
      <input
        accept={accept}
        className="hidden"
        multiple
        onChange={handleChange}
        ref={input}
        type="file"
      />
    </>
  );
};

/**
 * The one button that ends the turn, taking the trailing edge of the row. While an
 * answer is arriving it becomes stop: the only thing worth doing to the composer then is
 * stopping it, and a second button beside send would make the reader choose between two
 * live actions on every frame.
 *
 * Under `streaming` there is nothing to send, so the arrow is disabled until there is.
 */
export const PromptInputSubmit = ({ className, ...props }: ButtonProps) => {
  const { canSend, stop, streaming } = usePromptInput();

  if (streaming) {
    return (
      <PromptInputButton
        aria-label="Stop"
        className={cn("ml-auto", className)}
        onClick={stop}
        type="button"
        variant="default"
        {...props}
      >
        <Square className="size-3.5 fill-current" strokeWidth={0} />
      </PromptInputButton>
    );
  }

  return (
    <PromptInputButton
      aria-label="Send message"
      className={cn("ml-auto", className)}
      disabled={!canSend}
      type="submit"
      variant="default"
      {...props}
    >
      <ArrowUp className="size-5" strokeWidth={2.25} />
    </PromptInputButton>
  );
};
model-picker.tsx
"use client";

import { ChevronDown } from "lucide-react";
import { useState } from "react";

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { PROVIDER_NAMES, ProviderMark } from "@/components/ui/provider-mark";
import { cn } from "@/lib/utils";

import type { ModelPickerProps } from "./types";

/* -- Which model answers -------------------------------------------------------
 * One mark, one name, one chevron. The mark is the loud part because it is the part read
 * at a glance; the name sits in the muted tone the rest of the composer's row uses, so
 * the row has one subject instead of two.
 *
 * The menu opens upward: a composer lives at the bottom of the window, and a menu that
 * unfolds over the message you are about to send is a menu covering the thing you are
 * deciding about. Radix flips it on its own if there is no room above.
 *
 * The pick is the picker's own until `value` is passed, like every other
 * value/defaultValue pair here: a composer that only needs to know which model to call
 * should not have to hold the answer in the page that renders it.
 * --------------------------------------------------------------------------- */

export const ModelPicker = ({
  className,
  defaultValue,
  models,
  onValueChange,
  value,
}: ModelPickerProps) => {
  const [picked, setPicked] = useState(defaultValue ?? models[0]?.id);
  const controlled = value !== undefined;
  const activeId = controlled ? value : picked;

  const active = models.find((model) => model.id === activeId) ?? models[0];

  if (!active) {
    return null;
  }

  const pick = (id: string) => {
    if (!controlled) {
      setPicked(id);
    }
    onValueChange?.(id);
  };

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <button
          aria-label={`Model: ${active.name}`}
          className={cn(
            "flex h-10 max-w-52 shrink-0 cursor-pointer items-center gap-2 rounded-full pr-2 pl-2.5 outline-none transition-colors duration-150 ease-out hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-accent",
            className
          )}
          type="button"
        >
          <ProviderMark
            className="size-5 text-foreground"
            provider={active.provider}
          />
          {/* The name is the part that changes, so it is the part that truncates. */}
          <span className="truncate text-sm text-muted-foreground">
            {active.name}
          </span>
          <ChevronDown className="size-4 shrink-0 text-muted-foreground/60" />
        </button>
      </DropdownMenuTrigger>

      <DropdownMenuContent align="start" className="min-w-56" side="top">
        <DropdownMenuRadioGroup onValueChange={pick} value={active.id}>
          {models.map((model) => (
            <DropdownMenuRadioItem
              className="gap-2.5 py-2"
              key={model.id}
              value={model.id}
            >
              <ProviderMark
                className="size-4.5 text-foreground"
                provider={model.provider}
              />
              <span className="truncate">{model.name}</span>
              {/* The maker, on the far side, where it answers a question the name
                  alone leaves open: two vendors ship a model called `Pro`. */}
              <span className="ml-auto pl-4 text-muted-foreground text-xs">
                {PROVIDER_NAMES[model.provider]}
              </span>
            </DropdownMenuRadioItem>
          ))}
        </DropdownMenuRadioGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  );
};
context.ts
"use client";

import type { RefObject } from "react";
import { createContext, useContext } from "react";

/**
 * What a part needs from the root to do its job: the draft, the door out of it, and the
 * field — so a part that takes a click can hand focus back to the place the user was
 * typing. The parts that only lay out (the header, the footer, a plain button chip) take
 * no context and can be used anywhere.
 */
export interface PromptInputState {
  /** There is something to send and the composer is not disabled. */
  canSend: boolean;
  disabled: boolean;
  field: RefObject<HTMLTextAreaElement | null>;
  setValue: (value: string) => void;
  /** The composer's `onStop`, for the submit part's stop face. */
  stop?: () => void;
  streaming: boolean;
  submit: () => void;
  value: string;
}

export const PromptInputContext = createContext<PromptInputState | null>(null);

export const usePromptInput = () => {
  const context = useContext(PromptInputContext);
  if (!context) {
    throw new Error("PromptInput parts must be used within a PromptInput.");
  }

  return context;
};
types.ts
import type { ComponentProps } from "react";

import type { AttachmentProps } from "@/components/ui/attachment";
import type { ButtonProps } from "@/components/ui/button";
import type { ProviderId } from "@/components/ui/provider-mark";

/**
 * A file riding along with the draft, the way a composer usually holds them: the chip's
 * own props plus an `id` for the key. Convenience only — `PromptInputAttachment` takes
 * `AttachmentProps`, so a caller with their own shape can spread it in instead.
 */
export type PromptAttachment = { id: string } & Omit<
  AttachmentProps,
  "className" | "icon" | "onRemove"
>;

/** One model the composer can be pointed at. */
export interface PromptModel {
  /** What `value` and `onValueChange` trade in. */
  id: string;
  name: string;
  provider: ProviderId;
}

export interface ModelPickerProps {
  className?: string;
  /** Model the picker opens on when it holds the choice itself. */
  defaultValue?: string;
  models: PromptModel[];
  onValueChange?: (id: string) => void;
  /** Controlled model id. */
  value?: string;
}

/** The root: the state of the message, and the door out of it. */
export interface PromptInputProps extends Omit<
  ComponentProps<"form">,
  "onSubmit"
> {
  /** Draft the composer starts with. Ignored under a controlled `value`. */
  defaultValue?: string;
  disabled?: boolean;
  /** Pairs with `streaming`: what the submit part's stop face calls. */
  onStop?: () => void;
  /** Called with the trimmed draft — Enter or the submit part. */
  onSubmit?: (value: string) => void;
  onValueChange?: (value: string) => void;
  /** Swaps the submit part's arrow for a stop square, and blocks Enter. */
  streaming?: boolean;
  /** Controlled draft. A controlled draft is never cleared by a send. */
  value?: string;
}

export interface PromptInputTextareaProps extends Omit<
  ComponentProps<"textarea">,
  "onChange" | "value"
> {
  /**
   * The field's own props, plus the cap. It is the composer's own primitive: no textarea
   * base exists to build on, and the growth cap, the two-line floor and the Enter binding
   * are the composer's, not the platform's.
   */
  maxHeight?: number;
}

/**
 * The platform button's own props — `variant`, `size`, `sound`, `haptic` — with the row's
 * geometry already applied on top.
 */
export type PromptInputButtonProps = ButtonProps;

export interface PromptInputAttachProps extends Omit<ButtonProps, "onClick"> {
  /** Offered to the file picker, e.g. `"image/*,.pdf"`. */
  accept?: string;
  /**
   * Makes the part real: the composer opens the file dialog and hands the files over.
   * Omit it and nothing is drawn — a composer with nowhere to attach should not offer
   * to attach.
   */
  onFiles?: (files: File[]) => void;
}