"use client"; import { forwardRef, useEffect, useId, useRef, useState, type CSSProperties, type FormEvent, } from "react"; import { useGSAP } from "@gsap/react"; import gsap from "gsap"; import { ArrowUp, CalendarDays, FileText, ImagePlus, LoaderCircle, Mic, Plus, StickyNote, X, } from "lucide-react"; import { useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { promptComposerBounded as bounded, promptComposerSpring, } from "./prompt-composer-motion"; import { PromptComposerArt, type PromptActionId } from "./prompt-composer-art"; import { PromptComposerModelPicker, type PromptComposerModel, } from "./prompt-composer-model"; import { PromptComposerRecorder, type PromptTranscriber, } from "./prompt-composer-recorder"; gsap.registerPlugin(useGSAP); export type { PromptActionId, PromptComposerModel, PromptTranscriber }; export interface PromptComposerAction { id: PromptActionId; label: string; placeholder: string; } export interface PromptComposerSubmission { text: string; action: PromptActionId | null; model: string; } export interface PromptComposerProps { value?: string; defaultValue?: string; onValueChange?: (value: string) => void; onSend?: (prompt: PromptComposerSubmission) => void | Promise; actions?: readonly PromptComposerAction[]; models?: readonly PromptComposerModel[]; defaultModel?: string; onModelChange?: (model: PromptComposerModel) => void; placeholder?: string; label?: string; theme?: "system" | "light" | "dark"; width?: number; radius?: number; cardLift?: number; cardSpread?: number; speed?: number; stiffness?: number; damping?: number; accentColor?: string; defaultTrayOpen?: boolean; disabled?: boolean; demoVoice?: boolean; onTranscribe?: PromptTranscriber; errorMessage?: string; className?: string; style?: CSSProperties; } export const PROMPT_COMPOSER_ACTIONS: readonly PromptComposerAction[] = [ { id: "image", label: "Create image", placeholder: "Describe the image you want…", }, { id: "note", label: "Add note", placeholder: "Write a note…" }, { id: "event", label: "Create event", placeholder: "What’s the event, and when?", }, { id: "voice", label: "Voice mode", placeholder: "" }, { id: "docs", label: "Analyse docs", placeholder: "Ask about a document…" }, ]; export const PROMPT_COMPOSER_MODELS: readonly PromptComposerModel[] = [ { id: "quick", name: "Quick 1.5", description: "Fast answers" }, { id: "deep", name: "Deep 2.0", description: "Thinks it through" }, { id: "vision", name: "Vision 1.2", description: "Sees your images" }, ]; const cardStyle = { image: { tilt: -8, rise: 0, layer: 1, Icon: ImagePlus }, note: { tilt: -4, rise: 7, layer: 2, Icon: StickyNote }, event: { tilt: 0, rise: 12, layer: 4, Icon: CalendarDays }, voice: { tilt: 4, rise: 7, layer: 3, Icon: Mic }, docs: { tilt: 8, rise: 0, layer: 2, Icon: FileText }, } as const; export const PromptComposer = forwardRef( function PromptComposer( { value, defaultValue = "", onValueChange, onSend, actions = PROMPT_COMPOSER_ACTIONS, models = PROMPT_COMPOSER_MODELS, defaultModel, onModelChange, placeholder = "Ask anything", label = "Message", theme = "system", width = 760, radius = 18, cardLift = 16, cardSpread = 10, speed = 1, stiffness = 360, damping = 32, accentColor = "#8662d9", defaultTrayOpen = true, disabled = false, demoVoice = false, onTranscribe, errorMessage = "Could not send your message. Please try again.", className, style, }, forwardedRef, ) { const rootRef = useRef(null); const inputRef = useRef(null); const mountedRef = useRef(false); const sendingRef = useRef(false); const wasSendingRef = useRef(false); const timelineRef = useRef(null); const [draft, setDraft] = useState(defaultValue); const [actionId, setActionId] = useState(null); const [trayOpen, setTrayOpen] = useState(defaultTrayOpen); const [hovered, setHovered] = useState(null); const [modelId, setModelId] = useState(defaultModel ?? models[0]?.id); const [voiceOpen, setVoiceOpen] = useState(false); const voiceWasOpenRef = useRef(false); const [sending, setSending] = useState(false); const [error, setError] = useState(""); const [round, setRound] = useState(0); const reduced = useReducedMotion(); const inputId = useId(); const fanId = useId(); const errorId = useId(); const text = value ?? draft; const uniqueActions = actions .filter( (action, index) => actions.findIndex((candidate) => candidate.id === action.id) === index, ) .slice(0, 5); const availableModels = models.filter( (model, index) => models.findIndex((candidate) => candidate.id === model.id) === index, ); const model = availableModels.find((candidate) => candidate.id === modelId) ?? availableModels[0]; const picked = uniqueActions.find((action) => action.id === actionId) ?? null; const voiceEnabled = Boolean(onTranscribe || demoVoice); const [voiceAvailable, setVoiceAvailable] = useState( !disabled && voiceEnabled, ); if (voiceAvailable !== (!disabled && voiceEnabled)) { setVoiceAvailable(!disabled && voiceEnabled); setVoiceOpen(false); } const recording = voiceOpen && voiceAvailable; const fanUp = trayOpen && !picked && !recording; const busy = disabled || sending || recording; const motionSpeed = bounded(speed, 1, 0.5, 2); const canSend = Boolean(text.trim() && model && !busy); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); useEffect(() => { if (voiceWasOpenRef.current && !recording && !disabled) inputRef.current?.focus({ preventScroll: true }); voiceWasOpenRef.current = recording; }, [recording, disabled]); useEffect(() => { if (wasSendingRef.current && !sending && !disabled) inputRef.current?.focus({ preventScroll: true }); wasSendingRef.current = sending; }, [sending, disabled]); const updateText = (next: string) => { setDraft(next); onValueChange?.(next); }; const focusInput = () => inputRef.current?.focus({ preventScroll: true }); const clearAction = () => { setActionId(null); setTrayOpen(true); setHovered(null); focusInput(); }; useGSAP( () => { gsap.set("[data-prompt-card]", { yPercent: 120, autoAlpha: 0, scale: 0.92, rotation: 0, }); }, { scope: rootRef }, ); useGSAP( () => { const root = rootRef.current; if (!root) return; const { duration, ease } = promptComposerSpring( stiffness, damping, motionSpeed, reduced, ); timelineRef.current?.kill(); const timeline = gsap.timeline({ defaults: { duration, ease, overwrite: "auto" }, }); timelineRef.current = timeline; const cards = Array.from( root.querySelectorAll("[data-prompt-card]"), ); const hoverIndex = cards.findIndex( (card) => card.dataset.promptCard === hovered, ); cards.forEach((card, index) => { const id = uniqueActions[index]?.id; if (!id) return; const config = cardStyle[id]; const lifted = fanUp && hovered === id; const near = hoverIndex >= 0 && Math.abs(hoverIndex - index) === 1; const direction = hoverIndex < 0 || lifted ? 0 : index < hoverIndex ? -1 : 1; timeline.to( card, { yPercent: fanUp ? 0 : 120, y: fanUp ? -config.rise - (lifted ? bounded(cardLift, 16, 0, 32) : near ? 3 : 0) : 0, x: fanUp ? direction * bounded(cardSpread, 10, 0, 20) * (near ? 1 : 0.4) : 0, rotation: fanUp ? lifted ? config.tilt * 0.3 : config.tilt + direction * (near ? 1.5 : 0.5) : 0, scale: fanUp ? (lifted ? 1.05 : 1) : 0.92, autoAlpha: fanUp ? 1 : 0, }, reduced ? 0 : hovered ? 0 : fanUp ? index * 0.035 : id === actionId ? 0.05 : 0, ); const artwork = (selector: string, vars: gsap.TweenVars) => { const targets = card.querySelectorAll(selector); if (targets.length) timeline.to( targets, { transformOrigin: "50% 50%", duration, ...vars, }, 0, ); }; artwork("[data-art-spark]", { rotation: lifted && !reduced ? 90 : 0, scale: lifted && !reduced ? 1.2 : 1, }); artwork("[data-art-triangle]", { rotation: lifted && !reduced ? -14 : 0, }); artwork("[data-art-circle], [data-art-date], [data-art-doc]", { y: lifted && !reduced ? -3 : 0, }); artwork("[data-art-line]", { scaleX: lifted && !reduced ? 0.9 : 1, transformOrigin: "0% 50%", }); artwork("[data-art-mic], [data-art-dot]", { scale: lifted && !reduced ? 1.08 : 1, }); artwork("[data-art-wave]", { scaleY: lifted && !reduced ? 0.65 : 1 }); }); timeline.to("[data-tray-icon]", { rotation: fanUp ? 0 : 135 }, 0); return () => { timeline.kill(); }; }, { scope: rootRef, dependencies: [ fanUp, hovered, picked?.id, voiceOpen, reduced, motionSpeed, cardLift, cardSpread, stiffness, damping, round, uniqueActions.map((action) => action.id).join(","), ], }, ); useGSAP( () => { if (!voiceOpen) gsap.fromTo( "[data-composer-content]", { opacity: 0, y: reduced ? 0 : 4 }, { opacity: 1, y: 0, duration: reduced ? 0 : 0.2 / motionSpeed, ease: "power2.out", overwrite: true, }, ); }, { scope: rootRef, dependencies: [picked?.id, voiceOpen, round, reduced, motionSpeed], }, ); const send = async (event: FormEvent) => { event.preventDefault(); if (!canSend || !model || sendingRef.current) return; sendingRef.current = true; setSending(true); setError(""); try { await onSend?.({ text: text.trim(), action: picked?.id ?? null, model: model.id, }); if (!mountedRef.current) return; updateText(""); setActionId(null); setTrayOpen(true); setHovered(null); setRound((current) => current + 1); } catch { if (mountedRef.current) setError(errorMessage); } finally { sendingRef.current = false; if (mountedRef.current) { setSending(false); inputRef.current?.focus({ preventScroll: true }); } } }; const tone = "[--pc-paper:#fff] [--pc-ink:#36333d] [--pc-muted:#77727f] [--pc-border:#3225431c] [--pc-pill:#f1eff5] [--pc-field:#fff] [--pc-image:#e7dcfa] [--pc-note:#f9edc8] [--pc-event:#d9eddf] [--pc-voice:#f6ded9] [--pc-docs:#dbe8f7]"; const darkTone = "[--pc-paper:#25232a] [--pc-ink:#eeeaf4] [--pc-muted:#b1abbc] [--pc-border:#e8daff1c] [--pc-pill:#302c38] [--pc-field:#211f26] [--pc-image:#40334f] [--pc-note:#4c4430] [--pc-event:#2d463d] [--pc-voice:#503835] [--pc-docs:#304156]"; const systemTone = "dark:[--pc-paper:#25232a] dark:[--pc-ink:#eeeaf4] dark:[--pc-muted:#b1abbc] dark:[--pc-border:#e8daff1c] dark:[--pc-pill:#302c38] dark:[--pc-field:#211f26] dark:[--pc-image:#40334f] dark:[--pc-note:#4c4430] dark:[--pc-event:#2d463d] dark:[--pc-voice:#503835] dark:[--pc-docs:#304156]"; const ChipIcon = picked ? cardStyle[picked.id].Icon : null; return (
{ rootRef.current = element; if (typeof forwardedRef === "function") forwardedRef(element); else if (forwardedRef) forwardedRef.current = element; }} data-slot="prompt-composer" data-theme={theme} className={cn( "@container/composer w-full min-w-0 text-[var(--pc-ink)]", tone, theme === "dark" ? darkTone : theme === "system" ? systemTone : "", className, )} style={ { maxWidth: bounded(width, 760, 280, 1200), "--pc-radius": `${bounded(radius, 18, 10, 28)}px`, "--pc-accent": accentColor, ...style, } as CSSProperties } >
{ if (event.key === "Escape" && voiceOpen) { setVoiceOpen(false); focusInput(); } }} className="relative grid min-w-0 grid-cols-1 items-end gap-x-3 gap-y-3 @min-[561px]/composer:grid-cols-[auto_minmax(0,1fr)]" > {model && (
{ setModelId(next.id); onModelChange?.(next); }} disabled={busy} speed={motionSpeed} stiffness={stiffness} damping={damping} />
)}
setHovered(null)} > {uniqueActions.map((action, index) => { const config = cardStyle[action.id]; const isVoiceDisabled = action.id === "voice" && !voiceEnabled; return ( ); })}
{recording ? ( { updateText( [text.trim(), transcript].filter(Boolean).join(" "), ); setVoiceOpen(false); focusInput(); }} onCancel={() => { setVoiceOpen(false); focusInput(); }} onError={(message) => { setError(message); setVoiceOpen(false); focusInput(); }} /> ) : (
{picked && ChipIcon && ( )} { updateText(event.target.value); if (error) setError(""); }} onKeyDown={(event) => { if ( picked && (event.key === "Escape" || (event.key === "Backspace" && !text)) ) { event.preventDefault(); clearAction(); } }} disabled={busy} placeholder={picked?.placeholder ?? placeholder} autoComplete="off" aria-invalid={Boolean(error)} aria-describedby={error ? errorId : undefined} className="h-full w-0 min-w-0 flex-1 bg-transparent text-base text-[var(--pc-ink)] outline-none placeholder:text-[var(--pc-muted)] disabled:opacity-60 @min-[561px]/composer:text-sm" />
)}
{error && ( )}
); }, ); export default PromptComposer;