diff --git a/react/src/assets/empower-agent.png b/react/src/assets/empower-agent.png new file mode 100644 index 000000000..08fad14d7 Binary files /dev/null and b/react/src/assets/empower-agent.png differ diff --git a/react/src/components/ChatWidget.jsx b/react/src/components/ChatWidget.jsx new file mode 100644 index 000000000..b82995077 --- /dev/null +++ b/react/src/components/ChatWidget.jsx @@ -0,0 +1,414 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import * as Sentry from '@sentry/react'; +import './chatWidget.css'; +import agentIcon from '../assets/empower-agent.png'; + +const CHAT_SESSION_INACTIVITY_TIMEOUT_MS = 15000; + +let messageIdCounter = 0; +const generateMessageId = () => `msg-${Date.now()}-${++messageIdCounter}`; + +const ChatWidget = () => { + const [isOpen, setIsOpen] = useState(false); + const [messages, setMessages] = useState([]); + const [userInput, setUserInput] = useState(''); + const [conversationState, setConversationState] = useState('initial'); + const [userResponses, setUserResponses] = useState({ + light: '', + maintenance: '' + }); + const messagesEndRef = useRef(null); + const chatSpanRef = useRef(null); + const typingSpanRef = useRef(null); + const typingTimeoutRef = useRef(null); + const inactivityTimeoutRef = useRef(null); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + const endChatSession = useCallback((reason = 'unknown') => { + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + typingTimeoutRef.current = null; + } + if (typingSpanRef.current) { + typingSpanRef.current.end(); + typingSpanRef.current = null; + } + if (inactivityTimeoutRef.current) { + clearTimeout(inactivityTimeoutRef.current); + inactivityTimeoutRef.current = null; + } + + // Record how the session ended + if (chatSpanRef.current) { + const isTimeout = reason === 'inactivity_timeout'; + Sentry.withActiveSpan(chatSpanRef.current, () => { + Sentry.startSpan( + { + op: isTimeout ? 'mark' : 'ui.action', + name: `Session End: ${reason}` + }, + () => { + // Span ends immediately + } + ); + }); + + chatSpanRef.current.end(); + chatSpanRef.current = null; + } + }, []); + + const startInactivityTimeout = useCallback(() => { + if (inactivityTimeoutRef.current) { + clearTimeout(inactivityTimeoutRef.current); + } + + if (chatSpanRef.current) { + inactivityTimeoutRef.current = setTimeout(() => { + endChatSession('inactivity_timeout'); + }, CHAT_SESSION_INACTIVITY_TIMEOUT_MS); + } + }, [endChatSession]); + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + const addBotMessage = useCallback((text, updateFn) => { + if (chatSpanRef.current) { + Sentry.withActiveSpan(chatSpanRef.current, () => { + Sentry.startSpan( + { op: 'ui.render', name: 'Render Bot Message' }, + () => { + if (updateFn) { + updateFn(); + } else { + setMessages(prev => [ + ...prev.filter(msg => msg.type !== 'typing'), + { + type: 'bot', + text, + id: generateMessageId() + } + ]); + } + } + ); + }); + } else { + if (updateFn) { + updateFn(); + } else { + setMessages(prev => [ + ...prev.filter(msg => msg.type !== 'typing'), + { + type: 'bot', + text, + id: generateMessageId() + } + ]); + } + } + }, []); + + useEffect(() => { + if (isOpen && conversationState === 'initial') { + // Show typing indicator + setMessages([{ type: 'typing', id: generateMessageId() }]); + + setTimeout(() => { + // Remove typing indicator and show welcome message + addBotMessage("Hi, I can help you pick the right plants for your home", () => { + setMessages([ + { + type: 'bot', + text: "Hi, I can help you pick the right plants for your home", + id: generateMessageId() + } + ]); + }); + + // Show second message after a brief pause + setTimeout(() => { + // Show typing indicator + setMessages(prev => [...prev, { type: 'typing', id: generateMessageId() }]); + + setTimeout(() => { + addBotMessage('How much light does your room get?'); + setConversationState('awaiting_light'); + }, 1000); + }, 500); + }, 1000); + } + }, [isOpen, conversationState, addBotMessage]); + + const handleInputFocus = () => { + if (chatSpanRef.current) { + Sentry.withActiveSpan(chatSpanRef.current, () => { + Sentry.startSpan( + { op: 'ui.action', name: 'Focus Chat Input' }, + () => { + // Span ends immediately after focus + } + ); + }); + } + }; + + const handleInputChange = (e) => { + setUserInput(e.target.value); + + // Start typing span if not already started + if (chatSpanRef.current && !typingSpanRef.current) { + Sentry.withActiveSpan(chatSpanRef.current, () => { + typingSpanRef.current = Sentry.startInactiveSpan({ + op: 'ui.action', + name: 'User Typing' + }); + }); + } + + // Clear existing timeout + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + + // Set new timeout to end typing span after 1 second of inactivity + typingTimeoutRef.current = setTimeout(() => { + if (typingSpanRef.current) { + typingSpanRef.current.end(); + typingSpanRef.current = null; + } + }, 1000); + }; + + const handleSendClick = () => { + // End any active typing span + if (typingSpanRef.current) { + typingSpanRef.current.end(); + typingSpanRef.current = null; + } + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + typingTimeoutRef.current = null; + } + + // Create send button click span + if (chatSpanRef.current) { + Sentry.withActiveSpan(chatSpanRef.current, () => { + Sentry.startSpan( + { op: 'ui.action.click', name: 'Send Message' }, + () => { + // Span ends immediately after click + } + ); + }); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!userInput.trim()) return; + + // Track the send click + handleSendClick(); + + // Add user message to chat + const userMessage = { + type: 'user', + text: userInput, + id: generateMessageId() + }; + setMessages(prev => [...prev, userMessage]); + + if (conversationState === 'awaiting_light') { + // Store light response + setUserResponses(prev => ({ ...prev, light: userInput })); + setUserInput(''); + + // Show typing indicator + setMessages(prev => [...prev, { type: 'typing', id: generateMessageId() }]); + + // Ask next question + setTimeout(() => { + addBotMessage('Are you only looking for low-maintenance plants?'); + setConversationState('awaiting_maintenance'); + }, 1000); + } else if (conversationState === 'awaiting_maintenance') { + // Store maintenance response + const maintenanceAnswer = userInput; + setUserInput(''); + + // Show typing indicator + setMessages(prev => [...prev, { type: 'typing', id: generateMessageId() }]); + + // Make API call within the active span context + try { + let response, data; + + if (chatSpanRef.current) { + await Sentry.withActiveSpan(chatSpanRef.current, async () => { + response = await fetch('https://empower-agent.sentry.gg/api/v1/buy-plants', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + light: userResponses.light, + maintenance: `Are you only looking for low-maintenance plants? Answer: ${maintenanceAnswer}` + }) + }); + data = await response.json(); + }); + } else { + response = await fetch('https://empower-agent.sentry.gg/api/v1/buy-plants', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + light: userResponses.light, + maintenance: `Are you only looking for low-maintenance plants? Answer: ${maintenanceAnswer}` + }) + }); + data = await response.json(); + } + + // Remove typing indicator and show response + addBotMessage(data.response, () => { + setMessages(prev => [ + ...prev.filter(msg => msg.type !== 'typing'), + { + type: 'bot', + text: data.response, + agentName: data.agent_name, + id: generateMessageId() + } + ]); + }); + setConversationState('completed'); + + // Start inactivity timeout after final bot response is rendered + startInactivityTimeout(); + } catch (error) { + // Handle error + addBotMessage('Sorry, I encountered an error. Please try again later.'); + setConversationState('error'); + + // Start inactivity timeout after error response is rendered + startInactivityTimeout(); + } + } + }; + + const openChat = () => { + // Opening the chat - start a new trace + Sentry.startNewTrace(() => { + const span = Sentry.startInactiveSpan({ + op: 'ui.interaction.chat', + name: 'AI Agent Chat Session', + forceTransaction: true + }); + chatSpanRef.current = span; + }); + setIsOpen(true); + }; + + const closeChat = (reason) => { + endChatSession(reason); + setIsOpen(false); + }; + + const handleAgentButtonClick = () => { + if (!isOpen) { + openChat(); + } else { + closeChat('click_agent_button'); + } + }; + + const handleCloseButtonClick = () => { + closeChat('click_close_button'); + }; + + // Clean up spans on unmount or navigation + useEffect(() => { + return () => { + endChatSession('navigation'); + }; + }, [endChatSession]); + + return ( +
+ {isOpen && ( +
+
+
+ AI Agent + AI Agent +
+ +
+ +
+ {messages.map((message) => { + if (message.type === 'typing') { + return ( +
+
+ + + +
+
+ ); + } + + return ( +
+
+ {(message.text || '').split('\n').map((line, index, array) => ( + + {line} + {index < array.length - 1 &&
} +
+ ))} +
+
+ ); + })} +
+
+ + {(conversationState === 'awaiting_light' || conversationState === 'awaiting_maintenance') && ( +
+ + +
+ )} +
+ )} + + +
+ ); +}; + +export default ChatWidget; diff --git a/react/src/components/Home.jsx b/react/src/components/Home.jsx index 9afc783ef..550b31bec 100644 --- a/react/src/components/Home.jsx +++ b/react/src/components/Home.jsx @@ -1,6 +1,7 @@ import * as Sentry from '@sentry/react'; import plantsBackground from '../assets/plants-background-img.jpg'; import Button from './ButtonLink'; +import ChatWidget from './ChatWidget'; import { useEffect } from 'react'; const divStyle = { @@ -32,6 +33,7 @@ function Home({ frontendSlowdown, backend }) { Browse products
+ ); } diff --git a/react/src/components/chatWidget.css b/react/src/components/chatWidget.css new file mode 100644 index 000000000..969bf4ba8 --- /dev/null +++ b/react/src/components/chatWidget.css @@ -0,0 +1,292 @@ +.chat-widget-container { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 1000; + font-family: 'Poppins', sans-serif; +} + +/* Position the chat button above the Sentry feedback widget */ +.chat-toggle-button { + position: fixed; + bottom: 70px; /* Above the Sentry feedback widget which is typically at bottom: 20px */ + right: 20px; + width: 90px; + height: 90px; + border-radius: 50%; + background-color: #002626; + border: none; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transition: transform 0.2s, box-shadow 0.2s; + padding: 12px; + z-index: 1001; +} + +.chat-toggle-button:hover { + transform: scale(1.05); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); +} + +.chat-toggle-button img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +/* Chat window */ +.chat-window { + position: fixed; + bottom: 180px; /* Above the toggle button */ + right: 20px; + width: 380px; + height: 550px; + background: white; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + overflow: hidden; + animation: slideUp 0.3s ease-out; + z-index: 1000; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Chat header */ +.chat-header { + background-color: #002626; + color: white; + padding: 16px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.chat-header-content { + display: flex; + align-items: center; + gap: 12px; +} + +.chat-header-icon { + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; +} + +.chat-header-title { + font-weight: 600; + font-size: 24px; +} + +.chat-close-button { + background: none; + border: none; + color: white; + font-size: 28px; + cursor: pointer; + padding: 0; + margin: 0; + line-height: 1; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.2s; +} + +.chat-close-button:hover { + opacity: 0.8; + background: none; + color: white; +} + +/* Messages area */ +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 20px; + background-color: #f8f8f8; + display: flex; + flex-direction: column; + gap: 12px; +} + +.message { + display: flex; + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.bot-message { + justify-content: flex-start; +} + +.user-message { + justify-content: flex-end; +} + +.message-bubble { + max-width: 75%; + padding: 12px 16px; + border-radius: 18px; + font-size: 15px; + line-height: 1.4; +} + +.bot-message .message-bubble { + background-color: #002626; + color: white; + border-bottom-left-radius: 4px; +} + +.user-message .message-bubble { + background-color: #dddc4e; + color: #002626; + border-bottom-right-radius: 4px; +} + +/* Typing indicator */ +.typing-indicator { + display: flex; + gap: 4px; + padding: 12px 16px; + background-color: #002626; + border-radius: 18px; + border-bottom-left-radius: 4px; + width: fit-content; +} + +.typing-indicator span { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: white; + animation: typing 1.4s infinite; +} + +.typing-indicator span:nth-child(2) { + animation-delay: 0.2s; +} + +.typing-indicator span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes typing { + 0%, 60%, 100% { + opacity: 0.3; + transform: scale(0.8); + } + 30% { + opacity: 1; + transform: scale(1); + } +} + +/* Input form */ +.chat-input-form { + display: flex; + padding: 16px; + background: white; + border-top: 1px solid #e0e0e0; + gap: 8px; + align-items: center; +} + +.chat-input { + flex: 1; + padding: 10px 14px; + border: 1px solid #ddd; + border-radius: 20px; + font-size: 15px !important; + outline: none; + font-family: 'Poppins', sans-serif; + margin: 0; + width: auto; + height: 40px; + box-sizing: border-box; + line-height: 1.4; +} + +.chat-input::placeholder { + font-size: 15px; +} + +.chat-input:focus { + border-color: #002626; +} + +.chat-send-button { + background-color: #002626; + color: white; + border: none; + border-radius: 20px; + padding: 0 20px; + cursor: pointer; + font-size: 15px; + font-weight: 500; + transition: background-color 0.2s; + margin: 0; + height: 40px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.chat-send-button:hover { + background-color: #dddc4e; + color: #002626; +} + +/* Mobile responsiveness */ +@media (max-width: 480px) { + .chat-window { + width: calc(100vw - 40px); + height: calc(100vh - 190px); + max-height: 550px; + } +} + +/* Scrollbar styling */ +.chat-messages::-webkit-scrollbar { + width: 6px; +} + +.chat-messages::-webkit-scrollbar-track { + background: #f1f1f1; +} + +.chat-messages::-webkit-scrollbar-thumb { + background: #888; + border-radius: 3px; +} + +.chat-messages::-webkit-scrollbar-thumb:hover { + background: #555; +} diff --git a/react/src/index.css b/react/src/index.css index 4d70df18f..8adc3ffa7 100644 --- a/react/src/index.css +++ b/react/src/index.css @@ -110,7 +110,6 @@ input { input[type='email'], input[type='text'] { display: block; - margin: 0.5rem auto 1rem; line-height: 1.5; font-size: 1.25rem; border: 1px solid; diff --git a/react/src/index.js b/react/src/index.js index 21e1baebf..e85518e63 100644 --- a/react/src/index.js +++ b/react/src/index.js @@ -46,6 +46,7 @@ const tracingOrigins = [ 'empower-plant.com', 'run.app', 'appspot.com', + 'empower-agent.sentry.gg', /^\//, ];