diff --git a/frontend/src/context/AppContext.test.tsx b/frontend/src/context/AppContext.test.tsx index 465d44e471..7df0556b7f 100644 --- a/frontend/src/context/AppContext.test.tsx +++ b/frontend/src/context/AppContext.test.tsx @@ -4,6 +4,10 @@ import ReactDOM from 'react-dom/client'; import TestUtils from 'react-dom/test-utils'; import { AppProvider, useAppContext } from './AppContext'; +jest.mock('./AuthContext', () => ({ + useAuth: () => ({ currentUser: null, loading: false }), +})); + jest.mock('axios', () => ({ get: jest.fn(), post: jest.fn(), put: jest.fn() })); const TestComponent = () => { diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx index bdb788fae3..dfe69cc823 100644 --- a/frontend/src/context/AppContext.tsx +++ b/frontend/src/context/AppContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useState, useContext, useEffect, useCallback } from 'react'; import axios from 'axios'; +import { useAuth } from './AuthContext'; interface Post { _id: string; @@ -16,7 +17,6 @@ interface User { interface AppContextValue { currentUser: User | null; - setCurrentUser: React.Dispatch>; posts: Post[]; setPosts: React.Dispatch>; loading: boolean; @@ -35,10 +35,10 @@ interface AppProviderProps { } export const AppProvider: React.FC = ({ children }) => { - const [currentUser, setCurrentUser] = useState(null); + // AuthProvider already owns this request and wraps AppProvider in App.tsx. + const { currentUser, loading: userLoading } = useAuth(); const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(false); - const [userLoading, setUserLoading] = useState(true); const [postsLoading, setPostsLoading] = useState(true); const [refreshTrigger, setRefreshTrigger] = useState(0); @@ -62,25 +62,6 @@ export const AppProvider: React.FC = ({ children }) => { setPosts((currentPosts) => currentPosts.filter((post) => post._id !== postId)); }, []); - useEffect(() => { - const fetchCurrentUser = async () => { - setUserLoading(true); - try { - const token = localStorage.getItem('token'); - if (!token) { setUserLoading(false); return; } - const response = await axios.get('/api/auth/user', { - headers: { Authorization: `Bearer ${token}` }, - }); - setCurrentUser(response.data); - } catch (error) { - console.error('Error fetching current user:', error); - } finally { - setUserLoading(false); - } - }; - fetchCurrentUser(); - }, [refreshTrigger]); - useEffect(() => { const fetchPosts = async () => { if (!currentUser) return; @@ -107,7 +88,6 @@ export const AppProvider: React.FC = ({ children }) => { = ({ children }) => return {children}; }; -// Index gate at `/v2`. Logged-out visitors see the public landing (the front -// door); authenticated users drop straight into the app shell. Deep routes -// stay behind V2RequireAuth (the `*` branch below), so an unauthenticated -// deep link still bounces through /v2/login?next=… and lands where it meant to. -const V2Home: React.FC = () => { +// One gate owns both `/v2` and `/v2/pods/:podId`, so auto-selection changes +// the URL without remounting the shell. Other deep routes stay behind +// V2RequireAuth (the `*` branch below). +const V2PodShellGate: React.FC = () => { const { isAuthenticated, loading } = useAuth(); + const location = useLocation(); + const podRoute = useMatch('/v2/pods/:podId'); if (loading) { return ; } if (!isAuthenticated) { - return ; + return location.pathname === '/v2' || location.pathname === '/v2/' + ? + : ; } - return ; + // Reject legacy pod-type paths before mounting V2Layout, which otherwise + // remembers the type name as the last selected pod. + if (podRoute?.params.podId && podTypeRoutes.has(podRoute.params.podId)) { + return ; + } + + return ; }; +const podTypeRoutes = new Set(['chat', 'study', 'games', 'gaming', 'agent-admin', 'agent-room']); + const V2UseCaseRedirect: React.FC = () => { const { useCaseId } = useParams<{ useCaseId: string }>(); return ; @@ -165,15 +176,6 @@ const V2LegacyChatRedirect: React.FC = () => { return ; }; -const V2PodIdRoute: React.FC = () => { - const { podId } = useParams<{ podId: string }>(); - const podTypeRoutes = new Set(['chat', 'study', 'games', 'gaming', 'agent-admin', 'agent-room']); - if (podId && podTypeRoutes.has(podId)) { - return ; - } - return ; -}; - const V2App: React.FC = () => { // The daily digest's 08:00 is the person's own (TASK-257 Phase 2b). useTimeZoneCapture(); @@ -199,7 +201,10 @@ const V2App: React.FC = () => { }> - } /> + }> + + + } /> {/* Public read-only agent profile — the "meet the agent" identity card. Singular /v2/agent/... so it never collides with the authed plural @@ -231,7 +236,6 @@ const V2App: React.FC = () => { element={( - } /> {/* Board must precede the param routes: the static "board" segment outranks both pods/:podId and pods/:podType/:roomId in v6 matching, but keeping it first makes intent legible. */} @@ -246,7 +250,6 @@ const V2App: React.FC = () => { false, )} /> - } /> { + const client = { + get: jest.fn(), post: jest.fn(), patch: jest.fn(), put: jest.fn(), delete: jest.fn(), + defaults: { baseURL: '', headers: { common: {} } }, + interceptors: { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }, + }; + return { __esModule: true, default: client, ...client }; +}); + +jest.mock('../hooks/useV2PodDetail', () => ({ + useV2PodDetail: () => ({ pod: null, members: [], messages: [], agents: [], loading: false, error: null }), +})); +jest.mock('../hooks/useV2PodAttention', () => ({ + useV2PodAttention: () => ({ items: [], count: 0, countByPod: {} }), + notifyAttentionChanged: jest.fn(), +})); +jest.mock('../hooks/useV2Pinned', () => ({ + useV2Pinned: () => ({ pinned: [], toggle: jest.fn() }), +})); +jest.mock('../components/V2NavRail', () => () => null); +jest.mock('../components/V2Thread', () => () => null); +jest.mock('../components/V2Inspector', () => () => null); +jest.mock('../components/V2InviteModal', () => () => null); +jest.mock('../components/V2FirstRunHero', () => () => null); +jest.mock('../components/V2MobileTabs', () => () => null); + +const Path = () => { + const location = useLocation(); + return {location.pathname}; +}; + +const renderShell = (path: string) => render( + + + + + + } /> + + + + , +); + +describe('first desktop shell load', () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem('token', 'test-token'); + jest.clearAllMocks(); + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: jest.fn(() => ({ matches: false, addEventListener: jest.fn(), removeEventListener: jest.fn() })), + }); + (axios.get as jest.Mock).mockImplementation((url: string) => { + if (url === '/api/auth/user') { + return Promise.resolve({ data: { _id: 'human-1', username: 'human', email: 'human@example.test', verified: true } }); + } + if (url === '/api/pods') { + return Promise.resolve({ data: [{ + _id: 'workspace', name: 'Workspace', joinPolicy: 'invite-only', createdBy: { _id: 'human-1' }, + }] }); + } + if (url === '/api/posts') return Promise.resolve({ data: [] }); + return Promise.resolve({ data: {} }); + }); + (axios.post as jest.Mock).mockResolvedValue({ data: {} }); + }); + + afterEach(() => localStorage.clear()); + + test('auto-select navigation keeps one layout fetch and one auth fetch', async () => { + renderShell('/v2'); + + await waitFor(() => expect(screen.getByTestId('path')).toHaveTextContent('/v2/pods/workspace')); + await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/pods', expect.anything())); + expect((axios.get as jest.Mock).mock.calls.filter(([url]) => url === '/api/pods')).toHaveLength(1); + expect((axios.get as jest.Mock).mock.calls.filter(([url]) => url === '/api/auth/user')).toHaveLength(1); + }); + + test('direct pod link uses the same single-fetch shell', async () => { + renderShell('/v2/pods/workspace'); + + await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/pods', expect.anything())); + expect(screen.getByTestId('path')).toHaveTextContent('/v2/pods/workspace'); + expect((axios.get as jest.Mock).mock.calls.filter(([url]) => url === '/api/pods')).toHaveLength(1); + expect((axios.get as jest.Mock).mock.calls.filter(([url]) => url === '/api/auth/user')).toHaveLength(1); + }); + + test('legacy pod-type path redirects before the shell stores a selected pod', async () => { + const store = jest.spyOn(Storage.prototype, 'setItem'); + renderShell('/v2/pods/chat'); + + await waitFor(() => expect(screen.getByTestId('path')).toHaveTextContent('/v2/pods/workspace')); + expect((axios.get as jest.Mock).mock.calls.filter(([url]) => url === '/api/pods')).toHaveLength(1); + expect(store).not.toHaveBeenCalledWith('v2:lastPodId', 'chat'); + store.mockRestore(); + }); +}); diff --git a/frontend/src/v2/components/V2Layout.tsx b/frontend/src/v2/components/V2Layout.tsx index 82d3cc0af1..4bb0d30445 100644 --- a/frontend/src/v2/components/V2Layout.tsx +++ b/frontend/src/v2/components/V2Layout.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; -import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { useLocation, useMatch, useNavigate } from 'react-router-dom'; import V2NavRail from './V2NavRail'; import V2PodsSidebar from './V2PodsSidebar'; import V2Thread from './V2Thread'; @@ -70,7 +70,9 @@ const createdAtTime = (createdAt?: string): number => { }; const V2Layout: React.FC = ({ selectionMode = 'auto' }) => { - const { podId: paramPodId } = useParams<{ podId: string }>(); + // This layout is shared by the index and pod routes. Read the URL directly: + // parent route params do not include a matched child's :podId. + const paramPodId = useMatch('/v2/pods/:podId')?.params.podId; const location = useLocation(); const navigate = useNavigate(); const { currentUser } = useAuth(); diff --git a/frontend/src/v2/components/V2PodsSidebar.tsx b/frontend/src/v2/components/V2PodsSidebar.tsx index ca55c45953..9e54f814ac 100644 --- a/frontend/src/v2/components/V2PodsSidebar.tsx +++ b/frontend/src/v2/components/V2PodsSidebar.tsx @@ -201,7 +201,7 @@ const V2PodsSidebar: React.FC = ({ const navigate = useNavigate(); const { currentUser } = useAuth(); const { pinned, toggle: togglePin } = useV2Pinned(); - const ownPodsState = useV2Pods(); + const ownPodsState = useV2Pods(!podsState); const { pods, loading, error, createPod, refresh: refreshPods, } = podsState || ownPodsState; diff --git a/frontend/src/v2/hooks/useV2Pods.ts b/frontend/src/v2/hooks/useV2Pods.ts index 23cb827480..43e53d2c0c 100644 --- a/frontend/src/v2/hooks/useV2Pods.ts +++ b/frontend/src/v2/hooks/useV2Pods.ts @@ -51,7 +51,7 @@ export interface UseV2PodsResult { patchLastMessage: (podId: string, last: V2PodLastMessage) => void; } -export const useV2Pods = (): UseV2PodsResult => { +export const useV2Pods = (enabled = true): UseV2PodsResult => { const api = useV2Api(); const [pods, setPods] = useState([]); const [loading, setLoading] = useState(true); @@ -143,8 +143,8 @@ export const useV2Pods = (): UseV2PodsResult => { }, [api]); useEffect(() => { - refresh(); - }, [refresh]); + if (enabled) void refresh(); + }, [enabled, refresh]); return { pods, loading, error, refresh, createPod, deletePod, patchLastMessage }; };