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.
This commit is contained in:
1 parent
477a0dff4a
commit
3799f73069
2 files changed
+133
-11
No files matched your search
@@ -1,11 +1,26 @@
|
|||||||
import { useState, type FormEvent, type MutableRefObject } from "react";
|
import { useEffect, useRef, useState, type FormEvent, type MutableRefObject } from "react";
|
||||||
import { Button, Icon, IconButton } from "../../shared/ui";
|
import { Avatar, Button, Icon, IconButton } from "../../shared/ui";
|
||||||
import type { FileObjectRecord } from "../../../../shared/domain";
|
import type { AgentRecord, EmployeeRecord, FileObjectRecord } from "../../../../shared/domain";
|
||||||
import { SLASH_COMMANDS } from "./chat-helpers";
|
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({
|
export function ChatComposer({
|
||||||
disabled,
|
disabled,
|
||||||
files,
|
files,
|
||||||
|
agents,
|
||||||
|
employees,
|
||||||
attachmentFileId,
|
attachmentFileId,
|
||||||
onAttachmentChange,
|
onAttachmentChange,
|
||||||
onAttach,
|
onAttach,
|
||||||
@@ -14,6 +29,8 @@ export function ChatComposer({
|
|||||||
}: {
|
}: {
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
files: FileObjectRecord[];
|
files: FileObjectRecord[];
|
||||||
|
agents: AgentRecord[];
|
||||||
|
employees: EmployeeRecord[];
|
||||||
attachmentFileId: string;
|
attachmentFileId: string;
|
||||||
onAttachmentChange: (next: string) => void;
|
onAttachmentChange: (next: string) => void;
|
||||||
onAttach: () => void;
|
onAttach: () => void;
|
||||||
@@ -21,7 +38,30 @@ export function ChatComposer({
|
|||||||
composerRef: MutableRefObject<HTMLTextAreaElement | null>;
|
composerRef: MutableRefObject<HTMLTextAreaElement | null>;
|
||||||
}) {
|
}) {
|
||||||
const [slashOpen, setSlashOpen] = useState(false);
|
const [slashOpen, setSlashOpen] = useState(false);
|
||||||
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||||
|
const [mentionIndex, setMentionIndex] = useState(0);
|
||||||
|
const mentionAnchor = useRef(0);
|
||||||
const attachment = files.find((file) => file.id === attachmentFileId) ?? null;
|
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 insert = (text: string) => {
|
||||||
const node = composerRef.current;
|
const node = composerRef.current;
|
||||||
@@ -36,9 +76,92 @@ export function ChatComposer({
|
|||||||
node.focus();
|
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<HTMLTextAreaElement>) => {
|
||||||
|
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 (
|
return (
|
||||||
<form onSubmit={onSubmit} style={{ padding: 14, paddingTop: 4, flexShrink: 0 }}>
|
<form onSubmit={onSubmit} style={{ padding: 14, paddingTop: 4, flexShrink: 0 }}>
|
||||||
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 12, background: "var(--panel)", padding: "10px 12px" }}>
|
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 12, background: "var(--panel)", padding: "10px 12px", position: "relative" }}>
|
||||||
|
{mentionQuery !== null && filtered.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "100%",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
marginBottom: 4,
|
||||||
|
background: "var(--panel)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 10,
|
||||||
|
boxShadow: "0 4px 16px rgba(0,0,0,0.12)",
|
||||||
|
maxHeight: 200,
|
||||||
|
overflow: "auto",
|
||||||
|
zIndex: 10,
|
||||||
|
}}>
|
||||||
|
{filtered.map((c, i) => (
|
||||||
|
<button
|
||||||
|
key={c.handle}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => { e.preventDefault(); insertMention(c.handle); }}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
border: 0,
|
||||||
|
background: i === mentionIndex ? "var(--accent-soft)" : "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--text-1)",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar name={c.handle} size="sm" bot={c.bot} />
|
||||||
|
<span style={{ fontWeight: 500 }}>{c.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
ref={composerRef}
|
ref={composerRef}
|
||||||
name="message"
|
name="message"
|
||||||
@@ -56,12 +179,9 @@ export function ChatComposer({
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
minHeight: 38,
|
minHeight: 38,
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onInput={handleInput}
|
||||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
onKeyDown={handleKeyDown}
|
||||||
event.preventDefault();
|
onBlur={() => setTimeout(closeMention, 200)}
|
||||||
event.currentTarget.form?.requestSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className="row" style={{ paddingTop: 6, borderTop: "1px solid var(--border-soft)", gap: 4 }}>
|
<div className="row" style={{ paddingTop: 6, borderTop: "1px solid var(--border-soft)", gap: 4 }}>
|
||||||
<IconButton icon="attach" label="Attach file" onClick={onAttach} disabled={disabled} />
|
<IconButton icon="attach" label="Attach file" onClick={onAttach} disabled={disabled} />
|
||||||
@@ -81,7 +201,7 @@ export function ChatComposer({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
|
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>⌘↵</span>
|
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>Ctrl+Enter</span>
|
||||||
<Button tone="primary" size="small" type="submit" disabled={disabled}><Icon name="send" size={12} /> Send</Button>
|
<Button tone="primary" size="small" type="submit" disabled={disabled}><Icon name="send" size={12} /> Send</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ export function ChatConversation({
|
|||||||
<ChatComposer
|
<ChatComposer
|
||||||
disabled={!space}
|
disabled={!space}
|
||||||
files={files}
|
files={files}
|
||||||
|
agents={context.agents}
|
||||||
|
employees={context.employees}
|
||||||
attachmentFileId={attachmentFileId}
|
attachmentFileId={attachmentFileId}
|
||||||
onAttachmentChange={onAttachmentChange}
|
onAttachmentChange={onAttachmentChange}
|
||||||
onAttach={onAttach}
|
onAttach={onAttach}
|
||||||
|
|||||||
Reference in new issue
Block a user