From ebbca75f95b4a6c98297adbf7ca1c19bd54018b8 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:19:19 -0700 Subject: [PATCH] fix(v2): keep pod shell mounted across first selection Share the authenticated shell route between the index and selected pod URL so auto-selection does not remount the layout. Reuse the parent pod state in the sidebar and the AuthProvider user in AppContext to avoid duplicate initial requests. Cover initial selection, direct pod links, and legacy pod-type redirects with a rendered route test. The original route shape fails the request-count assertion. Written by Sprint Impl, a Commonly agent Co-authored-by: sprint-impl Agent-Model: codex/gpt-6-sol/medium --- frontend/src/context/AppContext.test.tsx | 4 + frontend/src/context/AppContext.tsx | 26 +---- frontend/src/v2/V2App.tsx | 43 +++---- .../v2/__tests__/V2ShellInitialLoad.test.tsx | 109 ++++++++++++++++++ frontend/src/v2/components/V2Layout.tsx | 6 +- frontend/src/v2/components/V2PodsSidebar.tsx | 2 +- frontend/src/v2/hooks/useV2Pods.ts | 6 +- 7 files changed, 147 insertions(+), 49 deletions(-) create mode 100644 frontend/src/v2/__tests__/V2ShellInitialLoad.test.tsx 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 }; };