diff --git a/ee/app/assets/javascripts/ai/components/ai_panel.vue b/ee/app/assets/javascripts/ai/components/ai_panel.vue
index 319485a4ee0aaa62bf7351ac483b909af5c73338..14e2f25912300ccad821000bff7aaef07a5d6bd3 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 5952c7b84c315506b8aa85d545ad5cf409ec25a0..ab2db947d501cf079dc12d9a117b78c4ca51bfeb 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 a0dd47424bbb09eeda5899b1d53bf02776804a58..fe91f1f4711ee1349599b5dad8e7d22942f29316 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 f7244d33c713d5442408e339c1776745473931c6..d17fbfac296fb81d0da046b18d1e3e76ccec06c5 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 0000000000000000000000000000000000000000..609ca73eb0704e1551a20c2e9b2173a92abfc256
--- /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 b7165271eb43fbb0db1c7e9346b385a9e2260c60..0b5ff757ce7a382cb853ac300095b00adf01b6a2 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 ""