import { useCallback, useEffect, useRef, useState } from "react"; import type { ProjectMember } from "@/lib/api"; import { getProjectMembers } from "@/lib/api"; interface Props { projectSlug: string; value: string; onChange: (value: string) => void; onSubmit: () => void; placeholder?: string; disabled?: boolean; } export default function MentionInput({ projectSlug, value, onChange, onSubmit, placeholder, disabled }: Props) { const [members, setMembers] = useState([]); const [showDropdown, setShowDropdown] = useState(false); const [filter, setFilter] = useState(""); const [selectedIdx, setSelectedIdx] = useState(0); const [mentionStart, setMentionStart] = useState(-1); const inputRef = useRef(null); const dropdownRef = useRef(null); // Load members once useEffect(() => { if (!projectSlug) return; getProjectMembers(projectSlug).then(setMembers).catch(() => {}); }, [projectSlug]); const filtered = members.filter((m) => m.name.toLowerCase().includes(filter.toLowerCase()) || m.slug.toLowerCase().includes(filter.toLowerCase()) ); const insertMention = useCallback((member: ProjectMember) => { if (mentionStart < 0) return; const before = value.slice(0, mentionStart); const after = value.slice(inputRef.current?.selectionStart ?? value.length); const newValue = `${before}@${member.slug} ${after}`; onChange(newValue); setShowDropdown(false); setMentionStart(-1); // Focus and set cursor after mention requestAnimationFrame(() => { const el = inputRef.current; if (el) { const pos = before.length + member.slug.length + 2; // @slug + space el.focus(); el.setSelectionRange(pos, pos); } }); }, [value, mentionStart, onChange]); const handleChange = (e: React.ChangeEvent) => { const newVal = e.target.value; onChange(newVal); const cursorPos = e.target.selectionStart; // Find @ before cursor const textBeforeCursor = newVal.slice(0, cursorPos); const lastAt = textBeforeCursor.lastIndexOf("@"); if (lastAt >= 0) { // Check: @ must be at start or after whitespace const charBefore = lastAt > 0 ? textBeforeCursor[lastAt - 1] : " "; if (/\s/.test(charBefore) || lastAt === 0) { const query = textBeforeCursor.slice(lastAt + 1); // No spaces in mention query if (!/\s/.test(query)) { setMentionStart(lastAt); setFilter(query); setShowDropdown(true); setSelectedIdx(0); return; } } } setShowDropdown(false); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (showDropdown && filtered.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIdx((i) => (i + 1) % filtered.length); return; } if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIdx((i) => (i - 1 + filtered.length) % filtered.length); return; } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); insertMention(filtered[selectedIdx]); return; } if (e.key === "Escape") { e.preventDefault(); setShowDropdown(false); return; } } if (e.key === "Enter" && !e.shiftKey && !showDropdown) { e.preventDefault(); onSubmit(); } }; const ICON: Record = { human: "👤", agent: "🤖" }; return (