From 82436d0e8332e46b7903574ae705734d6f22d857 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Thu, 12 Feb 2026 11:23:55 +0800 Subject: [PATCH 1/2] feat: update chat-header look & feel Signed-off-by: SuZhou-Joe --- .../chat/public/components/chat_header.scss | 30 +- .../chat/public/components/chat_header.tsx | 56 ++-- .../components/chat_header_button.test.tsx | 1 - .../public/components/chat_header_button.tsx | 48 +--- .../chat/public/components/chat_window.tsx | 28 +- .../chat_window_conversation_name.test.tsx | 269 ++++++++++++++++++ 6 files changed, 355 insertions(+), 77 deletions(-) create mode 100644 src/plugins/chat/public/components/chat_window_conversation_name.test.tsx diff --git a/src/plugins/chat/public/components/chat_header.scss b/src/plugins/chat/public/components/chat_header.scss index 22e530094fb4..0d1f1a97de66 100644 --- a/src/plugins/chat/public/components/chat_header.scss +++ b/src/plugins/chat/public/components/chat_header.scss @@ -4,12 +4,38 @@ justify-content: space-between; align-items: center; padding: 8px 0; - border-bottom: 1px solid $euiColorLightShade; + gap: 16px; + overflow-x: hidden; + + &__titleGroup { + gap: 8px; + flex: 1; + min-width: 0; + overflow: hidden; + } + + &__titleContainer { + min-width: 0; + overflow: hidden; + } + + &__chatIcon { + color: $euiColorDarkShade; + } + + &__title { + margin: 0; + font-weight: 600; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } &__buttons { display: flex; - gap: 8px; + gap: 4px; align-items: center; + flex-shrink: 0; } &__experimentalBadge { diff --git a/src/plugins/chat/public/components/chat_header.tsx b/src/plugins/chat/public/components/chat_header.tsx index fe09baa331e4..366f596215bd 100644 --- a/src/plugins/chat/public/components/chat_header.tsx +++ b/src/plugins/chat/public/components/chat_header.tsx @@ -4,61 +4,57 @@ */ import React from 'react'; -import { EuiText, EuiButtonIcon, EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { ChatLayoutMode } from './chat_header_button'; +import { EuiText, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; import './chat_header.scss'; interface ChatHeaderProps { - layoutMode: ChatLayoutMode; + conversationName?: string; isStreaming: boolean; - onToggleLayout?: () => void; onNewChat: () => void; onClose: () => void; } export const ChatHeader: React.FC = ({ - layoutMode, + conversationName = '', isStreaming, - onToggleLayout, onNewChat, onClose, }) => { return (
- + - -

Ask AI

-
-
- - - Experimental - + + {conversationName && ( + + +

{conversationName}

+
+
+ )}
- {onToggleLayout && ( - - )} + -
); diff --git a/src/plugins/chat/public/components/chat_header_button.test.tsx b/src/plugins/chat/public/components/chat_header_button.test.tsx index f9dc87957609..dacd9d5cca80 100644 --- a/src/plugins/chat/public/components/chat_header_button.test.tsx +++ b/src/plugins/chat/public/components/chat_header_button.test.tsx @@ -147,7 +147,6 @@ describe('ChatHeaderButton', () => { ); expect(mockChatService.isWindowOpen).toHaveBeenCalled(); - expect(mockChatService.getWindowMode).toHaveBeenCalled(); }); it('should initialize with window open state from ChatService', () => { diff --git a/src/plugins/chat/public/components/chat_header_button.tsx b/src/plugins/chat/public/components/chat_header_button.tsx index 7d789f7bd02d..1140c650bf3f 100644 --- a/src/plugins/chat/public/components/chat_header_button.tsx +++ b/src/plugins/chat/public/components/chat_header_button.tsx @@ -47,7 +47,6 @@ export const ChatHeaderButton = React.forwardRef { // Use ChatService as source of truth for window state const [isOpen, setIsOpen] = useState(chatService.isWindowOpen()); - const [layoutMode, setLayoutMode] = useState(chatService.getWindowMode()); const sideCarRef = useRef<{ close: () => void }>(); const chatWindowRef = useRef(null); const flyoutMountPoint = useRef(null); @@ -71,12 +70,9 @@ export const ChatHeaderButton = React.forwardRef { if (sideCarRef.current) { @@ -106,28 +102,6 @@ export const ChatHeaderButton = React.forwardRef { - const newLayoutMode = - layoutMode === ChatLayoutMode.SIDECAR ? ChatLayoutMode.FULLSCREEN : ChatLayoutMode.SIDECAR; - - setLayoutMode(newLayoutMode); - - // Update sidecar config dynamically if currently open - if (isOpen && sideCarRef.current) { - core.overlays.sidecar.setSidecarConfig({ - dockedMode: - newLayoutMode === ChatLayoutMode.FULLSCREEN - ? SIDECAR_DOCKED_MODE.TAKEOVER - : SIDECAR_DOCKED_MODE.RIGHT, - paddingSize: newLayoutMode === ChatLayoutMode.FULLSCREEN ? window.innerHeight - 50 : 400, - isHidden: false, - }); - } - - // Update ChatService with new layout mode - chatService.setWindowState({ windowMode: newLayoutMode }); - }, [layoutMode, isOpen, chatService, core.overlays.sidecar]); - const startNewConversation = useCallback( async ({ content }) => { openSidecar(); @@ -141,16 +115,11 @@ export const ChatHeaderButton = React.forwardRef { - const unsubscribe = chatService.onWindowStateChange( - ({ isWindowOpen, windowMode }, changed) => { - if (changed.isWindowOpen) { - setIsOpen(isWindowOpen); - } - if (changed.windowMode) { - setLayoutMode(windowMode as ChatLayoutMode); - } + const unsubscribe = chatService.onWindowStateChange(({ isWindowOpen }, changed) => { + if (changed.isWindowOpen) { + setIsOpen(isWindowOpen); } - ); + }); return () => { unsubscribe(); }; @@ -244,8 +213,7 @@ export const ChatHeaderButton = React.forwardRef diff --git a/src/plugins/chat/public/components/chat_window.tsx b/src/plugins/chat/public/components/chat_window.tsx index b9fd7fec4878..502585f38f8a 100644 --- a/src/plugins/chat/public/components/chat_window.tsx +++ b/src/plugins/chat/public/components/chat_window.tsx @@ -34,7 +34,6 @@ export interface ChatWindowInstance { interface ChatWindowProps { layoutMode?: ChatLayoutMode; - onToggleLayout?: () => void; onClose: ()=>void; } @@ -47,7 +46,6 @@ export const ChatWindow = React.forwardRef( const ChatWindowContent = React.forwardRef(({ layoutMode = ChatLayoutMode.SIDECAR, - onToggleLayout, onClose, }, ref) => { @@ -356,6 +354,29 @@ const ChatWindowContent = React.forwardRef( getActionRenderer: service.getActionRenderer, }; + // Get conversation name from first user message with text content + const conversationName = useMemo(() => { + // Find first user message that has text content + for (const msg of timeline) { + if (msg.role !== 'user') continue; + + // Handle string content + if (typeof msg.content === 'string' && msg.content.trim()) { + return msg.content; + } + + // Handle array content - look for text content + if (Array.isArray(msg.content)) { + const textContent = msg.content.find((item) => item.type === 'text'); + if (textContent?.text && textContent.text.trim()) { + return textContent.text; + } + } + } + + return ''; + }, [timeline]); + useImperativeHandle(ref, ()=>({ startNewChat: ()=>handleNewChat(), sendMessage: async ({content, messages})=>(await handleSendRef.current?.({input:content, messages})) @@ -364,9 +385,8 @@ const ChatWindowContent = React.forwardRef( return ( diff --git a/src/plugins/chat/public/components/chat_window_conversation_name.test.tsx b/src/plugins/chat/public/components/chat_window_conversation_name.test.tsx new file mode 100644 index 000000000000..fa311ee35533 --- /dev/null +++ b/src/plugins/chat/public/components/chat_window_conversation_name.test.tsx @@ -0,0 +1,269 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { ChatWindow } from './chat_window'; +import { coreMock } from '../../../../core/public/mocks'; +import { of } from 'rxjs'; +import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; +import { ChatProvider } from '../contexts/chat_context'; +import { ChatService } from '../services/chat_service'; +import { SuggestedActionsService } from '../services/suggested_action'; +import { ConfirmationService } from '../services/confirmation_service'; + +// Create mock observable before using it in mocks +const mockObservable = of({ toolDefinitions: [], toolCallStates: {} }); + +// Mock dependencies +jest.mock('../../../context_provider/public', () => ({ + AssistantActionService: { + getInstance: jest.fn(() => ({ + getState$: jest.fn(() => mockObservable), + getCurrentState: jest.fn(() => ({ toolDefinitions: [], toolCallStates: {} })), + getActionRenderer: jest.fn(), + })), + }, +})); + +jest.mock('../services/chat_event_handler', () => ({ + ChatEventHandler: jest.fn().mockImplementation(() => ({ + handleEvent: jest.fn(), + clearState: jest.fn(), + })), +})); + +jest.mock('../actions/graph_timeseries_data_action', () => ({ + useGraphTimeseriesDataAction: jest.fn(), +})); + +describe('ChatWindow - Conversation Name', () => { + let mockCore: ReturnType; + let mockContextProvider: any; + let mockChatService: jest.Mocked; + let mockSuggestedActionsService: jest.Mocked; + let mockConfirmationService: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + mockCore = coreMock.createStart(); + mockContextProvider = {}; + mockChatService = { + sendMessage: jest.fn().mockResolvedValue({ + observable: of({ type: 'message', content: 'test' }), + userMessage: { id: '1', content: 'test', role: 'user' }, + }), + newThread: jest.fn(), + getCurrentMessages: jest.fn().mockReturnValue([]), + updateCurrentMessages: jest.fn(), + getThreadId: jest.fn().mockReturnValue('mock-thread-id'), + } as any; + mockSuggestedActionsService = {} as any; + mockConfirmationService = { + getPendingConfirmations$: jest.fn().mockReturnValue(of([])), + requestConfirmation: jest.fn(), + approve: jest.fn(), + reject: jest.fn(), + cancel: jest.fn(), + } as any; + }); + + const renderWithContext = (component: React.ReactElement) => { + return render( + + + {component} + + + ); + }; + + describe('conversation name extraction', () => { + it('should not display conversation name when timeline is empty', () => { + mockChatService.getCurrentMessages.mockReturnValue([]); + + const { container } = renderWithContext(); + + // ChatHeader should not have the title text when there's no message + const header = container.querySelector('.chatHeader'); + expect(header).toBeInTheDocument(); + + // Title should not be rendered when conversationName is empty + const titleElement = container.querySelector('.chatHeader__title'); + expect(titleElement).not.toBeInTheDocument(); + }); + + it('should extract conversation name from first user message with string content', () => { + const messages = [ + { id: '1', role: 'user' as const, content: 'How can I find the largest index?' }, + { id: '2', role: 'assistant' as const, content: 'You can use...' }, + ]; + mockChatService.getCurrentMessages.mockReturnValue(messages); + + const { container } = renderWithContext(); + + const titleElement = container.querySelector('.chatHeader__title'); + expect(titleElement).toBeInTheDocument(); + expect(titleElement?.textContent).toBe('How can I find the largest index?'); + }); + + it('should extract conversation name from first user message with array content', () => { + const messages = [ + { + id: '1', + role: 'user' as const, + content: [ + { type: 'text' as const, text: 'What is the weather today?' }, + { type: 'binary' as const, mimeType: 'image/png', url: 'example.com/image.png' }, + ], + }, + { id: '2', role: 'assistant' as const, content: 'The weather is...' }, + ]; + mockChatService.getCurrentMessages.mockReturnValue(messages); + + const { container } = renderWithContext(); + + const titleElement = container.querySelector('.chatHeader__title'); + expect(titleElement).toBeInTheDocument(); + expect(titleElement?.textContent).toBe('What is the weather today?'); + }); + + it('should find first user message with text when earlier messages have no text', () => { + const messages = [ + { + id: '1', + role: 'user' as const, + content: [ + { type: 'binary' as const, mimeType: 'image/png', url: 'example.com/image.png' }, + ], + }, + { id: '2', role: 'assistant' as const, content: 'I see an image...' }, + { id: '3', role: 'user' as const, content: 'Can you describe this image?' }, + { id: '4', role: 'assistant' as const, content: 'Sure...' }, + ]; + mockChatService.getCurrentMessages.mockReturnValue(messages); + + const { container } = renderWithContext(); + + // Should use the first user message with text content + const titleElement = container.querySelector('.chatHeader__title'); + expect(titleElement).toBeInTheDocument(); + expect(titleElement?.textContent).toBe('Can you describe this image?'); + }); + + it('should return empty string when no user message has text content', () => { + const messages = [ + { + id: '1', + role: 'user' as const, + content: [ + { type: 'binary' as const, mimeType: 'image/png', url: 'example.com/image.png' }, + ], + }, + { id: '2', role: 'assistant' as const, content: 'I see an image...' }, + ]; + mockChatService.getCurrentMessages.mockReturnValue(messages); + + const { container } = renderWithContext(); + + // Title should not be rendered when conversationName is empty + const titleElement = container.querySelector('.chatHeader__title'); + expect(titleElement).not.toBeInTheDocument(); + }); + + it('should skip assistant messages and find first user message', () => { + const messages = [ + { id: '1', role: 'assistant' as const, content: 'Hello! How can I help?' }, + { id: '2', role: 'user' as const, content: 'Tell me about TypeScript' }, + { id: '3', role: 'assistant' as const, content: 'TypeScript is...' }, + ]; + mockChatService.getCurrentMessages.mockReturnValue(messages); + + const { container } = renderWithContext(); + + const titleElement = container.querySelector('.chatHeader__title'); + expect(titleElement).toBeInTheDocument(); + expect(titleElement?.textContent).toBe('Tell me about TypeScript'); + }); + + it('should skip whitespace-only messages and find first message with text', () => { + const messages = [ + { id: '1', role: 'user' as const, content: ' ' }, + { id: '2', role: 'assistant' as const, content: 'I need more information' }, + { id: '3', role: 'user' as const, content: 'How do I debug my code?' }, + { id: '4', role: 'assistant' as const, content: 'You can use...' }, + ]; + mockChatService.getCurrentMessages.mockReturnValue(messages); + + const { container } = renderWithContext(); + + const titleElement = container.querySelector('.chatHeader__title'); + expect(titleElement).toBeInTheDocument(); + expect(titleElement?.textContent).toBe('How do I debug my code?'); + }); + + it('should handle long conversation name with CSS truncation', () => { + const longMessage = + 'This is a very long message that should be truncated by CSS ellipsis when it exceeds the available width in the header'; + const messages = [ + { id: '1', role: 'user' as const, content: longMessage }, + { id: '2', role: 'assistant' as const, content: 'Response' }, + ]; + mockChatService.getCurrentMessages.mockReturnValue(messages); + + const { container } = renderWithContext(); + + const titleElement = container.querySelector('.chatHeader__title'); + expect(titleElement).toBeInTheDocument(); + // Full text should be present (CSS handles truncation) + expect(titleElement?.textContent).toBe(longMessage); + + // Verify the title has the class that applies CSS truncation styles + expect(titleElement).toHaveClass('chatHeader__title'); + }); + + it('should update conversation name when new messages are added', () => { + // Start with empty timeline + mockChatService.getCurrentMessages.mockReturnValue([]); + + const { container, rerender } = renderWithContext(); + + // Initially no title + let titleElement = container.querySelector('.chatHeader__title'); + expect(titleElement).not.toBeInTheDocument(); + + // Simulate adding a message + const messagesWithUser = [ + { id: '1', role: 'user' as const, content: 'New conversation started' }, + ]; + mockChatService.getCurrentMessages.mockReturnValue(messagesWithUser); + + rerender( + + + + + + ); + + // Now title should be present + titleElement = container.querySelector('.chatHeader__title'); + expect(titleElement).toBeInTheDocument(); + expect(titleElement?.textContent).toBe('New conversation started'); + }); + }); +}); From 609e232ba29e27ee9995e86a70b3b21b1932b2a0 Mon Sep 17 00:00:00 2001 From: "opensearch-changeset-bot[bot]" <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 03:25:34 +0000 Subject: [PATCH 2/2] Changeset file for PR #11330 created/updated --- changelogs/fragments/11330.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelogs/fragments/11330.yml diff --git a/changelogs/fragments/11330.yml b/changelogs/fragments/11330.yml new file mode 100644 index 000000000000..ab951cede724 --- /dev/null +++ b/changelogs/fragments/11330.yml @@ -0,0 +1,2 @@ +feat: +- Update chat-header look & feel ([#11330](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/11330)) \ No newline at end of file