mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
⚡ perf: Warm Feature Catalogs in the Background After First Paint (#15047)
* perf: warm feature catalogs in the background after first paint Prompt groups and MCP server/tool queries no longer fire on the app startup path. A catalog warmup store releases them after first paint on browser idle, staggered with jitter so a fleet of clients does not burst the API all at once. Panels opened before warmup activates their catalog immediately and fall back to their existing loading states. The prompts list endpoints now also run their independent access lookups in parallel instead of in three serial rounds. * perf: gate MCP icon observers and re-arm warmup across sessions MCP icon/name observers mounted from rendered messages now wait for the warmup gate like every other server-catalog consumer, so conversations with MCP tool calls no longer pull the server list onto the first-render path. The warmup schedule resets on logout so a second login in the same tab warms on its own stagger instead of releasing every catalog at once. Panel mount activations now require a visible sidebar, since a persisted active panel stays mounted while hidden. * perf: void stale warmup callbacks and gate the agent panel tools query Reset now bumps a generation captured by every idle callback and its stagger timer, so callbacks pending across a logout can no longer release catalogs into the next session. The agent form's MCP tools query keeps its own readiness gate so a hidden persisted panel cannot pull the tools request ahead of its stagger once the server list resolves. * perf: reset warmup on Root unmount and honor the insights route collapse Root can unmount in the same render that flips authentication on logout, so the warmup effect now resets from its cleanup as well as the unauthenticated branch. Panel activations mirror UnifiedSidebar's panelExpanded condition, treating the insights route as collapsed instead of reading the raw sidebar atom. * test: re-expand the approval tool card the saved message remounts The helper opened the card once and then waited on its body. Saving the response swaps the placeholder message id for the persisted one, which rekeys every part in the turn: the card remounts collapsed, its body unmounts, and the output assertion waits out its timeout against a disclosure nothing is going to reopen. The redis transport lane pays a round trip per stream event, so its finalization lands late enough to catch the helper mid-assertion. Wait for the closing model turn before expanding anything, then re-open the group and the card on each attempt until the scoped output is on screen. * Update AgentPanelContext.tsx import order --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
749eed0d60
commit
199de92c51
17 changed files with 542 additions and 55 deletions
|
|
@ -110,14 +110,13 @@ router.get('/all', async (req, res) => {
|
|||
category,
|
||||
});
|
||||
|
||||
let accessibleIds = await findAccessibleResources({
|
||||
userId,
|
||||
role: req.user.role,
|
||||
resourceType: ResourceType.PROMPTGROUP,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
|
||||
const [publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([
|
||||
const [accessibleIds, publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([
|
||||
findAccessibleResources({
|
||||
userId,
|
||||
role: req.user.role,
|
||||
resourceType: ResourceType.PROMPTGROUP,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
}),
|
||||
findPubliclyAccessibleResources({
|
||||
resourceType: ResourceType.PROMPTGROUP,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
|
|
@ -183,14 +182,13 @@ router.get('/groups', async (req, res) => {
|
|||
actualCursor = null;
|
||||
}
|
||||
|
||||
let accessibleIds = await findAccessibleResources({
|
||||
userId,
|
||||
role: req.user.role,
|
||||
resourceType: ResourceType.PROMPTGROUP,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
|
||||
const [publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([
|
||||
const [accessibleIds, publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([
|
||||
findAccessibleResources({
|
||||
userId,
|
||||
role: req.user.role,
|
||||
resourceType: ResourceType.PROMPTGROUP,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
}),
|
||||
findPubliclyAccessibleResources({
|
||||
resourceType: ResourceType.PROMPTGROUP,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
|
|
|
|||
|
|
@ -1,21 +1,26 @@
|
|||
import React, { createContext, useContext, useState, useMemo } from 'react';
|
||||
import React, { createContext, useContext, useState, useMemo, useEffect } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { MCP, Action, TPlugin } from 'librechat-data-provider';
|
||||
import type { AgentPanelContextType, MCPServerInfo } from '~/common';
|
||||
import {
|
||||
useMCPConnectionStatus,
|
||||
useMCPServerManager,
|
||||
useGetAgentsConfig,
|
||||
activateCatalog,
|
||||
useCatalogReady,
|
||||
useLocalize,
|
||||
} from '~/hooks';
|
||||
import {
|
||||
useAvailableToolsQuery,
|
||||
useGetActionsQuery,
|
||||
useGetStartupConfig,
|
||||
useMCPToolsQuery,
|
||||
} from '~/data-provider';
|
||||
import {
|
||||
useLocalize,
|
||||
useGetAgentsConfig,
|
||||
useMCPConnectionStatus,
|
||||
useMCPServerManager,
|
||||
} from '~/hooks';
|
||||
import { isMCPServerReadyForAgent } from '~/components/MCP/mcpServerUtils';
|
||||
import { Panel, isEphemeralAgent } from '~/common';
|
||||
import store from '~/store';
|
||||
|
||||
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
|
||||
|
||||
|
|
@ -30,6 +35,18 @@ export function useAgentPanelContext() {
|
|||
/** Houses relevant state for the Agent Form Panels (formerly 'commonProps') */
|
||||
export function AgentPanelProvider({ children }: { children: React.ReactNode }) {
|
||||
const localize = useLocalize();
|
||||
const location = useLocation();
|
||||
/** The panel stays mounted while the sidebar is hidden (collapsed, mobile
|
||||
* drawer, or the insights route collapsing it), so only a visible form
|
||||
* releases the MCP catalogs ahead of the background warmup schedule */
|
||||
const sidebarExpanded = useRecoilValue(store.sidebarExpanded);
|
||||
const panelVisible = sidebarExpanded && !location.pathname.startsWith('/insights');
|
||||
useEffect(() => {
|
||||
if (panelVisible) {
|
||||
activateCatalog('mcpServers');
|
||||
activateCatalog('mcpTools');
|
||||
}
|
||||
}, [panelVisible]);
|
||||
const [mcp, setMcp] = useState<MCP | undefined>(undefined);
|
||||
const [mcps, setMcps] = useState<MCP[] | undefined>(undefined);
|
||||
const [action, setAction] = useState<Action | undefined>(undefined);
|
||||
|
|
@ -43,8 +60,12 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
|||
|
||||
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents);
|
||||
|
||||
/** The tools query keeps its own warmup gate: the servers list resolving
|
||||
* alone must not pull the heavier tools request ahead of its stagger. */
|
||||
const mcpToolsReady = useCatalogReady('mcpTools');
|
||||
const { data: mcpData, isFetching: mcpToolsFetching } = useMCPToolsQuery({
|
||||
enabled:
|
||||
mcpToolsReady &&
|
||||
!isEphemeralAgent(agent_id) &&
|
||||
!isLoading &&
|
||||
availableMCPServers != null &&
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { createContext, useContext, ReactNode, useMemo } from 'react';
|
|||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { TPromptGroup } from 'librechat-data-provider';
|
||||
import type { PromptOption } from '~/common';
|
||||
import { usePromptGroupsNav, useHasAccess } from '~/hooks';
|
||||
import { usePromptGroupsNav, useHasAccess, useCatalogReady } from '~/hooks';
|
||||
import { useGetAllPromptGroups } from '~/data-provider';
|
||||
import { CategoryIcon } from '~/components/Prompts';
|
||||
import { mapPromptGroups } from '~/utils';
|
||||
|
|
@ -31,10 +31,14 @@ export const PromptGroupsProvider = ({ children }: { children: ReactNode }) => {
|
|||
permissionType: PermissionTypes.PROMPTS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
/** Prompt groups are a background-warmed catalog: the queries stay off the
|
||||
* startup path until warmup releases them (or a prompts UI activates them). */
|
||||
const promptsReady = useCatalogReady('prompts');
|
||||
const promptsEnabled = hasAccess && promptsReady;
|
||||
|
||||
const promptGroupsNav = usePromptGroupsNav(hasAccess);
|
||||
const promptGroupsNav = usePromptGroupsNav(promptsEnabled);
|
||||
const { data: allGroupsData, isLoading: isLoadingAll } = useGetAllPromptGroups(undefined, {
|
||||
enabled: hasAccess,
|
||||
enabled: promptsEnabled,
|
||||
select: (data) => {
|
||||
const mappedArray: PromptOption[] = data.map((group) => ({
|
||||
id: group._id ?? '',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { removeCharIfLast, detectVariables } from '~/utils';
|
|||
import { useRecordPromptUsage } from '~/data-provider';
|
||||
import { VariableDialog } from '~/components/Prompts';
|
||||
import { usePromptGroupsContext } from '~/Providers';
|
||||
import { activateCatalog } from '~/hooks';
|
||||
import MentionItem from './MentionItem';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
|
@ -140,6 +141,8 @@ function PromptsCommand({
|
|||
setActiveIndex(0);
|
||||
setSearchValue('');
|
||||
} else {
|
||||
/** Opening the picker before background warmup starts the fetch now */
|
||||
activateCatalog('prompts');
|
||||
setVariableGroup(null);
|
||||
}
|
||||
}, [open, setSearchValue]);
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ jest.mock('~/components/Prompts', () => ({
|
|||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
activateCatalog: jest.fn(),
|
||||
}));
|
||||
|
||||
/* react-virtualized renders nothing in jsdom without a measured size; replace
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button, Sidebar, Spinner, TooltipAnchor } from '@librechat/client';
|
||||
import type { PromptGroupListResponse } from 'librechat-data-provider';
|
||||
import { useLocalize, useNavScrolling, activateCatalog } from '~/hooks';
|
||||
import PromptGroupSkeleton from '../lists/PromptGroupSkeleton';
|
||||
import { useLocalize, useNavScrolling } from '~/hooks';
|
||||
import { usePromptGroupsContext } from '~/Providers';
|
||||
import { PanelContent } from '~/components/ui';
|
||||
import List from '../lists/List';
|
||||
|
|
@ -34,6 +35,17 @@ export default function GroupSidePanel({
|
|||
|
||||
/** A collapsed sidebar keeps this panel mounted, so stop draining pages into it */
|
||||
const sidebarExpanded = useRecoilValue(store.sidebarExpanded);
|
||||
/** Mirrors UnifiedSidebar's panelExpanded: the insights route collapses the
|
||||
* panel while the atom stays true, so visibility is atom AND route */
|
||||
const panelVisible = sidebarExpanded && !location.pathname.startsWith('/insights');
|
||||
|
||||
/** The panel stays mounted while hidden, so only a visible panel releases
|
||||
* its catalog ahead of the background warmup schedule */
|
||||
useEffect(() => {
|
||||
if (panelVisible) {
|
||||
activateCatalog('prompts');
|
||||
}
|
||||
}, [panelVisible]);
|
||||
|
||||
const { containerRef } = useNavScrolling<PromptGroupListResponse>({
|
||||
nextCursor: context?.nextCursor,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,37 @@
|
|||
import { useState, useRef, useMemo } from 'react';
|
||||
import { useState, useRef, useMemo, useEffect } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { Button, FilterInput, OGDialogTrigger, TooltipAnchor } from '@librechat/client';
|
||||
import { useLocalize, useMCPServerManager, useHasAccess, useAuthContext } from '~/hooks';
|
||||
import {
|
||||
useLocalize,
|
||||
useMCPServerManager,
|
||||
useHasAccess,
|
||||
useAuthContext,
|
||||
activateCatalog,
|
||||
} from '~/hooks';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import { PanelFooter, PanelContent } from '~/components/ui';
|
||||
import MCPServerCardSkeleton from './MCPServerCardSkeleton';
|
||||
import MCPAdminSettings from './MCPAdminSettings';
|
||||
import MCPServerDialog from './MCPServerDialog';
|
||||
import MCPServerList from './MCPServerList';
|
||||
import store from '~/store';
|
||||
|
||||
export default function MCPBuilderPanel() {
|
||||
const localize = useLocalize();
|
||||
const location = useLocation();
|
||||
/** The panel stays mounted while the sidebar is hidden (collapsed, mobile
|
||||
* drawer, or the insights route collapsing it), so only a visible panel
|
||||
* releases its catalog ahead of the background warmup schedule */
|
||||
const sidebarExpanded = useRecoilValue(store.sidebarExpanded);
|
||||
const panelVisible = sidebarExpanded && !location.pathname.startsWith('/insights');
|
||||
useEffect(() => {
|
||||
if (panelVisible) {
|
||||
activateCatalog('mcpServers');
|
||||
}
|
||||
}, [panelVisible]);
|
||||
const { user } = useAuthContext();
|
||||
const { availableMCPServers, isLoading, getServerStatusIconProps, getConfigDialogProps } =
|
||||
useMCPServerManager();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ type CloudFrontRetryOptions = { getAuthorizationHeader: () => string | undefined
|
|||
const mockUseHasAccess = jest.fn();
|
||||
const mockUseMCPServersQuery = jest.fn();
|
||||
const mockUseMCPToolsQuery = jest.fn();
|
||||
const mockUseCatalogReady = jest.fn();
|
||||
const mockInstallCloudFrontImageRetry = jest.fn(
|
||||
(_startupConfig: unknown, _options: CloudFrontRetryOptions): (() => void) =>
|
||||
() =>
|
||||
|
|
@ -31,6 +32,7 @@ jest.mock('librechat-data-provider', () => {
|
|||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useHasAccess: (args: unknown) => mockUseHasAccess(args),
|
||||
useCatalogReady: (id: unknown) => mockUseCatalogReady(id),
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
|
|
@ -71,11 +73,12 @@ const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|||
<RecoilRoot>{children}</RecoilRoot>
|
||||
);
|
||||
|
||||
describe('useAppStartup — MCP permission gating', () => {
|
||||
describe('useAppStartup: MCP permission gating', () => {
|
||||
beforeEach(() => {
|
||||
mockInstallCloudFrontImageRetry.mockClear();
|
||||
mockUseMCPServersQuery.mockReturnValue({ data: undefined, isLoading: false });
|
||||
mockUseMCPToolsQuery.mockReturnValue({ data: undefined, isLoading: false });
|
||||
mockUseCatalogReady.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('checks the MCP_SERVERS.USE permission via useHasAccess', () => {
|
||||
|
|
@ -98,6 +101,18 @@ describe('useAppStartup — MCP permission gating', () => {
|
|||
expect(mockUseMCPToolsQuery).toHaveBeenCalledWith({ enabled: false });
|
||||
});
|
||||
|
||||
it('suppresses MCP queries while background catalog warmup has not released them', () => {
|
||||
mockUseHasAccess.mockReturnValue(true);
|
||||
mockUseCatalogReady.mockReturnValue(false);
|
||||
|
||||
renderHook(() => useAppStartup({ startupConfig: undefined, user: mockUser }), { wrapper });
|
||||
|
||||
expect(mockUseCatalogReady).toHaveBeenCalledWith('mcpServers');
|
||||
expect(mockUseCatalogReady).toHaveBeenCalledWith('mcpTools');
|
||||
expect(mockUseMCPServersQuery).toHaveBeenCalledWith({ enabled: false });
|
||||
expect(mockUseMCPToolsQuery).toHaveBeenCalledWith({ enabled: false });
|
||||
});
|
||||
|
||||
it('enables servers query and tools query when permission granted, servers loaded, and user present', () => {
|
||||
mockUseHasAccess.mockReturnValue(true);
|
||||
mockUseMCPServersQuery.mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type { TStartupConfig, TUser } from 'librechat-data-provider';
|
|||
import { useMCPToolsQuery, useMCPServersQuery } from '~/data-provider';
|
||||
import { cleanupTimestampedStorage } from '~/utils/timestamps';
|
||||
import useSpeechSettingsInit from './useSpeechSettingsInit';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
import { useHasAccess, useCatalogReady } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function useAppStartup({
|
||||
|
|
@ -30,13 +30,18 @@ export default function useAppStartup({
|
|||
});
|
||||
|
||||
useSpeechSettingsInit(!!user);
|
||||
/** MCP catalogs are background-warmed: the queries stay off the startup
|
||||
* path until warmup releases them (or an MCP UI activates them). */
|
||||
const mcpServersReady = useCatalogReady('mcpServers');
|
||||
const mcpToolsReady = useCatalogReady('mcpTools');
|
||||
const { data: loadedServers, isLoading: serversLoading } = useMCPServersQuery({
|
||||
enabled: canUseMcp,
|
||||
enabled: canUseMcp && mcpServersReady,
|
||||
});
|
||||
|
||||
useMCPToolsQuery({
|
||||
enabled:
|
||||
canUseMcp &&
|
||||
mcpToolsReady &&
|
||||
!serversLoading &&
|
||||
!!loadedServers &&
|
||||
Object.keys(loadedServers).length > 0 &&
|
||||
|
|
|
|||
40
client/src/hooks/MCP/__tests__/useMCPIconMap.spec.tsx
Normal file
40
client/src/hooks/MCP/__tests__/useMCPIconMap.spec.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { act } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { activateCatalog, resetCatalogWarmup } from '../../useCatalogWarmup';
|
||||
import { useMCPIconMap, useMCPServerNames } from '../useMCPIconMap';
|
||||
|
||||
const mockUseMCPServersQuery = jest.fn();
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useMCPServersQuery: (config: unknown) => mockUseMCPServersQuery(config),
|
||||
}));
|
||||
|
||||
describe('useMCPIconMap', () => {
|
||||
beforeEach(() => {
|
||||
resetCatalogWarmup();
|
||||
mockUseMCPServersQuery.mockReturnValue({ data: undefined });
|
||||
mockUseMCPServersQuery.mockClear();
|
||||
});
|
||||
|
||||
it('keeps the servers query disabled until warmup releases the catalog', () => {
|
||||
renderHook(() => {
|
||||
useMCPIconMap();
|
||||
useMCPServerNames();
|
||||
});
|
||||
|
||||
expect(mockUseMCPServersQuery).toHaveBeenCalledWith({ enabled: false });
|
||||
});
|
||||
|
||||
it('enables the servers query once the catalog is active', () => {
|
||||
renderHook(() => {
|
||||
useMCPIconMap();
|
||||
useMCPServerNames();
|
||||
});
|
||||
expect(mockUseMCPServersQuery).toHaveBeenLastCalledWith({ enabled: false });
|
||||
|
||||
act(() => {
|
||||
activateCatalog('mcpServers');
|
||||
});
|
||||
|
||||
expect(mockUseMCPServersQuery).toHaveBeenLastCalledWith({ enabled: true });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
import { useMemo } from 'react';
|
||||
import { normalizeServerName } from 'librechat-data-provider';
|
||||
import { useCatalogReady } from '../useCatalogWarmup';
|
||||
import { useMCPServersQuery } from '~/data-provider';
|
||||
|
||||
/** These observers mount from rendered messages, so they must not pull the
|
||||
* server catalog onto the first-render path ahead of the warmup schedule. */
|
||||
export function useMCPIconMap(): Map<string, string> {
|
||||
const { data: servers } = useMCPServersQuery();
|
||||
const mcpServersReady = useCatalogReady('mcpServers');
|
||||
const { data: servers } = useMCPServersQuery({ enabled: mcpServersReady });
|
||||
|
||||
return useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
|
|
@ -26,6 +30,7 @@ export function useMCPIconMap(): Map<string, string> {
|
|||
* so they can be matched against a key. The config is keyed by the raw name.
|
||||
*/
|
||||
export function useMCPServerNames(): string[] {
|
||||
const { data: servers } = useMCPServersQuery();
|
||||
const mcpServersReady = useCatalogReady('mcpServers');
|
||||
const { data: servers } = useMCPServersQuery({ enabled: mcpServersReady });
|
||||
return useMemo(() => (servers ? Object.keys(servers).map(normalizeServerName) : []), [servers]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,13 @@ import {
|
|||
isTerminalMCPOAuthPollingError,
|
||||
shouldUseMCPConnectionStatus,
|
||||
} from './polling';
|
||||
import { useLocalize, useHasAccess, useMCPSelect, useMCPConnectionStatus } from '~/hooks';
|
||||
import {
|
||||
useLocalize,
|
||||
useHasAccess,
|
||||
useMCPSelect,
|
||||
useCatalogReady,
|
||||
useMCPConnectionStatus,
|
||||
} from '~/hooks';
|
||||
import { useGetStartupConfig, useMCPServersQuery } from '~/data-provider';
|
||||
import { mcpServerInitStatesAtom, getServerInitState } from '~/store/mcp';
|
||||
import { getMCPReinitializeErrorMessage } from './errors';
|
||||
|
|
@ -66,12 +72,16 @@ export function useMCPServerManager({
|
|||
permissionType: PermissionTypes.MCP_SERVERS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
/** MCP catalogs are background-warmed: the server list powers nav-link
|
||||
* visibility and the chat-menu select, none of which gate first paint. */
|
||||
const mcpServersReady = useCatalogReady('mcpServers');
|
||||
const mcpEnabled = canUseMcp && mcpServersReady;
|
||||
|
||||
const { data: loadedServers, isLoading } = useMCPServersQuery({ enabled: canUseMcp });
|
||||
const { data: loadedServers, isLoading } = useMCPServersQuery({ enabled: mcpEnabled });
|
||||
|
||||
// Fetch effective permissions for all MCP servers
|
||||
const { data: permissionsMap } = useGetAllEffectivePermissionsQuery(ResourceType.MCPSERVER, {
|
||||
enabled: canUseMcp,
|
||||
enabled: mcpEnabled,
|
||||
});
|
||||
|
||||
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
||||
|
|
|
|||
208
client/src/hooks/__tests__/useCatalogWarmup.spec.tsx
Normal file
208
client/src/hooks/__tests__/useCatalogWarmup.spec.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import React from 'react';
|
||||
import { render, act, cleanup } from '@testing-library/react';
|
||||
import type { CatalogId } from '../useCatalogWarmup';
|
||||
import {
|
||||
useCatalogWarmup,
|
||||
useCatalogReady,
|
||||
activateCatalog,
|
||||
resetCatalogWarmup,
|
||||
} from '../useCatalogWarmup';
|
||||
|
||||
const CATALOG_IDS: CatalogId[] = ['prompts', 'mcpServers', 'mcpTools'];
|
||||
|
||||
let readyState: Record<CatalogId, boolean>;
|
||||
|
||||
function Harness({ authenticated }: { authenticated: boolean }) {
|
||||
useCatalogWarmup(authenticated);
|
||||
readyState = {
|
||||
prompts: useCatalogReady('prompts'),
|
||||
mcpServers: useCatalogReady('mcpServers'),
|
||||
mcpTools: useCatalogReady('mcpTools'),
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
const idleCallbacks: Array<() => void> = [];
|
||||
|
||||
function installIdleCallback() {
|
||||
Object.defineProperty(window, 'requestIdleCallback', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: (callback: () => void) => {
|
||||
idleCallbacks.push(callback);
|
||||
return idleCallbacks.length;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function flushIdle() {
|
||||
act(() => {
|
||||
idleCallbacks.splice(0).forEach((callback) => callback());
|
||||
});
|
||||
}
|
||||
|
||||
describe('useCatalogWarmup', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(Math, 'random').mockReturnValue(0);
|
||||
idleCallbacks.length = 0;
|
||||
installIdleCallback();
|
||||
resetCatalogWarmup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps every catalog gated until idle fires and each stagger elapses', () => {
|
||||
render(<Harness authenticated={true} />);
|
||||
expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false });
|
||||
|
||||
flushIdle();
|
||||
expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false });
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(0);
|
||||
});
|
||||
expect(readyState.prompts).toBe(true);
|
||||
expect(readyState.mcpServers).toBe(false);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(250);
|
||||
});
|
||||
expect(readyState.mcpServers).toBe(true);
|
||||
expect(readyState.mcpTools).toBe(false);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
expect(readyState.mcpTools).toBe(true);
|
||||
});
|
||||
|
||||
it('does not schedule warmup while unauthenticated', () => {
|
||||
render(<Harness authenticated={false} />);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(idleCallbacks.length).toBe(0);
|
||||
expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false });
|
||||
});
|
||||
|
||||
it('releases a catalog immediately on activation', () => {
|
||||
render(<Harness authenticated={true} />);
|
||||
flushIdle();
|
||||
|
||||
act(() => {
|
||||
activateCatalog('mcpTools');
|
||||
});
|
||||
expect(readyState.mcpTools).toBe(true);
|
||||
expect(readyState.prompts).toBe(false);
|
||||
|
||||
/** The superseded stagger timer must not flip anything back */
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
CATALOG_IDS.forEach((id) => {
|
||||
expect(readyState[id]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a timeout when requestIdleCallback is unavailable', () => {
|
||||
Object.defineProperty(window, 'requestIdleCallback', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
render(<Harness authenticated={true} />);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(199);
|
||||
});
|
||||
expect(readyState.prompts).toBe(false);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1);
|
||||
});
|
||||
/** The stagger timer is scheduled from inside the fallback timeout */
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
expect(readyState.prompts).toBe(true);
|
||||
});
|
||||
|
||||
it('resets to fully gated state', () => {
|
||||
render(<Harness authenticated={true} />);
|
||||
flushIdle();
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
resetCatalogWarmup();
|
||||
});
|
||||
expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false });
|
||||
});
|
||||
|
||||
it('re-arms the schedule after logout so the next session warms again', () => {
|
||||
const view = render(<Harness authenticated={true} />);
|
||||
flushIdle();
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
CATALOG_IDS.forEach((id) => expect(readyState[id]).toBe(true));
|
||||
|
||||
view.rerender(<Harness authenticated={false} />);
|
||||
expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false });
|
||||
|
||||
view.rerender(<Harness authenticated={true} />);
|
||||
flushIdle();
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(0);
|
||||
});
|
||||
expect(readyState.prompts).toBe(true);
|
||||
expect(readyState.mcpTools).toBe(false);
|
||||
});
|
||||
|
||||
it('voids idle callbacks scheduled before a logout', () => {
|
||||
const view = render(<Harness authenticated={true} />);
|
||||
/** Idle has not fired yet when the user logs out */
|
||||
view.rerender(<Harness authenticated={false} />);
|
||||
|
||||
flushIdle();
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false });
|
||||
|
||||
/** The next session schedules and warms normally */
|
||||
view.rerender(<Harness authenticated={true} />);
|
||||
flushIdle();
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(0);
|
||||
});
|
||||
expect(readyState.prompts).toBe(true);
|
||||
});
|
||||
|
||||
it('resets on unmount, for logouts that tear Root down without a false render', () => {
|
||||
const view = render(<Harness authenticated={true} />);
|
||||
flushIdle();
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
CATALOG_IDS.forEach((id) => expect(readyState[id]).toBe(true));
|
||||
|
||||
view.unmount();
|
||||
idleCallbacks.length = 0;
|
||||
|
||||
render(<Harness authenticated={true} />);
|
||||
expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false });
|
||||
flushIdle();
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(0);
|
||||
});
|
||||
expect(readyState.prompts).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -42,3 +42,10 @@ export { default as useGenerationsByLatest } from './useGenerationsByLatest';
|
|||
export { default as useLocalizedConfig } from './useLocalizedConfig';
|
||||
export { default as useResourcePermissions } from './useResourcePermissions';
|
||||
export { useRoleSelector } from './useRoleSelector';
|
||||
export {
|
||||
useCatalogWarmup,
|
||||
useCatalogReady,
|
||||
activateCatalog,
|
||||
resetCatalogWarmup,
|
||||
} from './useCatalogWarmup';
|
||||
export type { CatalogId } from './useCatalogWarmup';
|
||||
|
|
|
|||
126
client/src/hooks/useCatalogWarmup.ts
Normal file
126
client/src/hooks/useCatalogWarmup.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { useCallback, useEffect, useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* Feature catalogs (prompts, MCP servers/tools) are not needed to render the
|
||||
* initial chat UI, so their queries stay disabled until this store releases
|
||||
* them: after first paint, on browser idle, staggered so the requests never
|
||||
* land as one burst. Panels that need a catalog sooner call `activateCatalog`
|
||||
* and their own loading states cover the wait.
|
||||
*/
|
||||
export type CatalogId = 'prompts' | 'mcpServers' | 'mcpTools';
|
||||
|
||||
/** Upper bound on how long warmup may wait behind a busy main thread. */
|
||||
const IDLE_TIMEOUT_MS = 2000;
|
||||
/** Browsers without `requestIdleCallback` get a short fixed delay instead. */
|
||||
const IDLE_FALLBACK_MS = 200;
|
||||
/** Spacing between catalogs, smaller and more commonly used first. */
|
||||
const STAGGER_MS: Record<CatalogId, number> = {
|
||||
prompts: 0,
|
||||
mcpServers: 250,
|
||||
mcpTools: 750,
|
||||
};
|
||||
/** Random jitter so a fleet of users loading at once does not warm in lockstep. */
|
||||
const MAX_JITTER_MS = 500;
|
||||
|
||||
const ready: Record<CatalogId, boolean> = {
|
||||
prompts: false,
|
||||
mcpServers: false,
|
||||
mcpTools: false,
|
||||
};
|
||||
const pendingTimers = new Map<CatalogId, ReturnType<typeof setTimeout>>();
|
||||
const listeners = new Set<() => void>();
|
||||
let scheduled = false;
|
||||
/** Bumped on reset: idle callbacks and their timers capture the value at
|
||||
* scheduling time and no-op after a reset, so a logout can never leave a
|
||||
* stale callback releasing catalogs into the next session. */
|
||||
let generation = 0;
|
||||
|
||||
function emitChange() {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function markReady(id: CatalogId) {
|
||||
const timer = pendingTimers.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
pendingTimers.delete(id);
|
||||
}
|
||||
if (ready[id]) {
|
||||
return;
|
||||
}
|
||||
ready[id] = true;
|
||||
emitChange();
|
||||
}
|
||||
|
||||
/** Releases a catalog immediately, for panels opened before warmup reaches it. */
|
||||
export function activateCatalog(id: CatalogId) {
|
||||
markReady(id);
|
||||
}
|
||||
|
||||
function scheduleIdle(callback: () => void) {
|
||||
const scheduledGeneration = generation;
|
||||
const runIfCurrent = () => {
|
||||
if (scheduledGeneration === generation) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
window.requestIdleCallback(runIfCurrent, { timeout: IDLE_TIMEOUT_MS });
|
||||
return;
|
||||
}
|
||||
setTimeout(runIfCurrent, IDLE_FALLBACK_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the one-time warmup schedule. Mounted from Root once the user is
|
||||
* authenticated; every catalog consumer below Root reads the same store.
|
||||
* Logout is SPA navigation (no reload) and can unmount Root in the same
|
||||
* render that flips `isAuthenticated`, so both the unauthenticated branch
|
||||
* and unmount cleanup reset the schedule for the next session.
|
||||
*/
|
||||
export function useCatalogWarmup(isAuthenticated: boolean) {
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
resetCatalogWarmup();
|
||||
return;
|
||||
}
|
||||
if (scheduled) {
|
||||
return;
|
||||
}
|
||||
scheduled = true;
|
||||
(Object.keys(STAGGER_MS) as CatalogId[]).forEach((id) => {
|
||||
scheduleIdle(() => {
|
||||
pendingTimers.set(
|
||||
id,
|
||||
setTimeout(() => markReady(id), STAGGER_MS[id] + Math.random() * MAX_JITTER_MS),
|
||||
);
|
||||
});
|
||||
});
|
||||
return () => resetCatalogWarmup();
|
||||
}, [isAuthenticated]);
|
||||
}
|
||||
|
||||
export function useCatalogReady(id: CatalogId): boolean {
|
||||
const subscribe = useCallback((onStoreChange: () => void) => {
|
||||
listeners.add(onStoreChange);
|
||||
return () => {
|
||||
listeners.delete(onStoreChange);
|
||||
};
|
||||
}, []);
|
||||
const getSnapshot = useCallback(() => ready[id], [id]);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
/** Clears timers and readiness so the next session warms on its own schedule.
|
||||
* Bumping `generation` also voids idle callbacks still pending from the
|
||||
* previous schedule, whose handles `scheduleIdle` does not retain. */
|
||||
export function resetCatalogWarmup() {
|
||||
generation++;
|
||||
scheduled = false;
|
||||
pendingTimers.forEach((timer) => clearTimeout(timer));
|
||||
pendingTimers.clear();
|
||||
(Object.keys(ready) as CatalogId[]).forEach((id) => {
|
||||
ready[id] = false;
|
||||
});
|
||||
emitChange();
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
useSearchEnabled,
|
||||
useAssistantsMap,
|
||||
useAuthContext,
|
||||
useCatalogWarmup,
|
||||
useAgentsMap,
|
||||
useFileMap,
|
||||
} from '~/hooks';
|
||||
|
|
@ -58,6 +59,8 @@ export default function Root() {
|
|||
[setSidebarExpanded],
|
||||
);
|
||||
const { isAuthenticated, logout } = useAuthContext();
|
||||
/** Releases feature-catalog queries after first paint on browser idle. */
|
||||
useCatalogWarmup(isAuthenticated);
|
||||
|
||||
useDrawerSwipe({
|
||||
paneRef,
|
||||
|
|
|
|||
|
|
@ -169,28 +169,37 @@ async function expectCompletedApprovalToolOutput(page: Page, toolCallId: string,
|
|||
// start collapsed. Wait for either the target card or its group before
|
||||
// deciding whether expansion is necessary.
|
||||
await expect(toolCall.or(groupToggle).first()).toBeVisible({ timeout: 30000 });
|
||||
if (
|
||||
!(await toolCall.isVisible()) &&
|
||||
(await groupToggle.getAttribute('aria-expanded')) !== 'true'
|
||||
) {
|
||||
await groupToggle.click();
|
||||
}
|
||||
// The final model turn is the quiescence barrier: all parallel tool work
|
||||
// has settled before invocation-count assertions inspect the audit. It is
|
||||
// also the fence the expansions below need, because the streamed response
|
||||
// carries a placeholder id that the saved message replaces, remounting
|
||||
// every card in the turn and closing whatever this helper had opened.
|
||||
await expect(view.getByText(/^E2E approval outcomes:/).last()).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await expect(toolCall).toBeVisible({ timeout: 30000 });
|
||||
const toggle = toolCall.getByRole('button', { name: /Ran approval_probe/ });
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
|
||||
await toggle.click();
|
||||
}
|
||||
|
||||
// Scope exact output to its stable call id. This catches both a dropped
|
||||
// completion and an output accidentally attached to a sibling tool card.
|
||||
await expect(
|
||||
view.locator(`[data-tool-call-output-id="${toolCallId}"]`).getByText(output, { exact: true }),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
// The final model turn is the quiescence barrier: all parallel tool work
|
||||
// has settled before invocation-count assertions inspect the audit.
|
||||
await expect(view.getByText(/^E2E approval outcomes:/).last()).toBeVisible({ timeout: 30000 });
|
||||
const toolOutput = view
|
||||
.locator(`[data-tool-call-output-id="${toolCallId}"]`)
|
||||
.getByText(output, { exact: true });
|
||||
|
||||
// Re-open on every attempt rather than expanding once: a card that a late
|
||||
// remount closes underneath would otherwise leave the assertion waiting on
|
||||
// a body that nothing is going to mount again.
|
||||
await expect(async () => {
|
||||
if (!(await toolCall.isVisible())) {
|
||||
const hasGroup = (await groupToggle.count()) > 0;
|
||||
if (hasGroup && (await groupToggle.getAttribute('aria-expanded')) !== 'true') {
|
||||
await groupToggle.click();
|
||||
}
|
||||
}
|
||||
await expect(toolCall).toBeVisible({ timeout: 5000 });
|
||||
await expect(toggle).toBeVisible({ timeout: 5000 });
|
||||
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toolOutput).toBeVisible({ timeout: 5000 });
|
||||
}).toPass({ timeout: 30000 });
|
||||
}
|
||||
|
||||
test.describe('tool approvals', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue