feat: Add collapsible groups for AI chat conversations
Implemented mobile-first collapsible conversation groups with full group management: Backend Changes: - Added PATCH /api/v1/ai/conversations/:id/group endpoint to move conversations - Added GET /api/v1/ai/groups endpoint to list user groups - Added updateConversationGroup() service method (ai.service.ts:687-710) - Added getConversationGroups() service method (ai.service.ts:712-730) - Uses existing metadata field in AIConversation entity (no migration needed) - Updated getUserConversations() to include metadata field Frontend Changes: - Implemented collapsible group headers with Folder/FolderOpen icons - Added organizeConversations() to group by metadata.groupName (lines 243-271) - Added toggleGroupCollapse() for expand/collapse functionality (lines 273-283) - Implemented context menu with "Move to Group" and "Delete" options (lines 309-320) - Created Move to Group dialog with existing groups list (lines 858-910) - Created Create New Group dialog with text input (lines 912-952) - Mobile-first design with touch-optimized targets and smooth animations - Right-click (desktop) or long-press (mobile) for context menu - Shows conversation count per group in header - Indented conversations (pl: 5) show visual hierarchy - Groups sorted alphabetically with "Ungrouped" always last Component Growth: - Backend: ai.controller.ts (+35 lines), ai.service.ts (+43 lines) - Frontend: AIChatInterface.tsx (663 → 955 lines, +292 lines) Mobile UX Enhancements: - MoreVert icon on mobile vs Delete icon on desktop - Touch-optimized group headers (larger padding) - Smooth Collapse animations (timeout: 'auto') - Context menu replaces inline actions on small screens 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,10 @@ import {
|
||||
DialogActions,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
Collapse,
|
||||
Menu,
|
||||
MenuItem,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Send,
|
||||
@@ -35,6 +39,13 @@ import {
|
||||
Add,
|
||||
Menu as MenuIcon,
|
||||
Close,
|
||||
ExpandMore,
|
||||
ExpandLess,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
MoreVert,
|
||||
DriveFileMove,
|
||||
CreateNewFolder,
|
||||
} from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
@@ -60,6 +71,16 @@ interface Conversation {
|
||||
totalTokens: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
metadata?: {
|
||||
groupName?: string | null;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
interface ConversationGroup {
|
||||
name: string;
|
||||
conversations: Conversation[];
|
||||
isCollapsed: boolean;
|
||||
}
|
||||
|
||||
const suggestedQuestions = [
|
||||
@@ -109,6 +130,11 @@ export const AIChatInterface: React.FC = () => {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [conversationToDelete, setConversationToDelete] = useState<string | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
const [contextMenu, setContextMenu] = useState<{ mouseX: number; mouseY: number; conversationId: string } | null>(null);
|
||||
const [moveToGroupDialog, setMoveToGroupDialog] = useState<{ open: boolean; conversationId: string | null }>({ open: false, conversationId: null });
|
||||
const [newGroupDialog, setNewGroupDialog] = useState(false);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const thinkingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { user } = useAuth();
|
||||
@@ -214,6 +240,95 @@ export const AIChatInterface: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Group conversations by their group name
|
||||
const organizeConversations = (): ConversationGroup[] => {
|
||||
const groups: { [key: string]: Conversation[] } = {};
|
||||
|
||||
// Separate conversations by group
|
||||
conversations.forEach((conv) => {
|
||||
const groupName = conv.metadata?.groupName || 'Ungrouped';
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = [];
|
||||
}
|
||||
groups[groupName].push(conv);
|
||||
});
|
||||
|
||||
// Convert to array and sort
|
||||
const groupArray: ConversationGroup[] = Object.entries(groups)
|
||||
.map(([name, convs]) => ({
|
||||
name,
|
||||
conversations: convs.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()),
|
||||
isCollapsed: collapsedGroups.has(name),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Ungrouped always last
|
||||
if (a.name === 'Ungrouped') return 1;
|
||||
if (b.name === 'Ungrouped') return -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return groupArray;
|
||||
};
|
||||
|
||||
const toggleGroupCollapse = (groupName: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(groupName)) {
|
||||
newSet.delete(groupName);
|
||||
} else {
|
||||
newSet.add(groupName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveToGroup = async (conversationId: string, groupName: string | null) => {
|
||||
try {
|
||||
await apiClient.patch(`/api/v1/ai/conversations/${conversationId}/group`, {
|
||||
groupName: groupName,
|
||||
});
|
||||
await loadConversations();
|
||||
setMoveToGroupDialog({ open: false, conversationId: null });
|
||||
setContextMenu(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to move conversation to group:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNewGroup = async () => {
|
||||
if (!newGroupName.trim()) return;
|
||||
|
||||
const conversationId = moveToGroupDialog.conversationId;
|
||||
if (conversationId) {
|
||||
await handleMoveToGroup(conversationId, newGroupName.trim());
|
||||
}
|
||||
setNewGroupName('');
|
||||
setNewGroupDialog(false);
|
||||
};
|
||||
|
||||
const handleContextMenu = (event: React.MouseEvent, conversationId: string) => {
|
||||
event.preventDefault();
|
||||
setContextMenu({
|
||||
mouseX: event.clientX - 2,
|
||||
mouseY: event.clientY - 4,
|
||||
conversationId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseContextMenu = () => {
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const getExistingGroups = (): string[] => {
|
||||
const groups = new Set<string>();
|
||||
conversations.forEach((conv) => {
|
||||
if (conv.metadata?.groupName) {
|
||||
groups.add(conv.metadata.groupName);
|
||||
}
|
||||
});
|
||||
return Array.from(groups).sort();
|
||||
};
|
||||
|
||||
const handleSend = async (message?: string) => {
|
||||
const messageText = message || input.trim();
|
||||
if (!messageText || isLoading) return;
|
||||
@@ -278,7 +393,7 @@ export const AIChatInterface: React.FC = () => {
|
||||
Chat History
|
||||
</Typography>
|
||||
{isMobile && (
|
||||
<IconButton onClick={() => setDrawerOpen(false)} size="small">
|
||||
<IconButton onClick={() => setDrawerOpen(false)} size="small" aria-label="Close drawer">
|
||||
<Close />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -302,50 +417,94 @@ export const AIChatInterface: React.FC = () => {
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
conversations.map((conversation) => (
|
||||
<ListItem key={conversation.id} disablePadding>
|
||||
organizeConversations().map((group) => (
|
||||
<Box key={group.name}>
|
||||
{/* Group Header */}
|
||||
<ListItemButton
|
||||
selected={conversation.id === currentConversationId}
|
||||
onClick={() => loadConversation(conversation.id)}
|
||||
onClick={() => toggleGroupCollapse(group.name)}
|
||||
sx={{
|
||||
mx: 1,
|
||||
borderRadius: 2,
|
||||
mb: 0.5,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'primary.light',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.light',
|
||||
},
|
||||
},
|
||||
py: 1,
|
||||
px: 2,
|
||||
bgcolor: 'background.default',
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Chat />
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
{group.isCollapsed ? <Folder /> : <FolderOpen />}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={conversation.title}
|
||||
secondary={new Date(conversation.createdAt).toLocaleDateString()}
|
||||
primary={group.name}
|
||||
secondary={`${group.conversations.length} chat${group.conversations.length !== 1 ? 's' : ''}`}
|
||||
primaryTypographyProps={{
|
||||
noWrap: true,
|
||||
fontWeight: conversation.id === currentConversationId ? 600 : 400,
|
||||
variant: 'body2',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
variant: 'caption',
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConversationToDelete(conversation.id);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
{group.isCollapsed ? <ExpandMore fontSize="small" /> : <ExpandLess fontSize="small" />}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{/* Conversations in Group */}
|
||||
<Collapse in={!group.isCollapsed} timeout="auto" unmountOnExit>
|
||||
<List disablePadding>
|
||||
{group.conversations.map((conversation) => (
|
||||
<ListItem key={conversation.id} disablePadding>
|
||||
<ListItemButton
|
||||
selected={conversation.id === currentConversationId}
|
||||
onClick={() => loadConversation(conversation.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, conversation.id)}
|
||||
sx={{
|
||||
mx: 1,
|
||||
pl: 5,
|
||||
borderRadius: 2,
|
||||
mb: 0.5,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'primary.light',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.light',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<Chat fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={conversation.title}
|
||||
secondary={new Date(conversation.updatedAt).toLocaleDateString()}
|
||||
primaryTypographyProps={{
|
||||
noWrap: true,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: conversation.id === currentConversationId ? 600 : 400,
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
variant: 'caption',
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isMobile) {
|
||||
handleContextMenu(e, conversation.id);
|
||||
} else {
|
||||
setConversationToDelete(conversation.id);
|
||||
setDeleteDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
sx={{ ml: 1 }}
|
||||
aria-label={isMobile ? 'More options' : 'Delete conversation'}
|
||||
>
|
||||
{isMobile ? <MoreVert fontSize="small" /> : <Delete fontSize="small" />}
|
||||
</IconButton>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
@@ -657,6 +816,140 @@ export const AIChatInterface: React.FC = () => {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Context Menu for Conversation Actions */}
|
||||
<Menu
|
||||
open={contextMenu !== null}
|
||||
onClose={handleCloseContextMenu}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={
|
||||
contextMenu !== null ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined
|
||||
}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (contextMenu) {
|
||||
setMoveToGroupDialog({ open: true, conversationId: contextMenu.conversationId });
|
||||
}
|
||||
handleCloseContextMenu();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<DriveFileMove fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Move to Group</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (contextMenu) {
|
||||
setConversationToDelete(contextMenu.conversationId);
|
||||
setDeleteDialogOpen(true);
|
||||
}
|
||||
handleCloseContextMenu();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Delete fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Delete</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Move to Group Dialog */}
|
||||
<Dialog
|
||||
open={moveToGroupDialog.open}
|
||||
onClose={() => setMoveToGroupDialog({ open: false, conversationId: null })}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Move to Group</DialogTitle>
|
||||
<DialogContent>
|
||||
<List>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
if (moveToGroupDialog.conversationId) {
|
||||
handleMoveToGroup(moveToGroupDialog.conversationId, null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Chat />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Ungrouped" />
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
{getExistingGroups().map((groupName) => (
|
||||
<ListItemButton
|
||||
key={groupName}
|
||||
onClick={() => {
|
||||
if (moveToGroupDialog.conversationId) {
|
||||
handleMoveToGroup(moveToGroupDialog.conversationId, groupName);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Folder />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={groupName} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
<Divider />
|
||||
<ListItemButton onClick={() => setNewGroupDialog(true)}>
|
||||
<ListItemIcon>
|
||||
<CreateNewFolder />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Create New Group" />
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setMoveToGroupDialog({ open: false, conversationId: null })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Create New Group Dialog */}
|
||||
<Dialog
|
||||
open={newGroupDialog}
|
||||
onClose={() => {
|
||||
setNewGroupDialog(false);
|
||||
setNewGroupName('');
|
||||
}}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Create New Group</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Group Name"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreateNewGroup();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNewGroupDialog(false);
|
||||
setNewGroupName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateNewGroup} variant="contained" disabled={!newGroupName.trim()}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user