diff --git a/.planning/codebase/CHAT_REACT_PATTERNS.md b/.planning/codebase/CHAT_REACT_PATTERNS.md new file mode 100644 index 000000000..66e80d008 --- /dev/null +++ b/.planning/codebase/CHAT_REACT_PATTERNS.md @@ -0,0 +1,1255 @@ +# React Patterns for Session Chat in jam-ui + +**Purpose:** Document available React patterns in jam-ui, identify what can be reused for session chat, and highlight gaps. + +**Date:** 2026-01-26 +**Phase:** 06-session-chat-research-design +**Plan:** 01 + +--- + +## Overview + +This document analyzes existing jam-ui patterns from Phases 2-5 to identify: +- โ **What's Available** - Components, patterns, hooks we can reuse +- โ **What's Missing** - Gaps that need to be filled for session chat +- ๐งช **TDD Candidates** - What should follow Test-Driven Development + +--- + +## Available Components + +### 1. WindowPortal (Modeless Dialog) + +**File:** `/jam-ui/src/components/common/WindowPortal.js` + +**Purpose:** Creates a popup window for modeless dialogs (used by Metronome, JamTrack player) + +**Features:** +- โ Opens new browser window with custom dimensions +- โ Copies all stylesheets from parent to popup +- โ Auto-detects window close and triggers callback +- โ Cross-window message passing (`postMessage` API) +- โ Auto-cleanup on parent window close +- โ Window ID tracking for multi-instance support + +**Props:** +```javascript +{ + children: ReactNode, + onClose: () => void, + windowFeatures: string, // "width=400,height=300,left=200,top=200,..." + title: string, + onWindowReady: (window, sendMessage) => void, + onWindowMessage: (data) => void, + windowId: string +} +``` + +**Example Usage (from JKSessionMetronomePlayer):** +```javascript +{isWindowOpen && ( + setIsWindowOpen(false)} + windowId="metronome-controls" + > + + {/* Metronome controls */} + + +)} +``` + +**Reuse for Chat:** +- โ Perfect for expanded chat dialog +- โ No modifications needed +- โ Already handles styling, lifecycle, cleanup +- **Recommendation:** Use as-is for Phase 8 (expanded chat window) + +--- + +### 2. JKModal / Dialog Patterns + +**Status:** โ NOT FOUND in jam-ui codebase + +**Legacy Pattern (web app):** +- Uses custom layout system: `layout='dialog'`, `layout-id='chat-dialog'` +- Controlled via `@app.layout.showDialog()`, `closeDialog()` + +**Gap:** +- jam-ui doesn't have equivalent modal/dialog component +- WindowPortal is used instead for modeless dialogs +- For modal-style dialogs, would need to create new component + +**Recommendation:** +- For session chat, use WindowPortal (matches legacy behavior) +- If modal dialog needed later, create `JKModal.js` component (Phase 11) + +--- + +### 3. Button Components + +**Files:** Various throughout jam-ui (not centralized yet) + +**Existing Patterns:** +```javascript +// Orange button (primary action) +SEND + +// Grey button (secondary action) +CLOSE + +// Icon buttons + + + +``` + +**Reuse for Chat:** +- โ Use existing button classes +- โ No custom button component needed for MVP +- **Recommendation:** Reuse CSS classes from legacy (already styled) + +--- + +### 4. Input Components + +**Files:** Various throughout jam-ui + +**Existing Patterns:** +```javascript +// Textarea + setMessage(e.target.value)} +/> + +// Controlled input + +``` + +**Reuse for Chat:** +- โ Use standard HTML textarea +- โ Add Enter-to-send logic (Shift+Enter for newline) +- **Recommendation:** Follow existing patterns (no custom component) + +--- + +## Redux State Patterns + +### 1. Existing Chat Slices + +#### lobbyChatMessagesSlice.js + +**File:** `/jam-ui/src/store/features/lobbyChatMessagesSlice.js` + +**Purpose:** Manages lobby (global) chat messages + +**State Structure:** +```javascript +{ + records: { + messages: [ + { + id: "msg-uuid", + message: "Hello", + user_id: "user-uuid", + user: { name: "John Doe" }, + created_at: "2026-01-26T12:00:00Z", + channel: "lobby", + status: "delivered" // added by reducer + } + ], + next: 20 // pagination cursor + }, + status: 'idle' | 'loading' | 'succeeded' | 'failed', + error: null, + create_status: 'idle' | 'loading' | 'succeeded' | 'failed' +} +``` + +**Async Thunks:** +```javascript +fetchLobbyChatMessages({ channel: 'lobby', lastOnly: false }) +postNewChatMessage({ message, channel, music_session, client_id }) +``` + +**Reducers:** +```javascript +addMessage(state, action) // Add message to state (for WebSocket) +``` + +**Key Features:** +- โ Sorts messages by `created_at` (oldest first) +- โ Maps server response to local format +- โ Adds `status: 'delivered'` field +- โ Handles pagination with `next` cursor + +**Gaps:** +- โ No message deduplication (by msg_id) +- โ No channel switching logic +- โ No unread tracking +- โ Only supports lobby channel (not session/lesson) + +--- + +#### textMessagesSlice.js + +**File:** `/jam-ui/src/store/features/textMessagesSlice.js` + +**Purpose:** Manages 1-on-1 text messages (direct messages, not chat) + +**State Structure:** +```javascript +{ + messages: [ + { + id: "msg-uuid", + message: "Hello", + senderId: "user-uuid", + senderName: "John Doe", + receiverId: "user-uuid", + receiverName: "Jane Smith", + createdAt: "2026-01-26T12:00:00Z", + sent: true + } + ], + status: 'idle' | 'loading' | 'succeeded' | 'failed', + error: null +} +``` + +**Async Thunks:** +```javascript +fetchMessagesByReceiverId({ userId, offset, limit }) +postNewMessage({ message, target_user_id }) +``` + +**Key Features:** +- โ Message deduplication (by id) +- โ Restructures server messages to local format +- โ Merges fetched messages with existing state + +**Gaps:** +- โ Only supports 1-on-1 messages (no channels) +- โ No WebSocket integration +- โ No unread tracking + +--- + +### 2. Patterns from Other Slices + +#### mediaSlice.js (Backing Track, JamTrack) + +**File:** `/jam-ui/src/store/features/mediaSlice.js` + +**Key Patterns:** + +1. **Async Thunks with jamClient Integration:** +```javascript +export const loadJamTrack = createAsyncThunk( + 'media/loadJamTrack', + async ({ jamTrack, jamClient, jamServer }, { dispatch, rejectWithValue }) => { + try { + // Call native client methods + const result = await jamClient.JamTrackPlay(fqId); + return { jamTrack, fqId, result }; + } catch (error) { + return rejectWithValue(error.message); + } + } +); +``` + +2. **Redux State for Media:** +```javascript +{ + backingTrack: { + file: null, + status: 'idle' | 'loading' | 'playing' | 'error', + error: null + }, + jamTrack: { + current: null, + isPlaying: false, + isPaused: false, + state: 'idle' | 'downloading' | 'loading' | 'playing' | 'error' + } +} +``` + +3. **Extra Reducers:** +```javascript +extraReducers: builder => { + builder + .addCase(loadJamTrack.pending, (state) => { + state.jamTrack.state = 'loading'; + }) + .addCase(loadJamTrack.fulfilled, (state, action) => { + state.jamTrack.current = action.payload.jamTrack; + state.jamTrack.state = 'playing'; + }) + .addCase(loadJamTrack.rejected, (state, action) => { + state.jamTrack.state = 'error'; + state.jamTrack.error = action.payload; + }); +} +``` + +**Reuse for Chat:** +- โ Follow async thunk pattern for API calls +- โ Use pending/fulfilled/rejected states +- โ Structure state by feature (not by data type) + +--- + +#### activeSessionSlice.js + +**File:** `/jam-ui/src/store/features/activeSessionSlice.js` + +**Key Patterns:** + +1. **Session State Structure:** +```javascript +{ + sessionId: null, + participants: [], + tracks: [], + jamTracks: [], + backingTracks: [], + metronome: null, + connectionStatus: 'disconnected' | 'connected' +} +``` + +2. **Reducers for Real-Time Updates:** +```javascript +addParticipant(state, action) { + state.participants.push(action.payload); +} + +removeParticipant(state, action) { + state.participants = state.participants.filter( + p => p.id !== action.payload + ); +} + +updateParticipant(state, action) { + const index = state.participants.findIndex( + p => p.id === action.payload.id + ); + if (index !== -1) { + state.participants[index] = action.payload; + } +} +``` + +**Reuse for Chat:** +- โ Follow add/remove/update pattern for messages +- โ Store session-related data in session slice +- โ Use connection status for chat availability + +--- + +#### sessionUISlice.js + +**File:** `/jam-ui/src/store/features/sessionUISlice.js` + +**Key Patterns:** + +1. **UI State (not data state):** +```javascript +{ + chatWindowOpen: false, + chatWindowPosition: { x: 200, y: 200 }, + unreadChatCount: 0, + activeChannel: 'session' +} +``` + +2. **UI Actions:** +```javascript +openChatWindow(state) { + state.chatWindowOpen = true; +} + +closeChatWindow(state) { + state.chatWindowOpen = false; + state.unreadChatCount = 0; +} + +incrementUnreadCount(state) { + state.unreadChatCount += 1; +} +``` + +**Reuse for Chat:** +- โ Store UI state separately from data +- โ Track window open/close in sessionUISlice +- โ Track unread count per channel + +--- + +### 3. Recommended Redux Architecture for Chat + +**New Slice: sessionChatSlice.js** + +```javascript +{ + // Message storage (keyed by channel for efficient lookup) + messagesByChannel: { + 'global': [msg1, msg2, ...], + 'session-uuid': [msg1, msg2, ...], + 'lesson-uuid': [msg1, msg2, ...] + }, + + // Active channel + activeChannel: 'session-uuid', + channelType: 'session', // 'global', 'session', 'lesson' + + // UI state + isWindowOpen: false, + windowPosition: { x: 200, y: 200 }, + + // Unread counts (keyed by channel) + unreadCounts: { + 'global': 0, + 'session-uuid': 3, + 'lesson-uuid': 0 + }, + + // Fetch status per channel + fetchStatus: { + 'global': 'idle', + 'session-uuid': 'loading' + }, + + // Send status + sendStatus: 'idle' | 'loading' | 'succeeded' | 'failed', + sendError: null, + + // Pagination cursors per channel + nextCursors: { + 'global': 20, + 'session-uuid': null // no more messages + } +} +``` + +**Async Thunks:** +```javascript +fetchChatHistory({ channel, channelType, sessionId, lessonId }) +sendChatMessage({ message, channel, channelType, sessionId, targetUserId }) +markAsRead({ channel }) +``` + +**Reducers:** +```javascript +addMessageFromWebSocket(state, action) // Real-time message +setActiveChannel(state, action) +incrementUnreadCount(state, action) +resetUnreadCount(state, action) +openChatWindow(state) +closeChatWindow(state) +``` + +**Integration with activeSessionSlice:** +- โ Get sessionId from activeSessionSlice +- โ Activate session channel on session join +- โ Switch back to global on session leave + +--- + +## WebSocket Integration + +### 1. Existing Pattern: useSessionWebSocket Hook + +**File:** `/jam-ui/src/hooks/useSessionWebSocket.js` + +**Purpose:** Integrates WebSocket messages with Redux state + +**Pattern:** +```javascript +export const useSessionWebSocket = (sessionId) => { + const dispatch = useDispatch(); + const { jamServer, isConnected } = useJamServerContext(); + + useEffect(() => { + if (!jamServer || !sessionId) return; + + // Define message callbacks + const callbacks = { + MIXER_CHANGES: (sessionMixers) => { + dispatch(updateMediaSummary(sessionMixers.mixers.mediaSummary)); + }, + JAM_TRACK_CHANGES: (changes) => { + dispatch(updateJamTrackState(changes.jamTrackState)); + }, + MIXDOWN_CHANGES: (message) => { + dispatch(setDownloadState(message.mixdownPackage)); + } + }; + + // Register callbacks with jamServer + 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]); +}; +``` + +**Reuse for Chat:** +- โ Add `CHAT_MESSAGE` callback to useSessionWebSocket +- โ Dispatch `addMessageFromWebSocket(payload)` action +- โ Follow same cleanup pattern + +**Example Chat Integration:** +```javascript +// In useSessionWebSocket.js +const callbacks = { + // ... existing callbacks + + CHAT_MESSAGE: (message) => { + console.log('Chat message received:', message); + dispatch(addMessageFromWebSocket({ + id: message.msg_id, + message: message.msg, + senderId: message.sender_id, + senderName: message.sender_name, + createdAt: message.created_at, + channel: message.channel, + lessonSessionId: message.lesson_session_id, + purpose: message.purpose + })); + + // Increment unread count if chat window is closed + const state = getState(); + if (!state.sessionUI.chatWindowOpen) { + dispatch(incrementUnreadCount(message.channel)); + } + } +}; +``` + +--- + +### 2. JamServer Integration + +**File:** `/jam-ui/src/helpers/JamServer.js` + +**Key Features:** +- โ EventEmitter-based WebSocket wrapper +- โ Handles connection, heartbeat, reconnect +- โ Message routing via `registerMessageCallback(type, callback)` +- โ Connection status events: `connected`, `disconnected`, `connection_down` + +**MessageType Constants:** +```javascript +const MessageType = { + LOGIN_ACK: 'login_ack', + HEARTBEAT: 'heartbeat', + HEARTBEAT_ACK: 'heartbeat_ack', + SERVER_REJECTION_ERROR: 'server_rejection_error', + PEER_MESSAGE: 'peer_message', + LOGOUT: 'logout', + CHAT_MESSAGE: 'chat_message', // โ Already defined! + USER_STATUS: 'user_status' +}; +``` + +**Reuse for Chat:** +- โ `CHAT_MESSAGE` type already defined +- โ Use existing `registerMessageCallback` API +- โ No modifications needed to JamServer.js + +--- + +### 3. JamServerContext + +**File:** `/jam-ui/src/context/JamServerContext.js` + +**Purpose:** Provides jamServer instance and connection status to components + +**Usage:** +```javascript +const { jamServer, isConnected } = useJamServerContext(); + +// Check connection before sending message +if (!isConnected) { + alert('Cannot send message: disconnected from server'); + return; +} +``` + +**Reuse for Chat:** +- โ Use context to access jamServer +- โ Disable send button if !isConnected +- โ Show connection status in chat UI + +--- + +## API Integration + +### 1. REST Client Pattern + +**File:** `/jam-ui/src/helpers/rest.js` (inferred from imports) + +**Existing Chat Methods:** +```javascript +// From lobbyChatMessagesSlice.js +import { getLobbyChatMessages, createLobbyChatMessage } from '../../helpers/rest'; + +export const getLobbyChatMessages = (params) => { + return fetch(`/api/chat?${new URLSearchParams(params)}`, { + credentials: 'include' // session cookie + }); +}; + +export const createLobbyChatMessage = (body) => { + return fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(body) + }); +}; +``` + +**Reuse for Chat:** +- โ Add similar methods for session chat +- โ Follow same fetch pattern (credentials: 'include') +- โ Use URLSearchParams for query strings + +**New Methods Needed:** +```javascript +// Get chat history (any channel) +export const getChatMessages = (params) => { + return fetch(`/api/chat?${new URLSearchParams(params)}`, { + credentials: 'include' + }); +}; + +// Send message (any channel) +export const sendChatMessage = (body) => { + return fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(body) + }); +}; + +// Mark messages as read (NEW - not in legacy API) +export const markChatAsRead = ({ channel, sessionId }) => { + return fetch('/api/chat/mark_read', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ channel, sessionId }) + }); +}; +``` + +--- + +### 2. Async Thunk Pattern + +**From mediaSlice.js:** +```javascript +export const loadJamTrack = createAsyncThunk( + 'media/loadJamTrack', + async ({ jamTrack, jamClient }, { dispatch, rejectWithValue }) => { + try { + const result = await jamClient.JamTrackPlay(fqId); + return { jamTrack, fqId, result }; + } catch (error) { + return rejectWithValue(error.message); + } + } +); +``` + +**Reuse for Chat:** +```javascript +export const fetchChatHistory = createAsyncThunk( + 'sessionChat/fetchHistory', + async ({ channel, channelType, sessionId, lessonId }, { rejectWithValue }) => { + try { + const params = { + channel: channelType, // 'global', 'session', 'lesson' + limit: 20, + page: 0 + }; + + 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) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + return { channel, messages: data.chats, next: data.next }; + } catch (error) { + return rejectWithValue(error.message); + } + } +); + +export const sendMessage = createAsyncThunk( + 'sessionChat/sendMessage', + async ({ message, channel, channelType, sessionId, targetUserId, clientId }, { rejectWithValue }) => { + try { + const body = { + message, + channel: channelType, + client_id: clientId + }; + + if (channelType === 'session' && sessionId) { + body.music_session = sessionId; + } else if (channelType === 'lesson' && targetUserId) { + body.target_user = targetUserId; + } + + const response = await sendChatMessage(body); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + return { channel, message: data }; + } catch (error) { + return rejectWithValue(error.message); + } + } +); +``` + +--- + +## Component Patterns + +### 1. Session Screen Structure + +**File:** `/jam-ui/src/components/client/JKSessionScreen.js` + +**Pattern:** +```javascript +export const JKSessionScreen = () => { + const { sessionId } = useParams(); + const dispatch = useDispatch(); + + // Load session data + useEffect(() => { + if (sessionId) { + dispatch(loadSession(sessionId)); + } + }, [sessionId, dispatch]); + + // WebSocket integration + useSessionWebSocket(sessionId); + + return ( + + {/* Mixer, tracks, etc. */} + + + + + {/* Chat window (to be added) */} + + + ); +}; +``` + +**Reuse for Chat:** +- โ Add `` to session screen +- โ Chat window conditionally renders based on Redux state +- โ Positioned absolutely (or as WindowPortal) + +--- + +### 2. Player Component Pattern (JKSessionJamTrackPlayer) + +**File:** `/jam-ui/src/components/client/JKSessionJamTrackPlayer.js` + +**Pattern:** +```javascript +export const JKSessionJamTrackPlayer = () => { + const dispatch = useDispatch(); + const jamTrack = useSelector(state => state.media.jamTrack.current); + const [isWindowOpen, setIsWindowOpen] = useState(false); + + const handlePlay = () => { + dispatch(loadJamTrack({ jamTrack, jamClient })); + }; + + return ( + + Play + setIsWindowOpen(true)}>Pop Out + + {isWindowOpen && ( + setIsWindowOpen(false)} + > + {/* Player controls */} + + )} + + ); +}; +``` + +**Reuse for Chat:** +```javascript +export const JKSessionChatWindow = () => { + const dispatch = useDispatch(); + const sessionId = useSelector(state => state.activeSession.sessionId); + const messages = useSelector(state => + state.sessionChat.messagesByChannel[sessionId] || [] + ); + const [isWindowOpen, setIsWindowOpen] = useState(false); + const [message, setMessage] = useState(''); + + const handleSend = () => { + dispatch(sendMessage({ + message, + channel: sessionId, + channelType: 'session', + sessionId + })); + setMessage(''); + }; + + return ( + + setIsWindowOpen(true)}> + Chat {unreadCount > 0 && {unreadCount}} + + + {isWindowOpen && ( + setIsWindowOpen(false)} + windowFeatures="width=400,height=600,left=200,top=200" + > + + + {messages.map(msg => ( + + {msg.senderName} + {msg.message} + {msg.createdAt} + + ))} + + + setMessage(e.target.value)} + placeholder="enter message" + /> + SEND + + + + )} + + ); +}; +``` + +--- + +## Gap Analysis + +### What's Available โ + +**Components:** +- โ WindowPortal (modeless dialog) +- โ Button/input patterns (CSS classes) +- โ Layout patterns (from session screen) + +**Redux:** +- โ lobbyChatMessagesSlice (lobby chat - can be reference) +- โ textMessagesSlice (1-on-1 messages - can be reference) +- โ mediaSlice (async thunk patterns) +- โ activeSessionSlice (session state) +- โ sessionUISlice (UI state patterns) + +**WebSocket:** +- โ useSessionWebSocket hook +- โ JamServer helper (CHAT_MESSAGE type defined) +- โ JamServerContext + +**API:** +- โ REST client pattern (fetch with credentials) +- โ Async thunk pattern +- โ Existing chat methods (lobby) + +--- + +### What's Missing โ + +**Components:** +- โ JKSessionChatWindow component +- โ JKChatMessageList sub-component +- โ JKChatInput sub-component +- โ JKChatButton (with unread badge) + +**Redux:** +- โ sessionChatSlice (multi-channel chat state) +- โ Channel switching logic +- โ Unread tracking per channel +- โ Message deduplication logic (by msg_id) + +**API:** +- โ getChatMessages (generic, not lobby-specific) +- โ sendChatMessage (generic) +- โ markChatAsRead (NEW endpoint - not in legacy API) + +**WebSocket:** +- โ CHAT_MESSAGE handler in useSessionWebSocket +- โ Unread count increment on message receive + +**Utilities:** +- โ Timestamp formatting (e.g., "5 minutes ago") +- โ Auto-scroll to bottom logic +- โ Enter-to-send keyboard handler + +--- + +### What Needs to Be Created + +**Phase 7 (Data Layer):** +1. โ sessionChatSlice.js - Multi-channel chat state management +2. โ Async thunks: fetchChatHistory, sendMessage, markAsRead +3. โ REST API methods: getChatMessages, sendChatMessage, markChatAsRead +4. โ WebSocket handler: CHAT_MESSAGE callback in useSessionWebSocket + +**Phase 8 (UI Components):** +1. โ JKSessionChatWindow.js - Main chat window component +2. โ JKChatMessageList.js - Scrollable message list +3. โ JKChatInput.js - Message composition area +4. โ JKChatButton.js - Chat button with unread badge (if needed) + +**Phase 9 (Integration):** +1. โ Wire up Redux actions in components +2. โ Integrate WebSocket subscription +3. โ Handle session join/leave (activate/deactivate channel) +4. โ Sync unread counts + +**Phase 10 (Testing):** +1. โ Unit tests: API client, Redux reducers +2. โ Integration tests: Message send/receive flow +3. โ E2E tests: Complete chat workflow + +**Phase 11 (Error Handling & Polish):** +1. โ Handle API errors (404, 403, 422) +2. โ Handle WebSocket disconnection +3. โ Retry failed message sends +4. โ Show error messages to user +5. โ Add loading states +6. โ Add timestamp formatting utility +7. โ Add auto-scroll logic + +--- + +## TDD Candidates + +Per CLAUDE.md, all jam-ui code changes MUST follow TDD methodology. + +### High Priority (MUST use TDD) ๐งช + +**Phase 7 (Data Layer):** +- ๐งช sessionChatSlice reducers (add/remove/update messages) +- ๐งช fetchChatHistory async thunk +- ๐งช sendMessage async thunk +- ๐งช Message deduplication logic (by msg_id) +- ๐งช Unread count increment/reset logic +- ๐งช CHAT_MESSAGE WebSocket handler + +**Phase 9 (State Management):** +- ๐งช Redux integration tests (dispatch actions, check state) +- ๐งช WebSocket integration tests (mock server, verify Redux updates) + +**Phase 10 (API Integration):** +- ๐งช getChatMessages API client (mock fetch) +- ๐งช sendChatMessage API client (mock fetch) +- ๐งช Error handling (404, 403, 422 responses) + +--- + +### Medium Priority (Should use TDD) ๐งช + +**Phase 8 (UI Components):** +- ๐งช JKSessionChatWindow component behavior tests + - Opens/closes window + - Sends message on Enter key + - Auto-scrolls to bottom on new message + - Shows unread badge +- ๐งช JKChatInput keyboard handling (Enter vs Shift+Enter) + +**Phase 11 (Error Handling):** +- ๐งช Retry logic for failed sends +- ๐งช Connection status handling (disable send when disconnected) + +--- + +### Low Priority (Optional TDD) ๐งช + +**Phase 8 (UI Components):** +- Styling-only changes (CSS/SCSS) +- Message list rendering (mostly visual) +- Timestamp formatting (simple utility) + +**Phase 11 (Polish):** +- Loading spinners +- Animations +- Accessibility (ARIA labels) + +--- + +## Test Types for Chat + +### 1. Unit Tests (Jest) + +**Test:** Redux reducers, async thunks, API client + +**Example:** +```javascript +// sessionChatSlice.test.js +describe('sessionChatSlice', () => { + test('addMessageFromWebSocket adds message to correct channel', () => { + const initialState = { + messagesByChannel: { 'session-123': [] } + }; + const action = { + type: 'sessionChat/addMessageFromWebSocket', + payload: { + id: 'msg-1', + message: 'Hello', + channel: 'session-123' + } + }; + const newState = sessionChatReducer(initialState, action); + expect(newState.messagesByChannel['session-123']).toHaveLength(1); + expect(newState.messagesByChannel['session-123'][0].message).toBe('Hello'); + }); + + test('incrementUnreadCount increments correct channel', () => { + const initialState = { + unreadCounts: { 'session-123': 0 } + }; + const action = { + type: 'sessionChat/incrementUnreadCount', + payload: { channel: 'session-123' } + }; + const newState = sessionChatReducer(initialState, action); + expect(newState.unreadCounts['session-123']).toBe(1); + }); +}); +``` + +--- + +### 2. Integration Tests (Playwright) + +**Test:** API calls, WebSocket messages, user flows + +**Example:** +```typescript +// test/session-chat/send-message.spec.ts +test('sends message when user clicks SEND button', async ({ page }) => { + const interceptor = new APIInterceptor(); + interceptor.intercept(page); + + await loginToJamUI(page); + await createAndJoinSession(page); + + // Open chat window + await page.click('[data-testid="chat-button"]'); + + // Type message + await page.fill('[data-testid="chat-input"]', 'Hello world'); + + // Send + 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('Hello world'); + + // Verify message appears in UI + await expect(page.locator('.chat-message').last()).toContainText('Hello world'); +}); +``` + +--- + +### 3. E2E Tests (Playwright) + +**Test:** Complete user workflows across multiple pages + +**Example:** +```typescript +// test/e2e/chat-flow.spec.ts +test('complete chat flow: join session โ send message โ receive message', async ({ 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.keyboard.press('Enter'); + + // Verify message sent + await expect(page.locator('.chat-message').last()).toContainText('Test message'); + + // Close chat + await page.click('[data-testid="chat-close"]'); + + // Simulate incoming message (via WebSocket mock) + await page.evaluate(() => { + window.__mockWebSocketMessage__({ + type: 'CHAT_MESSAGE', + payload: { + sender_name: 'Other User', + msg: 'Reply message', + msg_id: 'msg-999', + created_at: new Date().toISOString(), + channel: 'session' + } + }); + }); + + // Verify unread badge appears + await expect(page.locator('[data-testid="chat-button"] .badge')).toHaveText('1'); + + // Open chat again + await page.click('[data-testid="chat-button"]'); + + // Verify unread badge cleared + await expect(page.locator('[data-testid="chat-button"] .badge')).not.toBeVisible(); + + // Verify reply message visible + await expect(page.locator('.chat-message').last()).toContainText('Reply message'); +}); +``` + +--- + +## Summary for Implementation + +### Phases 7-11 Roadmap + +**Phase 7 (Data Layer):** +- [ ] Create sessionChatSlice.js with multi-channel state +- [ ] Add async thunks: fetchChatHistory, sendMessage +- [ ] Add REST API methods: getChatMessages, sendChatMessage +- [ ] Add CHAT_MESSAGE handler to useSessionWebSocket +- [ ] Write unit tests for all reducers and thunks + +**Phase 8 (UI Components):** +- [ ] Create JKSessionChatWindow.js (main component) +- [ ] Create JKChatMessageList.js (message list) +- [ ] Create JKChatInput.js (composition area) +- [ ] Use WindowPortal for modeless dialog +- [ ] Write component behavior tests + +**Phase 9 (State Management):** +- [ ] Wire up Redux actions in components +- [ ] Integrate WebSocket subscription +- [ ] Handle session join/leave (activate/deactivate channel) +- [ ] Sync unread counts with Redux +- [ ] Write integration tests for state flow + +**Phase 10 (Testing):** +- [ ] Integration tests: Message send/receive flow +- [ ] E2E tests: Complete chat workflow +- [ ] Test error scenarios (API failures, disconnection) + +**Phase 11 (Error Handling & Polish):** +- [ ] Handle API errors (404, 403, 422) +- [ ] Handle WebSocket disconnection +- [ ] Retry failed message sends +- [ ] Add loading states +- [ ] Add timestamp formatting utility (e.g., dayjs relative time) +- [ ] Add auto-scroll to bottom logic +- [ ] Accessibility improvements (ARIA, keyboard nav) + +--- + +### Key Decisions Made + +1. **WindowPortal for Chat Window:** + - โ Reuse existing component (no modifications needed) + - โ Matches legacy modeless dialog behavior + - โ Already handles styling, lifecycle, cleanup + +2. **sessionChatSlice for State:** + - โ New slice (not extending lobbyChatMessagesSlice) + - โ Multi-channel support (global, session, lesson) + - โ Unread tracking per channel + - โ Message deduplication by msg_id + +3. **WebSocket Integration:** + - โ Add CHAT_MESSAGE handler to useSessionWebSocket + - โ No modifications to JamServer.js (already supports CHAT_MESSAGE) + - โ Dispatch Redux actions from WebSocket handler + +4. **TDD Approach:** + - โ Follow TDD for all data layer (Redux, API) + - โ Follow TDD for component behavior (send, scroll, unread) + - โ Skip TDD for styling-only changes + +5. **Read/Unread Tracking:** + - โ Use client-side unread counts (Redux state) + - โ No server-side tracking in Phase 6 (deferred to later milestone) + - โ Reset unread count when chat window opened + +--- + +### Next Steps + +1. **Phase 6 Plan 2:** Design Redux architecture and implementation roadmap +2. **Phase 7:** Implement data layer (TDD approach) +3. **Phase 8:** Implement UI components (TDD approach) +4. **Phase 9:** Integrate state management +5. **Phase 10:** Comprehensive testing +6. **Phase 11:** Error handling and polish + +--- + +**Document Complete!** +Ready for Phase 6 Plan 2 (React Architecture & Implementation Roadmap).