From 8f4ccc1b7a382bae8cb2ee0df57c8a19fa0baaa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 7 Jun 2026 17:29:51 -0400 Subject: [PATCH] Extract MentionTextarea and add @mention to thread replies Shared MentionTextarea component provides @mention autocomplete in both the channel composer and the thread reply composer. Type @ to see agents and employees, arrow-navigate, Enter to select. --- .../renderer/src/pages/chat/ChatComposer.tsx | 153 ++-------------- .../src/pages/chat/ChatThreadPanel.tsx | 15 +- .../src/pages/chat/MentionTextarea.tsx | 171 ++++++++++++++++++ 3 files changed, 190 insertions(+), 149 deletions(-) create mode 100644 AgentCompany-New/src/renderer/src/pages/chat/MentionTextarea.tsx diff --git a/AgentCompany-New/src/renderer/src/pages/chat/ChatComposer.tsx b/AgentCompany-New/src/renderer/src/pages/chat/ChatComposer.tsx index 3a324f33..3f7b7937 100644 --- a/AgentCompany-New/src/renderer/src/pages/chat/ChatComposer.tsx +++ b/AgentCompany-New/src/renderer/src/pages/chat/ChatComposer.tsx @@ -1,20 +1,8 @@ -import { useEffect, useRef, useState, type FormEvent, type MutableRefObject } from "react"; -import { Avatar, Button, Icon, IconButton } from "../../shared/ui"; +import { useRef, useState, type FormEvent, type MutableRefObject } from "react"; +import { Button, Icon, IconButton } from "../../shared/ui"; import type { AgentRecord, EmployeeRecord, FileObjectRecord } from "../../../../shared/domain"; import { SLASH_COMMANDS } from "./chat-helpers"; - -interface MentionCandidate { - handle: string; - label: string; - bot: boolean; -} - -function buildCandidates(agents: AgentRecord[], employees: EmployeeRecord[]): MentionCandidate[] { - return [ - ...agents.map((a) => ({ handle: a.name, label: `${a.name} · ${a.role}`, bot: true })), - ...employees.map((e) => ({ handle: e.displayName, label: `${e.displayName} · ${e.role}`, bot: false })), - ]; -} +import { MentionTextarea } from "./MentionTextarea"; export function ChatComposer({ disabled, @@ -38,33 +26,12 @@ export function ChatComposer({ composerRef: MutableRefObject; }) { const [slashOpen, setSlashOpen] = useState(false); - const [mentionQuery, setMentionQuery] = useState(null); - const [mentionIndex, setMentionIndex] = useState(0); - const mentionAnchor = useRef(0); const attachment = files.find((file) => file.id === attachmentFileId) ?? null; - const candidates = buildCandidates(agents, employees); - const filtered = mentionQuery !== null - ? candidates.filter((c) => c.handle.toLowerCase().includes(mentionQuery.toLowerCase())) - : []; - - useEffect(() => { setMentionIndex(0); }, [mentionQuery]); - - const closeMention = () => setMentionQuery(null); - - const insertMention = (handle: string) => { - const node = composerRef.current; - if (!node) return; - const before = node.value.slice(0, mentionAnchor.current); - const after = node.value.slice(node.selectionStart); - node.value = `${before}@${handle} ${after}`; - const caret = before.length + handle.length + 2; - node.setSelectionRange(caret, caret); - node.focus(); - closeMention(); - }; + const internalRef = useRef(null); + const ref = composerRef ?? internalRef; const insert = (text: string) => { - const node = composerRef.current; + const node = ref.current; if (!node) return; const start = node.selectionStart; const end = node.selectionEnd; @@ -76,112 +43,18 @@ export function ChatComposer({ node.focus(); }; - const handleInput = () => { - const node = composerRef.current; - if (!node) return; - const pos = node.selectionStart; - const text = node.value.slice(0, pos); - const atMatch = text.match(/(^|\s)@(\S*)$/); - if (atMatch) { - mentionAnchor.current = pos - atMatch[2].length - 1; - setMentionQuery(atMatch[2]); - } else { - closeMention(); - } - }; - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (mentionQuery !== null && filtered.length > 0) { - if (event.key === "ArrowDown") { - event.preventDefault(); - setMentionIndex((i) => (i + 1) % filtered.length); - return; - } - if (event.key === "ArrowUp") { - event.preventDefault(); - setMentionIndex((i) => (i - 1 + filtered.length) % filtered.length); - return; - } - if (event.key === "Enter" || event.key === "Tab") { - event.preventDefault(); - insertMention(filtered[mentionIndex].handle); - return; - } - if (event.key === "Escape") { - event.preventDefault(); - closeMention(); - return; - } - } - if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - event.currentTarget.form?.requestSubmit(); - } - }; - return (
-
- {mentionQuery !== null && filtered.length > 0 && ( -
- {filtered.map((c, i) => ( - - ))} -
- )} -