feat(editor): Link to the skills settings page from the agent skill picker (no-changelog) (#41228)

This commit is contained in:
Ricardo Espinoza 2026-10-16 08:18:13 +00:00 committed by GitHub
parent cc83b5bd77
commit dde9871497
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 61 additions and 1 deletions

View File

@ -8979,6 +8979,7 @@
"agents.builder.skills.picker.create": "Create skill",
"agents.builder.skills.picker.create.description": "Write a new skill for this agent",
"agents.builder.skills.picker.add": "Add skill",
"agents.builder.skills.picker.manage": "Manage skills",
"agents.builder.skills.picker.loading": "Loading skills…",
"agents.builder.skills.picker.empty": "No skills to add. Create one, or ask an admin to share one with this project.",
"agents.builder.skills.picker.noResults": "No skills match your search",

View File

@ -3,6 +3,7 @@ import { ref } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
import { VIEWS } from '@/app/constants';
import { useUIStore } from '@/app/stores/ui.store';
import { configure, fireEvent, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
@ -75,6 +76,14 @@ vi.mock('@/features/shared/toolsConnection/ToolsConnectionModal.vue', () => ({
},
}));
const { resolveRoute } = vi.hoisted(() => ({
resolveRoute: vi.fn(() => ({ href: '/settings/context/skills' })),
}));
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal<typeof import('vue-router')>()),
useRouter: () => ({ resolve: resolveRoute }),
}));
vi.mock('../composables/useAgentTelemetry', () => ({
useAgentTelemetry: () => ({ trackImportedSkill }),
}));
@ -667,6 +676,40 @@ describe('AgentSkillModal', () => {
);
});
it('links to the skills settings page from the picker, in a new tab', async () => {
listAttachableSkills.mockResolvedValue([projectSkill]);
const open = vi.spyOn(window, 'open').mockReturnValue(null);
const { getByTestId, queryByTestId } = renderModal({
hubSkillsEnabled: true,
onAttach: vi.fn(),
});
expect(getByTestId('agent-skill-picker-manage')).toHaveTextContent(
'agents.builder.skills.picker.manage',
);
// The picker step has no Cancel and no Save.
expect(queryByTestId('agent-modal-cancel')).not.toBeInTheDocument();
expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
await fireEvent.click(getByTestId('agent-skill-picker-manage'));
expect(resolveRoute).toHaveBeenCalledWith({ name: VIEWS.SETTINGS_CONTEXT_SKILLS });
expect(open).toHaveBeenCalledWith('/settings/context/skills', '_blank', 'noopener');
open.mockRestore();
});
it('shows the settings link only on the picker step', async () => {
listAttachableSkills.mockResolvedValue([projectSkill]);
const { getByTestId, queryByTestId } = renderModal({
hubSkillsEnabled: true,
onAttach: vi.fn(),
});
await fireEvent.click(getByTestId('agent-skill-picker-create'));
expect(queryByTestId('agent-skill-picker-manage')).not.toBeInTheDocument();
});
it('goes from the picker to the upload step and back', async () => {
listAttachableSkills.mockResolvedValue([projectSkill]);
const { getByTestId, queryByTestId } = renderModal({

View File

@ -2,6 +2,7 @@
import cloneDeep from 'lodash/cloneDeep';
import isEqual from 'lodash/isEqual';
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH,
AGENT_SKILL_REFERENCE_MAX_COUNT,
@ -25,6 +26,7 @@ import type { IconName } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { VIEWS } from '@/app/constants';
import { useUIStore } from '@/app/stores/ui.store';
import ToolsConnectionModal from '@/features/shared/toolsConnection/ToolsConnectionModal.vue';
import type {
@ -104,6 +106,7 @@ const props = defineProps<{
const i18n = useI18n();
const uiStore = useUIStore();
const router = useRouter();
const rootStore = useRootStore();
const { showError } = useToast();
const agentTelemetry = useAgentTelemetry();
@ -231,6 +234,7 @@ const showRemove = computed(() => {
return step.value === 'preview' ? previewIsAdded.value : isEditing.value;
});
const footerPrimaryAction = computed<'add' | 'save' | 'useLatest' | undefined>(() => {
if (step.value === 'select') return undefined;
if (step.value === 'preview') return previewIsAdded.value ? undefined : 'add';
if (isOutdated.value) return props.data.onUseLatestVersion ? 'useLatest' : undefined;
return isReadOnly.value ? undefined : 'save';
@ -469,6 +473,11 @@ async function attachLibrarySkill(skillId: string) {
}
}
/** Opens Settings > Context > Skills in a new tab, so the agent and the picker stay open. */
function onManageSkills() {
window.open(router.resolve({ name: VIEWS.SETTINGS_CONTEXT_SKILLS }).href, '_blank', 'noopener');
}
function onPreviewAdd() {
if (!previewSkill.value) return;
void attachLibrarySkill(previewSkill.value.id);
@ -651,7 +660,8 @@ function onRemove() {
:title-error="visibleNameError"
:editable-title="step === 'manual' || (step === 'edit' && !isReadOnly)"
:show-back="step === 'manual' || step === 'preview' || (step === 'upload' && hasPicker)"
:show-footer="step === 'manual' || step === 'edit' || step === 'preview'"
:show-footer="step === 'select' || step === 'manual' || step === 'edit' || step === 'preview'"
:show-cancel="step !== 'select'"
:body-flush="step !== 'select'"
:busy="isImporting || previewLoading || isSaving"
size="fit"
@ -846,6 +856,12 @@ function onRemove() {
{{ removeLabel }}
</N8nButton>
</template>
<template v-else-if="step === 'select'" #footerLeft>
<N8nButton variant="ghost" data-testid="agent-skill-picker-manage" @click="onManageSkills">
<template #icon><N8nIcon icon="settings" :size="16" /></template>
{{ i18n.baseText('agents.builder.skills.picker.manage') }}
</N8nButton>
</template>
<template v-if="footerHint" #footerBeforeCancel>
<N8nText
size="small"