import type { ProjectRepositoryItem, RoomResponse, RoomMemberResponse, RoomMessageResponse, RoomThreadResponse } from '@/client'; import { useRoom, type MessageWithMeta } from '@/contexts'; import { type RoomAiConfig } from '@/contexts/room-context'; import { useRoomDraft } from '@/hooks/useRoomDraft'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { buildMentionHtml } from '@/lib/mention-ast'; import { mentionSelectedIdxRef, mentionVisibleRef } from '@/lib/mention-refs'; import { MentionInput } from './MentionInput'; import { ChevronLeft, Hash, Send, Settings, Timer, Trash2, Users, X, Search, Bell } from 'lucide-react'; import { memo, useCallback, useEffect, useImperativeHandle, useRef, useState, type ReactNode, } from 'react'; import { toast } from 'sonner'; import { RoomAiTasksPanel } from './RoomAiTasksPanel'; import { MentionPopover } from './MentionPopover'; import { RoomMessageEditDialog } from './RoomMessageEditDialog'; import { RoomMessageEditHistoryDialog } from './RoomMessageEditHistoryDialog'; import { RoomMessageList } from './RoomMessageList'; import { RoomParticipantsPanel } from './RoomParticipantsPanel'; import { RoomSettingsPanel } from './RoomSettingsPanel'; import { RoomMessageSearch } from './RoomMessageSearch'; import { RoomMentionPanel } from './RoomMentionPanel'; import { RoomThreadPanel } from './RoomThreadPanel'; const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/; export interface ChatInputAreaHandle { insertMention: (id: string, label: string, type: 'user' | 'ai') => void; /** Insert a category prefix (e.g. 'ai') into the textarea and trigger React onChange */ insertCategory: (category: string) => void; } interface ChatInputAreaProps { roomName: string; onSend: (content: string) => void; isSending: boolean; members: RoomMemberResponse[]; repos?: ProjectRepositoryItem[]; reposLoading?: boolean; aiConfigs?: RoomAiConfig[]; aiConfigsLoading?: boolean; replyingTo?: { id: string; display_name?: string; content: string } | null; onCancelReply?: () => void; draft: string; onDraftChange: (content: string) => void; onClearDraft: () => void; ref?: React.Ref; } const ChatInputArea = memo(function ChatInputArea({ roomName, onSend, isSending, members, repos, reposLoading, aiConfigs, aiConfigsLoading, replyingTo, onCancelReply, draft, onDraftChange, onClearDraft, ref, }: ChatInputAreaProps) { const containerRef = useRef(null); const cursorRef = useRef(0); const [showMentionPopover, setShowMentionPopover] = useState(false); const handleMentionSelect = useCallback((_newValue: string, _newCursorPos: number) => { if (!containerRef.current) return; const container = containerRef.current; const cursorPos = cursorRef.current; 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); setShowMentionPopover(false); setTimeout(() => { const container = containerRef.current; if (!container) return; container.innerHTML = newValue .replace(/&/g, '&') .replace(//g, '>') .replace(/\n/g, '
'); // Place cursor at newCursorPos const sel = window.getSelection(); if (!sel) 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 >= newCursorPos) { range.setStart(node, Math.min(newCursorPos - 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); } sel.removeAllRanges(); sel.addRange(range); container.focus(); }, 0); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Uses draft from closure useImperativeHandle(ref, () => ({ insertMention: (id: string, label: string) => { const cursorPos = cursorRef.current; const escapedLabel = label.replace(//g, '>'); const escapedId = id.replace(/"/g, '"'); const mentionText = `${escapedLabel} `; const before = draft.substring(0, cursorPos); const after = draft.substring(cursorPos); const newValue = before + mentionText + after; onDraftChange(newValue); setShowMentionPopover(false); setTimeout(() => { containerRef.current?.focus(); }, 0); }, insertCategory: (category: string) => { const cursorPos = cursorRef.current; 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 afterPartial = draft.substring(startPos + fullMatch.length); const newValue = before + '@' + category + ':' + afterPartial; const newCursorPos = startPos + 1 + category.length + 1; onDraftChange(newValue); setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(MENTION_PATTERN)); }, })); // Listen for mention-click events from message content (e.g. 🤖 AI button) useEffect(() => { const onMentionClick = (e: Event) => { const { type, id, label } = (e as CustomEvent<{ type: string; id: string; label: string }>).detail; const cursorPos = cursorRef.current; const textBefore = draft.substring(0, cursorPos); const html = buildMentionHtml(type as 'user' | 'repository' | 'ai', id, label); const spacer = ' '; const newValue = textBefore + html + spacer + draft.substring(cursorPos); const newCursorPos = cursorPos + html.length + spacer.length; onDraftChange(newValue); setTimeout(() => { const container = containerRef.current; if (!container) return; container.focus(); const sel = window.getSelection(); if (!sel) 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 >= newCursorPos) { range.setStart(node, Math.min(newCursorPos - 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); } sel.removeAllRanges(); sel.addRange(range); }, 0); }; document.addEventListener('mention-click', onMentionClick); return () => document.removeEventListener('mention-click', onMentionClick); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
{replyingTo && (
Replying to {replyingTo.display_name} {replyingTo.content.length > 80 ? replyingTo.content.slice(0, 80) + '…' : replyingTo.content}
)}
{ onDraftChange(v); const textBefore = v.substring(0, cursorRef.current); if (textBefore.match(MENTION_PATTERN)) { setShowMentionPopover(true); } else { setShowMentionPopover(false); } }} onSend={() => { const content = draft.trim(); if (content && !isSending) { onSend(content); onClearDraft(); } }} placeholder={`Message #${roomName}...`} />
{showMentionPopover && ( { const cursorPos = cursorRef.current; 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 afterPartial = draft.substring(startPos + fullMatch.length); const newValue = before + '@' + category + ':' + afterPartial; const newCursorPos = startPos + 1 + category.length + 1; onDraftChange(newValue); setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(MENTION_PATTERN)); }} /> )}
); }); /** Animated slide panel - handles enter/exit animations */ function SlidePanel({ open, width = 'w-[360px]', children, }: { open: boolean; width?: string; children: ReactNode; }) { const [visible, setVisible] = useState(open); const [animate, setAnimate] = useState(open); useEffect(() => { if (open) { setVisible(true); // Force reflow then animate in requestAnimationFrame(() => { requestAnimationFrame(() => { setAnimate(true); }); }); } else { setAnimate(false); const timer = setTimeout(() => setVisible(false), 200); return () => clearTimeout(timer); } }, [open]); if (!visible) return null; return ( ); } interface RoomChatPanelProps { room: RoomResponse; isAdmin: boolean; onClose: () => void; onDelete: () => void; } export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPanelProps) { const { messages, members, membersLoading, sendMessage, editMessage, revokeMessage, updateRoom, wsStatus, wsError, wsClient, threads, refreshThreads, projectRepos, reposLoading, roomAiConfigs, aiConfigsLoading, } = useRoom(); const messagesEndRef = useRef(null); const chatInputRef = useRef(null); const [replyingTo, setReplyingTo] = useState(null); const [editingMessage, setEditingMessage] = useState(null); const [editDialogOpen, setEditDialogOpen] = useState(false); const [editHistoryDialogOpen, setEditHistoryDialogOpen] = useState(false); const [selectedMessageForHistory, setSelectedMessageForHistory] = useState(''); const [showAiTasks, setShowAiTasks] = useState(false); const [showParticipants, setShowParticipants] = useState(false); const [showSettings, setShowSettings] = useState(false); const [showSearch, setShowSearch] = useState(false); const [showMentions, setShowMentions] = useState(false); const [isUpdatingRoom, setIsUpdatingRoom] = useState(false); const [activeThread, setActiveThread] = useState<{ thread: RoomThreadResponse; parentMessage: MessageWithMeta } | null>(null); // Draft management const { draft, setDraft, clearDraft } = useRoomDraft(room.id); const isWsConnected = wsStatus === 'open'; const connectionLabel = wsStatus === 'connecting' ? 'Connecting...' : wsStatus === 'closed' ? 'Disconnected' : wsStatus === 'error' ? (wsError ?? 'Connection error') : null; // Visual connection status dot (Discord-style) const statusDotColor = wsStatus === 'open' ? 'bg-green-500' : wsStatus === 'connecting' ? 'bg-yellow-400 animate-pulse' : 'bg-red-500'; const handleSend = useCallback( (content: string) => { sendMessage(content, 'text', replyingTo?.id ?? undefined); setReplyingTo(null); }, // sendMessage from useRoom is already stable; replyingTo changes trigger handleSend rebuild (acceptable) [sendMessage, replyingTo], ); const handleEdit = useCallback((message: MessageWithMeta) => { setEditingMessage(message); setEditDialogOpen(true); }, []); const handleViewEditHistory = useCallback((message: MessageWithMeta) => { setSelectedMessageForHistory(message.id); setEditHistoryDialogOpen(true); }, []); const handleEditConfirm = useCallback( (newContent: string) => { if (!editingMessage) return; editMessage(editingMessage.id, newContent); setEditDialogOpen(false); setEditingMessage(null); toast.success('Message updated'); }, // Only rebuild when editingMessage.id actually changes, not on every new message [editingMessage?.id, editMessage], ); const handleRevoke = useCallback( (message: MessageWithMeta) => { revokeMessage(message.id); toast.success('Message deleted'); }, [revokeMessage], ); // Stable: chatInputRef is stable, no deps that change on message updates const handleMention = useCallback((id: string, label: string) => { chatInputRef.current?.insertMention(id, label, 'user'); }, []); const handleSelectSearchResult = useCallback((message: RoomMessageResponse) => { toast.info(`Selected message from ${message.send_at}`); setShowSearch(false); }, []); const handleUpdateRoom = useCallback( async (name: string, isPublic: boolean) => { setIsUpdatingRoom(true); try { await updateRoom(room.id, name, isPublic); } finally { setIsUpdatingRoom(false); } }, [room.id, updateRoom], ); // Thread callbacks const handleOpenThread = useCallback((message: MessageWithMeta) => { if (!message.thread_id) return; const thread = threads.find(t => t.id === message.thread_id); if (thread) { setActiveThread({ thread, parentMessage: message }); } }, [threads]); const handleCreateThread = useCallback(async (message: MessageWithMeta) => { if (!wsClient || message.thread_id) return; try { const thread = await wsClient.threadCreate(room.id, message.seq); setActiveThread({ thread, parentMessage: message }); refreshThreads(); } catch (err) { console.error('Failed to create thread:', err); toast.error('Failed to create thread'); } }, [wsClient, room.id, refreshThreads]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages.length]); useEffect(() => { setReplyingTo(null); setEditingMessage(null); setEditDialogOpen(false); setShowAiTasks(false); setShowParticipants(false); setShowSettings(false); setShowSearch(false); setShowMentions(false); setActiveThread(null); }, [room.id]); return (

{room.room_name}

{!room.public && ( Private )}

{members.length} member{members.length !== 1 ? 's' : ''} {messages.length > 0 && ` · ${messages.length} message${messages.length !== 1 ? 's' : ''}`} {!isWsConnected && connectionLabel && ( — {connectionLabel} )}

{isAdmin && ( )} {isAdmin && ( )}
setReplyingTo(null)} draft={draft} onDraftChange={setDraft} onClearDraft={clearDraft} />
{/* Side panels with slide animations */} setShowMentions(false)} onSelectNotification={(mention) => { toast.info(`Navigate to message in ${mention.room_name}`); setShowMentions(false); }} /> setShowSearch(false)} /> setShowAiTasks(false)} /> setShowSettings(false)} isPending={isUpdatingRoom} /> {activeThread && ( setActiveThread(null)} /> )}
); }