Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
85593b3
Add error handling utilities
cotti Nov 4, 2025
3ad6fa6
Show more specific errors when needed
cotti Nov 4, 2025
3b625bf
Merge branch 'main' into feature/soa_error_handling
cotti Nov 4, 2025
69193d5
Improve error display on AskAi, and interrupt streams when rate limit…
cotti Nov 4, 2025
246c2a8
Improve CallOut behavior between tabs
cotti Nov 4, 2025
58180cb
Move custom Callout to its own component
cotti Nov 5, 2025
3d97e1f
Fix state issues
cotti Nov 5, 2025
5852ae9
Fix some more state quirks
cotti Nov 5, 2025
94af4cc
Remove unused parameter
cotti Nov 5, 2025
798595e
Clean ups
cotti Nov 5, 2025
10533b4
More clean ups
cotti Nov 5, 2025
ccbc85f
Cleanup
cotti Nov 5, 2025
53af01b
Merge branch 'refs/heads/main' into feature/soa_error_handling
cotti Nov 5, 2025
0faa9dc
Remove useCallback()
cotti Nov 5, 2025
2cd2b55
Refactor ErrorCallout state management to use modal store. Remove non…
cotti Nov 5, 2025
b40457b
Lint fixes
cotti Nov 5, 2025
9df7113
Reduce duplication in modalStore, lint
cotti Nov 5, 2025
8b8059e
Fix tests
cotti Nov 5, 2025
b06b579
Add test suite for SearchOrAskAiErrorCallout
cotti Nov 5, 2025
5711fdb
Move state into hooks and perform fixes
cotti Nov 6, 2025
4851554
Fix partial change
cotti Nov 6, 2025
ee2a54f
Cooldown should keep decrementing when moving between tabs
cotti Nov 7, 2025
08ac3c0
Disable sending rogue chat requests from search during chat cooldown
cotti Nov 7, 2025
1e024a4
Move ratelimithandler
cotti Nov 7, 2025
bf9564f
Add cancellation
cotti Nov 7, 2025
c59d6d3
Merge branch 'main' into feature/soa_error_handling
cotti Nov 7, 2025
8f152b9
Fix a few state quirks
cotti Nov 7, 2025
617b8cc
Adjust file name
cotti Nov 7, 2025
8cadcf0
Lint
cotti Nov 7, 2025
7582b50
Missed lint
cotti Nov 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import { icon as EuiIconThumbUp } from '@elastic/eui/es/components/icon/assets/t
import { icon as EuiIconTrash } from '@elastic/eui/es/components/icon/assets/trash'
import { icon as EuiIconUser } from '@elastic/eui/es/components/icon/assets/user'
import { icon as EuiIconWrench } from '@elastic/eui/es/components/icon/assets/wrench'
import { icon as EuiIconArrowStart } from '@elastic/eui/es/components/icon/assets/arrowStart'
import { icon as EuiIconArrowEnd } from '@elastic/eui/es/components/icon/assets/arrowEnd'
import { appendIconComponentCache } from '@elastic/eui/es/components/icon/icon'

appendIconComponentCache({
Expand Down Expand Up @@ -57,4 +59,6 @@ appendIconComponentCache({
copy: EuiIconCopy,
play: EuiIconPlay,
sortUp: EuiIconSortUp,
arrowStart: EuiIconArrowStart,
arrowEnd: EuiIconArrowEnd,
})
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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}
</EuiButton>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/** @jsxImportSource @emotion/react */
import { SearchOrAskAiErrorCallout } from '../SearchOrAskAiErrorCallout'
import { useIsCooldownActive } from '../hooks/useIsCooldownActive'
import { AiProviderSelector } from './AiProviderSelector'
import { AskAiSuggestions } from './AskAiSuggestions'
import { ChatMessageList } from './ChatMessageList'
Expand All @@ -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`
Expand Down Expand Up @@ -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
}) => (
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="flexEnd" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty size="xs" onClick={onClick} iconType="refresh">
<EuiButtonEmpty
size="xs"
onClick={onClick}
iconType="refresh"
disabled={disabled}
>
New conversation
</EuiButtonEmpty>
</EuiFlexItem>
Expand All @@ -63,21 +75,56 @@ const NewConversationHeader = ({ onClick }: { onClick: () => void }) => (

export const Chat = () => {
const messages = useChatMessages()
const { submitQuestion, clearChat } = useChatActions()
const { submitQuestion, clearChat, clearNon429Errors } = useChatActions()
const isCooldownActive = useIsCooldownActive()
const inputRef = useRef<HTMLInputElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const lastMessageStatusRef = useRef<string | null>(null)
const abortFunctionRef = useRef<(() => void) | null>(null)
const [inputValue, setInputValue] = useState('')
const [hasClearedError, setHasClearedError] = useState(false)

const dynamicScrollableStyles = css`
${scrollableStyles}
${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 = useCallback((abort: () => void) => {
abortFunctionRef.current = abort
}, [])

// Clear abort function when streaming ends
useEffect(() => {
if (!isStreaming) {
abortFunctionRef.current = null
}
}, [isStreaming])

useEffect(() => {
if (inputValue && !hasClearedError) {
clearNon429Errors()
setHasClearedError(true)
} else if (!inputValue) {
setHasClearedError(false)
}
}, [inputValue, clearNon429Errors, hasClearedError])

const handleSubmit = useCallback(
(question: string) => {
if (!question.trim()) return

// Prevent submission during countdown
if (isCooldownActive) {
return
}

submitQuestion(question.trim())

if (inputRef.current) {
Expand All @@ -88,9 +135,19 @@ export const Chat = () => {
// Scroll to bottom after new message
setTimeout(() => scrollToBottom(scrollRef.current), 100)
},
[submitQuestion]
[submitQuestion, isCooldownActive]
)

const handleButtonClick = useCallback(() => {
if (isStreaming && abortFunctionRef.current) {
// Interrupt current query
abortFunctionRef.current()
abortFunctionRef.current = null
} else if (inputRef.current) {
handleSubmit(inputRef.current.value)
}
}, [isStreaming, handleSubmit])

// Refocus input when AI answer transitions to complete
useEffect(() => {
if (messages.length > 0) {
Expand Down Expand Up @@ -126,43 +183,59 @@ export const Chat = () => {
<EuiSpacer size="m" />

{messages.length > 0 && (
<NewConversationHeader onClick={clearChat} />
<NewConversationHeader
onClick={clearChat}
disabled={isCooldownActive}
/>
)}

<EuiFlexItem grow={true} css={scrollContainerStyles}>
<div ref={scrollRef} css={dynamicScrollableStyles}>
{messages.length === 0 ? (
<EuiEmptyPrompt
iconType="logoElastic"
title={
<h2>Hi! I'm the Elastic Docs AI Assistant</h2>
}
body={
<>
<p>
I can help answer your questions about
Elastic documentation. <br />
Ask me anything about Elasticsearch,
Kibana, Observability, Security, and
more.
</p>
<EuiSpacer size="m" />
<AiProviderSelector />
</>
}
footer={
<>
<EuiTitle size="xxs">
<h3>Try asking me:</h3>
</EuiTitle>
<EuiSpacer size="s" />
<AskAiSuggestions />
</>
}
/>
<>
<EuiEmptyPrompt
iconType="logoElastic"
title={
<h2>
Hi! I'm the Elastic Docs AI Assistant
</h2>
}
body={
<>
<p>
I can help answer your questions
about Elastic documentation. <br />
Ask me anything about Elasticsearch,
Kibana, Observability, Security, and
more.
</p>
<EuiSpacer size="m" />
<AiProviderSelector />
</>
}
footer={
<>
<EuiTitle size="xxs">
<h3>Try asking me:</h3>
</EuiTitle>
<EuiSpacer size="s" />
<AskAiSuggestions
disabled={isCooldownActive}
/>
</>
}
/>
{/* Show error callout when there's a cooldown, even on initial page */}
<div css={messagesStyles}>
<SearchOrAskAiErrorCallout error={null} />
</div>
</>
) : (
<div css={messagesStyles}>
<ChatMessageList messages={messages} />
<ChatMessageList
messages={messages}
onAbortReady={handleAbortReady}
/>
</div>
)}
</div>
Expand All @@ -187,9 +260,12 @@ export const Chat = () => {
handleSubmit(e.currentTarget.value)
}
}}
disabled={isCooldownActive}
/>
<EuiButtonIcon
aria-label="Send message"
aria-label={
isStreaming ? 'Interrupt query' : 'Send message'
}
css={css`
position: absolute;
right: 8px;
Expand All @@ -198,13 +274,12 @@ export const Chat = () => {
border-radius: 9999px;
`}
color="primary"
iconType="sortUp"
display={inputValue.trim() ? 'fill' : 'base'}
onClick={() => {
if (inputRef.current) {
handleSubmit(inputRef.current.value)
}
}}
iconType={isStreaming ? 'cross' : 'sortUp'}
display={
inputValue.trim() || isStreaming ? 'fill' : 'base'
}
onClick={handleButtonClick}
disabled={isCooldownActive}
></EuiButtonIcon>
</div>
<EuiSpacer size="m" />
Expand Down
Loading
Loading