From f12ca7a8210b8829779ff1987d181fd57990195b Mon Sep 17 00:00:00 2001
From: hailin
Date: Fri, 9 Jan 2026 21:42:07 -0800
Subject: [PATCH] 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
---
.claude/settings.local.json | 3 +-
.../conversation/conversation.controller.ts | 18 +++++
.../src/conversation/conversation.service.ts | 14 ++++
.../presentation/components/ChatSidebar.tsx | 72 ++++++++++++++-----
.../chat/presentation/pages/ChatPage.tsx | 57 +++++++++++++--
.../chat/presentation/stores/chatStore.ts | 38 +++++++++-
6 files changed, 177 insertions(+), 25 deletions(-)
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index e00609d..f5968bb 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -15,7 +15,8 @@
"Bash(ssh:*)",
"Bash(ping:*)",
"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:*)"
]
}
}
diff --git a/packages/services/conversation-service/src/conversation/conversation.controller.ts b/packages/services/conversation-service/src/conversation/conversation.controller.ts
index de447df..010d52d 100644
--- a/packages/services/conversation-service/src/conversation/conversation.controller.ts
+++ b/packages/services/conversation-service/src/conversation/conversation.controller.ts
@@ -2,6 +2,7 @@ import {
Controller,
Get,
Post,
+ Delete,
Param,
Body,
Headers,
@@ -144,4 +145,21 @@ export class ConversationController {
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',
+ };
+ }
}
diff --git a/packages/services/conversation-service/src/conversation/conversation.service.ts b/packages/services/conversation-service/src/conversation/conversation.service.ts
index 33c894e..eba93a7 100644
--- a/packages/services/conversation-service/src/conversation/conversation.service.ts
+++ b/packages/services/conversation-service/src/conversation/conversation.service.ts
@@ -187,6 +187,20 @@ export class ConversationService {
});
}
+ /**
+ * Delete a conversation and its messages
+ */
+ async deleteConversation(conversationId: string, userId: string): Promise {
+ // 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
*/
diff --git a/packages/web-client/src/features/chat/presentation/components/ChatSidebar.tsx b/packages/web-client/src/features/chat/presentation/components/ChatSidebar.tsx
index 22ee0bc..3041ab4 100644
--- a/packages/web-client/src/features/chat/presentation/components/ChatSidebar.tsx
+++ b/packages/web-client/src/features/chat/presentation/components/ChatSidebar.tsx
@@ -1,13 +1,15 @@
+import { useState } from 'react';
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 { useChatStore } from '../stores/chatStore';
import { useChat } from '../hooks/useChat';
export function ChatSidebar() {
const navigate = useNavigate();
- const { conversations, currentConversationId } = useChatStore();
+ const { conversations, currentConversationId, userId, deleteConversation, toggleSidebar } = useChatStore();
const { createConversation } = useChat();
+ const [deletingId, setDeletingId] = useState(null);
const handleNewChat = async () => {
const conversationId = await createConversation();
@@ -20,17 +22,38 @@ export function ChatSidebar() {
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 (
{/* Header */}
{/* Conversation list */}
@@ -49,7 +72,9 @@ export function ChatSidebar() {
title={conv.title || '新对话'}
isActive={conv.id === currentConversationId}
updatedAt={conv.updatedAt}
+ isDeleting={deletingId === conv.id}
onClick={() => handleSelectConversation(conv.id)}
+ onDelete={() => handleDeleteConversation(conv.id)}
/>
))}
@@ -71,14 +96,18 @@ interface ConversationItemProps {
title: string;
isActive: boolean;
updatedAt: string;
+ isDeleting: boolean;
onClick: () => void;
+ onDelete: () => void;
}
function ConversationItem({
title,
isActive,
updatedAt,
+ isDeleting,
onClick,
+ onDelete,
}: ConversationItemProps) {
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
@@ -106,11 +135,13 @@ function ConversationItem({
return (
{formatTime(updatedAt)}
-
+ {isDeleting ? (
+
+ ) : (
+
+ )}
);
}
diff --git a/packages/web-client/src/features/chat/presentation/pages/ChatPage.tsx b/packages/web-client/src/features/chat/presentation/pages/ChatPage.tsx
index 2c17b06..db9e1ed 100644
--- a/packages/web-client/src/features/chat/presentation/pages/ChatPage.tsx
+++ b/packages/web-client/src/features/chat/presentation/pages/ChatPage.tsx
@@ -1,5 +1,7 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
+import { Menu, X } from 'lucide-react';
+import { clsx } from 'clsx';
import { ChatWindow } from '../components/ChatWindow';
import { ChatSidebar } from '../components/ChatSidebar';
import { useChatStore } from '../stores/chatStore';
@@ -8,7 +10,7 @@ import { useAnonymousAuth } from '@/shared/hooks/useAnonymousAuth';
export default function ChatPage() {
const { conversationId } = useParams();
const { userId, isLoading: authLoading } = useAnonymousAuth();
- const { setCurrentConversation, loadConversations } = useChatStore();
+ const { setCurrentConversation, loadConversations, sidebarOpen, toggleSidebar, setSidebarOpen } = useChatStore();
useEffect(() => {
if (userId) {
@@ -22,6 +24,13 @@ export default function ChatPage() {
}
}, [conversationId, setCurrentConversation]);
+ // Close sidebar on mobile when selecting a conversation
+ useEffect(() => {
+ if (conversationId && window.innerWidth < 768) {
+ setSidebarOpen(false);
+ }
+ }, [conversationId, setSidebarOpen]);
+
if (authLoading) {
return (
@@ -34,14 +43,52 @@ export default function ChatPage() {
}
return (
-
- {/* Sidebar - hidden on mobile */}
-
+
+ {/* Mobile overlay */}
+ {sidebarOpen && (
+
setSidebarOpen(false)}
+ />
+ )}
+
+ {/* Sidebar */}
+
{/* Main chat area */}
-
+
+ {/* Mobile header with menu button */}
+
+
+ 香港移民咨询
+
+
+ {/* Desktop toggle button */}
+ {!sidebarOpen && (
+
+ )}
+
diff --git a/packages/web-client/src/features/chat/presentation/stores/chatStore.ts b/packages/web-client/src/features/chat/presentation/stores/chatStore.ts
index 4213f6c..7859daa 100644
--- a/packages/web-client/src/features/chat/presentation/stores/chatStore.ts
+++ b/packages/web-client/src/features/chat/presentation/stores/chatStore.ts
@@ -21,13 +21,20 @@ interface ChatState {
userId: string | null;
setUserId: (id: string) => void;
+ // Sidebar
+ sidebarOpen: boolean;
+ setSidebarOpen: (open: boolean) => void;
+ toggleSidebar: () => void;
+
// Conversations
conversations: Conversation[];
currentConversationId: string | null;
setConversations: (conversations: Conversation[]) => void;
addConversation: (conversation: Conversation) => void;
+ removeConversation: (id: string) => void;
setCurrentConversation: (id: string | null) => void;
loadConversations: (userId: string) => Promise
;
+ deleteConversation: (userId: string, conversationId: string) => Promise;
// Messages
messages: Record;
@@ -46,11 +53,16 @@ interface ChatState {
setConnected: (connected: boolean) => void;
}
-export const useChatStore = create((set) => ({
+export const useChatStore = create((set, get) => ({
// User
userId: null,
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: [],
currentConversationId: null,
@@ -59,6 +71,12 @@ export const useChatStore = create((set) => ({
set((state) => ({
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 }),
loadConversations: async (userId) => {
try {
@@ -75,6 +93,24 @@ export const useChatStore = create((set) => ({
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: {},