From 3799f73069ac4b89031e2950f4c26dfd45ab87a2 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:24:40 -0400 Subject: [PATCH] Add @mention autocomplete to chat composer Type @ in the message composer to see a dropdown of agents and employees. Arrow keys navigate, Enter/Tab selects, Escape dismisses. The selected handle is inserted at the cursor position. --- .../renderer/src/pages/chat/ChatComposer.tsx | 142 ++++++++++++++++-- .../src/pages/chat/ChatConversation.tsx | 2 + 2 files changed, 133 insertions(+), 11 deletions(-) diff --git a/AgentCompany-New/src/renderer/src/pages/chat/ChatComposer.tsx b/AgentCompany-New/src/renderer/src/pages/chat/ChatComposer.tsx index 158a0834..3a324f33 100644 --- a/AgentCompany-New/src/renderer/src/pages/chat/ChatComposer.tsx +++ b/AgentCompany-New/src/renderer/src/pages/chat/ChatComposer.tsx @@ -1,11 +1,26 @@ -import { useState, type FormEvent, type MutableRefObject } from "react"; -import { Button, Icon, IconButton } from "../../shared/ui"; -import type { FileObjectRecord } from "../../../../shared/domain"; +import { useEffect, useRef, useState, type FormEvent, type MutableRefObject } from "react"; +import { Avatar, 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 })), + ]; +} + export function ChatComposer({ disabled, files, + agents, + employees, attachmentFileId, onAttachmentChange, onAttach, @@ -14,6 +29,8 @@ export function ChatComposer({ }: { disabled: boolean; files: FileObjectRecord[]; + agents: AgentRecord[]; + employees: EmployeeRecord[]; attachmentFileId: string; onAttachmentChange: (next: string) => void; onAttach: () => void; @@ -21,7 +38,30 @@ 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 insert = (text: string) => { const node = composerRef.current; @@ -36,9 +76,92 @@ 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) => ( + + ))} +
+ )}