From 4f5339d7eb981b88c4819e01b8672dd67a7b2d22 Mon Sep 17 00:00:00 2001 From: Nuwan Date: Tue, 27 Jan 2026 08:16:26 +0530 Subject: [PATCH] feat(07-02): implement fetchChatHistory async thunk with extra reducers Add fetchChatHistory async thunk with complete lifecycle handling: - pending: Sets loading status and clears errors - fulfilled: Adds messages with deduplication and sorting - rejected: Sets error status with error message - Message deduplication by ID to prevent duplicates - Chronological sorting (createdAt ASC) after prepending - Pagination cursor storage in nextCursors state - Handles both session and global channels All 46 unit tests passing. Co-Authored-By: Claude Sonnet 4.5 --- jam-ui/src/store/features/sessionChatSlice.js | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/jam-ui/src/store/features/sessionChatSlice.js b/jam-ui/src/store/features/sessionChatSlice.js index 8af8cc757..612c11d76 100644 --- a/jam-ui/src/store/features/sessionChatSlice.js +++ b/jam-ui/src/store/features/sessionChatSlice.js @@ -1,4 +1,24 @@ -import { createSlice } from '@reduxjs/toolkit'; +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { getChatMessages } from '../../helpers/rest'; + +/** + * Async thunk to fetch chat history for a channel + * @param {Object} params - Request parameters + * @param {string} params.channel - Channel ID (session ID, lesson ID, or 'global') + * @param {string} params.sessionId - Session ID for session channels + * @param {number} [params.before] - Pagination cursor + */ +export const fetchChatHistory = createAsyncThunk( + 'sessionChat/fetchChatHistory', + async ({ channel, sessionId, before }) => { + const response = await getChatMessages({ + channel: sessionId ? 'session' : 'global', + sessionId, + before + }); + return { channel, ...response }; + } +); /** * Initial state for session chat @@ -139,6 +159,46 @@ const sessionChatSlice = createSlice({ setWindowPosition: (state, action) => { state.windowPosition = action.payload; } + }, + extraReducers: (builder) => { + builder + // fetchChatHistory pending + .addCase(fetchChatHistory.pending, (state, action) => { + const channel = action.meta.arg.channel; + state.fetchStatus[channel] = 'loading'; + state.fetchError[channel] = null; + }) + // fetchChatHistory fulfilled + .addCase(fetchChatHistory.fulfilled, (state, action) => { + const channel = action.meta.arg.channel; + const { messages, next } = action.payload; + + // Initialize channel if not exists + if (!state.messagesByChannel[channel]) { + state.messagesByChannel[channel] = []; + } + + // Deduplicate messages + const existingIds = new Set(state.messagesByChannel[channel].map(m => m.id)); + const newMessages = messages.filter(m => !existingIds.has(m.id)); + + // Prepend new messages (oldest first for pagination) + state.messagesByChannel[channel] = [...newMessages, ...state.messagesByChannel[channel]]; + + // Sort by createdAt ASC to maintain chronological order + state.messagesByChannel[channel].sort((a, b) => + new Date(a.createdAt) - new Date(b.createdAt) + ); + + state.fetchStatus[channel] = 'succeeded'; + state.nextCursors[channel] = next; + }) + // fetchChatHistory rejected + .addCase(fetchChatHistory.rejected, (state, action) => { + const channel = action.meta.arg.channel; + state.fetchStatus[channel] = 'failed'; + state.fetchError[channel] = action.error.message; + }); } });