diff --git a/aspire/README.md b/aspire/README.md index 621a7c895..cc93eeb3f 100644 --- a/aspire/README.md +++ b/aspire/README.md @@ -87,7 +87,7 @@ Should have these secrets > Parameters:DocumentationElasticApiKey = **** To set them: - + ```bash dotnet user-secrets --project aspire set Parameters:DocumentationElasticApiKey ``` @@ -149,4 +149,3 @@ dotnet test tests-integration/Elastic.Assembler.IntegrationTests --filter "Fully - Network connectivity issues with Elasticsearch - **Performance optimization**: Subsequent test runs against the same Elasticsearch instance are significantly faster because indexing is skipped when data is already up-to-date - The base class `SearchTestBase` can be extended for additional search-related tests, providing consistent initialization and intelligent indexing behavior - diff --git a/src/Elastic.Documentation.Site/.parcelrc b/src/Elastic.Documentation.Site/.parcelrc new file mode 100644 index 000000000..5d62400c3 --- /dev/null +++ b/src/Elastic.Documentation.Site/.parcelrc @@ -0,0 +1,6 @@ +{ + "extends": "@parcel/config-default", + "transformers": { + "*.svg": ["@parcel/transformer-svg-react"] + } +} diff --git a/src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts b/src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts index 4e5a6f474..0228f8126 100644 --- a/src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts +++ b/src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts @@ -19,6 +19,7 @@ import { icon as EuiIconEmpty } from '@elastic/eui/es/components/icon/assets/emp import { icon as EuiIconError } from '@elastic/eui/es/components/icon/assets/error' import { icon as EuiIconFaceHappy } from '@elastic/eui/es/components/icon/assets/face_happy' import { icon as EuiIconFaceSad } from '@elastic/eui/es/components/icon/assets/face_sad' +import { icon as EuiIconKqlFunction } from '@elastic/eui/es/components/icon/assets/kql_function' import { icon as EuiIconLogoElastic } from '@elastic/eui/es/components/icon/assets/logo_elastic' import { icon as EuiIconNewChat } from '@elastic/eui/es/components/icon/assets/new_chat' import { icon as EuiIconPlay } from '@elastic/eui/es/components/icon/assets/play' @@ -29,6 +30,7 @@ import { icon as EuiIconSearch } from '@elastic/eui/es/components/icon/assets/se import { icon as EuiIconSortDown } from '@elastic/eui/es/components/icon/assets/sort_down' import { icon as EuiIconSortUp } from '@elastic/eui/es/components/icon/assets/sort_up' import { icon as EuiIconSparkles } from '@elastic/eui/es/components/icon/assets/sparkles' +import { icon as EuiIconStop } from '@elastic/eui/es/components/icon/assets/stop' import { icon as EuiIconThumbDown } from '@elastic/eui/es/components/icon/assets/thumbDown' import { icon as EuiIconThumbUp } from '@elastic/eui/es/components/icon/assets/thumbUp' import { icon as EuiIconTrash } from '@elastic/eui/es/components/icon/assets/trash' @@ -68,9 +70,11 @@ const iconMapping = { play: EuiIconPlay, sortUp: EuiIconSortUp, sortDown: EuiIconSortDown, + stop: EuiIconStop, arrowStart: EuiIconArrowStart, arrowEnd: EuiIconArrowEnd, comment: EuiIconComment, + kqlFunction: EuiIconKqlFunction, } appendIconComponentCache(iconMapping) diff --git a/src/Elastic.Documentation.Site/Assets/global.d.ts b/src/Elastic.Documentation.Site/Assets/global.d.ts index 5ec0fc014..cbd7d63b3 100644 --- a/src/Elastic.Documentation.Site/Assets/global.d.ts +++ b/src/Elastic.Documentation.Site/Assets/global.d.ts @@ -3,3 +3,9 @@ declare module '@elastic/highlightjs-esql' { const esql: LanguageFn export default esql } + +declare module '*.svg' { + import { ComponentType, SVGProps } from 'react' + const component: ComponentType> + export default component +} 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 66c1e5ec2..58157a01b 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 @@ -59,9 +59,7 @@ export const AskAiSuggestions = ({ disabled }: { disabled?: boolean }) => { `} > { if (!disabled) { 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 3210356dd..57c048b4b 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 @@ -1,99 +1,45 @@ +import { cooldownStore } from '../cooldown.store' +import { modalStore } from '../modal.store' import { Chat } from './Chat' +import { chatStore } from './chat.store' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -// Mock the chat store -jest.mock('./chat.store', () => ({ - chatStore: { - getState: jest.fn(), - }, - useChatMessages: jest.fn(() => []), - useAiProvider: jest.fn(() => 'LlmGateway'), - useChatActions: jest.fn(() => ({ - submitQuestion: jest.fn(), - clearChat: jest.fn(), - clearNon429Errors: jest.fn(), - setAiProvider: jest.fn(), - })), +// Mock only external HTTP calls - fetchEventSource makes the actual API request +jest.mock('@microsoft/fetch-event-source', () => ({ + fetchEventSource: jest.fn(), + EventStreamContentType: 'text/event-stream', })) -// Mock ChatMessageList -jest.mock('./ChatMessageList', () => ({ - ChatMessageList: () =>
Messages
, -})) - -// Mock AskAiSuggestions -jest.mock('./AskAiSuggestions', () => ({ - AskAiSuggestions: () => ( -
Suggestions
- ), -})) - -// 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 -) -const mockUseChatActions = jest.mocked( - jest.requireMock('./chat.store').useChatActions -) +// Helper to reset all stores to initial state +const resetStores = () => { + chatStore.setState({ + chatMessages: [], + conversationId: null, + aiProvider: 'LlmGateway', + scrollPosition: 0, + }) + modalStore.setState({ + isOpen: false, + mode: 'search', + }) + cooldownStore.setState({ + cooldowns: { + search: { cooldown: null, awaitingNewInput: false }, + askAi: { cooldown: null, awaitingNewInput: false }, + }, + }) +} 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, - }) + resetStores() }) describe('Empty state', () => { it('should show empty prompt when no messages', () => { - // Arrange - mockUseChatMessages.mockReturnValue([]) - // Act render() @@ -102,139 +48,152 @@ describe('Chat Component', () => { screen.getByText(/Hi! I'm the Elastic Docs AI Assistant/i) ).toBeInTheDocument() expect( - screen.getByText(/Ask me anything about Elasticsearch/i) + screen.getByText( + /I'm here to help you find answers about Elastic/i + ) ).toBeInTheDocument() }) - it('should show suggestions when no messages', () => { - // Arrange - mockUseChatMessages.mockReturnValue([]) - + it('should show example questions when no messages', () => { // Act render() // Assert - expect(screen.getByText(/Try asking me:/i)).toBeInTheDocument() + expect(screen.getByText(/Example questions/i)).toBeInTheDocument() }) - it('should not show "New conversation" button when no messages', () => { - // Arrange - mockUseChatMessages.mockReturnValue([]) - + it('should not show "Clear conversation" button when no messages', () => { // Act render() // Assert expect( - screen.queryByRole('button', { name: /new conversation/i }) + screen.queryByRole('button', { name: /clear conversation/i }) ).not.toBeInTheDocument() }) }) describe('With messages', () => { - const mockMessages = [ - { - id: '1', - type: 'user' as const, - content: 'What is Elasticsearch?', - conversationId: 'thread-1', - timestamp: Date.now(), - }, - { - id: '2', - type: 'ai' as const, - content: 'Elasticsearch is a search engine...', + const setupMessages = () => { + chatStore.setState({ + chatMessages: [ + { + id: '1', + type: 'user', + content: 'What is Elasticsearch?', + conversationId: 'thread-1', + timestamp: Date.now(), + }, + { + id: '2', + type: 'ai', + content: + 'Elasticsearch is a distributed search engine...', + conversationId: 'thread-1', + timestamp: Date.now(), + status: 'complete', + }, + ], conversationId: 'thread-1', - timestamp: Date.now(), - status: 'complete' as const, - }, - ] + }) + } - it('should show message list when there are messages', () => { + it('should show messages when there are messages', () => { // Arrange - mockUseChatMessages.mockReturnValue(mockMessages) + setupMessages() // Act render() - // Assert - expect(screen.getByTestId('chat-message-list')).toBeInTheDocument() + // Assert - real messages should be rendered + expect( + screen.getByText('What is Elasticsearch?') + ).toBeInTheDocument() + expect( + screen.getByText( + /Elasticsearch is a distributed search engine/i + ) + ).toBeInTheDocument() expect( screen.queryByText(/Hi! I'm the Elastic Docs AI Assistant/i) ).not.toBeInTheDocument() }) - it('should show "New conversation" button when there are messages', () => { + it('should show "Clear conversation" button when there are messages', () => { // Arrange - mockUseChatMessages.mockReturnValue(mockMessages) + setupMessages() // Act render() // Assert expect( - screen.getByRole('button', { name: /new conversation/i }) + screen.getByRole('button', { name: /clear conversation/i }) ).toBeInTheDocument() }) - it('should call clearChat when "New conversation" is clicked', async () => { + it('should clear messages when "Clear conversation" is clicked', async () => { // Arrange - mockUseChatMessages.mockReturnValue(mockMessages) + setupMessages() const user = userEvent.setup() // Act render() await user.click( - screen.getByRole('button', { name: /new conversation/i }) + screen.getByRole('button', { name: /clear conversation/i }) ) - // Assert - expect(mockClearChat).toHaveBeenCalledTimes(1) + // Assert - messages should be cleared + await waitFor(() => { + expect(chatStore.getState().chatMessages).toHaveLength(0) + }) }) }) describe('Input and submission', () => { it('should render input field', () => { - // Arrange - mockUseChatMessages.mockReturnValue([]) - // Act render() // Assert expect( - screen.getByPlaceholderText(/Ask Elastic Docs AI Assistant/i) + screen.getByPlaceholderText( + /Ask the Elastic Docs AI Assistant/i + ) ).toBeInTheDocument() }) - it('should submit question when Enter is pressed', async () => { + it('should add user message to store when question is submitted', async () => { // Arrange - mockUseChatMessages.mockReturnValue([]) const user = userEvent.setup() const question = 'What is Kibana?' // Act render() const input = screen.getByPlaceholderText( - /Ask Elastic Docs AI Assistant/i + /Ask the Elastic Docs AI Assistant/i ) await user.type(input, question) await user.keyboard('{Enter}') - // Assert - expect(mockSubmitQuestion).toHaveBeenCalledWith(question) + // Assert - user message should be in the store + await waitFor(() => { + const messages = chatStore.getState().chatMessages + expect(messages.length).toBeGreaterThanOrEqual(1) + expect(messages[0].type).toBe('user') + expect(messages[0].content).toBe(question) + }) }) - it('should submit question when Send button is clicked', async () => { + it('should add user message when Send button is clicked', async () => { // Arrange - mockUseChatMessages.mockReturnValue([]) const user = userEvent.setup() const question = 'What is Kibana?' // Act render() const input = screen.getByPlaceholderText( - /Ask Elastic Docs AI Assistant/i + /Ask the Elastic Docs AI Assistant/i ) await user.type(input, question) await user.click( @@ -242,36 +201,37 @@ describe('Chat Component', () => { ) // Assert - expect(mockSubmitQuestion).toHaveBeenCalledWith(question) + await waitFor(() => { + const messages = chatStore.getState().chatMessages + expect(messages[0].content).toBe(question) + }) }) it('should not submit empty question', async () => { // Arrange - mockUseChatMessages.mockReturnValue([]) const user = userEvent.setup() // Act render() const input = screen.getByPlaceholderText( - /Ask Elastic Docs AI Assistant/i + /Ask the Elastic Docs AI Assistant/i ) await user.type(input, ' ') await user.keyboard('{Enter}') - // Assert - expect(mockSubmitQuestion).not.toHaveBeenCalled() + // Assert - no messages should be added + expect(chatStore.getState().chatMessages).toHaveLength(0) }) it('should clear input after submission', async () => { // Arrange - mockUseChatMessages.mockReturnValue([]) const user = userEvent.setup() // Act render() const input = screen.getByPlaceholderText( - /Ask Elastic Docs AI Assistant/i - ) as HTMLInputElement + /Ask the Elastic Docs AI Assistant/i + ) as HTMLTextAreaElement await user.type(input, 'test question') await user.keyboard('{Enter}') @@ -282,45 +242,20 @@ describe('Chat Component', () => { }) }) - describe('Auto-focus', () => { - it('should focus input when AI completes response', () => { + describe('Close modal', () => { + it('should close modal when close button is clicked', async () => { // Arrange - const streamingMessages = [ - { - id: '1', - type: 'user' as const, - content: 'Question', - conversationId: 'thread-1', - timestamp: Date.now(), - }, - { - id: '2', - type: 'ai' as const, - content: 'Answer...', - conversationId: 'thread-1', - timestamp: Date.now(), - status: 'streaming' as const, - }, - ] - - mockUseChatMessages.mockReturnValue(streamingMessages) - const { rerender } = render() - - // Act - simulate AI completing the response - const completeMessages = [ - ...streamingMessages.slice(0, 1), - { ...streamingMessages[1], status: 'complete' as const }, - ] - mockUseChatMessages.mockReturnValue(completeMessages) - rerender() + modalStore.setState({ isOpen: true }) + const user = userEvent.setup() - // Assert - const input = screen.getByPlaceholderText( - /Ask Elastic Docs AI Assistant/i + // Act + render() + await user.click( + screen.getByRole('button', { name: /close ask ai modal/i }) ) - // In a real test environment, we'd check if focus() was called - // This is a limitation of jsdom - expect(input).toBeInTheDocument() + + // Assert + expect(modalStore.getState().isOpen).toBe(false) }) }) }) 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 b8f306c0b..45ebb7559 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,283 +1,391 @@ -/** @jsxImportSource @emotion/react */ +import { InfoBanner } from '../InfoBanner' +import { KeyboardShortcutsFooter } from '../KeyboardShortcutsFooter' import { SearchOrAskAiErrorCallout } from '../SearchOrAskAiErrorCallout' -import { AiProviderSelector } from './AiProviderSelector' +import AiIcon from '../ai-icon.svg' +import { useModalActions } from '../modal.store' import { AskAiSuggestions } from './AskAiSuggestions' +import { ChatInput } from './ChatInput' import { ChatMessageList } from './ChatMessageList' -import { useChatActions, useChatMessages } from './chat.store' +import { + ChatMessage, + useChatActions, + useChatMessages, + useChatScrollPosition, + useIsStreaming, +} from './chat.store' import { useIsAskAiCooldownActive } from './useAskAiCooldown' import { - useEuiOverflowScroll, - EuiButtonEmpty, EuiButtonIcon, - EuiFieldText, + EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, - EuiEmptyPrompt, + EuiHorizontalRule, + EuiIcon, EuiSpacer, - EuiTitle, + EuiText, + EuiToolTip, + useEuiFontSize, + useEuiOverflowScroll, useEuiTheme, } from '@elastic/eui' import { css } from '@emotion/react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { RefObject, useCallback, useEffect, useRef, useState } from 'react' -const containerStyles = css` - height: 100%; - max-height: 70vh; - overflow: hidden; -` +export const Chat = () => { + const messages = useChatMessages() + const inputRef = useRef(null) + const scrollRef = useRef(null) -const scrollContainerStyles = css` - position: relative; - overflow: hidden; -` + const handleScroll = useScrollPersistence(scrollRef) + useFocusOnComplete(inputRef) -const scrollableStyles = css` - height: 100%; - overflow-y: auto; - scrollbar-gutter: stable; - padding: 1rem; -` + const { + inputValue, + setInputValue, + handleSubmit, + handleAbort, + handleAbortReady, + isStreaming, + isCooldownActive, + } = useChatSubmit(scrollRef) -const messagesStyles = css` - max-width: 800px; - margin: 0 auto; -` + return ( + + -const scrollToBottom = (container: HTMLDivElement | null) => { - if (!container) return - container.scrollTop = container.scrollHeight + + + + + + + + ) } -// Header shown when a conversation exists -const NewConversationHeader = ({ - onClick, - disabled, -}: { - onClick: () => void - disabled?: boolean -}) => ( - - - - { + const { closeModal } = useModalActions() + const { clearChat } = useChatActions() + const messages = useChatMessages() + const { euiTheme } = useEuiTheme() + const smallFontsize = useEuiFontSize('s').fontSize + return ( + <> +
+
- New conversation - - - - - -) + + + Elastic Docs AI Assistant + +
+
+ {messages.length > 0 && ( + + clearChat()} + /> + + )} + closeModal()} + /> +
+
+ + + ) +} -export const Chat = () => { +interface ChatScrollAreaProps { + scrollRef: RefObject + onScroll: () => void + messages: ChatMessage[] + isCooldownActive: boolean + onAbortReady: (abort: () => void) => void +} + +const ChatScrollArea = ({ + scrollRef, + onScroll, + messages, + isCooldownActive, + onAbortReady, +}: ChatScrollAreaProps) => { const { euiTheme } = useEuiTheme() - const messages = useChatMessages() - 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` - ${scrollableStyles} + const scrollableStyles = css` + height: 100%; + overflow-y: auto; + scrollbar-gutter: stable; + padding: ${euiTheme.size.l}; ${useEuiOverflowScroll('y', true)} ` - const isStreaming = - messages.length > 0 && - messages[messages.length - 1].type === 'ai' && - messages[messages.length - 1].status === 'streaming' + return ( + +
+ {messages.length === 0 ? ( + + ) : ( +
+ +
+ )} +
+
+ ) +} - const handleAbortReady = useCallback((abort: () => void) => { - abortFunctionRef.current = abort - }, []) +const ChatEmptyState = ({ disabled }: { disabled: boolean }) => ( + <> + } + title={

Hi! I'm the Elastic Docs AI Assistant

} + body={ +

+ I'm here to help you find answers about Elastic, powered + entirely by our technical documentation. How can I help? +

+ } + /> + +
+ + Example questions + + + +
+
+ +
+ +) + +interface ChatInputAreaProps { + inputRef: RefObject + value: string + onChange: (value: string) => void + onSubmit: (question: string) => void + onAbort: () => void + disabled: boolean + isStreaming: boolean +} + +const ChatInputArea = ({ + inputRef, + value, + onChange, + onSubmit, + onAbort, + disabled, + isStreaming, +}: ChatInputAreaProps) => { + const { euiTheme } = useEuiTheme() + + return ( + + +
+ +
+
+ ) +} + +function useChatSubmit(scrollRef: RefObject) { + const { submitQuestion, clearNon429Errors, cancelStreaming } = + useChatActions() + const isCooldownActive = useIsAskAiCooldownActive() + const isStreaming = useIsStreaming() + + const [inputValue, setInputValue] = useState('') + const abortRef = useRef<(() => void) | null>(null) useEffect(() => { if (!isStreaming) { - abortFunctionRef.current = null + abortRef.current = null } }, [isStreaming]) const handleSubmit = useCallback( (question: string) => { - const trimmedQuestion = question.trim() - if (!trimmedQuestion || isCooldownActive) { - return - } + const trimmed = question.trim() + if (!trimmed || isCooldownActive) return clearNon429Errors() - submitQuestion(trimmedQuestion) - - if (inputRef.current) { - inputRef.current.value = '' - } + submitQuestion(trimmed) setInputValue('') - // Scroll to bottom after new message - setTimeout(() => scrollToBottom(scrollRef.current), 100) + setTimeout(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, 100) }, - [submitQuestion, isCooldownActive, clearNon429Errors] + [submitQuestion, isCooldownActive, clearNon429Errors, scrollRef] ) - const handleButtonClick = useCallback(() => { - if (isStreaming && abortFunctionRef.current) { - // Interrupt current query - abortFunctionRef.current() - abortFunctionRef.current = null - // Update message status from 'streaming' to 'complete' + const handleAbort = useCallback(() => { + if (abortRef.current) { + abortRef.current() + abortRef.current = null cancelStreaming() - } else if (inputRef.current) { - handleSubmit(inputRef.current.value) } - }, [isStreaming, handleSubmit, cancelStreaming]) + }, [cancelStreaming]) + + const handleAbortReady = useCallback((abort: () => void) => { + abortRef.current = abort + }, []) + + return { + inputValue, + setInputValue, + handleSubmit, + handleAbort, + handleAbortReady, + isStreaming, + isCooldownActive, + } +} + +/** + * Manages scroll position persistence across modal open/close. + */ +function useScrollPersistence(scrollRef: RefObject) { + const savedPosition = useChatScrollPosition() + const { setScrollPosition } = useChatActions() - // Refocus input when AI answer transitions to complete useEffect(() => { - if (messages.length > 0) { - const lastMessage = messages[messages.length - 1] - - // Track status transitions for AI messages - if (lastMessage.type === 'ai') { - const currentStatus = lastMessage.status - const previousStatus = lastMessageStatusRef.current - - // If status changed from streaming to complete, focus input - if ( - previousStatus === 'streaming' && - currentStatus === 'complete' - ) { - setTimeout(() => { - inputRef.current?.focus() - }, 100) + if (scrollRef.current && savedPosition > 0) { + requestAnimationFrame(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = savedPosition } + }) + } + }, []) - // Update the tracked status - lastMessageStatusRef.current = currentStatus || null - } + const handleScroll = useCallback(() => { + if (scrollRef.current) { + setScrollPosition(scrollRef.current.scrollTop) } - }, [messages]) + }, [setScrollPosition, scrollRef]) - return ( - - + return handleScroll +} - {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:

-
- - - - } - /> - {/* Show error callout when there's a cooldown, even on initial page */} -
- -
- - ) : ( -
- -
- )} -
-
+/** + * Auto-focuses input when AI response completes streaming. + */ +function useFocusOnComplete(inputRef: RefObject) { + const messages = useChatMessages() + const lastStatusRef = useRef(null) - {/* Input */} - - -
- setInputValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleSubmit(e.currentTarget.value) - } - }} - disabled={isCooldownActive} - /> - -
- -
-
- ) + useEffect(() => { + const last = messages[messages.length - 1] + if (last?.type === 'ai') { + const currentStatus = last.status + const previousStatus = lastStatusRef.current + + if ( + previousStatus === 'streaming' && + currentStatus === 'complete' + ) { + setTimeout(() => inputRef.current?.focus(), 100) + } + + lastStatusRef.current = currentStatus || null + } + }, [messages, inputRef]) } + +// ============================================================================ +// Constants & Styles (implementation details) +// ============================================================================ + +const KEYBOARD_SHORTCUTS = [ + { keys: ['⌘K'], label: 'to search' }, + { keys: ['Esc'], label: 'to close' }, +] + +const containerStyles = css` + height: 100%; + max-height: 70vh; + overflow: hidden; +` + +const scrollContainerStyles = css` + position: relative; + overflow: hidden; +` + +const messagesStyles = css` + max-width: 800px; + margin: 0 auto; +` diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatInput.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatInput.tsx new file mode 100644 index 000000000..75be4ce75 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatInput.tsx @@ -0,0 +1,170 @@ +import { ElasticAiAssistantButtonIcon } from '../ElasticAiAssitant' +import { euiShadow, useEuiScrollBar, useEuiTheme } from '@elastic/eui' +import { css } from '@emotion/react' +import { useCallback, useEffect, useRef } from 'react' + +const CONFIG = { + MAX_LINES: 10, + MIN_HEIGHT: 48, + BORDER_RADIUS: 24, + BUTTON_SIZE: 32, +} as const + +interface ChatInputProps { + value: string + onChange: (value: string) => void + onSubmit: (value: string) => void + onAbort?: () => void + disabled?: boolean + placeholder?: string + inputRef?: React.MutableRefObject + isStreaming?: boolean +} + +export const ChatInput = ({ + value, + onChange, + onSubmit, + onAbort, + disabled = false, + placeholder = 'Ask the Elastic Docs AI Assistant', + inputRef, + isStreaming = false, +}: ChatInputProps) => { + const { euiTheme } = useEuiTheme() + const scollbarStyling = useEuiScrollBar() + const shadowStyling = euiShadow(useEuiTheme(), 's') + const internalRef = useRef(null) + + const textareaRefCallback = useCallback( + (element: HTMLTextAreaElement | null) => { + internalRef.current = element + if (inputRef) { + inputRef.current = element + } + }, + [inputRef] + ) + + // Adjust height based on content + useEffect(() => { + const textarea = internalRef.current + if (!textarea) return + + // Calculate max height from computed styles + const computedStyle = window.getComputedStyle(textarea) + const lineHeight = parseFloat(computedStyle.lineHeight) || 20 + const verticalPadding = + parseFloat(computedStyle.paddingTop) + + parseFloat(computedStyle.paddingBottom) + const maxHeight = lineHeight * CONFIG.MAX_LINES + verticalPadding + + if (!value.trim()) { + textarea.style.height = `${CONFIG.MIN_HEIGHT}px` + return + } + + textarea.style.height = 'auto' + textarea.style.height = `${Math.min( + Math.max(textarea.scrollHeight, CONFIG.MIN_HEIGHT), + maxHeight + )}px` + }, [value]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + if (!isStreaming) { + onSubmit(value) + } + } + } + + const hasContent = value.trim().length > 0 + + return ( +
+