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:
ZacharyZhang-NY committed 2026-06-07 17:29:51 -04:00
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>
);
}