mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
🐜 fix: Forward Ref to MCPSubMenu and ArtifactsSubMenu (#8696)
ToolsDropdown uses a menu library that passes refs to submenu items. Function components can't receive refs by default though, so we get "Function components cannot be given refs" warnings in the console. React.forwardRef() allows them to properly handle ref forwarding by wrapping the component and attaching the ref to the outer div element.
This commit is contained in:
parent
0ef3fefaec
commit
4639dc3255
2 changed files with 232 additions and 223 deletions
|
|
@ -15,133 +15,142 @@ interface ArtifactsSubMenuProps {
|
||||||
handleCustomToggle: () => void;
|
handleCustomToggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ArtifactsSubMenu = ({
|
const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>(
|
||||||
isArtifactsPinned,
|
(
|
||||||
setIsArtifactsPinned,
|
{
|
||||||
artifactsMode,
|
isArtifactsPinned,
|
||||||
handleArtifactsToggle,
|
setIsArtifactsPinned,
|
||||||
handleShadcnToggle,
|
artifactsMode,
|
||||||
handleCustomToggle,
|
handleArtifactsToggle,
|
||||||
...props
|
handleShadcnToggle,
|
||||||
}: ArtifactsSubMenuProps) => {
|
handleCustomToggle,
|
||||||
const localize = useLocalize();
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
const menuStore = Ariakit.useMenuStore({
|
const menuStore = Ariakit.useMenuStore({
|
||||||
focusLoop: true,
|
focusLoop: true,
|
||||||
showTimeout: 100,
|
showTimeout: 100,
|
||||||
placement: 'right',
|
placement: 'right',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isEnabled = artifactsMode !== '' && artifactsMode !== undefined;
|
const isEnabled = artifactsMode !== '' && artifactsMode !== undefined;
|
||||||
const isShadcnEnabled = artifactsMode === ArtifactModes.SHADCNUI;
|
const isShadcnEnabled = artifactsMode === ArtifactModes.SHADCNUI;
|
||||||
const isCustomEnabled = artifactsMode === ArtifactModes.CUSTOM;
|
const isCustomEnabled = artifactsMode === ArtifactModes.CUSTOM;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Ariakit.MenuProvider store={menuStore}>
|
<div ref={ref}>
|
||||||
<Ariakit.MenuItem
|
<Ariakit.MenuProvider store={menuStore}>
|
||||||
{...props}
|
<Ariakit.MenuItem
|
||||||
hideOnClick={false}
|
{...props}
|
||||||
render={
|
hideOnClick={false}
|
||||||
<Ariakit.MenuButton
|
render={
|
||||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
<Ariakit.MenuButton
|
||||||
e.stopPropagation();
|
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
handleArtifactsToggle();
|
e.stopPropagation();
|
||||||
}}
|
handleArtifactsToggle();
|
||||||
onMouseEnter={() => {
|
}}
|
||||||
if (isEnabled) {
|
onMouseEnter={() => {
|
||||||
menuStore.show();
|
if (isEnabled) {
|
||||||
}
|
menuStore.show();
|
||||||
}}
|
}
|
||||||
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover"
|
}}
|
||||||
/>
|
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover"
|
||||||
}
|
/>
|
||||||
>
|
}
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<WandSparkles className="icon-md" />
|
<div className="flex items-center gap-2">
|
||||||
<span>{localize('com_ui_artifacts')}</span>
|
<WandSparkles className="icon-md" />
|
||||||
{isEnabled && <ChevronRight className="ml-auto h-3 w-3" />}
|
<span>{localize('com_ui_artifacts')}</span>
|
||||||
</div>
|
{isEnabled && <ChevronRight className="ml-auto h-3 w-3" />}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsArtifactsPinned(!isArtifactsPinned);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'rounded p-1 transition-all duration-200',
|
|
||||||
'hover:bg-surface-tertiary hover:shadow-sm',
|
|
||||||
!isArtifactsPinned && 'text-text-secondary hover:text-text-primary',
|
|
||||||
)}
|
|
||||||
aria-label={isArtifactsPinned ? 'Unpin' : 'Pin'}
|
|
||||||
>
|
|
||||||
<div className="h-4 w-4">
|
|
||||||
<PinIcon unpin={isArtifactsPinned} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</Ariakit.MenuItem>
|
|
||||||
|
|
||||||
{isEnabled && (
|
|
||||||
<Ariakit.Menu
|
|
||||||
portal={true}
|
|
||||||
unmountOnHide={true}
|
|
||||||
className={cn(
|
|
||||||
'animate-popover-left z-50 ml-3 flex min-w-[250px] flex-col rounded-xl',
|
|
||||||
'border border-border-light bg-surface-secondary px-1.5 py-1 shadow-lg',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="px-2 py-1.5">
|
|
||||||
<div className="mb-2 text-xs font-medium text-text-secondary">
|
|
||||||
{localize('com_ui_artifacts_options')}
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
{/* Include shadcn/ui Option */}
|
type="button"
|
||||||
<Ariakit.MenuItem
|
onClick={(e) => {
|
||||||
hideOnClick={false}
|
e.stopPropagation();
|
||||||
onClick={(event) => {
|
setIsArtifactsPinned(!isArtifactsPinned);
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
handleShadcnToggle();
|
|
||||||
}}
|
|
||||||
disabled={isCustomEnabled}
|
|
||||||
className={cn(
|
|
||||||
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
|
|
||||||
'cursor-pointer text-text-primary outline-none transition-colors',
|
|
||||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
|
||||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
|
||||||
isCustomEnabled && 'cursor-not-allowed opacity-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
|
|
||||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
|
||||||
</div>
|
|
||||||
</Ariakit.MenuItem>
|
|
||||||
|
|
||||||
{/* Custom Prompt Mode Option */}
|
|
||||||
<Ariakit.MenuItem
|
|
||||||
hideOnClick={false}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
handleCustomToggle();
|
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between rounded-lg px-2 py-2',
|
'rounded p-1 transition-all duration-200',
|
||||||
'cursor-pointer text-text-primary outline-none transition-colors',
|
'hover:bg-surface-tertiary hover:shadow-sm',
|
||||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
!isArtifactsPinned && 'text-text-secondary hover:text-text-primary',
|
||||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
)}
|
||||||
|
aria-label={isArtifactsPinned ? 'Unpin' : 'Pin'}
|
||||||
|
>
|
||||||
|
<div className="h-4 w-4">
|
||||||
|
<PinIcon unpin={isArtifactsPinned} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</Ariakit.MenuItem>
|
||||||
|
|
||||||
|
{isEnabled && (
|
||||||
|
<Ariakit.Menu
|
||||||
|
portal={true}
|
||||||
|
unmountOnHide={true}
|
||||||
|
className={cn(
|
||||||
|
'animate-popover-left z-50 ml-3 flex min-w-[250px] flex-col rounded-xl',
|
||||||
|
'border border-border-light bg-surface-secondary px-1.5 py-1 shadow-lg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="px-2 py-1.5">
|
||||||
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
|
<div className="mb-2 text-xs font-medium text-text-secondary">
|
||||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
{localize('com_ui_artifacts_options')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Include shadcn/ui Option */}
|
||||||
|
<Ariakit.MenuItem
|
||||||
|
hideOnClick={false}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
handleShadcnToggle();
|
||||||
|
}}
|
||||||
|
disabled={isCustomEnabled}
|
||||||
|
className={cn(
|
||||||
|
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
|
||||||
|
'cursor-pointer text-text-primary outline-none transition-colors',
|
||||||
|
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||||
|
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||||
|
isCustomEnabled && 'cursor-not-allowed opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
|
||||||
|
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||||
|
</div>
|
||||||
|
</Ariakit.MenuItem>
|
||||||
|
|
||||||
|
{/* Custom Prompt Mode Option */}
|
||||||
|
<Ariakit.MenuItem
|
||||||
|
hideOnClick={false}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
handleCustomToggle();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between rounded-lg px-2 py-2',
|
||||||
|
'cursor-pointer text-text-primary outline-none transition-colors',
|
||||||
|
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||||
|
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
|
||||||
|
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||||
|
</div>
|
||||||
|
</Ariakit.MenuItem>
|
||||||
</div>
|
</div>
|
||||||
</Ariakit.MenuItem>
|
</Ariakit.Menu>
|
||||||
</div>
|
)}
|
||||||
</Ariakit.Menu>
|
</Ariakit.MenuProvider>
|
||||||
)}
|
</div>
|
||||||
</Ariakit.MenuProvider>
|
);
|
||||||
);
|
},
|
||||||
};
|
);
|
||||||
|
|
||||||
|
ArtifactsSubMenu.displayName = 'ArtifactsSubMenu';
|
||||||
|
|
||||||
export default React.memo(ArtifactsSubMenu);
|
export default React.memo(ArtifactsSubMenu);
|
||||||
|
|
|
||||||
|
|
@ -11,115 +11,115 @@ interface MCPSubMenuProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MCPSubMenu = ({ placeholder, ...props }: MCPSubMenuProps) => {
|
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
||||||
const {
|
({ placeholder, ...props }, ref) => {
|
||||||
configuredServers,
|
const {
|
||||||
mcpValues,
|
configuredServers,
|
||||||
isPinned,
|
mcpValues,
|
||||||
setIsPinned,
|
isPinned,
|
||||||
placeholderText,
|
setIsPinned,
|
||||||
toggleServerSelection,
|
placeholderText,
|
||||||
getServerStatusIconProps,
|
toggleServerSelection,
|
||||||
getConfigDialogProps,
|
getServerStatusIconProps,
|
||||||
} = useMCPServerManager();
|
getConfigDialogProps,
|
||||||
|
} = useMCPServerManager();
|
||||||
|
|
||||||
const menuStore = Ariakit.useMenuStore({
|
const menuStore = Ariakit.useMenuStore({
|
||||||
focusLoop: true,
|
focusLoop: true,
|
||||||
showTimeout: 100,
|
showTimeout: 100,
|
||||||
placement: 'right',
|
placement: 'right',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't render if no MCP servers are configured
|
// Don't render if no MCP servers are configured
|
||||||
if (!configuredServers || configuredServers.length === 0) {
|
if (!configuredServers || configuredServers.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const configDialogProps = getConfigDialogProps();
|
const configDialogProps = getConfigDialogProps();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div ref={ref}>
|
||||||
<Ariakit.MenuProvider store={menuStore}>
|
<Ariakit.MenuProvider store={menuStore}>
|
||||||
<Ariakit.MenuItem
|
<Ariakit.MenuItem
|
||||||
{...props}
|
{...props}
|
||||||
render={
|
render={
|
||||||
<Ariakit.MenuButton
|
<Ariakit.MenuButton
|
||||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
menuStore.toggle();
|
menuStore.toggle();
|
||||||
}}
|
|
||||||
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<MCPIcon className="icon-md" />
|
|
||||||
<span>{placeholder || placeholderText}</span>
|
|
||||||
<ChevronRight className="ml-auto h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsPinned(!isPinned);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'rounded p-1 transition-all duration-200',
|
|
||||||
'hover:bg-surface-tertiary hover:shadow-sm',
|
|
||||||
!isPinned && 'text-text-secondary hover:text-text-primary',
|
|
||||||
)}
|
|
||||||
aria-label={isPinned ? 'Unpin' : 'Pin'}
|
|
||||||
>
|
|
||||||
<div className="h-4 w-4">
|
|
||||||
<PinIcon unpin={isPinned} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</Ariakit.MenuItem>
|
|
||||||
<Ariakit.Menu
|
|
||||||
portal={true}
|
|
||||||
unmountOnHide={true}
|
|
||||||
className={cn(
|
|
||||||
'animate-popover-left z-50 ml-3 flex min-w-[200px] flex-col rounded-xl',
|
|
||||||
'border border-border-light bg-surface-secondary p-1 shadow-lg',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{configuredServers.map((serverName) => {
|
|
||||||
const statusIconProps = getServerStatusIconProps(serverName);
|
|
||||||
const isSelected = mcpValues?.includes(serverName) ?? false;
|
|
||||||
|
|
||||||
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Ariakit.MenuItem
|
|
||||||
key={serverName}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
toggleServerSelection(serverName);
|
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover"
|
||||||
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-text-primary hover:cursor-pointer',
|
/>
|
||||||
'scroll-m-1 outline-none transition-colors',
|
}
|
||||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
>
|
||||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
<div className="flex items-center gap-2">
|
||||||
'w-full min-w-0 justify-between text-sm',
|
<MCPIcon className="icon-md" />
|
||||||
)}
|
<span>{placeholder || placeholderText}</span>
|
||||||
>
|
<ChevronRight className="ml-auto h-3 w-3" />
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<button
|
||||||
className="flex flex-grow items-center gap-2 rounded bg-transparent p-0 text-left transition-colors focus:outline-none"
|
type="button"
|
||||||
tabIndex={0}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsPinned(!isPinned);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'rounded p-1 transition-all duration-200',
|
||||||
|
'hover:bg-surface-tertiary hover:shadow-sm',
|
||||||
|
!isPinned && 'text-text-secondary hover:text-text-primary',
|
||||||
|
)}
|
||||||
|
aria-label={isPinned ? 'Unpin' : 'Pin'}
|
||||||
|
>
|
||||||
|
<div className="h-4 w-4">
|
||||||
|
<PinIcon unpin={isPinned} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</Ariakit.MenuItem>
|
||||||
|
<Ariakit.Menu
|
||||||
|
portal={true}
|
||||||
|
unmountOnHide={true}
|
||||||
|
className={cn(
|
||||||
|
'animate-popover-left z-50 ml-3 flex min-w-[200px] flex-col rounded-xl',
|
||||||
|
'border border-border-light bg-surface-secondary p-1 shadow-lg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{configuredServers.map((serverName) => {
|
||||||
|
const statusIconProps = getServerStatusIconProps(serverName);
|
||||||
|
const isSelected = mcpValues?.includes(serverName) ?? false;
|
||||||
|
|
||||||
|
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Ariakit.MenuItem
|
||||||
|
key={serverName}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleServerSelection(serverName);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-text-primary hover:cursor-pointer',
|
||||||
|
'scroll-m-1 outline-none transition-colors',
|
||||||
|
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||||
|
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||||
|
'w-full min-w-0 justify-between text-sm',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Ariakit.MenuItemCheck checked={isSelected} />
|
<div className="flex flex-grow items-center gap-2">
|
||||||
<span>{serverName}</span>
|
<Ariakit.MenuItemCheck checked={isSelected} />
|
||||||
</button>
|
<span>{serverName}</span>
|
||||||
{statusIcon && <div className="ml-2 flex items-center">{statusIcon}</div>}
|
</div>
|
||||||
</Ariakit.MenuItem>
|
{statusIcon && <div className="ml-2 flex items-center">{statusIcon}</div>}
|
||||||
);
|
</Ariakit.MenuItem>
|
||||||
})}
|
);
|
||||||
</Ariakit.Menu>
|
})}
|
||||||
</Ariakit.MenuProvider>
|
</Ariakit.Menu>
|
||||||
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
|
</Ariakit.MenuProvider>
|
||||||
</>
|
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
|
||||||
);
|
</div>
|
||||||
};
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
MCPSubMenu.displayName = 'MCPSubMenu';
|
||||||
|
|
||||||
export default React.memo(MCPSubMenu);
|
export default React.memo(MCPSubMenu);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue