registry:block
Prompt Bar
A composer with @ sources, / commands, a model picker, and dictation.
Rendered example
Install
orion-theme
Open registry JSON registry/blocks/prompt-bar.tsx
"use client"
import * as React from "react"
/**
* PROMPT BAR
* A composer with @ sources, / commands, a model picker, and dictation.
* Type @ or / to open the menus; up/down + Enter to pick.
*/
function Icon({
children,
size = 15,
strokeWidth = 1.8,
}: {
children: React.ReactNode
size?: number
strokeWidth?: number
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{children}
</svg>
)
}
const GLYPHS: Record<string, React.ReactNode> = {
clip: (
<path d="m21.4 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />
),
chart: <path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />,
layers: (
<g>
<path d="M12 2 2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5M2 12l10 5 10-5" />
</g>
),
globe: (
<g>
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</g>
),
}
type Source = { key: string; name: string; desc: string; glyph: string; attach?: boolean }
const SOURCES: Source[] = [
{ key: "attach", name: "Add files", desc: "Upload from your computer", glyph: "clip", attach: true },
{ key: "metrics", name: "Metrics", desc: "Usage & billing data", glyph: "chart" },
{ key: "records", name: "Records", desc: "42 tables, schemas, links", glyph: "layers" },
{ key: "web", name: "Web search", desc: "Real-time news and info", glyph: "globe" },
]
const COMMANDS = [
{ key: "summarize", name: "/summarize", desc: "Digest the thread so far" },
{ key: "plan", name: "/plan", desc: "Draft an implementation plan" },
{ key: "review", name: "/review", desc: "Review the current diff" },
{ key: "draft-email", name: "/draft-email", desc: "Write a status update" },
]
const MODELS = [
{ key: "orion-large", name: "Orion Large", tag: "Flagship" },
{ key: "orion-fast", name: "Orion Fast", tag: "Basic" },
]
const FILES = ["dashboard.png", "release-notes.md", "usage-export.csv"]
function parseToken(draft: string): { kind: "at" | "slash"; query: string; start: number } | null {
const match = /(^|\s)([@/])([\w-]*)$/.exec(draft)
if (!match) return null
return {
kind: match[2] === "@" ? "at" : "slash",
query: match[3]!.toLowerCase(),
start: match.index + match[1]!.length,
}
}
export function PromptBar({ variant = "Rounded" }: { variant?: string }) {
const pill = variant === "Pill"
const [draft, setDraft] = React.useState("")
const [dismissed, setDismissed] = React.useState(false)
const [modelOpen, setModelOpen] = React.useState(false)
const [model, setModel] = React.useState<(typeof MODELS)[number]>(MODELS[0]!)
const [attachments, setAttachments] = React.useState<string[]>([])
const [active, setActive] = React.useState(0)
const [listening, setListening] = React.useState(false)
const inputRef = React.useRef<HTMLTextAreaElement>(null)
const token = dismissed ? null : parseToken(draft)
const menu: "at" | "slash" | null = token?.kind ?? null
const query = token?.query ?? ""
const rows: { key: string; name: string; desc: string }[] =
menu === "at"
? SOURCES.filter((s) => s.name.toLowerCase().includes(query))
: menu === "slash"
? COMMANDS.filter((c) => c.name.slice(1).startsWith(query))
: []
const [activeResetKey, setActiveResetKey] = React.useState(`${menu}:${query}`)
if (activeResetKey !== `${menu}:${query}`) {
setActiveResetKey(`${menu}:${query}`)
setActive(0)
}
React.useEffect(() => {
if (!listening) return
const t = setTimeout(() => {
setDraft((current) =>
current ? `${current.trimEnd()} Summarize this week's deploys` : "Summarize this week's deploys",
)
setListening(false)
inputRef.current?.focus()
}, 2000)
return () => clearTimeout(t)
}, [listening])
const pick = (row: { key: string; name: string }) => {
const source = SOURCES.find((s) => s.key === row.key)
if (source?.attach) {
setAttachments((current) => [...current, FILES[current.length % FILES.length]!])
if (token) setDraft(draft.slice(0, token.start))
} else if (menu === "at") {
setDraft(`${token ? draft.slice(0, token.start) : draft}@${row.name} `)
} else {
setDraft(`${token ? draft.slice(0, token.start) : draft}${row.name} `)
}
setDismissed(false)
inputRef.current?.focus()
}
const canSend = draft.trim().length > 0 || attachments.length > 0
const send = () => {
if (!canSend) return
setDraft("")
setAttachments([])
}
return (
<div className="flex w-full max-w-105 flex-col justify-end pb-2">
<div className="relative">
{menu && (
<div
className="absolute inset-x-0 bottom-full z-10 mb-2 rounded-[10px] bg-surface p-1 shadow-raised"
style={{ animation: "pop-in 180ms cubic-bezier(0.23,1,0.32,1) both", transformOrigin: "bottom center" }}
>
{rows.map((row, i) => {
const source = menu === "at" ? SOURCES.find((s) => s.key === row.key) : undefined
return (
<button
key={row.key}
type="button"
onMouseEnter={() => setActive(i)}
onClick={() => pick(row)}
className={`relative z-10 flex h-9 w-full items-center gap-2.5 rounded-[6px] px-2 text-left transition-colors duration-100 ${
active === i ? "bg-hover" : ""
}`}
>
{source && (
<span className="flex size-5.5 shrink-0 items-center justify-center text-ink-2">
<Icon size={15}>{GLYPHS[source.glyph]}</Icon>
</span>
)}
<span className="shrink-0 text-[12.5px] font-medium text-ink">{row.name}</span>
<span className="min-w-0 flex-1 truncate text-[12px] text-ink-3">{row.desc}</span>
</button>
)
})}
{rows.length === 0 && (
<div className="flex h-9 items-center px-2 text-[12px] text-ink-3">
No matches for "{query}"
</div>
)}
<div className="mt-1 border-t border-line px-2 pt-1.5 pb-1 text-[11px] text-ink-3">
{menu === "at" ? "Type to search sources & files" : "Type to search commands"}
</div>
</div>
)}
{modelOpen && (
<div
className="absolute right-0 bottom-full z-10 mb-2 w-44 rounded-[10px] bg-surface p-1 shadow-raised"
style={{ animation: "pop-in 180ms cubic-bezier(0.23,1,0.32,1) both", transformOrigin: "bottom right" }}
>
{MODELS.map((m) => (
<button
key={m.key}
type="button"
onClick={() => {
setModel(m)
setModelOpen(false)
inputRef.current?.focus()
}}
className="flex h-7.5 w-full items-center gap-2 rounded-[6px] px-2 text-left transition-colors duration-100 hover:bg-hover"
>
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium text-ink">{m.name}</span>
<span className="shrink-0 text-[11px] text-ink-3">{m.tag}</span>
<span className={`shrink-0 text-ink ${m.key === model.key ? "" : "invisible"}`}>
<Icon size={13} strokeWidth={2.5}>
<path d="M20 6L9 17l-5-5" />
</Icon>
</span>
</button>
))}
</div>
)}
<div
className={`relative flex flex-col gap-1.5 overflow-hidden border border-line bg-surface p-1.5 shadow-card transition-[border-color,border-radius] duration-150 focus-within:border-line-strong ${
pill ? "rounded-[24px]" : "rounded-[14px]"
}`}
>
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-0.5 pt-0.5">
{attachments.map((file, i) => (
<span
key={`${file}-${i}`}
className="flex h-6.5 items-center gap-1.5 rounded-chip bg-field py-1 pr-1 pl-1.5 text-[11.5px] text-ink-2 shadow-hairline"
style={{ animation: "pop-in 200ms cubic-bezier(0.23,1,0.32,1) both" }}
>
<Icon size={12}>
<g>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
</g>
</Icon>
<span className="max-w-36 truncate">{file}</span>
<button
type="button"
aria-label={`Remove ${file}`}
onClick={() => setAttachments((current) => current.filter((_, j) => j !== i))}
className="flex size-4 items-center justify-center rounded-[4px] text-ink-3 transition-colors duration-100 hover:bg-line/70 hover:text-ink"
>
<Icon size={10} strokeWidth={2.5}>
<path d="M18 6L6 18M6 6l12 12" />
</Icon>
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-1">
<button
type="button"
aria-label="Add attachments and sources"
onClick={() => {
setDraft((d) => (d.endsWith("@") ? d : `${d}@`))
inputRef.current?.focus()
}}
className="flex size-7 shrink-0 items-center justify-center rounded-control text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover hover:text-ink active:scale-[0.94]"
>
<Icon size={16} strokeWidth={2}>
<path d="M12 5v14M5 12h14" />
</Icon>
</button>
<textarea
ref={inputRef}
rows={1}
value={draft}
onChange={(event) => {
setDraft(event.target.value)
setDismissed(false)
}}
onKeyDown={(event) => {
if (menu && rows.length > 0) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault()
setActive((current) => (current + (event.key === "ArrowDown" ? 1 : rows.length - 1)) % rows.length)
return
}
if ((event.key === "Enter" && !event.shiftKey) || event.key === "Tab") {
event.preventDefault()
pick(rows[active]!)
return
}
}
if (event.key === "Escape") {
setDismissed(true)
return
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
send()
}
}}
placeholder={listening ? "Listening…" : "Write a message…"}
aria-label="Prompt"
className="min-h-7 min-w-0 flex-1 resize-none bg-transparent px-1 py-[5px] text-[13px] leading-[18px] text-ink outline-none placeholder:text-ink-3"
/>
<button
type="button"
aria-expanded={modelOpen}
aria-label="Choose model"
onClick={() => setModelOpen((current) => !current)}
className="flex h-7 shrink-0 items-center gap-1 rounded-control px-1.5 text-[12px] font-medium text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink active:scale-[0.94]"
>
{model.name}
<span className="text-ink-3">
<Icon size={11} strokeWidth={2.4}>
<path d="M6 9l6 6 6-6" />
</Icon>
</span>
</button>
<button
type="button"
aria-label={listening ? "Stop dictation" : "Start dictation"}
aria-pressed={listening}
onClick={() => setListening((current) => !current)}
className={`flex size-7 shrink-0 items-center justify-center rounded-control transition-[background-color,color,transform] duration-150 active:scale-[0.94] ${
listening ? "bg-accent-tint text-accent-ink" : "text-ink-3 hover:bg-hover hover:text-ink"
}`}
>
{listening ? (
<span className="flex h-3.5 items-center gap-[2.5px]">
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-[2.5px] rounded-full bg-current"
style={{ height: "100%", animation: `eq-bounce 900ms ease-in-out ${i * 150}ms infinite` }}
/>
))}
</span>
) : (
<Icon size={15} strokeWidth={2}>
<g>
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3" />
</g>
</Icon>
)}
</button>
<button
type="button"
aria-label="Send"
disabled={!canSend}
onClick={send}
className="flex size-7 shrink-0 items-center justify-center rounded-control transition-[background-color,color,transform] duration-200 enabled:active:scale-[0.94]"
style={{
background: canSend ? "var(--ink)" : "var(--line-strong)",
color: canSend ? "var(--surface)" : "var(--ink-2)",
}}
>
<Icon size={16} strokeWidth={2.4}>
<path d="M12 19V5M5 12l7-7 7 7" />
</Icon>
</button>
</div>
</div>
</div>
</div>
)
}