feat: @mention autocomplete in chat input + mention highlighting
This commit is contained in:
parent
1c832ff99f
commit
f4693e8f0b
@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import type { Message } from "@/lib/api";
|
import type { Message } from "@/lib/api";
|
||||||
import { getMessages, sendMessage } from "@/lib/api";
|
import { getMessages, sendMessage } from "@/lib/api";
|
||||||
import { wsClient } from "@/lib/ws";
|
import { wsClient } from "@/lib/ws";
|
||||||
|
import MentionInput from "./MentionInput";
|
||||||
|
|
||||||
const PAGE_SIZE = 30;
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
@ -13,9 +14,10 @@ const AUTHOR_ICON: Record<string, string> = {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
projectSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatPanel({ chatId }: Props) {
|
export default function ChatPanel({ chatId, projectSlug }: Props) {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
@ -114,6 +116,17 @@ export default function ChatPanel({ chatId }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderContent = (text: string) => {
|
||||||
|
const parts = text.split(/(@\w+)/g);
|
||||||
|
return parts.map((part, i) =>
|
||||||
|
/^@\w+$/.test(part) ? (
|
||||||
|
<span key={i} className="text-[var(--accent)] font-medium">{part}</span>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{part}</span>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderMessage = (msg: Message) => {
|
const renderMessage = (msg: Message) => {
|
||||||
const bgStyle =
|
const bgStyle =
|
||||||
msg.author_type === "system"
|
msg.author_type === "system"
|
||||||
@ -129,7 +142,7 @@ export default function ChatPanel({ chatId }: Props) {
|
|||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
|
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-5 whitespace-pre-wrap">{msg.content}</div>
|
<div className="ml-5 whitespace-pre-wrap">{renderContent(msg.content)}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -153,18 +166,19 @@ export default function ChatPanel({ chatId }: Props) {
|
|||||||
)}
|
)}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-3 border-t border-[var(--border)] flex gap-2">
|
<div className="px-6 py-3 border-t border-[var(--border)] flex gap-2 items-end">
|
||||||
<input
|
<MentionInput
|
||||||
|
projectSlug={projectSlug || ""}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={setInput}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
onSubmit={handleSend}
|
||||||
placeholder="Сообщение..."
|
placeholder="Сообщение... (@mention)"
|
||||||
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)]"
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
|
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 shrink-0"
|
||||||
>
|
>
|
||||||
↵
|
↵
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
155
src/components/MentionInput.tsx
Normal file
155
src/components/MentionInput.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { ProjectMember } from "@/lib/api";
|
||||||
|
import { getProjectMembers } from "@/lib/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectSlug: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MentionInput({ projectSlug, value, onChange, onSubmit, placeholder, disabled }: Props) {
|
||||||
|
const [members, setMembers] = useState<ProjectMember[]>([]);
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||||
|
const [mentionStart, setMentionStart] = useState(-1);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Load members once
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectSlug) return;
|
||||||
|
getProjectMembers(projectSlug).then(setMembers).catch(() => {});
|
||||||
|
}, [projectSlug]);
|
||||||
|
|
||||||
|
const filtered = members.filter((m) =>
|
||||||
|
m.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
m.slug.toLowerCase().includes(filter.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertMention = useCallback((member: ProjectMember) => {
|
||||||
|
if (mentionStart < 0) return;
|
||||||
|
const before = value.slice(0, mentionStart);
|
||||||
|
const after = value.slice(inputRef.current?.selectionStart ?? value.length);
|
||||||
|
const newValue = `${before}@${member.slug} ${after}`;
|
||||||
|
onChange(newValue);
|
||||||
|
setShowDropdown(false);
|
||||||
|
setMentionStart(-1);
|
||||||
|
// Focus and set cursor after mention
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = inputRef.current;
|
||||||
|
if (el) {
|
||||||
|
const pos = before.length + member.slug.length + 2; // @slug + space
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(pos, pos);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [value, mentionStart, onChange]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newVal = e.target.value;
|
||||||
|
onChange(newVal);
|
||||||
|
|
||||||
|
const cursorPos = e.target.selectionStart;
|
||||||
|
// Find @ before cursor
|
||||||
|
const textBeforeCursor = newVal.slice(0, cursorPos);
|
||||||
|
const lastAt = textBeforeCursor.lastIndexOf("@");
|
||||||
|
|
||||||
|
if (lastAt >= 0) {
|
||||||
|
// Check: @ must be at start or after whitespace
|
||||||
|
const charBefore = lastAt > 0 ? textBeforeCursor[lastAt - 1] : " ";
|
||||||
|
if (/\s/.test(charBefore) || lastAt === 0) {
|
||||||
|
const query = textBeforeCursor.slice(lastAt + 1);
|
||||||
|
// No spaces in mention query
|
||||||
|
if (!/\s/.test(query)) {
|
||||||
|
setMentionStart(lastAt);
|
||||||
|
setFilter(query);
|
||||||
|
setShowDropdown(true);
|
||||||
|
setSelectedIdx(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShowDropdown(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (showDropdown && filtered.length > 0) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIdx((i) => (i + 1) % filtered.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIdx((i) => (i - 1 + filtered.length) % filtered.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" || e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
insertMention(filtered[selectedIdx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowDropdown(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && !showDropdown) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICON: Record<string, string> = { human: "👤", agent: "🤖" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
rows={1}
|
||||||
|
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)] resize-none"
|
||||||
|
style={{ minHeight: "38px", maxHeight: "120px" }}
|
||||||
|
onInput={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 120) + "px";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showDropdown && filtered.length > 0 && (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="absolute bottom-full left-0 mb-1 w-64 bg-[var(--surface)] border border-[var(--border)] rounded-lg shadow-lg overflow-hidden z-50"
|
||||||
|
>
|
||||||
|
{filtered.map((m, i) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
className={`w-full text-left px-3 py-2 text-sm flex items-center gap-2 hover:bg-white/10 ${
|
||||||
|
i === selectedIdx ? "bg-white/10" : ""
|
||||||
|
}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault(); // prevent blur
|
||||||
|
insertMention(m);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{ICON[m.type] || "👤"}</span>
|
||||||
|
<span className="font-medium">{m.name}</span>
|
||||||
|
<span className="text-[var(--muted)]">@{m.slug}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -101,7 +101,7 @@ export default function ProjectPage() {
|
|||||||
<KanbanBoard projectId={project.id} projectSlug={project.slug} />
|
<KanbanBoard projectId={project.id} projectSlug={project.slug} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "chat" && project.chat_id && (
|
{activeTab === "chat" && project.chat_id && (
|
||||||
<ChatPanel chatId={project.chat_id} />
|
<ChatPanel chatId={project.chat_id} projectSlug={project.slug} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "chat" && !project.chat_id && (
|
{activeTab === "chat" && !project.chat_id && (
|
||||||
<div className="flex items-center justify-center h-full text-[var(--muted)] text-sm">
|
<div className="flex items-center justify-center h-full text-[var(--muted)] text-sm">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user