= React.memo(({ chart, id, i
return (
Rendering Mermaid diagram...
@@ -207,8 +216,8 @@ const MermaidDiagram: React.FC = React.memo(({ chart, id, i
ref={containerRef}
style={{
padding: '12px',
- backgroundColor: '#1a1a1a',
- border: '1px solid #333',
+ backgroundColor: isDarkMode ? '#1a1a1a' : '#ffffff',
+ border: isDarkMode ? '1px solid #333' : '1px solid #ddd',
borderRadius: '4px',
overflow: 'auto'
}}
diff --git a/lib/user-interface/react/src/components/chatbot/components/Message.module.css b/lib/user-interface/react/src/components/chatbot/components/Message.module.css
new file mode 100644
index 000000000..a8bd3d916
--- /dev/null
+++ b/lib/user-interface/react/src/components/chatbot/components/Message.module.css
@@ -0,0 +1,59 @@
+/* Scoped styles for Message component markdown content */
+
+/* First child margin reset */
+.messageContent div :first-child {
+ margin-top: 0;
+}
+
+/* List styling */
+.messageContent ol {
+ list-style-type: decimal;
+ padding-left: 1.5rem;
+ margin-bottom: 1rem;
+}
+
+.messageContent ul {
+ list-style-type: disc;
+ padding-left: 1.5rem;
+ margin-bottom: 1rem;
+}
+
+.messageContent li {
+ margin-bottom: 0.5rem;
+ line-height: 1.5;
+}
+
+/* Paragraph styling */
+.messageContent p {
+ margin-bottom: 1rem;
+ line-height: 1.6;
+}
+
+/* Table styling */
+.messageContent table {
+ border-collapse: collapse;
+ margin-bottom: 1rem;
+ width: 100%;
+}
+
+/* Default/Light mode table styling */
+.messageContent th,
+.messageContent td {
+ border: 1px solid #d1d5db;
+ padding: 0.5rem 0.75rem;
+ text-align: left;
+}
+
+.messageContent tbody tr:nth-child(even) {
+ background-color: #f9fafb;
+}
+
+/* Dark mode table styling */
+:global(.awsui-dark-mode) .messageContent th,
+:global(.awsui-dark-mode) .messageContent td {
+ border: 1px solid #4b5563;
+}
+
+:global(.awsui-dark-mode) .messageContent tbody tr:nth-child(even) {
+ background-color: #1f2937;
+}
diff --git a/lib/user-interface/react/src/components/chatbot/components/Message.tsx b/lib/user-interface/react/src/components/chatbot/components/Message.tsx
index f23084d84..ae24d6965 100644
--- a/lib/user-interface/react/src/components/chatbot/components/Message.tsx
+++ b/lib/user-interface/react/src/components/chatbot/components/Message.tsx
@@ -18,20 +18,21 @@ import ReactMarkdown from 'react-markdown';
import Box from '@cloudscape-design/components/box';
import ExpandableSection from '@cloudscape-design/components/expandable-section';
import { ButtonDropdown, ButtonGroup, Grid, SpaceBetween, StatusIndicator } from '@cloudscape-design/components';
-import { JsonView, darkStyles } from 'react-json-view-lite';
+import { JsonView, darkStyles, defaultStyles } from 'react-json-view-lite';
import 'react-json-view-lite/dist/index.css';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
-import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
+import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { LisaChatMessage, LisaChatMessageMetadata, MessageTypes } from '../../types';
import { useAppSelector } from '@/config/store';
import { selectCurrentUsername } from '@/shared/reducers/user.reducer';
import ChatBubble from '@cloudscape-design/chat-components/chat-bubble';
import Avatar from '@cloudscape-design/chat-components/avatar';
-import remarkBreaks from 'remark-breaks';
import remarkMath from 'remark-math';
+import remarkGfm from 'remark-gfm';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
+import styles from './Message.module.css';
import { MessageContent } from '@langchain/core/messages';
import { base64ToBlob, fetchImage, getDisplayableMessage, messageContainsImage } from '@/components/utils';
@@ -43,6 +44,9 @@ import ImageViewer from '@/components/chatbot/components/ImageViewer';
import MermaidDiagram from '@/components/chatbot/components/MermaidDiagram';
import UsageInfo from '@/components/chatbot/components/UsageInfo';
import { merge } from 'lodash';
+import { useContext } from 'react';
+import { Mode } from '@cloudscape-design/global-styles';
+import ColorSchemeContext from '@/shared/color-scheme.provider';
type MessageProps = {
message?: LisaChatMessage;
@@ -66,6 +70,8 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami
const [showImageViewer, setShowImageViewer] = useState(false);
const [selectedImage, setSelectedImage] = useState(undefined);
const [selectedMetadata, setSelectedMetadata] = useState(undefined);
+ const { colorScheme } = useContext(ColorSchemeContext);
+ const isDarkMode = colorScheme === Mode.Dark;
useEffect(() => {
if (resend) {
@@ -115,7 +121,7 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami
/>
);
},
- }), [isStreaming, onMermaidRenderComplete]); // Include isStreaming and onMermaidRenderComplete so the component can access them
+ }), [isStreaming, onMermaidRenderComplete, isDarkMode]);
- const renderContent = (messageType: string, content: MessageContent, metadata?: LisaChatMessageMetadata) => {
+ const renderContent = (content: MessageContent, metadata?: LisaChatMessageMetadata) => {
if (Array.isArray(content)) {
- return content.map((item, index) => {
- if (item.type === 'text') {
- return item.text.startsWith('File context:') ? <>> : {getDisplayableMessage(item.text, message.type === MessageTypes.AI ? ragCitations : undefined)}
;
- } else if (item.type === 'image_url') {
+ return content.map((item: any, index) => {
+ if (item.type === 'text' && typeof item.text === 'string') {
+ if (item.text.startsWith('File context:')) return null;
+
+ const displayableText = getDisplayableMessage(item.text, message.type === MessageTypes.AI ? ragCitations : undefined);
+
+ return (
+
+ {markdownDisplay ? (
+
+ ) : (
+
{displayableText}
+ )}
+
+ );
+ } else if (item.type === 'image_url' && item.image_url?.url) {
return message.type === MessageTypes.HUMAN ?
:
@@ -275,10 +298,10 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami
});
}
return (
-
+
{markdownDisplay ? (
: undefined}
>
- {renderContent(message.type, message.content, message.metadata)}
+ {renderContent(message.content, message.metadata)}
{showMetadata && !isStreaming &&
+ }} style={isDarkMode ? darkStyles : defaultStyles} />
}
{!isStreaming && !messageContainsImage(message.content) &&
}
>
-
- {renderContent(message.type, message.content)}
+
+ {renderContent(message.content)}
)}
{message?.type === MessageTypes.TOOL && (
-
+
+ }} style={isDarkMode ? darkStyles : defaultStyles} />
)}
diff --git a/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx b/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx
index 47a46d5fc..c1db76ef5 100644
--- a/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx
+++ b/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx
@@ -234,6 +234,7 @@ export default function RagControls ({ isRunning, setUseRag, setRagConfig, ragCo
value: repository.repositoryId,
label: repository?.repositoryName?.length ? repository?.repositoryName : repository.repositoryId
})) || []}
+ controlId='rag-repository-autosuggest'
/>
`Use: "${text}"`}
onChange={handleCollectionChange}
options={collectionOptions}
+ controlId='rag-collection-autosuggest'
/>
diff --git a/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx b/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx
index 2bbb73dd4..4586b31c9 100644
--- a/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx
+++ b/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx
@@ -16,7 +16,6 @@
import {
AttributeEditor,
- Box,
Container,
FormField,
Grid,
@@ -105,7 +104,7 @@ export const SessionConfiguration = ({
size='large'
>
-
+
updateSessionConfiguration('streaming', detail.checked)}
checked={chatConfiguration.sessionConfiguration.streaming}
@@ -128,11 +127,7 @@ export const SessionConfiguration = ({
Show Message Metadata
}
{systemConfig && systemConfig.configuration.enabledComponents.editChatHistoryBuffer && !isImageModel && !modelOnly &&
-
-
-
-
+
}
+ }
{systemConfig && systemConfig.configuration.enabledComponents.editNumOfRagDocument && !isImageModel && !modelOnly &&
-
-
-
-
+
}
+ }
{systemConfig && systemConfig.configuration.enabledComponents.editKwargs && !isImageModel &&
- {!modelOnly &&
-
+ {!modelOnly &&
+
+
{
@@ -391,8 +385,7 @@ export const SessionConfiguration = ({
]}
empty='No stop sequences provided.'
/>
-
-
+
}
{!modelOnly &&
-
-
-
- }
- >
+
+
+
+
+
+ setSearchQuery(detail.value)}
+ placeholder='Search sessions by name...'
+ clearAriaLabel='Clear search'
+ type='search'
+ controlId='session-search-input'
+ />
+ {searchQuery && (
+
+ Found {filteredSessions.length} session{filteredSessions.length !== 1 ? 's' : ''}
+
+ )}
+
+ }
+ >
+
+
+
+
{isSessionsLoading && (
@@ -291,111 +286,111 @@ export function Sessions ({ newSession }) {
)}
{!isSessionsLoading && (
-
- {(() => {
- const timeGroups = Object.entries(groupedSessions);
-
- return timeGroups.map(([timeGroup, sessions]) => {
- if (sessions.length === 0) return null;
-
- return (
-
-
- {sessions.map((item) => (
-
-
-
- navigate(`ai-assistant/${item.sessionId}`)}>
-
- {getSessionDisplay(item, 40)}
-
-
-
-
- {
- if (e.detail.id === 'delete-session') {
- dispatch(
- setConfirmationModal({
- action: 'Delete',
- resourceName: 'Session',
- onConfirm: () => {
- setSessionBeingDeleted(item.sessionId);
- deleteById(item.sessionId);
- },
- description: `This will delete the Session: ${item.sessionId}.`
- })
- );
- } else if (e.detail.id === 'download-session') {
- getSessionById(item.sessionId).then((resp) => {
- const sess: LisaChatSession = resp.data;
- const file = new Blob([JSON.stringify(sess, null, 2)], { type: 'application/json' });
- downloadFile(URL.createObjectURL(file), `${sess.sessionId}.json`);
- });
- } else if (e.detail.id === 'export-images') {
- getSessionById(item.sessionId).then(async (resp) => {
- const sess: LisaChatSession = resp.data;
- const images = sess.history.filter((msg) => msg.type === 'ai' && messageContainsImage(msg.content))
- .flatMap((msg) => {
- if (Array.isArray(msg.content)) {
- return msg.content.map((contentItem) => {
- if (contentItem.type === 'image_url') {
- return contentItem.image_url.url;
- }
- });
- }
- return [];
- });
-
- if (images.length === 0) {
- notificationService.generateNotification('No images found to export', 'info');
- } else {
- const zip = new JSZip();
- const imagePromises = images.map(async (imageUrl, index) => {
- try {
- const blob = await fetchImage(imageUrl);
- zip.file(`image_${index + 1}.png`, blob, { binary: true });
- } catch (error) {
- console.error(`Error processing image ${index + 1}:`, error);
- }
- });
-
- // Wait for all images to be processed
- await Promise.all(imagePromises);
- const content = await zip.generateAsync({ type: 'blob' });
- downloadFile(URL.createObjectURL(content), `${sess.sessionId}-images.zip`);
- }
- });
- } else if (e.detail.id === 'rename-session') {
- handleRenameSession(item);
- }
- }}
- />
-
-
-
- ))}
-
-
- );
- });
- })()}
-
+
+
+ {(() => {
+ const timeGroups = Object.entries(groupedSessions);
+
+ return timeGroups.map(([timeGroup, sessions]) => {
+ if (sessions.length === 0) return null;
+
+ return (
+
+
+ {sessions.map((item) => (
+
+
+
+ navigate(`/ai-assistant/${item.sessionId}`)}>
+
+ {getSessionDisplay(item, 40)}
+
+
+
+
+ {
+ if (e.detail.id === 'delete-session') {
+ dispatch(
+ setConfirmationModal({
+ action: 'Delete',
+ resourceName: 'Session',
+ onConfirm: () => {
+ setSessionBeingDeleted(item.sessionId);
+ deleteById(item.sessionId);
+ },
+ description: `This will delete the Session: ${item.sessionId}.`
+ })
+ );
+ } else if (e.detail.id === 'download-session') {
+ getSessionById(item.sessionId).then((resp) => {
+ const sess: LisaChatSession = resp.data;
+ const file = new Blob([JSON.stringify(sess, null, 2)], { type: 'application/json' });
+ downloadFile(URL.createObjectURL(file), `${sess.sessionId}.json`);
+ });
+ } else if (e.detail.id === 'export-images') {
+ getSessionById(item.sessionId).then(async (resp) => {
+ const sess: LisaChatSession = resp.data;
+ const images = sess.history.filter((msg) => msg.type === 'ai' && messageContainsImage(msg.content))
+ .flatMap((msg) => {
+ if (Array.isArray(msg.content)) {
+ return msg.content
+ .filter((contentItem: any) => contentItem.type === 'image_url' && contentItem.image_url?.url)
+ .map((contentItem: any) => contentItem.image_url.url as string);
+ }
+ return [];
+ });
+
+ if (images.length === 0) {
+ notificationService.generateNotification('No images found to export', 'info');
+ } else {
+ const zip = new JSZip();
+ const imagePromises = images.map(async (imageUrl, index) => {
+ try {
+ const blob = await fetchImage(imageUrl);
+ zip.file(`image_${index + 1}.png`, blob, { binary: true });
+ } catch (error) {
+ console.error(`Error processing image ${index + 1}:`, error);
+ }
+ });
+
+ // Wait for all images to be processed
+ await Promise.all(imagePromises);
+ const content = await zip.generateAsync({ type: 'blob' });
+ downloadFile(URL.createObjectURL(content), `${sess.sessionId}-images.zip`);
+ }
+ });
+ } else if (e.detail.id === 'rename-session') {
+ handleRenameSession(item);
+ }
+ }}
+ />
+
+
+
+ ))}
+
+
+ );
+ });
+ })()}
+
+
)}
{/* Rename Session Modal */}
@@ -425,6 +420,7 @@ export function Sessions ({ newSession }) {
{
+ const dispatch = useAppDispatch();
const [isRunning, setIsRunning] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const stopRequested = useRef(false);
@@ -79,6 +82,10 @@ export const useChatGeneration = ({
setIsRunning(true);
stopRequested.current = false;
const startTime = performance.now(); // Start client timer
+
+ // Capture session state before adding messages to determine if this is a new session
+ const isNewSession = session.history.length === 0;
+
try {
// Handle image generation mode specifically
if (isImageGenerationMode) {
@@ -403,7 +410,7 @@ export const useChatGeneration = ({
} else {
const response = await llmClient.invoke(messages, { tools: modelSupportsTools ? openAiTools : undefined });
const content = response.content as string;
- const usage = response.response_metadata.tokenUsage;
+ const usage = (response.response_metadata as any)?.tokenUsage;
// Check if guardrail was triggered
const isGuardrailTriggered = (response as any)?.id === 'guardrail-response';
@@ -447,6 +454,16 @@ export const useChatGeneration = ({
throw error;
} finally {
setIsRunning(false);
+ // Invalidate session cache after any message is sent to ensure fresh data
+ // This ensures session details are up-to-date when viewed from other components
+ if (session.sessionId) {
+ // Invalidate session cache after any message is sent to ensure fresh data
+ // For new sessions, also invalidate the session list so they appear in the sidebar
+ dispatch(sessionApi.util.invalidateTags([
+ { type: 'session' as const, id: session.sessionId },
+ ...(isNewSession ? ['sessions' as const] : [])
+ ]));
+ }
}
};
diff --git a/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.test.tsx b/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.test.tsx
new file mode 100644
index 000000000..82704fdc4
--- /dev/null
+++ b/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.test.tsx
@@ -0,0 +1,244 @@
+/**
+ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+
+ Licensed under the Apache License, Version 2.0 (the "License").
+ You may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { useAuth } from 'react-oidc-context';
+
+import { useMemory } from './useMemory.hooks';
+import { LisaChatSession } from '@/components/types';
+import { IChatConfiguration } from '@/shared/model/chat.configurations.model';
+import { IModel } from '@/shared/model/model-management.model';
+import { ChatMemory } from '@/shared/util/chat-memory';
+
+vi.mock('react-oidc-context');
+
+const createMockSession = (overrides?: Partial): LisaChatSession => ({
+ sessionId: 'test-session-id',
+ history: [],
+ userId: 'test-user',
+ startTime: '2024-01-01T00:00:00.000Z',
+ ...overrides,
+});
+
+const createMockChatConfiguration = (overrides?: Partial): IChatConfiguration => ({
+ sessionConfiguration: {
+ chatHistoryBufferSize: 10,
+ max_tokens: 1024,
+ modelArgs: {},
+ ...overrides,
+ },
+} as IChatConfiguration);
+
+const createMockModel = (overrides?: Partial): IModel => ({
+ modelId: 'test-model',
+ modelName: 'Test Model',
+ features: [],
+ ...overrides,
+} as IModel);
+
+const mockNotificationService = {
+ generateNotification: vi.fn(),
+};
+
+describe('useMemory', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (useAuth as ReturnType).mockReturnValue({
+ isAuthenticated: true,
+ user: { profile: { sub: 'test-user' } },
+ });
+ });
+
+ it('creates memory with correct configuration from session and chat config', () => {
+ const session = createMockSession();
+ const chatConfig = createMockChatConfiguration({ chatHistoryBufferSize: 15 });
+
+ const { result } = renderHook(() =>
+ useMemory(session, chatConfig, undefined, '', '', mockNotificationService)
+ );
+
+ expect(result.current.memory).toBeInstanceOf(ChatMemory);
+ expect(result.current.memory.k).toBe(15);
+ expect(result.current.memory.memoryKey).toBe('history');
+ expect(result.current.memory.returnMessages).toBe(false);
+ });
+
+ it('updates memory when session changes', () => {
+ const session1 = createMockSession({ sessionId: 'session-1' });
+ const session2 = createMockSession({ sessionId: 'session-2' });
+ const chatConfig = createMockChatConfiguration();
+
+ const { result, rerender } = renderHook(
+ ({ session }) => useMemory(session, chatConfig, undefined, '', '', mockNotificationService),
+ { initialProps: { session: session1 } }
+ );
+
+ const firstMemory = result.current.memory;
+
+ rerender({ session: session2 });
+
+ expect(result.current.memory).not.toBe(firstMemory);
+ });
+
+ it('updates memory when buffer size changes', () => {
+ const session = createMockSession();
+ const chatConfig1 = createMockChatConfiguration({ chatHistoryBufferSize: 10 });
+ const chatConfig2 = createMockChatConfiguration({ chatHistoryBufferSize: 20 });
+
+ const { result, rerender } = renderHook(
+ ({ chatConfig }) => useMemory(session, chatConfig, undefined, '', '', mockNotificationService),
+ { initialProps: { chatConfig: chatConfig1 } }
+ );
+
+ expect(result.current.memory.k).toBe(10);
+
+ rerender({ chatConfig: chatConfig2 });
+
+ expect(result.current.memory.k).toBe(20);
+ });
+
+ it('does not recreate memory when unrelated props change', async () => {
+ const session = createMockSession();
+ const chatConfig = createMockChatConfiguration();
+ const model = createMockModel();
+
+ const { result, rerender } = renderHook(
+ ({ userPrompt }) => useMemory(session, chatConfig, model, userPrompt, '', mockNotificationService),
+ { initialProps: { userPrompt: 'initial prompt' } }
+ );
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ const firstMemory = result.current.memory;
+
+ await act(async () => {
+ rerender({ userPrompt: 'updated prompt' });
+ });
+
+ expect(result.current.memory).toBe(firstMemory);
+ });
+
+ it('updates metadata when model and auth are available', async () => {
+ const session = createMockSession();
+ const chatConfig = createMockChatConfiguration({ max_tokens: 2048, modelArgs: { temperature: 0.7 } });
+ const model = createMockModel({ modelId: 'claude-v2' });
+
+ const { result } = renderHook(() =>
+ useMemory(session, chatConfig, model, '', '', mockNotificationService)
+ );
+
+ await waitFor(() => {
+ expect(result.current.metadata.modelName).toBe('claude-v2');
+ });
+
+ expect(result.current.metadata.modelKwargs).toEqual({
+ max_tokens: 2048,
+ modelKwargs: { temperature: 0.7 },
+ });
+ });
+
+ it('does not update metadata when not authenticated', async () => {
+ (useAuth as ReturnType).mockReturnValue({
+ isAuthenticated: false,
+ user: null,
+ });
+
+ const session = createMockSession();
+ const chatConfig = createMockChatConfiguration();
+ const model = createMockModel();
+
+ const { result } = renderHook(() =>
+ useMemory(session, chatConfig, model, '', '', mockNotificationService)
+ );
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ });
+
+ expect(result.current.metadata).toEqual({});
+ });
+
+ it('shows notification when model without image support receives image context', async () => {
+ const session = createMockSession();
+ const chatConfig = createMockChatConfiguration();
+ const modelWithoutImageSupport = createMockModel({ features: [] });
+
+ await act(async () => {
+ renderHook(() =>
+ useMemory(
+ session,
+ chatConfig,
+ modelWithoutImageSupport,
+ '',
+ 'File context: data:image/png;base64,abc123',
+ mockNotificationService
+ )
+ );
+ });
+
+ expect(mockNotificationService.generateNotification).toHaveBeenCalledWith(
+ 'Removed file from context as new model doesn\'t support image input',
+ 'info'
+ );
+ });
+
+ it('does not show notification when model supports image input', async () => {
+ const session = createMockSession();
+ const chatConfig = createMockChatConfiguration();
+ const modelWithImageSupport = createMockModel({
+ features: [{ name: 'imageInput' }],
+ });
+
+ await act(async () => {
+ renderHook(() =>
+ useMemory(
+ session,
+ chatConfig,
+ modelWithImageSupport,
+ '',
+ 'File context: data:image/png;base64,abc123',
+ mockNotificationService
+ )
+ );
+ });
+
+ expect(mockNotificationService.generateNotification).not.toHaveBeenCalled();
+ });
+
+ it('does not show notification when file context is not an image', async () => {
+ const session = createMockSession();
+ const chatConfig = createMockChatConfiguration();
+ const modelWithoutImageSupport = createMockModel({ features: [] });
+
+ await act(async () => {
+ renderHook(() =>
+ useMemory(
+ session,
+ chatConfig,
+ modelWithoutImageSupport,
+ '',
+ 'File context: some text content',
+ mockNotificationService
+ )
+ );
+ });
+
+ expect(mockNotificationService.generateNotification).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.tsx b/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.tsx
index 8bdd04427..2e1aa03ec 100644
--- a/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.tsx
+++ b/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.tsx
@@ -14,7 +14,7 @@
limitations under the License.
*/
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { useAuth } from 'react-oidc-context';
import { ChatMemory } from '@/shared/util/chat-memory';
import { LisaChatMessageHistory } from '@/components/adapters/lisa-chat-history';
@@ -32,26 +32,18 @@ export const useMemory = (
) => {
const auth = useAuth();
const [metadata, setMetadata] = useState({});
- const [memory, setMemory] = useState(
- new ChatMemory({
- chatHistory: new LisaChatMessageHistory(session),
- returnMessages: false,
- memoryKey: 'history',
- k: chatConfiguration.sessionConfiguration.chatHistoryBufferSize,
- }),
- );
- // Update memory when session history or buffer size changes
- useEffect(() => {
- setMemory(
+ // Memoize memory to update when session history or buffer size changes
+ const memory = useMemo(
+ () =>
new ChatMemory({
chatHistory: new LisaChatMessageHistory(session),
returnMessages: false,
memoryKey: 'history',
k: chatConfiguration.sessionConfiguration.chatHistoryBufferSize,
}),
- );
- }, [session, chatConfiguration.sessionConfiguration.chatHistoryBufferSize]);
+ [session, chatConfiguration.sessionConfiguration.chatHistoryBufferSize]
+ );
// Update metadata when model or configuration changes
useEffect(() => {
@@ -83,7 +75,6 @@ export const useMemory = (
return {
memory,
- setMemory,
metadata,
};
};
diff --git a/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.test.tsx b/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.test.tsx
new file mode 100644
index 000000000..1e008740e
--- /dev/null
+++ b/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.test.tsx
@@ -0,0 +1,191 @@
+/**
+ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+
+ Licensed under the Apache License, Version 2.0 (the "License").
+ You may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+import { renderHook, act } from '@testing-library/react';
+import { Provider } from 'react-redux';
+import { configureStore } from '@reduxjs/toolkit';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { useAuth } from 'react-oidc-context';
+
+import { useSession } from './useSession.hooks';
+import breadcrumbsReducer from '@/shared/reducers/breadcrumbs.reducer';
+
+// Mock react-oidc-context
+vi.mock('react-oidc-context');
+
+// Mock uuid
+vi.mock('uuid', () => ({
+ v4: () => 'test-session-id-123'
+}));
+
+const mockAuth = {
+ user: {
+ profile: {
+ sub: 'test-user-id'
+ }
+ }
+};
+
+const mockGetSessionById = vi.fn();
+
+const createMockStore = () => configureStore({
+ reducer: {
+ breadcrumbs: breadcrumbsReducer,
+ },
+});
+
+const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+describe('useSession', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (useAuth as any).mockReturnValue(mockAuth);
+ });
+
+ it('creates new session when sessionId is undefined', async () => {
+ const { result } = renderHook(
+ () => useSession(undefined, mockGetSessionById),
+ { wrapper }
+ );
+
+ await act(async () => {
+ // Wait for useEffect to complete
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ expect(result.current.session.sessionId).toBe('test-session-id-123');
+ expect(result.current.session.history).toEqual([]);
+ expect(result.current.session.userId).toBe('test-user-id');
+ expect(result.current.loadingSession).toBe(false);
+ });
+
+ it('loads existing session when sessionId is provided', async () => {
+ const mockSession = {
+ sessionId: 'existing-session-id',
+ history: [{ type: 'human', content: 'test message' }],
+ userId: 'test-user-id',
+ startTime: '2024-01-01T00:00:00.000Z',
+ configuration: {
+ selectedModel: { modelId: 'test-model' },
+ ragConfig: { repositoryId: 'test-repo' }
+ }
+ };
+
+ mockGetSessionById.mockResolvedValue({ data: mockSession });
+
+ const { result } = renderHook(
+ () => useSession('existing-session-id', mockGetSessionById),
+ { wrapper }
+ );
+
+ // Initially loading
+ expect(result.current.loadingSession).toBe(true);
+
+ // Wait for session to load
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ expect(mockGetSessionById).toHaveBeenCalledWith('existing-session-id');
+ expect(result.current.session.sessionId).toBe('existing-session-id');
+ expect(result.current.session.history).toEqual(mockSession.history);
+ expect(result.current.loadingSession).toBe(false);
+ });
+
+ it('creates new session when transitioning from existing session to undefined', async () => {
+ const { result, rerender } = renderHook(
+ ({ sessionId }) => useSession(sessionId, mockGetSessionById),
+ {
+ wrapper,
+ initialProps: { sessionId: 'existing-session-id' }
+ }
+ );
+
+ await act(async () => {
+ // Wait for initial useEffect
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ // Initially has existing session ID
+ expect(result.current.internalSessionId).toBe('existing-session-id');
+
+ // Transition to new session (sessionId becomes undefined)
+ await act(async () => {
+ rerender({ sessionId: undefined });
+ // Wait for useEffect to complete
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ // Should create new session immediately (no cache interference)
+ expect(result.current.session.sessionId).toBe('test-session-id-123');
+ expect(result.current.session.history).toEqual([]);
+ expect(result.current.internalSessionId).toBe('test-session-id-123');
+ });
+
+ it('does not reload session if sessionId has not changed', async () => {
+ const { rerender } = renderHook(
+ ({ sessionId }) => useSession(sessionId, mockGetSessionById),
+ {
+ wrapper,
+ initialProps: { sessionId: 'same-session-id' }
+ }
+ );
+
+ await act(async () => {
+ // Wait for initial useEffect
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ // Clear the mock call count
+ mockGetSessionById.mockClear();
+
+ // Re-render with same sessionId
+ await act(async () => {
+ rerender({ sessionId: 'same-session-id' });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ // Should not call getSessionById again
+ expect(mockGetSessionById).not.toHaveBeenCalled();
+ });
+
+ it('always creates fresh session when sessionId is undefined (no cache persistence)', () => {
+ // First render with undefined sessionId
+ const { result: result1, unmount } = renderHook(
+ () => useSession(undefined, mockGetSessionById),
+ { wrapper }
+ );
+
+ const firstSessionId = result1.current.session.sessionId;
+ expect(firstSessionId).toBe('test-session-id-123');
+
+ // Unmount and remount (simulating new session button click)
+ unmount();
+
+ const { result: result2 } = renderHook(
+ () => useSession(undefined, mockGetSessionById),
+ { wrapper }
+ );
+
+ // Should create a new session (not restore from cache)
+ expect(result2.current.session.sessionId).toBe('test-session-id-123');
+ expect(result2.current.session.history).toEqual([]);
+ });
+});
diff --git a/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx b/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx
index 33ab70023..219f014c1 100644
--- a/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx
+++ b/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx
@@ -14,7 +14,7 @@
limitations under the License.
*/
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useState, useRef } from 'react';
import { useAuth } from 'react-oidc-context';
import { v4 as uuidv4 } from 'uuid';
import { LisaChatSession } from '@/components/types';
@@ -28,61 +28,87 @@ export const useSession = (sessionId: string, getSessionById: any) => {
const dispatch = useAppDispatch();
const auth = useAuth();
- const [session, setSession] = useState({
+ const [session, setSession] = useState(() => ({
history: [],
sessionId: '',
userId: '',
startTime: new Date(Date.now()).toISOString(),
- });
+ }));
const [internalSessionId, setInternalSessionId] = useState(null);
const [loadingSession, setLoadingSession] = useState(false);
const [chatConfiguration, setChatConfiguration] = useState(baseConfig);
const [selectedModel, setSelectedModel] = useState();
const [ragConfig, setRagConfig] = useState({} as RagConfig);
+ const hasCreatedNewSessionRef = useRef(false);
- useEffect(() => {
- // always hide breadcrumbs
- dispatch(setBreadcrumbs([]));
-
- if (sessionId) {
- setInternalSessionId(sessionId);
+ // Memoize the session loading function to prevent unnecessary re-renders
+ const loadSession = useCallback(async (id: string) => {
+ try {
setLoadingSession(true);
- setSession((prev) => ({ ...prev, history: [] }));
+ const resp = await getSessionById(id);
+ let sess: LisaChatSession = resp.data;
- getSessionById(sessionId).then((resp: any) => {
- // session doesn't exist so we create it
- let sess: LisaChatSession = resp.data;
- if (sess.history === undefined) {
- sess = {
- history: [],
- sessionId: sessionId,
- userId: auth.user?.profile.sub,
- startTime: new Date(Date.now()).toISOString(),
- };
- }
- setSession(sess);
- setChatConfiguration(sess.configuration ?? baseConfig);
- setSelectedModel(sess.configuration?.selectedModel ?? undefined);
- setRagConfig(sess.configuration?.ragConfig ?? {} as RagConfig);
- setLoadingSession(false);
- });
- } else {
- const newSessionId = uuidv4();
- setChatConfiguration(baseConfig);
- setInternalSessionId(newSessionId);
- const newSession = {
- history: [],
- sessionId: newSessionId,
- userId: auth.user?.profile.sub,
- startTime: new Date(Date.now()).toISOString(),
- };
- setSession(newSession);
+ if (sess.history === undefined) {
+ sess = {
+ history: [],
+ sessionId: id,
+ userId: auth.user?.profile.sub,
+ startTime: new Date(Date.now()).toISOString(),
+ };
+ }
+ setSession(sess);
+ setChatConfiguration(sess.configuration ?? baseConfig);
+ setSelectedModel(sess.configuration?.selectedModel ?? undefined);
+ setRagConfig(sess.configuration?.ragConfig ?? {} as RagConfig);
+ } catch (error) {
+ console.error('Error loading session:', error);
+ } finally {
+ setLoadingSession(false);
}
- }, [sessionId, dispatch, auth.user?.profile.sub, getSessionById]);
+ }, [getSessionById, auth.user?.profile.sub]);
+ const createNewSession = useCallback(() => {
+ const newSessionId = uuidv4();
+ // Reset all session-related state
+ setChatConfiguration(baseConfig);
+ setSelectedModel(undefined);
+ setRagConfig({} as RagConfig);
+ setInternalSessionId(newSessionId);
+ const newSession = {
+ history: [],
+ sessionId: newSessionId,
+ userId: auth.user?.profile.sub,
+ startTime: new Date(Date.now()).toISOString(),
+ };
+ setSession(newSession);
+ setLoadingSession(false);
+ }, [auth.user?.profile.sub]);
+ useEffect(() => {
+ // always hide breadcrumbs
+ dispatch(setBreadcrumbs([]));
+
+ if (sessionId) {
+ // Reset the ref when we have a sessionId
+ hasCreatedNewSessionRef.current = false;
+ // Only load if this is a different session than what we currently have
+ if (internalSessionId !== sessionId) {
+ setInternalSessionId(sessionId);
+ setSession((prev) => ({ ...prev, history: [] }));
+ loadSession(sessionId);
+ }
+ } else {
+ // No sessionId in URL - create a new session only once
+ // Use ref to prevent creating multiple sessions if effect runs multiple times
+ if (!hasCreatedNewSessionRef.current) {
+ hasCreatedNewSessionRef.current = true;
+ createNewSession();
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [sessionId, dispatch, loadSession, createNewSession]);
return {
session,
diff --git a/lib/user-interface/react/src/components/configuration/ActivatedUserComponents.tsx b/lib/user-interface/react/src/components/configuration/ActivatedUserComponents.tsx
index 6e6e2ffac..02b00f0ea 100644
--- a/lib/user-interface/react/src/components/configuration/ActivatedUserComponents.tsx
+++ b/lib/user-interface/react/src/components/configuration/ActivatedUserComponents.tsx
@@ -19,19 +19,19 @@ import React, { useCallback } from 'react';
import { SetFieldsFunction } from '../../shared/validation';
const ragOptions = {
- uploadRagDocs: 'Allow document upload from Chat',
+ uploadRagDocs: 'Document upload from Chat',
editNumOfRagDocument: 'Edit number of referenced documents',
};
const libraryOptions = {
- modelLibrary: 'Show Model Library',
- showRagLibrary: 'Show Document Library',
- showPromptTemplateLibrary: 'Show Prompt Template Library'
+ modelLibrary: 'Model Library',
+ showRagLibrary: 'Document Library',
+ showPromptTemplateLibrary: 'Prompt Template Library'
};
const inContextOptions = {
- uploadContextDocs: 'Allow document upload to context',
- documentSummarization: 'Allow Document Summarization',
+ uploadContextDocs: 'Document upload to context',
+ documentSummarization: 'Document Summarization',
};
const advancedOptions = {
@@ -45,12 +45,12 @@ const advancedOptions = {
};
const mcpOptions = {
- mcpConnections: 'Allow MCP Server Connections',
- showMcpWorkbench: 'Show MCP Workbench'
+ mcpConnections: 'MCP Server Connections',
+ showMcpWorkbench: 'MCP Workbench'
};
const apiTokenOptions = {
- enableUserApiTokens: 'Allow user managed API tokens'
+ enableUserApiTokens: 'User managed API tokens'
};
type AllOptionKeys>> = {
diff --git a/lib/user-interface/react/src/components/mcp-workbench/McpWorkbenchManagementComponent.tsx b/lib/user-interface/react/src/components/mcp-workbench/McpWorkbenchManagementComponent.tsx
index a967133fc..1199f150e 100644
--- a/lib/user-interface/react/src/components/mcp-workbench/McpWorkbenchManagementComponent.tsx
+++ b/lib/user-interface/react/src/components/mcp-workbench/McpWorkbenchManagementComponent.tsx
@@ -174,7 +174,9 @@ export function McpWorkbenchManagementComponent (): ReactElement {
}, [validateMcpToolMutation, editor, notificationService]), 300);
// remove top breadcrumbs
- dispatch(setBreadcrumbs([]));
+ useEffect(() => {
+ dispatch(setBreadcrumbs([]));
+ }, [dispatch]);
// Reset pagination when filter changes
useEffect(() => {
diff --git a/lib/user-interface/react/src/components/model-management/ModelManagementActions.tsx b/lib/user-interface/react/src/components/model-management/ModelManagementActions.tsx
index 879f225e4..478b6c884 100644
--- a/lib/user-interface/react/src/components/model-management/ModelManagementActions.tsx
+++ b/lib/user-interface/react/src/components/model-management/ModelManagementActions.tsx
@@ -142,6 +142,7 @@ function ModelActionButton (dispatch: ThunkDispatch, notificat
return (
{
};
export const createCardDefinitions = (defaultModelId?: string) => ({
- header: (model: IModel) => {model.modelId} {model.modelId === defaultModelId && DEFAULT}
,
+ header: (model: IModel) => {model.modelId} {model.modelId === defaultModelId && DEFAULT}
,
sections: [
{
id: 'modelName',
diff --git a/lib/user-interface/react/src/components/model-management/components/ModelComparisonComponents.tsx b/lib/user-interface/react/src/components/model-management/components/ModelComparisonComponents.tsx
index bc9a0109e..1efb25ec2 100644
--- a/lib/user-interface/react/src/components/model-management/components/ModelComparisonComponents.tsx
+++ b/lib/user-interface/react/src/components/model-management/components/ModelComparisonComponents.tsx
@@ -187,6 +187,7 @@ export const PromptInputSection = memo(function PromptInputSection ({
minRows={2}
spellcheck={true}
disabled={!canCompare && !shouldShowStopButton}
+ controlId='model-comparison-prompt-input'
/>
);
diff --git a/lib/user-interface/react/src/components/model-management/create-model/BaseModelConfig.tsx b/lib/user-interface/react/src/components/model-management/create-model/BaseModelConfig.tsx
index 764d508ab..f4158045b 100644
--- a/lib/user-interface/react/src/components/model-management/create-model/BaseModelConfig.tsx
+++ b/lib/user-interface/react/src/components/model-management/create-model/BaseModelConfig.tsx
@@ -181,13 +181,14 @@ export function BaseModelConfig (props: FormProps & BaseModelConf
>
)}
-
+
props.setFields({'streaming': detail.checked})
}
diff --git a/lib/user-interface/react/src/components/model-management/create-model/CreateModelModal.tsx b/lib/user-interface/react/src/components/model-management/create-model/CreateModelModal.tsx
index 032b6993c..80395f1f4 100644
--- a/lib/user-interface/react/src/components/model-management/create-model/CreateModelModal.tsx
+++ b/lib/user-interface/react/src/components/model-management/create-model/CreateModelModal.tsx
@@ -495,64 +495,66 @@ export function CreateModelModal (props: CreateModelModalProps) : ReactElement {
description: 'Are you sure you want to discard your changes?'
}));
}} visible={props.visible} header={`${props.isEdit ? 'Update' : 'Create'} Model`}>
- `Step ${stepNumber}`,
- collapsedStepsLabel: (stepNumber, stepsCount) => `Step ${stepNumber} of ${stepsCount}`,
- skipToButtonLabel: () => `Skip to ${props.isEdit ? 'Update' : 'Create'}`,
- navigationAriaLabel: 'Steps',
- cancelButton: 'Cancel',
- previousButton: 'Previous',
- nextButton: 'Next',
- optional: 'LISA hosted models only'
- }}
- onNavigate={(event) => {
- switch (event.detail.reason) {
- case 'step':
- case 'previous':
- setState({
- ...state,
- activeStepIndex: event.detail.requestedStepIndex,
- });
- break;
- case 'next':
- case 'skip':
- {
- if (touchFields(requiredFields[state.activeStepIndex]) && isValid) {
- setState({
- ...state,
- activeStepIndex: event.detail.requestedStepIndex,
- });
- break;
+
+ `Step ${stepNumber}`,
+ collapsedStepsLabel: (stepNumber, stepsCount) => `Step ${stepNumber} of ${stepsCount}`,
+ skipToButtonLabel: () => `Skip to ${props.isEdit ? 'Update' : 'Create'}`,
+ navigationAriaLabel: 'Steps',
+ cancelButton: 'Cancel',
+ previousButton: 'Previous',
+ nextButton: 'Next',
+ optional: 'LISA hosted models only'
+ }}
+ onNavigate={(event) => {
+ switch (event.detail.reason) {
+ case 'step':
+ case 'previous':
+ setState({
+ ...state,
+ activeStepIndex: event.detail.requestedStepIndex,
+ });
+ break;
+ case 'next':
+ case 'skip':
+ {
+ if (touchFields(requiredFields[state.activeStepIndex]) && isValid) {
+ setState({
+ ...state,
+ activeStepIndex: event.detail.requestedStepIndex,
+ });
+ break;
+ }
}
- }
- break;
- }
+ break;
+ }
- scrollToInvalid();
- }}
- onCancel={() => {
- dispatch(
- setConfirmationModal({
- action: 'Discard',
- resourceName: 'Model Creation',
- onConfirm: () => {
- props.setVisible(false);
- props.setIsEdit(false);
- resetState();
- },
- description: 'Are you sure you want to discard your changes?'
- }));
- }}
- onSubmit={() => {
- handleSubmit();
- }}
- activeStepIndex={state.activeStepIndex}
- isLoadingNextStep={isCreating || isUpdating || isScheduleUpdating || isScheduleDeleting}
- allowSkipTo
- steps={steps}
- />
+ scrollToInvalid();
+ }}
+ onCancel={() => {
+ dispatch(
+ setConfirmationModal({
+ action: 'Discard',
+ resourceName: 'Model Creation',
+ onConfirm: () => {
+ props.setVisible(false);
+ props.setIsEdit(false);
+ resetState();
+ },
+ description: 'Are you sure you want to discard your changes?'
+ }));
+ }}
+ onSubmit={() => {
+ handleSubmit();
+ }}
+ activeStepIndex={state.activeStepIndex}
+ isLoadingNextStep={isCreating || isUpdating || isScheduleUpdating || isScheduleDeleting}
+ allowSkipTo
+ steps={steps}
+ />
+
);
}
diff --git a/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateForm.tsx b/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateForm.tsx
index 8e84fb1e0..c1f07a656 100644
--- a/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateForm.tsx
+++ b/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateForm.tsx
@@ -65,16 +65,21 @@ export function PromptTemplateForm (props: PromptTemplateFormProps) {
const notificationService = useNotificationService(dispatch);
// if create/update was successful, redirect back to list
- if (isCreatingSuccess || isUpdatingSuccess) {
- navigate('/prompt-templates');
- }
+ useEffect(() => {
+ if (isCreatingSuccess || isUpdatingSuccess) {
+ navigate('/prompt-templates');
+ }
+ }, [isCreatingSuccess, isUpdatingSuccess, navigate]);
- if (isSuccess) {
- dispatch(setBreadcrumbs([
- { text: 'Prompt Templates', href: '/prompt-templates' },
- { text: data.title, href: '' }
- ]));
- }
+ // Set breadcrumbs when data is loaded
+ useEffect(() => {
+ if (isSuccess && data) {
+ dispatch(setBreadcrumbs([
+ { text: 'Prompt Templates', href: '/prompt-templates' },
+ { text: data.title, href: '' }
+ ]));
+ }
+ }, [isSuccess, data, dispatch]);
const schema = z.object({
title: z.string().trim().min(1, 'String cannot be empty.'),
@@ -91,13 +96,16 @@ export function PromptTemplateForm (props: PromptTemplateFormProps) {
const canEdit = promptTemplateId ? (isUserAdmin || data?.isOwner) : true;
const disabled = isFetching || isCreating || isUpdating;
- if (isEdit && isUninitialized && promptTemplateId) {
- getPromptTemplateQuery(promptTemplateId).then((response) => {
- if (response.isSuccess) {
- setFields(response.data);
- }
- });
- }
+ // Load prompt template data in edit mode
+ useEffect(() => {
+ if (isEdit && isUninitialized && promptTemplateId) {
+ getPromptTemplateQuery(promptTemplateId).then((response) => {
+ if (response.isSuccess) {
+ setFields(response.data);
+ }
+ });
+ }
+ }, [isEdit, isUninitialized, promptTemplateId, getPromptTemplateQuery, setFields]);
const submit = (promptTemplate: NewPromptTemplate) => {
if (isValid) {
@@ -158,11 +166,14 @@ export function PromptTemplateForm (props: PromptTemplateFormProps) {
header={}
actions={
- navigate(-1)}>Cancel
- navigate(-1)} data-testid='prompt-template-cancel-button'>Cancel
+ submit(state.form)}>
+ onClick={() => submit(state.form)}
+ data-testid='prompt-template-submit-button'
+ >
{ promptTemplateId ? 'Update' : 'Create'} Template
@@ -171,11 +182,18 @@ export function PromptTemplateForm (props: PromptTemplateFormProps) {
Details}>
- touchFields(['title'])} onChange={({ detail }) => {
- setFields({ 'title': detail.value });
- }}
- disabled={disabled}
- placeholder='Enter template title' />
+ touchFields(['title'])}
+ onChange={({ detail }) => {
+ setFields({ 'title': detail.value });
+ }}
+ disabled={disabled}
+ placeholder='Enter template title'
+ controlId='prompt-template-title-input'
+ data-testid='prompt-template-title-input'
+ />
@@ -185,41 +203,51 @@ export function PromptTemplateForm (props: PromptTemplateFormProps) {
setFields({'type': detail.selectedOption.value});
}}
options={Object.entries(PromptTemplateType).map(([key, value]) => ({label: key, value}))}
+ data-testid='prompt-template-type-select'
/>
- {
- setSharePublic(detail.checked);
- setFields({groups: detail.checked ? ['lisa:public'] : []});
- touchFields(['groups'], ModifyMethod.Unset);
- setTokenText('');
- }}
- disabled={disabled} />
+ {
+ setSharePublic(detail.checked);
+ setFields({groups: detail.checked ? ['lisa:public'] : []});
+ touchFields(['groups'], ModifyMethod.Unset);
+ setTokenText('');
+ }}
+ disabled={disabled}
+ data-testid='prompt-template-share-public-toggle'
+ />
- {
- setTokenText(detail.value);
- if (detail.value.length === 0) {
- touchFields(['groups'], ModifyMethod.Unset);
- }
- }} onKeyDown={({detail}) => {
- if (detail.keyCode === KeyCode.enter) {
- setFields({groups: state.form.groups.concat(`group:${tokenText}`)});
- touchFields(['groups'], ModifyMethod.Unset);
- setTokenText('');
- }
- }}
- onBlur={() => {
- if (tokenText.length === 0) {
- touchFields(['groups'], ModifyMethod.Unset);
- } else {
- touchFields(['groups']);
- }
- }}
- placeholder='Enter group name'
- disabled={disabled || sharePublic} />
+ {
+ setTokenText(detail.value);
+ if (detail.value.length === 0) {
+ touchFields(['groups'], ModifyMethod.Unset);
+ }
+ }} onKeyDown={({detail}) => {
+ if (detail.keyCode === KeyCode.enter) {
+ setFields({groups: state.form.groups.concat(`group:${tokenText}`)});
+ touchFields(['groups'], ModifyMethod.Unset);
+ setTokenText('');
+ }
+ }}
+ onBlur={() => {
+ if (tokenText.length === 0) {
+ touchFields(['groups'], ModifyMethod.Unset);
+ } else {
+ touchFields(['groups']);
+ }
+ }}
+ placeholder='Enter group name'
+ disabled={disabled || sharePublic}
+ controlId='prompt-template-groups-input'
+ />
{
const newTokens = [...state.form.groups];
@@ -230,11 +258,16 @@ export function PromptTemplateForm (props: PromptTemplateFormProps) {
-
diff --git a/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateModal.tsx b/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateModal.tsx
index f4a9f7a75..a6e043b4c 100644
--- a/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateModal.tsx
+++ b/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateModal.tsx
@@ -138,6 +138,7 @@ export const PromptTemplateModal = ({
filteringType='auto'
value={suggestText}
empty={'No Prompt found'}
+ enteredTextLabel={(value) => `Use "${value}"`}
statusType={isFetchingList ? 'loading' : 'finished'}
onChange={({detail}) => {
setSuggestText(detail.value);
diff --git a/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplatesActions.tsx b/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplatesActions.tsx
index 7fd2b4c0d..4aa190052 100644
--- a/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplatesActions.tsx
+++ b/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplatesActions.tsx
@@ -105,6 +105,7 @@ function PromptTemplatesActionButton (dispatch: ThunkDispatch,
onItemClick={(e) =>
ModelActionHandler(e, selectedPromptTemplate, dispatch, deleteMutation, navigate)
}
+ data-testid='prompt-template-actions-dropdown'
>
Actions
diff --git a/lib/user-interface/react/src/components/repository-management/createRepository/BedrockKnowledgeBaseConfigForm.tsx b/lib/user-interface/react/src/components/repository-management/createRepository/BedrockKnowledgeBaseConfigForm.tsx
index 8b69b1c0d..b608f7ffc 100644
--- a/lib/user-interface/react/src/components/repository-management/createRepository/BedrockKnowledgeBaseConfigForm.tsx
+++ b/lib/user-interface/react/src/components/repository-management/createRepository/BedrockKnowledgeBaseConfigForm.tsx
@@ -172,6 +172,7 @@ export function BedrockKnowledgeBaseConfigForm (
) : (