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 @@
+
+
+
+
+ {{
+ item.metadata.name
+ }}
+
+
+
+ {{
+ translate('DuoChatContextItemPopover.ProjectLabel', 'Project:')
+ }}
+ {{ itemInfo.project }}
+
+
+ {{
+ translate('DuoChatContextItemPopover.PathLabel', 'Path:')
+ }}
+ {{ filePath }}
+
+
+ {{ translate('DuoChatContextItemPopover.IdLabel', 'ID:') }}
+ {{ idPrefix }}{{ id }}
+
+
+ {{
+ translate('DuoChatContextItemPopover.TypeLabel', 'Type:')
+ }}
+ {{ item.type }}
+
+
+ {{
+ translate('DuoChatContextItemPopover.DisabledMessageLabel', 'Note:')
+ }}
+ {{ disabledMessage }}
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+ {{ item.metadata.name }}
+
+
+
+
+
+
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',