From 199de92c51b96e5a527efebe373e5ea7cb0e612e Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:33:29 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20perf:=20Warm=20Feature=20Catalogs?= =?UTF-8?q?=20in=20the=20Background=20After=20First=20Paint=20(#15047)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- api/server/routes/prompts.js | 30 ++- client/src/Providers/AgentPanelContext.tsx | 35 ++- client/src/Providers/PromptGroupsContext.tsx | 10 +- .../components/Chat/Input/PromptsCommand.tsx | 3 + .../Input/__tests__/PromptsCommand.spec.tsx | 1 + .../Prompts/sidebar/GroupSidePanel.tsx | 14 +- .../SidePanel/MCPBuilder/MCPBuilderPanel.tsx | 24 +- .../Config/__tests__/useAppStartup.spec.tsx | 17 +- client/src/hooks/Config/useAppStartup.ts | 9 +- .../MCP/__tests__/useMCPIconMap.spec.tsx | 40 ++++ client/src/hooks/MCP/useMCPIconMap.ts | 9 +- client/src/hooks/MCP/useMCPServerManager.ts | 16 +- .../hooks/__tests__/useCatalogWarmup.spec.tsx | 208 ++++++++++++++++++ client/src/hooks/index.ts | 7 + client/src/hooks/useCatalogWarmup.ts | 126 +++++++++++ client/src/routes/Root.tsx | 3 + e2e/specs/mock/tool-approvals.spec.ts | 45 ++-- 17 files changed, 542 insertions(+), 55 deletions(-) create mode 100644 client/src/hooks/MCP/__tests__/useMCPIconMap.spec.tsx create mode 100644 client/src/hooks/__tests__/useCatalogWarmup.spec.tsx create mode 100644 client/src/hooks/useCatalogWarmup.ts diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index 5fcf51ba73..f248d79fc1 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -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, diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx index afb3a1e0df..c561dd81b1 100644 --- a/client/src/Providers/AgentPanelContext.tsx +++ b/client/src/Providers/AgentPanelContext.tsx @@ -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(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(undefined); const [mcps, setMcps] = useState(undefined); const [action, setAction] = useState(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 && diff --git a/client/src/Providers/PromptGroupsContext.tsx b/client/src/Providers/PromptGroupsContext.tsx index 147d2bc336..70751df846 100644 --- a/client/src/Providers/PromptGroupsContext.tsx +++ b/client/src/Providers/PromptGroupsContext.tsx @@ -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 ?? '', diff --git a/client/src/components/Chat/Input/PromptsCommand.tsx b/client/src/components/Chat/Input/PromptsCommand.tsx index 6893c2e5b9..7f08c343ab 100644 --- a/client/src/components/Chat/Input/PromptsCommand.tsx +++ b/client/src/components/Chat/Input/PromptsCommand.tsx @@ -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]); diff --git a/client/src/components/Chat/Input/__tests__/PromptsCommand.spec.tsx b/client/src/components/Chat/Input/__tests__/PromptsCommand.spec.tsx index adf9f63716..a15eba3760 100644 --- a/client/src/components/Chat/Input/__tests__/PromptsCommand.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/PromptsCommand.spec.tsx @@ -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 diff --git a/client/src/components/Prompts/sidebar/GroupSidePanel.tsx b/client/src/components/Prompts/sidebar/GroupSidePanel.tsx index ee06940978..533e21e7e5 100644 --- a/client/src/components/Prompts/sidebar/GroupSidePanel.tsx +++ b/client/src/components/Prompts/sidebar/GroupSidePanel.tsx @@ -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({ nextCursor: context?.nextCursor, diff --git a/client/src/components/SidePanel/MCPBuilder/MCPBuilderPanel.tsx b/client/src/components/SidePanel/MCPBuilder/MCPBuilderPanel.tsx index ae7b0532a1..cd84fde546 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPBuilderPanel.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPBuilderPanel.tsx @@ -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(); diff --git a/client/src/hooks/Config/__tests__/useAppStartup.spec.tsx b/client/src/hooks/Config/__tests__/useAppStartup.spec.tsx index b5ad9afa69..8a82141d45 100644 --- a/client/src/hooks/Config/__tests__/useAppStartup.spec.tsx +++ b/client/src/hooks/Config/__tests__/useAppStartup.spec.tsx @@ -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 }) => ( {children} ); -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({ diff --git a/client/src/hooks/Config/useAppStartup.ts b/client/src/hooks/Config/useAppStartup.ts index 6df61d2230..bf644ed9df 100644 --- a/client/src/hooks/Config/useAppStartup.ts +++ b/client/src/hooks/Config/useAppStartup.ts @@ -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 && diff --git a/client/src/hooks/MCP/__tests__/useMCPIconMap.spec.tsx b/client/src/hooks/MCP/__tests__/useMCPIconMap.spec.tsx new file mode 100644 index 0000000000..66b676f885 --- /dev/null +++ b/client/src/hooks/MCP/__tests__/useMCPIconMap.spec.tsx @@ -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 }); + }); +}); diff --git a/client/src/hooks/MCP/useMCPIconMap.ts b/client/src/hooks/MCP/useMCPIconMap.ts index 00cc7a75e1..4e61668d08 100644 --- a/client/src/hooks/MCP/useMCPIconMap.ts +++ b/client/src/hooks/MCP/useMCPIconMap.ts @@ -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 { - const { data: servers } = useMCPServersQuery(); + const mcpServersReady = useCatalogReady('mcpServers'); + const { data: servers } = useMCPServersQuery({ enabled: mcpServersReady }); return useMemo(() => { const map = new Map(); @@ -26,6 +30,7 @@ export function useMCPIconMap(): Map { * 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]); } diff --git a/client/src/hooks/MCP/useMCPServerManager.ts b/client/src/hooks/MCP/useMCPServerManager.ts index 46718b59bf..e72186c64c 100644 --- a/client/src/hooks/MCP/useMCPServerManager.ts +++ b/client/src/hooks/MCP/useMCPServerManager.ts @@ -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); diff --git a/client/src/hooks/__tests__/useCatalogWarmup.spec.tsx b/client/src/hooks/__tests__/useCatalogWarmup.spec.tsx new file mode 100644 index 0000000000..2ed7982684 --- /dev/null +++ b/client/src/hooks/__tests__/useCatalogWarmup.spec.tsx @@ -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; + +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(); + 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(); + + 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(); + 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(); + + 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(); + 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(); + flushIdle(); + act(() => { + jest.runAllTimers(); + }); + CATALOG_IDS.forEach((id) => expect(readyState[id]).toBe(true)); + + view.rerender(); + expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false }); + + view.rerender(); + 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(); + /** Idle has not fired yet when the user logs out */ + view.rerender(); + + flushIdle(); + act(() => { + jest.runAllTimers(); + }); + expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false }); + + /** The next session schedules and warms normally */ + view.rerender(); + 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(); + flushIdle(); + act(() => { + jest.runAllTimers(); + }); + CATALOG_IDS.forEach((id) => expect(readyState[id]).toBe(true)); + + view.unmount(); + idleCallbacks.length = 0; + + render(); + expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false }); + flushIdle(); + act(() => { + jest.advanceTimersByTime(0); + }); + expect(readyState.prompts).toBe(true); + }); +}); diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index f339fc5f81..fe7868ebd3 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -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'; diff --git a/client/src/hooks/useCatalogWarmup.ts b/client/src/hooks/useCatalogWarmup.ts new file mode 100644 index 0000000000..27a93d5854 --- /dev/null +++ b/client/src/hooks/useCatalogWarmup.ts @@ -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 = { + 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 = { + prompts: false, + mcpServers: false, + mcpTools: false, +}; +const pendingTimers = new Map>(); +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(); +} diff --git a/client/src/routes/Root.tsx b/client/src/routes/Root.tsx index 236c88faa5..65cca1564d 100644 --- a/client/src/routes/Root.tsx +++ b/client/src/routes/Root.tsx @@ -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, diff --git a/e2e/specs/mock/tool-approvals.spec.ts b/e2e/specs/mock/tool-approvals.spec.ts index 7e38a4059b..21517fb4ea 100644 --- a/e2e/specs/mock/tool-approvals.spec.ts +++ b/e2e/specs/mock/tool-approvals.spec.ts @@ -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', () => {