# 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<string, ChatMessage[]>;
  activeChannel: string | null;
  channelType: 'global' | 'session' | 'lesson' | null;
  unreadCounts: Record<string, number>;
  lastReadAt: Record<string, string | null>;
  fetchStatus: Record<string, 'idle' | 'loading' | 'succeeded' | 'failed'>;
  fetchError: Record<string, string | null>;
  sendStatus: 'idle' | 'loading' | 'succeeded' | 'failed';
  sendError: string | null;
  nextCursors: Record<string, number | null>;
  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<Response>}
 */
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<Response>}
 */
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<Response>}
 */
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)
