import { type ComponentPropsWithRef, useEffect, useRef, useState } from "react"; import { animate, m as motion, useMotionValue, useTransform } from "motion/react"; import { cn } from "../../lib/utils"; import { ComposerActions } from "./composer-actions"; import { ComposerSubmit } from "./composer-submit"; import { IconArrowDownToLineMicro } from "../../icons"; import "./composer.css"; type DivProps = ComponentPropsWithRef<"div">; type AttachmentsProps = DivProps & { /** Whether the tray is visible above the composer. */ open?: boolean; }; /** Top attachments row above the composer header. */ function Attachments({ className, children, open = false, ref }: AttachmentsProps) { return (
{children}
); } /** Left-aligned toolbar section. */ function Start({ className, ref, ...props }: DivProps) { return (
); } /** Right-aligned toolbar section. */ function End({ className, ref, ...props }: DivProps) { return (
); } /** Main input area between the header and footer rows. */ function Input({ className, ref, ...props }: DivProps) { return (
); } /** Bottom footer row for actions, model selector, and send. */ function Footer({ className, ref, ...props }: DivProps) { return (
); } type HeaderProps = DivProps & { /** Whether the header is expanded. */ open?: boolean; }; const HEADER_HEIGHT = 35; /** Top bar below attachments for file count, mode badge, and metadata. */ function Header({ className, ref, open = true, children, ...props }: HeaderProps) { return (
{children}
); } type BodyProps = DivProps & { /** Called when files are dropped onto the composer body. */ onFiles?: (files: File[]) => void; }; /** Bordered wrapper grouping Header, Input, and Footer with drag-and-drop. */ function Body({ className, ref, onFiles, children, ...props }: BodyProps) { let [dragging, setDragging] = useState(false); let [hovering, setHovering] = useState(false); let global = useRef(0); let local = useRef(0); useEffect(() => { function enter(e: DragEvent) { e.preventDefault(); if (!onFiles) return; if (++global.current === 1) setDragging(true); } function leave(e: DragEvent) { e.preventDefault(); if (!onFiles) return; if (--global.current === 0) setDragging(false); } function over(e: DragEvent) { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = onFiles ? "copy" : "none"; } function drop(e: DragEvent) { e.preventDefault(); global.current = 0; local.current = 0; setDragging(false); setHovering(false); if (!onFiles) return; let files = Array.from(e.dataTransfer?.files || []); if (files.length) onFiles(files); } document.addEventListener("dragenter", enter); document.addEventListener("dragleave", leave); document.addEventListener("dragover", over); document.addEventListener("drop", drop); return () => { document.removeEventListener("dragenter", enter); document.removeEventListener("dragleave", leave); document.removeEventListener("dragover", over); document.removeEventListener("drop", drop); global.current = 0; local.current = 0; setDragging(false); setHovering(false); }; }, [onFiles]); return ( <>
{ e.preventDefault(); if (!onFiles) return; if (++local.current === 1) setHovering(true); }} onDragLeave={(e) => { e.preventDefault(); if (!onFiles) return; if (--local.current === 0) setHovering(false); }} {...props} > {children} {dragging && (
Drop your files here.
)}
{dragging && (
)} ); } type GlowProps = { /** AnalyserNode for mic-reactive pulsing. */ analyser?: AnalyserNode | null; /** Whether the glow is active (e.g. recording). */ active?: boolean; }; /** Mic-reactive recording glow behind the composer body. */ function Glow({ analyser = null, active = false }: GlowProps) { let raw = useMotionValue(0); let glow = useMotionValue(0); useEffect(() => { let controls = animate(glow, active ? 1 : 0, { duration: 0.3 }); return () => controls.stop(); }, [active, glow]); useEffect(() => { if (!analyser) return; raw.set(0); let data = new Uint8Array(analyser.frequencyBinCount); let raf = 0; function tick() { analyser!.getByteFrequencyData(data); let sum = 0; for (let i = 0; i < data.length; i++) sum += data[i]; raw.set(Math.min(1, sum / data.length / 255 * 3)); raf = requestAnimationFrame(tick); } raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [analyser, raw]); let o1 = useTransform([glow, raw], ([a, l]: number[]) => a * (0.1 + l * 0.6)); let s1 = useTransform([glow, raw], ([a, l]: number[]) => 0.97 + a * (0.03 + l * 0.02)); let o2 = useTransform([glow, raw], ([a, l]: number[]) => a * (0.08 + l * 0.7)); let o3 = useTransform([glow, raw], ([a, l]: number[]) => a * (0.06 + l * 0.5)); let s3 = useTransform([glow, raw], ([a, l]: number[]) => 1 + a * l * 0.015); return ( <> ); } /** Chat message composer with text input and actions. */ function Root({ className, ref, ...props }: DivProps) { return (
); } /** Chat message composer with header, input area, and toolbar. */ export const Composer: typeof Root & { Actions: typeof ComposerActions; Attachments: typeof Attachments; Body: typeof Body; Glow: typeof Glow; Header: typeof Header; Input: typeof Input; Footer: typeof Footer; Start: typeof Start; End: typeof End; Submit: typeof ComposerSubmit; } = Object.assign(Root, { Actions: ComposerActions, Attachments, Body, Glow, Header, Input, Footer, Start, End, Submit: ComposerSubmit, });