diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/constants.js b/src/components/experimental/duo/chat/components/duo_chat_context/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..d1d1b777dbf775644fad584d3691a2957d88c19c --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/constants.js @@ -0,0 +1,3 @@ +export const CONTEXT_ITEM_TYPE_ISSUE = 'issue'; +export const CONTEXT_ITEM_TYPE_MERGE_REQUEST = 'merge_request'; +export const CONTEXT_ITEM_TYPE_PROJECT_FILE = 'project_file'; diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_popover/duo_chat_context_item_popover.spec.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_popover/duo_chat_context_item_popover.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..68160276e98037ba7a43e2489605fb5b010f7e07 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_popover/duo_chat_context_item_popover.spec.js @@ -0,0 +1,115 @@ +import { shallowMount } from '@vue/test-utils'; +import GlPopover from '../../../../../../base/popover/popover.vue'; +import { + MOCK_CONTEXT_ITEM_FILE, + MOCK_CONTEXT_ITEM_FILE_DISABLED, + MOCK_CONTEXT_ITEM_ISSUE, + MOCK_CONTEXT_ITEM_ISSUE_DISABLED, + MOCK_CONTEXT_ITEM_MERGE_REQUEST, +} from '../mock_context_data'; +import GlDuoChatContextItemPopover from './duo_chat_context_item_popover.vue'; + +describe('GlDuoChatContextItemPopover', () => { + let wrapper; + + const createComponent = (props = {}, options = {}) => { + wrapper = shallowMount(GlDuoChatContextItemPopover, { + propsData: { + item: MOCK_CONTEXT_ITEM_FILE, + target: 'test-target', + placement: 'top', + ...props, + }, + ...options, + }); + }; + + const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); + const findPopover = () => wrapper.findComponent(GlPopover); + const findPopoverTitle = () => findByTestId('chat-context-popover-title'); + const findDisabledMessage = () => findByTestId('chat-context-popover-disabled'); + + it('renders the popover component', () => { + createComponent(); + + expect(findPopover().exists()).toBe(true); + }); + + it('passes the correct props to the popover', () => { + createComponent(); + + const popover = findPopover(); + + expect(popover.attributes('target')).toBe('test-target'); + expect(popover.props('triggers')).toBe('hover focus'); + expect(popover.props('placement')).toBe('top'); + expect(popover.props('title')).toBe(MOCK_CONTEXT_ITEM_FILE.metadata.name); + }); + + it('renders the item name in the title slot', () => { + createComponent( + {}, + { + stubs: { + GlPopover: { + name: 'GlPopover', + template: '
', + }, + }, + } + ); + + expect(findPopoverTitle().text()).toBe(MOCK_CONTEXT_ITEM_FILE.metadata.name); + }); + + describe('item info rendering', () => { + it.each([ + ['file', MOCK_CONTEXT_ITEM_FILE], + ['issue', MOCK_CONTEXT_ITEM_ISSUE], + ['merge request', MOCK_CONTEXT_ITEM_MERGE_REQUEST], + ])('renders correct project and type for %s', (_, item) => { + createComponent({ item }); + + const content = findPopover().text(); + expect(content).toContain(item.metadata.info.project); + expect(content).toContain(item.type); + }); + + it('renders file path for file items', () => { + createComponent({ item: MOCK_CONTEXT_ITEM_FILE }); + + const content = findPopover().text(); + expect(content).toContain(MOCK_CONTEXT_ITEM_FILE.metadata.info.relFilePath); + }); + + it.each([ + ['issue', MOCK_CONTEXT_ITEM_ISSUE, '#1234'], + ['merge request', MOCK_CONTEXT_ITEM_MERGE_REQUEST, '!1122'], + ])('renders ID for %s items with correct prefix', (_, item, expected) => { + createComponent({ item }); + + const content = findPopover().text(); + expect(content).toContain(expected); + }); + }); + + describe('disabled items', () => { + it('renders disabled message', () => { + createComponent({ item: MOCK_CONTEXT_ITEM_ISSUE_DISABLED }); + + expect(findDisabledMessage().text()).toContain( + 'This foo is not available to bar, Lorem something something wow?' + ); + }); + + it('renders default disabled message when no specific reasons are provided', () => { + const itemWithoutReasons = { + ...MOCK_CONTEXT_ITEM_FILE_DISABLED, + info: { ...MOCK_CONTEXT_ITEM_FILE_DISABLED.metadata.info, disabledReasons: undefined }, + }; + createComponent({ item: itemWithoutReasons }); + + expect(findDisabledMessage().text()).toContain('This item is disabled'); + }); + }); +}); 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..2cf9d2643ab52a8c5590cd2fd73add438ce64150 --- /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,63 @@ +import { makeContainer } from '../../../../../../../utils/story_decorators/container'; +import { + MOCK_CONTEXT_ITEM_FILE, + MOCK_CONTEXT_ITEM_FILE_DISABLED, + MOCK_CONTEXT_ITEM_ISSUE, + MOCK_CONTEXT_ITEM_ISSUE_DISABLED, + MOCK_CONTEXT_ITEM_MERGE_REQUEST, + MOCK_CONTEXT_ITEM_MERGE_REQUEST_DISABLED, +} from '../mock_context_data'; +import GlDuoChatContextItemPopover from './duo_chat_context_item_popover.vue'; + +export default { + title: 'experimental/duo/chat/components/duo-chat-context/duo-chat-context-item-popover', + component: GlDuoChatContextItemPopover, + tags: ['skip-visual-test'], + decorators: [makeContainer({ height: '300px' })], +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { GlDuoChatContextItemPopover }, + template: ` +
+ + +
+ `, +}); + +export const File = Template.bind({}); +File.args = { + item: MOCK_CONTEXT_ITEM_FILE, +}; + +export const DisabledFile = Template.bind({}); +DisabledFile.args = { + item: MOCK_CONTEXT_ITEM_FILE_DISABLED, +}; + +export const Issue = Template.bind({}); +Issue.args = { + item: MOCK_CONTEXT_ITEM_ISSUE, +}; + +export const DisabledIssue = Template.bind({}); +DisabledIssue.args = { + item: MOCK_CONTEXT_ITEM_ISSUE_DISABLED, +}; + +export const MergeRequest = Template.bind({}); +MergeRequest.args = { + item: MOCK_CONTEXT_ITEM_MERGE_REQUEST, +}; + +export const DisabledMergeRequest = Template.bind({}); +DisabledMergeRequest.args = { + item: MOCK_CONTEXT_ITEM_MERGE_REQUEST_DISABLED, +}; 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..61854ec514239eb074ae243bc4e46dfb58aa043e --- /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,111 @@ + + diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.spec.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0d8800f360628f7fd990fbd14dc7af3aa5499840 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.spec.js @@ -0,0 +1,147 @@ +import { shallowMount } from '@vue/test-utils'; +import GlIcon from '../../../../../../base/icon/icon.vue'; +import GlToken from '../../../../../../base/token/token.vue'; +import GlDuoChatContextItemPopover from '../duo_chat_context_item_popover/duo_chat_context_item_popover.vue'; +import { + getMockContextItems, + MOCK_CONTEXT_ITEM_FILE, + MOCK_CONTEXT_ITEM_ISSUE, + MOCK_CONTEXT_ITEM_MERGE_REQUEST, +} from '../mock_context_data'; +import GlDuoChatContextItemSelections from './duo_chat_context_item_selections.vue'; + +describe('GlDuoChatContextItemSelections', () => { + let wrapper; + let mockSelections; + + const createComponent = (props = {}) => { + mockSelections = getMockContextItems().slice(0, 3); + wrapper = shallowMount(GlDuoChatContextItemSelections, { + propsData: { + selections: mockSelections, + title: 'Test Title', + defaultCollapsed: true, + showClose: true, + ...props, + }, + }); + }; + + const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); + + const findTitle = () => findByTestId('chat-context-selections-title'); + const findTokensWrapper = () => findByTestId('chat-context-tokens-wrapper'); + const findTokens = () => wrapper.findAllComponents(GlToken); + const findTokensIcons = () => findTokensWrapper().findAllComponents(GlIcon); + const findPopovers = () => wrapper.findAllComponents(GlDuoChatContextItemPopover); + const findCollapseIcon = () => findByTestId('chat-context-collapse-icon'); + + describe('component rendering', () => { + it('renders the component when selections are provided', () => { + createComponent(); + + expect(wrapper.exists()).toBe(true); + }); + + it('renders the correct title', () => { + createComponent(); + + expect(findTitle().text()).toBe('Test Title'); + }); + + it('renders tokens for each selection', () => { + createComponent(); + + expect(findTokens()).toHaveLength(3); + }); + + it('renders icons for each selection', () => { + createComponent(); + + expect(findTokensIcons()).toHaveLength(3); + }); + + it('renders popovers for each selection', () => { + createComponent(); + + expect(findPopovers()).toHaveLength(3); + }); + }); + + describe('collapsable behavior', () => { + it('renders collapse indicator when collapsed', () => { + createComponent({ defaultCollapsed: true }); + + expect(findCollapseIcon().props('name')).toEqual('chevron-right'); + }); + + it('renders expanded indicator when expanded', () => { + createComponent({ defaultCollapsed: false }); + + expect(findCollapseIcon().props('name')).toEqual('chevron-down'); + }); + + it('toggles collapse state when title is clicked and collapsable is true', async () => { + createComponent({ defaultCollapsed: true }); + + await findTitle().trigger('click'); + + expect(findTokensWrapper().isVisible()).toBe(true); + + await findTitle().trigger('click'); + + expect(findTokensWrapper().isVisible()).toBe(false); + }); + + it('does not toggle collapse state when title is clicked and collapsable is false', async () => { + createComponent({ collapsable: false }); + + await findTitle().trigger('click'); + + expect(findTokensWrapper().isVisible()).toBe(true); + }); + }); + + describe('icon rendering', () => { + it('renders the correct icon for file type', () => { + createComponent({ selections: [MOCK_CONTEXT_ITEM_FILE] }); + + expect(findTokensIcons().at(0).props('name')).toBe('document'); + }); + + it('renders the correct icon for issue type', () => { + createComponent({ selections: [MOCK_CONTEXT_ITEM_ISSUE] }); + + expect(findTokensIcons().at(0).props('name')).toBe('issues'); + }); + + it('renders the correct icon for merge request type', () => { + createComponent({ selections: [MOCK_CONTEXT_ITEM_MERGE_REQUEST] }); + + expect(findTokensIcons().at(0).props('name')).toBe('merge-request'); + }); + + it('renders the default icon for unknown types', () => { + const unknownItem = { ...MOCK_CONTEXT_ITEM_FILE, type: 'unknown' }; + createComponent({ selections: [unknownItem] }); + + expect(findTokensIcons().at(0).props('name')).toBe('document'); + }); + }); + + describe('popover rendering', () => { + it('passes correct props to the popover component', () => { + createComponent(); + + const index = 0; + const item = mockSelections.at(index); + const popover = findPopovers().at(index); + + expect(popover.props('item')).toEqual(item); + expect(popover.props('target')).toMatch( + /^context-item-123e4567-e89b-12d3-a456-426614174000-\d+$/ + ); + expect(popover.props('placement')).toBe('bottom'); + }); + }); +}); 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..f6f739e177778d39a6f11b6627e0906583882a66 --- /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,40 @@ +import { makeContainer } from '../../../../../../../utils/story_decorators/container'; +import { getMockContextItems } from '../mock_context_data'; +import GlDuoChatContextItemSelections from './duo_chat_context_item_selections.vue'; + +const sampleContextItems = getMockContextItems(); + +export default { + title: 'experimental/duo/chat/components/duo-chat-context/duo-chat-context-item-selections', + component: GlDuoChatContextItemSelections, + argTypes: { + defaultCollapsed: { control: 'boolean' }, + title: { control: 'text' }, + selections: { control: 'object' }, + }, + tags: ['skip-visual-test'], + decorators: [makeContainer({ height: '300px' })], +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { GlDuoChatContextItemSelections }, + template: ` +
+ +
+ `, +}); + +export const Default = Template.bind({}); +Default.args = { + title: 'Included references', + defaultCollapsed: true, + selections: sampleContextItems, +}; 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..ee724fcc22d3bb89b2fb195aa93bceeef2484b0c --- /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,94 @@ + + + diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/mock_context_data.js b/src/components/experimental/duo/chat/components/duo_chat_context/mock_context_data.js new file mode 100644 index 0000000000000000000000000000000000000000..bd1dc34d03381ff3a8ed1505fc3c995ca0c81f29 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/mock_context_data.js @@ -0,0 +1,137 @@ +export const MOCK_CONTEXT_ITEM_FILE = { + id: '123e4567-e89b-12d3-a456-426614174000', + isEnabled: true, + metadata: { + name: 'strawberry.ts', + info: { + project: 'example/garden', + relFilePath: 'src/plants/strawberry.ts', + }, + }, + type: 'project_file', +}; + +export const MOCK_CONTEXT_ITEM_FILE_DISABLED = { + id: '323e4567-e89b-12d3-a456-426614174002', + isEnabled: false, + metadata: { + name: 'motorbike.cs', + info: { + project: 'example/vehicles', + relFilePath: '/src/VehicleFoo/motorbike.cs', + }, + }, + type: 'project_file', +}; +const mockFiles = [ + MOCK_CONTEXT_ITEM_FILE, + { + id: '223e4567-e89b-12d3-a456-426614174001', + isEnabled: true, + metadata: { + name: 'potato.ts', + info: { + project: 'example/garden', + relFilePath: '/src/plants/potato.ts', + }, + }, + type: 'project_file', + }, + MOCK_CONTEXT_ITEM_FILE_DISABLED, +]; + +export const MOCK_CONTEXT_ITEM_ISSUE = { + id: '423e4567-e89b-12d3-a456-426614174003', + isEnabled: true, + metadata: { + name: 'Implement watering schedule', + info: { + project: 'example/garden', + iid: 1234, + }, + }, + type: 'issue', +}; +export const MOCK_CONTEXT_ITEM_ISSUE_DISABLED = { + id: 'c463fb31-2a4c-4f8e-a609-97230ac48ae5', + isEnabled: false, + metadata: { + name: 'Fix vehicle colours', + info: { + project: 'example/vehicle', + iid: 91011, + }, + }, + disabledReasons: ['This foo is not available to bar', 'Lorem something something wow?'], + type: 'issue', +}; + +const mockIssues = [ + MOCK_CONTEXT_ITEM_ISSUE, + { + id: '523e4567-e89b-12d3-a456-426614174004', + isEnabled: true, + metadata: { + name: 'Refactor plant growth rates', + info: { + project: 'example/garden', + iid: 5678, + }, + }, + type: 'issue', + }, + MOCK_CONTEXT_ITEM_ISSUE_DISABLED, +]; + +export const MOCK_CONTEXT_ITEM_MERGE_REQUEST = { + id: '623e4567-e89b-12d3-a456-426614174005', + isEnabled: true, + metadata: { + name: 'Improve database performance', + info: { + project: 'example/garden', + iid: 1122, + }, + }, + type: 'merge_request', +}; +export const MOCK_CONTEXT_ITEM_MERGE_REQUEST_DISABLED = { + id: '4eb665fc-e5e1-49b0-9789-2a16964e461a', + isEnabled: false, + metadata: { + name: 'Fix broken layout at small viewports', + info: { + project: 'example/vehicle', + iid: 5566, + }, + }, + disabledReasons: ['This foo is not available to bar', 'Lorem something something wow?'], + type: 'merge_request', +}; + +const mockMergeRequests = [ + MOCK_CONTEXT_ITEM_MERGE_REQUEST, + { + id: '723e4567-e89b-12d3-a456-426614174006', + isEnabled: false, + metadata: { + name: 'Add vehicle registration details', + info: { + project: 'example/vehicle', + iid: 3344, + }, + }, + disabledReasons: ['This foo is not available to bar', 'Lorem something something wow?'], + type: 'merge_request', + }, + MOCK_CONTEXT_ITEM_MERGE_REQUEST_DISABLED, +]; + +export const getMockContextItems = () => { + const allItems = [...mockFiles, ...mockIssues, ...mockMergeRequests]; + + // put disabled items in the back + const disabledItems = allItems.filter((item) => !item.isEnabled); + const enabledItems = allItems.filter((item) => item.isEnabled); + return [...enabledItems, ...disabledItems]; +}; diff --git a/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.md b/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.md index 5504ce30acac29a58b9b38084f7291e20662f073..7bcadaead20df88d782cc6b81752a7232afe7f99 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.md +++ b/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.md @@ -62,3 +62,8 @@ The component emits the `track-feedback` event, a proxy of the `feedback` event the `GlDuoUserFeedback` component. Please refer to [the documentation on that component](/story/experimental-duo-user-feedback--docs#listening-to-the-feedback-form-submission) when processing feedback from users. + +## Included context references + +Messages will display any included context references (files, issues merge requests etc.) when +the message `meta.contextItems` array contains valid items. diff --git a/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.spec.js b/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.spec.js index 2051625b9c8e3ca984f216abc152303c7a22b24e..f15997b6b86d57eee3bd142c5bcc39a417b62584 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.spec.js +++ b/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.spec.js @@ -8,7 +8,9 @@ import { MOCK_RESPONSE_MESSAGE, generateSeparateChunks, } from '../../mock_data'; +import GlDuoChatContextItemSelections from '../duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.vue'; import DocumentationSources from '../duo_chat_message_sources/duo_chat_message_sources.vue'; +import { getMockContextItems } from '../duo_chat_context/mock_context_data'; import GlDuoChatMessage from './duo_chat_message.vue'; describe('DuoChatMessage', () => { @@ -23,6 +25,8 @@ describe('DuoChatMessage', () => { const findInsertCodeSnippetButton = () => wrapper.find('insert-code-snippet'); const findErrorIcon = () => wrapper.findComponent(GlIcon); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findContextItemSelections = () => wrapper.findComponent(GlDuoChatContextItemSelections); + const mockMarkdownContent = 'foo **bar**'; let renderMarkdown; @@ -167,6 +171,119 @@ describe('DuoChatMessage', () => { }); }); + describe('context item selections', () => { + it('does not render context item selections when there are no items', () => { + const messageWithoutContext = { + ...MOCK_USER_PROMPT_MESSAGE, + extras: {}, + }; + createComponent({ message: messageWithoutContext }); + expect(findContextItemSelections().exists()).toBe(false); + }); + + describe('title rendering', () => { + describe('when there is one context item', () => { + it('uses singular form for assistant message', () => { + const contextItems = [getMockContextItems().at(0)]; + createComponent({ + message: { + ...MOCK_RESPONSE_MESSAGE, + extras: { ...MOCK_RESPONSE_MESSAGE.extras, contextItems }, + }, + }); + const selections = findContextItemSelections(); + expect(selections.props('title')).toBe('Used 1 included reference'); + }); + + it('uses singular form for user message', () => { + const contextItems = [getMockContextItems().at(0)]; + createComponent({ + message: { + ...MOCK_USER_PROMPT_MESSAGE, + extras: { ...MOCK_RESPONSE_MESSAGE.extras, contextItems }, + }, + }); + const selections = findContextItemSelections(); + expect(selections.props('title')).toBe('Included reference'); + }); + }); + + describe('when there are multiple context items', () => { + it('uses plural form for assistant message', () => { + const contextItems = getMockContextItems().slice(0, 2); + createComponent({ + message: { + ...MOCK_RESPONSE_MESSAGE, + extras: { ...MOCK_RESPONSE_MESSAGE.extras, contextItems }, + }, + }); + const selections = findContextItemSelections(); + expect(selections.props('title')).toBe('Used 2 included references'); + }); + + it('uses plural form for user message', () => { + const contextItems = getMockContextItems().slice(0, 2); + createComponent({ + message: { + ...MOCK_USER_PROMPT_MESSAGE, + extras: { ...MOCK_RESPONSE_MESSAGE.extras, contextItems }, + }, + }); + const selections = findContextItemSelections(); + expect(selections.props('title')).toBe('Included references'); + }); + }); + }); + + it('renders context item selections when there are items', () => { + const contextItems = getMockContextItems().slice(0, 2); + createComponent({ + message: { + ...MOCK_USER_PROMPT_MESSAGE, + extras: { contextItems }, + }, + }); + + expect(findContextItemSelections().exists()).toBe(true); + }); + + it('passes correct props to context item selections for user message', () => { + const contextItems = getMockContextItems().slice(0, 2); + createComponent({ + message: { + ...MOCK_USER_PROMPT_MESSAGE, + extras: { contextItems }, + }, + }); + + const selections = findContextItemSelections(); + expect(selections.props()).toMatchObject( + expect.objectContaining({ + selections: contextItems, + defaultCollapsed: false, + }) + ); + }); + + it('passes correct props to context item selections for assistant message', () => { + const contextItems = getMockContextItems().slice(0, 2); + createComponent({ + message: { + ...MOCK_RESPONSE_MESSAGE, + extras: { contextItems }, + }, + }); + + const selections = findContextItemSelections(); + expect(selections.props()).toMatchObject( + expect.objectContaining({ + selections: contextItems, + defaultCollapsed: true, + }) + ); + }); + }); + describe('message output', () => { it('renders the warning icon when message has errors', () => { const error = 'foo'; diff --git a/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.stories.js b/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.stories.js index e65a05d063566c932489dc8ccdcf15e7c3f11442..bc7b1b0d2a44eac96945ad64ec2471825a95a279 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.stories.js +++ b/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.stories.js @@ -20,7 +20,7 @@ const Template = (args, { argTypes }) => ({ renderGFM, }, template: ` - + `, }); @@ -38,6 +38,7 @@ export const ErrorResponse = Template.bind({}); ErrorResponse.args = generateProps({ message: { ...MOCK_RESPONSE_MESSAGE, + extras: {}, errors: ['Error: Whatever you see is wrong'], }, }); 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..2ebde8d872db1e4fb3884a74042a3126b8fe52b1 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 @@ -2,10 +2,12 @@ import GlIcon from '../../../../../base/icon/icon.vue'; import GlLoadingIcon from '../../../../../base/loading_icon/loading_icon.vue'; import { GlTooltipDirective } from '../../../../../../directives/tooltip'; +import GlDuoChatContextItemSelections from '../duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.vue'; import GlDuoUserFeedback from '../../../user_feedback/user_feedback.vue'; import GlFormGroup from '../../../../../base/form/form_group/form_group.vue'; import GlFormTextarea from '../../../../../base/form/form_textarea/form_textarea.vue'; import { SafeHtmlDirective as SafeHtml } from '../../../../../../directives/safe_html/safe_html'; +import { sprintf, translatePlural } from '../../../../../../utils/i18n'; 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 @@ -35,6 +37,7 @@ export default { }, components: { DocumentationSources, + GlDuoChatContextItemSelections, GlDuoUserFeedback, GlFormGroup, GlFormTextarea, @@ -130,6 +133,39 @@ export default { error() { return Boolean(this.message?.errors?.length) && this.message.errors.join('; '); }, + selectedContextItems() { + return this.message.extras?.contextItems || []; + }, + displaySelectedContextItems() { + return Boolean(this.selectedContextItems.length); + }, + selectedContextItemsDefaultCollapsed() { + return this.isAssistantMessage; + }, + selectedContextItemsTitle() { + if (!this.displaySelectedContextItems) return ''; + + const count = this.selectedContextItems.length; + + if (this.isUserMessage) { + return translatePlural( + 'GlDuoChatMessage.SelectedContextItemsTitleUserMessage', + 'Included reference', + 'Included references' + )(count); + } + + return sprintf( + translatePlural( + 'GlDuoChatMessage.SelectedContextItemsTitleAssistantMessage', + 'Used %{count} included reference', + 'Used %{count} included references' + )(count), + { + count, + } + ); + }, }, beforeCreate() { if (!customElements.get('copy-code')) { @@ -213,6 +249,13 @@ export default { data-testid="error" />
+
content; diff --git a/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-conversation-default-1-snap.png b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-conversation-default-1-snap.png index 860a739a8cc328f5488dfe3a44f04cfbe6937f18..59acf247a864e71de14ff0a67dad111314aeffed 100644 Binary files a/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-conversation-default-1-snap.png and b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-conversation-default-1-snap.png differ diff --git a/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-conversation-multiple-conversations-1-snap.png b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-conversation-multiple-conversations-1-snap.png index f8b719c5f2dc6b6c14083e2ceb33e695d6c73a8e..493f35308a62fe856e362eb37295cf1d01290ce5 100644 Binary files a/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-conversation-multiple-conversations-1-snap.png and b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-conversation-multiple-conversations-1-snap.png differ diff --git a/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-message-response-1-snap.png b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-message-response-1-snap.png index 8a89b0a974c40ae9e2e6071d438c6e28f3dced3a..6dced4c899803920c4a0f073cffcf65fbbdc30d3 100644 Binary files a/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-message-response-1-snap.png and b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-message-response-1-snap.png differ diff --git a/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-message-user-1-snap.png b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-message-user-1-snap.png index 2f6498114a93f3c5904467ac0e7a3ffa2ba5b5c1..23ae3c1e16d61ef484a73ec9cf5ebda7fe8ca29b 100644 Binary files a/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-message-user-1-snap.png and b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-experimental-duo-chat-duo-chat-message-user-1-snap.png differ diff --git a/translations.js b/translations.js index 9b1405440e846d58b4714fe9030434eccecb2b7b..e564a8ef953d151b0f61b42a50a9fb0df6569d03 100644 --- a/translations.js +++ b/translations.js @@ -1,8 +1,11 @@ /* eslint-disable import/no-default-export */ export default { 'ClearIconButton.title': 'Clear', + 'DuoChatContextItemPopover.DisabledReason': 'This item is disabled', 'GlBreadcrumb.showMoreLabel': 'Show more breadcrumbs', 'GlCollapsibleListbox.srOnlyResultsLabel': null, + 'GlDuoChatMessage.SelectedContextItemsTitleAssistantMessage': null, + 'GlDuoChatMessage.SelectedContextItemsTitleUserMessage': null, 'GlDuoWorkflowPanel.collapseButtonTitle': 'Collapse', 'GlDuoWorkflowPanel.expandButtonTitle': 'Expand', 'GlDuoWorkflowPrompt.cancelButtonText': 'Cancel',