Skip to content
Snippets Groups Projects
Verified Commit 63909dd7 authored by Elwyn Benson's avatar Elwyn Benson :red_circle:
Browse files

feat(GlDuoChat): view context item content/details

- adds support for view context item content/details
- clicking selected context item of type file/git will
emit event to load additional data, and displays in new
details modal
- adds hook for syntax highlighting the content from consumer
applications, in the same manner the chat highlighting is done
parent 127691f7
No related branches found
No related tags found
1 merge request!4648feat(GlDuoChat): view context item content/details
Showing
with 539 additions and 6 deletions
......@@ -5,3 +5,7 @@ export const CONTEXT_ITEM_CATEGORY_LOCAL_GIT = 'local_git';
export const CONTEXT_ITEM_LOCAL_GIT_COMMIT = 'commit';
export const CONTEXT_ITEM_LOCAL_GIT_DIFF = 'diff';
export const LANGUAGE_IDENTIFIER_PREFIX = 'language-';
export const LANGUAGE_IDENTIFIER_DIFF = 'language-diff';
export const LANGUAGE_IDENTIFIER_PLAINTEXT = 'language-plaintext';
<script>
import { nextTick } from 'vue';
import { contextItemValidator } from '../utils';
import GlModal from '../../../../../../base/modal/modal.vue';
import { SafeHtmlDirective as SafeHtml } from '../../../../../../../directives/safe_html/safe_html';
import GlSkeletonLoader from '../../../../../../base/skeleton_loader/skeleton_loader.vue';
import {
CONTEXT_ITEM_CATEGORY_LOCAL_GIT,
LANGUAGE_IDENTIFIER_DIFF,
LANGUAGE_IDENTIFIER_PLAINTEXT,
LANGUAGE_IDENTIFIER_PREFIX,
} from '../constants';
import { translate } from '../../../../../../../utils/i18n';
export default {
name: 'GlDuoChatContextItemDetails',
components: {
GlSkeletonLoader,
GlModal,
},
directives: {
SafeHtml,
},
inject: {
renderGFM: {
from: 'renderGFM',
default: () => (element) => {
element.classList.add('gl-markdown', 'gl-compact-markdown');
},
},
},
props: {
/**
* Context items to preview. If it has no `content`, the loading state will be displayed.
*/
contextItem: {
type: Object,
required: true,
validator: contextItemValidator,
},
},
computed: {
isLoadingContent() {
return this.contextItem.content === undefined;
},
languageIdentifierClass() {
if (this.contextItem.category === CONTEXT_ITEM_CATEGORY_LOCAL_GIT) {
return LANGUAGE_IDENTIFIER_DIFF;
}
const fileExtension = this.contextItem.metadata?.relativePath?.split('.').at(-1);
if (fileExtension && fileExtension !== this.contextItem.metadata?.relativePath) {
return `${LANGUAGE_IDENTIFIER_PREFIX}${fileExtension}`;
}
return LANGUAGE_IDENTIFIER_PLAINTEXT;
},
title() {
return (
this.contextItem.metadata?.title ||
this.contextItem.metadata?.relativePath ||
translate('GlDuoChatContextItemDetails.title', 'Preview')
);
},
},
watch: {
contextItem: {
async handler(newVal, oldVal) {
const shouldFormat = newVal?.content !== oldVal?.content && newVal?.content;
if (shouldFormat) {
await nextTick();
await this.hydrateContentWithGFM();
}
},
immediate: true,
},
},
methods: {
async hydrateContentWithGFM() {
await nextTick();
if (this.$refs.content) {
this.renderGFM(this.$refs.content);
}
},
},
ACTION_PRIMARY: { text: translate('GlDuoChatContextItemDetails.close', 'Close') },
};
</script>
<template>
<gl-modal
modal-id="context-item-details-modal"
:title="title"
:visible="true"
:action-primary="$options.ACTION_PRIMARY"
:scrollable="true"
size="lg"
@change="$emit('close')"
@primary="$emit('close')"
>
<gl-skeleton-loader v-if="isLoadingContent" />
<div v-else ref="content" data-testid="context-item-content">
<pre
v-safe-html="contextItem.content"
class="code js-syntax-highlight p-3"
:class="languageIdentifierClass"
></pre>
</div>
</gl-modal>
</template>
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import GlSkeletonLoader from '../../../../../../base/skeleton_loader/skeleton_loader.vue';
import GlModal from '../../../../../../base/modal/modal.vue';
import { MOCK_CONTEXT_ITEM_FILE } from '../mock_context_data';
import {
CONTEXT_ITEM_CATEGORY_LOCAL_GIT,
LANGUAGE_IDENTIFIER_DIFF,
LANGUAGE_IDENTIFIER_PLAINTEXT,
LANGUAGE_IDENTIFIER_PREFIX,
} from '../constants';
import GlDuoChatContextItemDetails from './duo_chat_content_item_details.vue';
describe('GlDuoChatContextItemDetails', () => {
let wrapper;
let renderGFM;
const createComponent = (propsData = {}) => {
renderGFM = jest.fn();
wrapper = shallowMount(GlDuoChatContextItemDetails, {
propsData,
provide: { renderGFM },
});
};
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
const findModal = () => wrapper.findComponent(GlModal);
const findLoadingState = () => wrapper.findComponent(GlSkeletonLoader);
const findContent = () => findByTestId('context-item-content');
function expectLanguageIdentifierClass(cls) {
const [el] = renderGFM.mock.calls.at(0);
const elMarkup = el.innerHTML.toString();
expect(elMarkup).toContain(cls);
}
describe('default behaviour', () => {
it('renders an open modal', () => {
createComponent({ contextItem: MOCK_CONTEXT_ITEM_FILE });
expect(findModal().props()).toEqual(
expect.objectContaining({
dismissLabel: 'Close',
actionPrimary: {
text: 'Close',
},
actionCancel: null,
actionSecondary: null,
size: 'lg',
visible: true,
})
);
});
it.each([
{ metadata: { title: 'WOW' }, expected: 'WOW' },
{ metadata: { title: undefined }, expected: 'Preview' },
])('sets modal title to contextItem value or fallbacks', ({ metadata, expected }) => {
const contextItem = {
...MOCK_CONTEXT_ITEM_FILE,
metadata: {
enabled: true,
...metadata,
},
};
createComponent({ contextItem });
expect(findModal().props('title')).toEqual(expected);
});
it('emits "close" event when modal closes', () => {
createComponent({ contextItem: MOCK_CONTEXT_ITEM_FILE });
findModal().vm.$emit('change', false);
expect(wrapper.emitted('close')).toHaveLength(1);
});
it('emits "close" event when modal primary action is triggered', () => {
createComponent({ contextItem: MOCK_CONTEXT_ITEM_FILE });
findModal().vm.$emit('primary');
expect(wrapper.emitted('close')).toHaveLength(1);
});
});
describe('when context item does not have content', () => {
beforeEach(() => {
const contextItem = {
...MOCK_CONTEXT_ITEM_FILE,
content: undefined,
};
createComponent({ contextItem });
});
it('should display the loading state', () => {
expect(findLoadingState().exists()).toBe(true);
});
it('should not display content', () => {
expect(findContent().exists()).toBe(false);
});
describe('when context item content finishes loading', () => {
beforeEach(async () => {
const hydratedContextItem = {
...MOCK_CONTEXT_ITEM_FILE,
content: 'water',
};
wrapper.setProps({ contextItem: hydratedContextItem });
await nextTick();
});
it('should not show the loading state', () => {
expect(findLoadingState().exists()).toBe(false);
});
it('should format content with provided "renderGFM" function', async () => {
expect(renderGFM).toHaveBeenCalledTimes(1);
const [el] = renderGFM.mock.calls.at(0);
const elMarkup = el.innerHTML.toString();
expect(elMarkup).toContain('water');
});
it('should apply necessary class for external highlight-js to perform syntax highlighting', () => {
expect(renderGFM).toHaveBeenCalledTimes(1);
const [el] = renderGFM.mock.calls.at(0);
const elMarkup = el.innerHTML.toString();
expect(elMarkup).toContain('js-syntax-highlight');
});
describe('language identifier classes', () => {
it('should apply "language-diff" for a contextItem fo category "git"', async () => {
wrapper.setProps({
contextItem: {
...MOCK_CONTEXT_ITEM_FILE,
category: CONTEXT_ITEM_CATEGORY_LOCAL_GIT,
content: 'ding',
},
});
await nextTick();
expectLanguageIdentifierClass(LANGUAGE_IDENTIFIER_DIFF);
});
it.each(['ts', 'js', 'md', 'sh'])(
'should apply "language-%s" when file extension is "%s"',
async (extension) => {
wrapper.setProps({
contextItem: {
...MOCK_CONTEXT_ITEM_FILE,
metadata: {
...MOCK_CONTEXT_ITEM_FILE.metadata,
relativePath: `wow/so/cool.${extension}`,
},
content: 'ding',
},
});
await nextTick();
expectLanguageIdentifierClass(`${LANGUAGE_IDENTIFIER_PREFIX}${extension}`);
}
);
it('should apply "language-plaintext" when file type is unknown', async () => {
wrapper.setProps({
contextItem: {
...MOCK_CONTEXT_ITEM_FILE,
metadata: {
...MOCK_CONTEXT_ITEM_FILE.metadata,
relativePath: `this/file/has/no/extension/ohno`,
},
content: 'ding',
},
});
await nextTick();
expectLanguageIdentifierClass(LANGUAGE_IDENTIFIER_PLAINTEXT);
});
});
});
});
});
......@@ -62,6 +62,18 @@ describe('GlDuoChatContextItemMenu', () => {
expect(wrapper.emitted('remove').at(0)).toEqual([removed]);
});
it('emits "get-context-item-content" event when an item is selected', () => {
const selections = getMockContextItems().slice(0, 2);
createComponent({ open: false, selections });
const selected = selections.at(0);
findContextItemSelections().vm.$emit('get-content', selected);
expect(wrapper.emitted('get-context-item-content').at(0)).toEqual([
{ contextItem: selected },
]);
});
});
describe('and there are no selections', () => {
......
import { makeContainer } from '../../../../../../../utils/story_decorators/container';
import { setStoryTimeout } from '../../../../../../../utils/test_utils';
import { getMockContextItems, MOCK_CATEGORIES } from '../mock_context_data';
import {
getMockContextItems,
MOCK_CATEGORIES,
MOCK_CONTEXT_FILE_CONTENT,
MOCK_CONTEXT_FILE_DIFF_CONTENT,
} from '../mock_context_data';
import { CONTEXT_ITEM_CATEGORY_LOCAL_GIT } from '../constants';
import GlDuoChatContextItemMenu from './duo_chat_context_item_menu.vue';
const sampleCategories = MOCK_CATEGORIES;
......@@ -59,6 +65,20 @@ const Template = (args, { argTypes }) => ({
this.selectedItems.splice(index, 1);
}
},
handleGetContent(contextItem) {
this.selectedItems = this.selectedItems.map((item) => {
if (item.id === contextItem.id) {
return {
...contextItem,
content:
contextItem.category === CONTEXT_ITEM_CATEGORY_LOCAL_GIT
? MOCK_CONTEXT_FILE_DIFF_CONTENT
: MOCK_CONTEXT_FILE_CONTENT,
};
}
return item;
});
},
},
template: `
<div class="gl-h-full gl-flex gl-flex-col gl-justify-end">
......@@ -73,6 +93,7 @@ const Template = (args, { argTypes }) => ({
@search="handleContextItemsSearch"
@select="handleContextItemSelect"
@remove="handleContextItemRemove"
@get-context-item-content="handleGetContent"
@close="isOpen = false"
/>
</div>
......
......@@ -218,6 +218,14 @@ export default {
this.activeIndex = newIndex;
},
onGetContextItemContent(contextItem) {
/**
* Emit get-context-item-content event that tells clients to load the full file content for a selected context item.
* The fully hydrated context item should be updated in the context item selections.
* @param {*} event An event containing the context item to hydrate
*/
this.$emit('get-context-item-content', { contextItem });
},
},
i18n: {
selectedContextItemsTitle: translate(
......@@ -233,11 +241,13 @@ export default {
<gl-duo-chat-context-item-selections
v-if="selections.length"
:selections="selections"
:categories="categories"
:removable="true"
:title="$options.i18n.selectedContextItemsTitle"
:default-collapsed="false"
class="gl-mb-3"
@remove="removeItem"
@get-content="onGetContextItemContent"
/>
<gl-card
v-if="open"
......
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { mount, shallowMount } from '@vue/test-utils';
import GlIcon from '../../../../../../base/icon/icon.vue';
import GlToken from '../../../../../../base/token/token.vue';
import GlDuoChatContextItemDetails from '../duo_chat_content_item_details/duo_chat_content_item_details.vue';
import GlDuoChatContextItemPopover from '../duo_chat_context_item_popover/duo_chat_context_item_popover.vue';
import {
getMockContextItems,
MOCK_CONTEXT_ITEM_FILE,
......@@ -16,9 +19,9 @@ describe('GlDuoChatContextItemSelections', () => {
let wrapper;
let mockSelections;
const createComponent = (props = {}) => {
const createComponent = (props = {}, mountFn = shallowMount) => {
mockSelections = getMockContextItems().slice(0, 3);
wrapper = shallowMount(GlDuoChatContextItemSelections, {
wrapper = mountFn(GlDuoChatContextItemSelections, {
propsData: {
selections: mockSelections,
title: 'Test Title',
......@@ -26,6 +29,9 @@ describe('GlDuoChatContextItemSelections', () => {
showClose: true,
...props,
},
stubs: {
GlSkeletonLoader: { name: 'GlSkeletonLoaderStub', template: '<div></div>' },
},
});
};
......@@ -37,6 +43,7 @@ describe('GlDuoChatContextItemSelections', () => {
const findTokensIcons = () => findTokensWrapper().findAllComponents(GlIcon);
const findPopovers = () => wrapper.findAllComponents(GlDuoChatContextItemPopover);
const findCollapseIcon = () => findByTestId('chat-context-collapse-icon');
const findItemDetailsModal = () => wrapper.findComponent(GlDuoChatContextItemDetails);
describe('component rendering', () => {
it('renders the component when selections are provided', () => {
......@@ -185,5 +192,55 @@ describe('GlDuoChatContextItemSelections', () => {
expect(wrapper.emitted('remove')[0]).toEqual([MOCK_CONTEXT_ITEM_FILE]);
});
});
describe('when opening context items', () => {
describe.each([{ item: MOCK_CONTEXT_ITEM_FILE }, { item: MOCK_CONTEXT_ITEM_GIT_DIFF }])(
'and the item is a "$item.category"',
({ item }) => {
beforeEach(() => createComponent({ selections: [item] }, mount));
describe.each(['click', 'keydown.enter', 'keydown.space'])(
'when opening by "$eventType"',
(eventType) => {
beforeEach(() => findTokens().at(0).trigger(eventType));
it('should display the details view', () => {
expect(findItemDetailsModal().props('contextItem')).toEqual(item);
});
it('should emit a "get-content" event to hydrate the item', () => {
expect(wrapper.emitted('get-content')).toHaveLength(1);
expect(wrapper.emitted('get-content').at(0)).toEqual([item]);
});
it('should close the details view when modal emits "close" event', async () => {
findItemDetailsModal().vm.$emit('close');
await nextTick();
expect(findItemDetailsModal().exists()).toBe(false);
});
}
);
}
);
describe.each([{ item: MOCK_CONTEXT_ITEM_MERGE_REQUEST }, { item: MOCK_CONTEXT_ITEM_ISSUE }])(
'and the item is a "$item.category"',
({ item }) => {
beforeEach(() => {
createComponent({ selections: [item] });
return findTokens().at(0).vm.$emit('click');
});
it('should not display any details view', () => {
expect(findItemDetailsModal().exists()).toBe(false);
});
it('should not emit any "get-content" event', () => {
expect(wrapper.emitted('get-content')).toBe(undefined);
});
}
);
});
});
});
......@@ -4,16 +4,27 @@ import GlIcon from '../../../../../../base/icon/icon.vue';
import GlToken from '../../../../../../base/token/token.vue';
import GlTruncate from '../../../../../../utilities/truncate/truncate.vue';
import GlDuoChatContextItemPopover from '../duo_chat_context_item_popover/duo_chat_context_item_popover.vue';
import { CONTEXT_ITEM_CATEGORY_FILE, CONTEXT_ITEM_CATEGORY_LOCAL_GIT } from '../constants';
import GlDuoChatContextItemDetails from '../duo_chat_content_item_details/duo_chat_content_item_details.vue';
import { contextItemsValidator, getContextItemIcon } from '../utils';
export default {
name: 'GlDuoChatContextItemSelections',
components: {
GlTruncate,
GlDuoChatContextItemDetails,
GlIcon,
GlDuoChatContextItemPopover,
GlToken,
},
inject: {
renderGFM: {
from: 'renderGFM',
default: () => (element) => {
element.classList.add('gl-markdown', 'gl-compact-markdown');
},
},
},
props: {
/**
* Array of selected context items.
......@@ -55,6 +66,7 @@ export default {
return {
isCollapsed: this.defaultCollapsed,
selectionsId: uniqueId(),
previewContextItemId: null,
};
},
computed: {
......@@ -73,6 +85,13 @@ export default {
}
return '';
},
contextItemPreview() {
if (!this.previewContextItemId) {
return undefined;
}
return this.selections.find((item) => item.id === this.previewContextItemId);
},
},
methods: {
getContextItemIcon,
......@@ -86,6 +105,23 @@ export default {
*/
this.$emit('remove', contextItem);
},
onOpenItem(contextItem) {
if (!this.canOpen(contextItem)) {
return;
}
if (!contextItem.content) {
this.$emit('get-content', contextItem);
}
this.previewContextItemId = contextItem.id;
},
canOpen(contextItem) {
return [CONTEXT_ITEM_CATEGORY_LOCAL_GIT, CONTEXT_ITEM_CATEGORY_FILE].includes(
contextItem.category
);
},
onClosePreview() {
this.previewContextItemId = null;
},
},
};
</script>
......@@ -113,7 +149,12 @@ export default {
:view-only="!removable"
variant="default"
class="gl-mb-2 gl-mr-2 gl-max-w-full"
:class="tokenVariantClasses"
:class="[tokenVariantClasses, canOpen(item) ? 'gl-cursor-pointer' : '']"
:tabindex="canOpen(item) ? 0 : -1"
role="button"
@click="onOpenItem(item)"
@keydown.enter="onOpenItem(item)"
@keydown.space.prevent="onOpenItem(item)"
@close="onRemoveItem(item)"
>
<div
......@@ -132,8 +173,14 @@ export default {
:context-item="item"
:target="`context-item-${item.id}-${selectionsId}-token`"
placement="bottom"
@show-git-diff="onOpenItem(item)"
/>
</gl-token>
</div>
<gl-duo-chat-context-item-details
v-if="contextItemPreview"
:context-item="contextItemPreview"
@close="onClosePreview"
/>
</div>
</template>
......@@ -16,6 +16,21 @@ export function getMockCategory(categoryValue) {
return MOCK_CATEGORIES.find((cat) => cat.value === categoryValue);
}
export const MOCK_CONTEXT_FILE_CONTENT = `export function waterPlants() {
console.log('sprinkle');
}`;
export const MOCK_CONTEXT_FILE_DIFF_CONTENT = `diff --git a/src/plants/strawberry.ts b/src/plants/strawberry.ts
index 1234567..8901234 100644
--- a/src/plants/strawberry.ts
+++ b/src/plants/strawberry.ts
@@ -1,4 +1,4 @@
export const strawberry = {
name: 'Strawberry',
- waterNeeds: 'moderate',
+ waterNeeds: 'high',
};`;
export const MOCK_CONTEXT_ITEM_FILE = {
id: '123e4567-e89b-12d3-a456-426614174000',
category: CONTEXT_ITEM_CATEGORY_FILE,
......
import { shallowMount } from '@vue/test-utils';
import { MOCK_USER_PROMPT_MESSAGE, MOCK_RESPONSE_MESSAGE } from '../../mock_data';
import { MOCK_CONTEXT_ITEM_FILE } from '../duo_chat_context/mock_context_data';
import GlDuoChatMessage from '../duo_chat_message/duo_chat_message.vue';
import GlDuoChatConversation from './duo_chat_conversation.vue';
......@@ -78,5 +79,15 @@ describe('GlDuoChatConversation', () => {
const chatMessages = findChatMessages();
expect(chatMessages.at(0).props('isCancelled')).toBe(false);
});
it('emits "get-context-item-content" when a message requests hydrated context item', () => {
const contextItem = MOCK_CONTEXT_ITEM_FILE;
createComponent();
const message = findChatMessages().at(0);
message.vm.$emit('get-context-item-content', contextItem);
expect(wrapper.emitted('get-context-item-content')).toHaveLength(1);
expect(wrapper.emitted('get-context-item-content').at(0)).toEqual([contextItem]);
});
});
});
......@@ -57,6 +57,9 @@ export default {
onInsertCodeSnippet(e) {
this.$emit('insert-code-snippet', e);
},
onGetContextItemContent(e) {
this.$emit('get-context-item-content', e);
},
},
i18n,
};
......@@ -81,6 +84,7 @@ export default {
:is-cancelled="canceledRequestIds.includes(msg.requestId)"
@track-feedback="onTrackFeedback"
@insert-code-snippet="onInsertCodeSnippet"
@get-context-item-content="onGetContextItemContent"
/>
</div>
</template>
......@@ -14,6 +14,10 @@ const generateProps = ({ message = MOCK_RESPONSE_MESSAGE } = {}) => ({
// eslint-disable-next-line no-alert
alert(`Insert code snippet triggered:\n${event.detail.code}`);
},
onGetContextItemContent: (event) => {
// eslint-disable-next-line no-alert
alert(`Get context item content triggered:\n${JSON.stringify(event.contextItem, null, 4)}`);
},
});
const Template = (args, { argTypes }) => ({
......@@ -24,7 +28,7 @@ const Template = (args, { argTypes }) => ({
renderGFM,
},
template: `
<gl-duo-chat-message :message="message" :is-cancelled="false" @insert-code-snippet="onInsertCode" />
<gl-duo-chat-message :message="message" :is-cancelled="false" @insert-code-snippet="onInsertCode" @get-context-item-content="onGetContextItemContent" />
`,
});
......
......@@ -234,6 +234,12 @@ export default {
onInsertCodeSnippet(e) {
this.$emit('insert-code-snippet', e);
},
onGetContextItemContent(contextItem) {
this.$emit('get-context-item-content', {
messageId: this.message.id,
contextItem,
});
},
},
};
</script>
......@@ -264,6 +270,7 @@ export default {
:title="selectedContextItemsTitle"
:default-collapsed="selectedContextItemsDefaultCollapsed"
variant="assistant"
@get-content="onGetContextItemContent"
/>
<div
v-if="error"
......@@ -309,6 +316,7 @@ export default {
:title="selectedContextItemsTitle"
:default-collapsed="selectedContextItemsDefaultCollapsed"
variant="user"
@get-content="onGetContextItemContent"
/>
</div>
</div>
......
......@@ -6,6 +6,8 @@ import GlDuoChatContextItemMenu from './components/duo_chat_context/duo_chat_con
import {
getMockContextItems,
MOCK_CATEGORIES,
MOCK_CONTEXT_FILE_CONTENT,
MOCK_CONTEXT_FILE_DIFF_CONTENT,
} from './components/duo_chat_context/mock_context_data';
import GlDuoChat from './duo_chat.vue';
import readme from './duo_chat.md';
......@@ -18,6 +20,7 @@ import {
generateMockResponseChunks,
renderGFM,
} from './mock_data';
import { CONTEXT_ITEM_CATEGORY_LOCAL_GIT } from './components/duo_chat_context/constants';
const sampleContextItems = getMockContextItems();
......@@ -204,6 +207,36 @@ export const Interactive = (args, { argTypes }) => ({
this.contextItems.splice(index, 1);
}
},
handleGetContextItemContent({ messageId, contextItem }) {
const hydratedItem = {
...contextItem,
content:
contextItem.category === CONTEXT_ITEM_CATEGORY_LOCAL_GIT
? MOCK_CONTEXT_FILE_DIFF_CONTENT
: MOCK_CONTEXT_FILE_CONTENT,
};
if (messageId === undefined) {
const index = this.contextItems.findIndex((item) => item.id === hydratedItem.id);
if (index !== -1) {
this.$set(this.contextItems, index, hydratedItem);
}
return;
}
const messageIndex = this.msgs.findIndex((msg) => msg.id === messageId);
if (messageIndex !== -1) {
const message = this.msgs[messageIndex];
if (message.extras && Array.isArray(message.extras.contextItems)) {
const contextItemIndex = message.extras.contextItems.findIndex(
(item) => item.id === contextItem.id
);
if (contextItemIndex !== -1) {
this.$set(message.extras.contextItems, contextItemIndex, hydratedItem);
}
}
}
},
},
template: `
<div style="height: 800px">
......@@ -237,6 +270,7 @@ export const Interactive = (args, { argTypes }) => ({
@chat-hidden="onChatHidden"
@chat-cancel="onChatCancel"
@insert-code-snippet="onInsertCodeSnippet"
@get-context-item-content="handleGetContextItemContent"
>
<template #context-items-menu="{ isOpen, onClose, setRef, focusPrompt }">
<gl-duo-chat-context-item-menu
......@@ -252,6 +286,7 @@ export const Interactive = (args, { argTypes }) => ({
@remove="handleContextItemRemove"
@close="onClose"
@focus-prompt="focusPrompt"
@get-context-item-content="handleGetContextItemContent"
/>
</template>
</gl-duo-chat>
......
......@@ -518,6 +518,14 @@ export default {
*/
this.$emit('insert-code-snippet', e);
},
onGetContextItemContent(event) {
/**
* Emit get-context-item-content event that tells clients to load the full file content for a selected context item.
* The fully hydrated context item should be updated in the chat message context item.
* @param {*} event An event containing the message ID and context item to hydrate
*/
this.$emit('get-context-item-content', event);
},
closeContextItemsMenuOpen() {
this.contextItemsMenuIsOpen = false;
this.setPromptAndFocus();
......@@ -603,6 +611,7 @@ export default {
:show-delimiter="index > 0"
@track-feedback="onTrackFeedback"
@insert-code-snippet="onInsertCodeSnippet"
@get-context-item-content="onGetContextItemContent"
/>
<template v-if="!hasMessages && !isLoading">
<gl-empty-state
......
......@@ -27,6 +27,8 @@ export default {
'GlDuoChat.chatPromptPlaceholderDefault': 'GitLab Duo Chat',
'GlDuoChat.chatPromptPlaceholderWithCommands': 'Type "/" for slash commands',
'GlDuoChat.chatSubmitLabel': 'Send chat message.',
'GlDuoChatContextItemDetails.close': 'Close',
'GlDuoChatContextItemDetails.title': 'Preview',
'GlDuoChatContextItemMenu.emptyStateMessage': 'No results found',
'GlDuoChatContextItemMenu.loadingMessage': 'Loading...',
'GlDuoChatContextItemMenu.searchInputPlaceholder': 'Search %{categoryLabel}...',
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment