"use client"; import { ArrowUp, Check, ChevronDown, CornerDownRight, Globe, Mic, Paperclip, Pencil, Slash, Square, Trash2, X, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ChangeEvent, type KeyboardEvent, type ReactNode, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, } from "react"; import { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface PromptBarSkill { id: string; label: string; hint?: string; } export interface PromptBarModel { id: string; label: string; } export interface PromptBarQueuedMessage { id: string; text: string; } export interface PromptBarProps { /** Slash-triggered skills — typing "/" opens the picker. */ skills?: PromptBarSkill[]; models?: PromptBarModel[]; defaultModelId?: string; /** Shows the credits banner above the bar when provided (including 0). */ credits?: number; onUpgrade?: () => void; placeholder?: string; onSubmit?: (payload: { text: string; skillId?: string; attachments: string[]; webSearch: boolean; modelId: string; }) => void; className?: string; } /** Demo file chip — no real file picker, just a label to show/remove. */ interface Attachment { id: string; name: string; } /** Demo streaming duration — a real integration would resolve this from the actual response. */ const STREAM_DEMO_MS = 3000; /** Flat demo cost per send — a real integration would price this server-side. */ const CREDIT_COST_PER_SEND = 10; const DEMO_FILE_NAMES = ["brief.pdf", "screenshot.png", "meeting-notes.md", "diagram.svg", "transcript.txt"]; /** Glass-surface hairline + soft shadow trio — same technique as agent-composer's Composer shell. */ const HAIRLINE_SHADOW = "0 0 0 0.5px var(--wb-hairline-soft), 0 3px 7.5px rgba(0,0,0,0.04), 0 0 20px rgba(0,0,0,0.05)"; /** Matches a skill against the text typed after "/", by id or label prefix. */ function matchesSkillQuery(skill: PromptBarSkill, query: string): boolean { if (query.length === 0) return true; const q = query.toLowerCase(); const label = skill.label.replace(/^\//, "").toLowerCase(); return label.startsWith(q) || skill.id.toLowerCase().startsWith(q); } function CreditsNumber({ value, reduce }: { value: number; reduce: boolean }) { return ( {value} ); } /** Dismissible banner shown above the bar when `credits` is provided. */ function CreditsBanner({ credits, onUpgrade, onDismiss, }: { credits: number; onUpgrade?: () => void; onDismiss: () => void; }) { const reduce = useReducedMotion() ?? false; return ( Credits Remaining
); } function RowActionButton({ "aria-label": ariaLabel, onClick, children, }: { "aria-label": string; onClick: () => void; children: ReactNode; }) { return ( ); } /** One queued message above the bar — steer it in now, edit it back into the draft, or drop it. */ function QueuedRow({ item, onSteer, onEdit, onDelete, }: { item: PromptBarQueuedMessage; onSteer: () => void; onEdit: () => void; onDelete: () => void; }) { const reduce = useReducedMotion() ?? false; return ( {item.text}
); } function AttachmentChip({ name, onRemove }: { name: string; onRemove: () => void }) { const reduce = useReducedMotion() ?? false; return ( {name} ); } /** The "/xxx" text solidified into a removable pill once a skill is picked. */ function SkillChip({ skill, onRemove }: { skill: PromptBarSkill; onRemove: () => void }) { const reduce = useReducedMotion() ?? false; return ( {skill.label.replace(/^\//, "")} ); } /** Floating slash-command list — arrow keys and Enter are driven from the textarea's onKeyDown. */ function SkillPanel({ listId, items, highlightIndex, onSelect, }: { listId: string; items: PromptBarSkill[]; highlightIndex: number; onSelect: (skill: PromptBarSkill) => void; }) { const reduce = useReducedMotion() ?? false; return ( {items.map((skill, index) => (
  • ))}
    ); } /** Small text-button model picker — a simplified, self-contained cousin of components/motion/select.tsx. */ function ModelMenu({ models, modelId, onSelect, }: { models: PromptBarModel[]; modelId: string; onSelect: (id: string) => void; }) { const reduce = useReducedMotion() ?? false; const [open, setOpen] = useState(false); const rootRef = useRef(null); const current = models.find((m) => m.id === modelId) ?? models[0]; useEffect(() => { if (!open) return; const onKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; const onPointerDown = (event: PointerEvent) => { if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false); }; window.addEventListener("keydown", onKeyDown); window.addEventListener("pointerdown", onPointerDown); return () => { window.removeEventListener("keydown", onKeyDown); window.removeEventListener("pointerdown", onPointerDown); }; }, [open]); return (
    {open ? ( {models.map((model) => (
  • ))}
    ) : null}
    ); } function ToolbarIconButton({ "aria-label": ariaLabel, onClick, children, }: { "aria-label": string; onClick?: () => void; children: ReactNode; }) { const reduce = useReducedMotion() ?? false; return ( {children} ); } /** Icon-only when off; springs open into a filled "Search" pill when on. */ function WebSearchToggle({ on, onToggle }: { on: boolean; onToggle: () => void }) { const reduce = useReducedMotion() ?? false; return ( {on ? "Search" : null} ); } /** Morphs between a circular send arrow and a stop square — icon swap via SPRING_SWAP, per spec. */ function SendStopButton({ streaming, canSubmit, onSend, onStop, }: { streaming: boolean; canSubmit: boolean; onSend: () => void; onStop: () => void; }) { const reduce = useReducedMotion() ?? false; return ( ); } /** * Compact single-line AI prompt bar — the lightweight sibling to * `agent-composer`'s full workbench input. Shares its glass-surface toolbar * language (hairline shadow trio, pill controls, send/stop morph) but adds * two moves of its own: typing "/" opens a skill picker whose pick * "solidifies" into a removable chip, and submitting again while a reply is * still streaming queues the message instead of sending it — queued rows can * be steered in immediately, edited back into the draft, or dropped. */ export function PromptBar({ skills = [], models = [], defaultModelId, credits, onUpgrade, placeholder = "What do you want to do today?", onSubmit, className, }: PromptBarProps) { const reduce = useReducedMotion() ?? false; const skillListId = useId(); const textareaRef = useRef(null); const composerShellRef = useRef(null); const streamTimerRef = useRef | null>(null); const idSeqRef = useRef(0); const attachSeqRef = useRef(0); const [text, setText] = useState(""); const [skillId, setSkillId] = useState(undefined); const [attachments, setAttachments] = useState([]); const [webSearch, setWebSearch] = useState(false); const [modelId, setModelId] = useState(defaultModelId ?? models[0]?.id ?? ""); const [queue, setQueue] = useState([]); const [streaming, setStreaming] = useState(false); const [creditsLeft, setCreditsLeft] = useState(credits ?? 0); const [creditsDismissed, setCreditsDismissed] = useState(false); const [dismissed, setDismissed] = useState(false); const [skillHighlight, setSkillHighlight] = useState(0); const makeId = useCallback((prefix: string) => { idSeqRef.current += 1; return `${prefix}-${idSeqRef.current}`; }, []); // Auto-resize up to ~5 lines; the wrapper (max-h + overflow-y-auto) clips/scrolls past that. // biome-ignore lint/correctness/useExhaustiveDependencies: `text` is the trigger — height depends on rendered content read from the DOM, not from the value itself. useLayoutEffect(() => { const el = textareaRef.current; if (!el) return; el.style.height = "auto"; el.style.height = `${el.scrollHeight}px`; }, [text]); const beginStream = useCallback( (messageText: string, opts?: { skillId?: string; attachments?: string[] }) => { onSubmit?.({ text: messageText, skillId: opts?.skillId, attachments: opts?.attachments ?? [], webSearch, modelId, }); setCreditsLeft((c) => Math.max(0, c - CREDIT_COST_PER_SEND)); setStreaming(true); if (streamTimerRef.current) clearTimeout(streamTimerRef.current); streamTimerRef.current = setTimeout(() => { streamTimerRef.current = null; setStreaming(false); }, STREAM_DEMO_MS); }, [onSubmit, webSearch, modelId], ); // Whenever a stream ends (timeout or manual stop), pop the next queued // message and fire it — closes the demo loop described in the spec. useEffect(() => { if (streaming || queue.length === 0) return; const head = queue[0]; setQueue((prev) => prev.slice(1)); beginStream(head.text); }, [streaming, queue, beginStream]); // Clear any pending demo timer on unmount. useEffect( () => () => { if (streamTimerRef.current) clearTimeout(streamTimerRef.current); }, [], ); const handleStop = useCallback(() => { if (streamTimerRef.current) { clearTimeout(streamTimerRef.current); streamTimerRef.current = null; } setStreaming(false); }, []); const canSubmit = text.trim().length > 0 || attachments.length > 0; const handleComposerSubmit = () => { const trimmed = text.trim(); if (trimmed.length === 0 && attachments.length === 0) return; if (streaming) { const queuedText = trimmed.length > 0 ? trimmed : attachments.map((a) => a.name).join(", "); setQueue((prev) => [...prev, { id: makeId("queued"), text: queuedText }]); } else { beginStream(trimmed, { skillId, attachments: attachments.map((a) => a.name) }); } setText(""); setSkillId(undefined); setAttachments([]); }; const handleSteer = (item: PromptBarQueuedMessage) => { setQueue((prev) => prev.filter((q) => q.id !== item.id)); if (streamTimerRef.current) { clearTimeout(streamTimerRef.current); streamTimerRef.current = null; } beginStream(item.text); }; const handleEditQueued = (item: PromptBarQueuedMessage) => { setQueue((prev) => prev.filter((q) => q.id !== item.id)); setText(item.text); textareaRef.current?.focus(); }; const handleDeleteQueued = (item: PromptBarQueuedMessage) => { setQueue((prev) => prev.filter((q) => q.id !== item.id)); }; const activeSkill = skills.find((s) => s.id === skillId); const slashMatch = !skillId && skills.length > 0 ? text.match(/^\/(\S*)/) : null; const slashQuery = slashMatch ? slashMatch[1] : null; const filteredSkills = slashQuery !== null ? skills.filter((s) => matchesSkillQuery(s, slashQuery)) : []; const skillPanelOpen = slashQuery !== null && !dismissed && filteredSkills.length > 0; const clampedHighlight = Math.min(skillHighlight, Math.max(filteredSkills.length - 1, 0)); const highlightedSkill = skillPanelOpen ? filteredSkills[clampedHighlight] : undefined; const activeOptionId = highlightedSkill ? `${skillListId}-${highlightedSkill.id}` : undefined; const selectSkill = (skill: PromptBarSkill) => { const match = text.match(/^\/(\S*)/); const rest = match ? text.slice(match[0].length).replace(/^\s+/, "") : text; setText(rest); setSkillId(skill.id); setDismissed(false); textareaRef.current?.focus(); }; const removeSkillChip = () => { setSkillId(undefined); textareaRef.current?.focus(); }; const addDemoAttachment = () => { const name = DEMO_FILE_NAMES[attachSeqRef.current % DEMO_FILE_NAMES.length]; attachSeqRef.current += 1; setAttachments((prev) => [...prev, { id: makeId("att"), name }]); }; const removeAttachment = (id: string) => { setAttachments((prev) => prev.filter((a) => a.id !== id)); }; // Close the skill panel on outside pointerdown, matching the popover // dismissal convention used across the composer family. useEffect(() => { if (!skillPanelOpen) return; const onPointerDown = (event: PointerEvent) => { if (composerShellRef.current && !composerShellRef.current.contains(event.target as Node)) { setDismissed(true); } }; window.addEventListener("pointerdown", onPointerDown); return () => window.removeEventListener("pointerdown", onPointerDown); }, [skillPanelOpen]); const handleTextChange = (event: ChangeEvent) => { setText(event.target.value); setDismissed(false); setSkillHighlight(0); }; const handleTextKeyDown = (event: KeyboardEvent) => { if (skillPanelOpen) { if (event.key === "ArrowDown") { event.preventDefault(); setSkillHighlight((i) => (Math.min(i, filteredSkills.length - 1) + 1) % filteredSkills.length); return; } if (event.key === "ArrowUp") { event.preventDefault(); setSkillHighlight((i) => { const clamped = Math.min(i, filteredSkills.length - 1); return (clamped - 1 + filteredSkills.length) % filteredSkills.length; }); return; } if (event.key === "Escape") { event.preventDefault(); setDismissed(true); return; } if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); if (highlightedSkill) selectSkill(highlightedSkill); return; } } if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); handleComposerSubmit(); } }; const effectivePlaceholder = activeSkill ? (activeSkill.hint ?? activeSkill.label) : placeholder; const showCreditsBanner = credits !== undefined && !creditsDismissed; return (
    {showCreditsBanner ? ( setCreditsDismissed(true)} /> ) : null} {queue.map((item) => ( handleSteer(item)} onEdit={() => handleEditQueued(item)} onDelete={() => handleDeleteQueued(item)} /> ))} {skillPanelOpen ? ( ) : null}
    {attachments.length > 0 ? (
    {attachments.map((a) => ( removeAttachment(a.id)} /> ))}
    ) : null}
    {activeSkill ? ( ) : null}