'use client'; /** * Chat input using the production-ready TipTap IMEditor. * Supports @mentions, file uploads, emoji picker, and rich message AST. */ import {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef} from 'react'; import type {IMEditorHandle} from './editor/IMEditor'; import {IMEditor} from './editor/IMEditor'; import {useRoom} from '@/contexts'; import type {EditorNode, MessageAST} from './editor/types'; export interface MessageInputProps { roomName: string; onSend: (content: string, attachmentIds?: string[]) => void; replyingTo?: { id: string; display_name?: string; content: string } | null; onCancelReply?: () => void; } export interface MessageInputHandle { focus: () => void; clearContent: () => void; getContent: () => string; insertMention: (type: string, id: string, label: string) => void; getAttachmentIds: () => string[]; } // Slash commands available in the editor const SLASH_COMMANDS = [ {id: 'ai', label: '/ai', description: 'Ask AI a question', type: 'command' as const}, { id: 'remind', label: '/remind', description: 'Set a reminder (e.g. /remind 10m Check CI)', type: 'command' as const }, {id: 'poll', label: '/poll', description: 'Create a poll (e.g. /poll "Question?" A B C)', type: 'command' as const}, {id: 'code-review', label: '/code-review', description: 'Request AI code review', type: 'command' as const}, ]; // Special mention items — @here (online), @channel (all members) const SPECIAL_MENTIONS = [ { id: '__here__', label: 'here', description: 'Notify online members', type: 'special_here' as const, }, { id: '__channel__', label: 'channel', description: 'Notify all members', type: 'special_channel' as const, }, ]; /** Serialize tiptap AST to backend-parseable string format. */ function serializeMessageAst(ast: MessageAST): string { return ast.content.map(serializeNode).join('\n'); } function serializeNode(node: EditorNode): string { if (node.type === 'text') return node.text; if (node.type === 'mention') return `@[${node.attrs.type}:${node.attrs.id}:${node.attrs.label}]`; if (node.type === 'hardBreak') return '\n'; if (node.type === 'file') return ''; // files are sent separately via attachmentIds if (node.type === 'emoji') return `[emoji:${node.attrs.name}]`; // Recurse into container nodes (paragraph, bulletList, etc.) const children = (node as any).content as EditorNode[] | undefined; if (children) return children.map(serializeNode).join(''); return ''; } export const MessageInput = forwardRef(function MessageInput( {roomName, onSend, replyingTo, onCancelReply}, ref, ) { const {members, activeRoomId, roomAiConfigs, projectRepos, wsClient} = useRoom(); // Ref passed to the inner IMEditor const innerEditorRef = useRef(null); // Expose a subset of IMEditorHandle (plus getAttachmentIds) as MessageInputHandle useImperativeHandle(ref, () => ({ focus: () => innerEditorRef.current?.focus(), clearContent: () => innerEditorRef.current?.clearContent(), getContent: () => innerEditorRef.current?.getContent() ?? '', insertMention: (type: string, id: string, label: string) => innerEditorRef.current?.insertMention(type, id, label), getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [], }), []); // Typing indicator: debounce start/stop const typingStopTimerRef = useRef | null>(null); const sendTypingStart = useCallback(() => { if (!wsClient || !activeRoomId) { console.debug('[MessageInput] sendTypingStart skipped: wsClient=', !!wsClient, 'activeRoomId=', activeRoomId); return; } console.debug('[MessageInput] sendTypingStart room:', activeRoomId); if (typingStopTimerRef.current) { clearTimeout(typingStopTimerRef.current); typingStopTimerRef.current = null; } wsClient.sendTyping(activeRoomId, 'start'); }, [wsClient, activeRoomId]); const sendTypingStop = useCallback(() => { if (!wsClient || !activeRoomId) return; if (typingStopTimerRef.current) { clearTimeout(typingStopTimerRef.current); typingStopTimerRef.current = null; } wsClient.sendTyping(activeRoomId, 'stop'); }, [wsClient, activeRoomId]); const handleEditorUpdate = useCallback((text: string) => { if (!text.trim()) { // Ignore empty updates (e.g. TipTap fires onUpdate("") on init). // Only stop typing on explicit clear or send. return; } console.debug('[MessageInput] handleEditorUpdate text_len:', text.length, 'ws:', !!wsClient, 'room:', activeRoomId); sendTypingStart(); // Auto-stop after 1.5s of inactivity if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current); typingStopTimerRef.current = setTimeout(sendTypingStop, 1500); }, [sendTypingStart, sendTypingStop]); // Stop typing on send or clear useEffect(() => { return () => { if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current); }; }, []); // Transform room data into MentionItems — memoized to prevent IMEditor re-creation const mentionItems = useMemo(() => ({ users: members.map((m) => ({ id: m.user, label: m.user_info?.username ?? m.user, type: 'user' as const, avatar: m.user_info?.avatar_url ?? undefined, })), channels: [] as { id: string; label: string; type: 'channel'; avatar?: string }[], ai: roomAiConfigs.map((cfg) => ({ id: cfg.model, // Fallback: try modelName, then short model ID (no provider prefix), then 'AI' label: cfg.modelName || cfg.model?.split('/').pop() || 'AI', type: 'ai' as const, })), repos: projectRepos.map((r) => ({ id: r.repo_name, label: r.repo_name, type: 'repo' as const, description: r.description ?? undefined, })), commands: SLASH_COMMANDS, specialMentions: SPECIAL_MENTIONS, }), [members, roomAiConfigs, projectRepos]); // File upload handler — POST to /rooms/{room_id}/upload const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => { if (!activeRoomId) throw new Error('No active room'); const formData = new FormData(); formData.append('file', file); const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin; const res = await fetch(`${baseUrl}/rooms/${activeRoomId}/upload`, {method: 'POST', body: formData}); if (!res.ok) throw new Error('Upload failed'); return res.json(); }; // onSend: serialize AST to backend-parseable format const handleSend = (_text: string, ast: MessageAST) => { sendTypingStop(); const serialized = serializeMessageAst(ast); onSend(serialized); }; return ( ); });