import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react"; import { useT } from '../i18n'; import type { Dict } from '../i18n/types'; import { projectRawUrl, uploadProjectFiles } from "../providers/registry"; import type { AppConfig, ChatAttachment, ChatCommentAttachment, ProjectFile } from "../types"; import { Icon } from "./Icon"; import { BUILT_IN_PETS, CUSTOM_PET_ID, resolveActivePet } from "./pet/pets"; type TranslateFn = (key: keyof Dict, vars?: Record) => string; interface SlashCommand { id: string; // Visible label, e.g. `/hatch`. Shown in the popover row. label: string; // Text inserted into the draft when the user picks the entry. The // cursor is positioned at the end of `insert`, so a trailing space // is the difference between a "ready for argument" command and a // "submit immediately" one. insert: string; // i18n key of the short description shown next to the label. descKey: keyof Dict; // Optional argument hint shown after the description. argHint?: string; // Icon glyph from the project Icon set. icon: 'sparkles' | 'eye' | 'sliders'; } interface Props { projectId: string | null; projectFiles: ProjectFile[]; streaming: boolean; initialDraft?: string; // Lazy ensure — the composer calls this before its first upload, so the // project folder exists on disk before files land in it. Returns the // project id when ready. onEnsureProject: () => Promise; commentAttachments?: ChatCommentAttachment[]; onRemoveCommentAttachment?: (id: string) => void; onSend: (prompt: string, attachments: ChatAttachment[], commentAttachments: ChatCommentAttachment[]) => void; onStop: () => void; // Opens the global settings dialog (CLI / model / agent picker). The // composer's leading gear icon routes here so users can switch models // without leaving the chat. onOpenSettings?: () => void; // Optional pet wiring — when present, the composer renders a small // 🐾 button + popover so users can adopt / wake / tuck a pet without // leaving chat. Typing `/pet` (or `/pet wake|tuck|`) is parsed // out of the draft and routed to the same handlers. petConfig?: AppConfig['pet']; onAdoptPet?: (petId: string) => void; onTogglePet?: () => void; onOpenPetSettings?: () => void; } // Imperative handle so ancestors (e.g. example chips in ChatPane) can // push text into the composer without owning its draft state. export interface ChatComposerHandle { setDraft: (text: string) => void; focus: () => void; } /** * The chat composer: textarea + paste/drop/attach buttons + @-mention * picker. Attachments are uploaded into the active project's folder so * the agent can reference them by relative path on its next turn. * * `@` typed at a word boundary opens a popover listing project files. * Selecting one inserts `@` into the prompt and stages it as an * attachment so the daemon also includes it explicitly. */ export const ChatComposer = forwardRef( function ChatComposer( { projectId, projectFiles, streaming, initialDraft, onEnsureProject, commentAttachments = [], onRemoveCommentAttachment, onSend, onStop, onOpenSettings, petConfig, onAdoptPet, onTogglePet, onOpenPetSettings, }, ref ) { const t = useT(); const [draft, setDraft] = useState(initialDraft ?? ""); const [staged, setStaged] = useState([]); const [dragActive, setDragActive] = useState(false); const [mention, setMention] = useState<{ q: string; cursor: number; } | null>(null); // Slash-command popover state — when the draft starts with `/` and // the cursor is still inside that token (no space committed yet), // we show a small palette of supported commands. The query is the // text after `/` so the user can type-to-filter. const [slash, setSlash] = useState<{ q: string; cursor: number; } | null>(null); const [slashIndex, setSlashIndex] = useState(0); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [importOpen, setImportOpen] = useState(false); const [petOpen, setPetOpen] = useState(false); const fileInputRef = useRef(null); const textareaRef = useRef(null); const importMenuRef = useRef(null); const importTriggerRef = useRef(null); const petMenuRef = useRef(null); const petTriggerRef = useRef(null); const petEnabled = Boolean(onAdoptPet && onTogglePet); // initialDraft is only honored on the first non-empty value the parent // hands us. After we seed once, the composer is fully under user control // — re-renders that pass the same prompt back must not reseed. If the // initial useState above already consumed a non-empty initialDraft we // mark it seeded immediately, so an early clear by the user (typing or // backspace before the parent stops passing initialDraft) does not get // overwritten by the effect. const seededRef = useRef(Boolean(initialDraft)); useEffect(() => { if (seededRef.current) return; if (initialDraft && initialDraft !== draft) { setDraft(initialDraft); seededRef.current = true; } else if (initialDraft === undefined) { seededRef.current = true; } }, [initialDraft, draft]); useEffect(() => { if (!importOpen) return; function onPointer(e: MouseEvent) { const target = e.target as Node; if (importMenuRef.current?.contains(target)) return; if (importTriggerRef.current?.contains(target)) return; setImportOpen(false); } function onKey(e: KeyboardEvent) { if (e.key === "Escape") setImportOpen(false); } document.addEventListener("mousedown", onPointer); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onPointer); document.removeEventListener("keydown", onKey); }; }, [importOpen]); useEffect(() => { if (!petOpen) return; function onPointer(e: MouseEvent) { const target = e.target as Node; if (petMenuRef.current?.contains(target)) return; if (petTriggerRef.current?.contains(target)) return; setPetOpen(false); } function onKey(e: KeyboardEvent) { if (e.key === "Escape") setPetOpen(false); } document.addEventListener("mousedown", onPointer); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onPointer); document.removeEventListener("keydown", onKey); }; }, [petOpen]); // Catalog of supported slash commands. Each entry shows up in the // popover when the user types `/` in the composer. The `insert` // value is what we drop into the draft when the user picks the // entry — usually the canonical command form with a trailing space // ready for an argument. const slashCommands = useMemo(() => { const list: SlashCommand[] = []; if (petEnabled) { list.push( { id: 'pet', label: '/pet', insert: '/pet ', descKey: 'pet.slashPet', icon: 'sparkles', argHint: 'wake | tuck | ', }, { id: 'pet-wake', label: '/pet wake', insert: '/pet wake', descKey: 'pet.slashPetWake', icon: 'eye', }, { id: 'pet-tuck', label: '/pet tuck', insert: '/pet tuck', descKey: 'pet.slashPetTuck', icon: 'eye', }, { id: 'hatch', label: '/hatch', insert: '/hatch ', descKey: 'pet.slashHatch', icon: 'sparkles', argHint: t('pet.slashHatchArg'), }, ); } return list; }, [petEnabled, t]); const filteredSlash = useMemo(() => { if (!slash) return [] as SlashCommand[]; const q = slash.q.toLowerCase(); if (!q) return slashCommands; return slashCommands.filter((c) => c.label.toLowerCase().includes(q)); }, [slash, slashCommands]); function pickSlash(cmd: SlashCommand) { const ta = textareaRef.current; if (!ta || !slash) return; const before = draft.slice(0, slash.cursor); const after = draft.slice(slash.cursor); // Replace the in-flight `/` token with the picked // command's canonical insertion text. const replaced = before.replace(/\/[^\s/]*$/, cmd.insert); const next = replaced + after; setDraft(next); setSlash(null); requestAnimationFrame(() => { ta.focus(); const pos = replaced.length; ta.setSelectionRange(pos, pos); }); } // Expand a `/hatch ` draft into the canonical hatch-pet // skill prompt before sending. Returns null when the draft is not a // hatch command so the caller can fall through to the regular // submit path. function expandHatchCommand(input: string): string | null { const m = /^\/hatch(?:\s+([\s\S]*))?$/i.exec(input.trim()); if (!m) return null; const concept = m[1]?.trim() ?? ''; const intro = concept ? `Hatch a Codex-compatible animated pet for me. Concept: ${concept}.` : 'Hatch a Codex-compatible animated pet for me.'; return [ intro, '', 'Use the @hatch-pet skill end-to-end:', '1. Generate the base look with $imagegen.', '2. Generate every row strip (idle, running-right, waving, jumping, failed, waiting, running, review).', '3. Mirror running-left from running-right only when the design is symmetric.', '4. Run the deterministic scripts (extract / compose / validate / contact-sheet / videos).', '5. Package the result into ${CODEX_HOME:-$HOME/.codex}/pets// with pet.json + spritesheet.webp.', '', 'When the spritesheet is saved, tell me the absolute path and the pet folder name. I will adopt it from Settings → Pets → Recently hatched.', ].join('\n'); } // Parse a `/pet [arg]` slash command out of the draft. Recognized // forms: `/pet` (toggle wake/tuck), `/pet wake`, `/pet tuck`, // `/pet adopt` (open settings), or `/pet ` to adopt a built-in // by id. The slash is stripped from the draft on a successful match // so the user does not accidentally send the command to the agent. function tryHandlePetSlash(): boolean { if (!petEnabled) return false; const trimmed = draft.trim(); const match = /^\/pet(?:\s+(\S+))?$/i.exec(trimmed); if (!match) return false; const arg = match[1]?.toLowerCase(); if (!arg || arg === 'toggle') { onTogglePet?.(); } else if (arg === 'wake' || arg === 'show') { if (petConfig?.adopted) { if (!petConfig.enabled) onTogglePet?.(); } else { onOpenPetSettings?.(); } } else if (arg === 'tuck' || arg === 'hide') { if (petConfig?.enabled) onTogglePet?.(); } else if (arg === 'adopt' || arg === 'settings' || arg === 'change') { onOpenPetSettings?.(); } else if (arg === CUSTOM_PET_ID) { onAdoptPet?.(CUSTOM_PET_ID); } else { const pet = BUILT_IN_PETS.find((p) => p.id === arg); if (pet) { onAdoptPet?.(pet.id); } else { return false; } } setDraft(''); return true; } useImperativeHandle( ref, () => ({ setDraft: (text: string) => { setDraft(text); seededRef.current = true; requestAnimationFrame(() => { const ta = textareaRef.current; if (!ta) return; ta.focus(); const pos = text.length; ta.setSelectionRange(pos, pos); }); }, focus: () => { textareaRef.current?.focus(); }, }), [] ); function reset() { setDraft(""); setStaged([]); setUploadError(null); setMention(null); setSlash(null); } async function ensureProject(): Promise { if (projectId) return projectId; return onEnsureProject(); } async function uploadFiles(files: File[]) { if (files.length === 0) return; const id = await ensureProject(); if (!id) return; setUploading(true); setUploadError(null); try { const result = await uploadProjectFiles(id, files); if (result.uploaded.length > 0) { setStaged((s) => [...s, ...result.uploaded]); } if (result.failed.length > 0) { const failedCount = result.failed.length; const uploadedCount = result.uploaded.length; const detail = result.error ? ` (${result.error})` : ''; setUploadError( uploadedCount > 0 ? `Attached ${uploadedCount} file(s), but ${failedCount} failed${detail}.` : `Attachment upload failed for ${failedCount} file(s)${detail}.`, ); console.warn('Some attachments failed to upload', result.failed); } } finally { setUploading(false); } } function handlePaste(e: React.ClipboardEvent) { const items = Array.from(e.clipboardData?.items ?? []); const files: File[] = []; for (const item of items) { if (item.kind === "file") { const f = item.getAsFile(); if (f) files.push(f); } } if (files.length > 0) { e.preventDefault(); void uploadFiles(files); } } function handleDrop(e: React.DragEvent) { e.preventDefault(); setDragActive(false); const files = Array.from(e.dataTransfer.files ?? []); if (files.length > 0) void uploadFiles(files); } function handleChange(e: React.ChangeEvent) { const value = e.target.value; const cursor = e.target.selectionStart; setDraft(value); // Detect a fresh @ at start or after whitespace; capture the typed // query up to the cursor. const before = value.slice(0, cursor); const m = /(^|\s)@([^\s@]*)$/.exec(before); if (m) setMention({ q: m[2] ?? "", cursor }); else setMention(null); // Slash-command popover — open as soon as the draft starts with // `/` (and the cursor is still inside the bare command token, no // space yet). Closes once the user commits a space or moves past // the prefix. const slashMatch = /^\/([^\s/]*)$/.exec(before); if (slashMatch) { setSlash({ q: slashMatch[1] ?? '', cursor }); setSlashIndex(0); } else { setSlash(null); } } function insertMention(filePath: string) { if (!mention) return; const ta = textareaRef.current; if (!ta) return; const cursor = mention.cursor; const before = draft.slice(0, cursor); const after = draft.slice(cursor); const replaced = before.replace(/@([^\s@]*)$/, `@${filePath} `); const next = replaced + after; setDraft(next); setMention(null); if (!staged.some((s) => s.path === filePath)) { setStaged((s) => [ ...s, { path: filePath, name: filePath.split("/").pop() || filePath, kind: looksLikeImage(filePath) ? "image" : "file", }, ]); } requestAnimationFrame(() => { ta.focus(); const pos = replaced.length; ta.setSelectionRange(pos, pos); }); } function removeStaged(p: string) { setStaged((s) => s.filter((a) => a.path !== p)); } async function submit() { const prompt = draft.trim(); // Intercept `/pet …` before sending so the slash command never // hits the agent — it is a local UX hook, not a model prompt. if (tryHandlePetSlash()) return; // `/hatch ` expands into the canonical hatch-pet skill // prompt and *is* sent to the agent — the agent runs the skill, // packages a Codex pet under `~/.codex/pets/`, and the user // adopts it from "Recently hatched" in pet settings afterwards. const hatched = expandHatchCommand(prompt); if (hatched) { if (streaming) return; onSend(hatched, staged, commentAttachments); reset(); return; } if ((!prompt && commentAttachments.length === 0) || streaming) return; onSend(prompt, staged, commentAttachments); reset(); } // The @-picker treats the project listing as path-shaped (path + size). // ProjectFile.path is optional, so fall back to .name for the legacy // flat shape — both ChatComposer and the old code paths see the same // entries. const filteredFiles = mention ? projectFiles .filter((f) => f.type === undefined || f.type === "file") .filter((f) => { const key = f.path ?? f.name; return key.toLowerCase().includes(mention.q.toLowerCase()); }) .slice(0, 12) : []; return (
{ e.preventDefault(); setDragActive(true); }} onDragLeave={() => setDragActive(false)} onDrop={handleDrop} >
{staged.length > 0 ? ( ) : null} {commentAttachments.length > 0 ? ( onRemoveCommentAttachment?.(id)} t={t} /> ) : null}