refactor(frontend): rewrite mention system with clean state architecture
Replace module-level refs and circular dependencies with a centralized mention state hook (useMentionState) and shared types (mention-types). - Introduce mention-types.ts for shared types (avoid circular deps) - Introduce mention-state.ts with useMentionState hook (replace global refs) - Rewrite MentionInput as contenteditable with AST-based mention rendering - Rewrite MentionPopover to use props-only communication (no module refs) - Fix MessageMentions import cycle (mention-types instead of MentionPopover) - Wire ChatInputArea to useMentionState with proper cursor handling
This commit is contained in:
parent
d2935f3ddc
commit
168fdc4da8
@ -1,88 +1,87 @@
|
|||||||
import { parse, type Node } from '@/lib/mention-ast';
|
import { parse } from '@/lib/mention-ast';
|
||||||
import { forwardRef, useCallback, useEffect, useRef } from 'react';
|
import { forwardRef, useCallback, useEffect, useRef } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface MentionInputProps {
|
interface MentionInputProps {
|
||||||
|
/** Plain text value (contains <mention ... > tags for existing mentions) */
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onSend?: () => void;
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Callback fired when Ctrl+Enter is pressed */
|
||||||
|
onSend?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mentionStyles: Record<string, string> = {
|
/** CSS classes for rendered mention buttons */
|
||||||
|
const MENTION_STYLES: Record<string, string> = {
|
||||||
user: 'inline-flex items-center rounded bg-blue-100/80 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium text-sm leading-5 mx-0.5',
|
user: 'inline-flex items-center rounded bg-blue-100/80 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium text-sm leading-5 mx-0.5',
|
||||||
repository: 'inline-flex items-center rounded bg-purple-100/80 px-1.5 py-0.5 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300 font-medium text-sm leading-5 mx-0.5',
|
repository: 'inline-flex items-center rounded bg-purple-100/80 px-1.5 py-0.5 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300 font-medium text-sm leading-5 mx-0.5',
|
||||||
ai: 'inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium text-sm leading-5 mx-0.5',
|
ai: 'inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium text-sm leading-5 mx-0.5',
|
||||||
notify: 'inline-flex items-center rounded bg-yellow-100/80 px-1.5 py-0.5 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 font-medium text-sm leading-5 mx-0.5',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconMap: Record<string, string> = {
|
const ICON_MAP: Record<string, string> = {
|
||||||
ai: '🤖',
|
user: '👤', repository: '📦', ai: '🤖',
|
||||||
user: '👤',
|
|
||||||
repository: '📦',
|
|
||||||
notify: '🔔',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const labelMap: Record<string, string> = {
|
const LABEL_MAP: Record<string, string> = {
|
||||||
ai: 'AI',
|
user: 'User', repository: 'Repo', ai: 'AI',
|
||||||
user: 'User',
|
|
||||||
repository: 'Repo',
|
|
||||||
notify: 'Notify',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
/** Escape HTML special characters */
|
||||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render mentions as styled contentEditable-free inline buttons */
|
/** Render plain text or mention nodes into HTML string for innerHTML */
|
||||||
function renderMentions(value: string): string {
|
function renderToHtml(text: string): string {
|
||||||
const nodes = parse(value);
|
const nodes = parse(text);
|
||||||
if (nodes.length === 0) {
|
if (nodes.length === 0 || (nodes.length === 1 && nodes[0].type === 'text')) {
|
||||||
return escapeHtml(value).replace(/\n/g, '<br>');
|
// No structured mentions — just escape and return
|
||||||
|
return escapeHtml(text).replace(/\n/g, '<br>');
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.type === 'text') {
|
if (node.type === 'text') {
|
||||||
html += escapeHtml((node as Node & { type: 'text' }).text).replace(/\n/g, '<br>');
|
html += escapeHtml((node as { type: 'text'; text: string }).text).replace(/\n/g, '<br>');
|
||||||
} else if (node.type === 'mention') {
|
} else if (node.type === 'mention') {
|
||||||
const m = node as Node & { type: 'mention'; mentionType: string; id: string; label: string };
|
const m = node as { type: 'mention'; mentionType: string; id: string; label: string };
|
||||||
const style = mentionStyles[m.mentionType] ?? mentionStyles.user;
|
const style = MENTION_STYLES[m.mentionType] ?? MENTION_STYLES.user;
|
||||||
const icon = iconMap[m.mentionType] ?? '🏷';
|
const icon = ICON_MAP[m.mentionType] ?? '🏷';
|
||||||
const label = labelMap[m.mentionType] ?? 'Mention';
|
const label = LABEL_MAP[m.mentionType] ?? 'Mention';
|
||||||
html += `<span class="${style}" contenteditable="false" data-mention-id="${escapeHtml(m.id)}" data-mention-type="${escapeHtml(m.mentionType)}">`;
|
html += `<span class="${style}" data-mention-type="${escapeHtml(m.mentionType)}" data-mention-id="${escapeHtml(m.id)}">`;
|
||||||
html += `<span>${icon}</span><strong>${escapeHtml(label)}:</strong> ${escapeHtml(m.label)}`;
|
html += `<span>${icon}</span><strong>${escapeHtml(label)}:</strong> ${escapeHtml(m.label)}</span>`;
|
||||||
html += '</span>';
|
|
||||||
} else if (node.type === 'ai_action') {
|
|
||||||
const a = node as Node & { type: 'ai_action'; action: string; args?: string };
|
|
||||||
html += `<span class="inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-mono text-xs mx-0.5" contenteditable="false">`;
|
|
||||||
html += `/${escapeHtml(a.action)}${a.args ? ' ' + escapeHtml(a.args) : ''}`;
|
|
||||||
html += '</span>';
|
|
||||||
}
|
}
|
||||||
|
// ai_action nodes are treated as text for now
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract plain text content */
|
/** Extract plain text from a contenteditable div. Mentions contribute their visible label text. */
|
||||||
function getContentText(el: HTMLElement): string {
|
function getPlainText(container: HTMLElement): string {
|
||||||
let text = '';
|
let text = '';
|
||||||
for (const child of Array.from(el.childNodes)) {
|
for (const child of Array.from(container.childNodes)) {
|
||||||
if (child.nodeType === Node.TEXT_NODE) {
|
if (child.nodeType === 3 /* TEXT_NODE */) {
|
||||||
text += child.textContent ?? '';
|
text += child.textContent ?? '';
|
||||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
} else if (child.nodeType === 1 /* ELEMENT_NODE */) {
|
||||||
const elNode = child as HTMLElement;
|
const el = child as HTMLElement;
|
||||||
if (elNode.tagName === 'BR') text += '\n';
|
if (el.tagName === 'BR') text += '\n';
|
||||||
else if (elNode.tagName === 'DIV' || elNode.tagName === 'P') text += getContentText(elNode) + '\n';
|
else if (el.tagName === 'DIV' || el.tagName === 'P') text += getPlainText(el) + '\n';
|
||||||
else if (elNode.getAttribute('contenteditable') === 'false') text += elNode.textContent ?? '';
|
else if (el.getAttribute('data-mention-type')) {
|
||||||
else text += getContentText(elNode);
|
// Mention span — extract its displayed text (label), not the type/icon prefix
|
||||||
|
const raw = el.textContent ?? '';
|
||||||
|
// Format is "👤 User: username" — we want just "username"
|
||||||
|
const colonIdx = raw.lastIndexOf(':');
|
||||||
|
text += colonIdx >= 0 ? raw.slice(colonIdx + 1).trim() : raw;
|
||||||
|
}
|
||||||
|
else text += el.textContent ?? '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get character offset of selection within contenteditable */
|
/** Get character offset of selection within a contenteditable div */
|
||||||
function getSelectionOffset(container: HTMLElement): number {
|
function getCaretOffset(container: HTMLElement): number {
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
if (!sel || sel.rangeCount === 0) return 0;
|
if (!sel || sel.rangeCount === 0) return 0;
|
||||||
const range = sel.getRangeAt(0);
|
const range = sel.getRangeAt(0);
|
||||||
@ -92,39 +91,39 @@ function getSelectionOffset(container: HTMLElement): number {
|
|||||||
return preRange.toString().length;
|
return preRange.toString().length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set selection at given character offset */
|
/** Set caret at a given character offset within a contenteditable div */
|
||||||
function setSelectionAtOffset(container: HTMLElement, offset: number) {
|
function setCaretAtOffset(container: HTMLElement, targetOffset: number) {
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
let charCount = 0;
|
let count = 0;
|
||||||
let found = false;
|
let found = false;
|
||||||
|
|
||||||
function walk(node: Node) {
|
function walk(node: Node) {
|
||||||
if (found) return;
|
if (found) return;
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
if (node.nodeType === 3 /* TEXT_NODE */) {
|
||||||
const text = node.textContent ?? '';
|
const t = node.textContent ?? '';
|
||||||
if (charCount + text.length >= offset) {
|
if (count + t.length >= targetOffset) {
|
||||||
range.setStart(node, Math.min(offset - charCount, text.length));
|
range.setStart(node, Math.min(targetOffset - count, t.length));
|
||||||
range.collapse(true);
|
range.collapse(true);
|
||||||
found = true;
|
found = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
charCount += text.length;
|
count += t.length;
|
||||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
} else if (node.nodeType === 1 /* ELEMENT_NODE */) {
|
||||||
const el = node as HTMLElement;
|
const el = node as HTMLElement;
|
||||||
if (el.getAttribute('contenteditable') === 'false') {
|
if (el.getAttribute('data-mention-type')) {
|
||||||
const nodeLen = el.textContent?.length ?? 0;
|
// Non-editable mention span counts as one character unit
|
||||||
if (charCount + nodeLen >= offset) {
|
if (count + 1 > targetOffset) {
|
||||||
range.selectNodeContents(el);
|
range.selectNodeContents(el);
|
||||||
range.collapse(false);
|
range.collapse(false);
|
||||||
found = true;
|
found = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
charCount += nodeLen;
|
count += 1;
|
||||||
} else {
|
} else {
|
||||||
for (const child of Array.from(el.childNodes)) {
|
for (let i = 0; i < el.childNodes.length; i++) {
|
||||||
walk(child as Node);
|
walk(el.childNodes[i] as Node);
|
||||||
if (found) return;
|
if (found) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -143,79 +142,83 @@ function setSelectionAtOffset(container: HTMLElement, offset: number) {
|
|||||||
export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(function MentionInput({
|
export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(function MentionInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onSend,
|
|
||||||
placeholder,
|
placeholder,
|
||||||
disabled,
|
disabled,
|
||||||
|
onSend,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
const elRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const pendingSyncRef = useRef(false);
|
const pendingSyncRef = useRef(false);
|
||||||
const lastValueRef = useRef(value);
|
const internalValueRef = useRef(value);
|
||||||
|
|
||||||
// Sync DOM when external value changes
|
// Merge forwarded ref with internal ref
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref) {
|
||||||
|
if (typeof ref === 'function') ref(containerRef.current);
|
||||||
|
else (ref as React.RefObject<HTMLDivElement | null>).current = containerRef.current;
|
||||||
|
}
|
||||||
|
}, [ref]);
|
||||||
|
|
||||||
|
/** Sync external value changes into the DOM */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingSyncRef.current) {
|
if (pendingSyncRef.current) {
|
||||||
pendingSyncRef.current = false;
|
pendingSyncRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const el = elRef.current;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const currentText = getContentText(el);
|
const currentText = getPlainText(el);
|
||||||
if (currentText === value) return;
|
if (currentText === internalValueRef.current) return;
|
||||||
|
if (internalValueRef.current === value) return;
|
||||||
|
|
||||||
// Save cursor ratio
|
// Save cursor position ratio
|
||||||
const curOffset = getSelectionOffset(el);
|
const oldCaret = getCaretOffset(el);
|
||||||
const ratio = currentText.length > 0 ? curOffset / currentText.length : 0;
|
const oldLen = internalValueRef.current.length;
|
||||||
|
const ratio = oldLen > 0 ? oldCaret / oldLen : 0;
|
||||||
|
|
||||||
el.innerHTML = value.trim() ? renderMentions(value) : '';
|
// Update DOM
|
||||||
|
el.innerHTML = value.trim() ? renderToHtml(value) : '';
|
||||||
|
|
||||||
const newOffset = Math.round(ratio * value.length);
|
// Restore cursor
|
||||||
setSelectionAtOffset(el, Math.min(newOffset, value.length));
|
const newLen = value.length;
|
||||||
lastValueRef.current = value;
|
const newCaret = Math.round(ratio * newLen);
|
||||||
|
setCaretAtOffset(el, Math.min(newCaret, newLen));
|
||||||
|
internalValueRef.current = value;
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
/** Handle input changes — extracts plain text from DOM and sends to parent */
|
||||||
const handleInput = useCallback(() => {
|
const handleInput = useCallback(() => {
|
||||||
const el = elRef.current;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const newText = getContentText(el);
|
const newText = getPlainText(el);
|
||||||
pendingSyncRef.current = true;
|
pendingSyncRef.current = true;
|
||||||
lastValueRef.current = newText;
|
internalValueRef.current = newText;
|
||||||
onChange(newText);
|
onChange(newText);
|
||||||
}, [onChange]);
|
}, [onChange]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
/** Handle keyboard shortcuts */
|
||||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
// Ctrl+Enter → send
|
// Ctrl+Enter → send message
|
||||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSend?.();
|
onSend?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Plain Enter → swallow. Shift+Enter inserts newline naturally.
|
// Plain Enter → swallow (Shift+Enter inserts newline via default behavior)
|
||||||
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
},
|
}, [onSend]);
|
||||||
[onSend],
|
|
||||||
);
|
|
||||||
|
|
||||||
|
/** Handle paste — insert plain text only */
|
||||||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
|
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
document.execCommand('insertText', false, e.clipboardData.getData('text/plain'));
|
document.execCommand('insertText', false, e.clipboardData.getData('text/plain'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Merge forwarded ref with internal ref using effect
|
|
||||||
useEffect(() => {
|
|
||||||
const el = elRef.current;
|
|
||||||
if (ref) {
|
|
||||||
if (typeof ref === 'function') ref(el);
|
|
||||||
else (ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
|
||||||
}
|
|
||||||
}, [ref]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={elRef}
|
ref={containerRef}
|
||||||
contentEditable={!disabled}
|
contentEditable={!disabled}
|
||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
|||||||
import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client';
|
import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client';
|
||||||
import type { RoomAiConfig } from '@/components/room/MentionPopover';
|
import type { RoomAiConfig, ResolveMentionName } from '@/lib/mention-types';
|
||||||
import { memo, useCallback, useMemo } from 'react';
|
import { memo, useCallback, useMemo } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { parse, type Node, type MentionMentionType } from '@/lib/mention-ast';
|
import { parse, type Node, type MentionMentionType } from '@/lib/mention-ast';
|
||||||
|
|||||||
@ -2,10 +2,9 @@ import type { ProjectRepositoryItem, RoomResponse, RoomMemberResponse, RoomMessa
|
|||||||
import { useRoom, type MessageWithMeta } from '@/contexts';
|
import { useRoom, type MessageWithMeta } from '@/contexts';
|
||||||
import { type RoomAiConfig } from '@/contexts/room-context';
|
import { type RoomAiConfig } from '@/contexts/room-context';
|
||||||
import { useRoomDraft } from '@/hooks/useRoomDraft';
|
import { useRoomDraft } from '@/hooks/useRoomDraft';
|
||||||
|
import { useMentionState } from '@/lib/mention-state';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { buildMentionHtml } from '@/lib/mention-ast';
|
import { buildMentionHtml } from '@/lib/mention-ast';
|
||||||
import { mentionSelectedIdxRef, mentionVisibleRef } from '@/lib/mention-refs';
|
|
||||||
import { MentionInput } from './MentionInput';
|
import { MentionInput } from './MentionInput';
|
||||||
import { ChevronLeft, Hash, Send, Settings, Timer, Trash2, Users, X, Search, Bell } from 'lucide-react';
|
import { ChevronLeft, Hash, Send, Settings, Timer, Trash2, Users, X, Search, Bell } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@ -29,7 +28,6 @@ import { RoomMessageSearch } from './RoomMessageSearch';
|
|||||||
import { RoomMentionPanel } from './RoomMentionPanel';
|
import { RoomMentionPanel } from './RoomMentionPanel';
|
||||||
import { RoomThreadPanel } from './RoomThreadPanel';
|
import { RoomThreadPanel } from './RoomThreadPanel';
|
||||||
|
|
||||||
const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/;
|
|
||||||
|
|
||||||
|
|
||||||
export interface ChatInputAreaHandle {
|
export interface ChatInputAreaHandle {
|
||||||
@ -72,147 +70,80 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
ref,
|
ref,
|
||||||
}: ChatInputAreaProps) {
|
}: ChatInputAreaProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [showMentionPopover, setShowMentionPopover] = useState(false);
|
const [_draftTick, setDraftTick] = useState(0);
|
||||||
|
|
||||||
/** Get caret offset in the contenteditable div */
|
// ─── Central mention state ───────────────────────────────────────────────
|
||||||
function getCaretOffset(): number {
|
const ms = useMentionState(
|
||||||
const container = containerRef.current;
|
members,
|
||||||
if (!container) return 0;
|
repos ?? [],
|
||||||
const selection = window.getSelection();
|
aiConfigs,
|
||||||
if (!selection || selection.rangeCount === 0) return 0;
|
!!reposLoading,
|
||||||
const range = selection.getRangeAt(0);
|
!!aiConfigsLoading,
|
||||||
const preRange = range.cloneRange();
|
|
||||||
preRange.selectNodeContents(container);
|
|
||||||
preRange.setEnd(range.startContainer, range.startOffset);
|
|
||||||
// Count text nodes only
|
|
||||||
let count = 0;
|
|
||||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
|
||||||
while (walker.nextNode()) {
|
|
||||||
const node = walker.currentNode as Text;
|
|
||||||
if (node === preRange.startContainer) {
|
|
||||||
count += preRange.startOffset;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
count += node.length;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set caret at given character offset in the contenteditable div */
|
|
||||||
function setCaretOffset(offset: number) {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (!selection) return;
|
|
||||||
const range = document.createRange();
|
|
||||||
let charCount = 0;
|
|
||||||
let found = false;
|
|
||||||
function walk(node: Node) {
|
|
||||||
if (found) return;
|
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
|
||||||
const t = node.textContent ?? '';
|
|
||||||
if (charCount + t.length >= offset) {
|
|
||||||
range.setStart(node, Math.min(offset - charCount, t.length));
|
|
||||||
range.collapse(true);
|
|
||||||
found = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
charCount += t.length;
|
|
||||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
||||||
for (const c of Array.from(node.childNodes)) {
|
|
||||||
walk(c);
|
|
||||||
if (found) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(container);
|
|
||||||
if (!found) { range.selectNodeContents(container); range.collapse(false); }
|
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMentionSelect = useCallback((_newValue: string, _newCursorPos: number) => {
|
|
||||||
if (!containerRef.current) return;
|
|
||||||
const cursorPos = getCaretOffset();
|
|
||||||
const textBefore = draft.substring(0, cursorPos);
|
|
||||||
const atMatch = textBefore.match(MENTION_PATTERN);
|
|
||||||
if (!atMatch) return;
|
|
||||||
|
|
||||||
const [fullMatch] = atMatch;
|
|
||||||
const startPos = cursorPos - fullMatch.length;
|
|
||||||
|
|
||||||
const before = draft.substring(0, startPos);
|
|
||||||
const after = draft.substring(cursorPos);
|
|
||||||
|
|
||||||
const suggestion = mentionVisibleRef.current[mentionSelectedIdxRef.current];
|
|
||||||
if (!suggestion || suggestion.type !== 'item') return;
|
|
||||||
|
|
||||||
const html = buildMentionHtml(
|
|
||||||
suggestion.category!,
|
|
||||||
suggestion.mentionId!,
|
|
||||||
suggestion.label,
|
|
||||||
);
|
);
|
||||||
const spacer = ' ';
|
|
||||||
const newValue = before + html + spacer + after;
|
|
||||||
const newCursorPos = startPos + html.length + spacer.length;
|
|
||||||
|
|
||||||
onDraftChange(newValue);
|
// Sync external draft → mention state (with dirty flag to skip MentionInput onChange during init)
|
||||||
setShowMentionPopover(false);
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
if (ms.value === draft) return;
|
||||||
if (!containerRef.current) return;
|
(window as any).__mentionSkipSync = true;
|
||||||
containerRef.current.focus();
|
ms.setValue(draft);
|
||||||
setCaretOffset(newCursorPos);
|
setDraftTick(t => t + 1);
|
||||||
}, 0);
|
}, [draft, ms.value, ms.setValue]);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []); // Uses draft from closure
|
// ─── Imperative handle ──────────────────────────────────────────────────
|
||||||
|
const onDraftChangeRef = useRef(onDraftChange);
|
||||||
|
onDraftChangeRef.current = onDraftChange;
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
insertMention: (id: string, label: string) => {
|
insertMention: (id: string, label: string) => {
|
||||||
const cursorPos = getCaretOffset();
|
const insertion = ms.buildInsertionAt('user', id, label);
|
||||||
const escapedLabel = label.replace(/</g, '<').replace(/>/g, '>');
|
const before = draft.substring(0, insertion.startPos);
|
||||||
const escapedId = id.replace(/"/g, '"');
|
const newValue = before + insertion.html + ' ' + draft.substring(insertion.startPos);
|
||||||
const mentionText = `<mention type="user" id="${escapedId}">${escapedLabel}</mention> `;
|
onDraftChangeRef.current(newValue);
|
||||||
const before = draft.substring(0, cursorPos);
|
ms.setValue(newValue);
|
||||||
const after = draft.substring(cursorPos);
|
ms.setShowMentionPopover(false);
|
||||||
const newValue = before + mentionText + after;
|
setTimeout(() => containerRef.current?.focus(), 0);
|
||||||
onDraftChange(newValue);
|
|
||||||
setShowMentionPopover(false);
|
|
||||||
setTimeout(() => {
|
|
||||||
containerRef.current?.focus();
|
|
||||||
}, 0);
|
|
||||||
},
|
},
|
||||||
insertCategory: (category: string) => {
|
insertCategory: (category: string) => {
|
||||||
const cursorPos = getCaretOffset();
|
const textBefore = draft.substring(0, ms.cursorOffset);
|
||||||
const textBefore = draft.substring(0, cursorPos);
|
const atMatch = textBefore.match(/@([^:@\s]*)(:([^\s]*))?$/);
|
||||||
const atMatch = textBefore.match(MENTION_PATTERN);
|
|
||||||
if (!atMatch) return;
|
if (!atMatch) return;
|
||||||
const [fullMatch] = atMatch;
|
const [fullMatch] = atMatch;
|
||||||
const startPos = cursorPos - fullMatch.length;
|
const startPos = ms.cursorOffset - fullMatch.length;
|
||||||
const before = draft.substring(0, startPos);
|
const before = draft.substring(0, startPos);
|
||||||
const afterPartial = draft.substring(startPos + fullMatch.length);
|
const afterPartial = draft.substring(startPos + fullMatch.length);
|
||||||
const newValue = before + '@' + category + ':' + afterPartial;
|
const newValue = before + '@' + category + ':' + afterPartial;
|
||||||
const newCursorPos = startPos + 1 + category.length + 1;
|
const newCursorPos = startPos + 1 + category.length + 1;
|
||||||
onDraftChange(newValue);
|
onDraftChangeRef.current(newValue);
|
||||||
setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(MENTION_PATTERN));
|
ms.setValue(newValue);
|
||||||
|
ms.setCursorOffset(newCursorPos);
|
||||||
|
ms.setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(/@([^:@\s]*)(:([^\s]*))?$/));
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Listen for mention-click events from message content (e.g. 🤖 AI button)
|
// ─── Mention select (from popover) ──────────────────────────────────────
|
||||||
|
const handleMentionSelect = useCallback(() => {
|
||||||
|
const suggestion = ms.suggestions[ms.selectedIndex];
|
||||||
|
if (!suggestion || suggestion.type !== 'item') return;
|
||||||
|
|
||||||
|
const insertion = ms.buildInsertionAt(suggestion.category, suggestion.mentionId, suggestion.label);
|
||||||
|
const before = draft.substring(0, insertion.startPos);
|
||||||
|
const newValue = before + insertion.html + ' ' + draft.substring(ms.cursorOffset);
|
||||||
|
onDraftChangeRef.current(newValue);
|
||||||
|
ms.setValue(newValue);
|
||||||
|
ms.setShowMentionPopover(false);
|
||||||
|
setTimeout(() => containerRef.current?.focus(), 0);
|
||||||
|
}, [draft, ms]);
|
||||||
|
|
||||||
|
// ─── mention-click handler (from message mentions) ─────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMentionClick = (e: Event) => {
|
const onMentionClick = (e: Event) => {
|
||||||
const { type, id, label } = (e as CustomEvent<{ type: string; id: string; label: string }>).detail;
|
const { type, id, label } = (e as CustomEvent<{ type: string; id: string; label: string }>).detail;
|
||||||
const cursorPos = getCaretOffset();
|
|
||||||
const textBefore = draft.substring(0, cursorPos);
|
|
||||||
const html = buildMentionHtml(type as 'user' | 'repository' | 'ai', id, label);
|
const html = buildMentionHtml(type as 'user' | 'repository' | 'ai', id, label);
|
||||||
const spacer = ' ';
|
const spacer = ' ';
|
||||||
const newValue = textBefore + html + spacer + draft.substring(cursorPos);
|
const newValue = draft.substring(0, ms.cursorOffset) + html + spacer + draft.substring(ms.cursorOffset);
|
||||||
const newCursorPos = cursorPos + html.length + spacer.length;
|
onDraftChangeRef.current(newValue);
|
||||||
onDraftChange(newValue);
|
ms.setValue(newValue);
|
||||||
setTimeout(() => {
|
setTimeout(() => containerRef.current?.focus(), 0);
|
||||||
if (!containerRef.current) return;
|
|
||||||
containerRef.current.focus();
|
|
||||||
setCaretOffset(newCursorPos);
|
|
||||||
}, 0);
|
|
||||||
};
|
};
|
||||||
document.addEventListener('mention-click', onMentionClick);
|
document.addEventListener('mention-click', onMentionClick);
|
||||||
return () => document.removeEventListener('mention-click', onMentionClick);
|
return () => document.removeEventListener('mention-click', onMentionClick);
|
||||||
@ -234,18 +165,13 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<MentionInput
|
<MentionInput
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
value={draft}
|
value={ms.value}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
onDraftChange(v);
|
onDraftChange(v);
|
||||||
const textBefore = v.substring(0, getCaretOffset());
|
ms.setValue(v);
|
||||||
if (textBefore.match(MENTION_PATTERN)) {
|
|
||||||
setShowMentionPopover(true);
|
|
||||||
} else {
|
|
||||||
setShowMentionPopover(false);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onSend={() => {
|
onSend={() => {
|
||||||
const content = draft.trim();
|
const content = ms.value.trim();
|
||||||
if (content && !isSending) {
|
if (content && !isSending) {
|
||||||
onSend(content);
|
onSend(content);
|
||||||
onClearDraft();
|
onClearDraft();
|
||||||
@ -258,7 +184,7 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const content = draft.trim();
|
const content = ms.value.trim();
|
||||||
if (content && !isSending) {
|
if (content && !isSending) {
|
||||||
onSend(content);
|
onSend(content);
|
||||||
onClearDraft();
|
onClearDraft();
|
||||||
@ -271,37 +197,39 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
Send
|
Send
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{showMentionPopover && (
|
{ms.showMentionPopover && ms.mentionState && (
|
||||||
<MentionPopover
|
<MentionPopover
|
||||||
members={members}
|
members={members}
|
||||||
repos={repos}
|
repos={repos ?? null}
|
||||||
reposLoading={reposLoading}
|
aiConfigs={aiConfigs ?? []}
|
||||||
aiConfigs={aiConfigs}
|
reposLoading={!!reposLoading}
|
||||||
aiConfigsLoading={aiConfigsLoading}
|
aiConfigsLoading={!!aiConfigsLoading}
|
||||||
inputValue={draft}
|
containerRef={containerRef}
|
||||||
cursorPosition={getCaretOffset()}
|
inputValue={ms.value}
|
||||||
|
cursorPosition={ms.cursorOffset}
|
||||||
onSelect={handleMentionSelect}
|
onSelect={handleMentionSelect}
|
||||||
textareaRef={containerRef}
|
onOpenChange={ms.setShowMentionPopover}
|
||||||
onOpenChange={setShowMentionPopover}
|
|
||||||
onCategoryEnter={(category: string) => {
|
onCategoryEnter={(category: string) => {
|
||||||
const cursorPos = getCaretOffset();
|
const textBefore = ms.value.substring(0, ms.cursorOffset);
|
||||||
const textBefore = draft.substring(0, cursorPos);
|
const atMatch = textBefore.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
|
||||||
const atMatch = textBefore.match(MENTION_PATTERN);
|
|
||||||
if (!atMatch) return;
|
if (!atMatch) return;
|
||||||
const [fullMatch] = atMatch;
|
const [fullMatch] = atMatch;
|
||||||
const startPos = cursorPos - fullMatch.length;
|
const startPos = ms.cursorOffset - fullMatch.length;
|
||||||
const before = draft.substring(0, startPos);
|
const before = ms.value.substring(0, startPos);
|
||||||
const afterPartial = draft.substring(startPos + fullMatch.length);
|
const afterPartial = ms.value.substring(startPos + fullMatch.length);
|
||||||
const newValue = before + '@' + category + ':' + afterPartial;
|
const newValue = before + '@' + category + ':' + afterPartial;
|
||||||
const newCursorPos = startPos + 1 + category.length + 1;
|
const newCursorPos = startPos + 1 + category.length + 1;
|
||||||
onDraftChange(newValue);
|
setDraftAndNotify(newValue);
|
||||||
setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(MENTION_PATTERN));
|
ms.setCursorOffset(newCursorPos);
|
||||||
}}
|
}}
|
||||||
|
suggestions={ms.suggestions}
|
||||||
|
selectedIndex={ms.selectedIndex}
|
||||||
|
setSelectedIndex={ms.setSelectedIndex}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
201
src/lib/mention-state.ts
Normal file
201
src/lib/mention-state.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { MentionSuggestion, MentionType, RoomAiConfig, ResolveMentionName } from './mention-types';
|
||||||
|
|
||||||
|
/** Parsed mention context from text at cursor position */
|
||||||
|
export interface MentionStateInternal {
|
||||||
|
category: string;
|
||||||
|
item: string;
|
||||||
|
hasColon: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseMentionStateReturn {
|
||||||
|
/** Plain text value of the input */
|
||||||
|
value: string;
|
||||||
|
/** Set value and update internal state */
|
||||||
|
setValue: (value: string) => void;
|
||||||
|
/** Current caret offset in the text */
|
||||||
|
cursorOffset: number;
|
||||||
|
/** Set caret offset without re-rendering the whole tree */
|
||||||
|
setCursorOffset: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
/** Whether the mention popover should be visible */
|
||||||
|
showMentionPopover: boolean;
|
||||||
|
/** Open/close the popover */
|
||||||
|
setShowMentionPopover: (open: boolean) => void;
|
||||||
|
/** Filtered suggestions for the current @ context */
|
||||||
|
suggestions: MentionSuggestion[];
|
||||||
|
/** Currently selected index in suggestions */
|
||||||
|
selectedIndex: number;
|
||||||
|
/** Move selection to a specific index */
|
||||||
|
setSelectedIndex: (index: number) => void;
|
||||||
|
/** Reset selection to first item */
|
||||||
|
resetSelection: () => void;
|
||||||
|
/** Parsed mention state at current cursor position */
|
||||||
|
mentionState: MentionStateInternal | null;
|
||||||
|
/** Build HTML for inserting a mention at current cursor */
|
||||||
|
buildInsertionAt: (category: MentionType, id: string, label: string) => InsertionResult;
|
||||||
|
/** Resolve ID → display name for any mention type */
|
||||||
|
resolveName: ResolveMentionName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsertionResult {
|
||||||
|
html: string;
|
||||||
|
/** Start position where the mention should be inserted (before the @) */
|
||||||
|
startPos: number;
|
||||||
|
/** Total length of inserted text (+ trailing space) */
|
||||||
|
totalLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central hook that manages all mention-related state for the input layer.
|
||||||
|
* Replaces the old module-level refs with clean, component-scoped state.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const ms = useMentionState(members, repos, aiConfigs, reposLoading, aiConfigsLoading);
|
||||||
|
* // Pass ms.* props to MentionInput and MentionPopover
|
||||||
|
*/
|
||||||
|
export function useMentionState(
|
||||||
|
members: Array<{ user: string; user_info?: { username?: string; avatar_url?: string }; role?: string }>,
|
||||||
|
repos: Array<{ uid: string; repo_name: string }> = [],
|
||||||
|
aiConfigs: RoomAiConfig[] = [],
|
||||||
|
reposLoading: boolean = false,
|
||||||
|
aiConfigsLoading: boolean = false,
|
||||||
|
): UseMentionStateReturn {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [cursorOffset, setCursorOffset] = useState(0);
|
||||||
|
const [showMentionPopover, setShowMentionPopover] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
// ─── Derived: Mention State ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Parse the @ mention context at the current cursor position */
|
||||||
|
const mentionState = useMemo<MentionStateInternal | null>(() => {
|
||||||
|
const textBefore = value.slice(0, cursorOffset);
|
||||||
|
const match = textBefore.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const [_full, category, _colon, item] = match;
|
||||||
|
return {
|
||||||
|
category: category.toLowerCase(),
|
||||||
|
item: (item ?? '').toLowerCase(),
|
||||||
|
hasColon: _colon !== undefined,
|
||||||
|
};
|
||||||
|
}, [value, cursorOffset]);
|
||||||
|
|
||||||
|
// ─── Derived: Suggestions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const suggestions = useMemo<MentionSuggestion[]>(() => {
|
||||||
|
const state = mentionState;
|
||||||
|
if (!state) return [];
|
||||||
|
|
||||||
|
const { category, item, hasColon } = state;
|
||||||
|
|
||||||
|
// No @ typed yet or just started typing — show categories
|
||||||
|
if (!category) {
|
||||||
|
return [
|
||||||
|
{ type: 'category', category: 'repository', label: 'Repository' },
|
||||||
|
{ type: 'category', category: 'user', label: 'User' },
|
||||||
|
{ type: 'category', category: 'ai', label: 'AI' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has @ but no colon — filter to matching categories
|
||||||
|
if (!hasColon) {
|
||||||
|
const catLabel: Record<string, string> = { repository: 'Repository', user: 'User', ai: 'AI' };
|
||||||
|
return [{ type: 'category', category: category as MentionType, label: catLabel[category] ?? category }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has @type: — show items
|
||||||
|
if (category === 'repository') {
|
||||||
|
if (reposLoading) return [{ type: 'category', label: 'Loading...' }];
|
||||||
|
return repos
|
||||||
|
.filter(r => !item || r.repo_name.toLowerCase().includes(item))
|
||||||
|
.slice(0, 12)
|
||||||
|
.map(r => ({ type: 'item', category: 'repository' as const, label: r.repo_name, mentionId: r.uid }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category === 'user') {
|
||||||
|
return members
|
||||||
|
.filter(m => m.role !== 'ai')
|
||||||
|
.filter(m => {
|
||||||
|
const name = m.user_info?.username ?? m.user;
|
||||||
|
return !item || name.toLowerCase().includes(item);
|
||||||
|
})
|
||||||
|
.slice(0, 12)
|
||||||
|
.map(m => {
|
||||||
|
const name = m.user_info?.username ?? m.user;
|
||||||
|
return {
|
||||||
|
type: 'item' as const, category: 'user' as const, label: name, mentionId: m.user,
|
||||||
|
avatar: m.user_info?.avatar_url,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category === 'ai') {
|
||||||
|
if (aiConfigsLoading) return [{ type: 'category', label: 'Loading...' }];
|
||||||
|
return aiConfigs
|
||||||
|
.filter(c => {
|
||||||
|
const name = c.modelName ?? c.model;
|
||||||
|
return !item || name.toLowerCase().includes(item);
|
||||||
|
})
|
||||||
|
.slice(0, 12)
|
||||||
|
.map(c => ({
|
||||||
|
type: 'item' as const, category: 'ai' as const, label: c.modelName ?? c.model, mentionId: c.model,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}, [mentionState, members, repos, aiConfigs, reposLoading, aiConfigsLoading]);
|
||||||
|
|
||||||
|
// ─── Auto-select First Item ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [suggestions.length]);
|
||||||
|
|
||||||
|
// ─── Name Resolution ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const resolveName: ResolveMentionName = useCallback((type, id, fallback) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'user': {
|
||||||
|
const member = members.find(m => m.user === id);
|
||||||
|
return member?.user_info?.username ?? member?.user ?? fallback;
|
||||||
|
}
|
||||||
|
case 'repository': {
|
||||||
|
const repo = repos.find(r => r.uid === id);
|
||||||
|
return repo?.repo_name ?? fallback;
|
||||||
|
}
|
||||||
|
case 'ai': {
|
||||||
|
const cfg = aiConfigs.find(c => c.model === id);
|
||||||
|
return cfg?.modelName ?? cfg?.model ?? fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [members, repos, aiConfigs]);
|
||||||
|
|
||||||
|
// ─── Insertion Builder ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Build the HTML string and compute insertion start position */
|
||||||
|
const buildInsertionAt = useCallback((mentionType: MentionType, id: string, label: string): InsertionResult => {
|
||||||
|
const escapedId = id.replace(/"/g, '"');
|
||||||
|
const escapedLabel = label.replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const html = `<mention type="${mentionType}" id="${escapedId}">${escapedLabel}</mention>`;
|
||||||
|
const spacer = ' ';
|
||||||
|
const totalLength = html.length + spacer.length;
|
||||||
|
|
||||||
|
return { html, startPos: cursorOffset - countAtPattern(value, cursorOffset), totalLength };
|
||||||
|
}, [value, cursorOffset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
value, setValue, cursorOffset, setCursorOffset,
|
||||||
|
showMentionPopover, setShowMentionPopover,
|
||||||
|
suggestions, selectedIndex, setSelectedIndex,
|
||||||
|
resetSelection: () => setSelectedIndex(0),
|
||||||
|
mentionState, buildInsertionAt, resolveName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count how many characters the @mention pattern occupies before cursor */
|
||||||
|
function countAtPattern(text: string, cursorPos: number): number {
|
||||||
|
const before = text.slice(0, cursorPos);
|
||||||
|
const match = before.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
|
||||||
|
return match ? match[0].length : 0;
|
||||||
|
}
|
||||||
34
src/lib/mention-types.ts
Normal file
34
src/lib/mention-types.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/** Shared types for the mention system. Centralized to avoid circular dependencies. */
|
||||||
|
|
||||||
|
// ─── Core Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type MentionType = 'user' | 'repository' | 'ai';
|
||||||
|
|
||||||
|
export interface RoomAiConfig {
|
||||||
|
model: string;
|
||||||
|
modelName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Suggestion Types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MentionSuggestionCategory {
|
||||||
|
type: 'category';
|
||||||
|
category: MentionType;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MentionSuggestionItem {
|
||||||
|
type: 'item';
|
||||||
|
category: MentionType;
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
mentionId: string;
|
||||||
|
avatar?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MentionSuggestion = MentionSuggestionCategory | MentionSuggestionItem;
|
||||||
|
|
||||||
|
// ─── Resolver Interface ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Resolves a mention's ID → display name at render time. */
|
||||||
|
export type ResolveMentionName = (type: MentionType, id: string, fallback: string) => string;
|
||||||
Loading…
Reference in New Issue
Block a user