[Obs AI Assistant] Archiving conversations (#216012)

Closes https://github.com/elastic/kibana/issues/209386

## Summary

1. The option to archive conversations are enabled via the conversation
contextual menu.
2. Archived conversations can be viewed under the "Archived" section of
the conversation list.
3. Only the owner of the conversation can archive and unarchive.
4. Once archived, the conversation cannot be continued until unarchived.
5. If the archived conversation is shared, other users (who are not the
owner) can duplicate the conversation, if they wish to continue the
conversation.
6. The archived section of the conversation list is collapsed by
default.
7. Updating the conversation such as title updates, regenerating,
providing chat feedback are disabled for archived conversations

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Viduni Wickramarachchi 2025-04-08 10:42:20 -04:00 committed by GitHub
parent a5f3c0ad03
commit 3aa036d515
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 976 additions and 319 deletions

View file

@ -13,10 +13,12 @@ export function ChatBanner({
title,
description,
button = null,
icon = 'users',
}: {
title: string;
description: string;
button?: ReactNode;
icon?: string;
}) {
const { euiTheme } = useEuiTheme();
@ -33,7 +35,7 @@ export function ChatBanner({
>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiIcon size="l" type="users" />
<EuiIcon size="l" type={icon} />
</EuiFlexItem>
<EuiFlexItem grow>
<EuiText size="xs">

View file

@ -54,6 +54,7 @@ import { useLicense } from '../hooks/use_license';
import { PromptEditor } from '../prompt_editor/prompt_editor';
import { useKibana } from '../hooks/use_kibana';
import { ChatBanner } from './chat_banner';
import { useConversationContextMenu } from '../hooks';
const fullHeightClassName = css`
height: 100%;
@ -229,6 +230,7 @@ export function ChatBody({
namespace,
public: isPublic,
'@timestamp': timestamp,
archived,
} = conversation.value;
const conversationWithoutMessagesAndTitle: ChatFeedback['conversation'] = {
@ -238,6 +240,7 @@ export function ChatBody({
numeric_labels: numericLabels,
namespace,
public: isPublic,
archived,
conversation: { id, last_updated: lastUpdated },
};
@ -364,27 +367,42 @@ export function ChatBody({
refreshConversations();
};
const { copyConversationToClipboard, copyUrl, deleteConversation, archiveConversation } =
useConversationContextMenu({
setIsUpdatingConversationList,
refreshConversations,
});
const handleArchiveConversation = async (id: string, isArchived: boolean) => {
await archiveConversation(id, isArchived);
conversation.refresh();
};
const isPublic = conversation.value?.public;
const showPromptEditor = !isPublic || isConversationOwnedByCurrentUser;
const bannerTitle = i18n.translate('xpack.aiAssistant.shareBanner.title', {
const isArchived = !!conversation.value?.archived;
const showPromptEditor = !isArchived && (!isPublic || isConversationOwnedByCurrentUser);
const sharedBannerTitle = i18n.translate('xpack.aiAssistant.shareBanner.title', {
defaultMessage: 'This conversation is shared with your team.',
});
const viewerDescription = i18n.translate('xpack.aiAssistant.banner.viewerDescription', {
defaultMessage:
"You can't edit or continue this conversation, but you can duplicate it into a new private conversation. The original conversation will remain unchanged.",
});
const duplicateButton = i18n.translate('xpack.aiAssistant.duplicateButton', {
defaultMessage: 'Duplicate',
});
let sharedBanner = null;
if (isPublic && !isConversationOwnedByCurrentUser) {
sharedBanner = (
<ChatBanner
title={bannerTitle}
description={i18n.translate('xpack.aiAssistant.shareBanner.viewerDescription', {
defaultMessage:
"You can't edit or continue this conversation, but you can duplicate it into a new private conversation. The original conversation will remain unchanged.",
})}
title={sharedBannerTitle}
description={viewerDescription}
button={
<EuiButton onClick={duplicateConversation} iconType="copy" size="s">
{i18n.translate('xpack.aiAssistant.duplicateButton', {
defaultMessage: 'Duplicate',
})}
{duplicateButton}
</EuiButton>
}
/>
@ -392,7 +410,7 @@ export function ChatBody({
} else if (isConversationOwnedByCurrentUser && isPublic) {
sharedBanner = (
<ChatBanner
title={bannerTitle}
title={sharedBannerTitle}
description={i18n.translate('xpack.aiAssistant.shareBanner.ownerDescription', {
defaultMessage:
'Any further edits you do to this conversation will be shared with the rest of the team.',
@ -401,6 +419,48 @@ export function ChatBody({
);
}
let archivedBanner = null;
const archivedBannerTitle = i18n.translate('xpack.aiAssistant.archivedBanner.title', {
defaultMessage: 'This conversation has been archived.',
});
if (isConversationOwnedByCurrentUser) {
archivedBanner = (
<ChatBanner
title={archivedBannerTitle}
icon="folderOpen"
description={i18n.translate('xpack.aiAssistant.archivedBanner.ownerDescription', {
defaultMessage:
"You can't edit or continue this conversation as it's been archived, but you can unarchive it.",
})}
button={
<EuiButton
onClick={() => handleArchiveConversation(conversationId!, !isArchived)}
iconType="folderOpen"
size="s"
>
{i18n.translate('xpack.aiAssistant.unarchiveButton', {
defaultMessage: 'Unarchive',
})}
</EuiButton>
}
/>
);
} else {
archivedBanner = (
<ChatBanner
title={archivedBannerTitle}
icon="folderOpen"
description={viewerDescription}
button={
<EuiButton onClick={duplicateConversation} iconType="copy" size="s">
{duplicateButton}
</EuiButton>
}
/>
);
}
let footer: React.ReactNode;
if (!hasCorrectLicense && !initialConversationId) {
footer = (
@ -483,6 +543,7 @@ export function ChatBody({
}
onStopGenerating={stop}
onActionClick={handleActionClick}
isArchived={isArchived}
/>
)}
</EuiPanel>
@ -496,12 +557,13 @@ export function ChatBody({
) : null}
<>
{conversationId ? sharedBanner : null}
{conversationId && !isArchived ? sharedBanner : null}
{conversationId && isArchived ? archivedBanner : null}
{showPromptEditor ? (
<EuiFlexItem
grow={false}
className={promptEditorClassname(euiTheme)}
style={{ height: promptEditorHeight }}
css={{ height: promptEditorHeight }}
>
<EuiHorizontalRule margin="none" />
<EuiPanel
@ -605,11 +667,13 @@ export function ChatBody({
navigateToConversation={
initialMessages?.length && !initialConversationId ? undefined : navigateToConversation
}
setIsUpdatingConversationList={setIsUpdatingConversationList}
refreshConversations={refreshConversations}
updateDisplayedConversation={updateDisplayedConversation}
handleConversationAccessUpdate={handleConversationAccessUpdate}
isConversationOwnedByCurrentUser={isConversationOwnedByCurrentUser}
copyConversationToClipboard={copyConversationToClipboard}
copyUrl={copyUrl}
deleteConversation={deleteConversation}
handleArchiveConversation={handleArchiveConversation}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>

View file

@ -22,16 +22,19 @@ describe('ChatContextMenu', () => {
const onCopyUrlClick = jest.fn();
const onDeleteClick = jest.fn();
const onDuplicateConversationClick = jest.fn();
const onArchiveConversation = jest.fn();
const renderComponent = (props = {}) =>
render(
<ChatContextMenu
isConversationOwnedByCurrentUser={true}
conversationTitle="Test Conversation"
isArchived={false}
onCopyToClipboardClick={onCopyToClipboardClick}
onCopyUrlClick={onCopyUrlClick}
onDeleteClick={onDeleteClick}
onDuplicateConversationClick={onDuplicateConversationClick}
onArchiveConversation={onArchiveConversation}
{...props}
/>
);
@ -49,8 +52,7 @@ describe('ChatContextMenu', () => {
it('opens the popover on button click', () => {
renderComponent();
const button = screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon');
fireEvent.click(button);
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
expect(screen.getByText('Copy to clipboard')).toBeInTheDocument();
expect(screen.getByText('Duplicate')).toBeInTheDocument();
});
@ -76,6 +78,20 @@ describe('ChatContextMenu', () => {
expect(onDuplicateConversationClick).toHaveBeenCalled();
});
it('calls onArchiveConversation when Archive is clicked (not archived)', () => {
renderComponent({ isArchived: false });
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
fireEvent.click(screen.getByText('Archive'));
expect(onArchiveConversation).toHaveBeenCalled();
});
it('calls onArchiveConversation when Unarchive is clicked (already archived)', () => {
renderComponent({ isArchived: true });
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
fireEvent.click(screen.getByText('Unarchive'));
expect(onArchiveConversation).toHaveBeenCalled();
});
it('calls onDeleteClick when delete is confirmed', async () => {
renderComponent();
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
@ -97,10 +113,12 @@ describe('ChatContextMenu', () => {
await waitFor(() => expect(onDeleteClick).not.toHaveBeenCalled());
});
it('does not render delete option if isConversationOwnedByCurrentUser is false', () => {
it('does not render delete or archive options if isConversationOwnedByCurrentUser is false', () => {
renderComponent({ isConversationOwnedByCurrentUser: false });
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
expect(screen.queryByText('Archive')).not.toBeInTheDocument();
expect(screen.queryByText('Unarchive')).not.toBeInTheDocument();
});
it('disables button when disabled prop is true', () => {

View file

@ -24,18 +24,22 @@ export function ChatContextMenu({
disabled = false,
isConversationOwnedByCurrentUser,
conversationTitle,
isArchived,
onCopyToClipboardClick,
onCopyUrlClick,
onDeleteClick,
onDuplicateConversationClick,
onArchiveConversation,
}: {
disabled?: boolean;
isConversationOwnedByCurrentUser: boolean;
conversationTitle: string;
isArchived: boolean;
onCopyToClipboardClick: () => void;
onCopyUrlClick: () => void;
onDeleteClick: () => void;
onDuplicateConversationClick: () => void;
onArchiveConversation: () => void;
}) {
const { euiTheme } = useEuiTheme();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@ -92,6 +96,29 @@ export function ChatContextMenu({
];
if (isConversationOwnedByCurrentUser) {
menuItems.push(
<EuiContextMenuItem
key="archive"
icon="folderOpen"
onClick={() => {
onArchiveConversation();
setIsPopoverOpen(false);
}}
data-test-subj={
!isArchived
? 'observabilityAiAssistantContextMenuArchive'
: 'observabilityAiAssistantContextMenuUnarchive'
}
>
{!isArchived
? i18n.translate('xpack.aiAssistant.chatHeader.contextMenu.archiveConversation', {
defaultMessage: 'Archive',
})
: i18n.translate('xpack.aiAssistant.chatHeader.contextMenu.unarchiveConversation', {
defaultMessage: 'Unarchive',
})}
</EuiContextMenuItem>
);
menuItems.push(<EuiHorizontalRule key="seperator" margin="none" />);
menuItems.push(
<EuiContextMenuItem

View file

@ -310,7 +310,7 @@ export function ChatFlyout({
</EuiFlexItem>
<EuiFlexItem
style={{
css={{
maxWidth: isSecondSlotVisible ? SIDEBAR_WIDTH : 0,
paddingTop: '56px',
}}

View file

@ -27,7 +27,6 @@ import type { UseGenAIConnectorsResult } from '../hooks/use_genai_connectors';
import { FlyoutPositionMode } from './chat_flyout';
import { ChatSharingMenu } from './chat_sharing_menu';
import { ChatContextMenu } from './chat_context_menu';
import { useConversationContextMenu } from '../hooks/use_conversation_context_menu';
// needed to prevent InlineTextEdit component from expanding container
const minWidthClassName = css`
@ -62,10 +61,12 @@ export function ChatHeader({
onSaveTitle,
onToggleFlyoutPositionMode,
navigateToConversation,
setIsUpdatingConversationList,
refreshConversations,
updateDisplayedConversation,
handleConversationAccessUpdate,
copyConversationToClipboard,
copyUrl,
deleteConversation,
handleArchiveConversation,
}: {
connectors: UseGenAIConnectorsResult;
conversationId?: string;
@ -79,10 +80,12 @@ export function ChatHeader({
onSaveTitle: (title: string) => void;
onToggleFlyoutPositionMode?: (newFlyoutPositionMode: FlyoutPositionMode) => void;
navigateToConversation?: (nextConversationId?: string) => void;
setIsUpdatingConversationList: (isUpdating: boolean) => void;
refreshConversations: () => void;
updateDisplayedConversation: (id?: string) => void;
handleConversationAccessUpdate: (access: ConversationAccess) => Promise<void>;
deleteConversation: (id: string) => Promise<void>;
copyConversationToClipboard: (conversation: Conversation) => void;
copyUrl: (id: string) => void;
handleArchiveConversation: (id: string, isArchived: boolean) => Promise<void>;
}) {
const theme = useEuiTheme();
const breakpoint = useCurrentEuiBreakpoint();
@ -103,11 +106,6 @@ export function ChatHeader({
}
};
const { copyConversationToClipboard, copyUrl, deleteConversation } = useConversationContextMenu({
setIsUpdatingConversationList,
refreshConversations,
});
return (
<EuiPanel
borderRadius="none"
@ -138,9 +136,11 @@ export function ChatHeader({
size={breakpoint === 'xs' ? 'xs' : 's'}
value={newTitle}
className={css`
color: ${!!title
? theme.euiTheme.colors.textParagraph
: theme.euiTheme.colors.textSubdued};
.euiTitle {
color: ${!conversation?.archived
? theme.euiTheme.colors.textParagraph
: theme.euiTheme.colors.textSubdued};
}
`}
inputAriaLabel={i18n.translate(
'xpack.aiAssistant.chatHeader.editConversationInput',
@ -153,7 +153,8 @@ export function ChatHeader({
!connectors.selectedConnector ||
licenseInvalid ||
!Boolean(onSaveTitle) ||
!isConversationOwnedByCurrentUser
!isConversationOwnedByCurrentUser ||
conversation?.archived
}
onChange={(e) => {
setNewTitle(e.currentTarget.nodeValue || '');
@ -175,6 +176,7 @@ export function ChatHeader({
<EuiFlexItem grow={false}>
<ChatSharingMenu
isPublic={conversation.public}
isArchived={!!conversation.archived}
onChangeConversationAccess={handleConversationAccessUpdate}
disabled={licenseInvalid || !isConversationOwnedByCurrentUser}
/>
@ -190,6 +192,10 @@ export function ChatHeader({
isConversationOwnedByCurrentUser={isConversationOwnedByCurrentUser}
onDuplicateConversationClick={onDuplicateConversation}
conversationTitle={conversation.conversation.title}
onArchiveConversation={() =>
handleArchiveConversation(conversationId, !conversation.archived)
}
isArchived={!!conversation.archived}
/>
</EuiFlexItem>
</>

View file

@ -17,6 +17,7 @@ describe('ChatSharingMenu', () => {
render(
<ChatSharingMenu
isPublic={false}
isArchived={false}
disabled={false}
onChangeConversationAccess={mockOnChangeConversationAccess}
{...props}
@ -108,4 +109,23 @@ describe('ChatSharingMenu', () => {
).not.toBeInTheDocument()
);
});
it('renders archived state with correct badges', () => {
renderComponent({ isArchived: true });
const accessBadge = screen.getByTestId('observabilityAiAssistantChatAccessBadge');
const archivedBadge = screen.getByTestId('observabilityAiAssistantArchivedBadge');
expect(accessBadge).toBeInTheDocument();
expect(archivedBadge).toBeInTheDocument();
expect(archivedBadge).toHaveTextContent('Archived');
});
it('does not open the popover when archived', () => {
renderComponent({ isArchived: true });
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatAccessBadge'));
expect(screen.queryByText('This conversation is only visible to you.')).not.toBeInTheDocument();
});
});

View file

@ -22,6 +22,16 @@ import { i18n } from '@kbn/i18n';
import { ConversationAccess } from '@kbn/observability-ai-assistant-plugin/public';
import { css } from '@emotion/css';
const iconOnlyBadgeStyle = css`
.euiBadge__icon {
width: 12px;
height: 12px;
}
padding: 0px 6px;
line-height: 1;
`;
interface OptionData {
description?: string;
}
@ -36,10 +46,12 @@ const sharedLabel = i18n.translate('xpack.aiAssistant.chatHeader.shareOptions.sh
export function ChatSharingMenu({
isPublic,
isArchived,
disabled,
onChangeConversationAccess,
}: {
isPublic: boolean;
isArchived: boolean;
disabled: boolean;
onChangeConversationAccess: (access: ConversationAccess) => Promise<void>;
}) {
@ -123,6 +135,33 @@ export function ChatSharingMenu({
);
}
if (isArchived) {
return (
<EuiFlexGroup gutterSize="xs" responsive={false} alignItems="center" wrap={false}>
<EuiFlexItem grow={false}>
<EuiBadge
iconType={selectedValue === ConversationAccess.SHARED ? 'users' : 'lock'}
color="hollow"
className={iconOnlyBadgeStyle}
data-test-subj="observabilityAiAssistantChatAccessBadge"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge
iconType="folderOpen"
color="default"
data-test-subj="observabilityAiAssistantArchivedBadge"
>
{i18n.translate('xpack.aiAssistant.chatHeader.archivedBadge', {
defaultMessage: 'Archived',
})}
</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
);
}
if (disabled) {
return (
<EuiBadge

View file

@ -53,6 +53,7 @@ export interface ChatTimelineProps {
hasConnector: boolean;
chatState: ChatState;
isConversationOwnedByCurrentUser: boolean;
isArchived: boolean;
currentUser?: Pick<AuthenticatedUser, 'full_name' | 'username'>;
onEdit: (message: Message, messageAfterEdit: Message) => void;
onFeedback: (feedback: Feedback) => void;
@ -75,6 +76,7 @@ export function ChatTimeline({
hasConnector,
currentUser,
isConversationOwnedByCurrentUser,
isArchived,
onEdit,
onFeedback,
onRegenerate,
@ -93,6 +95,7 @@ export function ChatTimeline({
isConversationOwnedByCurrentUser,
chatState,
onActionClick,
isArchived,
});
const consolidatedChatItems: Array<ChatTimelineItem | ChatTimelineItem[]> = [];
@ -123,6 +126,7 @@ export function ChatTimeline({
currentUser,
chatState,
isConversationOwnedByCurrentUser,
isArchived,
onActionClick,
]);

View file

@ -116,50 +116,83 @@ describe('ConversationList', () => {
(useConversationsByDate as jest.Mock).mockReturnValue(mockCategorizedConversations);
});
it('renders the component without errors', () => {
it('renders conversations and archived sections properly', () => {
render(<ConversationList {...defaultProps} />);
const todayCategoryLabel = screen.getByText(/today/i, {
selector: 'div.euiText',
});
expect(todayCategoryLabel).toBeInTheDocument();
const yesterdayCategoryLabel = screen.getByText(/yesterday/i, {
selector: 'div.euiText',
});
expect(yesterdayCategoryLabel).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
expect(
screen.queryByText(
i18n.translate('xpack.aiAssistant.conversationList.errorMessage', {
defaultMessage: 'Failed to load',
screen.getByText(
i18n.translate('xpack.aiAssistant.conversationList.conversationsTitle', {
defaultMessage: 'Conversations',
})
)
).not.toBeInTheDocument();
).toBeInTheDocument();
expect(
screen.queryByText(
i18n.translate('xpack.aiAssistant.conversationList.noConversations', {
defaultMessage: 'No conversations',
screen.getByText(
i18n.translate('xpack.aiAssistant.conversationList.archivedTitle', {
defaultMessage: 'Archived',
})
)
).not.toBeInTheDocument();
expect(screen.getByTestId('observabilityAiAssistantNewChatButton')).toBeInTheDocument();
).toBeInTheDocument();
});
it('displays loading state', () => {
render(<ConversationList {...defaultProps} isLoading={true} />);
it('toggles conversations and archived sections correctly', async () => {
render(<ConversationList {...defaultProps} />);
const conversationsHeader = screen.getByText(/Conversations/i);
const archivedHeader = screen.getByText(/Archived/i);
fireEvent.click(archivedHeader);
expect(
screen.getByText(
i18n.translate('xpack.aiAssistant.conversationList.archivedTitle', {
defaultMessage: 'Archived',
})
)
).toBeInTheDocument();
fireEvent.click(conversationsHeader);
expect(
screen.getByText(
i18n.translate('xpack.aiAssistant.conversationList.conversationsTitle', {
defaultMessage: 'Conversations',
})
)
).toBeInTheDocument();
});
it('renders categorized conversations inside collapsible sections', () => {
render(<ConversationList {...defaultProps} />);
const todayLabels = screen.getAllByText(DATE_CATEGORY_LABELS.TODAY);
expect(todayLabels.length).toBeGreaterThan(0);
const conversationLinks = screen.getAllByText("Today's Conversation");
expect(conversationLinks.length).toBeGreaterThan(0);
});
it('calls onConversationSelect when a conversation is clicked', () => {
render(<ConversationList {...defaultProps} />);
const conversationLinks = screen.getAllByText("Today's Conversation");
fireEvent.click(conversationLinks[0]);
expect(defaultProps.onConversationSelect).toHaveBeenCalledWith('1');
});
it('displays loading state when isLoading is true and no conversations', () => {
render(
<ConversationList
{...defaultProps}
isLoading={true}
conversations={{ ...mockConversations, value: { conversations: [] } }}
/>
);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
it('displays error state', () => {
const errorProps = {
...defaultProps,
conversations: { ...mockConversations, error: new Error('An error occurred') },
};
render(<ConversationList {...errorProps} />);
it('displays error state when error is set', () => {
render(
<ConversationList
{...defaultProps}
conversations={{ ...mockConversations, error: new Error('An error occurred') }}
/>
);
expect(
screen.getByText(
i18n.translate('xpack.aiAssistant.conversationList.errorMessage', {
@ -169,28 +202,24 @@ describe('ConversationList', () => {
).toBeInTheDocument();
});
it('renders categorized conversations', () => {
render(<ConversationList {...defaultProps} />);
Object.entries(mockCategorizedConversations).forEach(([category, conversationList]) => {
if (conversationList.length > 0) {
expect(screen.getByText(DATE_CATEGORY_LABELS[category])).toBeInTheDocument();
conversationList.forEach((conversation) => {
expect(screen.getByText(conversation.label)).toBeInTheDocument();
});
}
});
it('renders "no conversations" message when conversation list is empty', () => {
render(
<ConversationList
{...defaultProps}
conversations={{ ...mockConversations, value: { conversations: [] } }}
/>
);
expect(
screen.getByText(
i18n.translate('xpack.aiAssistant.conversationList.noConversations', {
defaultMessage: 'No conversations',
})
)
).toBeInTheDocument();
});
it('calls onConversationSelect when a conversation is clicked', () => {
render(<ConversationList {...defaultProps} />);
const todayConversation = screen.getByText("Today's Conversation");
fireEvent.click(todayConversation);
expect(defaultProps.onConversationSelect).toHaveBeenCalledWith('1');
});
it('calls delete conversation when delete icon is clicked', async () => {
it('triggers delete flow when delete icon is clicked and confirmed', async () => {
const mockDeleteConversation = jest.fn(() => Promise.resolve());
(useConversationContextMenu as jest.Mock).mockReturnValue({
deleteConversation: mockDeleteConversation,
});
@ -198,27 +227,39 @@ describe('ConversationList', () => {
render(<ConversationList {...defaultProps} />);
fireEvent.click(screen.getAllByLabelText('Delete')[0]);
await waitFor(() => expect(mockDeleteConversation).toHaveBeenCalledTimes(1));
expect(mockDeleteConversation).toHaveBeenCalledWith('1');
await waitFor(() => {
expect(mockDeleteConversation).toHaveBeenCalledWith('1');
});
});
it('renders a new chat button and triggers onConversationSelect when clicked', () => {
it('renders new chat button and triggers onConversationSelect', () => {
render(<ConversationList {...defaultProps} />);
const newChatButton = screen.getByTestId('observabilityAiAssistantNewChatButton');
fireEvent.click(newChatButton);
const newChatBtn = screen.getByTestId('observabilityAiAssistantNewChatButton');
fireEvent.click(newChatBtn);
expect(defaultProps.onConversationSelect).toHaveBeenCalledWith(undefined);
});
it('renders "no conversations" message when there are no conversations', () => {
const emptyProps = {
...defaultProps,
conversations: { ...mockConversations, value: { conversations: [] } },
it('defaults to archived section open if selected conversation is archived', () => {
const archivedConversation = {
...mockConversations.value!.conversations[0],
archived: true,
};
render(<ConversationList {...emptyProps} />);
render(
<ConversationList
{...defaultProps}
selectedConversationId="1"
conversations={{
...mockConversations,
value: { conversations: [archivedConversation] },
}}
/>
);
expect(
screen.getByText(
i18n.translate('xpack.aiAssistant.conversationList.noConversations', {
defaultMessage: 'No conversations',
i18n.translate('xpack.aiAssistant.conversationList.archivedTitle', {
defaultMessage: 'Archived',
})
)
).toBeInTheDocument();

View file

@ -6,6 +6,7 @@
*/
import {
EuiCollapsibleNavGroup,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
@ -16,11 +17,12 @@ import {
euiScrollBarStyles,
EuiSpacer,
EuiText,
UseEuiTheme,
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import React, { MouseEvent } from 'react';
import React, { MouseEvent, useEffect, useMemo, useState } from 'react';
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import type { UseConversationListResult } from '../hooks/use_conversation_list';
import { useConfirmModal, useConversationsByDate, useConversationContextMenu } from '../hooks';
@ -29,13 +31,21 @@ import { NewChatButton } from '../buttons/new_chat_button';
import { ConversationListItemLabel } from './conversation_list_item_label';
import { isConversationOwnedByUser } from '../utils/is_conversation_owned_by_current_user';
enum ListSections {
CONVERSATIONS = 'conversations',
ARCHIVED = 'archived',
}
const panelClassName = css`
max-height: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding-top: 56px;
`;
const overflowScrollClassName = (scrollBarStyles: string) => css`
const scrollSectionClass = (scrollBarStyles: string) => css`
overflow-y: auto;
max-height: ${Math.floor(window.innerHeight * 0.7)}px;
${scrollBarStyles}
`;
@ -43,6 +53,16 @@ const newChatButtonWrapperClassName = css`
padding-bottom: 5px;
`;
const titleClassName = css`
text-transform: uppercase;
font-weight: bold;
`;
const containerClassName = (theme: UseEuiTheme) => css`
height: 100%;
border-top: solid 1px ${theme.euiTheme.border.color};
`;
export function ConversationList({
conversations,
isLoading,
@ -69,16 +89,32 @@ export function ConversationList({
const euiTheme = useEuiTheme();
const scrollBarStyles = euiScrollBarStyles(euiTheme);
const containerClassName = css`
height: 100%;
border-top: solid 1px ${euiTheme.euiTheme.border.color};
padding: ${euiTheme.euiTheme.size.s};
`;
const [allConversations, activeConversations, archivedConversations] = useMemo(() => {
const conversationList = conversations.value?.conversations ?? [];
const titleClassName = css`
text-transform: uppercase;
font-weight: ${euiTheme.euiTheme.font.weight.bold};
`;
return [
conversationList,
conversationList.filter((c) => !c.archived),
conversationList.filter((c) => c.archived),
];
}, [conversations.value?.conversations]);
const selectedConversation = useMemo(
() => allConversations.find((c) => c.conversation.id === selectedConversationId),
[allConversations, selectedConversationId]
);
const categorizedActiveConversations = useConversationsByDate(
activeConversations,
getConversationHref
);
const categorizedArchivedConversations = useConversationsByDate(
archivedConversations,
getConversationHref
);
const [openSection, setOpenSection] = useState<ListSections>(ListSections.CONVERSATIONS);
const { element: confirmDeleteElement, confirm: confirmDeleteCallback } = useConfirmModal({
title: i18n.translate('xpack.aiAssistant.flyout.confirmDeleteConversationTitle', {
@ -92,12 +128,6 @@ export function ConversationList({
}),
});
// Categorize conversations by date
const conversationsCategorizedByDate = useConversationsByDate(
conversations.value?.conversations,
getConversationHref
);
const onClickConversation = (
e: MouseEvent<HTMLButtonElement> | MouseEvent<HTMLAnchorElement>,
conversationId?: string
@ -113,125 +143,192 @@ export function ConversationList({
refreshConversations,
});
const renderCategorizedList = (
categorizedConversations: ReturnType<typeof useConversationsByDate>
) => {
return Object.entries(categorizedConversations).map(([category, list]) =>
list.length ? (
<EuiFlexItem grow={false} key={category}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiText className={titleClassName} size="s">
{DATE_CATEGORY_LABELS[category]}
</EuiText>
</EuiPanel>
<EuiListGroup flush={false} gutterSize="none">
{list.map((conversation) => (
<EuiListGroupItem
data-test-subj="observabilityAiAssistantConversationsLink"
key={conversation.id}
label={
<ConversationListItemLabel
labelText={conversation.label}
isPublic={conversation.public}
/>
}
size="s"
isActive={conversation.id === selectedConversationId}
isDisabled={isLoading}
showToolTip
toolTipText={conversation.label}
href={conversation.href}
onClick={(event) => onClickConversation(event, conversation.id)}
extraAction={{
iconType: 'trash',
color: 'danger',
'aria-label': i18n.translate(
'xpack.aiAssistant.conversationList.deleteConversationIconLabel',
{ defaultMessage: 'Delete' }
),
disabled: !isConversationOwnedByUser({
conversationId: conversation.id,
conversationUser: conversation.conversation.user,
currentUser,
}),
onClick: () => {
confirmDeleteCallback(
i18n.translate('xpack.aiAssistant.flyout.confirmDeleteCheckboxLabel', {
defaultMessage: 'Delete "{title}"',
values: { title: conversation.label },
})
).then((confirmed) => {
if (!confirmed) return;
deleteConversation(conversation.id).then(() => {
if (conversation.id === selectedConversationId) {
updateDisplayedConversation();
}
});
});
},
}}
/>
))}
</EuiListGroup>
<EuiSpacer size="s" />
</EuiFlexItem>
) : null
);
};
useEffect(() => {
if (selectedConversation?.archived) {
setOpenSection(ListSections.ARCHIVED);
} else {
setOpenSection(ListSections.CONVERSATIONS);
}
}, [selectedConversation]);
const loader = (
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="s" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
return (
<>
<EuiPanel paddingSize="none" hasShadow={false} className={panelClassName}>
<EuiFlexGroup direction="column" gutterSize="none" className={containerClassName}>
<EuiFlexItem grow className={overflowScrollClassName(scrollBarStyles)}>
<EuiFlexGroup direction="column" gutterSize="xs">
{isLoading ? (
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="s" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
) : null}
{conversations.error ? (
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiFlexGroup direction="row" alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type="warning" color="danger" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="danger">
{i18n.translate('xpack.aiAssistant.conversationList.errorMessage', {
defaultMessage: 'Failed to load',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
) : null}
{/* Render conversations categorized by date */}
{Object.entries(conversationsCategorizedByDate).map(([category, conversationList]) =>
conversationList.length ? (
<EuiFlexItem grow={false} key={category}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiText className={titleClassName} size="s">
{DATE_CATEGORY_LABELS[category]}
</EuiText>
</EuiPanel>
<EuiListGroup flush={false} gutterSize="none">
{conversationList.map((conversation) => (
<EuiListGroupItem
data-test-subj="observabilityAiAssistantConversationsLink"
key={conversation.id}
label={
<ConversationListItemLabel
labelText={conversation.label}
isPublic={conversation.public}
/>
}
size="s"
isActive={conversation.id === selectedConversationId}
isDisabled={isLoading}
showToolTip
toolTipText={conversation.label}
href={conversation.href}
onClick={(event) => onClickConversation(event, conversation.id)}
extraAction={{
iconType: 'trash',
color: 'danger',
'aria-label': i18n.translate(
'xpack.aiAssistant.conversationList.deleteConversationIconLabel',
{
defaultMessage: 'Delete',
}
),
disabled: !isConversationOwnedByUser({
conversationId: conversation.id,
conversationUser: conversation.conversation.user,
currentUser,
}),
onClick: () => {
confirmDeleteCallback(
i18n.translate(
'xpack.aiAssistant.flyout.confirmDeleteCheckboxLabel',
{
defaultMessage: 'Delete "{title}"',
values: { title: conversation.label },
}
)
).then((confirmed) => {
if (!confirmed) {
return;
}
deleteConversation(conversation.id).then(() => {
if (conversation.id === selectedConversationId) {
updateDisplayedConversation();
}
});
});
},
}}
/>
))}
</EuiListGroup>
<EuiSpacer size="s" />
<EuiFlexGroup direction="column" gutterSize="none" className={containerClassName(euiTheme)}>
{isLoading && !allConversations.length ? (
<EuiFlexItem grow={true}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="m">
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="s" />
</EuiFlexItem>
) : null
)}
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
) : null}
{!isLoading && !conversations.error && !conversations.value?.conversations?.length ? (
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiText color="subdued" size="s">
{i18n.translate('xpack.aiAssistant.conversationList.noConversations', {
defaultMessage: 'No conversations',
})}
</EuiText>
</EuiPanel>
) : null}
</EuiFlexGroup>
</EuiFlexItem>
{conversations.error ? (
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="m">
<EuiFlexGroup direction="row" alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type="warning" color="danger" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="danger">
{i18n.translate('xpack.aiAssistant.conversationList.errorMessage', {
defaultMessage: 'Failed to load',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
) : null}
{!isLoading && !conversations.error && !allConversations?.length ? (
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="m">
<EuiText color="subdued" size="s">
{i18n.translate('xpack.aiAssistant.conversationList.noConversations', {
defaultMessage: 'No conversations',
})}
</EuiText>
</EuiPanel>
) : null}
{!conversations.error && allConversations?.length ? (
<>
<EuiFlexItem grow>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem grow={openSection === ListSections.CONVERSATIONS}>
<EuiCollapsibleNavGroup
isCollapsible
title={i18n.translate(
'xpack.aiAssistant.conversationList.conversationsTitle',
{
defaultMessage: 'Conversations',
}
)}
titleSize="xs"
iconType="list"
iconSize="m"
onToggle={(isOpen) =>
setOpenSection(isOpen ? ListSections.CONVERSATIONS : ListSections.ARCHIVED)
}
forceState={openSection === ListSections.CONVERSATIONS ? 'open' : 'closed'}
>
<div className={scrollSectionClass(scrollBarStyles)}>
{isLoading ? loader : null}
<EuiFlexGroup direction="column" gutterSize="xs">
{renderCategorizedList(categorizedActiveConversations)}
</EuiFlexGroup>
</div>
</EuiCollapsibleNavGroup>
</EuiFlexItem>
<EuiFlexItem grow={openSection === ListSections.ARCHIVED}>
<EuiCollapsibleNavGroup
isCollapsible
title={i18n.translate('xpack.aiAssistant.conversationList.archivedTitle', {
defaultMessage: 'Archived',
})}
titleSize="xs"
iconType="folderOpen"
iconSize="m"
onToggle={(isOpen) =>
setOpenSection(isOpen ? ListSections.ARCHIVED : ListSections.CONVERSATIONS)
}
forceState={openSection === ListSections.ARCHIVED ? 'open' : 'closed'}
borders="horizontal"
>
<div className={scrollSectionClass(scrollBarStyles)}>
{isLoading ? loader : null}
<EuiFlexGroup direction="column" gutterSize="xs">
{renderCategorizedList(categorizedArchivedConversations)}
</EuiFlexGroup>
</div>
</EuiCollapsibleNavGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</>
) : null}
<EuiFlexItem grow={false}>
<EuiPanel paddingSize="s" hasBorder={false} hasShadow={false}>

View file

@ -32,14 +32,10 @@ const mockHttp = {
};
const useKibanaMockServices = {
uiSettings: {
get: jest.fn(),
},
uiSettings: { get: jest.fn() },
notifications: mockNotifications,
http: mockHttp,
observabilityAIAssistant: {
service: mockService,
},
observabilityAIAssistant: { service: mockService },
};
describe('useConversationContextMenu', () => {
@ -104,6 +100,7 @@ describe('useConversationContextMenu', () => {
expect(mockNotifications.toasts.addError).toHaveBeenCalledWith(expect.any(Error), {
title: 'Could not delete conversation',
toastMessage: undefined,
});
});
@ -181,4 +178,72 @@ describe('useConversationContextMenu', () => {
title: 'Could not copy conversation URL',
});
});
it('archives a conversation successfully', async () => {
const archivedConversation = { conversation: { id: '1' }, archived: true };
mockService.callApi.mockResolvedValueOnce(archivedConversation);
const { result } = renderHook(
() => useConversationContextMenu({ setIsUpdatingConversationList, refreshConversations }),
{ wrapper }
);
await act(async () => {
const resultValue = await result.current.archiveConversation('1', true);
expect(resultValue).toEqual(archivedConversation);
});
expect(setIsUpdatingConversationList).toHaveBeenCalledWith(true);
expect(mockService.callApi).toHaveBeenCalledWith(
'PATCH /internal/observability_ai_assistant/conversation/{conversationId}',
{
signal: null,
params: {
path: { conversationId: '1' },
body: { archived: true },
},
}
);
expect(mockNotifications.toasts.addSuccess).toHaveBeenCalledWith({
title: 'Conversation archived successfully',
});
expect(setIsUpdatingConversationList).toHaveBeenCalledWith(false);
});
it('handles errors when archiving a conversation', async () => {
mockService.callApi.mockRejectedValueOnce(new Error('Archive failed'));
const { result } = renderHook(
() => useConversationContextMenu({ setIsUpdatingConversationList, refreshConversations }),
{ wrapper }
);
await act(async () => {
await expect(result.current.archiveConversation('1', true)).rejects.toThrow('Archive failed');
});
expect(mockNotifications.toasts.addError).toHaveBeenCalledWith(expect.any(Error), {
title: 'Could not archive conversation',
});
expect(setIsUpdatingConversationList).toHaveBeenCalledWith(false);
});
it('unarchives a conversation successfully', async () => {
const unarchivedConversation = { conversation: { id: '1' }, archived: false };
mockService.callApi.mockResolvedValueOnce(unarchivedConversation);
const { result } = renderHook(
() => useConversationContextMenu({ setIsUpdatingConversationList, refreshConversations }),
{ wrapper }
);
await act(async () => {
const resultValue = await result.current.archiveConversation('1', false);
expect(resultValue).toEqual(unarchivedConversation);
});
expect(mockNotifications.toasts.addSuccess).toHaveBeenCalledWith({
title: 'Conversation unarchived successfully',
});
});
});

View file

@ -19,6 +19,7 @@ export interface UseConversationContextMenuResult {
deleteConversation: (id: string) => Promise<void>;
copyConversationToClipboard: (conversation: Conversation) => void;
copyUrl: (id: string) => void;
archiveConversation: (id: string, isArchived: boolean) => Promise<Conversation>;
}
export function useConversationContextMenu({
@ -114,9 +115,60 @@ export function useConversationContextMenu({
}
};
const handleArchiveConversation = async (id: string, isArchived: boolean) => {
setIsUpdatingConversationList(true);
try {
const archivedConversation = await service.callApi(
`PATCH /internal/observability_ai_assistant/conversation/{conversationId}`,
{
signal: null,
params: {
path: {
conversationId: id,
},
body: {
archived: isArchived,
},
},
}
);
refreshConversations();
setIsUpdatingConversationList(false);
notifications!.toasts.addSuccess({
title: isArchived
? i18n.translate('xpack.aiAssistant.archiveConversationSuccessToast', {
defaultMessage: 'Conversation archived successfully',
})
: i18n.translate('xpack.aiAssistant.unarchiveConversationSuccessToast', {
defaultMessage: 'Conversation unarchived successfully',
}),
});
return archivedConversation;
} catch (err) {
notifications!.toasts.addError(err, {
title: isArchived
? i18n.translate('xpack.aiAssistant.archiveConversationErrorToast', {
defaultMessage: 'Could not archive conversation',
})
: i18n.translate('xpack.aiAssistant.unarchiveConversationErrorToast', {
defaultMessage: 'Could not unarchive conversation',
}),
});
setIsUpdatingConversationList(false);
throw err;
}
};
return {
deleteConversation: handleDeleteConversation,
copyConversationToClipboard: handleCopyConversationToClipboard,
copyUrl: handleCopyUrl,
archiveConversation: handleArchiveConversation,
};
}

View file

@ -43,6 +43,7 @@ describe('getTimelineItemsFromConversation', () => {
messages: [],
chatState: ChatState.Ready,
onActionClick: jest.fn(),
isArchived: false,
});
expect(items.length).toBe(1);
@ -70,8 +71,10 @@ describe('getTimelineItemsFromConversation', () => {
},
],
onActionClick: jest.fn(),
isArchived: false,
});
});
it('includes the opening message and the user message', () => {
expect(items.length).toBe(2);
expect(items[0].title).toBe('started a conversation');
@ -137,6 +140,7 @@ describe('getTimelineItemsFromConversation', () => {
},
],
onActionClick: jest.fn(),
isArchived: false,
});
});
@ -222,6 +226,7 @@ describe('getTimelineItemsFromConversation', () => {
},
],
onActionClick: jest.fn(),
isArchived: false,
});
});
@ -298,6 +303,7 @@ describe('getTimelineItemsFromConversation', () => {
},
],
onActionClick: jest.fn(),
isArchived: false,
});
});
@ -364,6 +370,7 @@ describe('getTimelineItemsFromConversation', () => {
},
],
onActionClick: jest.fn(),
isArchived: false,
});
});
@ -415,6 +422,7 @@ describe('getTimelineItemsFromConversation', () => {
},
],
onActionClick: jest.fn(),
isArchived: false,
});
});
@ -467,6 +475,7 @@ describe('getTimelineItemsFromConversation', () => {
...extraMessages,
],
onActionClick: jest.fn(),
isArchived: false,
});
};

View file

@ -68,6 +68,7 @@ export function getTimelineItemsfromConversation({
chatState,
isConversationOwnedByCurrentUser,
onActionClick,
isArchived,
}: {
conversationId?: string;
chatService: ObservabilityAIAssistantChatService;
@ -83,6 +84,7 @@ export function getTimelineItemsfromConversation({
message: Message;
payload: ChatActionClickPayload;
}) => void;
isArchived: boolean;
}): ChatTimelineItem[] {
const items: ChatTimelineItem[] = [
{
@ -198,14 +200,14 @@ export function getTimelineItemsfromConversation({
content = convertMessageToMarkdownCodeBlock(message.message);
actions.canEdit = hasConnector && isConversationOwnedByCurrentUser;
actions.canEdit = hasConnector && isConversationOwnedByCurrentUser && !isArchived;
display.collapsed = true;
} else {
// is a prompt by the user
title = '';
content = message.message.content;
actions.canEdit = hasConnector && isConversationOwnedByCurrentUser;
actions.canEdit = hasConnector && isConversationOwnedByCurrentUser && !isArchived;
display.collapsed = false;
}
@ -218,8 +220,8 @@ export function getTimelineItemsfromConversation({
break;
case MessageRole.Assistant:
actions.canRegenerate = hasConnector && isConversationOwnedByCurrentUser;
actions.canGiveFeedback = isConversationOwnedByCurrentUser;
actions.canRegenerate = hasConnector && isConversationOwnedByCurrentUser && !isArchived;
actions.canGiveFeedback = isConversationOwnedByCurrentUser && !isArchived;
display.hide = false;
// is a function suggestion by the assistant
@ -246,7 +248,7 @@ export function getTimelineItemsfromConversation({
display.collapsed = true;
}
actions.canEdit = isConversationOwnedByCurrentUser;
actions.canEdit = isConversationOwnedByCurrentUser && !isArchived;
} else {
// is an assistant response
title = '';

View file

@ -62,6 +62,7 @@ export interface Conversation {
numeric_labels: Record<string, number>;
namespace: string;
public: boolean;
archived?: boolean;
}
type ConversationRequestBase = Omit<Conversation, 'user' | 'conversation' | 'namespace'> & {

View file

@ -91,6 +91,13 @@ export const chatFeedbackEventSchema: EventTypeOpts<ChatFeedback> = {
description: 'Whether the conversation is public or not.',
},
},
archived: {
type: 'boolean',
_meta: {
description: 'Whether the conversation is archived or not.',
optional: true,
},
},
},
},
},

View file

@ -191,6 +191,7 @@ const patchConversationRoute = createObservabilityAIAssistantServerRoute({
}),
body: t.partial({
public: t.boolean,
archived: t.boolean,
}),
}),
security: {

View file

@ -68,6 +68,7 @@ export const conversationCreateRt: t.Type<ConversationCreateRequest> = t.interse
}),
t.partial({
systemMessage: t.string,
archived: toBooleanRt,
}),
]);

View file

@ -456,6 +456,7 @@ describe('Observability AI Assistant client', () => {
labels: {},
numeric_labels: {},
public: false,
archived: false,
namespace: 'default',
user: {
name: 'johndoe',

View file

@ -388,6 +388,7 @@ export class ObservabilityAIAssistantClient {
numeric_labels: {},
systemMessage,
messages: initialMessagesWithAddedMessages,
archived: false,
})
).pipe(
map((conversationCreated): ConversationCreateEvent => {
@ -611,7 +612,7 @@ export class ObservabilityAIAssistantClient {
updates,
}: {
conversationId: string;
updates: Partial<{ public: boolean }>;
updates: Partial<{ public: boolean; archived: boolean }>;
}): Promise<Conversation> => {
const conversation = await this.get(conversationId);
if (!conversation) {
@ -620,6 +621,7 @@ export class ObservabilityAIAssistantClient {
const updatedConversation: Conversation = merge({}, conversation, {
...(updates.public !== undefined && { public: updates.public }),
...(updates.archived !== undefined && { archived: updates.archived }),
});
return this.update(conversationId, updatedConversation);
@ -639,6 +641,7 @@ export class ObservabilityAIAssistantClient {
id: v4(),
},
public: false,
archived: false,
});
};

View file

@ -8,36 +8,16 @@
import expect from '@kbn/expect';
import { merge, omit } from 'lodash';
import {
type ConversationCreateRequest,
type ConversationUpdateRequest,
MessageRole,
Conversation,
} from '@kbn/observability-ai-assistant-plugin/common/types';
import type { SupertestReturnType } from '../../../../services/observability_ai_assistant_api';
import type { DeploymentAgnosticFtrProviderContext } from '../../../../ftr_provider_context';
import { clearConversations, conversationCreate, createConversation } from '../utils/conversation';
export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderContext) {
const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantApi');
const conversationCreate: ConversationCreateRequest = {
'@timestamp': new Date().toISOString(),
conversation: {
title: 'My title',
},
labels: {},
numeric_labels: {},
systemMessage: 'this is a system message',
messages: [
{
'@timestamp': new Date().toISOString(),
message: {
role: MessageRole.User,
content: 'My message',
},
},
],
public: false,
};
const es = getService('es');
const conversationUpdate: ConversationUpdateRequest = merge({}, conversationCreate, {
conversation: {
@ -90,15 +70,9 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
let createResponse: Awaited<
SupertestReturnType<'POST /internal/observability_ai_assistant/conversation'>
>;
before(async () => {
createResponse = await observabilityAIAssistantAPIClient.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversation',
params: {
body: {
conversation: conversationCreate,
},
},
});
createResponse = await createConversation({ observabilityAIAssistantAPIClient });
expect(createResponse.status).to.be(200);
});
@ -141,6 +115,7 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
messages: conversationCreate.messages,
namespace: 'default',
public: conversationCreate.public,
archived: conversationCreate.archived,
});
});
@ -582,15 +557,9 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
let createResponse: Awaited<
SupertestReturnType<'POST /internal/observability_ai_assistant/conversation'>
>;
before(async () => {
createResponse = await observabilityAIAssistantAPIClient.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversation',
params: {
body: {
conversation: conversationCreate,
},
},
});
createResponse = await createConversation({ observabilityAIAssistantAPIClient });
expect(createResponse.status).to.be(200);
});
@ -607,13 +576,9 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
});
it('POST /internal/observability_ai_assistant/conversation', async () => {
const { status } = await observabilityAIAssistantAPIClient.viewer({
endpoint: 'POST /internal/observability_ai_assistant/conversation',
params: {
body: {
conversation: conversationCreate,
},
},
const { status } = await createConversation({
observabilityAIAssistantAPIClient,
user: 'viewer',
});
expect(status).to.be(403);
@ -675,27 +640,14 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
'PATCH /internal/observability_ai_assistant/conversation/{conversationId}';
before(async () => {
const { status, body } = await observabilityAIAssistantAPIClient.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversation',
params: {
body: {
conversation: conversationCreate,
},
},
});
const { status, body } = await createConversation({ observabilityAIAssistantAPIClient });
expect(status).to.be(200);
createdConversationId = body.conversation.id;
});
after(async () => {
const { status } = await observabilityAIAssistantAPIClient.editor({
endpoint: 'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: { conversationId: createdConversationId },
},
});
expect(status).to.be(200);
await clearConversations(es);
});
it('allows the owner to update the access of their conversation', async () => {
@ -756,14 +708,7 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
before(async () => {
// Create a conversation to delete
const { status, body } = await observabilityAIAssistantAPIClient.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversation',
params: {
body: {
conversation: conversationCreate,
},
},
});
const { status, body } = await createConversation({ observabilityAIAssistantAPIClient });
expect(status).to.be(200);
createdConversationId = body.conversation.id;
@ -792,15 +737,7 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
it('does not allow a different user (admin) to delete a conversation they do not own', async () => {
// Create another conversation (editor)
const { body } = await observabilityAIAssistantAPIClient.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversation',
params: {
body: {
conversation: conversationCreate,
},
},
});
const { body } = await createConversation({ observabilityAIAssistantAPIClient });
const unauthorizedConversationId = body.conversation.id;
// try deleting as an admin
@ -824,5 +761,73 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
expect(ownerDeleteResponse.status).to.be(200);
});
});
describe('conversation archiving', () => {
let createdConversationId: string;
before(async () => {
const { status, body } = await createConversation({
observabilityAIAssistantAPIClient,
isPublic: true,
});
expect(status).to.be(200);
createdConversationId = body.conversation.id;
});
after(async () => {
await clearConversations(es);
});
it('allows the owner to archive a conversation', async () => {
const archiveResponse = await observabilityAIAssistantAPIClient.editor({
endpoint: 'PATCH /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: { conversationId: createdConversationId },
body: { archived: true },
},
});
expect(archiveResponse.status).to.be(200);
expect(archiveResponse.body.archived).to.be(true);
});
it('allows the owner to unarchive a conversation', async () => {
const unarchiveResponse = await observabilityAIAssistantAPIClient.editor({
endpoint: 'PATCH /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: { conversationId: createdConversationId },
body: { archived: false },
},
});
expect(unarchiveResponse.status).to.be(200);
expect(unarchiveResponse.body.archived).to.be(false);
});
it('does not allow a different user (admin) to archive a conversation they do not own', async () => {
const updateResponse = await observabilityAIAssistantAPIClient.admin({
endpoint: 'PATCH /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: { conversationId: createdConversationId },
body: { archived: true },
},
});
expect(updateResponse.status).to.be(403);
});
it('returns 404 when trying to archive a non-existing conversation', async () => {
const response = await observabilityAIAssistantAPIClient.editor({
endpoint: 'PATCH /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: { conversationId: 'non-existing-conversation-id' },
body: { archived: true },
},
});
expect(response.status).to.be(404);
});
});
});
}

View file

@ -14,6 +14,7 @@ import {
MessageRole,
StreamingChatResponseEvent,
StreamingChatResponseEventType,
type ConversationCreateRequest,
} from '@kbn/observability-ai-assistant-plugin/common';
import { Readable } from 'stream';
import type { AssistantScope } from '@kbn/ai-assistant-common';
@ -180,3 +181,45 @@ export function getConversationUpdatedEvent(body: Readable | string) {
return conversationUpdatedEvent;
}
export const conversationCreate: ConversationCreateRequest = {
'@timestamp': new Date().toISOString(),
conversation: {
title: 'My title',
},
labels: {},
numeric_labels: {},
systemMessage: 'this is a system message',
messages: [
{
'@timestamp': new Date().toISOString(),
message: {
role: MessageRole.User,
content: 'My message',
},
},
],
public: false,
archived: false,
};
export async function createConversation({
observabilityAIAssistantAPIClient,
user = 'editor',
isPublic = false,
}: {
observabilityAIAssistantAPIClient: ObservabilityAIAssistantApiClient;
user?: 'admin' | 'editor' | 'viewer';
isPublic?: boolean;
}) {
const response = await observabilityAIAssistantAPIClient[user]({
endpoint: 'POST /internal/observability_ai_assistant/conversation',
params: {
body: {
conversation: { ...conversationCreate, public: isPublic },
},
},
});
return response;
}

View file

@ -60,7 +60,7 @@ async function getTestConfig({
services: {
observabilityAIAssistantUI: (context: InheritedFtrProviderContext) =>
ObservabilityAIAssistantUIProvider(context),
observabilityAIAssistantAPIClient: async (context: InheritedFtrProviderContext) => {
observabilityAIAssistantAPIClient: async () => {
return {
admin: getScopedApiClient(kibanaServer, 'elastic'),
viewer: getScopedApiClient(kibanaServer, viewer.username),

View file

@ -47,6 +47,12 @@ const pages = {
privateOption: 'observabilityAiAssistantChatPrivateOption',
loadingBadge: 'observabilityAiAssistantChatAccessLoadingBadge',
},
contextMenu: {
button: 'observabilityAiAssistantChatContextMenuButtonIcon',
archiveOption: 'observabilityAiAssistantContextMenuArchive',
unarchiveOption: 'observabilityAiAssistantContextMenuUnarchive',
},
archivedBadge: 'observabilityAiAssistantArchivedBadge',
},
createConnectorFlyout: {
flyout: 'create-connector-flyout',
@ -81,7 +87,6 @@ const pages = {
export async function ObservabilityAIAssistantUIProvider({
getPageObjects,
getService,
}: InheritedFtrProviderContext): Promise<ObservabilityAIAssistantUIService> {
const pageObjects = getPageObjects(['common', 'security']);

View file

@ -0,0 +1,144 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import {
createLlmProxy,
LlmProxy,
} from '../../../observability_ai_assistant_api_integration/common/create_llm_proxy';
import { FtrProviderContext } from '../../ftr_provider_context';
import { createConnector, deleteConnectors } from '../../common/connectors';
import { deleteConversations } from '../../common/conversations';
import { interceptRequest } from '../../common/intercept_request';
export default function ApiTest({ getService, getPageObjects }: FtrProviderContext) {
const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantAPIClient');
const ui = getService('observabilityAIAssistantUI');
const testSubjects = getService('testSubjects');
const supertest = getService('supertest');
const log = getService('log');
const retry = getService('retry');
const driver = getService('__webdriver__');
const { header } = getPageObjects(['header', 'security']);
const PageObjects = getPageObjects(['common', 'error', 'navigationalSearch', 'security']);
const expectedTitle = 'My title';
const expectedResponse = 'Hello from LLM Proxy';
async function createConversation(proxy: LlmProxy) {
await PageObjects.common.navigateToUrl('obsAIAssistant', '', {
ensureCurrentUrl: false,
shouldLoginIfPrompted: false,
shouldUseHashForSubUrl: false,
});
void proxy.interceptTitle(expectedTitle);
void proxy.interceptConversation(expectedResponse);
await testSubjects.setValue(ui.pages.conversations.chatInput, 'Hello');
await testSubjects.pressEnter(ui.pages.conversations.chatInput);
await proxy.waitForAllInterceptorsToHaveBeenCalled();
await header.waitUntilLoadingHasFinished();
}
describe('Conversation Archiving', () => {
let proxy: LlmProxy;
before(async () => {
proxy = await createLlmProxy(log);
await ui.auth.login('editor');
await ui.router.goto('/conversations/new', { path: {}, query: {} });
await deleteConnectors(supertest);
await deleteConversations(getService);
await createConnector(proxy, supertest);
await createConversation(proxy);
});
after(async () => {
await deleteConnectors(supertest);
await deleteConversations(getService);
await ui.auth.logout();
proxy.close();
});
it('should display the context menu button', async () => {
await testSubjects.existOrFail(ui.pages.conversations.contextMenu.button);
});
it('should open the context menu on click', async () => {
await testSubjects.click(ui.pages.conversations.contextMenu.button);
await testSubjects.existOrFail(ui.pages.conversations.contextMenu.archiveOption);
await testSubjects.click(ui.pages.conversations.contextMenu.button);
});
describe('when archiving a conversation', () => {
before(async () => {
await interceptRequest(
driver.driver,
'*observability_ai_assistant\\/conversation\\/*',
(responseFactory) => responseFactory.continue(),
async () => {
await testSubjects.click(ui.pages.conversations.contextMenu.button);
await testSubjects.click(ui.pages.conversations.contextMenu.archiveOption);
}
);
});
it('should display the "Archived" badge', async () => {
await retry.try(async () => {
const badgeText = await testSubjects.getVisibleText(ui.pages.conversations.archivedBadge);
expect(badgeText).to.contain('Archived');
});
});
it('should persist the archived state in the backend', async () => {
await retry.try(async () => {
const response = await observabilityAIAssistantAPIClient.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversations',
});
const conversation = response.body.conversations.pop();
expect(conversation?.archived).to.eql(true);
});
});
});
describe('when unarchiving a conversation', () => {
before(async () => {
await interceptRequest(
driver.driver,
'*observability_ai_assistant\\/conversation\\/*',
(responseFactory) => responseFactory.continue(),
async () => {
await testSubjects.click(ui.pages.conversations.contextMenu.button);
await testSubjects.click(ui.pages.conversations.contextMenu.unarchiveOption);
}
);
});
it('should hide the "Archived" badge', async () => {
await retry.try(async () => {
const exists = await testSubjects.exists(ui.pages.conversations.archivedBadge);
expect(exists).to.eql(false);
});
});
it('should persist the unarchived state in the backend', async () => {
await retry.try(async () => {
const response = await observabilityAIAssistantAPIClient.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversations',
});
const conversation = response.body.conversations.pop();
expect(conversation?.archived).to.eql(false);
});
});
});
});
}