diff --git a/ee/app/assets/javascripts/ai/tanuki_bot/components/app.vue b/ee/app/assets/javascripts/ai/tanuki_bot/components/app.vue index 25cecfa21769006dfa1e0e48fcbf60c5c7ae801c..cf61e98d88d871df08f0dff88a2e503e3ccf61b6 100644 --- a/ee/app/assets/javascripts/ai/tanuki_bot/components/app.vue +++ b/ee/app/assets/javascripts/ai/tanuki_bot/components/app.vue @@ -15,7 +15,8 @@ import duoUserFeedbackMutation from 'ee/ai/graphql/duo_user_feedback.mutation.gr import Tracking from '~/tracking'; import { i18n, GENIE_CHAT_RESET_MESSAGE, GENIE_CHAT_CLEAR_MESSAGE } from 'ee/ai/constants'; import getAiSlashCommands from 'ee/ai/graphql/get_ai_slash_commands.query.graphql'; -import { TANUKI_BOT_TRACKING_EVENT_NAME, MESSAGE_TYPES } from '../constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { TANUKI_BOT_TRACKING_EVENT_NAME, MESSAGE_TYPES, WIDTH_OFFSET } from '../constants'; import TanukiBotSubscriptions from './tanuki_bot_subscriptions.vue'; export default { @@ -42,7 +43,7 @@ export default { DuoChatCallout, TanukiBotSubscriptions, }, - mixins: [Tracking.mixin()], + mixins: [Tracking.mixin(), glFeatureFlagsMixin()], provide() { return { renderGFM, @@ -104,6 +105,10 @@ export default { cancelledRequestIds: [], completedRequestId: null, aiSlashCommands: [], + width: 400, + height: window.innerHeight, + minWidth: 400, + minHeight: 400, }; }, computed: { @@ -115,6 +120,21 @@ export default { return this.resourceId || this.userId; }, + shouldRenderResizable() { + return this.glFeatures.duoChatDynamicDimension; + }, + dimensions() { + return { + width: this.width, + height: this.height, + top: this.top, + maxHeight: this.maxHeight, + maxWidth: this.maxWidth, + minWidth: this.minWidth, + minHeight: this.minHeight, + left: this.left, + }; + }, hasCommands() { return this.duoChatGlobalState.commands.length > 0; }, @@ -129,8 +149,34 @@ export default { }, }, }, + mounted() { + this.setDimensions(); + window.addEventListener('resize', this.onWindowResize); + }, + beforeDestroy() { + // Remove the event listener when the component is destroyed + window.removeEventListener('resize', this.onWindowResize); + }, methods: { ...mapActions(['addDuoChatMessage', 'setMessages', 'setLoading']), + setDimensions() { + this.updateDimensions(); + }, + updateDimensions(width, height) { + this.maxWidth = window.innerWidth - WIDTH_OFFSET; + this.maxHeight = window.innerHeight; + + this.width = Math.min(width || this.width, this.maxWidth); + this.height = Math.min(height || this.height, this.maxHeight); + this.top = window.innerHeight - this.height; + this.left = window.innerWidth - this.width; + }, + onChatResize(e) { + this.updateDimensions(e.width, e.height); + }, + onWindowResize() { + this.updateDimensions(); + }, isClearOrResetMessage(question) { return [GENIE_CHAT_CLEAR_MESSAGE, GENIE_CHAT_RESET_MESSAGE].includes(question); }, @@ -277,9 +323,11 @@ export default { id="duo-chat" :slash-commands="aiSlashCommands" :title="$options.i18n.gitlabChat" + :dimensions="dimensions" :messages="messages" :error="error" :is-loading="loading" + :should-render-resizable="shouldRenderResizable" :predefined-prompts="$options.i18n.predefinedPrompts" :badge-type="null" :tool-name="toolName" @@ -289,6 +337,7 @@ export default { @send-chat-prompt="onSendChatPrompt" @chat-hidden="onChatClose" @track-feedback="onTrackFeedback" + @chat-resize="onChatResize" /> </div> <duo-chat-callout @callout-dismissed="onCalloutDismissed" /> diff --git a/ee/app/assets/javascripts/ai/tanuki_bot/constants.js b/ee/app/assets/javascripts/ai/tanuki_bot/constants.js index 6ac2f3c551d964b679a73c3213d590ac0e6a602c..04f0753459b5a86effa18fd368371eca3929e3f9 100644 --- a/ee/app/assets/javascripts/ai/tanuki_bot/constants.js +++ b/ee/app/assets/javascripts/ai/tanuki_bot/constants.js @@ -27,3 +27,5 @@ export const ERROR_MESSAGE = s__( export const TANUKI_BOT_TRACKING_EVENT_NAME = 'ask_gitlab_chat'; export const TANUKI_BOT_FEEDBACK_ISSUE_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/408527'; + +export const WIDTH_OFFSET = 10; diff --git a/ee/app/assets/stylesheets/components/tanuki_bot.scss b/ee/app/assets/stylesheets/components/tanuki_bot.scss index d9064c52c12b885cfb45584b99260c03063f99a2..06bbc143d4e5de7e93c701a911b0d7d7bcfe0940 100644 --- a/ee/app/assets/stylesheets/components/tanuki_bot.scss +++ b/ee/app/assets/stylesheets/components/tanuki_bot.scss @@ -1,3 +1,9 @@ +.duo-chat-resizable { + // important because we're overwritting styles on data-attr set by https://github.com/nikitasnv/vue-resizable + // to make sure we're having the correct scroll behaviour + position: fixed !important; +} + .duo-chat-container .duo-chat-drawer { z-index: $zindex-duo-chat; diff --git a/ee/config/feature_flags/beta/duo_chat_dynamic_dimension.yml b/ee/config/feature_flags/beta/duo_chat_dynamic_dimension.yml new file mode 100644 index 0000000000000000000000000000000000000000..f26c462b212916f75de098f2ceae577bd3b1c05b --- /dev/null +++ b/ee/config/feature_flags/beta/duo_chat_dynamic_dimension.yml @@ -0,0 +1,9 @@ +--- +name: duo_chat_dynamic_dimension +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/508981 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175019 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/508981 +milestone: '17.7' +group: group::duo chat +type: beta +default_enabled: false diff --git a/ee/lib/ee/gitlab/gon_helper.rb b/ee/lib/ee/gitlab/gon_helper.rb index 05622f69fc0e8902fd50e24fa91dd36b40cae77c..11603533cec61ed6c8cc3e9dec96d72d8e5b154c 100644 --- a/ee/lib/ee/gitlab/gon_helper.rb +++ b/ee/lib/ee/gitlab/gon_helper.rb @@ -28,6 +28,7 @@ def add_gon_variables gon.billing_accounts_url = ::Gitlab::Routing.url_helpers.subscription_portal_billing_accounts_url gon.payment_form_url = ::Gitlab::Routing.url_helpers.subscription_portal_payment_form_url gon.payment_validation_form_id = ::Gitlab::SubscriptionPortal::PAYMENT_VALIDATION_FORM_ID + push_frontend_feature_flag(:duo_chat_dynamic_dimension) push_frontend_feature_flag(:advanced_context_resolver, current_user) end diff --git a/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js b/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js index 5d8f6ea298e8a70a0b2b68ba722a4076de487823..92b5bf1015462bcd0e604a9db03f4bc287c0bf2c 100644 --- a/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js +++ b/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js @@ -9,7 +9,7 @@ import TanukiBotChatApp from 'ee/ai/tanuki_bot/components/app.vue'; import DuoChatCallout from 'ee/ai/components/global_callout/duo_chat_callout.vue'; import TanukiBotSubscriptions from 'ee/ai/tanuki_bot/components/tanuki_bot_subscriptions.vue'; import { GENIE_CHAT_RESET_MESSAGE, GENIE_CHAT_CLEAR_MESSAGE } from 'ee/ai/constants'; -import { TANUKI_BOT_TRACKING_EVENT_NAME } from 'ee/ai/tanuki_bot/constants'; +import { TANUKI_BOT_TRACKING_EVENT_NAME, WIDTH_OFFSET } from 'ee/ai/tanuki_bot/constants'; import chatMutation from 'ee/ai/graphql/chat.mutation.graphql'; import duoUserFeedbackMutation from 'ee/ai/graphql/duo_user_feedback.mutation.graphql'; import getAiMessages from 'ee/ai/graphql/get_ai_messages.query.graphql'; @@ -82,6 +82,7 @@ describeSkipVue3(skipReason, () => { const createComponent = ({ initialState = {}, propsData = { userId: MOCK_USER_ID, resourceId: MOCK_RESOURCE_ID }, + glFeatures = { duoChatDynamicDimension: false }, } = {}) => { const store = new Vuex.Store({ actions: actionSpies, @@ -101,6 +102,9 @@ describeSkipVue3(skipReason, () => { store, apolloProvider, propsData, + provide: { + glFeatures, + }, }); }; @@ -611,4 +615,68 @@ describeSkipVue3(skipReason, () => { }); }); }); + + describe('Resizable Dimensions', () => { + beforeEach(() => { + duoChatGlobalState.isShown = true; + createComponent(); + }); + + it('initializes dimensions correctly on mount', () => { + createComponent(); + expect(wrapper.vm.width).toBe(400); + expect(wrapper.vm.height).toBe(window.innerHeight); + expect(wrapper.vm.maxWidth).toBe(window.innerWidth - WIDTH_OFFSET); + expect(wrapper.vm.maxHeight).toBe(window.innerHeight); + }); + + it('updates dimensions correctly when `chat-resize` event is emitted', async () => { + const newWidth = 600; + const newHeight = 500; + const chat = findDuoChat(); + chat.vm.$emit('chat-resize', { width: newWidth, height: newHeight }); + await nextTick(); + + expect(wrapper.vm.width).toBe(newWidth); + expect(wrapper.vm.height).toBe(newHeight); + }); + + it('ensures dimensions do not exceed maxWidth or maxHeight', async () => { + const newWidth = window.innerWidth + 100; + const newHeight = window.innerHeight + 100; + const chat = findDuoChat(); + + chat.vm.$emit('chat-resize', { width: newWidth, height: newHeight }); + await nextTick(); + + expect(wrapper.vm.width).toBe(window.innerWidth - WIDTH_OFFSET); + expect(wrapper.vm.height).toBe(window.innerHeight); + }); + + it('updates dimensions when the window is resized', async () => { + createComponent(); + window.innerWidth = 1200; + window.innerHeight = 800; + + window.dispatchEvent(new Event('resize')); + await nextTick(); + + expect(wrapper.vm.maxWidth).toBe(1200 - WIDTH_OFFSET); + expect(wrapper.vm.maxHeight).toBe(800); + }); + + it('renders DuoChat with shouldRenderResizable=false when duoChatDynamicDimension flag is false', () => { + createComponent({ glFeatures: { duoChatDynamicDimension: false } }); + const duoChat = findDuoChat(); + expect(duoChat.exists()).toBe(true); + expect(duoChat.props('shouldRenderResizable')).toBe(false); + }); + + it('renders DuoChat with shouldRenderResizable=true when duoChatDynamicDimension flag is true', () => { + createComponent({ glFeatures: { duoChatDynamicDimension: true } }); + const duoChat = findDuoChat(); + expect(duoChat.exists()).toBe(true); + expect(duoChat.props('shouldRenderResizable')).toBe(true); + }); + }); });