From f897f6a48aaa407bf12587f0a8899abf76223e1f Mon Sep 17 00:00:00 2001 From: Florie Guibert Date: Tue, 26 Aug 2025 16:26:36 +1000 Subject: [PATCH] AI Catalog: Flow editor - Agent configuration Move agent selection from modal to side panel Behind :global_ai_catalog feature flag --- .../components/ai_catalog_flow_form.vue | 180 ++++++++++------- .../components/ai_catalog_form_side_panel.vue | 185 ++++++++++++++++++ .../components/ai_catalog_steps_editor.vue | 109 +---------- .../ai_catalog_flow_version.fragment.graphql | 7 + .../ai_catalog_item.fragment.graphql | 7 + .../catalog/pages/ai_catalog_flows_edit.vue | 2 + .../ai_catalog_form_side_panel_spec.js | 144 ++++++++++++++ .../ai_catalog_steps_editor_spec.js | 173 +--------------- ee/spec/frontend/ai/catalog/mock_data.js | 15 ++ locale/gitlab.pot | 12 +- 10 files changed, 479 insertions(+), 355 deletions(-) create mode 100644 ee/app/assets/javascripts/ai/catalog/components/ai_catalog_form_side_panel.vue create mode 100644 ee/spec/frontend/ai/catalog/components/ai_catalog_form_side_panel_spec.js 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 e4d99a8390d50c..695424f3449dd0 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 { 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 00000000000000..482b37b03c4892 --- /dev/null +++ b/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_form_side_panel.vue @@ -0,0 +1,185 @@ + + + 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 906afaeda0c40e..cc75d2d3a2a7c5 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') }} - - - - 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 2d5e2884631dcc..a1b37333e2836e 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 2ea152c6d8216e..c4d64562939e8d 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 414b1ede2b8fd2..4bafda28a438e1 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 00000000000000..bbeda6e80a81aa --- /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 65a631a086ce9f..f8ac7ea04dc3be 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 48aa4002c88a79..1dfc4a4f6005ec 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 487c6603c60783..52b076c495e6d6 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" -- GitLab