diff --git a/package.json b/package.json index b3a561345be078bcf7f023769e419360a8aec06f..889785cb501778ed9c27ed5c6321d4a83ec6c023 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gitlab/ui", - "version": "88.0.0", + "version": "89.5.0", "description": "GitLab UI Components", "license": "MIT", "main": "dist/index.js", @@ -108,13 +108,14 @@ "@babel/core": "^7.25.2", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6", "@babel/plugin-proposal-optional-chaining": "^7.21.0", - "@babel/preset-env": "^7.25.2", + "@babel/preset-env": "^7.25.3", "@babel/preset-react": "^7.24.7", "@cypress/grep": "^4.0.1", - "@gitlab/eslint-plugin": "19.6.0", + "@faker-js/faker": "^8.4.1", + "@gitlab/eslint-plugin": "20.0.0", "@gitlab/fonts": "^1.3.0", "@gitlab/stylelint-config": "6.2.1", - "@gitlab/svgs": "3.112.0", + "@gitlab/svgs": "3.114.0", "@jest/test-sequencer": "^29.7.0", "@rollup/plugin-commonjs": "^11.1.0", "@rollup/plugin-node-resolve": "^7.1.3", @@ -148,7 +149,7 @@ "babel-loader": "^8.0.5", "bootstrap": "4.6.2", "cobertura-merge": "^1.0.4", - "cypress": "13.13.2", + "cypress": "13.13.3", "cypress-axe": "^1.4.0", "cypress-real-events": "^1.11.0", "dompurify": "^3.1.2", @@ -156,7 +157,7 @@ "esbuild": "^0.18.0", "eslint": "8.57.0", "eslint-import-resolver-jest": "3.0.2", - "eslint-plugin-cypress": "3.4.0", + "eslint-plugin-cypress": "3.5.0", "eslint-plugin-storybook": "0.8.0", "gitlab-api-async-iterator": "^1.3.1", "glob": "10.3.3", diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context.scss b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context.scss new file mode 100644 index 0000000000000000000000000000000000000000..cb75998b94fd5740a6e9d8de2f1421858030bd55 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context.scss @@ -0,0 +1,18 @@ +.gl-duo-chat-context-item-disabled { + &, + &:hover, + &:focus, + &:active { + background: var(--gl-control-background-color-disabled); + color: var(--gl-text-color-disabled); + cursor: not-allowed; + } + + span { + color: var(--gl-text-color-disabled); + } + + .gl-icon { + color: var(--gl-text-color-disabled); + } +} diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_event_bus.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_event_bus.js new file mode 100644 index 0000000000000000000000000000000000000000..e962714e1bc7be4d903982acc4c05a388ba4fd1e --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_event_bus.js @@ -0,0 +1,8 @@ +export const EVENT_BUS_TYPES = { + TOGGLE_CONTEXT_MENU: 'toggle_context_menu', + CONTEXT_ITEM_SEARCH_QUERY: 'context_item_search_query', + CONTEXT_ITEM_SEARCH_RESULT: 'context_item_search_result', + CONTEXT_ITEM_SEARCH_ERROR: 'context_item_search_error', + CONTEXT_ITEM_ADDED: 'context_item_added', + CONTEXT_ITEM_REMOVED: 'context_item_removed', +}; diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.stories.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..122086b5058e19132c7b81a8a66068faa3c29f7a --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.stories.js @@ -0,0 +1,118 @@ +import Vue from 'vue'; +import { categories, generateSampleContextItems } from '../duo_chat_context_items_sample_data'; +import { setStoryTimeout } from '../../../../../../../utils/test_utils'; +import { EVENT_BUS_TYPES } from '../duo_chat_context_event_bus'; +import GlDuoChatContextItemMenu from './duo_chat_context_item_menu.vue'; + +const eventBus = new Vue(); +const sampleCategories = categories; +const sampleContextItems = generateSampleContextItems(); + +export default { + title: 'experimental/duo/chat/components/duo-chat-context/duo-chat-context-item-menu', + component: GlDuoChatContextItemMenu, + argTypes: { + cursorPosition: { control: { type: 'number', min: 0, max: 1000, step: 10 } }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { GlDuoChatContextItemMenu }, + data() { + return { + localEventBus: eventBus, + contextCategories: sampleCategories, + contextItems: sampleContextItems, + addedItemJson: null, + }; + }, + mounted() { + this.localEventBus.$on(EVENT_BUS_TYPES.CONTEXT_ITEM_SEARCH_QUERY, this.handleSearch); + this.localEventBus.$on(EVENT_BUS_TYPES.CONTEXT_ITEM_ADDED, this.handleItemAdded); + }, + beforeDestroy() { + this.localEventBus.$on(EVENT_BUS_TYPES.CONTEXT_ITEM_SEARCH_QUERY, this.handleSearch); + this.localEventBus.$on(EVENT_BUS_TYPES.CONTEXT_ITEM_ADDED, this.handleItemAdded); + }, + methods: { + handleSearch({ category, query }) { + const filteredResults = this.contextItems + .filter((item) => item.type === category) + .filter((item) => item.name.toLowerCase().includes(query.toLowerCase())); + setStoryTimeout(() => { + this.localEventBus.$emit(EVENT_BUS_TYPES.CONTEXT_ITEM_SEARCH_RESULT, filteredResults); + }, 300); + }, + handleItemAdded(item) { + this.addedItemJson = JSON.stringify(item, null, 2); + }, + toggleMenu() { + this.localEventBus.$emit(EVENT_BUS_TYPES.TOGGLE_CONTEXT_MENU, true); + }, + }, + template: ` +
+ + +
{{ addedItemJson }}
+
+ `, +}); + +export const Default = Template.bind({}); +Default.args = { + cursorPosition: 100, +}; + +const errorEventBus = new Vue(); + +// create an error template +const ErrorTemplate = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { GlDuoChatContextItemMenu }, + data() { + return { + localEventBus: errorEventBus, + contextCategories: sampleCategories, + }; + }, + mounted() { + this.localEventBus.$on(EVENT_BUS_TYPES.CONTEXT_ITEM_SEARCH_QUERY, this.handleSearch); + this.localEventBus.$on(EVENT_BUS_TYPES.CONTEXT_ITEM_ADDED, this.handleItemAdded); + }, + beforeDestroy() { + this.localEventBus.$on(EVENT_BUS_TYPES.CONTEXT_ITEM_SEARCH_QUERY, this.handleSearch); + this.localEventBus.$on(EVENT_BUS_TYPES.CONTEXT_ITEM_ADDED, this.handleItemAdded); + }, + methods: { + handleSearch() { + setStoryTimeout(() => { + this.localEventBus.$emit(EVENT_BUS_TYPES.CONTEXT_ITEM_SEARCH_ERROR, args.searchError); + }, 300); + }, + toggleMenu() { + this.localEventBus.$emit(EVENT_BUS_TYPES.TOGGLE_CONTEXT_MENU, true); + }, + }, + template: ` +
+ + +
+ `, +}); + +export const WithError = ErrorTemplate.bind({}); +WithError.args = { + cursorPosition: 100, + searchError: 'Something went wrong', +}; diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.vue b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.vue new file mode 100644 index 0000000000000000000000000000000000000000..2dfdb346973da66013b7c2e7c2b33747dd1bf839 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.vue @@ -0,0 +1,319 @@ + + + + + diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_popover/duo_chat_context_item_popover.stories.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_popover/duo_chat_context_item_popover.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..939eb71d80ab69cf3cdacbc88070c74c95d17ba9 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_popover/duo_chat_context_item_popover.stories.js @@ -0,0 +1,46 @@ +import { generateSampleContextItems } from '../duo_chat_context_items_sample_data'; +import GlDuoChatContextItemPopover from './duo_chat_context_item_popover.vue'; + +const sampleContextItems = generateSampleContextItems(10); + +export default { + title: 'experimental/duo/chat/components/duo-chat-context/duo-chat-context-item-popover', + component: GlDuoChatContextItemPopover, + argTypes: { + placement: { + control: { type: 'select', options: ['top', 'right', 'bottom', 'left'] }, + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { GlDuoChatContextItemPopover }, + data() { + return { + items: sampleContextItems, + }; + }, + template: ` +
+
+ + + +
+
+ `, +}); + +export const Default = Template.bind({}); +Default.args = { + placement: 'right', +}; diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_popover/duo_chat_context_item_popover.vue b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_popover/duo_chat_context_item_popover.vue new file mode 100644 index 0000000000000000000000000000000000000000..78e034a8926e15de3dae4962fe3caaaf99ae3b30 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_popover/duo_chat_context_item_popover.vue @@ -0,0 +1,86 @@ + + diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.stories.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..836188c287d56fdc66ab8fe433134032c340712b --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.stories.js @@ -0,0 +1,91 @@ +import Vue from 'vue'; +import { generateSampleContextItems } from '../duo_chat_context_items_sample_data'; +import { EVENT_BUS_TYPES } from '../duo_chat_context_event_bus'; +import GlDuoChatContextItemSelections from './duo_chat_context_item_selections.vue'; + +const eventBus = new Vue(); +const sampleContextItems = generateSampleContextItems(5); + +export default { + title: 'experimental/duo/chat/components/duo-chat-context/duo-chat-context-item-selections', + component: GlDuoChatContextItemSelections, + argTypes: { + title: { control: 'text' }, + collapsable: { control: 'boolean' }, + showClose: { control: 'boolean' }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { GlDuoChatContextItemSelections }, + data() { + return { + localEventBus: eventBus, + itemSelections: [...sampleContextItems], + }; + }, + mounted() { + this.localEventBus.$on(EVENT_BUS_TYPES.CONTEXT_ITEM_REMOVED, this.handleRemoveItem); + }, + beforeDestroy() { + this.localEventBus.$off(EVENT_BUS_TYPES.CONTEXT_ITEM_REMOVED, this.handleRemoveItem); + }, + methods: { + handleRemoveItem(item) { + const index = this.itemSelections.findIndex((i) => i.id === item.id); + if (index !== -1) { + this.itemSelections.splice(index, 1); + } + }, + }, + template: ` +
+ +
+ `, +}); + +export const Default = Template.bind({}); +Default.args = { + title: 'Added Context', + collapsable: false, + showClose: true, +}; + +export const Collapsable = Template.bind({}); +Collapsable.args = { + ...Default.args, + collapsable: true, +}; + +export const NoCloseButton = Template.bind({}); +NoCloseButton.args = { + ...Default.args, + showClose: false, +}; + +export const CustomTitle = Template.bind({}); +CustomTitle.args = { + ...Default.args, + title: 'Selected Items', +}; + +export const EmptySelection = Template.bind({}); +EmptySelection.args = { + ...Default.args, +}; +EmptySelection.decorators = [ + () => ({ + template: '', + data() { + return { + contextItemSelections: [], + }; + }, + }), +]; diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.vue b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.vue new file mode 100644 index 0000000000000000000000000000000000000000..370d5cca042449d957cc822393830c2ae5db4176 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.vue @@ -0,0 +1,110 @@ + + + diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_items_sample_data.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_items_sample_data.js new file mode 100644 index 0000000000000000000000000000000000000000..964a2b336976c18c4b843cd8090284fdb6913632 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_items_sample_data.js @@ -0,0 +1,65 @@ +import { faker } from '@faker-js/faker'; + +export const categories = [ + { label: 'Files', value: 'file', icon: 'document' }, + { label: 'Issues', value: 'issue', icon: 'issues' }, + { label: 'Merge Requests', value: 'merge_request', icon: 'merge-request' }, +]; + +const generateFile = () => ({ + id: faker.string.uuid(), + name: faker.system.fileName({ extensionCount: { min: 1, max: 3 } }), + isEnabled: faker.datatype.boolean(), + info: { + project: `${faker.internet.domainWord()}/${faker.internet.domainWord()}`, + relFilePath: faker.system.filePath(), + }, + type: 'file', + subType: faker.helpers.arrayElement(['open_tab', 'local_file_search']), +}); + +const generateIssue = () => ({ + id: faker.string.uuid(), + name: faker.hacker.phrase(), + isEnabled: faker.datatype.boolean(), + info: { + project: `${faker.internet.domainWord()}/${faker.internet.domainWord()}`, + iid: faker.number.int({ min: 1000, max: 9999 }), + disabledReasons: faker.datatype.boolean() + ? [faker.lorem.sentence(), faker.lorem.sentence()] + : [], + }, + type: 'issue', +}); + +const generateMergeRequest = () => ({ + id: faker.string.uuid(), + name: faker.git.commitMessage(), + isEnabled: faker.datatype.boolean(), + info: { + project: `${faker.internet.domainWord()}/${faker.internet.domainWord()}`, + iid: faker.number.int({ min: 1000, max: 9999 }), + }, + type: 'merge_request', +}); + +export const generateSampleContextItems = (count = 100) => { + const items = Array.from({ length: count }, () => { + const type = faker.helpers.arrayElement(['file', 'issue', 'merge_request']); + switch (type) { + case 'file': + return generateFile(); + case 'issue': + return generateIssue(); + case 'merge_request': + return generateMergeRequest(); + default: + throw new Error(`Unknown type: ${type}`); + } + }); + + // put disabled items in the back + const disabledItems = items.filter((item) => !item.isEnabled); + const enabledItems = items.filter((item) => item.isEnabled); + return [...enabledItems, ...disabledItems]; +}; diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_types.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_types.js new file mode 100644 index 0000000000000000000000000000000000000000..32ce56b84f6a838910609fff88ef36b3cc4f3854 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_types.js @@ -0,0 +1,32 @@ +/** + * @typedef {Object} AiContextItemInfo + * @property {string} [project] - The project associated with the context item + * @property {string[]} [disabledReasons] - Reasons why the context item might be disabled + * @property {number} [iid] - Internal ID of the context item + * @property {string} [relFilePath] - Relative file path of the context item + */ + +/** + * @typedef {'issue' | 'merge_request' | 'file'} AiContextItemType + */ + +/** + * @typedef {'open_tab' | 'local_file_search'} AiContextItemSubType + */ + +/** + * @typedef {Object} AiContextItemBase + * @property {string} id - Unique identifier for the context item + * @property {string} name - Name of the context item + * @property {boolean} isEnabled - Whether the context item is enabled + * @property {AiContextItemInfo} info - Additional information about the context item + * @property {AiContextItemType} type - Type of the context item + */ + +/** + * @typedef {AiContextItemBase & ({type: 'issue' | 'merge_request', subType?: never} | {type: 'file', subType: AiContextItemSubType})} AiContextItem + */ + +/** + * @typedef {AiContextItem & {content: string}} AiContextItemWithContent + */ diff --git a/src/components/experimental/duo/chat/components/duo_chat_conversation/duo_chat_conversation.vue b/src/components/experimental/duo/chat/components/duo_chat_conversation/duo_chat_conversation.vue index 01c45a9d464c5c31710a02907241e99d405764c7..cbc1273d27ead31efc3c751d0878268b65fa2ce0 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_conversation/duo_chat_conversation.vue +++ b/src/components/experimental/duo/chat/components/duo_chat_conversation/duo_chat_conversation.vue @@ -77,6 +77,7 @@ export default { v-for="(msg, index) in messages" :key="`${msg.role}-${index}`" :message="msg" + :message-index="index" :is-cancelled="canceledRequestIds.includes(msg.requestId)" @track-feedback="onTrackFeedback" @insert-code-snippet="onInsertCodeSnippet" diff --git a/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.vue b/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.vue index 3eaaccfd4ecc0c46d86c4fb8a3ead165b4becb63..983f1f23400da48386894c8c244edc6daa34eabe 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.vue +++ b/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.vue @@ -10,6 +10,7 @@ import { MESSAGE_MODEL_ROLES } from '../../constants'; import DocumentationSources from '../duo_chat_message_sources/duo_chat_message_sources.vue'; // eslint-disable-next-line no-restricted-imports import { renderDuoChatMarkdownPreview } from '../../markdown_renderer'; +import GlDuoChatContextItemSelections from '../duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.vue'; import { CopyCodeElement } from './copy_code_element'; import { InsertCodeSnippetElement } from './insert_code_snippet_element'; import { concatUntilEmpty } from './utils'; @@ -40,6 +41,7 @@ export default { GlFormTextarea, GlIcon, GlLoadingIcon, + GlDuoChatContextItemSelections, }, directives: { SafeHtml, @@ -79,6 +81,10 @@ export default { type: Boolean, required: true, }, + messageIndex: { + type: Number, + required: true, + }, }, data() { return { @@ -130,6 +136,25 @@ export default { error() { return Boolean(this.message?.errors?.length) && this.message.errors.join('; '); }, + selectedContextItems() { + return this.message.extras?.contextItems || []; + }, + displaySelectedContextItems() { + return this.message.extras && this.message.extras.contextItems; + }, + showSelectedContextItemCollapsible() { + return this.messageIndex === 1; // Make the second message (index 1) collapsible + }, + selectedContextItemTitle() { + if (!this.displaySelectedContextItems) return ''; + const count = this.selectedContextItems.length; + + if (this.messageIndex === 0) { + return `added context`; + } + + return `used ${count} reference${count !== 1 ? 's' : ''}`; + }, }, beforeCreate() { if (!customElements.get('copy-code')) { @@ -213,6 +238,14 @@ export default { data-testid="error" />
+
typeof GlDuoChat.props[prop].default === 'function' @@ -79,6 +86,10 @@ Default.args = generateProps({ }); Default.decorators = [makeContainer({ height: '800px' })]; +const contextItemMenuEventBus = new Vue(); + +let previousSelectedContextItems = []; + export const Interactive = (args, { argTypes }) => ({ components: { GlDuoChat, GlButton }, props: Object.keys(argTypes), @@ -95,15 +106,30 @@ export const Interactive = (args, { argTypes }) => ({ timeout: null, requestId: 1, canceledMessageRequestIds: [], + sampleContextItems: [], + eventBus: contextItemMenuEventBus, + sampleContextCategories: categories, }; }, + mounted() { + this.eventBus.$on(EVENT_BUS_TYPES.CONTEXT_ITEM_ADDED, this.onAddSelectedItem); + this.eventBus.$on(EVENT_BUS_TYPES.CONTEXT_ITEM_REMOVED, this.onRemoveSelectedItem); + this.eventBus.$on(EVENT_BUS_TYPES.CONTEXT_ITEM_SEARCH_QUERY, this.handleMockSearch); + }, + beforeDestroy() { + this.eventBus.$off(EVENT_BUS_TYPES.CONTEXT_ITEM_ADDED, this.onAddSelectedItem); + this.eventBus.$off(EVENT_BUS_TYPES.CONTEXT_ITEM_REMOVED, this.onRemoveSelectedItem); + this.eventBus.$off(EVENT_BUS_TYPES.CONTEXT_ITEM_SEARCH_QUERY, this.handleMockSearch); + }, methods: { onSendChatPrompt(prompt) { + previousSelectedContextItems = [...this.sampleContextItems]; const newPrompt = { ...MOCK_USER_PROMPT_MESSAGE, contentHtml: '', content: prompt, requestId: this.requestId, + extras: { contextItems: previousSelectedContextItems }, }; this.loggerInfo += `New prompt: ${JSON.stringify(newPrompt)}\n\n`; if ([CHAT_CLEAN_MESSAGE, CHAT_CLEAR_MESSAGE].includes(prompt)) { @@ -112,6 +138,15 @@ export const Interactive = (args, { argTypes }) => ({ this.msgs.push(newPrompt); this.promptInFlight = true; } + this.sampleContextItems = []; + }, + onAddSelectedItem(item) { + this.sampleContextItems = [...this.sampleContextItems, item]; + this.loggerInfo += `Added selected item: ${JSON.stringify(item)}\n\n`; + }, + onRemoveSelectedItem(item) { + this.sampleContextItems = this.sampleContextItems.filter((i) => i.id !== item.id); + this.loggerInfo += `Removed selected item: ${JSON.stringify(item)}\n\n`; }, onChatHidden() { this.isHidden = true; @@ -133,8 +168,8 @@ export const Interactive = (args, { argTypes }) => ({ }, async mockResponseFromAi() { const generator = generateMockResponseChunks(this.requestId); - for await (const newResponse of generator) { + newResponse.extras.contextItems = previousSelectedContextItems; if (!this.canceledMessageRequestIds.includes(newResponse.requestId)) { const existingMessageIndex = this.msgs.findIndex( (msg) => msg.requestId === newResponse.requestId && msg.role === newResponse.role @@ -159,39 +194,52 @@ export const Interactive = (args, { argTypes }) => ({ ...newResponse, }); }, + + async handleMockSearch({ category, query }) { + const mockData = generateSampleContextItems(30); + const filteredResults = mockData.filter( + (item) => item.type === category && item.name.toLowerCase().includes(query.toLowerCase()) + ); + this.loggerInfo += `Search results for ${category} - "${query}": ${JSON.stringify(filteredResults)}\n\n`; + + // Simulate an async operation + await new Promise((resolve) => { + setStoryTimeout(resolve, 100); + }); + + this.eventBus.$emit(EVENT_BUS_TYPES.CONTEXT_ITEM_SEARCH_RESULT, filteredResults); + }, }, template: ` -
-
-
-{{ loggerInfo }}
-      
- Mock the response +
+ Mock the response +
- Show chat - -
`, + `, }); Interactive.args = generateProps({}); diff --git a/src/components/experimental/duo/chat/duo_chat.vue b/src/components/experimental/duo/chat/duo_chat.vue index 1dc3cdba137418a8a0114abdbeea8ca347cd0336..e5efdfaa034c94ba128bbb9edc54d9ca2926bd9b 100644 --- a/src/components/experimental/duo/chat/duo_chat.vue +++ b/src/components/experimental/duo/chat/duo_chat.vue @@ -16,6 +16,9 @@ import { SafeHtmlDirective as SafeHtml } from '../../../../directives/safe_html/ import GlDuoChatLoader from './components/duo_chat_loader/duo_chat_loader.vue'; import GlDuoChatPredefinedPrompts from './components/duo_chat_predefined_prompts/duo_chat_predefined_prompts.vue'; import GlDuoChatConversation from './components/duo_chat_conversation/duo_chat_conversation.vue'; +import GlDuoChatContextItemMenu from './components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.vue'; +import GlDuoChatContextItemSelections from './components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.vue'; +import { EVENT_BUS_TYPES } from './components/duo_chat_context/duo_chat_context_event_bus'; import { CHAT_CLEAN_MESSAGE, CHAT_RESET_MESSAGE, CHAT_CLEAR_MESSAGE } from './constants'; export const i18n = { @@ -62,6 +65,8 @@ export default { GlDuoChatConversation, GlCard, GlDropdownItem, + GlDuoChatContextItemMenu, + GlDuoChatContextItemSelections, }, directives: { SafeHtml, @@ -198,6 +203,37 @@ export default { required: false, default: '', }, + + /** + * Additional Context Menu Item Event Bus + */ + contextItemMenuEventBus: { + type: Object, + required: false, + default: () => ({ + $on: () => {}, + $off: () => {}, + $emit: () => {}, + }), + }, + + /** + * Items selected by Additional Context Menu + */ + contextItemSelections: { + type: Array, + required: false, + default: () => [], + }, + + /** + * Categories to enable in the Additional Context Menu + */ + contextCategories: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -207,6 +243,8 @@ export default { activeCommandIndex: 0, displaySubmitButton: true, compositionJustEnded: false, + contextMenuOpen: false, + cursorPosition: 0, }; }, computed: { @@ -290,10 +328,17 @@ export default { }, created() { this.handleScrollingTrottled = throttle(this.handleScrolling, 200); // Assume a 200ms throttle for example + this.contextItemMenuEventBus.$on( + EVENT_BUS_TYPES.CONTEXT_ITEM_ADDED, + this.handleContextItemAdded + ); }, mounted() { this.scrollToBottom(); }, + beforeDestroy() { + document.removeEventListener('keydown', this.handleContextMenuKeydown); + }, methods: { compositionEnd() { this.compositionJustEnded = true; @@ -412,11 +457,42 @@ export default { this.sendChatPrompt(); } else { this.setPromptAndFocus(`${command.name} `); + if (command.name === '/include' && this.contextCategories.length > 0) { + this.showContextItemMenu(true); + } } }, onInsertCodeSnippet(e) { this.$emit('insert-code-snippet', e); }, + + async showContextItemMenu(show = true) { + this.contextMenuOpen = show; + + if (show) { + await this.$nextTick(); + this.$refs.prompt.$el.blur(); + document.addEventListener('keydown', this.handleContextMenuKeydown); + await this.$nextTick(); + this.$refs.contextItemMenu.focusSearchInput(); + } else { + document.removeEventListener('keydown', this.handleContextMenuKeydown); + this.$refs.prompt.$el.focus(); + } + }, + + async handleContextMenuKeydown(event) { + if (this.contextMenuOpen) { + this.$refs.contextItemMenu.handleKeydown(event); + } + }, + + async handleContextItemAdded() { + this.prompt = this.prompt.replace('/include', '').trim(); + await this.$nextTick(); + this.$refs.prompt.$el.focus(); + this.contextMenuOpen = false; + }, }, i18n, emptySvg, @@ -532,6 +608,10 @@ export default { :class="{ 'duo-chat-drawer-body-scrim-on-footer': !scrolledToBottom }" > +
- +