import { t } from "@lingui/core/macro"; import { Trans, useLingui } from "@lingui/react/macro"; import type { AgentSkillCatalogEntry, ThreadMessage } from "@rakazo/contracts"; import { ATTACHMENT_ALLOWED_MIME_TYPES } from "@rakazo/contracts"; import type { ComposerMention, SlashActionId } from "@rakazo/core"; import { clampMentionHighlightIndex, mentionChipKey, resolveMentionPickerKey, SLASH_ACTIONS, serializeComposerPrompt, truncateSlashDescription, } from "@rakazo/core"; import { BotAvatar, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@rakazo/ui-web"; import { ArrowUp, Box, Clock, Mic, Paperclip, Play, Plus, Puzzle, Settings, Square, X, } from "lucide-react"; import type { ClipboardEvent, DragEvent, RefObject } from "react"; import { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { VoiceAuroraRim } from "../../components/voice/VoiceAurora"; import { isFileDrag, isFilePaste } from "../../lib/pending-attachments"; import { AutopilotDialog } from "./autopilot-dialog"; import { previewMessageText } from "./message-preview"; import type { PendingAttachment } from "./shell-types"; import { FALLBACK_BOT_COLOR } from "./shell-types"; // ".zip" too: Windows reports ZIPs as application/x-zip-compressed. const ATTACHMENT_ACCEPT = [...ATTACHMENT_ALLOWED_MIME_TYPES, ".zip"].join(","); const addButtonClass = "tactile-press size-10 shrink-0 rounded-full border border-border bg-foreground/5 text-foreground/80 transition-all hover:bg-foreground/10 hover:text-foreground flex items-center justify-center cursor-pointer"; export const Composer = memo(function Composer({ running, disabled, pendingAttachments, attachmentNotice, sendError, runError, runErrorId, onRunErrorPresented, onDismissError, sending, fileInputRef, onAttachmentPick, onRemoveAttachment, onSend, onStop, onVoice, voiceReady = false, voiceColor, replyTarget, replyQuote, replyTargetName, onClearReply, mentionTargets, agentSkills, onSlashOpen, onSlashAction, onConnectModel, onAutopilot, }: { running: boolean; disabled?: boolean; pendingAttachments: PendingAttachment[]; attachmentNotice: string | null; sendError: string | null; runError: string | null; runErrorId: string | null; onRunErrorPresented: (runId: string) => void; onDismissError: () => void; sending: boolean; fileInputRef: RefObject; onAttachmentPick: (files: FileList | null) => void | Promise; onRemoveAttachment: (attachment: PendingAttachment) => void; onSend: (text: string, mentions?: ComposerMention[]) => Promise; onStop: () => Promise; onVoice?: () => void; voiceReady?: boolean; voiceColor?: string; replyTarget?: ThreadMessage | null; replyQuote?: string | null; replyTargetName?: string; onClearReply?: () => void; mentionTargets?: ComposerMention[]; agentSkills?: AgentSkillCatalogEntry[]; onSlashOpen?: () => void; onSlashAction?: (action: SlashActionId) => void; /** Set while no model is connected: nothing can run until one is. */ onConnectModel?: () => void; /** Hand the order to the autopilot; the composer menu offers it only when set. */ onAutopilot?: (goal: string, until?: string) => Promise; }) { const { t } = useLingui(); const [draft, setDraft] = useState(""); const [autopilotOpen, setAutopilotOpen] = useState(false); const [mentionQuery, setMentionQuery] = useState(null); const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0); const [slashQuery, setSlashQuery] = useState(null); const [selectedSkill, setSelectedSkill] = useState(null); const [selectedMentions, setSelectedMentions] = useState([]); const textareaRef = useRef(null); const runErrorRef = useRef(null); const submitFingerprint = useRef(null); useEffect(() => { if (!sendError && !runError) return; const timer = setTimeout(() => { onDismissError(); }, 8000); return () => clearTimeout(timer); }, [sendError, runError, onDismissError]); const presentedRunErrorIdRef = useRef(null); const mentionListboxId = useId(); const dragDepth = useRef(0); const [draggingFiles, setDraggingFiles] = useState(false); const canSend = draft.trim().length > 0 || selectedSkill !== null || selectedMentions.length > 0 || pendingAttachments.length > 0; // One slot, two jobs: an empty draft talks, a written one sends. const voiceInSlot = Boolean(onVoice) && !canSend; const primaryAction = voiceInSlot ? ( ) : ( ); useEffect(() => { if (!runError || !runErrorId) return; const currentRunErrorId = runErrorId; let frame = 0; function recordIfPresented() { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { const element = runErrorRef.current; if (!element || document.visibilityState !== "visible") return; const rect = element.getBoundingClientRect(); const topElement = document.elementFromPoint( rect.left + rect.width / 2, rect.top + rect.height / 2, ); if (topElement && (topElement === element || element.contains(topElement))) { if (presentedRunErrorIdRef.current === currentRunErrorId) return; presentedRunErrorIdRef.current = currentRunErrorId; onRunErrorPresented(currentRunErrorId); } }); } recordIfPresented(); const observer = new MutationObserver(recordIfPresented); observer.observe(document.body, { childList: true, subtree: true }); document.addEventListener("transitionend", recordIfPresented); document.addEventListener("visibilitychange", recordIfPresented); return () => { cancelAnimationFrame(frame); observer.disconnect(); document.removeEventListener("transitionend", recordIfPresented); document.removeEventListener("visibilitychange", recordIfPresented); }; }, [onRunErrorPresented, runError, runErrorId]); useLayoutEffect(() => { const el = textareaRef.current; if (!el) return; function syncHeight() { const textarea = textareaRef.current; if (!textarea) return; textarea.style.height = "0px"; textarea.style.height = `${textarea.scrollHeight}px`; } syncHeight(); let lastWidth = el.getBoundingClientRect().width; const observer = new ResizeObserver(() => { const textarea = textareaRef.current; if (!textarea) return; const width = textarea.getBoundingClientRect().width; if (width === lastWidth) return; lastWidth = width; syncHeight(); }); observer.observe(el); return () => observer.disconnect(); }, [draft]); function updateDraft(value: string) { submitFingerprint.current = null; setDraft(value); const mentionMatch = /(?:^|\s)@([\w-]*)$/.exec(value); setMentionQuery(mentionMatch ? (mentionMatch[1] ?? "") : null); // `/` only at the start of the draft so forced skills expand (`Use skill:` / `/Name` prefix). const slashMatch = selectedSkill === null ? /^\/([^\n]*)$/.exec(value) : null; const nextSlash = slashMatch ? (slashMatch[1] ?? "") : null; if (nextSlash !== null && slashQuery === null) onSlashOpen?.(); setSlashQuery(nextSlash); } function focusComposer() { textareaRef.current?.focus(); } function insertMention(mention: ComposerMention) { setDraft((current) => current.replace(/@([\w-]*)$/, "")); setMentionQuery(null); setMentionHighlightIndex(0); setSelectedMentions((current) => current.some((selected) => mentionChipKey(selected) === mentionChipKey(mention)) ? current : [...current, mention], ); focusComposer(); } function insertSkill(skill: AgentSkillCatalogEntry) { setSelectedSkill(skill); setDraft(""); setSlashQuery(null); } function runSlashAction(action: SlashActionId) { setDraft(""); setSlashQuery(null); onSlashAction?.(action); } function removeLastChip() { if (selectedMentions.length > 0) { setSelectedMentions((current) => current.slice(0, -1)); return; } if (selectedSkill) setSelectedSkill(null); } const mentionOptions = useMemo(() => { if (mentionQuery === null || !mentionTargets?.length) return []; const query = mentionQuery.trim().toLowerCase(); return mentionTargets .filter((target) => !query || target.name.toLowerCase().startsWith(query)) .slice(0, 10); }, [mentionQuery, mentionTargets]); useEffect(() => { setMentionHighlightIndex(0); }, [mentionQuery]); const activeMentionIndex = clampMentionHighlightIndex( mentionHighlightIndex, mentionOptions.length, ); const mentionPickerOpen = mentionOptions.length > 0; const activeMentionOptionId = mentionPickerOpen ? `${mentionListboxId}-option-${activeMentionIndex}` : undefined; const slashSkillOptions = useMemo(() => { if (slashQuery === null) return []; const query = slashQuery.trim().toLowerCase(); const skills = agentSkills ?? []; return skills .filter((skill) => { if (!query) return true; return ( skill.name.toLowerCase().includes(query) || skill.description.toLowerCase().includes(query) ); }) .slice(0, 8); }, [agentSkills, slashQuery]); const slashActionOptions = useMemo(() => { if (slashQuery === null) return []; const query = slashQuery.trim().toLowerCase(); return SLASH_ACTIONS.filter((action) => !query || action.label.toLowerCase().includes(query)); }, [slashQuery]); const showSlashPicker = slashQuery !== null && mentionQuery === null && (slashSkillOptions.length > 0 || slashActionOptions.length > 0); function send() { if (!canSend || disabled) return; const text = serializeComposerPrompt(draft, selectedSkill, selectedMentions); const fingerprint = [text, ...selectedMentions.map((mention) => mentionChipKey(mention))].join( "\n", ); if (submitFingerprint.current === fingerprint) return; submitFingerprint.current = fingerprint; setDraft(""); setMentionQuery(null); setMentionHighlightIndex(0); setSlashQuery(null); setSelectedSkill(null); const mentions = selectedMentions; setSelectedMentions([]); void onSend(text, mentions); } function handleDragEnter(event: DragEvent) { const dataTransfer = event.dataTransfer; if (!isFileDrag(dataTransfer)) return; event.preventDefault(); if (disabled) { dragDepth.current = 0; setDraggingFiles(false); return; } dragDepth.current += 1; setDraggingFiles(true); } function handleDragOver(event: DragEvent) { const dataTransfer = event.dataTransfer; if (!isFileDrag(dataTransfer)) return; event.preventDefault(); dataTransfer.dropEffect = disabled ? "none" : "copy"; if (disabled) { dragDepth.current = 0; setDraggingFiles(false); return; } setDraggingFiles(true); } function handleDragLeave(event: DragEvent) { if (!isFileDrag(event.dataTransfer)) return; if (disabled) { dragDepth.current = 0; setDraggingFiles(false); return; } dragDepth.current = Math.max(0, dragDepth.current - 1); if (dragDepth.current === 0) setDraggingFiles(false); } function handleDrop(event: DragEvent) { const dataTransfer = event.dataTransfer; if (!isFileDrag(dataTransfer)) return; event.preventDefault(); dragDepth.current = 0; setDraggingFiles(false); if (!disabled) void onAttachmentPick(dataTransfer.files); } function handlePaste(event: ClipboardEvent) { const clipboardData = event.clipboardData; // Only intercept real FileList pastes; leave text-only / empty-files native. if (disabled || !clipboardData || !isFilePaste(clipboardData)) return; event.preventDefault(); void onAttachmentPick(clipboardData.files); const text = clipboardData.getData("text/plain"); if (!text) return; const textarea = event.currentTarget; const start = textarea.selectionStart; const end = textarea.selectionEnd; updateDraft(`${draft.slice(0, start)}${text}${draft.slice(end)}`); const caret = start + text.length; window.requestAnimationFrame(() => { textareaRef.current?.setSelectionRange(caret, caret); }); } const showComposerPlaceholder = draft.length === 0 && selectedSkill === null && selectedMentions.length === 0; const replyName = replyTarget ? (replyTargetName ?? previewMessageText(replyTarget)) : ""; return (
{onConnectModel ? (
Connect a model so your Oo can reply.
) : sendError || runError ? (
{sendError ?? runError}
) : null} {replyTarget ? (
{replyQuote ? t`Replying to ${replyName}: “${replyQuote}”` : t`Replying to ${replyName}`}
) : null} {attachmentNotice ? (
{attachmentNotice}
) : null} {pendingAttachments.length ? (
{pendingAttachments.map((attachment) => (
{attachment.previewUrl ? ( {attachment.file.name} ) : ( )} {attachment.file.name}
))}
) : null} {mentionPickerOpen ? (
{mentionOptions.map((mention, index) => { const optionId = `${mentionListboxId}-option-${index}`; const highlighted = index === activeMentionIndex; return ( ); })}
) : null} {showSlashPicker ? (
{slashSkillOptions.map((skill) => ( ))} {slashActionOptions.map((action) => { const label = slashActionLabel(action.id); return ( ); })}
) : null}
void onAttachmentPick(event.target.files)} /> {onAutopilot ? ( } > fileInputRef.current?.click()}> {t`Attach file`} setAutopilotOpen(true)}> {t`Autopilot`} ) : ( )} {autopilotOpen && onAutopilot ? ( setAutopilotOpen(false)} onStart={async (goal, until) => { await onAutopilot(goal, until); setDraft(""); }} /> ) : null}
{selectedSkill ? ( {selectedSkill.name} ) : null} {selectedMentions.map((mention) => ( {mention.name} ))}