/**
* 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 (
{greetingSubtitle}
{msg.closeConfirmation?.message || `Are you sure you want to close ticket #${msg.closeConfirmation?.ticket_id || "this ticket"}? This will mark the ticket as resolved.`}
)}{msg.closeSuccess.message || 'The ticket has been marked as resolved.'}
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.
Error:
{msg.ticketSubmission.error}
Submitted details:
{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.'}
> )}
{msg.requestContext.system_prompt || 'β'}
{m.content || 'β'}
Countdown paused
{att.name}
{formatFileSize(att.size)}
{att.name}
{formatFileSize(att.size)}
{formatFileSize(viewingAttachment.size)}
Preview not available for this attachment.
{/* Text file content would be loaded here */}
Text preview not yet implemented
) : (
Preview not available for this file type
{viewingAttachment.type}
{viewingTicketDetail ? 'Details' : `${ticketListData.tickets?.length || 0} ${ticketListData.mode_used === 'mycapex' ? 'requests' : 'tickets'}`}
AI-generated summary of the ticket (description and comments).
{hasActiveFilters ? 'No tickets match your search or filters' : (ticketListData.mode_used === 'mycapex' ? 'No CapEx requests found' : ticketListData.mode_used === 'opened_by_user' ? 'No tickets found for this person' : 'No tickets found')}
{hasActiveFilters ? 'Try changing your search or filter options.' : (ticketListData.mode_used === 'handledbyme' ? "You don't have any tickets assigned to you." : ticketListData.mode_used === 'opened_by_user' ? "This person hasn't opened any tickets." : ticketListData.mode_used === 'mycapex' ? "You don't have any CapEx requests." : "You don't have any support tickets.")}
Last updated: {new Date(ticket.updated).toLocaleString()}
}{pendingClearChatReason === 'clear' ? 'Are you sure you want to clear this chat? All conversation history will be removed and you\'ll start with a fresh conversation.' : 'Great! Starting a new conversation will clear the current chat history.'}
Please review the details below. If anything is wrong, click Cancel and tell the assistant what to change.
{pendingTicketSubmission.type === 'capex' ? 'This request will be sent to the appropriate team for review.' : 'Our IT team will be notified and will review your request.'}
The following changes will be applied. Only the fields listed below will be updated.
No changes specified.
)}Review and submit your support ticket
{att.name}
{formatFileSize(att.size)}
{attachments.length > 0 ? 'Add more files: drag and drop here, or ' : 'Drag and drop files here, or '}
Supports images, documents, and other files