Move web-only Duo Chat view components to GitLab monolith
What does this MR do and why?
Migrates web-only Duo Chat view components from the @gitlab/duo-ui package into the GitLab EE application as part of #593464. This consolidates all Web Duo Chat source code in a single repository, eliminating the need to coordinate changes across multiple repositories and simplifying the development workflow.
This MR is the first of two:
- This MR — adds the view components and their specs
- Follow-up MR !228844 — renames the existing state manager components to clarify their role and updates all references
Duo UI → GitLab Monolith Migration: Diff Summary
web_duo_chat.vue → ee/app/assets/javascripts/ai/tanuki_bot/components/duo_chat_view.vue
File diff
--- node_modules/@gitlab/duo-ui/src/components/chat/web_duo_chat.vue 2026-03-23 13:41:55
+++ ee/app/assets/javascripts/ai/tanuki_bot/components/duo_chat_view.vue 2026-03-26 16:02:49
@@ -1,85 +1,53 @@
<script>
import { throttle } from 'lodash-es';
-import {
- GlButton,
- GlDropdownItem,
- GlCard,
- GlFormTextarea,
- GlForm,
- GlSafeHtmlDirective as SafeHtml,
-} from '@gitlab/ui';
+import { GlButton, GlDropdownItem, GlCard, GlFormTextarea, GlForm } from '@gitlab/ui';
-import { sprintf, translate, translatePlural } from '@gitlab/ui/dist/utils/i18n';
-
import {
+ MESSAGE_MODEL_ROLES,
+ DuoChatLoader,
+ DuoChatPredefinedPrompts,
+ DuoChatContextConversation as DuoChatConversation,
+ DuoChatThreads,
+} from '@gitlab/duo-ui';
+import { s__, n__, sprintf } from '~/locale';
+import { DUO_CHAT_VIEWS } from 'ee/ai/constants';
+import {
badgeTypes,
badgeTypeValidator,
CHAT_RESET_MESSAGE,
CHAT_CLEAR_MESSAGE,
CHAT_NEW_MESSAGE,
CHAT_INCLUDE_MESSAGE,
- MESSAGE_MODEL_ROLES,
MAX_PROMPT_LENGTH,
PROMPT_LENGTH_WARNING,
-} from './constants';
-import { VIEW_TYPES } from './components/duo_chat_header/constants';
-import DuoChatLoader from './components/duo_chat_loader/duo_chat_loader.vue';
-import DuoChatPredefinedPrompts from './components/duo_chat_predefined_prompts/duo_chat_predefined_prompts.vue';
-import DuoChatConversation from './components/duo_chat_conversation/duo_chat_conversation.vue';
-import WebDuoChatHeader from './components/duo_chat_header/web_duo_chat_header.vue';
-import DuoChatThreads from './components/duo_chat_threads/duo_chat_threads.vue';
+} from '../constants';
+import DuoChatHeader from '../../duo_agentic_chat/components/duo_chat_header.vue';
export const i18n = {
- CHAT_DEFAULT_TITLE: translate('WebDuoChat.chatDefaultTitle', 'GitLab Duo Chat'),
- CHAT_HISTORY_TITLE: translate('WebDuoChat.chatHistoryTitle', 'Chat history'),
- CHAT_DISCLAIMER: translate(
- 'WebDuoChat.chatDisclaimer',
- 'Responses may be inaccurate. Verify before use.'
- ),
+ CHAT_DEFAULT_TITLE: s__('DuoChat|GitLab Duo Chat'),
+ CHAT_HISTORY_TITLE: s__('DuoChat|Chat history'),
+ CHAT_DISCLAIMER: s__('DuoChat|Responses may be inaccurate. Verify before use.'),
CHAT_EMPTY_STATE_EMOJI: '👋',
- CHAT_EMPTY_STATE_TITLE: translate(
- 'WebDuoChat.chatEmptyStateTitle',
- 'I am GitLab Duo Chat, your personal AI-powered assistant.'
- ),
- CHAT_EMPTY_STATE_DESCRIPTION: translate(
- 'WebDuoChat.chatEmptyStateDescription',
- 'How can I help you today?'
- ),
- CHAT_PROMPT_PLACEHOLDER_DEFAULT: translate(
- 'WebDuoChat.chatPromptPlaceholderDefault',
- "Let's work through this together..."
- ),
- CHAT_PROMPT_PLACEHOLDER_WITH_COMMANDS: translate(
- 'WebDuoChat.chatPromptPlaceholderWithCommands',
- 'Type /help to learn more'
- ),
- CHAT_SUBMIT_LABEL: translate('WebDuoChat.chatSubmitLabel', 'Send chat message.'),
- CHAT_CANCEL_LABEL: translate('WebDuoChat.chatCancelLabel', 'Cancel'),
- CHAT_MODEL_PLACEHOLDER: translate('WebDuoChat.chatModelPlaceholder', 'GitLab Duo Chat'),
+ CHAT_EMPTY_STATE_TITLE: s__('DuoChat|I am GitLab Duo Chat, your personal AI-powered assistant.'),
+ CHAT_EMPTY_STATE_DESCRIPTION: s__('DuoChat|How can I help you today?'),
+ CHAT_PROMPT_PLACEHOLDER_DEFAULT: s__("DuoChat|Let's work through this together..."),
+ CHAT_PROMPT_PLACEHOLDER_WITH_COMMANDS: s__('DuoChat|Type /help to learn more'),
+ CHAT_SUBMIT_LABEL: s__('DuoChat|Send chat message.'),
+ CHAT_CANCEL_LABEL: s__('DuoChat|Cancel'),
+ CHAT_MODEL_PLACEHOLDER: s__('DuoChat|GitLab Duo Chat'),
CHAT_DEFAULT_PREDEFINED_PROMPTS: [
- translate(
- 'WebDuoChat.chatDefaultPredefinedPromptsChangePassword',
- 'How do I change my password in GitLab?'
- ),
- translate('WebDuoChat.chatDefaultPredefinedPromptsForkProject', 'How do I fork a project?'),
- translate(
- 'WebDuoChat.chatDefaultPredefinedPromptsCloneRepository',
- 'How do I clone a repository?'
- ),
- translate(
- 'WebDuoChat.chatDefaultPredefinedPromptsCreateTemplate',
- 'How do I create a template?'
- ),
+ s__('DuoChat|How do I change my password in GitLab?'),
+ s__('DuoChat|How do I fork a project?'),
+ s__('DuoChat|How do I clone a repository?'),
+ s__('DuoChat|How do I create a template?'),
],
};
const isMessage = (item) => Boolean(item) && item?.role;
const isSlashCommand = (command) => Boolean(command) && command?.name && command.description;
-// eslint-disable-next-line unicorn/no-array-callback-reference
const itemsValidator = (items) => items.every(isMessage);
-// eslint-disable-next-line unicorn/no-array-callback-reference
const slashCommandsValidator = (commands) => commands.every(isSlashCommand);
const isThread = (thread) =>
@@ -90,7 +58,6 @@
typeof thread.conversationType === 'string' &&
(thread.title === null || typeof thread.title === 'string');
-// eslint-disable-next-line unicorn/no-array-callback-reference
const threadListValidator = (threads) => threads.every(isThread);
const localeValidator = (value) => {
@@ -103,7 +70,7 @@
};
export default {
- name: 'DuoChat',
+ name: 'DuoChatView',
components: {
GlButton,
GlFormTextarea,
@@ -111,14 +78,11 @@
DuoChatLoader,
DuoChatPredefinedPrompts,
DuoChatConversation,
- WebDuoChatHeader,
+ DuoChatHeader,
DuoChatThreads,
GlCard,
GlDropdownItem,
},
- directives: {
- SafeHtml,
- },
props: {
/**
* The title of the chat/feature.
@@ -151,8 +115,8 @@
multiThreadedView: {
type: String,
required: false,
- default: VIEW_TYPES.LIST,
- validator: (value) => [VIEW_TYPES.LIST, VIEW_TYPES.CHAT].includes(value),
+ default: DUO_CHAT_VIEWS.LIST,
+ validator: (value) => [DUO_CHAT_VIEWS.LIST, DUO_CHAT_VIEWS.CHAT].includes(value),
},
/**
* Array of RequestIds that have been canceled.
@@ -163,14 +127,6 @@
default: () => [],
},
/**
- * A non-recoverable error message to display in the chat.
- */
- error: {
- type: String,
- required: false,
- default: '',
- },
- /**
* Array of messages to display in the chat.
*/
threadList: {
@@ -310,6 +266,23 @@
default: true,
},
},
+ emits: [
+ 'back-to-chat',
+ 'back-to-list',
+ 'chat-cancel',
+ 'chat-hidden',
+ 'chat-slash',
+ 'copy-code-snippet',
+ 'copy-message',
+ 'delete-thread',
+ 'get-context-item-content',
+ 'insert-code-snippet',
+ 'new-chat',
+ 'open-file-path',
+ 'send-chat-prompt',
+ 'thread-selected',
+ 'track-feedback',
+ ],
data() {
return {
prompt: '',
@@ -321,13 +294,12 @@
contextItemMenuRef: null,
currentView: this.multiThreadedView,
maxPromptLength: MAX_PROMPT_LENGTH,
- maxPromptLengthWarning: PROMPT_LENGTH_WARNING,
promptLengthWarningCount: MAX_PROMPT_LENGTH - PROMPT_LENGTH_WARNING,
};
},
computed: {
shouldShowThreadList() {
- return this.isMultithreaded && this.currentView === VIEW_TYPES.LIST;
+ return this.isMultithreaded && this.currentView === DUO_CHAT_VIEWS.LIST;
},
withSlashCommands() {
return this.slashCommands.length > 0;
@@ -350,7 +322,7 @@
}
return acc;
},
- [[]]
+ [[]],
);
},
lastMessage() {
@@ -368,7 +340,7 @@
}
return Boolean(
(this.lastMessage?.chunks?.length > 0 && !this.lastMessage?.content) ||
- typeof this.lastMessage?.chunkId === 'number'
+ typeof this.lastMessage?.chunkId === 'number',
);
},
filteredSlashCommands() {
@@ -385,7 +357,7 @@
if (!this.withSlashCommands || this.contextItemsMenuIsOpen) return false;
const startsWithSlash = this.caseInsensitivePrompt.startsWith('/');
const startsWithSlashCommand = this.slashCommands.some((c) =>
- this.caseInsensitivePrompt.startsWith(c.name)
+ this.caseInsensitivePrompt.startsWith(c.name),
);
return startsWithSlash && this.filteredSlashCommands.length && !startsWithSlashCommand;
},
@@ -423,7 +395,7 @@
return this.activeThread?.title;
},
activeThreadTitleForView() {
- return (this.currentView === VIEW_TYPES.CHAT && this.activeThreadTitle) || '';
+ return (this.currentView === DUO_CHAT_VIEWS.CHAT && this.activeThreadTitle) || '';
},
hasFooterControls() {
return (
@@ -523,7 +495,7 @@
if (
![CHAT_RESET_MESSAGE, CHAT_CLEAR_MESSAGE, CHAT_NEW_MESSAGE].includes(
- this.caseInsensitivePrompt
+ this.caseInsensitivePrompt,
)
) {
this.displaySubmitButton = false;
@@ -712,26 +684,22 @@
},
remainingCharacterCountMessage(count) {
return sprintf(
- translatePlural(
- 'WebDuoChat.remainingCharacterCountMessage',
- '%{count} character remaining.',
- '%{count} characters remaining.'
- )(count),
- {
+ n__(
+ 'DuoChat|%{count} character remaining.',
+ 'DuoChat|%{count} characters remaining.',
count,
- }
+ ),
+ { count },
);
},
overLimitCharacterCountMessage(count) {
return sprintf(
- translatePlural(
- 'WebDuoChat.overLimitCharacterCountMessage',
- '%{count} character over limit.',
- '%{count} characters over limit.'
- )(count),
- {
+ n__(
+ 'DuoChat|%{count} character over limit.',
+ 'DuoChat|%{count} characters over limit.',
count,
- }
+ ),
+ { count },
);
},
},
@@ -745,7 +713,7 @@
role="complementary"
data-testid="chat-component"
>
- <web-duo-chat-header
+ <duo-chat-header
v-if="showHeader"
ref="header"
:active-thread-id="activeThreadId"
@@ -764,7 +732,7 @@
<template #subheader>
<slot name="subheader"></slot>
</template>
- </web-duo-chat-header>
+ </duo-chat-header>
<div
:class="{ 'gl-border-t': !showStudioHeader }"
@@ -904,9 +872,6 @@
'!gl-bg-transparent',
'!gl-py-4',
'!gl-shadow-none',
- 'form-control',
- 'gl-form-input',
- 'gl-form-textarea',
'!gl-rounded-t-none',
'forced-colors:!gl-border-l-0',
'forced-colors:!gl-border-r-0',
Imports & Dependencies
web_duo_chat.vue (duo-ui) |
duo_chat_view.vue (monolith) |
|
|---|---|---|
| Sub-components | Imported from local relative paths | Imported from @gitlab/duo-ui package |
| Header component |
WebDuoChatHeader (local) |
DuoChatHeader (sibling in duo_agentic_chat/components/) |
| View constants |
VIEW_TYPES from local ./components/duo_chat_header/constants
|
DUO_CHAT_VIEWS from ee/ai/constants
|
| i18n |
translate + translatePlural from @gitlab/ui/dist/utils/i18n
|
s__ + n__ + sprintf from ~/locale
|
SafeHtml directive |
Registered at component level via @gitlab/ui
|
Not registered (provided globally by GitLab) |
Component Name
- duo-ui:
DuoChat - monolith:
DuoChatView
Props
The duo-ui version has 1 extra prop not present in the monolith:
-
error(String) — a non-recoverable error message to display in the chat.
data()
The duo-ui version has one extra reactive field:
maxPromptLengthWarning: PROMPT_LENGTH_WARNING
Emits
The monolith version has an explicit emits declaration; the duo-ui version does not.
Methods
-
remainingCharacterCountMessage/overLimitCharacterCountMessage: duo-ui usestranslatePlural(...)(count); monolith usesn__(...).
Template
| duo-ui | monolith | |
|---|---|---|
| Header tag | <web-duo-chat-header> |
<duo-chat-header> |
textarea-classes |
Includes extra form-control, gl-form-input, gl-form-textarea
|
Does not include these |
web_agentic_duo_chat.vue → ee/app/assets/javascripts/ai/duo_agentic_chat/components/duo_agentic_chat_view.vue
File diff
--- node_modules/@gitlab/duo-ui/src/components/agentic_chat/web_agentic_duo_chat.vue 2026-03-23 13:41:55
+++ ee/app/assets/javascripts/ai/duo_agentic_chat/components/duo_agentic_chat_view.vue 2026-03-26 16:33:01
@@ -1,91 +1,54 @@
<script>
import { throttle } from 'lodash-es';
-import {
- GlButton,
- GlDropdownItem,
- GlCard,
- GlFormTextarea,
- GlForm,
- GlSafeHtmlDirective as SafeHtml,
-} from '@gitlab/ui';
+import { GlButton, GlDropdownItem, GlCard, GlFormTextarea, GlForm } from '@gitlab/ui';
-import { sprintf, translate, translatePlural } from '@gitlab/ui/dist/utils/i18n';
-
import {
+ MESSAGE_MODEL_ROLES,
+ DuoChatLoader,
+ DuoChatPredefinedPrompts,
+ DuoChatContextConversation as DuoChatConversation,
+ DuoChatThreads,
+} from '@gitlab/duo-ui';
+import { DUO_CHAT_VIEWS } from 'ee/ai/constants';
+import {
badgeTypes,
badgeTypeValidator,
CHAT_RESET_MESSAGE,
CHAT_INCLUDE_MESSAGE,
CHAT_BASE_COMMANDS,
- MESSAGE_MODEL_ROLES,
MAX_PROMPT_LENGTH,
PROMPT_LENGTH_WARNING,
-} from '../chat/constants';
-import { VIEW_TYPES } from '../chat/components/duo_chat_header/constants';
-import DuoChatLoader from '../chat/components/duo_chat_loader/duo_chat_loader.vue';
-import DuoChatPredefinedPrompts from '../chat/components/duo_chat_predefined_prompts/duo_chat_predefined_prompts.vue';
-import DuoChatConversation from '../chat/components/duo_chat_conversation/duo_chat_conversation.vue';
-import { messageRenderersValidator } from '../chat/components/utils';
-import WebDuoChatHeader from '../chat/components/duo_chat_header/web_duo_chat_header.vue';
-import DuoChatThreads from '../chat/components/duo_chat_threads/duo_chat_threads.vue';
+} from 'ee/ai/tanuki_bot/constants';
+import { s__, n__, sprintf } from '~/locale';
+import DuoChatHeader from './duo_chat_header.vue';
export const i18n = {
- CHAT_DEFAULT_TITLE: translate('WebAgenticDuoChat.chatDefaultTitle', 'GitLab Duo Agentic Chat'),
- CHAT_HISTORY_TITLE: translate('WebAgenticDuoChat.chatHistoryTitle', 'Chat history'),
- CHAT_DISCLAIMER: translate(
- 'WebAgenticDuoChat.chatDisclaimer',
- 'Responses may be inaccurate. Verify before use.'
- ),
+ CHAT_DEFAULT_TITLE: s__('DuoAgenticChat|GitLab Duo Agentic Chat'),
+ CHAT_HISTORY_TITLE: s__('DuoAgenticChat|Chat history'),
+ CHAT_DISCLAIMER: s__('DuoAgenticChat|Responses may be inaccurate. Verify before use.'),
CHAT_EMPTY_STATE_EMOJI: '👋',
- CHAT_EMPTY_STATE_TITLE: translate(
- 'WebAgenticDuoChat.chatEmptyStateTitle',
- 'I am GitLab Duo Agentic Chat, your personal AI-powered assistant.'
+ CHAT_EMPTY_STATE_TITLE: s__(
+ 'DuoAgenticChat|I am GitLab Duo Agentic Chat, your personal AI-powered assistant.',
),
- CHAT_EMPTY_STATE_DESCRIPTION: translate(
- 'WebAgenticDuoChat.chatEmptyStateDescription',
- 'How can I help you today?'
- ),
- CHAT_PROMPT_PLACEHOLDER_DEFAULT: translate(
- 'WebAgenticDuoChat.chatPromptPlaceholderDefault',
- "Let's work through this together..."
- ),
- CHAT_MODEL_PLACEHOLDER: translate(
- 'WebAgenticDuoChat.chatModelPlaceholder',
- 'GitLab Duo Agentic Chat'
- ),
- CHAT_PROMPT_PLACEHOLDER_WITH_COMMANDS: translate(
- 'WebAgenticDuoChat.chatPromptPlaceholderWithCommands',
- 'Type /help to learn more'
- ),
- CHAT_SUBMIT_LABEL: translate('WebAgenticDuoChat.chatSubmitLabel', 'Send chat message.'),
- CHAT_CANCEL_LABEL: translate('WebAgenticDuoChat.chatCancelLabel', 'Cancel'),
+ CHAT_EMPTY_STATE_DESCRIPTION: s__('DuoAgenticChat|How can I help you today?'),
+ CHAT_PROMPT_PLACEHOLDER_DEFAULT: s__("DuoAgenticChat|Let's work through this together..."),
+ CHAT_MODEL_PLACEHOLDER: s__('DuoAgenticChat|GitLab Duo Agentic Chat'),
+ CHAT_PROMPT_PLACEHOLDER_WITH_COMMANDS: s__('DuoAgenticChat|Type /help to learn more'),
+ CHAT_SUBMIT_LABEL: s__('DuoAgenticChat|Send chat message.'),
+ CHAT_CANCEL_LABEL: s__('DuoAgenticChat|Cancel'),
CHAT_DEFAULT_PREDEFINED_PROMPTS: [
- translate(
- 'WebAgenticDuoChat.chatDefaultPredefinedPromptsChangePassword',
- 'How do I change my password in GitLab?'
- ),
- translate(
- 'WebAgenticDuoChat.chatDefaultPredefinedPromptsForkProject',
- 'How do I fork a project?'
- ),
- translate(
- 'WebAgenticDuoChat.chatDefaultPredefinedPromptsCloneRepository',
- 'How do I clone a repository?'
- ),
- translate(
- 'WebAgenticDuoChat.chatDefaultPredefinedPromptsCreateTemplate',
- 'How do I create a template?'
- ),
+ s__('DuoAgenticChat|How do I change my password in GitLab?'),
+ s__('DuoAgenticChat|How do I fork a project?'),
+ s__('DuoAgenticChat|How do I clone a repository?'),
+ s__('DuoAgenticChat|How do I create a template?'),
],
};
const isMessage = (item) => Boolean(item) && item?.role;
const isSlashCommand = (command) => Boolean(command) && command?.name && command.description;
-// eslint-disable-next-line unicorn/no-array-callback-reference
const itemsValidator = (items) => items.every(isMessage);
-// eslint-disable-next-line unicorn/no-array-callback-reference
const slashCommandsValidator = (commands) => commands.every(isSlashCommand);
const isThread = (thread) =>
@@ -95,7 +58,6 @@
(typeof thread.updatedAt === 'string' &&
(thread.title === null || typeof thread.title === 'string' || typeof thread.goal === 'string'));
-// eslint-disable-next-line unicorn/no-array-callback-reference
const threadListValidator = (threads) => threads.every(isThread);
const localeValidator = (value) => {
@@ -108,7 +70,7 @@
};
export default {
- name: 'DuoChat',
+ name: 'DuoAgenticChatView',
components: {
GlButton,
GlFormTextarea,
@@ -116,14 +78,11 @@
DuoChatLoader,
DuoChatPredefinedPrompts,
DuoChatConversation,
- WebDuoChatHeader,
+ DuoChatHeader,
DuoChatThreads,
GlCard,
GlDropdownItem,
},
- directives: {
- SafeHtml,
- },
props: {
/**
* Determines if the component should be resizable. When true, it renders inside
@@ -133,33 +92,7 @@
type: Boolean,
required: false,
default: false,
- },
- /**
- * Defines the dimensions of the chat container when resizable.
- * By default, the height is set to match the height of the browser window,
- * and the width is fixed at 400px. The `top` position is left undefined,
- * allowing it to be dynamically adjusted if needed.
- */
- dimensions: {
- type: Object,
- required: false,
- default: () => ({
- width: undefined,
- height: undefined,
- top: undefined,
- left: undefined,
- maxWidth: undefined,
- minWidth: 400,
- maxHeight: undefined,
- minHeight: 400,
- }),
},
- agents: {
- type: Array,
-
- required: false,
- default: () => [],
- },
/**
* The name of the agent to display in the empty state.
*/
@@ -199,8 +132,8 @@
multiThreadedView: {
type: String,
required: false,
- default: VIEW_TYPES.LIST,
- validator: (value) => [VIEW_TYPES.LIST, VIEW_TYPES.CHAT].includes(value),
+ default: DUO_CHAT_VIEWS.LIST,
+ validator: (value) => [DUO_CHAT_VIEWS.LIST, DUO_CHAT_VIEWS.CHAT].includes(value),
},
/**
@@ -228,7 +161,7 @@
},
},
/**
- * Array of messages to display in the chat.
+ * Array of threads to display in the thread list.
*/
threadList: {
type: Array,
@@ -414,12 +347,37 @@
messageRenderers: {
type: Array,
required: false,
- validator: messageRenderersValidator,
default() {
return [];
},
+ validator: (renderers) =>
+ renderers.every(
+ (r) =>
+ r !== null &&
+ typeof r === 'object' &&
+ typeof r.component === 'object' &&
+ typeof r.matchMessage === 'function',
+ ),
},
},
+ emits: [
+ 'approve-tool',
+ 'back-to-list',
+ 'chat-cancel',
+ 'chat-hidden',
+ 'chat-slash',
+ 'copy-code-snippet',
+ 'copy-message',
+ 'delete-thread',
+ 'deny-tool',
+ 'get-context-item-content',
+ 'insert-code-snippet',
+ 'new-chat',
+ 'open-file-path',
+ 'send-chat-prompt',
+ 'thread-selected',
+ 'track-feedback',
+ ],
data() {
return {
prompt: '',
@@ -432,13 +390,12 @@
contextItemMenuRef: null,
currentView: this.multiThreadedView,
maxPromptLength: MAX_PROMPT_LENGTH,
- maxPromptLengthWarning: PROMPT_LENGTH_WARNING,
promptLengthWarningCount: MAX_PROMPT_LENGTH - PROMPT_LENGTH_WARNING,
};
},
computed: {
shouldShowThreadList() {
- return this.isMultithreaded && this.currentView === VIEW_TYPES.LIST;
+ return this.isMultithreaded && this.currentView === DUO_CHAT_VIEWS.LIST;
},
withSlashCommands() {
return this.slashCommands.length > 0;
@@ -461,7 +418,7 @@
}
return acc;
},
- [[]]
+ [[]],
);
},
lastMessage() {
@@ -476,7 +433,7 @@
isStreaming() {
return Boolean(
(this.lastMessage?.chunks?.length > 0 && !this.lastMessage?.content) ||
- typeof this.lastMessage?.chunkId === 'number'
+ typeof this.lastMessage?.chunkId === 'number',
);
},
filteredSlashCommands() {
@@ -493,7 +450,7 @@
if (!this.withSlashCommands || this.contextItemsMenuIsOpen) return false;
const startsWithSlash = this.caseInsensitivePrompt.startsWith('/');
const startsWithSlashCommand = this.slashCommands.some((c) =>
- this.caseInsensitivePrompt.startsWith(c.name)
+ this.caseInsensitivePrompt.startsWith(c.name),
);
return startsWithSlash && this.filteredSlashCommands.length && !startsWithSlashCommand;
},
@@ -531,7 +488,7 @@
return this.activeThread?.title;
},
activeThreadTitleForView() {
- return (this.currentView === VIEW_TYPES.CHAT && this.activeThreadTitle) || '';
+ return (this.currentView === DUO_CHAT_VIEWS.CHAT && this.activeThreadTitle) || '';
},
hasFooterControls() {
return (
@@ -541,12 +498,9 @@
);
},
emptyStateGreeting() {
- return sprintf(
- translate('WebAgenticDuoChat.agenticChatEmptyStateGreeting', 'Hello, I’m %{agentName}!'),
- {
- agentName: this.agentName,
- }
- );
+ return sprintf(s__('DuoAgenticChat|Hello, I am %{agentName}!'), {
+ agentName: this.agentName,
+ });
},
emptyStateMainText() {
if (this.emptyStateTitle) {
@@ -838,26 +792,22 @@
},
remainingCharacterCountMessage(count) {
return sprintf(
- translatePlural(
- 'WebAgenticDuoChat.remainingCharacterCountMessage',
- '%{count} character remaining.',
- '%{count} characters remaining.'
- )(count),
- {
+ n__(
+ 'DuoAgenticChat|%{count} character remaining.',
+ 'DuoAgenticChat|%{count} characters remaining.',
count,
- }
+ ),
+ { count },
);
},
overLimitCharacterCountMessage(count) {
return sprintf(
- translatePlural(
- 'WebAgenticDuoChat.overLimitCharacterCountMessage',
- '%{count} character over limit.',
- '%{count} characters over limit.'
- )(count),
- {
+ n__(
+ 'DuoAgenticChat|%{count} character over limit.',
+ 'DuoAgenticChat|%{count} characters over limit.',
count,
- }
+ ),
+ { count },
);
},
},
@@ -871,7 +821,7 @@
role="complementary"
data-testid="chat-component"
>
- <web-duo-chat-header
+ <duo-chat-header
v-if="showHeader"
ref="header"
:active-thread-id="activeThreadId"
@@ -884,7 +834,6 @@
:should-render-resizable="shouldRenderResizable"
:badge-type="isMultithreaded ? null : badgeType"
:session-id="sessionId"
- :agents="agents"
:show-studio-header="showStudioHeader"
@go-back="onGoBack"
@new-chat="onNewChat"
@@ -893,7 +842,7 @@
<template #subheader>
<slot name="subheader"></slot>
</template>
- </web-duo-chat-header>
+ </duo-chat-header>
<div
:class="{ 'gl-border-t': !showStudioHeader }"
@@ -1068,9 +1017,6 @@
'!gl-bg-transparent',
'!gl-py-4',
'!gl-shadow-none',
- 'form-control',
- 'gl-form-input',
- 'gl-form-textarea',
'!gl-rounded-t-none',
'forced-colors:!gl-border-l-0',
'forced-colors:!gl-border-r-0',
@@ -1078,7 +1024,7 @@
{ 'gl-truncate': !prompt },
]"
:autofocus="shouldAutoFocusInput"
- aria-label="Chat prompt input"
+ :aria-label="s__('DuoAgenticChat|Chat prompt input')"
@keydown.enter.exact.native.prevent
@keydown.ctrl.z.exact="handleUndo"
@keydown.meta.z.exact="handleUndo"
Imports & Dependencies
web_agentic_duo_chat.vue (duo-ui) |
duo_agentic_chat_view.vue (monolith) |
|
|---|---|---|
| Sub-components | Imported from local relative paths | Imported from @gitlab/duo-ui package |
| Header component |
WebDuoChatHeader (local) |
DuoChatHeader (local sibling) |
| View constants |
VIEW_TYPES from local ./constants
|
DUO_CHAT_VIEWS from ee/ai/constants
|
| i18n |
translate + translatePlural from @gitlab/ui/dist/utils/i18n
|
s__ + n__ + sprintf from ~/locale
|
SafeHtml directive |
Registered at component level via @gitlab/ui
|
Not registered (provided globally by GitLab) |
| Message validators |
messageRenderersValidator imported from local utils |
Inlined in the prop definition |
Component Name
- duo-ui:
DuoChat - monolith:
DuoAgenticChatView
Props
The duo-ui version has 2 extra props dropped in the monolith:
-
dimensions(Object) — controls resizable width/height/top/left/min/max constraints -
agents(Array) — list of agents passed down to the header
data()
The duo-ui version has one extra reactive field:
-
maxPromptLengthWarning: PROMPT_LENGTH_WARNING(used for internal tracking of warning threshold)
Methods
-
remainingCharacterCountMessage/overLimitCharacterCountMessage: duo-ui usestranslatePlural(...)(count); monolith usesn__(...)for pluralization. -
emptyStateGreeting: slight wording difference — duo-ui uses"Hello, I'm %{agentName}!", monolith uses"Hello, I am %{agentName}!".
Template
| duo-ui | monolith | |
|---|---|---|
| Header tag | <web-duo-chat-header> |
<duo-chat-header> |
:agents prop on header |
Passed (:agents="agents") |
Not passed |
DuoChatConversation |
Passes :message-renderers="messageRenderers"
|
Passes :message-renderers="messageRenderers" ✓ |
textarea-classes |
Includes extra form-control, gl-form-input, gl-form-textarea classes |
Does not include these |
aria-label on textarea |
Hardcoded string "Chat prompt input"
|
Localized via `s__('DuoAgenticChat |
web_duo_chat_header.vue → ee/app/assets/javascripts/ai/duo_agentic_chat/components/duo_chat_header.vue
File diff
--- node_modules/@gitlab/duo-ui/src/components/chat/components/duo_chat_header/web_duo_chat_header.vue 2026-03-23 13:41:55
+++ ee/app/assets/javascripts/ai/duo_agentic_chat/components/duo_chat_header.vue 2026-03-26 16:02:49
@@ -1,36 +1,20 @@
<script>
-import Vue from 'vue';
-import {
- GlAlert,
- GlAvatar,
- GlButton,
- GlSafeHtmlDirective as SafeHtml,
- GlTooltipDirective,
- GlToast,
- GlDisclosureDropdown,
-} from '@gitlab/ui';
-import { sprintf, translate } from '../../../../utils/i18n';
-import { copyToClipboard } from '../utils';
-import { VIEW_TYPES } from './constants';
+import { GlAlert, GlAvatar, GlButton, GlTooltipDirective, GlDisclosureDropdown } from '@gitlab/ui';
+import { copyToClipboard } from '~/lib/utils/copy_to_clipboard';
+import { DUO_CHAT_VIEWS } from 'ee/ai/constants';
+import { s__, sprintf } from '~/locale';
+import SafeHtml from '~/vue_shared/directives/safe_html';
export const i18n = {
- CHAT_CLOSE_LABEL: translate('WebDuoChat.closeChatHeaderLabel', 'Close chat'),
- CHAT_BACK_TO_CHAT_TOOLTIP: translate('WebDuoChat.chatBackToChatToolTip', 'Back to chat'),
- CHAT_TITLE: translate('WebDuoChat.chatTitle', 'GitLab Duo Chat'),
- CHAT_DROPDOWN_MORE_OPTIONS: translate('WebDuoChat.chatDropdownMoreOptions', 'More options'),
- CHAT_COPY_TOOLTIP: translate('WebDuoChat.copySessionIdTooltip', 'Copy Chat Session ID (%{id})'),
- CHAT_COPY_SUCCESS_TOAST: translate(
- 'WebDuoChat.copySessionIdSuccessToast',
- 'Session ID copied to clipboard'
- ),
- CHAT_COPY_FAILED_TOAST: translate(
- 'WebDuoChat.copySessionIdFailedToast',
- 'Could not copy session ID'
- ),
+ CHAT_CLOSE_LABEL: s__('DuoChat|Close chat'),
+ CHAT_BACK_TO_CHAT_TOOLTIP: s__('DuoChat|Back to chat'),
+ CHAT_TITLE: s__('DuoChat|GitLab Duo Chat'),
+ CHAT_DROPDOWN_MORE_OPTIONS: s__('DuoChat|More options'),
+ CHAT_COPY_TOOLTIP: s__('DuoChat|Copy Chat Session ID (%{id})'),
+ CHAT_COPY_SUCCESS_TOAST: s__('DuoChat|Session ID copied to clipboard'),
+ CHAT_COPY_FAILED_TOAST: s__('DuoChat|Could not copy session ID'),
};
-Vue.use(GlToast);
-
export default {
name: 'DuoChatHeader',
@@ -80,51 +64,31 @@
required: false,
default: false,
},
- shouldRenderResizable: {
- type: Boolean,
- required: false,
- default: false,
- },
activeThreadId: {
type: String,
required: false,
default: null,
},
- badgeType: {
- type: String,
- required: false,
- default: null,
- },
currentView: {
type: String,
required: true,
},
- agents: {
- type: Array,
- required: false,
- default: () => [],
- },
showStudioHeader: {
type: Boolean,
required: false,
default: false,
},
},
+ emits: ['close', 'go-back-to-chat'],
data() {
return {
isSessionDropdownVisible: false,
};
},
computed: {
- VIEW_TYPES() {
- return VIEW_TYPES;
+ DUO_CHAT_VIEWS() {
+ return DUO_CHAT_VIEWS;
},
- hasManyAgents() {
- return this.agents.length > 1;
- },
- hasAgents() {
- return this.agents.length > 0;
- },
sessionText() {
return sprintf(this.$options.i18n.CHAT_COPY_TOOLTIP, { id: this.sessionId });
},
@@ -142,7 +106,7 @@
return !this.isSessionDropdownVisible ? this.$options.i18n.CHAT_DROPDOWN_MORE_OPTIONS : '';
},
showSubheader() {
- return this.currentView !== VIEW_TYPES.LIST;
+ return this.currentView !== DUO_CHAT_VIEWS.LIST;
},
},
methods: {
@@ -152,22 +116,13 @@
hideSessionDropdown() {
this.isSessionDropdownVisible = false;
},
- startNewChat(agent) {
- if (agent) {
- this.$emit('new-chat', agent);
- } else if (this.hasAgents) {
- this.$emit('new-chat', this.agents[0]);
- } else {
- this.$emit('new-chat');
- }
- },
async copySessionIdToClipboard() {
try {
await copyToClipboard(this.sessionId, this.$el);
- this.$toast.show('Session ID copied to clipboard');
+ this.$toast.show(this.$options.i18n.CHAT_COPY_SUCCESS_TOAST);
} catch {
- this.$toast.show('Could not copy session ID');
+ this.$toast.show(this.$options.i18n.CHAT_COPY_FAILED_TOAST);
}
},
},
@@ -234,7 +189,7 @@
<div class="gl-flex gl-gap-3">
<gl-button
- v-if="isMultithreaded && activeThreadId && currentView === VIEW_TYPES.LIST"
+ v-if="isMultithreaded && activeThreadId && currentView === DUO_CHAT_VIEWS.LIST"
v-gl-tooltip
:title="$options.i18n.CHAT_BACK_TO_CHAT_TOOLTIP"
data-testid="go-back-to-chat-button"
Imports & Dependencies
web_duo_chat_header.vue (duo-ui) |
duo_chat_header.vue (monolith) |
|
|---|---|---|
SafeHtml |
GlSafeHtmlDirective from @gitlab/ui
|
~/vue_shared/directives/safe_html |
copyToClipboard |
Local ../utils
|
~/lib/utils/copy_to_clipboard |
| View constants |
VIEW_TYPES from local ./constants
|
DUO_CHAT_VIEWS from ee/ai/constants
|
| i18n |
sprintf + translate() from local utils |
s__ + sprintf from ~/locale
|
| Toast |
GlToast + Vue.use(GlToast)
|
Not needed (GitLab provides it globally) |
Props
The duo-ui version has 3 unused extra props that were dropped in the monolith:
-
shouldRenderResizable(Boolean) -
badgeType(String) -
agents(Array)
Computed Properties
The duo-ui version has 2 unused extra computed properties related to the removed agents prop:
hasManyAgentshasAgents
Methods
The duo-ui version has an unused startNewChat(agent) method that emits a 'new-chat' event — absent in the monolith. Also, the duo-ui version hardcodes toast strings in the method body instead of referencing i18n constants.
Emits
The monolith version explicitly declares emits: ['close', 'go-back-to-chat']; the duo-ui version has no emits declaration.
Template
Functionally identical — the only difference is VIEW_TYPES.LIST vs DUO_CHAT_VIEWS.LIST, reflecting the different constant names.
References
Part of #593464
Follow-up: !228844 (merged)
Screenshots or screen recordings
N/A - This is a refactoring change with no visual impact.
How to set up and validate locally
-
Verify that the Duo Chat functionality works as expected in both classic and agentic modes
-
Run the test suite to ensure all specs pass:
yarn jest ee/spec/frontend/ai/duo_agentic_chat/components/duo_agentic_chat_view_spec.js yarn jest ee/spec/frontend/ai/tanuki_bot/components/duo_chat_view_spec.js
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.
- Code follows the established patterns and conventions
- Tests are included for all migrated components
- ESLint todo lists have been updated
- No breaking changes to the public API