feat(web): add collapsible sidebar and delete conversation

Frontend:
- Add sidebarOpen state to chatStore with toggle functionality
- Make sidebar collapsible with smooth animation
- Add mobile-friendly drawer behavior with overlay
- Add toggle button for desktop view
- Implement delete conversation functionality with loading state

Backend:
- Add DELETE /conversations/:id endpoint
- Implement deleteConversation service method
- Delete messages before conversation (foreign key constraint)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-09 21:42:07 -08:00
parent 72e67fa5d9
commit f12ca7a821
6 changed files with 177 additions and 25 deletions

View File

@ -15,7 +15,8 @@
"Bash(ssh:*)", "Bash(ssh:*)",
"Bash(ping:*)", "Bash(ping:*)",
"Bash(curl:*)", "Bash(curl:*)",
"Bash(echo \"No health endpoint\" curl -s https://iconsulting.szaiai.com/api/v1/users/profile -H \"x-user-id: test\")" "Bash(echo \"No health endpoint\" curl -s https://iconsulting.szaiai.com/api/v1/users/profile -H \"x-user-id: test\")",
"Bash(scp:*)"
] ]
} }
} }

View File

@ -2,6 +2,7 @@ import {
Controller, Controller,
Get, Get,
Post, Post,
Delete,
Param, Param,
Body, Body,
Headers, Headers,
@ -144,4 +145,21 @@ export class ConversationController {
message: 'Conversation ended', message: 'Conversation ended',
}; };
} }
/**
* Delete a conversation
*/
@Delete(':id')
@HttpCode(HttpStatus.OK)
async deleteConversation(
@Headers('x-user-id') userId: string,
@Param('id') conversationId: string,
) {
await this.conversationService.deleteConversation(conversationId, userId);
return {
success: true,
message: 'Conversation deleted',
};
}
} }

View File

@ -187,6 +187,20 @@ export class ConversationService {
}); });
} }
/**
* Delete a conversation and its messages
*/
async deleteConversation(conversationId: string, userId: string): Promise<void> {
// Verify user owns the conversation
const conversation = await this.getConversation(conversationId, userId);
// Delete messages first (due to foreign key constraint)
await this.messageRepo.delete({ conversationId: conversation.id });
// Delete conversation
await this.conversationRepo.delete(conversation.id);
}
/** /**
* Generate a title from the first message * Generate a title from the first message
*/ */

View File

@ -1,13 +1,15 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Plus, MessageSquare, Trash2 } from 'lucide-react'; import { Plus, MessageSquare, Trash2, ChevronLeft } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { useChatStore } from '../stores/chatStore'; import { useChatStore } from '../stores/chatStore';
import { useChat } from '../hooks/useChat'; import { useChat } from '../hooks/useChat';
export function ChatSidebar() { export function ChatSidebar() {
const navigate = useNavigate(); const navigate = useNavigate();
const { conversations, currentConversationId } = useChatStore(); const { conversations, currentConversationId, userId, deleteConversation, toggleSidebar } = useChatStore();
const { createConversation } = useChat(); const { createConversation } = useChat();
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleNewChat = async () => { const handleNewChat = async () => {
const conversationId = await createConversation(); const conversationId = await createConversation();
@ -20,17 +22,38 @@ export function ChatSidebar() {
navigate(`/chat/${id}`); navigate(`/chat/${id}`);
}; };
const handleDeleteConversation = async (id: string) => {
if (!userId || deletingId) return;
setDeletingId(id);
const success = await deleteConversation(userId, id);
setDeletingId(null);
if (success && currentConversationId === id) {
navigate('/chat');
}
};
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */} {/* Header */}
<div className="p-4 border-b border-secondary-200"> <div className="p-4 border-b border-secondary-200">
<button <div className="flex items-center gap-2">
onClick={handleNewChat} <button
className="w-full flex items-center justify-center gap-2 btn-primary py-2.5" onClick={handleNewChat}
> className="flex-1 flex items-center justify-center gap-2 btn-primary py-2.5"
<Plus className="w-4 h-4" /> >
<span></span> <Plus className="w-4 h-4" />
</button> <span></span>
</button>
<button
onClick={toggleSidebar}
className="hidden md:flex p-2.5 hover:bg-secondary-100 rounded-lg transition-colors"
title="收起侧边栏"
>
<ChevronLeft className="w-5 h-5 text-secondary-500" />
</button>
</div>
</div> </div>
{/* Conversation list */} {/* Conversation list */}
@ -49,7 +72,9 @@ export function ChatSidebar() {
title={conv.title || '新对话'} title={conv.title || '新对话'}
isActive={conv.id === currentConversationId} isActive={conv.id === currentConversationId}
updatedAt={conv.updatedAt} updatedAt={conv.updatedAt}
isDeleting={deletingId === conv.id}
onClick={() => handleSelectConversation(conv.id)} onClick={() => handleSelectConversation(conv.id)}
onDelete={() => handleDeleteConversation(conv.id)}
/> />
))} ))}
</div> </div>
@ -71,14 +96,18 @@ interface ConversationItemProps {
title: string; title: string;
isActive: boolean; isActive: boolean;
updatedAt: string; updatedAt: string;
isDeleting: boolean;
onClick: () => void; onClick: () => void;
onDelete: () => void;
} }
function ConversationItem({ function ConversationItem({
title, title,
isActive, isActive,
updatedAt, updatedAt,
isDeleting,
onClick, onClick,
onDelete,
}: ConversationItemProps) { }: ConversationItemProps) {
const formatTime = (dateStr: string) => { const formatTime = (dateStr: string) => {
const date = new Date(dateStr); const date = new Date(dateStr);
@ -106,11 +135,13 @@ function ConversationItem({
return ( return (
<button <button
onClick={onClick} onClick={onClick}
disabled={isDeleting}
className={clsx( className={clsx(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors group', 'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors group',
isActive isActive
? 'bg-primary-50 text-primary-700' ? 'bg-primary-50 text-primary-700'
: 'hover:bg-secondary-50 text-secondary-700', : 'hover:bg-secondary-50 text-secondary-700',
isDeleting && 'opacity-50 cursor-not-allowed',
)} )}
> >
<MessageSquare <MessageSquare
@ -123,15 +154,20 @@ function ConversationItem({
<p className="text-sm font-medium truncate">{title}</p> <p className="text-sm font-medium truncate">{title}</p>
<p className="text-xs text-secondary-400">{formatTime(updatedAt)}</p> <p className="text-xs text-secondary-400">{formatTime(updatedAt)}</p>
</div> </div>
<button {isDeleting ? (
onClick={(e) => { <div className="w-4 h-4 border-2 border-secondary-400 border-t-transparent rounded-full animate-spin" />
e.stopPropagation(); ) : (
// TODO: Implement delete <button
}} onClick={(e) => {
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-secondary-200 rounded transition-all" e.stopPropagation();
> onDelete();
<Trash2 className="w-3.5 h-3.5 text-secondary-400" /> }}
</button> className="opacity-0 group-hover:opacity-100 p-1 hover:bg-red-100 hover:text-red-600 rounded transition-all"
title="删除对话"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</button> </button>
); );
} }

View File

@ -1,5 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Menu, X } from 'lucide-react';
import { clsx } from 'clsx';
import { ChatWindow } from '../components/ChatWindow'; import { ChatWindow } from '../components/ChatWindow';
import { ChatSidebar } from '../components/ChatSidebar'; import { ChatSidebar } from '../components/ChatSidebar';
import { useChatStore } from '../stores/chatStore'; import { useChatStore } from '../stores/chatStore';
@ -8,7 +10,7 @@ import { useAnonymousAuth } from '@/shared/hooks/useAnonymousAuth';
export default function ChatPage() { export default function ChatPage() {
const { conversationId } = useParams(); const { conversationId } = useParams();
const { userId, isLoading: authLoading } = useAnonymousAuth(); const { userId, isLoading: authLoading } = useAnonymousAuth();
const { setCurrentConversation, loadConversations } = useChatStore(); const { setCurrentConversation, loadConversations, sidebarOpen, toggleSidebar, setSidebarOpen } = useChatStore();
useEffect(() => { useEffect(() => {
if (userId) { if (userId) {
@ -22,6 +24,13 @@ export default function ChatPage() {
} }
}, [conversationId, setCurrentConversation]); }, [conversationId, setCurrentConversation]);
// Close sidebar on mobile when selecting a conversation
useEffect(() => {
if (conversationId && window.innerWidth < 768) {
setSidebarOpen(false);
}
}, [conversationId, setSidebarOpen]);
if (authLoading) { if (authLoading) {
return ( return (
<div className="flex items-center justify-center h-screen"> <div className="flex items-center justify-center h-screen">
@ -34,14 +43,52 @@ export default function ChatPage() {
} }
return ( return (
<div className="flex h-screen"> <div className="flex h-screen relative">
{/* Sidebar - hidden on mobile */} {/* Mobile overlay */}
<div className="hidden md:block w-72 border-r border-secondary-200 bg-white"> {sidebarOpen && (
<div
className="fixed inset-0 bg-black/30 z-20 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<div
className={clsx(
'fixed md:relative z-30 h-full w-72 border-r border-secondary-200 bg-white transition-transform duration-300 ease-in-out',
sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0 md:hidden'
)}
>
<ChatSidebar /> <ChatSidebar />
</div> </div>
{/* Main chat area */} {/* Main chat area */}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col min-w-0">
{/* Mobile header with menu button */}
<div className="flex items-center gap-3 p-3 border-b border-secondary-200 md:hidden">
<button
onClick={toggleSidebar}
className="p-2 hover:bg-secondary-100 rounded-lg transition-colors"
>
{sidebarOpen ? (
<X className="w-5 h-5 text-secondary-600" />
) : (
<Menu className="w-5 h-5 text-secondary-600" />
)}
</button>
<span className="font-medium text-secondary-800"></span>
</div>
{/* Desktop toggle button */}
{!sidebarOpen && (
<button
onClick={toggleSidebar}
className="hidden md:flex absolute left-0 top-4 z-10 p-2 bg-white border border-secondary-200 rounded-r-lg shadow-sm hover:bg-secondary-50 transition-colors"
>
<Menu className="w-5 h-5 text-secondary-600" />
</button>
)}
<ChatWindow /> <ChatWindow />
</div> </div>
</div> </div>

View File

@ -21,13 +21,20 @@ interface ChatState {
userId: string | null; userId: string | null;
setUserId: (id: string) => void; setUserId: (id: string) => void;
// Sidebar
sidebarOpen: boolean;
setSidebarOpen: (open: boolean) => void;
toggleSidebar: () => void;
// Conversations // Conversations
conversations: Conversation[]; conversations: Conversation[];
currentConversationId: string | null; currentConversationId: string | null;
setConversations: (conversations: Conversation[]) => void; setConversations: (conversations: Conversation[]) => void;
addConversation: (conversation: Conversation) => void; addConversation: (conversation: Conversation) => void;
removeConversation: (id: string) => void;
setCurrentConversation: (id: string | null) => void; setCurrentConversation: (id: string | null) => void;
loadConversations: (userId: string) => Promise<void>; loadConversations: (userId: string) => Promise<void>;
deleteConversation: (userId: string, conversationId: string) => Promise<boolean>;
// Messages // Messages
messages: Record<string, Message[]>; messages: Record<string, Message[]>;
@ -46,11 +53,16 @@ interface ChatState {
setConnected: (connected: boolean) => void; setConnected: (connected: boolean) => void;
} }
export const useChatStore = create<ChatState>((set) => ({ export const useChatStore = create<ChatState>((set, get) => ({
// User // User
userId: null, userId: null,
setUserId: (id) => set({ userId: id }), setUserId: (id) => set({ userId: id }),
// Sidebar - default open on desktop
sidebarOpen: window.innerWidth >= 768,
setSidebarOpen: (open) => set({ sidebarOpen: open }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
// Conversations // Conversations
conversations: [], conversations: [],
currentConversationId: null, currentConversationId: null,
@ -59,6 +71,12 @@ export const useChatStore = create<ChatState>((set) => ({
set((state) => ({ set((state) => ({
conversations: [conversation, ...state.conversations], conversations: [conversation, ...state.conversations],
})), })),
removeConversation: (id) =>
set((state) => ({
conversations: state.conversations.filter((c) => c.id !== id),
currentConversationId:
state.currentConversationId === id ? null : state.currentConversationId,
})),
setCurrentConversation: (id) => set({ currentConversationId: id }), setCurrentConversation: (id) => set({ currentConversationId: id }),
loadConversations: async (userId) => { loadConversations: async (userId) => {
try { try {
@ -75,6 +93,24 @@ export const useChatStore = create<ChatState>((set) => ({
console.error('Failed to load conversations:', error); console.error('Failed to load conversations:', error);
} }
}, },
deleteConversation: async (userId, conversationId) => {
try {
const response = await fetch(`/api/v1/conversations/${conversationId}`, {
method: 'DELETE',
headers: {
'x-user-id': userId,
},
});
const data = await response.json();
if (data.success) {
get().removeConversation(conversationId);
return true;
}
} catch (error) {
console.error('Failed to delete conversation:', error);
}
return false;
},
// Messages // Messages
messages: {}, messages: {},