refactor: update CheckboxButton to support controlled state and enhance ToolsDropdown with permission-based toggles for web search and code interpreter

This commit is contained in:
Danny Avila 2025-06-22 11:45:15 -04:00
parent 727d4a8a77
commit 9eb62370a4
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
5 changed files with 84 additions and 51 deletions

View file

@ -29,7 +29,7 @@ function CodeInterpreter() {
<CheckboxButton
ref={triggerRef}
className="max-w-fit"
defaultChecked={runCode}
checked={runCode}
setValue={debouncedChange}
label={localize('com_assistants_code_interpreter')}
isCheckedClassName="border-purple-600/40 bg-purple-500/10 hover:bg-purple-700/10"

View file

@ -1,9 +1,11 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import * as Ariakit from '@ariakit/react';
import { Settings2, Search, ImageIcon, Globe, PenTool } from 'lucide-react';
import { Settings2, Globe, TerminalSquareIcon } from 'lucide-react';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { TooltipAnchor, DropdownPopup } from '~/components';
import { useBadgeRowContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { useLocalize, useHasAccess } from '~/hooks';
import type { MenuItemProps } from '~/common';
import { cn } from '~/utils';
interface ToolsDropdownProps {
@ -12,12 +14,32 @@ interface ToolsDropdownProps {
const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
const localize = useLocalize();
const { conversationId } = useBadgeRowContext();
const { webSearch, codeInterpreter } = useBadgeRowContext();
const isDisabled = disabled ?? false;
const [isPopoverActive, setIsPopoverActive] = useState(false);
const canUseWebSearch = useHasAccess({
permissionType: PermissionTypes.WEB_SEARCH,
permission: Permissions.USE,
});
const canRunCode = useHasAccess({
permissionType: PermissionTypes.RUN_CODE,
permission: Permissions.USE,
});
const handleWebSearchToggle = useCallback(() => {
const newValue = !webSearch.toggleState;
webSearch.debouncedChange({ isChecked: newValue });
}, [webSearch]);
const handleCodeInterpreterToggle = useCallback(() => {
const newValue = !codeInterpreter.toggleState;
codeInterpreter.debouncedChange({ isChecked: newValue });
}, [codeInterpreter]);
const dropdownItems = useMemo(() => {
return [
const items: MenuItemProps[] = [
{
render: () => (
<div className="px-3 py-2 text-xs font-semibold text-text-secondary">
@ -26,41 +48,40 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
),
hideOnClick: false,
},
{
label: 'Search connectors',
onClick: () => {
// TODO: Implement search connectors functionality
console.log('Search connectors clicked');
},
icon: <Search className="icon-md" />,
badge: 'NEW',
},
{
label: 'Create an image',
onClick: () => {
// TODO: Implement create image functionality
console.log('Create an image clicked');
},
icon: <ImageIcon className="icon-md" />,
},
{
label: 'Search the web',
onClick: () => {
// TODO: Implement web search functionality
console.log('Search the web clicked');
},
icon: <Globe className="icon-md" />,
},
{
label: 'Write or code',
onClick: () => {
// TODO: Implement write or code functionality
console.log('Write or code clicked');
},
icon: <PenTool className="icon-md" />,
},
];
}, []);
if (canUseWebSearch) {
items.push({
onClick: handleWebSearchToggle,
hideOnClick: true,
render: (props) => (
<div className="flex w-full cursor-pointer items-center justify-between" {...props}>
<div className="flex items-center gap-2">
<Globe className="icon-md" />
<span>{localize('com_ui_web_search')}</span>
</div>
</div>
),
});
}
if (canRunCode) {
items.push({
onClick: handleCodeInterpreterToggle,
hideOnClick: true,
render: (props) => (
<div className="flex w-full cursor-pointer items-center justify-between" {...props}>
<div className="flex items-center gap-2">
<TerminalSquareIcon className="icon-md" />
<span>{localize('com_assistants_code_interpreter')}</span>
</div>
</div>
),
});
}
return items;
}, [canUseWebSearch, canRunCode, localize, handleWebSearchToggle, handleCodeInterpreterToggle]);
const menuTrigger = (
<TooltipAnchor

View file

@ -29,7 +29,7 @@ function WebSearch() {
<CheckboxButton
ref={triggerRef}
className="max-w-fit"
defaultChecked={webSearch}
checked={webSearch}
setValue={debouncedChange}
label={localize('com_ui_search')}
isCheckedClassName="border-blue-600/40 bg-blue-500/10 hover:bg-blue-700/10"

View file

@ -9,11 +9,12 @@ const CheckboxButton = React.forwardRef<
icon?: React.ReactNode;
label: string;
className?: string;
checked?: boolean;
defaultChecked?: boolean;
isCheckedClassName?: string;
setValue?: (e: React.ChangeEvent<HTMLInputElement>, isChecked: boolean) => void;
setValue?: (values: { e?: React.ChangeEvent<HTMLInputElement>; isChecked: boolean }) => void;
}
>(({ icon, label, setValue, className, defaultChecked, isCheckedClassName }, ref) => {
>(({ icon, label, setValue, className, checked, defaultChecked, isCheckedClassName }, ref) => {
const checkbox = useCheckboxStore();
const isChecked = useStoreState(checkbox, (state) => state?.value);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -21,20 +22,28 @@ const CheckboxButton = React.forwardRef<
if (typeof isChecked !== 'boolean') {
return;
}
setValue?.(e, !isChecked);
setValue?.({ e, isChecked: !isChecked });
};
// Sync with controlled checked prop
useEffect(() => {
if (defaultChecked) {
if (checked !== undefined) {
checkbox.setValue(checked);
}
}, [checked, checkbox]);
// Set initial value from defaultChecked
useEffect(() => {
if (defaultChecked !== undefined && checked === undefined) {
checkbox.setValue(defaultChecked);
}
}, [defaultChecked, checkbox]);
}, [defaultChecked, checked, checkbox]);
return (
<Checkbox
ref={ref}
store={checkbox}
onChange={onChange}
defaultChecked={defaultChecked}
className={cn(
// Base styling from MultiSelect's selectClassName
'group relative inline-flex items-center justify-center gap-1.5',

View file

@ -54,8 +54,11 @@ export function useToolToggle({
},
);
const isAuthenticated =
externalIsAuthenticated ?? (authConfig ? (authQuery?.data?.authenticated ?? false) : false);
const isAuthenticated = useMemo(
() =>
externalIsAuthenticated ?? (authConfig ? (authQuery?.data?.authenticated ?? false) : false),
[externalIsAuthenticated, authConfig, authQuery.data?.authenticated],
);
const isToolEnabled = useMemo(() => {
return ephemeralAgent?.[toolKey] ?? false;
@ -72,10 +75,10 @@ export function useToolToggle({
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>, isChecked: boolean) => {
({ e, isChecked }: { e?: React.ChangeEvent<HTMLInputElement>; isChecked: boolean }) => {
if (isAuthenticated !== undefined && !isAuthenticated && setIsDialogOpen) {
setIsDialogOpen(true);
e.preventDefault();
e?.preventDefault?.();
return;
}
setToggleState(isChecked);