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:

  1. This MR — adds the view components and their specs
  2. 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.vueee/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 uses translatePlural(...)(count); monolith uses n__(...).

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.vueee/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 uses translatePlural(...)(count); monolith uses n__(...) 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.vueee/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:

  • hasManyAgents
  • hasAgents

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

  1. Verify that the Duo Chat functionality works as expected in both classic and agentic modes

  2. 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
Edited by Enrique Alcántara

Merge request reports

Loading