From 52aa628dd408d9390af165cec338fd8f6471e56e Mon Sep 17 00:00:00 2001 From: fran Date: Wed, 8 Oct 2025 02:03:19 +0200 Subject: [PATCH 1/2] full chat implementantion files --- .claude/agents/backend-test-architect.md | 2 +- .claude/doc/chat_history/FINAL_PLAN.md | 793 +++++++ .../doc/chat_history/acceptance-criteria.md | 978 ++++++++ .../chat_history/backend-testing-strategy.md | 1674 ++++++++++++++ .claude/doc/chat_history/backend.md | 1898 ++++++++++++++++ .../frontend-data-architecture.md | 2003 +++++++++++++++++ .../chat_history/frontend-testing-strategy.md | 1994 ++++++++++++++++ .claude/doc/chat_history/sidebar-ui-design.md | 988 ++++++++ .../doc/chat_history/test-scenario-mapping.md | 427 ++++ .../doc/chat_history/validation-checklist.md | 239 ++ .../sessions/context_session_chat_history.md | 1223 ++++++++++ 11 files changed, 12218 insertions(+), 1 deletion(-) create mode 100644 .claude/doc/chat_history/FINAL_PLAN.md create mode 100644 .claude/doc/chat_history/acceptance-criteria.md create mode 100644 .claude/doc/chat_history/backend-testing-strategy.md create mode 100644 .claude/doc/chat_history/backend.md create mode 100644 .claude/doc/chat_history/frontend-data-architecture.md create mode 100644 .claude/doc/chat_history/frontend-testing-strategy.md create mode 100644 .claude/doc/chat_history/sidebar-ui-design.md create mode 100644 .claude/doc/chat_history/test-scenario-mapping.md create mode 100644 .claude/doc/chat_history/validation-checklist.md create mode 100644 .claude/sessions/context_session_chat_history.md diff --git a/.claude/agents/backend-test-architect.md b/.claude/agents/backend-test-architect.md index b0fff73..4e23d46 100644 --- a/.claude/agents/backend-test-architect.md +++ b/.claude/agents/backend-test-architect.md @@ -47,7 +47,7 @@ For Web Layer Testing (NextJS Specific): **Best Practices You Follow:** -1. **Test Pyramid Adherence**: Focus primarily on unit tests, with fewer integration tests and minimal E2E tests +1. **Test Pyramid Adherence**: Focus primarily on unit tests, with fewer integration tests 2. **AAA Pattern**: Structure all tests with Arrange-Act-Assert sections clearly delineated 3. **Test Isolation**: Each test must be completely independent and runnable in any order 4. **Descriptive Naming**: Use behavior-driven test descriptions that explain what is being tested and expected outcome diff --git a/.claude/doc/chat_history/FINAL_PLAN.md b/.claude/doc/chat_history/FINAL_PLAN.md new file mode 100644 index 0000000..aaeb7e6 --- /dev/null +++ b/.claude/doc/chat_history/FINAL_PLAN.md @@ -0,0 +1,793 @@ +# Chat History Feature - Final Implementation Plan + +**Date**: 2025-10-08 +**Status**: ✅ Planning Complete - Ready for Implementation +**Session**: `context_session_chat_history` + +--- + +## Executive Summary + +This document consolidates all architectural advice from 6 specialized subagents into a single, actionable implementation plan for adding persistent chat history with MongoDB Atlas and a collapsible sidebar UI. + +**Total Planning Documents**: 7 comprehensive documents (~30,000 words) +**Total Test Scenarios Defined**: 135 (105 unit + 15 integration + 15 E2E) +**Implementation Timeline**: 2-3 weeks (4 phases) + +--- + +## User Requirements (Confirmed by Fran) + +✅ MongoDB Atlas (cloud deployment) +✅ Hard delete (no recovery) +✅ Collapsible sidebar (hamburger menu, all screen sizes) +✅ Show 50-100 recent conversations +✅ Keep InMemory repository for testing (env-based switching) +✅ Filter by status only (Active/Archived) - no search initially + +--- + +## Implementation Phases + +### **Phase 1: Backend Infrastructure** (Week 1) +**Estimated Time**: 5-7 days +**Lead**: hexagonal-backend-architect + backend-test-architect + +#### 1.1 Dependencies +```bash +yarn add mongodb +yarn add -D mongodb-memory-server +``` + +#### 1.2 MongoDB Connection Manager +**File**: `src/infrastructure/adapters/database/MongoDBClient.ts` +- Singleton pattern with connection pooling (MaxPoolSize=10) +- Retry logic with exponential backoff (3 attempts) +- Health check via ping command +- Environment variables: `MONGODB_URL`, `DATABASE_NAME` + +#### 1.3 Document Schema & Mapper +**Files**: +- `src/infrastructure/adapters/database/types/ConversationDocument.ts` (TypeScript interfaces) +- `src/infrastructure/adapters/database/mappers/ConversationDocumentMapper.ts` (entity ↔ document) + +**Schema**: +```typescript +{ + _id: string, // UUID v4 + messages: Message[], // Embedded (not referenced) + status: 'active' | 'waiting_for_response' | 'completed' | 'archived', + title?: string, + createdAt: Date, // MongoDB Date object (NOT ISO string) + updatedAt: Date, + metadata: Record +} +``` + +**Indexes**: +1. `{ _id: 1 }` - Auto primary +2. `{ updatedAt: -1 }` - Sort by recent +3. `{ status: 1, updatedAt: -1 }` - Filter + sort + +#### 1.4 MongoDB Repository +**File**: `src/infrastructure/repositories/MongoDBConversationRepository.ts` + +Implements `IConversationRepository`: +- `save()` - Upsert with `replaceOne({ _id }, { upsert: true })` +- `findById()` - Use mapper to restore entity with `Conversation.restore()` (**CRITICAL**: NOT `.create()`) +- `findAll(options)` - Pagination with projection (exclude `messages` array for list views) +- `delete()` - Hard delete with `deleteOne()` +- `count()`, `findActive()`, `archiveOlderThan()` + +#### 1.5 Dependency Injection Updates +**File**: `src/infrastructure/config/DependencyContainer.ts` + +**Changes**: +1. Make `initializeAdapters()` async +2. Replace `getInstance()` with async `create()` factory +3. Add repository selection based on `REPOSITORY_TYPE` env var +4. Graceful fallback: MongoDB failure → InMemory + warning log + +**Update ALL API routes** to await container: +```typescript +const container = await DependencyContainer.create({ enableLogging: true }); +``` + +#### 1.6 Backend Tests +**Files**: +- `tests/unit/infrastructure/repositories/MongoDBConversationRepository.unit.test.ts` (mocked MongoDB) +- `tests/integration/infrastructure/repositories/MongoDBConversationRepository.integration.test.ts` (mongodb-memory-server) +- `tests/unit/infrastructure/mappers/ConversationDocumentMapper.test.ts` +- `tests/unit/application/use-cases/ListConversationsUseCase.test.ts` + +**Coverage Targets**: 95% domain, 90% use cases, 80% infrastructure + +--- + +### **Phase 2: Backend API Endpoints** (Week 1-2) +**Estimated Time**: 3-4 days +**Lead**: hexagonal-backend-architect + +#### 2.1 New API Routes +**File**: `app/api/conversations/list/route.ts` +```typescript +GET /api/conversations/list?status=active&limit=100&offset=0 +``` + +**Response**: +```json +{ + "conversations": [ + { + "id": "uuid", + "title": "First conversation", + "status": "active", + "messageCount": 15, + "lastMessage": { + "role": "assistant", + "preview": "Sure, I can help with that..." + }, + "createdAt": "2025-10-08T12:34:56.789Z", // ISO 8601 string + "updatedAt": "2025-10-08T14:20:10.123Z" + } + ], + "total": 42 +} +``` + +**File**: `app/api/conversations/[id]/route.ts` +```typescript +GET /api/conversations/:id // Load conversation messages +DELETE /api/conversations/:id // Hard delete +PATCH /api/conversations/:id // Update title/metadata +``` + +#### 2.2 Use Cases +**File**: `src/application/use-cases/ListConversationsUseCase.ts` +- Constructor injection: `IConversationRepository` +- Delegates to `repository.findAll({ status, limit, offset })` +- Returns DTOs (not domain entities) + +#### 2.3 API Tests +- Integration tests for each route (mocked use cases) +- E2E tests against MongoDB Atlas test cluster + +--- + +### **Phase 3: Frontend Data Layer** (Week 2) +**Estimated Time**: 4-5 days +**Lead**: frontend-developer + frontend-test-engineer + +#### 3.1 Service Layer +**File**: `app/features/conversation/data/services/conversation.service.ts` + +**Enhanced Methods**: +```typescript +listConversations(options?: { status?, limit?, offset? }): Promise +getConversationHistory(id: string): Promise +deleteConversation(id: string): Promise +``` + +**Error Handling**: Custom `ConversationServiceError` with status code classification + +#### 3.2 React Query Hooks +**File**: `app/features/conversation/hooks/queries/useConversationQuery.ts` + +**New Hooks**: +```typescript +useConversationsListQuery(status?: 'active' | 'archived', options?) +useConversationMessagesQuery(conversationId: string, options?) +usePrefetchConversation(conversationId: string) // Hover trigger +``` + +**Query Keys**: +```typescript +conversationKeys = { + all: ['conversations'], + lists: () => [...conversationKeys.all, 'list'], + list: (filters) => [...conversationKeys.lists(), filters], + detail: (id) => [...conversationKeys.all, 'detail', id], + messages: (id) => [...conversationKeys.detail(id), 'messages'] +} +``` + +**Cache Strategy**: +- List: 2min stale, 10min gc, refetch on mount +- Messages: 5min stale, 30min gc, no refetch on mount + +#### 3.3 Mutation Hooks +**File**: `app/features/conversation/hooks/mutations/useConversationMutation.ts` + +**Enhanced Delete**: +```typescript +useDeleteConversationMutation({ + onMutate: (id) => { + // Optimistic update: remove from cache + const previousConversations = queryClient.getQueryData(conversationKeys.lists()); + queryClient.setQueryData(conversationKeys.lists(), (old) => + old.filter(c => c.id !== id) + ); + return { previousConversations }; + }, + onError: (error, id, context) => { + // Rollback on error + queryClient.setQueryData(conversationKeys.lists(), context.previousConversations); + toast.error('Failed to delete conversation'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: conversationKeys.lists() }); + toast.success('Conversation deleted'); + } +}) +``` + +#### 3.4 Business Hook +**File**: `app/features/conversation/hooks/useSwitchConversation.ts` + +**Orchestrates**: +1. Prefetch messages (if not in cache) +2. Update `conversationStorage` +3. Clear current messages (`setMessages([])`) +4. Load new messages +5. Handle errors with toast notifications + +#### 3.5 Enhanced useConversation +**File**: `app/features/conversation/hooks/useConversation.tsx` + +**New Method**: +```typescript +async loadConversation(conversationId: string) { + setIsLoadingConversation(true); + setLoadError(null); + + try { + // Fetch conversation messages + const history = await conversationService.getConversationHistory(conversationId); + + // Update storage + storage.setConversationId(conversationId); + + // Clear current messages (CRITICAL for Vercel AI SDK) + setMessages([]); + + // Load new messages + setMessages(history.messages); + } catch (error) { + setLoadError(error); + toast.error('Failed to load conversation'); + } finally { + setIsLoadingConversation(false); + } +} +``` + +**New State**: +- `isLoadingConversation: boolean` +- `loadError: Error | null` + +#### 3.6 Frontend Tests +**Files**: +- `app/features/conversation/hooks/__tests__/useConversationListQuery.test.tsx` +- `app/features/conversation/hooks/__tests__/useDeleteConversationMutation.test.tsx` +- `app/features/conversation/hooks/__tests__/useSwitchConversation.test.tsx` + +**Setup**: MSW for API mocking +```bash +yarn add -D msw@latest +``` + +**MSW Handlers**: +- `GET /api/conversations/list` (success, error, slow, empty) +- `GET /api/conversations/:id` (success, 404, error) +- `DELETE /api/conversations/:id` (success, error) + +**Coverage Target**: 80%+ statements + +--- + +### **Phase 4: Frontend UI Components** (Week 2-3) +**Estimated Time**: 5-6 days +**Lead**: shadcn-ui-architect + ui-ux-analyzer + +#### 4.1 Sidebar Component Architecture +**Files** (6 new components): +``` +app/features/conversation/components/ +├── conversation-sidebar.tsx # Main sidebar wrapper +├── conversation-sidebar-header.tsx # New Chat + Filters +├── conversation-list.tsx # List container +├── conversation-list-item.tsx # Individual item +├── conversation-list-skeleton.tsx # Loading state +└── conversation-empty-state.tsx # Empty state +``` + +#### 4.2 Component Selection (shadcn/ui v4) +- **Sidebar**: `Sidebar`, `SidebarProvider`, `SidebarInset`, `SidebarTrigger` +- **List**: `ScrollArea` (no virtualization for 100 items) +- **Actions**: `Button` (New Chat: default, Delete: ghost+icon) +- **Status**: `Badge` (Archived only) +- **Confirmation**: `AlertDialog` (delete confirmation) + +#### 4.3 ConversationSidebar (Main Component) +```tsx + + + + {/* All | Active | Archived */} + + + + + {isLoading && } + {isEmpty && } + {error && } + {conversations.map(conv => ( + + ))} + + + +``` + +#### 4.4 ConversationListItem Design +**Layout**: +``` +┌─────────────────────────────────────────┐ +│ [Title] [Delete]│ +│ Last message preview... │ +│ 2:30 PM [Badge] │ +└─────────────────────────────────────────┘ +``` + +**Specs**: +- Width: 280px (expanded), 56px (icon mode) +- Title: Single line, truncate with tooltip +- Preview: 60-80 chars, single line +- Timestamp: `date-fns` formatting ("2:30 PM", "Yesterday", "Jan 15") +- Delete: Ghost button, show on hover (desktop), always visible (mobile) +- Active state: `bg-secondary` + left border accent + +#### 4.5 Responsive Behavior +**Mobile (<768px)**: +- Overlay mode, full width (80-100vw) +- Backdrop with blur +- Swipe to close gesture +- Auto-close after selection + +**Desktop (≥768px)**: +- Persistent sidebar +- Icon collapse mode (56px) +- Hover to expand +- State persistence (localStorage: `sidebarOpen`) + +#### 4.6 States +**Loading**: 5 skeleton conversation items +**Empty**: Icon + "No conversations yet" + "Start chatting" CTA +**Error**: AlertCircle + error message + Retry button +**Active**: Highlighted background + blue left border + +#### 4.7 Layout Integration +**File**: `app/layout.tsx` or `app/(chat)/layout.tsx` + +```tsx + + + + + {/* Hamburger menu */} + + {children} + + +``` + +#### 4.8 Accessibility (WCAG 2.1 AA) +- Keyboard navigation (Tab, Arrow keys, Enter/Space, ESC) +- ARIA labels (`aria-label`, `aria-current`, `aria-live`) +- Screen reader announcements (live regions) +- Focus management (trap on mobile) +- Color contrast 4.5:1 ratio +- Keyboard shortcut: Cmd/Ctrl+B to toggle + +#### 4.9 Frontend Component Tests +**Files**: +- `app/features/conversation/components/__tests__/Sidebar.test.tsx` +- `app/features/conversation/components/__tests__/ConversationListItem.test.tsx` + +**Test Coverage**: +- Render with conversations +- Click to load conversation +- Delete with confirmation +- Filter by status +- Empty/loading/error states +- Keyboard navigation +- Responsive behavior (mobile/desktop) + +--- + +## Environment Variables + +**File**: `.env.example` +```bash +# OpenAI +OPENAI_API_KEY=your_openai_api_key_here + +# MongoDB Atlas +MONGODB_URL=mongodb+srv://user:password@cluster.mongodb.net/ +DATABASE_NAME=ai_chat_app + +# Repository Selection +REPOSITORY_TYPE=mongodb # or 'inmemory' for testing +``` + +**File**: `.env.local` (not committed, Fran creates this) +```bash +OPENAI_API_KEY=sk-... +MONGODB_URL=mongodb+srv://fran:...@cluster.mongodb.net/ +DATABASE_NAME=ai_chat_prod +REPOSITORY_TYPE=mongodb +``` + +--- + +## Testing Strategy + +### Backend Testing +**Tools**: Vitest, mongodb-memory-server + +**Unit Tests** (Mocked MongoDB): +- `MongoDBConversationRepository.unit.test.ts` - Mock MongoDB client +- `ConversationDocumentMapper.test.ts` - Round-trip mapping +- Use cases - Mock repository + +**Integration Tests** (Real MongoDB): +- `MongoDBConversationRepository.integration.test.ts` - mongodb-memory-server +- Full CRUD lifecycle +- Pagination, filtering, concurrent updates + +**Coverage**: 95% domain, 90% use cases, 80% infrastructure + +### Frontend Testing +**Tools**: Vitest, React Testing Library, MSW + +**Unit Tests**: +- Query hooks (list, messages) +- Mutation hooks (delete with optimistic updates) +- Components (Sidebar, ConversationListItem) + +**Integration Tests**: +- Conversation switching flow +- Delete + refetch flow +- Filter changes + query updates + +**Coverage**: 80%+ statements + +### E2E Testing +**Decision**: ❌ **Skipped per Fran's request** +- Focus on comprehensive unit and integration tests +- Manual testing will cover critical user flows +- Can add Playwright tests in future if needed + +--- + +## Acceptance Criteria (75+ Scenarios) + +### Critical Quality Gates (MUST PASS) +1. ✅ Zero message loss (100% persistence) +2. ✅ No duplicate conversations (UUID v4 uniqueness) +3. ✅ Correct message ordering (server timestamps) +4. ✅ Load time <2s for list, <1.5s for conversation +5. ✅ Hard delete with confirmation +6. ✅ MongoDB connection resilience (fallback to InMemory on startup) + +### Important (SHOULD PASS) +7. ✅ Sidebar 60fps animations (300ms transition) +8. ✅ WCAG 2.1 AA accessibility +9. ✅ Error auto-recovery 90% success rate +10. ✅ Responsive design (mobile overlay, desktop collapsible) + +**Full Criteria**: `.claude/doc/chat_history/acceptance-criteria.md` (8 functional areas, 75+ scenarios) + +--- + +## Performance Targets + +**Load Times**: +- Conversation list: <2 seconds (P95) +- Single conversation: <1.5 seconds (P95) +- Sidebar toggle: 300ms animation + +**Responsiveness**: +- Sidebar animations: 60fps +- Scroll performance: Smooth (no jank) +- Message rendering: <100ms per message + +**Database**: +- `findById()`: O(1) - Primary index +- `findAll()`: O(log n) - Compound index +- Connection pool: MaxPoolSize=10 (serverless) + +**Memory**: +- Long sessions: <200MB heap +- Conversation cache: 10min GC for list, 30min for messages + +--- + +## File Structure Summary + +### Backend (NEW) +``` +src/infrastructure/ +├── adapters/database/ +│ ├── MongoDBClient.ts +│ ├── MongoDBConversationRepository.ts +│ ├── types/ConversationDocument.ts +│ └── mappers/ConversationDocumentMapper.ts +└── config/ + └── DependencyContainer.ts [MODIFIED - async init] + +app/api/ +├── conversations/ +│ ├── list/route.ts [NEW] +│ └── [id]/route.ts [NEW] +└── health/route.ts [NEW] +``` + +### Backend Tests (NEW) +``` +tests/ +├── unit/ +│ ├── infrastructure/repositories/ +│ │ └── MongoDBConversationRepository.unit.test.ts +│ ├── infrastructure/mappers/ +│ │ └── ConversationDocumentMapper.test.ts +│ └── application/use-cases/ +│ └── ListConversationsUseCase.test.ts +└── integration/ + └── infrastructure/repositories/ + └── MongoDBConversationRepository.integration.test.ts +``` + +### Frontend (NEW) +``` +app/features/conversation/ +├── components/ +│ ├── conversation-sidebar.tsx +│ ├── conversation-sidebar-header.tsx +│ ├── conversation-list.tsx +│ ├── conversation-list-item.tsx +│ ├── conversation-list-skeleton.tsx +│ └── conversation-empty-state.tsx +├── hooks/ +│ ├── queries/useConversationQuery.ts [MODIFIED - add list query] +│ ├── mutations/useConversationMutation.ts [MODIFIED - enhance delete] +│ ├── useSwitchConversation.ts [NEW] +│ └── useConversation.tsx [MODIFIED - add loadConversation] +└── data/services/ + └── conversation.service.ts [MODIFIED - add listConversations] +``` + +### Frontend Tests (NEW) +``` +app/features/conversation/ +├── components/__tests__/ +│ ├── Sidebar.test.tsx +│ └── ConversationListItem.test.tsx +├── hooks/__tests__/ +│ ├── useConversationListQuery.test.tsx +│ ├── useDeleteConversationMutation.test.tsx +│ └── useSwitchConversation.test.tsx +└── __tests__/integration/ + ├── conversation-switching.test.tsx + ├── delete-conversation.test.tsx + └── filter-conversations.test.tsx + +app/__test-helpers__/ +├── msw/ +│ ├── server.ts +│ └── handlers.ts +├── fixtures/conversations.ts +├── factories/conversation.ts +└── wrappers.tsx +``` + +### E2E Tests (NEW) +``` +e2e/ +└── conversation-history.spec.ts (15 scenarios) +``` + +--- + +## Critical Implementation Notes + +### Backend ⚠️ +1. **Use `Conversation.restore()`** when mapping from MongoDB - NEVER `.create()` (bypasses domain validation) +2. **Tool Invocation State Machine** - Manually replay state transitions when restoring +3. **Async Container** - Update ALL API routes to `await DependencyContainer.create()` +4. **Date Objects** - Keep MongoDB dates as Date objects (NOT ISO strings) +5. **Fallback Strategy** - Startup fallback only (MongoDB failure → InMemory with warning) +6. **Index Creation** - Call `createIndexes()` once on repository initialization +7. **Connection Pooling** - MaxPoolSize=10 for serverless environments + +### Frontend ⚠️ +1. **React Query v5** - Use `gcTime` (not `cacheTime`), `isPending` (not `isLoading`) +2. **Vercel AI SDK** - MUST call `setMessages([])` before loading new conversation +3. **Date Serialization** - Backend MUST return ISO 8601 strings (Zod schema expects strings) +4. **Optimistic Delete** - Implement rollback on error (restore previous cache state) +5. **SidebarProvider** - Must wrap entire app in layout.tsx +6. **Focus Management** - Built-in focus trap on mobile (useSidebar hook) +7. **SessionStorage** - Active conversation ID persists per tab (intentional) + +--- + +## MongoDB Atlas Setup + +### 1. Create Free Cluster (M0) +1. Go to [cloud.mongodb.com](https://cloud.mongodb.com) +2. Create account (if needed) +3. Create new project: "AI Chat App" +4. Build Database → Shared (FREE) → Create +5. Choose provider: AWS, Region: Nearest to you +6. Cluster name: "ai-chat-cluster" + +### 2. Configure Database Access +1. Database Access → Add New Database User +2. Username: `ai_chat_user` +3. Password: Generate secure password (save it!) +4. Database User Privileges: "Read and write to any database" + +### 3. Configure Network Access +1. Network Access → Add IP Address +2. Option A: Allow access from anywhere (0.0.0.0/0) - **Development only** +3. Option B: Add your current IP - **More secure** + +### 4. Get Connection String +1. Clusters → Connect → Connect your application +2. Driver: Node.js, Version: 5.5 or later +3. Copy connection string: + ``` + mongodb+srv://ai_chat_user:@ai-chat-cluster.xxxxx.mongodb.net/?retryWrites=true&w=majority + ``` +4. Replace `` with your actual password +5. Add to `.env.local`: + ```bash + MONGODB_URL=mongodb+srv://ai_chat_user:YOUR_PASSWORD@ai-chat-cluster.xxxxx.mongodb.net/ + DATABASE_NAME=ai_chat_app + ``` + +### 5. Create Database & Collection +The repository will auto-create the database and collection on first write. Indexes are created on initialization. + +--- + +## Dependencies to Install + +```bash +# Backend +yarn add mongodb + +# Testing +yarn add -D mongodb-memory-server +yarn add -D msw@latest +``` + +--- + +## Implementation Timeline (Revised) + +**Phase 1: Backend** (Days 1-5) +- Days 1-3: Backend infrastructure (MongoDB client, repository, mapper, DI updates) +- Days 4-5: Backend API routes (list, get, delete) + +**Phase 2: Frontend** (Days 6-10) +- Days 6-7: Frontend data layer (services, query hooks, mutations) +- Days 8-9: Frontend UI components (sidebar, list, items) +- Day 10: Integration polish + +**Phase 3: Testing** (Days 11-13) +- Day 11: Backend tests (unit + integration) +- Day 12: Frontend tests (hooks, components, integration) +- Day 13: Bug fixes and test coverage + +**Phase 4: Polish** (Day 14-15) +- Day 14: Manual testing, responsive behavior verification +- Day 15: Documentation, cleanup, final review + +**Total**: 12-15 days (~2 weeks) + +--- + +## Validation Process + +### Phase 1: Automated Tests (CI/CD) +1. Run backend unit tests (mocked MongoDB) +2. Run backend integration tests (mongodb-memory-server) +3. Run frontend unit tests (MSW) +4. Verify coverage meets targets (80%+) + +### Phase 2: E2E Validation (Playwright) +1. Execute 15 critical scenarios +2. Test on 3 viewports (mobile, tablet, desktop) +3. Verify all acceptance criteria +4. Document pass/fail status + +### Phase 3: Manual Validation (Fran) +1. Conversation flow (5 min): Create, load, delete +2. Responsive design (5 min): Resize across breakpoints +3. Error states (3 min): Disconnect internet, kill MongoDB +4. Performance (2 min): Rapid switching with 50+ conversations + +**Total Manual Testing Time**: ~15 minutes + +### Phase 4: Validation Report +1. Map acceptance criteria to test results +2. Document any deviations +3. Provide actionable feedback +4. Update session context + +--- + +## Documentation Reference + +All detailed documentation is located in `.claude/doc/chat_history/`: + +1. **backend.md** (200+ lines) - MongoDB repository architecture +2. **backend-testing-strategy.md** (400+ lines) - Backend test patterns +3. **frontend-data-architecture.md** (500+ lines) - React Query integration +4. **frontend-testing-strategy.md** (450+ lines) - Frontend test patterns +5. **sidebar-ui-design.md** (600+ lines) - UI/UX specifications +6. **acceptance-criteria.md** (900+ lines) - 75+ acceptance criteria +7. **validation-checklist.md** (100+ lines) - Quick validation guide + +**Total Documentation**: ~3,000+ lines, 30,000+ words + +--- + +## ✅ Decisions from Fran (Answered 2025-10-08) + +1. **MongoDB Atlas**: ✅ Already have cluster configured - will provide connection string +2. **Testing Priority**: ✅ Features first, then comprehensive testing phase +3. **Health Check Endpoint**: ✅ Defer to Phase 2 - focus on core features +4. **Logging**: ✅ Console.log is fine for MVP +5. **Data Migration**: ✅ Fresh start - no existing data to migrate +6. **Pagination UI**: ✅ Don't implement yet - 100 conversations enough for MVP +7. **Real-time Updates**: ✅ Manual refresh - defer to future enhancement +8. **E2E Tests**: ✅ Skip E2E tests (Playwright) - unit + integration tests only + +--- + +## Success Metrics + +**Primary**: +- ✅ Zero message loss: 100% of sent messages persisted +- ✅ Fast loading: P95 <2s for list, <1.5s for conversation +- ✅ High availability: Database uptime >99.5% + +**UX**: +- ✅ Smooth animations: >55fps sidebar transitions +- ✅ Auto-recovery: 90% of transient errors recover automatically +- ✅ Data consistency: 100% correct message ordering + +**Technical**: +- ✅ Test coverage: 80%+ statements (backend + frontend) +- ✅ No regressions: All existing tests pass +- ✅ Accessibility: WCAG 2.1 AA compliance + +--- + +## Ready for Implementation ✅ + +Fran, this plan is **complete and ready for execution**. All architectural decisions have been made, all test strategies defined, and all edge cases documented. + +**Next step**: Please review this plan and the 7 detailed documents. Once approved, we can begin Phase 1 implementation. + +**Estimated completion**: 2-3 weeks with comprehensive testing. diff --git a/.claude/doc/chat_history/acceptance-criteria.md b/.claude/doc/chat_history/acceptance-criteria.md new file mode 100644 index 0000000..9067d4d --- /dev/null +++ b/.claude/doc/chat_history/acceptance-criteria.md @@ -0,0 +1,978 @@ +# Chat History Feature - Acceptance Criteria + +**Feature**: Persistent Chat History with Sidebar UI +**Last Updated**: 2025-10-08 +**Status**: Awaiting Implementation + +## Table of Contents +1. [Conversation Persistence](#1-conversation-persistence) +2. [Sidebar UI](#2-sidebar-ui) +3. [Conversation Loading](#3-conversation-loading) +4. [Conversation Management](#4-conversation-management) +5. [Error Scenarios](#5-error-scenarios) +6. [Performance Requirements](#6-performance-requirements) +7. [Responsive Design](#7-responsive-design) +8. [Data Integrity](#8-data-integrity) + +--- + +## 1. Conversation Persistence + +### User Story +As a user, I want my conversations to be saved automatically so that I can access them later without losing my chat history. + +### 1.1 Conversation Creation and Persistence + +**AC-1.1.1: Auto-save New Conversation** +- **Given** a user starts typing in an empty chat +- **When** they send their first message +- **Then** the conversation should be automatically created in MongoDB with: + - Unique conversation ID (UUID) + - Auto-generated title from first 50 characters of first user message + - Status set to `WAITING_FOR_RESPONSE` + - `createdAt` timestamp + - `updatedAt` timestamp + - Empty messages array (user message saved separately) + +**AC-1.1.2: Conversation ID Generation** +- **Given** a new conversation is being created +- **When** the conversation entity is instantiated +- **Then** a UUID v4 should be generated for the conversation ID +- **And** this ID should be returned to the frontend +- **And** this ID should be stored in browser's localStorage as `currentConversationId` + +**AC-1.1.3: Title Auto-generation** +- **Given** a user sends the first message in a new conversation +- **When** the message content is "What is the weather like in San Francisco today?" +- **Then** the conversation title should be "What is the weather like in San Francisco today?" +- **And** when the message content exceeds 50 characters +- **Then** the title should be truncated with "..." (e.g., "What is the weather like in San Francisco tod...") +- **And** when the message has multiple lines +- **Then** only the first line should be used for the title + +### 1.2 Message Persistence + +**AC-1.2.1: User Message Saved** +- **Given** a user sends a message in an active conversation +- **When** the API receives the message +- **Then** the message should be persisted to MongoDB with: + - Message ID + - Role: `user` + - Content (text) + - Timestamp + - Conversation ID reference +- **And** the conversation's `updatedAt` should be updated +- **And** the conversation status should be set to `WAITING_FOR_RESPONSE` + +**AC-1.2.2: Assistant Message Saved** +- **Given** the AI generates a response +- **When** the streaming completes successfully +- **Then** the assistant message should be persisted with: + - Message ID + - Role: `assistant` + - Full response content + - Timestamp + - Tool invocations (if any) + - Conversation ID reference +- **And** the conversation status should be set to `ACTIVE` +- **And** the conversation's `updatedAt` should be updated + +**AC-1.2.3: Tool Message Saved** +- **Given** a tool is invoked during a conversation +- **When** the tool execution completes +- **Then** the tool message should be persisted with: + - Message ID + - Role: `tool` + - Tool name + - Tool call ID + - Tool result content + - Timestamp + - Conversation ID reference + +**AC-1.2.4: Message Ordering Preserved** +- **Given** a conversation with multiple messages +- **When** messages are retrieved from the database +- **Then** they should be ordered by timestamp (ascending) +- **And** message sequence should match the order they were created + +### 1.3 Conversation Updates + +**AC-1.3.1: Incremental Message Addition** +- **Given** an existing conversation in MongoDB +- **When** a new message is added +- **Then** only the new message should be inserted +- **And** the conversation's `updatedAt` timestamp should be updated +- **And** no existing messages should be modified or duplicated + +**AC-1.3.2: Status Transitions** +- **Given** a conversation with status `ACTIVE` +- **When** a user sends a message +- **Then** status should transition to `WAITING_FOR_RESPONSE` +- **And** when the assistant responds +- **Then** status should transition back to `ACTIVE` + +**AC-1.3.3: Concurrent Update Handling** +- **Given** two browser tabs with the same conversation open +- **When** a user sends a message from tab 1 +- **And** simultaneously sends another message from tab 2 +- **Then** both messages should be persisted without data loss +- **And** the message order should be determined by server timestamp +- **And** no race condition errors should occur + +--- + +## 2. Sidebar UI + +### User Story +As a user, I want to see all my past conversations in a sidebar so that I can easily switch between them. + +### 2.1 Sidebar Visibility and Toggle + +**AC-2.1.1: Desktop Sidebar Default State** +- **Given** a user visits the chat application on a desktop (≥1024px width) +- **When** the page loads +- **Then** the sidebar should be collapsed by default +- **And** a hamburger menu icon should be visible in the top-left corner +- **And** the main chat area should occupy full width + +**AC-2.1.2: Sidebar Toggle on Desktop** +- **Given** the sidebar is collapsed +- **When** the user clicks the hamburger menu icon +- **Then** the sidebar should slide in from the left with a smooth animation (300ms) +- **And** the main chat area should resize to accommodate the sidebar +- **And** the hamburger icon should change to a close icon (X) + +**AC-2.1.3: Sidebar Close on Desktop** +- **Given** the sidebar is expanded +- **When** the user clicks the close icon (X) +- **Then** the sidebar should slide out to the left with animation +- **And** the main chat area should expand to full width +- **And** the close icon should change back to hamburger menu + +**AC-2.1.4: Mobile Sidebar Behavior** +- **Given** a user on mobile (≤768px width) +- **When** they open the sidebar +- **Then** it should overlay the main chat (not push it) +- **And** should cover the full screen width (minus 48px right margin for close) +- **And** a semi-transparent backdrop should appear behind it + +**AC-2.1.5: Backdrop Dismiss on Mobile** +- **Given** the sidebar is open on mobile +- **When** the user clicks the backdrop area +- **Then** the sidebar should close +- **And** the backdrop should fade out + +### 2.2 Conversation List Display + +**AC-2.2.1: Recent Conversations List** +- **Given** a user has 75 conversations in the database +- **When** they open the sidebar +- **Then** the most recent 50-100 conversations should be displayed +- **And** conversations should be sorted by `updatedAt` (most recent first) +- **And** each conversation should show: + - Title (truncated to 60 characters with "...") + - Last message timestamp (relative: "2m ago", "1h ago", "Yesterday", "Jan 15") + - Status indicator (if `WAITING_FOR_RESPONSE`, show loading spinner) + +**AC-2.2.2: Conversation Item Visual Design** +- **Given** a conversation list item +- **Then** it should display: + - Icon: Message bubble icon on the left + - Title: Bold, single line with ellipsis overflow + - Timestamp: Smaller, lighter text on the right + - Status badge: Small colored dot (green=ACTIVE, yellow=WAITING_FOR_RESPONSE, gray=ARCHIVED) +- **And** on hover, background should change to light gray +- **And** active conversation should have blue left border and light blue background + +**AC-2.2.3: Active Conversation Highlight** +- **Given** a user is viewing conversation ID "abc-123" +- **When** they open the sidebar +- **Then** the conversation with ID "abc-123" should be visually highlighted with: + - Blue left border (4px) + - Light blue background (#EFF6FF) + - Bold title text + +### 2.3 Empty States + +**AC-2.3.1: No Conversations Empty State** +- **Given** a new user with no conversations +- **When** they open the sidebar +- **Then** they should see: + - Centered empty state icon (chat bubble with plus) + - Text: "No conversations yet" + - Subtext: "Start a new conversation to see it here" + - "New Chat" button +- **And** the button should close the sidebar and focus the chat input + +**AC-2.3.2: Filtered Results Empty State** +- **Given** a user filters by "Archived" +- **When** there are no archived conversations +- **Then** they should see: + - Icon: Archive icon + - Text: "No archived conversations" + - Subtext: "Archive conversations to organize your chat history" + +### 2.4 Loading States + +**AC-2.4.1: Initial Sidebar Load** +- **Given** a user opens the sidebar for the first time +- **When** the conversation list is being fetched +- **Then** they should see: + - 3-5 skeleton loading placeholders (animated pulse) + - Each placeholder showing title and timestamp shapes +- **And** loading should not block sidebar opening animation + +**AC-2.4.2: Conversation List Refetch** +- **Given** the sidebar is already open +- **When** the user triggers a manual refresh +- **Then** existing conversations should remain visible +- **And** a subtle loading spinner should appear at the top +- **And** the list should update smoothly without flickering + +### 2.5 Error States + +**AC-2.5.1: MongoDB Connection Failure** +- **Given** MongoDB is unreachable +- **When** the user opens the sidebar +- **Then** they should see: + - Error icon (alert triangle) + - Text: "Unable to load conversations" + - Subtext: "Please check your connection and try again" + - "Retry" button +- **And** clicking "Retry" should re-fetch the conversation list + +**AC-2.5.2: Network Timeout** +- **Given** the API request times out (>10 seconds) +- **When** loading the conversation list +- **Then** the error state should be shown +- **And** a toast notification should appear: "Request timed out. Please try again." + +--- + +## 3. Conversation Loading + +### User Story +As a user, I want to click on a past conversation and see all my previous messages so that I can continue where I left off. + +### 3.1 Click to Load Conversation + +**AC-3.1.1: Load Conversation Messages** +- **Given** a user clicks on a conversation titled "Weather in Tokyo" +- **When** the conversation has 10 messages +- **Then** all 10 messages should be loaded into the chat area +- **And** the message order should be preserved (oldest to newest) +- **And** the chat input should be cleared +- **And** the sidebar should remain open on desktop, close on mobile + +**AC-3.1.2: Conversation ID Storage** +- **Given** a user loads conversation ID "xyz-789" +- **When** the conversation loads successfully +- **Then** localStorage should be updated with key `currentConversationId` = "xyz-789" +- **And** this ID should be included in all subsequent API requests + +**AC-3.1.3: Loading Indicator During Fetch** +- **Given** a user clicks a conversation +- **When** the messages are being fetched +- **Then** the clicked conversation item should show a loading spinner +- **And** the chat area should show a loading skeleton +- **And** the user should not be able to click other conversations until load completes + +### 3.2 Message Restoration + +**AC-3.2.1: Text Message Display** +- **Given** a loaded conversation has user and assistant messages +- **When** the messages are rendered +- **Then** user messages should appear on the right with blue background +- **And** assistant messages should appear on the left with gray background +- **And** all text formatting (line breaks, code blocks) should be preserved + +**AC-3.2.2: Tool Invocation Display** +- **Given** a message contains a tool invocation (e.g., weather lookup) +- **When** the message is rendered +- **Then** the tool call should be displayed with: + - Tool name badge + - Tool parameters (collapsed by default) + - Tool result (if completed) + - Expand/collapse icon +- **And** clicking should toggle the tool details visibility + +**AC-3.2.3: Attachment Display** +- **Given** a message has image attachments +- **When** the message is rendered +- **Then** images should be displayed inline +- **And** images should be lazy-loaded +- **And** clicking an image should open it in a lightbox + +### 3.3 Scroll Position + +**AC-3.3.1: Scroll to Bottom on Load** +- **Given** a user loads a conversation with 50 messages +- **When** the messages finish loading +- **Then** the chat should automatically scroll to the bottom (latest message) +- **And** scroll should be smooth (300ms animation) + +**AC-3.3.2: Preserve Scroll for Long Conversations** +- **Given** a user is viewing message 20 of 100 +- **When** they receive a new message +- **And** they are not at the bottom (>100px from bottom) +- **Then** the scroll position should remain unchanged +- **And** a "New message" indicator should appear at the bottom +- **And** clicking the indicator should scroll to bottom + +### 3.4 Active Conversation Indication + +**AC-3.4.1: Active State in Sidebar** +- **Given** conversation "abc-123" is currently active +- **When** the sidebar is open +- **Then** conversation "abc-123" should be highlighted (blue border, light background) +- **And** all other conversations should have default styling + +**AC-3.4.2: Active State Persists Across Refreshes** +- **Given** a user has conversation "abc-123" active +- **When** they refresh the browser +- **Then** conversation "abc-123" should remain active +- **And** all messages should be restored +- **And** the sidebar should show "abc-123" as highlighted + +--- + +## 4. Conversation Management + +### User Story +As a user, I want to manage my conversations (create new, delete old, filter by status) to keep my chat history organized. + +### 4.1 Creating New Conversations + +**AC-4.1.1: New Chat Button in Sidebar Header** +- **Given** the sidebar is open +- **When** the user looks at the sidebar header +- **Then** they should see a "New Chat" button with a plus icon +- **And** the button should be prominently placed (top-right of sidebar header) + +**AC-4.1.2: Create New Conversation** +- **Given** a user is viewing an existing conversation +- **When** they click the "New Chat" button +- **Then** a new conversation should be created with a UUID +- **And** the chat area should be cleared +- **And** the chat input should be focused +- **And** localStorage `currentConversationId` should be updated +- **And** the sidebar should close on mobile, remain open on desktop + +**AC-4.1.3: New Conversation Not Persisted Until First Message** +- **Given** a user clicks "New Chat" +- **When** no message has been sent yet +- **Then** the conversation should NOT appear in the sidebar list +- **And** no database entry should be created +- **And** when they send the first message +- **Then** the conversation should be saved to MongoDB +- **And** should appear in the sidebar list + +### 4.2 Deleting Conversations + +**AC-4.2.1: Delete Button Visibility** +- **Given** a user hovers over a conversation in the sidebar +- **When** the hover state is active +- **Then** a delete icon (trash can) should appear on the right side +- **And** the delete icon should be red on hover + +**AC-4.2.2: Delete Confirmation Dialog** +- **Given** a user clicks the delete icon +- **When** the icon is clicked +- **Then** a confirmation dialog should appear with: + - Title: "Delete conversation?" + - Message: "This will permanently delete '{conversation_title}' and all its messages. This action cannot be undone." + - "Cancel" button (secondary) + - "Delete" button (destructive red) + +**AC-4.2.3: Confirm Delete - Active Conversation** +- **Given** a user confirms deletion of the currently active conversation +- **When** the "Delete" button is clicked +- **Then** the conversation should be deleted from MongoDB (hard delete) +- **And** the conversation should be removed from the sidebar list +- **And** a new conversation should be automatically created +- **And** the chat area should be cleared +- **And** a toast notification should appear: "Conversation deleted" + +**AC-4.2.4: Confirm Delete - Inactive Conversation** +- **Given** a user confirms deletion of a non-active conversation +- **When** the "Delete" button is clicked +- **Then** the conversation should be deleted from MongoDB +- **And** removed from the sidebar list +- **And** the current conversation should remain active and unchanged +- **And** a toast notification should appear: "Conversation deleted" + +**AC-4.2.5: Cancel Delete** +- **Given** the delete confirmation dialog is open +- **When** the user clicks "Cancel" or presses ESC +- **Then** the dialog should close +- **And** no conversation should be deleted +- **And** the sidebar should return to normal state + +**AC-4.2.6: Keyboard Shortcut for Delete** +- **Given** a conversation is highlighted/focused in the sidebar +- **When** the user presses the Delete key +- **Then** the delete confirmation dialog should open +- **And** follow the same flow as clicking the delete icon + +### 4.3 Filter by Status + +**AC-4.3.1: Status Filter UI** +- **Given** the sidebar is open +- **When** the user views the sidebar header +- **Then** they should see a filter dropdown with options: + - "All" (default) + - "Active" + - "Archived" +- **And** the current selection should be visually indicated + +**AC-4.3.2: Filter by Active** +- **Given** the user selects "Active" from the filter +- **When** the filter is applied +- **Then** only conversations with status `ACTIVE` or `WAITING_FOR_RESPONSE` should be displayed +- **And** the API should be called with query parameter `?status=active` +- **And** archived conversations should not be visible + +**AC-4.3.3: Filter by Archived** +- **Given** the user selects "Archived" from the filter +- **When** the filter is applied +- **Then** only conversations with status `ARCHIVED` should be displayed +- **And** active conversations should not be visible +- **And** if no archived conversations exist, show empty state + +**AC-4.3.4: Filter Persistence** +- **Given** a user has selected "Archived" filter +- **When** they refresh the browser +- **Then** the filter should remain set to "Archived" +- **And** only archived conversations should be displayed +- **And** the filter should be stored in localStorage as `conversationFilter` + +**AC-4.3.5: Clear Filter** +- **Given** the user has "Active" filter selected +- **When** they select "All" +- **Then** all conversations (regardless of status) should be displayed +- **And** the filter parameter should be removed from API calls + +### 4.4 Archive/Unarchive Conversations + +**AC-4.4.1: Archive Menu Option** +- **Given** a user right-clicks a conversation in the sidebar +- **When** the context menu appears +- **Then** they should see an "Archive" option +- **And** clicking it should set conversation status to `ARCHIVED` +- **And** the conversation should be removed from the active list +- **And** a toast should appear: "Conversation archived" + +**AC-4.4.2: Unarchive Menu Option** +- **Given** a user views an archived conversation +- **When** they right-click it +- **Then** they should see an "Unarchive" option +- **And** clicking it should set status back to `ACTIVE` +- **And** the conversation should appear in the active list +- **And** a toast should appear: "Conversation restored" + +**AC-4.4.3: Cannot Send Messages to Archived Conversations** +- **Given** a user loads an archived conversation +- **When** the conversation is displayed +- **Then** the chat input should be disabled +- **And** a banner should appear: "This conversation is archived. Unarchive to continue chatting." +- **And** an "Unarchive" button should be present in the banner + +--- + +## 5. Error Scenarios + +### User Story +As a user, I want to see clear error messages when something goes wrong so that I know what happened and how to fix it. + +### 5.1 MongoDB Connection Errors + +**AC-5.1.1: Initial Connection Failure** +- **Given** MongoDB Atlas is unreachable +- **When** the application starts +- **Then** a global error banner should appear: "Database connection failed. Some features may not work." +- **And** the sidebar should show the connection error state +- **And** the main chat should still allow new conversations (fallback to in-memory) + +**AC-5.1.2: Connection Lost During Session** +- **Given** MongoDB connection is lost after initial connection +- **When** the user tries to load a conversation +- **Then** an error toast should appear: "Unable to load conversation. Connection lost." +- **And** the user should be offered a "Retry" option +- **And** local state should be preserved + +**AC-5.1.3: Automatic Reconnection** +- **Given** MongoDB connection was lost +- **When** the connection is restored +- **Then** the error banner should automatically disappear +- **And** a success toast should appear: "Connection restored" +- **And** pending operations should retry automatically + +### 5.2 Network Errors + +**AC-5.2.1: API Request Timeout** +- **Given** an API request takes longer than 10 seconds +- **When** loading a conversation +- **Then** the request should be aborted +- **And** an error toast should appear: "Request timed out. Please try again." +- **And** the loading state should clear + +**AC-5.2.2: Offline Detection** +- **Given** the user's device goes offline +- **When** they try to load a conversation +- **Then** an error message should appear: "You are offline. Please check your internet connection." +- **And** the last loaded conversation should remain accessible +- **And** new messages should be queued locally + +**AC-5.2.3: API Server Error (500)** +- **Given** the API returns a 500 error +- **When** performing any operation +- **Then** an error toast should appear: "Server error. Please try again later." +- **And** the error should be logged to console with details +- **And** the user should not see technical error messages + +### 5.3 Conversation Not Found + +**AC-5.3.1: Deleted Conversation Link** +- **Given** a user has a URL to conversation ID "deleted-123" +- **When** that conversation no longer exists in the database +- **Then** an error message should appear: "This conversation no longer exists." +- **And** the user should be redirected to a new conversation +- **And** localStorage `currentConversationId` should be cleared + +**AC-5.3.2: Invalid Conversation ID Format** +- **Given** a user tries to load conversation ID "invalid" +- **When** the ID is not a valid UUID +- **Then** an error should appear: "Invalid conversation ID" +- **And** the user should be redirected to home +- **And** a new conversation should be created + +### 5.4 Concurrent Update Conflicts + +**AC-5.4.1: Optimistic Locking Failure** +- **Given** two tabs have the same conversation open +- **When** both tabs send a message simultaneously +- **Then** both messages should be saved successfully +- **And** the message order should be determined by server timestamp +- **And** no "conflict" error should be shown to the user + +**AC-5.4.2: Stale Data Refresh** +- **Given** a conversation is updated in another tab +- **When** the user is viewing it in the current tab +- **Then** the conversation should auto-refresh within 5 seconds +- **And** new messages should appear with a subtle animation +- **And** the user's scroll position should be preserved + +### 5.5 Data Validation Errors + +**AC-5.5.1: Message Exceeds Max Length** +- **Given** a user types a message longer than 10,000 characters +- **When** they try to send it +- **Then** an error should appear: "Message is too long. Maximum 10,000 characters." +- **And** the message should not be sent +- **And** the character count should be displayed + +**AC-5.5.2: Conversation Max Messages Exceeded** +- **Given** a conversation has reached 1,000 messages (domain limit) +- **When** the user tries to send another message +- **Then** an error should appear: "This conversation has reached the maximum message limit. Please start a new conversation." +- **And** a "New Chat" button should be provided + +--- + +## 6. Performance Requirements + +### User Story +As a user, I want the application to be fast and responsive so that I can work efficiently. + +### 6.1 Load Time Requirements + +**AC-6.1.1: Conversation List Load Time** +- **Given** a user opens the sidebar +- **When** there are 100 conversations in the database +- **Then** the initial load should complete within 2 seconds +- **And** if it takes longer, a loading state should be shown + +**AC-6.1.2: Single Conversation Load Time** +- **Given** a user clicks on a conversation +- **When** the conversation has 50 messages +- **Then** all messages should load within 1.5 seconds +- **And** the conversation should be usable (input enabled) within 1 second + +**AC-6.1.3: Message Send Latency** +- **Given** a user sends a message +- **When** the network latency is normal (<100ms) +- **Then** the message should appear optimistically within 100ms +- **And** the API confirmation should occur in the background +- **And** if the API fails, the message should show an error state + +### 6.2 Sidebar Responsiveness + +**AC-6.2.1: Sidebar Open Animation** +- **Given** a user clicks the hamburger menu +- **When** the sidebar opens +- **Then** the animation should be smooth (60fps) +- **And** should complete within 300ms +- **And** should not block the main thread + +**AC-6.2.2: Conversation List Scrolling** +- **Given** a sidebar with 100 conversations +- **When** the user scrolls the list +- **Then** scrolling should be smooth with no jank +- **And** list items should use virtualization for 100+ items +- **And** only visible items + 10 buffer items should be rendered + +**AC-6.2.3: Search/Filter Performance** +- **Given** a user applies a filter +- **When** the filter changes +- **Then** the list should update within 200ms +- **And** the transition should be smooth +- **And** no full page re-render should occur + +### 6.3 Database Query Performance + +**AC-6.3.1: Conversation List Query** +- **Given** a database with 10,000 conversations +- **When** fetching the most recent 100 +- **Then** the query should execute in under 500ms +- **And** should use proper indexes (on `updatedAt` descending) +- **And** should use pagination (limit + offset) + +**AC-6.3.2: Message Retrieval Query** +- **Given** a conversation with 1,000 messages +- **When** loading the conversation +- **Then** messages should be retrieved in under 800ms +- **And** should use index on `conversationId` + `timestamp` + +**AC-6.3.3: Delete Operation Performance** +- **Given** a conversation with 500 messages +- **When** the user deletes it +- **Then** the deletion should complete within 1 second +- **And** the sidebar should update immediately (optimistic) +- **And** if deletion fails, the conversation should reappear + +### 6.4 Memory Management + +**AC-6.4.1: Memory Limits for Long Sessions** +- **Given** a user keeps the app open for 8 hours +- **When** they have loaded 20 different conversations +- **Then** memory usage should not exceed 200MB +- **And** old conversations should be unloaded from memory +- **And** only the active conversation should be fully loaded + +**AC-6.4.2: Image Attachment Memory** +- **Given** a conversation has 50 image attachments +- **When** the conversation is loaded +- **Then** images should be lazy-loaded (only when scrolled into view) +- **And** images outside viewport should be unloaded +- **And** total memory for images should not exceed 100MB + +--- + +## 7. Responsive Design + +### User Story +As a user, I want the chat application to work well on any device so that I can use it anywhere. + +### 7.1 Mobile Behavior (≤768px) + +**AC-7.1.1: Sidebar as Overlay** +- **Given** a user on mobile (375px width) +- **When** they open the sidebar +- **Then** the sidebar should overlay the chat area (not push it) +- **And** sidebar width should be 100vw - 48px (leaving room for backdrop edge) +- **And** a semi-transparent backdrop should cover the chat area + +**AC-7.1.2: Auto-close on Mobile** +- **Given** the sidebar is open on mobile +- **When** the user selects a conversation +- **Then** the sidebar should automatically close +- **And** the selected conversation should load +- **And** the hamburger menu should remain accessible + +**AC-7.1.3: Touch Gestures** +- **Given** a user on a mobile device +- **When** the sidebar is open +- **Then** swiping left should close the sidebar +- **And** tapping the backdrop should close the sidebar +- **And** gestures should feel natural (follow finger movement) + +**AC-7.1.4: Mobile Chat Input** +- **Given** a user on mobile +- **When** they focus the chat input +- **Then** the virtual keyboard should appear +- **And** the chat should scroll to show the input +- **And** messages above should remain accessible via scroll + +### 7.2 Tablet Behavior (769px - 1023px) + +**AC-7.2.1: Sidebar Behavior** +- **Given** a user on tablet (768px width) +- **When** they open the sidebar +- **Then** the sidebar should push the chat area (not overlay) +- **And** sidebar width should be 320px +- **And** chat area should resize smoothly + +**AC-7.2.2: Landscape vs Portrait** +- **Given** a tablet in landscape mode (1024px width) +- **When** the sidebar is open +- **Then** both sidebar and chat should be comfortably visible +- **And** when rotated to portrait (768px width) +- **Then** the sidebar should remain open if it was open +- **And** layout should adjust smoothly + +### 7.3 Desktop Behavior (≥1024px) + +**AC-7.3.1: Default Collapsed State** +- **Given** a user on desktop (1440px width) +- **When** they first visit the app +- **Then** the sidebar should be collapsed +- **And** the chat area should occupy full width +- **And** the hamburger menu should be visible + +**AC-7.3.2: Expanded Sidebar Width** +- **Given** the sidebar is expanded on desktop +- **When** viewing the layout +- **Then** the sidebar should be 320px wide +- **And** the chat area should be (viewport width - 320px) +- **And** both areas should be fully functional + +**AC-7.3.3: Sidebar State Persistence** +- **Given** a user expands the sidebar on desktop +- **When** they refresh the page +- **Then** the sidebar should remember its state (expanded/collapsed) +- **And** the state should be stored in localStorage as `sidebarOpen` + +### 7.4 Ultra-wide Screens (≥1920px) + +**AC-7.4.1: Maximum Chat Width** +- **Given** a user on a 4K monitor (3840px width) +- **When** viewing the chat +- **Then** the chat area should have a maximum width of 1200px +- **And** should be centered in the available space +- **And** should not stretch to full width + +**AC-7.4.2: Sidebar Scaling** +- **Given** an ultra-wide screen +- **When** the sidebar is open +- **Then** the sidebar width should remain 320px (not scale) +- **And** font sizes should remain readable + +--- + +## 8. Data Integrity + +### User Story +As a user, I expect my conversation data to be reliable and never lost or corrupted. + +### 8.1 No Message Loss + +**AC-8.1.1: Message Persistence Guarantee** +- **Given** a user sends a message +- **When** the API confirms receipt (200 OK) +- **Then** the message must be persisted to MongoDB before the response is sent +- **And** if database write fails, the API should return 500 error +- **And** the frontend should retry the send + +**AC-8.1.2: Streaming Interruption** +- **Given** the AI is streaming a response +- **When** the connection is interrupted mid-stream +- **Then** the partial response should be saved to the database +- **And** the conversation status should reflect the interruption +- **And** the user should be able to retry with "Regenerate" button + +**AC-8.1.3: Browser Crash Recovery** +- **Given** a user sends a message +- **When** the browser crashes before the response completes +- **Then** on reopen, the conversation should be restored +- **And** the last user message should be visible +- **And** if the assistant response was interrupted, show "Response interrupted" state + +### 8.2 No Duplicate Conversations + +**AC-8.2.1: UUID Uniqueness** +- **Given** a new conversation is created +- **When** the UUID is generated +- **Then** it must be a valid UUID v4 +- **And** must be unique in the database (checked via unique index) +- **And** if a collision occurs (astronomically rare), generate a new UUID + +**AC-8.2.2: Concurrent Creation Prevention** +- **Given** two tabs both create a new conversation simultaneously +- **When** both try to save +- **Then** both should succeed with different UUIDs +- **And** no race condition should cause the same UUID to be used +- **And** both conversations should appear in the sidebar + +**AC-8.2.3: Duplicate Detection** +- **Given** a conversation already exists with ID "abc-123" +- **When** a save operation is called for "abc-123" +- **Then** it should update the existing conversation (not create a duplicate) +- **And** the `updatedAt` timestamp should be modified +- **And** the `createdAt` timestamp should remain unchanged + +### 8.3 Correct Message Ordering + +**AC-8.3.1: Timestamp Accuracy** +- **Given** messages are added to a conversation +- **When** each message is saved +- **Then** the timestamp should be server-generated (not client-generated) +- **And** timestamps should use UTC +- **And** messages should be sortable by timestamp + +**AC-8.3.2: Message Sequence Validation** +- **Given** a conversation follows domain rules (user → assistant → user) +- **When** messages are retrieved from the database +- **Then** they should be ordered chronologically +- **And** role alternation should be validated +- **And** invalid sequences should be flagged in logs + +**AC-8.3.3: Tool Message Ordering** +- **Given** an assistant message invokes a tool +- **When** the tool result is received +- **Then** the tool message should be inserted immediately after the assistant message +- **And** should reference the correct `tool_call_id` +- **And** should not break message ordering + +### 8.4 Transaction Integrity + +**AC-8.4.1: Atomic Conversation + Message Creation** +- **Given** a new conversation with the first message +- **When** both are saved +- **Then** they should be saved in a single transaction +- **And** if message save fails, conversation should not be created +- **And** if conversation save fails, message should not be saved + +**AC-8.4.2: Update Consistency** +- **Given** a conversation is being updated +- **When** the `updatedAt` timestamp is modified +- **Then** it must be updated atomically with the message insertion +- **And** no partial updates should occur +- **And** if any part fails, the entire update should rollback + +**AC-8.4.3: Cascade Delete Integrity** +- **Given** a conversation is deleted +- **When** the delete operation executes +- **Then** all associated messages must also be deleted +- **And** the delete should be atomic (all or nothing) +- **And** no orphaned messages should remain in the database + +--- + +## Non-Functional Requirements + +### Accessibility + +**AC-NFR-1: Keyboard Navigation** +- All sidebar interactions must be accessible via keyboard +- Tab order must be logical (hamburger → filter → conversation list → new chat) +- Delete confirmation must support Enter (confirm) and ESC (cancel) + +**AC-NFR-2: Screen Reader Support** +- Conversation list must have proper ARIA labels +- Active conversation must be announced as "selected" +- Loading states must be announced to screen readers + +**AC-NFR-3: Color Contrast** +- All text must meet WCAG 2.1 AA standards (4.5:1 ratio) +- Status indicators must not rely solely on color (use icons too) + +### Security + +**AC-NFR-4: Data Sanitization** +- All user input must be sanitized before saving to MongoDB +- Conversation titles must be escaped to prevent XSS +- Message content must be validated for malicious scripts + +**AC-NFR-5: MongoDB Connection Security** +- Connection string must use TLS/SSL +- Credentials must be stored in environment variables only +- No database credentials in client-side code + +### Browser Compatibility + +**AC-NFR-6: Supported Browsers** +- Chrome/Edge (last 2 versions) +- Firefox (last 2 versions) +- Safari (last 2 versions) +- Mobile Safari (iOS 14+) +- Chrome Mobile (Android 10+) + +--- + +## Edge Cases + +### EC-1: Very Long Conversation Titles +- **Given** a user's first message is 500 characters long +- **Then** the title should truncate to 50 characters + "..." +- **And** the full title should be visible in a tooltip on hover + +### EC-2: Rapid Conversation Switching +- **Given** a user clicks 5 different conversations within 2 seconds +- **Then** only the last clicked conversation should load +- **And** previous pending requests should be cancelled +- **And** no race condition should occur + +### EC-3: Empty Message Content +- **Given** a user sends a message with only whitespace +- **Then** the message should not be sent +- **And** an error should appear: "Message cannot be empty" + +### EC-4: Conversation at Exact Limit +- **Given** a conversation has exactly 1,000 messages +- **When** the user tries to send message 1,001 +- **Then** the domain error should be caught and displayed +- **And** the user should be prompted to start a new conversation + +### EC-5: MongoDB Quota Exceeded +- **Given** MongoDB Atlas free tier reaches storage limit +- **When** trying to save a new conversation +- **Then** a clear error should appear: "Storage limit reached. Please upgrade your account." +- **And** existing conversations should remain accessible (read-only) + +--- + +## Success Metrics + +### Primary Metrics +1. **Zero Message Loss**: 100% of sent messages are persisted successfully +2. **Fast Conversation Loading**: 95th percentile load time < 2 seconds +3. **High Availability**: Database connection uptime > 99.5% + +### User Experience Metrics +1. **Sidebar Responsiveness**: Animation frame rate > 55fps +2. **Error Recovery**: 90% of transient errors auto-recover without user action +3. **Data Consistency**: 100% of conversations load with correct message order + +--- + +## Acceptance Testing Approach + +### Manual Testing +- Fran will use Playwright MCP to validate all UI interactions +- Each acceptance criterion will have a corresponding Playwright test +- Tests will cover desktop (1920x1080), tablet (768x1024), and mobile (375x667) + +### Automated Testing +- Backend: Vitest unit tests for all use cases and repository methods +- Frontend: React Testing Library for hooks and components +- Integration: Playwright E2E tests for critical user flows + +### Test Environments +- **Local**: In-memory repository for fast iteration +- **Staging**: MongoDB Atlas test cluster +- **Production**: MongoDB Atlas production cluster (after validation) + +--- + +## Open Questions for Implementation + +1. **Pagination Strategy**: Should we implement infinite scroll or "Load More" button for 100+ conversations? +2. **Real-time Updates**: Should conversations update in real-time when changed in another tab/device (WebSocket)? +3. **Conversation Search**: Should we add full-text search in a future iteration? +4. **Conversation Export**: Should users be able to export conversations as PDF/JSON? + +--- + +**Document Version**: 1.0 +**Created By**: QA Criteria Validator Agent +**Review Status**: Awaiting Fran's Approval diff --git a/.claude/doc/chat_history/backend-testing-strategy.md b/.claude/doc/chat_history/backend-testing-strategy.md new file mode 100644 index 0000000..cde2d60 --- /dev/null +++ b/.claude/doc/chat_history/backend-testing-strategy.md @@ -0,0 +1,1674 @@ +# MongoDB Repository Testing Strategy - Hexagonal Architecture + +## Document Information +**Feature**: Chat History Persistence with MongoDB +**Created**: 2025-10-08 +**Author**: backend-test-architect +**Target Audience**: Fran & Implementation Team +**Architecture**: Hexagonal (Ports & Adapters) with Next.js + +--- + +## Table of Contents +1. [Testing Philosophy](#testing-philosophy) +2. [Unit Testing MongoDB Repository](#unit-testing-mongodb-repository) +3. [Integration Testing](#integration-testing) +4. [Use Case Testing](#use-case-testing) +5. [API Route Testing](#api-route-testing) +6. [Test Data Management](#test-data-management) +7. [CI/CD Considerations](#cicd-considerations) +8. [Recommended Test Structure](#recommended-test-structure) +9. [Tools & Dependencies](#tools--dependencies) +10. [Implementation Roadmap](#implementation-roadmap) + +--- + +## Testing Philosophy + +### Core Principles for Hexagonal Architecture Testing + +1. **Test Pyramid Adherence**: 70% unit tests, 20% integration tests, 10% E2E tests +2. **Layer Isolation**: Each layer should be testable in complete isolation +3. **Mock at Boundaries**: Mock external dependencies (MongoDB, OpenAI) at adapter boundaries +4. **Domain Purity**: Domain layer tests have ZERO infrastructure dependencies +5. **Fast Feedback**: Unit tests must run in milliseconds, integration tests in seconds +6. **Test Behavior, Not Implementation**: Focus on contracts and behavior, not internal implementation details + +### Your Specific Hexagonal Architecture + +``` +Domain Layer (Pure Business Logic) + ↑ No dependencies on infrastructure +Application Layer (Use Cases) + ↑ Depends on domain + repository interfaces (ports) +Infrastructure Layer (Adapters) + ↑ Implements repository interfaces +Web Layer (Next.js API Routes) + ↑ Thin controllers delegating to use cases +``` + +**Testing Strategy**: Test each layer independently with mocked dependencies from outer layers. + +--- + +## 1. Unit Testing MongoDB Repository + +### 1.1 Testing Strategy + +**Goal**: Test the MongoDB repository adapter in complete isolation without actual database connections. + +### 1.2 Approach: Dual Testing Strategy + +#### Strategy A: Pure Unit Tests with MongoDB Client Mocking + +**When to Use**: +- CI/CD pipelines (fast, no external dependencies) +- Testing error scenarios (connection failures, timeouts) +- Testing data mapping logic +- Quick developer feedback loop + +**How to Mock MongoDB Client**: + +```typescript +// Use Vitest's vi.mock() to mock mongodb client +import { vi } from 'vitest'; + +// Mock the entire mongodb module +vi.mock('mongodb', () => ({ + MongoClient: vi.fn(() => ({ + connect: vi.fn(), + db: vi.fn(() => ({ + collection: vi.fn(() => ({ + findOne: vi.fn(), + insertOne: vi.fn(), + updateOne: vi.fn(), + deleteOne: vi.fn(), + find: vi.fn(() => ({ + toArray: vi.fn(), + skip: vi.fn(), + limit: vi.fn(), + sort: vi.fn(), + })), + countDocuments: vi.fn(), + })), + })), + close: vi.fn(), + })), + ObjectId: vi.fn(), +})); +``` + +**What to Test with Mocks**: +1. **Entity-to-Document Mapping**: Verify `Conversation.toObject()` → MongoDB document conversion +2. **Document-to-Entity Restoration**: Verify MongoDB document → `Conversation.restore()` conversion +3. **Query Construction**: Verify correct filters, pagination, sorting parameters +4. **Error Translation**: MongoDB errors → Domain exceptions (e.g., `ConversationError`) +5. **Connection Lifecycle**: Verify `connect()`, `close()`, connection pooling behavior + +**Example Test Structure**: + +```typescript +describe('MongoConversationRepository - Unit Tests (Mocked Client)', () => { + let repository: MongoConversationRepository; + let mockCollection: any; + + beforeEach(() => { + // Setup mocked MongoDB client and collection + mockCollection = createMockCollection(); + repository = new MongoConversationRepository(mockMongoConfig); + }); + + describe('save()', () => { + it('should insert new conversation document with correct structure', async () => { + const conversation = Conversation.create(); + await repository.save(conversation); + + expect(mockCollection.insertOne).toHaveBeenCalledWith( + expect.objectContaining({ + _id: conversation.getId(), + messages: expect.any(Array), + status: 'active', + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }) + ); + }); + + it('should update existing conversation using replaceOne', async () => { + const existingConversation = Conversation.create('existing-id'); + mockCollection.findOne.mockResolvedValue({ _id: 'existing-id' }); + + await repository.save(existingConversation); + + expect(mockCollection.replaceOne).toHaveBeenCalledWith( + { _id: 'existing-id' }, + expect.any(Object) + ); + }); + }); + + describe('findById()', () => { + it('should restore Conversation entity from MongoDB document', async () => { + const mockDocument = { + _id: 'conv-123', + messages: [], + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + title: 'Test Conversation', + }; + + mockCollection.findOne.mockResolvedValue(mockDocument); + + const conversation = await repository.findById('conv-123'); + + expect(conversation).toBeInstanceOf(Conversation); + expect(conversation?.getId()).toBe('conv-123'); + expect(conversation?.getTitle()).toBe('Test Conversation'); + }); + + it('should return null when conversation not found', async () => { + mockCollection.findOne.mockResolvedValue(null); + + const conversation = await repository.findById('non-existent'); + + expect(conversation).toBeNull(); + }); + }); + + describe('Error Handling', () => { + it('should throw ConversationError when MongoDB connection fails', async () => { + mockCollection.findOne.mockRejectedValue(new Error('Connection timeout')); + + await expect(repository.findById('conv-123')).rejects.toThrow( + 'Failed to retrieve conversation' + ); + }); + + it('should handle duplicate key errors gracefully', async () => { + const duplicateError = new Error('E11000 duplicate key error'); + mockCollection.insertOne.mockRejectedValue(duplicateError); + + const conversation = Conversation.create('duplicate-id'); + + await expect(repository.save(conversation)).rejects.toThrow( + 'Conversation already exists' + ); + }); + }); + + describe('Complex Entity Restoration', () => { + it('should restore conversation with messages, tool invocations, and metadata', async () => { + const mockDocument = { + _id: 'conv-complex', + messages: [ + { + id: 'msg-1', + role: 'user', + content: 'Hello', + toolInvocations: [], + metadata: {}, + }, + { + id: 'msg-2', + role: 'assistant', + content: 'Hi there', + toolInvocations: [ + { + callId: 'call-1', + toolName: 'weather', + args: { location: 'NYC' }, + status: 'completed', + result: { temp: 72 }, + }, + ], + metadata: {}, + }, + ], + status: 'active', + createdAt: new Date('2025-01-01'), + updatedAt: new Date('2025-01-02'), + title: 'Weather Chat', + }; + + mockCollection.findOne.mockResolvedValue(mockDocument); + + const conversation = await repository.findById('conv-complex'); + + expect(conversation?.getMessageCount()).toBe(2); + expect(conversation?.getLastAssistantMessage()?.hasToolInvocations()).toBe(true); + }); + }); +}); +``` + +#### Strategy B: Integration Tests with mongodb-memory-server + +**When to Use**: +- Verifying actual MongoDB query behavior +- Testing complex queries (aggregations, indexes) +- Testing transaction behavior (if needed) +- Validating schema constraints +- Pre-deployment verification + +**Setup**: + +```bash +yarn add -D mongodb-memory-server +``` + +**Example Integration Test**: + +```typescript +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { MongoClient } from 'mongodb'; + +describe('MongoConversationRepository - Integration Tests (In-Memory MongoDB)', () => { + let mongoServer: MongoMemoryServer; + let mongoClient: MongoClient; + let repository: MongoConversationRepository; + + beforeAll(async () => { + // Start in-memory MongoDB server + mongoServer = await MongoMemoryServer.create(); + const uri = mongoServer.getUri(); + + // Connect real MongoDB client to in-memory server + mongoClient = new MongoClient(uri); + await mongoClient.connect(); + + // Create repository with real connection + repository = new MongoConversationRepository({ + mongoUrl: uri, + databaseName: 'test-db', + }); + }); + + afterAll(async () => { + await mongoClient.close(); + await mongoServer.stop(); + }); + + afterEach(async () => { + // Clean up test data between tests + const db = mongoClient.db('test-db'); + await db.collection('conversations').deleteMany({}); + }); + + it('should perform full CRUD lifecycle with real MongoDB', async () => { + // CREATE + const conversation = Conversation.create(); + conversation.setTitle('Integration Test Conversation'); + await repository.save(conversation); + + // READ + const retrieved = await repository.findById(conversation.getId()); + expect(retrieved?.getTitle()).toBe('Integration Test Conversation'); + + // UPDATE + retrieved?.setTitle('Updated Title'); + await repository.save(retrieved!); + + const updated = await repository.findById(conversation.getId()); + expect(updated?.getTitle()).toBe('Updated Title'); + + // DELETE + await repository.delete(conversation.getId()); + const deleted = await repository.findById(conversation.getId()); + expect(deleted).toBeNull(); + }); + + it('should handle pagination correctly with real data', async () => { + // Insert 10 conversations + const conversations = Array.from({ length: 10 }, (_, i) => + Conversation.create(`conv-${i}`) + ); + + for (const conv of conversations) { + await repository.save(conv); + } + + // Test pagination + const page1 = await repository.findAll({ limit: 5, offset: 0 }); + expect(page1).toHaveLength(5); + + const page2 = await repository.findAll({ limit: 5, offset: 5 }); + expect(page2).toHaveLength(5); + + // Verify no duplicates between pages + const page1Ids = page1.map(c => c.getId()); + const page2Ids = page2.map(c => c.getId()); + expect(page1Ids).not.toEqual(expect.arrayContaining(page2Ids)); + }); + + it('should correctly sort by updatedAt (newest first)', async () => { + const conv1 = Conversation.create('conv-1'); + await repository.save(conv1); + + // Wait to ensure different timestamp + await new Promise(resolve => setTimeout(resolve, 10)); + + const conv2 = Conversation.create('conv-2'); + await repository.save(conv2); + + const all = await repository.findAll(); + + expect(all[0].getId()).toBe('conv-2'); // Newest first + expect(all[1].getId()).toBe('conv-1'); + }); + + it('should handle concurrent writes correctly', async () => { + const conversation = Conversation.create('concurrent-test'); + + // Simulate concurrent updates + const promises = Array.from({ length: 5 }, async (_, i) => { + const conv = await repository.findById('concurrent-test'); + conv?.setTitle(`Update ${i}`); + if (conv) await repository.save(conv); + }); + + await Promise.all(promises); + + const final = await repository.findById('concurrent-test'); + expect(final).toBeTruthy(); + expect(final?.getTitle()).toMatch(/Update \d/); + }); +}); +``` + +### 1.3 Recommended Approach: Both Strategies + +**Recommendation**: Use BOTH mocked unit tests AND mongodb-memory-server integration tests. + +| Aspect | Mocked Unit Tests | mongodb-memory-server Integration | +|--------|------------------|----------------------------------| +| **Speed** | Milliseconds | Seconds | +| **CI/CD** | Every commit | Pre-merge, nightly builds | +| **Coverage** | Error scenarios, edge cases | Real query behavior | +| **Dependencies** | None | In-memory MongoDB | +| **Maintenance** | High (mocks can drift) | Low (real MongoDB API) | + +**File Organization**: +``` +src/infrastructure/repositories/ + MongoConversationRepository.ts + __tests__/ + MongoConversationRepository.unit.test.ts # Mocked MongoDB client + MongoConversationRepository.integration.test.ts # mongodb-memory-server +``` + +--- + +## 2. Integration Testing + +### 2.1 Integration Test Scenarios + +**Definition**: Tests that verify interactions between multiple layers (e.g., repository + database, use case + repository). + +### 2.2 Testing with Real MongoDB Atlas Connection + +**When to Use**: Pre-production verification, smoke tests in staging environment. + +**Setup**: + +```typescript +// vitest.integration.config.ts +export default defineConfig({ + test: { + include: ['**/*.integration.test.ts'], + testTimeout: 30000, // Allow longer timeout for network calls + setupFiles: ['./tests/setup/integration.setup.ts'], + }, +}); +``` + +**Environment Configuration**: + +```typescript +// tests/setup/integration.setup.ts +import { beforeAll, afterAll } from 'vitest'; + +let testMongoClient: MongoClient; + +beforeAll(async () => { + const mongoUrl = process.env.TEST_MONGODB_URL || 'mongodb://localhost:27017'; + testMongoClient = new MongoClient(mongoUrl); + await testMongoClient.connect(); + + // Create test database + const db = testMongoClient.db('test-chat-history'); + await db.createCollection('conversations'); +}); + +afterAll(async () => { + // Clean up test database + const db = testMongoClient.db('test-chat-history'); + await db.dropDatabase(); + await testMongoClient.close(); +}); +``` + +### 2.3 Test Data Setup and Teardown + +**Best Practices**: + +1. **Isolated Test Database**: Use separate database for tests (e.g., `test-chat-history`) +2. **Transaction Rollback**: If MongoDB supports transactions, use them for test isolation +3. **Cleanup Between Tests**: Use `afterEach()` to delete test data +4. **Deterministic Data**: Use fixed timestamps, UUIDs for predictable test results + +**Example Setup**: + +```typescript +describe('Repository Integration Tests', () => { + let repository: MongoConversationRepository; + let testDbClient: MongoClient; + + beforeEach(async () => { + // Create fresh repository instance + repository = new MongoConversationRepository({ + mongoUrl: process.env.TEST_MONGODB_URL!, + databaseName: 'test-chat-history', + }); + + // Clean up previous test data + const db = testDbClient.db('test-chat-history'); + await db.collection('conversations').deleteMany({}); + }); + + afterEach(async () => { + // Ensure cleanup even if test fails + const db = testDbClient.db('test-chat-history'); + await db.collection('conversations').deleteMany({}); + }); +}); +``` + +### 2.4 Testing Concurrent Operations + +**Critical Test Case**: MongoDB repository must handle concurrent saves correctly. + +```typescript +it('should handle concurrent conversation updates without data loss', async () => { + const conversationId = 'concurrent-test'; + const conversation = Conversation.create(conversationId); + await repository.save(conversation); + + // Simulate 10 concurrent updates + const updatePromises = Array.from({ length: 10 }, async (_, i) => { + const conv = await repository.findById(conversationId); + + // Simulate user adding a message + const { Message } = require('@/domain/entities/Message'); + const { MessageRole } = require('@/domain/value-objects/MessageRole'); + const { MessageContent } = require('@/domain/value-objects/MessageContent'); + + const message = Message.create( + MessageRole.from('user'), + MessageContent.from(`Message ${i}`) + ); + + conv?.addMessage(message); + await repository.save(conv!); + }); + + await Promise.all(updatePromises); + + // Verify final state + const final = await repository.findById(conversationId); + + // Due to race conditions, we might not have all 10 messages + // This is expected behavior - we're testing that no corruption occurs + expect(final?.getMessageCount()).toBeGreaterThan(0); + expect(final?.getMessageCount()).toBeLessThanOrEqual(10); + + // All messages should be valid + const messages = final?.getMessages() || []; + messages.forEach(msg => { + expect(msg.getContent().getValue()).toMatch(/Message \d/); + }); +}); +``` + +**Note**: This test verifies that concurrent writes don't corrupt data, but may result in lost updates. If you need true concurrent write handling, implement optimistic locking with version fields. + +--- + +## 3. Use Case Testing + +### 3.1 Testing Philosophy + +**Goal**: Test use case orchestration logic WITHOUT dependencies on actual database. + +**Key Principle**: Mock the repository, test the business logic. + +### 3.2 Mocking Repository in Use Case Tests + +**Approach**: Use Vitest's `vi.fn()` to create repository mocks. + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ManageConversationUseCase } from '@/application/use-cases/ManageConversationUseCase'; +import { IConversationRepository } from '@/domain/repositories/IConversationRepository'; +import { Conversation } from '@/domain/entities/Conversation'; + +describe('ManageConversationUseCase - Unit Tests', () => { + let useCase: ManageConversationUseCase; + let mockRepository: jest.Mocked; + + beforeEach(() => { + // Create mock repository implementing the interface + mockRepository = { + findById: vi.fn(), + save: vi.fn(), + delete: vi.fn(), + findAll: vi.fn(), + count: vi.fn(), + findActive: vi.fn(), + archiveOlderThan: vi.fn(), + }; + + // Inject mocked repository into use case + useCase = new ManageConversationUseCase(mockRepository); + }); + + describe('createConversation()', () => { + it('should create new conversation and save to repository', async () => { + const conversation = await useCase.createConversation('Test Title'); + + expect(mockRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + getTitle: expect.any(Function), + }) + ); + + expect(conversation.getTitle()).toBe('Test Title'); + }); + + it('should create conversation without title if not provided', async () => { + const conversation = await useCase.createConversation(); + + expect(mockRepository.save).toHaveBeenCalledTimes(1); + expect(conversation.getTitle()).toBeUndefined(); + }); + }); + + describe('getConversation()', () => { + it('should retrieve conversation from repository', async () => { + const mockConversation = Conversation.create('test-id'); + mockRepository.findById.mockResolvedValue(mockConversation); + + const result = await useCase.getConversation('test-id'); + + expect(mockRepository.findById).toHaveBeenCalledWith('test-id'); + expect(result).toBe(mockConversation); + }); + + it('should return null when conversation not found', async () => { + mockRepository.findById.mockResolvedValue(null); + + const result = await useCase.getConversation('non-existent'); + + expect(result).toBeNull(); + }); + }); + + describe('archiveConversation()', () => { + it('should archive conversation and save to repository', async () => { + const conversation = Conversation.create('test-id'); + mockRepository.findById.mockResolvedValue(conversation); + + await useCase.archiveConversation('test-id'); + + expect(mockRepository.findById).toHaveBeenCalledWith('test-id'); + expect(mockRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + isArchived: expect.any(Function), + }) + ); + }); + + it('should throw error when conversation not found', async () => { + mockRepository.findById.mockResolvedValue(null); + + await expect(useCase.archiveConversation('non-existent')).rejects.toThrow( + 'Conversation not found' + ); + }); + }); + + describe('listConversations()', () => { + it('should retrieve paginated conversations from repository', async () => { + const mockConversations = [ + Conversation.create('conv-1'), + Conversation.create('conv-2'), + ]; + + mockRepository.findAll.mockResolvedValue(mockConversations); + + const result = await useCase.listConversations({ + limit: 50, + offset: 0, + }); + + expect(mockRepository.findAll).toHaveBeenCalledWith({ + limit: 50, + offset: 0, + status: 'active', + }); + + expect(result).toHaveLength(2); + }); + + it('should include archived conversations when requested', async () => { + const mockConversations = [ + Conversation.create('conv-1'), + Conversation.create('conv-2'), + ]; + mockConversations[1].archive(); + + mockRepository.findAll.mockResolvedValue(mockConversations); + + await useCase.listConversations({ + includeArchived: true, + }); + + expect(mockRepository.findAll).toHaveBeenCalledWith({ + limit: undefined, + offset: undefined, + status: undefined, // No status filter when includeArchived + }); + }); + }); +}); +``` + +### 3.3 Testing ListConversationsUseCase with Pagination + +**New Use Case to Test**: + +```typescript +describe('ListConversationsUseCase', () => { + let useCase: ListConversationsUseCase; + let mockRepository: jest.Mocked; + + beforeEach(() => { + mockRepository = createMockRepository(); + useCase = new ListConversationsUseCase(mockRepository); + }); + + it('should apply default pagination (50 conversations)', async () => { + mockRepository.findAll.mockResolvedValue([]); + + await useCase.execute({}); + + expect(mockRepository.findAll).toHaveBeenCalledWith({ + limit: 50, + offset: 0, + status: 'active', + }); + }); + + it('should respect custom pagination limits', async () => { + await useCase.execute({ limit: 100, offset: 50 }); + + expect(mockRepository.findAll).toHaveBeenCalledWith({ + limit: 100, + offset: 50, + status: 'active', + }); + }); + + it('should map conversations to DTOs correctly', async () => { + const mockConversations = [ + Conversation.restore( + 'conv-1', + [], + ConversationStatus.ACTIVE, + new Date('2025-01-01'), + new Date('2025-01-02'), + 'Test Conversation' + ), + ]; + + mockRepository.findAll.mockResolvedValue(mockConversations); + + const result = await useCase.execute({}); + + expect(result[0]).toMatchObject({ + id: 'conv-1', + title: 'Test Conversation', + messageCount: 0, + status: 'active', + }); + }); +}); +``` + +### 3.4 Testing Error Propagation + +**Critical Test**: Verify that repository errors propagate correctly through use cases. + +```typescript +describe('Error Propagation from Repository to Use Case', () => { + it('should propagate repository connection errors', async () => { + mockRepository.findById.mockRejectedValue( + new Error('MongoDB connection timeout') + ); + + await expect(useCase.getConversation('test-id')).rejects.toThrow( + 'MongoDB connection timeout' + ); + }); + + it('should wrap repository errors in domain exceptions', async () => { + mockRepository.save.mockRejectedValue( + new Error('Duplicate key error') + ); + + const conversation = Conversation.create(); + + await expect(useCase.createConversation()).rejects.toThrow( + ConversationError + ); + }); +}); +``` + +--- + +## 4. API Route Testing + +### 4.1 Testing Next.js API Routes + +**Goal**: Test API routes as thin controllers delegating to use cases. + +**Approach**: Mock use cases, test HTTP request/response handling. + +### 4.2 Testing `/api/conversations/list` Endpoint + +**Example Test Structure**: + +```typescript +import { NextRequest } from 'next/server'; +import { GET } from '@/app/api/conversations/list/route'; +import { DependencyContainer } from '@/infrastructure/config/DependencyContainer'; + +// Mock DependencyContainer +vi.mock('@/infrastructure/config/DependencyContainer'); + +describe('GET /api/conversations/list', () => { + let mockListConversationsUseCase: any; + + beforeEach(() => { + mockListConversationsUseCase = { + execute: vi.fn(), + }; + + // Mock DependencyContainer to return mocked use case + vi.mocked(DependencyContainer.getInstance).mockReturnValue({ + getListConversationsUseCase: () => mockListConversationsUseCase, + } as any); + }); + + it('should return 200 with conversation list', async () => { + const mockConversations = [ + { id: 'conv-1', title: 'Conversation 1', messageCount: 5 }, + { id: 'conv-2', title: 'Conversation 2', messageCount: 3 }, + ]; + + mockListConversationsUseCase.execute.mockResolvedValue(mockConversations); + + const request = new NextRequest('http://localhost:3000/api/conversations/list'); + const response = await GET(request); + + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toEqual(mockConversations); + }); + + it('should handle pagination query parameters', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/conversations/list?limit=100&offset=50' + ); + + mockListConversationsUseCase.execute.mockResolvedValue([]); + + await GET(request); + + expect(mockListConversationsUseCase.execute).toHaveBeenCalledWith({ + limit: 100, + offset: 50, + }); + }); + + it('should return 500 when use case throws error', async () => { + mockListConversationsUseCase.execute.mockRejectedValue( + new Error('Database connection failed') + ); + + const request = new NextRequest('http://localhost:3000/api/conversations/list'); + const response = await GET(request); + + expect(response.status).toBe(500); + + const data = await response.json(); + expect(data.error).toBe('Internal server error'); + }); + + it('should return 503 when database is unavailable', async () => { + mockListConversationsUseCase.execute.mockRejectedValue( + new Error('MONGODB_URL not configured') + ); + + const request = new NextRequest('http://localhost:3000/api/conversations/list'); + const response = await GET(request); + + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ + error: expect.stringContaining('not configured'), + }); + }); +}); +``` + +### 4.3 Testing Database State in API Tests + +**Recommendation**: Do NOT use real database in API route tests. Mock at the use case layer. + +**Why**: +- API route tests should be fast (unit tests) +- Testing database connection is the job of integration tests +- Mocking use cases allows testing HTTP-specific concerns (headers, status codes, error handling) + +**What to Test in API Routes**: +1. HTTP status codes (200, 400, 500, 503) +2. Request parsing (query params, body, headers) +3. Response formatting (JSON structure, headers) +4. Error translation (domain errors → HTTP errors) +5. Authentication/Authorization (when implemented) + +--- + +## 5. Test Data Management + +### 5.1 Fixtures vs Factories + +**Recommendation**: Use **Test Data Builders (Factories)** instead of static fixtures. + +**Why**: +- Flexibility: Customize test data per test case +- Maintainability: Single source of truth for test data creation +- Readability: Expressive builder pattern + +### 5.2 Test Data Builder Pattern + +**Example Implementation**: + +```typescript +// tests/builders/ConversationBuilder.ts +import { Conversation, ConversationStatus } from '@/domain/entities/Conversation'; +import { Message } from '@/domain/entities/Message'; +import { MessageRole } from '@/domain/value-objects/MessageRole'; +import { MessageContent } from '@/domain/value-objects/MessageContent'; + +export class ConversationBuilder { + private id?: string; + private messages: Message[] = []; + private status: ConversationStatus = ConversationStatus.ACTIVE; + private title?: string; + private createdAt: Date = new Date('2025-01-01T00:00:00Z'); + private updatedAt: Date = new Date('2025-01-01T00:00:00Z'); + + static aConversation(): ConversationBuilder { + return new ConversationBuilder(); + } + + withId(id: string): this { + this.id = id; + return this; + } + + withTitle(title: string): this { + this.title = title; + return this; + } + + withStatus(status: ConversationStatus): this { + this.status = status; + return this; + } + + withMessages(count: number): this { + for (let i = 0; i < count; i++) { + const role = i % 2 === 0 ? 'user' : 'assistant'; + const message = Message.create( + MessageRole.from(role), + MessageContent.from(`Message ${i}`) + ); + this.messages.push(message); + } + return this; + } + + withUserMessage(content: string): this { + this.messages.push( + Message.create( + MessageRole.from('user'), + MessageContent.from(content) + ) + ); + return this; + } + + withAssistantMessage(content: string): this { + this.messages.push( + Message.create( + MessageRole.from('assistant'), + MessageContent.from(content) + ) + ); + return this; + } + + withCreatedAt(date: Date): this { + this.createdAt = date; + return this; + } + + withUpdatedAt(date: Date): this { + this.updatedAt = date; + return this; + } + + archived(): this { + this.status = ConversationStatus.ARCHIVED; + return this; + } + + build(): Conversation { + if (this.messages.length === 0 && !this.id) { + return Conversation.create(); + } + + return Conversation.restore( + this.id || 'test-conversation', + this.messages, + this.status, + this.createdAt, + this.updatedAt, + this.title + ); + } +} +``` + +**Usage in Tests**: + +```typescript +import { ConversationBuilder } from '@/tests/builders/ConversationBuilder'; + +describe('Repository Tests with Builder', () => { + it('should save conversation with messages', async () => { + const conversation = ConversationBuilder.aConversation() + .withId('conv-123') + .withTitle('Test Conversation') + .withMessages(5) + .build(); + + await repository.save(conversation); + + const retrieved = await repository.findById('conv-123'); + expect(retrieved?.getMessageCount()).toBe(5); + }); + + it('should filter archived conversations', async () => { + const activeConv = ConversationBuilder.aConversation() + .withId('active') + .build(); + + const archivedConv = ConversationBuilder.aConversation() + .withId('archived') + .archived() + .build(); + + await repository.save(activeConv); + await repository.save(archivedConv); + + const active = await repository.findAll({ status: 'active' }); + expect(active).toHaveLength(1); + expect(active[0].getId()).toBe('active'); + }); +}); +``` + +### 5.3 Seeding Test Database + +**For Integration Tests**: Create seed data scripts. + +```typescript +// tests/seeds/conversationSeeds.ts +export async function seedConversations( + repository: IConversationRepository, + count: number = 10 +): Promise { + const conversations = Array.from({ length: count }, (_, i) => + ConversationBuilder.aConversation() + .withId(`seed-conv-${i}`) + .withTitle(`Seeded Conversation ${i}`) + .withMessages(Math.floor(Math.random() * 10)) + .build() + ); + + for (const conv of conversations) { + await repository.save(conv); + } + + return conversations; +} +``` + +**Usage**: + +```typescript +describe('Pagination Integration Tests', () => { + beforeEach(async () => { + await seedConversations(repository, 100); + }); + + it('should paginate through 100 conversations', async () => { + const page1 = await repository.findAll({ limit: 50, offset: 0 }); + const page2 = await repository.findAll({ limit: 50, offset: 50 }); + + expect(page1).toHaveLength(50); + expect(page2).toHaveLength(50); + }); +}); +``` + +### 5.4 Cleaning Up Between Tests + +**Best Practices**: + +```typescript +describe('Repository Tests', () => { + afterEach(async () => { + // Option 1: Delete all test data + const db = mongoClient.db('test-chat-history'); + await db.collection('conversations').deleteMany({}); + }); + + // Option 2: Track created IDs and delete only those + const createdIds: string[] = []; + + afterEach(async () => { + for (const id of createdIds) { + await repository.delete(id); + } + createdIds.length = 0; + }); +}); +``` + +--- + +## 6. CI/CD Considerations + +### 6.1 Testing Strategy for CI/CD Pipelines + +**Recommended Approach**: Multi-stage testing pipeline. + +```yaml +# .github/workflows/test.yml +name: Test Pipeline + +on: [push, pull_request] + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run unit tests (mocked MongoDB) + run: yarn test --run + env: + NODE_ENV: test + + - name: Upload coverage + uses: codecov/codecov-action@v3 + + integration-tests: + runs-on: ubuntu-latest + services: + mongodb: + image: mongo:7.0 + ports: + - 27017:27017 + options: >- + --health-cmd "mongosh --eval 'db.adminCommand({ping: 1})'" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run integration tests + run: yarn test:integration + env: + NODE_ENV: test + TEST_MONGODB_URL: mongodb://localhost:27017 + + e2e-tests: + runs-on: ubuntu-latest + needs: [unit-tests, integration-tests] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run E2E tests against test cluster + run: yarn test:e2e + env: + MONGODB_URL: ${{ secrets.TEST_MONGODB_ATLAS_URL }} + DATABASE_NAME: test-chat-history +``` + +### 6.2 MongoDB Atlas Test Cluster vs Local MongoDB + +**Recommendation**: Use both depending on test type. + +| Test Type | Environment | Why | +|-----------|------------|-----| +| Unit Tests | No MongoDB (mocked) | Fast, no external dependencies | +| Integration Tests (CI) | Local MongoDB (Docker service) | Faster than Atlas, no secrets needed | +| Integration Tests (Local Dev) | mongodb-memory-server | Zero configuration, instant setup | +| E2E Tests | MongoDB Atlas Test Cluster | Real production environment | +| Smoke Tests (Pre-deploy) | MongoDB Atlas Staging | Verify against actual deployment | + +### 6.3 Environment Variable Management + +**Setup**: + +```typescript +// tests/config/testEnv.ts +export const getTestMongoUrl = (): string => { + // Priority: + // 1. Environment variable (CI/CD) + // 2. Local MongoDB (Docker) + // 3. mongodb-memory-server (auto-start) + return ( + process.env.TEST_MONGODB_URL || + process.env.MONGODB_URL || + 'mongodb://localhost:27017' + ); +}; + +export const getTestDatabaseName = (): string => { + return process.env.TEST_DATABASE_NAME || 'test-chat-history'; +}; +``` + +**Usage**: + +```typescript +describe('Integration Tests', () => { + beforeAll(async () => { + const mongoUrl = getTestMongoUrl(); + const dbName = getTestDatabaseName(); + + repository = new MongoConversationRepository({ + mongoUrl, + databaseName: dbName, + }); + }); +}); +``` + +### 6.4 Performance Considerations + +**Best Practices**: + +1. **Parallelize Unit Tests**: Run all unit tests in parallel (Vitest default) +2. **Sequential Integration Tests**: Run integration tests sequentially to avoid database conflicts +3. **Cleanup Strategy**: Delete only necessary data, not entire collections +4. **Connection Pooling**: Reuse MongoDB connections across tests +5. **Test Timeout**: Set reasonable timeouts (5s for unit, 30s for integration) + +**Vitest Configuration**: + +```typescript +// vitest.config.ts +export default defineConfig({ + test: { + // Unit tests - parallel + pool: 'threads', + poolOptions: { + threads: { + singleThread: false, + }, + }, + }, +}); + +// vitest.integration.config.ts +export default defineConfig({ + test: { + // Integration tests - sequential + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, + }, + }, + }, +}); +``` + +--- + +## 7. Recommended Test Structure + +### 7.1 Directory Organization + +``` +src/ + domain/ + entities/ + Conversation.ts + __tests__/ + Conversation.test.ts + repositories/ + IConversationRepository.ts + + application/ + use-cases/ + ManageConversationUseCase.ts + ListConversationsUseCase.ts + __tests__/ + ManageConversationUseCase.test.ts + ListConversationsUseCase.test.ts + + infrastructure/ + repositories/ + MongoConversationRepository.ts + InMemoryConversationRepository.ts + __tests__/ + MongoConversationRepository.unit.test.ts + MongoConversationRepository.integration.test.ts + InMemoryConversationRepository.test.ts + +app/ + api/ + conversations/ + route.ts + list/ + route.ts + __tests__/ + conversations.test.ts + list.test.ts + +tests/ + builders/ + ConversationBuilder.ts + MessageBuilder.ts + fixtures/ + conversationFixtures.ts + seeds/ + conversationSeeds.ts + setup/ + integration.setup.ts + testEnv.ts + helpers/ + mockRepository.ts + mockMongoClient.ts +``` + +### 7.2 Naming Conventions + +| File Type | Naming Convention | Example | +|-----------|------------------|---------| +| Unit Tests | `*.test.ts` | `Conversation.test.ts` | +| Integration Tests | `*.integration.test.ts` | `MongoConversationRepository.integration.test.ts` | +| E2E Tests | `*.e2e.test.ts` | `conversationFlow.e2e.test.ts` | +| Test Builders | `*Builder.ts` | `ConversationBuilder.ts` | +| Test Fixtures | `*Fixtures.ts` | `conversationFixtures.ts` | +| Mock Helpers | `mock*.ts` | `mockRepository.ts` | + +### 7.3 Test Coverage Goals + +| Layer | Coverage Target | Priority | +|-------|----------------|----------| +| Domain Entities | 95%+ | Critical | +| Domain Value Objects | 95%+ | Critical | +| Application Use Cases | 90%+ | High | +| Infrastructure Repositories | 80%+ | High | +| API Routes | 70%+ | Medium | +| Mappers/DTOs | 80%+ | Medium | + +**Note**: Coverage is a metric, not a goal. Focus on meaningful tests that verify behavior, not implementation details. + +--- + +## 8. Tools & Dependencies + +### 8.1 Required Dependencies + +```json +{ + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@vitejs/plugin-react": "^5.0.4", + "@vitest/coverage-v8": "^3.2.4", + "@vitest/ui": "^3.2.4", + "jsdom": "^27.0.0", + "mongodb-memory-server": "^10.1.2", // NEW + "vite": "^7.1.9", + "vitest": "^3.2.4" + } +} +``` + +### 8.2 Installation + +```bash +yarn add -D mongodb-memory-server +``` + +### 8.3 Vitest Configuration Files + +**vitest.config.ts** (Unit Tests): +```typescript +export default defineConfig({ + test: { + environment: 'node', + globals: true, + pool: 'threads', + include: ['**/*.test.ts'], + exclude: ['**/*.integration.test.ts', '**/*.e2e.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['**/__tests__/**', '**/*.test.ts'], + thresholds: { + statements: 80, + branches: 75, + functions: 80, + lines: 80, + }, + }, + }, +}); +``` + +**vitest.integration.config.ts** (Integration Tests): +```typescript +export default defineConfig({ + test: { + environment: 'node', + globals: true, + pool: 'forks', + poolOptions: { + forks: { singleFork: true }, + }, + include: ['**/*.integration.test.ts'], + testTimeout: 30000, + setupFiles: ['./tests/setup/integration.setup.ts'], + }, +}); +``` + +### 8.4 Package.json Scripts + +```json +{ + "scripts": { + "test": "vitest", + "test:unit": "vitest --config vitest.config.ts", + "test:integration": "vitest --config vitest.integration.config.ts", + "test:e2e": "vitest --config vitest.e2e.config.ts", + "test:coverage": "vitest --coverage", + "test:ui": "vitest --ui", + "test:watch": "vitest --watch" + } +} +``` + +--- + +## 9. Implementation Roadmap + +### Phase 1: Foundation (Week 1) + +**Goal**: Set up testing infrastructure and test MongoDB repository. + +1. **Install Dependencies** + ```bash + yarn add -D mongodb-memory-server + ``` + +2. **Create Test Builders** + - `ConversationBuilder.ts` + - `MessageBuilder.ts` + +3. **Implement MongoDB Repository** + - `MongoConversationRepository.ts` + - Implement all `IConversationRepository` methods + +4. **Write Unit Tests (Mocked MongoDB)** + - `MongoConversationRepository.unit.test.ts` + - Test all CRUD operations + - Test error scenarios + - Test entity restoration + +5. **Write Integration Tests (mongodb-memory-server)** + - `MongoConversationRepository.integration.test.ts` + - Test pagination + - Test concurrent operations + - Test real MongoDB queries + +**Success Criteria**: +- All repository methods implemented +- 80%+ test coverage on repository +- All tests passing + +### Phase 2: Use Case Testing (Week 2) + +**Goal**: Test application layer with mocked repositories. + +1. **Implement New Use Cases** + - `ListConversationsUseCase.ts` + +2. **Write Use Case Tests** + - `ManageConversationUseCase.test.ts` + - `ListConversationsUseCase.test.ts` + - Mock repository, test orchestration logic + +3. **Test Error Propagation** + - Verify repository errors propagate correctly + - Test domain exception handling + +**Success Criteria**: +- All use cases tested with mocked repository +- 90%+ coverage on use cases +- Error scenarios covered + +### Phase 3: API Route Testing (Week 2-3) + +**Goal**: Test web layer with mocked use cases. + +1. **Implement API Routes** + - `GET /api/conversations/list` + - `DELETE /api/conversations/[id]` + +2. **Write API Route Tests** + - Mock use cases + - Test HTTP status codes + - Test request parsing + - Test error responses + +**Success Criteria**: +- All API routes tested +- 70%+ coverage on API routes +- HTTP error handling verified + +### Phase 4: CI/CD Integration (Week 3) + +**Goal**: Integrate tests into CI/CD pipeline. + +1. **Configure GitHub Actions** + - Unit tests (every commit) + - Integration tests (pre-merge) + - E2E tests (staging deployment) + +2. **Setup Test MongoDB Atlas Cluster** + - Create dedicated test database + - Configure connection string as secret + +3. **Performance Optimization** + - Parallelize unit tests + - Optimize cleanup strategies + - Monitor test execution time + +**Success Criteria**: +- CI/CD pipeline running all tests +- Tests complete in < 5 minutes +- No flaky tests + +### Phase 5: Maintenance & Monitoring (Ongoing) + +**Goal**: Maintain high test quality over time. + +1. **Code Coverage Monitoring** + - Integrate Codecov or similar + - Set coverage thresholds + - Block PRs below threshold + +2. **Test Performance Monitoring** + - Track test execution time + - Identify slow tests + - Optimize or parallelize + +3. **Test Quality Reviews** + - Review test failures + - Update tests when APIs change + - Refactor brittle tests + +**Success Criteria**: +- Maintaining 80%+ overall coverage +- Zero flaky tests +- Test suite execution < 5 minutes + +--- + +## 10. Key Recommendations Summary + +### 10.1 Critical Decisions + +1. **Use BOTH Mocked Unit Tests AND Integration Tests** + - Mocked: Fast feedback, CI/CD + - Integration: Real behavior verification + +2. **Use mongodb-memory-server for Local Integration Tests** + - Zero configuration + - Fast setup + - Isolated test environment + +3. **Use Test Data Builders Instead of Fixtures** + - More flexible + - Easier to maintain + - Expressive test code + +4. **Mock Repositories in Use Case Tests** + - Test business logic in isolation + - Fast test execution + - No database dependencies + +5. **Mock Use Cases in API Route Tests** + - Test HTTP concerns separately + - Fast test execution + - Clear separation of concerns + +### 10.2 Testing Anti-Patterns to Avoid + +❌ **Don't**: Use real MongoDB in unit tests +✅ **Do**: Mock MongoDB client or use mongodb-memory-server + +❌ **Don't**: Use real database in use case tests +✅ **Do**: Mock repository interface + +❌ **Don't**: Use static fixtures for test data +✅ **Do**: Use test data builders + +❌ **Don't**: Test implementation details +✅ **Do**: Test behavior and contracts + +❌ **Don't**: Share test database between tests +✅ **Do**: Clean up between tests or use transactions + +❌ **Don't**: Ignore test failures in CI/CD +✅ **Do**: Fail builds on test failures + +❌ **Don't**: Skip error scenario tests +✅ **Do**: Comprehensively test error handling + +### 10.3 Quick Reference + +**Test Type** | **What to Test** | **What to Mock** | **Speed** +---|---|---|--- +Domain Entity Unit Tests | Business rules, invariants | Nothing | Milliseconds +Repository Unit Tests | Data mapping, error translation | MongoDB client | Milliseconds +Repository Integration Tests | Real queries, pagination | Nothing | Seconds +Use Case Unit Tests | Orchestration logic | Repository | Milliseconds +API Route Tests | HTTP handling | Use cases | Milliseconds +E2E Tests | Full user flows | Nothing (real services) | Minutes + +--- + +## Next Steps + +1. **Review this strategy with Fran** - Get approval on approach +2. **Install mongodb-memory-server** - `yarn add -D mongodb-memory-server` +3. **Create test builders** - Start with `ConversationBuilder` +4. **Implement MongoDB repository** - Focus on `save()` and `findById()` first +5. **Write first tests** - Start with mocked unit tests +6. **Iterate** - Gradually add integration tests and use case tests + +--- + +## Questions for Fran + +Before implementation, please clarify: + +1. **MongoDB Atlas Setup**: Do you already have a test database cluster, or should we create one? +2. **CI/CD Platform**: Are you using GitHub Actions, GitLab CI, or another platform? +3. **Test Coverage Goals**: Are you comfortable with 80% overall coverage, or do you want higher? +4. **Test Execution Time**: What's your acceptable test suite execution time (current target: < 5 minutes)? +5. **MongoDB Version**: Which MongoDB version are you targeting (e.g., 7.0, 6.0)? + +--- + +**End of Testing Strategy Document** + +This document will be continuously updated as we implement and discover new testing patterns specific to your hexagonal architecture. diff --git a/.claude/doc/chat_history/backend.md b/.claude/doc/chat_history/backend.md new file mode 100644 index 0000000..dc11f19 --- /dev/null +++ b/.claude/doc/chat_history/backend.md @@ -0,0 +1,1898 @@ +# MongoDB Repository Implementation - Architectural Guidance + +## Executive Summary + +This document provides comprehensive architectural guidance for implementing a MongoDB repository adapter for the conversation persistence layer. The implementation follows hexagonal architecture principles, maintaining clean separation between domain logic and infrastructure concerns. + +**Key Decisions:** +- Use native MongoDB Node.js driver (NOT Mongoose) +- MongoDB Atlas cloud deployment +- Environment-based repository selection (MongoDB vs InMemory) +- Connection pooling with singleton client +- Robust error handling with graceful degradation +- Document schema optimized for query patterns + +--- + +## 1. MongoDB Connection Management + +### 1.1 Singleton Pattern with Connection Pooling + +**Recommendation: Create a MongoDBClient singleton wrapper** + +The MongoDB Node.js driver provides built-in connection pooling. You should implement a singleton wrapper that: + +1. **Initializes once** on first access +2. **Reuses the connection pool** across all repository instances +3. **Provides health check** capabilities +4. **Handles graceful shutdown** + +**Architecture Pattern:** + +``` +src/infrastructure/adapters/database/ + MongoDBClient.ts # Singleton connection manager + MongoDBHealthCheck.ts # Health check implementation + MongoDBConversationRepository.ts # Repository implementation + mappers/ + ConversationDocumentMapper.ts # Entity <-> Document mapping +``` + +**Key Implementation Points:** + +```typescript +// MongoDBClient.ts conceptual structure +class MongoDBClient { + private static instance: MongoDBClient | null = null; + private client: MongoClient | null = null; + private db: Db | null = null; + + private constructor() {} // Private constructor + + static async getInstance(): Promise { + if (!instance) { + instance = new MongoDBClient(); + await instance.connect(); + } + return instance; + } + + async connect(): Promise { + // Connection with retry logic + // Configuration from env variables + } + + getDatabase(): Db { + // Returns database instance + } + + async disconnect(): Promise { + // Graceful shutdown + } + + async healthCheck(): Promise { + // Ping database to verify connection + } +} +``` + +**Connection Configuration:** + +```typescript +// Recommended connection options +const mongoClientOptions = { + maxPoolSize: 10, // Max connections in pool + minPoolSize: 2, // Min connections maintained + maxIdleTimeMS: 60000, // Close idle connections after 60s + serverSelectionTimeoutMS: 5000, // Timeout for server selection + socketTimeoutMS: 45000, // Socket timeout + retryWrites: true, // Retry failed writes + retryReads: true, // Retry failed reads +}; +``` + +### 1.2 Environment Variables + +**Required Environment Variables:** + +```bash +MONGODB_URL=mongodb+srv://username:password@cluster.mongodb.net/?retryWrites=true&w=majority +DATABASE_NAME=ai_chat_app +REPOSITORY_TYPE=mongodb # or "inmemory" +``` + +**Validation Strategy:** + +- Validate on application startup +- Throw descriptive errors if missing +- Provide clear fallback messaging + +### 1.3 Reconnection Strategy + +**Automatic Reconnection:** + +The MongoDB driver handles reconnection automatically with `retryWrites` and `retryReads` options. However, you should implement: + +1. **Initial Connection Retry**: Retry connection on startup (3-5 attempts with exponential backoff) +2. **Connection Event Monitoring**: Log connection events for observability +3. **Circuit Breaker Pattern** (optional): Temporarily fail fast if connection is repeatedly failing + +**Event Monitoring Example:** + +```typescript +client.on('serverHeartbeatFailed', (event) => { + console.error('MongoDB heartbeat failed:', event); +}); + +client.on('serverHeartbeatSucceeded', (event) => { + console.log('MongoDB heartbeat succeeded'); +}); + +client.on('connectionPoolCleared', (event) => { + console.warn('MongoDB connection pool cleared:', event); +}); +``` + +### 1.4 Error Handling for Atlas Connection Failures + +**Three-Tier Error Handling Strategy:** + +**Tier 1: Initialization Errors (Startup)** +- If MongoDB connection fails on startup → Log warning and fallback to InMemory +- Application continues to function with transient storage +- Expose health check endpoint to monitor database status + +**Tier 2: Runtime Errors (Repository Operations)** +- Wrap all database operations in try-catch +- Classify errors: Network, Authentication, Query, Document validation +- Return domain-specific errors (not MongoDB errors) + +**Tier 3: Graceful Degradation** +- If MongoDB becomes unavailable during runtime: + - Log error with full context + - Optionally: Switch to InMemory repository (requires state migration - complex) + - OR: Return error to application layer to handle + +**Error Classification:** + +```typescript +enum MongoDBErrorType { + CONNECTION_FAILED = 'connection_failed', + AUTHENTICATION_FAILED = 'authentication_failed', + NETWORK_TIMEOUT = 'network_timeout', + QUERY_FAILED = 'query_failed', + DOCUMENT_VALIDATION_FAILED = 'document_validation_failed', + UNKNOWN = 'unknown' +} + +// Map MongoDB errors to domain errors +class RepositoryError extends Error { + constructor( + message: string, + public readonly type: MongoDBErrorType, + public readonly originalError?: Error + ) { + super(message); + } +} +``` + +--- + +## 2. Document Schema Design + +### 2.1 MongoDB Collection Structure + +**Collection Name:** `conversations` + +**Document Schema:** + +```typescript +interface ConversationDocument { + _id: string; // Conversation.id (use custom UUID, not ObjectId) + title?: string; // Auto-generated from first message + status: string; // 'active' | 'waiting_for_response' | 'completed' | 'archived' + messages: MessageDocument[]; // Embedded messages array + metadata: Record; // Flexible metadata storage + createdAt: Date; // Date object (NOT ISO string) + updatedAt: Date; // Date object (NOT ISO string) + + // Optional fields for future features + userId?: string; // For multi-user support + tags?: string[]; // For categorization +} + +interface MessageDocument { + id: string; // Message.id + role: string; // 'user' | 'assistant' | 'system' | 'tool' + content: string; // Message content + timestamp: Date; // Message timestamp + toolInvocations?: ToolInvocationDocument[]; // Optional tool calls + attachments?: AttachmentDocument[]; // Optional attachments + metadata?: Record; // Optional metadata +} + +interface ToolInvocationDocument { + callId: string; + toolName: string; + args: Record; + state: string; // 'pending' | 'executing' | 'completed' | 'failed' + result?: any; + error?: string; +} + +interface AttachmentDocument { + name: string; + contentType: string; + url: string; +} +``` + +**Why Embedded Messages (Not References)?** + +✅ **Pros:** +- Single query to fetch entire conversation +- Atomic updates for conversation + messages +- Better performance for read-heavy workload +- Simpler transaction management +- Matches aggregate root pattern (Conversation is the aggregate) + +❌ **Cons:** +- Document size limit (16MB - unlikely to hit with 1000 message limit) +- Cannot query messages independently across conversations + +**Decision:** Use embedded messages because Conversation is an aggregate root in DDD, and messages should never exist without their parent conversation. + +### 2.2 Date Handling Strategy + +**Recommendation: Use MongoDB Date objects (NOT ISO strings)** + +**Rationale:** +1. Native MongoDB date queries (`$gte`, `$lte`, `$sort`) +2. Automatic timezone handling +3. Better index performance +4. Simpler query syntax + +**Mapping Strategy:** + +```typescript +// Entity -> Document (saving) +const document: ConversationDocument = { + _id: conversation.getId(), + createdAt: conversation.getCreatedAt(), // Keep as Date + updatedAt: conversation.getUpdatedAt(), // Keep as Date + // ... +}; + +// Document -> Entity (restoration) +const entity = Conversation.restore( + document._id, + messages, + document.status as ConversationStatus, + document.createdAt, // MongoDB Date -> JS Date (automatic) + document.updatedAt, // MongoDB Date -> JS Date (automatic) + document.title +); +``` + +**Important Notes:** +- MongoDB stores dates in UTC +- JavaScript Date objects handle timezone conversion automatically +- Always use `new Date()` for timestamps, never `Date.now()` (which returns number) + +### 2.3 Index Strategy + +**Recommended Indexes:** + +```typescript +// In MongoDBConversationRepository initialization +async createIndexes() { + const collection = this.getCollection(); + + // Primary lookup by _id (automatic index, no need to create) + + // Index 1: Sort by updatedAt (for findAll queries) + await collection.createIndex( + { updatedAt: -1 }, + { name: 'idx_updatedAt_desc' } + ); + + // Index 2: Filter by status + sort by updatedAt (for findActive, findAll with status filter) + await collection.createIndex( + { status: 1, updatedAt: -1 }, + { name: 'idx_status_updatedAt' } + ); + + // Index 3: Optional - userId for multi-user support + await collection.createIndex( + { userId: 1, updatedAt: -1 }, + { name: 'idx_userId_updatedAt', sparse: true } + ); + + // Index 4: Compound index for archive operations + await collection.createIndex( + { status: 1, updatedAt: 1 }, + { name: 'idx_status_updatedAt_asc' } + ); +} +``` + +**Index Performance Notes:** + +- **Don't over-index**: Each index slows down writes +- **MongoDB Atlas auto-suggests indexes**: Monitor in Performance Advisor +- **Consider covering indexes** for frequently queried fields +- **Use sparse indexes** for optional fields (like userId) + +**Query Optimization Tips:** + +1. Use `.explain()` to analyze query performance +2. Monitor slow queries in Atlas Performance Advisor +3. Use projection to exclude `messages` array when only metadata is needed +4. Consider TTL index if conversations auto-expire after X days + +--- + +## 3. Repository Implementation Pattern + +### 3.1 Mapper Pattern (Entity ↔ Document) + +**Architectural Pattern: Dedicated Mapper Class** + +Create a dedicated mapper to isolate serialization/deserialization logic: + +``` +src/infrastructure/adapters/database/mappers/ + ConversationDocumentMapper.ts +``` + +**Responsibilities:** + +1. **`toDocument(entity: Conversation): ConversationDocument`** + - Convert entity to MongoDB document + - Handle nested entities (Message, ToolInvocation) + - Extract metadata from Map to object + +2. **`toEntity(document: ConversationDocument): Conversation`** + - Restore entity from document + - Reconstruct value objects (MessageRole, MessageContent, ToolName) + - Handle missing optional fields + - Validate document structure + +**Implementation Strategy:** + +```typescript +class ConversationDocumentMapper { + static toDocument(conversation: Conversation): ConversationDocument { + const conversationData = conversation.toObject(); + + return { + _id: conversationData.id, + title: conversationData.title, + status: conversationData.status, + messages: conversationData.messages.map(msg => ({ + id: msg.id, + role: msg.role, + content: msg.content, + timestamp: new Date(msg.timestamp), // ISO string -> Date + toolInvocations: msg.toolInvocations?.map(ti => ({ + callId: ti.callId, + toolName: ti.toolName, + args: ti.args, + state: ti.state, + result: ti.result, + error: ti.error, + })), + attachments: msg.attachments?.map(att => ({ + name: att.name, + contentType: att.contentType, + url: att.url, + })), + metadata: msg.metadata, + })), + metadata: {}, // Extract from conversation metadata if needed + createdAt: conversationData.createdAt, + updatedAt: conversationData.updatedAt, + }; + } + + static toEntity(document: ConversationDocument): Conversation { + // Reconstruct Message entities + const messages = document.messages.map(msgDoc => { + const role = MessageRole.from(msgDoc.role); + const content = MessageContent.from(msgDoc.content); + + // Reconstruct tool invocations + const toolInvocations = (msgDoc.toolInvocations || []).map(tiDoc => { + const invocation = ToolInvocation.create( + tiDoc.callId, + ToolName.from(tiDoc.toolName), + tiDoc.args + ); + + // Restore state + if (tiDoc.state === 'completed') { + invocation.markAsExecuting(); + invocation.complete(tiDoc.result); + } else if (tiDoc.state === 'failed') { + invocation.markAsExecuting(); + invocation.fail(new Error(tiDoc.error || 'Unknown error')); + } else if (tiDoc.state === 'executing') { + invocation.markAsExecuting(); + } + + return invocation; + }); + + // Reconstruct message + const message = Message.createWithId( + msgDoc.id, + role, + content, + [], // attachments - implement when needed + toolInvocations + ); + + // Restore metadata + if (msgDoc.metadata) { + Object.entries(msgDoc.metadata).forEach(([key, value]) => { + message.addMetadata(key, value); + }); + } + + return message; + }); + + // Restore conversation entity + return Conversation.restore( + document._id, + messages, + document.status as ConversationStatus, + document.createdAt, + document.updatedAt, + document.title + ); + } +} +``` + +**Key Notes:** + +1. **Rely on `Conversation.toObject()`**: The domain entity already provides serialization +2. **Handle Value Objects**: Must reconstruct MessageRole, MessageContent, ToolName using `.from()` methods +3. **Restore Tool Invocation State**: Must manually replay state transitions +4. **Defensive Programming**: Validate document structure before mapping (handle missing fields) +5. **Error Handling**: Wrap mapping in try-catch and throw descriptive errors + +### 3.2 Entity Restoration Best Practices + +**Critical: Use `Conversation.restore()` Static Factory Method** + +The `Conversation` entity provides a `restore()` method specifically for rebuilding from persistence: + +```typescript +static restore( + id: string, + messages: Message[], + status: ConversationStatus, + createdAt: Date, + updatedAt: Date, + title?: string +): Conversation +``` + +**Why This Matters:** + +- Bypasses domain validation rules (e.g., "can't add message to archived conversation") +- Preserves historical state +- Sets readonly properties (createdAt, updatedAt) + +**Common Mistakes to Avoid:** + +❌ **WRONG**: `const conv = Conversation.create(); conv.addMessage(...)` +- This triggers validation rules +- Loses historical timestamps + +✅ **CORRECT**: `Conversation.restore(id, messages, status, createdAt, updatedAt, title)` +- Directly restores state +- Preserves all historical data + +### 3.3 Transaction Handling + +**MongoDB Transaction Support: Optional for This Use Case** + +**Current Architecture Analysis:** + +- Each repository operation is atomic at the document level +- `save()` replaces entire conversation document (atomic) +- `delete()` removes single document (atomic) +- No cross-document operations + +**Recommendation: Transactions NOT Required** + +**Rationale:** + +1. **Single document operations are atomic** in MongoDB +2. **Conversation is aggregate root** - all changes go through it +3. **No distributed transactions** needed (no cross-aggregate operations) +4. **Simpler implementation** without transaction overhead + +**When Transactions Would Be Needed:** + +- If you split messages into separate collection (don't do this) +- If you have cross-conversation operations (e.g., bulk archiving with audit log) +- If you implement saga patterns for distributed operations + +**Exception: Optimistic Locking** + +Consider adding optimistic locking for concurrent updates: + +```typescript +interface ConversationDocument { + _id: string; + version: number; // Increment on each update + // ... other fields +} + +// Update with version check +async save(conversation: Conversation): Promise { + const document = ConversationDocumentMapper.toDocument(conversation); + const currentVersion = document.version || 0; + + const result = await collection.updateOne( + { _id: document._id, version: currentVersion }, + { $set: { ...document, version: currentVersion + 1 } }, + { upsert: false } + ); + + if (result.matchedCount === 0) { + throw new ConflictError('Conversation was modified by another process'); + } +} +``` + +**Decision: Skip optimistic locking for MVP** (implement if concurrency issues arise) + +### 3.4 Pagination Best Practices for `findAll()` + +**Requirement: Limit to 50-100 recent conversations** + +**Recommended Implementation:** + +```typescript +async findAll(options?: { + limit?: number; + offset?: number; + status?: string; +}): Promise { + const limit = options?.limit || 100; // Default 100 + const offset = options?.offset || 0; + + const filter: any = {}; + if (options?.status) { + filter.status = options.status; + } + + const documents = await this.collection + .find(filter) + .sort({ updatedAt: -1 }) // Newest first + .skip(offset) + .limit(limit) + .toArray(); + + return documents.map(doc => ConversationDocumentMapper.toEntity(doc)); +} +``` + +**Performance Optimizations:** + +1. **Use Projection for List Views:** + +```typescript +// When you only need metadata (not full messages array) +async findAllMetadata(options?: { limit?: number }): Promise { + const documents = await this.collection + .find({}) + .project({ + _id: 1, + title: 1, + status: 1, + updatedAt: 1, + createdAt: 1, + messageCount: { $size: '$messages' } // Compute message count + }) + .sort({ updatedAt: -1 }) + .limit(options?.limit || 100) + .toArray(); + + return documents; // Much smaller payload +} +``` + +2. **Cursor-Based Pagination** (for infinite scroll): + +```typescript +async findAllCursor(options?: { + afterId?: string; + limit?: number; +}): Promise { + const filter: any = {}; + + if (options?.afterId) { + // Find conversations with updatedAt older than the reference conversation + const refDoc = await this.collection.findOne({ _id: options.afterId }); + if (refDoc) { + filter.updatedAt = { $lt: refDoc.updatedAt }; + } + } + + const documents = await this.collection + .find(filter) + .sort({ updatedAt: -1 }) + .limit(options?.limit || 100) + .toArray(); + + return documents.map(doc => ConversationDocumentMapper.toEntity(doc)); +} +``` + +**Key Notes:** + +- **Default limit of 100** prevents unbounded queries +- **Always sort by updatedAt descending** for "most recent" behavior +- **Use skip + limit** for offset pagination (simpler for MVP) +- **Consider projection** if frontend only needs metadata +- **Index on updatedAt** is critical for performance + +--- + +## 4. Dependency Injection Updates + +### 4.1 Environment-Based Repository Selection + +**Update `DependencyContainer.ts`:** + +```typescript +// Add to ContainerConfig interface +export interface ContainerConfig { + openaiApiKey?: string; + repositoryType?: 'mongodb' | 'inmemory'; // NEW + mongodbUrl?: string; // NEW + databaseName?: string; // NEW + enableLogging?: boolean; +} + +// Update initializeAdapters() method +private async initializeAdapters(): Promise { + // ... existing AI provider initialization + + // Initialize repository based on config + const repositoryType = this.config.repositoryType + || process.env.REPOSITORY_TYPE + || 'inmemory'; + + if (repositoryType === 'mongodb') { + try { + const mongodbUrl = this.config.mongodbUrl || process.env.MONGODB_URL; + const databaseName = this.config.databaseName || process.env.DATABASE_NAME; + + if (!mongodbUrl || !databaseName) { + throw new Error('MongoDB configuration missing (MONGODB_URL or DATABASE_NAME)'); + } + + // Import dynamically to avoid loading MongoDB driver if not needed + const { MongoDBConversationRepository } = await import( + '../repositories/MongoDBConversationRepository' + ); + + this.conversationRepository = await MongoDBConversationRepository.create( + mongodbUrl, + databaseName + ); + + console.log('✓ MongoDB repository initialized'); + } catch (error) { + console.error('MongoDB repository initialization failed:', error); + console.warn('⚠ Falling back to InMemory repository'); + this.conversationRepository = new InMemoryConversationRepository(); + } + } else { + console.log('✓ InMemory repository initialized'); + this.conversationRepository = new InMemoryConversationRepository(); + } + + // ... rest of initialization +} +``` + +**Key Design Decisions:** + +1. **Graceful Fallback**: If MongoDB fails, fall back to InMemory (with clear warning logs) +2. **Dynamic Import**: Only load MongoDB driver if needed (reduces bundle size for InMemory mode) +3. **Environment Priority**: Config > Env variables > Default (inmemory) +4. **Async Initialization**: `initializeAdapters()` must become async + +### 4.2 Async Container Initialization + +**Challenge: Constructor Cannot Be Async** + +**Solution: Factory Method Pattern** + +```typescript +export class DependencyContainer { + private static instance: DependencyContainer | null = null; + + private constructor(private config: ContainerConfig) { + // Synchronous initialization only + } + + private async initialize(): Promise { + await this.initializeAdapters(); + this.initializeUseCases(); + } + + // NEW: Async factory method + static async create(config: ContainerConfig): Promise { + if (!DependencyContainer.instance) { + const container = new DependencyContainer(config); + await container.initialize(); + DependencyContainer.instance = container; + } + return DependencyContainer.instance; + } + + // DEPRECATED: Keep for backward compatibility but log warning + static getInstance(config?: ContainerConfig): DependencyContainer { + console.warn('getInstance() is deprecated. Use create() instead.'); + if (!DependencyContainer.instance) { + throw new Error('Container not initialized. Call create() first.'); + } + return DependencyContainer.instance; + } +} +``` + +**Update API Route:** + +```typescript +// app/api/conversations/route.ts +import { DependencyContainer } from '@/infrastructure/config/DependencyContainer'; + +// Initialize container once on module load +let containerPromise: Promise | null = null; + +async function getContainer(): Promise { + if (!containerPromise) { + containerPromise = DependencyContainer.create({ + openaiApiKey: process.env.OPENAI_API_KEY, + repositoryType: (process.env.REPOSITORY_TYPE as any) || 'inmemory', + mongodbUrl: process.env.MONGODB_URL, + databaseName: process.env.DATABASE_NAME, + enableLogging: process.env.NODE_ENV === 'development', + }); + } + return containerPromise; +} + +export async function POST(request: Request) { + const container = await getContainer(); + const useCase = container.getManageConversationUseCase(); + // ... rest of handler +} +``` + +### 4.3 Health Check Integration + +**Add Health Check Endpoint:** + +```typescript +// app/api/health/route.ts +import { NextResponse } from 'next/server'; +import { getContainer } from '@/lib/container'; // Centralized container getter + +export async function GET() { + try { + const container = await getContainer(); + const health = await container.healthCheck(); + + const statusCode = health.status === 'healthy' ? 200 : 503; + + return NextResponse.json(health, { status: statusCode }); + } catch (error) { + return NextResponse.json({ + status: 'unhealthy', + error: (error as Error).message, + }, { status: 503 }); + } +} +``` + +**Update `DependencyContainer.healthCheck()`:** + +```typescript +async healthCheck(): Promise<{ + status: 'healthy' | 'unhealthy'; + services: Record; + errors: string[]; +}> { + const errors: string[] = []; + const services: Record = {}; + + // ... existing health checks (AI provider, weather service) + + // Add MongoDB health check + try { + if (this.conversationRepository instanceof MongoDBConversationRepository) { + services.repositoryType = 'mongodb'; + services.mongodbConnected = await this.conversationRepository.healthCheck(); + } else { + services.repositoryType = 'inmemory'; + services.mongodbConnected = 'N/A'; + } + + const count = await this.conversationRepository.count(); + services.repository = true; + services.conversationCount = count; + } catch (error) { + services.repository = false; + errors.push(`Repository: ${(error as Error).message}`); + } + + // ... rest of health check +} +``` + +**MongoDB Health Check in Repository:** + +```typescript +// In MongoDBConversationRepository +async healthCheck(): Promise { + try { + const client = await MongoDBClient.getInstance(); + await client.ping(); // Simple ping command + return true; + } catch (error) { + console.error('MongoDB health check failed:', error); + return false; + } +} +``` + +--- + +## 5. Edge Cases & Error Handling + +### 5.1 Connection Failures + +**Scenario 1: Initial Connection Failure (Startup)** + +```typescript +// In MongoDBClient.connect() +async connect(): Promise { + const maxRetries = 3; + const retryDelayMs = 2000; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + this.client = new MongoClient(this.url, this.options); + await this.client.connect(); + this.db = this.client.db(this.databaseName); + + console.log(`✓ Connected to MongoDB (attempt ${attempt}/${maxRetries})`); + return; + } catch (error) { + console.error(`MongoDB connection attempt ${attempt}/${maxRetries} failed:`, error); + + if (attempt < maxRetries) { + await this.sleep(retryDelayMs * attempt); // Exponential backoff + } else { + throw new Error(`Failed to connect to MongoDB after ${maxRetries} attempts`); + } + } + } +} +``` + +**Scenario 2: Runtime Connection Loss** + +The MongoDB driver handles this automatically with: +- `retryWrites: true` - Retries failed writes once +- `retryReads: true` - Retries failed reads once +- Automatic reconnection on network errors + +**Your responsibility**: Log errors and surface to application layer. + +```typescript +async findById(id: string): Promise { + try { + const document = await this.collection.findOne({ _id: id }); + if (!document) return null; + + return ConversationDocumentMapper.toEntity(document); + } catch (error) { + if (error.name === 'MongoNetworkError') { + console.error('MongoDB network error:', error); + throw new RepositoryError( + 'Database connection lost', + MongoDBErrorType.NETWORK_TIMEOUT, + error + ); + } else if (error.name === 'MongoServerError') { + console.error('MongoDB server error:', error); + throw new RepositoryError( + 'Database server error', + MongoDBErrorType.QUERY_FAILED, + error + ); + } else { + console.error('Unexpected error finding conversation:', error); + throw new RepositoryError( + 'Failed to retrieve conversation', + MongoDBErrorType.UNKNOWN, + error + ); + } + } +} +``` + +### 5.2 Fallback to InMemory Repository + +**Challenge: Runtime Fallback is Complex** + +If MongoDB fails during runtime, switching to InMemory repository would require: +1. Migrating all in-memory data to MongoDB +2. Or accepting data loss +3. Complex state management + +**Recommendation: Don't Implement Runtime Fallback** + +Instead: +- **Startup Fallback Only**: If MongoDB fails on startup, use InMemory from the beginning +- **Clear User Communication**: If MongoDB fails during runtime, return error to user +- **Monitoring & Alerts**: Set up alerts for MongoDB errors (use health check endpoint) + +**Alternative: Graceful Error Responses** + +```typescript +// In API route handler +try { + const conversation = await useCase.execute(conversationId); + return NextResponse.json(conversation); +} catch (error) { + if (error instanceof RepositoryError) { + return NextResponse.json({ + error: 'Database temporarily unavailable. Please try again.', + code: error.type, + }, { status: 503 }); + } + + throw error; // Re-throw unexpected errors +} +``` + +### 5.3 Document Not Found Scenarios + +**Scenario: `findById()` returns null** + +This is normal behavior, not an error. Use cases should handle: + +```typescript +// In ManageConversationUseCase +async execute(conversationId?: string): Promise { + if (conversationId) { + const existing = await this.repository.findById(conversationId); + if (existing) { + return existing; + } + + // Document not found - create new conversation with this ID + console.warn(`Conversation ${conversationId} not found, creating new`); + } + + const newConversation = Conversation.create(conversationId); + await this.repository.save(newConversation); + return newConversation; +} +``` + +**Key Point**: Repository returns `null` for not found (not throwing error). + +### 5.4 Concurrent Updates + +**Problem**: Two processes update the same conversation simultaneously. + +**Solutions:** + +**Option 1: Optimistic Locking (Recommended for Future)** + +```typescript +interface ConversationDocument { + _id: string; + version: number; // Increment on each save + // ... other fields +} + +async save(conversation: Conversation): Promise { + const document = ConversationDocumentMapper.toDocument(conversation); + const currentVersion = await this.getCurrentVersion(document._id); + + const result = await this.collection.updateOne( + { _id: document._id, version: currentVersion }, + { + $set: { ...document }, + $inc: { version: 1 } + }, + { upsert: false } + ); + + if (result.matchedCount === 0) { + throw new ConcurrentModificationError( + `Conversation ${document._id} was modified by another process` + ); + } +} +``` + +**Option 2: Last-Write-Wins (Current Behavior)** + +Accept that the last write overwrites previous changes. This is acceptable for MVP since: +- Conversation operations are typically sequential (user sends message, waits for response) +- No multi-user editing of same conversation +- If conflicts occur, they're rare and recoverable + +**Recommendation for MVP**: Use last-write-wins. Add optimistic locking if concurrency issues arise. + +### 5.5 Database Migration Strategy + +**Phase 1: Zero Downtime Migration (MongoDB Introduction)** + +Since you're starting with InMemory: + +1. Deploy with `REPOSITORY_TYPE=inmemory` (current state) +2. Set up MongoDB Atlas cluster +3. Deploy with `REPOSITORY_TYPE=mongodb` (new deployments use MongoDB) +4. No data migration needed (InMemory was transient anyway) + +**Phase 2: Schema Evolution (Future)** + +When you need to change the document schema: + +**Option A: Runtime Migration (Small Changes)** + +```typescript +// In ConversationDocumentMapper.toEntity() +static toEntity(document: any): Conversation { + // Handle missing fields (migration) + const messages = document.messages || []; + const status = document.status || ConversationStatus.ACTIVE; + const createdAt = document.createdAt || new Date(document._id); // Fallback + + // ... rest of mapping +} +``` + +**Option B: Migration Script (Breaking Changes)** + +```typescript +// scripts/migrate-conversations.ts +import { MongoClient } from 'mongodb'; + +async function migrate() { + const client = new MongoClient(process.env.MONGODB_URL!); + await client.connect(); + + const db = client.db(process.env.DATABASE_NAME!); + const collection = db.collection('conversations'); + + // Example: Add version field to all documents + const result = await collection.updateMany( + { version: { $exists: false } }, + { $set: { version: 1 } } + ); + + console.log(`Migrated ${result.modifiedCount} documents`); + + await client.close(); +} + +migrate().catch(console.error); +``` + +**Option C: Versioned Collections (Major Rewrites)** + +```typescript +// Create new collection: conversations_v2 +// Gradually migrate data +// Update repository to read from both collections +// Switch over when complete +``` + +**Recommendation**: Start with runtime migration (Option A) for MVP. + +--- + +## 6. Implementation Checklist + +### 6.1 Files to Create + +``` +src/infrastructure/adapters/database/ + ├── MongoDBClient.ts # Singleton connection manager + ├── MongoDBConversationRepository.ts # Repository implementation + └── mappers/ + └── ConversationDocumentMapper.ts # Entity <-> Document mapping + +src/infrastructure/adapters/database/types/ + └── ConversationDocument.ts # TypeScript interfaces for documents + +src/infrastructure/config/ + └── (Update) DependencyContainer.ts # Add MongoDB initialization +``` + +### 6.2 Files to Modify + +``` +src/infrastructure/config/DependencyContainer.ts + - Make initializeAdapters() async + - Add repository selection logic + - Update healthCheck() to include MongoDB status + +.env.example (CREATE) + - Add MONGODB_URL example + - Add DATABASE_NAME example + - Add REPOSITORY_TYPE example + +package.json (UPDATE) + - Verify mongodb driver is installed (should already be from @vercel/kv) + - If not: yarn add mongodb +``` + +### 6.3 Environment Variables to Add + +```bash +# MongoDB Configuration +MONGODB_URL=mongodb+srv://username:password@cluster.mongodb.net/?retryWrites=true&w=majority +DATABASE_NAME=ai_chat_app + +# Repository Selection (optional, defaults to 'inmemory') +REPOSITORY_TYPE=mongodb # or 'inmemory' +``` + +### 6.4 MongoDB Atlas Setup Steps + +1. **Create Atlas Account**: https://www.mongodb.com/cloud/atlas/register +2. **Create Free Tier Cluster**: + - Select M0 Free Tier + - Choose cloud provider & region (e.g., AWS us-east-1) + - Cluster name: `ai-chat-cluster` +3. **Create Database User**: + - Username: `ai_chat_user` + - Password: Generate strong password + - Role: `readWrite` on specific database +4. **Configure Network Access**: + - Add IP: `0.0.0.0/0` (allow all) for development + - For production: Add specific IPs or use Vercel IP ranges +5. **Get Connection String**: + - Click "Connect" > "Connect your application" + - Copy connection string + - Replace `` with actual password +6. **Create Database**: + - Database name: `ai_chat_app` + - Collection name: `conversations` + +### 6.5 Development Workflow + +**Step 1: Install MongoDB Driver (if not already installed)** + +```bash +yarn add mongodb +``` + +**Step 2: Create MongoDBClient** + +Implement singleton with connection pooling and health check. + +**Step 3: Create ConversationDocumentMapper** + +Implement bidirectional mapping with full Message/ToolInvocation support. + +**Step 4: Create MongoDBConversationRepository** + +Implement all IConversationRepository methods using mapper. + +**Step 5: Update DependencyContainer** + +Add async initialization and repository selection logic. + +**Step 6: Test with InMemory First** + +Deploy with `REPOSITORY_TYPE=inmemory` to ensure no regressions. + +**Step 7: Test with MongoDB** + +Set up Atlas cluster, configure env variables, test with `REPOSITORY_TYPE=mongodb`. + +**Step 8: Add Health Check Endpoint** + +Create `/api/health` route to monitor MongoDB connection. + +--- + +## 7. Testing Recommendations + +### 7.1 Unit Tests (Repository) + +**Test File**: `src/infrastructure/adapters/database/MongoDBConversationRepository.test.ts` + +**Key Test Cases:** + +1. **Connection Management** + - ✓ Connects successfully with valid credentials + - ✓ Throws error with invalid credentials + - ✓ Reuses existing connection on subsequent calls + - ✓ Handles connection pool exhaustion + +2. **CRUD Operations** + - ✓ `save()` creates new conversation document + - ✓ `save()` updates existing conversation document + - ✓ `findById()` retrieves existing conversation + - ✓ `findById()` returns null for non-existent ID + - ✓ `delete()` removes conversation + - ✓ `findAll()` returns limited results sorted by updatedAt + +3. **Mapper Tests** + - ✓ `toDocument()` correctly serializes entity + - ✓ `toEntity()` correctly deserializes document + - ✓ Round-trip: entity -> document -> entity preserves data + - ✓ Handles nested messages with tool invocations + - ✓ Handles empty messages array + - ✓ Handles missing optional fields + +4. **Error Handling** + - ✓ Network errors are properly caught and wrapped + - ✓ Query errors are properly classified + - ✓ Malformed documents throw descriptive errors + +**Testing Strategy:** + +Use **MongoDB Memory Server** for integration tests: + +```bash +yarn add -D mongodb-memory-server +``` + +```typescript +import { MongoMemoryServer } from 'mongodb-memory-server'; + +describe('MongoDBConversationRepository', () => { + let mongoServer: MongoMemoryServer; + let repository: MongoDBConversationRepository; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const uri = mongoServer.getUri(); + repository = await MongoDBConversationRepository.create(uri, 'test_db'); + }); + + afterAll(async () => { + await mongoServer.stop(); + }); + + it('should save and retrieve a conversation', async () => { + const conversation = Conversation.create(); + await repository.save(conversation); + + const retrieved = await repository.findById(conversation.getId()); + expect(retrieved).not.toBeNull(); + expect(retrieved?.getId()).toBe(conversation.getId()); + }); +}); +``` + +### 7.2 Integration Tests + +**Test File**: `src/infrastructure/config/DependencyContainer.test.ts` + +**Key Test Cases:** + +1. ✓ Container initializes with InMemory repository when `REPOSITORY_TYPE=inmemory` +2. ✓ Container initializes with MongoDB repository when `REPOSITORY_TYPE=mongodb` +3. ✓ Container falls back to InMemory when MongoDB connection fails +4. ✓ Health check reflects correct repository type + +### 7.3 End-to-End Tests + +**Test File**: `app/api/conversations/route.test.ts` + +**Key Test Cases:** + +1. ✓ POST creates conversation and persists to MongoDB +2. ✓ GET retrieves persisted conversation +3. ✓ Conversation survives server restart (when using MongoDB) +4. ✓ Returns error when MongoDB is unavailable + +--- + +## 8. Performance Considerations + +### 8.1 Query Optimization + +**Current Performance Profile:** + +| Operation | Query Pattern | Index Used | Performance | +|-----------|---------------|------------|-------------| +| `findById()` | `{ _id: id }` | Primary (`_id`) | O(1) - Excellent | +| `findAll()` | `{ status? }` + sort by `updatedAt` | `idx_status_updatedAt` | O(log n) - Good | +| `findActive()` | `{ status: { $in: [...] } }` + sort | `idx_status_updatedAt` | O(log n) - Good | +| `save()` | `{ _id: id }` | Primary (`_id`) | O(1) - Excellent | +| `delete()` | `{ _id: id }` | Primary (`_id`) | O(1) - Excellent | + +**Optimization Opportunities:** + +1. **Projection for List Queries** + - When fetching conversation list, exclude `messages` array + - Reduces network transfer by ~90% for large conversations + + ```typescript + const documents = await this.collection + .find({}) + .project({ messages: 0 }) // Exclude messages + .sort({ updatedAt: -1 }) + .limit(100) + .toArray(); + ``` + +2. **Aggregation Pipeline for Statistics** + + ```typescript + async getConversationStats(): Promise<{ + totalConversations: number; + avgMessagesPerConversation: number; + statusBreakdown: Record; + }> { + const result = await this.collection.aggregate([ + { + $facet: { + totalCount: [{ $count: 'count' }], + avgMessages: [ + { $project: { messageCount: { $size: '$messages' } } }, + { $group: { _id: null, avg: { $avg: '$messageCount' } } } + ], + statusCounts: [ + { $group: { _id: '$status', count: { $sum: 1 } } } + ] + } + } + ]).toArray(); + + // Process result... + } + ``` + +3. **Caching Layer (Future Optimization)** + - Add Redis cache for frequently accessed conversations + - Cache conversation metadata for list views + - Invalidate on save/delete + +### 8.2 Document Size Management + +**Current Limits:** + +- MongoDB document limit: **16MB** +- Conversation message limit: **1000 messages** (from domain) +- Estimated message size: ~1KB per message (with content + metadata) +- **Estimated max conversation size: ~1MB** (well within limit) + +**Monitoring:** + +```typescript +async checkDocumentSize(conversationId: string): Promise { + const document = await this.collection.findOne( + { _id: conversationId }, + { projection: { _id: 1 } } + ); + + if (!document) return 0; + + // BSON.calculateObjectSize() from mongodb driver + const bsonSize = BSON.calculateObjectSize(document); + + if (bsonSize > 10 * 1024 * 1024) { // 10MB warning threshold + console.warn(`Conversation ${conversationId} is ${bsonSize} bytes (approaching 16MB limit)`); + } + + return bsonSize; +} +``` + +**Mitigation Strategy (if needed in future):** + +1. Archive old messages to separate collection +2. Implement message pagination at domain level +3. Store message content in GridFS for very large messages + +### 8.3 Connection Pool Tuning + +**Recommended Settings for Next.js:** + +```typescript +const mongoClientOptions = { + maxPoolSize: 10, // Serverless: Keep low (10) + minPoolSize: 2, // Maintain baseline connections + maxIdleTimeMS: 60000, // Close idle after 60s (important for serverless) + serverSelectionTimeoutMS: 5000, + socketTimeoutMS: 45000, +}; +``` + +**Why Low Pool Size?** + +- Next.js API routes are stateless +- Each serverless function has its own connection pool +- High pool size wastes connections +- MongoDB Atlas M0 (free tier) allows max 100 concurrent connections + +**Monitoring Connection Pool:** + +```typescript +client.on('connectionPoolCreated', (event) => { + console.log('Connection pool created:', event); +}); + +client.on('connectionPoolClosed', (event) => { + console.log('Connection pool closed:', event); +}); + +client.on('connectionCheckedOut', (event) => { + console.log('Connection checked out from pool'); +}); + +client.on('connectionCheckedIn', (event) => { + console.log('Connection checked in to pool'); +}); +``` + +--- + +## 9. Security Considerations + +### 9.1 Connection String Security + +**Environment Variable Storage:** + +```bash +# .env (NEVER commit this file) +MONGODB_URL=mongodb+srv://user:password@cluster.mongodb.net/?retryWrites=true&w=majority + +# .env.example (commit this for documentation) +MONGODB_URL=mongodb+srv://username:password@cluster.mongodb.net/?retryWrites=true&w=majority +DATABASE_NAME=your_database_name +``` + +**Vercel Deployment:** + +1. Add environment variables in Vercel dashboard +2. Use different MongoDB users for dev/staging/production +3. Enable IP allowlisting in MongoDB Atlas (use Vercel IP ranges) + +**Best Practices:** + +- ✅ Use MongoDB Atlas secrets management +- ✅ Rotate passwords regularly +- ✅ Use least-privilege principle (readWrite on specific database only) +- ❌ Never hardcode credentials +- ❌ Never log connection strings + +### 9.2 Data Validation + +**Input Sanitization:** + +MongoDB Node.js driver automatically escapes queries, but: + +1. **Validate entity data before saving**: + ```typescript + // In ConversationDocumentMapper.toDocument() + if (!conversation.getId() || typeof conversation.getId() !== 'string') { + throw new ValidationError('Invalid conversation ID'); + } + ``` + +2. **Sanitize user input at API boundary**: + ```typescript + // In API route + const { conversationId } = await request.json(); + + if (conversationId && !isValidUUID(conversationId)) { + return NextResponse.json({ error: 'Invalid conversation ID' }, { status: 400 }); + } + ``` + +3. **Use MongoDB schema validation** (optional): + ```typescript + await db.createCollection('conversations', { + validator: { + $jsonSchema: { + bsonType: 'object', + required: ['_id', 'status', 'messages', 'createdAt', 'updatedAt'], + properties: { + _id: { bsonType: 'string' }, + status: { enum: ['active', 'waiting_for_response', 'completed', 'archived'] }, + messages: { bsonType: 'array' }, + createdAt: { bsonType: 'date' }, + updatedAt: { bsonType: 'date' } + } + } + } + }); + ``` + +### 9.3 Access Control + +**MongoDB User Permissions:** + +```javascript +// In MongoDB Atlas, create user with limited permissions +{ + "role": "readWrite", + "db": "ai_chat_app" +} +``` + +**API-Level Authorization (Future):** + +When adding user authentication: + +```typescript +async findByUser(userId: string): Promise { + // Only return conversations belonging to this user + const documents = await this.collection + .find({ userId }) + .sort({ updatedAt: -1 }) + .limit(100) + .toArray(); + + return documents.map(doc => ConversationDocumentMapper.toEntity(doc)); +} +``` + +--- + +## 10. Monitoring & Observability + +### 10.1 Logging Strategy + +**Recommended Logging Levels:** + +```typescript +// Production: Log errors + warnings +// Development: Log info + debug + +class MongoDBConversationRepository { + private logger = { + debug: (msg: string, meta?: any) => { + if (process.env.NODE_ENV === 'development') { + console.debug(`[MongoDB] ${msg}`, meta); + } + }, + info: (msg: string, meta?: any) => { + console.info(`[MongoDB] ${msg}`, meta); + }, + warn: (msg: string, meta?: any) => { + console.warn(`[MongoDB] ${msg}`, meta); + }, + error: (msg: string, error?: Error, meta?: any) => { + console.error(`[MongoDB] ${msg}`, error, meta); + }, + }; + + async findById(id: string): Promise { + this.logger.debug('Finding conversation by ID', { id }); + + try { + const document = await this.collection.findOne({ _id: id }); + + if (!document) { + this.logger.debug('Conversation not found', { id }); + return null; + } + + this.logger.debug('Conversation found', { id, messageCount: document.messages.length }); + return ConversationDocumentMapper.toEntity(document); + } catch (error) { + this.logger.error('Error finding conversation', error as Error, { id }); + throw error; + } + } +} +``` + +### 10.2 Performance Monitoring + +**MongoDB Atlas Performance Advisor:** + +- Automatically suggests indexes based on query patterns +- Monitors slow queries (>100ms) +- Tracks query execution plans + +**Application-Level Metrics:** + +```typescript +class RepositoryMetrics { + private queryDurations: number[] = []; + + async measureQuery( + operation: string, + query: () => Promise + ): Promise { + const start = Date.now(); + + try { + const result = await query(); + const duration = Date.now() - start; + + this.queryDurations.push(duration); + + if (duration > 1000) { // Slow query threshold + console.warn(`Slow ${operation} query: ${duration}ms`); + } + + return result; + } catch (error) { + const duration = Date.now() - start; + console.error(`Failed ${operation} query after ${duration}ms:`, error); + throw error; + } + } + + getAverageQueryDuration(): number { + if (this.queryDurations.length === 0) return 0; + const sum = this.queryDurations.reduce((a, b) => a + b, 0); + return sum / this.queryDurations.length; + } +} +``` + +### 10.3 Health Check Endpoint + +**Comprehensive Health Check:** + +```typescript +// app/api/health/route.ts +export async function GET() { + const container = await getContainer(); + + const health = { + status: 'healthy', + timestamp: new Date().toISOString(), + services: { + api: true, + repository: { + type: 'unknown', + connected: false, + conversationCount: 0, + }, + mongodb: { + connected: false, + latency: 0, + }, + }, + errors: [] as string[], + }; + + // Check repository + try { + const repository = container.getConversationRepository(); + + if (repository instanceof MongoDBConversationRepository) { + health.services.repository.type = 'mongodb'; + + const startTime = Date.now(); + const isConnected = await repository.healthCheck(); + const latency = Date.now() - startTime; + + health.services.mongodb.connected = isConnected; + health.services.mongodb.latency = latency; + health.services.repository.connected = isConnected; + } else { + health.services.repository.type = 'inmemory'; + health.services.repository.connected = true; + } + + health.services.repository.conversationCount = await repository.count(); + } catch (error) { + health.status = 'unhealthy'; + health.errors.push(`Repository: ${(error as Error).message}`); + } + + const statusCode = health.status === 'healthy' ? 200 : 503; + return NextResponse.json(health, { status: statusCode }); +} +``` + +**Usage:** + +- Monitor in production: Poll `/api/health` every 60 seconds +- Alert if status becomes 'unhealthy' for > 2 consecutive checks +- Use for Vercel health checks or uptime monitoring services + +--- + +## 11. Summary & Next Steps + +### 11.1 Implementation Priority + +**Phase 1: Core Infrastructure (MVP)** +1. ✅ Create `MongoDBClient` singleton with connection pooling +2. ✅ Create `ConversationDocumentMapper` with full entity restoration +3. ✅ Implement `MongoDBConversationRepository` with all CRUD methods +4. ✅ Update `DependencyContainer` with async initialization and repository selection +5. ✅ Add environment variables and test with MongoDB Atlas + +**Phase 2: Reliability & Monitoring** +6. ✅ Add health check endpoint (`/api/health`) +7. ✅ Implement retry logic for initial connection +8. ✅ Add comprehensive error logging +9. ✅ Test fallback to InMemory on connection failure + +**Phase 3: Optimization (Post-MVP)** +10. ⬜ Add projection for list queries (exclude messages) +11. ⬜ Implement cursor-based pagination +12. ⬜ Add optimistic locking for concurrent updates +13. ⬜ Set up MongoDB Atlas Performance Advisor alerts + +**Phase 4: Testing (Parallel to Phase 1)** +14. ✅ Write unit tests with MongoDB Memory Server +15. ✅ Write integration tests for DependencyContainer +16. ✅ Write E2E tests for API routes with MongoDB + +### 11.2 Key Architectural Decisions Summary + +| Decision Point | Recommendation | Rationale | +|----------------|----------------|-----------| +| **Driver** | Native MongoDB driver | Lighter weight, more control, no ORM overhead | +| **Connection** | Singleton with pooling | Efficient resource usage, automatic reconnection | +| **Schema** | Embedded messages | Aggregate root pattern, single-query retrieval | +| **Dates** | MongoDB Date objects | Native queries, better performance | +| **Indexes** | `updatedAt`, `status + updatedAt` | Optimized for common queries (findAll, findActive) | +| **Mapping** | Dedicated mapper class | Clean separation, testable, reusable | +| **Transactions** | Not needed | Single-document operations are atomic | +| **Pagination** | Skip + limit | Simpler for MVP, sufficient for 100 conversations | +| **Fallback** | Startup only | Avoid complex runtime state migration | +| **Error Handling** | Wrap in domain errors | Hide infrastructure details from application layer | + +### 11.3 Important Implementation Notes + +**Critical Points to Remember:** + +1. **Use `Conversation.restore()`**: Never rebuild entities with `.create()` + `.addMessage()` +2. **Handle Tool Invocation State**: Must manually replay state transitions when mapping +3. **Async Container Initialization**: Update API routes to await container creation +4. **Environment-Based Selection**: Always provide fallback to InMemory +5. **Date Objects, Not Strings**: Keep MongoDB dates as Date objects for query performance +6. **Index Creation**: Call `createIndexes()` once on repository initialization +7. **Connection Pooling**: Use low `maxPoolSize` (10) for serverless environments +8. **Error Logging**: Always log MongoDB errors with full context for debugging + +### 11.4 Questions to Clarify Before Implementation + +Before you start coding, Fran, please confirm: + +1. **MongoDB Atlas Setup**: Do you want me to guide you through setting up the Atlas cluster, or will you handle that separately? + +2. **Package Installation**: The `mongodb` driver should already be installed as a dependency of `@vercel/kv`. Should I verify this, or do you want to install it explicitly? + +3. **Testing Strategy**: Do you want to implement tests alongside the repository, or implement repository first and tests later? + +4. **Environment Variables**: Should I create a `.env.example` file with all required variables? + +5. **Health Check Priority**: Is the health check endpoint critical for MVP, or can it be implemented in Phase 2? + +6. **Logging Library**: Do you want to use a structured logging library (e.g., `pino`, `winston`), or stick with `console.log`? + +7. **Migration Concerns**: Since InMemory is transient, do you want any data export/import functionality before switching to MongoDB? + +--- + +## 12. File Structure Reference + +**Complete File Structure After Implementation:** + +``` +src/ + domain/ + entities/ + Conversation.ts # ✅ Existing + Message.ts # ✅ Existing + ToolInvocation.ts # ✅ Existing + repositories/ + IConversationRepository.ts # ✅ Existing (interface) + + infrastructure/ + adapters/ + database/ + MongoDBClient.ts # 🆕 CREATE - Singleton connection manager + MongoDBConversationRepository.ts # 🆕 CREATE - Repository implementation + types/ + ConversationDocument.ts # 🆕 CREATE - TypeScript document interfaces + mappers/ + ConversationDocumentMapper.ts # 🆕 CREATE - Entity <-> Document mapper + repositories/ + InMemoryConversationRepository.ts # ✅ Existing + config/ + DependencyContainer.ts # 🔄 UPDATE - Add MongoDB initialization + + application/ + use-cases/ + ManageConversationUseCase.ts # ✅ Existing (no changes needed) + +app/ + api/ + conversations/ + route.ts # 🔄 UPDATE - Await container initialization + health/ + route.ts # 🆕 CREATE - Health check endpoint + +.env.example # 🆕 CREATE - Environment variable template +``` + +**Legend:** +- ✅ Existing - Already implemented +- 🆕 CREATE - New file to create +- 🔄 UPDATE - Existing file to modify + +--- + +## 13. Environment Configuration Template + +**Create `.env.example`:** + +```bash +# OpenAI Configuration (Required) +OPENAI_API_KEY=sk-...your-api-key + +# MongoDB Configuration (Required for MongoDB repository) +MONGODB_URL=mongodb+srv://username:password@cluster.mongodb.net/?retryWrites=true&w=majority +DATABASE_NAME=ai_chat_app + +# Repository Selection (Optional - defaults to 'inmemory') +# Options: 'mongodb' | 'inmemory' +REPOSITORY_TYPE=mongodb + +# Application Environment +NODE_ENV=development # or 'production' +``` + +**Create `.env.local` (for local development):** + +```bash +# Copy .env.example to .env.local and fill in actual values +OPENAI_API_KEY=sk-...actual-key +MONGODB_URL=mongodb+srv://user:pass@cluster.mongodb.net/?retryWrites=true&w=majority +DATABASE_NAME=ai_chat_app_dev +REPOSITORY_TYPE=inmemory # Use inmemory for local dev, mongodb for testing +NODE_ENV=development +``` + +--- + +## Conclusion + +This architectural guidance provides a comprehensive blueprint for implementing the MongoDB repository adapter while maintaining the integrity of your hexagonal architecture. The approach prioritizes: + +- **Clean Architecture**: Clear separation between domain, application, and infrastructure +- **Resilience**: Graceful error handling and fallback mechanisms +- **Performance**: Optimized queries, proper indexing, and connection pooling +- **Maintainability**: Dedicated mapper, comprehensive logging, and health monitoring +- **Testability**: Isolated components with clear interfaces + +The implementation follows DDD principles, ensuring the domain remains pure and infrastructure concerns are properly abstracted behind the `IConversationRepository` interface. + +**Next Steps**: Once you confirm the clarifying questions, I'll be ready to guide you through the actual implementation following this architectural blueprint. + +--- + +**Document Version**: 1.0 +**Last Updated**: 2025-10-08 +**Author**: Hexagonal Backend Architect Agent +**Review Status**: Ready for Implementation diff --git a/.claude/doc/chat_history/frontend-data-architecture.md b/.claude/doc/chat_history/frontend-data-architecture.md new file mode 100644 index 0000000..ec429ec --- /dev/null +++ b/.claude/doc/chat_history/frontend-data-architecture.md @@ -0,0 +1,2003 @@ +# Frontend Data Layer Architecture - Chat History Management + +**Feature**: Conversation History Management +**Author**: Frontend Developer Agent +**Date**: 2025-10-08 +**Session**: `context_session_chat_history` + +--- + +## Executive Summary + +This document provides comprehensive architectural guidance for implementing the frontend data layer to manage conversation history. The architecture follows established patterns in the codebase using React Query, Zod schemas, service layers, and custom hooks composition. + +**Key Requirements:** +- Fetch and display conversation list (recent 50-100) +- Load specific conversations by ID +- Delete conversations with optimistic updates +- Switch between conversations seamlessly +- Proper cache management and error handling + +--- + +## 1. React Query Integration + +### 1.1 Query Keys Structure + +The project already has an excellent query key factory pattern in `useConversationQuery.ts`. **Extend this pattern** rather than creating a new one: + +```typescript +// app/features/conversation/hooks/queries/useConversationQuery.ts +export const conversationKeys = { + all: ['conversations'] as const, + lists: () => [...conversationKeys.all, 'list'] as const, + list: (filters?: Record) => [...conversationKeys.lists(), filters] as const, + details: () => [...conversationKeys.all, 'detail'] as const, + detail: (id: string) => [...conversationKeys.details(), id] as const, + messages: (id: string) => [...conversationKeys.detail(id), 'messages'] as const, +}; +``` + +**Why this structure works:** +- Hierarchical keys enable precise cache invalidation +- `conversationKeys.all` invalidates everything +- `conversationKeys.lists()` invalidates only list queries +- `conversationKeys.detail(id)` invalidates a specific conversation +- Filters in `list(filters)` enable separate caches for "active" vs "archived" + +**Filter Strategy:** +```typescript +// For active conversations +conversationKeys.list({ status: 'active' }) + +// For archived conversations +conversationKeys.list({ status: 'archived' }) + +// All conversations (no filter) +conversationKeys.list() +``` + +### 1.2 Stale Time and Cache Invalidation Strategy + +Based on the existing patterns in `useConversationQuery.ts` and user behavior analysis: + +**Conversation List Query:** +```typescript +staleTime: 1000 * 60 * 2, // 2 minutes (already implemented) +gcTime: 1000 * 60 * 10, // 10 minutes (already implemented) +refetchOnMount: true, // Always get fresh data when sidebar opens +refetchOnWindowFocus: false, // Don't refetch on tab switch (annoying) +``` + +**Rationale:** +- 2-minute stale time balances freshness vs unnecessary requests +- Users don't switch conversations every second +- Refetch on mount ensures sidebar shows current state +- Window focus refetch disabled to avoid disrupting active chat + +**Individual Conversation Messages Query:** +```typescript +staleTime: 1000 * 60 * 5, // 5 minutes (already implemented) +gcTime: 1000 * 60 * 30, // 30 minutes (already implemented) +refetchOnMount: false, // Don't refetch if already cached +refetchOnWindowFocus: false, +``` + +**Rationale:** +- Messages don't change once loaded (append-only in this architecture) +- Higher stale time reduces redundant fetches +- Longer garbage collection keeps recently viewed conversations accessible + +**Cache Invalidation Events:** +```typescript +// Invalidate list when: +1. New conversation created → invalidateQueries(conversationKeys.lists()) +2. Conversation deleted → invalidateQueries(conversationKeys.lists()) +3. Message sent → invalidateQueries(conversationKeys.lists()) // Updates "lastMessageAt" +4. Conversation archived → invalidateQueries(conversationKeys.lists()) + +// Invalidate detail when: +1. Messages added → invalidateQueries(conversationKeys.messages(id)) +2. Conversation deleted → removeQueries(conversationKeys.detail(id)) +3. Metadata updated → invalidateQueries(conversationKeys.detail(id)) +``` + +### 1.3 Prefetching Conversations on Hover + +Implement intelligent prefetching to improve perceived performance: + +```typescript +// app/features/conversation/hooks/queries/useConversationQuery.ts +export function usePrefetchConversation(conversationId: string) { + const queryClient = useQueryClient(); + + const prefetchConversation = useCallback(async () => { + // Check if already in cache + const existingData = queryClient.getQueryData( + conversationKeys.messages(conversationId) + ); + + if (existingData) { + return; // Already cached, skip prefetch + } + + await queryClient.prefetchQuery({ + queryKey: conversationKeys.messages(conversationId), + queryFn: async () => { + const messages = await ConversationService.getConversationHistory(conversationId); + return MessagesSchema.parse(messages); + }, + staleTime: 1000 * 60 * 5, + }); + }, [conversationId, queryClient]); + + return { prefetchConversation }; +} +``` + +**Usage in Conversation List Item:** +```typescript +// Trigger on mouseEnter, debounced by 300ms + { + if (hoverTimeout) clearTimeout(hoverTimeout); + hoverTimeout = setTimeout(() => { + prefetchConversation(); + }, 300); + }} + onMouseLeave={() => { + clearTimeout(hoverTimeout); + }} +/> +``` + +**Prefetch Strategy:** +- Only prefetch if not already in cache (avoid redundant requests) +- 300ms debounce prevents accidental hovers from triggering fetches +- Prefetched data shares same cache as normal queries +- Reduces perceived load time when user clicks conversation + +### 1.4 Optimistic Updates for Delete Operation + +The existing `useDeleteConversationMutation` already has excellent optimistic update logic. **Keep this pattern:** + +```typescript +// Current implementation (already correct): +onMutate: async (conversationId) => { + // 1. Cancel outgoing queries to prevent race conditions + await queryClient.cancelQueries({ + queryKey: conversationKeys.detail(conversationId) + }); + + // 2. Optimistically remove from cache + queryClient.removeQueries({ + queryKey: conversationKeys.detail(conversationId) + }); + + return { conversationId }; +}, +onSuccess: (_, conversationId) => { + // 3. Invalidate list to remove from UI + queryClient.invalidateQueries({ + queryKey: conversationKeys.lists() + }); +}, +onError: (error, conversationId) => { + // 4. Refetch to restore state on error + queryClient.invalidateQueries({ + queryKey: conversationKeys.detail(conversationId) + }); +} +``` + +**Enhancement for List Optimistic Update:** +```typescript +// Add this to onMutate for instant UI feedback: +onMutate: async (conversationId) => { + await queryClient.cancelQueries({ + queryKey: conversationKeys.lists() + }); + + // Get previous list data for rollback + const previousLists = queryClient.getQueryData(conversationKeys.lists()); + + // Optimistically remove from list + queryClient.setQueryData( + conversationKeys.lists(), + (old: ConversationList | undefined) => { + return old?.filter(conv => conv.id !== conversationId) ?? []; + } + ); + + // Remove detail cache + queryClient.removeQueries({ + queryKey: conversationKeys.detail(conversationId) + }); + + return { conversationId, previousLists }; +}, +onError: (error, conversationId, context) => { + // Rollback optimistic update + if (context?.previousLists) { + queryClient.setQueryData( + conversationKeys.lists(), + context.previousLists + ); + } +} +``` + +### 1.5 Error Handling and Retry Logic + +Follow existing patterns with domain-specific enhancements: + +```typescript +// Default retry configuration (already used): +retry: 2, // Retry failed requests twice +retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), + +// Custom retry logic for conversation list: +retry: (failureCount, error) => { + // Don't retry on 404 (conversation not found) + if (error?.response?.status === 404) return false; + + // Don't retry on 403 (forbidden - user doesn't have access) + if (error?.response?.status === 403) return false; + + // Retry network errors and 5xx errors up to 2 times + return failureCount < 2; +}, + +// Error handling in query: +onError: (error) => { + console.error('Failed to fetch conversations:', error); + + if (error?.response?.status === 404) { + toast.error('Conversations not found'); + } else if (error?.response?.status === 403) { + toast.error('Access denied'); + } else if (error?.response?.status >= 500) { + toast.error('Server error. Please try again later.'); + } else if (error?.message?.includes('Network')) { + toast.error('Network error. Check your connection.'); + } else { + toast.error('Failed to load conversations'); + } +} +``` + +--- + +## 2. Service Layer Design + +### 2.1 conversation.service.ts Enhancement + +The existing service has good foundations. **Enhance with:** + +**Type Safety Improvements:** +```typescript +// app/features/conversation/data/services/conversation.service.ts + +import axios, { AxiosInstance, AxiosError } from 'axios'; +import { Message } from 'ai'; +import type { ConversationListItem } from '../schemas/conversation.schema'; + +export class ConversationService { + private static axiosInstance: AxiosInstance = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, + timeout: 10000, // 10 second timeout + }); + + /** + * Fetch paginated list of conversations + * @param options - Filtering and pagination options + */ + static async listConversations(options?: { + status?: 'active' | 'archived'; + limit?: number; + offset?: number; + }): Promise { + try { + const params = new URLSearchParams(); + if (options?.status) params.append('status', options.status); + if (options?.limit) params.append('limit', options.limit.toString()); + if (options?.offset) params.append('offset', options.offset.toString()); + + const response = await this.axiosInstance.get( + `/conversations/list?${params.toString()}` + ); + + return response.data; + } catch (error) { + console.error('Failed to list conversations:', error); + throw this.handleError(error); + } + } + + /** + * Fetch conversation messages by ID + */ + static async getConversationHistory(conversationId: string): Promise { + try { + const response = await this.axiosInstance.get( + `/conversations/${conversationId}/messages` + ); + return response.data; + } catch (error) { + console.error('Failed to fetch conversation history:', error); + throw this.handleError(error); + } + } + + /** + * Delete conversation by ID (hard delete) + */ + static async deleteConversation(conversationId: string): Promise { + try { + await this.axiosInstance.delete(`/conversations/${conversationId}`); + return true; + } catch (error) { + console.error('Failed to delete conversation:', error); + throw this.handleError(error); + } + } + + /** + * Centralized error handling + */ + private static handleError(error: unknown): Error { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError<{ error?: string; message?: string }>; + + if (axiosError.response) { + // Server responded with error + const message = axiosError.response.data?.error + || axiosError.response.data?.message + || `Request failed with status ${axiosError.response.status}`; + + const enhancedError = new Error(message); + (enhancedError as any).response = axiosError.response; + return enhancedError; + } else if (axiosError.request) { + // Request made but no response + return new Error('Network error: No response from server'); + } + } + + // Unknown error + return error instanceof Error ? error : new Error('Unknown error occurred'); + } +} +``` + +### 2.2 Error Handling Patterns + +**Axios vs Fetch:** +- **Keep Axios** (already installed, better TypeScript support) +- Axios advantages: + - Automatic JSON parsing + - Request/response interceptors + - Better error handling with `AxiosError` + - Request cancellation built-in + - Timeout support + - Consistent API across browsers + +**Error Classification:** +```typescript +export class ConversationServiceError extends Error { + constructor( + message: string, + public statusCode?: number, + public code?: string + ) { + super(message); + this.name = 'ConversationServiceError'; + } + + isNetworkError(): boolean { + return !this.statusCode; + } + + isClientError(): boolean { + return this.statusCode ? this.statusCode >= 400 && this.statusCode < 500 : false; + } + + isServerError(): boolean { + return this.statusCode ? this.statusCode >= 500 : false; + } + + isNotFound(): boolean { + return this.statusCode === 404; + } +} +``` + +### 2.3 Request Cancellation + +React Query handles request cancellation automatically via `AbortSignal`. **No additional implementation needed** unless you need manual control: + +```typescript +// React Query provides AbortSignal automatically +static async listConversations( + options?: ListOptions, + signal?: AbortSignal +): Promise { + const response = await this.axiosInstance.get('/conversations/list', { + params: options, + signal, // Axios supports AbortSignal + }); + return response.data; +} + +// React Query automatically passes signal: +useQuery({ + queryKey: conversationKeys.lists(), + queryFn: ({ signal }) => ConversationService.listConversations(undefined, signal), +}); +``` + +--- + +## 3. Hook Architecture + +### 3.1 Query Hooks + +**Pattern: One hook per query operation** + +The existing `useConversationQuery.ts` already implements this correctly. **Continue this pattern:** + +```typescript +// app/features/conversation/hooks/queries/useConversationQuery.ts + +/** + * Hook to fetch filtered conversation list + * @param status - Filter by status (active/archived) + * @param enabled - Enable/disable query + */ +export function useConversationsListQuery( + status?: 'active' | 'archived', + options?: { enabled?: boolean } +) { + return useQuery({ + queryKey: conversationKeys.list({ status }), + queryFn: async () => { + const conversations = await ConversationService.listConversations({ + status, + limit: 100, // As per Fran's requirement: 50-100 conversations + }); + return ConversationListSchema.parse(conversations); + }, + enabled: options?.enabled ?? true, + staleTime: 1000 * 60 * 2, + gcTime: 1000 * 60 * 10, + retry: 2, + }); +} + +/** + * Hook to fetch single conversation with messages + * @param conversationId - ID of conversation to fetch + * @param enabled - Enable/disable query + */ +export function useConversationMessagesQuery( + conversationId: string, + options?: { enabled?: boolean } +) { + return useQuery({ + queryKey: conversationKeys.messages(conversationId), + queryFn: async () => { + const messages = await ConversationService.getConversationHistory(conversationId); + return MessagesSchema.parse(messages); + }, + enabled: (options?.enabled ?? true) && !!conversationId, + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 30, + retry: 2, + }); +} +``` + +### 3.2 Mutation Hooks + +**Pattern: Mutation hooks return standardized response** + +Existing `useConversationMutation.ts` follows correct patterns. **Enhance delete mutation** with list optimistic update: + +```typescript +// app/features/conversation/hooks/mutations/useConversationMutation.ts + +/** + * Hook to delete a conversation with optimistic UI updates + */ +export function useDeleteConversationMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (conversationId: string) => { + return await ConversationService.deleteConversation(conversationId); + }, + + onMutate: async (conversationId) => { + // Cancel all related queries + await queryClient.cancelQueries({ + queryKey: conversationKeys.lists() + }); + await queryClient.cancelQueries({ + queryKey: conversationKeys.detail(conversationId) + }); + + // Snapshot previous state for rollback + const previousLists = queryClient.getQueriesData({ + queryKey: conversationKeys.lists() + }); + + // Optimistically remove from all list variants + queryClient.setQueriesData( + { queryKey: conversationKeys.lists() }, + (old: ConversationListItem[] | undefined) => { + return old?.filter(conv => conv.id !== conversationId) ?? []; + } + ); + + // Remove detail cache + queryClient.removeQueries({ + queryKey: conversationKeys.detail(conversationId) + }); + + return { conversationId, previousLists }; + }, + + onSuccess: (_, conversationId) => { + // Invalidate to sync with server + queryClient.invalidateQueries({ + queryKey: conversationKeys.lists() + }); + + console.log('Conversation deleted:', conversationId); + toast.success('Conversation deleted'); + }, + + onError: (error, conversationId, context) => { + console.error('Failed to delete conversation:', error); + toast.error('Failed to delete conversation'); + + // Rollback optimistic updates + if (context?.previousLists) { + context.previousLists.forEach(([queryKey, data]) => { + queryClient.setQueryData(queryKey, data); + }); + } + + // Refetch to ensure correct state + queryClient.invalidateQueries({ + queryKey: conversationKeys.lists() + }); + }, + }); +} +``` + +**Mutation Hook Response Pattern:** +```typescript +// All mutation hooks should expose: +{ + mutate: (params) => void, // Trigger mutation + mutateAsync: (params) => Promise, // Async trigger + isPending: boolean, // Loading state + isSuccess: boolean, // Success state + isError: boolean, // Error state + error: Error | null, // Error object + data: Result | undefined, // Result data + reset: () => void, // Reset mutation state +} +``` + +### 3.3 Custom Business Hook: useSwitchConversation + +**Create a new business hook** to orchestrate conversation switching: + +```typescript +// app/features/conversation/hooks/useSwitchConversation.ts +// ABOUTME: Business hook for switching between conversations +// ABOUTME: Orchestrates loading conversation messages and updating active state + +import { useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useConversationMessagesQuery, conversationKeys } from './queries/useConversationQuery'; +import { useConversationStorage } from './useConversationStorage'; +import { toast } from 'sonner'; + +export interface UseSwitchConversationOptions { + /** + * Callback when conversation switch starts + */ + onSwitchStart?: (conversationId: string) => void; + + /** + * Callback when conversation switch completes + */ + onSwitchComplete?: (conversationId: string) => void; + + /** + * Callback when conversation switch fails + */ + onSwitchError?: (error: Error) => void; +} + +/** + * Hook for switching between conversations + * Handles loading messages, updating storage, and state management + */ +export function useSwitchConversation(options: UseSwitchConversationOptions = {}) { + const { onSwitchStart, onSwitchComplete, onSwitchError } = options; + const storage = useConversationStorage(); + const queryClient = useQueryClient(); + + /** + * Switch to a different conversation + * @param conversationId - ID of conversation to switch to + */ + const switchConversation = useCallback(async (conversationId: string) => { + // Don't switch if already on this conversation + if (storage.conversationId === conversationId) { + return; + } + + try { + // Notify switch start + onSwitchStart?.(conversationId); + + // Prefetch conversation messages if not in cache + const existingData = queryClient.getQueryData( + conversationKeys.messages(conversationId) + ); + + if (!existingData) { + await queryClient.prefetchQuery({ + queryKey: conversationKeys.messages(conversationId), + queryFn: async () => { + const messages = await ConversationService.getConversationHistory(conversationId); + return MessagesSchema.parse(messages); + }, + }); + } + + // Update storage to new conversation ID + storage.loadConversation(conversationId); + + // Notify switch complete + onSwitchComplete?.(conversationId); + + console.log('Switched to conversation:', conversationId); + } catch (error) { + console.error('Failed to switch conversation:', error); + + const errorObj = error instanceof Error ? error : new Error('Failed to switch conversation'); + onSwitchError?.(errorObj); + + toast.error('Failed to load conversation'); + } + }, [storage, queryClient, onSwitchStart, onSwitchComplete, onSwitchError]); + + return { + switchConversation, + currentConversationId: storage.conversationId, + }; +} +``` + +--- + +## 4. State Management + +### 4.1 Active Conversation Tracking + +**Recommendation: Use existing `useConversationStorage` hook** + +The current implementation already manages active conversation ID via sessionStorage. **This is the right approach** because: + +✅ **Pros of current approach:** +- Simple and focused (single responsibility) +- Persists across page reloads within session +- No additional dependencies (no Zustand needed) +- Already integrated with `useConversation` hook +- Tab-scoped (each tab has independent conversation) + +❌ **Why NOT to use Zustand/Context for this:** +- Overkill for single string value +- Adds unnecessary complexity +- Context causes re-renders on every change +- Zustand adds bundle size +- sessionStorage already provides persistence + +**Enhancement: Add active conversation validation** + +```typescript +// app/features/conversation/hooks/useConversationStorage.ts + +import { useState, useEffect, useCallback } from 'react'; +import { ConversationStorageService } from '../data/services/storage.service'; +import { useQueryClient } from '@tanstack/react-query'; +import { conversationKeys } from './queries/useConversationQuery'; + +export function useConversationStorage() { + const [conversationId, setConversationId] = useState(() => { + return ConversationStorageService.getOrCreateConversationId(); + }); + + const queryClient = useQueryClient(); + + /** + * Load a specific conversation with validation + */ + const loadConversation = useCallback(async (id: string) => { + // Validate conversation exists in cache or fetch it + const existsInCache = queryClient.getQueryData( + conversationKeys.messages(id) + ); + + if (!existsInCache) { + // Prefetch to validate it exists + try { + await queryClient.prefetchQuery({ + queryKey: conversationKeys.messages(id), + queryFn: async () => { + const messages = await ConversationService.getConversationHistory(id); + return MessagesSchema.parse(messages); + }, + }); + } catch (error) { + console.error('Failed to load conversation:', error); + throw new Error('Conversation not found'); + } + } + + // Update storage + ConversationStorageService.setConversationId(id); + setConversationId(id); + console.log('Loaded conversation:', id); + }, [queryClient]); + + /** + * Check if conversation exists before loading + */ + const conversationExists = useCallback((id: string) => { + const existsInCache = queryClient.getQueryData( + conversationKeys.messages(id) + ); + return !!existsInCache; + }, [queryClient]); + + return { + conversationId, + startNewConversation, + clearConversation, + loadConversation, + conversationExists, + hasStoredConversation, + getMetadata, + setMetadata, + }; +} +``` + +### 4.2 Syncing Active Conversation with URL Params + +**Recommendation: DON'T sync with URL params** (at least initially) + +**Rationale:** +- Current architecture uses sessionStorage (tab-scoped) +- URL params make conversations shareable (adds complexity) +- Would require authentication/authorization checks +- Browser history becomes polluted with conversation switches +- "New conversation" button semantics become unclear + +**Alternative: If you need URL routing later:** +```typescript +// Future enhancement: Optional URL routing +// app/chat/[conversationId]/page.tsx + +export default function ChatPage({ params }: { params: { conversationId: string } }) { + const { loadConversation } = useConversationStorage(); + + useEffect(() => { + if (params.conversationId) { + loadConversation(params.conversationId); + } + }, [params.conversationId, loadConversation]); + + return ; +} + +// Then use Next.js navigation: +import { useRouter } from 'next/navigation'; + +const switchConversation = (id: string) => { + router.push(`/chat/${id}`); +}; +``` + +**For now: Keep it simple with sessionStorage only.** + +### 4.3 localStorage vs sessionStorage Sync + +**Current approach: sessionStorage only** ✅ + +**Should you add localStorage sync?** + +**No**, because: +- sessionStorage is intentional (fresh conversation per tab) +- localStorage would leak conversations across tabs (confusing UX) +- MongoDB is the source of truth for persistence +- Session-scoped conversations prevent accidental cross-contamination + +**When to use localStorage:** +- User preferences (theme, sidebar state) +- Draft messages (auto-save) +- UI state (sidebar collapsed/expanded) + +### 4.4 Message State When Switching Conversations + +**Critical: Clear messages before loading new conversation** + +Enhance `useConversation` hook to support loading conversations: + +```typescript +// app/features/conversation/hooks/useConversation.tsx + +export function useConversation(options: UseConversationOptions = {}) { + // ... existing code ... + + /** + * Load an existing conversation by ID + * Clears current messages and loads history + */ + const loadConversation = useCallback(async (conversationId: string) => { + try { + // 1. Clear current messages immediately (optimistic) + setMessages([]); + setInput(''); + + // 2. Update storage to new conversation + storage.loadConversation(conversationId); + + // 3. Fetch messages for new conversation + const messages = await ConversationService.getConversationHistory(conversationId); + + // 4. Set messages from history + setMessages(messages); + + console.log('Loaded conversation:', conversationId, 'with', messages.length, 'messages'); + + return messages; + } catch (error) { + console.error('Failed to load conversation:', error); + toast.error('Failed to load conversation'); + + // On error, keep messages cleared + setMessages([]); + throw error; + } + }, [storage, setMessages, setInput]); + + return { + // ... existing returns ... + loadConversation, // NEW: Add this to returned interface + }; +} +``` + +**Usage in Sidebar:** +```typescript +// Sidebar conversation item click handler +const { loadConversation } = useConversation(); + +const handleConversationClick = async (conversationId: string) => { + setIsLoading(true); + try { + await loadConversation(conversationId); + // Optionally close sidebar on mobile + if (isMobile) { + closeSidebar(); + } + } catch (error) { + // Error already handled by loadConversation + } finally { + setIsLoading(false); + } +}; +``` + +--- + +## 5. useConversation Hook Enhancement + +### 5.1 Adding loadConversation Function + +**Implementation already outlined in Section 4.4** + +**Enhanced type safety:** +```typescript +export interface UseConversationReturn { + // Existing properties... + conversationId: string; + messages: Message[]; + + // NEW: Loading state for conversation switching + isLoadingConversation: boolean; + + // NEW: Load existing conversation + loadConversation: (conversationId: string) => Promise; + + // Existing methods... + startNewConversation: () => string; + clearMessages: () => void; +} +``` + +### 5.2 Handling Loading States During Switch + +**Add loading state tracking:** +```typescript +export function useConversation(options: UseConversationOptions = {}) { + const [isLoadingConversation, setIsLoadingConversation] = useState(false); + + const loadConversation = useCallback(async (conversationId: string) => { + setIsLoadingConversation(true); + try { + setMessages([]); + setInput(''); + storage.loadConversation(conversationId); + + const messages = await ConversationService.getConversationHistory(conversationId); + setMessages(messages); + + return messages; + } catch (error) { + console.error('Failed to load conversation:', error); + toast.error('Failed to load conversation'); + setMessages([]); + throw error; + } finally { + setIsLoadingConversation(false); + } + }, [storage, setMessages, setInput]); + + return { + // ... existing ... + isLoadingConversation, + loadConversation, + }; +} +``` + +**UI feedback during loading:** +```typescript +// In ChatContainer component +const { isLoadingConversation, messages } = useConversation(); + +if (isLoadingConversation) { + return ( +
+ +

Loading conversation...

+
+ ); +} +``` + +### 5.3 Error States When Conversation Not Found + +**Comprehensive error handling:** +```typescript +export function useConversation(options: UseConversationOptions = {}) { + const [loadError, setLoadError] = useState(null); + + const loadConversation = useCallback(async (conversationId: string) => { + setIsLoadingConversation(true); + setLoadError(null); // Clear previous errors + + try { + setMessages([]); + setInput(''); + storage.loadConversation(conversationId); + + const messages = await ConversationService.getConversationHistory(conversationId); + setMessages(messages); + + return messages; + } catch (error) { + const errorObj = error instanceof Error ? error : new Error('Failed to load conversation'); + setLoadError(errorObj); + + console.error('Failed to load conversation:', error); + + // User-friendly error messages + if (errorObj.message.includes('404') || errorObj.message.includes('not found')) { + toast.error('Conversation not found'); + } else if (errorObj.message.includes('Network')) { + toast.error('Network error. Check your connection.'); + } else { + toast.error('Failed to load conversation'); + } + + setMessages([]); + throw errorObj; + } finally { + setIsLoadingConversation(false); + } + }, [storage, setMessages, setInput]); + + return { + // ... existing ... + loadError, + clearLoadError: () => setLoadError(null), + }; +} +``` + +**Error UI:** +```typescript +// In ChatContainer component +const { loadError, clearLoadError } = useConversation(); + +if (loadError) { + return ( +
+

Failed to load conversation

+ +
+ ); +} +``` + +--- + +## 6. Performance Optimization + +### 6.1 Pagination vs Infinite Scroll + +**Recommendation: Start with simple pagination (limit 100)** + +**Rationale:** +- Fran specified "50-100 conversations" limit +- 100 conversations render instantly (no performance issues) +- Simpler implementation and maintenance +- Better UX for power users (can scan entire list) +- Avoid complexity of infinite scroll for MVP + +**Future: If conversation count exceeds 200, implement pagination** + +```typescript +// Future implementation pattern: +export function useConversationsListQuery(options?: { + page?: number; + pageSize?: number; + status?: 'active' | 'archived'; +}) { + const page = options?.page ?? 1; + const pageSize = options?.pageSize ?? 50; + + return useQuery({ + queryKey: conversationKeys.list({ + status: options?.status, + page, + pageSize + }), + queryFn: async () => { + const conversations = await ConversationService.listConversations({ + status: options?.status, + limit: pageSize, + offset: (page - 1) * pageSize, + }); + return ConversationListSchema.parse(conversations); + }, + keepPreviousData: true, // Keep old data while fetching new page + }); +} +``` + +**Don't implement infinite scroll because:** +- Adds complexity (intersection observer, scroll position management) +- Harder to search/filter +- Pagination is simpler and more predictable +- 100 items is not a performance bottleneck + +### 6.2 Debouncing Filter Changes + +**When to debounce:** +- Search input (user typing) +- Slider/range filters (rapid changes) + +**When NOT to debounce:** +- Radio buttons (active/archived status) +- Dropdown selections +- Checkboxes + +**Implementation for search (future enhancement):** +```typescript +import { useDebouncedValue } from 'usehooks-ts'; + +export function ConversationSidebar() { + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearch = useDebouncedValue(searchTerm, 300); // 300ms delay + + const { data: conversations } = useConversationsListQuery(); + + const filteredConversations = useMemo(() => { + if (!debouncedSearch) return conversations; + return conversations?.filter(conv => + conv.metadata.title?.toLowerCase().includes(debouncedSearch.toLowerCase()) + ); + }, [conversations, debouncedSearch]); + + return ( + setSearchTerm(e.target.value)} + placeholder="Search conversations..." + /> + ); +} +``` + +**Note: Fran only wants status filter (active/archived), so debouncing isn't needed for MVP.** + +### 6.3 Memoization Strategies + +**What to memoize:** + +```typescript +// 1. Expensive computations +const sortedConversations = useMemo(() => { + if (!conversations) return []; + return [...conversations].sort((a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); +}, [conversations]); + +// 2. Callback functions passed to children +const handleConversationClick = useCallback((id: string) => { + loadConversation(id); +}, [loadConversation]); + +// 3. Derived state +const hasConversations = useMemo(() => + conversations && conversations.length > 0 +, [conversations]); + +// 4. Filter functions +const activeConversations = useMemo(() => + conversations?.filter(c => c.status === 'active') ?? [] +, [conversations]); +``` + +**What NOT to memoize:** +```typescript +// ❌ Don't memoize primitives +const conversationCount = useMemo(() => conversations?.length ?? 0, [conversations]); +// ✅ Just use directly +const conversationCount = conversations?.length ?? 0; + +// ❌ Don't memoize simple object access +const firstConversation = useMemo(() => conversations?.[0], [conversations]); +// ✅ Just use directly +const firstConversation = conversations?.[0]; +``` + +### 6.4 Suspense Boundaries + +**Recommendation: DON'T use Suspense for this feature** (at least initially) + +**Rationale:** +- Suspense with React Query requires experimental features +- Loading states with `isLoading` are clearer and more debuggable +- Suspense error boundaries add complexity +- Traditional loading patterns work perfectly fine + +**If you want to use Suspense later:** +```typescript +// Enable suspense mode in query +export function useConversationsListQuery() { + return useSuspenseQuery({ // Notice: useSuspenseQuery + queryKey: conversationKeys.lists(), + queryFn: async () => { + const conversations = await ConversationService.listConversations(); + return ConversationListSchema.parse(conversations); + }, + }); +} + +// Wrap component with Suspense +}> + + +``` + +**Stick with traditional loading for now:** +```typescript +const { data, isLoading, error } = useConversationsListQuery(); + +if (isLoading) return ; +if (error) return ; +if (!data) return null; + +return ; +``` + +--- + +## 7. Integration with Existing Architecture + +### 7.1 File Structure + +``` +app/features/conversation/ +├── components/ +│ ├── chat-container.tsx # Existing +│ ├── conversation-sidebar.tsx # NEW: Main sidebar component +│ ├── conversation-list.tsx # NEW: List of conversations +│ ├── conversation-list-item.tsx # NEW: Individual item +│ ├── conversation-list-skeleton.tsx # NEW: Loading skeleton +│ └── ...existing components +├── hooks/ +│ ├── useConversation.tsx # ENHANCE: Add loadConversation +│ ├── useConversationStorage.ts # ENHANCE: Add validation +│ ├── useSwitchConversation.ts # NEW: Switching orchestration +│ ├── queries/ +│ │ └── useConversationQuery.ts # ENHANCE: Add list query +│ └── mutations/ +│ └── useConversationMutation.ts # Already has delete mutation +├── data/ +│ ├── services/ +│ │ ├── conversation.service.ts # ENHANCE: Add listConversations +│ │ └── storage.service.ts # Keep as-is +│ └── schemas/ +│ ├── conversation.schema.ts # Already has ConversationListItem +│ └── message.schema.ts # Keep as-is +``` + +### 7.2 Component Integration Pattern + +**Sidebar should be added to layout:** + +```typescript +// app/layout.tsx or app/page.tsx +import { ConversationSidebar } from '@/app/features/conversation/components/conversation-sidebar'; +import { ChatContainer } from '@/app/features/conversation/components/chat-container'; + +export default function ChatPage() { + return ( +
+ +
+ +
+
+ ); +} +``` + +**Sidebar component structure:** +```typescript +// app/features/conversation/components/conversation-sidebar.tsx +export function ConversationSidebar() { + const [statusFilter, setStatusFilter] = useState<'active' | 'archived'>('active'); + const { data: conversations, isLoading, error } = useConversationsListQuery(statusFilter); + const { switchConversation } = useSwitchConversation(); + + return ( + + ); +} +``` + +### 7.3 Backend API Expectations + +**Expected endpoints (need to be implemented by backend team):** + +1. **GET /api/conversations/list** + - Query params: `?status=active&limit=100&offset=0` + - Response: + ```typescript + [ + { + id: string; + title?: string; + status: 'active' | 'archived'; + createdAt: string; // ISO 8601 + updatedAt: string; // ISO 8601 + messageCount: number; + lastMessage?: string; // Preview of last message + } + ] + ``` + +2. **GET /api/conversations/:id/messages** + - Already implemented (used by `getConversationHistory`) + - Response: `Message[]` (Vercel AI SDK format) + +3. **DELETE /api/conversations/:id** + - Already planned in backend architecture + - Response: `{ success: boolean }` or 204 No Content + +**Schema alignment:** +- Frontend `ConversationListItemSchema` must match backend response +- Backend should transform MongoDB documents to match schema +- Use Zod on backend too for consistency (if using TypeScript) + +--- + +## 8. Testing Considerations + +### 8.1 Query Hook Tests + +```typescript +// app/features/conversation/hooks/queries/__tests__/useConversationQuery.test.ts + +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useConversationsListQuery } from '../useConversationQuery'; +import { ConversationService } from '../../../data/services/conversation.service'; + +jest.mock('../../../data/services/conversation.service'); + +describe('useConversationsListQuery', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + }); + + it('should fetch conversations successfully', async () => { + const mockConversations = [ + { id: '1', title: 'Test', createdAt: '2025-01-01T00:00:00Z', updatedAt: '2025-01-01T00:00:00Z' }, + ]; + + (ConversationService.listConversations as jest.Mock).mockResolvedValue(mockConversations); + + const { result } = renderHook(() => useConversationsListQuery(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockConversations); + }); + + it('should handle errors gracefully', async () => { + (ConversationService.listConversations as jest.Mock).mockRejectedValue( + new Error('Network error') + ); + + const { result } = renderHook(() => useConversationsListQuery(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBeDefined(); + }); +}); +``` + +### 8.2 Mutation Hook Tests + +```typescript +// Test optimistic updates, rollbacks, cache invalidation +describe('useDeleteConversationMutation', () => { + it('should optimistically remove conversation from cache', async () => { + // Setup cache with conversations + queryClient.setQueryData(conversationKeys.lists(), mockConversations); + + const { result } = renderHook(() => useDeleteConversationMutation(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + // Trigger mutation + act(() => { + result.current.mutate('conversation-1'); + }); + + // Check optimistic update + const cacheData = queryClient.getQueryData(conversationKeys.lists()); + expect(cacheData).not.toContainEqual(expect.objectContaining({ id: 'conversation-1' })); + }); + + it('should rollback on error', async () => { + // Test error rollback logic + }); +}); +``` + +### 8.3 Service Layer Tests + +```typescript +// app/features/conversation/data/services/__tests__/conversation.service.test.ts + +import { ConversationService } from '../conversation.service'; +import axios from 'axios'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('ConversationService', () => { + describe('listConversations', () => { + it('should fetch conversations with correct params', async () => { + const mockResponse = { data: [] }; + mockedAxios.create.mockReturnValue({ + get: jest.fn().mockResolvedValue(mockResponse), + } as any); + + const result = await ConversationService.listConversations({ status: 'active', limit: 100 }); + + expect(result).toEqual([]); + // Verify correct API call + }); + }); +}); +``` + +--- + +## 9. Migration Path and Rollout Strategy + +### 9.1 Phase 1: Data Layer (Week 1) + +**Goals:** +- ✅ Backend implements `/api/conversations/list` endpoint +- ✅ Frontend creates schemas, services, query hooks +- ✅ Unit tests for service layer and query hooks + +**Implementation order:** +1. Update `conversation.schema.ts` (if needed) +2. Enhance `conversation.service.ts` with `listConversations` +3. Add `useConversationsListQuery` to `useConversationQuery.ts` +4. Add tests +5. Verify with backend integration tests + +**Acceptance criteria:** +- Query hook successfully fetches conversation list +- Schemas validate backend responses +- Error handling works for network/server errors + +### 9.2 Phase 2: State Management (Week 1-2) + +**Goals:** +- ✅ Enhance `useConversationStorage` with validation +- ✅ Add `loadConversation` to `useConversation` hook +- ✅ Create `useSwitchConversation` business hook +- ✅ Add tests + +**Implementation order:** +1. Add `conversationExists` to `useConversationStorage` +2. Add `loadConversation` and loading states to `useConversation` +3. Create `useSwitchConversation` hook +4. Add integration tests +5. Test conversation switching flow end-to-end + +**Acceptance criteria:** +- Can switch between conversations without errors +- Messages clear before loading new conversation +- Loading states display correctly +- Errors handled gracefully + +### 9.3 Phase 3: Mutation Operations (Week 2) + +**Goals:** +- ✅ Enhance delete mutation with list optimistic updates +- ✅ Test optimistic updates and rollbacks +- ✅ Verify cache invalidation + +**Implementation order:** +1. Enhance `useDeleteConversationMutation` (already mostly done) +2. Add optimistic list updates +3. Test rollback scenarios +4. Integration test with backend + +**Acceptance criteria:** +- Conversation deleted immediately in UI +- Rollback works if server returns error +- Deleted conversation removed from all cache variants + +### 9.4 Phase 4: UI Integration (Week 2-3) + +**Goals:** +- ✅ Build sidebar components +- ✅ Integrate with existing chat container +- ✅ Add loading skeletons +- ✅ Mobile responsive design + +**Implementation order:** +1. Create `conversation-sidebar.tsx` +2. Create `conversation-list.tsx` and `conversation-list-item.tsx` +3. Add loading skeletons +4. Integrate into main layout +5. Add mobile hamburger menu +6. E2E tests + +**Acceptance criteria:** +- Sidebar displays conversation list +- Click conversation → loads messages +- Delete conversation → removes from list +- Mobile: sidebar collapsible +- Loading states smooth + +--- + +## 10. Important Notes and Gotchas + +### 10.1 React Query v5 Breaking Changes + +Your project uses `@tanstack/react-query` v5.90.2. **Key changes from v4:** + +✅ **Already handled correctly in existing code:** +- `cacheTime` → `gcTime` (garbage collection time) +- `useQuery` returns `isPending` instead of `isLoading` for initial load +- `useMutation` has `isPending` instead of `isLoading` + +⚠️ **Watch out for:** +- `onSuccess`, `onError`, `onSettled` in `useQuery` are deprecated + - Move to `queryClient.setQueryData` in `onSuccess` callback of `useMutation` + - Or use `useEffect` to react to query state changes + +❌ **Don't use deprecated patterns:** +```typescript +// ❌ Deprecated in v5 +useQuery({ + queryKey: ['data'], + queryFn: fetchData, + onSuccess: (data) => { + // This is deprecated! + } +}); + +// ✅ Use this instead +const query = useQuery({ + queryKey: ['data'], + queryFn: fetchData, +}); + +useEffect(() => { + if (query.isSuccess) { + // Handle success + } +}, [query.isSuccess]); +``` + +### 10.2 Vercel AI SDK Integration + +Your `useConversation` hook wraps Vercel AI SDK's `useChat`. **Critical considerations:** + +⚠️ **Message state synchronization:** +- `useChat` manages its own messages state +- When you call `setMessages([])`, it clears Vercel AI SDK's internal state +- When loading conversation, must use `setMessages(loadedMessages)` +- Don't try to manually append to `messages` array (use SDK's `append` method for new messages) + +⚠️ **Body parameter with conversationId:** +```typescript +useChat({ + body: { + conversationId: storage.conversationId, // ✅ Correct: reactive to changes + } +}); +``` +- This `body` object is sent with every message +- When `storage.conversationId` changes, new messages go to new conversation +- **But**: Old messages still display until `setMessages` is called +- **Solution**: Always clear messages when switching conversations + +### 10.3 SessionStorage Lifecycle + +**SessionStorage behavior:** +- ✅ Persists across page reloads (within same tab) +- ✅ Separate per tab (each tab has independent conversation) +- ❌ Cleared when tab closes +- ❌ Not shared across tabs + +**Implications:** +- User opens new tab → new conversation starts +- User reloads page → same conversation continues +- User closes tab → conversation ID lost (but data in MongoDB remains) + +**Edge case to handle:** +```typescript +// What if user loads a conversation, then sessionStorage has stale ID? +useEffect(() => { + // Sync React Query cache with sessionStorage on mount + const storedId = storage.conversationId; + const messagesInCache = queryClient.getQueryData( + conversationKeys.messages(storedId) + ); + + if (messagesInCache && messages.length === 0) { + // SessionStorage has ID but UI is empty → load from cache + setMessages(messagesInCache); + } +}, []); +``` + +### 10.4 Zod Schema Version + +Your project uses `zod` v4.1.11. **Key notes:** + +✅ **V4 features available:** +- `.transform()` for data transformation +- `.superRefine()` for custom validation +- `.pipe()` for schema composition +- Branded types with `.brand()` + +⚠️ **Common mistakes:** +```typescript +// ❌ Wrong: Optional with default +.optional().default('value') // Default never applies! + +// ✅ Correct: Default handles undefined +.default('value') // Automatically optional +``` + +### 10.5 Axios Timeout and Cancellation + +Your service layer uses Axios with 10s timeout: + +```typescript +axios.create({ + timeout: 10000, // 10 seconds +}); +``` + +**Implications:** +- Long-running queries fail after 10s +- Loading large conversations may timeout +- **Solution**: Increase timeout for specific calls: + +```typescript +static async getConversationHistory(conversationId: string): Promise { + const response = await this.axiosInstance.get( + `/conversations/${conversationId}/messages`, + { timeout: 30000 } // 30 seconds for large conversations + ); + return response.data; +} +``` + +**Automatic cancellation:** +- React Query cancels requests when component unmounts +- Axios respects `AbortSignal` automatically +- No manual cleanup needed + +### 10.6 Performance: 100 Conversations Rendering + +**Expectation: 100 items should render instantly** + +But if performance issues arise: + +1. **Use virtualization (react-virtual or react-window):** + ```typescript + import { useVirtualizer } from '@tanstack/react-virtual'; + + const virtualizer = useVirtualizer({ + count: conversations.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 60, // 60px per item + }); + ``` + +2. **Memoize list items:** + ```typescript + const ConversationListItem = memo(({ conversation, onClick }) => { + // Component implementation + }); + ``` + +3. **Debounce scroll events:** + - Only needed if you add infinite scroll + - Not needed for simple list + +**For 100 items: None of these optimizations should be necessary.** + +### 10.7 MongoDB Date Serialization + +**Backend consideration (inform backend team):** + +MongoDB stores dates as `Date` objects. When serialized to JSON: +```json +{ + "createdAt": "2025-10-08T12:34:56.789Z" // ✅ ISO 8601 string +} +``` + +**Zod schema expects:** +```typescript +z.string().datetime() // ISO 8601 string +``` + +**Backend must ensure:** +- Dates serialized as ISO 8601 strings, not MongoDB `ISODate` objects +- Timezone is UTC (Z suffix) +- Milliseconds included for precision + +**Example backend transformation:** +```typescript +// Backend: Convert MongoDB document to API response +const conversationDto = { + id: doc._id.toString(), + createdAt: doc.createdAt.toISOString(), // ✅ Convert Date to string + updatedAt: doc.updatedAt.toISOString(), +}; +``` + +--- + +## 11. Summary and Key Decisions + +### Architectural Decisions + +| Decision | Rationale | +|----------|-----------| +| **Use existing query key factory** | Already well-designed, hierarchical, supports filters | +| **Keep sessionStorage (no URL routing)** | Simpler UX, tab-scoped conversations, avoid auth complexity | +| **Don't use Zustand/Context for active conversation** | Overkill for single string value, sessionStorage sufficient | +| **Keep Axios over fetch** | Better TypeScript support, error handling, already installed | +| **Use simple pagination (limit 100)** | Meets requirements, simpler than infinite scroll | +| **Don't use Suspense** | Traditional loading states clearer, no experimental features | +| **Optimistic delete with rollback** | Better UX, instant feedback, graceful error handling | +| **Prefetch on hover (debounced 300ms)** | Improves perceived performance without over-fetching | + +### Files to Create + +``` +app/features/conversation/ +├── components/ +│ ├── conversation-sidebar.tsx # NEW +│ ├── conversation-list.tsx # NEW +│ ├── conversation-list-item.tsx # NEW +│ ├── conversation-list-skeleton.tsx # NEW +│ └── conversation-sidebar-header.tsx # NEW +├── hooks/ +│ └── useSwitchConversation.ts # NEW +``` + +### Files to Enhance + +``` +app/features/conversation/ +├── hooks/ +│ ├── useConversation.tsx # Add: loadConversation +│ ├── useConversationStorage.ts # Add: validation +│ ├── queries/useConversationQuery.ts # Add: list query (if not exists) +│ └── mutations/useConversationMutation.ts # Enhance: delete optimistic +├── data/ +│ └── services/ +│ └── conversation.service.ts # Add: listConversations, error handling +``` + +### Backend Dependencies + +Backend team must implement: +1. `GET /api/conversations/list?status=active&limit=100` +2. Response schema matching `ConversationListItemSchema` +3. Proper date serialization (ISO 8601) +4. Hard delete for `DELETE /api/conversations/:id` + +### Testing Requirements + +- Unit tests: Query hooks, mutation hooks, service layer +- Integration tests: Hook composition, cache invalidation +- E2E tests: Conversation switching flow, delete with rollback +- Edge cases: Empty lists, network errors, 404s + +--- + +## 12. Next Steps for Implementation + +1. **Review this document** with the team +2. **Confirm backend API contract** matches frontend expectations +3. **Start with Phase 1** (data layer) - can be done in parallel with backend +4. **Mock API responses** for frontend development if backend not ready +5. **Create Storybook stories** for UI components (optional but recommended) +6. **Write tests as you go** - don't defer to the end + +--- + +## Appendix: Code Examples + +### Complete useConversationsListQuery Hook + +```typescript +// app/features/conversation/hooks/queries/useConversationQuery.ts + +/** + * Hook to fetch filtered conversation list + */ +export function useConversationsListQuery( + options?: { + status?: 'active' | 'archived'; + enabled?: boolean; + } +) { + const { status, enabled = true } = options ?? {}; + + return useQuery({ + queryKey: conversationKeys.list({ status }), + queryFn: async () => { + const conversations = await ConversationService.listConversations({ + status, + limit: 100, // As per requirements + }); + return ConversationListSchema.parse(conversations); + }, + enabled, + staleTime: 1000 * 60 * 2, // 2 minutes + gcTime: 1000 * 60 * 10, // 10 minutes + retry: (failureCount, error: any) => { + // Don't retry on 404 or 403 + if (error?.response?.status === 404 || error?.response?.status === 403) { + return false; + } + return failureCount < 2; + }, + onError: (error: any) => { + console.error('Failed to fetch conversations:', error); + + if (error?.response?.status === 404) { + toast.error('Conversations not found'); + } else if (error?.response?.status === 403) { + toast.error('Access denied'); + } else if (error?.response?.status >= 500) { + toast.error('Server error. Please try again later.'); + } else { + toast.error('Failed to load conversations'); + } + }, + }); +} +``` + +### Complete Enhanced conversation.service.ts + +```typescript +// app/features/conversation/data/services/conversation.service.ts + +import axios, { AxiosInstance, AxiosError } from 'axios'; +import { Message } from 'ai'; +import type { ConversationListItem } from '../schemas/conversation.schema'; + +export class ConversationServiceError extends Error { + constructor( + message: string, + public statusCode?: number, + public code?: string + ) { + super(message); + this.name = 'ConversationServiceError'; + } +} + +export class ConversationService { + private static axiosInstance: AxiosInstance = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, + timeout: 10000, + }); + + /** + * Fetch paginated list of conversations + */ + static async listConversations(options?: { + status?: 'active' | 'archived'; + limit?: number; + offset?: number; + }): Promise { + try { + const params = new URLSearchParams(); + if (options?.status) params.append('status', options.status); + if (options?.limit) params.append('limit', options.limit.toString()); + if (options?.offset) params.append('offset', options.offset.toString()); + + const queryString = params.toString(); + const url = `/conversations/list${queryString ? `?${queryString}` : ''}`; + + const response = await this.axiosInstance.get(url); + return response.data; + } catch (error) { + throw this.handleError(error); + } + } + + /** + * Fetch conversation messages by ID + */ + static async getConversationHistory(conversationId: string): Promise { + try { + const response = await this.axiosInstance.get( + `/conversations/${conversationId}/messages`, + { timeout: 30000 } // 30s for large conversations + ); + return response.data; + } catch (error) { + throw this.handleError(error); + } + } + + /** + * Delete conversation by ID + */ + static async deleteConversation(conversationId: string): Promise { + try { + await this.axiosInstance.delete(`/conversations/${conversationId}`); + return true; + } catch (error) { + throw this.handleError(error); + } + } + + /** + * Centralized error handling + */ + private static handleError(error: unknown): ConversationServiceError { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError<{ error?: string; message?: string }>; + + if (axiosError.response) { + const message = + axiosError.response.data?.error || + axiosError.response.data?.message || + `Request failed with status ${axiosError.response.status}`; + + return new ConversationServiceError( + message, + axiosError.response.status, + axiosError.code + ); + } else if (axiosError.request) { + return new ConversationServiceError( + 'Network error: No response from server', + undefined, + axiosError.code + ); + } + } + + return new ConversationServiceError( + error instanceof Error ? error.message : 'Unknown error occurred' + ); + } +} +``` + +--- + +**End of Document** + +This architectural guidance provides a comprehensive foundation for implementing the conversation history management frontend data layer. Follow the established patterns, refer to existing implementations, and maintain consistency with the project's architecture. + +For questions or clarifications, consult with the backend team on API contracts and with the UI/UX team on component designs. diff --git a/.claude/doc/chat_history/frontend-testing-strategy.md b/.claude/doc/chat_history/frontend-testing-strategy.md new file mode 100644 index 0000000..52de66c --- /dev/null +++ b/.claude/doc/chat_history/frontend-testing-strategy.md @@ -0,0 +1,1994 @@ +# Frontend Testing Strategy for Conversation History Feature + +## Document Overview +This document provides comprehensive testing strategies and patterns for the conversation history frontend implementation. It covers React component testing, custom hooks, React Query integration, and MSW for API mocking. + +**Target Audience**: Frontend developers implementing tests for the conversation history sidebar feature. + +**Last Updated**: 2025-10-08 + +--- + +## Table of Contents +1. [Testing Philosophy & Principles](#testing-philosophy--principles) +2. [Test Environment Setup](#test-environment-setup) +3. [Component Testing Strategy](#component-testing-strategy) +4. [Hook Testing Strategy](#hook-testing-strategy) +5. [Integration Testing Strategy](#integration-testing-strategy) +6. [Mock Strategy & Patterns](#mock-strategy--patterns) +7. [E2E Testing with Playwright](#e2e-testing-with-playwright) +8. [Test Utilities & Helpers](#test-utilities--helpers) +9. [Coverage Requirements](#coverage-requirements) +10. [Common Pitfalls & Solutions](#common-pitfalls--solutions) + +--- + +## Testing Philosophy & Principles + +### Core Testing Approach +Following the **Testing Trophy** methodology: +- **Unit Tests (20%)**: Pure functions, utilities, value objects +- **Integration Tests (60%)**: Component + hooks + React Query interactions +- **E2E Tests (20%)**: Critical user flows with Playwright + +### Guiding Principles +1. **Test Behavior, Not Implementation**: Focus on what users see and interact with +2. **User-Centric Queries**: Prefer `getByRole` > `getByLabelText` > `getByText` > `getByTestId` +3. **Integration Over Isolation**: Test components with their hooks and providers +4. **Realistic Mocking**: Use MSW for API mocking to simulate real network behavior +5. **Accessibility First**: Ensure components are testable via ARIA roles and labels + +--- + +## Test Environment Setup + +### Vitest Configuration Enhancement + +Create a separate config for frontend tests: + +**File**: `vitest.config.frontend.ts` + +```typescript +import { defineConfig } from 'vitest/config'; +import path from 'path'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + // Use jsdom for React component testing + environment: 'jsdom', + + // Enable global test APIs + globals: true, + + // Setup file for test utilities + setupFiles: ['./app/__test-setup__/setup.ts'], + + // Test file patterns + include: [ + 'app/**/__tests__/**/*.test.{ts,tsx}', + 'app/**/*.test.{ts,tsx}' + ], + exclude: ['node_modules', 'dist', '.next', 'coverage', 'src/**'], + + // Auto-reset mocks + clearMocks: true, + resetMocks: true, + restoreMocks: true, + + // Test timeout + testTimeout: 10000, + + // Coverage for frontend code + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + include: [ + 'app/features/**/*.{ts,tsx}', + ], + exclude: [ + '**/__tests__/**', + '**/__test-helpers__/**', + '**/*.test.{ts,tsx}', + '**/index.ts', + '**/*.d.ts', + ], + thresholds: { + statements: 80, + branches: 75, + functions: 80, + lines: 80, + }, + }, + }, + + resolve: { + alias: { + '@': path.resolve(__dirname, './'), + '@/components': path.resolve(__dirname, './components'), + '@/lib': path.resolve(__dirname, './lib'), + '@/hooks': path.resolve(__dirname, './hooks'), + }, + }, +}); +``` + +### Test Setup File + +**File**: `app/__test-setup__/setup.ts` + +```typescript +// ABOUTME: Global test setup for frontend tests +// ABOUTME: Configures testing-library, mocks, and global utilities + +import '@testing-library/jest-dom'; +import { cleanup } from '@testing-library/react'; +import { afterEach, beforeAll, afterAll, vi } from 'vitest'; +import { server } from '../__test-helpers__/msw/server'; + +// MSW Server lifecycle +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +// Cleanup after each test +afterEach(() => { + cleanup(); +}); + +// Mock window.matchMedia (needed for responsive components) +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +// Mock IntersectionObserver (for lazy loading) +global.IntersectionObserver = class IntersectionObserver { + constructor() {} + disconnect() {} + observe() {} + takeRecords() { return []; } + unobserve() {} +}; +``` + +--- + +## Component Testing Strategy + +### 1. Sidebar Component Testing + +**What to Test**: +- Collapse/expand functionality +- Conversation list rendering +- Filter controls interaction +- Empty states +- Error states +- Loading states +- Accessibility (keyboard navigation) + +**Test Structure**: + +```typescript +// File: app/features/conversation/components/__tests__/Sidebar.test.tsx + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Sidebar } from '../Sidebar'; +import { createTestWrapper } from '@/app/__test-helpers__/wrappers'; +import { mockConversationList } from '@/app/__test-helpers__/fixtures/conversations'; + +describe('Sidebar', () => { + describe('Rendering', () => { + it('should render sidebar with conversations list', async () => { + const wrapper = createTestWrapper(); + + render(, { wrapper }); + + // Wait for conversations to load + await waitFor(() => { + expect(screen.getByRole('navigation', { name: /conversations/i })) + .toBeInTheDocument(); + }); + + // Verify conversation items are rendered + const conversationItems = screen.getAllByRole('button', { + name: /conversation/i + }); + expect(conversationItems).toHaveLength(mockConversationList.length); + }); + + it('should show empty state when no conversations exist', async () => { + const wrapper = createTestWrapper({ + mswHandlers: [handlers.emptyConversationList()] + }); + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByText(/no conversations yet/i)).toBeInTheDocument(); + }); + }); + + it('should show loading state while fetching', () => { + const wrapper = createTestWrapper({ + mswHandlers: [handlers.slowConversationList()] + }); + + render(, { wrapper }); + + expect(screen.getByRole('status', { name: /loading/i })) + .toBeInTheDocument(); + }); + }); + + describe('Collapse/Expand', () => { + it('should toggle sidebar collapse on button click', async () => { + const user = userEvent.setup(); + const wrapper = createTestWrapper(); + + render(, { wrapper }); + + const toggleButton = screen.getByRole('button', { name: /collapse/i }); + + // Initially expanded + expect(screen.getByRole('navigation')).toHaveAttribute( + 'aria-expanded', + 'true' + ); + + // Click to collapse + await user.click(toggleButton); + + expect(screen.getByRole('navigation')).toHaveAttribute( + 'aria-expanded', + 'false' + ); + + // Click to expand + await user.click(toggleButton); + + expect(screen.getByRole('navigation')).toHaveAttribute( + 'aria-expanded', + 'true' + ); + }); + + it('should persist collapse state in localStorage', async () => { + const user = userEvent.setup(); + const wrapper = createTestWrapper(); + + render(, { wrapper }); + + const toggleButton = screen.getByRole('button', { name: /collapse/i }); + await user.click(toggleButton); + + expect(localStorage.getItem('sidebar-collapsed')).toBe('true'); + }); + }); + + describe('Filter Controls', () => { + it('should filter conversations by status', async () => { + const user = userEvent.setup(); + const wrapper = createTestWrapper(); + + render(, { wrapper }); + + // Wait for initial render + await waitFor(() => { + expect(screen.getAllByRole('button', { name: /conversation/i })) + .toHaveLength(10); + }); + + // Click "Archived" filter + const archivedFilter = screen.getByRole('radio', { + name: /archived/i + }); + await user.click(archivedFilter); + + // Should only show archived conversations + await waitFor(() => { + expect(screen.getAllByRole('button', { name: /conversation/i })) + .toHaveLength(3); + }); + }); + + it('should show all conversations when "All" filter is selected', async () => { + const user = userEvent.setup(); + const wrapper = createTestWrapper(); + + render(, { wrapper }); + + const allFilter = screen.getByRole('radio', { name: /all/i }); + await user.click(allFilter); + + await waitFor(() => { + expect(screen.getAllByRole('button', { name: /conversation/i })) + .toHaveLength(13); // Total conversations + }); + }); + }); + + describe('Error Handling', () => { + it('should display error message when fetch fails', async () => { + const wrapper = createTestWrapper({ + mswHandlers: [handlers.conversationListError()] + }); + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent( + /failed to load conversations/i + ); + }); + }); + + it('should show retry button on error', async () => { + const user = userEvent.setup(); + const wrapper = createTestWrapper({ + mswHandlers: [handlers.conversationListError()] + }); + + render(, { wrapper }); + + const retryButton = await screen.findByRole('button', { + name: /retry/i + }); + expect(retryButton).toBeInTheDocument(); + + // Click retry should refetch + await user.click(retryButton); + + await waitFor(() => { + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + }); + }); + + describe('Accessibility', () => { + it('should be keyboard navigable', async () => { + const user = userEvent.setup(); + const wrapper = createTestWrapper(); + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getAllByRole('button')).toBeDefined(); + }); + + // Tab through conversation items + await user.tab(); + expect(screen.getAllByRole('button')[0]).toHaveFocus(); + + await user.tab(); + expect(screen.getAllByRole('button')[1]).toHaveFocus(); + }); + + it('should have proper ARIA labels', async () => { + const wrapper = createTestWrapper(); + + render(, { wrapper }); + + await waitFor(() => { + const navigation = screen.getByRole('navigation', { + name: /conversations/i + }); + expect(navigation).toBeInTheDocument(); + }); + }); + }); +}); +``` + +### 2. ConversationListItem Component Testing + +**What to Test**: +- Click to select conversation +- Delete button interaction +- Active state styling +- Hover states +- Truncated title display +- Timestamp formatting + +**Test Pattern**: + +```typescript +// File: app/features/conversation/components/__tests__/ConversationListItem.test.tsx + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ConversationListItem } from '../ConversationListItem'; +import { createMockConversation } from '@/app/__test-helpers__/factories/conversation'; + +describe('ConversationListItem', () => { + it('should render conversation title', () => { + const conversation = createMockConversation({ + title: 'Test Conversation', + }); + + render( + + ); + + expect(screen.getByText('Test Conversation')).toBeInTheDocument(); + }); + + it('should call onClick when clicked', async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + const conversation = createMockConversation(); + + render( + + ); + + await user.click(screen.getByRole('button', { + name: new RegExp(conversation.title, 'i') + })); + + expect(handleClick).toHaveBeenCalledWith(conversation.id); + }); + + it('should call onDelete when delete button clicked', async () => { + const user = userEvent.setup(); + const handleDelete = vi.fn(); + const conversation = createMockConversation(); + + render( + + ); + + // Hover to reveal delete button + await user.hover(screen.getByRole('button', { + name: new RegExp(conversation.title, 'i') + })); + + const deleteButton = screen.getByRole('button', { name: /delete/i }); + await user.click(deleteButton); + + expect(handleDelete).toHaveBeenCalledWith(conversation.id); + }); + + it('should apply active styling when isActive is true', () => { + const conversation = createMockConversation(); + + render( + + ); + + const button = screen.getByRole('button', { + name: new RegExp(conversation.title, 'i') + }); + + expect(button).toHaveAttribute('aria-current', 'true'); + expect(button).toHaveClass('bg-accent'); // Or whatever active class + }); + + it('should truncate long titles', () => { + const longTitle = 'A'.repeat(100); + const conversation = createMockConversation({ title: longTitle }); + + render( + + ); + + const button = screen.getByRole('button'); + expect(button.textContent?.length).toBeLessThan(longTitle.length); + }); + + it('should format timestamp correctly', () => { + const conversation = createMockConversation({ + updatedAt: '2025-10-08T10:30:00Z', + }); + + render( + + ); + + // Expect relative time format (e.g., "2 hours ago") + expect(screen.getByText(/ago|just now/i)).toBeInTheDocument(); + }); +}); +``` + +--- + +## Hook Testing Strategy + +### 1. Testing React Query Hooks + +**Key Concepts**: +- Use `QueryClientProvider` wrapper with fresh client per test +- Test loading, success, and error states +- Verify cache updates and invalidations +- Test retry logic + +**Pattern: useConversationsListQuery** + +```typescript +// File: app/features/conversation/hooks/__tests__/useConversationListQuery.test.tsx + +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useConversationsListQuery } from '../queries/useConversationQuery'; +import { server } from '@/app/__test-helpers__/msw/server'; +import { handlers } from '@/app/__test-helpers__/msw/handlers'; +import { mockConversationList } from '@/app/__test-helpers__/fixtures/conversations'; + +describe('useConversationsListQuery', () => { + let queryClient: QueryClient; + + beforeEach(() => { + // Fresh QueryClient for each test + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, // Disable retries in tests + gcTime: 0, + }, + }, + }); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + it('should fetch conversations list successfully', async () => { + const { result } = renderHook(() => useConversationsListQuery(), { + wrapper, + }); + + // Initially loading + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + + // Wait for success + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockConversationList); + expect(result.current.error).toBeNull(); + }); + + it('should handle fetch errors gracefully', async () => { + server.use(handlers.conversationListError()); + + const { result } = renderHook(() => useConversationsListQuery(), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeDefined(); + expect(result.current.data).toBeUndefined(); + }); + + it('should respect enabled flag', async () => { + const { result } = renderHook( + () => useConversationsListQuery(false), // disabled + { wrapper } + ); + + // Should not fetch + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + + // Status should be idle, not loading + expect(result.current.status).toBe('pending'); + expect(result.current.fetchStatus).toBe('idle'); + }); + + it('should cache data with correct stale time', async () => { + const { result, rerender } = renderHook( + () => useConversationsListQuery(), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const firstData = result.current.data; + + // Rerender should use cached data + rerender(); + + expect(result.current.data).toBe(firstData); + expect(result.current.isStale).toBe(false); // Within stale time + }); + + it('should refetch on window focus if stale', async () => { + vi.useFakeTimers(); + + const { result } = renderHook(() => useConversationsListQuery(), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + // Fast-forward past stale time (2 minutes) + vi.advanceTimersByTime(1000 * 60 * 3); + + expect(result.current.isStale).toBe(true); + + // Simulate window focus (would trigger refetch) + window.dispatchEvent(new Event('focus')); + + await waitFor(() => { + expect(result.current.isFetching).toBe(true); + }); + + vi.useRealTimers(); + }); +}); +``` + +### 2. Testing Mutations with Optimistic Updates + +**Pattern: useDeleteConversationMutation** + +```typescript +// File: app/features/conversation/hooks/__tests__/useDeleteConversationMutation.test.tsx + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useDeleteConversationMutation } from '../mutations/useConversationMutation'; +import { conversationKeys } from '../queries/useConversationQuery'; +import { server } from '@/app/__test-helpers__/msw/server'; +import { handlers } from '@/app/__test-helpers__/msw/handlers'; +import { mockConversationList } from '@/app/__test-helpers__/fixtures/conversations'; +import { toast } from 'sonner'; + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe('useDeleteConversationMutation', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + mutations: { + retry: false, + }, + }, + }); + + // Pre-populate cache with conversation list + queryClient.setQueryData( + conversationKeys.lists(), + mockConversationList + ); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + it('should delete conversation and invalidate cache', async () => { + const { result } = renderHook(() => useDeleteConversationMutation(), { + wrapper, + }); + + const conversationId = mockConversationList[0].id; + + // Trigger mutation + result.current.mutate(conversationId); + + // Should be pending + await waitFor(() => { + expect(result.current.isPending).toBe(true); + }); + + // Wait for success + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + // Cache should be invalidated + const cachedData = queryClient.getQueryData(conversationKeys.lists()); + expect(cachedData).toBeDefined(); + + // Success toast should be shown + expect(toast.success).toHaveBeenCalledWith('Conversation deleted'); + }); + + it('should optimistically remove conversation from cache', async () => { + const { result } = renderHook(() => useDeleteConversationMutation(), { + wrapper, + }); + + const conversationId = mockConversationList[0].id; + + result.current.mutate(conversationId); + + // Immediately after mutation (optimistic update) + await waitFor(() => { + expect(result.current.isPending).toBe(true); + }); + + // Detail cache should be removed + const detailCache = queryClient.getQueryData( + conversationKeys.detail(conversationId) + ); + expect(detailCache).toBeUndefined(); + }); + + it('should handle deletion errors and show toast', async () => { + server.use(handlers.deleteConversationError()); + + const { result } = renderHook(() => useDeleteConversationMutation(), { + wrapper, + }); + + result.current.mutate('conv_123'); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(toast.error).toHaveBeenCalledWith('Failed to delete conversation'); + }); + + it('should refetch on error to restore state', async () => { + server.use(handlers.deleteConversationError()); + + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useDeleteConversationMutation(), { + wrapper, + }); + + result.current.mutate('conv_123'); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + // Should invalidate to refetch and restore + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: conversationKeys.detail('conv_123') + }); + }); +}); +``` + +### 3. Testing Enhanced useConversation Hook + +**Pattern: useConversation with loadConversation** + +```typescript +// File: app/features/conversation/hooks/__tests__/useConversation.test.tsx + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useConversation } from '../useConversation'; +import { mockMessages } from '@/app/__test-helpers__/fixtures/messages'; + +describe('useConversation', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + it('should initialize with empty messages', () => { + const { result } = renderHook(() => useConversation(), { wrapper }); + + expect(result.current.messages).toEqual([]); + expect(result.current.isEmpty).toBe(true); + expect(result.current.hasMessages).toBe(false); + }); + + it('should load conversation with initial messages', async () => { + const { result } = renderHook( + () => useConversation({ initialMessages: mockMessages }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.messages).toEqual(mockMessages); + }); + + expect(result.current.isEmpty).toBe(false); + expect(result.current.hasMessages).toBe(true); + }); + + it('should track conversation ID from storage', () => { + const { result } = renderHook(() => useConversation(), { wrapper }); + + expect(result.current.conversationId).toBeDefined(); + expect(result.current.conversationId).toMatch(/^conv_/); + }); + + it('should start new conversation and clear messages', async () => { + const { result } = renderHook( + () => useConversation({ initialMessages: mockMessages }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.messages.length).toBeGreaterThan(0); + }); + + const oldConversationId = result.current.conversationId; + + act(() => { + result.current.startNewConversation(); + }); + + expect(result.current.conversationId).not.toBe(oldConversationId); + expect(result.current.messages).toEqual([]); + expect(result.current.isEmpty).toBe(true); + }); + + it('should call onConversationStart on first message', async () => { + const onConversationStart = vi.fn(); + + const { result } = renderHook( + () => useConversation({ onConversationStart }), + { wrapper } + ); + + await act(async () => { + result.current.handleSubmit(); + }); + + expect(onConversationStart).toHaveBeenCalledWith( + result.current.conversationId + ); + }); + + it('should derive isThinking state correctly', async () => { + const { result } = renderHook(() => useConversation(), { wrapper }); + + // Initially not thinking + expect(result.current.isThinking).toBe(false); + + // TODO: Test thinking state during message submission + // This requires mocking the streaming API + }); + + it('should handle errors with default handler', async () => { + const { result } = renderHook(() => useConversation(), { wrapper }); + + // Trigger error by submitting with network failure + // This would require MSW handler for streaming endpoint + }); + + it('should call custom error handler when provided', async () => { + const onError = vi.fn(); + + const { result } = renderHook( + () => useConversation({ onError }), + { wrapper } + ); + + // Trigger error and verify custom handler is called + }); +}); +``` + +--- + +## Integration Testing Strategy + +### 1. Conversation Switching Flow + +**Test Scenario**: User clicks conversation in sidebar → Messages load → Chat updates + +```typescript +// File: app/features/conversation/__tests__/integration/conversation-switching.test.tsx + +import { describe, it, expect } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ChatPage } from '@/app/page'; +import { createTestWrapper } from '@/app/__test-helpers__/wrappers'; +import { mockConversationList } from '@/app/__test-helpers__/fixtures/conversations'; +import { mockMessages } from '@/app/__test-helpers__/fixtures/messages'; + +describe('Conversation Switching Integration', () => { + it('should switch conversations and load messages', async () => { + const user = userEvent.setup(); + const wrapper = createTestWrapper(); + + render(, { wrapper }); + + // Wait for sidebar to load + await waitFor(() => { + expect(screen.getAllByRole('button', { name: /conversation/i })) + .toBeDefined(); + }); + + const conversationButtons = screen.getAllByRole('button', { + name: /conversation/i + }); + + // Click second conversation + await user.click(conversationButtons[1]); + + // Should show loading state + expect(screen.getByRole('status', { name: /loading/i })) + .toBeInTheDocument(); + + // Wait for messages to load + await waitFor(() => { + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + // Verify messages are displayed + const messages = screen.getAllByRole('article'); + expect(messages.length).toBeGreaterThan(0); + + // Verify conversation is marked as active in sidebar + expect(conversationButtons[1]).toHaveAttribute('aria-current', 'true'); + }); + + it('should preserve input when switching conversations', async () => { + const user = userEvent.setup(); + const wrapper = createTestWrapper(); + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: /message/i })) + .toBeInTheDocument(); + }); + + const input = screen.getByRole('textbox', { name: /message/i }); + + // Type message + await user.type(input, 'Unsent message'); + expect(input).toHaveValue('Unsent message'); + + // Switch conversation + const conversationButtons = screen.getAllByRole('button', { + name: /conversation/i + }); + await user.click(conversationButtons[1]); + + // Input should be cleared for new conversation + await waitFor(() => { + expect(input).toHaveValue(''); + }); + }); +}); +``` + +### 2. Delete Conversation Flow + +**Test Scenario**: User deletes conversation → Optimistic update → Refetch → UI updates + +```typescript +// File: app/features/conversation/__tests__/integration/delete-conversation.test.tsx + +import { describe, it, expect } from 'vitest'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ChatPage } from '@/app/page'; +import { createTestWrapper } from '@/app/__test-helpers__/wrappers'; + +describe('Delete Conversation Integration', () => { + it('should delete conversation and update UI', async () => { + const user = userEvent.setup(); + const wrapper = createTestWrapper(); + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getAllByRole('button', { name: /conversation/i })) + .toHaveLength(10); + }); + + const initialCount = screen.getAllByRole('button', { + name: /conversation/i + }).length; + + // Hover over first conversation to reveal delete button + const firstConversation = screen.getAllByRole('button', { + name: /conversation/i + })[0]; + + await user.hover(firstConversation); + + const deleteButton = within(firstConversation.parentElement!) + .getByRole('button', { name: /delete/i }); + + // Click delete + await user.click(deleteButton); + + // Confirm deletion in dialog + const confirmButton = screen.getByRole('button', { name: /confirm/i }); + await user.click(confirmButton); + + // Should show success toast + await waitFor(() => { + expect(screen.getByText(/conversation deleted/i)).toBeInTheDocument(); + }); + + // Conversation count should decrease + await waitFor(() => { + const newCount = screen.getAllByRole('button', { + name: /conversation/i + }).length; + expect(newCount).toBe(initialCount - 1); + }); + }); + + it('should handle delete errors and restore state', async () => { + const user = userEvent.setup(); + const wrapper = createTestWrapper({ + mswHandlers: [handlers.deleteConversationError()] + }); + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getAllByRole('button', { name: /conversation/i })) + .toBeDefined(); + }); + + const initialCount = screen.getAllByRole('button', { + name: /conversation/i + }).length; + + // Attempt to delete + const firstConversation = screen.getAllByRole('button', { + name: /conversation/i + })[0]; + + await user.hover(firstConversation); + + const deleteButton = within(firstConversation.parentElement!) + .getByRole('button', { name: /delete/i }); + + await user.click(deleteButton); + await user.click(screen.getByRole('button', { name: /confirm/i })); + + // Should show error toast + await waitFor(() => { + expect(screen.getByText(/failed to delete/i)).toBeInTheDocument(); + }); + + // Count should remain the same (state restored) + expect(screen.getAllByRole('button', { name: /conversation/i })) + .toHaveLength(initialCount); + }); +}); +``` + +### 3. Filter Changes Flow + +```typescript +// File: app/features/conversation/__tests__/integration/filter-conversations.test.tsx + +import { describe, it, expect } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Sidebar } from '@/app/features/conversation/components/Sidebar'; +import { createTestWrapper } from '@/app/__test-helpers__/wrappers'; + +describe('Filter Conversations Integration', () => { + it('should filter by status and refetch', async () => { + const user = userEvent.setup(); + const wrapper = createTestWrapper(); + + render(, { wrapper }); + + // Wait for all conversations to load + await waitFor(() => { + expect(screen.getAllByRole('button', { name: /conversation/i })) + .toHaveLength(13); + }); + + // Select "Active" filter + const activeFilter = screen.getByRole('radio', { name: /active/i }); + await user.click(activeFilter); + + // Should refetch with filter parameter + await waitFor(() => { + expect(screen.getAllByRole('button', { name: /conversation/i })) + .toHaveLength(10); // Only active conversations + }); + + // Select "Archived" filter + const archivedFilter = screen.getByRole('radio', { name: /archived/i }); + await user.click(archivedFilter); + + await waitFor(() => { + expect(screen.getAllByRole('button', { name: /conversation/i })) + .toHaveLength(3); // Only archived conversations + }); + }); +}); +``` + +--- + +## Mock Strategy & Patterns + +### 1. MSW Setup + +**File**: `app/__test-helpers__/msw/server.ts` + +```typescript +// ABOUTME: MSW server configuration for API mocking in tests +// ABOUTME: Provides realistic network behavior simulation + +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); +``` + +### 2. MSW Handlers + +**File**: `app/__test-helpers__/msw/handlers.ts` + +```typescript +// ABOUTME: MSW request handlers for conversation API endpoints +// ABOUTME: Defines success and error scenarios for all conversation operations + +import { http, HttpResponse, delay } from 'msw'; +import { mockConversationList } from '../fixtures/conversations'; +import { mockMessages } from '../fixtures/messages'; + +export const handlers = [ + // GET /api/conversations - List all conversations + http.get('/api/conversations', async () => { + await delay(100); // Simulate network delay + return HttpResponse.json(mockConversationList); + }), + + // GET /api/conversations/:id/messages - Get conversation messages + http.get('/api/conversations/:id/messages', async ({ params }) => { + const { id } = params; + await delay(100); + + // Return messages for specific conversation + const messages = mockMessages[id as string] || []; + return HttpResponse.json(messages); + }), + + // DELETE /api/conversations/:id - Delete conversation + http.delete('/api/conversations/:id', async () => { + await delay(100); + return HttpResponse.json({ success: true }); + }), + + // POST /api/conversations - Create conversation + http.post('/api/conversations', async () => { + await delay(100); + return HttpResponse.json({ + conversationId: 'conv_new_123' + }); + }), + + // PATCH /api/conversations/:id - Update conversation + http.patch('/api/conversations/:id', async ({ params }) => { + await delay(100); + return HttpResponse.json({ success: true }); + }), +]; + +// Error handlers for specific test scenarios +export const errorHandlers = { + conversationListError: () => + http.get('/api/conversations', async () => { + await delay(100); + return HttpResponse.json( + { error: 'Failed to fetch conversations' }, + { status: 500 } + ); + }), + + deleteConversationError: () => + http.delete('/api/conversations/:id', async () => { + await delay(100); + return HttpResponse.json( + { error: 'Failed to delete conversation' }, + { status: 500 } + ); + }), + + conversationNotFound: (conversationId: string) => + http.get(`/api/conversations/${conversationId}/messages`, async () => { + return HttpResponse.json( + { error: 'Conversation not found' }, + { status: 404 } + ); + }), + + slowConversationList: () => + http.get('/api/conversations', async () => { + await delay(5000); // Slow response + return HttpResponse.json(mockConversationList); + }), + + emptyConversationList: () => + http.get('/api/conversations', async () => { + await delay(100); + return HttpResponse.json([]); + }), +}; +``` + +### 3. Test Fixtures + +**File**: `app/__test-helpers__/fixtures/conversations.ts` + +```typescript +// ABOUTME: Mock conversation data for testing +// ABOUTME: Provides realistic conversation objects with various states + +import type { Conversation } from '@/app/features/conversation/data/schemas/conversation.schema'; + +export const mockConversationList: Conversation[] = [ + { + id: 'conv_001', + title: 'Weather in San Francisco', + status: 'active', + createdAt: '2025-10-08T08:00:00Z', + updatedAt: '2025-10-08T09:30:00Z', + metadata: { + messageCount: 4, + lastMessageAt: '2025-10-08T09:30:00Z', + }, + }, + { + id: 'conv_002', + title: 'How to use React Query', + status: 'active', + createdAt: '2025-10-07T14:20:00Z', + updatedAt: '2025-10-07T15:45:00Z', + metadata: { + messageCount: 8, + lastMessageAt: '2025-10-07T15:45:00Z', + }, + }, + { + id: 'conv_003', + title: 'MongoDB connection issues', + status: 'archived', + createdAt: '2025-10-06T10:00:00Z', + updatedAt: '2025-10-06T11:00:00Z', + metadata: { + messageCount: 6, + lastMessageAt: '2025-10-06T11:00:00Z', + }, + }, + // ... more conversations +]; + +export const createMockConversation = ( + overrides?: Partial +): Conversation => ({ + id: 'conv_mock', + title: 'Mock Conversation', + status: 'active', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + metadata: { + messageCount: 0, + lastMessageAt: new Date().toISOString(), + }, + ...overrides, +}); +``` + +**File**: `app/__test-helpers__/fixtures/messages.ts` + +```typescript +// ABOUTME: Mock message data for testing conversation history +// ABOUTME: Provides realistic message arrays for different conversations + +import type { Message } from 'ai'; + +export const mockMessages: Record = { + 'conv_001': [ + { + id: 'msg_001', + role: 'user', + content: 'What is the weather in San Francisco?', + createdAt: new Date('2025-10-08T08:00:00Z'), + }, + { + id: 'msg_002', + role: 'assistant', + content: 'The current weather in San Francisco is...', + createdAt: new Date('2025-10-08T08:01:00Z'), + }, + ], + 'conv_002': [ + { + id: 'msg_003', + role: 'user', + content: 'How do I use React Query?', + createdAt: new Date('2025-10-07T14:20:00Z'), + }, + { + id: 'msg_004', + role: 'assistant', + content: 'React Query is a powerful data fetching library...', + createdAt: new Date('2025-10-07T14:21:00Z'), + }, + ], +}; + +export const createMockMessage = ( + overrides?: Partial +): Message => ({ + id: 'msg_mock', + role: 'user', + content: 'Mock message', + createdAt: new Date(), + ...overrides, +}); +``` + +### 4. Test Wrappers + +**File**: `app/__test-helpers__/wrappers.tsx` + +```typescript +// ABOUTME: Reusable test wrapper for React Query and other providers +// ABOUTME: Centralizes provider setup for consistent test environment + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactNode } from 'react'; + +interface TestWrapperOptions { + queryClient?: QueryClient; + mswHandlers?: any[]; +} + +export function createTestWrapper(options: TestWrapperOptions = {}) { + const { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + mutations: { + retry: false, + }, + }, + }), + mswHandlers = [], + } = options; + + // Apply MSW handlers if provided + if (mswHandlers.length > 0) { + const { server } = require('./msw/server'); + server.use(...mswHandlers); + } + + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} +``` + +--- + +## E2E Testing with Playwright + +### 1. Setup Playwright + +**Installation**: +```bash +yarn add -D @playwright/test +npx playwright install +``` + +**Configuration**: `playwright.config.ts` + +```typescript +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], + + webServer: { + command: 'yarn dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, +}); +``` + +### 2. Database Seeding for E2E + +**File**: `e2e/helpers/db-seed.ts` + +```typescript +// ABOUTME: Database seeding utilities for E2E tests +// ABOUTME: Provides test data setup and teardown for isolated test runs + +import { MongoClient } from 'mongodb'; + +export class TestDatabase { + private client: MongoClient; + private dbName: string = 'test_nextjs_ai_chat'; + + constructor(mongoUrl: string) { + this.client = new MongoClient(mongoUrl); + } + + async connect() { + await this.client.connect(); + } + + async disconnect() { + await this.client.close(); + } + + async seedConversations(conversations: any[]) { + const db = this.client.db(this.dbName); + const collection = db.collection('conversations'); + + await collection.deleteMany({}); // Clear existing + await collection.insertMany(conversations); + } + + async clearConversations() { + const db = this.client.db(this.dbName); + await db.collection('conversations').deleteMany({}); + } + + async getConversationCount(): Promise { + const db = this.client.db(this.dbName); + return await db.collection('conversations').countDocuments(); + } +} +``` + +### 3. E2E Test Scenarios + +**File**: `e2e/conversation-history.spec.ts` + +```typescript +// ABOUTME: E2E tests for conversation history feature +// ABOUTME: Tests critical user flows with real database and API + +import { test, expect } from '@playwright/test'; +import { TestDatabase } from './helpers/db-seed'; +import { mockConversationList } from '../app/__test-helpers__/fixtures/conversations'; + +let testDb: TestDatabase; + +test.beforeAll(async () => { + testDb = new TestDatabase(process.env.MONGODB_URL!); + await testDb.connect(); +}); + +test.afterAll(async () => { + await testDb.clearConversations(); + await testDb.disconnect(); +}); + +test.describe('Conversation History - User Flows', () => { + test.beforeEach(async () => { + // Seed database with test conversations + await testDb.seedConversations(mockConversationList); + }); + + test('User can view conversation list in sidebar', async ({ page }) => { + await page.goto('/'); + + // Wait for sidebar to load + await page.waitForSelector('[data-testid="sidebar"]'); + + // Verify conversations are displayed + const conversations = await page.locator('[data-testid="conversation-item"]'); + await expect(conversations).toHaveCount(mockConversationList.length); + }); + + test('User can switch between conversations', async ({ page }) => { + await page.goto('/'); + + // Click on second conversation + await page.locator('[data-testid="conversation-item"]').nth(1).click(); + + // Wait for messages to load + await page.waitForSelector('[data-testid="message"]'); + + // Verify messages are displayed + const messages = await page.locator('[data-testid="message"]'); + await expect(messages.count()).toBeGreaterThan(0); + + // Verify active state in sidebar + const activeConversation = page.locator('[data-testid="conversation-item"][aria-current="true"]'); + await expect(activeConversation).toBeVisible(); + }); + + test('User can delete a conversation', async ({ page }) => { + await page.goto('/'); + + const initialCount = await page.locator('[data-testid="conversation-item"]').count(); + + // Hover over first conversation + await page.locator('[data-testid="conversation-item"]').first().hover(); + + // Click delete button + await page.locator('[data-testid="delete-conversation"]').first().click(); + + // Confirm deletion + await page.locator('button:has-text("Confirm")').click(); + + // Wait for deletion to complete + await page.waitForTimeout(500); + + // Verify conversation count decreased + const newCount = await page.locator('[data-testid="conversation-item"]').count(); + expect(newCount).toBe(initialCount - 1); + + // Verify database + const dbCount = await testDb.getConversationCount(); + expect(dbCount).toBe(initialCount - 1); + }); + + test('User can filter conversations by status', async ({ page }) => { + await page.goto('/'); + + // Click "Active" filter + await page.locator('input[type="radio"][value="active"]').check(); + + // Wait for filtered results + await page.waitForTimeout(300); + + // Verify only active conversations are shown + const activeConversations = await page.locator('[data-testid="conversation-item"]').count(); + const expectedActive = mockConversationList.filter(c => c.status === 'active').length; + expect(activeConversations).toBe(expectedActive); + }); + + test('User can create new conversation', async ({ page }) => { + await page.goto('/'); + + const initialCount = await page.locator('[data-testid="conversation-item"]').count(); + + // Click new conversation button + await page.locator('[data-testid="new-conversation"]').click(); + + // Type a message + await page.fill('[data-testid="message-input"]', 'Hello, new conversation!'); + await page.locator('[data-testid="send-message"]').click(); + + // Wait for conversation to be created + await page.waitForSelector('[data-testid="message"]'); + + // Verify conversation appears in sidebar + const newCount = await page.locator('[data-testid="conversation-item"]').count(); + expect(newCount).toBe(initialCount + 1); + + // Verify persistence in database + const dbCount = await testDb.getConversationCount(); + expect(dbCount).toBe(initialCount + 1); + }); + + test('Conversation persists after page reload', async ({ page }) => { + await page.goto('/'); + + // Select a conversation + await page.locator('[data-testid="conversation-item"]').first().click(); + await page.waitForSelector('[data-testid="message"]'); + + const conversationTitle = await page.locator('[data-testid="conversation-item"]').first().textContent(); + + // Reload page + await page.reload(); + + // Verify same conversation is still active + await page.waitForSelector('[data-testid="message"]'); + const activeTitle = await page.locator('[data-testid="conversation-item"][aria-current="true"]').textContent(); + expect(activeTitle).toBe(conversationTitle); + }); +}); +``` + +--- + +## Test Utilities & Helpers + +### 1. Factory Functions + +**File**: `app/__test-helpers__/factories/conversation.ts` + +```typescript +// ABOUTME: Factory functions for creating test conversation objects +// ABOUTME: Provides flexible conversation creation with sensible defaults + +import type { Conversation } from '@/app/features/conversation/data/schemas/conversation.schema'; + +let conversationIdCounter = 1; + +export function createMockConversation( + overrides?: Partial +): Conversation { + const id = `conv_test_${conversationIdCounter++}`; + + return { + id, + title: `Test Conversation ${conversationIdCounter}`, + status: 'active', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + metadata: { + messageCount: 0, + lastMessageAt: new Date().toISOString(), + }, + ...overrides, + }; +} + +export function createMockConversationList(count: number): Conversation[] { + return Array.from({ length: count }, (_, i) => + createMockConversation({ + title: `Conversation ${i + 1}`, + }) + ); +} + +export function createArchivedConversation( + overrides?: Partial +): Conversation { + return createMockConversation({ + status: 'archived', + ...overrides, + }); +} +``` + +### 2. Custom Render Utilities + +**File**: `app/__test-helpers__/custom-render.tsx` + +```typescript +// ABOUTME: Custom render function with all necessary providers +// ABOUTME: Simplifies component testing by wrapping common setup + +import { render, RenderOptions } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactElement, ReactNode } from 'react'; + +interface CustomRenderOptions extends Omit { + queryClient?: QueryClient; +} + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +export function renderWithProviders( + ui: ReactElement, + options: CustomRenderOptions = {} +) { + const { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }), + ...renderOptions + } = options; + + const Wrapper = createWrapper(queryClient); + + return { + ...render(ui, { wrapper: Wrapper, ...renderOptions }), + queryClient, + }; +} + +// Re-export everything from testing-library +export * from '@testing-library/react'; +export { renderWithProviders as render }; +``` + +### 3. Query Client Test Utilities + +**File**: `app/__test-helpers__/query-client-utils.ts` + +```typescript +// ABOUTME: Utilities for testing React Query cache and state +// ABOUTME: Helpers for verifying query data, invalidation, and mutations + +import { QueryClient } from '@tanstack/react-query'; +import { waitFor } from '@testing-library/react'; + +export async function waitForQueryToSucceed( + queryClient: QueryClient, + queryKey: any[] +) { + await waitFor(() => { + const state = queryClient.getQueryState(queryKey); + expect(state?.status).toBe('success'); + }); +} + +export async function waitForQueryToError( + queryClient: QueryClient, + queryKey: any[] +) { + await waitFor(() => { + const state = queryClient.getQueryState(queryKey); + expect(state?.status).toBe('error'); + }); +} + +export function getQueryData( + queryClient: QueryClient, + queryKey: any[] +): T | undefined { + return queryClient.getQueryData(queryKey); +} + +export function setQueryData( + queryClient: QueryClient, + queryKey: any[], + data: T +) { + queryClient.setQueryData(queryKey, data); +} + +export async function invalidateQueries( + queryClient: QueryClient, + queryKey: any[] +) { + await queryClient.invalidateQueries({ queryKey }); +} +``` + +--- + +## Coverage Requirements + +### Target Metrics +- **Statements**: 80%+ +- **Branches**: 75%+ +- **Functions**: 80%+ +- **Lines**: 80%+ + +### Coverage Focus Areas +1. **Critical Paths**: Conversation switching, deletion, creation +2. **Error Handling**: Network failures, 404s, validation errors +3. **Edge Cases**: Empty states, loading states, concurrent operations +4. **User Interactions**: Click, hover, keyboard navigation + +### Running Coverage + +```bash +# Frontend tests with coverage +yarn test:coverage + +# View HTML report +open coverage/index.html +``` + +--- + +## Common Pitfalls & Solutions + +### 1. **Pitfall**: Tests fail due to async state updates + +**Solution**: Use `waitFor` and `findBy` queries + +```typescript +// ❌ Bad - doesn't wait for async updates +expect(screen.getByText('Loaded')).toBeInTheDocument(); + +// ✅ Good - waits for element to appear +await waitFor(() => { + expect(screen.getByText('Loaded')).toBeInTheDocument(); +}); + +// ✅ Better - use findBy (implicit waitFor) +expect(await screen.findByText('Loaded')).toBeInTheDocument(); +``` + +### 2. **Pitfall**: React Query cache pollution between tests + +**Solution**: Create fresh QueryClient for each test + +```typescript +describe('MyComponent', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + }, + }); + }); + + // Tests use fresh queryClient +}); +``` + +### 3. **Pitfall**: MSW handlers not being reset + +**Solution**: Use `server.resetHandlers()` in `afterEach` + +```typescript +afterEach(() => { + server.resetHandlers(); // Reset to default handlers +}); +``` + +### 4. **Pitfall**: Testing implementation details + +**Solution**: Test user-visible behavior + +```typescript +// ❌ Bad - testing implementation +expect(component.state.isLoading).toBe(false); + +// ✅ Good - testing user-visible outcome +expect(screen.queryByRole('status')).not.toBeInTheDocument(); +``` + +### 5. **Pitfall**: Not cleaning up side effects + +**Solution**: Use `cleanup()` and restore mocks + +```typescript +afterEach(() => { + cleanup(); // Clean up rendered components + vi.clearAllMocks(); // Clear mock call history + vi.restoreAllMocks(); // Restore original implementations +}); +``` + +### 6. **Pitfall**: Race conditions in React Query tests + +**Solution**: Wait for query state changes + +```typescript +// ✅ Wait for query to complete +await waitFor(() => { + expect(result.current.isSuccess).toBe(true); +}); + +// Then assert on data +expect(result.current.data).toEqual(expectedData); +``` + +### 7. **Pitfall**: Incorrect userEvent usage + +**Solution**: Always `await` userEvent interactions + +```typescript +// ❌ Bad - missing await +user.click(button); + +// ✅ Good - await async action +await user.click(button); +``` + +--- + +## Summary + +This testing strategy provides: + +1. **Comprehensive Coverage**: Unit, integration, and E2E tests +2. **Realistic Mocking**: MSW for API simulation +3. **Maintainable Tests**: Reusable helpers and factories +4. **User-Centric Testing**: Focus on behavior over implementation +5. **Fast Feedback**: Optimized test execution with Vitest + +**Key Takeaways**: +- Test behavior, not implementation details +- Use MSW for realistic API mocking +- Create fresh QueryClient instances per test +- Always await async operations +- Focus on critical user flows in E2E tests +- Maintain 80%+ coverage for frontend code + +**Next Steps**: +1. Set up Vitest config for frontend tests +2. Create MSW handlers and fixtures +3. Implement component tests for Sidebar and ConversationListItem +4. Add hook tests for React Query hooks +5. Write integration tests for user flows +6. Set up Playwright for E2E tests (optional but recommended) + +This strategy ensures robust, maintainable tests that give confidence in the conversation history feature implementation. diff --git a/.claude/doc/chat_history/sidebar-ui-design.md b/.claude/doc/chat_history/sidebar-ui-design.md new file mode 100644 index 0000000..0d2195c --- /dev/null +++ b/.claude/doc/chat_history/sidebar-ui-design.md @@ -0,0 +1,988 @@ +# Conversation History Sidebar - UI/UX Design Recommendations + +**Project**: Next.js AI Chat Application +**Feature**: Chat History Sidebar +**Style Guide**: shadcn/ui (new-york) +**Theme**: Dark Mode +**Date**: 2025-10-08 + +--- + +## 1. Component Selection + +### Primary Components + +#### **Sidebar Component** (shadcn/ui v4 `Sidebar`) +- **Why**: Built-in responsive behavior, collapsible state management, mobile support +- **Key Features**: + - `SidebarProvider` - Manages sidebar state across the app + - `SidebarTrigger` - Hamburger menu button (already understood by users) + - `SidebarInset` - Main content wrapper that adjusts when sidebar opens/closes + - `collapsible="icon"` prop - Shrinks to icon-only mode (good for desktop power users) + - Built-in `useSidebar()` hook for state management (`isMobile`, `open`, `setOpen`) + +**Recommendation**: Use the shadcn Sidebar component as the foundation. It handles all the complexity of responsive collapsing, state management, and accessibility. + +#### **ScrollArea Component** +- **Why**: Handles 50-100 conversations with custom scrollbar styling +- **Usage**: Wrap the conversation list for smooth, styled scrolling +- **Benefits**: + - Consistent scrollbar appearance across browsers + - Better touch support on mobile + - Proper overflow handling without layout shifts + +#### **Button Component** +- **"New Chat" button**: `variant="default"` (primary action, visually prominent) +- **Delete button**: `variant="ghost" size="icon"` (subtle, icon-only) +- **Filter buttons**: `variant="outline"` when inactive, `variant="secondary"` when active + +#### **Badge Component** +- **Status indicators**: Show conversation status (Active/Archived) +- **Variants**: + - Active: `variant="default"` or custom blue badge (`bg-blue-500 text-white`) + - Archived: `variant="secondary"` (muted appearance) +- **Size**: Small, non-intrusive (`text-xs px-2 py-0.5`) + +#### **AlertDialog Component** +- **Delete confirmation**: Standard destructive action pattern +- **Why**: Prevents accidental deletion, follows UX best practices +- **Structure**: + ```tsx + + + + + + + Delete conversation? + + This action cannot be undone. This will permanently delete this conversation. + + + + Cancel + Delete + + + + ``` + +#### **Separator Component** +- **Usage**: Between header sections (filters and list) +- **Styling**: `className="mx-0"` for full-width separators + +#### **Skeleton Component** +- **Loading states**: Show skeleton conversation items while fetching +- **Pattern**: Use existing `.skeleton` class from `globals.css` + +--- + +## 2. Layout Structure + +### Overall Sidebar Dimensions + +#### **Collapsed State** (Mobile & Icon Mode) +- **Width**: `0` (completely hidden on mobile with overlay option) +- **Icon Mode (Desktop)**: `56px` (shows only icons) +- **Trigger**: Hamburger icon button in Navbar or main content header + +#### **Expanded State** +- **Width**: `280px` (desktop) - industry standard for sidebars +- **Mobile**: `100vw` or `80vw` (full-screen overlay with backdrop) +- **Transition**: Smooth `300ms ease-in-out` animation + +### Sidebar Internal Structure + +``` +┌─────────────────────────────────┐ +│ HEADER (fixed) │ +│ ├─ New Chat Button │ +│ └─ Filter Buttons (Active/All) │ +├─────────────────────────────────┤ +│ CONTENT (scrollable) │ +│ ├─ ScrollArea │ +│ │ ├─ ConversationItem │ +│ │ ├─ ConversationItem │ +│ │ ├─ ConversationItem (active) │ +│ │ └─ ... (50-100 items) │ +├─────────────────────────────────┤ +│ FOOTER (optional, fixed) │ +│ └─ User settings or info │ +└─────────────────────────────────┘ +``` + +### Header Section +**Height**: `auto` (flexible, ~120px with padding) +**Content**: +1. **New Chat Button** (primary CTA) + - Full width with icon: ` New Chat` + - `variant="default"` (prominent) + - Margin: `p-4 pb-2` + +2. **Filter Buttons** (Toggle group or button group) + - Layout: Horizontal flex row + - Options: "Active" | "Archived" | "All" + - Styling: + - Active filter: `variant="secondary"` + - Inactive filter: `variant="ghost"` + - Gap: `gap-2` + - Margin: `px-4 pb-4` + +3. **Separator** after header + +### List Section +**Height**: `flex-1` (fills remaining space) +**Overflow**: ScrollArea handles vertical scrolling +**Padding**: `px-2 py-2` + +**Virtualization Decision**: +- **For 50-100 items**: NOT necessary +- **Reasoning**: + - Modern browsers handle 100 DOM elements efficiently + - Each item is lightweight (text + timestamp) + - Added complexity not justified + - If list grows to 500+, then consider `@tanstack/react-virtual` + +**Empty State Design**: +```tsx +
+ +

No conversations yet

+

+ Start a new chat to begin +

+
+``` + +### Footer Section (Optional) +**Recommendation**: Skip footer for simplicity unless needed for user profile/settings +**Alternative**: Use Navbar for user-related actions + +--- + +## 3. Conversation List Item Design + +### Item Structure +```tsx + + +

+ {lastMessagePreview} +

+
+ {timestamp} + {status && {status}} +
+ +``` + +### Title Truncation Strategy +- **CSS**: `line-clamp-1` (Tailwind utility) +- **Max lines**: 1 line +- **Overflow**: Ellipsis (`...`) +- **Tooltip**: Consider adding on hover for full title visibility + ```tsx + + + +

{title}

+
+ {title} +
+
+ ``` + +### Preview Text Length +- **Characters**: 60-80 characters max +- **Truncation**: Server-side truncation preferred (consistent across devices) +- **CSS**: `line-clamp-1` (single line) +- **Logic**: + ```ts + const getPreviewText = (lastMessage: string, maxLength = 70) => { + const stripped = lastMessage.replace(/\s+/g, ' ').trim(); + return stripped.length > maxLength + ? stripped.slice(0, maxLength).trim() + '...' + : stripped; + }; + ``` + +### Timestamp Format +**Library**: Use `date-fns` (already popular in React ecosystem) + +**Install**: `yarn add date-fns` + +**Format Strategy**: +```ts +import { formatDistanceToNow, format, isToday, isYesterday } from 'date-fns'; + +const formatTimestamp = (date: Date): string => { + if (isToday(date)) { + return format(date, 'h:mm a'); // "2:30 PM" + } + if (isYesterday(date)) { + return 'Yesterday'; + } + // Within last 7 days + if (Date.now() - date.getTime() < 7 * 24 * 60 * 60 * 1000) { + return format(date, 'EEEE'); // "Monday" + } + // Older + return format(date, 'MMM d'); // "Jan 15" +}; +``` + +**Alternative (Relative)**: +```ts +// "2 hours ago", "3 days ago" +formatDistanceToNow(date, { addSuffix: true }) +``` + +**Recommendation**: Use the first approach (absolute time) for clarity. Users prefer knowing exact time for recent chats. + +### Status Indicator +**Visual Approach**: Badge component + +**Options**: +1. **Text Badge** (recommended) + - Active: No badge (default state) + - Archived: `Archived` + +2. **Dot Indicator** (alternative) + - Small colored dot next to title + - Active: Blue dot (`bg-blue-500`) + - Archived: Gray dot (`bg-muted-foreground`) + +**Recommendation**: Use text badge only for Archived state. Active conversations don't need visual clutter. + +### Hover States +```css +/* Applied via className */ +hover:bg-accent hover:text-accent-foreground +/* Smooth transition */ +transition-all duration-150 +``` + +### Active State Styling +```tsx +className={cn( + "bg-secondary text-secondary-foreground", // Active background + "border-l-2 border-primary" // Left accent border (optional, adds visual weight) +)} +``` + +**Design Tokens** (from `globals.css`): +- `--secondary`: `240 3.7% 15.9%` (dark mode - subtle highlight) +- `--primary`: `0 0% 98%` (dark mode - white/light accent) + +### Delete Button Visibility +**Recommendation**: **Show on hover** (cleaner default state) + +**Implementation**: +```tsx +
{/* Add group to parent */} + +
+``` + +**Mobile Consideration**: On touch devices, show delete button on long-press or swipe gesture (future enhancement) + +--- + +## 4. Interactions + +### Toggle Animation +**shadcn Sidebar built-in animation**: Smooth slide-in/out with backdrop + +**CSS Transitions**: +```css +/* Handled by Sidebar component */ +transition: width 300ms cubic-bezier(0.4, 0, 0.2, 1); +``` + +**Mobile Overlay**: +- Background dim: `backdrop-blur-sm bg-background/80` +- Click outside to close: Built into `SidebarProvider` + +### Click Behavior - Load Conversation +**User Experience Decision**: + +**Recommended**: **Immediate switch** (no confirmation) + +**Reasoning**: +- Most chat apps (Slack, Discord, ChatGPT) switch immediately +- Fast, responsive feel +- If users have unsaved state, handle via: + 1. Auto-save on message input changes + 2. Show toast notification: "Message saved automatically" + 3. No user input = no data loss risk + +**Implementation**: +```tsx +const handleConversationClick = (conversationId: string) => { + // Update URL + router.push(`/chat/${conversationId}`); + + // Load conversation (via useConversation hook) + loadConversation(conversationId); + + // Close sidebar on mobile + if (isMobile) { + setOpen(false); + } +}; +``` + +**Edge Case**: If implementing "unsaved drafts", show alert-dialog: +```tsx +if (hasUnsavedDraft) { + // Show AlertDialog: "You have unsaved changes. Switch anyway?" +} +``` + +### Loading States + +#### **Fetching Conversation List** +```tsx +{isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+) : ( + +)} +``` + +#### **Loading Individual Conversation** +- Show spinner or skeleton in main chat area +- Keep sidebar responsive (no blocking UI) + +### Empty State Design +**Location**: Center of scrollable list area +**Components**: +- Icon: `MessageSquare` or `MessageCircle` from `lucide-react` +- Primary text: "No conversations yet" +- Secondary text: "Start a new chat to begin" +- Optional CTA: "New Chat" button (duplicate of header button) + +**Styling**: +```tsx +
+ +

No conversations yet

+

+ Start a new chat to begin +

+ +
+``` + +### Error State Design +**Scenario**: Failed to load conversations from API + +```tsx +
+ +

Failed to load conversations

+

+ {error.message || 'Please try again'} +

+ +
+``` + +--- + +## 5. Performance Considerations + +### Virtualization +**Decision**: **NOT needed for 50-100 items** + +**Reasoning**: +- Each list item is lightweight (~300 bytes rendered HTML) +- Total: 100 items × 300 bytes = 30KB DOM +- Modern browsers handle this effortlessly +- Scroll performance is smooth without virtualization +- Avoid premature optimization + +**When to implement**: +- List grows beyond 500 items +- Performance issues detected via profiling +- Library: `@tanstack/react-virtual` (well-maintained, React Query team) + +### Lazy Loading / Pagination +**Recommendation**: **Pagination** over infinite scroll + +**Implementation**: +```tsx +// Fetch 50 most recent conversations initially +// "Load more" button at bottom of list +// Or cursor-based pagination via API +``` + +**Infinite Scroll Alternative**: +- Use `IntersectionObserver` to detect scroll bottom +- Auto-fetch next page +- Better UX for browsing history + +### Image/Avatar Optimization +**Not applicable**: No avatars in current design +**If added later**: Use Next.js `` component with lazy loading + +### Scroll Position Persistence +**Problem**: User scrolls through list, clicks conversation, returns to sidebar → scroll resets to top + +**Solution**: +```tsx +import { useEffect, useRef } from 'react'; + +const ConversationList = () => { + const scrollRef = useRef(null); + + // Save scroll position before unmount + useEffect(() => { + const savedPosition = sessionStorage.getItem('sidebar-scroll-position'); + if (savedPosition && scrollRef.current) { + scrollRef.current.scrollTop = parseInt(savedPosition, 10); + } + + return () => { + if (scrollRef.current) { + sessionStorage.setItem( + 'sidebar-scroll-position', + scrollRef.current.scrollTop.toString() + ); + } + }; + }, []); + + return ...; +}; +``` + +**Note**: shadcn `ScrollArea` may need viewport ref access. Test implementation. + +--- + +## 6. Accessibility + +### Keyboard Navigation + +#### **Tab Navigation** +- Tab order: New Chat button → Filter buttons → Conversation items → Delete buttons +- `tabIndex={0}` on all interactive elements (automatic with ` + +
+ + +
+ +
    +
  • + +
  • +
+ +``` + +#### **Live Regions** +```tsx +
+ {announcements.map(msg => {msg})} +
+ +// Usage: +announce("Conversation deleted"); +announce("Loading conversations"); +announce("5 conversations loaded"); +``` + +#### **Delete Button** +```tsx + +``` + +### Focus Management + +#### **When Opening Sidebar** +```tsx +const sidebarRef = useRef(null); + +useEffect(() => { + if (open && sidebarRef.current) { + // Focus first interactive element (New Chat button) + const firstButton = sidebarRef.current.querySelector('button'); + firstButton?.focus(); + } +}, [open]); +``` + +#### **When Closing Sidebar** +```tsx +// Return focus to sidebar trigger button +triggerButtonRef.current?.focus(); +``` + +#### **After Deleting Conversation** +```tsx +// Focus next item in list, or previous if last item +const nextIndex = deletedIndex < items.length ? deletedIndex : deletedIndex - 1; +focusItem(nextIndex); +``` + +### Color Contrast +**WCAG 2.1 AA Compliance**: All text must meet 4.5:1 contrast ratio + +**Dark Mode Colors** (from `globals.css`): +- `--foreground`: `0 0% 98%` (near white) +- `--background`: `240 10% 3.9%` (dark gray) +- Contrast ratio: ~19:1 ✓ + +**Muted Text**: +- `--muted-foreground`: `240 5% 64.9%` +- Check contrast with background: Should be ~7:1 ✓ + +**Active State**: +- Ensure `--secondary` background + `--secondary-foreground` text have sufficient contrast + +--- + +## 7. Responsive Design Strategy + +### Breakpoints (Tailwind defaults) +- **Mobile**: `< 768px` (md breakpoint) +- **Desktop**: `≥ 768px` + +### Mobile Behavior (`< 768px`) + +#### **Sidebar State** +- **Default**: Closed (overlay mode) +- **Trigger**: Hamburger icon in Navbar or floating button +- **Width**: `80vw` (allows peek at main content) or `100vw` (full screen) +- **Backdrop**: Dark overlay with blur (`backdrop-blur-sm bg-background/80`) +- **Animation**: Slide in from left + +#### **Mobile-Specific Adjustments** +```tsx +const { isMobile } = useSidebar(); + +// Adjust padding +
+ +// Close sidebar after selecting conversation +if (isMobile) { + setOpen(false); +} + +// Show delete button always (no hover on touch) + -
void; + onDelete: () => void; +} + +export function ConversationListItemComponent({ + conversation, + isActive, + onClick, + onDelete, +}: ConversationListItemProps) { + const formatTimestamp = (dateString: string) => { + try { + return formatDistanceToNow(new Date(dateString), { addSuffix: true }); + } catch { + return "Unknown"; + } + }; + + return ( + +
+ + ); +} diff --git a/app/features/conversation/components/conversation-list.tsx b/app/features/conversation/components/conversation-list.tsx new file mode 100644 index 0000000..a4d7e18 --- /dev/null +++ b/app/features/conversation/components/conversation-list.tsx @@ -0,0 +1,95 @@ +// ABOUTME: Conversation list component displaying all conversations with filtering. +// ABOUTME: Handles loading, empty, and error states with user-friendly messages. + +"use client"; + +import { AlertCircle, MessageSquare } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { ConversationListItemComponent } from "./conversation-list-item"; +import type { ConversationListItem } from "../data/schemas/conversation.schema"; + +interface ConversationListProps { + conversations: ConversationListItem[]; + activeConversationId?: string; + isLoading: boolean; + isError: boolean; + error?: Error | null; + onConversationClick: (conversationId: string) => void; + onConversationDelete: (conversationId: string) => void; + onRetry?: () => void; +} + +export function ConversationList({ + conversations, + activeConversationId, + isLoading, + isError, + error, + onConversationClick, + onConversationDelete, + onRetry, +}: ConversationListProps) { + // Loading state + if (isLoading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+ ); + } + + // Error state + if (isError) { + return ( +
+ +

Unable to load conversations

+

+ {error?.message || "An error occurred while fetching conversations"} +

+ {onRetry && ( + + )} +
+ ); + } + + // Empty state + if (conversations.length === 0) { + return ( +
+ +

No conversations yet

+

+ Start a new conversation to see it here +

+
+ ); + } + + // Conversation list + return ( + +
+ {conversations.map((conversation) => ( + onConversationClick(conversation.id)} + onDelete={() => onConversationDelete(conversation.id)} + /> + ))} +
+
+ ); +} diff --git a/app/features/conversation/components/conversation-sidebar.tsx b/app/features/conversation/components/conversation-sidebar.tsx new file mode 100644 index 0000000..693aff6 --- /dev/null +++ b/app/features/conversation/components/conversation-sidebar.tsx @@ -0,0 +1,140 @@ +// ABOUTME: Main conversation sidebar component with filtering and new conversation button. +// ABOUTME: Integrates with React Query hooks and shadcn sidebar components. + +"use client"; + +import { useState } from "react"; +import { Plus } from "lucide-react"; +import { + Sidebar, + SidebarContent, + SidebarHeader, + SidebarFooter, +} from "@/components/ui/sidebar"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { ConversationList } from "./conversation-list"; +import { useConversationsListQuery } from "../hooks/queries/useConversationQuery"; +import { useDeleteConversationMutation } from "../hooks/mutations/useConversationMutation"; +import { useConversationStorage } from "../hooks/useConversationStorage"; + +interface ConversationSidebarProps { + onNewConversation: () => void; + onConversationSelect: (conversationId: string) => void; +} + +export function ConversationSidebar({ + onNewConversation, + onConversationSelect, +}: ConversationSidebarProps) { + const [statusFilter, setStatusFilter] = useState(undefined); + const [conversationToDelete, setConversationToDelete] = useState(null); + + const { conversationId: activeConversationId } = useConversationStorage(); + + // Query for conversation list + const { + data: conversations = [], + isLoading, + isError, + error, + refetch, + } = useConversationsListQuery({ + status: statusFilter, + limit: 100, + }); + + // Delete mutation + const deleteMutation = useDeleteConversationMutation(); + + const handleDelete = () => { + if (conversationToDelete) { + deleteMutation.mutate(conversationToDelete); + setConversationToDelete(null); + } + }; + + const handleNewConversation = () => { + onNewConversation(); + }; + + const handleConversationClick = (conversationId: string) => { + onConversationSelect(conversationId); + }; + + return ( + <> + + {/* Header with New Chat button */} + + + + + + + {/* Conversation list */} + + + + + {/* Footer (optional) */} + +

+ {conversations.length} conversation{conversations.length !== 1 ? "s" : ""} +

+
+
+ + {/* Delete confirmation dialog */} + !open && setConversationToDelete(null)} + > + + + Delete conversation? + + This action cannot be undone. This will permanently delete the + conversation and all its messages. + + + + Cancel + + Delete + + + + + + ); +} diff --git a/app/features/conversation/components/message.tsx b/app/features/conversation/components/message.tsx index 3a7ddd6..858a771 100644 --- a/app/features/conversation/components/message.tsx +++ b/app/features/conversation/components/message.tsx @@ -25,7 +25,7 @@ export const PreviewMessage = ({ >
{message.role === "assistant" && ( @@ -35,67 +35,78 @@ export const PreviewMessage = ({ )}
- {message.content && ( -
- {message.content as string} -
- )} - - {message.toolInvocations && message.toolInvocations.length > 0 && ( -
- {message.toolInvocations.map((toolInvocation) => { - const { toolName, toolCallId, state } = toolInvocation; + +
+
+ + ); +}; - // Only render ONE widget per tool invocation based on state - if (toolName === "get_current_weather") { - if (state === "result") { - // Show the completed weather widget with data - const { result } = toolInvocation; - return ( -
- -
- ); - } else { - // Show loading skeleton for pending/executing states - return ( -
- -
- ); - } - } +export const MessageRender = ({ message }: { message: Message }) => { + if (message.toolInvocations && message.toolInvocations.length > 0) { + return ( +
+ {message.toolInvocations.map((toolInvocation) => { + const { toolName, toolCallId, state } = toolInvocation; - // Handle other tools - if (state === "result") { - const { result } = toolInvocation; - return ( -
-
{JSON.stringify(result, null, 2)}
-
- ); - } + // Only render ONE widget per tool invocation based on state + if (toolName === "get_current_weather") { + if (state === "result") { + // Show the completed weather widget with data + const { result } = toolInvocation; + return ( +
+ +
+ ); + } else { + // Show loading skeleton for pending/executing states + return ( +
+ +
+ ); + } + } - // Return null for non-weather tools in pending state - return null; - })} -
- )} + // Handle other tools + if (state === "result") { + const { result } = toolInvocation; + return ( +
+
{JSON.stringify(result, null, 2)}
+
+ ); + } - {message.experimental_attachments && ( -
- {message.experimental_attachments.map((attachment) => ( - - ))} -
- )} -
+ // Return null for non-weather tools in pending state + return null; + })}
- - ); + ); + } + if ( + message.content && + message.toolInvocations && + message.toolInvocations.length == 0 && + (message.role == "user" || message.role == "assistant") + ) { + return ( +
+ {message.content as string} +
+ ); + } + if (message.experimental_attachments) { + return ( +
+ {message.experimental_attachments.map((attachment) => ( + + ))} +
+ ); + } + return null; }; export const ThinkingMessage = () => { @@ -113,7 +124,7 @@ export const ThinkingMessage = () => { "flex gap-4 group-data-[role=user]/message:px-3 w-full group-data-[role=user]/message:w-fit group-data-[role=user]/message:ml-auto group-data-[role=user]/message:max-w-2xl group-data-[role=user]/message:py-2 rounded-xl", { "group-data-[role=user]/message:bg-muted": true, - }, + } )} >
diff --git a/app/features/conversation/data/schemas/conversation.schema.ts b/app/features/conversation/data/schemas/conversation.schema.ts index 30bedc6..0edb2b5 100644 --- a/app/features/conversation/data/schemas/conversation.schema.ts +++ b/app/features/conversation/data/schemas/conversation.schema.ts @@ -49,14 +49,15 @@ export const UpdateConversationSchema = z.object({ }); /** - * Schema for conversation list response + * Schema for conversation list response (matches backend API) */ export const ConversationListItemSchema = z.object({ id: z.string(), - metadata: ConversationMetadataSchema, - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), - lastMessage: z.string().optional(), + title: z.string(), + status: z.string(), + messageCount: z.number(), + createdAt: z.string(), + updatedAt: z.string(), }); export const ConversationListSchema = z.array(ConversationListItemSchema); diff --git a/app/features/conversation/data/services/conversation.service.ts b/app/features/conversation/data/services/conversation.service.ts index cee5ecc..8c88359 100644 --- a/app/features/conversation/data/services/conversation.service.ts +++ b/app/features/conversation/data/services/conversation.service.ts @@ -1,9 +1,38 @@ // ABOUTME: Service layer for conversation API communication // ABOUTME: Handles HTTP requests for conversation management and history retrieval -import axios, { AxiosInstance } from 'axios'; +import axios, { AxiosInstance, AxiosError } from 'axios'; import { Message } from 'ai'; +export class ConversationServiceError extends Error { + constructor( + message: string, + public statusCode?: number, + public originalError?: Error + ) { + super(message); + this.name = 'ConversationServiceError'; + } +} + +export interface ConversationListItem { + id: string; + title: string; + status: string; + messageCount: number; + createdAt: string; + updatedAt: string; +} + +export interface ConversationDetail { + id: string; + title: string; + status: string; + messages: Message[]; + createdAt: string; + updatedAt: string; +} + /** * Conversation service for API communication * Handles all conversation-related HTTP requests @@ -14,20 +43,80 @@ export class ConversationService { headers: { 'Content-Type': 'application/json', }, + timeout: 10000, // 10s default timeout }); + private static handleError(error: unknown, operation: string): never { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError<{error?: string}>; + const statusCode = axiosError.response?.status; + const message = axiosError.response?.data?.error || axiosError.message; + + throw new ConversationServiceError( + `${operation} failed: ${message}`, + statusCode, + error + ); + } + + throw new ConversationServiceError( + `${operation} failed: ${(error as Error).message}`, + undefined, + error as Error + ); + } + /** - * Fetch conversation history by ID - * This can be used to restore previous conversations + * List all conversations with optional filtering */ - static async getConversationHistory(conversationId: string): Promise { + static async listConversations(options?: { + status?: string; + limit?: number; + offset?: number; + }): Promise { + try { + const params = new URLSearchParams(); + if (options?.status) params.append('status', options.status); + if (options?.limit) params.append('limit', options.limit.toString()); + if (options?.offset) params.append('offset', options.offset.toString()); + + const response = await this.axiosInstance.get<{ + conversations: ConversationListItem[]; + }>(`/conversations/list?${params.toString()}`); + + return response.data.conversations; + } catch (error) { + this.handleError(error, 'List conversations'); + } + } + + /** + * Get full conversation details with messages + */ + static async getConversationById( + conversationId: string + ): Promise { try { - const response = await this.axiosInstance.get(`/conversations/${conversationId}/messages`); + const response = await this.axiosInstance.get( + `/conversations/${conversationId}`, + { timeout: 30000 } // Extended timeout for large conversations + ); return response.data; + } catch (error) { + this.handleError(error, 'Get conversation'); + } + } + + /** + * Fetch conversation messages by ID (legacy compatibility) + * @deprecated Use getConversationById instead + */ + static async getConversationHistory(conversationId: string): Promise { + try { + const conversation = await this.getConversationById(conversationId); + return conversation.messages; } catch (error) { console.error('Failed to fetch conversation history:', error); - // Return empty array as fallback for now - // In production, you might want to handle this differently return []; } } @@ -50,15 +139,13 @@ export class ConversationService { } /** - * Delete a conversation by ID + * Delete a conversation by ID (hard delete) */ - static async deleteConversation(conversationId: string): Promise { + static async deleteConversation(conversationId: string): Promise { try { await this.axiosInstance.delete(`/conversations/${conversationId}`); - return true; } catch (error) { - console.error('Failed to delete conversation:', error); - return false; + this.handleError(error, 'Delete conversation'); } } @@ -81,24 +168,6 @@ export class ConversationService { } } - /** - * Get all conversations for the current user - * This could be used for a conversation history sidebar - */ - static async listConversations(): Promise; - }>> { - try { - const response = await this.axiosInstance.get('/conversations'); - return response.data; - } catch (error) { - console.error('Failed to list conversations:', error); - return []; - } - } /** * Archive a conversation diff --git a/app/features/conversation/hooks/mutations/useConversationMutation.ts b/app/features/conversation/hooks/mutations/useConversationMutation.ts index 9970fa2..5906114 100644 --- a/app/features/conversation/hooks/mutations/useConversationMutation.ts +++ b/app/features/conversation/hooks/mutations/useConversationMutation.ts @@ -34,44 +34,71 @@ export function useCreateConversationMutation() { } /** - * Hook to delete a conversation + * Hook to delete a conversation with optimistic list updates */ export function useDeleteConversationMutation() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (conversationId: string) => { - return await ConversationService.deleteConversation(conversationId); + await ConversationService.deleteConversation(conversationId); }, onMutate: async (conversationId) => { - // Cancel outgoing queries + // Cancel outgoing queries for detail and lists await queryClient.cancelQueries({ - queryKey: conversationKeys.detail(conversationId) + queryKey: conversationKeys.detail(conversationId), + }); + await queryClient.cancelQueries({ + queryKey: conversationKeys.lists(), }); - // Optimistically remove from cache + // Snapshot previous state for rollback + const previousLists = queryClient.getQueriesData({ + queryKey: conversationKeys.lists(), + }); + + // Optimistically remove from all list caches + queryClient.setQueriesData( + { queryKey: conversationKeys.lists() }, + (old: any) => { + if (!old || !Array.isArray(old)) return old; + return old.filter((conv: any) => conv.id !== conversationId); + } + ); + + // Optimistically remove detail cache queryClient.removeQueries({ - queryKey: conversationKeys.detail(conversationId) + queryKey: conversationKeys.detail(conversationId), }); - return { conversationId }; + return { conversationId, previousLists }; }, onSuccess: (_, conversationId) => { - // Invalidate conversations list + // Invalidate conversations list to ensure consistency queryClient.invalidateQueries({ - queryKey: conversationKeys.lists() + queryKey: conversationKeys.lists(), }); console.log('Conversation deleted successfully:', conversationId); toast.success('Conversation deleted'); }, - onError: (error, conversationId) => { + onError: (error, conversationId, context) => { console.error('Failed to delete conversation:', error); toast.error('Failed to delete conversation'); - // Refetch on error to restore state + // Rollback optimistic updates + if (context?.previousLists) { + context.previousLists.forEach(([queryKey, data]) => { + queryClient.setQueryData(queryKey, data); + }); + } + + // Refetch to restore correct state queryClient.invalidateQueries({ - queryKey: conversationKeys.detail(conversationId) + queryKey: conversationKeys.lists(), + }); + queryClient.invalidateQueries({ + queryKey: conversationKeys.detail(conversationId), }); }, }); diff --git a/app/features/conversation/hooks/queries/useConversationQuery.ts b/app/features/conversation/hooks/queries/useConversationQuery.ts index 36b991c..f91e1e4 100644 --- a/app/features/conversation/hooks/queries/useConversationQuery.ts +++ b/app/features/conversation/hooks/queries/useConversationQuery.ts @@ -21,31 +21,62 @@ export const conversationKeys = { }; /** - * Hook to fetch conversation history/messages + * Hook to fetch full conversation with messages */ -export function useConversationHistoryQuery(conversationId: string, enabled = true) { +export function useConversationMessagesQuery( + conversationId: string, + options?: { enabled?: boolean } +) { return useQuery({ queryKey: conversationKeys.messages(conversationId), queryFn: async () => { - const messages = await ConversationService.getConversationHistory(conversationId); - // Validate with schema - return MessagesSchema.parse(messages); + const conversation = await ConversationService.getConversationById(conversationId); + // Validate messages with schema + const messages = MessagesSchema.parse(conversation.messages); + return { + ...conversation, + messages, + }; }, - enabled: enabled && !!conversationId, + enabled: (options?.enabled ?? true) && !!conversationId, staleTime: 1000 * 60 * 5, // 5 minutes - gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime) + gcTime: 1000 * 60 * 30, // 30 minutes retry: 2, + refetchOnMount: false, // Don't refetch on mount for messages }); } /** - * Hook to fetch list of conversations + * Hook to fetch conversation history/messages (legacy) + * @deprecated Use useConversationMessagesQuery instead */ -export function useConversationsListQuery(enabled = true) { +export function useConversationHistoryQuery(conversationId: string, enabled = true) { + const query = useConversationMessagesQuery(conversationId, { enabled }); + return { + ...query, + data: query.data?.messages, + }; +} + +/** + * Hook to fetch list of conversations with optional filtering + */ +export function useConversationsListQuery(options?: { + status?: string; + limit?: number; + offset?: number; + enabled?: boolean; +}) { + const { status, limit = 100, offset = 0, enabled = true } = options || {}; + return useQuery({ - queryKey: conversationKeys.lists(), + queryKey: conversationKeys.list({ status, limit, offset }), queryFn: async () => { - const conversations = await ConversationService.listConversations(); + const conversations = await ConversationService.listConversations({ + status, + limit, + offset, + }); // Validate with schema return ConversationListSchema.parse(conversations); }, @@ -53,26 +84,34 @@ export function useConversationsListQuery(enabled = true) { staleTime: 1000 * 60 * 2, // 2 minutes gcTime: 1000 * 60 * 10, // 10 minutes retry: 2, + refetchOnMount: true, // Always refetch list on mount }); } /** * Hook to prefetch conversation data - * Useful for preloading data before navigation + * Useful for hover-triggered preloading (debounced 300ms) */ -export function usePrefetchConversation(conversationId: string) { +export function usePrefetchConversation() { const queryClient = useQueryClient(); - const prefetchConversation = useCallback(async () => { - await queryClient.prefetchQuery({ - queryKey: conversationKeys.messages(conversationId), - queryFn: async () => { - const messages = await ConversationService.getConversationHistory(conversationId); - return MessagesSchema.parse(messages); - }, - staleTime: 1000 * 60 * 5, - }); - }, [conversationId, queryClient]); + const prefetchConversation = useCallback( + async (conversationId: string) => { + await queryClient.prefetchQuery({ + queryKey: conversationKeys.messages(conversationId), + queryFn: async () => { + const conversation = await ConversationService.getConversationById(conversationId); + const messages = MessagesSchema.parse(conversation.messages); + return { + ...conversation, + messages, + }; + }, + staleTime: 1000 * 60 * 5, + }); + }, + [queryClient] + ); return { prefetchConversation }; } diff --git a/app/features/conversation/hooks/useConversation.tsx b/app/features/conversation/hooks/useConversation.tsx index 2d12bfb..a141866 100644 --- a/app/features/conversation/hooks/useConversation.tsx +++ b/app/features/conversation/hooks/useConversation.tsx @@ -4,7 +4,10 @@ import { useChat } from 'ai/react'; import { toast } from 'sonner'; import { useConversationStorage } from './useConversationStorage'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { ConversationService } from '../data/services/conversation.service'; +import { useQueryClient } from '@tanstack/react-query'; +import { conversationKeys } from './queries/useConversationQuery'; import type { Message } from 'ai'; export interface UseConversationOptions { @@ -46,6 +49,9 @@ export function useConversation(options: UseConversationOptions = {}) { // Manage conversation storage const storage = useConversationStorage(); + // Get query client for cache invalidation + const queryClient = useQueryClient(); + // Log component lifecycle useEffect(() => { console.log('useConversation hook initialized with conversationId:', storage.conversationId); @@ -91,6 +97,7 @@ export function useConversation(options: UseConversationOptions = {}) { handleSubmit: originalHandleSubmit, handleInputChange, } = useChat({ + id: storage.conversationId, // Use id to make useChat reactive to conversation changes initialMessages, api: '/api/conversations', maxSteps, @@ -98,13 +105,30 @@ export function useConversation(options: UseConversationOptions = {}) { conversationId: storage.conversationId, }, onError: handleError, - onFinish: (message) => { - console.log('Message completed:', message); + onFinish: async (message:Message) => { + console.log('[useConversation] Stream finished:', message); + // Update conversation metadata storage.setMetadata({ lastMessageAt: new Date().toISOString(), messageCount: messages.length + 1, }); + + // Invalidate conversation list cache to show the new/updated conversation + queryClient.invalidateQueries({ queryKey: conversationKeys.lists() }); + + // Refetch the conversation from the server to ensure UI is in sync with DB + try { + console.log('[useConversation] Refetching conversation to sync with DB...'); + const conversation = await ConversationService.getConversationById(storage.conversationId); + console.log('[useConversation] Refetched conversation with', conversation.messages.length, 'messages'); + + // Update messages with the server state + setMessages(conversation.messages); + } catch (error) { + console.error('[useConversation] Failed to refetch conversation:', error); + // Don't show error to user - the streaming already worked + } }, }); @@ -139,7 +163,6 @@ export function useConversation(options: UseConversationOptions = {}) { if (messages.length === 0 && onConversationStart) { onConversationStart(storage.conversationId); } - // Call original handleSubmit - it accepts both signatures originalHandleSubmit(event as any, chatRequestOptions); }, [messages.length, onConversationStart, storage.conversationId, originalHandleSubmit]); @@ -152,6 +175,66 @@ export function useConversation(options: UseConversationOptions = {}) { console.log('Cleared messages for conversation:', storage.conversationId); }, [setMessages, storage.conversationId]); + /** + * Load conversation state for loading a conversation + */ + const [isLoadingConversation, setIsLoadingConversation] = useState(false); + const [loadError, setLoadError] = useState(null); + + /** + * Load a specific conversation by ID + */ + const loadConversation = useCallback( + async (conversationId: string) => { + try { + setIsLoadingConversation(true); + setLoadError(null); + + console.log('Loading conversation:', conversationId); + + // Clear current messages + setMessages([]); + + // Update storage to new conversation ID + storage.loadConversation(conversationId); + + // Fetch conversation from API + const conversation = await ConversationService.getConversationById(conversationId); + + // Set messages from conversation history + setMessages(conversation.messages); + + console.log( + `Loaded conversation ${conversationId} with ${conversation.messages.length} messages` + ); + + } catch (error) { + const err = error as Error; + console.error('Failed to load conversation:', err); + setLoadError(err); + + // User-friendly error messages + if (err.message.includes('404') || err.message.includes('not found')) { + toast.error('Conversation not found'); + } else if (err.message.includes('Network')) { + toast.error('Network error. Please check your connection.'); + } else { + toast.error('Failed to load conversation'); + } + } finally { + setIsLoadingConversation(false); + } + }, + [storage, setMessages] + ); + + /** + * Clear load error + */ + const clearLoadError = useCallback(() => { + setLoadError(null); + }, []); + /** * Check if conversation has messages */ @@ -193,6 +276,10 @@ export function useConversation(options: UseConversationOptions = {}) { isUserLastMessage, isThinking, + // Loading state + isLoadingConversation, + loadError, + // Message operations setMessages, setInput, @@ -207,6 +294,8 @@ export function useConversation(options: UseConversationOptions = {}) { // Conversation operations startNewConversation, + loadConversation, + clearLoadError, // Storage operations getMetadata: storage.getMetadata, diff --git a/app/features/conversation/hooks/useConversationHandlers.tsx b/app/features/conversation/hooks/useConversationHandlers.tsx new file mode 100644 index 0000000..b5003cb --- /dev/null +++ b/app/features/conversation/hooks/useConversationHandlers.tsx @@ -0,0 +1,23 @@ +// ABOUTME: Context hook for accessing conversation handlers from the layout +// ABOUTME: Provides startNewConversation and loadConversation methods to child components + +"use client"; + +import { createContext, useContext } from "react"; + +// Context for conversation handlers +export const ConversationHandlersContext = createContext<{ + setHandlers: (handlers: { + startNewConversation: () => string; + loadConversation: (id: string) => Promise; + } | null) => void; + handlers: { + startNewConversation: () => string; + loadConversation: (id: string) => Promise; + } | null; +}>({ + setHandlers: () => {}, + handlers: null, +}); + +export const useConversationHandlers = () => useContext(ConversationHandlersContext); diff --git a/app/globals.css b/app/globals.css index 8b9a6f4..623c1ae 100644 --- a/app/globals.css +++ b/app/globals.css @@ -35,6 +35,14 @@ --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; --radius: 0.5rem; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; } .dark { --background: 240 10% 3.9%; @@ -61,6 +69,14 @@ --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; } } diff --git a/app/layout.tsx b/app/layout.tsx index bc04459..a4ea514 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,7 +2,6 @@ import "./globals.css"; import { GeistSans } from "geist/font/sans"; import { Toaster } from "sonner"; import { cn } from "@/lib/utils"; -import { Navbar } from "@/components/navbar"; export const metadata = { title: "AI SDK Streaming Preview", @@ -35,7 +34,6 @@ export default function RootLayout({ - {children} diff --git a/components/navbar.tsx b/components/navbar.tsx index d67a53d..555bea3 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -1,13 +1,20 @@ "use client"; import { Button } from "./ui/button"; +import { SidebarTrigger } from "./ui/sidebar"; import { GitIcon, VercelIcon } from "../app/features/conversation/components/icons"; import Link from "next/link"; export const Navbar = () => { return ( -
- +
+
+ +

AI Chat

+
+
+ {/* Add other nav items here if needed */} +
); }; diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..57760f2 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..e87d62b --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..69b64fb --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/components/ui/scroll-area.tsx b/components/ui/scroll-area.tsx new file mode 100644 index 0000000..0b4a48d --- /dev/null +++ b/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx new file mode 100644 index 0000000..12d81c4 --- /dev/null +++ b/components/ui/separator.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx new file mode 100644 index 0000000..272cb72 --- /dev/null +++ b/components/ui/sheet.tsx @@ -0,0 +1,140 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx new file mode 100644 index 0000000..7447ff3 --- /dev/null +++ b/components/ui/sidebar.tsx @@ -0,0 +1,773 @@ +"use client" + +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { VariantProps, cva } from "class-variance-authority" +import { PanelLeft } from "lucide-react" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + +
+ {children} +
+
+
+ ) + } +) +SidebarProvider.displayName = "SidebarProvider" + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" + } +>( + ( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) + } +) +Sidebar.displayName = "Sidebar" + +const SidebarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + + ) +}) +SidebarTrigger.displayName = "SidebarTrigger" + +const SidebarRail = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( +