diff --git a/src/components/oauth/OAuthLoginSection.tsx b/src/components/oauth/OAuthLoginSection.tsx new file mode 100644 index 000000000..833d63f28 --- /dev/null +++ b/src/components/oauth/OAuthLoginSection.tsx @@ -0,0 +1,46 @@ +import OAuthProviderButton from './OAuthProviderButton'; +import TelegramLoginButton from '../TelegramLoginButton'; +import { oauthProviderConfigs } from './providerConfigs'; + +interface OAuthLoginSectionProps { + className?: string; + disabled?: boolean; + showTelegram?: boolean; +} + +const OAuthLoginSection = ({ + className = '', + disabled = false, + showTelegram = true +}: OAuthLoginSectionProps) => { + return ( +
+ {/* OAuth Providers */} +
+ {oauthProviderConfigs.map((provider) => ( + + ))} +
+ + {/* Divider */} + {showTelegram && ( + <> +
+
+
or
+
+
+ + {/* Telegram Login */} + + + )} +
+ ); +}; + +export default OAuthLoginSection; \ No newline at end of file diff --git a/src/components/oauth/OAuthProviderButton.tsx b/src/components/oauth/OAuthProviderButton.tsx new file mode 100644 index 000000000..d98583674 --- /dev/null +++ b/src/components/oauth/OAuthProviderButton.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import type { OAuthProviderConfig } from '../../types/oauth'; +import { openUrl } from '../../utils/openUrl'; +import { isTauri } from '../../utils/tauriCommands'; +import { IS_DEV } from '../../utils/config'; + +interface OAuthProviderButtonProps { + provider: OAuthProviderConfig; + className?: string; + disabled?: boolean; +} + +const OAuthProviderButton = ({ + provider, + className = '', + disabled: externalDisabled = false, +}: OAuthProviderButtonProps) => { + const [isLoading, setIsLoading] = useState(false); + + const handleOAuthLogin = async () => { + if (externalDisabled || isLoading) return; + + console.log(`Starting ${provider.name} OAuth login`, isTauri()); + + if (IS_DEV) { + console.log(`[dev] OAuth debug mode enabled. OAuth URL: ${provider.loginUrl}`); + console.log('[dev] In debug mode, OAuth will return JSON response instead of redirect.'); + console.log('[dev] After OAuth completion, copy the loginToken and use: window.__simulateDeepLink("alphahuman://auth?token=YOUR_TOKEN")'); + } + + setIsLoading(true); + + try { + // Desktop (Tauri): use system browser → backend OAuth → deep link back to app + if (isTauri()) { + await openUrl(provider.loginUrl); + } else { + // Web fallback: direct OAuth flow in current window + window.location.href = provider.loginUrl; + } + } catch (error) { + console.error(`Failed to initiate ${provider.name} OAuth login:`, error); + setIsLoading(false); + } + }; + + const isDisabled = externalDisabled || isLoading; + const IconComponent = provider.icon; + + return ( + + ); +}; + +export default OAuthProviderButton; \ No newline at end of file diff --git a/src/components/oauth/providerConfigs.tsx b/src/components/oauth/providerConfigs.tsx new file mode 100644 index 000000000..6f4766f00 --- /dev/null +++ b/src/components/oauth/providerConfigs.tsx @@ -0,0 +1,76 @@ +/** + * OAuth provider configurations with brand colors and icons + */ +import type { OAuthProviderConfig } from '../../types/oauth'; +import { BACKEND_URL, IS_DEV } from '../../utils/config'; + +// Provider Icons +const GoogleIcon = ({ className = '' }: { className?: string }) => ( + + + + + + +); + +const TwitterIcon = ({ className = '' }: { className?: string }) => ( + + + +); + +const GitHubIcon = ({ className = '' }: { className?: string }) => ( + + + +); + +const DiscordIcon = ({ className = '' }: { className?: string }) => ( + + + +); + +export const oauthProviderConfigs: OAuthProviderConfig[] = [ + { + id: 'google', + name: 'Google', + icon: GoogleIcon, + color: 'bg-white border border-gray-200', + hoverColor: 'hover:bg-gray-50 hover:border-gray-300', + textColor: 'text-gray-900', + loginUrl: `${BACKEND_URL}/auth/google/login${IS_DEV ? '?debug=true' : ''}`, + }, + { + id: 'github', + name: 'GitHub', + icon: GitHubIcon, + color: 'bg-gray-900 border border-gray-800', + hoverColor: 'hover:bg-gray-800 hover:border-gray-700', + textColor: 'text-white', + loginUrl: `${BACKEND_URL}/auth/github/login${IS_DEV ? '?debug=true' : ''}`, + }, + { + id: 'twitter', + name: 'Twitter', + icon: TwitterIcon, + color: 'bg-black border border-gray-800', + hoverColor: 'hover:bg-gray-900 hover:border-gray-700', + textColor: 'text-white', + loginUrl: `${BACKEND_URL}/auth/twitter/login${IS_DEV ? '?debug=true' : ''}`, + }, + { + id: 'discord', + name: 'Discord', + icon: DiscordIcon, + color: 'bg-indigo-600 border border-indigo-500', + hoverColor: 'hover:bg-indigo-700 hover:border-indigo-600', + textColor: 'text-white', + loginUrl: `${BACKEND_URL}/auth/discord/login${IS_DEV ? '?debug=true' : ''}`, + }, +]; + +export const getProviderConfig = (provider: string): OAuthProviderConfig | undefined => { + return oauthProviderConfigs.find(config => config.id === provider); +}; \ No newline at end of file diff --git a/src/components/settings/panels/components/ActionPanel.tsx b/src/components/settings/panels/components/ActionPanel.tsx index baac0f594..07262c1a1 100644 --- a/src/components/settings/panels/components/ActionPanel.tsx +++ b/src/components/settings/panels/components/ActionPanel.tsx @@ -78,10 +78,12 @@ const PrimaryButton: React.FC = ({ className={`${baseClasses} ${variantClasses[variant]} ${className} flex items-center justify-center`} onClick={onClick} disabled={disabled || loading}> - {loading && ( -
- )} - {children} +
+ {loading && ( +
+ )} + {children} +
); }; diff --git a/src/pages/Conversations.tsx b/src/pages/Conversations.tsx index d1020fa56..2decf7122 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,22 @@ 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 +318,16 @@ const Conversations = () => {

Conversations

@@ -407,35 +374,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 +603,9 @@ const Conversations = () => {

Failed to load messages

{messagesError}

) : messages.length > 0 ? ( diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 1f0a95514..6809be045 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -11,7 +11,7 @@ const Login = () => { const dispatch = useAppDispatch(); const [consumeError, setConsumeError] = useState(null); - // Handle login token from URL (e.g. from Telegram bot "Open AlphaHuman" button) + // Handle login token from URL (e.g. from Telegram bot or OAuth provider callback) // Consume the token with the backend and store the returned JWT useEffect(() => { const loginToken = searchParams.get('token'); @@ -46,7 +46,7 @@ const Login = () => {

{consumeError}

- Get a new link by sending '/start login' to the AlphaHuman bot on Telegram. + Please try logging in again with your preferred method.

diff --git a/src/pages/Welcome.tsx b/src/pages/Welcome.tsx index 4e4b78d2d..028821ad8 100644 --- a/src/pages/Welcome.tsx +++ b/src/pages/Welcome.tsx @@ -1,5 +1,5 @@ import DownloadScreen from '../components/DownloadScreen'; -import TelegramLoginButton from '../components/TelegramLoginButton'; +import OAuthLoginSection from '../components/oauth/OAuthLoginSection'; import TypewriterGreeting from '../components/TypewriterGreeting'; interface WelcomeProps { @@ -25,10 +25,10 @@ const Welcome = ({ isWeb }: WelcomeProps) => {

Are you ready for this?

- {/* Show Telegram login button in Tauri app, download screen on web */} + {/* Show OAuth login options in Tauri app, download screen on web */} {!isWeb && (
- +
)}
diff --git a/src/services/api/authApi.ts b/src/services/api/authApi.ts index 8b7f9bc48..822affc3a 100644 --- a/src/services/api/authApi.ts +++ b/src/services/api/authApi.ts @@ -12,6 +12,7 @@ interface IntegrationTokensResponse { /** * Consume a verified login token and return the JWT. + * Works for both Telegram and OAuth login tokens. * POST /telegram/login-tokens/:token/consume (no auth required) */ export async function consumeLoginToken(loginToken: string): Promise { diff --git a/src/store/index.ts b/src/store/index.ts index 93ee95eeb..3d642173a 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -46,8 +46,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..a5bf15b0a 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,48 @@ 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 +230,93 @@ 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 +353,6 @@ const threadSlice = createSlice({ export const { setSelectedThread, clearSelectedThread, - clearCreateStatus, clearDeleteStatus, clearPurgeStatus, addOptimisticMessage, @@ -361,5 +362,10 @@ export const { clearSuggestedQuestions, setPanelWidth, setLastViewed, + createThreadLocal, + addMessageLocal, + deleteThreadLocal, + updateMessagesForThread, + clearAllThreads, } = threadSlice.actions; export default threadSlice.reducer; diff --git a/src/types/oauth.ts b/src/types/oauth.ts new file mode 100644 index 000000000..ac74956c4 --- /dev/null +++ b/src/types/oauth.ts @@ -0,0 +1,26 @@ +/** + * OAuth provider types and interfaces + */ + +export type OAuthProvider = 'google' | 'twitter' | 'github' | 'discord'; + +export interface OAuthProviderConfig { + id: OAuthProvider; + name: string; + icon: React.ComponentType<{ className?: string }>; + color: string; + hoverColor: string; + textColor: string; + loginUrl: string; +} + +export interface OAuthLoginResponse { + success: boolean; + data: { jwtToken: string }; +} + +export interface OAuthError { + provider: OAuthProvider; + message: string; + code?: string; +} \ No newline at end of file