{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"message-composer","type":"registry:ui","title":"MessageComposer","description":"Input with file drop \u2014 handbooks, decks, question banks.","package":"teachers-aid-ui","dependencies":["@particle-academy\/react-fancy"],"registryDependencies":[],"files":[{"path":"components\/fancy\/message-composer\/MessageComposer.tsx","content":"import { Badge, Button, Text } from '@particle-academy\/react-fancy';\r\nimport { useRef, useState } from 'react';\r\n\r\nconst ACCEPT = '.pdf,.png,.jpg,.jpeg,.gif,.webp,.docx,.xlsx,.csv,.txt,.pptx';\r\n\r\nexport interface MessageComposerProps {\r\n    onSend: (message: string, files: File[], reset: () => void) => void;\r\n    agentName: string;\r\n    \/** Blocks input \u2014 in flight, or no model configured. *\/\r\n    disabled?: boolean;\r\n    \/** Spins the send button. Distinct from `disabled` on purpose. *\/\r\n    busy?: boolean;\r\n    accept?: string;\r\n    maxFiles?: number;\r\n    hint?: React.ReactNode;\r\n}\r\n\r\n\/**\r\n * Message box with file drop.\r\n *\r\n * Named to avoid colliding with react-fancy's own `Composer`, and hand-rolled\r\n * rather than using its `PromptInput` because that component maps dropped\r\n * files to `{id, name, bytes}` and discards the File \u2014 so the chip renders but\r\n * the upload can never happen. Reported upstream; switch to it once the\r\n * attachment carries the file.\r\n *\r\n * Attachments are held here until send, so a mistaken drop can be removed\r\n * before it costs a model call.\r\n *\/\r\nexport function MessageComposer({\r\n    onSend,\r\n    agentName,\r\n    disabled,\r\n    busy,\r\n    accept = ACCEPT,\r\n    maxFiles = 10,\r\n    hint,\r\n}: MessageComposerProps) {\r\n    const [message, setMessage] = useState('');\r\n    const [files, setFiles] = useState<File[]>([]);\r\n    const [dragging, setDragging] = useState(false);\r\n    const inputRef = useRef<HTMLInputElement>(null);\r\n\r\n    function addFiles(list: FileList | null) {\r\n        const incoming = Array.from(list ?? []);\r\n        if (incoming.length === 0) return;\r\n\r\n        setFiles((current) => {\r\n            \/\/ De-dupe by name+size so dropping the same file twice does not\r\n            \/\/ send it twice and pay for it twice.\r\n            const seen = new Set(current.map((f) => `${f.name}:${f.size}`));\r\n            return [...current, ...incoming.filter((f) => !seen.has(`${f.name}:${f.size}`))].slice(\r\n                0,\r\n                maxFiles,\r\n            );\r\n        });\r\n    }\r\n\r\n    function submit(e: React.FormEvent | React.KeyboardEvent) {\r\n        e.preventDefault();\r\n        if (disabled || message.trim() === '') return;\r\n\r\n        onSend(message.trim(), files, () => {\r\n            setMessage('');\r\n            setFiles([]);\r\n            if (inputRef.current) inputRef.current.value = '';\r\n        });\r\n    }\r\n\r\n    function onKeyDown(e: React.KeyboardEvent) {\r\n        \/\/ Enter sends, Shift+Enter breaks the line \u2014 the convention everywhere\r\n        \/\/ else, and course descriptions are multi-line often enough to matter.\r\n        if (e.key === 'Enter' && !e.shiftKey) submit(e);\r\n    }\r\n\r\n    return (\r\n        <form\r\n            onSubmit={submit}\r\n            onDragOver={(e) => {\r\n                e.preventDefault();\r\n                setDragging(true);\r\n            }}\r\n            onDragLeave={() => setDragging(false)}\r\n            onDrop={(e) => {\r\n                e.preventDefault();\r\n                setDragging(false);\r\n                addFiles(e.dataTransfer.files);\r\n            }}\r\n            className={`border-t px-4 py-3 transition ${\r\n                dragging ? 'border-brand bg-primary-50' : 'border-secondary-200 bg-white'\r\n            }`}\r\n        >\r\n            {files.length > 0 && (\r\n                <div className=\"mb-2 flex flex-wrap gap-1.5\">\r\n                    {files.map((file, i) => (\r\n                        <span key={`${file.name}:${i}`} className=\"inline-flex items-center gap-1\">\r\n                            <Badge color=\"blue\" variant=\"soft\" size=\"sm\">\r\n                                {file.name} \u00b7 {Math.max(1, Math.round(file.size \/ 1024))} KB\r\n                            <\/Badge>\r\n                            <button\r\n                                type=\"button\"\r\n                                onClick={() => setFiles((c) => c.filter((_, j) => j !== i))}\r\n                                className=\"text-xs text-secondary-500 hover:text-primary-600\"\r\n                                aria-label={`Remove ${file.name}`}\r\n                            >\r\n                                \u00d7\r\n                            <\/button>\r\n                        <\/span>\r\n                    ))}\r\n                <\/div>\r\n            )}\r\n\r\n            <div className=\"flex items-end gap-2\">\r\n                <button\r\n                    type=\"button\"\r\n                    onClick={() => inputRef.current?.click()}\r\n                    className=\"shrink-0 rounded-md border border-secondary-300 px-3 py-2 text-sm text-secondary-700 hover:bg-secondary-50\"\r\n                    title=\"Attach course material\"\r\n                >\r\n                    Attach\r\n                <\/button>\r\n                <input\r\n                    ref={inputRef}\r\n                    type=\"file\"\r\n                    multiple\r\n                    accept={accept}\r\n                    className=\"hidden\"\r\n                    onChange={(e) => addFiles(e.target.files)}\r\n                \/>\r\n\r\n                <textarea\r\n                    rows={2}\r\n                    value={message}\r\n                    disabled={disabled}\r\n                    onChange={(e) => setMessage(e.target.value)}\r\n                    onKeyDown={onKeyDown}\r\n                    placeholder={`Ask ${agentName} to draft or update course material\u2026`}\r\n                    className=\"flex-1 resize-none rounded-md border border-secondary-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500\/15 disabled:bg-secondary-50\"\r\n                \/>\r\n\r\n                <Button\r\n                    type=\"submit\"\r\n                    \/\/ Only `busy`, never `disabled` \u2014 the box is also disabled\r\n                    \/\/ when no model is configured, and a permanent spinner reads\r\n                    \/\/ as \"working\" when nothing is happening at all.\r\n                    loading={busy}\r\n                    disabled={disabled || message.trim() === ''}\r\n                    className=\"!bg-brand hover:!bg-primary-600 !text-white !font-semibold !px-4 !py-2 !rounded-md disabled:!opacity-60\"\r\n                >\r\n                    Send\r\n                <\/Button>\r\n            <\/div>\r\n\r\n            <Text className=\"!text-[11px] !text-secondary-500 !mt-1.5\">\r\n                {hint ?? (\r\n                    <>\r\n                        PDF and images are read directly; Word, Excel, PowerPoint and CSV are converted\r\n                        to text first. Drop files anywhere on this box.\r\n                    <\/>\r\n                )}\r\n            <\/Text>\r\n        <\/form>\r\n    );\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/message-composer\/MessageComposer.tsx"}]}