diff --git a/apps/console/public/assets/ai-tools/claude.svg b/apps/console/public/assets/ai-tools/claude.svg new file mode 100644 index 00000000000..33d4acaaf9d --- /dev/null +++ b/apps/console/public/assets/ai-tools/claude.svg @@ -0,0 +1,7 @@ + + + + diff --git a/apps/console/public/assets/ai-tools/codex.png b/apps/console/public/assets/ai-tools/codex.png new file mode 100644 index 00000000000..34c958bc7f5 Binary files /dev/null and b/apps/console/public/assets/ai-tools/codex.png differ diff --git a/apps/console/public/assets/ai-tools/cursor.svg b/apps/console/public/assets/ai-tools/cursor.svg new file mode 100644 index 00000000000..3b86b0903e4 --- /dev/null +++ b/apps/console/public/assets/ai-tools/cursor.svg @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/apps/console/public/assets/ai-tools/gemini.png b/apps/console/public/assets/ai-tools/gemini.png new file mode 100644 index 00000000000..5298b4be72e Binary files /dev/null and b/apps/console/public/assets/ai-tools/gemini.png differ diff --git a/apps/console/public/assets/ai-tools/opencode.svg b/apps/console/public/assets/ai-tools/opencode.svg new file mode 100644 index 00000000000..07dce1f089d --- /dev/null +++ b/apps/console/public/assets/ai-tools/opencode.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/console/src/routes/__root.tsx b/apps/console/src/routes/__root.tsx index a03381a75a4..e7f51ac0ead 100644 --- a/apps/console/src/routes/__root.tsx +++ b/apps/console/src/routes/__root.tsx @@ -1,5 +1,6 @@ import { type QueryClient } from '@tanstack/react-query' import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' +import { McpSuggestionPortal } from '@qovery/shared/mcp-suggestion/feature' import { ModalProvider, ToastBehavior } from '@qovery/shared/ui' import { type Auth0ContextType } from '../auth/auth0' @@ -13,6 +14,7 @@ const RootLayout = () => { + ) } diff --git a/libs/domains/clusters/feature/src/lib/cluster-advanced-settings/cluster-advanced-settings.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-advanced-settings/cluster-advanced-settings.spec.tsx index 5b5850c927d..a93acd62bc8 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-advanced-settings/cluster-advanced-settings.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-advanced-settings/cluster-advanced-settings.spec.tsx @@ -1,6 +1,6 @@ import { wrapWithReactHookForm } from '__tests__/utils/wrap-with-react-hook-form' import { type ClusterAdvancedSettings } from 'qovery-typescript-axios' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' import { ClusterAdvancedSettings as ClusterAdvancedSettingsComponent } from './cluster-advanced-settings' const mockClusterAdvancedSettings = { @@ -102,7 +102,7 @@ describe('ClusterAdvancedSettings', () => { expect(screen.getByTestId('sticky-action-form-toaster')).toBeInTheDocument() const toaster = screen.getByTestId('sticky-action-form-toaster') - expect(toaster).toHaveClass('visible') + await waitFor(() => expect(toaster).toHaveClass('visible')) }) it('should not show StickyActionFormToaster when form is not dirty', () => { diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-progress-card/cluster-deployment-progress-card.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress-card/cluster-deployment-progress-card.tsx index 32b40712b2e..4913ca0edb8 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-deployment-progress-card/cluster-deployment-progress-card.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-progress-card/cluster-deployment-progress-card.tsx @@ -3,7 +3,7 @@ import clsx from 'clsx' import { type Cluster, type ClusterStatus, type Project } from 'qovery-typescript-axios' import { match } from 'ts-pattern' import { useProjects } from '@qovery/domains/projects/feature' -import { AnimatedGradientText, Icon, Link, LogoIcon } from '@qovery/shared/ui' +import { AnimatedGradientText, FloatingStackPortal, Icon, Link, LogoIcon } from '@qovery/shared/ui' import { twMerge } from '@qovery/shared/util-js' import { useDeploymentProgress } from './use-deployment-progress' @@ -23,15 +23,17 @@ export function ClusterDeploymentProgressCard({ if (!clusters.length) return null return ( -
- - {clusters.map((cluster) => { - const clusterStatus = clusterStatuses.find(({ cluster_id }) => cluster_id === cluster.id) + +
+ + {clusters.map((cluster) => { + const clusterStatus = clusterStatuses.find(({ cluster_id }) => cluster_id === cluster.id) - return - })} - -
+ return + })} +
+
+ ) } diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-create-cluster/use-create-cluster.ts b/libs/domains/clusters/feature/src/lib/hooks/use-create-cluster/use-create-cluster.ts index 8680a2d2cca..74cf7355c05 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-create-cluster/use-create-cluster.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-create-cluster/use-create-cluster.ts @@ -1,15 +1,17 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { mutations } from '@qovery/domains/clusters/data-access' +import { showMcpSuggestionToast } from '@qovery/shared/mcp-suggestion/feature' import { queries } from '@qovery/state/util-queries' export function useCreateCluster() { const queryClient = useQueryClient() return useMutation(mutations.createCluster, { - onSuccess(_, { organizationId }) { + onSuccess(data, { organizationId }) { queryClient.invalidateQueries({ queryKey: queries.clusters.list({ organizationId }).queryKey, }) + showMcpSuggestionToast({ type: 'cluster', name: data.name, clusterType: data.cloud_provider }) }, meta: { notifyOnSuccess: { diff --git a/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.spec.tsx b/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.spec.tsx index f013eb21a47..49b79962701 100644 --- a/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.spec.tsx @@ -76,6 +76,9 @@ describe('SectionProductionHealth', () => { renderWithProviders() expect(screen.getByRole('heading', { name: 'Production health' })).toBeInTheDocument() + expect(screen.getByText('Let your agent do the configuration with')).toBeInTheDocument() + expect(screen.getByText('Just install our AI skills and ask your agent to get you started!')).toBeInTheDocument() + expect(screen.getByText('curl -fsSL https://skill.qovery.com/install.sh | bash')).toBeInTheDocument() expect(screen.getByText('Qovery managed')).toBeInTheDocument() expect(screen.getByText('Bring your own cluster')).toBeInTheDocument() expect(screen.getByText('Local machine (demo)')).toBeInTheDocument() diff --git a/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.tsx b/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.tsx index 42ec5d5579a..c60706b692a 100644 --- a/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.tsx +++ b/libs/domains/clusters/feature/src/lib/section-production-health/section-production-health.tsx @@ -3,6 +3,7 @@ import { Link as RouterLink, useParams } from '@tanstack/react-router' import clsx from 'clsx' import { type ReactNode, useMemo } from 'react' import { IconEnum } from '@qovery/shared/enums' +import { McpSuggestionCard } from '@qovery/shared/mcp-suggestion/feature' import { EmptyState, Heading, Icon, Link, LogoIcon, Section, useModal } from '@qovery/shared/ui' import { twMerge } from '@qovery/shared/util-js' import { AddCreditCardModalFeature } from '../add-credit-card-modal-feature/add-credit-card-modal-feature' @@ -211,64 +212,71 @@ export function SectionProductionHealth() { ) : ( -
-
-

- Install your first cluster and start tracking your production health -

-

- Create a cluster on your cloud provider to be able to deploy apps later -

-
-
- {CLUSTERS_OPTIONS.map((option) => - option.action === 'create-cluster' ? ( - - {renderOptionCardContent(option)} - - ) : ( - - ) - )} -
-
-

Related docs

-
- {RELATED_DOCUMENTATION.map((doc) => ( - - {doc.title} - - - ))} +
+ +
+
+

+ Install your first cluster and start tracking your production health +

+

+ Create a cluster on your cloud provider to be able to deploy apps later +

+
+
+ {CLUSTERS_OPTIONS.map((option) => + option.action === 'create-cluster' ? ( + + {renderOptionCardContent(option)} + + ) : ( + + ) + )} +
+
+

Related docs

+
+ {RELATED_DOCUMENTATION.map((doc) => ( + + {doc.title} + + + ))} +
diff --git a/libs/domains/environments/feature/src/lib/hooks/use-create-environment/use-create-environment.ts b/libs/domains/environments/feature/src/lib/hooks/use-create-environment/use-create-environment.ts index 973a536515c..4592a960a4b 100644 --- a/libs/domains/environments/feature/src/lib/hooks/use-create-environment/use-create-environment.ts +++ b/libs/domains/environments/feature/src/lib/hooks/use-create-environment/use-create-environment.ts @@ -1,18 +1,20 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { mutations } from '@qovery/domains/environments/data-access' +import { showMcpSuggestionToast } from '@qovery/shared/mcp-suggestion/feature' import { queries } from '@qovery/state/util-queries' export function useCreateEnvironment() { const queryClient = useQueryClient() return useMutation(mutations.createEnvironment, { - onSuccess(_, { projectId }) { + onSuccess(data, { projectId }) { queryClient.invalidateQueries({ queryKey: queries.environments.list({ projectId }).queryKey, }) queryClient.invalidateQueries({ queryKey: queries.projects.environmentsOverview({ projectId }).queryKey, }) + showMcpSuggestionToast({ type: 'environment', name: data.name, environmentType: data.mode }) }, meta: { notifyOnSuccess: { diff --git a/libs/domains/organizations/feature/src/lib/settings-billing-summary/show-usage-modal-feature/show-usage-modal-feature.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-billing-summary/show-usage-modal-feature/show-usage-modal-feature.spec.tsx index 64a760ef56c..550d8c1b8b3 100644 --- a/libs/domains/organizations/feature/src/lib/settings-billing-summary/show-usage-modal-feature/show-usage-modal-feature.spec.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-billing-summary/show-usage-modal-feature/show-usage-modal-feature.spec.tsx @@ -55,6 +55,15 @@ describe('ShowUsageModal', () => { expect(baseElement).toBeTruthy() }) + it('should render the report form and info callout', () => { + renderWithProviders(wrapWithReactHookForm()) + + expect(screen.queryByText('Try optimizing your costs with')).not.toBeInTheDocument() + expect(screen.queryByText('Or create a report')).not.toBeInTheDocument() + expect(screen.getByLabelText('Report period')).toBeInTheDocument() + expect(screen.getByText('The report generation can take a few seconds.')).toBeInTheDocument() + }) + it('should call onSubmit', async () => { const onSubmit = jest.fn((event) => event.preventDefault()) const reportPeriods = getReportPeriods({ organization: mockOrganization, orgRenewalAt: modalProps.renewalAt }) diff --git a/libs/domains/organizations/feature/src/lib/settings-billing-summary/show-usage-modal-feature/show-usage-modal-feature.tsx b/libs/domains/organizations/feature/src/lib/settings-billing-summary/show-usage-modal-feature/show-usage-modal-feature.tsx index c58a7544790..598020032d5 100644 --- a/libs/domains/organizations/feature/src/lib/settings-billing-summary/show-usage-modal-feature/show-usage-modal-feature.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-billing-summary/show-usage-modal-feature/show-usage-modal-feature.tsx @@ -3,8 +3,7 @@ import { type OrganizationCurrentCost } from 'qovery-typescript-axios' import { type Organization } from 'qovery-typescript-axios' import { FormProvider, useForm } from 'react-hook-form' import { Controller, useFormContext } from 'react-hook-form' -import { useModal } from '@qovery/shared/ui' -import { Callout, Icon, InputSelect, InputText, ModalCrud } from '@qovery/shared/ui' +import { Callout, Icon, InputSelect, InputText, ModalCrud, useModal } from '@qovery/shared/ui' import { setDayOfTheMonth } from '@qovery/shared/util-dates' import { useGenerateBillingUsageReport } from '../../hooks/use-generate-billing-usage-report/use-generate-billing-usage-report' import { useOrganization } from '../../hooks/use-organization/use-organization' @@ -113,14 +112,6 @@ export function ShowUsageModal({ organizationId, renewalAt, onSubmit, onClose, l onClose={onClose} loading={loading} > - - - - - - The report generation could take a few seconds, please be patient. - - ( ( )} /> + + + + + The report generation can take a few seconds. + ) } diff --git a/libs/domains/projects/feature/src/lib/hooks/use-create-project/use-create-project.ts b/libs/domains/projects/feature/src/lib/hooks/use-create-project/use-create-project.ts index 207a6cfae97..db0613a03a2 100644 --- a/libs/domains/projects/feature/src/lib/hooks/use-create-project/use-create-project.ts +++ b/libs/domains/projects/feature/src/lib/hooks/use-create-project/use-create-project.ts @@ -1,15 +1,19 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { mutations } from '@qovery/domains/projects/data-access' +import { showMcpSuggestionToast } from '@qovery/shared/mcp-suggestion/feature' import { queries } from '@qovery/state/util-queries' export function useCreateProject({ silently = false } = {}) { const queryClient = useQueryClient() return useMutation(mutations.createProject, { - onSuccess(_, { organizationId }) { + onSuccess(data, { organizationId }) { queryClient.invalidateQueries({ queryKey: queries.projects.list({ organizationId }).queryKey, }) + if (!silently) { + showMcpSuggestionToast({ type: 'project', name: data.name }) + } }, meta: { ...(silently diff --git a/libs/domains/services/feature/src/lib/hooks/use-create-service/use-create-service.ts b/libs/domains/services/feature/src/lib/hooks/use-create-service/use-create-service.ts index 11645dbf375..0176adf8bc3 100644 --- a/libs/domains/services/feature/src/lib/hooks/use-create-service/use-create-service.ts +++ b/libs/domains/services/feature/src/lib/hooks/use-create-service/use-create-service.ts @@ -1,15 +1,17 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { mutations } from '@qovery/domains/services/data-access' +import { showMcpSuggestionToast } from '@qovery/shared/mcp-suggestion/feature' import { queries } from '@qovery/state/util-queries' export function useCreateService({ organizationId }: { organizationId: string }) { const queryClient = useQueryClient() return useMutation(mutations.createService, { - onSuccess(response) { + onSuccess(response, { payload }) { queryClient.invalidateQueries({ queryKey: queries.services.list(response.environment.id).queryKey, }) + showMcpSuggestionToast({ type: 'service', name: response.name, serviceType: payload.serviceType }) // gitTokens requests queryClient.invalidateQueries({ diff --git a/libs/shared/mcp-suggestion/feature/.eslintrc.json b/libs/shared/mcp-suggestion/feature/.eslintrc.json new file mode 100644 index 00000000000..772a43d2783 --- /dev/null +++ b/libs/shared/mcp-suggestion/feature/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/shared/mcp-suggestion/feature/jest.config.ts b/libs/shared/mcp-suggestion/feature/jest.config.ts new file mode 100644 index 00000000000..a9583981339 --- /dev/null +++ b/libs/shared/mcp-suggestion/feature/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'shared-mcp-suggestion-feature', + preset: '../../../../jest.preset.js', + transform: { + '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest', + '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../../../coverage/libs/shared/mcp-suggestion/feature', +} diff --git a/libs/shared/mcp-suggestion/feature/project.json b/libs/shared/mcp-suggestion/feature/project.json new file mode 100644 index 00000000000..8d71c5d0e1a --- /dev/null +++ b/libs/shared/mcp-suggestion/feature/project.json @@ -0,0 +1,27 @@ +{ + "name": "shared-mcp-suggestion-feature", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/shared/mcp-suggestion/feature/src", + "projectType": "library", + "tags": ["scope:shared", "type:feature"], + "targets": { + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/shared/mcp-suggestion/feature/**/*.{ts,tsx,js,jsx}"] + } + }, + "test": { + "options": { + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "coverage": true + } + } + } + } +} diff --git a/libs/shared/mcp-suggestion/feature/src/index.ts b/libs/shared/mcp-suggestion/feature/src/index.ts new file mode 100644 index 00000000000..c1fe4256211 --- /dev/null +++ b/libs/shared/mcp-suggestion/feature/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/mcp-suggestion-toast/mcp-suggestion-toast' +export * from './lib/utils/mcp-suggestion-toast' diff --git a/libs/shared/mcp-suggestion/feature/src/lib/ai-tool-badge/ai-tool-badge.spec.tsx b/libs/shared/mcp-suggestion/feature/src/lib/ai-tool-badge/ai-tool-badge.spec.tsx new file mode 100644 index 00000000000..64892232dcd --- /dev/null +++ b/libs/shared/mcp-suggestion/feature/src/lib/ai-tool-badge/ai-tool-badge.spec.tsx @@ -0,0 +1,10 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { AiToolBadge } from './ai-tool-badge' + +describe('AiToolBadge', () => { + it('should render the default tool name', () => { + renderWithProviders() + + expect(screen.getByText('Claude')).toBeInTheDocument() + }) +}) diff --git a/libs/shared/mcp-suggestion/feature/src/lib/ai-tool-badge/ai-tool-badge.tsx b/libs/shared/mcp-suggestion/feature/src/lib/ai-tool-badge/ai-tool-badge.tsx new file mode 100644 index 00000000000..ad5a01a1f7b --- /dev/null +++ b/libs/shared/mcp-suggestion/feature/src/lib/ai-tool-badge/ai-tool-badge.tsx @@ -0,0 +1,139 @@ +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' +import { type CSSProperties, useEffect, useState } from 'react' + +const AI_TOOL_NAMES = ['Claude', 'Cursor', 'Codex', 'Opencode', 'Gemini'] as const + +type AiToolName = (typeof AI_TOOL_NAMES)[number] + +interface AiToolBadgeConfig { + name: AiToolName + badgeClassName: string + textClassName: string + badgeStyle?: CSSProperties + iconSrc: string + useCurrentColorIcon?: boolean +} + +const CODEX_GRADIENT = 'linear-gradient(180deg, #B7A7FF 0%, #7196FF 48%, #3347FF 100%)' +const CODEX_GRADIENT_SUBTLE = + 'linear-gradient(180deg, rgba(183, 167, 255, 0.1), rgba(113, 150, 255, 0.1), rgba(51, 71, 255, 0.1))' +const GEMINI_GRADIENT = 'linear-gradient(90deg, #4893FC 0%, #4893FC 27%, #969DFF 77.6981%, #BD99FE 100%)' +const GEMINI_GRADIENT_SUBTLE = + 'linear-gradient(90deg, rgba(72, 147, 252, 0.1), rgba(72, 147, 252, 0.1), rgba(150, 157, 255, 0.1), rgba(189, 153, 254, 0.1))' + +function gradientBadgeStyle(gradient: string): CSSProperties { + return { + background: `${gradient} padding-box, ${gradient} border-box`, + borderColor: 'transparent', + } +} + +const GRADIENT_TEXT_CLASSNAME = 'bg-clip-text text-transparent' + +const AI_TOOL_BADGES: Record = { + Claude: { + name: 'Claude', + badgeClassName: 'border-[#D97757]/10 bg-[#D97757]/10 text-[#D97757]', + textClassName: 'text-[#D97757]', + iconSrc: '/assets/ai-tools/claude.svg', + }, + Cursor: { + name: 'Cursor', + badgeClassName: 'border-neutral bg-surface-neutral-subtle text-neutral', + textClassName: 'text-neutral', + iconSrc: '/assets/ai-tools/cursor.svg', + useCurrentColorIcon: true, + }, + Codex: { + name: 'Codex', + badgeClassName: 'border', + badgeStyle: gradientBadgeStyle(CODEX_GRADIENT_SUBTLE), + textClassName: GRADIENT_TEXT_CLASSNAME, + iconSrc: '/assets/ai-tools/codex.png', + }, + Opencode: { + name: 'Opencode', + badgeClassName: 'border-neutralInvert bg-surface-neutralInvert-component text-neutralInvert', + textClassName: 'text-neutralInvert', + iconSrc: '/assets/ai-tools/opencode.svg', + }, + Gemini: { + name: 'Gemini', + badgeClassName: 'border', + badgeStyle: gradientBadgeStyle(GEMINI_GRADIENT_SUBTLE), + textClassName: GRADIENT_TEXT_CLASSNAME, + iconSrc: '/assets/ai-tools/gemini.png', + }, +} + +export function AiToolBadge() { + const reducedMotion = useReducedMotion() + const [toolIndex, setToolIndex] = useState(0) + const tool = AI_TOOL_BADGES[AI_TOOL_NAMES[toolIndex]] + const gradientStyle = + tool.name === 'Codex' + ? { backgroundImage: CODEX_GRADIENT } + : tool.name === 'Gemini' + ? { backgroundImage: GEMINI_GRADIENT } + : undefined + const textSpacingClassName = tool.name === 'Codex' || tool.name === 'Cursor' ? 'ml-1' : 'ml-0.5' + const badgeClassName = `col-start-1 row-start-1 inline-flex h-5 items-center whitespace-nowrap rounded-full border pl-1 pr-1.5 ${tool.badgeClassName}` + const icon = tool.useCurrentColorIcon ? ( +