UI improvement for mobile/desktop on chat page

This commit is contained in:
James Ketr 2025-05-31 17:27:44 -07:00
parent 8f0ff5da68
commit 40ab58fffe
5 changed files with 768 additions and 167 deletions

View File

@ -14,37 +14,94 @@ import { useTheme } from '@mui/material/styles';
import ResetIcon from '@mui/icons-material/History'; import ResetIcon from '@mui/icons-material/History';
interface DeleteConfirmationProps { interface DeleteConfirmationProps {
onDelete: () => void, // Legacy props for backward compatibility (uncontrolled mode)
disabled?: boolean, onDelete?: () => void;
label?: string, disabled?: boolean;
color?: "inherit" | "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" | undefined label?: string;
}; action?: "delete" | "reset";
color?: "inherit" | "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" | undefined;
const DeleteConfirmation = (props : DeleteConfirmationProps) => { // New props for controlled mode
const { onDelete, disabled, label, color } = props; open?: boolean;
const [open, setOpen] = useState(false); 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 theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('md')); 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 = () => { const handleClickOpen = () => {
setOpen(true); if (!isControlled) {
setInternalOpen(true);
}
}; };
const handleClose = () => { const handleClose = () => {
setOpen(false); if (isControlled) {
controlledOnClose?.();
} else {
setInternalOpen(false);
}
}; };
const handleConfirmReset = () => { const handleConfirm = () => {
onDelete(); if (isControlled) {
setOpen(false); 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 ( return (
<> <>
<Tooltip title={label ? `Reset ${label}` : "Reset"} > {/* Only show button if not hidden (for controlled mode) */}
<span style={{ display: "flex" }}> { /* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */} {!hideButton && (
<Tooltip title={label ? `${capitalizeFirstLetter(action)} ${label}` : "Reset"}>
<span style={{ display: "flex" }}> {/* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
<IconButton <IconButton
aria-label="reset" aria-label={action}
onClick={handleClickOpen} onClick={handleClickOpen}
color={color || "inherit"} color={color || "inherit"}
sx={{ display: "flex", margin: 'auto 0px' }} sx={{ display: "flex", margin: 'auto 0px' }}
@ -56,28 +113,28 @@ const DeleteConfirmation = (props : DeleteConfirmationProps) => {
</IconButton> </IconButton>
</span> </span>
</Tooltip> </Tooltip>
)}
<Dialog <Dialog
fullScreen={fullScreen} fullScreen={fullScreen}
open={open} open={isOpen}
onClose={handleClose} onClose={handleClose}
aria-labelledby="responsive-dialog-title" aria-labelledby="responsive-dialog-title"
> >
<DialogTitle id="responsive-dialog-title"> <DialogTitle id="responsive-dialog-title">
{"Confirm Reset"} {dialogTitle}
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
This action will permanently reset { label ? label.toLocaleLowerCase() : "all data" } without the ability to recover it. {dialogMessage}
Are you sure you want to continue?
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button autoFocus onClick={handleClose}> <Button autoFocus onClick={handleClose}>
Cancel {cancelButtonText}
</Button> </Button>
<Button onClick={handleConfirmReset} color="error" variant="contained"> <Button onClick={handleConfirm} color="error" variant="contained">
Reset { label || "Everything" } {confirmText}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@ -15,15 +15,21 @@ import {
Card, Card,
CardContent, CardContent,
Avatar, Avatar,
Drawer,
useTheme, useTheme,
useMediaQuery, useMediaQuery,
Fab Fab,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Backdrop
} from '@mui/material'; } from '@mui/material';
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Chat as ChatIcon Chat as ChatIcon,
Edit as EditIcon,
Delete as DeleteIcon
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
import { ChatMessageBase, ChatMessage, ChatSession, ChatStatusType, ChatMessageType, ChatMessageUser } from 'types/types'; 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'; import PropagateLoader from 'react-spinners/PropagateLoader';
const DRAWER_WIDTH = 300; const DRAWER_WIDTH = 300;
const HANDLE_WIDTH = 48; const FAB_WIDTH = 48;
const FAB_HEIGHT = 64;
const defaultMessage: ChatMessage = { const defaultMessage: ChatMessage = {
type: "preparing", status: "done", sender: "system", sessionId: "", timestamp: new Date(), content: "" type: "preparing", status: "done", sender: "system", sessionId: "", timestamp: new Date(), content: ""
}; };
type HandlePosition = 'center' | 'top' | 'bottom';
const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((props: BackstoryPageProps, ref) => { const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((props: BackstoryPageProps, ref) => {
const { apiClient } = useAuth(); const { apiClient } = useAuth();
const { selectedCandidate } = useSelectedCandidate() const { selectedCandidate } = useSelectedCandidate()
@ -66,13 +75,76 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
const [streaming, setStreaming] = useState<boolean>(false); const [streaming, setStreaming] = useState<boolean>(false);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
// Drawer state - defaults to open on md+ screens or when no session is selected // Drawer state - defaults to open on md+ screens
const [drawerOpen, setDrawerOpen] = useState(() => isMdUp || !chatSession); 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<HandlePosition>('center');
const lastScrollY = useRef(0);
const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
// Edit session state
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editingSession, setEditingSession] = useState<ChatSession | null>(null);
const [editSessionTitle, setEditSessionTitle] = useState('');
// Delete confirmation state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [sessionToDelete, setSessionToDelete] = useState<ChatSession | null>(null);
// Update drawer state when screen size changes
useEffect(() => { useEffect(() => {
setDrawerOpen(isMdUp || !chatSession); if (isMdUp && !drawerOpen) {
}, [isMdUp, chatSession]); 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 // Load sessions for the selectedCandidate
const loadSessions = async () => { const loadSessions = async () => {
@ -113,7 +185,7 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
const newSession = await apiClient.createCandidateChatSession( const newSession = await apiClient.createCandidateChatSession(
selectedCandidate.username, selectedCandidate.username,
'candidate_chat', 'candidate_chat',
`Interview Discussion - ${selectedCandidate.username}` `Backstory chat about ${selectedCandidate.fullName}`
); );
setChatSession(newSession); setChatSession(newSession);
setMessages([]); setMessages([]);
@ -127,6 +199,60 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((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 // Send message
const sendMessage = async () => { const sendMessage = async () => {
if (!newMessage.trim() || !chatSession?.id || streaming) return; if (!newMessage.trim() || !chatSession?.id || streaming) return;
@ -215,8 +341,48 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
navigate('/find-a-candidate'); 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 = ( const drawerContent = (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', p: 2 }}> <Box sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden' // Prevent overall drawer from scrolling
}}>
{/* Fixed Header Section */}
<Box sx={{ p: 2, flexShrink: 0 }}>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Chat Sessions Chat Sessions
{sessions && ( {sessions && (
@ -232,20 +398,42 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
variant="outlined" variant="outlined"
onClick={createNewSession} onClick={createNewSession}
disabled={loading || !selectedCandidate} disabled={loading || !selectedCandidate}
sx={{ mb: 2 }} sx={{ mb: 2, width: '100%' }}
> >
New Session New Session
</Button> </Button>
</Box>
<Box sx={{ flexGrow: 1, overflow: 'auto' }}> {/* Scrollable Sessions List */}
<Box sx={{
flexGrow: 1,
overflow: 'auto',
px: 2,
pb: 2,
// 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],
},
},
}}>
{sessions ? ( {sessions ? (
<List> <List sx={{ padding: 0 }}>
{sessions.sessions.data.map((session: any) => ( {sessions.sessions.data.map((session: any) => (
<ListItem <ListItem
key={session.id} key={session.id}
onClick={() => { onClick={() => {
setChatSession(session); setChatSession(session);
// Auto-close drawer on smaller screens when session is selected // Auto-close drawer on mobile only when session is selected
if (!isMdUp) { if (!isMdUp) {
setDrawerOpen(false); setDrawerOpen(false);
} }
@ -254,22 +442,54 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
mb: 1, mb: 1,
borderRadius: 1, borderRadius: 1,
border: '1px solid', border: '1px solid',
borderColor: chatSession?.id === session.id ? 'primary.main' : 'divider', borderColor: chatSession?.id === session.id ? 'orange' : 'divider',
cursor: 'pointer', cursor: 'pointer',
backgroundColor: 'transparent',
'&:hover': { '&:hover': {
backgroundColor: 'action.hover' backgroundColor: chatSession?.id === session.id ? 'primary.light' : 'action.hover'
} }
}} }}
secondaryAction={
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton
edge="end"
size="small"
onClick={(e) => handleEditSession(session, e)}
sx={{
'&:hover': {
backgroundColor: 'action.hover',
color: 'primary.main'
}
}}
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={(e) => handleDeleteSession(session, e)}
sx={{
'&:hover': {
backgroundColor: 'error.light',
color: 'error.main'
}
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
}
> >
<ListItemText <ListItemText
primary={session.title} primary={session.title}
secondary={`${new Date(session.lastActivity).toLocaleDateString()}${session.context.type}`} secondary={`${new Date(session.lastActivity).toLocaleDateString()}${session.context.type}`}
sx={{ pr: 2 }}
/> />
</ListItem> </ListItem>
))} ))}
</List> </List>
) : ( ) : (
<Typography color="text.secondary" align="center"> <Typography color="text.secondary" align="center" sx={{ mt: 4 }}>
Enter a username and click "Load Sessions" Enter a username and click "Load Sessions"
</Typography> </Typography>
)} )}
@ -277,30 +497,96 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
</Box> </Box>
); );
const drawerHandle = ( return (
<Box ref={ref} sx={{
width: "100%",
height: "100vh", // Fixed viewport height
display: "flex",
flexDirection: "column",
overflow: 'hidden' // Prevent page-level scrolling
}}>
{selectedCandidate && (
<CandidateInfo
action={`Chat with Backstory about ${selectedCandidate.firstName}`}
elevation={4}
candidate={selectedCandidate}
sx={{ flexShrink: 0 }} // Prevent header from shrinking
/>
)}
<Box sx={{
display: "flex",
mt: 1,
gap: 1,
flexGrow: 1,
position: 'relative',
overflow: 'hidden',
minHeight: 0 // Important for flex child to shrink
}}>
{/* Mobile Backdrop */}
{!isMdUp && drawerOpen && (
<Backdrop
open={drawerOpen}
onClick={handleBackdropClick}
sx={{
position: 'absolute',
zIndex: theme.zIndex.drawer - 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
}}
/>
)}
{/* Drawer Container - Different behavior for mobile vs desktop */}
<Box <Box
sx={{ sx={{
position: 'fixed', position: isMdUp ? 'relative' : 'absolute',
left: drawerOpen ? DRAWER_WIDTH : 0, left: 0,
top: '50%', top: 0,
transform: 'translateY(-50%)', width: isMdUp ? DRAWER_WIDTH : DRAWER_WIDTH + FAB_WIDTH,
zIndex: theme.zIndex.drawer + 1, height: '100%',
transition: theme.transitions.create('left', { transform: isMdUp ? 'none' : (drawerOpen ? 'translateX(0)' : `translateX(-${DRAWER_WIDTH}px)`),
transition: isMdUp ? 'none' : theme.transitions.create('transform', {
easing: theme.transitions.easing.sharp, easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.standard, duration: theme.transitions.duration.standard,
}), }),
zIndex: theme.zIndex.drawer,
}} }}
> >
{/* Drawer Content */}
<Paper
sx={{
position: 'absolute',
left: 0,
top: 0,
width: DRAWER_WIDTH,
height: '100%',
borderRight: '1px solid',
borderColor: 'divider',
borderRadius: 0,
overflow: 'hidden',
display: isMdUp ? 'block' : (drawerOpen ? 'block' : 'none'),
}}
>
{drawerContent}
</Paper>
{/* Integrated Fab Handle - Mobile Only */}
{!isMdUp && (
<Box sx={getHandleStyles()}>
<Fab <Fab
size="small" size="small"
color="primary" color="primary"
onClick={() => setDrawerOpen(!drawerOpen)} onClick={() => setDrawerOpen(!drawerOpen)}
sx={{ sx={{
borderRadius: '0 50% 50% 0', borderRadius: '0 50% 50% 0',
width: 32, width: FAB_WIDTH,
height: 64, height: FAB_HEIGHT,
minHeight: 64, minHeight: FAB_HEIGHT,
boxShadow: 2, boxShadow: 2,
transition: theme.transitions.create(['box-shadow', 'background-color'], {
duration: theme.transitions.duration.short,
}),
'&:hover': { '&:hover': {
boxShadow: 4, boxShadow: 4,
} }
@ -309,37 +595,8 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
{drawerOpen ? <ChevronLeft /> : <ChatIcon />} {drawerOpen ? <ChevronLeft /> : <ChatIcon />}
</Fab> </Fab>
</Box> </Box>
); )}
</Box>
return (
<Box ref={ref} sx={{ width: "100%", height: "100%", display: "flex", flexGrow: 1, flexDirection: "column" }}>
{selectedCandidate && <CandidateInfo action={`Chat with Backstory about ${selectedCandidate.firstName}`} elevation={4} candidate={selectedCandidate} sx={{ minHeight: "max-content" }} />}
<Box sx={{ display: "flex", mt: 1, gap: 1, height: "100%", position: 'relative' }}>
{/* Drawer */}
<Drawer
variant="persistent"
anchor="left"
open={drawerOpen}
sx={{
width: drawerOpen ? DRAWER_WIDTH : 0,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH,
boxSizing: 'border-box',
position: 'relative',
height: '100%',
border: 'none',
borderRight: '1px solid',
borderColor: 'divider',
},
}}
>
{drawerContent}
</Drawer>
{/* Drawer Handle */}
{drawerHandle}
{/* Chat Interface */} {/* Chat Interface */}
<Paper <Paper
@ -347,29 +604,49 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
flexGrow: 1, flexGrow: 1,
transition: theme.transitions.create('margin', { marginLeft: isMdUp ? 0 : 0,
easing: theme.transitions.easing.sharp, width: isMdUp ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%',
duration: theme.transitions.duration.standard, overflow: 'hidden',
}), minHeight: 0, // Important for flex child
}} }}
> >
{chatSession?.id ? ( {chatSession?.id ? (
<> <>
<Box sx={{ flexGrow: 1, overflow: 'auto', p: 2 }}> {/* Scrollable Messages Area */}
{ <Box sx={{
messages.map((message: ChatMessageBase) => ( flexGrow: 1,
overflow: 'auto',
p: 2,
display: 'flex',
flexDirection: 'column',
minHeight: 0, // 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.map((message: ChatMessageBase) => (
<Message key={message.id} {...{ chatSession, message, setSnack, submitQuery }} /> <Message key={message.id} {...{ chatSession, message, setSnack, submitQuery }} />
)) ))}
} {processingMessage !== null && (
{
processingMessage !== null &&
<Message {...{ chatSession, message: processingMessage, setSnack, submitQuery }} /> <Message {...{ chatSession, message: processingMessage, setSnack, submitQuery }} />
} )}
{ {streamingMessage !== null && (
streamingMessage !== null &&
<Message {...{ chatSession, message: streamingMessage, setSnack, submitQuery }} /> <Message {...{ chatSession, message: streamingMessage, setSnack, submitQuery }} />
} )}
{streaming && <Box sx={{ {streaming && (
<Box sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
@ -383,15 +660,14 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
data-testid="loader" data-testid="loader"
/> />
</Box> </Box>
} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</Box> </Box>
<Divider /> <Divider />
{/* Message Input */} {/* Fixed Message Input */}
<Box sx={{ p: 2, display: 'flex', gap: 1 }}> <Box sx={{ p: 2, display: 'flex', gap: 1, flexShrink: 0 }}>
<TextField <TextField
fullWidth fullWidth
variant="outlined" variant="outlined"
@ -439,6 +715,46 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
)} )}
</Paper> </Paper>
</Box> </Box>
{/* Edit Session Dialog */}
<Dialog open={editDialogOpen} onClose={() => setEditDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Edit Session Title</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Session Title"
fullWidth
variant="outlined"
value={editSessionTitle}
onChange={(e) => setEditSessionTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveEdit();
}
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSaveEdit} variant="contained" disabled={!editSessionTitle.trim()}>
Save
</Button>
</DialogActions>
</Dialog>
{/* Delete Confirmation Dialog */}
<DeleteConfirmation
open={deleteDialogOpen}
action="delete"
label="chat session"
hideButton={true}
onClose={() => 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.`}
/>
</Box> </Box>
); );
}); });

View File

@ -109,6 +109,13 @@ export interface CreateChatSessionRequest {
title?: string; title?: string;
} }
export interface UpdateChatSessionRequest {
title?: string;
context?: Partial<Types.ChatContext>;
isArchived?: boolean;
systemPrompt?: string;
}
export interface CandidateSessionsResponse { export interface CandidateSessionsResponse {
candidate: { candidate: {
id: string; id: string;
@ -505,6 +512,34 @@ class ApiClient {
return this.handleApiResponseWithConversion<Types.ChatSession>(response, 'ChatSession'); return this.handleApiResponseWithConversion<Types.ChatSession>(response, 'ChatSession');
} }
/**
* Update a chat session's properties
*/
async updateChatSession(
id: string,
updates: UpdateChatSessionRequest
): Promise<Types.ChatSession> {
const response = await fetch(`${this.baseUrl}/chat/sessions/${id}`, {
method: 'PATCH',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(updates))
});
return this.handleApiResponseWithConversion<Types.ChatSession>(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 * Send message with streaming response support and date conversion
*/ */
@ -971,6 +1006,24 @@ try {
console.error('Failed to fetch jobs:', error); 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 // Streaming with proper date conversion
const streamResponse = apiClient.sendMessageStream(sessionId, 'Tell me about job opportunities', { const streamResponse = apiClient.sendMessageStream(sessionId, 'Tell me about job opportunities', {
onStreaming: (chunk) => { onStreaming: (chunk) => {

View File

@ -418,10 +418,27 @@ class RedisDatabase:
return result return result
async def delete_chat_session(self, session_id: str): async def delete_chat_session(self, session_id: str) -> bool:
"""Delete chat session""" '''Delete a chat session from Redis'''
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}" try:
await self.redis.delete(key) 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) # Chat Messages operations (stored as lists)
async def get_chat_messages(self, session_id: str) -> List[Dict]: async def get_chat_messages(self, session_id: str) -> List[Dict]:

View File

@ -1576,6 +1576,164 @@ async def get_chat_session_messages(
content=create_error_response("FETCH_ERROR", str(e)) 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") @api_router.get("/candidates/{username}/chat-sessions")
async def get_candidate_chat_sessions( async def get_candidate_chat_sessions(
username: str = Path(...), username: str = Path(...),