feat: refine agent builder tools, actions, and MCP sections

This commit is contained in:
Marco Beretta 2026-06-30 04:12:52 +02:00
parent e3ec9544ef
commit 178be2d4c7
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
38 changed files with 1214 additions and 704 deletions

View file

@ -42,13 +42,16 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents);
const { data: mcpData } = useMCPToolsQuery({
const { data: mcpData, isFetching: mcpToolsFetching } = useMCPToolsQuery({
enabled:
!isEphemeralAgent(agent_id) &&
!isLoading &&
availableMCPServers != null &&
availableMCPServers.length > 0,
});
/** Tools are still arriving when the query is in flight and nothing is cached
* yet (e.g., right after a hard refresh). Lets the MCP dialog show a skeleton. */
const mcpToolsLoading = mcpToolsFetching && mcpData == null;
const { agentsConfig, endpointsConfig } = useGetAgentsConfig();
const mcpServerNames = useMemo(
@ -148,6 +151,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
agentsConfig,
startupConfig,
mcpServersMap,
mcpToolsLoading,
setActivePanel,
endpointsConfig,
setCurrentAgentId,

View file

@ -233,6 +233,9 @@ export type AgentPanelContextType = {
endpointsConfig?: t.TEndpointsConfig | null;
/** Pre-computed MCP server information indexed by server key */
mcpServersMap: Map<string, MCPServerInfo>;
/** True while the MCP tools list is being fetched and no data has arrived yet,
* so consumers can show a skeleton instead of an empty "no tools" state. */
mcpToolsLoading: boolean;
availableMCPServers: MCPServerDefinition[];
availableMCPServersMap: t.MCPServersListResponse | undefined;
};

View file

@ -1,7 +1,7 @@
import { Save } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { HoverCard, HoverCardTrigger, SecretInput } from '@librechat/client';
import { TPlugin, TPluginAuthConfig, TPluginAction } from 'librechat-data-provider';
import { useForm, useWatch } from 'react-hook-form';
import { HoverCard, HoverCardTrigger, SecretInput, Input, Label, Button } from '@librechat/client';
import type { TPlugin, TPluginAuthConfig, TPluginAction } from 'librechat-data-provider';
import type { RegisterOptions } from 'react-hook-form';
import PluginTooltip from './PluginTooltip';
import { useLocalize } from '~/hooks';
@ -34,6 +34,38 @@ const AUTH_PLACEHOLDERS: Record<string, string> = {
AZURE_AI_SEARCH_INDEX_NAME: 'my-index',
};
/**
* Endpoint/URL fields, keyed by primary `authField`. These get a hard validation
* rule: a value that isn't a valid http(s) URL blocks submission.
*/
const URL_FIELDS = new Set<string>(['SD_WEBUI_URL', 'AZURE_AI_SEARCH_SERVICE_ENDPOINT']);
/**
* Known credential prefixes per primary `authField`. A value missing its expected
* prefix surfaces a non-blocking hint, never an error key formats drift over
* time, so we guide without trapping a key that just doesn't match.
*/
const AUTH_PREFIXES: Record<string, string> = {
OPENAI_API_KEY: 'sk-',
DALLE_API_KEY: 'sk-',
DALLE2_API_KEY: 'sk-',
DALLE3_API_KEY: 'sk-',
IMAGE_GEN_OAI_API_KEY: 'sk-',
GEMINI_API_KEY: 'AIza',
GOOGLE_KEY: 'AIza',
GOOGLE_SEARCH_API_KEY: 'AIza',
TAVILY_API_KEY: 'tvly-',
};
const isValidUrl = (value: string): boolean => {
try {
const { protocol } = new URL(value);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
};
type TPluginAuthFormProps = {
plugin: TPlugin | undefined;
onSubmit: (installActionData: TPluginAction) => void;
@ -43,113 +75,120 @@ type TPluginAuthFormProps = {
function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps) {
const {
register,
control,
handleSubmit,
formState: { errors, isDirty, isValid, isSubmitting },
} = useForm();
} = useForm({ mode: 'onChange' });
const localize = useLocalize();
const watchedValues = useWatch({ control });
const authConfig = plugin?.authConfig ?? [];
const allFieldsOptional = authConfig.length > 0 && authConfig.every((c) => c.optional === true);
const submit = handleSubmit((auth) =>
onSubmit({
pluginKey: plugin?.pluginKey ?? '',
action: 'install',
auth,
isEntityTool,
}),
);
return (
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full gap-6 sm:grid-cols-2">
<form
className="col-span-1 flex w-full flex-col items-start justify-start gap-2"
method="POST"
onSubmit={handleSubmit((auth) =>
onSubmit({
pluginKey: plugin?.pluginKey ?? '',
action: 'install',
auth,
isEntityTool,
}),
)}
>
{authConfig.map((config: TPluginAuthConfig, i: number) => {
const authField = config.authField.split('||')[0];
const isOptional = config.optional === true;
const inputClassName =
'flex h-10 max-h-10 w-full resize-none rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm text-gray-700 shadow-[0_0_10px_rgba(0,0,0,0.05)] outline-none placeholder:text-gray-400 focus:border-gray-400 focus:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-gray-400 focus:ring-opacity-0 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-400 focus:dark:bg-gray-600 dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0';
const sharedProps = {
id: authField,
placeholder: AUTH_PLACEHOLDERS[authField],
'aria-invalid': !!errors[authField],
'aria-describedby': `${authField}-error`,
'aria-label': config.label,
'aria-required': !isOptional,
/* autoFocus is generally disorienting, but here the required field must be navigated to
* anyway, and the form emulates a modal opening where users expect focus to shift. */
autoFocus: i === 0,
className: inputClassName,
...register(
authField,
isOptional
? {}
: {
required: `${config.label} is required.`,
minLength: {
value: 1,
message: `${config.label} must be at least 1 character long`,
},
},
),
<form className="flex w-full flex-col" method="POST" onSubmit={submit}>
<div className="flex max-h-[40vh] flex-col gap-4 overflow-y-auto pr-1">
{authConfig.map((config: TPluginAuthConfig, i: number) => {
const authField = config.authField.split('||')[0];
const isOptional = config.optional === true;
const rules: RegisterOptions = {};
if (!isOptional) {
rules.required = `${config.label} is required.`;
rules.minLength = {
value: 1,
message: `${config.label} must be at least 1 character long`,
};
return (
<div key={`${authField}-${i}`} className="flex w-full flex-col gap-1">
<label
htmlFor={authField}
className="mb-1 text-left text-sm font-medium text-gray-700/70 dark:text-gray-50/70"
>
{config.label}
</label>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
{config.sensitive === false ? (
<input type="text" autoComplete="off" {...sharedProps} />
) : (
<SecretInput
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
{...sharedProps}
/>
)}
</HoverCardTrigger>
<PluginTooltip content={config.description} position="right" />
</HoverCard>
{errors[authField] && (
<span role="alert" className="mt-1 text-sm text-red-400">
{String(errors?.[authField]?.message ?? '')}
</span>
)}
</div>
);
})}
<button
disabled={allFieldsOptional ? isSubmitting : !isDirty || !isValid || isSubmitting}
type="button"
className="btn btn-primary relative"
onClick={() => {
handleSubmit((auth) =>
onSubmit({
pluginKey: plugin?.pluginKey ?? '',
action: 'install',
auth,
isEntityTool,
}),
)();
}}
>
<div className="flex items-center justify-center gap-2">
{localize('com_ui_save')}
<Save className="flex h-4 w-4 items-center stroke-2" aria-hidden="true" />
}
if (URL_FIELDS.has(authField)) {
rules.validate = (value: string) =>
!value || isValidUrl(value) || localize('com_ui_auth_invalid_url');
}
const hasError = !!errors[authField];
const expectedPrefix = AUTH_PREFIXES[authField];
const value = String(watchedValues?.[authField] ?? '');
const showHint =
!hasError && !!expectedPrefix && value.length > 0 && !value.startsWith(expectedPrefix);
const describedBy = showHint
? `${authField}-error ${authField}-hint`
: `${authField}-error`;
const reserveMessage = !isOptional || URL_FIELDS.has(authField) || !!expectedPrefix;
const sharedProps = {
id: authField,
placeholder: AUTH_PLACEHOLDERS[authField],
'aria-invalid': hasError,
'aria-describedby': describedBy,
'aria-label': config.label,
'aria-required': !isOptional,
/* autoFocus is generally disorienting, but here the required field must be navigated to
* anyway, and the form emulates a modal opening where users expect focus to shift. */
autoFocus: i === 0,
className:
'hover:border-border-light focus-visible:border-border-light focus-visible:ring-2 focus-visible:ring-ring-primary',
...register(authField, rules),
};
return (
<div key={`${authField}-${i}`} className="flex w-full flex-col gap-1.5">
<Label htmlFor={authField} className="text-sm font-medium text-text-secondary">
{config.label}
</Label>
<HoverCard openDelay={300}>
<HoverCardTrigger className="block w-full">
{config.sensitive === false ? (
<Input type="text" autoComplete="off" {...sharedProps} />
) : (
<SecretInput
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
{...sharedProps}
/>
)}
</HoverCardTrigger>
<PluginTooltip content={config.description} position="right" />
</HoverCard>
{reserveMessage && (
<div className="min-h-4 text-xs leading-4">
{hasError && (
<span id={`${authField}-error`} role="alert" className="text-red-500">
{String(errors?.[authField]?.message ?? '')}
</span>
)}
{showHint && (
<span
id={`${authField}-hint`}
aria-live="polite"
className="text-amber-600 dark:text-amber-500"
>
{localize('com_ui_auth_format_hint', { 0: expectedPrefix })}
</span>
)}
</div>
)}
</div>
</button>
</form>
);
})}
</div>
</div>
<div className="mt-4 flex justify-end">
<Button
type="button"
variant="submit"
disabled={allFieldsOptional ? isSubmitting : !isDirty || !isValid || isSubmitting}
onClick={submit}
>
{isSubmitting ? localize('com_ui_saving') : localize('com_ui_save')}
</Button>
</div>
</form>
);
}

View file

@ -2,24 +2,22 @@ import { useState, useEffect } from 'react';
import debounce from 'lodash/debounce';
import { Maximize2 } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import {
validateAndParseOpenAPISpec,
openapiToFunction,
AuthTypeEnum,
} from 'librechat-data-provider';
import {
Button,
Spinner,
Textarea,
Skeleton,
OGDialog,
OGDialogClose,
OGDialogTitle,
OGDialogHeader,
OGDialogContent,
OGDialogDescription,
useToastContext,
} from '@librechat/client';
import {
validateAndParseOpenAPISpec,
openapiToFunction,
AuthTypeEnum,
} from 'librechat-data-provider';
import type {
Action,
FunctionTool,
@ -28,8 +26,8 @@ import type {
} from 'librechat-data-provider';
import type { ActionAuthForm } from '~/common';
import type { Spec } from './ActionsTable';
import { ActionsTable, ActionsTableSkeleton, columns } from './ActionsTable';
import ActionCallback from '~/components/SidePanel/Builder/ActionCallback';
import { ActionsTable, columns } from './ActionsTable';
import { useUpdateAgentAction } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { logger } from '~/utils';
@ -43,20 +41,6 @@ const debouncedValidation = debounce(
);
/** Placeholder rows shaped like the "Available actions" table (Name / Method / Path). */
function ActionsTableSkeleton() {
return (
<div className="flex flex-col gap-2 pt-1" aria-busy="true" aria-live="polite">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="flex items-center gap-3 border-b border-border-light pb-2.5 pt-1">
<Skeleton className="h-3.5 w-1/3" />
<Skeleton className="h-3.5 w-12" />
<Skeleton className="h-3.5 flex-1" />
</div>
))}
</div>
);
}
export default function ActionsInput({
action,
agent_id,
@ -97,7 +81,7 @@ export default function ActionsInput({
}
setInputValue(rawSpec);
setIsValidating(true);
debouncedValidation(rawSpec, handleResult);
handleResult(validateAndParseOpenAPISpec(rawSpec));
}, [action?.metadata.raw_spec]);
useEffect(() => {
@ -251,7 +235,7 @@ export default function ActionsInput({
return (
<>
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 flex-col">
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
<label
htmlFor="schemaInput"
@ -269,40 +253,36 @@ export default function ActionsInput({
<Maximize2 className="h-4 w-4" strokeWidth={1.75} aria-hidden={true} />
</button>
</div>
<div className="mb-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border-medium ring-0 hover:border-border-heavy">
<div className="relative flex min-h-0 flex-1 flex-col">
<Textarea
id="schemaInput"
value={inputValue}
onChange={handleInputChange}
spellCheck="false"
placeholder={localize('com_ui_enter_openapi_schema')}
className="min-h-[12rem] flex-1 resize-y rounded-none border-0 bg-transparent p-2 font-mono text-xs focus-visible:ring-0"
/>
<Textarea
id="schemaInput"
value={inputValue}
onChange={handleInputChange}
spellCheck="false"
placeholder={localize('com_ui_enter_openapi_schema')}
className="block min-h-[12rem] w-full resize-y rounded-lg border border-border-light bg-transparent p-3 font-mono text-xs leading-relaxed transition-colors focus-visible:border-border-heavy focus-visible:ring-0"
/>
{validationError && (
<div className="mt-1.5 text-xs text-red-500">
{validationError.split('\n').map((line: string, i: number) => (
<div key={i}>{line}</div>
))}
</div>
{validationError && (
<div className="border-t border-border-light p-2 text-red-500">
{validationError.split('\n').map((line: string, i: number) => (
<div key={i}>{line}</div>
))}
</div>
)}
</div>
)}
</div>
{(data || showSkeleton) && (
<div className="my-2">
<div className="flex items-center">
<label className="block text-sm font-medium text-text-primary">
{localize('com_assistants_available_actions')}
</label>
<div className="mt-4 flex min-h-0 flex-1 flex-col">
<label className="mb-1 block shrink-0 text-sm font-medium text-text-primary">
{localize('com_assistants_available_actions')}
</label>
<div className="min-h-0 flex-1 overflow-y-auto">
{data ? <ActionsTable columns={columns} data={data} /> : <ActionsTableSkeleton />}
</div>
{data ? <ActionsTable columns={columns} data={data} /> : <ActionsTableSkeleton />}
</div>
)}
<div className="relative my-1">
<div className="mt-4 shrink-0">
<ActionCallback action_id={action?.action_id} />
</div>
<div className="mt-auto flex items-center justify-between gap-2 pt-2">
<div className="flex shrink-0 items-center justify-between gap-2 pt-4">
<div className="flex items-center">{footerStart}</div>
<Button
type="button"
@ -316,38 +296,32 @@ export default function ActionsInput({
</div>
<OGDialog open={isSchemaDialogOpen} onOpenChange={setIsSchemaDialogOpen}>
<OGDialogContent
className="flex h-[85vh] max-h-[85vh] w-11/12 max-w-5xl flex-col gap-4 p-6"
showCloseButton={false}
>
<OGDialogHeader className="mb-2 pr-14">
<OGDialogTitle className="text-left text-2xl font-semibold">
<OGDialogContent className="flex h-[85vh] max-h-[85vh] w-11/12 max-w-5xl flex-col gap-3 p-5">
<OGDialogHeader className="space-y-0 pr-10">
<OGDialogTitle className="text-left text-sm font-medium text-text-primary">
{localize('com_ui_schema')}
</OGDialogTitle>
<OGDialogDescription className="sr-only">
{localize('com_ui_enter_openapi_schema')}
</OGDialogDescription>
</OGDialogHeader>
<Textarea
value={inputValue}
onChange={handleInputChange}
spellCheck="false"
placeholder={localize('com_ui_enter_openapi_schema')}
aria-label={localize('com_ui_schema')}
className="min-h-0 flex-1 resize-none bg-transparent font-mono text-sm leading-relaxed"
/>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border-medium bg-surface-secondary focus-within:border-border-heavy">
<Textarea
value={inputValue}
onChange={handleInputChange}
spellCheck="false"
placeholder={localize('com_ui_enter_openapi_schema')}
aria-label={localize('com_ui_schema')}
className="min-h-0 flex-1 resize-none border-0 bg-transparent p-4 font-mono text-[13px] leading-relaxed focus-visible:ring-0"
/>
</div>
{validationError && (
<div className="max-h-24 overflow-y-auto text-sm text-red-500">
<div className="max-h-24 shrink-0 overflow-y-auto text-xs text-red-500">
{validationError.split('\n').map((line: string, i: number) => (
<div key={i}>{line}</div>
))}
</div>
)}
<div className="flex items-center justify-end">
<OGDialogClose asChild>
<Button>{localize('com_ui_done')}</Button>
</OGDialogClose>
</div>
</OGDialogContent>
</OGDialog>
</>

View file

@ -1,4 +1,7 @@
import type { ColumnDef } from '@tanstack/react-table';
import type { TranslationKeys } from '~/hooks';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export type Spec = {
name: string;
@ -7,48 +10,49 @@ export type Spec = {
domain: string;
};
export const fakeData: Spec[] = [
{
name: 'listPets',
method: 'get',
path: '/pets',
domain: 'petstore.swagger.io',
},
{
name: 'createPets',
method: 'post',
path: '/pets',
domain: 'petstore.swagger.io',
},
{
name: 'showPetById',
method: 'get',
path: '/pets/{petId}',
domain: 'petstore.swagger.io',
},
];
/** Color-codes the HTTP verb the way API docs do, so the method reads at a glance. */
const METHOD_STYLES: Record<string, string> = {
get: 'bg-sky-500/10 text-sky-600 dark:text-sky-400',
post: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
put: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
patch: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
delete: 'bg-red-500/10 text-red-600 dark:text-red-400',
};
function HeaderCell({ labelKey }: { labelKey: TranslationKeys }) {
const localize = useLocalize();
return <>{localize(labelKey)}</>;
}
function MethodBadge({ method }: { method: string }) {
return (
<span
className={cn(
'inline-flex items-center rounded-md px-1.5 py-0.5 font-mono text-[11px] font-semibold uppercase tracking-wide',
METHOD_STYLES[method.toLowerCase()] ?? 'bg-surface-secondary text-text-secondary',
)}
>
{method}
</span>
);
}
export const columns: ColumnDef<Spec>[] = [
{
header: 'Name',
accessorKey: 'name',
header: () => <HeaderCell labelKey="com_ui_name" />,
cell: ({ row }) => <span className="font-medium text-text-primary">{row.original.name}</span>,
},
{
header: 'Method',
accessorKey: 'method',
header: () => <HeaderCell labelKey="com_ui_method" />,
cell: ({ row }) => <MethodBadge method={row.original.method} />,
},
{
header: 'Path',
accessorKey: 'path',
header: () => <HeaderCell labelKey="com_ui_path" />,
cell: ({ row }) => (
<span className="break-all font-mono text-xs text-text-secondary">{row.original.path}</span>
),
},
// {
// header: '',
// accessorKey: 'action',
// // eslint-disable-next-line @typescript-eslint/no-unused-vars
// cell: ({ row: _row }) => (
// <button className="btn relative btn-neutral h-8 rounded-lg border-token-border-light font-medium">
// <div className="flex w-full gap-2 items-center justify-center">Test</div>
// </button>
// ),
// },
];

View file

@ -0,0 +1,45 @@
import { Skeleton } from '@librechat/client';
import { useLocalize } from '~/hooks';
const SKELETON_ROWS = 3;
const HEADER_KEYS = ['com_ui_name', 'com_ui_method', 'com_ui_path'] as const;
/**
* Loading state for the actions table. Mirrors the table's chrome (eyebrow header,
* row dividers, a badge-shaped method cell) so the swap to real rows doesn't shift
* the layout. The headers are real since they never change.
*/
export default function ActionsTableSkeleton() {
const localize = useLocalize();
return (
<table className="w-full table-auto text-sm" aria-busy="true" aria-live="polite">
<thead>
<tr className="border-b border-border-light">
{HEADER_KEYS.map((key) => (
<th
key={key}
className="py-2 pr-3 text-left text-[11px] font-medium uppercase tracking-wide text-text-secondary"
>
{localize(key)}
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: SKELETON_ROWS }, (_, row) => (
<tr key={row} className="border-b border-border-light last:border-0">
<td className="py-2.5 pr-3">
<Skeleton className="h-4 w-24 rounded" />
</td>
<td className="py-2.5 pr-3">
<Skeleton className="h-5 w-12 rounded-md" />
</td>
<td className="py-2.5 pr-3">
<Skeleton className="h-4 w-32 rounded" />
</td>
</tr>
))}
</tbody>
</table>
);
}

View file

@ -14,15 +14,15 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
});
return (
<table className="w-full text-sm">
<table className="w-full table-auto text-sm">
<thead>
{table.getHeaderGroups().map((headerGroup, i) => (
<tr
key={i}
className="border-token-border-light text-token-text-tertiary border-b text-left text-xs"
>
{headerGroup.headers.map((header, j) => (
<th key={j} className="py-1 font-normal text-text-secondary-alt">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="sticky top-0 z-10 border-b border-border-light bg-background py-2 pr-3 text-left text-[11px] font-medium uppercase tracking-wide text-text-secondary"
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
@ -32,10 +32,13 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row, i) => (
<tr key={i} className="border-token-border-light border-b">
{row.getVisibleCells().map((cell, j) => (
<td key={j} className="py-2">
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="border-b border-border-light transition-colors last:border-0 hover:bg-surface-secondary"
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="py-2 pr-3 align-middle">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}

View file

@ -1,2 +1,3 @@
export { default as ActionsTable } from './Table';
export { default as ActionsTableSkeleton } from './Skeleton';
export * from './Columns';

View file

@ -1,23 +1,25 @@
import { Input } from '@librechat/client';
import { Controller, useWatch, useFormContext } from 'react-hook-form';
import { EModelEndpoint, getEndpointField } from 'librechat-data-provider';
import type { AgentForm, IconComponentTypes } from '~/common';
import type { AgentForm, ExtendedFile, IconComponentTypes } from '~/common';
import { useLocalize, useAgentCapabilities } from '~/hooks';
import AgentCategorySelector from './AgentCategorySelector';
import { validateEmail, getIconKey, cn } from '~/utils';
import { useAgentPanelContext } from '~/Providers';
import AgentCategorySelector from './AgentCategorySelector';
import { useLocalize } from '~/hooks';
import { Panel } from '~/common';
import ToolsSection from './Tools/ToolsSection';
import { icons } from '~/hooks/Endpoint/Icons';
import Instructions from './Instructions';
import ToolsSection from './Tools/ToolsSection';
import FileContext from './FileContext';
import AgentAvatar from './AgentAvatar';
import { Panel } from '~/common';
const fieldClass = 'h-9';
export default function AgentConfig() {
const localize = useLocalize();
const methods = useFormContext<AgentForm>();
const { setActivePanel, endpointsConfig } = useAgentPanelContext();
const { setActivePanel, endpointsConfig, agentsConfig } = useAgentPanelContext();
const { contextEnabled } = useAgentCapabilities(agentsConfig?.capabilities);
const {
control,
@ -27,6 +29,7 @@ export default function AgentConfig() {
const model = useWatch({ control, name: 'model' });
const agent = useWatch({ control, name: 'agent' });
const agent_id = useWatch({ control, name: 'id' });
const contextFiles = (agent?.context_files ?? []) as Array<[string, ExtendedFile]>;
const providerValue = typeof provider === 'string' ? provider : provider?.value;
let Icon: IconComponentTypes | null | undefined;
@ -148,6 +151,13 @@ export default function AgentConfig() {
{/* TOOLS — unified built-ins / tools / actions / mcp / skills */}
<ToolsSection agentId={agent_id} />
{/* FILE CONTEXT — standalone section, separate from the tool library */}
{contextEnabled && (
<div className="mb-3">
<FileContext agent_id={agent_id} files={contextFiles} />
</div>
)}
{/* SUPPORT CONTACT */}
<div className="mb-3 flex flex-col">
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wide text-text-secondary">

View file

@ -1,18 +1,23 @@
import { memo, useMemo, useRef, useState } from 'react';
import { Folder } from 'lucide-react';
import * as Ariakit from '@ariakit/react';
import { Folder, Plus, Info } from 'lucide-react';
import { EModelEndpoint, EToolResources } from 'librechat-data-provider';
import { DropdownPopup, SharePointIcon } from '@librechat/client';
import { DropdownPopup, SharePointIcon, TooltipAnchor } from '@librechat/client';
import type { ExtendedFile } from '~/common';
import { useSharePointFileHandlingNoChatContext } from '~/hooks/Files/useSharePointFileHandling';
import { useFileHandlingNoChatContext } from '~/hooks/Files/useFileHandling';
import { useAgentFileConfig, useLocalize, useLazyEffect } from '~/hooks';
import DropzoneContent, { dropzoneClassName } from './UploadDropzone';
import { SharePointPickerDialog } from '~/components/SharePoint';
import FileRow from '~/components/Chat/Input/Files/FileRow';
import { useGetStartupConfig } from '~/data-provider';
import SectionHeader from './SectionHeader';
import { isEphemeralAgent } from '~/common';
import { cn } from '~/utils';
const addButtonClassName = cn(
'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-lg px-2 text-xs font-medium text-text-secondary transition-colors',
'hover:bg-surface-secondary hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-text-secondary',
);
function FileContext({
agent_id,
@ -96,24 +101,75 @@ function FileContext({
},
];
const dropzoneLabel = localize('com_ui_upload_file_context');
const dropzoneHint = localize('com_ui_upload_files_hint');
const addLabel = localize('com_ui_upload_file_context');
const fileCount = files.size;
const menuTrigger = (
<Ariakit.MenuButton disabled={disabledUploadButton} className={dropzoneClassName}>
<DropzoneContent label={dropzoneLabel} hint={dropzoneHint} />
</Ariakit.MenuButton>
const addControl = sharePointEnabled ? (
<DropdownPopup
gutter={2}
menuId="file-context-upload-menu"
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
trigger={
<Ariakit.MenuButton
disabled={disabledUploadButton}
aria-label={addLabel}
className={addButtonClassName}
>
<Plus className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden="true" />
{localize('com_ui_add')}
</Ariakit.MenuButton>
}
items={dropdownItems}
modal={true}
unmountOnHide={true}
/>
) : (
<button
type="button"
disabled={disabledUploadButton}
aria-label={addLabel}
className={addButtonClassName}
onClick={handleLocalFileClick}
>
<Plus className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden="true" />
{localize('com_ui_add')}
</button>
);
return (
<div className="w-full">
{showHeader && (
<SectionHeader
title={localize('com_agents_file_context_label')}
info={localize('com_agents_file_context_description')}
/>
)}
<div className="flex flex-col gap-3">
{/* File Context Files */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
{showHeader ? (
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[11px] font-medium uppercase tracking-wide text-text-secondary">
{localize('com_agents_file_context_label')}
</span>
<TooltipAnchor
description={localize('com_agents_file_context_description')}
side="top"
render={
<button
type="button"
aria-label={localize('com_agents_file_context_description')}
className="flex size-4 shrink-0 items-center justify-center rounded text-text-tertiary transition-colors hover:text-text-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
>
<Info className="size-3.5" aria-hidden="true" />
</button>
}
/>
{fileCount > 0 && (
<span className="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-surface-tertiary px-1.5 text-[10px] font-medium text-text-secondary">
{fileCount}
</span>
)}
</div>
) : (
<span />
)}
{addControl}
</div>
{fileCount > 0 && (
<FileRow
files={files}
setFiles={setFiles}
@ -121,45 +177,21 @@ function FileContext({
tool_resource={EToolResources.context}
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
/>
<div>
{sharePointEnabled ? (
<DropdownPopup
gutter={2}
menuId="file-context-upload-menu"
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
trigger={menuTrigger}
items={dropdownItems}
modal={true}
unmountOnHide={true}
/>
) : (
<button
type="button"
disabled={disabledUploadButton}
className={dropzoneClassName}
onClick={handleLocalFileClick}
>
<DropzoneContent label={dropzoneLabel} hint={dropzoneHint} />
</button>
)}
<input
multiple={true}
type="file"
style={{ display: 'none' }}
tabIndex={-1}
ref={fileInputRef}
disabled={disabledUploadButton}
onChange={handleFileChange}
/>
</div>
{/* Disabled Message */}
{agent_id ? null : (
<div className="text-xs text-text-secondary">
{localize('com_agents_file_context_disabled')}
</div>
)}
</div>
)}
{!agent_id && (
<p className="text-[11px] leading-snug text-text-secondary">
{localize('com_agents_file_context_disabled')}
</p>
)}
<input
multiple={true}
type="file"
style={{ display: 'none' }}
tabIndex={-1}
ref={fileInputRef}
disabled={disabledUploadButton}
onChange={handleFileChange}
/>
<SharePointPickerDialog
isOpen={isSharePointDialogOpen}
onOpenChange={setIsSharePointDialogOpen}

View file

@ -1,6 +1,8 @@
import { useState, useId } from 'react';
import { PlusCircle, Maximize2 } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import { PlusCircle, Maximize2 } from 'lucide-react';
import { specialVariables } from 'librechat-data-provider';
import { Controller, useFormContext } from 'react-hook-form';
import {
Button,
DropdownPopup,
@ -10,12 +12,10 @@ import {
OGDialogHeader,
OGDialogTitle,
} from '@librechat/client';
import { specialVariables } from 'librechat-data-provider';
import { Controller, useFormContext } from 'react-hook-form';
import type { TSpecialVarLabel } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import { cn } from '~/utils';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
const textareaClass =
'lc-field flex w-full rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-text-primary placeholder:text-text-secondary focus-visible:outline-none focus-visible:border-border-medium focus-visible:ring-2 focus-visible:ring-ring-primary disabled:cursor-not-allowed disabled:opacity-50';
@ -84,7 +84,7 @@ export default function Instructions() {
}
items={variableItems}
menuId={menuId}
className="z-30"
className="pointer-events-auto z-30"
/>
<button
type="button"
@ -172,7 +172,7 @@ export default function Instructions() {
}
items={variableItems}
menuId={dialogMenuId}
className="z-[200]"
className="pointer-events-auto z-[200]"
/>
<OGDialogClose asChild>
<Button>{localize('com_ui_done')}</Button>

View file

@ -1,14 +1,6 @@
import React from 'react';
import { Clock, MoreHorizontal, Code2 } from 'lucide-react';
import {
Checkbox,
DropdownMenu,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuCheckboxItem,
} from '@librechat/client';
import { useState } from 'react';
import { TooltipAnchor } from '@librechat/client';
import { Check, Clock, Code2, Info } from 'lucide-react';
import type { AgentToolType } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -25,18 +17,8 @@ interface MCPToolItemProps {
onToggleProgrammatic: () => void;
}
function getToolItemStyle(isDeferred: boolean, isProgrammatic: boolean): string {
if (isDeferred && isProgrammatic) {
return 'border-purple-500/50 bg-purple-500/5 hover:bg-purple-500/10';
}
if (isDeferred) {
return 'border-amber-500/50 bg-amber-500/5 hover:bg-amber-500/10';
}
if (isProgrammatic) {
return 'border-violet-500/50 bg-violet-500/5 hover:bg-violet-500/10';
}
return 'border-token-border-light hover:bg-token-surface-secondary';
}
const iconButton =
'flex size-6 items-center justify-center rounded-md transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary';
export default function MCPToolItem({
tool,
@ -50,103 +32,116 @@ export default function MCPToolItem({
programmaticToolsEnabled,
}: MCPToolItemProps) {
const localize = useLocalize();
const hasOptions = isDeferred || isProgrammatic;
const [expanded, setExpanded] = useState(false);
const description = tool.metadata.description?.trim();
const detailsId = `mcp-tool-details-${tool.tool_id}`;
return (
<div
className={cn(
'group/item flex cursor-pointer items-center rounded-lg border p-2',
'ml-2 mr-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
getToolItemStyle(isDeferred, isProgrammatic),
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Checkbox
id={tool.tool_id}
checked={isSelected}
onCheckedChange={onToggleSelect}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const checkbox = e.currentTarget as HTMLButtonElement;
checkbox.click();
}
}}
onClick={(e) => e.stopPropagation()}
className="relative mr-2 inline-flex h-4 w-4 shrink-0 cursor-pointer rounded border border-border-medium transition-[border-color] duration-200 hover:border-border-heavy focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
aria-label={tool.metadata.name}
/>
<span className="text-token-text-primary min-w-0 flex-1 select-none truncate">
{tool.metadata.name}
</span>
<div className="ml-2 flex shrink-0 items-center gap-1.5">
{isDeferred && <Clock className="h-3.5 w-3.5 text-amber-500" aria-hidden="true" />}
{isProgrammatic && <Code2 className="h-3.5 w-3.5 text-violet-500" aria-hidden="true" />}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'flex h-6 w-6 items-center justify-center rounded transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
hasOptions
? 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'
: 'text-text-tertiary opacity-0 hover:bg-surface-hover hover:text-text-primary group-focus-within/item:opacity-100 group-hover/item:opacity-100',
)}
onClick={(e) => e.stopPropagation()}
aria-label={localize('com_ui_mcp_tool_options')}
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="left"
className="w-80"
onClick={(e) => e.stopPropagation()}
<div className="overflow-hidden rounded-lg">
<div className="flex items-center gap-1 rounded-lg pr-1 transition-colors hover:bg-surface-secondary">
<button
type="button"
onClick={onToggleSelect}
aria-pressed={isSelected}
aria-label={tool.metadata.name}
className={cn(
'flex min-w-0 flex-1 items-center gap-2.5 rounded-lg p-2 text-left',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
)}
>
<span
aria-hidden="true"
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded border border-border-medium transition-colors',
isSelected && 'bg-primary text-primary-foreground',
)}
>
<DropdownMenuLabel className="text-xs font-normal text-text-primary">
{tool.metadata.description || localize('com_ui_mcp_no_description')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{deferredToolsEnabled && (
<DropdownMenuCheckboxItem
checked={isDeferred}
onCheckedChange={onToggleDefer}
className="cursor-pointer"
>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-amber-500" />
<div className="flex flex-col">
<span>{localize('com_ui_mcp_defer_loading')}</span>
<span className="text-xs text-text-secondary">
{localize('com_ui_mcp_click_to_defer')}
</span>
</div>
</div>
</DropdownMenuCheckboxItem>
{isSelected && <Check className="size-4" />}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-primary">
{tool.metadata.name}
</span>
</button>
<div className="flex shrink-0 items-center gap-0.5">
{deferredToolsEnabled && (
<TooltipAnchor
description={localize('com_ui_mcp_click_to_defer')}
side="top"
render={
<button
type="button"
onClick={onToggleDefer}
aria-pressed={isDeferred}
aria-label={localize('com_ui_mcp_defer_loading')}
className={cn(
iconButton,
isDeferred ? 'text-amber-500' : 'text-text-secondary hover:text-text-primary',
)}
>
<Clock className="size-4" aria-hidden="true" />
</button>
}
/>
)}
{programmaticToolsEnabled && (
<TooltipAnchor
description={localize('com_ui_mcp_click_to_programmatic')}
side="top"
render={
<button
type="button"
onClick={onToggleProgrammatic}
aria-pressed={isProgrammatic}
aria-label={localize('com_ui_mcp_programmatic')}
className={cn(
iconButton,
isProgrammatic
? 'text-violet-500'
: 'text-text-secondary hover:text-text-primary',
)}
>
<Code2 className="size-4" aria-hidden="true" />
</button>
}
/>
)}
<button
type="button"
onClick={() => setExpanded((value) => !value)}
aria-expanded={expanded}
aria-controls={detailsId}
aria-label={localize('com_ui_tools_info')}
className={cn(
iconButton,
expanded ? 'text-text-primary' : 'text-text-secondary hover:text-text-primary',
)}
{programmaticToolsEnabled && (
<DropdownMenuCheckboxItem
checked={isProgrammatic}
onCheckedChange={onToggleProgrammatic}
className="cursor-pointer"
>
<div className="flex items-center gap-2">
<Code2 className="h-4 w-4 text-violet-500" />
<div className="flex flex-col">
<span>{localize('com_ui_mcp_programmatic')}</span>
<span className="text-xs text-text-secondary">
{localize('com_ui_mcp_click_to_programmatic')}
</span>
</div>
</div>
</DropdownMenuCheckboxItem>
>
<Info className="size-4" aria-hidden="true" />
</button>
</div>
</div>
{/* Auto-height reveal via grid-template-rows 0fr -> 1fr so the panel and
the auto-sized dialog around it grow/shrink smoothly instead of jumping. */}
<div
id={detailsId}
className={cn(
'grid transition-[grid-template-rows] [transition-duration:var(--resize-dur)] [transition-timing-function:var(--resize-ease)] motion-reduce:transition-none',
expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
>
<div className="min-h-0 overflow-hidden">
<div
className={cn(
'border-t border-border-light px-3 py-3 transition-opacity duration-200 ease-out motion-reduce:transition-none',
expanded ? 'opacity-100' : 'opacity-0',
)}
</DropdownMenuContent>
</DropdownMenu>
>
<p className="max-h-44 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-text-secondary">
{description || localize('com_ui_mcp_no_description')}
</p>
</div>
</div>
</div>
</div>
);

View file

@ -17,9 +17,9 @@ import {
import type { ActionAuthForm } from '~/common';
import ActionsAuth from '~/components/SidePanel/Builder/ActionsAuth';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import ActionsInput from '../ActionsInput';
import { useDeleteAgentAction } from '~/data-provider';
import { isEphemeralAgent } from '~/common';
import ActionsInput from '../ActionsInput';
import { useLocalize } from '~/hooks';
interface ActionEditorProps {
@ -100,11 +100,11 @@ export default function ActionEditor({
<OGDialogTrigger asChild>
<Button
type="button"
variant="subtle"
variant="destructive"
size="icon"
disabled={isEphemeralAgent(agentId) || !action.action_id}
aria-label={localize('com_ui_delete_action')}
className="flex-shrink-0 text-red-500"
title={localize('com_ui_delete_action')}
>
<TrashIcon className="h-4 w-4" />
</Button>
@ -141,26 +141,15 @@ export default function ActionEditor({
return (
<FormProvider {...methods}>
<form className="h-full grow overflow-hidden">
<div className="h-full overflow-auto text-sm">
<div className="flex min-h-full flex-col pb-3">
<div>
<p className="mt-1 text-xs text-text-secondary">
{localize('com_assistants_actions_info')}
</p>
<ActionsAuth />
</div>
<div className="flex flex-1 flex-col">
<ActionsInput
action={action}
agent_id={agentId}
setAction={setAction}
onCreated={onCreated}
footerStart={deleteButton}
/>
</div>
</div>
</div>
<form className="flex min-h-0 flex-1 flex-col text-sm">
<ActionsAuth />
<ActionsInput
action={action}
agent_id={agentId}
setAction={setAction}
onCreated={onCreated}
footerStart={deleteButton}
/>
</form>
</FormProvider>
);

View file

@ -24,7 +24,14 @@ export default function ItemDialog({ item, agentId, onClose }: Props) {
{item && (
<div className="flex max-h-[85vh] flex-col">
<ItemDialogHeader item={item} />
<div className="flex-1 overflow-y-auto px-6 pb-6 pt-2">
<div
className={cn(
'px-6 pb-6 pt-2',
isAction
? 'flex min-h-0 flex-1 flex-col overflow-hidden'
: 'flex-1 overflow-y-auto',
)}
>
<ItemDialogBody item={item} agentId={agentId} onClose={onClose} />
</div>
</div>

View file

@ -1,13 +1,14 @@
import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render, screen } from '@testing-library/react';
import McpSection from '../sections/McpSection';
import type { McpItem } from '../../items/types';
import McpSection from '../sections/McpSection';
const mockSetValue = jest.fn();
const mockGetValues = jest.fn((): string[] => []);
jest.mock('react-hook-form', () => ({
useFormContext: () => ({ setValue: mockSetValue, getValues: mockGetValues }),
useFormContext: () => ({ control: {}, setValue: mockSetValue, getValues: mockGetValues }),
useWatch: () => mockGetValues(),
}));
jest.mock('~/Providers', () => ({
@ -30,6 +31,10 @@ jest.mock('~/hooks', () => ({
isToolProgrammatic: () => false,
toggleToolDefer: jest.fn(),
toggleToolProgrammatic: jest.fn(),
areAllToolsDeferred: () => false,
areAllToolsProgrammatic: () => false,
toggleDeferAll: jest.fn(),
toggleProgrammaticAll: jest.fn(),
}),
}));
@ -148,6 +153,13 @@ describe('McpSection', () => {
);
});
test('selection state tracks the watched tools field (re-render on toggle)', () => {
mockGetValues.mockReturnValue(['mcp:srv:a']);
render(<McpSection item={item} />);
expect(screen.getByTestId('tool-mcp:srv:a')).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByTestId('tool-mcp:srv:b')).toHaveAttribute('aria-pressed', 'false');
});
test('shows empty hint when the server exposes no tools', () => {
const empty: McpItem = {
...item,

View file

@ -1,7 +1,7 @@
import { useEffect } from 'react';
import type { ActionItem } from '../../items/types';
import ActionEditor from '../../ActionEditor';
import { useAgentPanelContext } from '~/Providers';
import ActionEditor from '../../ActionEditor';
/** Sentinel id the marketplace assigns to its create-action selection (cross-file contract). */
const NEW_ACTION_ID = '__new_action__';
@ -22,7 +22,7 @@ export default function ActionSection({ item, agentId, onClose }: Props) {
}, [isCreate, item.action, setAction]);
return (
<div className="flex flex-col gap-5">
<div className="flex min-h-0 flex-1 flex-col">
<ActionEditor
agentId={agentId}
onClose={onClose}

View file

@ -1,19 +1,20 @@
import { useFormContext } from 'react-hook-form';
import { Checkbox } from '@librechat/client';
import { Clock, Code2 } from 'lucide-react';
import { Constants } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import { useFormContext, useWatch } from 'react-hook-form';
import { Checkbox, Skeleton, TooltipAnchor } from '@librechat/client';
import type { TranslationKeys } from '~/hooks/useLocalize';
import type { McpItem } from '../../items/types';
import MCPToolItem from '../../../MCPToolItem';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import { useAgentPanelContext } from '~/Providers';
import type { AgentForm } from '~/common';
import {
useAgentCapabilities,
useGetAgentsConfig,
useMCPServerManager,
useMCPToolOptions,
} from '~/hooks';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useAgentPanelContext } from '~/Providers';
import MCPToolItem from '../../../MCPToolItem';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -54,15 +55,23 @@ interface Props {
export default function McpSection({ item }: Props) {
const localize = useLocalize();
const { getValues, setValue } = useFormContext<AgentForm>();
const { control, getValues, setValue } = useFormContext<AgentForm>();
const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager();
const { mcpServersMap } = useAgentPanelContext();
const { mcpServersMap, mcpToolsLoading } = useAgentPanelContext();
const { agentsConfig } = useGetAgentsConfig();
const { deferredToolsEnabled, programmaticToolsEnabled } = useAgentCapabilities(
agentsConfig?.capabilities,
);
const { isToolDeferred, isToolProgrammatic, toggleToolDefer, toggleToolProgrammatic } =
useMCPToolOptions();
const {
isToolDeferred,
isToolProgrammatic,
toggleToolDefer,
toggleToolProgrammatic,
areAllToolsDeferred,
areAllToolsProgrammatic,
toggleDeferAll,
toggleProgrammaticAll,
} = useMCPToolOptions();
const serverName = item.server.serverName;
const serverToken = `${Constants.mcp_server}${Constants.mcp_delimiter}${serverName}`;
@ -72,10 +81,12 @@ export default function McpSection({ item }: Props) {
const tools = liveServer.tools ?? [];
const hasTools = tools.length > 0;
const getSelectedTools = (): string[] => {
const formTools = (getValues('tools') ?? []) as string[];
return tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id);
};
/** Subscribe to the tools field so selection toggles re-render this section.
* `getValues` is a non-reactive read and left the checkboxes visually stale. */
const formTools = (useWatch({ control, name: 'tools' }) ?? []) as string[];
const getSelectedTools = (): string[] =>
tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id);
/** Replace this server's selection. Strips every tool_id AND the server-level
* placeholder token, so passing `[]` fully detaches the server. */
@ -101,13 +112,18 @@ export default function McpSection({ item }: Props) {
const selectedTools = getSelectedTools();
const allSelected = hasTools && selectedTools.length === tools.length;
const allDeferred = areAllToolsDeferred(tools);
const allProgrammatic = areAllToolsProgrammatic(tools);
const statusIconProps = getServerStatusIconProps(serverName);
const configDialogProps = getConfigDialogProps();
const statusDisplay = getStatusDisplay(
statusIconProps?.serverStatus?.connectionState,
statusIconProps?.isInitializing ?? false,
liveServer.isConfigured,
);
const connectionState = statusIconProps?.serverStatus?.connectionState;
const isInitializing = statusIconProps?.isInitializing ?? false;
const statusDisplay = getStatusDisplay(connectionState, isInitializing, liveServer.isConfigured);
/** A connected server's tools arrive with the (cold-cache) MCP tools fetch, and
* the server is also briefly toolless while initializing show a skeleton in
* both cases instead of a misleading "no tools" message. */
const toolsLoading =
!hasTools && (mcpToolsLoading || isInitializing || connectionState === 'connecting');
return (
<div className="flex flex-col gap-5">
@ -134,26 +150,93 @@ export default function McpSection({ item }: Props) {
{localize('com_ui_tools_mcp_tools_section')}
</span>
{hasTools && (
<label className="flex cursor-pointer items-center gap-2 text-xs text-text-secondary">
<Checkbox
checked={allSelected}
onCheckedChange={(checked) => toggleAll(checked === true)}
aria-label={
allSelected
<div className="flex items-center gap-0.5">
{deferredToolsEnabled && (
<TooltipAnchor
description={
allDeferred
? localize('com_ui_mcp_undefer_all')
: localize('com_ui_mcp_defer_all')
}
side="top"
render={
<button
type="button"
onClick={() => toggleDeferAll(tools)}
aria-pressed={allDeferred}
aria-label={
allDeferred
? localize('com_ui_mcp_undefer_all')
: localize('com_ui_mcp_defer_all')
}
className={cn(
'flex size-7 items-center justify-center rounded-md transition-colors',
'hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
allDeferred
? 'text-amber-600 dark:text-amber-500'
: 'text-text-secondary hover:text-text-primary',
)}
>
<Clock className="size-4" aria-hidden="true" />
</button>
}
/>
)}
{programmaticToolsEnabled && (
<TooltipAnchor
description={
allProgrammatic
? localize('com_ui_mcp_unprogrammatic_all')
: localize('com_ui_mcp_programmatic_all')
}
side="top"
render={
<button
type="button"
onClick={() => toggleProgrammaticAll(tools)}
aria-pressed={allProgrammatic}
aria-label={
allProgrammatic
? localize('com_ui_mcp_unprogrammatic_all')
: localize('com_ui_mcp_programmatic_all')
}
className={cn(
'flex size-7 items-center justify-center rounded-md transition-colors',
'hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
allProgrammatic
? 'text-violet-600 dark:text-violet-500'
: 'text-text-secondary hover:text-text-primary',
)}
>
<Code2 className="size-4" aria-hidden="true" />
</button>
}
/>
)}
{(deferredToolsEnabled || programmaticToolsEnabled) && (
<span className="mx-1 h-4 w-px bg-border-light" aria-hidden="true" />
)}
<label className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs text-text-secondary">
<Checkbox
checked={allSelected}
onCheckedChange={(checked) => toggleAll(checked === true)}
aria-label={
allSelected
? localize('com_ui_tools_mcp_deselect_all')
: localize('com_ui_tools_mcp_select_all')
}
className="size-4 rounded border border-border-medium"
/>
<span>
{allSelected
? localize('com_ui_tools_mcp_deselect_all')
: localize('com_ui_tools_mcp_select_all')
}
className="size-4 rounded border border-border-medium"
/>
<span>
{allSelected
? localize('com_ui_tools_mcp_deselect_all')
: localize('com_ui_tools_mcp_select_all')}
</span>
</label>
: localize('com_ui_tools_mcp_select_all')}
</span>
</label>
</div>
)}
</div>
{hasTools ? (
{hasTools && (
<div className="flex flex-col gap-1">
{tools.map((tool) => (
<MCPToolItem
@ -170,7 +253,18 @@ export default function McpSection({ item }: Props) {
/>
))}
</div>
) : (
)}
{!hasTools && toolsLoading && (
<div className="flex flex-col gap-1" aria-busy="true" aria-live="polite">
{['w-3/5', 'w-1/2', 'w-2/5'].map((width) => (
<div key={width} className="flex items-center gap-2.5 rounded-lg px-2 py-2">
<Skeleton className="size-4 shrink-0 rounded" />
<Skeleton className={cn('h-4 rounded', width)} />
</div>
))}
</div>
)}
{!hasTools && !toolsLoading && (
<p className="rounded-xl border border-dashed border-border-light p-3 text-center text-xs text-text-tertiary">
{localize('com_ui_tools_mcp_no_tools')}
</p>

View file

@ -1,5 +1,11 @@
import { useState } from 'react';
import { CheckCircle2 } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { useToastContext } from '@librechat/client';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TError, TPluginAction } from 'librechat-data-provider';
import type { ToolItem } from '../../items/types';
import type { AgentForm } from '~/common';
import PluginAuthForm from '~/components/Plugins/Store/PluginAuthForm';
import { pluginNeedsAuth } from '../../items/auth';
import { useLocalize } from '~/hooks';
@ -10,8 +16,48 @@ interface Props {
export default function ToolSection({ item }: Props) {
const localize = useLocalize();
const [authDone, setAuthDone] = useState(false);
const needsAuth = pluginNeedsAuth(item.plugin) && !authDone;
const { showToast } = useToastContext();
const { getValues, setValue } = useFormContext<AgentForm>();
const updateUserPlugins = useUpdateUserPluginsMutation();
const requiresAuth = pluginNeedsAuth(item.plugin);
const [savedAuth, setSavedAuth] = useState(false);
const [editing, setEditing] = useState(false);
const showForm = requiresAuth && (!savedAuth || editing);
const showConfigured = requiresAuth && savedAuth && !editing;
/** Add this tool's pluginKey to the agent's tools so it becomes usable. */
const enableTool = () => {
const current = (getValues('tools') ?? []) as string[];
if (!current.includes(item.id)) {
setValue('tools', [...current, item.id], { shouldDirty: true });
}
};
const handleSubmit = (data: TPluginAction) => {
const hasAuth = data.auth != null && Object.keys(data.auth).length > 0;
if (!hasAuth) {
enableTool();
setSavedAuth(true);
setEditing(false);
return;
}
updateUserPlugins.mutate(data, {
onError: (error: unknown) => {
showToast({
message: (error as TError)?.message || localize('com_nav_plugin_auth_error'),
status: 'error',
});
},
onSuccess: () => {
enableTool();
setSavedAuth(true);
setEditing(false);
showToast({ message: localize('com_ui_tool_credentials_saved'), status: 'success' });
},
});
};
return (
<div className="flex flex-col gap-5">
@ -22,9 +68,22 @@ export default function ToolSection({ item }: Props) {
{localize('com_ui_tools_no_description')}
</p>
)}
{needsAuth && (
<PluginAuthForm plugin={item.plugin} isEntityTool onSubmit={() => setAuthDone(true)} />
{showConfigured && (
<div className="flex items-center justify-between rounded-xl border border-border-light bg-surface-secondary px-3 py-2.5">
<span className="flex items-center gap-2 text-sm font-medium text-text-primary">
<CheckCircle2 className="size-4 text-emerald-500" aria-hidden="true" />
{localize('com_ui_tools_info_configured')}
</span>
<button
type="button"
onClick={() => setEditing(true)}
className="rounded-md px-2 py-1 text-xs font-medium text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
>
{localize('com_ui_edit')}
</button>
</div>
)}
{showForm && <PluginAuthForm plugin={item.plugin} isEntityTool onSubmit={handleSubmit} />}
</div>
);
}

View file

@ -1,7 +1,7 @@
import { LayoutGrid, ListFilter, Wrench, Server, Workflow, Star, User, Plus } from 'lucide-react';
import { useState, useMemo } from 'react';
import { Button, DropdownPopup } from '@librechat/client';
import * as Ariakit from '@ariakit/react/menu';
import { Button, DropdownPopup } from '@librechat/client';
import { LayoutGrid, ListFilter, Wrench, Server, Workflow, Star, User, Plus } from 'lucide-react';
import type { ReactNode } from 'react';
import type { AgentItemKind, ItemFilter } from './items/types';
import { useLocalize } from '~/hooks';
@ -91,6 +91,7 @@ export default function MarketplaceSidebar({
isOpen={createOpen}
setIsOpen={setCreateOpen}
menuId="marketplace-create-new"
className="pointer-events-auto"
trigger={
<Ariakit.MenuButton
render={

View file

@ -1,9 +1,9 @@
import { memo, useState } from 'react';
import { BadgeCheck, Check, Settings } from 'lucide-react';
import type { AgentItem } from './items/types';
import type { TranslationKeys } from '~/hooks/useLocalize';
import type { AgentItem } from './items/types';
import { hasConfigurableSettings } from './items/configurable';
import { getIconForItem } from './items/icons';
import { pluginNeedsAuth } from './items/auth';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -33,16 +33,6 @@ const KIND_LABEL_KEYS: Record<AgentItem['kind'], TranslationKeys> = {
action: 'com_ui_tools_kind_actions',
};
function hasConfigurableSettings(item: AgentItem): boolean {
if (item.kind === 'builtin') {
return item.id === 'artifacts' || item.id === 'file_search' || item.id === 'context';
}
if (item.kind === 'tool') {
return pluginNeedsAuth(item.plugin);
}
return false;
}
interface ItemIconProps {
item: AgentItem;
size: 'sm' | 'md';
@ -141,9 +131,9 @@ function ToolCardImpl({ item, selected, onToggle, onConfigure }: ToolCardProps)
</div>
</div>
{description ? (
<p className="line-clamp-2 text-xs leading-relaxed text-text-secondary">{description}</p>
<p className="line-clamp-3 text-xs leading-snug text-text-secondary">{description}</p>
) : (
<p className="line-clamp-2 text-xs italic text-text-tertiary">
<p className="line-clamp-3 text-xs italic leading-snug text-text-tertiary">
{isNative ? localize('com_ui_tools_native_short') : kindLabel}
</p>
)}
@ -169,8 +159,8 @@ function ToolCardImpl({ item, selected, onToggle, onConfigure }: ToolCardProps)
}}
aria-label={localize('com_ui_tools_configure')}
className={cn(
'absolute bottom-2 right-2 flex size-7 items-center justify-center rounded-lg text-text-tertiary',
'opacity-0 transition-opacity duration-150 hover:bg-surface-tertiary hover:text-text-primary',
'absolute bottom-2 right-2 flex size-7 items-center justify-center rounded-lg text-text-secondary',
'opacity-0 transition duration-150 hover:bg-surface-hover hover:text-text-primary',
'group-focus-within:opacity-100 group-hover:opacity-100',
'focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring-primary',
)}

View file

@ -1,7 +1,8 @@
import { memo, useState } from 'react';
import { Info, X } from 'lucide-react';
import type { AgentItem } from './items/types';
import { Info, Settings, X } from 'lucide-react';
import type { TranslationKeys } from '~/hooks/useLocalize';
import type { AgentItem } from './items/types';
import { hasConfigurableSettings } from './items/configurable';
import { getIconForItem } from './items/icons';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -53,6 +54,8 @@ function ToolRowImpl({ item, onInfo, onRemove }: Props) {
const localize = useLocalize();
const suffix = getSuffix(item);
const displayName = item.kind === 'builtin' ? localize(item.name as TranslationKeys) : item.name;
const configurable = hasConfigurableSettings(item);
const DetailIcon = configurable ? Settings : Info;
return (
<div className="group relative flex w-full items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-surface-secondary">
@ -60,7 +63,7 @@ function ToolRowImpl({ item, onInfo, onRemove }: Props) {
<RowIcon item={item} />
<span className="flex min-w-0 items-center gap-1 truncate text-sm text-text-primary">
<span className="truncate font-medium">{displayName}</span>
{suffix && <span className="text-text-tertiary">{suffix}</span>}
{suffix && <span className="text-text-secondary">{suffix}</span>}
</span>
</div>
{item.status === 'needs_setup' && (
@ -78,14 +81,16 @@ function ToolRowImpl({ item, onInfo, onRemove }: Props) {
<button
type="button"
onClick={() => onInfo(item)}
aria-label={localize('com_ui_tools_info')}
aria-label={
configurable ? localize('com_ui_tools_configure') : localize('com_ui_tools_info')
}
className={cn(
'flex size-6 items-center justify-center rounded-md text-text-secondary',
'hover:bg-surface-hover hover:text-text-primary',
'focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring-primary',
)}
>
<Info className="size-3.5" aria-hidden="true" />
<DetailIcon className="size-3.5" aria-hidden="true" />
</button>
<button
type="button"

View file

@ -1,6 +1,7 @@
import { useState, useMemo, useCallback } from 'react';
import { Search } from 'lucide-react';
import { useFormContext, useWatch } from 'react-hook-form';
import { Constants, PermissionTypes, Permissions } from 'librechat-data-provider';
import {
Input,
OGDialog,
@ -8,19 +9,19 @@ import {
OGDialogContent,
OGDialogDescription,
} from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { AgentItem, AgentItemKind, ItemFilter } from './items/types';
import type { TranslationKeys } from '~/hooks/useLocalize';
import type { AgentForm } from '~/common';
import { useLocalize, useHasAccess } from '~/hooks';
import { useBuiltinAuthMap, useUninstallToolCredentials } from './hooks';
import AddMcpServerDialog from './ItemDialog/AddMcpServerDialog';
import { deriveSelectedItems, itemKey } from './items/selectors';
import { computeToggleAction } from './items/mutations';
import { useGetFavoritesQuery } from '~/data-provider';
import { useAgentPanelContext } from '~/Providers';
import MarketplaceSidebar from './MarketplaceSidebar';
import MarketplaceCatalog from './MarketplaceCatalog';
import { useLocalize, useHasAccess } from '~/hooks';
import { useAgentPanelContext } from '~/Providers';
import ItemDialog from './ItemDialog/ItemDialog';
import AddMcpServerDialog from './ItemDialog/AddMcpServerDialog';
import { computeToggleAction } from './items/mutations';
import { deriveSelectedItems, itemKey } from './items/selectors';
import { applyFilter } from './items/filtering';
import { buildCatalog } from './items/catalog';
@ -49,6 +50,8 @@ export default function ToolsMarketplaceDialog({
permissionType: PermissionTypes.MCP_SERVERS,
permission: Permissions.USE,
});
const builtinAuthMap = useBuiltinAuthMap();
const uninstallToolCredentials = useUninstallToolCredentials();
const { data: favorites } = useGetFavoritesQuery();
@ -109,8 +112,9 @@ export default function ToolsMarketplaceDialog({
skills: [],
actions: agentActions,
permissions: { mcp: hasMcpAccess, skills: false },
builtinAuthMap,
}),
[agentsConfig, regularTools, mcpServersMap, agentActions, hasMcpAccess],
[agentsConfig, regularTools, mcpServersMap, agentActions, hasMcpAccess, builtinAuthMap],
);
const selectedItems = useMemo(
@ -180,18 +184,39 @@ export default function ToolsMarketplaceDialog({
current.filter((t) => t !== patch.id),
{ shouldDirty: true },
);
uninstallToolCredentials(patch.id);
break;
}
case 'mcp-add': {
if (item.kind !== 'mcp') break;
const toolIds = (item.server.tools ?? []).map((t) => t.tool_id);
const current = (getValues('tools') ?? []) as string[];
setValue('tools', Array.from(new Set([...current, ...toolIds])), { shouldDirty: true });
break;
}
case 'mcp-remove': {
if (item.kind !== 'mcp') break;
const toolIds = new Set((item.server.tools ?? []).map((t) => t.tool_id));
const serverToken = `${Constants.mcp_server}${Constants.mcp_delimiter}${item.id}`;
const current = (getValues('tools') ?? []) as string[];
setValue(
'tools',
current.filter((t) => t !== serverToken && !toolIds.has(t)),
{ shouldDirty: true },
);
break;
}
default:
break;
}
},
[getValues, setValue, selectedIds],
[getValues, setValue, selectedIds, uninstallToolCredentials],
);
const handleCardClick = useCallback(
(item: AgentItem) => {
if (item.kind === 'mcp' || item.kind === 'action') {
/** Actions have no simple enable toggle — clicking always opens the editor. */
if (item.kind === 'action') {
setDetailItem(item);
return;
}
@ -199,6 +224,12 @@ export default function ToolsMarketplaceDialog({
setDetailItem(item);
return;
}
/** An MCP server with no exposed tools yet can't be enabled in place open
* its dialog so it can be connected/configured first. */
if (item.kind === 'mcp' && item.toolCount === 0) {
setDetailItem(item);
return;
}
const wasSelected = selectedIds.has(itemKey(item));
if (!wasSelected && item.status === 'needs_setup') {
setDetailItem(item);

View file

@ -2,21 +2,23 @@ import { useState, useMemo, useCallback } from 'react';
import { Plus } from 'lucide-react';
import { useFormContext, useWatch } from 'react-hook-form';
import { Label, OGDialog, OGDialogTemplate, useToastContext } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import { PermissionTypes, Permissions, AgentCapabilities } from 'librechat-data-provider';
import type { TPlugin } from 'librechat-data-provider';
import type { AgentItem } from './items/types';
import ToolRow from './ToolRow';
import ItemDialog from './ItemDialog/ItemDialog';
import SkillsDialog from './SkillsDialog';
import type { AgentForm } from '~/common';
import { useListSkillsQuery, useDeleteAgentAction } from '~/data-provider';
import { useBuiltinAuthMap, useUninstallToolCredentials } from './hooks';
import { useRemoveMCPTool, useVisibleTools } from '~/hooks/MCP';
import ToolsMarketplaceDialog from './ToolsMarketplaceDialog';
import { buildCatalog } from './items/catalog';
import { deriveSelectedItems } from './items/selectors';
import { computeToggleAction } from './items/mutations';
import { useAgentPanelContext } from '~/Providers';
import { useListSkillsQuery, useDeleteAgentAction } from '~/data-provider';
import { useRemoveMCPTool } from '~/hooks/MCP';
import { useLocalize, useHasAccess } from '~/hooks';
import { useAgentPanelContext } from '~/Providers';
import ItemDialog from './ItemDialog/ItemDialog';
import { buildCatalog } from './items/catalog';
import { isEphemeralAgent } from '~/common';
import SkillsDialog from './SkillsDialog';
import ToolRow from './ToolRow';
interface Props {
agentId: string;
@ -56,7 +58,14 @@ export default function ToolsSection({ agentId }: Props) {
permissionType: PermissionTypes.SKILLS,
permission: Permissions.USE,
});
const { data: skillsData } = useListSkillsQuery({ limit: 100 }, { enabled: hasSkillsAccess });
const skillsEnabled = useMemo(
() => agentsConfig?.capabilities?.includes(AgentCapabilities.skills) ?? false,
[agentsConfig],
);
const showSkills = hasSkillsAccess && skillsEnabled;
const { data: skillsData } = useListSkillsQuery({ limit: 100 }, { enabled: showSkills });
const builtinAuthMap = useBuiltinAuthMap();
const uninstallToolCredentials = useUninstallToolCredentials();
const tools = (useWatch({ control, name: 'tools' }) ?? []) as string[];
const skills = (useWatch({ control, name: 'skills' }) ?? []) as string[];
@ -83,7 +92,8 @@ export default function ToolsSection({ agentId }: Props) {
mcpServersMap: mcpServersMap ?? new Map(),
skills: skillsData?.skills ?? [],
actions: agentActions,
permissions: { mcp: hasMcpAccess, skills: hasSkillsAccess },
permissions: { mcp: hasMcpAccess, skills: showSkills },
builtinAuthMap,
}),
[
agentsConfig,
@ -92,7 +102,8 @@ export default function ToolsSection({ agentId }: Props) {
skillsData,
agentActions,
hasMcpAccess,
hasSkillsAccess,
showSkills,
builtinAuthMap,
],
);
@ -146,6 +157,7 @@ export default function ToolsSection({ agentId }: Props) {
current.filter((t) => t !== patch.id),
{ shouldDirty: true },
);
uninstallToolCredentials(patch.id);
break;
}
case 'skill-remove': {
@ -167,7 +179,7 @@ export default function ToolsSection({ agentId }: Props) {
setOpen(true);
}
},
[getValues, setValue, removeMCPTool],
[getValues, setValue, removeMCPTool, uninstallToolCredentials],
);
const confirmActionRemoval = useCallback(() => {
@ -186,7 +198,56 @@ export default function ToolsSection({ agentId }: Props) {
setPendingActionRemoval(null);
}, [pendingActionRemoval, agentId, deleteAgentAction, showToast, localize]);
const toolItems = useMemo(() => selected.filter((item) => item.kind !== 'skill'), [selected]);
const { mcpServerNames: attachedMcpServers } = useVisibleTools(
tools,
regularTools ?? undefined,
mcpServersMap ?? new Map(),
);
/** MCP servers still referenced by the agent's tools but absent from the available
* servers map (removed from config, or a legacy server-only token). The catalog is
* built from available servers, so these would otherwise be invisible and
* unremovable surface them as removable "needs setup" rows, mirroring the old
* UnconfiguredMCPTool. */
const orphanedMcpItems = useMemo<AgentItem[]>(
() =>
attachedMcpServers
.filter((name) => mcpServersMap?.has(name) !== true)
.map((name) => ({
kind: 'mcp',
id: name,
name,
description: '',
iconKey: 'mcp',
status: 'needs_setup',
toolCount: 0,
server: {
serverName: name,
tools: [],
isConfigured: false,
isConnected: false,
metadata: { name, pluginKey: name, description: '' } as TPlugin,
},
})),
[attachedMcpServers, mcpServersMap],
);
/** MCP rows show how many of the server's tools are enabled for this agent, not
* the total the server exposes, so the count reflects what the agent can use. */
const toolItems = useMemo(() => {
const enabled = new Set(tools);
const withCounts = selected
.filter((item) => item.kind !== 'skill')
.map((item) =>
item.kind === 'mcp'
? {
...item,
toolCount: (item.server.tools ?? []).filter((t) => enabled.has(t.tool_id)).length,
}
: item,
);
return [...withCounts, ...orphanedMcpItems];
}, [selected, orphanedMcpItems, tools]);
const skillItems = useMemo(() => selected.filter((item) => item.kind === 'skill'), [selected]);
return (
@ -201,7 +262,7 @@ export default function ToolsSection({ agentId }: Props) {
onInfo={setDialogItem}
onRemove={handleQuickRemove}
/>
{hasSkillsAccess && (
{showSkills && (
<SelectedSection
title={localize('com_ui_skills')}
addLabel={localize('com_ui_add_skills')}

View file

@ -57,6 +57,11 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => jest.fn(),
}));
jest.mock('../hooks', () => ({
useBuiltinAuthMap: () => new Map(),
useUninstallToolCredentials: () => jest.fn(),
}));
jest.mock('@librechat/client', () => {
const React = jest.requireActual('react');
const passthrough =

View file

@ -19,7 +19,7 @@ jest.mock('~/hooks', () => ({
jest.mock('~/Providers', () => ({
useAgentPanelContext: () => ({
agentsConfig: { capabilities: [] },
agentsConfig: { capabilities: ['skills'] },
regularTools: [],
mcpServersMap: new Map(),
actions: [],
@ -33,6 +33,12 @@ jest.mock('~/data-provider', () => ({
jest.mock('~/hooks/MCP', () => ({
useRemoveMCPTool: () => ({ removeTool: jest.fn() }),
useVisibleTools: () => ({ toolIds: [], mcpServerNames: [] }),
}));
jest.mock('../hooks', () => ({
useBuiltinAuthMap: () => new Map(),
useUninstallToolCredentials: () => jest.fn(),
}));
jest.mock('@librechat/client', () => ({

View file

@ -0,0 +1,53 @@
import { useMemo, useCallback } from 'react';
import { useToastContext } from '@librechat/client';
import { Tools, AuthType, AgentCapabilities } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import { useVerifyAgentToolAuth } from '~/data-provider';
import { useLocalize } from '~/hooks';
/**
* Maps builtin capability ids to whether they still need setup (USER_PROVIDED auth
* not yet satisfied). Threaded into `buildCatalog` as `builtinAuthMap` so the
* marketplace marks the capability `needs_setup` and routes its toggle to the
* config dialog instead of silently enabling it without credentials. Only
* `web_search` currently carries a user-provided key with an in-dialog entry point.
*/
export function useBuiltinAuthMap(): Map<string, boolean> {
const { data } = useVerifyAgentToolAuth({ toolId: Tools.web_search }, { retry: 1 });
return useMemo(() => {
const map = new Map<string, boolean>();
const isUserProvided =
data?.authTypes?.some(([, authType]) => authType === AuthType.USER_PROVIDED) ?? false;
if (isUserProvided && data?.authenticated !== true) {
map.set(AgentCapabilities.web_search, true);
}
return map;
}, [data]);
}
/**
* Returns a callback that revokes a tool's stored user credentials when it is
* removed from an agent, mirroring the legacy `AgentTool` removal. Without it,
* removing a tool only drops it from the form and leaves the saved credentials
* orphaned server-side. Fired unconditionally a no-op for tools without creds.
*/
export function useUninstallToolCredentials(): (toolId: string) => void {
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
return useCallback(
(toolId: string) => {
if (!toolId) {
return;
}
updateUserPlugins.mutate(
{ pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true },
{
onError: () =>
showToast({ message: localize('com_ui_delete_tool_error'), status: 'error' }),
},
);
},
[updateUserPlugins, showToast, localize],
);
}

View file

@ -1,7 +1,7 @@
import { AgentCapabilities } from 'librechat-data-provider';
import type { TPlugin, TSkillSummary, Action } from 'librechat-data-provider';
import type { MCPServerInfo } from '~/common';
import type { AgentItem, SkillItem, BuiltinId } from './types';
import type { MCPServerInfo } from '~/common';
import { pluginNeedsAuth } from './auth';
/** Maps skill summaries to catalog items, flagging the current user's own skills. */
@ -65,12 +65,6 @@ const BUILTIN_DEFINITIONS: BuiltinDef[] = [
nameKey: 'com_ui_artifacts',
descriptionKey: 'com_ui_artifacts_subtext',
},
{
id: AgentCapabilities.context,
iconKey: 'context',
nameKey: 'com_agents_file_context_label',
descriptionKey: 'com_agents_file_context_description',
},
{
id: AgentCapabilities.file_search,
iconKey: 'file_search',
@ -118,6 +112,7 @@ export function buildCatalog(inputs: BuildCatalogInputs): AgentItem[] {
iconKey: 'mcp',
server,
toolCount: server.tools?.length ?? 0,
status: server.isConfigured === false ? 'needs_setup' : undefined,
});
}
}

View file

@ -0,0 +1,24 @@
import type { AgentItem } from './types';
import { pluginNeedsAuth } from './auth';
/**
* Whether an item's detail dialog exposes configurable controls (credentials,
* tool selection, server/action settings) beyond a plain description. Drives the
* Settings-vs-Info affordance: a configurable item gets a cog button, an
* informational one gets an info icon.
*/
export function hasConfigurableSettings(item: AgentItem): boolean {
switch (item.kind) {
case 'builtin':
return item.id === 'artifacts' || item.id === 'file_search' || item.id === 'context';
case 'tool':
return pluginNeedsAuth(item.plugin);
case 'mcp':
case 'action':
return true;
case 'skill':
return false;
default:
return false;
}
}

View file

@ -11,7 +11,11 @@ function DropzoneContent({ label, hint }: { label: string; hint?: string }) {
</span>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-text-primary">{label}</span>
{hint ? <span className="text-xs font-normal text-text-tertiary">{hint}</span> : null}
{hint ? (
<span className="text-xs font-normal text-text-secondary transition-colors group-hover:text-text-primary">
{hint}
</span>
) : null}
</span>
</span>
);

View file

@ -58,6 +58,7 @@ jest.mock('@ariakit/react', () => ({
jest.mock('@librechat/client', () => ({
SharePointIcon: () => <span />,
DropdownPopup: () => null,
TooltipAnchor: ({ render }: { render: React.ReactElement }) => render,
CircleHelpIcon: () => <span />,
HoverCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
HoverCardPortal: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,

View file

@ -0,0 +1,94 @@
import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render, screen } from '@testing-library/react';
import type { AgentToolType } from 'librechat-data-provider';
import MCPToolItem from '../MCPToolItem';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('@librechat/client', () => ({
TooltipAnchor: ({ render }: { render: React.ReactElement }) => render,
}));
const tool = {
tool_id: 'mcp:srv:execute',
metadata: { name: 'execute', description: 'Runs JavaScript code against the API.' },
} as unknown as AgentToolType;
function setup(overrides: Partial<React.ComponentProps<typeof MCPToolItem>> = {}) {
const props = {
tool,
isSelected: false,
isDeferred: false,
isProgrammatic: false,
deferredToolsEnabled: false,
programmaticToolsEnabled: false,
onToggleSelect: jest.fn(),
onToggleDefer: jest.fn(),
onToggleProgrammatic: jest.fn(),
...overrides,
};
render(<MCPToolItem {...props} />);
return props;
}
describe('MCPToolItem', () => {
test('clicking the row (not just a checkbox) toggles selection', () => {
const props = setup();
fireEvent.click(screen.getByRole('button', { name: 'execute' }));
expect(props.onToggleSelect).toHaveBeenCalledTimes(1);
});
test('reflects selection via aria-pressed', () => {
setup({ isSelected: true });
expect(screen.getByRole('button', { name: 'execute' })).toHaveAttribute('aria-pressed', 'true');
});
test('the info button toggles the description panel via aria-expanded', () => {
setup();
const infoButton = screen.getByRole('button', { name: 'com_ui_tools_info' });
expect(infoButton).toHaveAttribute('aria-expanded', 'false');
// The description is always rendered (the panel animates open/closed), so it stays queryable.
expect(screen.getByText('Runs JavaScript code against the API.')).toBeInTheDocument();
fireEvent.click(infoButton);
expect(infoButton).toHaveAttribute('aria-expanded', 'true');
});
test('detail button is an info affordance when no options are enabled', () => {
setup();
expect(screen.getByRole('button', { name: 'com_ui_tools_info' })).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'com_ui_mcp_tool_options' }),
).not.toBeInTheDocument();
});
test('detail button stays an info affordance (never a cog) when options exist', () => {
setup({ deferredToolsEnabled: true, programmaticToolsEnabled: true });
expect(screen.getByRole('button', { name: 'com_ui_tools_info' })).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'com_ui_mcp_tool_options' }),
).not.toBeInTheDocument();
});
test('defer loading is an inline button (left of the cog) that toggles defer', () => {
const props = setup({ deferredToolsEnabled: true });
const deferButton = screen.getByRole('button', { name: 'com_ui_mcp_defer_loading' });
fireEvent.click(deferButton);
expect(props.onToggleDefer).toHaveBeenCalledTimes(1);
});
test('defer button is absent when deferred tools are disabled', () => {
setup();
expect(
screen.queryByRole('button', { name: 'com_ui_mcp_defer_loading' }),
).not.toBeInTheDocument();
});
test('programmatic is an inline button rendered only when enabled', () => {
const props = setup({ programmaticToolsEnabled: true });
const programmaticButton = screen.getByRole('button', { name: 'com_ui_mcp_programmatic' });
fireEvent.click(programmaticButton);
expect(props.onToggleProgrammatic).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,65 +1,45 @@
import { useState } from 'react';
import { Copy, CopyCheck } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { Input, Label } from '@librechat/client';
import { AuthTypeEnum } from 'librechat-data-provider';
import { Button, useToastContext } from '@librechat/client';
import CopyButton from '~/components/Messages/Content/CopyButton';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { cn } from '~/utils';
export default function ActionCallback({ action_id }: { action_id?: string }) {
const localize = useLocalize();
const { watch } = useFormContext();
const { showToast } = useToastContext();
const [isCopying, setIsCopying] = useState(false);
const callbackURL = `${window.location.protocol}//${window.location.host}/api/actions/${action_id}/oauth/callback`;
const copyLink = useCopyToClipboard({ text: callbackURL });
const [isCopied, setIsCopied] = useState(false);
if (!action_id) {
return null;
}
const type = watch('type');
if (type !== AuthTypeEnum.OAuth) {
const callbackURL = `${window.location.protocol}//${window.location.host}/api/actions/${action_id}/oauth/callback`;
const copyCallbackURL = useCopyToClipboard({ text: callbackURL });
if (!action_id || watch('type') !== AuthTypeEnum.OAuth) {
return null;
}
return (
<div className="mb-1.5 flex flex-col space-y-2">
<label className="font-semibold">{localize('com_ui_callback_url')}</label>
<div className="relative flex items-center">
<div className="border-token-border-medium bg-token-surface-primary hover:border-token-border-hover flex h-10 w-full rounded-lg border">
<div className="flex-1 overflow-hidden">
<div className="relative w-full">
<input
type="text"
readOnly
value={callbackURL}
className="w-full border-0 bg-transparent px-3 py-2 pr-12 text-sm text-text-secondary-alt focus:outline-none"
style={{ direction: 'ltr' }}
/>
</div>
</div>
<div className="absolute right-0 flex h-full items-center pr-1">
<Button
size="sm"
variant="ghost"
type="button"
onClick={() => {
if (isCopying) {
return;
}
showToast({ message: localize('com_ui_copied_to_clipboard') });
copyLink(setIsCopying);
}}
className={cn('h-8 rounded-md px-2', isCopying ? 'cursor-default' : '')}
aria-label={localize('com_ui_copy_link')}
>
{isCopying ? (
<CopyCheck className="size-4" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
)}
</Button>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-callback-url" className="text-sm font-medium text-text-primary">
{localize('com_ui_callback_url')}
</Label>
<div className="relative">
<Input
id="oauth-callback-url"
type="text"
readOnly
dir="ltr"
value={callbackURL}
aria-label={localize('com_ui_callback_url')}
onFocus={(event) => event.currentTarget.select()}
className="pr-10 text-text-secondary"
/>
<CopyButton
iconOnly
isCopied={isCopied}
onClick={() => copyCallbackURL(setIsCopied)}
label={localize('com_ui_copy_link')}
className="absolute right-1 top-1/2 -translate-y-1/2"
/>
</div>
</div>
);

View file

@ -1,6 +1,6 @@
import { useState } from 'react';
import { useRef, useState, useLayoutEffect } from 'react';
import { motion } from 'framer-motion';
import { useFormContext } from 'react-hook-form';
import * as RadioGroup from '@radix-ui/react-radio-group';
import { ChevronRight, KeyRound, ShieldCheck, ShieldOff } from 'lucide-react';
import {
AuthTypeEnum,
@ -9,21 +9,19 @@ import {
} from 'librechat-data-provider';
import {
Input,
Radio,
Button,
OGDialog,
SecretInput,
OGDialogClose,
OGDialogTitle,
OGDialogHeader,
OGDialogContent,
OGDialogTrigger,
OGDialogDescription,
} from '@librechat/client';
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import type { TranslationKeys } from '~/hooks';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface AuthMethod {
value: AuthTypeEnum;
@ -59,6 +57,13 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
const { watch, setValue, trigger } = useFormContext();
const type = watch('type');
const current = AUTH_METHODS.find((method) => method.value === type) ?? AUTH_METHODS[0];
const authOptions = AUTH_METHODS.filter(
(method) => !(method.value === AuthTypeEnum.OAuth && disableOAuth === true),
).map((method) => ({
value: method.value,
label: localize(method.titleKey),
icon: <method.icon className="size-4" aria-hidden={true} />,
}));
const handleSave = async () => {
const result = await trigger(undefined, { shouldFocus: true });
@ -75,7 +80,7 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
<OGDialogTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-3 rounded-xl border border-border-medium bg-surface-primary px-3 py-2.5 text-left transition-colors hover:border-border-heavy hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
className="group flex w-full items-center gap-3 rounded-xl border border-border-light bg-transparent px-3 py-2.5 text-left transition-colors hover:bg-surface-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
>
<current.icon className="size-5 shrink-0 text-text-secondary" aria-hidden={true} />
<span className="min-w-0 flex-1">
@ -86,84 +91,32 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
{localize(current.descKey)}
</span>
</span>
<ChevronRight
className="size-4 shrink-0 text-text-tertiary transition-transform group-hover:translate-x-0.5"
aria-hidden={true}
/>
<ChevronRight className="text-text-tertiar size-4 shrink-0" aria-hidden={true} />
</button>
</OGDialogTrigger>
</div>
<OGDialogContent className="w-full max-w-lg overflow-hidden bg-surface-primary p-0 text-text-primary">
<OGDialogHeader className="space-y-1 border-b border-border-light px-6 py-4 pr-10">
<OGDialogContent className="w-full max-w-lg bg-surface-primary text-text-primary">
<OGDialogHeader>
<OGDialogTitle className="text-lg font-semibold">
{localize('com_ui_authentication')}
</OGDialogTitle>
<OGDialogDescription>{localize('com_ui_authentication_desc')}</OGDialogDescription>
</OGDialogHeader>
<div className="max-h-[60vh] overflow-y-auto px-6 py-5">
<RadioGroup.Root
<div>
<span id="auth-method-label" className="sr-only">
{localize('com_ui_authentication_type')}
</span>
<Radio
options={authOptions}
value={type}
onValueChange={(value) => setValue('type', value)}
aria-label={localize('com_ui_authentication_type')}
className="flex flex-col gap-2"
>
{AUTH_METHODS.map((method) => {
const selected = type === method.value;
const disabled = method.value === AuthTypeEnum.OAuth && disableOAuth === true;
return (
<RadioGroup.Item
key={method.value}
value={method.value}
disabled={disabled}
className={cn(
'group flex items-center gap-3 rounded-xl border p-3 text-left transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
'disabled:cursor-not-allowed disabled:opacity-50',
selected
? 'border-border-heavy bg-surface-active'
: 'border-border-light hover:border-border-medium hover:bg-surface-hover',
)}
>
<method.icon
className={cn(
'size-5 shrink-0',
selected ? 'text-text-primary' : 'text-text-secondary',
)}
aria-hidden={true}
/>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-text-primary">
{localize(method.titleKey)}
</span>
<span className="block text-xs text-text-secondary">
{localize(method.descKey)}
</span>
</span>
<span
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-full border',
selected ? 'border-text-primary' : 'border-border-heavy',
)}
>
{selected && <span className="size-2 rounded-full bg-text-primary" />}
</span>
</RadioGroup.Item>
);
})}
</RadioGroup.Root>
{type !== AuthTypeEnum.None && (
<div className="mt-5 space-y-4 border-t border-border-light pt-5">
{type === AuthTypeEnum.ServiceHttp ? <ApiKey /> : <OAuth />}
</div>
)}
onChange={(value) => setValue('type', value)}
fullWidth
aria-labelledby="auth-method-label"
/>
<AnimatedAuthFields type={type} />
</div>
<div className="flex justify-end gap-2 border-t border-border-light px-6 py-4">
<OGDialogClose asChild>
<Button variant="outline">{localize('com_ui_cancel')}</Button>
</OGDialogClose>
<Button variant="submit" onClick={handleSave}>
{localize('com_ui_save')}
<div className="flex justify-end">
<Button variant="default" onClick={handleSave}>
{localize('com_ui_done')}
</Button>
</div>
</OGDialogContent>
@ -171,6 +124,49 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
);
}
/**
* Animates the auth fields' height to their measured content height. Animating to
* a measured pixel value (instead of `height: 'auto'`) is what lets the
* ApiKey <-> OAuth swap tween between two different heights `auto` never changes,
* so it would jump. The content swaps instantly; only the container height eases.
*/
function AnimatedAuthFields({ type }: { type: AuthTypeEnum }) {
const innerRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number | 'auto'>(type === AuthTypeEnum.None ? 0 : 'auto');
useLayoutEffect(() => {
const el = innerRef.current;
if (!el) {
return;
}
const measure = () => setHeight(type === AuthTypeEnum.None ? 0 : el.scrollHeight);
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [type]);
let fields: ReactNode = null;
if (type === AuthTypeEnum.ServiceHttp) {
fields = <ApiKey />;
} else if (type === AuthTypeEnum.OAuth) {
fields = <OAuth />;
}
return (
<motion.div
initial={false}
animate={{ height, opacity: type === AuthTypeEnum.None ? 0 : 1 }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
className="overflow-hidden"
>
<div ref={innerRef} className="space-y-4 pt-4">
{fields}
</div>
</motion.div>
);
}
function Field({
htmlFor,
label,
@ -209,28 +205,16 @@ function SegmentedField({
<label id={id} className="block text-xs font-medium text-text-secondary">
{label}
</label>
<RadioGroup.Root
<Radio
options={options.map((option) => ({
value: option.value,
label: localize(option.labelKey),
}))}
value={value}
onValueChange={onValueChange}
onChange={onValueChange}
fullWidth
aria-labelledby={id}
className="inline-flex w-full rounded-lg border border-border-light p-0.5"
>
{options.map((option) => (
<RadioGroup.Item
key={option.value}
value={option.value}
className={cn(
'flex-1 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
value === option.value
? 'bg-surface-active text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary',
)}
>
{localize(option.labelKey)}
</RadioGroup.Item>
))}
</RadioGroup.Root>
/>
</div>
);
}

View file

@ -884,6 +884,8 @@
"com_ui_attach_warn_endpoint": "Non-Assistant files may be ignored without a compatible tool",
"com_ui_attachment": "Attachment",
"com_ui_auth_apikey_desc": "Send a key or token with each request",
"com_ui_auth_format_hint": "This usually starts with \"{{0}}\"",
"com_ui_auth_invalid_url": "Enter a valid URL, including http:// or https://",
"com_ui_auth_none_desc": "No authentication required",
"com_ui_auth_oauth_desc": "Authorize through an OAuth 2.0 provider",
"com_ui_auth_type": "Auth Type",
@ -1101,6 +1103,7 @@
"com_ui_delete_success": "Successfully deleted",
"com_ui_delete_tool": "Delete Tool",
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
"com_ui_delete_tool_error": "Error removing the tool's saved credentials.",
"com_ui_delete_tool_save_reminder": "Tool removed. Save the agent to apply changes.",
"com_ui_deleted": "Deleted",
"com_ui_deleting": "Deleting...",
@ -1308,8 +1311,8 @@
"com_ui_max_file_size": "PNG, JPG or JPEG (max {{0}})",
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
"com_ui_mcp_authenticated_success": "MCP server '{{0}}' authenticated successfully",
"com_ui_mcp_click_to_defer": "Click to defer - tool will be discoverable via search but not loaded until needed",
"com_ui_mcp_click_to_programmatic": "Enable programmatic calling - tool can only be invoked via code execution",
"com_ui_mcp_click_to_defer": "Loads this tool only when the agent needs it instead of keeping it active the whole time. Useful when a server has many tools.",
"com_ui_mcp_click_to_programmatic": "The agent calls this tool by writing code, not as a direct tool call. Best for tools meant to run in code.",
"com_ui_mcp_configure_server": "Configure {{0}}",
"com_ui_mcp_configure_server_description": "Configure custom variables for {{0}}",
"com_ui_mcp_defer": "Defer",
@ -1387,6 +1390,7 @@
"com_ui_message_nav_go_to_user": "Go to user message: {{0}}",
"com_ui_message_nav_next": "Navigate to next message",
"com_ui_message_nav_previous": "Navigate to previous message",
"com_ui_method": "Method",
"com_ui_microphone_unavailable": "Microphone is not available",
"com_ui_min_tags": "Cannot remove more values, a minimum of {{0}} are required.",
"com_ui_minimal": "Minimal",
@ -1469,6 +1473,7 @@
"com_ui_page": "Page",
"com_ui_pagination": "Pagination",
"com_ui_parameters": "Parameters",
"com_ui_path": "Path",
"com_ui_people": "people",
"com_ui_people_picker": "People Picker",
"com_ui_people_picker_allow_view_groups": "Allow viewing groups",
@ -1868,6 +1873,7 @@
"com_ui_token_url": "Token URL",
"com_ui_tokens": "tokens",
"com_ui_tool_collection_prefix": "A collection of tools from",
"com_ui_tool_credentials_saved": "Credentials saved",
"com_ui_tool_failed": "failed",
"com_ui_tool_list_collapse": "Collapse {{serverName}} tool list",
"com_ui_tool_list_expand": "Expand {{serverName}} tool list",

View file

@ -2663,7 +2663,7 @@ html {
}
.tooltip {
z-index: 150;
z-index: 900;
cursor: pointer;
border-radius: 0.275rem;
background-color: var(--surface-primary);

View file

@ -8,7 +8,7 @@ const Input: React.ForwardRefExoticComponent<InputProps & React.RefAttributes<HT
return (
<input
className={cn(
'lc-field flex h-10 w-full rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-sm text-text-primary ring-offset-background placeholder:text-text-secondary focus-visible:border-border-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary disabled:cursor-not-allowed disabled:opacity-50',
'lc-field flex h-10 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm text-text-primary ring-offset-background placeholder:text-text-secondary focus-visible:border-border-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary disabled:cursor-not-allowed disabled:opacity-50',
className ?? '',
)}
ref={ref}

View file

@ -1,4 +1,4 @@
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, memo } from 'react';
import React, { useState, useRef, useLayoutEffect, useCallback, memo } from 'react';
import { useLocalize } from '~/hooks';
interface Option {
@ -29,6 +29,7 @@ const Radio: React.NamedExoticComponent<RadioProps> = memo(function Radio({
'aria-labelledby': ariaLabelledBy,
}: RadioProps) {
const localize = useLocalize();
const containerRef = useRef<HTMLDivElement | null>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [isMounted, setIsMounted] = useState(false);
const [currentValue, setCurrentValue] = useState<string>(value ?? '');
@ -41,36 +42,33 @@ const Radio: React.NamedExoticComponent<RadioProps> = memo(function Radio({
const updateBackgroundStyle = useCallback(() => {
const selectedIndex = options.findIndex((opt) => opt.value === currentValue);
if (selectedIndex >= 0 && buttonRefs.current[selectedIndex]) {
const selectedButton = buttonRefs.current[selectedIndex];
const container = selectedButton?.parentElement;
if (selectedButton && container) {
const containerRect = container.getBoundingClientRect();
const buttonRect = selectedButton.getBoundingClientRect();
const offsetLeft = buttonRect.left - containerRect.left;
setBackgroundStyle({
width: `${buttonRect.width}px`,
transform: `translateX(${offsetLeft}px)`,
});
}
const selectedButton = buttonRefs.current[selectedIndex];
if (selectedIndex < 0 || !selectedButton) {
return;
}
// offsetWidth/offsetLeft are layout metrics: unlike getBoundingClientRect they
// are not distorted by the dialog's open transform (scale), and they resolve to
// whole pixels, so the indicator matches its segment and keeps crisp borders.
setBackgroundStyle({
width: `${selectedButton.offsetWidth}px`,
transform: `translateX(${selectedButton.offsetLeft}px)`,
});
}, [currentValue, options]);
// Mark as mounted after dialog animations settle
// Timeout ensures we wait for CSS transitions to complete
useEffect(() => {
const timeout = setTimeout(() => {
setIsMounted(true);
}, 50);
return () => clearTimeout(timeout);
}, []);
// Measure before paint and re-measure on any later layout change (the dialog's
// open animation settling, a window resize). A fixed timeout previously raced
// the dialog transition and left the indicator mis-sized.
useLayoutEffect(() => {
if (isMounted) {
updateBackgroundStyle();
const container = containerRef.current;
if (!container) {
return;
}
}, [isMounted, updateBackgroundStyle]);
updateBackgroundStyle();
setIsMounted(true);
const observer = new ResizeObserver(() => updateBackgroundStyle());
observer.observe(container);
return () => observer.disconnect();
}, [updateBackgroundStyle]);
useLayoutEffect(() => {
if (value !== undefined) {
@ -96,7 +94,8 @@ const Radio: React.NamedExoticComponent<RadioProps> = memo(function Radio({
return (
<div
className={`relative ${fullWidth ? 'flex' : 'inline-flex'} items-center rounded-lg bg-muted ${className}`}
ref={containerRef}
className={`relative ${fullWidth ? 'flex' : 'inline-flex'} items-center rounded-lg bg-muted px-1 ${className}`}
role="radiogroup"
aria-labelledby={ariaLabelledBy}
>

View file

@ -1,5 +1,5 @@
.tooltip {
z-index: 150;
z-index: 900;
cursor: pointer;
pointer-events: auto;
border-radius: 0.275rem;