diff --git a/src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts b/src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts index 64ad3d6b4..df60b9ae9 100644 --- a/src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts +++ b/src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts @@ -1,10 +1,13 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck: EUI icons do not have types import { icon as EuiIconVisualizeApp } from '@elastic/eui/es/components/icon/assets/app_visualize' +import { icon as EuiIconArrowEnd } from '@elastic/eui/es/components/icon/assets/arrowEnd' +import { icon as EuiIconArrowStart } from '@elastic/eui/es/components/icon/assets/arrowStart' import { icon as EuiIconArrowDown } from '@elastic/eui/es/components/icon/assets/arrow_down' import { icon as EuiIconArrowLeft } from '@elastic/eui/es/components/icon/assets/arrow_left' import { icon as EuiIconArrowRight } from '@elastic/eui/es/components/icon/assets/arrow_right' import { icon as EuiIconCheck } from '@elastic/eui/es/components/icon/assets/check' +import { icon as EuiIconComment } from '@elastic/eui/es/components/icon/assets/comment' import { icon as EuiIconCopy } from '@elastic/eui/es/components/icon/assets/copy' import { icon as EuiIconCopyClipboard } from '@elastic/eui/es/components/icon/assets/copy_clipboard' import { icon as EuiIconCross } from '@elastic/eui/es/components/icon/assets/cross' @@ -59,4 +62,7 @@ appendIconComponentCache({ copy: EuiIconCopy, play: EuiIconPlay, sortUp: EuiIconSortUp, + arrowStart: EuiIconArrowStart, + arrowEnd: EuiIconArrowEnd, + comment: EuiIconComment, }) diff --git a/src/Elastic.Documentation.Site/Assets/modal.css b/src/Elastic.Documentation.Site/Assets/modal.css index 66af72638..207742cde 100644 --- a/src/Elastic.Documentation.Site/Assets/modal.css +++ b/src/Elastic.Documentation.Site/Assets/modal.css @@ -29,3 +29,35 @@ @apply text-ink-dark hover:text-ink text-2xl font-bold no-underline; } } + +/* Search or Ask AI animation for secondary buttons */ + +@keyframes slideInFromRight { + from { + right: 8px; + opacity: 0; + } + to { + right: 40px; + opacity: 1; + } +} + +@keyframes slideOutToRight { + from { + right: 40px; + opacity: 1; + } + to { + right: 8px; + opacity: 0; + } +} + +.slideInSearchOrAskAiInputAnimation { + animation: slideInFromRight 0.2s ease-out forwards; +} + +.slideOutSearchOrAskAiInputAnimation { + animation: slideOutToRight 0.2s ease-out forwards; +} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/AskAiSuggestions.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/AskAiSuggestions.tsx index cb8084193..66c1e5ec2 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/AskAiSuggestions.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/AskAiSuggestions.tsx @@ -37,7 +37,7 @@ const ALL_SUGGESTIONS: AskAiSuggestion[] = [ }, ] -export const AskAiSuggestions = () => { +export const AskAiSuggestions = ({ disabled }: { disabled?: boolean }) => { const { submitQuestion } = useChatActions() const { setModalMode } = useModalActions() const { euiTheme } = useEuiTheme() @@ -64,9 +64,12 @@ export const AskAiSuggestions = () => { fullWidth size="s" onClick={() => { - submitQuestion(suggestion.question) - setModalMode('askAi') + if (!disabled) { + submitQuestion(suggestion.question) + setModalMode('askAi') + } }} + disabled={disabled} > {suggestion.question} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.test.tsx index 6171b8f26..3210356dd 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.test.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.test.tsx @@ -13,6 +13,7 @@ jest.mock('./chat.store', () => ({ useChatActions: jest.fn(() => ({ submitQuestion: jest.fn(), clearChat: jest.fn(), + clearNon429Errors: jest.fn(), setAiProvider: jest.fn(), })), })) @@ -29,6 +30,44 @@ jest.mock('./AskAiSuggestions', () => ({ ), })) +// Mock AiProviderSelector +jest.mock('./AiProviderSelector', () => ({ + AiProviderSelector: () => ( +
Provider Selector
+ ), +})) + +// Mock modal.store +jest.mock('../modal.store', () => ({ + useModalActions: jest.fn(() => ({ + setModalMode: jest.fn(), + openModal: jest.fn(), + closeModal: jest.fn(), + toggleModal: jest.fn(), + })), +})) + +// Mock cooldown hooks +jest.mock('./useAskAiCooldown', () => ({ + useIsAskAiCooldownActive: jest.fn(() => false), + useAskAiCooldown: jest.fn(() => null), + useAskAiCooldownActions: jest.fn(() => ({ + setCooldown: jest.fn(), + updateCooldown: jest.fn(), + notifyCooldownFinished: jest.fn(), + acknowledgeCooldownFinished: jest.fn(), + })), +})) + +jest.mock('../useCooldown', () => ({ + useCooldown: jest.fn(), +})) + +// Mock SearchOrAskAiErrorCallout +jest.mock('../SearchOrAskAiErrorCallout', () => ({ + SearchOrAskAiErrorCallout: () => null, +})) + const mockUseChatMessages = jest.mocked( jest.requireMock('./chat.store').useChatMessages ) @@ -39,12 +78,14 @@ const mockUseChatActions = jest.mocked( describe('Chat Component', () => { const mockSubmitQuestion = jest.fn() const mockClearChat = jest.fn() + const mockClearNon429Errors = jest.fn() beforeEach(() => { jest.clearAllMocks() mockUseChatActions.mockReturnValue({ submitQuestion: mockSubmitQuestion, clearChat: mockClearChat, + clearNon429Errors: mockClearNon429Errors, }) }) @@ -196,7 +237,9 @@ describe('Chat Component', () => { /Ask Elastic Docs AI Assistant/i ) await user.type(input, question) - await user.click(screen.getByRole('button', { name: /send/i })) + await user.click( + screen.getByRole('button', { name: /send message/i }) + ) // Assert expect(mockSubmitQuestion).toHaveBeenCalledWith(question) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx index 367b0fb3f..1c50ff1fc 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx @@ -1,8 +1,10 @@ /** @jsxImportSource @emotion/react */ +import { SearchOrAskAiErrorCallout } from '../SearchOrAskAiErrorCallout' import { AiProviderSelector } from './AiProviderSelector' import { AskAiSuggestions } from './AskAiSuggestions' import { ChatMessageList } from './ChatMessageList' import { useChatActions, useChatMessages } from './chat.store' +import { useIsAskAiCooldownActive } from './useAskAiCooldown' import { useEuiOverflowScroll, EuiButtonEmpty, @@ -15,7 +17,6 @@ import { EuiTitle, } from '@elastic/eui' import { css } from '@emotion/react' -import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' const containerStyles = css` @@ -48,11 +49,22 @@ const scrollToBottom = (container: HTMLDivElement | null) => { } // Header shown when a conversation exists -const NewConversationHeader = ({ onClick }: { onClick: () => void }) => ( +const NewConversationHeader = ({ + onClick, + disabled, +}: { + onClick: () => void + disabled?: boolean +}) => ( - + New conversation @@ -63,10 +75,13 @@ const NewConversationHeader = ({ onClick }: { onClick: () => void }) => ( export const Chat = () => { const messages = useChatMessages() - const { submitQuestion, clearChat } = useChatActions() + const { submitQuestion, clearChat, clearNon429Errors, cancelStreaming } = + useChatActions() + const isCooldownActive = useIsAskAiCooldownActive() const inputRef = useRef(null) const scrollRef = useRef(null) const lastMessageStatusRef = useRef(null) + const abortFunctionRef = useRef<(() => void) | null>(null) const [inputValue, setInputValue] = useState('') const dynamicScrollableStyles = css` @@ -74,10 +89,36 @@ export const Chat = () => { ${useEuiOverflowScroll('y', true)} ` + // Check if there's an active streaming query + const isStreaming = + messages.length > 0 && + messages[messages.length - 1].type === 'ai' && + messages[messages.length - 1].status === 'streaming' + + // Handle abort function from StreamingAiMessage + const handleAbortReady = (abort: () => void) => { + console.log('[Chat] Abort function ready, storing in ref') + abortFunctionRef.current = abort + } + + // Clear abort function when streaming ends + useEffect(() => { + if (!isStreaming) { + abortFunctionRef.current = null + } + }, [isStreaming]) + const handleSubmit = useCallback( (question: string) => { if (!question.trim()) return + // Prevent submission during countdown + if (isCooldownActive) { + return + } + + clearNon429Errors() + submitQuestion(question.trim()) if (inputRef.current) { @@ -88,9 +129,26 @@ export const Chat = () => { // Scroll to bottom after new message setTimeout(() => scrollToBottom(scrollRef.current), 100) }, - [submitQuestion] + [submitQuestion, isCooldownActive, clearNon429Errors] ) + const handleButtonClick = useCallback(() => { + console.log('[Chat] Button clicked', { + isStreaming, + hasAbortFunction: !!abortFunctionRef.current, + }) + if (isStreaming && abortFunctionRef.current) { + // Interrupt current query + console.log('[Chat] Calling abort function') + abortFunctionRef.current() + abortFunctionRef.current = null + // Update message status from 'streaming' to 'complete' + cancelStreaming() + } else if (inputRef.current) { + handleSubmit(inputRef.current.value) + } + }, [isStreaming, handleSubmit, cancelStreaming]) + // Refocus input when AI answer transitions to complete useEffect(() => { if (messages.length > 0) { @@ -126,43 +184,62 @@ export const Chat = () => { {messages.length > 0 && ( - + )}
{messages.length === 0 ? ( - Hi! I'm the Elastic Docs AI Assistant - } - body={ - <> -

- I can help answer your questions about - Elastic documentation.
- Ask me anything about Elasticsearch, - Kibana, Observability, Security, and - more. -

- - - - } - footer={ - <> - -

Try asking me:

-
- - - - } - /> + <> + + Hi! I'm the Elastic Docs AI Assistant + + } + body={ + <> +

+ I can help answer your questions + about Elastic documentation.
+ Ask me anything about Elasticsearch, + Kibana, Observability, Security, and + more. +

+ + + + } + footer={ + <> + +

Try asking me:

+
+ + + + } + /> + {/* Show error callout when there's a cooldown, even on initial page */} +
+ +
+ ) : (
- +
)}
@@ -187,9 +264,12 @@ export const Chat = () => { handleSubmit(e.currentTarget.value) } }} + disabled={isCooldownActive} /> { border-radius: 9999px; `} color="primary" - iconType="sortUp" - display={inputValue.trim() ? 'fill' : 'base'} - onClick={() => { - if (inputRef.current) { - handleSubmit(inputRef.current.value) - } - }} + iconType={isStreaming ? 'cross' : 'comment'} + display={ + inputValue.trim() || isStreaming ? 'fill' : 'base' + } + onClick={handleButtonClick} + disabled={isCooldownActive} > diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx index 90a79238b..7f4f5327b 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx @@ -1,8 +1,98 @@ +import { ApiError } from '../errorHandling' import { ChatMessage } from './ChatMessage' import { ChatMessage as ChatMessageType } from './chat.store' import { render, screen } from '@testing-library/react' import * as React from 'react' +// Mock EuiCallOut and EuiSpacer for SearchOrAskAiErrorCallout +jest.mock('@elastic/eui', () => { + const actual = jest.requireActual('@elastic/eui') + return { + ...actual, + EuiCallOut: ({ + title, + children, + color, + iconType, + size, + }: { + title: string + children: React.ReactNode + color: string + iconType: string + size: string + }) => ( +
+ {children} +
+ ), + EuiSpacer: ({ size }: { size: string }) => ( +
+ ), + } +}) + +// Mock domain-specific cooldown hooks +jest.mock('../Search/useSearchCooldown', () => ({ + useSearchErrorCalloutState: jest.fn(() => ({ + hasActiveCooldown: false, + countdown: null, + awaitingNewInput: false, + })), +})) + +jest.mock('../AskAi/useAskAiCooldown', () => ({ + useAskAiErrorCalloutState: jest.fn(() => ({ + hasActiveCooldown: false, + countdown: null, + awaitingNewInput: false, + })), +})) + +// Mock errorHandling utilities +jest.mock('../errorHandling', () => { + const actual = jest.requireActual('../errorHandling') + return { + ...actual, + getErrorMessage: jest.fn((error: ApiError | Error | null) => { + if (!error) return 'Unknown error' + if ('statusCode' in error) { + return `Error ${error.statusCode}: ${error.message}` + } + return error.message + }), + isApiError: jest.fn((error: ApiError | Error | null) => { + return ( + error instanceof Error && + 'statusCode' in error && + error.name === 'ApiError' + ) + }), + isRateLimitError: jest.fn((error: ApiError | Error | null) => { + return ( + error instanceof Error && + 'statusCode' in error && + (error as ApiError).statusCode === 429 + ) + }), + } +}) + +// Mock rate limit handlers +jest.mock('./useAskAiRateLimitHandler', () => ({ + useAskAiRateLimitHandler: jest.fn(), +})) + +jest.mock('../Search/useSearchRateLimitHandler', () => ({ + useSearchRateLimitHandler: jest.fn(), +})) + describe('ChatMessage Component', () => { beforeEach(() => { jest.clearAllMocks() @@ -138,6 +228,10 @@ describe('ChatMessage Component', () => { }) describe('AI messages - error', () => { + const testError = new Error('Test error') as ApiError + testError.name = 'ApiError' + testError.statusCode = 500 + const errorMessage: ChatMessageType = { id: '4', type: 'ai', @@ -145,6 +239,7 @@ describe('ChatMessage Component', () => { conversationId: 'thread-1', timestamp: Date.now(), status: 'error', + error: testError, } it('should show error message', () => { @@ -152,14 +247,13 @@ describe('ChatMessage Component', () => { render() // Assert - expect( - screen.getByText(/Sorry, there was an error/i) - ).toBeInTheDocument() - expect( - screen.getByText( - /The Elastic Docs AI Assistant encountered an error/i - ) - ).toBeInTheDocument() + const callout = screen.getByTestId('eui-callout') + expect(callout).toBeInTheDocument() + expect(callout).toHaveAttribute( + 'data-title', + 'Sorry, there was an error' + ) + expect(callout).toHaveTextContent('Test error') }) it('should display previous content before error occurred', () => { @@ -167,7 +261,12 @@ describe('ChatMessage Component', () => { render() // Assert - expect(screen.getByText(/Previous content/i)).toBeInTheDocument() + // When there's an error, the content is hidden, only the error callout is shown + expect(screen.getByTestId('eui-callout')).toBeInTheDocument() + // The content is not rendered when hasError is true + expect( + screen.queryByText(/Previous content/i) + ).not.toBeInTheDocument() }) }) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx index a026415c0..ba2d5af15 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx @@ -1,13 +1,14 @@ import { initCopyButton } from '../../../copybutton' import { hljs } from '../../../hljs' -import { AskAiEvent, EventTypes } from './AskAiEvent' +import { SearchOrAskAiErrorCallout } from '../SearchOrAskAiErrorCallout' +import { ApiError } from '../errorHandling' +import { AskAiEvent, ChunkEvent, EventTypes } from './AskAiEvent' import { GeneratingStatus } from './GeneratingStatus' import { References } from './RelatedResources' import { ChatMessage as ChatMessageType } from './chat.store' import { useStatusMinDisplay } from './useStatusMinDisplay' import { EuiButtonIcon, - EuiCallOut, EuiCopy, EuiFlexGroup, EuiFlexItem, @@ -22,8 +23,7 @@ import { import { css } from '@emotion/react' import DOMPurify from 'dompurify' import { Marked, RendererObject, Tokens } from 'marked' -import * as React from 'react' -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' // Create the marked instance once globally (renderer never changes) const createMarkedInstance = () => { @@ -59,8 +59,10 @@ interface ChatMessageProps { message: ChatMessageType events?: AskAiEvent[] streamingContent?: string - error?: Error | null + error?: ApiError | Error onRetry?: () => void + onCountdownChange?: (countdown: number | null) => void + showError?: boolean } const splitContentAndReferences = ( @@ -90,7 +92,7 @@ const splitContentAndReferences = ( const getMessageState = (message: ChatMessageType) => ({ isUser: message.type === 'user', isLoading: message.status === 'streaming', - isComplete: message.status === 'complete', + isComplete: message.status === 'complete' || message.status === 'error', hasError: message.status === 'error', }) @@ -129,6 +131,11 @@ const computeAiStatus = ( ): string | null => { if (isComplete) return null + // Don't show status if there's an error event + if (events.some((e) => e.type === EventTypes.ERROR)) { + return null + } + // Get events sorted by timestamp (most recent last) const statusEvents = events .filter( @@ -162,7 +169,7 @@ const computeAiStatus = ( case EventTypes.MESSAGE_CHUNK: { const allContent = events .filter((m) => m.type === EventTypes.MESSAGE_CHUNK) - .map((m) => m.content) + .map((m) => (m as ChunkEvent).content) .join('') if (allContent.includes('