From d2499248b1d107033fce96dce65f36c85cc790c0 Mon Sep 17 00:00:00 2001 From: Tomas Bulva Date: Wed, 17 Dec 2025 17:54:46 +0100 Subject: [PATCH] Add recent conversations to navigation rail for agentic chat - Create RecentConversations component showing up to 5 workflow avatars - Display avatars when in agentic mode with 2+ workflows, else show chat button - Clicking avatar opens panel and loads selected conversation - Add watcher in duo_agentic_chat to handle activeThread changes from global state - Show skeleton loading state while conversation loads Changelog: added EE: true --- .../javascripts/ai/components/ai_panel.vue | 4 + .../ai/components/navigation_rail.vue | 55 ++--- .../ai/components/recent_conversations.vue | 164 +++++++++++++ .../components/duo_agentic_chat.vue | 17 +- .../ai/components/navigation_rail_spec.js | 70 +++--- .../components/recent_conversations_spec.js | 225 ++++++++++++++++++ locale/gitlab.pot | 3 - 7 files changed, 450 insertions(+), 88 deletions(-) create mode 100644 ee/app/assets/javascripts/ai/components/recent_conversations.vue create mode 100644 ee/spec/frontend/ai/components/recent_conversations_spec.js diff --git a/ee/app/assets/javascripts/ai/components/ai_panel.vue b/ee/app/assets/javascripts/ai/components/ai_panel.vue index 319485a4ee0aaa..14e2f25912300c 100644 --- a/ee/app/assets/javascripts/ai/components/ai_panel.vue +++ b/ee/app/assets/javascripts/ai/components/ai_panel.vue @@ -194,6 +194,9 @@ export default { this.handleChangeTab(tab); }, + openTab(tab) { + this.handleChangeTab(tab); + }, handleChangeTab(tab) { this.setActiveTab(tab); @@ -262,6 +265,7 @@ export default { :project-id="projectId" :namespace-id="namespaceId" @handleTabToggle="handleTabToggle" + @openTab="openTab" @new-chat="handleNewChat" @newChatError="handleNewChatError" /> diff --git a/ee/app/assets/javascripts/ai/components/navigation_rail.vue b/ee/app/assets/javascripts/ai/components/navigation_rail.vue index 5952c7b84c3155..ab2db947d501cf 100644 --- a/ee/app/assets/javascripts/ai/components/navigation_rail.vue +++ b/ee/app/assets/javascripts/ai/components/navigation_rail.vue @@ -1,26 +1,23 @@ + + diff --git a/ee/app/assets/javascripts/ai/duo_agentic_chat/components/duo_agentic_chat.vue b/ee/app/assets/javascripts/ai/duo_agentic_chat/components/duo_agentic_chat.vue index a0dd47424bbb09..fe91f1f4711ee1 100644 --- a/ee/app/assets/javascripts/ai/duo_agentic_chat/components/duo_agentic_chat.vue +++ b/ee/app/assets/javascripts/ai/duo_agentic_chat/components/duo_agentic_chat.vue @@ -467,7 +467,9 @@ export default { 'duoChatGlobalState.isAgenticChatShown': { handler(newVal) { if (newVal) { - if (this.hasActiveThread) { + if (this.duoChatGlobalState.activeThread) { + this.onThreadSelected({ id: this.duoChatGlobalState.activeThread }); + } else if (this.hasActiveThread) { this.hydrateActiveThread(); } else { this.onNewChat(); @@ -484,6 +486,15 @@ export default { } }, }, + 'duoChatGlobalState.activeThread': { + async handler(newVal) { + if (newVal && newVal !== this.activeThread) { + this.isLoading = true; + this.onThreadSelected({ id: newVal }); + this.loadActiveThread(); + } + }, + }, workflowStatus(newStatus, oldStatus) { if ( oldStatus === DUO_WORKFLOW_STATUS_TOOL_CALL_APPROVAL_REQUIRED && @@ -622,7 +633,6 @@ export default { }, cleanupState(resetWorkflowId = true) { - this.isLoading = false; this.isWaitingOnPrompt = false; this.lastProcessedMessageId = null; this.isProcessingMessage = false; @@ -847,12 +857,10 @@ export default { }, async onThreadSelected(thread) { const { activeThread, workflowId } = parseThreadForSelection(thread); - this.activeThread = activeThread; this.workflowId = workflowId; this.clearActiveThread(); this.cleanupState(false); - if (!this.isEmbedded) { // We should not hydrate when in embedded mode - the SSOT for // when to hydrate the thread is on the `mode` and is managed in @@ -989,6 +997,7 @@ export default { this.$router.push('/chat'); } this.isInitialLoad = false; + this.isLoading = false; await this.focusInput(); }, onModelSelect(selectedModelValue) { diff --git a/ee/spec/frontend/ai/components/navigation_rail_spec.js b/ee/spec/frontend/ai/components/navigation_rail_spec.js index f7244d33c713d5..d17fbfac296fb8 100644 --- a/ee/spec/frontend/ai/components/navigation_rail_spec.js +++ b/ee/spec/frontend/ai/components/navigation_rail_spec.js @@ -1,16 +1,12 @@ import { GlButton } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle'; -import { keysFor, DUO_CHAT } from '~/behaviors/shortcuts/keybindings'; +import { createMockDirective } from 'helpers/vue_mock_directive'; import NavigationRail from 'ee/ai/components/navigation_rail.vue'; import NewChatButton from 'ee/ai/components/new_chat_button.vue'; +import RecentConversations from 'ee/ai/components/recent_conversations.vue'; import { CHAT_MODES } from 'ee/ai/tanuki_bot/constants'; import { duoChatGlobalState } from '~/super_sidebar/constants'; -jest.mock('~/behaviors/shortcuts/shortcuts_toggle'); -jest.mock('~/behaviors/shortcuts/keybindings'); - describe('NavigationRail', () => { let wrapper; @@ -40,37 +36,40 @@ describe('NavigationRail', () => { }); }; - const findChatToggle = () => wrapper.findByTestId('ai-chat-toggle'); const findHistoryToggle = () => wrapper.findByTestId('ai-history-toggle'); const findSuggestionsToggle = () => wrapper.findByTestId('ai-suggestions-toggle'); const findSessionsToggle = () => wrapper.findByTestId('ai-sessions-toggle'); const findNewChatButton = () => wrapper.findComponent(NewChatButton); + const findRecentConversations = () => wrapper.findComponent(RecentConversations); beforeEach(() => { - shouldDisableShortcuts.mockReturnValue(false); - keysFor.mockReturnValue([DUO_CHAT]); - // Reset global state before each test duoChatGlobalState.chatMode = CHAT_MODES.AGENTIC; - createComponent(); }); + it('renders RecentConversations component', () => { + expect(findRecentConversations().exists()).toBe(true); + }); + + it('passes correct props to RecentConversations', () => { + createComponent({ activeTab: 'history', isExpanded: false, chatDisabledReason: 'project' }); + + expect(findRecentConversations().props()).toMatchObject({ + activeTab: 'history', + isExpanded: false, + isChatDisabled: true, + chatDisabledTooltip: 'An administrator has turned off GitLab Duo for this project.', + }); + }); + it('sets the correct aria-labels for toggles', () => { - expect(findChatToggle().attributes('aria-label')).toBe('Active GitLab Duo Chat'); expect(findSuggestionsToggle().attributes('aria-label')).toBe('GitLab Duo suggestions'); expect(findSessionsToggle().attributes('aria-label')).toBe('GitLab Duo sessions'); }); - it('sets the correct aria-selected attribute based on the active tab', () => { - expect(findChatToggle().attributes('aria-selected')).toBe('true'); - expect(findSuggestionsToggle().attributes('aria-selected')).toBeUndefined(); - expect(findSessionsToggle().attributes('aria-selected')).toBeUndefined(); - }); - describe('when buttons are clicked', () => { it.each` buttonName | finder | expectedEvent | expectedPayload - ${'chat'} | ${() => findChatToggle()} | ${'handleTabToggle'} | ${'chat'} ${'history'} | ${() => findHistoryToggle()} | ${'handleTabToggle'} | ${'history'} ${'sessions'} | ${() => findSessionsToggle()} | ${'handleTabToggle'} | ${'sessions'} ${'suggestions'} | ${() => findSuggestionsToggle()} | ${'handleTabToggle'} | ${'suggestions'} @@ -88,14 +87,18 @@ describe('NavigationRail', () => { expect(wrapper.emitted('new-chat')).toHaveLength(1); }); - }); - it('does not show keyshortcuts when shortcuts are disabled', () => { - shouldDisableShortcuts.mockReturnValue(true); + it('emits handleTabToggle when RecentConversations emits handle-tab-toggle', async () => { + await findRecentConversations().vm.$emit('handle-tab-toggle', 'chat'); - createComponent(); + expect(wrapper.emitted('handleTabToggle')).toEqual([['chat']]); + }); + + it('emits openTab when RecentConversations emits open-tab', async () => { + await findRecentConversations().vm.$emit('open-tab', 'chat'); - expect(findChatToggle().attributes('aria-keyshortcuts')).toBeUndefined(); + expect(wrapper.emitted('openTab')).toEqual([['chat']]); + }); }); it('does not render suggestions tab when showSuggestionsTab is false', () => { @@ -125,10 +128,9 @@ describe('NavigationRail', () => { createComponent({ chatDisabledReason: 'project' }); }); - describe('all buttons', () => { + describe('buttons', () => { it.each` buttonName | finder - ${'chat'} | ${findChatToggle} ${'history'} | ${findHistoryToggle} ${'sessions'} | ${findSessionsToggle} ${'suggestions'} | ${findSuggestionsToggle} @@ -138,7 +140,6 @@ describe('NavigationRail', () => { it.each` buttonName | finder - ${'chat'} | ${findChatToggle} ${'history'} | ${findHistoryToggle} ${'sessions'} | ${findSessionsToggle} ${'suggestions'} | ${findSuggestionsToggle} @@ -147,14 +148,10 @@ describe('NavigationRail', () => { }); it('prevents tab toggle when clicking disabled buttons', async () => { - await findChatToggle().trigger('click'); + await findHistoryToggle().trigger('click'); expect(wrapper.emitted('handleTabToggle')).toBeUndefined(); }); - - it('disables keyboard shortcut', () => { - expect(findChatToggle().attributes('aria-keyshortcuts')).toBeUndefined(); - }); }); describe('buttons with title attribute', () => { @@ -169,15 +166,6 @@ describe('NavigationRail', () => { ); }); }); - - describe('button with HTML tooltip', () => { - it('shows disabled tooltip', () => { - const tooltip = getBinding(findChatToggle().element, 'gl-tooltip'); - expect(tooltip.value.title).toBe( - 'An administrator has turned off GitLab Duo for this project.', - ); - }); - }); }); describe('new chat button', () => { diff --git a/ee/spec/frontend/ai/components/recent_conversations_spec.js b/ee/spec/frontend/ai/components/recent_conversations_spec.js new file mode 100644 index 00000000000000..609ca73eb0704e --- /dev/null +++ b/ee/spec/frontend/ai/components/recent_conversations_spec.js @@ -0,0 +1,225 @@ +import { GlAvatar, GlButton } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle'; +import { keysFor, DUO_CHAT } from '~/behaviors/shortcuts/keybindings'; +import RecentConversations from 'ee/ai/components/recent_conversations.vue'; +import { duoChatGlobalState } from '~/super_sidebar/constants'; +import { CHAT_MODES } from 'ee/ai/tanuki_bot/constants'; + +jest.mock('~/behaviors/shortcuts/shortcuts_toggle'); +jest.mock('~/behaviors/shortcuts/keybindings'); + +const createMockWorkflows = (count) => + Array.from({ length: count }, (_, i) => ({ + id: `gid://gitlab/Ai::DuoWorkflow/${i + 1}`, + title: `Conversation ${i + 1}`, + lastUpdatedAt: new Date().toISOString(), + })); + +describe('RecentConversations', () => { + let wrapper; + + const createComponent = ({ + activeTab = null, + isExpanded = true, + isChatDisabled = false, + chatDisabledTooltip = null, + workflows = [], + } = {}) => { + wrapper = shallowMountExtended(RecentConversations, { + propsData: { + activeTab, + isExpanded, + isChatDisabled, + chatDisabledTooltip, + }, + data() { + return { + duoWorkflows: workflows, + }; + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + stubs: { + GlButton, + GlAvatar, + }, + mocks: { + $apollo: { + queries: { + duoWorkflows: { loading: false }, + }, + }, + }, + }); + }; + + const findDefaultChatButton = () => wrapper.findByTestId('ai-chat-toggle'); + const findRecentConversationButtons = () => wrapper.findAllByTestId('recent-conversation-button'); + + beforeEach(() => { + shouldDisableShortcuts.mockReturnValue(false); + keysFor.mockReturnValue([DUO_CHAT]); + duoChatGlobalState.activeThread = undefined; + duoChatGlobalState.multithreadedView = 'chat'; + duoChatGlobalState.chatMode = CHAT_MODES.AGENTIC; + }); + + describe.each([ + [0, true, false, 0], + [1, true, false, 0], + [2, false, true, 2], + [5, false, true, 5], + [6, false, true, 5], + // eslint-disable-next-line max-params + ])('with %i conversations', (count, showsDefaultButton, showsAvatars, expectedAvatarCount) => { + beforeEach(() => { + createComponent({ workflows: createMockWorkflows(count) }); + }); + + it(`shows default chat button: ${showsDefaultButton}`, () => { + expect(findDefaultChatButton().exists()).toBe(showsDefaultButton); + }); + + it(`shows avatar buttons: ${showsAvatars}`, () => { + expect(findRecentConversationButtons().length > 0).toBe(showsAvatars); + }); + + if (showsAvatars) { + it(`displays ${expectedAvatarCount} avatar buttons (max 5)`, () => { + expect(findRecentConversationButtons()).toHaveLength(expectedAvatarCount); + }); + } + }); + + describe('tooltip behavior', () => { + beforeEach(() => { + createComponent({ workflows: createMockWorkflows(3) }); + }); + + it('displays conversation title in tooltip', () => { + const firstButton = findRecentConversationButtons().at(0); + expect(firstButton.attributes('title')).toBe('Conversation 1'); + }); + + it('displays full title for each conversation', () => { + const buttons = findRecentConversationButtons(); + expect(buttons.at(0).attributes('title')).toBe('Conversation 1'); + expect(buttons.at(1).attributes('title')).toBe('Conversation 2'); + expect(buttons.at(2).attributes('title')).toBe('Conversation 3'); + }); + }); + + describe('click behavior', () => { + const workflows = createMockWorkflows(3); + + beforeEach(() => { + createComponent({ workflows }); + }); + + it('emits open-tab with chat when avatar is clicked', async () => { + findRecentConversationButtons().at(0).vm.$emit('click'); + await nextTick(); + await nextTick(); + + expect(wrapper.emitted('open-tab')).toEqual([['chat']]); + }); + + it('sets activeThread in global state when avatar is clicked', async () => { + findRecentConversationButtons().at(1).vm.$emit('click'); + await nextTick(); + + expect(duoChatGlobalState.activeThread).toBe(workflows[1].id); + }); + + it('sets multithreadedView to chat in global state when avatar is clicked', async () => { + duoChatGlobalState.multithreadedView = 'list'; + findRecentConversationButtons().at(0).vm.$emit('click'); + await nextTick(); + + expect(duoChatGlobalState.multithreadedView).toBe('chat'); + }); + }); + + describe('default chat button click', () => { + beforeEach(() => { + createComponent({ workflows: createMockWorkflows(1) }); + }); + + it('emits handle-tab-toggle with chat when clicked', async () => { + await findDefaultChatButton().vm.$emit('click'); + + expect(wrapper.emitted('handle-tab-toggle')).toEqual([['chat']]); + }); + }); + + describe('when chat is disabled', () => { + const disabledTooltip = 'Chat is disabled for this project'; + + beforeEach(() => { + createComponent({ + isChatDisabled: true, + chatDisabledTooltip: disabledTooltip, + workflows: createMockWorkflows(0), + }); + }); + + it('shows default chat button with disabled state', () => { + expect(findDefaultChatButton().exists()).toBe(true); + expect(findDefaultChatButton().attributes('aria-disabled')).toBe('true'); + expect(findDefaultChatButton().classes()).toContain('gl-opacity-5'); + }); + + it('does not emit event when clicked while disabled', async () => { + await findDefaultChatButton().vm.$emit('click'); + + expect(wrapper.emitted('handle-tab-toggle')).toBeUndefined(); + }); + + it('shows disabled tooltip', () => { + const tooltip = getBinding(findDefaultChatButton().element, 'gl-tooltip'); + expect(tooltip.value.title).toBe(disabledTooltip); + }); + }); + + describe('active thread highlighting', () => { + const workflows = createMockWorkflows(3); + + beforeEach(() => { + duoChatGlobalState.activeThread = workflows[1].id; + createComponent({ activeTab: 'chat', workflows }); + }); + + it('applies active class to the active thread button', () => { + const buttons = findRecentConversationButtons(); + expect(buttons.at(0).classes()).not.toContain('ai-nav-icon-active'); + expect(buttons.at(1).classes()).toContain('ai-nav-icon-active'); + expect(buttons.at(2).classes()).not.toContain('ai-nav-icon-active'); + }); + + it('sets aria-selected on the active thread button', () => { + const buttons = findRecentConversationButtons(); + expect(buttons.at(0).attributes('aria-selected')).toBeUndefined(); + expect(buttons.at(1).attributes('aria-selected')).toBe('true'); + expect(buttons.at(2).attributes('aria-selected')).toBeUndefined(); + }); + }); + + describe('when in classic mode', () => { + beforeEach(() => { + duoChatGlobalState.chatMode = CHAT_MODES.CLASSIC; + createComponent({ workflows: createMockWorkflows(5) }); + }); + + it('shows default chat button regardless of workflow count', () => { + expect(findDefaultChatButton().exists()).toBe(true); + }); + + it('does not show avatar buttons', () => { + expect(findRecentConversationButtons()).toHaveLength(0); + }); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b7165271eb43fb..0b5ff757ce7a38 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -21175,9 +21175,6 @@ msgstr "" msgid "Current Chat" msgstr "" -msgid "Current GitLab Duo Chat" -msgstr "" - msgid "Current Project" msgstr "" -- GitLab