web-client-vite/src/components/MentionInput.tsx

156 lines
5.0 KiB
TypeScript

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<ProjectMember[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const [filter, setFilter] = useState("");
const [selectedIdx, setSelectedIdx] = useState(0);
const [mentionStart, setMentionStart] = useState(-1);
const inputRef = useRef<HTMLTextAreaElement>(null);
const dropdownRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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<string, string> = { human: "👤", agent: "🤖" };
return (
<div className="relative flex-1">
<textarea
ref={inputRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)] resize-none"
style={{ minHeight: "38px", maxHeight: "120px" }}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 120) + "px";
}}
/>
{showDropdown && filtered.length > 0 && (
<div
ref={dropdownRef}
className="absolute bottom-full left-0 mb-1 w-64 bg-[var(--surface)] border border-[var(--border)] rounded-lg shadow-lg overflow-hidden z-50"
>
{filtered.map((m, i) => (
<button
key={m.id}
className={`w-full text-left px-3 py-2 text-sm flex items-center gap-2 hover:bg-white/10 ${
i === selectedIdx ? "bg-white/10" : ""
}`}
onMouseDown={(e) => {
e.preventDefault(); // prevent blur
insertMention(m);
}}
>
<span>{ICON[m.type] || "👤"}</span>
<span className="font-medium">{m.name}</span>
<span className="text-[var(--muted)]">@{m.slug}</span>
</button>
))}
</div>
)}
</div>
);
}