diff --git a/src/pages/Conversations.tsx b/src/pages/Conversations.tsx index d1020fa56..a15780924 100644 --- a/src/pages/Conversations.tsx +++ b/src/pages/Conversations.tsx @@ -14,15 +14,12 @@ import { useAppDispatch, useAppSelector } from '../store/hooks'; import { addInferenceResponse, addOptimisticMessage, - clearCreateStatus, clearDeleteStatus, clearPurgeStatus, clearSelectedThread, - createThread, - deleteThread, + createThreadLocal, + deleteThreadLocal, fetchSuggestedQuestions, - fetchThreadMessages, - fetchThreads, purgeThreads, removeOptimisticMessages, setLastViewed, @@ -52,13 +49,10 @@ const Conversations = () => { const { threadId: urlThreadId } = useParams<{ threadId?: string }>(); const { threads, - isLoading, - error, selectedThreadId, messages, isLoadingMessages, messagesError, - createStatus, deleteStatus, purgeStatus, panelWidth, @@ -157,10 +151,7 @@ const Conversations = () => { .finally(() => setIsLoadingModels(false)); }, []); - // Fetch threads on mount - useEffect(() => { - dispatch(fetchThreads()); - }, [dispatch]); + // Remove thread fetching - threads are now loaded from Redux persist // Sync URL → Redux: when URL has a threadId param, select that thread useEffect(() => { @@ -176,12 +167,7 @@ const Conversations = () => { if (selectedThreadId) dispatch(setLastViewed(selectedThreadId)); }, [selectedThreadId, dispatch]); - // Fetch messages when a thread is selected - useEffect(() => { - if (selectedThreadId) { - dispatch(fetchThreadMessages(selectedThreadId)); - } - }, [dispatch, selectedThreadId]); + // Remove message fetching - messages load from local storage automatically // Fetch suggested questions when thread is empty (beginning of new thread) useEffect(() => { @@ -197,12 +183,7 @@ const Conversations = () => { } }, [messages]); - // Clear transient status flags after they settle - useEffect(() => { - if (createStatus === 'success' || createStatus === 'error') { - dispatch(clearCreateStatus()); - } - }, [createStatus, dispatch]); + // Remove create status handling - using local thread creation useEffect(() => { if (deleteStatus === 'success' || deleteStatus === 'error') { @@ -228,17 +209,20 @@ const Conversations = () => { navigate(`/conversations/${threadId}`, { replace: true }); }; - const handleNewThread = async () => { - const result = await dispatch(createThread(undefined)); - if (createThread.fulfilled.match(result)) { - navigate(`/conversations/${result.payload.id}`, { replace: true }); - } + const handleNewThread = () => { + const threadId = crypto.randomUUID(); + dispatch(createThreadLocal({ + id: threadId, + title: 'New Conversation', + createdAt: new Date().toISOString(), + })); + navigate(`/conversations/${threadId}`, { replace: true }); }; - const handleDeleteThread = async (threadId: string) => { - const result = await dispatch(deleteThread(threadId)); + const handleDeleteThread = (threadId: string) => { + dispatch(deleteThreadLocal(threadId)); setConfirmDeleteId(null); - if (deleteThread.fulfilled.match(result) && threadId === selectedThreadId) { + if (threadId === selectedThreadId) { navigate('/conversations', { replace: true }); } }; @@ -332,35 +316,16 @@ const Conversations = () => {

Conversations

@@ -407,35 +372,7 @@ const Conversations = () => { {/* Thread list */}
- {isLoading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( -
- ))} -
- ) : error ? ( -
- - - -

Failed to load conversations

-

{error}

- -
- ) : filteredThreads.length > 0 ? ( + {filteredThreads.length > 0 ? (
{filteredThreads.map(thread => (
{

No conversations yet

@@ -665,11 +601,9 @@ const Conversations = () => {

Failed to load messages

{messagesError}

) : messages.length > 0 ? ( diff --git a/src/store/index.ts b/src/store/index.ts index c3a9fb227..c80a1128a 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -38,8 +38,12 @@ const aiPersistConfig = { key: 'ai', storage, whitelist: ['config'] }; // Persist config for skills state (setupComplete per skill) const skillsPersistConfig = { key: 'skills', storage, whitelist: ['skills'] }; -// Persist config for thread UI prefs only (panel width, last viewed for unread) -const threadPersistConfig = { key: 'thread', storage, whitelist: ['panelWidth', 'lastViewedAt'] }; +// Persist config for thread data and UI prefs (includes threads and messages) +const threadPersistConfig = { + key: 'thread', + storage, + whitelist: ['panelWidth', 'lastViewedAt', 'threads', 'messagesByThreadId', 'selectedThreadId'] +}; const persistedAuthReducer = persistReducer(authPersistConfig, authReducer); const persistedAiReducer = persistReducer(aiPersistConfig, aiReducer); diff --git a/src/store/threadSlice.ts b/src/store/threadSlice.ts index ee9bbff99..8d002f45c 100644 --- a/src/store/threadSlice.ts +++ b/src/store/threadSlice.ts @@ -4,23 +4,25 @@ import { threadApi } from '../services/api/threadApi'; import type { Thread, ThreadMessage } from '../types/thread'; interface ThreadState { + // Existing local data (will be persisted) threads: Thread[]; - isLoading: boolean; - error: string | null; selectedThreadId: string | null; + panelWidth: number; + lastViewedAt: Record; + + // NEW: Add efficient message storage + messagesByThreadId: Record; + + // Current messages view (not persisted) messages: ThreadMessage[]; - isLoadingMessages: boolean; - messagesError: string | null; - createStatus: 'idle' | 'loading' | 'success' | 'error'; - deleteStatus: 'idle' | 'loading' | 'success' | 'error'; - deleteError: string | null; - purgeStatus: 'idle' | 'loading' | 'success' | 'error'; + + // Keep these API states (NOT persisted) + isLoadingMessages: boolean; // For AI response waiting + messagesError: string | null; // For send API errors sendStatus: 'idle' | 'loading' | 'success' | 'error'; sendError: string | null; - panelWidth: number; - /** threadId -> timestamp when user last viewed that thread (for unread indicators) */ - lastViewedAt: Record; - /** Suggested starter questions for empty threads (from GET /chat/autocomplete) */ + deleteStatus: 'idle' | 'loading' | 'success' | 'error'; + purgeStatus: 'idle' | 'loading' | 'success' | 'error'; suggestedQuestions: Array<{ text: string; confidence: number }>; isLoadingSuggestions: boolean; suggestError: string | null; @@ -28,93 +30,29 @@ interface ThreadState { const initialState: ThreadState = { threads: [], - isLoading: false, - error: null, selectedThreadId: null, + panelWidth: 320, + lastViewedAt: {}, + messagesByThreadId: {}, messages: [], isLoadingMessages: false, messagesError: null, - createStatus: 'idle', - deleteStatus: 'idle', - deleteError: null, - purgeStatus: 'idle', sendStatus: 'idle', sendError: null, - panelWidth: 320, - lastViewedAt: {}, + deleteStatus: 'idle', + purgeStatus: 'idle', suggestedQuestions: [], isLoadingSuggestions: false, suggestError: null, }; -export const fetchThreads = createAsyncThunk( - 'thread/fetchThreads', - async (_, { rejectWithValue }) => { - try { - const data = await threadApi.getThreads(); - return data.threads; - } catch (error) { - const msg = - error && typeof error === 'object' && 'error' in error - ? String(error.error) - : 'Failed to fetch threads'; - return rejectWithValue(msg); - } - } -); +// Removed fetchThreads - threads are now managed locally -export const fetchThreadMessages = createAsyncThunk( - 'thread/fetchThreadMessages', - async (threadId: string, { rejectWithValue }) => { - try { - const data = await threadApi.getThreadMessages(threadId); - return data.messages; - } catch (error) { - const msg = - error && typeof error === 'object' && 'error' in error - ? String(error.error) - : 'Failed to fetch messages'; - return rejectWithValue(msg); - } - } -); +// Removed fetchThreadMessages - messages are now managed locally -export const createThread = createAsyncThunk( - 'thread/createThread', - async (chatId: number | undefined, { dispatch, rejectWithValue }) => { - try { - const data = await threadApi.createThread(chatId); - dispatch(fetchThreads()); - return data; - } catch (error) { - const msg = - error && typeof error === 'object' && 'error' in error - ? String(error.error) - : 'Failed to create thread'; - return rejectWithValue(msg); - } - } -); +// Removed createThread - using local thread creation -export const deleteThread = createAsyncThunk( - 'thread/deleteThread', - async (threadId: string, { dispatch, getState, rejectWithValue }) => { - try { - await threadApi.deleteThread(threadId); - const state = (getState() as { thread: ThreadState }).thread; - if (state.selectedThreadId === threadId) { - dispatch(clearSelectedThread()); - } - return threadId; - } catch (error) { - const msg = - error && typeof error === 'object' && 'error' in error - ? String(error.error) - : 'Failed to delete thread'; - return rejectWithValue(msg); - } - } -); +// Removed deleteThread - using local thread deletion export const purgeThreads = createAsyncThunk( 'thread/purgeThreads', @@ -125,7 +63,8 @@ export const purgeThreads = createAsyncThunk( agentThreads: true, deleteEverything: true, }); - dispatch(fetchThreads()); + // Clear local threads after successful purge + dispatch({ type: 'thread/clearAllThreads' }); return data; } catch (error) { const msg = @@ -141,19 +80,38 @@ export const sendMessage = createAsyncThunk( 'thread/sendMessage', async ( { threadId, message }: { threadId: string; message: string }, - { dispatch, rejectWithValue } + { dispatch, getState, rejectWithValue } ) => { + // 1. Add user message locally immediately (optimistic update) + const userMessage: ThreadMessage = { + id: `msg_${Date.now()}_${Math.random()}`, + content: message, + type: 'text', + extraMetadata: {}, + sender: 'user', + createdAt: new Date().toISOString(), + }; + try { + dispatch(addMessageLocal({ threadId, message: userMessage })); + + // 2. Send to API (existing logic) const data = await threadApi.sendMessage(message, threadId); - // Re-fetch messages to get the stored user message + agent response - dispatch(fetchThreadMessages(threadId)); - // Re-fetch threads to update lastMessageAt / messageCount in the list - dispatch(fetchThreads()); + + // 3. For now, we'll handle AI response via the existing inference API + // The AI response will be added separately via addInferenceResponse + return data; } catch (error) { + // Remove optimistic user message on failure + const state = (getState() as { thread: ThreadState }).thread; + const messages = state.messagesByThreadId[threadId] || []; + const filteredMessages = messages.filter(m => m.id !== userMessage.id); + dispatch(updateMessagesForThread({ threadId, messages: filteredMessages })); + const msg = error && typeof error === 'object' && 'error' in error - ? String(error.error) + ? String((error as { error: unknown }).error) : 'Failed to send message'; return rejectWithValue(msg); } @@ -182,7 +140,8 @@ const threadSlice = createSlice({ reducers: { setSelectedThread: (state, action: { payload: string }) => { state.selectedThreadId = action.payload; - state.messages = []; + // Load messages from local storage instead of clearing + state.messages = state.messagesByThreadId[action.payload] || []; state.messagesError = null; state.suggestedQuestions = []; state.suggestError = null; @@ -198,12 +157,8 @@ const threadSlice = createSlice({ state.suggestedQuestions = []; state.suggestError = null; }, - clearCreateStatus: state => { - state.createStatus = 'idle'; - }, clearDeleteStatus: state => { state.deleteStatus = 'idle'; - state.deleteError = null; }, clearPurgeStatus: state => { state.purgeStatus = 'idle'; @@ -219,14 +174,50 @@ const threadSlice = createSlice({ }); }, addInferenceResponse: (state, action: { payload: { content: string } }) => { - state.messages.push({ + const aiMessage: ThreadMessage = { id: `inference-${Date.now()}`, content: action.payload.content, type: 'text', extraMetadata: {}, sender: 'agent', createdAt: new Date().toISOString(), - }); + }; + + // Add to current messages view + state.messages.push(aiMessage); + + // Also add to persistent storage + if (state.selectedThreadId) { + if (!state.messagesByThreadId[state.selectedThreadId]) { + state.messagesByThreadId[state.selectedThreadId] = []; + } + + // CRITICAL FIX: Ensure the preceding user message is also persisted + // Find the last user message that might not be in persistent storage yet + const lastUserMessage = state.messages + .filter(m => m.sender === 'user') + .pop(); + + if (lastUserMessage) { + const persistedMessages = state.messagesByThreadId[state.selectedThreadId]; + const userMessageExists = persistedMessages.some(m => m.id === lastUserMessage.id); + + // If user message isn't persisted yet, add it first + if (!userMessageExists) { + persistedMessages.push(lastUserMessage); + } + } + + // Now add the AI response + state.messagesByThreadId[state.selectedThreadId].push(aiMessage); + + // Update thread metadata + const thread = state.threads.find(t => t.id === state.selectedThreadId); + if (thread) { + thread.messageCount = state.messagesByThreadId[state.selectedThreadId].length; + thread.lastMessageAt = aiMessage.createdAt; + } + } }, removeOptimisticMessages: state => { state.messages = state.messages.filter(m => !m.id.startsWith('optimistic-')); @@ -241,80 +232,86 @@ const threadSlice = createSlice({ const ts = Date.now(); state.lastViewedAt[action.payload] = ts; }, + // Local thread management + createThreadLocal: (state, action: { payload: { id: string; title: string; createdAt: string } }) => { + const newThread: Thread = { + id: action.payload.id, + title: action.payload.title, + chatId: null, + isActive: true, + messageCount: 0, + lastMessageAt: action.payload.createdAt, + createdAt: action.payload.createdAt, + }; + state.threads.unshift(newThread); + state.messagesByThreadId[action.payload.id] = []; + }, + addMessageLocal: (state, action: { payload: { threadId: string; message: ThreadMessage } }) => { + const { threadId, message } = action.payload; + if (!state.messagesByThreadId[threadId]) { + state.messagesByThreadId[threadId] = []; + } + state.messagesByThreadId[threadId].push(message); + + // Update thread metadata + const thread = state.threads.find(t => t.id === threadId); + if (thread) { + thread.messageCount = state.messagesByThreadId[threadId].length; + thread.lastMessageAt = message.createdAt; + } + }, + deleteThreadLocal: (state, action: { payload: string }) => { + const threadId = action.payload; + state.threads = state.threads.filter(t => t.id !== threadId); + delete state.messagesByThreadId[threadId]; + delete state.lastViewedAt[threadId]; + if (state.selectedThreadId === threadId) { + state.selectedThreadId = null; + } + }, + updateMessagesForThread: (state, action: { payload: { threadId: string; messages: ThreadMessage[] } }) => { + const { threadId, messages } = action.payload; + state.messagesByThreadId[threadId] = messages; + + // Update thread metadata + const thread = state.threads.find(t => t.id === threadId); + if (thread) { + thread.messageCount = messages.length; + thread.lastMessageAt = messages.length > 0 ? messages[messages.length - 1].createdAt : thread.createdAt; + } + }, + clearAllThreads: (state) => { + state.threads = []; + state.messagesByThreadId = {}; + state.selectedThreadId = null; + state.messages = []; + state.lastViewedAt = {}; + }, }, extraReducers: builder => { builder - // fetchThreads — only show skeleton on initial load (stale-while-revalidate) - .addCase(fetchThreads.pending, state => { - if (state.threads.length === 0) { - state.isLoading = true; - } - state.error = null; - }) - .addCase(fetchThreads.fulfilled, (state, action) => { - state.isLoading = false; - state.threads = action.payload; - }) - .addCase(fetchThreads.rejected, (state, action) => { - state.isLoading = false; - state.error = action.payload as string; - }) - // fetchThreadMessages - .addCase(fetchThreadMessages.pending, state => { - state.isLoadingMessages = true; - state.messagesError = null; - }) - .addCase(fetchThreadMessages.fulfilled, (state, action) => { - state.isLoadingMessages = false; - state.messages = action.payload; - // Hide suggestions once thread has messages - if (action.payload.length > 0) { - state.suggestedQuestions = []; - state.suggestError = null; - } - }) - .addCase(fetchThreadMessages.rejected, (state, action) => { - state.isLoadingMessages = false; - state.messagesError = action.payload as string; - }) - // createThread - .addCase(createThread.pending, state => { - state.createStatus = 'loading'; - }) - .addCase(createThread.fulfilled, (state, action) => { - state.createStatus = 'success'; - state.selectedThreadId = action.payload.id; - state.messages = []; - state.messagesError = null; - }) - .addCase(createThread.rejected, state => { - state.createStatus = 'error'; - }) - // deleteThread - .addCase(deleteThread.pending, state => { - state.deleteStatus = 'loading'; - state.deleteError = null; - }) - .addCase(deleteThread.fulfilled, (state, action) => { - state.deleteStatus = 'success'; - state.threads = state.threads.filter(t => t.id !== action.payload); - }) - .addCase(deleteThread.rejected, (state, action) => { - state.deleteStatus = 'error'; - state.deleteError = action.payload as string; - }) + // Removed fetchThreads and fetchThreadMessages cases + // Removed createThread cases - using local thread creation + // Removed deleteThread cases - using local thread deletion // purgeThreads .addCase(purgeThreads.pending, state => { state.purgeStatus = 'loading'; }) .addCase(purgeThreads.fulfilled, state => { state.purgeStatus = 'success'; - state.selectedThreadId = null; - state.messages = []; + // clearAllThreads is dispatched from the thunk }) .addCase(purgeThreads.rejected, state => { state.purgeStatus = 'error'; }) + // clearAllThreads action + .addCase('thread/clearAllThreads', state => { + state.threads = []; + state.messagesByThreadId = {}; + state.selectedThreadId = null; + state.messages = []; + state.lastViewedAt = {}; + }) // sendMessage .addCase(sendMessage.pending, state => { state.sendStatus = 'loading'; @@ -351,7 +348,6 @@ const threadSlice = createSlice({ export const { setSelectedThread, clearSelectedThread, - clearCreateStatus, clearDeleteStatus, clearPurgeStatus, addOptimisticMessage, @@ -361,5 +357,10 @@ export const { clearSuggestedQuestions, setPanelWidth, setLastViewed, + createThreadLocal, + addMessageLocal, + deleteThreadLocal, + updateMessagesForThread, + clearAllThreads, } = threadSlice.actions; export default threadSlice.reducer;