Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/src/context/AppContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
26 changes: 3 additions & 23 deletions frontend/src/context/AppContext.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,7 +17,6 @@ interface User {

interface AppContextValue {
currentUser: User | null;
setCurrentUser: React.Dispatch<React.SetStateAction<User | null>>;
posts: Post[];
setPosts: React.Dispatch<React.SetStateAction<Post[]>>;
loading: boolean;
Expand All @@ -35,10 +35,10 @@ interface AppProviderProps {
}

export const AppProvider: React.FC<AppProviderProps> = ({ children }) => {
const [currentUser, setCurrentUser] = useState<User | null>(null);
// AuthProvider already owns this request and wraps AppProvider in App.tsx.
const { currentUser, loading: userLoading } = useAuth();
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(false);
const [userLoading, setUserLoading] = useState(true);
const [postsLoading, setPostsLoading] = useState(true);
const [refreshTrigger, setRefreshTrigger] = useState(0);

Expand All @@ -62,25 +62,6 @@ export const AppProvider: React.FC<AppProviderProps> = ({ 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<User>('/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;
Expand All @@ -107,7 +88,6 @@ export const AppProvider: React.FC<AppProviderProps> = ({ children }) => {
<AppContext.Provider
value={{
currentUser,
setCurrentUser,
posts,
setPosts,
loading,
Expand Down
43 changes: 23 additions & 20 deletions frontend/src/v2/V2App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { Routes, Route, Navigate, useLocation, useParams } from 'react-router-dom';
import { Routes, Route, Navigate, useLocation, useMatch, useParams } from 'react-router-dom';
import V2Layout from './components/V2Layout';
import V2FeaturePage from './components/V2FeaturePage';
import { useAuth } from '../context/AuthContext';
Expand Down Expand Up @@ -118,24 +118,35 @@ const V2RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) =>
return <V2AuthenticatedShell>{children}</V2AuthenticatedShell>;
};

// 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 <V2Boot />;
}

if (!isAuthenticated) {
return <Navigate to="/" replace />;
return location.pathname === '/v2' || location.pathname === '/v2/'
? <Navigate to="/" replace />
: <Navigate to="/v2/login" state={{ from: location }} replace />;
}

return <V2AuthenticatedShell><V2Layout selectionMode="auto" /></V2AuthenticatedShell>;
// 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 <Navigate to="/v2" replace />;
}

return <V2AuthenticatedShell><V2Layout /></V2AuthenticatedShell>;
};

const podTypeRoutes = new Set(['chat', 'study', 'games', 'gaming', 'agent-admin', 'agent-room']);

const V2UseCaseRedirect: React.FC = () => {
const { useCaseId } = useParams<{ useCaseId: string }>();
return <Navigate to={useCaseId ? `/use-cases/${encodeURIComponent(useCaseId)}/` : '/'} replace />;
Expand Down Expand Up @@ -165,15 +176,6 @@ const V2LegacyChatRedirect: React.FC = () => {
return <Navigate to={podId ? `/v2/pods/chat/${podId}` : '/v2'} replace />;
};

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 <Navigate to="/v2" replace />;
}
return <V2Layout selectionMode="param" />;
};

const V2App: React.FC = () => {
// The daily digest's 08:00 is the person's own (TASK-257 Phase 2b).
useTimeZoneCapture();
Expand All @@ -199,7 +201,10 @@ const V2App: React.FC = () => {
<V2ErrorBoundary>
<React.Suspense fallback={<V2Boot />}>
<Routes>
<Route index element={<V2Home />} />
<Route element={<V2PodShellGate />}>
<Route index element={null} />
<Route path="pods/:podId" element={null} />
</Route>
<Route path="landing" element={<Navigate to="/" replace />} />
{/* Public read-only agent profile — the "meet the agent" identity card.
Singular /v2/agent/... so it never collides with the authed plural
Expand Down Expand Up @@ -231,7 +236,6 @@ const V2App: React.FC = () => {
element={(
<V2RequireAuth>
<Routes>
<Route path="/" element={<V2Layout selectionMode="auto" />} />
{/* 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. */}
Expand All @@ -246,7 +250,6 @@ const V2App: React.FC = () => {
false,
)}
/>
<Route path="pods/:podId" element={<V2PodIdRoute />} />
<Route
path="pods/:podType/:roomId"
element={feature(
Expand Down
109 changes: 109 additions & 0 deletions frontend/src/v2/__tests__/V2ShellInitialLoad.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import axios from 'axios';
import { AuthProvider } from '../../context/AuthContext';
import { AppProvider } from '../../context/AppContext';
import V2App from '../V2App';

jest.mock('axios', () => {
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 <span data-testid="path">{location.pathname}</span>;
};

const renderShell = (path: string) => render(
<AuthProvider>
<AppProvider>
<MemoryRouter initialEntries={[path]}>
<Path />
<Routes>
<Route path="/v2/*" element={<V2App />} />
</Routes>
</MemoryRouter>
</AppProvider>
</AuthProvider>,
);

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();
});
});
6 changes: 4 additions & 2 deletions frontend/src/v2/components/V2Layout.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -70,7 +70,9 @@ const createdAtTime = (createdAt?: string): number => {
};

const V2Layout: React.FC<V2LayoutProps> = ({ 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();
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/v2/components/V2PodsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ const V2PodsSidebar: React.FC<V2PodsSidebarProps> = ({
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;
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/v2/hooks/useV2Pods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<V2Pod[]>([]);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -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 };
};
Loading