Prompt input
AIThe composer for AI chat: an autosizing textarea with attachments, tools and a send button that turns into a stop button while the model answers.
npx prodular-ui add prompt-inputAssistant thread
Everything together: question, reasoning, tool calls, a streamed answer with citations, actions, follow-ups and the composer. Press Replay to stream it again, or stop it halfway.
Copilot can make mistakes. Check figures before you share them.
"use client"
import * as React from "react"import { ChevronDownIcon, DatabaseIcon, GitPullRequestIcon, GlobeIcon, MicIcon, PaperclipIcon, RefreshCwIcon, RotateCcwIcon, Share2Icon,} from "lucide-react"
import { AiMark } from "@/components/ui/ai-mark"import { Bubble, BubbleContent } from "@/components/ui/bubble"import { Button } from "@/components/ui/button"import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu"import { Marker, MarkerContent } from "@/components/ui/marker"import { Message, MessageContent, MessageHeader, MessageMeta } from "@/components/ui/message"import { MessageAction, MessageActionCopy, MessageActionFeedback, MessageActions, MessageVersions,} from "@/components/ui/message-actions"import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport,} from "@/components/ui/message-scroller"import { PromptInput, PromptInputActions, PromptInputButton, PromptInputSubmit, PromptInputTextarea, PromptInputToggle, PromptInputToolbar, PromptInputTools, type PromptInputStatus,} from "@/components/ui/prompt-input"import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ui/reasoning"import { Response } from "@/components/ui/response"import { Source, Sources, SourcesContent, SourcesTrigger, type SourceData,} from "@/components/ui/sources"import { Suggestion, Suggestions } from "@/components/ui/suggestions"import { ToolCall, ToolCallContent, ToolCallHeader, ToolCallIcon, ToolCallSection, ToolCallStatus, ToolCallSummary, ToolCallTitle, type ToolCallState,} from "@/components/ui/tool-call"
const QUESTION = "Why did our cloud spend jump in September?"
const REASONING = "They want the cause of a month over month jump, so I need September against August by service. If one service carries most of the change, I should look for an event that lines up with it: an expired commitment, a scaling change or a retention change. Then check the change log for those dates before I explain anything."
const ANSWER = `Cloud spend rose **$49,475 (27%)** in September, from $182,440 to $231,915. Three changes explain 95% of it, and the largest is one expired commitment.
| Service | August | September | Change || --- | ---: | ---: | ---: || Amazon EC2 | $96,210 | $127,490 | +$31,280 || Amazon S3 | $28,930 | $38,800 | +$9,870 || BigQuery | $19,400 | $25,525 | +$6,125 || Everything else | $37,900 | $40,100 | +$2,200 |
**What drove it**
1. **Savings Plan SP-2291 expired on September 4.** About $22,600 of EC2 usage moved from the committed rate to on demand pricing [1].2. **Data scaled ingest workers from 24 to 48 nodes** on September 9 for the Q3 backfill, adding roughly $8,700 [2].3. **S3 storage grew 31%** after a Terraform change removed the 90 day lifecycle rule on \`events-raw\` [3].
**What I would do next**
- Renew the Savings Plan at the August baseline. At September usage that saves about $19,000 a month.- Ask Data whether the backfill is finished, then scale ingest back to 24 nodes.- Restore the lifecycle rule on \`events-raw\` once compliance signs off on retention.`
const SOURCES: SourceData[] = [ { title: "Savings Plan SP-2291 expiration notice", url: "https://console.aws.amazon.com/billing/home#/savingsplans", siteName: "AWS Billing", snippet: "Your 1 year Compute Savings Plan SP-2291 ($31.50 per hour) expires on September 4, 2026. Usage after this date is billed at On-Demand rates.", }, { title: "Q3 backfill: scale ingest workers to 48 nodes", url: "https://linear.app/ledgerline/issue/DATA-1184", siteName: "Linear", snippet: "Doubling ingest capacity until the historical backfill finishes. Expected to run through the end of September.", }, { title: "Drop expiration on events-raw while retention is reviewed", url: "https://github.com/ledgerline/infra/pull/2207", siteName: "GitHub", snippet: "Removes the 90 day lifecycle rule from events-raw. Compliance needs 13 months of raw events for the SOC 2 audit window.", }, { title: "Cost and usage report, September 2026", url: "https://console.aws.amazon.com/cost-management/home", siteName: "Cost Explorer", }, { title: "BigQuery billing export, September 2026", url: "https://console.cloud.google.com/billing/reports", siteName: "Google Cloud", }, { title: "FinOps monthly review, August", url: "https://www.notion.so/ledgerline/finops-august", siteName: "Notion", },]
const FOLLOW_UPS = [ "Draft a Savings Plan renewal for approval", "Show daily EC2 spend for September", "Which teams own the biggest increases?",]
type Phase = "submitting" | "thinking" | "tools" | "answering" | "done" | "stopped"
const tokenize = (text: string) => text.match(/\S+\s*/g) ?? []const REASONING_TOKENS = tokenize(REASONING)const ANSWER_TOKENS = tokenize(ANSWER)
/** Without autoplay the first run starts finished, and only Replay plays it. */function useScenario(run: number, autoplay: boolean) { const still = !autoplay && run === 0 const [phase, setPhase] = React.useState<Phase>(still ? "done" : "submitting") const [reasoningCount, setReasoningCount] = React.useState(still ? REASONING_TOKENS.length : 0) const [answerCount, setAnswerCount] = React.useState(still ? ANSWER_TOKENS.length : 0) const [tools, setTools] = React.useState<[ToolCallState, ToolCallState]>(still ? ["done", "done"] : ["pending", "pending"]) const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])
const clear = React.useCallback(() => { timers.current.forEach((t) => { clearTimeout(t) clearInterval(t) }) timers.current = [] }, [])
React.useEffect(() => { if (still) return const at = (ms: number, fn: () => void) => timers.current.push(setTimeout(fn, ms)) const stream = (total: number, every: number, step: (n: number) => void, done: () => void) => { let n = 0 const id = setInterval(() => { n = Math.min(total, n + 1 + (n % 3 === 0 ? 1 : 0)) step(n) if (n >= total) { clearInterval(id) done() } }, every) timers.current.push(id) }
at(600, () => { setPhase("thinking") stream(REASONING_TOKENS.length, 45, setReasoningCount, () => { at(300, () => { setPhase("tools") setTools(["running", "pending"]) at(1400, () => { setTools(["done", "running"]) at(900, () => { setTools(["done", "done"]) setPhase("answering") stream(ANSWER_TOKENS.length, 40, setAnswerCount, () => setPhase("done")) }) }) }) }) }) return clear }, [run, still, clear])
const reset = React.useCallback(() => { clear() setPhase("submitting") setReasoningCount(0) setAnswerCount(0) setTools(["pending", "pending"]) }, [clear])
const stop = React.useCallback(() => { clear() setPhase("stopped") setTools(([a, b]) => [a === "running" ? "error" : a, b === "running" ? "error" : b]) }, [clear])
return { phase, reasoningCount, answerCount, tools, stop, reset }}
export default function PromptInputChat({ autoplay = true }: { autoplay?: boolean }) { const [run, setRun] = React.useState(0) const [question, setQuestion] = React.useState(QUESTION) const [draft, setDraft] = React.useState("") const [model, setModel] = React.useState("Claude Opus 5.5") const [version, setVersion] = React.useState(2) const { phase, reasoningCount, answerCount, tools, stop, reset } = useScenario(run, autoplay)
const status: PromptInputStatus = phase === "submitting" ? "submitting" : phase === "done" || phase === "stopped" ? "idle" : "streaming" const thinking = phase === "thinking" || phase === "submitting" const answerText = ANSWER_TOKENS.slice(0, answerCount).join("") const answering = phase === "answering" const finished = phase === "done"
const restart = (text = QUESTION) => { reset() setQuestion(text) setRun((r) => r + 1) }
return ( <div className="flex h-140 w-full min-w-0 flex-col overflow-hidden rounded-xl border bg-background"> <div className="flex h-12 shrink-0 items-center gap-2 border-b px-4"> <AiMark /> <span className="text-sm font-medium">Ledgerline Copilot</span> <span className="truncate text-[13px] text-muted-foreground">September cloud spend</span> <Button variant="ghost" size="sm" className="ms-auto" onClick={() => restart()}> <RotateCcwIcon data-icon="inline-start" /> Replay </Button> </div>
<MessageScrollerProvider autoScroll> <MessageScroller className="min-h-0 flex-1"> <MessageScrollerViewport> <MessageScrollerContent className="mx-auto w-full max-w-2xl gap-8 px-4 pt-6 pb-8"> <MessageScrollerItem messageId={`q-${run}`} scrollAnchor> <Message from="user"> <MessageContent> <Bubble align="end"> <BubbleContent>{question}</BubbleContent> </Bubble> </MessageContent> </Message> </MessageScrollerItem>
<MessageScrollerItem messageId={`a-${run}`}> <Message from="assistant"> <MessageContent className="gap-4"> <MessageHeader> <AiMark animated={status === "streaming"} model={`Generated by ${model}`} /> Ledgerline Copilot <MessageMeta>{model}</MessageMeta> </MessageHeader>
{phase !== "submitting" && ( <Reasoning streaming={thinking}> <ReasoningTrigger /> <ReasoningContent> {REASONING_TOKENS.slice(0, reasoningCount).join("")} </ReasoningContent> </Reasoning> )}
{tools[0] !== "pending" && ( <div className="flex animate-rise flex-col gap-2 motion-reduce:animate-none"> <ToolCall status={tools[0]}> <ToolCallHeader> <ToolCallIcon> <DatabaseIcon /> </ToolCallIcon> <ToolCallTitle>Query cloud costs</ToolCallTitle> <ToolCallSummary>AWS and GCP, Aug 1 to Sep 30, by service</ToolCallSummary> <ToolCallStatus duration="1.4s"> {tools[0] === "error" ? "Stopped" : undefined} </ToolCallStatus> </ToolCallHeader> <ToolCallContent> <ToolCallSection label="Input" value={{ providers: ["aws", "gcp"], period: { from: "2026-08-01", to: "2026-09-30" }, group_by: "service", granularity: "month", }} /> <ToolCallSection label="Output" value={{ total: { "2026-08": 182440, "2026-09": 231915 }, top_changes: [ { service: "Amazon EC2", delta: 31280 }, { service: "Amazon S3", delta: 9870 }, { service: "BigQuery", delta: 6125 }, ], }} /> </ToolCallContent> </ToolCall> {tools[1] !== "pending" && ( <ToolCall status={tools[1]} className="animate-rise motion-reduce:animate-none"> <ToolCallHeader> <ToolCallIcon> <GitPullRequestIcon /> </ToolCallIcon> <ToolCallTitle>Search change log</ToolCallTitle> <ToolCallSummary>Deploys and contract events in September</ToolCallSummary> <ToolCallStatus duration="0.8s"> {tools[1] === "error" ? "Stopped" : undefined} </ToolCallStatus> </ToolCallHeader> <ToolCallContent> <ToolCallSection label="Input" value={{ query: "infra OR contract", from: "2026-09-01", to: "2026-09-30" }} /> <ToolCallSection label="Output" value={{ matches: 3, items: [ { date: "2026-09-04", title: "Savings Plan SP-2291 expired", owner: "Platform" }, { date: "2026-09-09", title: "Scale ingest workers to 48 nodes", owner: "Data" }, { date: "2026-09-12", title: "Remove lifecycle rule on events-raw", owner: "Platform" }, ], }} /> </ToolCallContent> </ToolCall> )} </div> )}
{(answering || finished || (phase === "stopped" && answerCount > 0)) && ( <Response streaming={answering} sources={SOURCES}> {answerText} </Response> )}
{phase === "stopped" && ( <Marker> <MarkerContent>You stopped this response.</MarkerContent> </Marker> )}
{finished && ( <div className="flex animate-rise flex-col gap-4 motion-reduce:animate-none"> <Sources> <SourcesTrigger sources={SOURCES} /> <SourcesContent> {SOURCES.map((source, i) => ( <Source key={source.url} source={source} index={i + 1} /> ))} </SourcesContent> </Sources> <MessageActions visibility="always"> <MessageActionCopy value={ANSWER} /> <MessageAction label="Regenerate" onClick={() => restart(question)}> <RefreshCwIcon /> </MessageAction> <MessageActionFeedback /> <MessageAction label="Share"> <Share2Icon /> </MessageAction> <MessageVersions value={version} total={2} onValueChange={setVersion} /> </MessageActions> <Suggestions orientation="vertical"> {FOLLOW_UPS.map((text) => ( <Suggestion key={text} suggestion={text} onSelect={setDraft} /> ))} </Suggestions> </div> )} </MessageContent> </Message> </MessageScrollerItem> </MessageScrollerContent> </MessageScrollerViewport> <MessageScrollerButton /> </MessageScroller> </MessageScrollerProvider>
<div className="mx-auto w-full max-w-2xl shrink-0 px-4 pb-3"> <PromptInput value={draft} onValueChange={setDraft} status={status} onSubmit={(text) => restart(text)} onStop={stop} > <PromptInputTextarea placeholder="Ask about spend, invoices or forecasts" /> <PromptInputToolbar> <PromptInputTools> <PromptInputButton tooltip="Attach files"> <PaperclipIcon /> </PromptInputButton> <PromptInputToggle tooltip="Search the web" defaultPressed={false}> <GlobeIcon data-icon="inline-start" /> Search </PromptInputToggle> <DropdownMenu> <DropdownMenuTrigger render={<PromptInputButton className="gap-1 px-2 text-[13px]" />} > {model.replace("Claude ", "")} <ChevronDownIcon data-icon="inline-end" className="size-3.5" /> </DropdownMenuTrigger> <DropdownMenuContent align="start" className="w-48"> <DropdownMenuRadioGroup value={model} onValueChange={(value) => setModel(value as string)}> <DropdownMenuRadioItem value="Claude Opus 5.5">Opus 5.5</DropdownMenuRadioItem> <DropdownMenuRadioItem value="Claude Sonnet 5">Sonnet 5</DropdownMenuRadioItem> <DropdownMenuRadioItem value="Claude Haiku 5">Haiku 5</DropdownMenuRadioItem> </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> </PromptInputTools> <PromptInputActions> <PromptInputButton tooltip="Dictate"> <MicIcon /> </PromptInputButton> <PromptInputSubmit /> </PromptInputActions> </PromptInputToolbar> </PromptInput> <p className="pt-2 text-center text-xs text-muted-foreground"> Copilot can make mistakes. Check figures before you share them. </p> </div> </div> )}With attachments and tools
Attachments sit above the text. Tools go left, dictation and send go right.
"use client"
import * as React from "react"import { FileSpreadsheetIcon, FileTextIcon, GlobeIcon, MicIcon, PaperclipIcon, XIcon } from "lucide-react"
import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentMedia, AttachmentTitle,} from "@/components/ui/attachment"import { PromptInput, PromptInputActions, PromptInputAttachments, PromptInputButton, PromptInputSubmit, PromptInputTextarea, PromptInputToggle, PromptInputToolbar, PromptInputTools, type PromptInputStatus,} from "@/components/ui/prompt-input"
const initialFiles = [ { name: "vendor-invoices-q3.csv", meta: "CSV, 214 KB", icon: FileSpreadsheetIcon }, { name: "Northbeam MSA 2026.pdf", meta: "PDF, 1.2 MB", icon: FileTextIcon },]
export default function PromptInputDefault() { const [files, setFiles] = React.useState(initialFiles) const [status, setStatus] = React.useState<PromptInputStatus>("idle") const [search, setSearch] = React.useState(true)
return ( <div className="w-full max-w-xl"> <PromptInput status={status} onSubmit={() => { setStatus("submitting") setTimeout(() => setStatus("streaming"), 700) setTimeout(() => setStatus("idle"), 3200) }} onStop={() => setStatus("idle")} > {files.length > 0 && ( <PromptInputAttachments> {files.map((file) => ( <Attachment key={file.name} size="sm"> <AttachmentMedia> <file.icon /> </AttachmentMedia> <AttachmentContent> <AttachmentTitle>{file.name}</AttachmentTitle> <AttachmentDescription>{file.meta}</AttachmentDescription> </AttachmentContent> <AttachmentActions> <AttachmentAction aria-label={`Remove ${file.name}`} onClick={() => setFiles((all) => all.filter((f) => f.name !== file.name))} > <XIcon /> </AttachmentAction> </AttachmentActions> </Attachment> ))} </PromptInputAttachments> )} <PromptInputTextarea placeholder="Which invoices do not match the contract rates?" /> <PromptInputToolbar> <PromptInputTools> <PromptInputButton tooltip="Attach files"> <PaperclipIcon /> </PromptInputButton> <PromptInputToggle tooltip="Search the web" pressed={search} onPressedChange={setSearch}> <GlobeIcon data-icon="inline-start" /> Search </PromptInputToggle> </PromptInputTools> <PromptInputActions> <PromptInputButton tooltip="Dictate"> <MicIcon /> </PromptInputButton> <PromptInputSubmit /> </PromptInputActions> </PromptInputToolbar> </PromptInput> </div> )}Status
Pass status on every render. The send button follows it.
The request timed out. Your message was not sent.
"use client"
import { PaperclipIcon } from "lucide-react"
import { PromptInput, PromptInputActions, PromptInputButton, PromptInputSubmit, PromptInputTextarea, PromptInputToolbar, PromptInputTools, type PromptInputStatus,} from "@/components/ui/prompt-input"
const states: { status: PromptInputStatus; label: string; value: string }[] = [ { status: "idle", label: "Idle", value: "Forecast October payroll with the two new hires" }, { status: "submitting", label: "Submitting", value: "" }, { status: "streaming", label: "Streaming, the send button stops the answer", value: "" }, { status: "error", label: "Error, the draft is kept so people can resend", value: "Reconcile the corporate card feed for September" },]
export default function PromptInputStates() { return ( <div className="grid w-full max-w-3xl gap-6 sm:grid-cols-2"> {states.map((state) => ( <div key={state.status} className="flex flex-col gap-2"> <span className="text-[13px] text-muted-foreground">{state.label}</span> <PromptInput status={state.status} defaultValue={state.value}> <PromptInputTextarea placeholder="Ask Copilot" /> <PromptInputToolbar> <PromptInputTools> <PromptInputButton tooltip="Attach files"> <PaperclipIcon /> </PromptInputButton> </PromptInputTools> <PromptInputActions> <PromptInputSubmit /> </PromptInputActions> </PromptInputToolbar> </PromptInput> {state.status === "error" && ( <p className="text-[13px] text-destructive-subtle-foreground"> The request timed out. Your message was not sent. </p> )} </div> ))} </div> )}Anatomy
<PromptInput status={status} onSubmit={send} onStop={abort}> <PromptInputAttachments>{attachments}</PromptInputAttachments> <PromptInputTextarea placeholder="Ask anything" /> <PromptInputToolbar> <PromptInputTools> <PromptInputButton tooltip="Attach files"><PaperclipIcon /></PromptInputButton> <PromptInputToggle><GlobeIcon data-icon="inline-start" />Search</PromptInputToggle> </PromptInputTools> <PromptInputActions> <PromptInputButton tooltip="Dictate"><MicIcon /></PromptInputButton> <PromptInputSubmit /> </PromptInputActions> </PromptInputToolbar></PromptInput>Guidance
Use it when
- One composer per conversation, pinned to the bottom of the thread and centered to the same width as the messages.
- Put attachments in PromptInputAttachments so they read as part of the message being written.
- Use PromptInputToggle for switches that change how the next answer is produced, such as web search or deep research.
Avoid
- Disabling the textarea while the model answers. People should be able to write the next message while they read.
- More than four controls on the left. Move rarely used tools into a menu.
- A separate stop button. PromptInputSubmit becomes the stop button while streaming.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- Always pass status to PromptInput: "submitting" from send until the first token, "streaming" while tokens arrive, "error" when the request fails, "idle" otherwise. Never leave it at idle during a request.
- Always pass onStop together with status="streaming" and abort the request in it.
- Use onSubmit(text) to send. It receives trimmed text, is never called for empty input and clears the field. Do not attach your own Enter key handler: Enter sends, Shift+Enter adds a line and IME composition is ignored.
- Every PromptInputButton that only holds an icon needs tooltip, which also becomes its aria-label.
- On error keep the draft (pass clearOnSubmit={false} or restore value) and show the reason in text next to the composer.
- Render the composer inside the page surface, not inside another card.
API
PromptInput
| Prop | Type | Default | Description |
|---|---|---|---|
| status | "idle" | "submitting" | "streaming" | "error" | "idle" | Request state. Streaming turns the send button into a stop button. |
| onSubmit | (text: string, event) => void | Called with the trimmed text on Enter or send. | |
| onStop | () => void | Called when the stop button is pressed while streaming. | |
| value / defaultValue / onValueChange | string | Controlled or uncontrolled text. | |
| allowEmptySubmit | boolean | false | Allow sending without text, for example with attachments only. |
| clearOnSubmit | boolean | true | Clear the text after sending. |
PromptInputTextarea
| Prop | Type | Default | Description |
|---|---|---|---|
| maxRows | number | 8 | Grows from one line up to this many, then scrolls. |
PromptInputButton
| Prop | Type | Default | Description |
|---|---|---|---|
| tooltip | string | Hover label and accessible name for icon buttons. |
PromptInputToggle
| Prop | Type | Default | Description |
|---|---|---|---|
| pressed / onPressedChange | boolean | On and off state for tools such as web search. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add prompt-input# or, with the registry in components.jsonnpx shadcn@latest add @prodular/prompt-input