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 { useRef, useState, type FormEvent, type MutableRefObject } from "react";
|
||||||
import { Avatar, Button, Icon, IconButton } from "../../shared/ui";
|
import { Button, Icon, IconButton } from "../../shared/ui";
|
||||||
import type { AgentRecord, EmployeeRecord, FileObjectRecord } from "../../../../shared/domain";
|
import type { AgentRecord, EmployeeRecord, FileObjectRecord } from "../../../../shared/domain";
|
||||||
import { SLASH_COMMANDS } from "./chat-helpers";
|
import { SLASH_COMMANDS } from "./chat-helpers";
|
||||||
|
import { MentionTextarea } from "./MentionTextarea";
|
||||||
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,
|
||||||
@@ -38,33 +26,12 @@ 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 internalRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const filtered = mentionQuery !== null
|
const ref = composerRef ?? internalRef;
|
||||||
? 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 = ref.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const start = node.selectionStart;
|
const start = node.selectionStart;
|
||||||
const end = node.selectionEnd;
|
const end = node.selectionEnd;
|
||||||
@@ -76,112 +43,18 @@ 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", position: "relative" }}>
|
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 12, background: "var(--panel)", padding: "10px 12px" }}>
|
||||||
{mentionQuery !== null && filtered.length > 0 && (
|
<MentionTextarea
|
||||||
<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}
|
|
||||||
name="message"
|
name="message"
|
||||||
rows={2}
|
rows={2}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label="Message"
|
ariaLabel="Message"
|
||||||
style={{
|
agents={agents}
|
||||||
width: "100%",
|
employees={employees}
|
||||||
border: 0,
|
textareaRef={ref}
|
||||||
resize: "none",
|
style={{ minHeight: 38 }}
|
||||||
outline: "none",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--text-1)",
|
|
||||||
background: "transparent",
|
|
||||||
padding: 0,
|
|
||||||
minHeight: 38,
|
|
||||||
}}
|
|
||||||
onInput={handleInput}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onBlur={() => setTimeout(closeMention, 200)}
|
|
||||||
/>
|
/>
|
||||||
<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} />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
} from "../../../../shared/domain";
|
} from "../../../../shared/domain";
|
||||||
import { auditEventLabel, formatTime } from "../types";
|
import { auditEventLabel, formatTime } from "../types";
|
||||||
import { ChatMessageRow } from "./ChatMessageRow";
|
import { ChatMessageRow } from "./ChatMessageRow";
|
||||||
|
import { MentionTextarea } from "./MentionTextarea";
|
||||||
import { senderName, type MessageContext } from "./chat-helpers";
|
import { senderName, type MessageContext } from "./chat-helpers";
|
||||||
|
|
||||||
export function ChatThreadPanel({
|
export function ChatThreadPanel({
|
||||||
@@ -121,17 +122,13 @@ export function ChatThreadPanel({
|
|||||||
</div>
|
</div>
|
||||||
<form onSubmit={onSubmit} style={{ padding: 12, flexShrink: 0 }}>
|
<form onSubmit={onSubmit} style={{ padding: 12, flexShrink: 0 }}>
|
||||||
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 10, background: "var(--panel)", padding: "8px 10px" }}>
|
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 10, background: "var(--panel)", padding: "8px 10px" }}>
|
||||||
<textarea
|
<MentionTextarea
|
||||||
name="message"
|
name="message"
|
||||||
rows={2}
|
rows={2}
|
||||||
aria-label="Thread reply"
|
ariaLabel="Thread reply"
|
||||||
style={{ width: "100%", border: 0, resize: "none", outline: "none", fontSize: 13, background: "transparent", padding: 0, minHeight: 36 }}
|
agents={context.agents}
|
||||||
onKeyDown={(event) => {
|
employees={context.employees}
|
||||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
style={{ minHeight: 36 }}
|
||||||
event.preventDefault();
|
|
||||||
event.currentTarget.form?.requestSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className="row" style={{ paddingTop: 6, gap: 4 }}>
|
<div className="row" style={{ paddingTop: 6, gap: 4 }}>
|
||||||
<IconButton icon="attach" label="Attach file" onClick={onAttach} />
|
<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