diff --git a/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_flow_form.vue b/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_flow_form.vue index e4d99a8390d50c929b5a97f78a923e020f95e5c0..695424f3449dd0618d59d5385eff22dea8ff189a 100644 --- a/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_flow_form.vue +++ b/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_flow_form.vue @@ -21,6 +21,7 @@ import { AI_CATALOG_FLOWS_ROUTE } from '../router/constants'; import { createFieldValidators } from '../utils'; import AiCatalogFormButtons from './ai_catalog_form_buttons.vue'; import AiCatalogStepsEditor from './ai_catalog_steps_editor.vue'; +import AiCatalogFormSidePanel from './ai_catalog_form_side_panel.vue'; import ErrorsAlert from './errors_alert.vue'; import FormProjectDropdown from './form_project_dropdown.vue'; @@ -29,6 +30,7 @@ export default { ErrorsAlert, AiCatalogFormButtons, AiCatalogStepsEditor, + AiCatalogFormSidePanel, FormProjectDropdown, GlAlert, GlButton, @@ -77,6 +79,8 @@ export default { : VISIBILITY_LEVEL_PRIVATE, }, formErrors: [], + isAgentPanelVisible: false, + activeStepIndex: null, }; }, computed: { @@ -153,7 +157,10 @@ export default { name: this.formValues.name.trim(), description: this.formValues.description.trim(), public: this.formValues.visibilityLevel === VISIBILITY_LEVEL_PUBLIC, - steps: this.formValues.steps.map((s) => ({ agentId: s.id })), + steps: this.formValues.steps.map((s) => ({ + agentId: s.id, + pinnedVersionPrefix: s.versionName.substring(1), // Strip v from version name + })), release: this.initialValues.release, }; this.$emit('submit', transformedValues); @@ -165,6 +172,14 @@ export default { this.formErrors = []; this.$emit('dismiss-errors'); }, + openAgentPanel(stepIndex) { + this.isAgentPanelVisible = true; + this.activeStepIndex = stepIndex; + }, + onCloseAgentPanel() { + this.isAgentPanelVisible = false; + this.activeStepIndex = null; + }, }, indexRoute: AI_CATALOG_FLOWS_ROUTE, }; @@ -172,83 +187,96 @@ export default { - - - - - - - - - - - + + + + + + + + + + + + + + + {{ level.label }} + + + {{ level.text }} + + + - - - - {{ level.label }} - - - {{ level.text }} - - - + + + + + + + - {{ visibilityLevelAlertText }} - - - - - - - - - {{ submitButtonText }} - - - + {{ submitButtonText }} + + + + + diff --git a/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_form_side_panel.vue b/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_form_side_panel.vue new file mode 100644 index 0000000000000000000000000000000000000000..482b37b03c4892cdc3095d3af302c9b18bb94d3f --- /dev/null +++ b/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_form_side_panel.vue @@ -0,0 +1,185 @@ + + + + + + {{ s__('AICatalog| Agent') }} + + {{ s__('AICatalog| Version') }} + + + + + + {{ __('Save') }} + + {{ __('Cancel') }} + + + {{ s__('AICatalog|Delete node') }} + + + + diff --git a/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_steps_editor.vue b/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_steps_editor.vue index 906afaeda0c40ee1908a3bb84dfba0ead37f1018..cc75d2d3a2a7c5b1da19837e40fe026c8d27e905 100644 --- a/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_steps_editor.vue +++ b/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_steps_editor.vue @@ -1,22 +1,11 @@ @@ -111,35 +33,10 @@ export default { :selected="step" class="gl-mb-3" aria-labelledby="flow-edit-steps" - @primary="openAgentModal(index)" + @primary="$emit('openAgentPanel', index)" /> - + {{ s__('AICatalog|Flow node') }} - - {{ s__('AICatalog| Agent') }} - - diff --git a/ee/app/assets/javascripts/ai/catalog/graphql/fragments/ai_catalog_flow_version.fragment.graphql b/ee/app/assets/javascripts/ai/catalog/graphql/fragments/ai_catalog_flow_version.fragment.graphql index 2d5e2884631dccb49c50a4f11e40f43332079717..a1b37333e2836e195acee868dc694856a14ff3b3 100644 --- a/ee/app/assets/javascripts/ai/catalog/graphql/fragments/ai_catalog_flow_version.fragment.graphql +++ b/ee/app/assets/javascripts/ai/catalog/graphql/fragments/ai_catalog_flow_version.fragment.graphql @@ -1,4 +1,5 @@ #import "../fragments/ai_catalog_item_version.fragment.graphql" +#import "../fragments/ai_catalog_agent_version.fragment.graphql" fragment BaseAiCatalogFlowVersion on AiCatalogFlowVersion { ...BaseAiCatalogItemVersion @@ -7,7 +8,13 @@ fragment BaseAiCatalogFlowVersion on AiCatalogFlowVersion { agent { id name + versions { + nodes { + ...BaseAiCatalogAgentVersion + } + } } + pinnedVersionPrefix } } } diff --git a/ee/app/assets/javascripts/ai/catalog/graphql/fragments/ai_catalog_item.fragment.graphql b/ee/app/assets/javascripts/ai/catalog/graphql/fragments/ai_catalog_item.fragment.graphql index 2ea152c6d8216e406dc8807eb9d6dd577669aea9..c4d64562939e8db811c1953001cfe1c7819998e5 100644 --- a/ee/app/assets/javascripts/ai/catalog/graphql/fragments/ai_catalog_item.fragment.graphql +++ b/ee/app/assets/javascripts/ai/catalog/graphql/fragments/ai_catalog_item.fragment.graphql @@ -1,3 +1,5 @@ +#import "../fragments/ai_catalog_agent_version.fragment.graphql" + fragment BaseAiCatalogItem on AiCatalogItem { id createdAt @@ -12,5 +14,10 @@ fragment BaseAiCatalogItem on AiCatalogItem { userPermissions { adminAiCatalogItem } + versions { + nodes { + ...BaseAiCatalogAgentVersion + } + } __typename } diff --git a/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_flows_edit.vue b/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_flows_edit.vue index 414b1ede2b8fd25df7077bd197d8e48329c1a27c..4bafda28a438e1331d54bb390afebd7da14b4755 100644 --- a/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_flows_edit.vue +++ b/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_flows_edit.vue @@ -40,6 +40,8 @@ export default { steps: this.aiCatalogFlow.latestVersion.steps.nodes.map((s) => ({ id: s.agent.id, name: s.agent.name, + versions: s.agent.versions, + versionName: s.pinnedVersionPrefix, })), }; }, diff --git a/ee/spec/frontend/ai/catalog/components/ai_catalog_form_side_panel_spec.js b/ee/spec/frontend/ai/catalog/components/ai_catalog_form_side_panel_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..bbeda6e80a81aa87dbe2c263eeb1f1146d8022f3 --- /dev/null +++ b/ee/spec/frontend/ai/catalog/components/ai_catalog_form_side_panel_spec.js @@ -0,0 +1,144 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlButton, GlCollapsibleListbox } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import AiCatalogFormSidePanel from 'ee/ai/catalog/components/ai_catalog_form_side_panel.vue'; +import aiCatalogAgentsQuery from 'ee/ai/catalog/graphql/queries/ai_catalog_agents.query.graphql'; +import { + mockCatalogItemsResponse, + mockCatalogEmptyItemsResponse, + mockVersions, +} from '../mock_data'; + +Vue.use(VueApollo); + +describe('AiCatalogFormSidePanel', () => { + let wrapper; + let mockApollo; + + const agents = [ + { value: 'gid://gitlab/Ai::Catalog::Item/1', text: 'Test AI Agent 1', versions: mockVersions }, + { value: 'gid://gitlab/Ai::Catalog::Item/2', text: 'Test AI Agent 2', versions: mockVersions }, + { value: 'gid://gitlab/Ai::Catalog::Item/3', text: 'Test AI Agent 3', versions: mockVersions }, + ]; + + const selectedAgent = agents[0]; + + const selectedSteps = [ + { id: 'gid://gitlab/Ai::Catalog::Item/1', name: 'Test AI Agent 1' }, + { id: 'gid://gitlab/Ai::Catalog::Item/3', name: 'Test AI Agent 3' }, + ]; + + const aiCatalogAgentsQueryHandler = jest.fn().mockResolvedValue(mockCatalogItemsResponse); + const aiCatalogEmptyAgentsQueryHandler = jest + .fn() + .mockResolvedValue(mockCatalogEmptyItemsResponse); + + const createComponent = ({ + catalogItemsQueryHandler = aiCatalogAgentsQueryHandler, + steps = [], + } = {}) => { + mockApollo = createMockApollo([[aiCatalogAgentsQuery, catalogItemsQueryHandler]]); + + wrapper = shallowMountExtended(AiCatalogFormSidePanel, { + apolloProvider: mockApollo, + propsData: { + steps, + }, + }); + }; + const findSaveButton = () => wrapper.findComponent(GlButton); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + + const selectAgent = async () => { + findListbox().vm.$emit('select', selectedAgent.value); + await nextTick(); + expect(findListbox().props('toggleText')).toEqual(selectedAgent.text); + findSaveButton().vm.$emit('click'); + }; + + beforeEach(() => { + createComponent(); + }); + + describe('listbox', () => { + it('renders the listbox inside the modal, with loading state', () => { + expect(findListbox().exists()).toBe(true); + expect(findListbox().props('loading')).toBe(true); + }); + }); + + describe('fetches agents', () => { + it('calls the aiCatalogAgents query with correct variables', () => { + expect(aiCatalogAgentsQueryHandler).toHaveBeenCalledWith({ + search: '', + }); + }); + + it('passes transformed aiCatalogAgents as listbox items', async () => { + await waitForPromises(); + + expect(findListbox().props('items')).toEqual(agents); + }); + + it('sets loading in listbox to false after query completes', async () => { + await waitForPromises(); + expect(findListbox().props('loading')).toBe(false); + }); + + it('handles empty query response', async () => { + createComponent({ catalogItemsQueryHandler: aiCatalogEmptyAgentsQueryHandler }); + await waitForPromises(); + + expect(findListbox().props('items')).toEqual([]); + }); + }); + + describe('component interactions', () => { + it('selects agent', async () => { + await waitForPromises(); + await selectAgent(); + + expect(wrapper.emitted('setSteps')).toEqual([[[selectedSteps[0]]]]); + }); + + it('passes correct props to GlCollapsibleListbox', async () => { + await waitForPromises(); + + const listbox = findListbox(); + expect(listbox.props()).toMatchObject({ + block: true, + searchable: true, + items: agents, + toggleText: 'Select agent', + loading: false, + searching: false, + }); + }); + + it('handles listbox search event', async () => { + const listbox = findListbox(); + listbox.vm.$emit('search', 'new search'); + await nextTick(); + + expect(listbox.props('searching')).toBe(true); + expect(aiCatalogAgentsQueryHandler).toHaveBeenCalledWith({ + search: 'new search', + }); + + await waitForPromises(); + expect(listbox.props('searching')).toBe(false); + }); + }); + + describe('v-model', () => { + it('should set steps and update the v-model bound data', async () => { + await waitForPromises(); + await selectAgent(); + + expect(wrapper.emitted('setSteps')).toEqual([[[selectedSteps[0]]]]); + }); + }); +}); diff --git a/ee/spec/frontend/ai/catalog/components/ai_catalog_steps_editor_spec.js b/ee/spec/frontend/ai/catalog/components/ai_catalog_steps_editor_spec.js index 65a631a086ce9f87cae69c5c89009b566e20e248..f8ac7ea04dc3bedd92f6e6ce82a75fd476fc2efc 100644 --- a/ee/spec/frontend/ai/catalog/components/ai_catalog_steps_editor_spec.js +++ b/ee/spec/frontend/ai/catalog/components/ai_catalog_steps_editor_spec.js @@ -1,19 +1,14 @@ -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { GlButton, GlCollapsibleListbox, GlModal } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; import AiCatalogStepsEditor from 'ee/ai/catalog/components/ai_catalog_steps_editor.vue'; import AiCatalogNodeField from 'ee/ai/catalog/components/ai_catalog_node_field.vue'; -import aiCatalogAgentsQuery from 'ee/ai/catalog/graphql/queries/ai_catalog_agents.query.graphql'; -import { mockCatalogItemsResponse, mockCatalogEmptyItemsResponse } from '../mock_data'; Vue.use(VueApollo); describe('AiCatalogStepsEditor', () => { let wrapper; - let mockApollo; const agents = [ { value: 'gid://gitlab/Ai::Catalog::Item/1', text: 'Test AI Agent 1' }, @@ -21,26 +16,13 @@ describe('AiCatalogStepsEditor', () => { { value: 'gid://gitlab/Ai::Catalog::Item/3', text: 'Test AI Agent 3' }, ]; - const selectedAgent = agents[0]; - const selectedSteps = [ { id: 'gid://gitlab/Ai::Catalog::Item/1', name: 'Test AI Agent 1' }, { id: 'gid://gitlab/Ai::Catalog::Item/3', name: 'Test AI Agent 3' }, ]; - const aiCatalogAgentsQueryHandler = jest.fn().mockResolvedValue(mockCatalogItemsResponse); - const aiCatalogEmptyAgentsQueryHandler = jest - .fn() - .mockResolvedValue(mockCatalogEmptyItemsResponse); - - const createComponent = ({ - catalogItemsQueryHandler = aiCatalogAgentsQueryHandler, - steps = [], - } = {}) => { - mockApollo = createMockApollo([[aiCatalogAgentsQuery, catalogItemsQueryHandler]]); - + const createComponent = ({ steps = [] } = {}) => { wrapper = shallowMountExtended(AiCatalogStepsEditor, { - apolloProvider: mockApollo, propsData: { steps, }, @@ -50,17 +32,6 @@ describe('AiCatalogStepsEditor', () => { const findNewNodeButton = () => wrapper.findComponent(GlButton); const findNodeFields = () => wrapper.findAllComponents(AiCatalogNodeField); const findFirstNodeField = () => findNodeFields().at(0); - const findModal = () => wrapper.findComponent(GlModal); - const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); - - const selectAgent = async () => { - findNewNodeButton().vm.$emit('click'); - await nextTick(); - findListbox().vm.$emit('select', selectedAgent.value); - await nextTick(); - expect(findListbox().props('toggleText')).toEqual(selectedAgent.text); - findModal().vm.$emit('primary'); - }; beforeEach(() => { createComponent(); @@ -89,142 +60,4 @@ describe('AiCatalogStepsEditor', () => { expect(findNodeFields().at(1).props('selected')).toMatchObject(agents[2]); }); }); - - describe('modal', () => { - it('renders the modal with correct props, not visible by default', () => { - const modal = findModal(); - expect(modal.exists()).toBe(true); - expect(modal.props('title')).toBe('Draft node'); - expect(modal.props('visible')).toBe(false); - }); - - it('renders the listbox inside the modal, with loading state', () => { - expect(findListbox().exists()).toBe(true); - expect(findListbox().props('loading')).toBe(true); - }); - }); - - describe('fetches agents', () => { - it('calls the aiCatalogAgents query with correct variables', () => { - expect(aiCatalogAgentsQueryHandler).toHaveBeenCalledWith({ - search: '', - }); - }); - - it('passes transformed aiCatalogAgents as listbox items', async () => { - await waitForPromises(); - - expect(findListbox().props('items')).toEqual(agents); - }); - - it('sets loading in listbox to false after query completes', async () => { - await waitForPromises(); - expect(findListbox().props('loading')).toBe(false); - }); - - it('handles empty query response', async () => { - createComponent({ catalogItemsQueryHandler: aiCatalogEmptyAgentsQueryHandler }); - await waitForPromises(); - - expect(findListbox().props('items')).toEqual([]); - }); - }); - - describe('component interactions', () => { - it('selects agent', async () => { - await waitForPromises(); - await selectAgent(); - - expect(wrapper.emitted('setSteps')).toEqual([[[selectedSteps[0]]]]); - }); - - it('updates existing step agent', async () => { - createComponent({ steps: [{ id: agents[0].value, name: agents[0].text }] }); - await waitForPromises(); - - findFirstNodeField().vm.$emit('primary'); - await nextTick(); - findListbox().vm.$emit('select', agents[1].value); - await nextTick(); - expect(findListbox().props('toggleText')).toEqual(agents[1].text); - findModal().vm.$emit('primary'); - await nextTick(); - - expect(wrapper.emitted('setSteps')).toEqual([ - [[{ id: agents[1].value, name: agents[1].text }]], - ]); - }); - - it('passes correct props to GlCollapsibleListbox', async () => { - await waitForPromises(); - - const listbox = findListbox(); - expect(listbox.props()).toMatchObject({ - block: true, - searchable: true, - items: agents, - toggleText: 'Select agent', - loading: false, - searching: false, - }); - }); - - it('handles listbox search event', async () => { - const listbox = findListbox(); - listbox.vm.$emit('search', 'new search'); - await nextTick(); - - expect(listbox.props('searching')).toBe(true); - expect(aiCatalogAgentsQueryHandler).toHaveBeenCalledWith({ - search: 'new search', - }); - - await waitForPromises(); - expect(listbox.props('searching')).toBe(false); - }); - }); - - describe('modal behavior', () => { - it('opens modal on click new node button', async () => { - await waitForPromises(); - findNewNodeButton().vm.$emit('click'); - await nextTick(); - - expect(findModal().props('visible')).toBe(true); - }); - - it('opens modal when AiCatalogNodeField emits primary event', async () => { - createComponent({ steps: selectedSteps }); - await waitForPromises(); - findFirstNodeField().vm.$emit('primary'); - await nextTick(); - - expect(findModal().props('visible')).toBe(true); - }); - - it('clears selected agent on cancel and closes modal', async () => { - await waitForPromises(); - findListbox().vm.$emit('select', 'gid://gitlab/Ai::Catalog::Item/1'); - await nextTick(); - - expect(findListbox().props('toggleText')).toEqual(selectedAgent.text); - - findModal().vm.$emit('cancel'); - await nextTick(); - - expect(findListbox().props('toggleText')).toEqual('Select agent'); - expect(findModal().props('visible')).toBe(false); - }); - }); - - describe('v-model', () => { - it('should set steps and update the v-model bound data', async () => { - await waitForPromises(); - expect(findNodeFields()).toHaveLength(0); - - await selectAgent(); - - expect(wrapper.emitted('setSteps')).toEqual([[[selectedSteps[0]]]]); - }); - }); }); diff --git a/ee/spec/frontend/ai/catalog/mock_data.js b/ee/spec/frontend/ai/catalog/mock_data.js index 48aa4002c88a79465109a30b85d1e6f4c4dd9dd8..1dfc4a4f6005ec0846bc3d370e82a9afd1607f58 100644 --- a/ee/spec/frontend/ai/catalog/mock_data.js +++ b/ee/spec/frontend/ai/catalog/mock_data.js @@ -83,6 +83,20 @@ export const mockToolQueryResponse = { /* AGENTS */ +export const mockVersions = { + nodes: [ + { + id: 'gid://gitlab/Ai::Catalog::ItemVersion/20', + systemPrompt: 'sys', + tools: { nodes: [], __typename: TYPENAME_AI_CATALOG_AGENT_TOOLS_CONNECTION }, + userPrompt: 'user', + versionName: 'v1.0.0', + __typename: TYPENAME_AI_CATALOG_AGENT_VERSION, + }, + ], + __typename: 'AiCatalogItemVersionConnection', +}; + const mockAgentFactory = (overrides = {}) => ({ id: 'gid://gitlab/Ai::Catalog::Item/1', name: 'Test AI Agent 1', @@ -98,6 +112,7 @@ const mockAgentFactory = (overrides = {}) => ({ id: 'gid://gitlab/Ai::Catalog::ItemVersion/1', updatedAt: '2025-08-21T14:30:00Z', }, + versions: mockVersions, ...overrides, }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 487c6603c6078399def382ba821ea5267119fcc4..52b076c495e6d665d0f11f56a57e74620c142c9c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2424,6 +2424,9 @@ msgstr "" msgid "AICatalog| Draft node" msgstr "" +msgid "AICatalog| Version" +msgstr "" + msgid "AICatalog|A public agent can be made private only if it is not used." msgstr "" @@ -2457,6 +2460,9 @@ msgstr "" msgid "AICatalog|Agents" msgstr "" +msgid "AICatalog|Always use latest version" +msgstr "" + msgid "AICatalog|Anyone can view and use the agent without authorization. Only maintainers and owners of this project can edit or delete the agent." msgstr "" @@ -2508,13 +2514,13 @@ msgstr "" msgid "AICatalog|Delete flow" msgstr "" -msgid "AICatalog|Description" +msgid "AICatalog|Delete node" msgstr "" -msgid "AICatalog|Description is required." +msgid "AICatalog|Description" msgstr "" -msgid "AICatalog|Draft node" +msgid "AICatalog|Description is required." msgstr "" msgid "AICatalog|Edit"