84 / 122 · Composer
Prompt library
Prompts you saved, searchable, with their variables shown before you insert one.
Review {branch} against the repo conventions. Judge whether it should exist before checking correctness.
{branch}
Installation
npx shadcn@latest add "@assistant-ui/elements-prompt-library"
Usage
import { PromptLibrary } from "@/components/elements/prompt-library";
<PromptLibrary
prompts={prompts}
query={query}
selectedId={selectedId}
onQueryChange={setQuery}
onSelect={setSelectedId}
onInsert={insert}
/>Props
PromptLibrary
prompts*SavedPrompt[]Everything saved. Filtering happens inside against the name.
query*stringCurrent search text.
selectedId*stringWhich prompt is previewed below the list.
onQueryChange(query: string) => voidCalled as the search is typed.
onSelect(id: string) => voidCalled when a prompt is highlighted.
onInsert(id: string) => voidCalled on double click, to drop the prompt into the composer.
classNamestringExtra classes merged onto the root.
SavedPrompt
id*stringStable identity, compared against selectedId.
name*stringPrompt name, and the only field the search filters on.
body*stringThe prompt text, shown in the preview below the list.
variables*string[]Placeholder names in the body, listed so you know what the prompt will ask for.
Source
prompt-library.tsx"use client";
import { BookmarkIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, mono, paper } from "./surfaces";
export interface SavedPrompt {
id: string;
name: string;
body: string;
variables: readonly string[];
}
export function PromptLibrary({
prompts,
query,
selectedId,
onQueryChange,
onSelect,
onInsert,
className,
}: {
prompts: readonly SavedPrompt[];
query: string;
selectedId: string;
onQueryChange?: (query: string) => void;
onSelect?: (id: string) => void;
onInsert?: (id: string) => void;
className?: string;
}) {
const matches = prompts.filter((prompt) =>
prompt.name.toLowerCase().includes(query.toLowerCase()),
);
const selected = matches.find((prompt) => prompt.id === selectedId);
return (
<div
className={cn(
paper,
"flex w-full max-w-sm flex-col gap-2 rounded-2xl p-3",
className,
)}
>
<div
className={cn(
field,
"flex items-center gap-2 rounded-xl px-2.5 py-1.5",
)}
>
<BookmarkIcon className="text-foreground/30 size-3.5 shrink-0" />
<input
value={query}
onChange={(event) => onQueryChange?.(event.target.value)}
placeholder="Search prompts"
aria-label="Search saved prompts"
className="text-foreground/85 placeholder:text-foreground/30 min-w-0 flex-1 bg-transparent text-[13px] outline-none"
/>
</div>
<div className="flex flex-col">
{matches.map((prompt) => (
<button
key={prompt.id}
type="button"
onClick={() => onSelect?.(prompt.id)}
onDoubleClick={() => onInsert?.(prompt.id)}
className={cn(
"flex items-center gap-2 rounded-xl px-2 py-1.5 text-start transition-colors",
prompt.id === selectedId
? "bg-foreground/[0.05]"
: "hover:bg-foreground/[0.03]",
)}
>
<span className="min-w-0 flex-1 truncate text-[13px]">
{prompt.name}
</span>
{prompt.variables.length > 0 && (
<span className={cn(mono, "text-foreground/25 shrink-0")}>
{prompt.variables.length} vars
</span>
)}
</button>
))}
{matches.length === 0 && (
<span className="text-foreground/30 px-2 py-3 text-center text-xs">
Nothing matches “{query}”
</span>
)}
</div>
{selected && (
<div
className={cn(
field,
"fade-in animate-in flex flex-col gap-2 rounded-xl p-2.5 duration-200",
)}
>
<p className="text-foreground/65 text-xs leading-relaxed">
{selected.body}
</p>
{selected.variables.length > 0 && (
<div className="flex flex-wrap gap-1">
{selected.variables.map((variable) => (
<span
key={variable}
className={cn(
mono,
"bg-background/70 text-foreground/50 rounded px-1.5 py-0.5",
)}
>
{`{${variable}}`}
</span>
))}
</div>
)}
</div>
)}
</div>
);
}