From 6ad74aac02f9bf074cb36ed1efe44d7bc3c65795 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 09:20:13 +0200 Subject: [PATCH 01/38] feat(agent-task): add Qovery service context Allow users to select services from the current environment and append their identifiers to the submitted agent prompt. --- .../use-agentic-workflow-context-services.ts | 13 +++ .../agentic-workflow-configuration.spec.tsx | 15 ++++ .../agentic-workflow-configuration.tsx | 79 +++++++++++++------ .../context/qovery-service-context-card.tsx | 45 +++++++++++ .../qovery-service-context-modal.spec.tsx | 38 +++++++++ .../context/qovery-service-context-modal.tsx | 57 +++++++++++++ .../agentic-workflow-context.tsx | 8 ++ .../agentic-workflow-request.spec.ts | 22 ++++++ .../agentic-workflow-request.ts | 12 ++- 9 files changed, 265 insertions(+), 24 deletions(-) create mode 100644 libs/domains/services/feature/src/lib/hooks/use-agentic-workflow-context-services/use-agentic-workflow-context-services.ts create mode 100644 libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx create mode 100644 libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx create mode 100644 libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx diff --git a/libs/domains/services/feature/src/lib/hooks/use-agentic-workflow-context-services/use-agentic-workflow-context-services.ts b/libs/domains/services/feature/src/lib/hooks/use-agentic-workflow-context-services/use-agentic-workflow-context-services.ts new file mode 100644 index 00000000000..8c2dd81f870 --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-agentic-workflow-context-services/use-agentic-workflow-context-services.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query' +import { queries } from '@qovery/state/util-queries' + +export function useAgenticWorkflowContextServices(environmentId: string) { + return useQuery({ + ...queries.services.list(environmentId), + enabled: Boolean(environmentId), + select: (services) => + services + .filter(({ serviceType }) => serviceType !== 'AGENTIC_WORKFLOW') + .map(({ id, name, serviceType }) => ({ id, name, type: serviceType })), + }) +} diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx index cc17d484743..91a7e8f58b2 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx @@ -27,6 +27,16 @@ jest.mock('../../../hooks/use-create-service/use-create-service', () => ({ useCreateService: () => ({ isLoading: false, mutateAsync: mockCreateService }), })) +jest.mock('../../../hooks/use-agentic-workflow-context-services/use-agentic-workflow-context-services', () => ({ + useAgenticWorkflowContextServices: () => ({ + data: [ + { id: 'application-1', name: 'api', type: 'APPLICATION' }, + { id: 'database-1', name: 'postgres', type: 'DATABASE' }, + ], + isLoading: false, + }), +})) + jest.mock('@qovery/domains/organizations/feature', () => ({ GitBranchSettings: () =>
Git branch
, GitProviderSetting: () =>
Git provider
, @@ -213,6 +223,11 @@ describe('AgenticWorkflowConfiguration', () => { expect(screen.getByRole('heading', { name: 'Add from Git repository' })).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Cancel' })) + await userEvent.click(screen.getByRole('button', { name: /Add Qovery services/ })) + expect(screen.getByRole('heading', { name: 'Add Qovery services' })).toBeInTheDocument() + expect(screen.getByLabelText('Qovery services')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })) + await userEvent.click(screen.getByRole('button', { name: 'Anthropic' })) expect(screen.getByRole('heading', { name: 'Configure provider' })).toBeInTheDocument() expect(screen.getByLabelText('API key')).toBeInTheDocument() diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx index 94333ca1cb4..c8817317223 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx @@ -25,6 +25,7 @@ import { prepareVariableImportRequest, } from '@qovery/shared/util-js' import { AgenticWorkflowExecutionModeSelector } from '../../../agentic-workflow-execution-mode-selector/agentic-workflow-execution-mode-selector' +import { useAgenticWorkflowContextServices } from '../../../hooks/use-agentic-workflow-context-services/use-agentic-workflow-context-services' import { useCreateService } from '../../../hooks/use-create-service/use-create-service' import { type AgenticWorkflowAutomation, @@ -37,6 +38,8 @@ import { AgenticWorkflowPromptEditor, type AgenticWorkflowPromptEditorHandle } f import { AutomationSheet } from './automations/automation-sheet' import { GitContextCard, GitContextCompactCard } from './context/git-context-card' import { GitContextModal } from './context/git-context-modal' +import { QoveryServiceContextCard, QoveryServiceContextCompactCard } from './context/qovery-service-context-card' +import { QoveryServiceContextModal } from './context/qovery-service-context-modal' import { AgenticWorkflowHeader, type AgenticWorkflowHeaderHandle } from './header/agentic-workflow-header' import { McpSheet } from './mcp/mcp-sheet' @@ -270,6 +273,8 @@ function DockerFragmentModal({ setOpen }: { setOpen?: (open: boolean) => void }) export function AgenticWorkflowConfiguration() { const { environmentId = '', organizationId = '', projectId = '' } = useParams({ strict: false }) const { data: mcpServers = [], isLoading: areMcpServersLoading } = useMcpServers({ organizationId }) + const { data: contextServices = [], isLoading: areContextServicesLoading } = + useAgenticWorkflowContextServices(environmentId) const navigate = useNavigate() const { closeModal, openModal } = useModal() const { form, onExit, variablesForm } = useAgenticWorkflowCreateContext() @@ -366,6 +371,26 @@ export function AgenticWorkflowConfiguration() { }) } + const openQoveryServiceContext = () => { + openModal({ + content: ( + { + if (!open) closeModal() + }} + onSave={(services) => form.setValue('contextServices', services, { shouldDirty: true })} + /> + ), + options: { + width: 488, + fakeModal: true, + }, + }) + } + const focusSettingsGroup = (group: SettingsGroup) => { setOpenSettingsGroups((groups) => (groups.includes(group) ? groups : [...groups, group])) @@ -735,30 +760,38 @@ export function AgenticWorkflowConfiguration() { />

Context

+
+ {values.gitRepositories.some(isGitRepositoryComplete) ? ( + values.gitRepositories.map((repository, index) => + isGitRepositoryComplete(repository) ? ( + openGitContext(index)} + /> + ) : null + ) + ) : ( + openGitContext()} /> + )} + {values.contextServices.length > 0 ? ( + name)} + onClick={openQoveryServiceContext} + /> + ) : ( + + )} +
{values.gitRepositories.some(isGitRepositoryComplete) ? ( - <> -
- {values.gitRepositories.map((repository, index) => - isGitRepositoryComplete(repository) ? ( - openGitContext(index)} - /> - ) : null - )} -
-
- -
- - ) : ( - openGitContext()} /> - )} +
+ +
+ ) : null}
diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx new file mode 100644 index 00000000000..751bfa29df3 --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx @@ -0,0 +1,45 @@ +import { Button, Icon } from '@qovery/shared/ui' + +export function QoveryServiceContextCard({ onClick }: { onClick: () => void }) { + return ( + + ) +} + +export function QoveryServiceContextCompactCard({ names, onClick }: { names: string[]; onClick: () => void }) { + return ( +
+ + + Qovery services + +
{names.join(', ')}
+ +
+ ) +} diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx new file mode 100644 index 00000000000..8c2d395a912 --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx @@ -0,0 +1,38 @@ +import selectEvent from 'react-select-event' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { QoveryServiceContextModal } from './qovery-service-context-modal' + +const services = [ + { id: 'application-1', name: 'api', type: 'APPLICATION' }, + { id: 'database-1', name: 'postgres', type: 'DATABASE' }, +] + +describe('QoveryServiceContextModal', () => { + it('selects multiple services from the environment', async () => { + const onSave = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + await selectEvent.select(screen.getByLabelText('Qovery services'), ['api', 'postgres'], { + container: document.body, + }) + await userEvent.click(screen.getByRole('button', { name: 'Apply changes' })) + + expect(onSave).toHaveBeenCalledWith(services) + }) + + it('keeps existing services selected when editing', () => { + renderWithProviders( + + ) + + expect(screen.getByText('api')).toBeInTheDocument() + }) +}) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx new file mode 100644 index 00000000000..5abfed774f3 --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react' +import { Button, Heading, InputSelect, Section } from '@qovery/shared/ui' +import { type AgenticWorkflowContextService } from '../../agentic-workflow-context' + +export function QoveryServiceContextModal({ + isLoading, + onSave, + services, + setOpen, + value, +}: { + isLoading: boolean + onSave: (services: AgenticWorkflowContextService[]) => void + services: AgenticWorkflowContextService[] + setOpen?: (open: boolean) => void + value: AgenticWorkflowContextService[] +}) { + const [selectedIds, setSelectedIds] = useState(value.map(({ id }) => id)) + + return ( +
+
+ + Add Qovery services + +

+ Select services from this environment to give the agent their Qovery context. +

+
+ ({ value: id, label: name, description: type }))} + isLoading={isLoading} + onChange={(ids) => setSelectedIds(ids as string[])} + /> +
+ + +
+
+ ) +} diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx index 91c457b1172..26d1ba54ab4 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx @@ -82,6 +82,12 @@ export interface AgenticWorkflowGitRepository { branch: string } +export interface AgenticWorkflowContextService { + id: string + name: string + type: string +} + export interface AgenticWorkflowFormData { name: string description: string @@ -93,6 +99,7 @@ export interface AgenticWorkflowFormData { mcpServerIds: string[] mcpJson: string gitRepositories: AgenticWorkflowGitRepository[] + contextServices: AgenticWorkflowContextService[] modelApiKey: string modelSettingsJson: string whitelistHosts: string @@ -131,6 +138,7 @@ export function getAgenticWorkflowDefaults(): AgenticWorkflowFormData { mcpServerIds: [], mcpJson: '', gitRepositories: [], + contextServices: [], modelApiKey: '', modelSettingsJson: DEFAULT_MODEL_SETTINGS, whitelistHosts: '*', diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.spec.ts b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.spec.ts index 299234edc73..38f8681f48d 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.spec.ts +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.spec.ts @@ -13,6 +13,7 @@ const values: AgenticWorkflowFormData = { mcpServerIds: ['mcp-1', 'mcp-2'], mcpJson: '', gitRepositories: [], + contextServices: [], modelApiKey: 'api-key', modelSettingsJson: '{}', whitelistHosts: '*', @@ -111,6 +112,27 @@ describe('formatAgenticWorkflowRequest', () => { ).toBe(AgenticWorkflowExecutionMode.CLONE_ENVIRONMENT) }) + it('appends selected Qovery services to the agent prompt', () => { + const request = formatAgenticWorkflowRequest({ + ...values, + agentPrompt: 'Investigate the incident.\n', + contextServices: [ + { id: 'application-1', name: 'api', type: 'APPLICATION' }, + { id: 'database-1', name: 'postgres', type: 'DATABASE' }, + ], + }) + + expect(request.agent_prompt).toBe(`Investigate the incident. + +## Context services +- api (APPLICATION) — service ID: application-1 +- postgres (DATABASE) — service ID: database-1`) + }) + + it('keeps the agent prompt unchanged when no Qovery service is selected', () => { + expect(formatAgenticWorkflowRequest(values).agent_prompt).toBe('Review the pull request') + }) + it('uses the full URL of a selected Git repository', () => { const request = formatAgenticWorkflowRequest({ ...values, diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts index ab3be9fa0c0..60dd8ed22e5 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts @@ -9,6 +9,16 @@ function formatWhitelistHosts(value: string) { .filter(Boolean) } +export function appendContextServicesToPrompt( + prompt: string, + contextServices: AgenticWorkflowFormData['contextServices'] +) { + if (contextServices.length === 0) return prompt + + const services = contextServices.map(({ id, name, type }) => `- ${name} (${type}) — service ID: ${id}`).join('\n') + return `${prompt.trimEnd()}\n\n## Context services\n${services}` +} + export function formatAgenticWorkflowRequest(values: AgenticWorkflowFormData): AgenticWorkflowRequest { const scheduleTrigger = values.automations .flatMap((automation) => automation.triggers) @@ -45,7 +55,7 @@ export function formatAgenticWorkflowRequest(values: AgenticWorkflowFormData): A branch: repository.branch, git_token_id: repository.gitTokenId ?? '', })), - agent_prompt: values.agentPrompt, + agent_prompt: appendContextServicesToPrompt(values.agentPrompt, values.contextServices), governance: { host_allowlist: formatWhitelistHosts(values.whitelistHosts), }, From 5416c69484a35cccd153576050ce53cda6001c2e Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 09:24:13 +0200 Subject: [PATCH 02/38] fix(agent-task): align context cards --- .../agentic-workflow-configuration.tsx | 2 +- .../context/qovery-service-context-card.tsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx index c8817317223..910fa8f1def 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx @@ -760,7 +760,7 @@ export function AgenticWorkflowConfiguration() { />

Context

-
+
{values.gitRepositories.some(isGitRepositoryComplete) ? ( values.gitRepositories.map((repository, index) => isGitRepositoryComplete(repository) ? ( diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx index 751bfa29df3..37632f521a0 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx @@ -1,3 +1,4 @@ +import { IconEnum } from '@qovery/shared/enums' import { Button, Icon } from '@qovery/shared/ui' export function QoveryServiceContextCard({ onClick }: { onClick: () => void }) { @@ -9,7 +10,7 @@ export function QoveryServiceContextCard({ onClick }: { onClick: () => void }) { className="h-[140px] w-full max-w-80 flex-col items-start justify-between whitespace-normal rounded-lg p-4 text-left transition-colors active:scale-100" onClick={onClick} > - + Add Qovery services @@ -24,7 +25,7 @@ export function QoveryServiceContextCompactCard({ names, onClick }: { names: str return (
- + Qovery services
{names.join(', ')}
From 2d67e4206a751232514a753b0920f68d2224cd13 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 09:26:58 +0200 Subject: [PATCH 03/38] fix(agent-task): streamline context selection --- .../agentic-workflow-configuration.spec.tsx | 6 +- .../agentic-workflow-configuration.tsx | 66 +++++++++++-------- .../context/git-context-card.tsx | 2 +- .../context/qovery-service-context-card.tsx | 22 +------ 4 files changed, 45 insertions(+), 51 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx index 91a7e8f58b2..8bfcd7d91c2 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx @@ -219,11 +219,13 @@ describe('AgenticWorkflowConfiguration', () => { it('should configure context, provider, triggers, and output from the main canvas', async () => { const { userEvent } = renderConfiguration() - await userEvent.click(screen.getByRole('button', { name: /Add from Git repository/ })) + await userEvent.click(screen.getByRole('button', { name: 'Add context' })) + await userEvent.click(screen.getByRole('menuitem', { name: 'Git repository' })) expect(screen.getByRole('heading', { name: 'Add from Git repository' })).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Cancel' })) - await userEvent.click(screen.getByRole('button', { name: /Add Qovery services/ })) + await userEvent.click(screen.getByRole('button', { name: 'Add context' })) + await userEvent.click(screen.getByRole('menuitem', { name: 'Qovery services' })) expect(screen.getByRole('heading', { name: 'Add Qovery services' })).toBeInTheDocument() expect(screen.getByLabelText('Qovery services')).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Cancel' })) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx index 910fa8f1def..19c26e9cdf6 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx @@ -6,11 +6,13 @@ import { type ReactNode, useRef, useState } from 'react' import { Controller, FormProvider, useFieldArray } from 'react-hook-form' import { useMcpServers } from '@qovery/domains/organizations/feature' import { VariableRow, useImportVariables } from '@qovery/domains/variables/feature' +import { IconEnum } from '@qovery/shared/enums' import { type VariableData } from '@qovery/shared/interfaces' import { Accordion, Button, CodeEditor, + DropdownMenu, Heading, Icon, InputText, @@ -36,9 +38,9 @@ import { import { formatAgenticWorkflowRequest } from '../agentic-workflow-request' import { AgenticWorkflowPromptEditor, type AgenticWorkflowPromptEditorHandle } from './agentic-workflow-prompt-editor' import { AutomationSheet } from './automations/automation-sheet' -import { GitContextCard, GitContextCompactCard } from './context/git-context-card' +import { GitContextCompactCard } from './context/git-context-card' import { GitContextModal } from './context/git-context-modal' -import { QoveryServiceContextCard, QoveryServiceContextCompactCard } from './context/qovery-service-context-card' +import { QoveryServiceContextCompactCard } from './context/qovery-service-context-card' import { QoveryServiceContextModal } from './context/qovery-service-context-modal' import { AgenticWorkflowHeader, type AgenticWorkflowHeaderHandle } from './header/agentic-workflow-header' import { McpSheet } from './mcp/mcp-sheet' @@ -760,38 +762,48 @@ export function AgenticWorkflowConfiguration() { />

Context

+ + + + + + } + onSelect={() => openGitContext()} + > + Git repository + + } + onSelect={openQoveryServiceContext} + > + Qovery services + + +
- {values.gitRepositories.some(isGitRepositoryComplete) ? ( - values.gitRepositories.map((repository, index) => - isGitRepositoryComplete(repository) ? ( - openGitContext(index)} - /> - ) : null - ) - ) : ( - openGitContext()} /> - )} + {values.gitRepositories.some(isGitRepositoryComplete) + ? values.gitRepositories.map((repository, index) => + isGitRepositoryComplete(repository) ? ( + openGitContext(index)} + /> + ) : null + ) + : null} {values.contextServices.length > 0 ? ( name)} onClick={openQoveryServiceContext} /> - ) : ( - - )} + ) : null}
- {values.gitRepositories.some(isGitRepositoryComplete) ? ( -
- -
- ) : null}
diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.tsx index 05385a3863a..f0ceef12167 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.tsx @@ -39,7 +39,7 @@ export function GitContextCompactCard({ repository: string }) { return ( -
+
{provider ? PROVIDER_LABELS[provider] ?? provider : 'Git'} diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx index 37632f521a0..7bad9ce9bb2 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx @@ -1,29 +1,9 @@ import { IconEnum } from '@qovery/shared/enums' import { Button, Icon } from '@qovery/shared/ui' -export function QoveryServiceContextCard({ onClick }: { onClick: () => void }) { - return ( - - ) -} - export function QoveryServiceContextCompactCard({ names, onClick }: { names: string[]; onClick: () => void }) { return ( -
+
Qovery services From a9cf213a14a8184694f73697b7e4a1a9b5291500 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 09:32:09 +0200 Subject: [PATCH 04/38] fix(agent-task): reset service context selection --- .../qovery-service-context-modal.spec.tsx | 18 +++++++++++ .../context/qovery-service-context-modal.tsx | 30 +++++++++++++------ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx index 8c2d395a912..ce1a836776b 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx @@ -35,4 +35,22 @@ describe('QoveryServiceContextModal', () => { expect(screen.getByText('api')).toBeInTheDocument() }) + + it('resets all selected services', async () => { + const onSave = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Reset all' })) + await userEvent.click(screen.getByRole('button', { name: 'Apply changes' })) + + expect(onSave).toHaveBeenCalledWith([]) + }) }) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx index 5abfed774f3..1b18a69c156 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx @@ -37,20 +37,32 @@ export function QoveryServiceContextModal({ isLoading={isLoading} onChange={(ids) => setSelectedIds(ids as string[])} /> -
- +
+
+ + +
) From 9e05a4822a1d9255b775597b379ca69662cc7291 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 09:33:36 +0200 Subject: [PATCH 05/38] fix(agent-task): style context reset action --- .../context/qovery-service-context-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx index 1b18a69c156..966ce630b64 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx @@ -41,7 +41,7 @@ export function QoveryServiceContextModal({ -
+ {selectedIds.length > 0 ? ( + + ) : null} +
From 3d3ba01d7c37aebb224d488d08ab3e5920ad644b Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 09:37:38 +0200 Subject: [PATCH 07/38] fix(agent-task): update service context label --- .../context/qovery-service-context-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx index 7bad9ce9bb2..f590b0728d2 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx @@ -6,7 +6,7 @@ export function QoveryServiceContextCompactCard({ names, onClick }: { names: str
- Qovery services + QOVERY SERVICE
{names.join(', ')}
- - - } - onSelect={() => openGitContext()} - > - Git repository - - } - onSelect={openQoveryServiceContext} - > - Qovery services - - - + {values.gitRepositories.some(isGitRepositoryComplete) || values.contextServices.length > 0 ? ( + + + + + + } + onSelect={() => openGitContext()} + > + Git repository + + } + onSelect={openQoveryServiceContext} + > + Qovery services + + + + ) : null}
- {values.gitRepositories.some(isGitRepositoryComplete) - ? values.gitRepositories.map((repository, index) => + {values.gitRepositories.some(isGitRepositoryComplete) || values.contextServices.length > 0 ? ( + <> + {values.gitRepositories.map((repository, index) => isGitRepositoryComplete(repository) ? ( openGitContext(index)} /> ) : null - ) - : null} - {values.contextServices.length > 0 ? ( - name)} - onClick={openQoveryServiceContext} - /> - ) : null} + )} + {values.contextServices.length > 0 ? ( + name)} + onClick={openQoveryServiceContext} + /> + ) : null} + + ) : ( + <> + + openGitContext()} /> + + )}
diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx index 8d9d470bfc3..921ad396381 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-card.tsx @@ -1,6 +1,28 @@ import { IconEnum } from '@qovery/shared/enums' import { Button, Icon } from '@qovery/shared/ui' +export function QoveryServiceContextCard({ onClick }: { onClick: () => void }) { + return ( + + ) +} + export function QoveryServiceContextCompactCard({ names, onClick }: { names: string[]; onClick: () => void }) { return (
From 92d3d40666ea72dddfd6c9e4873745451fa68276 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 09:44:01 +0200 Subject: [PATCH 10/38] fix(agent-task): list Qovery context services --- .../agentic-workflow-configuration.spec.tsx | 3 +- .../qovery-service-context-modal.spec.tsx | 8 ++- .../context/qovery-service-context-modal.tsx | 54 +++++++++++++++---- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx index c4c736af6cd..27ea514dd81 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx @@ -226,7 +226,8 @@ describe('AgenticWorkflowConfiguration', () => { await userEvent.click(screen.getByRole('button', { name: /Import from existing Qovery environment/ })) expect(screen.getByRole('heading', { name: 'Add Qovery services' })).toBeInTheDocument() - expect(screen.getByLabelText('Qovery services')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Services to include' })).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'api' })).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Cancel' })) await userEvent.click(screen.getByRole('button', { name: 'Anthropic' })) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx index 4915f0f29fe..0d3f8409cc7 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx @@ -1,4 +1,3 @@ -import selectEvent from 'react-select-event' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { QoveryServiceContextModal } from './qovery-service-context-modal' @@ -14,9 +13,8 @@ describe('QoveryServiceContextModal', () => { ) - await selectEvent.select(screen.getByLabelText('Qovery services'), ['api', 'postgres'], { - container: document.body, - }) + await userEvent.click(screen.getByRole('checkbox', { name: 'api' })) + await userEvent.click(screen.getByRole('checkbox', { name: 'postgres' })) expect(screen.getByRole('button', { name: 'Reset all' })).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Apply changes' })) @@ -34,7 +32,7 @@ describe('QoveryServiceContextModal', () => { /> ) - expect(screen.getByText('api')).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'api' })).toBeChecked() }) it('hides the reset action when no service is selected', () => { diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx index 1d4b18f3e9b..f31790d948b 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Button, Heading, InputSelect, Section } from '@qovery/shared/ui' +import { Button, Checkbox, Heading, Icon, Section } from '@qovery/shared/ui' import { type AgenticWorkflowContextService } from '../../agentic-workflow-context' export function QoveryServiceContextModal({ @@ -27,16 +27,48 @@ export function QoveryServiceContextModal({ Select services from this environment to give the agent their Qovery context.

- ({ value: id, label: name, description: type }))} - isLoading={isLoading} - onChange={(ids) => setSelectedIds(ids as string[])} - /> +
+
+

Services to include

+ +
+
+ {isLoading ? ( +

Loading services...

+ ) : ( + services.map(({ id, name, type }) => { + const checked = selectedIds.includes(id) + + return ( + + ) + }) + )} +
+
{selectedIds.length > 0 ? ( From be92e5281a1c4dd4ad341019506202fac4abe610 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 09:47:29 +0200 Subject: [PATCH 12/38] fix(agent-task): hide completed select all action --- .../qovery-service-context-modal.spec.tsx | 2 ++ .../context/qovery-service-context-modal.tsx | 21 ++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx index 0d3f8409cc7..53957d04cce 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx @@ -16,6 +16,7 @@ describe('QoveryServiceContextModal', () => { await userEvent.click(screen.getByRole('checkbox', { name: 'api' })) await userEvent.click(screen.getByRole('checkbox', { name: 'postgres' })) expect(screen.getByRole('button', { name: 'Reset all' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Select all' })).not.toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Apply changes' })) expect(onSave).toHaveBeenCalledWith(services) @@ -33,6 +34,7 @@ describe('QoveryServiceContextModal', () => { ) expect(screen.getByRole('checkbox', { name: 'api' })).toBeChecked() + expect(screen.getByRole('button', { name: 'Select all' })).toBeInTheDocument() }) it('hides the reset action when no service is selected', () => { diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx index f31790d948b..f04869524ea 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx @@ -30,16 +30,17 @@ export function QoveryServiceContextModal({

Services to include

- + {!isLoading && selectedIds.length < services.length ? ( + + ) : null}
{isLoading ? ( From 4792d0351b239d5d7883ce1c7c785ebd26d84cf5 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 09:57:49 +0200 Subject: [PATCH 13/38] fix(agent-task): refine service import modal --- .../agentic-workflow-configuration.spec.tsx | 2 +- .../qovery-service-context-modal.spec.tsx | 35 +------------------ .../context/qovery-service-context-modal.tsx | 15 +++----- 3 files changed, 7 insertions(+), 45 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx index 02b7971732f..c587fc0a438 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx @@ -225,7 +225,7 @@ describe('AgenticWorkflowConfiguration', () => { await userEvent.click(screen.getByRole('button', { name: 'Cancel' })) await userEvent.click(screen.getByRole('button', { name: /Add Qovery services/ })) - expect(screen.getByRole('heading', { name: 'Add Qovery services' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Import existing Qovery services' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Services to include' })).toBeInTheDocument() expect(screen.getByRole('checkbox', { name: 'api' })).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Cancel' })) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx index 53957d04cce..049cff10f76 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx @@ -15,9 +15,8 @@ describe('QoveryServiceContextModal', () => { await userEvent.click(screen.getByRole('checkbox', { name: 'api' })) await userEvent.click(screen.getByRole('checkbox', { name: 'postgres' })) - expect(screen.getByRole('button', { name: 'Reset all' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Select all' })).not.toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: 'Apply changes' })) + await userEvent.click(screen.getByRole('button', { name: 'Confirm' })) expect(onSave).toHaveBeenCalledWith(services) }) @@ -36,36 +35,4 @@ describe('QoveryServiceContextModal', () => { expect(screen.getByRole('checkbox', { name: 'api' })).toBeChecked() expect(screen.getByRole('button', { name: 'Select all' })).toBeInTheDocument() }) - - it('hides the reset action when no service is selected', () => { - renderWithProviders( - - ) - - expect(screen.queryByRole('button', { name: 'Reset all' })).not.toBeInTheDocument() - }) - - it('resets all selected services', async () => { - const onSave = jest.fn() - const { userEvent } = renderWithProviders( - - ) - - await userEvent.click(screen.getByRole('button', { name: 'Reset all' })) - await userEvent.click(screen.getByRole('button', { name: 'Apply changes' })) - - expect(onSave).toHaveBeenCalledWith([]) - }) }) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx index f04869524ea..43ddbde74e4 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx @@ -21,10 +21,10 @@ export function QoveryServiceContextModal({
- Add Qovery services + Import existing Qovery services

- Select services from this environment to give the agent their Qovery context. + Link Qovery services from this environment. The agent will use them as context.

@@ -70,13 +70,8 @@ export function QoveryServiceContextModal({ )}
-
- {selectedIds.length > 0 ? ( - - ) : null} -
+
+
@@ -88,7 +83,7 @@ export function QoveryServiceContextModal({ setOpen?.(false) }} > - Apply changes + Confirm
From b7bde493d75efb5bfc81caea4306a4b29d560051 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 10:03:58 +0200 Subject: [PATCH 14/38] fix(agent-task): preserve select all spacing --- .../context/qovery-service-context-modal.tsx | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx index 43ddbde74e4..4f626db0a44 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import { useState } from 'react' import { Button, Checkbox, Heading, Icon, Section } from '@qovery/shared/ui' import { type AgenticWorkflowContextService } from '../../agentic-workflow-context' @@ -16,6 +17,7 @@ export function QoveryServiceContextModal({ value: AgenticWorkflowContextService[] }) { const [selectedIds, setSelectedIds] = useState(value.map(({ id }) => id)) + const hideSelectAll = isLoading || selectedIds.length === services.length return (
@@ -30,17 +32,18 @@ export function QoveryServiceContextModal({

Services to include

- {!isLoading && selectedIds.length < services.length ? ( - - ) : null} +
{isLoading ? ( From dec18ad98ee9853b8fd19a9ed915c2acb42391d0 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 10:28:43 +0200 Subject: [PATCH 15/38] feat(agent-task): connect Qovery MCP to service context --- .../service/create/agentic-workflow/route.tsx | 1 + .../domains-organizations-data-access.spec.ts | 10 +++ .../lib/domains-organizations-data-access.ts | 11 +++ .../organizations/feature/src/index.ts | 1 + .../use-create-qovery-mcp-server.ts | 21 +++++ .../agentic-workflow-configuration.spec.tsx | 73 +++++++++++++++- .../agentic-workflow-configuration.tsx | 87 ++++++++++++++----- .../context/qovery-service-context-modal.tsx | 15 +++- .../mcp/mcp-sheet.spec.tsx | 29 ++++++- .../mcp/mcp-sheet.tsx | 44 ++++++++-- .../mcp/qovery-mcp-server.spec.ts | 11 +++ .../mcp/qovery-mcp-server.ts | 7 ++ .../agentic-workflow-context.tsx | 4 + .../agentic-workflow-templates.spec.ts | 7 ++ .../agentic-workflow-templates.ts | 4 + package.json | 2 +- yarn.lock | 10 +-- 17 files changed, 296 insertions(+), 41 deletions(-) create mode 100644 libs/domains/organizations/feature/src/lib/hooks/use-create-qovery-mcp-server/use-create-qovery-mcp-server.ts create mode 100644 libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/qovery-mcp-server.spec.ts create mode 100644 libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/qovery-mcp-server.ts diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/agentic-workflow/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/agentic-workflow/route.tsx index 1f66f512122..f8cccae4706 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/agentic-workflow/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/agentic-workflow/route.tsx @@ -30,6 +30,7 @@ function RouteComponent() { return ( diff --git a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts index f6a8fbf2c0d..5806761a976 100644 --- a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts +++ b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts @@ -72,6 +72,16 @@ describe('MCP server mutations', () => { expect(result).toEqual(mockData) }) + it('should create the read-only Qovery MCP server', async () => { + const mockData = { id: 'qovery-mcp', name: 'Qovery MCP', url: 'https://mcp.qovery.com/mcp' } + jest.spyOn(MCPServersApi.prototype, 'createQoveryMcpServer').mockResolvedValue({ data: mockData } as never) + + const result = await mutations.createQoveryMcpServer({ organizationId: 'org-1' }) + + expect(MCPServersApi.prototype.createQoveryMcpServer).toHaveBeenCalledWith('org-1', undefined) + expect(result).toEqual(mockData) + }) + it('should edit an MCP server', async () => { const mockData = { id: 'mcp-1', ...mcpServerRequest } jest.spyOn(MCPServersApi.prototype, 'editMcpServer').mockResolvedValue({ data: mockData } as never) diff --git a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts index 2804c627a73..7cce133a153 100644 --- a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts +++ b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts @@ -40,6 +40,7 @@ import { OrganizationWebhookApi, type OrganizationWebhookCreateRequest, PlanEnum, + type QoveryMcpServerRequest, type TfVarsDiscoveryMode, } from 'qovery-typescript-axios' import { match } from 'ts-pattern' @@ -648,6 +649,16 @@ export const mutations = { const response = await mcpServersApi.createMcpServer(organizationId, mcpServerRequest) return response.data }, + async createQoveryMcpServer({ + organizationId, + qoveryMcpServerRequest, + }: { + organizationId: string + qoveryMcpServerRequest?: QoveryMcpServerRequest + }) { + const response = await mcpServersApi.createQoveryMcpServer(organizationId, qoveryMcpServerRequest) + return response.data + }, async editMcpServer({ mcpServerId, mcpServerRequest, diff --git a/libs/domains/organizations/feature/src/index.ts b/libs/domains/organizations/feature/src/index.ts index d8acc7c85b0..651d5151a5d 100644 --- a/libs/domains/organizations/feature/src/index.ts +++ b/libs/domains/organizations/feature/src/index.ts @@ -99,6 +99,7 @@ export * from './lib/hooks/use-parse-terraform-variables-from-git-repo/use-parse export * from './lib/hooks/use-list-tfvars-files-from-git-repo/use-list-tfvars-files-from-git-repo' export * from './lib/hooks/use-mcp-servers/use-mcp-servers' export * from './lib/hooks/use-create-mcp-server/use-create-mcp-server' +export * from './lib/hooks/use-create-qovery-mcp-server/use-create-qovery-mcp-server' export * from './lib/hooks/use-edit-mcp-server/use-edit-mcp-server' export * from './lib/hooks/use-delete-mcp-server/use-delete-mcp-server' export * from './lib/invoice-banner/invoice-banner' diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-create-qovery-mcp-server/use-create-qovery-mcp-server.ts b/libs/domains/organizations/feature/src/lib/hooks/use-create-qovery-mcp-server/use-create-qovery-mcp-server.ts new file mode 100644 index 00000000000..445fb1b3172 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/hooks/use-create-qovery-mcp-server/use-create-qovery-mcp-server.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { mutations } from '@qovery/domains/organizations/data-access' +import { queries } from '@qovery/state/util-queries' + +export function useCreateQoveryMcpServer() { + const queryClient = useQueryClient() + + return useMutation(mutations.createQoveryMcpServer, { + onSuccess(_, { organizationId }) { + queryClient.invalidateQueries({ + queryKey: queries.organizations.mcpServers({ organizationId }).queryKey, + }) + }, + meta: { + notifyOnSuccess: { + title: 'Qovery MCP has been created', + }, + notifyOnError: true, + }, + }) +} diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx index c587fc0a438..c0bcf29fb15 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx @@ -14,6 +14,8 @@ import { const mockNavigate = jest.fn() const mockCreateService = jest.fn() const mockImportVariables = jest.fn() +const mockCreateQoveryMcpServer = jest.fn() +let mockMcpServers: Array> = [] jest.mock('@tanstack/react-router', () => ({ ...jest.requireActual('@tanstack/react-router'), @@ -43,7 +45,8 @@ jest.mock('@qovery/domains/organizations/feature', () => ({ GitRepositorySetting: () =>
Git repository
, McpServerCreateEditModal: () =>
Create MCP server
, McpServerSetting: () =>
Organization MCP connectors
, - useMcpServers: () => ({ data: [], isLoading: false }), + useCreateQoveryMcpServer: () => ({ mutateAsync: mockCreateQoveryMcpServer }), + useMcpServers: () => ({ data: mockMcpServers, isLoading: false }), })) jest.mock('@qovery/domains/variables/feature', () => ({ @@ -79,13 +82,20 @@ function renderConfiguration({ onExit = jest.fn(), seed, variablesSeed, + requiresQoveryMcp, }: { onExit?: () => void seed?: Partial variablesSeed?: Parameters[0]['variablesSeed'] + requiresQoveryMcp?: boolean } = {}) { return renderWithProviders( - + ) @@ -99,6 +109,16 @@ const validSeed: Partial = { } describe('AgenticWorkflowConfiguration validation', () => { + beforeEach(() => { + mockMcpServers = [] + mockCreateQoveryMcpServer.mockReset().mockResolvedValue({ + id: 'qovery-mcp', + name: 'Qovery MCP', + url: 'https://mcp.qovery.com/mcp', + scope: 'ORGANIZATION', + attachable: true, + }) + }) it('should require valid JSON only when a required JSON field is empty or invalid', () => { expect(getJsonError('', false)).toBeUndefined() expect(getJsonError('', true)).toBe('Please enter a valid JSON configuration.') @@ -162,6 +182,14 @@ describe('AgenticWorkflowConfiguration validation', () => { describe('AgenticWorkflowConfiguration', () => { beforeEach(() => { jest.clearAllMocks() + mockMcpServers = [] + mockCreateQoveryMcpServer.mockResolvedValue({ + id: 'qovery-mcp', + name: 'Qovery MCP', + url: 'https://mcp.qovery.com/mcp', + scope: 'ORGANIZATION', + attachable: true, + }) mockCreateService.mockResolvedValue({ id: 'workflow-1' }) mockImportVariables.mockResolvedValue(undefined) }) @@ -274,6 +302,47 @@ describe('AgenticWorkflowConfiguration', () => { expect(screen.getByRole('heading', { name: 'Manage MCP' })).toBeInTheDocument() }) + it('should create and lock the Qovery MCP when Qovery service context is added', async () => { + const { userEvent } = renderConfiguration() + + await userEvent.click(screen.getByRole('button', { name: /Add Qovery services/ })) + await userEvent.click(screen.getByRole('checkbox', { name: 'api' })) + await userEvent.click(screen.getByRole('button', { name: 'Confirm' })) + + await waitFor(() => expect(mockCreateQoveryMcpServer).toHaveBeenCalledWith({ organizationId: 'org-1' })) + await userEvent.click(screen.getByRole('button', { name: 'Add MCP' })) + + expect(screen.getByRole('button', { name: 'Qovery MCP is required by Qovery service context' })).toBeDisabled() + }) + + it('should automatically select an existing Qovery MCP without creating another one', async () => { + mockMcpServers = [ + { + id: 'existing-qovery-mcp', + name: 'Qovery MCP', + url: 'https://mcp.qovery.com/mcp', + scope: 'ORGANIZATION', + attachable: true, + }, + ] + const { userEvent } = renderConfiguration() + + await userEvent.click(screen.getByRole('button', { name: /Add Qovery services/ })) + await userEvent.click(screen.getByRole('checkbox', { name: 'api' })) + await userEvent.click(screen.getByRole('button', { name: 'Confirm' })) + await userEvent.click(screen.getByRole('button', { name: 'Add MCP' })) + + expect(mockCreateQoveryMcpServer).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Qovery MCP is required by Qovery service context' })).toBeDisabled() + }) + + it('should create and select the Qovery MCP for templates that require it', async () => { + renderConfiguration({ requiresQoveryMcp: true }) + + await waitFor(() => expect(mockCreateQoveryMcpServer).toHaveBeenCalledWith({ organizationId: 'org-1' })) + expect(screen.getByText('Qovery MCP')).toBeInTheDocument() + }) + it('should surface validation feedback when a creation action is clicked with incomplete configuration', async () => { const { userEvent } = renderConfiguration() const createButton = screen.getByRole('button', { name: 'Create' }) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx index 651d4c0c3d3..fb0c11d61d1 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx @@ -2,9 +2,9 @@ import { useNavigate, useParams } from '@tanstack/react-router' import clsx from 'clsx' import posthog from 'posthog-js' import { APIVariableScopeEnum, type McpServerResponse } from 'qovery-typescript-axios' -import { type ReactNode, useRef, useState } from 'react' +import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { Controller, FormProvider, useFieldArray } from 'react-hook-form' -import { useMcpServers } from '@qovery/domains/organizations/feature' +import { useCreateQoveryMcpServer, useMcpServers } from '@qovery/domains/organizations/feature' import { VariableRow, useImportVariables } from '@qovery/domains/variables/feature' import { IconEnum } from '@qovery/shared/enums' import { type VariableData } from '@qovery/shared/interfaces' @@ -19,6 +19,7 @@ import { InputTextArea, Modal, Section, + Tooltip, useModal, } from '@qovery/shared/ui' import { @@ -44,6 +45,7 @@ import { QoveryServiceContextCard, QoveryServiceContextCompactCard } from './con import { QoveryServiceContextModal } from './context/qovery-service-context-modal' import { AgenticWorkflowHeader, type AgenticWorkflowHeaderHandle } from './header/agentic-workflow-header' import { McpSheet } from './mcp/mcp-sheet' +import { isQoveryMcpServer } from './mcp/qovery-mcp-server' type SettingsGroup = 'general' | 'resources' | 'governance' | 'variables' | 'advanced' @@ -279,7 +281,8 @@ export function AgenticWorkflowConfiguration() { useAgenticWorkflowContextServices(environmentId) const navigate = useNavigate() const { closeModal, openModal } = useModal() - const { form, onExit, variablesForm } = useAgenticWorkflowCreateContext() + const { form, onExit, requiresQoveryMcp, variablesForm } = useAgenticWorkflowCreateContext() + const { mutateAsync: createQoveryMcpServer } = useCreateQoveryMcpServer() const { isLoading: isCreating, mutateAsync: createService } = useCreateService({ organizationId }) const { isLoading: isImportingVariables, mutateAsync: importVariables } = useImportVariables() const { @@ -309,6 +312,7 @@ export function AgenticWorkflowConfiguration() { const headerRef = useRef(null) const promptEditorRef = useRef(null) const createdServiceIdRef = useRef() + const qoveryMcpInitializationStartedRef = useRef(false) const values = form.watch() const { dirtyFields } = form.formState const modelSettingsJsonError = getJsonError(values.modelSettingsJson, true) @@ -331,6 +335,35 @@ export function AgenticWorkflowConfiguration() { const availableMcpServers = [...mcpServers, ...createdMcpServers].filter( (mcpServer, index, servers) => servers.findIndex(({ id }) => id === mcpServer.id) === index ) + const qoveryMcpServer = availableMcpServers.find(isQoveryMcpServer) + const ensureQoveryMcpServer = useCallback(async () => { + const existingQoveryMcpServer = [...mcpServers, ...createdMcpServers].find(isQoveryMcpServer) + const mcpServer = + existingQoveryMcpServer ?? + (await createQoveryMcpServer({ + organizationId, + })) + + if (!existingQoveryMcpServer) { + setCreatedMcpServers((servers) => + servers.some(({ id }) => id === mcpServer.id) ? servers : [...servers, mcpServer] + ) + } + + const selectedMcpServerIds = form.getValues('mcpServerIds') + if (!selectedMcpServerIds.includes(mcpServer.id)) { + form.setValue('mcpServerIds', [...selectedMcpServerIds, mcpServer.id], { shouldDirty: true }) + } + + return mcpServer + }, [createQoveryMcpServer, createdMcpServers, form, mcpServers, organizationId]) + + useEffect(() => { + if (!requiresQoveryMcp || areMcpServersLoading || qoveryMcpInitializationStartedRef.current) return + + qoveryMcpInitializationStartedRef.current = true + void ensureQoveryMcpServer().catch(() => undefined) + }, [areMcpServersLoading, ensureQoveryMcpServer, requiresQoveryMcp]) const openGitContext = (index?: number) => { const editingContext = typeof index === 'number' ? values.gitRepositories[index] : undefined @@ -383,7 +416,10 @@ export function AgenticWorkflowConfiguration() { setOpen={(open) => { if (!open) closeModal() }} - onSave={(services) => form.setValue('contextServices', services, { shouldDirty: true })} + onSave={async (services) => { + if (services.length > 0) await ensureQoveryMcpServer() + form.setValue('contextServices', services, { shouldDirty: true }) + }} /> ), options: { @@ -843,24 +879,32 @@ export function AgenticWorkflowConfiguration() { className="flex h-7 max-w-full items-center rounded border border-neutral bg-surface-neutral pl-2 pr-1 text-ssm font-medium text-neutral" > {name} - + + + +
))} ) + + return locked ? ( + + {row} + + ) : ( + {row} + ) } return ( @@ -114,7 +136,14 @@ function McpServerPicker({ Connected ({connectedMcpServers.length}) -
@@ -150,6 +179,7 @@ function McpServerPicker({ export function McpSheet({ createdMcpServers, isLoading, + lockedMcpServerIds = [], mcpServers, onChange, onClose, @@ -158,6 +188,7 @@ export function McpSheet({ }: { createdMcpServers: McpServerResponse[] isLoading: boolean + lockedMcpServerIds?: string[] mcpServers: McpServerResponse[] onChange: (value: string[]) => void onClose: () => void @@ -171,6 +202,7 @@ export function McpSheet({ { + it.each(['https://mcp.qovery.com/mcp', 'https://mcp.qovery.com/mcp/'])('recognizes the Qovery MCP URL %s', (url) => { + expect(isQoveryMcpServer({ url })).toBe(true) + }) + + it('rejects a custom MCP URL', () => { + expect(isQoveryMcpServer({ url: 'https://example.com/mcp' })).toBe(false) + }) +}) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/qovery-mcp-server.ts b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/qovery-mcp-server.ts new file mode 100644 index 00000000000..7df3afc924e --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/qovery-mcp-server.ts @@ -0,0 +1,7 @@ +import { type McpServerResponse } from 'qovery-typescript-axios' + +export const QOVERY_MCP_SERVER_URL = 'https://mcp.qovery.com/mcp' + +export function isQoveryMcpServer({ url }: Pick) { + return url.replace(/\/+$/, '') === QOVERY_MCP_SERVER_URL +} diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx index 26d1ba54ab4..76dd311dc9e 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx @@ -111,6 +111,7 @@ export interface AgenticWorkflowFormData { export interface AgenticWorkflowCreateContextInterface { form: UseFormReturn onExit: () => void + requiresQoveryMcp: boolean variablesForm: UseFormReturn } @@ -153,12 +154,14 @@ export interface AgenticWorkflowCreationFlowProps extends PropsWithChildren { // A template use case pre-fills part of the form and its variables when the // flow is entered with a `?template=` param (see agentic-workflow-templates.ts). seed?: Partial + requiresQoveryMcp?: boolean variablesSeed?: FlowVariableData['variables'] } export function AgenticWorkflowCreationFlow({ children, onExit, + requiresQoveryMcp = false, seed, variablesSeed, }: AgenticWorkflowCreationFlowProps) { @@ -183,6 +186,7 @@ export function AgenticWorkflowCreationFlow({ value={{ form, onExit, + requiresQoveryMcp, variablesForm, }} > diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-templates.spec.ts b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-templates.spec.ts index 9b6e24ce61b..8cb451f7c88 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-templates.spec.ts +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-templates.spec.ts @@ -70,6 +70,13 @@ describe('agentic-workflow-templates', () => { expect(template?.darkLogoPath).toBe('/assets/agent-templates/linear-light.svg') }) + it.each(['incident-io-analyzer', 'honeybadger-incident-analyzer', 'build-optimizer'])( + 'requires the Qovery MCP for the %s template', + (id) => { + expect(getAgenticWorkflowTemplate(id)?.requiresQoveryMcp).toBe(true) + } + ) + it('exposes the Build & deployment optimizer use case', () => { const template = getAgenticWorkflowTemplate('build-optimizer') expect(template).toBeDefined() diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-templates.ts b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-templates.ts index 8abbb55617b..54f28e113c4 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-templates.ts +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-templates.ts @@ -10,6 +10,7 @@ export interface AgenticWorkflowTemplate { iconName?: IconName logoPath?: string darkLogoPath?: string + requiresQoveryMcp?: boolean seed: Partial variables?: VariableData[] } @@ -79,6 +80,7 @@ export const AGENTIC_WORKFLOW_TEMPLATES: AgenticWorkflowTemplate[] = [ title: 'Incident Analyzer with incident.io', description: 'Analyze incident.io incidents with deployment, code, logs, and metrics context.', logoPath: '/assets/agent-templates/incident-io.svg', + requiresQoveryMcp: true, seed: { name: 'Incident Analyzer with incident.io', description: 'Analyze incident.io incidents with deployment, code, logs, and metrics context.', @@ -96,6 +98,7 @@ export const AGENTIC_WORKFLOW_TEMPLATES: AgenticWorkflowTemplate[] = [ title: 'Incident Analyzer with Honeybadger', description: 'Analyze Honeybadger incidents with deployment, code, logs, and metrics context.', logoPath: '/assets/agent-templates/honeybadger.svg', + requiresQoveryMcp: true, seed: { name: 'Incident Analyzer with Honeybadger', description: 'Analyze Honeybadger incidents with deployment, code, logs, and metrics context.', @@ -113,6 +116,7 @@ export const AGENTIC_WORKFLOW_TEMPLATES: AgenticWorkflowTemplate[] = [ title: 'Build & deployment optimizer', description: 'Analyse build and deployment times, identify optimization levers, and open a PR with the changes.', iconName: 'gauge-high', + requiresQoveryMcp: true, seed: { name: 'Build & deployment optimizer', description: 'Analyse build and deployment times, identify optimization levers, and open a PR with the changes.', diff --git a/package.json b/package.json index aff125a38a8..5b0538d4035 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "mermaid": "11.6.0", "monaco-editor": "0.53.0", "posthog-js": "1.345.1", - "qovery-typescript-axios": "1.1.972", + "qovery-typescript-axios": "1.1.974", "react": "18.3.1", "react-country-flag": "3.0.2", "react-datepicker": "4.12.0", diff --git a/yarn.lock b/yarn.lock index a6270e881a8..b912773e76d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6438,7 +6438,7 @@ __metadata: prettier: 3.2.5 prettier-plugin-tailwindcss: 0.5.14 pretty-quick: 4.0.0 - qovery-typescript-axios: 1.1.972 + qovery-typescript-axios: 1.1.974 qovery-ws-typescript-axios: 0.1.646 react: 18.3.1 react-country-flag: 3.0.2 @@ -25964,12 +25964,12 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:1.1.972": - version: 1.1.972 - resolution: "qovery-typescript-axios@npm:1.1.972" +"qovery-typescript-axios@npm:1.1.974": + version: 1.1.974 + resolution: "qovery-typescript-axios@npm:1.1.974" dependencies: axios: 1.18.1 - checksum: faba51ff348d8ee3bb7ebcfeed25bbbdd7e838ee4901bb55e5e45981f5333316a64732866746984a693afbc7441f565146d1cb51bd380c9a97f1205ad35995bd + checksum: 3ab1609af28a1fc660e1b9c6c370ba0275b951339f90e81ccef5bd20b446973e615360413f222ed47613cf956c41832e9eeedc79770b1c0450eb70af2e4bd995 languageName: node linkType: hard From ded735ee0a116b2f01d1169314971a62024936ac Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 10:38:15 +0200 Subject: [PATCH 16/38] fix(agent-task): align context styling --- .../context/git-context-card.spec.tsx | 8 ++++---- .../context/git-context-card.tsx | 8 +------- .../context/qovery-service-context-modal.tsx | 2 +- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.spec.tsx index 14cb677505e..a4d4e13a7b7 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.spec.tsx @@ -13,17 +13,17 @@ describe('GitContextCard', () => { }) describe('GitContextCompactCard', () => { - it('shows a friendly provider label and the repository', () => { + it('shows an uppercase provider label and the repository', () => { renderWithProviders() - expect(screen.getByText('GitHub')).toBeInTheDocument() + expect(screen.getByText('GITHUB')).toBeInTheDocument() expect(screen.getByText('Qovery/console')).toBeInTheDocument() }) it('falls back to a generic Git label when no provider', () => { renderWithProviders() - expect(screen.getByText('Git')).toBeInTheDocument() + expect(screen.getByText('GIT')).toBeInTheDocument() }) it('calls onClick from the manage button', async () => { @@ -32,7 +32,7 @@ describe('GitContextCompactCard', () => { ) - expect(screen.getByText('GitLab')).toBeInTheDocument() + expect(screen.getByText('GITLAB')).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Manage context' })) expect(onClick).toHaveBeenCalled() diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.tsx index baaed0a5981..258d48b9596 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.tsx @@ -1,12 +1,6 @@ import { IconEnum } from '@qovery/shared/enums' import { Button, Icon } from '@qovery/shared/ui' -const PROVIDER_LABELS: Record = { - BITBUCKET: 'Bitbucket', - GITHUB: 'GitHub', - GITLAB: 'GitLab', -} - export function GitContextCard({ onClick }: { onClick: () => void }) { return ( - + )} +
+ ) + + return locked ? ( + +
{chip}
-
- ))} + ) : ( +
{chip}
+ ) + })}
-

{mcpServer.name}

+

{displayName}

{mcpServer.scope === McpServerScope.USER ? `Personal · ${mcpServer.owner_name ?? 'Unknown owner'}` diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/qovery-mcp-server.spec.ts b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/qovery-mcp-server.spec.ts index 0bfea7fa93f..7bbe2318630 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/qovery-mcp-server.spec.ts +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/qovery-mcp-server.spec.ts @@ -1,4 +1,4 @@ -import { isQoveryMcpServer } from './qovery-mcp-server' +import { getMcpServerDisplayName, isQoveryMcpServer } from './qovery-mcp-server' describe('isQoveryMcpServer', () => { it.each(['https://mcp.qovery.com/mcp', 'https://mcp.qovery.com/mcp/'])('recognizes the Qovery MCP URL %s', (url) => { @@ -8,4 +8,8 @@ describe('isQoveryMcpServer', () => { it('rejects a custom MCP URL', () => { expect(isQoveryMcpServer({ url: 'https://example.com/mcp' })).toBe(false) }) + + it('uses a consistent name for the Qovery MCP', () => { + expect(getMcpServerDisplayName({ name: 'qovery', url: 'https://mcp.qovery.com/mcp' })).toBe('MCP Qovery') + }) }) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/qovery-mcp-server.ts b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/qovery-mcp-server.ts index 7df3afc924e..d1c05c53f5b 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/qovery-mcp-server.ts +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/qovery-mcp-server.ts @@ -5,3 +5,7 @@ export const QOVERY_MCP_SERVER_URL = 'https://mcp.qovery.com/mcp' export function isQoveryMcpServer({ url }: Pick) { return url.replace(/\/+$/, '') === QOVERY_MCP_SERVER_URL } + +export function getMcpServerDisplayName(mcpServer: Pick) { + return isQoveryMcpServer(mcpServer) ? 'MCP Qovery' : mcpServer.name +} From c145bbbc76caef04c4a29334e0599ab168e98d39 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 10:42:46 +0200 Subject: [PATCH 18/38] fix(agent-task): keep default cursor on required MCP --- .../agentic-workflow-configuration.tsx | 2 +- .../agentic-workflow-configuration/mcp/mcp-sheet.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx index faa64567129..a7fb91c219e 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx @@ -881,7 +881,7 @@ export function AgenticWorkflowConfiguration() {

{name} diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/mcp-sheet.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/mcp-sheet.tsx index 7570eb97617..a37ce712fca 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/mcp-sheet.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/mcp/mcp-sheet.tsx @@ -87,7 +87,7 @@ function McpServerPicker({ + ) : null}
Import existing Qovery services @@ -74,9 +89,17 @@ export function QoveryServiceContextModal({ )}
+ {saveError ?

{saveError}

: null}
-
From eb1fc583f40095b72e90919d4fb62e3d26aff866 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 10:58:09 +0200 Subject: [PATCH 21/38] fix(agent-task): show preconfigured Qovery MCP --- .../agentic-workflow-configuration.spec.tsx | 20 +++++++++++++++++-- .../agentic-workflow-configuration.tsx | 16 ++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx index d7ae12fc26f..fabc519dac7 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx @@ -53,7 +53,7 @@ jest.mock('@qovery/domains/organizations/feature', () => ({ GitRepositorySetting: () =>
Git repository
, McpServerCreateEditModal: () =>
Create MCP server
, McpServerSetting: () =>
Organization MCP connectors
, - useCreateQoveryMcpServer: () => ({ mutateAsync: mockCreateQoveryMcpServer }), + useCreateQoveryMcpServer: () => ({ isLoading: false, mutateAsync: mockCreateQoveryMcpServer }), useMcpServers: () => ({ data: mockMcpServers, isLoading: false }), })) @@ -348,7 +348,7 @@ describe('AgenticWorkflowConfiguration', () => { renderConfiguration({ requiresQoveryMcp: true }) await waitFor(() => expect(mockCreateQoveryMcpServer).toHaveBeenCalledWith({ organizationId: 'org-1' })) - expect(await screen.findByText('MCP Qovery')).toBeInTheDocument() + expect(await screen.findByRole('button', { name: 'Remove MCP Qovery' })).toBeInTheDocument() }) it('should select an existing Qovery MCP using its origin URL for templates that require it', async () => { @@ -400,6 +400,22 @@ describe('AgenticWorkflowConfiguration', () => { expect(mockCreateQoveryMcpServer).toHaveBeenCalledTimes(1) }) + it('should immediately show the preconfigured Qovery MCP while a template initializes it', async () => { + const creation = deferred<{ + id: string + name: string + url: string + scope: string + attachable: boolean + }>() + mockCreateQoveryMcpServer.mockReturnValue(creation.promise) + + renderConfiguration({ requiresQoveryMcp: true }) + + expect(screen.getByText('MCP Qovery')).toBeInTheDocument() + expect(mockCreateQoveryMcpServer).toHaveBeenCalledTimes(1) + }) + it('should wait for the required Qovery MCP before creating from a template', async () => { const creation = deferred<{ id: string diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx index f272aafb3b5..019c4299ca4 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx @@ -282,7 +282,7 @@ export function AgenticWorkflowConfiguration() { const navigate = useNavigate() const { closeModal, openModal } = useModal() const { form, onExit, requiresQoveryMcp, variablesForm } = useAgenticWorkflowCreateContext() - const { mutateAsync: createQoveryMcpServer } = useCreateQoveryMcpServer() + const { isLoading: isCreatingQoveryMcpServer, mutateAsync: createQoveryMcpServer } = useCreateQoveryMcpServer() const { isLoading: isCreating, mutateAsync: createService } = useCreateService({ organizationId }) const { isLoading: isImportingVariables, mutateAsync: importVariables } = useImportVariables() const { @@ -882,6 +882,20 @@ export function AgenticWorkflowConfiguration() { ) : null} + {requiresQoveryMcp && !qoveryMcpServer ? ( +
+ MCP Qovery + {isCreatingQoveryMcpServer ? ( + + + + ) : null} +
+ ) : null} {availableMcpServers .filter(({ id }) => values.mcpServerIds.includes(id)) .map((mcpServer) => { From 91f594e062a730a558e4e2ded81302759ad7d52e Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 11:02:47 +0200 Subject: [PATCH 22/38] fix(agent-task): display required Qovery MCP selection --- .../agentic-workflow-configuration.spec.tsx | 24 +++++++++++++++++-- .../agentic-workflow-configuration.tsx | 3 ++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx index fabc519dac7..66f2f166fe7 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx @@ -16,6 +16,7 @@ const mockCreateService = jest.fn() const mockImportVariables = jest.fn() const mockCreateQoveryMcpServer = jest.fn() let mockMcpServers: Array> = [] +let mockMcpServersLoading = false function deferred() { let resolve!: (value: T) => void @@ -54,7 +55,7 @@ jest.mock('@qovery/domains/organizations/feature', () => ({ McpServerCreateEditModal: () =>
Create MCP server
, McpServerSetting: () =>
Organization MCP connectors
, useCreateQoveryMcpServer: () => ({ isLoading: false, mutateAsync: mockCreateQoveryMcpServer }), - useMcpServers: () => ({ data: mockMcpServers, isLoading: false }), + useMcpServers: () => ({ data: mockMcpServers, isLoading: mockMcpServersLoading }), })) jest.mock('@qovery/domains/variables/feature', () => ({ @@ -191,6 +192,7 @@ describe('AgenticWorkflowConfiguration', () => { beforeEach(() => { jest.clearAllMocks() mockMcpServers = [] + mockMcpServersLoading = false mockCreateQoveryMcpServer.mockResolvedValue({ id: 'qovery-mcp', name: 'Qovery MCP', @@ -364,7 +366,7 @@ describe('AgenticWorkflowConfiguration', () => { renderConfiguration({ requiresQoveryMcp: true }) - expect(await screen.findByText('MCP Qovery')).toBeInTheDocument() + expect(await screen.findByRole('button', { name: 'Remove MCP Qovery' })).toBeInTheDocument() expect(mockCreateQoveryMcpServer).not.toHaveBeenCalled() }) @@ -416,6 +418,24 @@ describe('AgenticWorkflowConfiguration', () => { expect(mockCreateQoveryMcpServer).toHaveBeenCalledTimes(1) }) + it('should show the preconfigured Qovery MCP when it exists but is not selected yet', () => { + mockMcpServers = [ + { + id: 'existing-qovery-mcp', + name: 'qovery', + url: 'https://mcp.qovery.com', + scope: 'ORGANIZATION', + attachable: true, + }, + ] + mockMcpServersLoading = true + + renderConfiguration({ requiresQoveryMcp: true }) + + expect(screen.getByText('MCP Qovery')).toBeInTheDocument() + expect(mockCreateQoveryMcpServer).not.toHaveBeenCalled() + }) + it('should wait for the required Qovery MCP before creating from a template', async () => { const creation = deferred<{ id: string diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx index 019c4299ca4..668671f0bad 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx @@ -337,6 +337,7 @@ export function AgenticWorkflowConfiguration() { (mcpServer, index, servers) => servers.findIndex(({ id }) => id === mcpServer.id) === index ) const qoveryMcpServer = availableMcpServers.find(isQoveryMcpServer) + const isQoveryMcpSelected = Boolean(qoveryMcpServer && values.mcpServerIds.includes(qoveryMcpServer.id)) const ensureQoveryMcpServer = useCallback(async () => { const existingQoveryMcpServer = [...mcpServers, ...createdMcpServers].find(isQoveryMcpServer) if (!qoveryMcpInitializationPromiseRef.current) { @@ -882,7 +883,7 @@ export function AgenticWorkflowConfiguration() { ) : null}
- {requiresQoveryMcp && !qoveryMcpServer ? ( + {requiresQoveryMcp && !isQoveryMcpSelected ? (
MCP Qovery {isCreatingQoveryMcpServer ? ( From a2c16147da038cabed3ba99e04000debdf2ca64c Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 11:08:34 +0200 Subject: [PATCH 23/38] fix(agent-task): preserve template during configuration redirect --- .../$environmentId/service/create/agentic-workflow/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/agentic-workflow/index.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/agentic-workflow/index.tsx index ab9b892b66c..62b60d93250 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/agentic-workflow/index.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/agentic-workflow/index.tsx @@ -13,6 +13,7 @@ function RouteComponent() { search} replace /> ) From 7d9cc95e0a70823125f654121ed69c2529ba3bf4 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 11:17:00 +0200 Subject: [PATCH 24/38] fix(agent-task): address context review feedback --- .../agentic-workflow-configuration.spec.tsx | 26 +++++++++++++++- .../agentic-workflow-configuration.tsx | 30 +++++++++++-------- .../context/qovery-service-context-card.tsx | 14 +++++++-- .../qovery-service-context-modal.spec.tsx | 22 ++++++++++++++ .../context/qovery-service-context-modal.tsx | 4 +-- 5 files changed, 78 insertions(+), 18 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx index 66f2f166fe7..60f50a9cb2a 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx @@ -17,6 +17,7 @@ const mockImportVariables = jest.fn() const mockCreateQoveryMcpServer = jest.fn() let mockMcpServers: Array> = [] let mockMcpServersLoading = false +let mockContextServicesLoading = false function deferred() { let resolve!: (value: T) => void @@ -44,7 +45,7 @@ jest.mock('../../../hooks/use-agentic-workflow-context-services/use-agentic-work { id: 'application-1', name: 'api', type: 'APPLICATION' }, { id: 'database-1', name: 'postgres', type: 'DATABASE' }, ], - isLoading: false, + isLoading: mockContextServicesLoading, }), })) @@ -193,6 +194,7 @@ describe('AgenticWorkflowConfiguration', () => { jest.clearAllMocks() mockMcpServers = [] mockMcpServersLoading = false + mockContextServicesLoading = false mockCreateQoveryMcpServer.mockResolvedValue({ id: 'qovery-mcp', name: 'Qovery MCP', @@ -305,6 +307,28 @@ describe('AgenticWorkflowConfiguration', () => { expect(screen.getByRole('menuitem', { name: 'Qovery services' })).toBeInTheDocument() }) + it('should keep an incomplete Git repository editable', async () => { + const { userEvent } = renderConfiguration({ + seed: { gitRepositories: [{ provider: 'GITHUB', repository: '', branch: '' }] }, + }) + + expect(screen.getByText('Configure Git repository')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Manage context' })) + + expect(screen.getByRole('heading', { name: 'Edit Git repository' })).toBeInTheDocument() + }) + + it('should prevent opening Qovery service context while services are loading', async () => { + mockContextServicesLoading = true + const { userEvent } = renderConfiguration() + const addQoveryServices = screen.getByRole('button', { name: /Add Qovery services/ }) + + expect(addQoveryServices).toBeDisabled() + await userEvent.click(addQoveryServices) + + expect(screen.queryByRole('heading', { name: 'Import existing Qovery services' })).not.toBeInTheDocument() + }) + it('should manage MCP from a side panel', async () => { const { userEvent } = renderConfiguration() diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx index 668671f0bad..1307fdd2acf 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx @@ -414,6 +414,8 @@ export function AgenticWorkflowConfiguration() { } const openQoveryServiceContext = () => { + if (areContextServicesLoading) return + openModal({ content: (

Context

- {values.gitRepositories.some(isGitRepositoryComplete) || values.contextServices.length > 0 ? ( + {values.gitRepositories.length > 0 || values.contextServices.length > 0 ? ( - ))} + .map((mcpServer) => { + const { id } = mcpServer + const displayName = getMcpServerDisplayName(mcpServer) + const locked = Boolean(qoveryMcpLockReason && qoveryMcpServer?.id === id) + const button = ( + + ) + + return locked ? ( + + {button} + + ) : ( + button + ) + })}
diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx index b3bca77f06c..fbb3f143c0c 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx @@ -18,6 +18,7 @@ const mockCreateQoveryMcpServer = jest.fn() const mockRefetchMcpServers = jest.fn() let mockMcpServers: Array> = [] let mockMcpServersLoading = false +let mockCreateQoveryMcpServerLoading = false let mockContextServicesLoading = false function deferred() { @@ -56,7 +57,10 @@ jest.mock('@qovery/domains/organizations/feature', () => ({ GitRepositorySetting: () =>
Git repository
, McpServerCreateEditModal: () =>
Create MCP server
, McpServerSetting: () =>
Organization MCP connectors
, - useCreateQoveryMcpServer: () => ({ isLoading: false, mutateAsync: mockCreateQoveryMcpServer }), + useCreateQoveryMcpServer: () => ({ + isLoading: mockCreateQoveryMcpServerLoading, + mutateAsync: mockCreateQoveryMcpServer, + }), useMcpServers: () => ({ data: mockMcpServers, isLoading: mockMcpServersLoading, @@ -199,6 +203,7 @@ describe('AgenticWorkflowConfiguration', () => { jest.clearAllMocks() mockMcpServers = [] mockMcpServersLoading = false + mockCreateQoveryMcpServerLoading = false mockContextServicesLoading = false mockRefetchMcpServers.mockImplementation(async () => ({ data: mockMcpServers })) mockCreateQoveryMcpServer.mockResolvedValue({ @@ -471,10 +476,15 @@ describe('AgenticWorkflowConfiguration', () => { attachable: boolean }>() mockCreateQoveryMcpServer.mockReturnValue(creation.promise) + mockCreateQoveryMcpServerLoading = true renderConfiguration({ requiresQoveryMcp: true }) expect(screen.getByText('MCP Qovery')).toBeInTheDocument() + expect(screen.getByLabelText('Configuring MCP Qovery')).toBeInTheDocument() + const createButton = screen.getByRole('button', { name: 'Create' }) + expect(createButton).toHaveClass('pointer-events-none') + expect(within(createButton).getByTestId('spinner')).toBeInTheDocument() expect(mockCreateQoveryMcpServer).toHaveBeenCalledTimes(1) }) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.spec.tsx index a4d4e13a7b7..fee9e210160 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.spec.tsx @@ -13,17 +13,17 @@ describe('GitContextCard', () => { }) describe('GitContextCompactCard', () => { - it('shows an uppercase provider label and the repository', () => { + it('shows a human-readable GitHub provider label and the repository', () => { renderWithProviders() - expect(screen.getByText('GITHUB')).toBeInTheDocument() + expect(screen.getByText('GitHub')).toBeInTheDocument() expect(screen.getByText('Qovery/console')).toBeInTheDocument() }) it('falls back to a generic Git label when no provider', () => { renderWithProviders() - expect(screen.getByText('GIT')).toBeInTheDocument() + expect(screen.getByText('Git')).toBeInTheDocument() }) it('calls onClick from the manage button', async () => { @@ -32,7 +32,7 @@ describe('GitContextCompactCard', () => { ) - expect(screen.getByText('GITLAB')).toBeInTheDocument() + expect(screen.getByText('GitLab')).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Manage context' })) expect(onClick).toHaveBeenCalled() diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.tsx index 258d48b9596..cfd29cdf9c5 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/git-context-card.tsx @@ -1,3 +1,4 @@ +import { match } from 'ts-pattern' import { IconEnum } from '@qovery/shared/enums' import { Button, Icon } from '@qovery/shared/ui' @@ -32,11 +33,16 @@ export function GitContextCompactCard({ provider?: string | null repository: string }) { + const providerLabel = match(provider) + .with('GITHUB', () => 'GitHub') + .with('GITLAB', () => 'GitLab') + .otherwise(() => 'Git') + return (
- {provider?.toUpperCase() ?? 'GIT'} + {providerLabel}
{repository}
+ + + ) +} describe('Modal', () => { let props: ModalProps @@ -48,4 +62,37 @@ describe('Modal', () => { expect(modal).toBeInTheDocument() }) + + it('preserves explicitly supplied content controls', async () => { + const setOpen = jest.fn() + const setCloseDisabled = jest.fn() + + const { userEvent } = renderWithProviders( + + + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Custom close' })) + await userEvent.click(screen.getByRole('button', { name: 'Custom lock' })) + + expect(setOpen).toHaveBeenCalledWith(false) + expect(setCloseDisabled).toHaveBeenCalledWith(true) + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('does not inject content controls into native elements', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation() + + render( + +
Native content
+
+ ) + + const errors = consoleError.mock.calls.flat().join(' ') + expect(errors).not.toContain('setOpen') + expect(errors).not.toContain('setCloseDisabled') + consoleError.mockRestore() + }) }) diff --git a/libs/shared/ui/src/lib/components/modal/modal.tsx b/libs/shared/ui/src/lib/components/modal/modal.tsx index ea48cc711bf..338daf19a28 100644 --- a/libs/shared/ui/src/lib/components/modal/modal.tsx +++ b/libs/shared/ui/src/lib/components/modal/modal.tsx @@ -104,6 +104,14 @@ export const Modal = (props: ModalProps) => { } } + const modalContentProps: ModalContentProps = + typeof children.type !== 'string' && children.type !== Fragment + ? { + setOpen: (children.props as ModalContentProps).setOpen ?? (setExternalOpen ? setExternalOpen : setOpen), + setCloseDisabled: (children.props as ModalContentProps).setCloseDisabled ?? setCloseDisabled, + } + : {} + return ( { className={`modal__content fixed left-1/2 top-[84px] z-modal overflow-hidden rounded-md border border-neutral bg-background shadow-[0_0_32px_rgba(0,0,0,0.08)] ${className}`} >
- {cloneElement( - children, - typeof children.type !== 'string' && children.type !== Fragment - ? { - setOpen: setExternalOpen ? setExternalOpen : setOpen, - setCloseDisabled, - } - : {} - )} + {cloneElement(children, modalContentProps)} {buttonClose && ( - ) : null}
Import existing Qovery services diff --git a/libs/shared/ui/src/lib/components/modal/modal-root.tsx b/libs/shared/ui/src/lib/components/modal/modal-root.tsx index d5ebcd60aa8..84039b2ab52 100644 --- a/libs/shared/ui/src/lib/components/modal/modal-root.tsx +++ b/libs/shared/ui/src/lib/components/modal/modal-root.tsx @@ -5,7 +5,6 @@ import Modal from './modal' interface ModalOptions { width?: number fullScreen?: boolean - buttonClose?: boolean /** * This is a workaround to avoid radix dialog restriction. * Radix use [react-remove-scroll](https://www.npmjs.com/package/react-remove-scroll) to prevent wheel / scroll event directly on `` node @@ -45,7 +44,6 @@ export const defaultContext = { optionsModal: { width: 488, fullScreen: false, - buttonClose: true, fakeModal: false, }, alertClickOutside: false, diff --git a/libs/shared/ui/src/lib/components/modal/modal.spec.tsx b/libs/shared/ui/src/lib/components/modal/modal.spec.tsx index c081ab0c896..71fd78ccdf3 100644 --- a/libs/shared/ui/src/lib/components/modal/modal.spec.tsx +++ b/libs/shared/ui/src/lib/components/modal/modal.spec.tsx @@ -1,19 +1,5 @@ import { fireEvent, render, screen } from '__tests__/utils/setup-jest' -import { renderWithProviders } from '@qovery/shared/util-tests' -import Modal, { type ModalContentProps, type ModalProps } from './modal' - -function ControlledContent({ setOpen, setCloseDisabled }: ModalContentProps) { - return ( - <> - - - - ) -} +import Modal, { type ModalProps } from './modal' describe('Modal', () => { let props: ModalProps @@ -62,37 +48,4 @@ describe('Modal', () => { expect(modal).toBeInTheDocument() }) - - it('preserves explicitly supplied content controls', async () => { - const setOpen = jest.fn() - const setCloseDisabled = jest.fn() - - const { userEvent } = renderWithProviders( - - - - ) - - await userEvent.click(screen.getByRole('button', { name: 'Custom close' })) - await userEvent.click(screen.getByRole('button', { name: 'Custom lock' })) - - expect(setOpen).toHaveBeenCalledWith(false) - expect(setCloseDisabled).toHaveBeenCalledWith(true) - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - it('does not inject content controls into native elements', () => { - const consoleError = jest.spyOn(console, 'error').mockImplementation() - - render( - -
Native content
-
- ) - - const errors = consoleError.mock.calls.flat().join(' ') - expect(errors).not.toContain('setOpen') - expect(errors).not.toContain('setCloseDisabled') - consoleError.mockRestore() - }) }) diff --git a/libs/shared/ui/src/lib/components/modal/modal.tsx b/libs/shared/ui/src/lib/components/modal/modal.tsx index 338daf19a28..3f7dc454a10 100644 --- a/libs/shared/ui/src/lib/components/modal/modal.tsx +++ b/libs/shared/ui/src/lib/components/modal/modal.tsx @@ -1,5 +1,5 @@ import * as Dialog from '@radix-ui/react-dialog' -import { Fragment, type ReactElement, type ReactNode, cloneElement, useContext, useEffect, useState } from 'react' +import { type ReactElement, type ReactNode, cloneElement, useContext, useEffect, useState } from 'react' import { Icon } from '../icon/icon' import useModalAlert from '../modal-alert/use-modal-alert/use-modal-alert' import { isToastInteraction } from '../toast/toast' @@ -31,7 +31,6 @@ export interface ModalProps { export interface ModalContentProps { setOpen?: (open: boolean) => void - setCloseDisabled?: (disabled: boolean) => void } export const Modal = (props: ModalProps) => { @@ -49,7 +48,6 @@ export const Modal = (props: ModalProps) => { } = props const [open, setOpen] = useState(defaultOpen) - const [closeDisabled, setCloseDisabled] = useState(false) const { setModalAlertOpen } = useModalAlert() const { setAlertModalChoice, enableAlertClickOutside, alertClickOutside, alertModalChoice } = useContext(ModalContext) @@ -84,12 +82,6 @@ export const Modal = (props: ModalProps) => { ]) const handleOutsideClick = (event: React.MouseEvent) => { - if (closeDisabled) { - event.preventDefault() - event.stopPropagation() - return - } - if (isToastInteraction(event)) { event.preventDefault() event.stopPropagation() @@ -104,33 +96,23 @@ export const Modal = (props: ModalProps) => { } } - const modalContentProps: ModalContentProps = - typeof children.type !== 'string' && children.type !== Fragment - ? { - setOpen: (children.props as ModalContentProps).setOpen ?? (setExternalOpen ? setExternalOpen : setOpen), - setCloseDisabled: (children.props as ModalContentProps).setCloseDisabled ?? setCloseDisabled, - } - : {} - return ( { - if (!nextOpen && closeDisabled) return + ? () => { if (alertClickOutside) { setModalAlertOpen(true) } else { - setExternalOpen(nextOpen) + setExternalOpen(!externalOpen) } } - : (nextOpen) => { - if (!nextOpen && closeDisabled) return + : () => { if (alertClickOutside) { setModalAlertOpen(true) } else { - setOpen(nextOpen) + setOpen(!open) } } } @@ -170,7 +152,9 @@ export const Modal = (props: ModalProps) => { className={`modal__content fixed left-1/2 top-[84px] z-modal overflow-hidden rounded-md border border-neutral bg-background shadow-[0_0_32px_rgba(0,0,0,0.08)] ${className}`} >
- {cloneElement(children, modalContentProps)} + {cloneElement(children, { + setOpen: setExternalOpen ? setExternalOpen : setOpen, + })} {buttonClose && (