Files
openhuman/src/store/threadSlice.ts
T
cyrus 9668f2ed23 fix: sync selected thread state and messages view updates
- Ensured the `messages` view updates when a new message is added to the currently selected thread.
- Simplified `useEffect` logic in `Conversations.tsx` to always set the selected thread for consistent message synchronization.
- Removed redundant `setSelectedThread` call during message dispatch to prevent state management issues.
2026-03-27 17:53:02 +05:30

383 lines
12 KiB
TypeScript

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
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, 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 plain message - orchestration and context injection happen in Rust.
const data = await threadApi.sendMessage(message, threadId);
// 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) {
// Add an error message as an agent response so the conversation flow continues
dispatch(
addInferenceResponse({ content: 'Something went wrong — please try again.', threadId })
);
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);
// Also update current messages view if this is the selected thread
if (threadId === state.selectedThreadId) {
state.messages.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;