KI-Agenten
AI action bar
Eine Werkzeugleiste, die sich in einen einzeiligen Prompt für den Agenten verwandelt und zurück.
Angepasst von Watermelon UI (MIT) und neu geschrieben mit Tastatur, Beschriftungen und reduzierter Bewegung.
Vorschau
Verwendung
ai-action-bar-demo.tsx
import { Bold, Heading, Image, Link } from 'lucide-react'
import { AiActionBar } from '@/components/ai-action-bar'
export default function AiActionBarDemo() {
return (
<div className="flex justify-center">
<AiActionBar
onAsk={(prompt) => console.log('send to the agent:', prompt)}
placeholder="Make the product text shorter…"
tools={[
{ id: 'heading', label: 'Heading', icon: Heading },
{ id: 'bold', label: 'Bold', icon: Bold },
{ id: 'link', label: 'Link', icon: Link },
{ id: 'image', label: 'Image', icon: Image },
]}
/>
</div>
)
}Quellcode
ai-action-bar.tsx
// Based on Watermelon UI's “Contextual AI bar” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten with named tools and a working submit.
import { ArrowUp, Sparkles, Wrench } from 'lucide-react'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'motion/react'
import { useId, useState, type ComponentType, type SubmitEvent } from 'react'
import { cn } from '@/lib/utils'
export type AiBarTool = {
id: string
/** Read aloud and shown as a tooltip. */
label: string
icon: ComponentType<{ className?: string }>
onSelect?: () => void
}
export type AiActionBarProps = {
tools: AiBarTool[]
/** Runs with the instruction when the person sends it. */
onAsk: (prompt: string) => void
placeholder?: string
labels?: { tools?: string; ask?: string; send?: string }
defaultMode?: 'tools' | 'ask'
className?: string
}
/** A toolbar that morphs into a one-line prompt for the AI, and back. */
export function AiActionBar({ tools, onAsk, placeholder = 'Ask AI to change it…', labels = {}, defaultMode = 'tools', className }: AiActionBarProps) {
const [mode, setMode] = useState(defaultMode)
const [text, setText] = useState('')
const group = useId()
const { tools: toolsLabel = 'Tools', ask = 'Ask AI', send = 'Send' } = labels
const spring = { type: 'spring', stiffness: 260, damping: 32 } as const
const submit = (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault()
const p = text.trim()
if (!p) return
onAsk(p)
setText('')
}
const modes = [
{ id: 'tools', label: toolsLabel, Icon: Wrench },
{ id: 'ask', label: ask, Icon: Sparkles },
] as const
return (
<MotionConfig reducedMotion="user" transition={spring}>
<LayoutGroup id={group}>
<motion.div layout style={{ borderRadius: 28 }} className={cn('flex w-full max-w-md items-center gap-1 border bg-card p-1 shadow-sm', className)}>
<div role="radiogroup" aria-label="Mode" className="flex shrink-0 items-center gap-0.5 rounded-full bg-background p-1 shadow-sm">
{modes.map(({ id, label, Icon }) => (
<button
key={id}
type="button"
role="radio"
aria-checked={mode === id}
aria-label={label}
title={label}
onClick={() => setMode(id)}
className="relative grid size-10 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{mode === id && <motion.span layoutId="mode" className="absolute inset-0 rounded-full bg-muted" />}
<Icon className="relative size-[18px]" aria-hidden />
</button>
))}
</div>
<AnimatePresence mode="popLayout" initial={false}>
{mode === 'tools' ? (
<motion.div
key="tools"
initial={{ opacity: 0, x: 24, filter: 'blur(4px)' }}
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, x: 24, filter: 'blur(4px)' }}
className="flex flex-1 items-center justify-end gap-1 pr-1"
>
{tools.map(({ id, label, icon: Icon, onSelect }) => (
<button
key={id}
type="button"
onClick={onSelect}
aria-label={label}
title={label}
className="grid size-10 place-items-center rounded-full text-foreground transition-[background-color,transform] duration-150 hover:bg-muted active:scale-[0.92]"
>
<Icon className="size-[18px]" />
</button>
))}
</motion.div>
) : (
<motion.form
key="ask"
onSubmit={submit}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex min-w-0 flex-1 items-center gap-1 pl-2"
>
<input
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={placeholder}
aria-label={ask}
className="min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-muted-foreground"
/>
<button
type="submit"
aria-label={send}
disabled={!text.trim()}
className="grid size-10 shrink-0 place-items-center rounded-full bg-foreground text-background transition-[opacity,transform] duration-150 active:scale-[0.92] disabled:opacity-30"
>
<ArrowUp className="size-[18px]" aria-hidden />
</button>
</motion.form>
)}
</AnimatePresence>
</motion.div>
</LayoutGroup>
</MotionConfig>
)
}Installieren
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/ai-action-bar.jsonMit eingetragener Registry (components.json)
npx shadcn@latest add @manniche/ai-action-bar