Merge remote-tracking branch 'upstream/develop' into feat/final-flow-check

This commit is contained in:
M3gA-Mind
2026-03-04 02:19:57 +05:30
11 changed files with 432 additions and 267 deletions
@@ -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 (
<div className={`space-y-4 ${className}`}>
{/* OAuth Providers */}
<div className="space-y-3">
{oauthProviderConfigs.map((provider) => (
<OAuthProviderButton
key={provider.id}
provider={provider}
disabled={disabled}
/>
))}
</div>
{/* Divider */}
{showTelegram && (
<>
<div className="relative flex items-center">
<div className="flex-1 border-t border-white/20"></div>
<div className="px-4 text-sm text-white/50 bg-transparent">or</div>
<div className="flex-1 border-t border-white/20"></div>
</div>
{/* Telegram Login */}
<TelegramLoginButton disabled={disabled} />
</>
)}
</div>
);
};
export default OAuthLoginSection;
@@ -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 (
<button
onClick={handleOAuthLogin}
disabled={isDisabled}
className={`w-full flex items-center justify-center space-x-3 ${provider.color} ${provider.hoverColor} ${provider.textColor} font-semibold py-4 rounded-xl transition-all duration-300 hover:shadow-medium hover:scale-[1.02] active:scale-[0.98] disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed ${className}`}
>
{isLoading ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
) : (
<IconComponent className="w-5 h-5" />
)}
<span>
{isLoading ? 'Connecting...' : `Continue with ${provider.name}`}
</span>
</button>
);
};
export default OAuthProviderButton;
+76
View File
@@ -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 }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
);
const TwitterIcon = ({ className = '' }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
);
const GitHubIcon = ({ className = '' }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
);
const DiscordIcon = ({ className = '' }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419-.0189 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1568 2.4189Z"/>
</svg>
);
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);
};
@@ -78,10 +78,12 @@ const PrimaryButton: React.FC<PrimaryButtonProps> = ({
className={`${baseClasses} ${variantClasses[variant]} ${className} flex items-center justify-center`}
onClick={onClick}
disabled={disabled || loading}>
{loading && (
<div className="h-4 w-4 border-2 border-white/20 border-t-white rounded-full animate-spin mr-2" />
)}
{children}
<div className="flex items-center gap-2">
{loading && (
<div className="h-4 w-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
)}
<span>{children}</span>
</div>
</button>
);
};
+31 -95
View File
@@ -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 = () => {
<h2 className="text-sm font-semibold">Conversations</h2>
<button
onClick={handleNewThread}
disabled={createStatus === 'loading'}
className="p-1.5 rounded-lg hover:bg-white/10 transition-colors text-stone-400 hover:text-stone-200 disabled:opacity-50 disabled:cursor-not-allowed"
className="p-1.5 rounded-lg hover:bg-white/10 transition-colors text-stone-400 hover:text-stone-200"
title="New Thread">
{createStatus === 'loading' ? (
<svg className="w-4.5 h-4.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<svg className="w-4.5 h-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
)}
<svg className="w-4.5 h-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
@@ -407,35 +374,7 @@ const Conversations = () => {
{/* Thread list */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="space-y-1 p-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-16 bg-white/5 rounded-xl animate-pulse" />
))}
</div>
) : error ? (
<div className="flex-1 flex flex-col items-center justify-center py-16 px-4">
<svg
className="w-8 h-8 text-coral-500/70 mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<p className="text-sm text-stone-400 mb-1">Failed to load conversations</p>
<p className="text-xs text-stone-600 mb-3 text-center">{error}</p>
<button
onClick={() => dispatch(fetchThreads())}
className="text-xs text-primary-400 hover:text-primary-300 transition-colors">
Retry
</button>
</div>
) : filteredThreads.length > 0 ? (
{filteredThreads.length > 0 ? (
<div className="py-1">
{filteredThreads.map(thread => (
<div
@@ -545,8 +484,7 @@ const Conversations = () => {
<p className="text-sm text-stone-500 mb-3">No conversations yet</p>
<button
onClick={handleNewThread}
disabled={createStatus === 'loading'}
className="text-xs text-primary-400 hover:text-primary-300 transition-colors disabled:opacity-50">
className="text-xs text-primary-400 hover:text-primary-300 transition-colors">
Start a new conversation
</button>
</div>
@@ -665,11 +603,9 @@ const Conversations = () => {
<p className="text-sm text-stone-400 mb-1">Failed to load messages</p>
<p className="text-xs text-stone-600 mb-3 text-center">{messagesError}</p>
<button
onClick={() =>
selectedThreadId && dispatch(fetchThreadMessages(selectedThreadId))
}
onClick={() => window.location.reload()}
className="text-xs text-primary-400 hover:text-primary-300 transition-colors">
Retry
Reload
</button>
</div>
) : messages.length > 0 ? (
+2 -2
View File
@@ -11,7 +11,7 @@ const Login = () => {
const dispatch = useAppDispatch();
const [consumeError, setConsumeError] = useState<string | null>(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 = () => {
<div className="glass rounded-3xl p-8 shadow-large animate-fade-up">
<p className="opacity-90 text-coral mb-4">{consumeError}</p>
<p className="text-sm opacity-70">
Get a new link by sending '/start login' to the AlphaHuman bot on Telegram.
Please try logging in again with your preferred method.
</p>
</div>
</div>
+3 -3
View File
@@ -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) => {
<p className="opacity-70 leading-relaxed">Are you ready for this?</p>
{/* Show Telegram login button in Tauri app, download screen on web */}
{/* Show OAuth login options in Tauri app, download screen on web */}
{!isWeb && (
<div className="mt-6">
<TelegramLoginButton />
<OAuthLoginSection />
</div>
)}
</div>
+1
View File
@@ -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<string> {
+6 -2
View File
@@ -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);
+167 -161
View File
@@ -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<string, number>;
// NEW: Add efficient message storage
messagesByThreadId: Record<string, ThreadMessage[]>;
// 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<string, number>;
/** 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;
+26
View File
@@ -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;
}