Files
openhuman/src/store/threadSlice.ts
T
cyrusandClaude 0233e721c0 fix: resolve race condition and message persistence bugs in conversations
- Fix race condition where agent responses went to wrong threads when users switched conversations
- Add activeThreadId state to track which thread is currently sending/receiving messages
- Implement single active conversation pattern to prevent concurrent message sending
- Fix optimistic message persistence bug where user messages disappeared after conversation switch
- Replace optimistic messaging with immediate persistent storage for user messages
- Add clear UI feedback when other conversations are active with disabled inputs
- Ensure responses always go to original sending thread regardless of current selection
- Simplify addInferenceResponse logic by removing complex retroactive user message handling
- Add proper error handling for failed message sends with cleanup of persisted messages

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-13 18:21:41 +05:30

406 lines
14 KiB
TypeScript

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { injectAll } from '../lib/ai/injector';
import type { Message } from '../lib/ai/providers/interface';
import { threadApi } from '../services/api/threadApi';
import type { Thread, ThreadMessage } from '../types/thread';
interface ThreadState {
// Existing local data (will be persisted)
threads: Thread[];
selectedThreadId: string | null;
panelWidth: number;
lastViewedAt: Record<string, number>;
activeThreadId: string | null; // Track which thread is currently sending/receiving
// NEW: Add efficient message storage
messagesByThreadId: Record<string, ThreadMessage[]>;
// Current messages view (not persisted)
messages: ThreadMessage[];
// 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;
deleteStatus: 'idle' | 'loading' | 'success' | 'error';
purgeStatus: 'idle' | 'loading' | 'success' | 'error';
suggestedQuestions: Array<{ text: string; confidence: number }>;
isLoadingSuggestions: boolean;
suggestError: string | null;
}
const initialState: ThreadState = {
threads: [],
selectedThreadId: null,
panelWidth: 320,
lastViewedAt: {},
activeThreadId: null,
messagesByThreadId: {},
messages: [],
isLoadingMessages: false,
messagesError: null,
sendStatus: 'idle',
sendError: null,
deleteStatus: 'idle',
purgeStatus: 'idle',
suggestedQuestions: [],
isLoadingSuggestions: false,
suggestError: null,
};
// Removed fetchThreads - threads are now managed locally
// Removed fetchThreadMessages - messages are now managed locally
// Removed createThread - using local thread creation
// Removed deleteThread - using local thread deletion
export const purgeThreads = createAsyncThunk(
'thread/purgeThreads',
async (_, { dispatch, rejectWithValue }) => {
try {
const data = await threadApi.purge({
messages: false,
agentThreads: true,
deleteEverything: true,
});
// Clear local threads after successful purge
dispatch({ type: 'thread/clearAllThreads' });
return data;
} catch (error) {
const msg =
error && typeof error === 'object' && 'error' in error
? String(error.error)
: 'Failed to purge threads';
return rejectWithValue(msg);
}
}
);
export const sendMessage = createAsyncThunk(
'thread/sendMessage',
async (
{ threadId, message }: { threadId: string; message: string },
{ 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. Process message with SOUL + TOOLS injection before sending to API
let processedMessage = message;
try {
const userMessage: Message = { role: 'user', content: [{ type: 'text', text: message }] };
const injectedMessage = await injectAll(userMessage, {
mode: 'context-block',
includeMetadata: false,
});
// Extract the processed text
processedMessage = injectedMessage.content
.filter(block => block.type === 'text')
.map(block => (block as { text: string }).text)
.join('\n');
console.log('✅ SOUL + TOOLS injection successful in Redux sendMessage thunk');
} catch (injectionError) {
console.warn(
'⚠️ SOUL + TOOLS injection failed in Redux sendMessage thunk:',
injectionError
);
// Continue with original message
}
// 3. Send to API with processed message (disable injection in threadApi to avoid double injection)
const data = await threadApi.sendMessage(processedMessage, threadId, { injectSoul: false });
// 4. 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 as { error: unknown }).error)
: 'Failed to send message';
return rejectWithValue(msg);
}
}
);
export const fetchSuggestedQuestions = createAsyncThunk(
'thread/fetchSuggestedQuestions',
async (conversationId: string | undefined, { rejectWithValue }) => {
try {
const data = await threadApi.getSuggestQuestions(conversationId);
return data.suggestions;
} catch (error) {
const msg =
error && typeof error === 'object' && 'error' in error
? String(error.error)
: 'Failed to load suggestions';
return rejectWithValue(msg);
}
}
);
const threadSlice = createSlice({
name: 'thread',
initialState,
reducers: {
setSelectedThread: (state, action: { payload: string }) => {
state.selectedThreadId = action.payload;
// Load messages from local storage instead of clearing
state.messages = state.messagesByThreadId[action.payload] || [];
state.messagesError = null;
state.suggestedQuestions = [];
state.suggestError = null;
},
clearSelectedThread: state => {
state.selectedThreadId = null;
state.messages = [];
state.messagesError = null;
state.suggestedQuestions = [];
state.suggestError = null;
},
clearSuggestedQuestions: state => {
state.suggestedQuestions = [];
state.suggestError = null;
},
clearDeleteStatus: state => {
state.deleteStatus = 'idle';
},
clearPurgeStatus: state => {
state.purgeStatus = 'idle';
},
addOptimisticMessage: (state, action: { payload: { content: string } }) => {
state.messages.push({
id: `optimistic-${Date.now()}`,
content: action.payload.content,
type: 'text',
extraMetadata: {},
sender: 'user',
createdAt: new Date().toISOString(),
});
},
addInferenceResponse: (state, action: { payload: { content: string; threadId?: string } }) => {
const aiMessage: ThreadMessage = {
id: `inference-${Date.now()}`,
content: action.payload.content,
type: 'text',
extraMetadata: {},
sender: 'agent',
createdAt: new Date().toISOString(),
};
// Use provided threadId or fall back to activeThreadId to ensure response goes to correct thread
const targetThreadId = action.payload.threadId || state.activeThreadId;
if (targetThreadId) {
// Ensure messagesByThreadId exists for this thread
if (!state.messagesByThreadId[targetThreadId]) {
state.messagesByThreadId[targetThreadId] = [];
}
// Add the AI response to persistent storage
state.messagesByThreadId[targetThreadId].push(aiMessage);
// Add to current messages view only if it's the currently selected thread
if (targetThreadId === state.selectedThreadId) {
state.messages.push(aiMessage);
}
// Update thread metadata
const thread = state.threads.find(t => t.id === targetThreadId);
if (thread) {
thread.messageCount = state.messagesByThreadId[targetThreadId].length;
thread.lastMessageAt = aiMessage.createdAt;
}
}
// Clear active thread when response is received
state.activeThreadId = null;
},
removeOptimisticMessages: state => {
state.messages = state.messages.filter(m => !m.id.startsWith('optimistic-'));
},
clearSendError: state => {
state.sendError = null;
},
setPanelWidth: (state, action: { payload: number }) => {
state.panelWidth = action.payload;
},
setLastViewed: (state, action: { payload: string }) => {
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 = {};
state.activeThreadId = null;
},
setActiveThread: (state, action: { payload: string | null }) => {
state.activeThreadId = action.payload;
},
},
extraReducers: builder => {
builder
// 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';
// 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 = {};
state.activeThreadId = null;
})
// sendMessage
.addCase(sendMessage.pending, (state, action) => {
state.sendStatus = 'loading';
state.sendError = null;
// Set the active thread when message sending starts
state.activeThreadId = action.meta.arg.threadId;
})
.addCase(sendMessage.fulfilled, state => {
state.sendStatus = 'success';
state.suggestedQuestions = [];
state.suggestError = null;
// Don't clear activeThreadId here - let addInferenceResponse handle it
})
.addCase(sendMessage.rejected, (state, action) => {
state.sendStatus = 'error';
state.sendError = action.payload as string;
// Remove optimistic messages so the user doesn't see phantom messages
state.messages = state.messages.filter(m => !m.id.startsWith('optimistic-'));
// Clear active thread on error
state.activeThreadId = null;
})
// fetchSuggestedQuestions
.addCase(fetchSuggestedQuestions.pending, state => {
state.isLoadingSuggestions = true;
state.suggestError = null;
})
.addCase(fetchSuggestedQuestions.fulfilled, (state, action) => {
state.isLoadingSuggestions = false;
state.suggestedQuestions = action.payload;
})
.addCase(fetchSuggestedQuestions.rejected, (state, action) => {
state.isLoadingSuggestions = false;
state.suggestError = action.payload as string;
state.suggestedQuestions = [];
});
},
});
export const {
setSelectedThread,
clearSelectedThread,
clearDeleteStatus,
clearPurgeStatus,
addOptimisticMessage,
addInferenceResponse,
removeOptimisticMessages,
clearSendError,
clearSuggestedQuestions,
setPanelWidth,
setLastViewed,
createThreadLocal,
addMessageLocal,
deleteThreadLocal,
updateMessagesForThread,
clearAllThreads,
setActiveThread,
} = threadSlice.actions;
export default threadSlice.reducer;