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.
This commit is contained in:
1 parent
1997b4aea5
commit
8f4ccc1b7a
3 files changed
+190
-149
No files matched your search
@@ -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<HTMLTextAreaElement | null>;
|
||||
}) {
|
||||
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 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<HTMLTextAreaElement | null>(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<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 (
|
||||
<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", 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
|
||||
ref={composerRef}
|
||||
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 12, background: "var(--panel)", padding: "10px 12px" }}>
|
||||
<MentionTextarea
|
||||
name="message"
|
||||
rows={2}
|
||||
disabled={disabled}
|
||||
aria-label="Message"
|
||||
style={{
|
||||
width: "100%",
|
||||
border: 0,
|
||||
resize: "none",
|
||||
outline: "none",
|
||||
fontSize: 13,
|
||||
color: "var(--text-1)",
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
minHeight: 38,
|
||||
}}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => setTimeout(closeMention, 200)}
|
||||
ariaLabel="Message"
|
||||
agents={agents}
|
||||
employees={employees}
|
||||
textareaRef={ref}
|
||||
style={{ minHeight: 38 }}
|
||||
/>
|
||||
<div className="row" style={{ paddingTop: 6, borderTop: "1px solid var(--border-soft)", gap: 4 }}>
|
||||
<IconButton icon="attach" label="Attach file" onClick={onAttach} disabled={disabled} />
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from "../../../../shared/domain";
|
||||
import { auditEventLabel, formatTime } from "../types";
|
||||
import { ChatMessageRow } from "./ChatMessageRow";
|
||||
import { MentionTextarea } from "./MentionTextarea";
|
||||
import { senderName, type MessageContext } from "./chat-helpers";
|
||||
|
||||
export function ChatThreadPanel({
|
||||
@@ -121,17 +122,13 @@ export function ChatThreadPanel({
|
||||
</div>
|
||||
<form onSubmit={onSubmit} style={{ padding: 12, flexShrink: 0 }}>
|
||||
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 10, background: "var(--panel)", padding: "8px 10px" }}>
|
||||
<textarea
|
||||
<MentionTextarea
|
||||
name="message"
|
||||
rows={2}
|
||||
aria-label="Thread reply"
|
||||
style={{ width: "100%", border: 0, resize: "none", outline: "none", fontSize: 13, background: "transparent", padding: 0, minHeight: 36 }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
ariaLabel="Thread reply"
|
||||
agents={context.agents}
|
||||
employees={context.employees}
|
||||
style={{ minHeight: 36 }}
|
||||
/>
|
||||
<div className="row" style={{ paddingTop: 6, gap: 4 }}>
|
||||
<IconButton icon="attach" label="Attach file" onClick={onAttach} />
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import { Avatar } from "../../shared/ui";
|
||||
import type { AgentRecord, EmployeeRecord } from "../../../../shared/domain";
|
||||
|
||||
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 MentionTextarea({
|
||||
name,
|
||||
rows,
|
||||
disabled,
|
||||
ariaLabel,
|
||||
agents,
|
||||
employees,
|
||||
textareaRef,
|
||||
style,
|
||||
}: {
|
||||
name: string;
|
||||
rows: number;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
agents: AgentRecord[];
|
||||
employees: EmployeeRecord[];
|
||||
textareaRef?: MutableRefObject<HTMLTextAreaElement | null>;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const [query, setQuery] = useState<string | null>(null);
|
||||
const [index, setIndex] = useState(0);
|
||||
const anchor = useRef(0);
|
||||
const innerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const ref = textareaRef ?? innerRef;
|
||||
const candidates = buildCandidates(agents, employees);
|
||||
const filtered = query !== null
|
||||
? candidates.filter((c) => c.handle.toLowerCase().includes(query.toLowerCase()))
|
||||
: [];
|
||||
|
||||
useEffect(() => { setIndex(0); }, [query]);
|
||||
|
||||
const close = () => setQuery(null);
|
||||
|
||||
const insertMention = (handle: string) => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const before = node.value.slice(0, anchor.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();
|
||||
close();
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const pos = node.selectionStart;
|
||||
const text = node.value.slice(0, pos);
|
||||
const atMatch = text.match(/(^|\s)@(\S*)$/);
|
||||
if (atMatch) {
|
||||
anchor.current = pos - atMatch[2].length - 1;
|
||||
setQuery(atMatch[2]);
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (query !== null && filtered.length > 0) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setIndex((i) => (i + 1) % filtered.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setIndex((i) => (i - 1 + filtered.length) % filtered.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
insertMention(filtered[index].handle);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
{query !== 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 === index ? "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
|
||||
ref={ref}
|
||||
name={name}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
style={{
|
||||
width: "100%",
|
||||
border: 0,
|
||||
resize: "none",
|
||||
outline: "none",
|
||||
fontSize: 13,
|
||||
color: "var(--text-1)",
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
...style,
|
||||
}}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => setTimeout(close, 200)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user