diff --git a/.planning/phases/06-session-chat-research-design/CHAT_REDUX_DESIGN.md b/.planning/phases/06-session-chat-research-design/CHAT_REDUX_DESIGN.md new file mode 100644 index 000000000..5f898353c --- /dev/null +++ b/.planning/phases/06-session-chat-research-design/CHAT_REDUX_DESIGN.md @@ -0,0 +1,1132 @@ +# Session Chat Redux State & WebSocket Integration Design + +**Purpose:** Design Redux state structure, async thunks, selectors, and WebSocket integration for session chat. + +**Date:** 2026-01-26 +**Phase:** 06-session-chat-research-design +**Plan:** 02 + +--- + +## Overview + +This document designs the Redux architecture for session chat in jam-ui, following patterns from Phase 3 (Backing Track), Phase 5 (JamTrack), and existing lobby chat implementation. + +**Key Design Principles:** +- Multi-channel architecture (global, session, lesson) +- Message deduplication by `msg_id` +- Unread tracking per channel (NEW functionality) +- WebSocket-first for real-time, REST for history +- State machine for fetch/send operations + +--- + +## Redux State Structure + +### 1. New Slice: sessionChatSlice + +**File:** `jam-ui/src/store/features/sessionChatSlice.js` + +**State Shape:** + +```javascript +{ + // Message storage (keyed by channel for efficient lookup) + messagesByChannel: { + 'global': [ + { + id: 'msg-uuid', + senderId: 'user-uuid', + senderName: 'John Doe', + message: 'Hello world', + createdAt: '2026-01-26T12:00:00.000Z', + channel: 'global', + lessonSessionId: null, + purpose: null + }, + // ... more messages + ], + 'session-abc123': [ + // ... session messages + ], + 'lesson-def456': [ + // ... lesson messages + ] + }, + + // Active channel (currently viewing) + activeChannel: 'session-abc123', + channelType: 'session', // 'global', 'session', 'lesson' + + // Unread counts per channel (NEW functionality) + unreadCounts: { + 'global': 0, + 'session-abc123': 3, + 'lesson-def456': 0 + }, + + // Last read timestamp per channel (for unread calculation) + lastReadAt: { + 'global': '2026-01-26T11:50:00.000Z', + 'session-abc123': '2026-01-26T11:45:00.000Z', + 'lesson-def456': null + }, + + // Fetch status per channel + fetchStatus: { + 'global': 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' + 'session-abc123': 'loading', + 'lesson-def456': 'idle' + }, + + // Fetch error per channel + fetchError: { + 'global': null, + 'session-abc123': null, + 'lesson-def456': 'Session not found' + }, + + // Send status (global for all channels) + sendStatus: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' + sendError: null, + + // Pagination cursors per channel + nextCursors: { + 'global': 20, // More messages available + 'session-abc123': null, // No more messages + 'lesson-def456': 0 + }, + + // Window state (UI) + isWindowOpen: false, + windowPosition: { x: 200, y: 200 } +} +``` + +**TypeScript Interfaces:** + +```typescript +interface ChatMessage { + id: string; // msg_id from server + senderId: string; // user_id + senderName: string; // user.name + message: string; // message text + createdAt: string; // ISO 8601 timestamp + channel: string; // 'global', 'session', 'lesson' + lessonSessionId: string | null; // if lesson channel + purpose: string | null; // 'Notation File', 'JamKazam Recording', etc. + musicNotation?: object; // if purpose is notation/audio + claimedRecording?: object; // if purpose is recording/video +} + +interface SessionChatState { + messagesByChannel: Record; + activeChannel: string | null; + channelType: 'global' | 'session' | 'lesson' | null; + unreadCounts: Record; + lastReadAt: Record; + fetchStatus: Record; + fetchError: Record; + sendStatus: 'idle' | 'loading' | 'succeeded' | 'failed'; + sendError: string | null; + nextCursors: Record; + isWindowOpen: boolean; + windowPosition: { x: number; y: number } | null; +} +``` + +--- + +## Reducers + +### Standard Reducers (createSlice) + +**1. addMessageFromWebSocket** +```javascript +addMessageFromWebSocket: (state, action) => { + const message = action.payload; + const channel = message.channel === 'session' ? message.sessionId : message.channel; + + // Initialize channel if not exists + if (!state.messagesByChannel[channel]) { + state.messagesByChannel[channel] = []; + } + + // Deduplicate by msg_id + const exists = state.messagesByChannel[channel].some(m => m.id === message.id); + if (exists) return; + + // Add message (sorted by createdAt ASC) + state.messagesByChannel[channel].push(message); + state.messagesByChannel[channel].sort((a, b) => + new Date(a.createdAt) - new Date(b.createdAt) + ); + + // Increment unread count if window closed OR viewing different channel + if (!state.isWindowOpen || state.activeChannel !== channel) { + state.unreadCounts[channel] = (state.unreadCounts[channel] || 0) + 1; + } +} +``` + +**2. setActiveChannel** +```javascript +setActiveChannel: (state, action) => { + const { channel, channelType } = action.payload; + state.activeChannel = channel; + state.channelType = channelType; +} +``` + +**3. openChatWindow** +```javascript +openChatWindow: (state) => { + state.isWindowOpen = true; + + // Reset unread count for active channel + if (state.activeChannel) { + state.unreadCounts[state.activeChannel] = 0; + state.lastReadAt[state.activeChannel] = new Date().toISOString(); + } +} +``` + +**4. closeChatWindow** +```javascript +closeChatWindow: (state) => { + state.isWindowOpen = false; +} +``` + +**5. markAsRead** +```javascript +markAsRead: (state, action) => { + const { channel } = action.payload; + state.unreadCounts[channel] = 0; + state.lastReadAt[channel] = new Date().toISOString(); +} +``` + +**6. incrementUnreadCount** +```javascript +incrementUnreadCount: (state, action) => { + const { channel } = action.payload; + state.unreadCounts[channel] = (state.unreadCounts[channel] || 0) + 1; +} +``` + +**7. setWindowPosition** +```javascript +setWindowPosition: (state, action) => { + state.windowPosition = action.payload; +} +``` + +--- + +## Async Thunks + +### 1. fetchChatHistory + +**Purpose:** Load message history for a channel (REST API). + +**Signature:** +```javascript +export const fetchChatHistory = createAsyncThunk( + 'sessionChat/fetchHistory', + async ({ channel, channelType, sessionId, lessonId }, { rejectWithValue }) => { + // Implementation + } +); +``` + +**Implementation:** +```javascript +export const fetchChatHistory = createAsyncThunk( + 'sessionChat/fetchHistory', + async ({ channel, channelType, sessionId, lessonId, limit = 20, page = 0 }, { rejectWithValue }) => { + try { + const params = { + channel: channelType, // 'global', 'session', 'lesson' + limit, + page + }; + + if (channelType === 'session' && sessionId) { + params.music_session = sessionId; + } else if (channelType === 'lesson' && lessonId) { + params.lesson_session = lessonId; + } + + const response = await getChatMessages(params); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `HTTP ${response.status}`); + } + + const data = await response.json(); + + // Convert server format to local format + const messages = data.chats.map(chat => ({ + id: chat.id, + senderId: chat.user_id, + senderName: chat.user?.name || 'Unknown', + message: chat.message, + createdAt: chat.created_at, + channel: channelType, + sessionId: channelType === 'session' ? sessionId : null, + lessonSessionId: chat.lesson_session_id, + purpose: chat.purpose, + musicNotation: chat.music_notation, + claimedRecording: chat.claimed_recording + })); + + // Server returns newest-first, but we store oldest-first + messages.reverse(); + + return { channel, messages, next: data.next }; + } catch (error) { + return rejectWithValue(error.message); + } + } +); +``` + +**Extra Reducers:** +```javascript +.addCase(fetchChatHistory.pending, (state, action) => { + const { channel } = action.meta.arg; + state.fetchStatus[channel] = 'loading'; + state.fetchError[channel] = null; +}) +.addCase(fetchChatHistory.fulfilled, (state, action) => { + const { channel, messages, next } = action.payload; + + // Initialize channel if not exists + if (!state.messagesByChannel[channel]) { + state.messagesByChannel[channel] = []; + } + + // Merge messages (deduplicate by id) + const existingIds = new Set(state.messagesByChannel[channel].map(m => m.id)); + const newMessages = messages.filter(m => !existingIds.has(m.id)); + state.messagesByChannel[channel] = [...newMessages, ...state.messagesByChannel[channel]]; + + // Sort by createdAt ASC + state.messagesByChannel[channel].sort((a, b) => + new Date(a.createdAt) - new Date(b.createdAt) + ); + + state.fetchStatus[channel] = 'succeeded'; + state.nextCursors[channel] = next; +}) +.addCase(fetchChatHistory.rejected, (state, action) => { + const { channel } = action.meta.arg; + state.fetchStatus[channel] = 'failed'; + state.fetchError[channel] = action.payload; +}) +``` + +--- + +### 2. sendMessage + +**Purpose:** Send a new chat message (REST API + optimistic update). + +**Signature:** +```javascript +export const sendMessage = createAsyncThunk( + 'sessionChat/sendMessage', + async ({ message, channel, channelType, sessionId, lessonId, targetUserId, clientId }, { dispatch, getState, rejectWithValue }) => { + // Implementation + } +); +``` + +**Implementation:** +```javascript +export const sendMessage = createAsyncThunk( + 'sessionChat/sendMessage', + async ({ message, channel, channelType, sessionId, lessonId, targetUserId, clientId }, { dispatch, getState, rejectWithValue }) => { + try { + // Build request body + const body = { + message: message.trim(), + channel: channelType, + client_id: clientId + }; + + if (channelType === 'session' && sessionId) { + body.music_session = sessionId; + } else if (channelType === 'lesson' && lessonId) { + body.lesson_session = lessonId; + body.target_user = targetUserId; + } + + // Send API request + const response = await sendChatMessage(body); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `HTTP ${response.status}`); + } + + const data = await response.json(); + + // Convert server response to local format + const sentMessage = { + id: data.id, + senderId: data.user_id, + senderName: data.user?.name || 'You', + message: data.message, + createdAt: data.created_at, + channel: channelType, + sessionId: channelType === 'session' ? sessionId : null, + lessonSessionId: data.lesson_session_id, + purpose: data.purpose + }; + + return { channel, message: sentMessage }; + } catch (error) { + return rejectWithValue(error.message); + } + } +); +``` + +**Extra Reducers:** +```javascript +.addCase(sendMessage.pending, (state) => { + state.sendStatus = 'loading'; + state.sendError = null; +}) +.addCase(sendMessage.fulfilled, (state, action) => { + const { channel, message } = action.payload; + + // Initialize channel if not exists + if (!state.messagesByChannel[channel]) { + state.messagesByChannel[channel] = []; + } + + // Add sent message (deduplicate by id) + const exists = state.messagesByChannel[channel].some(m => m.id === message.id); + if (!exists) { + state.messagesByChannel[channel].push(message); + state.messagesByChannel[channel].sort((a, b) => + new Date(a.createdAt) - new Date(b.createdAt) + ); + } + + state.sendStatus = 'succeeded'; +}) +.addCase(sendMessage.rejected, (state, action) => { + state.sendStatus = 'failed'; + state.sendError = action.payload; +}) +``` + +--- + +### 3. markMessagesAsRead (Optional - NEW API endpoint) + +**Purpose:** Mark messages as read on server (future enhancement). + +**Note:** This endpoint does NOT exist in legacy API. For Phase 6, we'll use client-side tracking only. This thunk is designed for future implementation. + +**Signature:** +```javascript +export const markMessagesAsRead = createAsyncThunk( + 'sessionChat/markMessagesAsRead', + async ({ channel, sessionId }, { rejectWithValue }) => { + // Implementation (future) + } +); +``` + +**Implementation (Future):** +```javascript +export const markMessagesAsRead = createAsyncThunk( + 'sessionChat/markMessagesAsRead', + async ({ channel, sessionId }, { rejectWithValue }) => { + try { + // NEW API endpoint (to be created in future milestone) + const response = await fetch('/api/chat/mark_read', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ channel, sessionId }) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return { channel, timestamp: new Date().toISOString() }; + } catch (error) { + return rejectWithValue(error.message); + } + } +); +``` + +**Extra Reducers (Future):** +```javascript +.addCase(markMessagesAsRead.fulfilled, (state, action) => { + const { channel, timestamp } = action.payload; + state.lastReadAt[channel] = timestamp; + state.unreadCounts[channel] = 0; +}) +``` + +**Phase 6 Implementation:** +- **Skip server-side tracking** (API endpoint doesn't exist) +- Use `markAsRead` reducer (local only) +- Store `lastReadAt` in Redux + localStorage for persistence + +--- + +## Selectors + +### Memoized Selectors (Reselect) + +**File:** `jam-ui/src/store/features/sessionChatSlice.js` + +**1. selectChatMessages** +```javascript +import { createSelector } from '@reduxjs/toolkit'; + +export const selectChatMessages = (channel) => createSelector( + state => state.sessionChat.messagesByChannel[channel] || [], + messages => messages +); +``` + +**Usage:** +```javascript +const messages = useSelector(selectChatMessages(sessionId)); +``` + +**2. selectUnreadCount** +```javascript +export const selectUnreadCount = (channel) => createSelector( + state => state.sessionChat.unreadCounts[channel] || 0, + count => count +); +``` + +**Usage:** +```javascript +const unreadCount = useSelector(selectUnreadCount(sessionId)); +``` + +**3. selectTotalUnreadCount** +```javascript +export const selectTotalUnreadCount = createSelector( + state => state.sessionChat.unreadCounts, + unreadCounts => Object.values(unreadCounts).reduce((sum, count) => sum + count, 0) +); +``` + +**Usage:** +```javascript +const totalUnread = useSelector(selectTotalUnreadCount); +``` + +**4. selectIsChatWindowOpen** +```javascript +export const selectIsChatWindowOpen = state => state.sessionChat.isWindowOpen; +``` + +**5. selectActiveChannel** +```javascript +export const selectActiveChannel = state => state.sessionChat.activeChannel; +``` + +**6. selectFetchStatus** +```javascript +export const selectFetchStatus = (channel) => createSelector( + state => state.sessionChat.fetchStatus[channel] || 'idle', + status => status +); +``` + +**7. selectSendStatus** +```javascript +export const selectSendStatus = state => state.sessionChat.sendStatus; +``` + +**8. selectSendError** +```javascript +export const selectSendError = state => state.sessionChat.sendError; +``` + +--- + +## WebSocket Integration + +### Message Handler: CHAT_MESSAGE + +**File:** `jam-ui/src/hooks/useSessionWebSocket.js` + +**Handler Registration:** +```javascript +export const useSessionWebSocket = (sessionId) => { + const dispatch = useDispatch(); + const { jamServer, isConnected } = useJamServerContext(); + + useEffect(() => { + if (!jamServer || !sessionId) return; + + const callbacks = { + // ... existing callbacks (MIXER_CHANGES, JAM_TRACK_CHANGES, etc.) + + CHAT_MESSAGE: (payload) => { + console.log('Chat message received:', payload); + + // Convert Protocol Buffer format to Redux format + const message = { + id: payload.msg_id, + senderId: payload.sender_id, + senderName: payload.sender_name, + message: payload.msg, + createdAt: payload.created_at, + channel: payload.channel, // 'global', 'session', 'lesson' + sessionId: payload.channel === 'session' ? sessionId : null, + lessonSessionId: payload.lesson_session_id, + purpose: payload.purpose, + attachmentId: payload.attachment_id, + attachmentType: payload.attachment_type, + attachmentName: payload.attachment_name + }; + + // Dispatch to Redux + dispatch(addMessageFromWebSocket(message)); + } + }; + + // Register callbacks + Object.entries(callbacks).forEach(([event, callback]) => { + if (jamServer.registerMessageCallback) { + jamServer.registerMessageCallback(event, callback); + } + }); + + // Cleanup + return () => { + Object.entries(callbacks).forEach(([event, callback]) => { + if (jamServer.unregisterMessageCallback) { + jamServer.unregisterMessageCallback(event, callback); + } + }); + }; + }, [jamServer, sessionId, isConnected, dispatch]); +}; +``` + +**WebSocket Message Format (Protocol Buffer):** + +```javascript +{ + sender_name: "John Doe", + sender_id: "user-uuid", + msg: "Hello world", + msg_id: "msg-uuid", + created_at: "2026-01-26T12:00:00.000Z", + channel: "session", // or "global", "lesson" + lesson_session_id: null, // or "lesson-uuid" if lesson channel + purpose: null, // or "Notation File", "JamKazam Recording", etc. + attachment_id: null, + attachment_type: null, + attachment_name: null +} +``` + +--- + +## Read/Unread Tracking System + +### Design Overview + +**Problem:** Session/global chat has NO persistent read/unread tracking in legacy. This is NEW functionality. + +**Solution (Phase 6 - Client-Side Tracking):** +- Store `lastReadAt` timestamp per channel in Redux +- Calculate unread count: messages with `createdAt > lastReadAt` +- Persist `lastReadAt` in localStorage for cross-session consistency +- Mark as read when chat window opens + +**Future Enhancement (Next Milestone - Server-Side Tracking):** +- Create new `chat_message_reads` table (user_id, channel, last_read_at) +- Sync read state via API: `PUT /api/chat/mark_read` +- Cross-device synchronization +- Per-message read receipts (optional) + +--- + +### State Machine + +**Unread Count States:** + +``` +[Chat Window Closed, Message Received] + ↓ +unreadCounts[channel]++ (increment) + ↓ +[Badge Shows Count] + ↓ +[User Opens Chat Window] + ↓ +lastReadAt[channel] = now() (mark as read) +unreadCounts[channel] = 0 (reset) + ↓ +[Badge Hidden] +``` + +**Redux State Transitions:** + +1. **Message Received (Window Closed):** + - Trigger: WebSocket CHAT_MESSAGE + - Action: `addMessageFromWebSocket()` + - State Change: `unreadCounts[channel]++` + +2. **Message Received (Window Open, Active Channel):** + - Trigger: WebSocket CHAT_MESSAGE + - Action: `addMessageFromWebSocket()` + - State Change: No unread count change (user is viewing) + +3. **Message Received (Window Open, Different Channel):** + - Trigger: WebSocket CHAT_MESSAGE + - Action: `addMessageFromWebSocket()` + - State Change: `unreadCounts[channel]++` (user not viewing this channel) + +4. **Window Opens:** + - Trigger: User clicks chat button + - Action: `openChatWindow()` + - State Change: `unreadCounts[activeChannel] = 0`, `lastReadAt[activeChannel] = now()` + +5. **Window Closes:** + - Trigger: User closes window + - Action: `closeChatWindow()` + - State Change: `isWindowOpen = false` + +6. **Channel Switch:** + - Trigger: User switches channel (global ↔ session ↔ lesson) + - Action: `setActiveChannel()` + `markAsRead()` + - State Change: `activeChannel` updates, unread count resets for new channel + +--- + +### Persistence Strategy + +**localStorage Schema:** + +```javascript +{ + "chatLastReadAt": { + "global": "2026-01-26T11:50:00.000Z", + "session-abc123": "2026-01-26T11:45:00.000Z", + "lesson-def456": null + } +} +``` + +**Load from localStorage (on app init):** +```javascript +// In sessionChatSlice initialState +const loadLastReadAt = () => { + try { + const stored = localStorage.getItem('chatLastReadAt'); + return stored ? JSON.parse(stored) : {}; + } catch { + return {}; + } +}; + +const initialState = { + // ... other state + lastReadAt: loadLastReadAt() +}; +``` + +**Save to localStorage (on mark as read):** +```javascript +// In markAsRead reducer +markAsRead: (state, action) => { + const { channel } = action.payload; + const timestamp = new Date().toISOString(); + + state.unreadCounts[channel] = 0; + state.lastReadAt[channel] = timestamp; + + // Persist to localStorage + try { + localStorage.setItem('chatLastReadAt', JSON.stringify(state.lastReadAt)); + } catch (error) { + console.warn('Failed to save lastReadAt to localStorage:', error); + } +} +``` + +--- + +### Unread Count Calculation (Alternative Approach) + +**Option A: Track lastReadAt, calculate on-the-fly** +- Store: `lastReadAt[channel]` +- Calculate: `messages.filter(m => m.createdAt > lastReadAt[channel]).length` +- **Pros:** Accurate, handles message deletion +- **Cons:** Performance cost for large message lists + +**Option B: Track unreadCounts, increment on receive** +- Store: `unreadCounts[channel]` +- Increment: On WebSocket message receive (if window closed) +- **Pros:** Fast lookup (no calculation) +- **Cons:** Can drift if messages deleted or sync issues + +**Phase 6 Decision: Option B (Track unreadCounts)** +- Simpler implementation +- Better performance +- Acceptable for MVP (no message deletion yet) +- Can migrate to Option A in future if needed + +--- + +## API Integration + +### REST API Client Methods + +**File:** `jam-ui/src/helpers/rest.js` + +**1. getChatMessages** +```javascript +/** + * Fetch chat message history + * @param {object} params - Query parameters + * @param {string} params.channel - Channel type: 'global', 'session', 'lesson' + * @param {string} [params.music_session] - Session UUID (required if channel='session') + * @param {string} [params.lesson_session] - Lesson UUID (required if channel='lesson') + * @param {number} [params.limit=20] - Number of messages to fetch + * @param {number} [params.page=0] - Page number (0-indexed) + * @param {number} [params.start] - Offset cursor for pagination + * @returns {Promise} + */ +export const getChatMessages = (params) => { + return fetch(`/api/chat?${new URLSearchParams(params)}`, { + credentials: 'include' // Session cookie + }); +}; +``` + +**2. sendChatMessage** +```javascript +/** + * Send a new chat message + * @param {object} body - Request body + * @param {string} body.message - Message text (1-255 chars) + * @param {string} body.channel - Channel type: 'global', 'session', 'lesson' + * @param {string} [body.music_session] - Session UUID (required if channel='session') + * @param {string} [body.lesson_session] - Lesson UUID (required if channel='lesson') + * @param {string} [body.target_user] - Target user UUID (required if channel='lesson') + * @param {string} [body.client_id] - Client UUID (for WebSocket routing) + * @returns {Promise} + */ +export const sendChatMessage = (body) => { + return fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(body) + }); +}; +``` + +**3. markChatAsRead (Future - NEW API endpoint)** +```javascript +/** + * Mark chat messages as read (server-side tracking) + * NOTE: This endpoint does NOT exist yet. Deferred to future milestone. + * @param {object} body - Request body + * @param {string} body.channel - Channel ID + * @param {string} body.sessionId - Session UUID + * @returns {Promise} + */ +export const markChatAsRead = (body) => { + return fetch('/api/chat/mark_read', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(body) + }); +}; +``` + +--- + +## Error Handling + +### API Error Scenarios + +**1. 404 Not Found (Session doesn't exist)** +```javascript +{ + error: "specified session not found" +} +``` +**Handling:** Show error message, disable send button + +**2. 403 Forbidden (No access to session)** +```javascript +{ + error: "not allowed to join the specified session" +} +``` +**Handling:** Show error message, redirect to dashboard + +**3. 422 Unprocessable Entity (Validation error)** +```javascript +{ + errors: { + message: ["is too short (minimum is 1 character)"], + user: ["Global chat is disabled for school users"] + } +} +``` +**Handling:** Show validation errors inline (e.g., "Message too short") + +**4. 500 Internal Server Error** +```javascript +{ + error: "Internal server error" +} +``` +**Handling:** Show generic error, retry button + +--- + +### WebSocket Error Scenarios + +**1. Connection Lost** +- Detect: `isConnected === false` from JamServerContext +- Handle: Disable send button, show "Disconnected" message +- Recover: Auto-reconnect via JamServer (built-in) + +**2. Message Delivery Failure** +- Detect: WebSocket error event +- Handle: Show error notification, offer retry +- Recover: Re-send message via API + +**3. Duplicate Messages** +- Detect: Message with same `msg_id` already exists +- Handle: Deduplicate in `addMessageFromWebSocket()` reducer +- Recover: No action needed (silently skip) + +--- + +## Testing Strategy + +### Unit Tests (Jest) + +**sessionChatSlice Reducers:** +```javascript +describe('sessionChatSlice reducers', () => { + test('addMessageFromWebSocket adds message to correct channel', () => { + const initialState = { + messagesByChannel: { 'session-123': [] }, + unreadCounts: { 'session-123': 0 }, + isWindowOpen: false, + activeChannel: 'session-123' + }; + + const action = addMessageFromWebSocket({ + id: 'msg-1', + message: 'Hello', + channel: 'session', + sessionId: 'session-123', + createdAt: '2026-01-26T12:00:00Z' + }); + + const newState = sessionChatReducer(initialState, action); + + expect(newState.messagesByChannel['session-123']).toHaveLength(1); + expect(newState.messagesByChannel['session-123'][0].message).toBe('Hello'); + expect(newState.unreadCounts['session-123']).toBe(1); // Window closed + }); + + test('addMessageFromWebSocket deduplicates by msg_id', () => { + const initialState = { + messagesByChannel: { + 'session-123': [ + { id: 'msg-1', message: 'Hello', createdAt: '2026-01-26T12:00:00Z' } + ] + } + }; + + const action = addMessageFromWebSocket({ + id: 'msg-1', // Duplicate + message: 'Hello', + channel: 'session', + sessionId: 'session-123', + createdAt: '2026-01-26T12:00:00Z' + }); + + const newState = sessionChatReducer(initialState, action); + + expect(newState.messagesByChannel['session-123']).toHaveLength(1); // No duplicate + }); + + test('openChatWindow resets unread count', () => { + const initialState = { + unreadCounts: { 'session-123': 5 }, + activeChannel: 'session-123', + isWindowOpen: false + }; + + const action = openChatWindow(); + const newState = sessionChatReducer(initialState, action); + + expect(newState.isWindowOpen).toBe(true); + expect(newState.unreadCounts['session-123']).toBe(0); + }); +}); +``` + +**Async Thunks:** +```javascript +describe('fetchChatHistory', () => { + test('fetches messages and updates state', async () => { + const mockResponse = { + chats: [ + { + id: 'msg-1', + message: 'Hello', + user_id: 'user-1', + user: { name: 'John' }, + created_at: '2026-01-26T12:00:00Z', + channel: 'session' + } + ], + next: null + }; + + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse) + }) + ); + + const dispatch = jest.fn(); + const getState = jest.fn(); + + await fetchChatHistory({ + channel: 'session-123', + channelType: 'session', + sessionId: 'session-123' + })(dispatch, getState, undefined); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/chat'), + expect.objectContaining({ credentials: 'include' }) + ); + }); +}); +``` + +--- + +### Integration Tests (Playwright) + +**Send Message Flow:** +```typescript +test('sends message and receives via WebSocket', async ({ page }) => { + const interceptor = new APIInterceptor(); + interceptor.intercept(page); + + await loginToJamUI(page); + await createAndJoinSession(page); + + // Open chat + await page.click('[data-testid="chat-button"]'); + + // Send message + await page.fill('[data-testid="chat-input"]', 'Test message'); + await page.click('[data-testid="chat-send"]'); + + // Verify API call + const chatCalls = interceptor.getCallsByPath('/api/chat'); + expect(chatCalls.length).toBe(1); + expect(chatCalls[0].request.postDataJSON().message).toBe('Test message'); + + // Wait for response + await page.waitForSelector('[data-testid="chat-message"]'); + + // Verify message in UI + const lastMessage = page.locator('[data-testid="chat-message"]').last(); + await expect(lastMessage).toContainText('Test message'); +}); +``` + +**Unread Badge Flow:** +```typescript +test('increments unread count on message receive', async ({ page }) => { + await loginToJamUI(page); + await createAndJoinSession(page); + + // Close chat window (if open) + await page.click('[data-testid="chat-close"]'); + + // Simulate incoming WebSocket message + await page.evaluate(() => { + window.__mockWebSocketMessage__({ + type: 'CHAT_MESSAGE', + payload: { + sender_name: 'Other User', + msg: 'Hello', + msg_id: 'msg-999', + created_at: new Date().toISOString(), + channel: 'session' + } + }); + }); + + // Verify badge appears + await expect(page.locator('[data-testid="chat-badge"]')).toHaveText('1'); + + // Open chat + await page.click('[data-testid="chat-button"]'); + + // Verify badge cleared + await expect(page.locator('[data-testid="chat-badge"]')).not.toBeVisible(); +}); +``` + +--- + +## Summary + +**Redux Architecture Designed:** +- ✅ sessionChatSlice with multi-channel state structure +- ✅ 3 async thunks (fetchChatHistory, sendMessage, markMessagesAsRead) +- ✅ 8 memoized selectors for efficient data access +- ✅ WebSocket integration via CHAT_MESSAGE handler +- ✅ Read/unread tracking system (client-side with localStorage) +- ✅ Error handling for API and WebSocket failures +- ✅ Testing strategy defined (unit, integration) + +**Key Patterns Applied:** +- Message deduplication by `msg_id` +- Multi-channel architecture (keyed by channel ID) +- Unread count increment on WebSocket receive (if window closed) +- Reset unread count on window open +- localStorage persistence for `lastReadAt` +- Follows existing Redux patterns from mediaSlice, activeSessionSlice + +**Ready for Phase 7-9 Implementation:** +- Redux state structure ready for TDD +- Async thunks specified with error handling +- Selectors optimized with Reselect +- WebSocket integration clear and modular +- Read/unread tracking logic detailed + +--- + +**Next Document:** IMPLEMENTATION_ROADMAP.md (Phases 7-11 breakdown and risk analysis)