/** * AI Chat view: IT/hotel assistant with KB search, tickets, CapEx, and (when permitted) property/portfolio data. * Sends messages to /api/chat; permissions are enforced server-side. Never trusts client for access control. */ const AIChatView = ({ onCreateTicket, onClearChat, messages, setMessages, attachments, setAttachments, allAttachments, setAllAttachments, selectedProperty, currentUser, onOpenKbDraft }) => { const { useState, useEffect, useRef } = React; const greetingMessage = (typeof window !== 'undefined' && window.getAIAssistantGreeting && window.getAIAssistantGreeting(currentUser)) || "Hi! I'm your OTO Assistant. I can help with property information, directory lookups, knowledge base articles, and general questions about OTO-HUB.\n\nHow can I help you today?"; const greetingTitle = (typeof window !== 'undefined' && window.getAIAssistantTitle && window.getAIAssistantTitle(currentUser)) || "AI Assistant"; const greetingSubtitle = (typeof window !== 'undefined' && window.getAIAssistantSubtitle && window.getAIAssistantSubtitle(currentUser)) || "Get instant help with property info, directory lookups, and knowledge base content across OTO-HUB."; const [input, setInput] = useState(''); const [isTyping, setIsTyping] = useState(false); const [hasUserMessage, setHasUserMessage] = useState(false); const [isChatEnding, setIsChatEnding] = useState(false); // Track if countdown is active const currentUsername = (currentUser?.Username || currentUser?.username || localStorage.getItem('oto_username') || '').trim(); const messagesEndRef = useRef(null); const inputRef = useRef(null); // Add ref for textarea input const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); const [ticketState, setTicketState] = useState(null); const [lastTicketId, setLastTicketId] = useState(null); const [viewingAttachment, setViewingAttachment] = useState(null); // For modal popup const [showTicketListModal, setShowTicketListModal] = useState(false); // For ticket list modal const [ticketListData, setTicketListData] = useState(null); // Ticket list data const [ticketListFilter, setTicketListFilter] = useState(''); // Search text for ticket list const [ticketListFilterStatus, setTicketListFilterStatus] = useState(''); // Status filter: '' | 'New' | 'In Progress' | 'Waiting' | 'Closed' const [ticketListFilterPriority, setTicketListFilterPriority] = useState(''); // Priority filter: '' | 'Low' | 'Normal' | 'High' | 'Critical' const [viewingTicketDetail, setViewingTicketDetail] = useState(null); // Selected ticket for detail view (object or null) const [ticketDetailFull, setTicketDetailFull] = useState(null); // Full ticket from API when viewing detail const [ticketDetailLoading, setTicketDetailLoading] = useState(false); const [ticketDetailError, setTicketDetailError] = useState(null); const [ticketDetailNewComment, setTicketDetailNewComment] = useState(''); const [ticketDetailActionLoading, setTicketDetailActionLoading] = useState(null); // 'comment' | 'close' | 'update' | null const [ticketDetailCloseConfirm, setTicketDetailCloseConfirm] = useState(false); const [ticketDetailEditMode, setTicketDetailEditMode] = useState(false); const [ticketDetailEditSubject, setTicketDetailEditSubject] = useState(''); const [ticketDetailEditBody, setTicketDetailEditBody] = useState(''); const [ticketDetailEditPriority, setTicketDetailEditPriority] = useState('normal'); const [showClearChatModal, setShowClearChatModal] = useState(false); // For clear chat confirmation modal const [pendingClearChatReason, setPendingClearChatReason] = useState(null); // Reason for clearing chat const [countdownPaused, setCountdownPaused] = useState(false); // Track if countdown is paused const [pausedCountdownValue, setPausedCountdownValue] = useState(null); // Store countdown value when paused const [showTicketSubmitConfirmModal, setShowTicketSubmitConfirmModal] = useState(false); // For ticket submission confirmation const [pendingTicketSubmission, setPendingTicketSubmission] = useState(null); // Pending ticket submission data const [ticketSubmitConfirmLoading, setTicketSubmitConfirmLoading] = useState(false); // Prevent double-submit const [showTicketUpdateConfirmModal, setShowTicketUpdateConfirmModal] = useState(false); // For ticket update confirmation const [pendingTicketUpdate, setPendingTicketUpdate] = useState(null); // Pending ticket update (subject/body/priority) const [ticketUpdateConfirmLoading, setTicketUpdateConfirmLoading] = useState(false); const [expandedRequestContextIndex, setExpandedRequestContextIndex] = useState(null); // Which assistant message shows "what was sent to the AI" const countdownIntervalRef = useRef(null); const countdownTimeoutRef = useRef(null); // Track which KB sources have already been shown in this chat so we don't repeat them. // Reset this when the user starts a New Chat / Clear Now. const seenSourceIdsRef = useRef(new Set()); // Format JitBit comment body: strip and HTML tags to plain text for display const formatTicketCommentBody = (raw) => { if (raw == null || raw === '') return 'β€”'; let s = String(raw) .replace(//gi, '') .replace(//gi, '\n') .replace(/<[^>]+>/g, '') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"'); return s.trim() || 'β€”'; }; // Debug: Log when viewing attachment changes useEffect(() => { console.log('viewingAttachment changed:', viewingAttachment); }, [viewingAttachment]); // Helper to convert file to base64 const fileToBase64 = (file) => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result.split(',')[1]); // Remove data:image/png;base64, prefix reader.onerror = reject; reader.readAsDataURL(file); }); }; // Attachment functions const handleFileSelect = (e) => { const files = Array.from(e.target.files); const newAttachments = files.map(file => ({ id: Date.now() + Math.random(), name: file.name, size: file.size, type: file.type, file: file // Store actual file object for base64 conversion })); setAttachments(prev => [...prev, ...newAttachments]); setAllAttachments(prev => [...prev, ...newAttachments]); }; const removeAttachment = (id) => { setAttachments(prev => prev.filter(att => att.id !== id)); }; const handleDragOver = (e) => { e.preventDefault(); setIsDragging(true); }; const handleDragLeave = (e) => { e.preventDefault(); setIsDragging(false); }; const handleDrop = (e) => { e.preventDefault(); setIsDragging(false); const files = Array.from(e.dataTransfer.files); const newAttachments = files.map(file => ({ id: Date.now() + Math.random(), name: file.name, size: file.size })); setAttachments(prev => [...prev, ...newAttachments]); }; const formatFileSize = (bytes) => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; }; const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; useEffect(scrollToBottom, [messages]); // Fetch full ticket details when user opens the ticket detail view useEffect(() => { if (!viewingTicketDetail || !viewingTicketDetail.id) { setTicketDetailFull(null); setTicketDetailError(null); setTicketDetailLoading(false); return; } let cancelled = false; setTicketDetailLoading(true); setTicketDetailError(null); setTicketDetailFull(null); const tid = viewingTicketDetail.id; fetch(`/api/tickets/${tid}`, { headers: window.getApiHeaders ? window.getApiHeaders(currentUsername) : { 'X-OTO-User': currentUsername }, }) .then((res) => { if (cancelled) return; if (!res.ok) return res.json().then((d) => { throw new Error(d.detail || res.statusText); }); return res.json(); }) .then((data) => { if (!cancelled) { setTicketDetailFull(data); setTicketDetailError(null); } }) .catch((err) => { if (!cancelled) { setTicketDetailError(err.message || 'Failed to load ticket'); setTicketDetailFull(null); } }) .finally(() => { if (!cancelled) setTicketDetailLoading(false); }); return () => { cancelled = true; }; }, [viewingTicketDetail?.id, currentUsername]); const refreshTicketDetail = () => { if (!viewingTicketDetail?.id) return; const tid = viewingTicketDetail.id; fetch(`/api/tickets/${tid}`, { headers: window.getApiHeaders ? window.getApiHeaders(currentUsername) : { 'X-OTO-User': currentUsername }, }) .then((res) => res.ok ? res.json() : res.json().then((d) => { throw new Error(d.detail || res.statusText); })) .then((data) => { setTicketDetailFull(data); setTicketDetailError(null); }) .catch((err) => { setTicketDetailError(err.message || 'Failed to refresh'); }); }; const handleSend = async () => { if (!input.trim() && attachments.length === 0) return; const userMessage = { role: 'user', content: input || '(File attachments only)', timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), attachments: attachments.length > 0 ? attachments.map(file => ({ name: file.name, size: file.size, type: file.type, file: file.file // Store the actual File object for viewing later })) : [] }; const updatedAllAttachments = [...allAttachments, ...attachments]; setAllAttachments(updatedAllAttachments); // Clear any existing ticket previews from previous messages (user is continuing conversation) const messagesWithoutPreviews = messages.map(msg => { if (msg.ticketPreview) { console.log('🧹 Clearing ticket preview from previous message (user continuing conversation)'); return { ...msg, ticketPreview: null }; } return msg; }); const newMessages = [...messagesWithoutPreviews, userMessage]; setMessages(newMessages); setInput(''); const currentAttachments = [...attachments]; // Save before clearing setAttachments([]); setIsTyping(true); try { // Collect ALL attachments from the entire conversation history const allConversationAttachments = []; for (const msg of newMessages) { if (msg.attachments && msg.attachments.length > 0) { allConversationAttachments.push(...msg.attachments); } } // Convert all attachments to base64 for AI const attachmentsBase64 = await Promise.all( allConversationAttachments.map(async (att) => { if (att.file) { const base64 = await fileToBase64(att.file); return { name: att.name, type: att.type, data: base64 }; } return null; }) ); const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(window.getApiHeaders ? window.getApiHeaders(currentUsername) : {}) }, body: JSON.stringify({ // Filter out system/display-only messages (like the default greeting) before sending to backend messages: newMessages.filter(m => !m.isSystemMessage).map(m => ({ role: m.role, content: m.content })), draft: ticketState ? ticketState.draft : null, property_code: selectedProperty?.property_code || null, property_name: selectedProperty?.name || null, attachments: attachmentsBase64.filter(a => a !== null), last_ticket_id: lastTicketId, current_user: { full_name: `${currentUser.FirstName} ${currentUser.LastName}`.trim(), first_name: currentUser.FirstName, last_name: currentUser.LastName, username: currentUser.Username, email: currentUser.Email, phone: currentUser.Phone, role: currentUser.Role } }) }); let data; try { data = await res.json(); } catch { throw new Error(`AI request failed (${res.status})`); } if (!res.ok) throw new Error(data.detail || 'AI request failed'); console.log('[AI Response] Full data:', data); console.log('[AI Response] ticket_state:', data.ticket_state); if (data.ticket_state) { console.log(' β”œβ”€ show_ticket_preview:', data.ticket_state.show_ticket_preview); console.log(' β”œβ”€ ready_to_submit:', data.ticket_state.ready_to_submit); console.log(' β”œβ”€ draft:', data.ticket_state.draft); console.log(' └─ missing_fields:', data.ticket_state.missing_fields); } // Store the updated ticket state if (data.ticket_state) { setTicketState(data.ticket_state); console.log('[Ticket State] Updated:', data.ticket_state); } // Create the AI message // Deduplicate sources within the same chat so we only show *new* sources. const incomingSources = Array.isArray(data.sources) ? data.sources : []; const filteredSources = incomingSources.filter((src) => { // Prefer stable keys (DB id). Fall back to title+category+property_code. const key = String( src?.id ?? `${src?.title || ''}::${src?.category || ''}::${src?.property_code || ''}` ); if (seenSourceIdsRef.current.has(key)) return false; seenSourceIdsRef.current.add(key); return true; }); console.log('πŸ› RAW RESPONSE FROM SERVER:'); console.log('Full data object:', data); console.log('data.reply:', data.reply); console.log('data.reply TYPE:', typeof data.reply); console.log('data.reply LENGTH:', data.reply?.length); console.log('data.reply IS EMPTY:', !data.reply); console.log('data.reply FIRST 100 chars:', data.reply?.substring(0, 100)); const aiMessage = { role: 'assistant', content: (data.reply && data.reply.trim()) ? data.reply.trim() : ((data.ticket_state?.show_ticket_preview || data.ticket_state?.chat_clear_requested || data.ticket_state?.issue_resolved || data.ticket_state?.chat_ended || data.closeConfirmation || data.ticket_submission) ? '' : "I couldn't complete that. You may need different permissions for this action, or something went wrongβ€”please try again or rephrase."), timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), ticketPreview: data.ticket_state?.show_ticket_preview ? data.ticket_state.draft : null, readyToSubmit: data.ticket_state?.ready_to_submit || false, closeConfirmation: data.closeConfirmation || null, ticketSubmission: data.ticket_submission || null, // Direct ticket submission result sources: filteredSources, kbDraft: data.kb_draft || null, requestContext: data.request_context || null // What was sent to the AI (prompt, messages, tools) }; console.log('πŸ› AI MESSAGE OBJECT:'); console.log('aiMessage.content:', aiMessage.content); console.log('aiMessage.content LENGTH:', aiMessage.content?.length); console.log('aiMessage.content FIRST 100:', aiMessage.content?.substring(0, 100)); setMessages(prev => [...prev, aiMessage]); // Handle ticket list modal display if (data.show_ticket_list && data.ticket_list) { console.log('πŸ“‹ Showing ticket list modal:', data.ticket_list); setTicketListData(data.ticket_list); setShowTicketListModal(true); } // Handle single-ticket detail view (e.g. "view that ticket", after add comment/close/update) if (data.show_ticket_detail && data.ticket_detail) { const detail = data.ticket_detail; console.log('🎫 Opening ticket detail view:', detail); setTicketListData({ mode_used: 'single', count: 1, tickets: [{ id: detail.id, subject: detail.subject || 'Untitled', status: detail.status || '', priority: detail.priority || 'Normal', updated: detail.updated || '', category: detail.category || '', jitbit_url: detail.jitbit_url || '', }], }); setViewingTicketDetail({ ...detail, id: detail.id }); setShowTicketListModal(true); } // Handle ticket update confirmation - show proposed changes before applying if (data.ticket_update && data.ticket_update.pending_confirmation) { console.log('🎫 Showing ticket update confirmation modal:', data.ticket_update); setPendingTicketUpdate(data.ticket_update); setShowTicketUpdateConfirmModal(true); setIsTyping(false); return; } // Handle direct ticket submission - show confirmation modal first if pending if (data.ticket_submission) { if (data.ticket_submission.pending_confirmation) { // Show confirmation modal before submission console.log('🎫 Showing ticket submission confirmation modal:', data.ticket_submission); setPendingTicketSubmission(data.ticket_submission); setShowTicketSubmitConfirmModal(true); setIsTyping(false); // Don't add ticketSubmission to message yet - wait for confirmation const lastMsg = aiMessage; lastMsg.ticketSubmission = null; setMessages(prev => { const updated = [...prev]; updated[updated.length - 1] = lastMsg; return updated; }); return; // Don't process further until user confirms } else { // Already submitted - show success/error modal console.log('🎫 Ticket submission result:', data.ticket_submission); // Success/error modal will be shown via ticketSubmission in the message } } // NOTE: ready_to_submit just enables the Submit button - actual submission // and countdown only happen when user clicks "Submit Ticket" button // (handled in handleTicketSubmit function) // When AI inferred conversation ended (end_conversation tool): show countdown in chat, then fresh chat (no modal). if (data.ticket_state?.chat_ended && !data.ticket_state?.show_ticket_preview) { setIsChatEnding(true); setMessages(prev => { const filtered = prev.filter(m => !(m.role === 'system' && m.countdown !== undefined)); return [...filtered, { role: 'system', content: 'Conversation ended. Starting fresh in...', timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), countdown: 15 }]; }); let remaining = 15; countdownIntervalRef.current = setInterval(() => { remaining--; if (remaining > 0) { setMessages(prev => { const newMessages = [...prev]; const lastMsg = newMessages[newMessages.length - 1]; if (lastMsg && lastMsg.role === 'system' && lastMsg.countdown !== undefined) { lastMsg.countdown = remaining; } return newMessages; }); } else { if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } } }, 1000); countdownTimeoutRef.current = setTimeout(() => { if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } setMessages([ { role: 'assistant', content: greetingMessage, isSystemMessage: true } ]); setInput(''); setAllAttachments([]); setTicketState(null); setLastTicketId(null); setIsChatEnding(false); if (onClearChat) onClearChat(); }, 15000); setIsTyping(false); return; } // If issue resolved or user requested clear (no ticket preview): modal flow (confirm then clear). const shouldTriggerModal = (data.ticket_state?.issue_resolved || data.ticket_state?.chat_clear_requested) && !data.ticket_state?.show_ticket_preview; console.log('πŸ” COUNTDOWN CHECK:', { issue_resolved: data.ticket_state?.issue_resolved, chat_clear_requested: data.ticket_state?.chat_clear_requested, chat_ended: data.ticket_state?.chat_ended, show_ticket_preview: data.ticket_state?.show_ticket_preview, shouldTriggerModal }); if (shouldTriggerModal) { console.log('[Countdown] Triggered modal:', { issue_resolved: data.ticket_state?.issue_resolved, chat_clear_requested: data.ticket_state?.chat_clear_requested, existing_interval: !!countdownIntervalRef.current, existing_timeout: !!countdownTimeoutRef.current }); // If a countdown is already running and user says "clear" again, clear immediately if (data.ticket_state?.chat_clear_requested && (countdownIntervalRef.current || countdownTimeoutRef.current)) { console.log('[Countdown] User requested clear again - clearing immediately'); // Clear existing countdowns if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } if (countdownTimeoutRef.current) { clearTimeout(countdownTimeoutRef.current); countdownTimeoutRef.current = null; } // Clear immediately setMessages([ { role: 'assistant', content: greetingMessage, isSystemMessage: true } ]); setInput(''); setAllAttachments([]); setTicketState(null); setLastTicketId(null); setIsChatEnding(false); if (onClearChat) onClearChat(); setIsTyping(false); return; // Exit early, don't start a new countdown } // Show confirmation modal for chat clearing (no countdown in chat - just modal then clear) const reason = data.ticket_state?.chat_clear_requested ? 'clear' : data.ticket_state?.issue_resolved ? 'resolved' : 'unknown'; setPendingClearChatReason(reason); setShowClearChatModal(true); setIsTyping(false); return; // Wait for user confirmation - no countdown in chat box } } catch (err) { setMessages(prev => [...prev, { role: 'assistant', content: `Sorry β€” I couldn't reach the AI backend. ${err?.message || ''}`.trim(), timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }]); } finally { setIsTyping(false); // Auto-focus input after AI response setTimeout(() => { if (inputRef.current) { inputRef.current.focus(); } }, 100); } }; const handleKeyPress = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; // Handle ticket submission to JitBit const handleTicketSubmit = async (ticketPreview) => { console.group('🎫 TICKET SUBMISSION STARTED'); console.log('πŸ“‹ Ticket Preview Data:', ticketPreview); console.log('πŸ‘€ Current Username:', currentUsername); console.log('⏰ Timestamp:', new Date().toISOString()); if (!ticketPreview || !ticketPreview.subject || !ticketPreview.description) { console.error('❌ VALIDATION FAILED: Missing required fields'); console.log(' - Has ticketPreview:', !!ticketPreview); console.log(' - Has subject:', !!ticketPreview?.subject); console.log(' - Has description:', !!ticketPreview?.description); console.groupEnd(); setMessages(prev => [...prev, { role: 'assistant', content: '❌ Unable to submit ticket - missing required information.', timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }]); return; } console.log('βœ“ Validation passed'); console.log('πŸ“€ Preparing to send request...'); // Collect image attachments from the chat const imageAttachments = []; for (const att of allAttachments) { if (att.type && att.type.startsWith('image/') && att.file) { try { // Convert file to base64 const base64 = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result.split(',')[1]); reader.onerror = reject; reader.readAsDataURL(att.file); }); imageAttachments.push({ name: att.name, type: att.type, data: base64 }); } catch (err) { console.warn('Failed to process attachment:', att.name, err); } } } console.log(`πŸ“Ž Including ${imageAttachments.length} image attachments`); // Add attachments to ticket preview const ticketWithAttachments = { ...ticketPreview, attachments: imageAttachments }; // Show submitting message setMessages(prev => [...prev, { role: 'system', content: '⏳ Submitting your ticket to JitBit...', timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }]); try { console.log('🌐 Making fetch request to /api/tickets/submit'); console.log('πŸ“¦ Request payload:', JSON.stringify(ticketWithAttachments, null, 2)); console.log('πŸ”‘ Headers:', { 'Content-Type': 'application/json', 'X-OTO-User': currentUsername || 'NOT SET' }); const response = await fetch('/api/tickets/submit', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-OTO-User': currentUsername || '' }, body: JSON.stringify(ticketWithAttachments) }); console.log('πŸ“₯ Response received:'); console.log(' - Status:', response.status, response.statusText); console.log(' - OK:', response.ok); console.log(' - Headers:', Object.fromEntries(response.headers.entries())); if (!response.ok) { let errorData; try { errorData = await response.json(); console.error('❌ API Error Response:', errorData); } catch (parseError) { console.error('❌ Failed to parse error response:', parseError); errorData = { detail: `Server returned ${response.status}: ${response.statusText}` }; } console.groupEnd(); throw new Error(errorData.detail || `HTTP ${response.status}: ${response.statusText}`); } const result = await response.json(); console.log('βœ… Success! Ticket created:', result); console.groupEnd(); // Remove the "submitting" message and clear all ticket previews setMessages(prev => prev.slice(0, -1).map(msg => { if (msg.ticketPreview) { console.log('🧹 Clearing ticket preview after successful submission'); return { ...msg, ticketPreview: null }; } return msg; })); // Show success card setMessages(prev => [...prev, { role: 'assistant', content: '', // Empty content timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), ticketSuccess: { ticketId: result.jitbit_ticket_id, propertyCode: result.property_code, jitbitUrl: result.jitbit_url } }]); setLastTicketId(result.jitbit_ticket_id); // Clear ticket state setTicketState(null); // Start countdown to clear chat after successful submission setTimeout(() => { setIsChatEnding(true); // Hide input controls setMessages(prev => { const filtered = prev.filter(m => !(m.role === 'system' && m.countdown !== undefined)); return [...filtered, { role: 'system', content: 'Ticket submitted successfully! Starting fresh conversation in...', timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), countdown: 15 }]; }); // Start countdown let remaining = 15; countdownIntervalRef.current = setInterval(() => { remaining--; if (remaining > 0) { setMessages(prev => { const newMessages = [...prev]; const lastMsg = newMessages[newMessages.length - 1]; if (lastMsg && lastMsg.role === 'system' && lastMsg.countdown !== undefined) { lastMsg.countdown = remaining; } return newMessages; }); } else { if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } } }, 1000); // Clear chat after 15 seconds countdownTimeoutRef.current = setTimeout(() => { if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } setMessages([ { role: 'assistant', content: greetingMessage } ]); setInput(''); setAllAttachments([]); setTicketState(null); setLastTicketId(null); setIsChatEnding(false); if (onClearChat) onClearChat(); }, 15000); }, 500); } catch (error) { console.error('πŸ’₯ EXCEPTION CAUGHT:', error); console.error(' - Error name:', error.name); console.error(' - Error message:', error.message); console.error(' - Error stack:', error.stack); console.groupEnd(); // Remove the "submitting" message setMessages(prev => prev.slice(0, -1)); // Build detailed error message let errorDetails = error.message; // Check if it's a network error if (error.message.includes('fetch') || error.name === 'TypeError') { errorDetails = 'Network error - could not reach the server. Please check your connection and try again.'; } // Show error message with troubleshooting tips let errorMessage = `❌ **Failed to Submit Ticket**\n\n**Error:** ${errorDetails}\n\n`; errorMessage += `**Troubleshooting:**\n`; errorMessage += `- Check the browser console (F12) for detailed error information\n`; errorMessage += `- Verify you have an email address in your profile\n`; errorMessage += `- Confirm your user exists in JitBit\n`; errorMessage += `- Contact IT support directly if the problem persists\n\n`; errorMessage += `**Note:** The server logs may contain additional details about what went wrong.`; setMessages(prev => [...prev, { role: 'assistant', content: errorMessage, timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }]); } }; // Clear chat immediately (called from confirmation modal - no countdown in chat box) const clearChatImmediately = () => { // Clear any existing countdowns first if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } if (countdownTimeoutRef.current) { clearTimeout(countdownTimeoutRef.current); countdownTimeoutRef.current = null; } // Reset source dedupe for a fresh conversation seenSourceIdsRef.current = new Set(); // Clear chat immediately setMessages([ { role: 'assistant', content: greetingMessage, isSystemMessage: true } ]); setInput(''); setAllAttachments([]); setTicketState(null); setLastTicketId(null); setIsChatEnding(false); setCountdownPaused(false); if (onClearChat) onClearChat(); }; // Handle ticket cancellation const handleTicketCancel = () => { console.log('🚫 Ticket cancelled - removing all ticket previews from messages'); // Remove ticketPreview from all messages setMessages(prev => prev.map(msg => { if (msg.ticketPreview) { return { ...msg, ticketPreview: null }; } return msg; }).concat({ role: 'assistant', content: 'Ticket creation cancelled. Is there anything else I can help you with?', timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) })); setTicketState(null); }; const handleCreateTicketFromChat = () => { const conversationSummary = messages .filter(m => m.role === 'user') .map(m => m.content) .join('\n\n'); onCreateTicket(conversationSummary, attachments); }; return (

{greetingTitle}

{greetingSubtitle}

{messages.length > 1 && !isChatEnding && ( )}
{messages.map((msg, idx) => (
{msg.role === 'assistant' && msg.ticketPreview && (() => { console.log('🎫 RENDERING TICKET PREVIEW:', msg.ticketPreview); return true; })() && (
{/* Header */}

Ticket Preview

{/* Content */}
{msg.ticketPreview.property_code && (
Property: {msg.ticketPreview.property_code} - {msg.ticketPreview.property_name}
)}
Subject: {msg.ticketPreview.subject}
Description: {(() => { const desc = msg.ticketPreview.description || ''; const issueSummaryIndex = desc.indexOf('Issue Summary:'); if (issueSummaryIndex !== -1) { let summary = desc.substring(issueSummaryIndex + 'Issue Summary:'.length).trim(); const troubleshootingIndex = summary.indexOf('Troubleshooting Performed:'); if (troubleshootingIndex !== -1) { summary = summary.substring(0, troubleshootingIndex).trim(); } return summary; } return desc; })()}
Category: {msg.ticketPreview.category || 'Not specified'}
Priority: {msg.ticketPreview.priority}
{msg.ticketPreview.reportee && (
On behalf of: {msg.ticketPreview.reportee}
)}
{/* Action Buttons */}
)} {/* Knowledge Base draft card */} {msg.role === 'assistant' && msg.kbDraft && onOpenKbDraft && (

Knowledge Base Draft

Title: {msg.kbDraft.title || 'Untitled'}
{msg.kbDraft.category && (
Category: {msg.kbDraft.category}
)}
Content: {msg.kbDraft.content ? (msg.kbDraft.content.length > 200 ? msg.kbDraft.content.slice(0, 200) + '…' : msg.kbDraft.content) : ''}
)} {/* Close Confirmation Dialog */} {msg.role === 'assistant' && msg.closeConfirmation && (
{/* Header */}

Close Ticket #{msg.closeConfirmation?.ticket_id}

{/* Content */}
{msg.closeConfirmation.error ? (
❌ {msg.closeConfirmation.error}
) : (

{msg.closeConfirmation?.message || `Are you sure you want to close ticket #${msg.closeConfirmation?.ticket_id || "this ticket"}? This will mark the ticket as resolved.`}

)}
{/* Action Buttons */}
{msg.closeConfirmation.loading ? (
Closing ticket...
) : ( )}
)} {/* Close Success Card */} {msg.role === 'assistant' && msg.closeSuccess && (
{/* Header */}

Ticket Closed Successfully

{/* Content */}
Ticket #: {msg.closeSuccess.ticketId}
Status: Closed

{msg.closeSuccess.message || 'The ticket has been marked as resolved.'}

)} {/* Ticket Success Card */} {msg.role === 'assistant' && msg.ticketSuccess && (
{/* Header */}

Ticket Submitted Successfully

{/* Content */}
Ticket #: {msg.ticketSuccess.ticketId}
{msg.ticketSuccess.propertyCode && (
Property: {msg.ticketSuccess.propertyCode}
)}

Our IT support team has been notified and will review your ticket shortly. You'll receive email updates as we work on resolving your issue.

Need to add more details? Reply to the ticket notification email or contact IT support directly.
{/* Action Button */} {msg.ticketSuccess.jitbitUrl && ( )}
)} {/* Direct Ticket Submission card – matches app (primary), shows full submitted details */} {msg.role === 'assistant' && msg.ticketSubmission && (
{/* Header – primary blue to match app */}
{msg.ticketSubmission.error ? ( ) : ( )}

{msg.ticketSubmission.error ? `Failed to Submit ${msg.ticketSubmission.type === 'capex' ? 'CapEx Request' : 'Support Ticket'}` : `${msg.ticketSubmission.type === 'capex' ? 'CapEx Request' : 'Support Ticket'} Submitted`}

{/* Content – structured list of everything submitted */}
{msg.ticketSubmission.error ? (

Error:

{msg.ticketSubmission.error}

) : ( <>

Submitted details:

Ticket #
{msg.ticketSubmission.ticket_id ?? 'β€”'}
{(msg.ticketSubmission.subject != null && msg.ticketSubmission.subject !== '') && (
Subject
{msg.ticketSubmission.subject}
)} {(msg.ticketSubmission.description != null && msg.ticketSubmission.description !== '') && (
Description
{msg.ticketSubmission.description}
)}
Priority
{msg.ticketSubmission.priority || 'Normal'}
Property / Site
{msg.ticketSubmission.property_code || 'β€”'}
{msg.ticketSubmission.type === 'capex' && (
Estimated cost
{msg.ticketSubmission.estimated_cost ?? 'β€”'}
)} {msg.ticketSubmission.type === 'it_support' && (msg.ticketSubmission.category != null && msg.ticketSubmission.category !== '') && (
Category
{msg.ticketSubmission.category}
)}

{msg.ticketSubmission.type === 'capex' ? 'Your CapEx request has been submitted and will be reviewed by the appropriate team.' : 'Our IT support team has been notified and will review your ticket shortly.'}

)}
{/* Action Buttons */}
{!msg.ticketSubmission.error && msg.ticketSubmission.ticket_url && ( View {msg.ticketSubmission.type === 'capex' ? 'Request' : 'Ticket'} in JitBit β†’ )}
)} {msg.role === 'system' && msg.countdown !== undefined ? (
{msg.content}
) : msg.role === 'assistant' && (msg.ticketPreview || msg.closeConfirmation || msg.closeSuccess || msg.ticketSuccess || msg.ticketSubmission) ? ( // Don't show content when confirmation/success dialogs or ticket preview are displayed // The dialogs/previews are rendered above null ) : msg.role === 'user' ? ( // User messages: render as plain text (no markdown formatting)
{msg.content}
) : ( // Assistant messages: render with markdown formatting )} {msg.role === 'assistant' && Array.isArray(msg.sources) && msg.sources.length > 0 && (
Sources
{msg.sources.map((src, i) => (
{src?.title || 'Untitled'}
{src?.category || 'kb'} {src?.property_code ? ` β€’ ${src.property_code}` : ' β€’ Global'}
{src?.download_url && ( )}
))}
)} {msg.role === 'assistant' && msg.requestContext && (
{expandedRequestContextIndex === idx ? (
What was sent to the AI
System prompt
                                                    {msg.requestContext.system_prompt || 'β€”'}
                                                
Messages ({Array.isArray(msg.requestContext.messages) ? msg.requestContext.messages.length : 0})
{Array.isArray(msg.requestContext.messages) && msg.requestContext.messages.map((m, i) => (
{m.role}:
{m.content || 'β€”'}
))}
{Array.isArray(msg.requestContext.tool_names) && msg.requestContext.tool_names.length > 0 && (
Tools available: {msg.requestContext.tool_names.join(', ')}
)} {msg.requestContext.mode && (
Mode: {msg.requestContext.mode}
)}
) : ( )}
)} {msg.countdown !== undefined && msg.role === 'system' && (
{countdownPaused ? '⏸' : msg.countdown}
{countdownPaused && (

Countdown paused

)}
{!countdownPaused ? ( ) : ( )}
)} {msg.attachments && msg.attachments.length > 0 && (
{msg.attachments.map((att, attIdx) => (
{ console.log('Attachment clicked:', att); // The attachment already has the file stored if (att.file) { console.log('Opening attachment:', att.name); setViewingAttachment(att); } else { console.log('No file object found for:', att.name); } }} className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${msg.role === 'user' ? 'bg-primary-dark hover:bg-primary-hover' : 'bg-gray-100 hover:bg-gray-200' }`} >

{att.name}

{formatFileSize(att.size)}

))}
)}
))} {isTyping && (
)}
{attachments.length > 0 && (
{attachments.map(att => (

{att.name}

{formatFileSize(att.size)}

))}
)} {!isChatEnding && ( <>