🐜 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:
Dustin Healy 2025-07-28 09:26:11 -07:00 committed by GitHub
parent 0ef3fefaec
commit 4639dc3255
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 232 additions and 223 deletions

View file

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

View file

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