diff --git a/frontend/src/components/CandidateInfo.tsx b/frontend/src/components/CandidateInfo.tsx index 02f138c..7ca6910 100644 --- a/frontend/src/components/CandidateInfo.tsx +++ b/frontend/src/components/CandidateInfo.tsx @@ -16,6 +16,7 @@ interface CandidateInfoProps { sx?: SxProps; action?: string; elevation?: number; + variant?: "small" | "normal" | null }; const CandidateInfo: React.FC = (props: CandidateInfoProps) => { @@ -23,7 +24,8 @@ const CandidateInfo: React.FC = (props: CandidateInfoProps) const { sx, action = '', - elevation = 1 + elevation = 1, + variant = "normal" } = props; const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); @@ -107,23 +109,25 @@ const CandidateInfo: React.FC = (props: CandidateInfoProps) {candidate.description} - + {variant !== "small" && <> + - {candidate.location && - - Location: {candidate.location.city}, {candidate.location.state || candidate.location.country} - - } - {candidate.email && - - Email: {candidate.email} - - } - { candidate.phone && + {candidate.location && + + Location: {candidate.location.city}, {candidate.location.state || candidate.location.country} + + } + {candidate.email && + + Email: {candidate.email} + + } + {candidate.phone && Phone: {candidate.phone} - } - - + + } + } + diff --git a/frontend/src/pages/CandidateChatPage.tsx b/frontend/src/pages/CandidateChatPage.tsx index 7c9b308..207c627 100644 --- a/frontend/src/pages/CandidateChatPage.tsx +++ b/frontend/src/pages/CandidateChatPage.tsx @@ -2,64 +2,35 @@ import React, { forwardRef, useState, useEffect, useRef } from 'react'; import { Box, Paper, - Typography, - TextField, Button, - List, - ListItem, - ListItemText, - Chip, - IconButton, - CircularProgress, Divider, - Card, - CardContent, - Avatar, useTheme, useMediaQuery, - Fab, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Backdrop } from '@mui/material'; import { - ChevronLeft, - ChevronRight, - Chat as ChatIcon, - Edit as EditIcon, - Delete as DeleteIcon + Send as SendIcon } from '@mui/icons-material'; import { useAuth } from 'hooks/AuthContext'; -import { ChatMessageBase, ChatMessage, ChatSession, ChatStatusType, ChatMessageType, ChatMessageUser } from 'types/types'; +import { ChatMessageBase, ChatMessage, ChatSession, ChatMessageUser } from 'types/types'; import { ConversationHandle } from 'components/Conversation'; import { BackstoryPageProps } from 'components/BackstoryTab'; import { Message } from 'components/Message'; import { DeleteConfirmation } from 'components/DeleteConfirmation'; -import { CandidateSessionsResponse } from 'services/api-client'; import { CandidateInfo } from 'components/CandidateInfo'; import { useNavigate } from 'react-router-dom'; import { useSelectedCandidate } from 'hooks/GlobalContext'; import PropagateLoader from 'react-spinners/PropagateLoader'; import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField'; -const DRAWER_WIDTH = 300; -const FAB_WIDTH = 48; -const FAB_HEIGHT = 64; - const defaultMessage: ChatMessage = { type: "preparing", status: "done", sender: "system", sessionId: "", timestamp: new Date(), content: "" }; -type HandlePosition = 'center' | 'top' | 'bottom'; - const CandidateChatPage = forwardRef((props: BackstoryPageProps, ref) => { const { apiClient } = useAuth(); - const { selectedCandidate } = useSelectedCandidate() const navigate = useNavigate(); + const { selectedCandidate } = useSelectedCandidate() const theme = useTheme(); - const isMdUp = useMediaQuery(theme.breakpoints.up('md')); const [processingMessage, setProcessingMessage] = useState(null); const [streamingMessage, setStreamingMessage] = useState(null); const backstoryTextRef = useRef(null); @@ -69,84 +40,12 @@ const CandidateChatPage = forwardRef((pr submitQuery, } = props; - const [sessions, setSessions] = useState(null); const [chatSession, setChatSession] = useState(null); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const [streaming, setStreaming] = useState(false); const messagesEndRef = useRef(null); - // Drawer state - defaults to open on md+ screens - const [drawerOpen, setDrawerOpen] = useState(() => isMdUp); - - // Handle position state for mobile scroll behavior - const [handlePosition, setHandlePosition] = useState('center'); - const lastScrollY = useRef(0); - const scrollTimeout = useRef(null); - - // Edit session state - const [editDialogOpen, setEditDialogOpen] = useState(false); - const [editingSession, setEditingSession] = useState(null); - const [editSessionTitle, setEditSessionTitle] = useState(''); - - // Delete confirmation state - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [sessionToDelete, setSessionToDelete] = useState(null); - - // Update drawer state when screen size changes - useEffect(() => { - if (isMdUp && !drawerOpen) { - setDrawerOpen(true); - } - }, [isMdUp]); - - // Scroll event handler for mobile handle positioning - useEffect(() => { - if (isMdUp) return; // Only for mobile - - const handleScroll = () => { - const currentScrollY = window.scrollY; - const scrollDirection = currentScrollY > lastScrollY.current ? 'down' : 'up'; - - // Clear existing timeout - if (scrollTimeout.current) { - clearTimeout(scrollTimeout.current); - } - - // Update handle position based on scroll direction - if (scrollDirection === 'down' && currentScrollY > 50) { - setHandlePosition('top'); - } else if (scrollDirection === 'up' && currentScrollY > 50) { - setHandlePosition('bottom'); - } - - lastScrollY.current = currentScrollY; - - // Reset to center after scrolling stops (with debounce) - scrollTimeout.current = setTimeout(() => { - if (currentScrollY <= 50) { - setHandlePosition('center'); - } - }, 150); - }; - - window.addEventListener('scroll', handleScroll, { passive: true }); - - return () => { - window.removeEventListener('scroll', handleScroll); - if (scrollTimeout.current) { - clearTimeout(scrollTimeout.current); - } - }; - }, [isMdUp]); - - // Close drawer when clicking outside on mobile only - const handleBackdropClick = () => { - if (!isMdUp) { - setDrawerOpen(false); - } - }; - // Load sessions for the selectedCandidate const loadSessions = async () => { if (!selectedCandidate) return; @@ -154,9 +53,20 @@ const CandidateChatPage = forwardRef((pr try { setLoading(true); const result = await apiClient.getCandidateChatSessions(selectedCandidate.username); - setSessions(result); + let session = null; + if (result.sessions.data.length === 0) { + session = await apiClient.createCandidateChatSession( + selectedCandidate.username, + 'candidate_chat', + `Backstory chat about ${selectedCandidate.fullName}` + ); + } else { + session = result.sessions.data[0]; + } + setChatSession(session); + setLoading(false); } catch (error) { - console.error('Failed to load sessions:', error); + setSnack('Unable to load chat session', 'error'); } finally { setLoading(false); } @@ -178,76 +88,15 @@ const CandidateChatPage = forwardRef((pr } }; - // Create new session - const createNewSession = async () => { - if (!selectedCandidate) { return } - try { - setLoading(true); - const newSession = await apiClient.createCandidateChatSession( - selectedCandidate.username, - 'candidate_chat', - `Backstory chat about ${selectedCandidate.fullName}` - ); - setChatSession(newSession); - setMessages([]); - setProcessingMessage(null); - setStreamingMessage(null); - await loadSessions(); // Refresh sessions list - } catch (error) { - console.error('Failed to create session:', error); - } finally { - setLoading(false); + const onDelete = async (session: ChatSession) => { + if (!session.id) { + return; } - }; - - // Edit session - const handleEditSession = (session: ChatSession, event: React.MouseEvent) => { - event.stopPropagation(); // Prevent session selection - setEditingSession(session); - setEditSessionTitle(session.title || ''); - setEditDialogOpen(true); - }; - - const handleSaveEdit = async () => { - if (!editingSession?.id || !editSessionTitle.trim()) return; - try { - // Assuming there's an API method to update session title - await apiClient.updateChatSession(editingSession.id, { title: editSessionTitle.trim() }); - await loadSessions(); // Refresh sessions list - setEditDialogOpen(false); - setEditingSession(null); - setEditSessionTitle(''); - setSnack('Session title updated successfully', 'success'); - } catch (error) { - console.error('Failed to update session:', error); - setSnack('Failed to update session title', 'error'); - } - }; - - // Delete session - const handleDeleteSession = (session: ChatSession, event: React.MouseEvent) => { - event.stopPropagation(); // Prevent session selection - setSessionToDelete(session); - setDeleteDialogOpen(true); - }; - - const handleConfirmDelete = async () => { - if (!sessionToDelete?.id) return; - - try { - await apiClient.deleteChatSession(sessionToDelete.id); - await loadSessions(); // Refresh sessions list - + await apiClient.resetChatSession(session.id); // If we're deleting the currently selected session, clear it - if (chatSession?.id === sessionToDelete.id) { - setChatSession(null); - setMessages([]); - } - - setDeleteDialogOpen(false); - setSessionToDelete(null); - setSnack('Session deleted successfully', 'success'); + setMessages([]); + setSnack('Session reset succeeded', 'success'); } catch (error) { console.error('Failed to delete session:', error); setSnack('Failed to delete session', 'error'); @@ -276,8 +125,8 @@ const CandidateChatPage = forwardRef((pr }); try { - await apiClient.sendMessageStream(chatMessage, { - onMessage: (msg: ChatMessage) => { + apiClient.sendMessageStream(chatMessage, { + onMessage: (msg: ChatMessage) => { console.log(`onMessage: ${msg.type} ${msg.content}`, msg); if (msg.type === "response") { setMessages(prev => { @@ -289,30 +138,30 @@ const CandidateChatPage = forwardRef((pr } else { setProcessingMessage(msg); } - }, - onError: (error: string | ChatMessageBase) => { - console.log("onError:", error); - // Type-guard to determine if this is a ChatMessageBase or a string - if (typeof error === "object" && error !== null && "content" in error) { - setProcessingMessage(error as ChatMessage); - } else { - setProcessingMessage({ ...defaultMessage, content: error as string }); - } - setStreaming(false); - }, - onStreaming: (chunk: ChatMessageBase) => { - // console.log("onStreaming:", chunk); - setStreamingMessage({ ...defaultMessage, ...chunk }); - }, + }, + onError: (error: string | ChatMessageBase) => { + console.log("onError:", error); + // Type-guard to determine if this is a ChatMessageBase or a string + if (typeof error === "object" && error !== null && "content" in error) { + setProcessingMessage(error as ChatMessage); + } else { + setProcessingMessage({ ...defaultMessage, content: error as string }); + } + setStreaming(false); + }, + onStreaming: (chunk: ChatMessageBase) => { + // console.log("onStreaming:", chunk); + setStreamingMessage({ ...defaultMessage, ...chunk }); + }, onStatusChange: (status: string) => { console.log(`onStatusChange: ${status}`); - }, - onComplete: () => { - console.log("onComplete"); - setStreamingMessage(null); - setProcessingMessage(null); - setStreaming(false); - } + }, + onComplete: () => { + console.log("onComplete"); + setStreamingMessage(null); + setProcessingMessage(null); + setStreaming(false); + } }); } catch (error) { console.error('Failed to send message:', error); @@ -339,412 +188,123 @@ const CandidateChatPage = forwardRef((pr if (!selectedCandidate) { navigate('/find-a-candidate'); + return (<>); } - // Get handle positioning styles based on current position - const getHandleStyles = () => { - const baseStyles = { - position: 'absolute' as const, - left: DRAWER_WIDTH, - zIndex: theme.zIndex.drawer + 1, - }; - - switch (handlePosition) { - case 'top': - return { - ...baseStyles, - position: 'fixed' as const, - top: 20, - transform: 'translateY(0)', - }; - case 'bottom': - return { - ...baseStyles, - position: 'fixed' as const, - bottom: 20, - transform: 'translateY(0)', - }; - case 'center': - default: - return { - ...baseStyles, - top: '50%', - transform: 'translateY(-50%)', - }; - } + const welcomeMessage: ChatMessage = { + sessionId: chatSession?.id || '', + type: "info", + status: "done", + sender: "system", + timestamp: new Date(), + content: `Welcome to the Backstory Chat about ${selectedCandidate.fullName}. Ask any questions you have about ${selectedCandidate.firstName}.` }; - const drawerContent = ( - - {/* Fixed Header Section */} - - - Chat Sessions - {sessions && ( - - )} - - - - - - {/* Scrollable Sessions List */} - - {sessions ? ( - - {sessions.sessions.data.map((session: any) => ( - { - setChatSession(session); - // Auto-close drawer on mobile only when session is selected - if (!isMdUp) { - setDrawerOpen(false); - } - }} - sx={{ - mb: 1, - borderRadius: 1, - border: '1px solid', - borderColor: chatSession?.id === session.id ? 'orange' : 'divider', - cursor: 'pointer', - backgroundColor: 'transparent', - '&:hover': { - backgroundColor: 'action.hover' - } - }} - secondaryAction={ - - handleEditSession(session, e)} - sx={{ - '&:hover': { - backgroundColor: 'action.hover', - color: 'primary.main' - } - }} - > - - - handleDeleteSession(session, e)} - sx={{ - '&:hover': { - backgroundColor: 'error.light', - color: 'error.main' - } - }} - > - - - - } - > - - - ))} - - ) : ( - - Enter a username and click "Load Sessions" - - )} - - - ); - return ( - {selectedCandidate && ( - - )} + - - - {/* Mobile Backdrop */} - {!isMdUp && drawerOpen && ( - - )} - - {/* Drawer Container - Different behavior for mobile vs desktop */} - - {/* Drawer Content */} - - {drawerContent} - - - {/* Integrated Fab Handle - Mobile Only */} - {!isMdUp && ( - - setDrawerOpen(!drawerOpen)} - sx={{ - borderRadius: '0 50% 50% 0', - width: FAB_WIDTH, - height: FAB_HEIGHT, - minHeight: FAB_HEIGHT, - boxShadow: 2, - transition: theme.transitions.create(['box-shadow', 'background-color'], { - duration: theme.transitions.duration.short, - }), - '&:hover': { - boxShadow: 4, - } - }} - > - {drawerOpen ? : } - - - )} - - - {/* Chat Interface */} - + {/* Scrollable Messages Area */} + {chatSession && <> + - {chatSession?.id ? ( - <> - {/* Scrollable Messages Area */} + minHeight: 'max-content', // Important for flex child + // Custom scrollbar styling + '&::-webkit-scrollbar': { + width: '8px', + }, + '&::-webkit-scrollbar-track': { + backgroundColor: theme.palette.grey[100], + borderRadius: '4px', + }, + '&::-webkit-scrollbar-thumb': { + backgroundColor: theme.palette.grey[400], + borderRadius: '4px', + '&:hover': { + backgroundColor: theme.palette.grey[600], + }, + }, + }}> + {messages.length === 0 && } + {messages.map((message: ChatMessageBase) => ( + + ))} + {processingMessage !== null && ( + + )} + {streamingMessage !== null && ( + + )} + {streaming && ( - {messages.map((message: ChatMessageBase) => ( - - ))} - {processingMessage !== null && ( - - )} - {streamingMessage !== null && ( - - )} - {streaming && ( - - - - )} -
- - - - - {/* Fixed Message Input */} - - - - - ) : ( - - - Select a session to start chatting - - - Create a new session or choose from existing ones to begin discussing the candidate - - - )} - + )} +
+ + } + + + {/* Fixed Message Input */} + + { chatSession && onDelete(chatSession); }} + disabled={!chatSession} + action="reset" + label="chat session" + title="Reset Chat Session" + message={`Are you sure you want to reset the session? This action cannot be undone.`} + /> + + - - {/* Edit Session Dialog */} - setEditDialogOpen(false)} maxWidth="sm" fullWidth> - Edit Session Title - - setEditSessionTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSaveEdit(); - } - }} - /> - - - - - - - - {/* Delete Confirmation Dialog */} - setDeleteDialogOpen(false)} - onConfirm={handleConfirmDelete} - title="Delete Chat Session" - message={`Are you sure you want to delete the session "${sessionToDelete?.title}"? This action cannot be undone.`} - /> ); }); diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index c5b2246..50ae821 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -932,6 +932,15 @@ class ApiClient { return handleApiResponse<{ success: boolean; message: string }>(response); } + async resetChatSession(id: string): Promise<{ success: boolean; message: string }> { + const response = await fetch(`${this.baseUrl}/chat/sessions/${id}/reset`, { + method: 'PATCH', + headers: this.defaultHeaders + }); + + return handleApiResponse<{ success: boolean; message: string }>(response); + } + /** * Send message with streaming response support and date conversion */ diff --git a/src/backend/main.py b/src/backend/main.py index 3e31ebb..fd4ce61 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -3065,6 +3065,64 @@ async def delete_chat_session( content=create_error_response("DELETE_ERROR", str(e)) ) +@api_router.patch("/chat/sessions/{session_id}/reset") +async def reset_chat_session( + session_id: str = Path(...), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Delete a chat session and all its messages""" + try: + # Get the session to verify it exists and check ownership + session_data = await database.get_chat_session(session_id) + if not session_data: + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Chat session not found") + ) + + session = ChatSession.model_validate(session_data) + + # Check authorization - user can only delete their own sessions + if session.user_id != current_user.id: + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Cannot reset another user's chat session") + ) + + # Delete all messages associated with this session + try: + chat_messages = await database.get_chat_messages(session_id) + message_count = len(chat_messages) + + # Delete each message + for message_data in chat_messages: + message_id = message_data.get("id") + if message_id: + await database.delete_chat_message(session_id, message_id) + + logger.info(f"🗑️ Deleted {message_count} messages from session {session_id}") + + except Exception as e: + logger.warning(f"⚠️ Error deleting messages for session {session_id}: {e}") + # Continue with session deletion even if message deletion fails + + + logger.info(f"🗑️ Chat session {session_id} reset by user {current_user.id}") + + return create_success_response({ + "success": True, + "message": "Chat session reset successfully", + "sessionId": session_id + }) + + except Exception as e: + logger.error(f"❌ Reset chat session error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("RESET_ERROR", str(e)) + ) + @api_router.get("/candidates/{username}/chat-sessions") async def get_candidate_chat_sessions( username: str = Path(...),