From 578d990b9cec17dc0e90d53677ab344409c3fd7f Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:13:54 +0530 Subject: [PATCH] [AI] Match ai chat composer to figma (#18874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=93653-368288&t=obTG32NRidXid4lN-0 closes https://discord.com/channels/1130383047699738754/1480990726442582086 --------- Co-authored-by: Félix Malfait Co-authored-by: Félix Malfait --- .../AIChatCreditsExhaustedMessage.tsx | 49 +--- .../ai/components/AIChatEditorSection.tsx | 72 +++-- .../AIChatErrorUnderMessageList.tsx | 14 +- .../components/AgentChatAiSdkStreamEffect.tsx | 3 +- .../modules/ai/constants/DefaultFastModel.ts | 1 - .../modules/ai/constants/DefaultSmartModel.ts | 1 - .../src/modules/ai/hooks/useAgentChat.ts | 6 + .../modules/ai/hooks/useAgentChatModelId.ts | 23 ++ .../src/modules/ai/hooks/useAiModelOptions.ts | 21 +- .../hooks/useWorkspaceAiModelAvailability.ts | 15 +- .../states/agentChatUserSelectedModelState.ts | 8 + .../__tests__/normalizeAiSdkError.test.ts | 60 ++++ .../modules/ai/utils/normalizeAiSdkError.ts | 43 +++ .../services/__tests__/apollo.factory.test.ts | 10 +- ...olumnDefinitionsFromObjectMetadata.test.ts | 10 +- .../DateTimeSettingsDateFormatSelect.tsx | 24 +- .../DateTimeSettingsTimeFormatSelect.tsx | 15 +- .../DateTimeSettingsTimeZoneSelect.tsx | 18 +- .../components/NumberFormatSelect.tsx | 21 +- .../modules/ui/input/components/Select.tsx | 60 +++- .../ui/input/components/SelectControl.tsx | 8 +- .../ai/components/SettingsAIModelsTab.tsx | 69 ++--- ...DateTimeSettingsCalendarStartDaySelect.tsx | 42 ++- .../src/testing/mock-data/users.ts | 10 +- ...0-migrate-model-ids-to-composite-format.ts | 6 +- ...t-manifest-to-universal-flat-agent.util.ts | 4 +- .../services/client-config.service.ts | 36 +-- .../workspace/workspace.entity.ts | 18 +- .../ai/ai-agent/entities/agent.entity.ts | 8 +- .../controllers/agent-chat.controller.ts | 6 +- .../services/agent-chat-streaming.service.ts | 3 + .../services/chat-execution.service.ts | 11 +- .../ai/ai-models/ai-providers.json | 270 +++++++++--------- .../constants/ai-models-types.const.spec.ts | 16 +- .../services/ai-model-registry.service.ts | 17 +- .../types/default-fast-model.const.ts | 1 - .../types/default-smart-model.const.ts | 1 - .../ai/ai-models/types/model-family.enum.ts | 10 +- .../utils/is-default-model-sentinel.util.ts | 5 - .../ai-models/utils/is-model-allowed.util.ts | 4 +- ...reate-standard-flat-agent-metadata.util.ts | 4 +- ...rsion-step-operations.workspace-service.ts | 4 +- .../ai-agent/ai-agent.workflow-action.ts | 4 +- .../src/constants/AutoSelectFastModelId.ts | 1 + .../src/constants/AutoSelectSmartModelId.ts | 1 + packages/twenty-shared/src/constants/index.ts | 2 + packages/twenty-shared/src/utils/index.ts | 1 + .../src/utils/isAutoSelectModelId.ts | 6 + .../button/components/RoundedIconButton.tsx | 4 + .../twenty-ui/src/input/types/SelectOption.ts | 1 + 50 files changed, 641 insertions(+), 406 deletions(-) delete mode 100644 packages/twenty-front/src/modules/ai/constants/DefaultFastModel.ts delete mode 100644 packages/twenty-front/src/modules/ai/constants/DefaultSmartModel.ts create mode 100644 packages/twenty-front/src/modules/ai/hooks/useAgentChatModelId.ts create mode 100644 packages/twenty-front/src/modules/ai/states/agentChatUserSelectedModelState.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/__tests__/normalizeAiSdkError.test.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/normalizeAiSdkError.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/default-fast-model.const.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/default-smart-model.const.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/is-default-model-sentinel.util.ts create mode 100644 packages/twenty-shared/src/constants/AutoSelectFastModelId.ts create mode 100644 packages/twenty-shared/src/constants/AutoSelectSmartModelId.ts create mode 100644 packages/twenty-shared/src/utils/isAutoSelectModelId.ts diff --git a/packages/twenty-front/src/modules/ai/components/AIChatCreditsExhaustedMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatCreditsExhaustedMessage.tsx index fa023b328a..c37e771fc2 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatCreditsExhaustedMessage.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatCreditsExhaustedMessage.tsx @@ -1,67 +1,28 @@ import { AIChatBanner } from '@/ai/components/AIChatBanner'; -import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod'; -import { useRedirect } from '@/domain-manager/hooks/useRedirect'; import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap'; import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus'; import { t } from '@lingui/core/macro'; -import { useState } from 'react'; import { SettingsPath } from 'twenty-shared/types'; -import { getSettingsPath, isDefined } from 'twenty-shared/utils'; import { IconSparkles } from 'twenty-ui/display'; -import { useQuery } from '@apollo/client/react'; import { PermissionFlagType, SubscriptionStatus, - BillingPortalSessionDocument, } from '~/generated-metadata/graphql'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; export const AIChatCreditsExhaustedMessage = () => { - const { redirect } = useRedirect(); + const navigateSettings = useNavigateSettings(); const subscriptionStatus = useSubscriptionStatus(); - const { endTrialPeriod, isLoading: isEndingTrial } = - useEndSubscriptionTrialPeriod(); - const [isProcessing, setIsProcessing] = useState(false); const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing; const { [PermissionFlagType.WORKSPACE]: hasPermissionToManageBilling } = usePermissionFlagMap(); - const { data: billingPortalData, loading: isBillingPortalLoading } = useQuery( - BillingPortalSessionDocument, - { - variables: { - returnUrlPath: getSettingsPath(SettingsPath.Billing), - }, - }, - ); - - const openBillingPortal = () => { - if ( - isDefined(billingPortalData) && - isDefined(billingPortalData.billingPortalSession.url) - ) { - redirect(billingPortalData.billingPortalSession.url); - } + const handleUpgradeClick = () => { + navigateSettings(SettingsPath.Billing); }; - const handleUpgradeClick = async () => { - if (!isTrialing) { - openBillingPortal(); - return; - } - - setIsProcessing(true); - const result = await endTrialPeriod(); - setIsProcessing(false); - - if (!result.success) { - openBillingPortal(); - } - }; - - const isLoading = isEndingTrial || isBillingPortalLoading || isProcessing; - const message = hasPermissionToManageBilling ? isTrialing ? t`Free trial credits exhausted. Subscribe now to continue using AI features.` @@ -79,8 +40,6 @@ export const AIChatCreditsExhaustedMessage = () => { buttonOnClick={ hasPermissionToManageBilling ? handleUpgradeClick : undefined } - isButtonDisabled={isLoading} - isButtonLoading={isLoading} /> ); }; diff --git a/packages/twenty-front/src/modules/ai/components/AIChatEditorSection.tsx b/packages/twenty-front/src/modules/ai/components/AIChatEditorSection.tsx index c04638e3bd..ff7eff864b 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatEditorSection.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatEditorSection.tsx @@ -1,6 +1,5 @@ import { styled } from '@linaria/react'; import { EditorContent } from '@tiptap/react'; -import { LightButton } from 'twenty-ui/input'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState'; @@ -12,10 +11,17 @@ import { AIChatEditorFocusEffect } from '@/ai/components/internal/AIChatEditorFo import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader'; import { SendMessageButton } from '@/ai/components/internal/SendMessageButton'; import { useAIChatEditor } from '@/ai/hooks/useAIChatEditor'; -import { useAiModelLabel } from '@/ai/hooks/useAiModelOptions'; +import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId'; +import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability'; +import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { aiModelsState } from '@/client-config/states/aiModelsState'; +import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon'; +import { Select } from '@/ui/input/components/Select'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { t } from '@lingui/core/macro'; const StyledInputArea = styled.div<{ isMobile: boolean }>` align-items: flex-end; @@ -102,24 +108,48 @@ const StyledRightButtonsContainer = styled.div` gap: ${themeCssVariables.spacing[1]}; `; -const StyledReadOnlyModelButtonContainer = styled.div` - > * { - cursor: default; - - &:hover, - &:active { - background: transparent; - } - } -`; - export const AIChatEditorSection = () => { const isMobile = useIsMobile(); const currentWorkspace = useAtomStateValue(currentWorkspaceState); - const smartModelLabel = useAiModelLabel(currentWorkspace?.smartModel, false); + const aiModels = useAtomStateValue(aiModelsState); + const { enabledModels } = useWorkspaceAiModelAvailability(); + const setAgentChatUserSelectedModel = useSetAtomState( + agentChatUserSelectedModelState, + ); + const { selectedModelId } = useAgentChatModelId(); const { editor, handleSendAndClear } = useAIChatEditor(); + const workspaceSmartModel = aiModels.find( + (model) => model.modelId === currentWorkspace?.smartModel, + ); + + const resolvedDefaultModelId = enabledModels.find( + (model) => + model.label === workspaceSmartModel?.label && + model.providerName === workspaceSmartModel?.providerName, + )?.modelId; + + const defaultPinnedOption = workspaceSmartModel + ? { + value: null as string | null, + label: workspaceSmartModel.label, + Icon: getModelIcon( + workspaceSmartModel.modelFamily, + workspaceSmartModel.providerName, + ), + contextualText: t`default`, + } + : undefined; + + const smartModelOptions = enabledModels + .filter((model) => model.modelId !== resolvedDefaultModelId) + .map((model) => ({ + value: model.modelId as string | null, + label: model.label, + Icon: getModelIcon(model.modelFamily, model.providerName), + })); + return ( <> @@ -139,9 +169,17 @@ export const AIChatEditorSection = () => { - - - + []), - ]} + pinnedOption={{ + label: t`System settings`, + value: 'system', + contextualText: systemTimeZoneOption?.label, + }} + options={AVAILABLE_TIMEZONE_OPTIONS as SelectOption[]} onChange={onChange} withSearchInput /> diff --git a/packages/twenty-front/src/modules/settings/experience/components/NumberFormatSelect.tsx b/packages/twenty-front/src/modules/settings/experience/components/NumberFormatSelect.tsx index 66257c2924..b5011c8e52 100644 --- a/packages/twenty-front/src/modules/settings/experience/components/NumberFormatSelect.tsx +++ b/packages/twenty-front/src/modules/settings/experience/components/NumberFormatSelect.tsx @@ -46,26 +46,31 @@ export const NumberFormatSelect = ({ dropdownWidthAuto fullWidth value={value} + pinnedOption={{ + label: t`System settings`, + value: NumberFormat.SYSTEM, + contextualText: systemNumberFormatLabel, + }} options={[ { - label: t`System Settings - ${systemNumberFormatLabel}`, - value: NumberFormat.SYSTEM, - }, - { - label: t`Commas and dot - ${commasAndDotExample}`, + label: t`Commas and dot`, value: NumberFormat.COMMAS_AND_DOT, + contextualText: commasAndDotExample, }, { - label: t`Spaces and comma - ${spacesAndCommaExample}`, + label: t`Spaces and comma`, value: NumberFormat.SPACES_AND_COMMA, + contextualText: spacesAndCommaExample, }, { - label: t`Dots and comma - ${dotsAndCommaExample}`, + label: t`Dots and comma`, value: NumberFormat.DOTS_AND_COMMA, + contextualText: dotsAndCommaExample, }, { - label: t`Apostrophe and dot - ${apostropheAndDotExample}`, + label: t`Apostrophe and dot`, value: NumberFormat.APOSTROPHE_AND_DOT, + contextualText: apostropheAndDotExample, }, ]} onChange={onChange} diff --git a/packages/twenty-front/src/modules/ui/input/components/Select.tsx b/packages/twenty-front/src/modules/ui/input/components/Select.tsx index 715da8a1b7..96adf7ed27 100644 --- a/packages/twenty-front/src/modules/ui/input/components/Select.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/Select.tsx @@ -49,9 +49,11 @@ export type SelectProps = { value?: Value; withSearchInput?: boolean; needIconCheck?: boolean; + pinnedOption?: SelectOption; callToActionButton?: CallToActionButton; dropdownOffset?: DropdownOffset; hasRightElement?: boolean; + showContextualTextInControl?: boolean; }; const StyledContainer = styled.div<{ fullWidth?: boolean }>` @@ -88,15 +90,21 @@ export const Select = ({ value, withSearchInput, needIconCheck, + pinnedOption, callToActionButton, dropdownOffset, hasRightElement, + showContextualTextInControl = true, }: SelectProps) => { const selectContainerRef = useRef(null); const [searchInputValue, setSearchInputValue] = useState(''); const selectedOption = useMemo(() => { + if (isDefined(pinnedOption) && pinnedOption.value === value) { + return pinnedOption; + } + const fromMatchingOption = options.find( ({ value: optionValue }) => optionValue === value, ); @@ -114,7 +122,7 @@ export const Select = ({ } return null; - }, [emptyOption, options, value]); + }, [emptyOption, options, pinnedOption, value]); const filteredOptions = useMemo( () => @@ -129,6 +137,7 @@ export const Select = ({ const isDisabled = disabledFromProps || (options.length <= 1 && + !isDefined(pinnedOption) && !isDefined(callToActionButton) && (!isDefined(emptyOption) || selectedOption !== emptyOption)); @@ -148,13 +157,26 @@ export const Select = ({ const { setSelectedItemId } = useSelectableList(dropdownId); + const controlSelectedOption = useMemo(() => { + if (!isDefined(selectedOption) || showContextualTextInControl) { + return selectedOption; + } + + const { contextualText: _, ...rest } = selectedOption; + + return rest; + }, [selectedOption, showContextualTextInControl]); + const handleDropdownOpen = () => { - if (isDefined(selectedOption) && !isNonEmptyString(searchInputValue)) { - setSelectedItemId(selectedOption.label); + if ( + isDefined(controlSelectedOption) && + !isNonEmptyString(searchInputValue) + ) { + setSelectedItemId(controlSelectedOption.label); } }; - if (!isDefined(selectedOption)) { + if (!isDefined(controlSelectedOption)) { return <>; } @@ -169,7 +191,7 @@ export const Select = ({ {isNonEmptyString(label) && {label}} {isDisabled ? ( ({ onOpen={handleDropdownOpen} clickableComponent={ ({ {withSearchInput === true && isNonEmptyArray(filteredOptions) && ( )} + {isDefined(pinnedOption) && ( + + { + onChange?.(pinnedOption.value); + onBlur?.(); + closeDropdown(dropdownId); + }} + /> + + )} + {isDefined(pinnedOption) && isNonEmptyArray(filteredOptions) && ( + + )} {isNonEmptyArray(filteredOptions) && ( ({ { diff --git a/packages/twenty-front/src/modules/ui/input/components/SelectControl.tsx b/packages/twenty-front/src/modules/ui/input/components/SelectControl.tsx index 2fbab01ed6..992f52dd36 100644 --- a/packages/twenty-front/src/modules/ui/input/components/SelectControl.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/SelectControl.tsx @@ -94,7 +94,13 @@ export const SelectControl = ({ stroke={theme.icon.stroke.sm} /> ) : null} - + diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAIModelsTab.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAIModelsTab.tsx index f79ff8e176..9bb03231a2 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAIModelsTab.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAIModelsTab.tsx @@ -1,8 +1,11 @@ import { useState } from 'react'; import { styled } from '@linaria/react'; -import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel'; -import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel'; +import { + AUTO_SELECT_FAST_MODEL_ID, + AUTO_SELECT_SMART_MODEL_ID, +} from 'twenty-shared/constants'; + import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { aiModelsState } from '@/client-config/states/aiModelsState'; @@ -51,46 +54,44 @@ export const SettingsAIModelsTab = () => { const currentSmartModel = currentWorkspace?.smartModel; const currentFastModel = currentWorkspace?.fastModel; - const buildVirtualModelOption = (virtualModelId: string) => { - const virtualModel = aiModels.find( - (model) => model.modelId === virtualModelId, + const buildPinnedOption = (autoSelectModelId: string) => { + const autoSelectEntry = aiModels.find( + (model) => model.modelId === autoSelectModelId, ); - return virtualModel - ? { - value: virtualModelId, - label: virtualModel.label, - Icon: IconTwentyStar, - } - : null; - }; - - const smartAutoOption = buildVirtualModelOption(DEFAULT_SMART_MODEL); - const fastAutoOption = buildVirtualModelOption(DEFAULT_FAST_MODEL); - - const modelOptions = enabledModels.map((model) => { - const residencyFlag = model.dataResidency - ? ` ${getDataResidencyDisplay(model.dataResidency)}` - : ''; + if (!autoSelectEntry) { + return undefined; + } return { - value: model.modelId, - label: `${model.label}${residencyFlag}`, - Icon: getModelIcon(model.modelFamily, model.providerName), + value: autoSelectModelId, + label: autoSelectEntry.label, + Icon: getModelIcon( + autoSelectEntry.modelFamily, + autoSelectEntry.providerName, + ), + contextualText: t`Best`, }; - }); + }; - const smartModelOptions = [...modelOptions]; + const smartPinnedOption = buildPinnedOption(AUTO_SELECT_SMART_MODEL_ID); + const fastPinnedOption = buildPinnedOption(AUTO_SELECT_FAST_MODEL_ID); - if (smartAutoOption !== null) { - smartModelOptions.unshift(smartAutoOption); - } + const buildModelOptions = () => + enabledModels.map((model) => { + const residencyFlag = model.dataResidency + ? ` ${getDataResidencyDisplay(model.dataResidency)}` + : ''; - const fastModelOptions = [...modelOptions]; + return { + value: model.modelId, + label: `${model.label}${residencyFlag}`, + Icon: getModelIcon(model.modelFamily, model.providerName), + }; + }); - if (fastAutoOption !== null) { - fastModelOptions.unshift(fastAutoOption); - } + const smartModelOptions = buildModelOptions(); + const fastModelOptions = buildModelOptions(); const handleModelFieldChange = async ( field: 'smartModel' | 'fastModel', @@ -241,6 +242,7 @@ export const SettingsAIModelsTab = () => { value={currentSmartModel} onChange={(value) => handleModelFieldChange('smartModel', value)} options={smartModelOptions} + pinnedOption={smartPinnedOption} selectSizeVariant="small" dropdownWidth={GenericDropdownContentWidth.ExtraLarge} /> @@ -255,6 +257,7 @@ export const SettingsAIModelsTab = () => { value={currentFastModel} onChange={(value) => handleModelFieldChange('fastModel', value)} options={fastModelOptions} + pinnedOption={fastPinnedOption} selectSizeVariant="small" dropdownWidth={GenericDropdownContentWidth.ExtraLarge} /> diff --git a/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsCalendarStartDaySelect.tsx b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsCalendarStartDaySelect.tsx index 77a3591963..af874e42f8 100644 --- a/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsCalendarStartDaySelect.tsx +++ b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsCalendarStartDaySelect.tsx @@ -2,7 +2,6 @@ import { useMemo } from 'react'; import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay'; import { Select } from '@/ui/input/components/Select'; -import { type DayNameWithIndex } from '@/ui/input/components/internal/date/types/DayNameWithIndex'; import { t } from '@lingui/core/macro'; import { CalendarStartDay } from 'twenty-shared/constants'; import { type SelectOption } from 'twenty-ui/input'; @@ -18,29 +17,21 @@ export const DateTimeSettingsCalendarStartDaySelect = ({ }: DateTimeSettingsCalendarStartDaySelectProps) => { const systemCalendarStartDay = CalendarStartDay[detectCalendarStartDay()]; - const options: SelectOption[] = useMemo(() => { - const systemDayLabel = - systemCalendarStartDay === CalendarStartDay.SUNDAY - ? t`System settings - Sunday` - : systemCalendarStartDay === CalendarStartDay.MONDAY - ? t`System settings - Monday` - : t`System settings - Saturday`; + const systemDayContextualText = + systemCalendarStartDay === CalendarStartDay.SUNDAY + ? t`Sunday` + : systemCalendarStartDay === CalendarStartDay.MONDAY + ? t`Monday` + : t`Saturday`; - const allowedDaysWeek: DayNameWithIndex[] = [ - { - day: systemDayLabel, - index: CalendarStartDay.SYSTEM, - }, - { day: t`Sunday`, index: CalendarStartDay.SUNDAY }, - { day: t`Monday`, index: CalendarStartDay.MONDAY }, - { day: t`Saturday`, index: CalendarStartDay.SATURDAY }, - ]; - - return allowedDaysWeek.map(({ day, index }) => ({ - label: day, - value: index as CalendarStartDay, - })); - }, [systemCalendarStartDay]); + const options: SelectOption[] = useMemo( + () => [ + { label: t`Sunday`, value: CalendarStartDay.SUNDAY }, + { label: t`Monday`, value: CalendarStartDay.MONDAY }, + { label: t`Saturday`, value: CalendarStartDay.SATURDAY }, + ], + [], + ); return (