From 67cf170a24c056b743b5fae229ff05f86913cb3d Mon Sep 17 00:00:00 2001 From: Viduni Wickramarachchi Date: Tue, 28 Jan 2025 13:50:18 -0500 Subject: [PATCH] [Obs AI Assistant] Chat history details in conversation list (#207426) Closes https://github.com/elastic/kibana/issues/176295 ## Summary Categorizes the chat history based on `lastUpdated` date of the conversation. ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/buttons/new_chat_button.tsx | 2 +- .../src/chat/conversation_list.test.tsx | 204 ++++++++++++++++ .../src/chat/conversation_list.tsx | 176 +++++++------- .../welcome_message_knowledge_base.test.tsx | 1 + .../kbn-ai-assistant/src/hooks/index.ts | 2 + .../hooks/use_conversations_by_date.test.ts | 223 ++++++++++++++++++ .../src/hooks/use_conversations_by_date.ts | 75 ++++++ .../shared/kbn-ai-assistant/src/i18n.ts | 27 +++ .../kbn-ai-assistant/src/utils/date.test.ts | 77 ++++++ .../shared/kbn-ai-assistant/src/utils/date.ts | 22 ++ .../shared/kbn-ai-assistant/tsconfig.json | 1 + .../translations/translations/fr-FR.json | 9 +- .../translations/translations/ja-JP.json | 9 +- .../translations/translations/zh-CN.json | 9 +- 14 files changed, 736 insertions(+), 101 deletions(-) create mode 100644 x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/conversation_list.test.tsx create mode 100644 x-pack/platform/packages/shared/kbn-ai-assistant/src/hooks/use_conversations_by_date.test.ts create mode 100644 x-pack/platform/packages/shared/kbn-ai-assistant/src/hooks/use_conversations_by_date.ts create mode 100644 x-pack/platform/packages/shared/kbn-ai-assistant/src/utils/date.test.ts create mode 100644 x-pack/platform/packages/shared/kbn-ai-assistant/src/utils/date.ts diff --git a/x-pack/platform/packages/shared/kbn-ai-assistant/src/buttons/new_chat_button.tsx b/x-pack/platform/packages/shared/kbn-ai-assistant/src/buttons/new_chat_button.tsx index 60e37d85b92d9..9435cca062816 100644 --- a/x-pack/platform/packages/shared/kbn-ai-assistant/src/buttons/new_chat_button.tsx +++ b/x-pack/platform/packages/shared/kbn-ai-assistant/src/buttons/new_chat_button.tsx @@ -24,7 +24,7 @@ export function NewChatButton( ) : ( ({ + useConversationsByDate: jest.fn(), +})); + +jest.mock('../hooks/use_confirm_modal', () => ({ + useConfirmModal: jest.fn().mockReturnValue({ + element:
, + confirm: jest.fn(() => Promise.resolve(true)), + }), +})); + +const mockConversations: UseConversationListResult['conversations'] = { + value: { + conversations: [ + { + conversation: { + id: '1', + title: "Today's Conversation", + last_updated: '2025-01-21T10:00:00Z', + }, + '@timestamp': '2025-01-21T10:00:00Z', + labels: {}, + numeric_labels: {}, + messages: [], + namespace: 'namespace-1', + public: true, + }, + { + conversation: { + id: '2', + title: "Yesterday's Conversation", + last_updated: '2025-01-20T10:00:00Z', + }, + '@timestamp': '2025-01-20T10:00:00Z', + labels: {}, + numeric_labels: {}, + messages: [], + namespace: 'namespace-2', + public: true, + }, + ], + }, + error: undefined, + loading: false, + refresh: jest.fn(), +}; + +const mockCategorizedConversations = { + TODAY: [ + { + id: '1', + label: "Today's Conversation", + lastUpdated: '2025-01-21T10:00:00Z', + href: '/conversation/1', + }, + ], + YESTERDAY: [ + { + id: '2', + label: "Yesterday's Conversation", + lastUpdated: '2025-01-20T10:00:00Z', + href: '/conversation/2', + }, + ], + THIS_WEEK: [], + LAST_WEEK: [], + THIS_MONTH: [], + LAST_MONTH: [], + THIS_YEAR: [], + OLDER: [], +}; + +const defaultProps = { + conversations: mockConversations, + isLoading: false, + selectedConversationId: undefined, + onConversationSelect: jest.fn(), + onConversationDeleteClick: jest.fn(), + newConversationHref: '/conversation/new', + getConversationHref: (id: string) => `/conversation/${id}`, +}; + +describe('ConversationList', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useConversationsByDate as jest.Mock).mockReturnValue(mockCategorizedConversations); + }); + + it('renders the component without errors', () => { + render(); + + const todayCategoryLabel = screen.getByText(/today/i, { + selector: 'div.euiText', + }); + expect(todayCategoryLabel).toBeInTheDocument(); + + const yesterdayCategoryLabel = screen.getByText(/yesterday/i, { + selector: 'div.euiText', + }); + expect(yesterdayCategoryLabel).toBeInTheDocument(); + + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + expect( + screen.queryByText( + i18n.translate('xpack.aiAssistant.conversationList.errorMessage', { + defaultMessage: 'Failed to load', + }) + ) + ).not.toBeInTheDocument(); + + expect( + screen.queryByText( + i18n.translate('xpack.aiAssistant.conversationList.noConversations', { + defaultMessage: 'No conversations', + }) + ) + ).not.toBeInTheDocument(); + + expect(screen.getByTestId('observabilityAiAssistantNewChatButton')).toBeInTheDocument(); + }); + + it('displays loading state', () => { + render(); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('displays error state', () => { + const errorProps = { + ...defaultProps, + conversations: { ...mockConversations, error: new Error('An error occurred') }, + }; + render(); + expect( + screen.getByText( + i18n.translate('xpack.aiAssistant.conversationList.errorMessage', { + defaultMessage: 'Failed to load', + }) + ) + ).toBeInTheDocument(); + }); + + it('renders categorized conversations', () => { + render(); + Object.entries(mockCategorizedConversations).forEach(([category, conversationList]) => { + if (conversationList.length > 0) { + expect(screen.getByText(DATE_CATEGORY_LABELS[category])).toBeInTheDocument(); + conversationList.forEach((conversation) => { + expect(screen.getByText(conversation.label)).toBeInTheDocument(); + }); + } + }); + }); + + it('calls onConversationSelect when a conversation is clicked', () => { + render(); + const todayConversation = screen.getByText("Today's Conversation"); + fireEvent.click(todayConversation); + expect(defaultProps.onConversationSelect).toHaveBeenCalledWith('1'); + }); + + it('calls onConversationDeleteClick when delete icon is clicked', async () => { + render(); + const deleteButtons = screen.getAllByLabelText('Delete'); + await fireEvent.click(deleteButtons[0]); + expect(defaultProps.onConversationDeleteClick).toHaveBeenCalledWith('1'); + }); + + it('renders a new chat button and triggers onConversationSelect when clicked', () => { + render(); + const newChatButton = screen.getByTestId('observabilityAiAssistantNewChatButton'); + fireEvent.click(newChatButton); + expect(defaultProps.onConversationSelect).toHaveBeenCalledWith(undefined); + }); + + it('renders "no conversations" message when there are no conversations', () => { + const emptyProps = { + ...defaultProps, + conversations: { ...mockConversations, value: { conversations: [] } }, + }; + render(); + expect( + screen.getByText( + i18n.translate('xpack.aiAssistant.conversationList.noConversations', { + defaultMessage: 'No conversations', + }) + ) + ).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/conversation_list.tsx b/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/conversation_list.tsx index e4a7022edc763..0b634f8ee9f7f 100644 --- a/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/conversation_list.tsx +++ b/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/conversation_list.tsx @@ -21,15 +21,11 @@ import { import { css } from '@emotion/css'; import { i18n } from '@kbn/i18n'; import React, { MouseEvent } from 'react'; -import { useConfirmModal } from '../hooks/use_confirm_modal'; import type { UseConversationListResult } from '../hooks/use_conversation_list'; -import { EMPTY_CONVERSATION_TITLE } from '../i18n'; +import { useConfirmModal, useConversationsByDate } from '../hooks'; +import { DATE_CATEGORY_LABELS } from '../i18n'; import { NewChatButton } from '../buttons/new_chat_button'; -const titleClassName = css` - text-transform: uppercase; -`; - const panelClassName = css` max-height: 100%; padding-top: 56px; @@ -70,6 +66,11 @@ export function ConversationList({ padding: ${euiTheme.euiTheme.size.s}; `; + const titleClassName = css` + text-transform: uppercase; + font-weight: ${euiTheme.euiTheme.font.weight.bold}; + `; + const { element: confirmDeleteElement, confirm: confirmDeleteCallback } = useConfirmModal({ title: i18n.translate('xpack.aiAssistant.flyout.confirmDeleteConversationTitle', { defaultMessage: 'Delete this conversation?', @@ -82,24 +83,21 @@ export function ConversationList({ }), }); - const displayedConversations = [ - ...(!selectedConversationId - ? [ - { - id: '', - label: EMPTY_CONVERSATION_TITLE, - lastUpdated: '', - href: newConversationHref, - }, - ] - : []), - ...(conversations.value?.conversations ?? []).map(({ conversation }) => ({ - id: conversation.id, - label: conversation.title, - lastUpdated: conversation.last_updated, - href: getConversationHref ? getConversationHref(conversation.id) : undefined, - })), - ]; + // Categorize conversations by date + const conversationsCategorizedByDate = useConversationsByDate( + conversations.value?.conversations, + getConversationHref + ); + + const onClickConversation = ( + e: MouseEvent | MouseEvent, + conversationId?: string + ) => { + if (onConversationSelect) { + e.preventDefault(); + onConversationSelect(conversationId); + } + }; return ( <> @@ -107,27 +105,17 @@ export function ConversationList({ - - - - - - - - {i18n.translate('xpack.aiAssistant.conversationList.title', { - defaultMessage: 'Previously', - })} - - - - {isLoading ? ( + {isLoading ? ( + + + - ) : null} - - - + + + + ) : null} {conversations.error ? ( @@ -148,55 +136,56 @@ export function ConversationList({ ) : null} - {displayedConversations?.length ? ( - - - {displayedConversations?.map((conversation) => ( - { - if (onConversationSelect) { - event.preventDefault(); - onConversationSelect(conversation.id); - } - }} - extraAction={ - conversation.id - ? { - iconType: 'trash', - 'aria-label': i18n.translate( - 'xpack.aiAssistant.conversationList.deleteConversationIconLabel', - { - defaultMessage: 'Delete', - } - ), - onClick: () => { - confirmDeleteCallback().then((confirmed) => { - if (!confirmed) { - return; - } - - onConversationDeleteClick(conversation.id); - }); - }, + {/* Render conversations categorized by date */} + {Object.entries(conversationsCategorizedByDate).map(([category, conversationList]) => + conversationList.length ? ( + + + + {i18n.translate('xpack.aiAssistant.conversationList.dateGroup', { + defaultMessage: DATE_CATEGORY_LABELS[category], + })} + + + + {conversationList.map((conversation) => ( + onClickConversation(event, conversation.id)} + extraAction={{ + iconType: 'trash', + 'aria-label': i18n.translate( + 'xpack.aiAssistant.conversationList.deleteConversationIconLabel', + { + defaultMessage: 'Delete', } - : undefined - } - /> - ))} - - - ) : null} + ), + onClick: () => { + confirmDeleteCallback().then((confirmed) => { + if (!confirmed) { + return; + } + onConversationDeleteClick(conversation.id); + }); + }, + }} + /> + ))} + + + + ) : null + )} - {!isLoading && !conversations.error && !displayedConversations?.length ? ( + {!isLoading && !conversations.error && !conversations.value?.conversations?.length ? ( {i18n.translate('xpack.aiAssistant.conversationList.noConversations', { @@ -214,14 +203,7 @@ export function ConversationList({ | MouseEvent - ) => { - if (onConversationSelect) { - event.preventDefault(); - onConversationSelect(undefined); - } - }} + onClick={(event) => onClickConversation(event)} /> diff --git a/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/welcome_message_knowledge_base.test.tsx b/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/welcome_message_knowledge_base.test.tsx index f5fe599946fde..db29938ee6262 100644 --- a/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/welcome_message_knowledge_base.test.tsx +++ b/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/welcome_message_knowledge_base.test.tsx @@ -15,6 +15,7 @@ describe('WelcomeMessageKnowledgeBase', () => { afterEach(() => { jest.clearAllMocks(); }); + function createMockKnowledgeBase( partial: Partial = {} ): UseKnowledgeBaseResult { diff --git a/x-pack/platform/packages/shared/kbn-ai-assistant/src/hooks/index.ts b/x-pack/platform/packages/shared/kbn-ai-assistant/src/hooks/index.ts index a879bbecdfd40..8826917597d0b 100644 --- a/x-pack/platform/packages/shared/kbn-ai-assistant/src/hooks/index.ts +++ b/x-pack/platform/packages/shared/kbn-ai-assistant/src/hooks/index.ts @@ -10,3 +10,5 @@ export * from './use_ai_assistant_chat_service'; export * from './use_knowledge_base'; export * from './use_scopes'; export * from './use_genai_connectors'; +export * from './use_confirm_modal'; +export * from './use_conversations_by_date'; diff --git a/x-pack/platform/packages/shared/kbn-ai-assistant/src/hooks/use_conversations_by_date.test.ts b/x-pack/platform/packages/shared/kbn-ai-assistant/src/hooks/use_conversations_by_date.test.ts new file mode 100644 index 0000000000000..bbb8456c604a7 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-ai-assistant/src/hooks/use_conversations_by_date.test.ts @@ -0,0 +1,223 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react'; +import { useConversationsByDate } from './use_conversations_by_date'; +import { getAbsoluteTime, isValidDateMath } from '../utils/date'; +import { Conversation } from '@kbn/observability-ai-assistant-plugin/common'; + +jest.mock('../utils/date', () => ({ + getAbsoluteTime: jest.fn(), + isValidDateMath: jest.fn(), +})); + +const getDisplayedConversation = (conversation: Conversation) => { + return { + id: conversation.conversation.id, + label: conversation.conversation.title, + lastUpdated: conversation.conversation.last_updated, + href: `/conversation/${conversation.conversation.id}`, + }; +}; + +describe('useConversationsByDate', () => { + const startOfToday = new Date('2025-01-21T00:00:00Z').valueOf(); + const startOfYesterday = new Date('2025-01-20T00:00:00Z').valueOf(); + const startOfThisWeek = new Date('2025-01-19T00:00:00Z').valueOf(); + const startOfLastWeek = new Date('2025-01-12T00:00:00Z').valueOf(); + const startOfThisMonth = new Date('2025-01-01T00:00:00Z').valueOf(); + const startOfLastMonth = new Date('2024-12-01T00:00:00Z').valueOf(); + const startOfThisYear = new Date('2025-01-01T00:00:00Z').valueOf(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + (getAbsoluteTime as jest.Mock).mockImplementation((range: string) => { + switch (range) { + case 'now/d': + return startOfToday; + case 'now-1d/d': + return startOfYesterday; + case 'now/w': + return startOfThisWeek; + case 'now-1w/w': + return startOfLastWeek; + case 'now/M': + return startOfThisMonth; + case 'now-1M/M': + return startOfLastMonth; + case 'now/y': + return startOfThisYear; + default: + return undefined; + } + }); + + (isValidDateMath as jest.Mock).mockImplementation((value: string) => { + const validTimestamps = [ + new Date(startOfToday + 5 * 60 * 60 * 1000).toISOString(), + new Date(startOfYesterday + 5 * 60 * 60 * 1000).toISOString(), + new Date(startOfLastWeek + 5 * 60 * 60 * 1000).toISOString(), + new Date(startOfLastMonth + 5 * 60 * 60 * 1000).toISOString(), + '2024-05-01T10:00:00Z', + ]; + return validTimestamps.includes(value); + }); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + const mockConversations = [ + { + conversation: { + id: '1', + title: `Today's Conversation`, + last_updated: new Date(startOfToday + 5 * 60 * 60 * 1000).toISOString(), + }, + '@timestamp': new Date(startOfToday + 5 * 60 * 60 * 1000).toISOString(), + labels: {}, + numeric_labels: {}, + messages: [], + namespace: 'namespace-1', + public: true, + }, + { + conversation: { + id: '2', + title: `Yesterday's Conversation`, + last_updated: new Date(startOfYesterday + 5 * 60 * 60 * 1000).toISOString(), + }, + '@timestamp': new Date(startOfYesterday + 5 * 60 * 60 * 1000).toISOString(), + labels: {}, + numeric_labels: {}, + messages: [], + namespace: 'namespace-2', + public: true, + }, + { + conversation: { + id: '3', + title: `Last Week's Conversation`, + last_updated: new Date(startOfLastWeek + 5 * 60 * 60 * 1000).toISOString(), + }, + '@timestamp': new Date(startOfLastWeek + 5 * 60 * 60 * 1000).toISOString(), + labels: {}, + numeric_labels: {}, + messages: [], + namespace: 'namespace-3', + public: true, + }, + { + conversation: { + id: '4', + title: 'Older Conversation', + last_updated: '2024-05-01T10:00:00Z', + }, + '@timestamp': '2024-05-01T10:00:00Z', + labels: {}, + numeric_labels: {}, + messages: [], + namespace: 'namespace-4', + public: true, + }, + ]; + + it('categorizes conversations by date', () => { + const { result } = renderHook(() => + useConversationsByDate(mockConversations, (id) => `/conversation/${id}`) + ); + + expect(result.current).toEqual({ + TODAY: [getDisplayedConversation(mockConversations[0])], + YESTERDAY: [getDisplayedConversation(mockConversations[1])], + THIS_WEEK: [], + LAST_WEEK: [getDisplayedConversation(mockConversations[2])], + THIS_MONTH: [], + LAST_MONTH: [], + THIS_YEAR: [], + OLDER: [getDisplayedConversation(mockConversations[3])], + }); + }); + + it('handles invalid timestamps gracefully', () => { + const invalidConversations = [ + { + ...mockConversations[0], + conversation: { id: '5', title: 'Invalid Timestamp', last_updated: 'invalid-date' }, + }, + ]; + + const { result } = renderHook(() => useConversationsByDate(invalidConversations)); + + expect(result.current).toEqual({ + TODAY: [], + YESTERDAY: [], + THIS_WEEK: [], + LAST_WEEK: [], + THIS_MONTH: [], + LAST_MONTH: [], + THIS_YEAR: [], + OLDER: [], + }); + }); + + it('handles undefined timestamps in conversations', () => { + const undefinedTimestampConversations = [ + { + ...mockConversations[0], + conversation: { id: '6', title: 'No Timestamp', last_updated: undefined }, + }, + ]; + + // @ts-expect-error date is forced to be undefined for testing purposes + const { result } = renderHook(() => useConversationsByDate(undefinedTimestampConversations)); + + expect(result.current).toEqual({ + TODAY: [], + YESTERDAY: [], + THIS_WEEK: [], + LAST_WEEK: [], + THIS_MONTH: [], + LAST_MONTH: [], + THIS_YEAR: [], + OLDER: [], + }); + }); + + it('handles undefined conversations input gracefully', () => { + const { result } = renderHook(() => useConversationsByDate(undefined)); + + expect(result.current).toEqual({ + TODAY: [], + YESTERDAY: [], + THIS_WEEK: [], + LAST_WEEK: [], + THIS_MONTH: [], + LAST_MONTH: [], + THIS_YEAR: [], + OLDER: [], + }); + }); + + it('returns empty categories when no conversations are provided', () => { + const { result } = renderHook(() => useConversationsByDate([])); + + expect(result.current).toEqual({ + TODAY: [], + YESTERDAY: [], + THIS_WEEK: [], + LAST_WEEK: [], + THIS_MONTH: [], + LAST_MONTH: [], + THIS_YEAR: [], + OLDER: [], + }); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-ai-assistant/src/hooks/use_conversations_by_date.ts b/x-pack/platform/packages/shared/kbn-ai-assistant/src/hooks/use_conversations_by_date.ts new file mode 100644 index 0000000000000..cc7e7927f381d --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-ai-assistant/src/hooks/use_conversations_by_date.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { type Conversation } from '@kbn/observability-ai-assistant-plugin/public'; +import { getAbsoluteTime, isValidDateMath } from '../utils/date'; + +export function useConversationsByDate( + conversations: Conversation[] = [], + getConversationHref?: (id: string) => string +) { + return useMemo(() => { + const now = new Date(); + + const startOfToday = getAbsoluteTime('now/d', { forceNow: now }) ?? 0; + const startOfYesterday = getAbsoluteTime('now-1d/d', { forceNow: now }) ?? 0; + const startOfThisWeek = getAbsoluteTime('now/w', { forceNow: now }) ?? 0; + const startOfLastWeek = getAbsoluteTime('now-1w/w', { forceNow: now }) ?? 0; + const startOfThisMonth = getAbsoluteTime('now/M', { forceNow: now }) ?? 0; + const startOfLastMonth = getAbsoluteTime('now-1M/M', { forceNow: now }) ?? 0; + const startOfThisYear = getAbsoluteTime('now/y', { forceNow: now }) ?? 0; + + const categorizedConversations: Record< + string, + Array<{ id: string; label: string; lastUpdated: string; href?: string }> + > = { + TODAY: [], + YESTERDAY: [], + THIS_WEEK: [], + LAST_WEEK: [], + THIS_MONTH: [], + LAST_MONTH: [], + THIS_YEAR: [], + OLDER: [], + }; + + conversations.forEach((conversation) => { + if (!isValidDateMath(conversation.conversation.last_updated)) { + return; + } + + const lastUpdated = new Date(conversation.conversation.last_updated).valueOf(); + const displayedConversation = { + id: conversation.conversation.id, + label: conversation.conversation.title, + lastUpdated: conversation.conversation.last_updated, + href: getConversationHref ? getConversationHref(conversation.conversation.id) : undefined, + }; + + if (lastUpdated >= startOfToday) { + categorizedConversations.TODAY.push(displayedConversation); + } else if (lastUpdated >= startOfYesterday) { + categorizedConversations.YESTERDAY.push(displayedConversation); + } else if (lastUpdated >= startOfThisWeek) { + categorizedConversations.THIS_WEEK.push(displayedConversation); + } else if (lastUpdated >= startOfLastWeek) { + categorizedConversations.LAST_WEEK.push(displayedConversation); + } else if (lastUpdated >= startOfThisMonth) { + categorizedConversations.THIS_MONTH.push(displayedConversation); + } else if (lastUpdated >= startOfLastMonth) { + categorizedConversations.LAST_MONTH.push(displayedConversation); + } else if (lastUpdated >= startOfThisYear) { + categorizedConversations.THIS_YEAR.push(displayedConversation); + } else { + categorizedConversations.OLDER.push(displayedConversation); + } + }); + + return categorizedConversations; + }, [conversations, getConversationHref]); +} diff --git a/x-pack/platform/packages/shared/kbn-ai-assistant/src/i18n.ts b/x-pack/platform/packages/shared/kbn-ai-assistant/src/i18n.ts index 5c5be1633a07a..cdfdb01ab2da3 100644 --- a/x-pack/platform/packages/shared/kbn-ai-assistant/src/i18n.ts +++ b/x-pack/platform/packages/shared/kbn-ai-assistant/src/i18n.ts @@ -18,3 +18,30 @@ export const EMPTY_CONVERSATION_TITLE = i18n.translate('xpack.aiAssistant.emptyC export const UPGRADE_LICENSE_TITLE = i18n.translate('xpack.aiAssistant.incorrectLicense.title', { defaultMessage: 'Upgrade your license', }); + +export const DATE_CATEGORY_LABELS: Record = { + TODAY: i18n.translate('xpack.aiAssistant.conversationList.dateGroupTitle.today', { + defaultMessage: 'Today', + }), + YESTERDAY: i18n.translate('xpack.aiAssistant.conversationList.dateGroupTitle.yesterday', { + defaultMessage: 'Yesterday', + }), + THIS_WEEK: i18n.translate('xpack.aiAssistant.conversationList.dateGroupTitle.thisWeek', { + defaultMessage: 'This Week', + }), + LAST_WEEK: i18n.translate('xpack.aiAssistant.conversationList.dateGroupTitle.lastWeek', { + defaultMessage: 'Last Week', + }), + THIS_MONTH: i18n.translate('xpack.aiAssistant.conversationList.dateGroupTitle.thisMonth', { + defaultMessage: 'This Month', + }), + LAST_MONTH: i18n.translate('xpack.aiAssistant.conversationList.dateGroupTitle.lastMonth', { + defaultMessage: 'Last Month', + }), + THIS_YEAR: i18n.translate('xpack.aiAssistant.conversationList.dateGroupTitle.thisYear', { + defaultMessage: 'This Year', + }), + OLDER: i18n.translate('xpack.aiAssistant.conversationList.dateGroupTitle.older', { + defaultMessage: 'Older', + }), +}; diff --git a/x-pack/platform/packages/shared/kbn-ai-assistant/src/utils/date.test.ts b/x-pack/platform/packages/shared/kbn-ai-assistant/src/utils/date.test.ts new file mode 100644 index 0000000000000..ce10579fcef48 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-ai-assistant/src/utils/date.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import datemath from '@kbn/datemath'; +import moment from 'moment'; +import { getAbsoluteTime, isValidDateMath } from './date'; + +describe('getAbsoluteTime', () => { + let parseSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + + parseSpy = jest + .spyOn(datemath, 'parse') + .mockImplementation((range: string, opts: { forceNow?: Date; roundUp?: boolean } = {}) => { + switch (range) { + case 'now/d': + return moment(opts.roundUp ? '2025-01-20T23:59:59Z' : '2025-01-20T00:00:00Z'); + default: + return undefined; + } + }); + }); + + it('returns the absolute timestamp for a valid relative range (e.g., now/d)', () => { + const startOfToday = new Date('2025-01-20T00:00:00Z').valueOf(); + + const result = getAbsoluteTime('now/d'); + expect(result).toEqual(startOfToday); + }); + + it('respects the options passed to datemath.parse (e.g., roundUp)', () => { + const endOfToday = new Date('2025-01-20T23:59:59Z').valueOf(); + + const result = getAbsoluteTime('now/d', { roundUp: true }); + expect(result).toEqual(endOfToday); + }); + + it('returns undefined for an invalid range', () => { + const result = getAbsoluteTime('invalid-range'); + expect(result).toBeUndefined(); + }); + + it('handles undefined input gracefully', () => { + const result = getAbsoluteTime(''); + expect(result).toBeUndefined(); + }); + + afterEach(() => { + parseSpy.mockRestore(); + }); +}); + +describe('isValidDateMath', () => { + it('Returns `false` for empty strings', () => { + expect(isValidDateMath('')).toBe(false); + }); + + it('Returns `false` for invalid strings', () => { + expect(isValidDateMath('wadus')).toBe(false); + expect(isValidDateMath('nowww-')).toBe(false); + expect(isValidDateMath('now-')).toBe(false); + expect(isValidDateMath('now-1')).toBe(false); + expect(isValidDateMath('now-1d/')).toBe(false); + }); + + it('Returns `true` for valid strings', () => { + expect(isValidDateMath('now')).toBe(true); + expect(isValidDateMath('now/d')).toBe(true); + expect(isValidDateMath('now-1d')).toBe(true); + expect(isValidDateMath('now-1d/d')).toBe(true); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-ai-assistant/src/utils/date.ts b/x-pack/platform/packages/shared/kbn-ai-assistant/src/utils/date.ts new file mode 100644 index 0000000000000..f8b280c1aefb7 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-ai-assistant/src/utils/date.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import datemath from '@kbn/datemath'; + +export const getAbsoluteTime = (range: string, opts: Parameters[1] = {}) => { + const parsed = datemath.parse(range, opts); + + if (parsed && parsed.isValid()) { + return parsed.valueOf(); + } + + return undefined; +}; + +export const isValidDateMath = (value: string): boolean => { + const parsedValue = datemath.parse(value); + return !!(parsedValue && parsedValue.isValid()); +}; diff --git a/x-pack/platform/packages/shared/kbn-ai-assistant/tsconfig.json b/x-pack/platform/packages/shared/kbn-ai-assistant/tsconfig.json index 1822c6370f7bf..5a99bebb724a5 100644 --- a/x-pack/platform/packages/shared/kbn-ai-assistant/tsconfig.json +++ b/x-pack/platform/packages/shared/kbn-ai-assistant/tsconfig.json @@ -40,5 +40,6 @@ "@kbn/inference-common", "@kbn/storybook", "@kbn/ai-assistant-icon", + "@kbn/datemath", ] } diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 0dc3d5be9969e..8d44380617b5b 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -9772,7 +9772,14 @@ "xpack.aiAssistant.conversationList.deleteConversationIconLabel": "Supprimer", "xpack.aiAssistant.conversationList.errorMessage": "Échec de chargement", "xpack.aiAssistant.conversationList.noConversations": "Aucune conversation", - "xpack.aiAssistant.conversationList.title": "Précédemment", + "xpack.aiAssistant.conversationList.dateGroupTitle.today": "Aujourd'hui", + "xpack.aiAssistant.conversationList.dateGroupTitle.yesterday": "Hier", + "xpack.aiAssistant.conversationList.dateGroupTitle.thisWeek": "Cette semaine", + "xpack.aiAssistant.conversationList.dateGroupTitle.lastWeek": "La semaine dernière", + "xpack.aiAssistant.conversationList.dateGroupTitle.thisMonth": "Ce mois-ci", + "xpack.aiAssistant.conversationList.dateGroupTitle.lastMonth": "Le mois dernier", + "xpack.aiAssistant.conversationList.dateGroupTitle.thisYear": "Cette année", + "xpack.aiAssistant.conversationList.dateGroupTitle.older": "Plus ancien", "xpack.aiAssistant.conversationStartTitle": "a démarré une conversation", "xpack.aiAssistant.couldNotFindConversationContent": "Impossible de trouver une conversation avec l'ID {conversationId}. Assurez-vous que la conversation existe et que vous y avez accès.", "xpack.aiAssistant.couldNotFindConversationTitle": "Conversation introuvable", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index d1091d8a558ca..7058c369c5404 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -9648,7 +9648,14 @@ "xpack.aiAssistant.conversationList.deleteConversationIconLabel": "削除", "xpack.aiAssistant.conversationList.errorMessage": "の読み込みに失敗しました", "xpack.aiAssistant.conversationList.noConversations": "会話なし", - "xpack.aiAssistant.conversationList.title": "以前", + "xpack.aiAssistant.conversationList.dateGroupTitle.today": "今日", + "xpack.aiAssistant.conversationList.dateGroupTitle.yesterday": "昨日", + "xpack.aiAssistant.conversationList.dateGroupTitle.thisWeek": "今週", + "xpack.aiAssistant.conversationList.dateGroupTitle.lastWeek": "先週", + "xpack.aiAssistant.conversationList.dateGroupTitle.thisMonth": "今月", + "xpack.aiAssistant.conversationList.dateGroupTitle.lastMonth": "先月", + "xpack.aiAssistant.conversationList.dateGroupTitle.thisYear": "今年", + "xpack.aiAssistant.conversationList.dateGroupTitle.older": "以前", "xpack.aiAssistant.conversationStartTitle": "会話を開始しました", "xpack.aiAssistant.couldNotFindConversationContent": "id {conversationId}の会話が見つかりませんでした。会話が存在し、それにアクセスできることを確認してください。", "xpack.aiAssistant.couldNotFindConversationTitle": "会話が見つかりません", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index b9cee03f0d919..48421acdfe1d9 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -9492,7 +9492,14 @@ "xpack.aiAssistant.conversationList.deleteConversationIconLabel": "删除", "xpack.aiAssistant.conversationList.errorMessage": "无法加载", "xpack.aiAssistant.conversationList.noConversations": "无对话", - "xpack.aiAssistant.conversationList.title": "以前", + "xpack.aiAssistant.conversationList.dateGroupTitle.today": "今天", + "xpack.aiAssistant.conversationList.dateGroupTitle.yesterday": "昨天", + "xpack.aiAssistant.conversationList.dateGroupTitle.thisWeek": "本周", + "xpack.aiAssistant.conversationList.dateGroupTitle.lastWeek": "上周", + "xpack.aiAssistant.conversationList.dateGroupTitle.thisMonth": "本月", + "xpack.aiAssistant.conversationList.dateGroupTitle.lastMonth": "上月", + "xpack.aiAssistant.conversationList.dateGroupTitle.thisYear": "今年", + "xpack.aiAssistant.conversationList.dateGroupTitle.older": "更早", "xpack.aiAssistant.conversationStartTitle": "已开始对话", "xpack.aiAssistant.couldNotFindConversationContent": "找不到 ID 为 {conversationId} 的对话。请确保该对话存在并且您具有访问权限。", "xpack.aiAssistant.couldNotFindConversationTitle": "未找到对话",