diff --git a/Cargo.toml b/Cargo.toml index cef04ee8d..250cdec80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,7 @@ url = "2" socketioxide = { version = "0.15", features = ["extensions"] } whisper-rs = "0.16" image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +tempfile = "3" matrix-sdk = { version = "0.16", optional = true, default-features = false, features = ["e2e-encryption", "rustls-tls", "markdown"] } fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] } @@ -108,7 +109,6 @@ landlock = { version = "0.4", optional = true } rppal = { version = "0.22", optional = true } [dev-dependencies] -tempfile = "3" [features] sandbox-landlock = ["dep:landlock"] diff --git a/app/src/App.tsx b/app/src/App.tsx index 2af959f70..29e86abd0 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -10,11 +10,10 @@ import DictationOverlay from './components/dictation/DictationOverlay'; import ErrorFallbackScreen from './components/ErrorFallbackScreen'; import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar'; import OnboardingOverlay from './components/OnboardingOverlay'; +import CoreStateProvider from './providers/CoreStateProvider'; import SocketProvider from './providers/SocketProvider'; -import UserProvider from './providers/UserProvider'; import { tagErrorSource } from './services/errorReportQueue'; import { persistor, store } from './store'; -import { syncMemoryClientToken } from './utils/tauriCommands'; function App() { return ( @@ -26,18 +25,8 @@ function App() { tagErrorSource(eventId, 'react', componentStack); }}> - { - const token = store.getState().auth.token; - console.info('[memory] PersistGate onBeforeLift: token_present=%s', !!token); - if (token) { - // Do not block initial render on core/memory availability. - void syncMemoryClientToken(token); - } - }}> - + + @@ -53,7 +42,7 @@ function App() { - + diff --git a/app/src/components/BottomTabBar.tsx b/app/src/components/BottomTabBar.tsx index 7df925019..008bbd271 100644 --- a/app/src/components/BottomTabBar.tsx +++ b/app/src/components/BottomTabBar.tsx @@ -1,5 +1,6 @@ import { useLocation, useNavigate } from 'react-router-dom'; +import { useCoreState } from '../providers/CoreStateProvider'; import { useAppSelector } from '../store/hooks'; const tabs = [ @@ -104,7 +105,8 @@ const tabs = [ const BottomTabBar = () => { const location = useLocation(); const navigate = useNavigate(); - const token = useAppSelector(state => state.auth.token); + const { snapshot } = useCoreState(); + const token = snapshot.sessionToken; const conversationsUnreadCount = useAppSelector(state => { const { threads, lastViewedAt } = state.thread; diff --git a/app/src/components/DefaultRedirect.tsx b/app/src/components/DefaultRedirect.tsx index 54c8fdcf1..9a256ae7e 100644 --- a/app/src/components/DefaultRedirect.tsx +++ b/app/src/components/DefaultRedirect.tsx @@ -1,6 +1,6 @@ import { Navigate } from 'react-router-dom'; -import { useAppSelector } from '../store/hooks'; +import { useCoreState } from '../providers/CoreStateProvider'; import RouteLoadingScreen from './RouteLoadingScreen'; /** @@ -9,14 +9,13 @@ import RouteLoadingScreen from './RouteLoadingScreen'; * - Logged in → /home (Home handles onboarding redirect if needed) */ const DefaultRedirect = () => { - const token = useAppSelector(state => state.auth.token); - const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete); + const { isBootstrapping, snapshot } = useCoreState(); - if (!isAuthBootstrapComplete) { + if (isBootstrapping) { return ; } - if (token) { + if (snapshot.sessionToken) { return ; } diff --git a/app/src/components/MiniSidebar.tsx b/app/src/components/MiniSidebar.tsx index 3ffdc7a3e..636614d33 100644 --- a/app/src/components/MiniSidebar.tsx +++ b/app/src/components/MiniSidebar.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { useCoreState } from '../providers/CoreStateProvider'; import { useAppSelector } from '../store/hooks'; import { isTauri } from '../utils/tauriCommands'; import DaemonHealthIndicator from './daemon/DaemonHealthIndicator'; @@ -138,7 +139,8 @@ const navItems = [ const MiniSidebar = () => { const location = useLocation(); const navigate = useNavigate(); - const token = useAppSelector(state => state.auth.token); + const { snapshot } = useCoreState(); + const token = snapshot.sessionToken; const [showDaemonPanel, setShowDaemonPanel] = useState(false); // Unread count for Conversations: threads with lastMessageAt > lastViewedAt (must be before early return) diff --git a/app/src/components/OnboardingOverlay.tsx b/app/src/components/OnboardingOverlay.tsx index 0187cf8f7..e2ab9f937 100644 --- a/app/src/components/OnboardingOverlay.tsx +++ b/app/src/components/OnboardingOverlay.tsx @@ -2,13 +2,8 @@ import { useCallback, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import Onboarding from '../pages/onboarding/Onboarding'; -import { selectIsOnboarded } from '../store/authSelectors'; -import { useAppSelector } from '../store/hooks'; +import { useCoreState } from '../providers/CoreStateProvider'; import { DEV_FORCE_ONBOARDING } from '../utils/config'; -import { - getOnboardingCompleted, - setOnboardingCompleted as persistOnboardingCompleted, -} from '../utils/tauriCommands'; /** * Full-screen overlay that renders the onboarding flow on top of any page @@ -17,70 +12,43 @@ import { * Reads `onboarding_completed` from the core config (persisted in config.toml). */ const OnboardingOverlay = () => { - const token = useAppSelector(state => state.auth.token); - const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete); - const user = useAppSelector(state => state.user.user); - const isOnboardedRedux = useAppSelector(selectIsOnboarded); - - /** null = still loading, true/false = resolved from core config */ - const [onboardingCompleted, setOnboardingCompleted] = useState(null); + const { isBootstrapping, snapshot, setOnboardingCompletedFlag } = useCoreState(); + const token = snapshot.sessionToken; + const user = snapshot.currentUser; const [userLoadTimedOut, setUserLoadTimedOut] = useState(false); // Reset local state on logout so re-login starts fresh. useEffect(() => { if (!token) { setUserLoadTimedOut(false); - setOnboardingCompleted(null); } }, [token]); // Timeout: if user profile hasn't loaded after 3s but we have token + bootstrap, // proceed anyway so onboarding isn't permanently invisible. useEffect(() => { - if (!token || !isAuthBootstrapComplete || user?._id) return; + if (!token || isBootstrapping || user?._id) return; const timer = setTimeout(() => setUserLoadTimedOut(true), 3000); return () => clearTimeout(timer); - }, [token, isAuthBootstrapComplete, user?._id]); + }, [token, isBootstrapping, user?._id]); // User is ready when profile loaded or timeout elapsed. const userReady = !!user?._id || userLoadTimedOut; - - // Read onboarding_completed from core config. - useEffect(() => { - if (!token || !isAuthBootstrapComplete || !userReady) return; - - let mounted = true; - const check = async () => { - try { - const completed = await getOnboardingCompleted(); - if (mounted) setOnboardingCompleted(completed); - } catch { - if (mounted) setOnboardingCompleted(isOnboardedRedux); - } - }; - void check(); - return () => { - mounted = false; - }; - }, [token, isAuthBootstrapComplete, userReady, isOnboardedRedux]); + const onboardingCompleted = snapshot.onboardingCompleted; const handleDone = useCallback(async () => { - setOnboardingCompleted(true); try { - await persistOnboardingCompleted(true); + await setOnboardingCompletedFlag(true); } catch { console.warn('[onboarding] Failed to persist onboarding_completed'); } - }, []); + }, [setOnboardingCompletedFlag]); // Don't show if not logged in, bootstrap not complete, or user not ready - if (!token || !isAuthBootstrapComplete || !userReady) return null; + if (!token || isBootstrapping || !userReady) return null; - // Still loading the flag from core - if (onboardingCompleted === null) return null; - - const shouldShow = DEV_FORCE_ONBOARDING || (!onboardingCompleted && !isOnboardedRedux); + const shouldShow = DEV_FORCE_ONBOARDING || !onboardingCompleted; if (!shouldShow) return null; diff --git a/app/src/components/ProtectedRoute.tsx b/app/src/components/ProtectedRoute.tsx index 027185ac5..f9874a224 100644 --- a/app/src/components/ProtectedRoute.tsx +++ b/app/src/components/ProtectedRoute.tsx @@ -1,6 +1,6 @@ import { Navigate } from 'react-router-dom'; -import { useAppSelector } from '../store/hooks'; +import { useCoreState } from '../providers/CoreStateProvider'; import RouteLoadingScreen from './RouteLoadingScreen'; interface ProtectedRouteProps { @@ -14,14 +14,13 @@ interface ProtectedRouteProps { * Onboarding is handled separately via OnboardingOverlay. */ const ProtectedRoute = ({ children, requireAuth = true, redirectTo }: ProtectedRouteProps) => { - const token = useAppSelector(state => state.auth.token); - const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete); + const { isBootstrapping, snapshot } = useCoreState(); - if (!isAuthBootstrapComplete) { + if (isBootstrapping) { return ; } - if (requireAuth && !token) { + if (requireAuth && !snapshot.sessionToken) { return ; } diff --git a/app/src/components/PublicRoute.tsx b/app/src/components/PublicRoute.tsx index b9856cf77..4f141a5c6 100644 --- a/app/src/components/PublicRoute.tsx +++ b/app/src/components/PublicRoute.tsx @@ -1,6 +1,6 @@ import { Navigate } from 'react-router-dom'; -import { useAppSelector } from '../store/hooks'; +import { useCoreState } from '../providers/CoreStateProvider'; import RouteLoadingScreen from './RouteLoadingScreen'; interface PublicRouteProps { @@ -13,16 +13,15 @@ interface PublicRouteProps { * Home handles the onboarding redirect once the user profile is loaded. */ const PublicRoute = ({ children, redirectTo }: PublicRouteProps) => { - const token = useAppSelector(state => state.auth.token); - const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete); + const { isBootstrapping, snapshot } = useCoreState(); - if (!isAuthBootstrapComplete) { + if (isBootstrapping) { return ; } // If user is logged in, always go to home. // Home itself will redirect to onboarding if needed. - if (token) { + if (snapshot.sessionToken) { return ; } diff --git a/app/src/components/WalletInfoSection.tsx b/app/src/components/WalletInfoSection.tsx index 273ad2830..74ca795c8 100644 --- a/app/src/components/WalletInfoSection.tsx +++ b/app/src/components/WalletInfoSection.tsx @@ -3,8 +3,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useUser } from '../hooks/useUser'; import { useSkillConnectionStatus } from '../lib/skills/hooks'; import { skillManager } from '../lib/skills/manager'; +import { useCoreState } from '../providers/CoreStateProvider'; import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; -import { useAppSelector } from '../store/hooks'; function truncateAddress(address: string): string { if (!address || address.length < 12) return address; @@ -14,9 +14,8 @@ function truncateAddress(address: string): string { export default function WalletInfoSection() { const walletStatus = useSkillConnectionStatus('wallet'); const { user } = useUser(); - const primaryAddress = useAppSelector(state => - user?._id ? state.auth.primaryWalletAddressByUser[user._id] : undefined - ); + const { snapshot } = useCoreState(); + const primaryAddress = user?._id ? snapshot.localState.primaryWalletAddress : undefined; const [networkName, setNetworkName] = useState(null); const [balance, setBalance] = useState(null); diff --git a/app/src/components/__tests__/OnboardingOverlay.test.tsx b/app/src/components/__tests__/OnboardingOverlay.test.tsx index bc66680ba..baf3eb722 100644 --- a/app/src/components/__tests__/OnboardingOverlay.test.tsx +++ b/app/src/components/__tests__/OnboardingOverlay.test.tsx @@ -1,137 +1,75 @@ -import { screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { clearToken, setToken } from '../../store/authSlice'; -import { renderWithProviders } from '../../test/test-utils'; import OnboardingOverlay from '../OnboardingOverlay'; -// Mock tauriCommands — onboarding defaults to not completed -vi.mock('../../utils/tauriCommands', () => ({ - isTauri: vi.fn(() => false), - getOnboardingCompleted: vi.fn().mockResolvedValue(false), - setOnboardingCompleted: vi.fn().mockResolvedValue(true), +const mockUseCoreState = vi.fn(); + +vi.mock('../../providers/CoreStateProvider', () => ({ useCoreState: () => mockUseCoreState() })); + +vi.mock('../../pages/onboarding/Onboarding', () => ({ + default: ({ onComplete }: { onComplete: () => void }) => ( +
+ +
+ ), })); -// DEV_FORCE_ONBOARDING is already mocked as false in test/setup.ts - -const baseAuth = { - token: 'test-jwt', - isAuthBootstrapComplete: true, - isOnboardedByUser: {} as Record, - onboardingDeferredByUser: {} as Record, - isAnalyticsEnabledByUser: {}, - onboardingTasksByUser: {}, - hasIncompleteOnboardingByUser: {}, - encryptionKeyByUser: {}, - primaryWalletAddressByUser: {}, -}; - -const baseUser = { user: { _id: 'user-1', username: 'tester', firstName: 'Test' } }; +function makeCoreState(overrides?: Record) { + return { + isBootstrapping: false, + snapshot: { + sessionToken: 'test-jwt', + currentUser: { _id: 'user-1', username: 'tester', firstName: 'Test' }, + onboardingCompleted: false, + ...overrides, + }, + setOnboardingCompletedFlag: vi.fn().mockResolvedValue(undefined), + }; +} describe('OnboardingOverlay', () => { - it('does not render when onboarding is completed', async () => { - const { getOnboardingCompleted } = await import('../../utils/tauriCommands'); - vi.mocked(getOnboardingCompleted).mockResolvedValue(true); + beforeEach(() => { + mockUseCoreState.mockReset(); + }); - renderWithProviders(, { - preloadedState: { auth: baseAuth, user: baseUser }, - }); + it('does not render when onboarding is completed', () => { + mockUseCoreState.mockReturnValue(makeCoreState({ onboardingCompleted: true })); - await vi.waitFor(() => { - expect(screen.queryByText('Skip')).not.toBeInTheDocument(); - }); + render(); + + expect(screen.queryByText('Skip')).not.toBeInTheDocument(); }); it('does not render when no token', () => { - renderWithProviders(, { - preloadedState: { auth: { ...baseAuth, token: null }, user: baseUser }, - }); + mockUseCoreState.mockReturnValue(makeCoreState({ sessionToken: null })); + + render(); expect(screen.queryByText('Skip')).not.toBeInTheDocument(); }); - it('does not render when user profile is not loaded', () => { - renderWithProviders(, { - preloadedState: { auth: baseAuth, user: { user: {} } }, - }); + it('does not render when user profile is not loaded yet', () => { + mockUseCoreState.mockReturnValue(makeCoreState({ currentUser: {} })); + + render(); expect(screen.queryByText('Skip')).not.toBeInTheDocument(); }); - it('resets local state on logout so re-login starts fresh', async () => { - // Ensure getOnboardingCompleted returns false (not completed) for this test - const { getOnboardingCompleted } = await import('../../utils/tauriCommands'); - vi.mocked(getOnboardingCompleted).mockResolvedValue(false); + it('renders when the user is authenticated and onboarding is incomplete', () => { + mockUseCoreState.mockReturnValue(makeCoreState()); - // Start with a loaded user profile so userReady is true via user._id, - // and onboarding not completed — overlay should show. - const { store } = renderWithProviders(, { - preloadedState: { auth: baseAuth, user: baseUser }, - }); + render(); - // Wait for getOnboardingCompleted to resolve and overlay to appear - await vi.waitFor(() => { - expect(screen.queryByText('Skip')).toBeInTheDocument(); - }); + expect(screen.getByText('Skip')).toBeInTheDocument(); + }); - // Simulate logout — the reset useEffect clears onboardingCompleted + userLoadTimedOut - await store.dispatch(clearToken()); + it('does not render while bootstrapping', () => { + mockUseCoreState.mockReturnValue({ ...makeCoreState(), isBootstrapping: true }); - await vi.waitFor(() => { - expect(screen.queryByText('Skip')).not.toBeInTheDocument(); - }); - - // Simulate re-login WITHOUT a loaded user profile. - // If the fix works, onboardingCompleted was reset to null and userLoadTimedOut - // was reset to false, so userReady is false — overlay should NOT appear. - // Without the fix, stale state would make the overlay appear immediately. - store.dispatch(setToken('new-jwt')); + render(); expect(screen.queryByText('Skip')).not.toBeInTheDocument(); }); - - it('does not render when RPC fails but Redux says onboarded', async () => { - const { getOnboardingCompleted } = await import('../../utils/tauriCommands'); - vi.mocked(getOnboardingCompleted).mockRejectedValue(new Error('RPC error')); - - renderWithProviders(, { - preloadedState: { - auth: { ...baseAuth, isOnboardedByUser: { 'user-1': true } }, - user: baseUser, - }, - }); - - await vi.waitFor(() => { - expect(screen.queryByText('Skip')).not.toBeInTheDocument(); - }); - }); - - it('does not render when RPC returns false but Redux says onboarded', async () => { - const { getOnboardingCompleted } = await import('../../utils/tauriCommands'); - vi.mocked(getOnboardingCompleted).mockResolvedValue(false); - - renderWithProviders(, { - preloadedState: { - auth: { ...baseAuth, isOnboardedByUser: { 'user-1': true } }, - user: baseUser, - }, - }); - - await vi.waitFor(() => { - expect(screen.queryByText('Skip')).not.toBeInTheDocument(); - }); - }); - - it('renders when both RPC fails and Redux says not onboarded', async () => { - const { getOnboardingCompleted } = await import('../../utils/tauriCommands'); - vi.mocked(getOnboardingCompleted).mockRejectedValue(new Error('RPC error')); - - renderWithProviders(, { - preloadedState: { auth: { ...baseAuth, isOnboardedByUser: {} }, user: baseUser }, - }); - - await vi.waitFor(() => { - expect(screen.queryByText('Skip')).toBeInTheDocument(); - }); - }); }); diff --git a/app/src/components/__tests__/ProtectedRoute.test.tsx b/app/src/components/__tests__/ProtectedRoute.test.tsx index ad1ceb584..c5f92eca0 100644 --- a/app/src/components/__tests__/ProtectedRoute.test.tsx +++ b/app/src/components/__tests__/ProtectedRoute.test.tsx @@ -1,41 +1,64 @@ -import { screen } from '@testing-library/react'; -import { Route, Routes } from 'react-router-dom'; -import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; -import { renderWithProviders } from '../../test/test-utils'; import ProtectedRoute from '../ProtectedRoute'; +const mockUseCoreState = vi.fn(); + +vi.mock('../../providers/CoreStateProvider', () => ({ useCoreState: () => mockUseCoreState() })); + +function renderRoute(routes: React.ReactNode, initialEntries = ['/']) { + return render( + + {routes} + + ); +} + describe('ProtectedRoute', () => { - it('renders children when token exists', () => { - renderWithProviders( - - -
Protected Content
- - } - /> -
, - { - preloadedState: { - auth: { - token: 'valid-jwt', - isAuthBootstrapComplete: true, - isOnboardedByUser: {}, - isAnalyticsEnabledByUser: {}, - }, - }, - } + it('renders a loading screen while bootstrapping', () => { + mockUseCoreState.mockReturnValue({ isBootstrapping: true, snapshot: { sessionToken: null } }); + + renderRoute( + +
Protected Content
+ + } + /> + ); + + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument(); + }); + + it('renders children when a session token exists', () => { + mockUseCoreState.mockReturnValue({ + isBootstrapping: false, + snapshot: { sessionToken: 'valid-jwt' }, + }); + + renderRoute( + +
Protected Content
+ + } + /> ); expect(screen.getByText('Protected Content')).toBeInTheDocument(); }); it('redirects to / when no token and requireAuth=true', () => { - renderWithProviders( - + mockUseCoreState.mockReturnValue({ isBootstrapping: false, snapshot: { sessionToken: null } }); + + renderRoute( + <> { } /> Landing} /> - , - { - initialEntries: ['/dashboard'], - preloadedState: { - auth: { - token: null, - isAuthBootstrapComplete: true, - isOnboardedByUser: {}, - isAnalyticsEnabledByUser: {}, - }, - }, - } + , + ['/dashboard'] ); expect(screen.queryByText('Dashboard')).not.toBeInTheDocument(); @@ -64,60 +77,23 @@ describe('ProtectedRoute', () => { }); it('redirects to custom redirectTo when no token', () => { - renderWithProviders( - + mockUseCoreState.mockReturnValue({ isBootstrapping: false, snapshot: { sessionToken: null } }); + + renderRoute( + <> -
Dashboard
+
Custom Protected
} /> Login Page} /> -
, - { - initialEntries: ['/dashboard'], - preloadedState: { - auth: { - token: null, - isAuthBootstrapComplete: true, - isOnboardedByUser: {}, - isAnalyticsEnabledByUser: {}, - }, - }, - } + , + ['/custom'] ); expect(screen.getByText('Login Page')).toBeInTheDocument(); }); - - it('renders children when authenticated (onboarding handled by overlay)', () => { - renderWithProviders( - - -
Home Content
- - } - /> -
, - { - initialEntries: ['/home'], - preloadedState: { - auth: { - token: 'valid-jwt', - isAuthBootstrapComplete: true, - isOnboardedByUser: {}, - isAnalyticsEnabledByUser: {}, - }, - user: { user: { _id: 'u1' }, isLoading: false, error: null }, - }, - } - ); - - expect(screen.getByText('Home Content')).toBeInTheDocument(); - }); }); diff --git a/app/src/components/__tests__/PublicRoute.test.tsx b/app/src/components/__tests__/PublicRoute.test.tsx index f56fc7c7c..9b80c1266 100644 --- a/app/src/components/__tests__/PublicRoute.test.tsx +++ b/app/src/components/__tests__/PublicRoute.test.tsx @@ -1,41 +1,47 @@ -import { screen } from '@testing-library/react'; -import { Route, Routes } from 'react-router-dom'; -import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; -import { renderWithProviders } from '../../test/test-utils'; import PublicRoute from '../PublicRoute'; +const mockUseCoreState = vi.fn(); + +vi.mock('../../providers/CoreStateProvider', () => ({ useCoreState: () => mockUseCoreState() })); + +function renderRoute(routes: React.ReactNode, initialEntries = ['/']) { + return render( + + {routes} + + ); +} + describe('PublicRoute', () => { it('renders children when user is not authenticated', () => { - renderWithProviders( - - -
Welcome Page
- - } - /> -
, - { - preloadedState: { - auth: { - token: null, - isAuthBootstrapComplete: true, - isOnboardedByUser: {}, - isAnalyticsEnabledByUser: {}, - }, - }, - } + mockUseCoreState.mockReturnValue({ isBootstrapping: false, snapshot: { sessionToken: null } }); + + renderRoute( + +
Welcome Page
+ + } + /> ); expect(screen.getByText('Welcome Page')).toBeInTheDocument(); }); it('redirects to /home when user is authenticated', () => { - renderWithProviders( - + mockUseCoreState.mockReturnValue({ + isBootstrapping: false, + snapshot: { sessionToken: 'jwt-token' }, + }); + + renderRoute( + <> { } /> Home} /> - , - { - preloadedState: { - auth: { - token: 'jwt-token', - isAuthBootstrapComplete: true, - isOnboardedByUser: {}, - isAnalyticsEnabledByUser: {}, - }, - }, - } + ); expect(screen.queryByText('Welcome Page')).not.toBeInTheDocument(); @@ -63,8 +59,13 @@ describe('PublicRoute', () => { }); it('redirects to custom path when authenticated', () => { - renderWithProviders( - + mockUseCoreState.mockReturnValue({ + isBootstrapping: false, + snapshot: { sessionToken: 'jwt-token' }, + }); + + renderRoute( + <> { } /> Dashboard} /> - , - { - preloadedState: { - auth: { - token: 'jwt-token', - isAuthBootstrapComplete: true, - isOnboardedByUser: {}, - isAnalyticsEnabledByUser: {}, - }, - }, - } + ); expect(screen.getByText('Dashboard')).toBeInTheDocument(); diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index 1af1cdd01..d0987c850 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -1,30 +1,27 @@ import { useState } from 'react'; import { skillManager } from '../../lib/skills/manager'; +import { useCoreState } from '../../providers/CoreStateProvider'; import { persistor } from '../../store'; -import { clearToken } from '../../store/authSlice'; -import { useAppDispatch } from '../../store/hooks'; -import { setOnboardingCompleted, logout as tauriLogout } from '../../utils/tauriCommands'; import SettingsHeader from './components/SettingsHeader'; import SettingsMenuItem from './components/SettingsMenuItem'; import { useSettingsNavigation } from './hooks/useSettingsNavigation'; const SettingsHome = () => { const { navigateToSettings } = useSettingsNavigation(); - const dispatch = useAppDispatch(); + const { clearSession, setOnboardingCompletedFlag } = useCoreState(); const [showLogoutAndClearModal, setShowLogoutAndClearModal] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const handleLogout = async () => { - await dispatch(clearToken()); try { - await setOnboardingCompleted(false); + await setOnboardingCompletedFlag(false); } catch (err) { console.warn('[Settings] Failed to clear onboarding_completed in config:', err); } try { - await tauriLogout(); + await clearSession(); } catch (err) { console.warn('[Settings] Rust logout failed:', err); } @@ -32,14 +29,13 @@ const SettingsHome = () => { }; const clearAllAppData = async () => { - await dispatch(clearToken()); try { - await setOnboardingCompleted(false); + await setOnboardingCompletedFlag(false); } catch (err) { console.warn('[Settings] Failed to clear onboarding_completed in config:', err); } try { - await tauriLogout(); + await clearSession(); } catch (err) { console.warn('[Settings] Rust logout failed during clearAllAppData:', err); } diff --git a/app/src/components/settings/panels/BillingPanel.tsx b/app/src/components/settings/panels/BillingPanel.tsx index 9734f39c7..0e9595b1e 100644 --- a/app/src/components/settings/panels/BillingPanel.tsx +++ b/app/src/components/settings/panels/BillingPanel.tsx @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; import createDebug from 'debug'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCoreState } from '../../../providers/CoreStateProvider'; import { billingApi } from '../../../services/api/billingApi'; import { type AutoRechargeSettings, @@ -9,8 +10,6 @@ import { type SavedCard, type TeamUsage, } from '../../../services/api/creditsApi'; -import { useAppDispatch, useAppSelector } from '../../../store/hooks'; -import { fetchCurrentUser } from '../../../store/userSlice'; import type { CurrentPlanData, PlanTier } from '../../../types/api'; import { openUrl } from '../../../utils/openUrl'; import SettingsHeader from '../components/SettingsHeader'; @@ -18,11 +17,11 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; import { annualSavings, buildPlanId, + isUpgrade as checkIsUpgrade, displayPrice, formatStorageLimit, formatUsdAmount, getPlanMeta, - isUpgrade as checkIsUpgrade, PLANS, } from './billingHelpers'; @@ -49,9 +48,8 @@ function cardBrandLabel(brand: string) { // ── Component ─────────────────────────────────────────────────────────── const BillingPanel = () => { const { navigateBack } = useSettingsNavigation(); - const dispatch = useAppDispatch(); - const user = useAppSelector(state => state.user.user); - const { teams } = useAppSelector(state => state.team); + const { snapshot, teams, refresh } = useCoreState(); + const user = snapshot.currentUser; // Active team context const activeTeamId = user?.activeTeamId; @@ -104,7 +102,9 @@ const BillingPanel = () => { const currentTier: PlanTier = currentPlan?.plan ?? activeTeam?.team.subscription?.plan ?? 'FREE'; const hasActive = - currentPlan?.hasActiveSubscription ?? activeTeam?.team.subscription?.hasActiveSubscription ?? false; + currentPlan?.hasActiveSubscription ?? + activeTeam?.team.subscription?.hasActiveSubscription ?? + false; const planExpiry = currentPlan?.planExpiry ?? activeTeam?.team.subscription?.planExpiry ?? null; const currentPlanMeta = getPlanMeta(currentTier); @@ -288,7 +288,7 @@ const BillingPanel = () => { } catch (e) { console.error('Failed to fetch current plan after payment', e); } - dispatch(fetchCurrentUser()); + await refresh(); // Auto-hide the success banner after 5 s timeoutRef.current = window.setTimeout(() => setPaymentConfirmed(false), 5_000); @@ -302,7 +302,7 @@ const BillingPanel = () => { timeoutRef.current = null; } }; - }, [dispatch]); + }, [refresh]); // ── Poll for plan change after checkout ───────────────────────────── const currentTierRef = useRef(currentTier); @@ -328,7 +328,7 @@ const BillingPanel = () => { log('[poll] plan=%s active=%s', plan.plan, plan.hasActiveSubscription); setCurrentPlan(plan); if (plan.hasActiveSubscription && plan.plan !== currentTierRef.current) { - dispatch(fetchCurrentUser()); + await refresh(); setIsPurchasing(false); setPurchasingTier(null); if (pollRef.current) clearInterval(pollRef.current); @@ -337,7 +337,7 @@ const BillingPanel = () => { // Ignore polling errors } }, 5_000); - }, [dispatch]); + }, [refresh]); // ── Purchase handlers ─────────────────────────────────────────────── const handleUpgrade = async (tier: PlanTier) => { @@ -1134,7 +1134,10 @@ const BillingPanel = () => { d="M5 13l4 4L19 7" /> - Higher tiers increase your premium-usage discount and included usage every cycle + + Higher tiers increase your premium-usage discount and included usage every + cycle + {currentTier === 'FREE' && (
  • diff --git a/app/src/components/settings/panels/PrivacyPanel.tsx b/app/src/components/settings/panels/PrivacyPanel.tsx index aab338e47..8c9cdab1d 100644 --- a/app/src/components/settings/panels/PrivacyPanel.tsx +++ b/app/src/components/settings/panels/PrivacyPanel.tsx @@ -1,31 +1,18 @@ -import { syncAnalyticsConsent } from '../../../services/analytics'; -import { setAnalyticsForUser } from '../../../store/authSlice'; -import { useAppDispatch, useAppSelector } from '../../../store/hooks'; -import { isTauri, openhumanUpdateAnalyticsSettings } from '../../../utils/tauriCommands'; +import { useCoreState } from '../../../providers/CoreStateProvider'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; const PrivacyPanel = () => { const { navigateBack } = useSettingsNavigation(); - const dispatch = useAppDispatch(); - const user = useAppSelector(state => state.user.user); - const analyticsEnabled = useAppSelector(state => { - const userId = state.user.user?._id; - if (!userId) return false; - return state.auth.isAnalyticsEnabledByUser[userId] !== false; - }); + const { snapshot, setAnalyticsEnabled } = useCoreState(); + const analyticsEnabled = snapshot.analyticsEnabled; - const handleToggleAnalytics = () => { - if (!user?._id) return; + const handleToggleAnalytics = async () => { const newValue = !analyticsEnabled; - dispatch(setAnalyticsForUser({ userId: user._id, enabled: newValue })); - syncAnalyticsConsent(newValue); - - // Sync to core config so the Rust process also respects the setting - if (isTauri()) { - openhumanUpdateAnalyticsSettings({ enabled: newValue }).catch(() => { - /* best-effort */ - }); + try { + await setAnalyticsEnabled(newValue); + } catch (error) { + console.warn('[privacy] failed to persist analytics setting:', error); } }; diff --git a/app/src/components/settings/panels/RecoveryPhrasePanel.tsx b/app/src/components/settings/panels/RecoveryPhrasePanel.tsx index 16cd79db8..b1422c462 100644 --- a/app/src/components/settings/panels/RecoveryPhrasePanel.tsx +++ b/app/src/components/settings/panels/RecoveryPhrasePanel.tsx @@ -1,8 +1,7 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { skillManager } from '../../../lib/skills/manager'; -import { setEncryptionKeyForUser } from '../../../store/authSlice'; -import { useAppDispatch, useAppSelector } from '../../../store/hooks'; +import { useCoreState } from '../../../providers/CoreStateProvider'; import { deriveAesKeyFromMnemonic, deriveEvmAddressFromMnemonic, @@ -18,9 +17,9 @@ const BIP39_IMPORT_LENGTHS = [12, 15, 18, 21, 24] as const; const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT; const RecoveryPhrasePanel = () => { - const dispatch = useAppDispatch(); const { navigateBack } = useSettingsNavigation(); - const user = useAppSelector(state => state.user.user); + const { snapshot, setEncryptionKey } = useCoreState(); + const user = snapshot.currentUser; const [mode, setMode] = useState<'generate' | 'import'>('generate'); const [copied, setCopied] = useState(false); @@ -184,7 +183,7 @@ const RecoveryPhrasePanel = () => { setError('User not loaded. Please sign in again or refresh the page.'); return; } - dispatch(setEncryptionKeyForUser({ userId: user._id, key: aesKey })); + await setEncryptionKey(aesKey); await skillManager.setWalletAddress(walletAddress); setSuccess(true); } catch (e) { diff --git a/app/src/components/settings/panels/TeamInvitesPanel.tsx b/app/src/components/settings/panels/TeamInvitesPanel.tsx index 457100e8c..27266ad11 100644 --- a/app/src/components/settings/panels/TeamInvitesPanel.tsx +++ b/app/src/components/settings/panels/TeamInvitesPanel.tsx @@ -1,9 +1,8 @@ import { useEffect, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; +import { useCoreState } from '../../../providers/CoreStateProvider'; import { teamApi } from '../../../services/api/teamApi'; -import { useAppDispatch, useAppSelector } from '../../../store/hooks'; -import { fetchInvites } from '../../../store/teamSlice'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; @@ -11,27 +10,30 @@ const TeamInvitesPanel = () => { const { teamId } = useParams<{ teamId: string }>(); const location = useLocation(); const { navigateBack } = useSettingsNavigation(); - const dispatch = useAppDispatch(); - const user = useAppSelector(state => state.user.user); - const { teams, invites, isLoadingInvites } = useAppSelector(state => state.team); + const { snapshot, teams, teamInvitesById, refreshTeamInvites } = useCoreState(); + const user = snapshot.currentUser; // Check if we're in team management context (has teamId in URL) const isInManagementContext = location.pathname.includes('/team/manage/'); const currentTeamId = isInManagementContext ? teamId : user?.activeTeamId; const currentTeam = teams.find(t => t.team._id === currentTeamId); const isAdmin = currentTeam?.role.toUpperCase() === 'ADMIN'; + const invites = currentTeamId ? (teamInvitesById[currentTeamId] ?? []) : []; const [isGenerating, setIsGenerating] = useState(false); const [copiedId, setCopiedId] = useState(null); const [revokingId, setRevokingId] = useState(null); const [error, setError] = useState(null); + const [isLoadingInvites, setIsLoadingInvites] = useState(false); // Confirmation modal state const [inviteToRevoke, setInviteToRevoke] = useState<{ id: string; code: string } | null>(null); useEffect(() => { - if (currentTeamId) dispatch(fetchInvites(currentTeamId)); - }, [currentTeamId, dispatch]); + if (!currentTeamId) return; + setIsLoadingInvites(true); + void refreshTeamInvites(currentTeamId).finally(() => setIsLoadingInvites(false)); + }, [currentTeamId, refreshTeamInvites]); const handleGenerate = async () => { if (!currentTeamId) return; @@ -39,7 +41,7 @@ const TeamInvitesPanel = () => { setError(null); try { await teamApi.createInvite(currentTeamId); - dispatch(fetchInvites(currentTeamId)); + await refreshTeamInvites(currentTeamId); } catch (err) { setError( err && typeof err === 'object' && 'error' in err @@ -74,7 +76,7 @@ const TeamInvitesPanel = () => { try { await teamApi.revokeInvite(currentTeamId, inviteToRevoke.id); - dispatch(fetchInvites(currentTeamId)); + await refreshTeamInvites(currentTeamId); setInviteToRevoke(null); } catch (err) { setError( diff --git a/app/src/components/settings/panels/TeamManagementPanel.tsx b/app/src/components/settings/panels/TeamManagementPanel.tsx index d313e4575..68b9c1ce4 100644 --- a/app/src/components/settings/panels/TeamManagementPanel.tsx +++ b/app/src/components/settings/panels/TeamManagementPanel.tsx @@ -1,17 +1,16 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { useCoreState } from '../../../providers/CoreStateProvider'; import { teamApi } from '../../../services/api/teamApi'; -import { useAppDispatch, useAppSelector } from '../../../store/hooks'; -import { fetchTeams } from '../../../store/teamSlice'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; const TeamManagementPanel = () => { const { teamId } = useParams<{ teamId: string }>(); const { navigateBack, navigateToSettings } = useSettingsNavigation(); - const dispatch = useAppDispatch(); - const { teams } = useAppSelector(state => state.team); + const { teams, refreshTeams } = useCoreState(); + const initialFetchAttemptedRef = useRef(false); const teamEntry = teams.find(t => t.team._id === teamId); const isAdmin = teamEntry?.role.toUpperCase() === 'ADMIN'; @@ -25,10 +24,16 @@ const TeamManagementPanel = () => { const [error, setError] = useState(null); useEffect(() => { - if (teams.length === 0) { - dispatch(fetchTeams()); + if (teams.length > 0) { + initialFetchAttemptedRef.current = true; + return; } - }, [dispatch, teams.length]); + + if (!initialFetchAttemptedRef.current) { + initialFetchAttemptedRef.current = true; + void refreshTeams(); + } + }, [refreshTeams, teams.length]); // Redirect if user doesn't have admin access to this team useEffect(() => { @@ -50,7 +55,7 @@ const TeamManagementPanel = () => { setError(null); try { await teamApi.updateTeam(teamId, { name: editTeamName.trim() }); - dispatch(fetchTeams()); + await refreshTeams(); setIsEditModalOpen(false); } catch (err) { setError( @@ -69,6 +74,7 @@ const TeamManagementPanel = () => { setError(null); try { await teamApi.deleteTeam(teamId); + await refreshTeams(); navigateBack(); // Navigate back after deletion } catch (err) { setError( diff --git a/app/src/components/settings/panels/TeamMembersPanel.tsx b/app/src/components/settings/panels/TeamMembersPanel.tsx index fbb83f85c..2d4c7b7f0 100644 --- a/app/src/components/settings/panels/TeamMembersPanel.tsx +++ b/app/src/components/settings/panels/TeamMembersPanel.tsx @@ -1,9 +1,8 @@ import { useEffect, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; +import { useCoreState } from '../../../providers/CoreStateProvider'; import { teamApi } from '../../../services/api/teamApi'; -import { useAppDispatch, useAppSelector } from '../../../store/hooks'; -import { fetchMembers } from '../../../store/teamSlice'; import type { TeamMember, TeamRole } from '../../../types/team'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; @@ -14,19 +13,20 @@ const TeamMembersPanel = () => { const { teamId } = useParams<{ teamId: string }>(); const location = useLocation(); const { navigateBack } = useSettingsNavigation(); - const dispatch = useAppDispatch(); - const user = useAppSelector(state => state.user.user); - const { teams, members, isLoadingMembers } = useAppSelector(state => state.team); + const { snapshot, teams, teamMembersById, refreshTeamMembers } = useCoreState(); + const user = snapshot.currentUser; // Check if we're in team management context (has teamId in URL) const isInManagementContext = location.pathname.includes('/team/manage/'); const currentTeamId = isInManagementContext ? teamId : user?.activeTeamId; const currentTeam = teams.find(t => t.team._id === currentTeamId); const isAdmin = currentTeam?.role.toUpperCase() === 'ADMIN'; + const members = currentTeamId ? (teamMembersById[currentTeamId] ?? []) : []; const [removingId, setRemovingId] = useState(null); const [changingRoleId, setChangingRoleId] = useState(null); const [error, setError] = useState(null); + const [isLoadingMembers, setIsLoadingMembers] = useState(false); // Confirmation modals state const [memberToRemove, setMemberToRemove] = useState(null); @@ -37,8 +37,10 @@ const TeamMembersPanel = () => { } | null>(null); useEffect(() => { - if (currentTeamId) dispatch(fetchMembers(currentTeamId)); - }, [currentTeamId, dispatch]); + if (!currentTeamId) return; + setIsLoadingMembers(true); + void refreshTeamMembers(currentTeamId).finally(() => setIsLoadingMembers(false)); + }, [currentTeamId, refreshTeamMembers]); const handleChangeRole = (member: TeamMember, newRole: TeamRole) => { if (!currentTeamId || member.role === newRole) return; @@ -56,7 +58,7 @@ const TeamMembersPanel = () => { try { await teamApi.changeMemberRole(currentTeamId, member.user._id, newRole); - dispatch(fetchMembers(currentTeamId)); + await refreshTeamMembers(currentTeamId); setRoleChangeConfirmation(null); } catch (err) { setError( @@ -82,7 +84,7 @@ const TeamMembersPanel = () => { try { await teamApi.removeMember(currentTeamId, memberToRemove.user._id); - dispatch(fetchMembers(currentTeamId)); + await refreshTeamMembers(currentTeamId); setMemberToRemove(null); } catch (err) { setError( diff --git a/app/src/components/settings/panels/TeamPanel.tsx b/app/src/components/settings/panels/TeamPanel.tsx index c585e3c5a..e0175d9c8 100644 --- a/app/src/components/settings/panels/TeamPanel.tsx +++ b/app/src/components/settings/panels/TeamPanel.tsx @@ -1,21 +1,19 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { useCoreState } from '../../../providers/CoreStateProvider'; import { teamApi } from '../../../services/api/teamApi'; -import { useAppDispatch, useAppSelector } from '../../../store/hooks'; -import { fetchTeams } from '../../../store/teamSlice'; -import { fetchCurrentUser } from '../../../store/userSlice'; import type { TeamWithRole } from '../../../types/team'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; const TeamPanel = () => { const { navigateBack, navigateToTeamManagement } = useSettingsNavigation(); - const dispatch = useAppDispatch(); - const user = useAppSelector(state => state.user.user); - const { teams, isLoading } = useAppSelector(state => state.team); + const { snapshot, teams, refresh, refreshTeams } = useCoreState(); + const user = snapshot.currentUser; const [newTeamName, setNewTeamName] = useState(''); const [joinCode, setJoinCode] = useState(''); + const [isLoading, setIsLoading] = useState(false); const [isCreating, setIsCreating] = useState(false); const [isJoining, setIsJoining] = useState(false); const [isSwitching, setIsSwitching] = useState(null); @@ -27,9 +25,18 @@ const TeamPanel = () => { const activeTeamId = user?.activeTeamId; + const refreshTeamsWithLoading = useCallback(async () => { + setIsLoading(true); + try { + await refreshTeams(); + } finally { + setIsLoading(false); + } + }, [refreshTeams]); + useEffect(() => { - dispatch(fetchTeams()); - }, [dispatch]); + void refreshTeamsWithLoading(); + }, [refreshTeamsWithLoading]); const handleCreateTeam = async () => { const name = newTeamName.trim(); @@ -39,7 +46,7 @@ const TeamPanel = () => { try { await teamApi.createTeam(name); setNewTeamName(''); - dispatch(fetchTeams()); + await refreshTeamsWithLoading(); } catch (err) { setError( err && typeof err === 'object' && 'error' in err @@ -59,8 +66,7 @@ const TeamPanel = () => { try { await teamApi.joinTeam(code); setJoinCode(''); - await dispatch(fetchCurrentUser()); - dispatch(fetchTeams()); + await Promise.all([refresh(), refreshTeamsWithLoading()]); } catch (err) { setError( err && typeof err === 'object' && 'error' in err @@ -78,8 +84,7 @@ const TeamPanel = () => { setError(null); try { await teamApi.switchTeam(teamId); - await dispatch(fetchCurrentUser()); - dispatch(fetchTeams()); + await Promise.all([refresh(), refreshTeamsWithLoading()]); } catch (err) { setError( err && typeof err === 'object' && 'error' in err @@ -104,8 +109,7 @@ const TeamPanel = () => { try { await teamApi.leaveTeam(teamToLeave.team._id); - await dispatch(fetchCurrentUser()); - dispatch(fetchTeams()); + await Promise.all([refresh(), refreshTeamsWithLoading()]); setTeamToLeave(null); } catch (err) { setError( diff --git a/app/src/components/settings/panels/billingHelpers.ts b/app/src/components/settings/panels/billingHelpers.ts index e9d41c320..6f1244e32 100644 --- a/app/src/components/settings/panels/billingHelpers.ts +++ b/app/src/components/settings/panels/billingHelpers.ts @@ -47,7 +47,10 @@ export const PLANS: PlanMeta[] = [ storageLimitBytes: 10 * 1024 * 1024 * 1024, features: [ { text: 'Higher included premium usage every billing cycle', included: true }, - { text: '20% premium-usage discount across integrations, bandwidth, and inference', included: true }, + { + text: '20% premium-usage discount across integrations, bandwidth, and inference', + included: true, + }, { text: 'Pay-as-you-go top-ups for overflow usage', included: true }, ], }, diff --git a/app/src/hooks/useConsciousItems.ts b/app/src/hooks/useConsciousItems.ts index 7d5eadb05..073b73095 100644 --- a/app/src/hooks/useConsciousItems.ts +++ b/app/src/hooks/useConsciousItems.ts @@ -15,10 +15,9 @@ */ import { listen } from '@tauri-apps/api/event'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { getCoreStateSnapshot } from '../lib/coreState/store'; import { getBackendUrl } from '../services/backendUrl'; -import type { RootState } from '../store'; import type { ActionableItem, ActionableItemPriority, @@ -143,8 +142,6 @@ export interface UseConsciousItemsResult { } export function useConsciousItems(): UseConsciousItemsResult { - const authToken = useSelector((state: RootState) => state.auth.token); - const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [isRunning, setIsRunning] = useState(false); @@ -176,13 +173,14 @@ export function useConsciousItems(): UseConsciousItemsResult { }, []); const triggerAnalysis = useCallback(async () => { + const authToken = getCoreStateSnapshot().snapshot.sessionToken; if (!isTauri() || !authToken || isRunning) return; try { await consciousLoopRun(authToken, await getBackendUrl()); } catch (err) { console.warn('[conscious] Failed to trigger analysis:', err); } - }, [authToken, isRunning]); + }, [isRunning]); // Initial fetch useEffect(() => { diff --git a/app/src/hooks/useIntelligenceSocket.ts b/app/src/hooks/useIntelligenceSocket.ts index df72c1a88..8aa687a91 100644 --- a/app/src/hooks/useIntelligenceSocket.ts +++ b/app/src/hooks/useIntelligenceSocket.ts @@ -1,361 +1,72 @@ import { useCallback, useEffect, useRef } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useCoreState } from '../providers/CoreStateProvider'; import { socketService } from '../services/socketService'; -import type { RootState } from '../store'; -import { - addMessage, - setExecutionResult, - setTyping, - updateExecutionProgress, -} from '../store/intelligenceSlice'; -import { createChatMessage } from '../utils/intelligenceTransforms'; +import { useAppSelector } from '../store/hooks'; +import { selectSocketStatus } from '../store/socketSelectors'; -/** - * WebSocket event payloads for Intelligence system - */ -interface ProcessMessagePayload { - message: string; - threadId: string; - sessionId?: string; - context?: Record; -} - -interface AgentResponsePayload { - message: string; - threadId: string; - sessionId?: string; - shouldExecute?: boolean; - executionPlan?: unknown; - metadata?: Record; -} - -interface ExecutionProgressPayload { - executionId: string; - sessionId: string; - step: { - id: string; - label: string; - status: 'pending' | 'in_progress' | 'completed' | 'failed'; - timestamp: string; - }; - progress: Array<{ - id: string; - label: string; - status: 'pending' | 'in_progress' | 'completed' | 'failed'; - timestamp?: Date; - }>; -} - -interface ExecutionCompletePayload { - executionId: string; - sessionId: string; - status: 'completed' | 'failed'; - result?: unknown; - error?: string; - artifacts?: Array<{ type: string; url: string; title: string; description?: string }>; -} - -interface ChatInitPayload { - tools: Record[]; - threadId: string; - sessionId?: string; - context?: Record; -} - -/** - * Hook for Intelligence WebSocket integration - * Handles real-time communication for chat and task execution - */ export const useIntelligenceSocket = () => { - const dispatch = useDispatch(); - const socket = socketService.getSocket(); - const eventHandlersRegistered = useRef(false); - - // Get current socket connection status - const isSocketConnected = useSelector((state: RootState) => { - const userId = state.user.user?._id || '__pending__'; - return state.socket.byUser[userId]?.status === 'connected'; - }); - - /** - * Send message to AI agent via WebSocket - */ - const sendMessage = useCallback( - async (payload: ProcessMessagePayload) => { - if (socket?.connected) { - socket.emit('processMessageForUser', payload); - } else { - console.warn('Cannot send message - socket not connected'); - throw new Error('Socket not connected'); - } - }, - [socket] - ); - - /** - * Initialize chat session with tools - */ - const sendChatInit = useCallback( - async (payload: ChatInitPayload) => { - if (socket?.connected) { - socket.emit('chat:init', payload); - } else { - console.warn('Cannot initialize chat - socket not connected'); - throw new Error('Socket not connected'); - } - }, - [socket] - ); - - /** - * Send typing indicator - */ - const sendTyping = useCallback( - (threadId: string, isTyping: boolean) => { - const payload = { threadId, isTyping }; - - if (socket?.connected) { - socket.emit('chat:typing', payload); - } - }, - [socket] - ); - - /** - * Register WebSocket event handlers - */ - const registerEventHandlers = useCallback(() => { - if (!socket || eventHandlersRegistered.current) return; - - // Agent response handler - const handleAgentResponse = (data: AgentResponsePayload) => { - console.log('Intelligence: Received agent response', { - threadId: data.threadId, - hasMessage: !!data.message, - shouldExecute: data.shouldExecute, - }); - - if (data.message && data.threadId) { - const aiMessage = createChatMessage(data.message, 'ai'); - dispatch(addMessage({ threadId: data.threadId, message: aiMessage })); - } - - // Stop typing indicator - dispatch(setTyping({ threadId: data.threadId, isTyping: false })); - - // Handle execution trigger - if (data.shouldExecute && data.executionPlan) { - console.log('Intelligence: Execution requested', { - threadId: data.threadId, - plan: data.executionPlan, - }); - // Execution will be handled by the component - } - }; - - // Execution progress handler - const handleExecutionProgress = (data: ExecutionProgressPayload) => { - console.log('Intelligence: Execution progress', { - executionId: data.executionId, - step: data.step?.label, - status: data.step?.status, - }); - - if (data.progress) { - dispatch( - updateExecutionProgress({ executionId: data.executionId, progress: data.progress }) - ); - } - }; - - // Execution complete handler - const handleExecutionComplete = (data: ExecutionCompletePayload) => { - console.log('Intelligence: Execution complete', { - executionId: data.executionId, - status: data.status, - hasResult: !!data.result, - hasArtifacts: !!data.artifacts?.length, - }); - - dispatch( - setExecutionResult({ - executionId: data.executionId, - result: data.result, - status: data.status, - error: data.error, - }) - ); - - // Send completion message if we have artifacts - if (data.artifacts?.length && data.sessionId) { - // This would need the threadId - we'd need to track execution to thread mapping - // For now, we'll let the component handle the completion message - console.log('Intelligence: Task completed with artifacts', { - artifactCount: data.artifacts.length, - sessionId: data.sessionId, - }); - } - }; - - // Typing indicator handler - const handleTyping = (data: { threadId: string; isTyping: boolean }) => { - dispatch(setTyping({ threadId: data.threadId, isTyping: data.isTyping })); - }; - - // Register all handlers - socket.on('agentResponse', handleAgentResponse); - socket.on('execution:step_progress', handleExecutionProgress); - socket.on('execution:complete', handleExecutionComplete); - socket.on('chat:typing', handleTyping); - - eventHandlersRegistered.current = true; - - // Return cleanup function - return () => { - socket.off('agentResponse', handleAgentResponse); - socket.off('execution:step_progress', handleExecutionProgress); - socket.off('execution:complete', handleExecutionComplete); - socket.off('chat:typing', handleTyping); - eventHandlersRegistered.current = false; - }; - }, [socket, dispatch]); - - /** - * Register event handlers when socket is available - */ - useEffect(() => { - if (socket && isSocketConnected) { - const cleanup = registerEventHandlers(); - return cleanup; - } - }, [socket, isSocketConnected, registerEventHandlers]); - - /** - * Cleanup on unmount - */ - useEffect(() => { - return () => { - eventHandlersRegistered.current = false; - }; - }, []); + const socketStatus = useAppSelector(selectSocketStatus); return { - // Connection status - isConnected: isSocketConnected, - - // Message sending - sendMessage, - sendChatInit, - sendTyping, - - // Utility functions - isReady: isSocketConnected && !!socket, + isConnected: socketStatus === 'connected', + isReady: socketStatus === 'connected', + sendMessage: async () => {}, + sendChatInit: async () => {}, + sendTyping: () => {}, }; }; -/** - * Hook for managing Intelligence WebSocket connection lifecycle - */ export const useIntelligenceSocketManager = () => { - const token = useSelector((state: RootState) => state.auth.token); - const isConnected = useSelector((state: RootState) => { - const userId = state.user.user?._id || '__pending__'; - return state.socket.byUser[userId]?.status === 'connected'; - }); + const { snapshot } = useCoreState(); + const socketStatus = useAppSelector(selectSocketStatus); + const isConnected = socketStatus === 'connected'; + const token = snapshot.sessionToken; + const previousTokenRef = useRef(null); - /** - * Initialize Intelligence socket connection - */ - const connect = useCallback(() => { - if (token && !isConnected) { - console.log('Intelligence: Initializing socket connection'); - socketService.connect(token); - } - }, [token, isConnected]); + const connect = useCallback( + (nextToken?: string | null) => { + const tokenToUse = nextToken ?? token; + if (tokenToUse) { + socketService.connect(tokenToUse); + } + }, + [token] + ); - /** - * Disconnect Intelligence socket - */ const disconnect = useCallback(() => { - console.log('Intelligence: Disconnecting socket'); socketService.disconnect(); }, []); - /** - * Auto-connect when token is available - */ useEffect(() => { - if (token && !isConnected) { + const previousToken = previousTokenRef.current; + + if (!token) { + if (previousToken || isConnected) { + disconnect(); + } + previousTokenRef.current = null; + return; + } + + if (previousToken && previousToken !== token) { + disconnect(); + previousTokenRef.current = token; + connect(token); + return; + } + + if (!isConnected) { + previousTokenRef.current = token; connect(); } - }, [token, isConnected, connect]); + }, [connect, disconnect, isConnected, token]); - return { connect, disconnect, isConnected, isReady: isConnected && !!token }; + return { connect, disconnect, isConnected, isReady: Boolean(token) && isConnected }; }; -/** - * Hook for Intelligence-specific event subscriptions - */ -export const useIntelligenceEvents = () => { - const socket = socketService.getSocket(); - - /** - * Subscribe to agent responses for a specific thread - */ - const onAgentResponse = useCallback( - (threadId: string, callback: (data: AgentResponsePayload) => void) => { - if (!socket) return () => {}; - - const handler = (data: AgentResponsePayload) => { - if (data.threadId === threadId) { - callback(data); - } - }; - - socket.on('agentResponse', handler); - return () => socket.off('agentResponse', handler); - }, - [socket] - ); - - /** - * Subscribe to execution progress for a specific execution - */ - const onExecutionProgress = useCallback( - (executionId: string, callback: (data: ExecutionProgressPayload) => void) => { - if (!socket) return () => {}; - - const handler = (data: ExecutionProgressPayload) => { - if (data.executionId === executionId) { - callback(data); - } - }; - - socket.on('execution:step_progress', handler); - return () => socket.off('execution:step_progress', handler); - }, - [socket] - ); - - /** - * Subscribe to execution completion for a specific execution - */ - const onExecutionComplete = useCallback( - (executionId: string, callback: (data: ExecutionCompletePayload) => void) => { - if (!socket) return () => {}; - - const handler = (data: ExecutionCompletePayload) => { - if (data.executionId === executionId) { - callback(data); - } - }; - - socket.on('execution:complete', handler); - return () => socket.off('execution:complete', handler); - }, - [socket] - ); - - return { onAgentResponse, onExecutionProgress, onExecutionComplete }; -}; +export const useIntelligenceEvents = () => ({ + onAgentResponse: () => () => {}, + onExecutionProgress: () => () => {}, + onExecutionComplete: () => () => {}, +}); diff --git a/app/src/hooks/useUser.ts b/app/src/hooks/useUser.ts index 0183525aa..3cf8ccb31 100644 --- a/app/src/hooks/useUser.ts +++ b/app/src/hooks/useUser.ts @@ -1,31 +1,10 @@ -import { useEffect, useRef } from 'react'; - -import { useAppDispatch, useAppSelector } from '../store/hooks'; -import { fetchCurrentUser } from '../store/userSlice'; +import { useCoreState } from '../providers/CoreStateProvider'; /** - * Hook to access user data and automatically fetch it when token is available + * Hook to access the current core-owned user snapshot. */ export const useUser = () => { - const dispatch = useAppDispatch(); - const token = useAppSelector(state => state.auth.token); - const user = useAppSelector(state => state.user.user); - const isLoading = useAppSelector(state => state.user.isLoading); - const error = useAppSelector(state => state.user.error); - const lastAutoFetchTokenRef = useRef(null); + const { isBootstrapping, snapshot, refresh } = useCoreState(); - useEffect(() => { - if (!token) { - lastAutoFetchTokenRef.current = null; - return; - } - - // Auto-fetch at most once per token to avoid infinite retry loops on persistent 401s. - if (!user && !isLoading && lastAutoFetchTokenRef.current !== token) { - lastAutoFetchTokenRef.current = token; - dispatch(fetchCurrentUser()); - } - }, [token, user, isLoading, dispatch]); - - return { user, isLoading, error, refetch: () => dispatch(fetchCurrentUser()) }; + return { user: snapshot.currentUser, isLoading: isBootstrapping, error: null, refetch: refresh }; }; diff --git a/app/src/hooks/useWebhooks.ts b/app/src/hooks/useWebhooks.ts index d47cbf88a..d882f2737 100644 --- a/app/src/hooks/useWebhooks.ts +++ b/app/src/hooks/useWebhooks.ts @@ -1,6 +1,7 @@ import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCoreState } from '../providers/CoreStateProvider'; import { tunnelsApi } from '../services/api/tunnelsApi'; import { getCoreHttpBaseUrl } from '../services/coreRpcClient'; import { useAppDispatch, useAppSelector } from '../store/hooks'; @@ -45,11 +46,12 @@ function logToActivity(entry: WebhookDebugLogEntry): WebhookActivityEntry { * - Subscribes to SSE /events/webhooks for real-time activity updates */ export function useWebhooks() { + const { snapshot } = useCoreState(); const dispatch = useAppDispatch(); const { tunnels, registrations, activity, loading, error } = useAppSelector( state => state.webhooks ); - const token = useAppSelector(state => state.auth.token); + const token = snapshot.sessionToken; const [coreConnected, setCoreConnected] = useState(false); const eventSourceRef = useRef(null); diff --git a/app/src/lib/coreState/store.ts b/app/src/lib/coreState/store.ts new file mode 100644 index 000000000..880596a6f --- /dev/null +++ b/app/src/lib/coreState/store.ts @@ -0,0 +1,85 @@ +import type { User } from '../../types/api'; +import type { TeamInvite, TeamMember, TeamWithRole } from '../../types/team'; + +export interface CoreOnboardingTasks { + accessibilityPermissionGranted: boolean; + localModelConsentGiven: boolean; + localModelDownloadStarted: boolean; + enabledTools: string[]; + connectedSources: string[]; + updatedAtMs?: number; +} + +export interface CoreLocalState { + encryptionKey: string | null; + primaryWalletAddress: string | null; + onboardingTasks: CoreOnboardingTasks | null; +} + +export interface CoreAppSnapshot { + auth: { + isAuthenticated: boolean; + userId: string | null; + user: unknown | null; + profileId: string | null; + }; + sessionToken: string | null; + currentUser: User | null; + onboardingCompleted: boolean; + analyticsEnabled: boolean; + localState: CoreLocalState; +} + +export interface CoreState { + isBootstrapping: boolean; + isReady: boolean; + snapshot: CoreAppSnapshot; + teams: TeamWithRole[]; + teamMembersById: Record; + teamInvitesById: Record; +} + +const emptySnapshot: CoreAppSnapshot = { + auth: { isAuthenticated: false, userId: null, user: null, profileId: null }, + sessionToken: null, + currentUser: null, + onboardingCompleted: false, + analyticsEnabled: false, + localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null }, +}; + +let currentState: CoreState = { + isBootstrapping: true, + isReady: false, + snapshot: emptySnapshot, + teams: [], + teamMembersById: {}, + teamInvitesById: {}, +}; + +export function getCoreStateSnapshot(): CoreState { + return currentState; +} + +export function setCoreStateSnapshot(next: CoreState): void { + currentState = next; +} + +export function patchCoreStateSnapshot(patch: { + snapshot?: Record & { localState?: Partial }; + [key: string]: unknown; +}): void { + currentState = { + ...currentState, + ...patch, + snapshot: patch.snapshot + ? { + ...currentState.snapshot, + ...patch.snapshot, + localState: patch.snapshot.localState + ? { ...currentState.snapshot.localState, ...patch.snapshot.localState } + : currentState.snapshot.localState, + } + : currentState.snapshot, + }; +} diff --git a/app/src/lib/skills/manager.ts b/app/src/lib/skills/manager.ts index 8dc6bc1d9..149c6ac2e 100644 --- a/app/src/lib/skills/manager.ts +++ b/app/src/lib/skills/manager.ts @@ -6,6 +6,7 @@ */ import { callCoreRpc } from "../../services/coreRpcClient"; +import { getCoreStateSnapshot, patchCoreStateSnapshot } from "../../lib/coreState/store"; import { SkillRuntime } from "./runtime"; import { emitSkillStateChange } from "./skillEvents"; import { @@ -25,8 +26,6 @@ import type { SkillToolDefinition, SkillOptionDefinition, } from "./types"; -import { store } from "../../store"; -import { setPrimaryWalletAddressForUser } from "../../store/authSlice"; import { runtimeSkillDataDir, runtimeSkillDataRead, @@ -47,10 +46,7 @@ class SkillManager { const params: Record = {}; if (skillId === "wallet") { - const state = store.getState(); - const userId = state.user.user?._id; - const primaryAddress = - userId && state.auth.primaryWalletAddressByUser?.[userId]; + const primaryAddress = getCoreStateSnapshot().snapshot.localState.primaryWalletAddress; if (primaryAddress) { params.walletAddress = primaryAddress; } @@ -525,12 +521,18 @@ class SkillManager { * sends load params so the skill receives onLoad({ walletAddress }). */ async setWalletAddress(address: string): Promise { - const state = store.getState(); - const userId = state.user.user?._id; - if (!userId) { - return; - } - store.dispatch(setPrimaryWalletAddressForUser({ userId, address })); + await callCoreRpc({ + method: "openhuman.app_state_update_local_state", + params: { primaryWalletAddress: address }, + }); + patchCoreStateSnapshot({ + snapshot: { + localState: { + ...getCoreStateSnapshot().snapshot.localState, + primaryWalletAddress: address, + }, + }, + }); const runtime = this.runtimes.get("wallet"); if (runtime?.isRunning) { await runtime.load({ walletAddress: address }); diff --git a/app/src/main.tsx b/app/src/main.tsx index dcd370548..77fa2cae2 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -5,13 +5,13 @@ import ReactDOM from 'react-dom/client'; import App from './App'; import ErrorReportNotification from './components/ErrorReportNotification'; import './index.css'; +import { getCoreStateSnapshot } from './lib/coreState/store'; import './polyfills'; import { initSentry } from './services/analytics'; import { setStoreForApiClient } from './services/apiClient'; -import { store } from './store'; import { setupDesktopDeepLinkListener } from './utils/desktopDeepLinkListener'; -setStoreForApiClient(() => store.getState().auth.token); +setStoreForApiClient(() => getCoreStateSnapshot().snapshot.sessionToken); const ensureDefaultHashRoute = () => { const hash = window.location.hash; diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index 46f42e316..75a0f1fdf 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { ActionableCard } from '../components/intelligence/ActionableCard'; import { ConfirmationModal } from '../components/intelligence/ConfirmationModal'; @@ -17,8 +16,6 @@ import { } from '../hooks/useIntelligenceSocket'; import { useIntelligenceStats } from '../hooks/useIntelligenceStats'; import { useScreenIntelligenceItems } from '../hooks/useScreenIntelligenceItems'; -import type { RootState } from '../store'; -import { setSearchFilter, setSourceFilter } from '../store/intelligenceSlice'; import type { ActionableItem, ActionableItemSource, @@ -30,14 +27,12 @@ import type { type IntelligenceTab = 'memory' | 'subconscious' | 'dreams'; export default function Intelligence() { - const dispatch = useDispatch(); const { aiStatus } = useIntelligenceStats(); const [activeTab, setActiveTab] = useState('memory'); - - // Redux state - const intelligenceState = useSelector((state: RootState) => state.intelligence); - const { filters } = intelligenceState; + const [sourceFilter, setSourceFilter] = useState('all'); + const [priorityFilter] = useState<'critical' | 'important' | 'normal' | 'all'>('all'); + const [searchFilter, setSearchFilter] = useState(''); // Conscious memory items (real data from the background analysis loop) const { @@ -96,11 +91,11 @@ export default function Intelligence() { const filteredItems = useMemo(() => { const activeItems = items.filter(item => item.status === 'active'); return filterItems(activeItems, { - source: filters.source, - priority: filters.priority, - searchTerm: filters.search, + source: sourceFilter, + priority: priorityFilter, + searchTerm: searchFilter, }); - }, [items, filters.source, filters.priority, filters.search]); + }, [items, priorityFilter, searchFilter, sourceFilter]); const timeGroups = useMemo(() => groupItemsByTime(filteredItems), [filteredItems]); const stats = useMemo(() => getItemStats(filteredItems), [filteredItems]); @@ -328,16 +323,14 @@ export default function Intelligence() { dispatch(setSearchFilter(e.target.value))} + value={searchFilter} + onChange={e => setSearchFilter(e.target.value)} className="w-full px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors" />