From f4693e8f0b7c836266e1c217a37abe6711863d00 Mon Sep 17 00:00:00 2001 From: Markov Date: Wed, 25 Feb 2026 06:01:30 +0100 Subject: [PATCH] feat: @mention autocomplete in chat input + mention highlighting --- src/components/ChatPanel.tsx | 32 +++++-- src/components/MentionInput.tsx | 155 ++++++++++++++++++++++++++++++++ src/pages/ProjectPage.tsx | 2 +- 3 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 src/components/MentionInput.tsx diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index f4cb4ef..89ba113 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Message } from "@/lib/api"; import { getMessages, sendMessage } from "@/lib/api"; import { wsClient } from "@/lib/ws"; +import MentionInput from "./MentionInput"; const PAGE_SIZE = 30; @@ -13,9 +14,10 @@ const AUTHOR_ICON: Record = { interface Props { chatId: string; + projectSlug?: string; } -export default function ChatPanel({ chatId }: Props) { +export default function ChatPanel({ chatId, projectSlug }: Props) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); @@ -114,6 +116,17 @@ export default function ChatPanel({ chatId }: Props) { } }; + const renderContent = (text: string) => { + const parts = text.split(/(@\w+)/g); + return parts.map((part, i) => + /^@\w+$/.test(part) ? ( + {part} + ) : ( + {part} + ) + ); + }; + const renderMessage = (msg: Message) => { const bgStyle = msg.author_type === "system" @@ -129,7 +142,7 @@ export default function ChatPanel({ chatId }: Props) { · {new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })} -
{msg.content}
+
{renderContent(msg.content)}
); }; @@ -153,18 +166,19 @@ export default function ChatPanel({ chatId }: Props) { )}
-
- + setInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSend()} - placeholder="Сообщение..." - className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)]" + onChange={setInput} + onSubmit={handleSend} + placeholder="Сообщение... (@mention)" + disabled={sending} /> diff --git a/src/components/MentionInput.tsx b/src/components/MentionInput.tsx new file mode 100644 index 0000000..002946d --- /dev/null +++ b/src/components/MentionInput.tsx @@ -0,0 +1,155 @@ +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 ( +
+