- {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)}
+
+ ) : (
+
+ )
+ )}
+
+
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 ? (
+
+ ) : (
+
+ )
+
+ useEffect(() => {
+ const interval = window.setInterval(() => {
+ setToolIndex((currentIndex) => (currentIndex + 1) % AI_TOOL_NAMES.length)
+ }, 4000)
+
+ return () => window.clearInterval(interval)
+ }, [])
+
+ return (
+
+
+
+ {icon}
+
+ {tool.name}
+
+
+
+
+ )
+}
+
+export default AiToolBadge
diff --git a/libs/shared/mcp-suggestion/feature/src/lib/mcp-suggestion-toast/mcp-suggestion-toast.spec.tsx b/libs/shared/mcp-suggestion/feature/src/lib/mcp-suggestion-toast/mcp-suggestion-toast.spec.tsx
new file mode 100644
index 00000000000..f009c494fff
--- /dev/null
+++ b/libs/shared/mcp-suggestion/feature/src/lib/mcp-suggestion-toast/mcp-suggestion-toast.spec.tsx
@@ -0,0 +1,62 @@
+import { act } from '@testing-library/react'
+import { renderWithProviders, screen } from '@qovery/shared/util-tests'
+import { showMcpSuggestionToast } from '../utils/mcp-suggestion-toast'
+import { McpSuggestionCard, McpSuggestionPortal } from './mcp-suggestion-toast'
+
+const MCP_SUGGESTION_DISMISSED_KEY = 'qovery_skill_suggestion_dismissed'
+
+beforeEach(() => {
+ jest.useFakeTimers()
+ localStorage.clear()
+})
+
+afterEach(() => {
+ document.getElementById('qovery-floating-stack')?.remove()
+ jest.useRealTimers()
+})
+
+describe('McpSuggestionCard', () => {
+ it('should render the default title and install command', () => {
+ renderWithProviders()
+
+ expect(screen.getByText('Try deploying with')).toBeInTheDocument()
+ expect(screen.getByText('curl -fsSL https://skill.qovery.com/install.sh | bash')).toBeInTheDocument()
+ })
+
+ it('should render a custom title and description', () => {
+ renderWithProviders()
+
+ expect(screen.getByText('Try optimizing your costs with')).toBeInTheDocument()
+ expect(screen.getByText('Ask your agent')).toBeInTheDocument()
+ })
+
+ it('should render a dismiss button when close handler is provided', () => {
+ renderWithProviders()
+
+ expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
+ })
+})
+
+describe('McpSuggestionPortal', () => {
+ it('should show the toast when a suggestion event is dispatched', async () => {
+ renderWithProviders()
+
+ act(() => {
+ showMcpSuggestionToast({ type: 'service', name: 'api' })
+ })
+
+ expect(await screen.findByText('"Deploy my service api on Qovery"')).toBeInTheDocument()
+ })
+
+ it('should store the dismissal flag when the toast is dismissed', async () => {
+ const { userEvent } = renderWithProviders()
+
+ act(() => {
+ showMcpSuggestionToast({ type: 'service', name: 'api' })
+ })
+
+ await userEvent.click(await screen.findByRole('button', { name: 'Dismiss' }))
+
+ expect(localStorage.getItem(MCP_SUGGESTION_DISMISSED_KEY)).toBe('true')
+ })
+})
diff --git a/libs/shared/mcp-suggestion/feature/src/lib/mcp-suggestion-toast/mcp-suggestion-toast.tsx b/libs/shared/mcp-suggestion/feature/src/lib/mcp-suggestion-toast/mcp-suggestion-toast.tsx
new file mode 100644
index 00000000000..c33866e373e
--- /dev/null
+++ b/libs/shared/mcp-suggestion/feature/src/lib/mcp-suggestion-toast/mcp-suggestion-toast.tsx
@@ -0,0 +1,201 @@
+import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
+import { type ReactNode, useEffect, useState } from 'react'
+import { match } from 'ts-pattern'
+import { Button, CopyToClipboardButtonIcon, FloatingStackPortal, Icon } from '@qovery/shared/ui'
+import { useLocalStorage } from '@qovery/shared/util-hooks'
+import { twMerge } from '@qovery/shared/util-js'
+import { AiToolBadge } from '../ai-tool-badge/ai-tool-badge'
+
+const MCP_SUGGESTION_DISMISSED_KEY = 'qovery_skill_suggestion_dismissed'
+
+export type McpSuggestionAction =
+ | { type: 'service'; name: string; serviceType?: string }
+ | { type: 'environment'; name: string; environmentType?: string }
+ | { type: 'cluster'; name: string; clusterType?: string }
+ | { type: 'project'; name: string }
+
+export type McpSuggestionActionType = McpSuggestionAction['type']
+
+function McpInstallCommand() {
+ const installCommand = 'curl -fsSL https://skill.qovery.com/install.sh | bash'
+
+ return (
+
+
+ {installCommand}
+
+
+
+ )
+}
+
+export interface McpSuggestionCardProps {
+ title?: ReactNode
+ description?: ReactNode
+ variant?: 'compact' | 'setup'
+ className?: string
+ onClose?: () => void
+}
+
+export function McpSuggestionCard({
+ title = 'Try deploying with',
+ description,
+ variant = 'compact',
+ className,
+ onClose,
+}: McpSuggestionCardProps) {
+ return (
+
+
+
+
+ {title}
+
+
+ {description &&
{description}
}
+
+ {onClose && (
+
+ )}
+
+
+
+ )
+}
+
+function formatServiceType(serviceType?: string): string {
+ return serviceType?.toLowerCase().replace(/_/g, ' ') ?? 'service'
+}
+
+function formatClusterType(clusterType?: string): string {
+ return clusterType?.replace(/_/g, ' ') ?? 'Kubernetes'
+}
+
+function formatEnvironmentType(environmentType?: string): string {
+ return environmentType?.toLowerCase().replace(/_/g, ' ') ?? 'environment'
+}
+
+function getIndefiniteArticle(value: string): string {
+ return /^[aeiou]/i.test(value) ? 'an' : 'a'
+}
+
+function getPrompt(action: McpSuggestionAction): string {
+ return match(action)
+ .with(
+ { type: 'service' },
+ ({ name, serviceType }) => `Deploy my ${formatServiceType(serviceType)} ${name} on Qovery`
+ )
+ .with({ type: 'environment' }, ({ name, environmentType }) => {
+ const formattedEnvironmentType = formatEnvironmentType(environmentType)
+ return `Create ${getIndefiniteArticle(formattedEnvironmentType)} ${formattedEnvironmentType} environment called ${name}`
+ })
+ .with({ type: 'cluster' }, ({ clusterType }) => `Deploy my ${formatClusterType(clusterType)} cluster on Qovery`)
+ .with({ type: 'project' }, ({ name }) => `Create a project named ${name} in Qovery`)
+ .exhaustive()
+}
+
+const TITLE_ACTIONS: Record = {
+ service: 'deploying it',
+ environment: 'creating this environment',
+ cluster: 'creating this cluster',
+ project: 'creating this project',
+}
+
+interface PortalState {
+ visible: boolean
+ event: McpSuggestionAction
+}
+
+export function McpSuggestionPortal() {
+ const reducedMotion = useReducedMotion()
+ const [dismissed, setDismissed] = useLocalStorage(MCP_SUGGESTION_DISMISSED_KEY, false)
+ const [isPortalMounted, setIsPortalMounted] = useState(false)
+ const [state, setState] = useState({
+ visible: false,
+ event: { type: 'service', name: '' },
+ })
+
+ useEffect(() => {
+ const handler = (e: Event) => {
+ const ev = e as CustomEvent
+ if (dismissed) return
+ setIsPortalMounted(true)
+ setState({ visible: true, event: ev.detail })
+ }
+ window.addEventListener('qovery:skill-suggestion', handler)
+ return () => window.removeEventListener('qovery:skill-suggestion', handler)
+ }, [dismissed])
+
+ useEffect(() => {
+ if (!state.visible) return
+
+ const timeout = window.setTimeout(() => {
+ setState((s) => ({ ...s, visible: false }))
+ }, 30_000)
+
+ return () => window.clearTimeout(timeout)
+ }, [state.event, state.visible])
+
+ const handleClose = () => {
+ setDismissed(true)
+ setState((s) => ({ ...s, visible: false }))
+ }
+
+ if (!isPortalMounted) return null
+
+ const prompt = getPrompt(state.event)
+ const titleAction = TITLE_ACTIONS[state.event.type]
+
+ return (
+
+ setIsPortalMounted(false)}>
+ {state.visible && (
+
+
+
+ )}
+
+
+ )
+}
diff --git a/libs/shared/mcp-suggestion/feature/src/lib/utils/mcp-suggestion-toast.spec.ts b/libs/shared/mcp-suggestion/feature/src/lib/utils/mcp-suggestion-toast.spec.ts
new file mode 100644
index 00000000000..149be8066a1
--- /dev/null
+++ b/libs/shared/mcp-suggestion/feature/src/lib/utils/mcp-suggestion-toast.spec.ts
@@ -0,0 +1,18 @@
+import { showMcpSuggestionToast } from './mcp-suggestion-toast'
+
+describe('MCP suggestion toast', () => {
+ it('should dispatch the suggestion event', () => {
+ const listener = jest.fn()
+ window.addEventListener('qovery:skill-suggestion', listener)
+
+ showMcpSuggestionToast({ type: 'service', name: 'my-service' })
+
+ expect(listener).toHaveBeenCalledTimes(1)
+ expect(listener.mock.calls[0][0].detail).toMatchObject({
+ type: 'service',
+ name: 'my-service',
+ })
+
+ window.removeEventListener('qovery:skill-suggestion', listener)
+ })
+})
diff --git a/libs/shared/mcp-suggestion/feature/src/lib/utils/mcp-suggestion-toast.ts b/libs/shared/mcp-suggestion/feature/src/lib/utils/mcp-suggestion-toast.ts
new file mode 100644
index 00000000000..5c29103c0d9
--- /dev/null
+++ b/libs/shared/mcp-suggestion/feature/src/lib/utils/mcp-suggestion-toast.ts
@@ -0,0 +1,7 @@
+import { type McpSuggestionAction } from '../mcp-suggestion-toast/mcp-suggestion-toast'
+
+export function showMcpSuggestionToast(action: McpSuggestionAction): void {
+ if (typeof window === 'undefined') return
+
+ window.dispatchEvent(new CustomEvent('qovery:skill-suggestion', { detail: action }))
+}
diff --git a/libs/shared/mcp-suggestion/feature/tsconfig.json b/libs/shared/mcp-suggestion/feature/tsconfig.json
new file mode 100644
index 00000000000..c88d07daddd
--- /dev/null
+++ b/libs/shared/mcp-suggestion/feature/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "allowJs": false,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ],
+ "extends": "../../../../tsconfig.base.json"
+}
diff --git a/libs/shared/mcp-suggestion/feature/tsconfig.lib.json b/libs/shared/mcp-suggestion/feature/tsconfig.lib.json
new file mode 100644
index 00000000000..66c12a4f6e0
--- /dev/null
+++ b/libs/shared/mcp-suggestion/feature/tsconfig.lib.json
@@ -0,0 +1,24 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../../dist/out-tsc",
+ "types": ["node"]
+ },
+ "files": [
+ "../../../../node_modules/@nx/react/typings/cssmodule.d.ts",
+ "../../../../node_modules/@nx/react/typings/image.d.ts",
+ "../../../../apps/console/src/router-types.d.ts"
+ ],
+ "exclude": [
+ "jest.config.ts",
+ "src/**/*.spec.ts",
+ "src/**/*.test.ts",
+ "src/**/*.spec.tsx",
+ "src/**/*.test.tsx",
+ "src/**/*.spec.js",
+ "src/**/*.test.js",
+ "src/**/*.spec.jsx",
+ "src/**/*.test.jsx"
+ ],
+ "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
+}
diff --git a/libs/shared/mcp-suggestion/feature/tsconfig.spec.json b/libs/shared/mcp-suggestion/feature/tsconfig.spec.json
new file mode 100644
index 00000000000..1033686367b
--- /dev/null
+++ b/libs/shared/mcp-suggestion/feature/tsconfig.spec.json
@@ -0,0 +1,20 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../../dist/out-tsc",
+ "module": "commonjs",
+ "types": ["jest", "node"]
+ },
+ "include": [
+ "jest.config.ts",
+ "src/**/*.test.ts",
+ "src/**/*.spec.ts",
+ "src/**/*.test.tsx",
+ "src/**/*.spec.tsx",
+ "src/**/*.test.js",
+ "src/**/*.spec.js",
+ "src/**/*.test.jsx",
+ "src/**/*.spec.jsx",
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/libs/shared/ui/src/index.ts b/libs/shared/ui/src/index.ts
index 0c558369793..0444f86ee70 100644
--- a/libs/shared/ui/src/index.ts
+++ b/libs/shared/ui/src/index.ts
@@ -111,6 +111,7 @@ export * from './lib/components/segmented-control/segmented-control'
export * from './lib/components/board/board'
export * from './lib/components/animated-gradient-text/animated-gradient-text'
export * from './lib/components/progress-bar/progress-bar'
+export * from './lib/components/floating-stack-portal/floating-stack-portal'
export * from './lib/components/chart/chart'
export * from './lib/components/chart/chart-utils'
export * from './lib/components/multiple-selector/multiple-selector'
@@ -119,6 +120,7 @@ export * from './lib/components/logo-branded/logo-branded'
export * from './lib/components/sidebar/sidebar'
export * from './lib/utils/toast'
export * from './lib/utils/toast-error'
+
export * from './lib/utils/ansi'
export * from './lib/components/deployment-action/deployment-action'
export * from './lib/components/summary-value/summary-value'
diff --git a/libs/shared/ui/src/lib/components/description-list/description-list.stories.tsx b/libs/shared/ui/src/lib/components/description-list/description-list.stories.tsx
index 05fe3749ba7..3bdeda76849 100644
--- a/libs/shared/ui/src/lib/components/description-list/description-list.stories.tsx
+++ b/libs/shared/ui/src/lib/components/description-list/description-list.stories.tsx
@@ -16,28 +16,24 @@ export default Story
export const HighlightDetails = {
render: () => (
- <>
-
- Name:
- Foobar
+
+ Name:
+ Foobar
- Version:
- 1.2.3
-
- >
+ Version:
+ 1.2.3
+
),
}
export const HighlightTerm = {
render: () => (
- <>
-
- Name:
- Foobar
+
+ Name:
+ Foobar
- Version:
- 1.2.3
-
- >
+ Version:
+ 1.2.3
+
),
}
diff --git a/libs/shared/ui/src/lib/components/floating-stack-portal/floating-stack-portal.spec.tsx b/libs/shared/ui/src/lib/components/floating-stack-portal/floating-stack-portal.spec.tsx
new file mode 100644
index 00000000000..a652387f076
--- /dev/null
+++ b/libs/shared/ui/src/lib/components/floating-stack-portal/floating-stack-portal.spec.tsx
@@ -0,0 +1,32 @@
+import { renderWithProviders, screen } from '@qovery/shared/util-tests'
+import { FloatingStackPortal } from './floating-stack-portal'
+
+describe('FloatingStackPortal', () => {
+ it('should render its children into the shared floating stack root', () => {
+ renderWithProviders(
+
+ Floating content
+
+ )
+
+ expect(screen.getByText('Floating content')).toBeInTheDocument()
+ expect(document.getElementById('qovery-floating-stack')).not.toBeNull()
+ })
+
+ it('should reuse the same root for multiple portals', () => {
+ renderWithProviders(
+ <>
+
+ Top content
+
+
+ Bottom content
+
+ >
+ )
+
+ expect(screen.getByText('Top content')).toBeInTheDocument()
+ expect(screen.getByText('Bottom content')).toBeInTheDocument()
+ expect(document.querySelectorAll('#qovery-floating-stack')).toHaveLength(1)
+ })
+})
diff --git a/libs/shared/ui/src/lib/components/floating-stack-portal/floating-stack-portal.tsx b/libs/shared/ui/src/lib/components/floating-stack-portal/floating-stack-portal.tsx
new file mode 100644
index 00000000000..f5dac2c1fab
--- /dev/null
+++ b/libs/shared/ui/src/lib/components/floating-stack-portal/floating-stack-portal.tsx
@@ -0,0 +1,51 @@
+import { type ReactNode, useEffect, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { twMerge } from '@qovery/shared/util-js'
+
+const FLOATING_STACK_ROOT_ID = 'qovery-floating-stack'
+
+function getFloatingStackRoot(): HTMLElement | null {
+ if (typeof document === 'undefined') return null
+
+ let root = document.getElementById(FLOATING_STACK_ROOT_ID)
+
+ if (!root) {
+ root = document.createElement('div')
+ root.id = FLOATING_STACK_ROOT_ID
+ root.className = 'pointer-events-none fixed bottom-4 left-4 right-4 ml-auto flex max-w-md flex-col items-end gap-2'
+ document.body.appendChild(root)
+ }
+
+ return root
+}
+
+export interface FloatingStackPortalProps {
+ children: ReactNode
+ position?: 'top' | 'bottom'
+ className?: string
+}
+
+export function FloatingStackPortal({ children, position = 'bottom', className }: FloatingStackPortalProps) {
+ const [root, setRoot] = useState(null)
+
+ useEffect(() => {
+ setRoot(getFloatingStackRoot())
+ }, [])
+
+ if (!root) return null
+
+ return createPortal(
+
+ {children}
+
,
+ root
+ )
+}
+
+export default FloatingStackPortal
diff --git a/tailwind-workspace-preset.js b/tailwind-workspace-preset.js
index 1f83a6ca6d2..1904125449a 100644
--- a/tailwind-workspace-preset.js
+++ b/tailwind-workspace-preset.js
@@ -519,6 +519,13 @@ module.exports = {
transform: 'scale(1)',
},
},
+ aiCarousel: {
+ '0%': { opacity: '0' },
+ '5%': { opacity: '1' },
+ '20%': { opacity: '1' },
+ '25%': { opacity: '0' },
+ '26%, 100%': { opacity: '0' },
+ },
...slideEntrances(),
...slideExits(),
},
diff --git a/tsconfig.base.json b/tsconfig.base.json
index f9f2a1aeca2..07d8f86aa77 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -61,6 +61,7 @@
"@qovery/shared/iam/data-access": ["libs/shared/iam/data-access/src/index.ts"],
"@qovery/shared/iam/feature": ["libs/shared/iam/feature/src/index.ts"],
"@qovery/shared/interfaces": ["libs/shared/interfaces/src/index.ts"],
+ "@qovery/shared/mcp-suggestion/feature": ["libs/shared/mcp-suggestion/feature/src/index.ts"],
"@qovery/shared/posthog/feature": ["libs/shared/posthog/feature/src/index.ts"],
"@qovery/shared/router": ["libs/shared/router/src/index.ts"],
"@qovery/shared/routes": ["libs/shared/routes/src/index.ts"],