From 40ab58fffe27b09b3369ec52cb2174655a7ccd42 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Sat, 31 May 2025 17:27:44 -0700 Subject: [PATCH] UI improvement for mobile/desktop on chat page --- .../src/components/DeleteConfirmation.tsx | 127 ++-- frontend/src/pages/CandidateChatPage.tsx | 568 ++++++++++++++---- frontend/src/services/api-client.ts | 53 ++ src/backend/database.py | 29 +- src/backend/main.py | 158 +++++ 5 files changed, 768 insertions(+), 167 deletions(-) diff --git a/frontend/src/components/DeleteConfirmation.tsx b/frontend/src/components/DeleteConfirmation.tsx index 9fdcad7..eac6e31 100644 --- a/frontend/src/components/DeleteConfirmation.tsx +++ b/frontend/src/components/DeleteConfirmation.tsx @@ -14,70 +14,127 @@ import { useTheme } from '@mui/material/styles'; import ResetIcon from '@mui/icons-material/History'; interface DeleteConfirmationProps { - onDelete: () => void, - disabled?: boolean, - label?: string, - color?: "inherit" | "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" | undefined -}; + // Legacy props for backward compatibility (uncontrolled mode) + onDelete?: () => void; + disabled?: boolean; + label?: string; + action?: "delete" | "reset"; + color?: "inherit" | "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" | undefined; -const DeleteConfirmation = (props : DeleteConfirmationProps) => { - const { onDelete, disabled, label, color } = props; - const [open, setOpen] = useState(false); + // New props for controlled mode + open?: boolean; + onClose?: () => void; + onConfirm?: () => void; + title?: string; + message?: string; + + // Optional props for button customization in controlled mode + hideButton?: boolean; + confirmButtonText?: string; + cancelButtonText?: string; +} + +function capitalizeFirstLetter(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +const DeleteConfirmation = (props: DeleteConfirmationProps) => { + const { + // Legacy props + onDelete, + disabled, + label, + color, + action = "delete", + // New props + open: controlledOpen, + onClose: controlledOnClose, + onConfirm, + title, + message, + hideButton = false, + confirmButtonText, + cancelButtonText = "Cancel" + } = props; + + // Internal state for uncontrolled mode + const [internalOpen, setInternalOpen] = useState(false); const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('md')); + // Determine if we're in controlled or uncontrolled mode + const isControlled = controlledOpen !== undefined; + const isOpen = isControlled ? controlledOpen : internalOpen; + const handleClickOpen = () => { - setOpen(true); + if (!isControlled) { + setInternalOpen(true); + } }; const handleClose = () => { - setOpen(false); + if (isControlled) { + controlledOnClose?.(); + } else { + setInternalOpen(false); + } }; - const handleConfirmReset = () => { - onDelete(); - setOpen(false); + const handleConfirm = () => { + if (isControlled) { + onConfirm?.(); + } else { + onDelete?.(); + setInternalOpen(false); + } }; + // Determine dialog content based on mode + const dialogTitle = title || "Confirm Reset"; + const dialogMessage = message || `This action will permanently ${capitalizeFirstLetter(action)} ${label ? label.toLowerCase() : "all data"} without the ability to recover it. Are you sure you want to continue?`; + const confirmText = confirmButtonText || `${capitalizeFirstLetter(action)} ${label || "Everything"}`; + return ( <> - - { /* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */} - - - - - + {/* Only show button if not hidden (for controlled mode) */} + {!hideButton && ( + + {/* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */} + + + + + + )} - {"Confirm Reset"} + {dialogTitle} - This action will permanently reset { label ? label.toLocaleLowerCase() : "all data" } without the ability to recover it. - Are you sure you want to continue? + {dialogMessage} - diff --git a/frontend/src/pages/CandidateChatPage.tsx b/frontend/src/pages/CandidateChatPage.tsx index 4e7b273..0f63c19 100644 --- a/frontend/src/pages/CandidateChatPage.tsx +++ b/frontend/src/pages/CandidateChatPage.tsx @@ -15,15 +15,21 @@ import { Card, CardContent, Avatar, - Drawer, useTheme, useMediaQuery, - Fab + Fab, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Backdrop } from '@mui/material'; import { ChevronLeft, ChevronRight, - Chat as ChatIcon + Chat as ChatIcon, + Edit as EditIcon, + Delete as DeleteIcon } from '@mui/icons-material'; import { useAuth } from 'hooks/AuthContext'; import { ChatMessageBase, ChatMessage, ChatSession, ChatStatusType, ChatMessageType, ChatMessageUser } from 'types/types'; @@ -38,12 +44,15 @@ import { useSelectedCandidate } from 'hooks/GlobalContext'; import PropagateLoader from 'react-spinners/PropagateLoader'; const DRAWER_WIDTH = 300; -const HANDLE_WIDTH = 48; +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() @@ -66,13 +75,76 @@ const CandidateChatPage = forwardRef((pr const [streaming, setStreaming] = useState(false); const messagesEndRef = useRef(null); - // Drawer state - defaults to open on md+ screens or when no session is selected - const [drawerOpen, setDrawerOpen] = useState(() => isMdUp || !chatSession); + // Drawer state - defaults to open on md+ screens + const [drawerOpen, setDrawerOpen] = useState(() => isMdUp); - // Update drawer state when screen size or session changes + // 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(() => { - setDrawerOpen(isMdUp || !chatSession); - }, [isMdUp, chatSession]); + 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 () => { @@ -113,7 +185,7 @@ const CandidateChatPage = forwardRef((pr const newSession = await apiClient.createCandidateChatSession( selectedCandidate.username, 'candidate_chat', - `Interview Discussion - ${selectedCandidate.username}` + `Backstory chat about ${selectedCandidate.fullName}` ); setChatSession(newSession); setMessages([]); @@ -127,6 +199,60 @@ const CandidateChatPage = forwardRef((pr } }; + // 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 + + // 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'); + } catch (error) { + console.error('Failed to delete session:', error); + setSnack('Failed to delete session', 'error'); + } + }; + // Send message const sendMessage = async () => { if (!newMessage.trim() || !chatSession?.id || streaming) return; @@ -215,37 +341,99 @@ const CandidateChatPage = forwardRef((pr navigate('/find-a-candidate'); } + // 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 drawerContent = ( - - - Chat Sessions - {sessions && ( - - )} - + + {/* Fixed Header Section */} + + + Chat Sessions + {sessions && ( + + )} + - + + - + {/* Scrollable Sessions List */} + {sessions ? ( - + {sessions.sessions.data.map((session: any) => ( { setChatSession(session); - // Auto-close drawer on smaller screens when session is selected + // Auto-close drawer on mobile only when session is selected if (!isMdUp) { setDrawerOpen(false); } @@ -254,22 +442,54 @@ const CandidateChatPage = forwardRef((pr mb: 1, borderRadius: 1, border: '1px solid', - borderColor: chatSession?.id === session.id ? 'primary.main' : 'divider', + borderColor: chatSession?.id === session.id ? 'orange' : 'divider', cursor: 'pointer', + backgroundColor: 'transparent', '&:hover': { - backgroundColor: 'action.hover' + backgroundColor: chatSession?.id === session.id ? 'primary.light' : '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" )} @@ -277,69 +497,106 @@ const CandidateChatPage = forwardRef((pr ); - const drawerHandle = ( - - setDrawerOpen(!drawerOpen)} - sx={{ - borderRadius: '0 50% 50% 0', - width: 32, - height: 64, - minHeight: 64, - boxShadow: 2, - '&:hover': { - boxShadow: 4, - } - }} - > - {drawerOpen ? : } - - - ); - return ( - - {selectedCandidate && } + + {selectedCandidate && ( + + )} - - {/* Drawer */} - + + {/* Mobile Backdrop */} + {!isMdUp && drawerOpen && ( + + )} + + {/* Drawer Container - Different behavior for mobile vs desktop */} + - {drawerContent} - + {/* Drawer Content */} + + {drawerContent} + - {/* Drawer Handle */} - {drawerHandle} + {/* 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 */} ((pr display: 'flex', flexDirection: 'column', flexGrow: 1, - transition: theme.transitions.create('margin', { - easing: theme.transitions.easing.sharp, - duration: theme.transitions.duration.standard, - }), + marginLeft: isMdUp ? 0 : 0, + width: isMdUp ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%', + overflow: 'hidden', + minHeight: 0, // Important for flex child }} > {chatSession?.id ? ( <> - - { - messages.map((message: ChatMessageBase) => ( - - )) - } - { - processingMessage !== null && + {/* Scrollable Messages Area */} + + {messages.map((message: ChatMessageBase) => ( + + ))} + {processingMessage !== null && ( - } - { - streamingMessage !== null && + )} + {streamingMessage !== null && ( - } - {streaming && - - - } + )} + {streaming && ( + + + + )}
- - {/* Message Input */} - + {/* Fixed Message Input */} + ((pr )} + + {/* 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 c320cd7..518c987 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -109,6 +109,13 @@ export interface CreateChatSessionRequest { title?: string; } +export interface UpdateChatSessionRequest { + title?: string; + context?: Partial; + isArchived?: boolean; + systemPrompt?: string; +} + export interface CandidateSessionsResponse { candidate: { id: string; @@ -505,6 +512,34 @@ class ApiClient { return this.handleApiResponseWithConversion(response, 'ChatSession'); } + /** + * Update a chat session's properties + */ + async updateChatSession( + id: string, + updates: UpdateChatSessionRequest + ): Promise { + const response = await fetch(`${this.baseUrl}/chat/sessions/${id}`, { + method: 'PATCH', + headers: this.defaultHeaders, + body: JSON.stringify(formatApiRequest(updates)) + }); + + return this.handleApiResponseWithConversion(response, 'ChatSession'); + } + + /** + * Delete a chat session + */ + async deleteChatSession(id: string): Promise<{ success: boolean; message: string }> { + const response = await fetch(`${this.baseUrl}/chat/sessions/${id}`, { + method: 'DELETE', + headers: this.defaultHeaders + }); + + return handleApiResponse<{ success: boolean; message: string }>(response); + } + /** * Send message with streaming response support and date conversion */ @@ -971,6 +1006,24 @@ try { console.error('Failed to fetch jobs:', error); } +// Update and delete chat sessions with proper date handling +try { + // Update a session title + const updatedSession = await apiClient.updateChatSession('session-id', { + title: 'New Session Title', + isArchived: false + }); + + console.log('Updated session:', updatedSession.title); + console.log('Last activity:', updatedSession.lastActivity.toLocaleString()); + + // Delete a session + const deleteResult = await apiClient.deleteChatSession('session-id'); + console.log('Delete result:', deleteResult.message); +} catch (error) { + console.error('Failed to manage session:', error); +} + // Streaming with proper date conversion const streamResponse = apiClient.sendMessageStream(sessionId, 'Tell me about job opportunities', { onStreaming: (chunk) => { diff --git a/src/backend/database.py b/src/backend/database.py index b1492b6..2995ae2 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -417,12 +417,29 @@ class RedisDatabase: result[session_id] = self._deserialize(value) return result - - async def delete_chat_session(self, session_id: str): - """Delete chat session""" - key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}" - await self.redis.delete(key) - + + async def delete_chat_session(self, session_id: str) -> bool: + '''Delete a chat session from Redis''' + try: + result = await self.redis.delete(f"chat_session:{session_id}") + return result > 0 + except Exception as e: + logger.error(f"Error deleting chat session {session_id}: {e}") + raise + + async def delete_chat_message(self, session_id: str, message_id: str) -> bool: + '''Delete a specific chat message from Redis''' + try: + # Remove from the session's message list + await self.redis.lrem(f"chat_messages:{session_id}", 0, message_id) + + # Delete the message data itself + result = await self.redis.delete(f"chat_message:{message_id}") + return result > 0 + except Exception as e: + logger.error(f"Error deleting chat message {message_id}: {e}") + raise + # Chat Messages operations (stored as lists) async def get_chat_messages(self, session_id: str) -> List[Dict]: """Get chat messages for a session""" diff --git a/src/backend/main.py b/src/backend/main.py index 226e1fb..01a6d25 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -1576,6 +1576,164 @@ async def get_chat_session_messages( content=create_error_response("FETCH_ERROR", str(e)) ) +@api_router.patch("/chat/sessions/{session_id}") +async def update_chat_session( + session_id: str = Path(...), + updates: Dict[str, Any] = Body(...), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Update a chat session's properties""" + try: + # Get the existing session + 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 update their own sessions + if session.user_id != current_user.id: + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Cannot update another user's chat session") + ) + + # Validate and apply updates + allowed_fields = {"title", "context", "isArchived", "systemPrompt"} + filtered_updates = {k: v for k, v in updates.items() if k in allowed_fields} + + if not filtered_updates: + return JSONResponse( + status_code=400, + content=create_error_response("INVALID_UPDATES", "No valid fields provided for update") + ) + + # Apply updates to session data + session_dict = session.model_dump() + + # Handle special field mappings (camelCase to snake_case) + if "isArchived" in filtered_updates: + session_dict["is_archived"] = filtered_updates["isArchived"] + if "systemPrompt" in filtered_updates: + session_dict["system_prompt"] = filtered_updates["systemPrompt"] + if "title" in filtered_updates: + session_dict["title"] = filtered_updates["title"] + if "context" in filtered_updates: + # Merge context updates with existing context + existing_context = session_dict.get("context", {}) + context_updates = filtered_updates["context"] + + # Update specific context fields while preserving others + for context_key, context_value in context_updates.items(): + if context_key == "additionalContext": + # Merge additional context + existing_additional = existing_context.get("additional_context", {}) + existing_additional.update(context_value) + existing_context["additional_context"] = existing_additional + else: + # Convert camelCase to snake_case for context fields + snake_key = context_key + if context_key == "relatedEntityId": + snake_key = "related_entity_id" + elif context_key == "relatedEntityType": + snake_key = "related_entity_type" + elif context_key == "aiParameters": + snake_key = "ai_parameters" + + existing_context[snake_key] = context_value + + session_dict["context"] = existing_context + + # Update last activity timestamp + session_dict["last_activity"] = datetime.now(UTC).isoformat() + + # Validate the updated session + updated_session = ChatSession.model_validate(session_dict) + + # Save to database + await database.set_chat_session(session_id, updated_session.model_dump()) + + logger.info(f"✅ Chat session {session_id} updated by user {current_user.id}") + + return create_success_response(updated_session.model_dump(by_alias=True, exclude_unset=True)) + + except ValueError as ve: + logger.warning(f"⚠️ Validation error updating chat session: {ve}") + return JSONResponse( + status_code=400, + content=create_error_response("VALIDATION_ERROR", str(ve)) + ) + except Exception as e: + logger.error(f"❌ Update chat session error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("UPDATE_ERROR", str(e)) + ) + +@api_router.delete("/chat/sessions/{session_id}") +async def delete_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 delete 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 + + # Delete the session itself + await database.delete_chat_session(session_id) + + logger.info(f"🗑️ Chat session {session_id} deleted by user {current_user.id}") + + return create_success_response({ + "success": True, + "message": "Chat session deleted successfully", + "sessionId": session_id + }) + + except Exception as e: + logger.error(f"❌ Delete chat session error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("DELETE_ERROR", str(e)) + ) + @api_router.get("/candidates/{username}/chat-sessions") async def get_candidate_chat_sessions( username: str = Path(...),