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:
Marco Beretta 2026-08-22 02:33:29 +02:00 committed by GitHub
parent 749eed0d60
commit 199de92c51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 542 additions and 55 deletions

View file

@ -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,

View file

@ -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 &&

View file

@ -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 ?? '',

View file

@ -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]);

View file

@ -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

View file

@ -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,

View file

@ -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();

View file

@ -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({

View file

@ -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 &&

View 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 });
});
});

View file

@ -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]);
}

View file

@ -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);

View 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);
});
});

View file

@ -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';

View 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();
}

View file

@ -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,

View file

@ -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', () => {