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('