diff --git a/packages/twenty-front/src/modules/ai/components/AIChatCreditsExhaustedMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatCreditsExhaustedMessage.tsx index fa023b328ab2d..c37e771fc2bb3 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 c04638e3bdef4..04667ca3f057b 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,16 @@ 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 66257c2924b57..b5011c8e52b3d 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 715da8a1b74a8..8e7f79d899bec 100644 --- a/packages/twenty-front/src/modules/ui/input/components/Select.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/Select.tsx @@ -49,6 +49,7 @@ export type SelectProps = { value?: Value; withSearchInput?: boolean; needIconCheck?: boolean; + pinnedOption?: SelectOption; callToActionButton?: CallToActionButton; dropdownOffset?: DropdownOffset; hasRightElement?: boolean; @@ -88,6 +89,7 @@ export const Select = ({ value, withSearchInput, needIconCheck, + pinnedOption, callToActionButton, dropdownOffset, hasRightElement, @@ -97,6 +99,10 @@ export const Select = ({ 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 +120,7 @@ export const Select = ({ } return null; - }, [emptyOption, options, value]); + }, [emptyOption, options, pinnedOption, value]); const filteredOptions = useMemo( () => @@ -129,6 +135,7 @@ export const Select = ({ const isDisabled = disabledFromProps || (options.length <= 1 && + !isDefined(pinnedOption) && !isDefined(callToActionButton) && (!isDefined(emptyOption) || selectedOption !== emptyOption)); @@ -200,6 +207,25 @@ export const Select = ({ {withSearchInput === true && isNonEmptyArray(filteredOptions) && ( )} + {isDefined(pinnedOption) && ( + + { + onChange?.(pinnedOption.value); + onBlur?.(); + closeDropdown(dropdownId); + }} + /> + + )} + {isDefined(pinnedOption) && isNonEmptyArray(filteredOptions) && ( + + )} {isNonEmptyArray(filteredOptions) && ( ({ ) : 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 f79ff8e176b45..9bb03231a27f8 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 77a3591963fcb..af874e42f820a 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 (