diff --git a/x-pack/platform/plugins/shared/agent_builder/moon.yml b/x-pack/platform/plugins/shared/agent_builder/moon.yml index f261e78837ab8..7a42e77a6c760 100644 --- a/x-pack/platform/plugins/shared/agent_builder/moon.yml +++ b/x-pack/platform/plugins/shared/agent_builder/moon.yml @@ -110,7 +110,6 @@ dependsOn: - '@kbn/core-elasticsearch-server-mocks' - '@kbn/core-data-streams-server' - '@kbn/task-manager-plugin' - - '@kbn/deeplinks-data-sources' - '@kbn/evals-plugin' - '@kbn/usage-api-plugin' - '@kbn/es-query' diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/active_item_row.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/active_item_row.tsx new file mode 100644 index 0000000000000..7ddde57b3c56b --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/active_item_row.tsx @@ -0,0 +1,88 @@ +/* + * 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 React from 'react'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiText, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; + +export interface ActiveItemRowProps { + id: string; + name: string; + isSelected: boolean; + onSelect: () => void; + onRemove: () => void; + isRemoving?: boolean; + removeAriaLabel: string; + readOnlyContent?: React.ReactNode; +} + +export const ActiveItemRow: React.FC = ({ + name, + isSelected, + onSelect, + onRemove, + isRemoving = false, + removeAriaLabel, + readOnlyContent, +}) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + {name} + + + {isSelected && ( + + {readOnlyContent ?? ( + { + event.stopPropagation(); + onRemove(); + }} + /> + )} + + )} + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/agent_selector/index.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/constants.ts similarity index 57% rename from x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/agent_selector/index.ts rename to x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/constants.ts index 4a8f5fcb2b374..1f688fa7a0f02 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/agent_selector/index.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/constants.ts @@ -5,4 +5,10 @@ * 2.0. */ -export { AgentSelector } from './agent_selector'; +export const FLYOUT_WIDTH = '960px'; + +export const CONTAINER_WIDTH = '1200px'; + +export const ICON_DIMENSIONS = { width: 24, height: 28 } as const; + +export const SEARCH_LIST_WIDTH = '340px'; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/detail_panel_layout.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/detail_panel_layout.tsx new file mode 100644 index 0000000000000..f557136a47392 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/detail_panel_layout.tsx @@ -0,0 +1,145 @@ +/* + * 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 React, { useState } from 'react'; +import { + EuiConfirmModal, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLoadingSpinner, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; + +interface ConfirmRemoveConfig { + title: string; + body: string; + confirmButtonText: string; + cancelButtonText: string; + onConfirm: () => void; +} + +export interface DetailPanelLayoutProps { + isLoading: boolean; + isEmpty: boolean; + title: string; + showAutoIcon?: boolean; + headerActions: (openConfirmRemove: () => void) => React.ReactNode; + headerContent?: React.ReactNode; + children: React.ReactNode; + confirmRemove?: ConfirmRemoveConfig; +} + +export const DetailPanelLayout: React.FC = ({ + isLoading, + isEmpty, + title, + showAutoIcon = false, + headerActions, + headerContent, + children, + confirmRemove, +}) => { + const { euiTheme } = useEuiTheme(); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + + const openConfirmRemove = () => setIsConfirmOpen(true); + + if (isLoading) { + return ( + + + + ); + } + + if (isEmpty) return null; + + return ( +
+
+ {/* Header: pinned at top, does not scroll */} +
+ + + + + +

{title}

+
+
+ {showAutoIcon && ( + + + + )} +
+
+ {headerActions(openConfirmRemove)} +
+ {headerContent} +
+ + {/* Body: fills remaining height and scrolls on overflow */} +
+ {children} +
+
+ + {confirmRemove && isConfirmOpen && ( + setIsConfirmOpen(false)} + onConfirm={() => { + setIsConfirmOpen(false); + confirmRemove.onConfirm(); + }} + cancelButtonText={confirmRemove.cancelButtonText} + confirmButtonText={confirmRemove.confirmButtonText} + buttonColor="danger" + > +

{confirmRemove.body}

+
+ )} +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/detail_row.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/detail_row.tsx new file mode 100644 index 0000000000000..4e09d9e048c59 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/detail_row.tsx @@ -0,0 +1,40 @@ +/* + * 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 React from 'react'; +import { EuiText, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; + +export interface DetailRowProps { + label: string; + children: React.ReactNode; + isLast?: boolean; +} + +export const DetailRow: React.FC = ({ label, children, isLast = false }) => { + const { euiTheme } = useEuiTheme(); + + return ( +
+ + {label} + +
+ {children} +
+
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/library_panel.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/library_panel.tsx new file mode 100644 index 0000000000000..68443ebba3a54 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/library_panel.tsx @@ -0,0 +1,168 @@ +/* + * 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 React, { useMemo, useState, useCallback } from 'react'; +import { + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiLink, + EuiSpacer, + EuiText, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { useNavigation } from '../../../hooks/use_navigation'; +import { LibraryToggleRow } from './library_toggle_row'; +import { FLYOUT_WIDTH } from './constants'; + +export interface LibraryItem { + id: string; + description: string; +} + +export interface LibraryPanelLabels { + title: string; + manageLibraryLink: string; + searchPlaceholder: string; + availableSummary: (filtered: number, total: number) => string; + noMatchMessage: string; + noItemsMessage: string; + disabledBadgeLabel?: string; + disabledTooltipTitle?: string; + disabledTooltipBody?: string; +} + +export interface LibraryPanelProps { + onClose: () => void; + allItems: T[]; + activeItemIdSet: Set; + onToggleItem: (item: T, isActive: boolean) => void; + mutatingItemId: string | null; + flyoutTitleId: string; + libraryLabels: LibraryPanelLabels; + manageLibraryPath: string; + getItemName?: (item: T) => string; + getSearchableText?: (item: T) => string[]; + disabledItemIdSet?: Set; + callout?: React.ReactNode; +} + +const defaultGetItemName = (item: T): string => item.id; + +export const LibraryPanel = ({ + onClose, + allItems, + activeItemIdSet, + onToggleItem, + mutatingItemId, + flyoutTitleId, + libraryLabels, + manageLibraryPath, + getItemName = defaultGetItemName, + getSearchableText, + disabledItemIdSet, + callout, +}: LibraryPanelProps) => { + const { createAgentBuilderUrl } = useNavigation(); + const manageLibraryUrl = createAgentBuilderUrl(manageLibraryPath); + const [searchQuery, setSearchQuery] = useState(''); + const { euiTheme } = useEuiTheme(); + + const getSearchFields = useCallback( + (item: T): string[] => + getSearchableText ? getSearchableText(item) : [getItemName(item), item.description], + [getSearchableText, getItemName] + ); + + const filteredItems = useMemo(() => { + if (!searchQuery.trim()) return allItems; + const lower = searchQuery.toLowerCase(); + return allItems.filter((item) => + getSearchFields(item).some((field) => field.toLowerCase().includes(lower)) + ); + }, [allItems, searchQuery, getSearchFields]); + + return ( + + + + + +

{libraryLabels.title}

+
+ + {libraryLabels.manageLibraryLink} + +
+
+
+ + setSearchQuery(e.target.value)} + incremental + fullWidth + /> + + + + + {libraryLabels.availableSummary(filteredItems.length, allItems.length)} + + + + + {callout} + + {filteredItems.length === 0 ? ( + + {searchQuery.trim() ? libraryLabels.noMatchMessage : libraryLabels.noItemsMessage} + + ) : ( + + {filteredItems.map((item) => ( + + onToggleItem(item, checked)} + isMutating={mutatingItemId === item.id} + isDisabled={disabledItemIdSet?.has(item.id)} + disabledBadgeLabel={libraryLabels.disabledBadgeLabel} + disabledTooltipTitle={libraryLabels.disabledTooltipTitle} + disabledTooltipBody={libraryLabels.disabledTooltipBody} + /> + + ))} + + )} + +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/library_toggle_row.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/library_toggle_row.tsx new file mode 100644 index 0000000000000..715db4214c6d5 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/library_toggle_row.tsx @@ -0,0 +1,113 @@ +/* + * 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 React from 'react'; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiSwitch, + EuiText, + EuiToolTip, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; + +export interface LibraryToggleRowProps { + id: string; + name: string; + description: string; + isActive: boolean; + onToggle: (isActive: boolean) => void; + isMutating: boolean; + isDisabled?: boolean; + disabledBadgeLabel?: string; + disabledTooltipTitle?: string; + disabledTooltipBody?: string; +} + +const EUI_TEXT_STYLES = css` + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +`; + +export const LibraryToggleRow: React.FC = ({ + name, + description, + isActive, + onToggle, + isMutating, + isDisabled = false, + disabledBadgeLabel, + disabledTooltipTitle, + disabledTooltipBody, +}) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + + + {name} + + + {isDisabled && ( + + + + )} + + + {description} + + + + {isDisabled ? ( + + {disabledTooltipTitle && ( +

+ {disabledTooltipTitle} +

+ )} + {disabledTooltipTitle && disabledTooltipBody && } + {disabledTooltipBody &&

{disabledTooltipBody}

} + + ) : undefined + } + > + + {disabledBadgeLabel ?? 'Auto-included'} + +
+ ) : ( + onToggle(e.target.checked)} + disabled={isMutating} + compressed + /> + )} +
+
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/page_wrapper.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/page_wrapper.tsx new file mode 100644 index 0000000000000..d3f2125531fe9 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/page_wrapper.tsx @@ -0,0 +1,23 @@ +/* + * 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 React from 'react'; +import { css } from '@emotion/react'; +import { CONTAINER_WIDTH } from './constants'; + +export const PageWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/render_skill_content_read_only.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/render_skill_content_read_only.tsx new file mode 100644 index 0000000000000..68119eb7229a1 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/render_skill_content_read_only.tsx @@ -0,0 +1,70 @@ +/* + * 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 React, { useState } from 'react'; +import { + EuiButtonGroup, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiMarkdownFormat, + EuiPanel, +} from '@elastic/eui'; +import { labels } from '../../../utils/i18n'; + +const viewToggleOptions = [ + { + id: 'rendered', + label: labels.agentSkills.instructionsViewRenderedLabel, + iconType: 'eye', + }, + { + id: 'raw', + label: labels.agentSkills.instructionsViewRawLabel, + iconType: 'code', + }, +]; + +interface RenderSkillContentReadOnlyProps { + content: string; +} + +export const RenderSkillContentReadOnly: React.FC = ({ + content, +}) => { + const [showRaw, setShowRaw] = useState(false); + + return ( + + + + + + setShowRaw(id === 'raw')} + isIconOnly + buttonSize="compressed" + /> + + + + + {showRaw ? ( + + {content} + + ) : ( + {content} + )} + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/styles.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/styles.ts new file mode 100644 index 0000000000000..142aa4a75c60d --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/common/styles.ts @@ -0,0 +1,54 @@ +/* + * 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 { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { SEARCH_LIST_WIDTH } from './constants'; + +export const useListDetailPageStyles = () => { + const { euiTheme } = useEuiTheme(); + + return useMemo( + () => ({ + header: css` + padding: ${euiTheme.size.l}; + flex-shrink: 0; + `, + body: css` + flex: 1; + overflow: hidden; + padding: 0 ${euiTheme.size.l}; + `, + searchColumn: css` + width: ${SEARCH_LIST_WIDTH}; + display: flex; + flex-direction: column; + overflow: hidden; + `, + searchInputWrapper: css` + padding: 0 ${euiTheme.size.m} ${euiTheme.size.s} 0; + flex-shrink: 0; + `, + scrollableList: css` + flex: 1; + overflow-y: auto; + padding: 0 ${euiTheme.size.m} ${euiTheme.size.s} 0; + `, + detailPanelWrapper: css` + overflow: hidden; + `, + noSelectionPlaceholder: css` + height: 100%; + `, + loadingSpinner: css` + padding: ${euiTheme.size.xxl}; + `, + }), + [euiTheme] + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/edit/agent_form.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/edit/agent_form.tsx index d4422634204ae..241c259b5e8ef 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/edit/agent_form.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/edit/agent_form.tsx @@ -215,7 +215,7 @@ export const AgentForm: React.FC = ({ editingAgentId, onDelete } buttonId: BUTTON_IDS.SAVE_AND_CHAT, navigateToListView: false, }); - deferNavigateToAgentBuilderUrl(appPaths.chat.newWithAgent({ agentId: data.id })); + deferNavigateToAgentBuilderUrl(appPaths.agent.conversations.new({ agentId: data.id })); }, [deferNavigateToAgentBuilderUrl, handleSave] ); @@ -431,7 +431,7 @@ export const AgentForm: React.FC = ({ editingAgentId, onDelete } - navigateToAgentBuilderUrl(appPaths.chat.newWithAgent({ agentId: editingAgentId })) + navigateToAgentBuilderUrl(appPaths.agent.conversations.new({ agentId: editingAgentId })) } > {i18n.translate('xpack.agentBuilder.agents.form.chatButton', { diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/list/agents.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/list/agents.tsx index 404896f0edeee..b67035b57e26e 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/list/agents.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/list/agents.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; -import { EuiButton, EuiButtonEmpty, EuiLink, EuiText, useEuiTheme } from '@elastic/eui'; +import { EuiButton, EuiLink, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import { FormattedMessage } from '@kbn/i18n-react'; import { useAgentBuilderServices } from '../../../hooks/use_agent_builder_service'; @@ -17,24 +17,10 @@ import { useNavigation } from '../../../hooks/use_navigation'; import { appPaths } from '../../../utils/app_paths'; import { DeleteAgentProvider } from '../../../context/delete_agent_context'; import { useUiPrivileges } from '../../../hooks/use_ui_privileges'; -import { useExperimentalFeatures } from '../../../hooks/use_experimental_features'; - -const manageToolsLabel = i18n.translate('xpack.agentBuilder.agents.manageToolsLabel', { - defaultMessage: 'Manage tools', -}); - -const manageSkillsLabel = i18n.translate('xpack.agentBuilder.agents.manageSkillsLabel', { - defaultMessage: 'Manage skills', -}); - -const managePluginsLabel = i18n.translate('xpack.agentBuilder.agents.managePluginsLabel', { - defaultMessage: 'Manage plugins', -}); export const AgentBuilderAgents = () => { const { euiTheme } = useEuiTheme(); const { manageAgents } = useUiPrivileges(); - const isExperimentalFeaturesEnabled = useExperimentalFeatures(); const { docLinksService } = useAgentBuilderServices(); const headerStyles = css` background-color: ${euiTheme.colors.backgroundBasePlain}; @@ -56,25 +42,6 @@ export const AgentBuilderAgents = () => { })} ), - - {manageToolsLabel} - , - ...(isExperimentalFeaturesEnabled - ? [ - - {manageSkillsLabel} - , - - {managePluginsLabel} - , - ] - : []), ]; return ( diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/list/agents_list.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/list/agents_list.tsx index 2196d31f15bec..5340ede0a64a9 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/list/agents_list.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/list/agents_list.tsx @@ -203,7 +203,7 @@ export const AgentsList: React.FC = () => { isPrimary: true, showOnHover: true, href: (agent) => - createAgentBuilderUrl(appPaths.chat.new, { [searchParamNames.agentId]: agent.id }), + createAgentBuilderUrl(appPaths.agent.conversations.new({ agentId: agent.id })), }, { type: 'icon', diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/agent_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/agent_header.tsx new file mode 100644 index 0000000000000..0bc2ec1e62e6f --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/agent_header.tsx @@ -0,0 +1,117 @@ +/* + * 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 React from 'react'; +import { + EuiBadge, + EuiButtonEmpty, + EuiCopy, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import type { AgentDefinition } from '@kbn/agent-builder-common'; +import { labels } from '../../../utils/i18n'; +import { AgentAvatar } from '../../common/agent_avatar'; +import { AgentVisibilityBadge } from '../list/agent_visibility_badge'; + +const { agentOverview: overviewLabels } = labels; + +export interface AgentHeaderProps { + agent: AgentDefinition; + docsUrl?: string; + canEditAgent: boolean; + onEditDetails: () => void; +} + +export const AgentHeader: React.FC = ({ + agent, + docsUrl, + canEditAgent, + onEditDetails, +}) => ( + <> + + + + + + + +

{agent.name}

+
+ + {agent.created_by?.username && ( + + {overviewLabels.byAuthor(agent.created_by.username)} + + )} + + {(copy) => ( + + {overviewLabels.agentId(agent.id)} + + )} + + + +
+
+ + + {docsUrl && ( + + {overviewLabels.docsLink} + + )} + {canEditAgent && ( + + {overviewLabels.editDetailsButton} + + )} + + +
+ + + + {agent.description} + + + {agent.labels && agent.labels.length > 0 && ( + <> + + + {agent.labels.map((label) => ( + + {label} + + ))} + + + )} + +); diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/agent_overview.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/agent_overview.tsx new file mode 100644 index 0000000000000..093c295abc94a --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/agent_overview.tsx @@ -0,0 +1,273 @@ +/* + * 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 React, { useCallback, useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { + EuiFlexGroup, + EuiHorizontalRule, + EuiLoadingSpinner, + EuiSpacer, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { hasAgentWriteAccess } from '@kbn/agent-builder-common'; +import { AGENT_BUILDER_CONNECTORS_ENABLED_SETTING_ID } from '@kbn/management-settings-ids'; +import { useMutation, useQueryClient } from '@kbn/react-query'; +import { useAgentBuilderAgentById } from '../../../hooks/agents/use_agent_by_id'; +import { useSkillsService } from '../../../hooks/skills/use_skills'; +import { usePluginsService } from '../../../hooks/plugins/use_plugins'; +import { useAgentBuilderServices } from '../../../hooks/use_agent_builder_service'; +import { useExperimentalFeatures } from '../../../hooks/use_experimental_features'; +import { useKibana } from '../../../hooks/use_kibana'; +import { useUiPrivileges } from '../../../hooks/use_ui_privileges'; +import { useHasConnectorsAllPrivileges } from '../../../hooks/use_has_connectors_all_privileges'; +import { useCurrentUser } from '../../../hooks/agents/use_current_user'; +import { useNavigation } from '../../../hooks/use_navigation'; +import { useToasts } from '../../../hooks/use_toasts'; +import { queryKeys } from '../../../query_keys'; +import { appPaths } from '../../../utils/app_paths'; +import { labels } from '../../../utils/i18n'; +import { storageKeys } from '../../../storage_keys'; +import { AgentHeader } from './agent_header'; +import { CapabilitiesSection } from './capabilities_section'; +import { EditDetailsFlyout } from './edit_details_flyout'; +import { SettingsSection } from './settings_section'; +import { TurnOffCapabilitiesModal } from './turn_off_capabilities_modal'; + +const { agentOverview: overviewLabels } = labels; + +export const AgentOverview: React.FC = () => { + const { agentId } = useParams<{ agentId: string }>(); + const { euiTheme } = useEuiTheme(); + const { agentService, docLinksService } = useAgentBuilderServices(); + const { addSuccessToast, addErrorToast } = useToasts(); + const queryClient = useQueryClient(); + const { navigateToAgentBuilderUrl } = useNavigation(); + + const isExperimentalFeaturesEnabled = useExperimentalFeatures(); + const { + services: { uiSettings }, + } = useKibana(); + const isConnectorsEnabled = uiSettings.get( + AGENT_BUILDER_CONNECTORS_ENABLED_SETTING_ID, + false + ); + + const { manageAgents, isAdmin } = useUiPrivileges(); + const hasConnectorsPrivileges = useHasConnectorsAllPrivileges(); + const { currentUser } = useCurrentUser({ enabled: isExperimentalFeaturesEnabled }); + + const { agent, isLoading } = useAgentBuilderAgentById(agentId); + const { skills: allSkills } = useSkillsService(); + const { plugins: allPlugins } = usePluginsService(); + + const [isEditFlyoutOpen, setIsEditFlyoutOpen] = useState(false); + const [isModalVisible, setIsModalVisible] = useState(false); + const [instructions, setInstructions] = useState(undefined); + + const [warningDismissed, setWarningDismissed] = useLocalStorage( + storageKeys.autoIncludeWarningDismissed, + false + ); + + const canEditAgent = useMemo(() => { + if (!manageAgents || !agent) return false; + if (!isExperimentalFeaturesEnabled) return true; + return hasAgentWriteAccess({ + visibility: agent.visibility, + owner: agent.created_by, + currentUser: currentUser ?? undefined, + isAdmin, + }); + }, [manageAgents, agent, isExperimentalFeaturesEnabled, currentUser, isAdmin]); + + const currentInstructions = instructions ?? agent?.configuration?.instructions ?? ''; + const enableElasticCapabilities = agent?.configuration?.enable_elastic_capabilities ?? false; + + const agentSkillIdSet = useMemo( + () => new Set(agent?.configuration?.skill_ids ?? []), + [agent?.configuration?.skill_ids] + ); + + const builtinSkills = useMemo(() => allSkills.filter((s) => s.readonly), [allSkills]); + + const skillsCount = useMemo(() => { + const explicitCount = agent?.configuration?.skill_ids?.length ?? 0; + if (!enableElasticCapabilities) return explicitCount; + const builtinNotExplicit = builtinSkills.filter((s) => !agentSkillIdSet.has(s.id)).length; + return explicitCount + builtinNotExplicit; + }, [agent?.configuration?.skill_ids, enableElasticCapabilities, builtinSkills, agentSkillIdSet]); + + const agentPluginIdSet = useMemo( + () => new Set(agent?.configuration?.plugin_ids ?? []), + [agent?.configuration?.plugin_ids] + ); + + const builtinPlugins = useMemo(() => allPlugins.filter((p) => p.readonly), [allPlugins]); + + const pluginsCount = useMemo(() => { + const explicitCount = agent?.configuration?.plugin_ids?.length ?? 0; + if (!enableElasticCapabilities) return explicitCount; + const builtinNotExplicit = builtinPlugins.filter((p) => !agentPluginIdSet.has(p.id)).length; + return explicitCount + builtinNotExplicit; + }, [ + agent?.configuration?.plugin_ids, + enableElasticCapabilities, + builtinPlugins, + agentPluginIdSet, + ]); + const connectorsCount = 0; + + const updateAgentMutation = useMutation({ + mutationFn: (data: Record) => agentService.update(agentId!, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.agentProfiles.byId(agentId) }); + }, + }); + + const handleToggleAutoInclude = useCallback( + (checked: boolean) => { + if (!checked && !warningDismissed) { + setIsModalVisible(true); + return; + } + updateAgentMutation.mutate( + { configuration: { enable_elastic_capabilities: checked } }, + { + onSuccess: () => { + addSuccessToast({ + title: checked + ? overviewLabels.autoIncludeEnabledToast + : overviewLabels.autoIncludeDisabledToast, + }); + }, + onError: () => { + addErrorToast({ title: overviewLabels.autoIncludeErrorToast }); + }, + } + ); + }, + [warningDismissed, updateAgentMutation, addSuccessToast, addErrorToast] + ); + + const handleConfirmTurnOff = useCallback( + (dontShowAgain: boolean) => { + if (dontShowAgain) { + setWarningDismissed(true); + } + setIsModalVisible(false); + updateAgentMutation.mutate( + { configuration: { enable_elastic_capabilities: false } }, + { + onSuccess: () => { + addSuccessToast({ title: overviewLabels.autoIncludeDisabledToast }); + }, + onError: () => { + addErrorToast({ title: overviewLabels.autoIncludeErrorToast }); + }, + } + ); + }, + [updateAgentMutation, setWarningDismissed, addSuccessToast, addErrorToast] + ); + + const handleSaveInstructions = useCallback(() => { + updateAgentMutation.mutate( + { configuration: { instructions: currentInstructions } }, + { + onSuccess: () => { + addSuccessToast({ title: overviewLabels.instructionsSavedToast }); + }, + onError: () => { + addErrorToast({ title: overviewLabels.instructionsErrorToast }); + }, + } + ); + }, [updateAgentMutation, currentInstructions, addSuccessToast, addErrorToast]); + + if (isLoading || !agent) { + return ( + + + + ); + } + + const containerStyles = css` + padding: ${euiTheme.size.l} ${euiTheme.size.xl}; + overflow-y: auto; + height: 100%; + `; + + return ( +
+ setIsEditFlyoutOpen(true)} + /> + + + + + + + navigateToAgentBuilderUrl(appPaths.agent.skills({ agentId: agentId! })) + } + onNavigateToPlugins={() => + navigateToAgentBuilderUrl(appPaths.agent.plugins({ agentId: agentId! })) + } + onNavigateToConnectors={() => + navigateToAgentBuilderUrl(appPaths.agent.connectors({ agentId: agentId! })) + } + /> + + + + + + + + {isEditFlyoutOpen && agent && ( + setIsEditFlyoutOpen(false)} /> + )} + + {isModalVisible && ( + setIsModalVisible(false)} + /> + )} +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/capabilities_section.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/capabilities_section.tsx new file mode 100644 index 0000000000000..e00614618fb34 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/capabilities_section.tsx @@ -0,0 +1,89 @@ +/* + * 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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { labels } from '../../../utils/i18n'; +import { CapabilityRow } from './capability_row'; + +const { agentOverview: overviewLabels } = labels; + +export interface CapabilitiesSectionProps { + skillsCount: number; + pluginsCount: number; + connectorsCount: number; + enableElasticCapabilities: boolean; + isExperimentalFeaturesEnabled: boolean; + isConnectorsEnabled: boolean; + hasConnectorsPrivileges: boolean; + onNavigateToSkills: () => void; + onNavigateToPlugins: () => void; + onNavigateToConnectors: () => void; +} + +export const CapabilitiesSection: React.FC = ({ + skillsCount, + pluginsCount, + connectorsCount, + enableElasticCapabilities, + isExperimentalFeaturesEnabled, + isConnectorsEnabled, + hasConnectorsPrivileges, + onNavigateToSkills, + onNavigateToPlugins, + onNavigateToConnectors, +}) => ( + + + +

{overviewLabels.capabilitiesTitle}

+
+ + + {overviewLabels.capabilitiesDescription} + +
+ + + + {isExperimentalFeaturesEnabled && ( + + )} + + {isExperimentalFeaturesEnabled && ( + + )} + + {isConnectorsEnabled && ( + + )} + + +
+); diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/capability_row.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/capability_row.tsx new file mode 100644 index 0000000000000..987c050230e14 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/capability_row.tsx @@ -0,0 +1,70 @@ +/* + * 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 React from 'react'; +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; + +export interface CapabilityRowProps { + count: number; + label: string; + description: string; + actionLabel: string; + onAction?: () => void; +} + +export const CapabilityRow: React.FC = ({ + count, + label, + description, + actionLabel, + onAction, +}) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + + + + {i18n.translate('xpack.agentBuilder.overview.capabilities.countLabel', { + defaultMessage: '{count} {label}', + values: { count, label }, + })} + + + + + {description} + + + + + {onAction ? ( + + {actionLabel} + + ) : ( + + + + )} + + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/edit_details_flyout.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/edit_details_flyout.tsx new file mode 100644 index 0000000000000..583477bdd15ad --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/edit_details_flyout.tsx @@ -0,0 +1,369 @@ +/* + * 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 React from 'react'; +import { + EuiBadge, + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiColorPicker, + EuiComboBox, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFormRow, + EuiSpacer, + EuiText, + EuiTextArea, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { + AgentVisibility, + VISIBILITY_ICON, + VISIBILITY_BADGE_COLOR, + type AgentDefinition, +} from '@kbn/agent-builder-common'; +import { Controller, useForm } from 'react-hook-form'; +import { useMutation, useQueryClient } from '@kbn/react-query'; +import { useAgentBuilderServices } from '../../../hooks/use_agent_builder_service'; +import { useAgentLabels } from '../../../hooks/agents/use_agent_labels'; +import { useToasts } from '../../../hooks/use_toasts'; +import { queryKeys } from '../../../query_keys'; +import { isValidAgentAvatarColor } from '../../../utils/color'; +import { truncateAvatarSymbol } from '../edit/agent_form_validation'; +import { labels as sharedLabels } from '../../../utils/i18n'; +import { FLYOUT_WIDTH } from '../common/constants'; + +interface EditDetailsFormData { + name: string; + description: string; + avatar_symbol: string; + avatar_color: string; + labels: string[]; +} + +interface EditDetailsFlyoutProps { + agent: AgentDefinition; + onClose: () => void; +} + +export const EditDetailsFlyout: React.FC = ({ agent, onClose }) => { + const { agentService } = useAgentBuilderServices(); + const { addSuccessToast, addErrorToast } = useToasts(); + const queryClient = useQueryClient(); + const { labels: existingLabels, isLoading: labelsLoading } = useAgentLabels(); + + const { control, handleSubmit, formState } = useForm({ + defaultValues: { + name: agent.name, + description: agent.description, + avatar_symbol: agent.avatar_symbol ?? '', + avatar_color: agent.avatar_color ?? '', + labels: agent.labels ?? [], + }, + mode: 'onBlur', + }); + + const updateMutation = useMutation({ + mutationFn: (data: EditDetailsFormData) => + agentService.update(agent.id, { + name: data.name, + description: data.description, + avatar_symbol: data.avatar_symbol || undefined, + avatar_color: data.avatar_color || undefined, + labels: data.labels, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.agentProfiles.byId(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agentProfiles.all }); + addSuccessToast({ + title: i18n.translate('xpack.agentBuilder.overview.editDetails.successToast', { + defaultMessage: 'Agent details updated', + }), + }); + onClose(); + }, + onError: () => { + addErrorToast({ + title: i18n.translate('xpack.agentBuilder.overview.editDetails.errorToast', { + defaultMessage: 'Unable to update agent details', + }), + }); + }, + }); + + const isShared = (agent.visibility as AgentVisibility) === AgentVisibility.Shared; + + return ( + + + +

+ {i18n.translate('xpack.agentBuilder.overview.editDetails.title', { + defaultMessage: 'Edit agent details', + })} +

+
+
+ + + {isShared && ( + <> + + {i18n.translate('xpack.agentBuilder.overview.editDetails.sharedWarningPrefix', { + defaultMessage: "You're editing a ", + })} + + {i18n.translate('xpack.agentBuilder.overview.editDetails.sharedWarningBadge', { + defaultMessage: 'Shared agent', + })} + + {i18n.translate('xpack.agentBuilder.overview.editDetails.sharedWarningSuffix', { + defaultMessage: '. Changes will affect all users.', + })} + + } + data-test-subj="editDetailsSharedWarning" + /> + + + )} + + +

+ {i18n.translate('xpack.agentBuilder.overview.editDetails.identificationTitle', { + defaultMessage: 'Identification', + })} +

+
+ + {i18n.translate('xpack.agentBuilder.overview.editDetails.identificationDescription', { + defaultMessage: 'Define how this agent is named and described.', + })} + + + + + ( + + )} + /> + + + + ( + + )} + /> + + + + + + + + {sharedLabels.common.optional} + + } + isInvalid={!!formState.errors.avatar_symbol} + error={formState.errors.avatar_symbol?.message} + > + ( + rest.onChange(truncateAvatarSymbol(e.target.value))} + inputRef={ref} + placeholder={i18n.translate( + 'xpack.agentBuilder.overview.editDetails.avatarSymbolPlaceholder', + { defaultMessage: 'Paste an emoji or use a two letter abbreviation' } + )} + data-test-subj="editDetailsAvatarSymbolInput" + /> + )} + /> + + + + + {sharedLabels.common.optional} + + } + isInvalid={!!formState.errors.avatar_color} + error={formState.errors.avatar_color?.message} + > + { + if (!value) return true; + return ( + isValidAgentAvatarColor(value) || + i18n.translate('xpack.agentBuilder.overview.editDetails.avatarColorInvalid', { + defaultMessage: 'Enter a color hex code', + }) + ); + }, + }} + render={({ field: { onChange, value } }) => ( + + )} + /> + + + + + + + +

+ {i18n.translate('xpack.agentBuilder.overview.editDetails.tagsTitle', { + defaultMessage: 'Tags', + })} +

+
+ + {i18n.translate('xpack.agentBuilder.overview.editDetails.tagsDescription', { + defaultMessage: 'Add labels to organize and quickly find this agent.', + })} + + + + + ( + ({ label: l }))} + options={existingLabels.map((label) => ({ label }))} + onCreateOption={(searchValue: string) => { + const newLabel = searchValue.trim(); + if (!newLabel) return; + field.onChange(Array.from(new Set([...(field.value || []), newLabel]))); + }} + onChange={(options) => field.onChange(options.map((o) => o.label))} + isLoading={labelsLoading} + isClearable + data-test-subj="editDetailsTagsComboBox" + /> + )} + /> + +
+ + + + + + {i18n.translate('xpack.agentBuilder.overview.editDetails.cancelButton', { + defaultMessage: 'Cancel', + })} + + + + updateMutation.mutate(data))} + isLoading={updateMutation.isLoading} + isDisabled={!formState.isDirty} + data-test-subj="editDetailsSaveButton" + > + {i18n.translate('xpack.agentBuilder.overview.editDetails.saveButton', { + defaultMessage: 'Save', + })} + + + + +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/settings_section.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/settings_section.tsx new file mode 100644 index 0000000000000..50e6a77092ca9 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/settings_section.tsx @@ -0,0 +1,111 @@ +/* + * 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 React from 'react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiSwitch, + EuiText, + EuiTextArea, + EuiTitle, +} from '@elastic/eui'; +import { labels } from '../../../utils/i18n'; + +const { agentOverview: overviewLabels } = labels; + +export interface SettingsSectionProps { + enableElasticCapabilities: boolean; + currentInstructions: string; + canEditAgent: boolean; + isLoading: boolean; + onToggleAutoInclude: (checked: boolean) => void; + onInstructionsChange: (value: string) => void; + onSaveInstructions: () => void; +} + +export const SettingsSection: React.FC = ({ + enableElasticCapabilities, + currentInstructions, + canEditAgent, + isLoading, + onToggleAutoInclude, + onInstructionsChange, + onSaveInstructions, +}) => ( + + + +

{overviewLabels.settingsTitle}

+
+ + + {overviewLabels.settingsDescription} + +
+ + + + + +

{overviewLabels.autoIncludeTitle}

+
+ + + {overviewLabels.autoIncludeDescription} + +
+ + onToggleAutoInclude(e.target.checked)} + disabled={!canEditAgent || isLoading} + data-test-subj="agentOverviewAutoIncludeSwitch" + /> + +
+ + + + +

{overviewLabels.instructionsTitle}

+
+ + + {overviewLabels.instructionsDescription} + + + onInstructionsChange(e.target.value)} + disabled={!canEditAgent} + data-test-subj="agentOverviewInstructionsInput" + /> + + {canEditAgent && ( + + + {overviewLabels.saveInstructionsButton} + + + )} +
+
+); diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/turn_off_capabilities_modal.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/turn_off_capabilities_modal.tsx new file mode 100644 index 0000000000000..3488f56b64286 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/overview/turn_off_capabilities_modal.tsx @@ -0,0 +1,99 @@ +/* + * 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 React, { useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiText, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +interface TurnOffCapabilitiesModalProps { + onConfirm: (dontShowAgain: boolean) => void; + onCancel: () => void; +} + +export const TurnOffCapabilitiesModal: React.FC = ({ + onConfirm, + onCancel, +}) => { + const [dontShowAgain, setDontShowAgain] = useState(false); + const modalTitleId = useGeneratedHtmlId(); + + return ( + + + + {i18n.translate('xpack.agentBuilder.overview.turnOffCapabilities.title', { + defaultMessage: 'Turn off auto-included capabilities?', + })} + + + + + +

+ {i18n.translate('xpack.agentBuilder.overview.turnOffCapabilities.body', { + defaultMessage: + 'This will remove all built-in skills, plugins, and tools that were added automatically. You\u2019ll need to add and manage those capabilities manually going forward. You can turn this back on at any time.', + })} +

+
+
+ + + + + setDontShowAgain(e.target.checked)} + data-test-subj="turnOffCapabilitiesDontShowAgainCheckbox" + /> + + + + + {i18n.translate('xpack.agentBuilder.overview.turnOffCapabilities.cancel', { + defaultMessage: 'Cancel', + })} + + onConfirm(dontShowAgain)} + data-test-subj="turnOffCapabilitiesConfirmButton" + > + {i18n.translate('xpack.agentBuilder.overview.turnOffCapabilities.confirm', { + defaultMessage: 'Turn off', + })} + + + + + +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/plugins/agent_plugins.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/plugins/agent_plugins.tsx new file mode 100644 index 0000000000000..a5ee35e005fd9 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/plugins/agent_plugins.tsx @@ -0,0 +1,374 @@ +/* + * 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 React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; +import { + EuiBadge, + EuiButton, + EuiButtonEmpty, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLoadingSpinner, + EuiPopover, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import type { PluginDefinition } from '@kbn/agent-builder-common'; +import { useMutation, useQueryClient } from '@kbn/react-query'; +import { labels } from '../../../utils/i18n'; +import { appPaths } from '../../../utils/app_paths'; +import { useNavigation } from '../../../hooks/use_navigation'; +import { usePluginsService } from '../../../hooks/plugins/use_plugins'; +import { useAgentBuilderAgentById } from '../../../hooks/agents/use_agent_by_id'; +import { useAgentBuilderServices } from '../../../hooks/use_agent_builder_service'; +import { useToasts } from '../../../hooks/use_toasts'; +import { queryKeys } from '../../../query_keys'; +import { useFlyoutState } from '../../../hooks/use_flyout_state'; +import { ActiveItemRow } from '../common/active_item_row'; +import { PluginLibraryPanel } from './plugin_library_panel'; +import { PluginDetailPanel } from './plugin_detail_panel'; +import { InstallPluginFlyout } from './install_plugin_flyout'; +import { PageWrapper } from '../common/page_wrapper'; +import { ICON_DIMENSIONS } from '../common/constants'; +import { useListDetailPageStyles } from '../common/styles'; + +export const AgentPlugins: React.FC = () => { + const { agentId } = useParams<{ agentId: string }>(); + const styles = useListDetailPageStyles(); + const { createAgentBuilderUrl } = useNavigation(); + const { agentService } = useAgentBuilderServices(); + const { addSuccessToast, addErrorToast } = useToasts(); + const queryClient = useQueryClient(); + + const { agent, isLoading: agentLoading } = useAgentBuilderAgentById(agentId); + const { plugins: allPlugins, isLoading: pluginsLoading } = usePluginsService(); + + const [searchQuery, setSearchQuery] = useState(''); + const [selectedPluginId, setSelectedPluginId] = useState(null); + const pendingSelectPluginIdRef = useRef(null); + const [isAddMenuOpen, setIsAddMenuOpen] = useState(false); + const [mutatingPluginId, setMutatingPluginId] = useState(null); + const { + isOpen: isLibraryOpen, + openFlyout: openLibrary, + closeFlyout: closeLibrary, + } = useFlyoutState(); + const { + isOpen: isInstallFlyoutOpen, + openFlyout: openInstallFlyout, + closeFlyout: closeInstallFlyout, + } = useFlyoutState(); + + const handleOpenLibrary = useCallback(() => { + setIsAddMenuOpen(false); + openLibrary(); + }, [openLibrary]); + + const handleOpenInstallFlyout = useCallback(() => { + setIsAddMenuOpen(false); + openInstallFlyout(); + }, [openInstallFlyout]); + + const agentPluginIds = useMemo( + () => agent?.configuration?.plugin_ids, + [agent?.configuration?.plugin_ids] + ); + + const agentPluginIdSet = useMemo( + () => (agentPluginIds ? new Set(agentPluginIds) : undefined), + [agentPluginIds] + ); + + const enableElasticCapabilities = agent?.configuration?.enable_elastic_capabilities ?? false; + + const builtinPlugins = useMemo(() => allPlugins.filter((p) => p.readonly), [allPlugins]); + + const builtinPluginIdSet = useMemo( + () => new Set(builtinPlugins.map((p) => p.id)), + [builtinPlugins] + ); + + const activePlugins = useMemo(() => { + if (!agentPluginIdSet) return []; + if (enableElasticCapabilities) { + const explicitPlugins = allPlugins.filter((p) => agentPluginIdSet.has(p.id)); + const builtinNotExplicit = builtinPlugins.filter((p) => !agentPluginIdSet.has(p.id)); + return [...explicitPlugins, ...builtinNotExplicit]; + } + return allPlugins.filter((p) => agentPluginIdSet.has(p.id)); + }, [allPlugins, agentPluginIdSet, enableElasticCapabilities, builtinPlugins]); + + useEffect(() => { + if (pendingSelectPluginIdRef.current) { + const pendingInActive = activePlugins.some((p) => p.id === pendingSelectPluginIdRef.current); + if (pendingInActive) { + setSelectedPluginId(pendingSelectPluginIdRef.current); + pendingSelectPluginIdRef.current = null; + return; + } + } + + if (!selectedPluginId) { + if (activePlugins.length > 0) { + setSelectedPluginId(activePlugins[0].id); + } + } else { + const stillActive = activePlugins.some((p) => p.id === selectedPluginId); + if (!stillActive) { + setSelectedPluginId(activePlugins[0]?.id ?? null); + } + } + }, [activePlugins, selectedPluginId]); + + const filteredActivePlugins = useMemo(() => { + if (!searchQuery.trim()) return activePlugins; + const lower = searchQuery.toLowerCase(); + return activePlugins.filter( + (p) => p.name.toLowerCase().includes(lower) || p.description.toLowerCase().includes(lower) + ); + }, [activePlugins, searchQuery]); + + const updatePluginsMutation = useMutation({ + mutationFn: (newPluginIds: string[]) => { + return agentService.update(agentId!, { configuration: { plugin_ids: newPluginIds } }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.agentProfiles.byId(agentId) }); + }, + onError: () => { + addErrorToast({ title: labels.agentPlugins.updatePluginsErrorToast }); + }, + }); + + const handleAddPlugin = useCallback( + async ( + plugin: PluginDefinition, + { selectOnSuccess = false }: { selectOnSuccess?: boolean } = {} + ) => { + const currentIds = agentPluginIds ?? []; + if (currentIds.includes(plugin.id)) return; + const newIds = [...currentIds, plugin.id]; + setMutatingPluginId(plugin.id); + try { + await updatePluginsMutation.mutateAsync(newIds); + if (selectOnSuccess) { + pendingSelectPluginIdRef.current = plugin.id; + } + addSuccessToast({ title: labels.agentPlugins.addPluginSuccessToast(plugin.name) }); + } finally { + setMutatingPluginId(null); + } + }, + [agentPluginIds, updatePluginsMutation, addSuccessToast] + ); + + const handleRemovePlugin = useCallback( + (plugin: PluginDefinition) => { + const currentIds = agentPluginIds ?? []; + const newIds = currentIds.filter((id) => id !== plugin.id); + setMutatingPluginId(plugin.id); + updatePluginsMutation.mutate(newIds, { + onSuccess: () => { + setSelectedPluginId(null); + addSuccessToast({ title: labels.agentPlugins.removePluginSuccessToast(plugin.name) }); + }, + onSettled: () => setMutatingPluginId(null), + }); + }, + [agentPluginIds, updatePluginsMutation, addSuccessToast] + ); + + const handleTogglePlugin = useCallback( + (plugin: PluginDefinition, isActive: boolean) => { + if (enableElasticCapabilities && plugin.readonly) return; + if (isActive) { + handleAddPlugin(plugin); + } else { + handleRemovePlugin(plugin); + } + }, + [handleAddPlugin, handleRemovePlugin, enableElasticCapabilities] + ); + + const handleRemoveSelectedPlugin = useCallback(() => { + if (!selectedPluginId) return; + const plugin = activePlugins.find((p) => p.id === selectedPluginId); + if (plugin) { + if (enableElasticCapabilities && plugin.readonly) return; + handleRemovePlugin(plugin); + } + }, [selectedPluginId, activePlugins, handleRemovePlugin, enableElasticCapabilities]); + + const libraryActivePluginIdSet = useMemo(() => { + if (!agentPluginIdSet) return new Set(); + if (enableElasticCapabilities) return new Set([...agentPluginIdSet, ...builtinPluginIdSet]); + return agentPluginIdSet; + }, [agentPluginIdSet, enableElasticCapabilities, builtinPluginIdSet]); + + const isLoading = agentLoading || pluginsLoading; + + if (isLoading) { + return ( + + + + ); + } + + return ( + +
+ + + + + + + + +

{labels.plugins.title}

+
+
+
+
+ + + + + {labels.agentPlugins.manageAllPlugins} + + + + setIsAddMenuOpen((prev) => !prev)} + > + {labels.agentPlugins.installPluginButton} + + } + isOpen={isAddMenuOpen} + closePopover={() => setIsAddMenuOpen(false)} + anchorPosition="downLeft" + panelPaddingSize="none" + > + + {labels.agentPlugins.fromUrlOrZipMenuItem} + , + + {labels.agentPlugins.fromLibraryMenuItem} + , + ]} + /> + + + + +
+ + + + {labels.agentPlugins.pageDescription} + +
+ + + +
+ setSearchQuery(e.target.value)} + incremental + fullWidth + /> +
+ +
+ {filteredActivePlugins.length === 0 ? ( + +

+ {searchQuery.trim() + ? labels.agentPlugins.noActivePluginsMatchMessage + : labels.agentPlugins.noActivePluginsMessage} +

+
+ ) : ( + filteredActivePlugins.map((plugin) => ( + setSelectedPluginId(plugin.id)} + onRemove={() => handleRemovePlugin(plugin)} + isRemoving={updatePluginsMutation.isLoading} + removeAriaLabel={labels.agentPlugins.removePluginAriaLabel} + readOnlyContent={ + enableElasticCapabilities && plugin.readonly ? ( + {labels.agentPlugins.autoBadge} + ) : undefined + } + /> + )) + )} +
+
+ + + {selectedPluginId ? ( + + ) : ( + + + {labels.agentPlugins.noPluginSelectedMessage} + + + )} + +
+ + {isLibraryOpen && ( + + )} + + {isInstallFlyoutOpen && ( + + )} +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/plugins/install_plugin_flyout.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/plugins/install_plugin_flyout.tsx new file mode 100644 index 0000000000000..cc899b626273c --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/plugins/install_plugin_flyout.tsx @@ -0,0 +1,198 @@ +/* + * 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 React, { useCallback, useState } from 'react'; +import type { EuiFilePickerProps } from '@elastic/eui'; +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiForm, + EuiFormRow, + EuiTab, + EuiTabs, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { PluginDefinition } from '@kbn/agent-builder-common'; +import { labels } from '../../../utils/i18n'; +import { + useInstallPluginFromUrl, + useUploadPlugin, +} from '../../../hooks/plugins/use_install_plugin'; + +type InstallTab = 'url' | 'upload'; + +interface InstallPluginFlyoutProps { + onClose: () => void; + /** Called after a plugin is successfully installed, receives the new plugin data. + * May return a promise — the flyout waits for it to settle before closing. */ + onPluginInstalled?: (plugin: PluginDefinition) => void | Promise; +} + +export const InstallPluginFlyout: React.FC = ({ + onClose, + onPluginInstalled, +}) => { + const [activeTab, setActiveTab] = useState('url'); + const { euiTheme } = useEuiTheme(); + const [url, setUrl] = useState(''); + const [file, setFile] = useState(null); + + const handleInstallSuccess = useCallback( + async (data: PluginDefinition) => { + try { + await onPluginInstalled?.(data); + } finally { + onClose(); + } + }, + [onPluginInstalled, onClose] + ); + + const { installFromUrl, isLoading: isUrlLoading } = useInstallPluginFromUrl({ + onSuccess: handleInstallSuccess, + }); + + const { uploadPlugin, isLoading: isUploadLoading } = useUploadPlugin({ + onSuccess: handleInstallSuccess, + }); + + const isLoading = isUrlLoading || isUploadLoading; + + const handleUrlSubmit = useCallback(async () => { + if (!url.trim()) return; + await installFromUrl({ url: url.trim() }); + }, [url, installFromUrl]); + + const handleUploadSubmit = useCallback(async () => { + if (!file) return; + await uploadPlugin({ file }); + }, [file, uploadPlugin]); + + const handleInstall = useCallback(() => { + if (activeTab === 'url') { + handleUrlSubmit(); + } else { + handleUploadSubmit(); + } + }, [activeTab, handleUrlSubmit, handleUploadSubmit]); + + const handleFileChange: EuiFilePickerProps['onChange'] = useCallback((files: FileList | null) => { + setFile(files && files.length > 0 ? files[0] : null); + }, []); + + const isInstallDisabled = isLoading || (activeTab === 'url' ? !url.trim() : !file); + + return ( + + + +

{labels.agentPlugins.installPluginFlyoutTitle}

+
+
+
+ + setActiveTab('url')} + disabled={isLoading} + > + {labels.agentPlugins.installPluginUrlTab} + + setActiveTab('upload')} + disabled={isLoading} + > + {labels.agentPlugins.installPluginUploadTab} + + +
+ + + {activeTab === 'url' ? ( + { + e.preventDefault(); + handleUrlSubmit(); + }} + > + + setUrl(e.target.value)} + placeholder={labels.plugins.urlFieldPlaceholder} + fullWidth + disabled={isLoading} + data-test-subj="agentBuilderInstallPluginUrlField" + /> + + + ) : ( + { + e.preventDefault(); + handleUploadSubmit(); + }} + > + + + + + )} + + + + + + + {labels.plugins.cancelButton} + + + + + {labels.plugins.installButton} + + + + +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/plugins/plugin_detail_panel.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/plugins/plugin_detail_panel.tsx new file mode 100644 index 0000000000000..5652b77534877 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/plugins/plugin_detail_panel.tsx @@ -0,0 +1,204 @@ +/* + * 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 React, { useState } from 'react'; +import { + EuiBadge, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiHorizontalRule, + EuiLink, + EuiLoadingSpinner, + EuiText, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { labels } from '../../../utils/i18n'; +import { usePlugin } from '../../../hooks/plugins/use_plugin'; +import { useSkill } from '../../../hooks/skills/use_skills'; +import { DetailRow } from '../common/detail_row'; +import { DetailPanelLayout } from '../common/detail_panel_layout'; +import { RenderSkillContentReadOnly } from '../common/render_skill_content_read_only'; + +interface PluginDetailPanelProps { + pluginId: string; + onRemove: () => void; + isAuto?: boolean; +} + +export const PluginDetailPanel: React.FC = ({ + pluginId, + onRemove, + isAuto = false, +}) => { + const { euiTheme } = useEuiTheme(); + const { plugin, isLoading } = usePlugin({ pluginId }); + const [selectedSkillId, setSelectedSkillId] = useState(null); + + return ( + <> + +
+ + {labels.agentPlugins.skillsCountBadge(plugin?.skill_ids.length ?? 0)} + +
+ + {plugin?.description || '\u2014'} + + + } + headerActions={(openConfirmRemove) => + isAuto ? ( + {labels.agentPlugins.autoIncludedBadgeLabel} + ) : ( + + {labels.agentPlugins.removePluginButtonLabel} + + ) + } + confirmRemove={{ + title: labels.agentPlugins.removePluginConfirmTitle(plugin?.name ?? pluginId), + body: labels.agentPlugins.removePluginConfirmBody, + confirmButtonText: labels.agentPlugins.removePluginConfirmButton, + cancelButtonText: labels.agentPlugins.removePluginCancelButton, + onConfirm: onRemove, + }} + > +
+ + {plugin?.id} + + + {plugin?.source_url ? ( + + {plugin.source_url} + + ) : ( + + {'\u2014'} + + )} + + + + {plugin?.skill_ids && plugin.skill_ids.length > 0 ? ( + + {plugin.skill_ids.map((skillId) => ( + + setSelectedSkillId(skillId)}>{skillId} + + ))} + + ) : ( + + {labels.plugins.noSkillsLabel} + + )} + +
+
+ + {selectedSkillId && ( + setSelectedSkillId(null)} + /> + )} + + ); +}; + +const SkillDetailFlyout: React.FC<{ + skillId: string; + pluginName: string; + onClose: () => void; +}> = ({ skillId, pluginName, onClose }) => { + const { skill, isLoading } = useSkill({ skillId }); + + return ( + + + + + +

{skill?.name ?? skillId}

+
+
+ + + {labels.agentSkills.readOnlyBadge} + + +
+ + {labels.agentPlugins.skillDetailInstalledVia(pluginName)} + +
+ + {isLoading ? ( + + + + ) : skill ? ( + + + +

{labels.agentPlugins.pluginDetailNameLabel}

+
+ {skill.name} +
+ + + +

{labels.agentPlugins.pluginDetailIdLabel}

+
+ {skill.id} +
+ + + +

{labels.agentPlugins.pluginDetailDescriptionLabel}

+
+ {skill.description || '\u2014'} +
+ + + + +
+ ) : null} +
+
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/plugins/plugin_library_panel.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/plugins/plugin_library_panel.tsx new file mode 100644 index 0000000000000..42bc172effa51 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/plugins/plugin_library_panel.tsx @@ -0,0 +1,58 @@ +/* + * 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 React from 'react'; +import type { PluginDefinition } from '@kbn/agent-builder-common'; +import { labels } from '../../../utils/i18n'; +import { appPaths } from '../../../utils/app_paths'; +import { LibraryPanel } from '../common/library_panel'; +import type { LibraryPanelLabels } from '../common/library_panel'; + +const libraryLabels: LibraryPanelLabels = { + title: labels.agentPlugins.addPluginFromLibraryTitle, + manageLibraryLink: labels.agentPlugins.managePluginLibraryLink, + searchPlaceholder: labels.agentPlugins.searchAvailablePluginsPlaceholder, + availableSummary: labels.agentPlugins.availablePluginsSummary, + noMatchMessage: labels.agentPlugins.noAvailablePluginsMatchMessage, + noItemsMessage: labels.agentPlugins.noAvailablePluginsMessage, + disabledBadgeLabel: labels.agentPlugins.autoIncludedBadgeLabel, + disabledTooltipTitle: labels.agentPlugins.autoIncludedTooltipTitle, + disabledTooltipBody: labels.agentPlugins.autoIncludedTooltipBody, +}; + +const getPluginName = (plugin: PluginDefinition): string => plugin.name; + +interface PluginLibraryPanelProps { + onClose: () => void; + allPlugins: PluginDefinition[]; + activePluginIdSet: Set; + onTogglePlugin: (plugin: PluginDefinition, isActive: boolean) => void; + mutatingPluginId: string | null; + autoPluginIdSet?: Set; +} + +export const PluginLibraryPanel: React.FC = ({ + onClose, + allPlugins, + activePluginIdSet, + onTogglePlugin, + mutatingPluginId, + autoPluginIdSet, +}) => ( + + onClose={onClose} + allItems={allPlugins} + activeItemIdSet={activePluginIdSet} + onToggleItem={onTogglePlugin} + mutatingItemId={mutatingPluginId} + flyoutTitleId="pluginLibraryFlyoutTitle" + libraryLabels={libraryLabels} + manageLibraryPath={appPaths.plugins.list} + getItemName={getPluginName} + disabledItemIdSet={autoPluginIdSet} + /> +); diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/active_skill_row.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/active_skill_row.tsx new file mode 100644 index 0000000000000..97aff8174a97f --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/active_skill_row.tsx @@ -0,0 +1,47 @@ +/* + * 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 React from 'react'; +import { EuiBadge } from '@elastic/eui'; +import type { PublicSkillSummary } from '@kbn/agent-builder-common'; +import { labels } from '../../../utils/i18n'; +import { ActiveItemRow } from '../common/active_item_row'; + +export interface ActiveSkillRowProps { + skill: PublicSkillSummary; + isSelected: boolean; + onSelect: (skill: PublicSkillSummary) => void; + onRemove: (skill: PublicSkillSummary) => void; + isRemoving?: boolean; + readOnly?: boolean; +} + +export const ActiveSkillRow: React.FC = ({ + skill, + isSelected, + onSelect, + onRemove, + isRemoving = false, + readOnly = false, +}) => { + return ( + onSelect(skill)} + onRemove={() => onRemove(skill)} + isRemoving={isRemoving} + removeAriaLabel={labels.agentSkills.removeSkillAriaLabel} + readOnlyContent={ + readOnly ? ( + {labels.agentSkills.elasticCapabilitiesReadOnlyBadge} + ) : undefined + } + /> + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/agent_skills.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/agent_skills.tsx new file mode 100644 index 0000000000000..da5ed62084c00 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/agent_skills.tsx @@ -0,0 +1,377 @@ +/* + * 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 React, { useMemo, useState, useEffect, useRef } from 'react'; +import { useParams } from 'react-router-dom'; +import { + EuiButton, + EuiButtonEmpty, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPopover, + EuiSpacer, + EuiText, + EuiIcon, + EuiTitle, +} from '@elastic/eui'; +import type { PublicSkillDefinition, PublicSkillSummary } from '@kbn/agent-builder-common'; +import { useMutation, useQueryClient } from '@kbn/react-query'; +import { labels } from '../../../utils/i18n'; +import { appPaths } from '../../../utils/app_paths'; +import { useNavigation } from '../../../hooks/use_navigation'; +import { useSkillsService } from '../../../hooks/skills/use_skills'; +import { useAgentBuilderAgentById } from '../../../hooks/agents/use_agent_by_id'; +import { useAgentBuilderServices } from '../../../hooks/use_agent_builder_service'; +import { useToasts } from '../../../hooks/use_toasts'; +import { queryKeys } from '../../../query_keys'; +import { useFlyoutState } from '../../../hooks/use_flyout_state'; +import { SkillLibraryPanel } from './skill_library_panel'; +import { ActiveSkillRow } from './active_skill_row'; +import { SkillDetailPanel } from './skill_detail_panel'; +import { SkillEditFlyout } from './skill_edit_flyout'; +import { SkillCreateFlyout } from './skill_create_flyout'; +import { PageWrapper } from '../common/page_wrapper'; +import { ICON_DIMENSIONS } from '../common/constants'; +import { useListDetailPageStyles } from '../common/styles'; + +export const AgentSkills: React.FC = () => { + const { agentId } = useParams<{ agentId: string }>(); + const styles = useListDetailPageStyles(); + const { createAgentBuilderUrl } = useNavigation(); + const { agentService } = useAgentBuilderServices(); + const { addSuccessToast, addErrorToast } = useToasts(); + const queryClient = useQueryClient(); + + const { agent, isLoading: agentLoading } = useAgentBuilderAgentById(agentId); + const { skills: allSkills, isLoading: skillsLoading } = useSkillsService(); + + const [searchQuery, setSearchQuery] = useState(''); + const [selectedSkillId, setSelectedSkillId] = useState(null); + const pendingSelectSkillIdRef = useRef(null); + const [editingSkillId, setEditingSkillId] = useState(null); + const [isCreateFlyoutOpen, setIsCreateFlyoutOpen] = useState(false); + const [isAddMenuOpen, setIsAddMenuOpen] = useState(false); + const [mutatingSkillId, setMutatingSkillId] = useState(null); + const { + isOpen: isLibraryOpen, + openFlyout: openLibrary, + closeFlyout: closeLibrary, + } = useFlyoutState(); + + const handleImportFromLibrary = () => { + setIsAddMenuOpen(false); + openLibrary(); + }; + + const handleOpenCreateFlyout = () => { + setIsAddMenuOpen(false); + setIsCreateFlyoutOpen(true); + }; + + const agentSkillIds = useMemo( + () => agent?.configuration?.skill_ids, + [agent?.configuration?.skill_ids] + ); + + const agentSkillIdSet = useMemo( + () => (agentSkillIds ? new Set(agentSkillIds) : undefined), + [agentSkillIds] + ); + + const enableElasticCapabilities = agent?.configuration?.enable_elastic_capabilities ?? false; + + const builtinSkills = useMemo(() => allSkills.filter((s) => s.readonly), [allSkills]); + + const builtinSkillIdSet = useMemo(() => new Set(builtinSkills.map((s) => s.id)), [builtinSkills]); + + const activeSkills = useMemo(() => { + if (!agentSkillIdSet) return allSkills; + if (enableElasticCapabilities) { + const explicitSkills = allSkills.filter((s) => agentSkillIdSet.has(s.id)); + const builtinNotExplicit = builtinSkills.filter((s) => !agentSkillIdSet.has(s.id)); + return [...explicitSkills, ...builtinNotExplicit]; + } + return allSkills.filter((s) => agentSkillIdSet.has(s.id)); + }, [allSkills, agentSkillIdSet, enableElasticCapabilities, builtinSkills]); + + useEffect(() => { + if (pendingSelectSkillIdRef.current) { + const pendingInActive = activeSkills.some((s) => s.id === pendingSelectSkillIdRef.current); + if (pendingInActive) { + setSelectedSkillId(pendingSelectSkillIdRef.current); + pendingSelectSkillIdRef.current = null; + return; + } + } + + if (!selectedSkillId) { + if (activeSkills.length > 0) { + setSelectedSkillId(activeSkills[0].id); + } + } else { + const stillActive = activeSkills.some((s) => s.id === selectedSkillId); + if (!stillActive) { + setSelectedSkillId(activeSkills[0]?.id ?? null); + } + } + }, [activeSkills, selectedSkillId]); + + const filteredActiveSkills = useMemo(() => { + if (!searchQuery.trim()) return activeSkills; + const lower = searchQuery.toLowerCase(); + return activeSkills.filter( + (s) => s.name.toLowerCase().includes(lower) || s.description.toLowerCase().includes(lower) + ); + }, [activeSkills, searchQuery]); + + const updateSkillsMutation = useMutation({ + mutationFn: (newSkillIds: string[]) => { + return agentService.update(agentId!, { configuration: { skill_ids: newSkillIds } }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.agentProfiles.byId(agentId) }); + }, + onError: () => { + addErrorToast({ title: labels.agentSkills.updateSkillsErrorToast }); + }, + }); + + const handleAddSkill = ( + skill: PublicSkillSummary | PublicSkillDefinition, + { selectOnSuccess = false }: { selectOnSuccess?: boolean } = {} + ) => { + const currentIds = agentSkillIds ?? allSkills.map((s) => s.id); + if (currentIds.includes(skill.id)) return; + const newIds = [...currentIds, skill.id]; + setMutatingSkillId(skill.id); + updateSkillsMutation.mutate(newIds, { + onSuccess: () => { + if (selectOnSuccess) { + pendingSelectSkillIdRef.current = skill.id; + } + addSuccessToast({ title: labels.agentSkills.addSkillSuccessToast(skill.name) }); + }, + onSettled: () => setMutatingSkillId(null), + }); + }; + + const handleRemoveSkill = (skill: PublicSkillSummary) => { + const currentIds = agentSkillIds ?? allSkills.map((s) => s.id); + const newIds = currentIds.filter((id) => id !== skill.id); + setMutatingSkillId(skill.id); + updateSkillsMutation.mutate(newIds, { + onSuccess: () => { + setSelectedSkillId(null); + addSuccessToast({ title: labels.agentSkills.removeSkillSuccessToast(skill.name) }); + }, + onSettled: () => setMutatingSkillId(null), + }); + }; + + const handleToggleSkill = (skill: PublicSkillSummary, isActive: boolean) => { + if (enableElasticCapabilities && skill.readonly) return; + if (isActive) { + handleAddSkill(skill); + } else { + handleRemoveSkill(skill); + } + }; + + const handleRemoveSelectedSkill = () => { + if (!selectedSkillId) return; + const skill = activeSkills.find((s) => s.id === selectedSkillId); + if (skill) { + if (enableElasticCapabilities && skill.readonly) return; + handleRemoveSkill(skill); + } + }; + + const libraryActiveSkillIdSet = useMemo(() => { + if (!agentSkillIdSet) return new Set(allSkills.map((s) => s.id)); + if (enableElasticCapabilities) return new Set([...agentSkillIdSet, ...builtinSkillIdSet]); + return agentSkillIdSet; + }, [agentSkillIdSet, allSkills, enableElasticCapabilities, builtinSkillIdSet]); + + const isLoading = agentLoading || skillsLoading; + + if (isLoading) { + return ( + + + + ); + } + + return ( + +
+ + + + + + + + +

{labels.skills.title}

+
+
+
+
+ + + + + {labels.agentSkills.manageAllSkills} + + + + setIsAddMenuOpen((prev) => !prev)} + > + {labels.agentSkills.addSkillButton} + + } + isOpen={isAddMenuOpen} + closePopover={() => setIsAddMenuOpen(false)} + anchorPosition="downLeft" + panelPaddingSize="none" + > + + {labels.agentSkills.importFromLibraryMenuItem} + , + + {labels.agentSkills.createSkillMenuItem} + , + ]} + /> + + + + +
+ + + + {labels.agentSkills.pageDescription} + +
+ + + +
+ setSearchQuery(e.target.value)} + incremental + fullWidth + /> +
+ +
+ {filteredActiveSkills.length === 0 ? ( + +

+ {searchQuery.trim() + ? labels.agentSkills.noActiveSkillsMatchMessage + : labels.agentSkills.noActiveSkillsMessage} +

+
+ ) : ( + filteredActiveSkills.map((skill) => ( + setSelectedSkillId(s.id)} + onRemove={handleRemoveSkill} + isRemoving={mutatingSkillId === skill.id} + readOnly={enableElasticCapabilities && skill.readonly} + /> + )) + )} +
+
+ + + {selectedSkillId ? ( + setEditingSkillId(selectedSkillId)} + onRemove={handleRemoveSelectedSkill} + isReadOnly={ + enableElasticCapabilities && + (activeSkills.find((s) => s.id === selectedSkillId)?.readonly ?? false) + } + /> + ) : ( + + + {labels.agentSkills.noSkillSelectedMessage} + + + )} + +
+ + {isLibraryOpen && ( + + )} + + {editingSkillId && ( + setEditingSkillId(null)} + onSaved={() => { + queryClient.invalidateQueries({ queryKey: queryKeys.skills.byId(editingSkillId) }); + setSelectedSkillId(editingSkillId); + }} + /> + )} + + {isCreateFlyoutOpen && ( + setIsCreateFlyoutOpen(false)} + onSkillCreated={(skill) => handleAddSkill(skill, { selectOnSuccess: true })} + /> + )} +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/skill_create_flyout.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/skill_create_flyout.tsx new file mode 100644 index 0000000000000..28b6ab804e38e --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/skill_create_flyout.tsx @@ -0,0 +1,137 @@ +/* + * 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 React, { useCallback, useMemo } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiForm, + EuiLink, + EuiSpacer, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { FormProvider } from 'react-hook-form'; +import type { PublicSkillDefinition } from '@kbn/agent-builder-common'; +import { labels } from '../../../utils/i18n'; +import { useCreateSkill } from '../../../hooks/skills/use_create_skill'; +import { useSkillForm } from '../../../hooks/skills/use_skill_form'; +import { useTools } from '../../../hooks/tools/use_tools'; +import { useNavigation } from '../../../hooks/use_navigation'; +import { appPaths } from '../../../utils/app_paths'; +import { FLYOUT_WIDTH } from '../common/constants'; +import { SkillForm } from './skill_form'; + +interface SkillCreateFlyoutProps { + onClose: () => void; + onSkillCreated?: (skill: PublicSkillDefinition) => void; +} + +export const SkillCreateFlyout: React.FC = ({ + onClose, + onSkillCreated, +}) => { + const { createAgentBuilderUrl } = useNavigation(); + const skillLibraryUrl = createAgentBuilderUrl(appPaths.manage.skills); + const { tools } = useTools(); + const { euiTheme } = useEuiTheme(); + + const form = useSkillForm(); + const { + control, + handleSubmit, + formState: { errors }, + } = form; + + const { isSubmitting, createSkill } = useCreateSkill({ + onSuccess: (response) => { + onSkillCreated?.(response); + onClose(); + }, + }); + + const toolOptions = useMemo( + () => tools.map((tool) => ({ label: tool.id, value: tool.id })), + [tools] + ); + + const onSubmit = useCallback( + async (data: { + id: string; + name: string; + description: string; + content: string; + tool_ids: string[]; + }) => { + await createSkill({ + id: data.id, + name: data.name, + description: data.description, + content: data.content, + tool_ids: data.tool_ids, + }); + }, + [createSkill] + ); + + const hasErrors = Object.keys(errors).length > 0; + + return ( + + + +

{labels.agentSkills.createSkillFlyoutTitle}

+
+ + + {labels.agentSkills.viewSkillLibraryLink} + +
+ + + + + + + + + + + + + + {labels.skills.cancelButtonLabel} + + + + {labels.skills.saveButtonLabel} + + + + +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/skill_detail_panel.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/skill_detail_panel.tsx new file mode 100644 index 0000000000000..a997679d8e20c --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/skill_detail_panel.tsx @@ -0,0 +1,135 @@ +/* + * 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 React, { useState } from 'react'; +import { + EuiBadge, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiText, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { labels } from '../../../utils/i18n'; +import { useSkill } from '../../../hooks/skills/use_skills'; +import { DetailRow } from '../common/detail_row'; +import { DetailPanelLayout } from '../common/detail_panel_layout'; +import { RenderSkillContentReadOnly } from '../common/render_skill_content_read_only'; +import { ToolReadOnlyFlyout } from '../tools/tool_readonly_flyout'; + +interface SkillDetailPanelProps { + skillId: string; + onEdit: () => void; + onRemove: () => void; + isReadOnly?: boolean; +} + +export const SkillDetailPanel: React.FC = ({ + skillId, + onEdit, + onRemove, + isReadOnly = false, +}) => { + const { euiTheme } = useEuiTheme(); + const { skill, isLoading } = useSkill({ skillId }); + const [selectedToolId, setSelectedToolId] = useState(null); + + return ( + <> + + + {skill?.id} + + + {skill?.description} + + + } + headerActions={(openConfirmRemove) => + isReadOnly ? ( + {labels.agentSkills.autoIncludedBadgeLabel} + ) : ( + + {!skill?.readonly && ( + + + {labels.skills.editSkillButtonLabel} + + + )} + + + {labels.agentSkills.removeSkillButtonLabel} + + + + ) + } + confirmRemove={{ + title: labels.agentSkills.removeSkillConfirmTitle(skill?.name ?? skillId), + body: labels.agentSkills.removeSkillConfirmBody, + confirmButtonText: labels.agentSkills.removeSkillConfirmButton, + cancelButtonText: labels.agentSkills.removeSkillCancelButton, + onConfirm: onRemove, + }} + > +
+
+ +
+ {skill?.tool_ids && skill.tool_ids.length > 0 && ( + + + {skill.tool_ids.map((toolId) => ( + + setSelectedToolId(toolId)}>{toolId} + + ))} + + + )} +
+
+ {selectedToolId && ( + setSelectedToolId(null)} /> + )} + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/skill_edit_flyout.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/skill_edit_flyout.tsx new file mode 100644 index 0000000000000..037b53fbaac3e --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/skill_edit_flyout.tsx @@ -0,0 +1,147 @@ +/* + * 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 React, { useCallback, useEffect, useMemo } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiForm, + EuiLink, + EuiLoadingSpinner, + EuiSpacer, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { FormProvider } from 'react-hook-form'; +import { labels } from '../../../utils/i18n'; +import { useEditSkill } from '../../../hooks/skills/use_edit_skill'; +import { useSkillForm } from '../../../hooks/skills/use_skill_form'; +import { useTools } from '../../../hooks/tools/use_tools'; +import { useNavigation } from '../../../hooks/use_navigation'; +import { appPaths } from '../../../utils/app_paths'; +import { FLYOUT_WIDTH } from '../common/constants'; +import { SkillForm } from './skill_form'; + +interface SkillEditFlyoutProps { + skillId: string; + onClose: () => void; + onSaved?: () => void; +} + +export const SkillEditFlyout: React.FC = ({ skillId, onClose, onSaved }) => { + const { createAgentBuilderUrl } = useNavigation(); + const skillLibraryUrl = createAgentBuilderUrl(appPaths.manage.skills); + const { tools } = useTools(); + const { euiTheme } = useEuiTheme(); + const form = useSkillForm(); + const { + control, + reset, + handleSubmit, + formState: { errors, isDirty }, + } = form; + + const { skill, isLoading, isSubmitting, editSkill } = useEditSkill({ + skillId, + onSuccess: () => { + onSaved?.(); + onClose(); + }, + }); + + useEffect(() => { + if (skill) { + reset({ + id: skill.id, + name: skill.name, + description: skill.description, + content: skill.content, + tool_ids: skill.tool_ids ?? [], + }); + } + }, [skill, reset]); + + const toolOptions = useMemo( + () => tools.map((tool) => ({ label: tool.id, value: tool.id })), + [tools] + ); + + const onSubmit = useCallback( + async (data: { name: string; description: string; content: string; tool_ids: string[] }) => { + await editSkill({ + name: data.name, + description: data.description, + content: data.content, + tool_ids: data.tool_ids, + }); + }, + [editSkill] + ); + + const hasErrors = Object.keys(errors).length > 0; + + return ( + + + +

{labels.agentSkills.editSkillFlyoutTitle}

+
+ + + {labels.agentSkills.viewSkillLibraryLink} + +
+ + + + {isLoading ? ( + + + + ) : ( + + + + + + )} + + + + + + {labels.skills.cancelButtonLabel} + + + + {labels.skills.saveButtonLabel} + + + + +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/skill_form.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/skill_form.tsx new file mode 100644 index 0000000000000..304d9b2db8c0f --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/skill_form.tsx @@ -0,0 +1,145 @@ +/* + * 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 React from 'react'; +import { + EuiAccordion, + EuiComboBox, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiMarkdownEditor, + EuiSpacer, + EuiTextArea, +} from '@elastic/eui'; +import { Controller } from 'react-hook-form'; +import type { Control } from 'react-hook-form'; +import { labels } from '../../../utils/i18n'; +import type { SkillFormData } from '../../skills/skill_form_validation'; + +interface SkillFormProps { + control: Control; + toolOptions: Array<{ label: string; value: string }>; + /** + * When provided, the ID field is rendered as a disabled static input. + * When omitted, the ID field is rendered as an editable Controller-driven input. + */ + readonlySkillId?: string; +} + +export const SkillForm: React.FC = ({ control, toolOptions, readonlySkillId }) => { + return ( + <> + + + {readonlySkillId !== undefined ? ( + + + + ) : ( + ( + + + + )} + /> + )} + + + ( + + + + )} + /> + + + + + + ( + + + + )} + /> + + ( + + + + )} + /> + + + + + + ( + + ({ label: toolId, value: toolId }))} + onChange={(selected) => onChange(selected.map((opt) => opt.value as string))} + /> + + )} + /> + + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/skill_library_panel.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/skill_library_panel.tsx new file mode 100644 index 0000000000000..657dc0c2f0108 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/skills/skill_library_panel.tsx @@ -0,0 +1,67 @@ +/* + * 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 React, { useMemo } from 'react'; +import type { PublicSkillSummary } from '@kbn/agent-builder-common'; +import { labels } from '../../../utils/i18n'; +import { appPaths } from '../../../utils/app_paths'; +import { LibraryPanel } from '../common/library_panel'; +import type { LibraryPanelLabels } from '../common/library_panel'; + +const libraryLabels: LibraryPanelLabels = { + title: labels.agentSkills.addSkillFromLibraryTitle, + manageLibraryLink: labels.agentSkills.manageSkillLibraryLink, + searchPlaceholder: labels.agentSkills.searchAvailableSkillsPlaceholder, + availableSummary: labels.agentSkills.availableSkillsSummary, + noMatchMessage: labels.agentSkills.noAvailableSkillsMatchMessage, + noItemsMessage: labels.agentSkills.noAvailableSkillsMessage, + disabledBadgeLabel: labels.agentSkills.autoIncludedBadgeLabel, + disabledTooltipTitle: labels.agentSkills.autoIncludedTooltipTitle, + disabledTooltipBody: labels.agentSkills.autoIncludedTooltipBody, +}; + +const getSkillName = (skill: PublicSkillSummary): string => skill.name; + +interface SkillLibraryPanelProps { + onClose: () => void; + allSkills: PublicSkillSummary[]; + activeSkillIdSet: Set; + onToggleSkill: (skill: PublicSkillSummary, isActive: boolean) => void; + mutatingSkillId: string | null; + enableElasticCapabilities?: boolean; + builtinSkillIdSet?: Set; +} + +export const SkillLibraryPanel: React.FC = ({ + onClose, + allSkills, + activeSkillIdSet, + onToggleSkill, + mutatingSkillId, + enableElasticCapabilities = false, + builtinSkillIdSet, +}) => { + const disabledItemIdSet = useMemo(() => { + if (!enableElasticCapabilities || !builtinSkillIdSet) return undefined; + return builtinSkillIdSet; + }, [enableElasticCapabilities, builtinSkillIdSet]); + + return ( + + onClose={onClose} + allItems={allSkills} + activeItemIdSet={activeSkillIdSet} + onToggleItem={onToggleSkill} + mutatingItemId={mutatingSkillId} + flyoutTitleId="skillLibraryFlyoutTitle" + libraryLabels={libraryLabels} + manageLibraryPath={appPaths.manage.skills} + getItemName={getSkillName} + disabledItemIdSet={disabledItemIdSet} + /> + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/tools/agent_tools.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/tools/agent_tools.tsx new file mode 100644 index 0000000000000..b4f5f5ba0b339 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/tools/agent_tools.tsx @@ -0,0 +1,346 @@ +/* + * 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 React, { useMemo, useState, useEffect, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; +import { + EuiBadge, + EuiButton, + EuiButtonEmpty, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLoadingSpinner, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import type { ToolDefinition, ToolSelection } from '@kbn/agent-builder-common'; +import { defaultAgentToolIds } from '@kbn/agent-builder-common'; +import { useMutation, useQueryClient } from '@kbn/react-query'; +import { labels } from '../../../utils/i18n'; +import { appPaths } from '../../../utils/app_paths'; +import { useNavigation } from '../../../hooks/use_navigation'; +import { useToolsService } from '../../../hooks/tools/use_tools'; +import { useAgentBuilderAgentById } from '../../../hooks/agents/use_agent_by_id'; +import { useAgentBuilderServices } from '../../../hooks/use_agent_builder_service'; +import { useToasts } from '../../../hooks/use_toasts'; +import { queryKeys } from '../../../query_keys'; +import { useFlyoutState } from '../../../hooks/use_flyout_state'; +import { + getActiveTools, + isToolSelected, + toggleToolSelection, +} from '../../../utils/tool_selection_utils'; +import { ActiveItemRow } from '../common/active_item_row'; +import { ToolLibraryPanel } from './tool_library_panel'; +import { ToolDetailPanel } from './tool_detail_panel'; +import { PageWrapper } from '../common/page_wrapper'; +import { ICON_DIMENSIONS } from '../common/constants'; +import { useListDetailPageStyles } from '../common/styles'; + +const ActiveToolsList: React.FC<{ + filteredActiveTools: ToolDefinition[]; + searchQuery: string; + selectedToolId: string | null; + enableElasticCapabilities: boolean; + defaultToolIdSet: Set; + isRemoving: boolean; + onSelect: (id: string) => void; + onRemove: (tool: ToolDefinition) => void; +}> = ({ + filteredActiveTools, + searchQuery, + selectedToolId, + enableElasticCapabilities, + defaultToolIdSet, + isRemoving, + onSelect, + onRemove, +}) => { + if (filteredActiveTools.length === 0) { + return ( + +

+ {searchQuery.trim() + ? labels.agentTools.noActiveToolsMatchMessage + : labels.agentTools.noActiveToolsMessage} +

+
+ ); + } + + return ( + <> + {filteredActiveTools.map((tool) => { + const isBuiltIn = defaultToolIdSet.has(tool.id); + const isAutoIncluded = enableElasticCapabilities && isBuiltIn; + return ( + onSelect(tool.id)} + onRemove={() => onRemove(tool)} + isRemoving={isRemoving} + removeAriaLabel={labels.agentTools.removeToolAriaLabel} + readOnlyContent={ + isAutoIncluded ? ( + + {labels.agentTools.elasticCapabilitiesReadOnlyBadge} + + ) : isBuiltIn ? ( + {labels.agentTools.readOnlyBadge} + ) : undefined + } + /> + ); + })} + + ); +}; + +export const AgentTools: React.FC = () => { + const { agentId } = useParams<{ agentId: string }>(); + const styles = useListDetailPageStyles(); + const { createAgentBuilderUrl } = useNavigation(); + const { agentService } = useAgentBuilderServices(); + const { addSuccessToast, addErrorToast } = useToasts(); + const queryClient = useQueryClient(); + + const { agent, isLoading: agentLoading } = useAgentBuilderAgentById(agentId); + const { tools: allTools, isLoading: toolsLoading } = useToolsService(); + + const [searchQuery, setSearchQuery] = useState(''); + const [selectedToolId, setSelectedToolId] = useState(null); + const [mutatingToolId, setMutatingToolId] = useState(null); + const { + isOpen: isLibraryOpen, + openFlyout: openLibrary, + closeFlyout: closeLibrary, + } = useFlyoutState(); + + const agentToolSelections = useMemo( + () => agent?.configuration?.tools ?? [], + [agent?.configuration?.tools] + ); + + const enableElasticCapabilities = agent?.configuration?.enable_elastic_capabilities ?? false; + + const defaultToolIdSet = useMemo(() => new Set(defaultAgentToolIds), []); + + const activeTools = useMemo( + () => + agent + ? getActiveTools(allTools, agentToolSelections, enableElasticCapabilities, defaultToolIdSet) + : [], + [allTools, agentToolSelections, agent, enableElasticCapabilities, defaultToolIdSet] + ); + + const activeToolIdSet = useMemo(() => new Set(activeTools.map((t) => t.id)), [activeTools]); + + const libraryActiveToolIdSet = useMemo(() => { + if (enableElasticCapabilities) return new Set([...activeToolIdSet, ...defaultToolIdSet]); + return activeToolIdSet; + }, [activeToolIdSet, enableElasticCapabilities, defaultToolIdSet]); + + useEffect(() => { + if (!selectedToolId) { + if (activeTools.length > 0) { + setSelectedToolId(activeTools[0].id); + } + } else { + const stillActive = activeTools.some((t) => t.id === selectedToolId); + if (!stillActive) { + setSelectedToolId(activeTools[0]?.id ?? null); + } + } + }, [activeTools, selectedToolId]); + + const filteredActiveTools = useMemo(() => { + if (!searchQuery.trim()) return activeTools; + const lower = searchQuery.toLowerCase(); + return activeTools.filter( + (t) => t.id.toLowerCase().includes(lower) || t.description.toLowerCase().includes(lower) + ); + }, [activeTools, searchQuery]); + + const updateToolsMutation = useMutation({ + mutationFn: (newToolSelections: ToolSelection[]) => { + return agentService.update(agentId!, { configuration: { tools: newToolSelections } }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.agentProfiles.byId(agentId) }); + }, + onError: () => { + addErrorToast({ title: labels.agentTools.updateToolsErrorToast }); + }, + }); + + const handleAddTool = useCallback( + async (tool: ToolDefinition) => { + if (isToolSelected(tool, agentToolSelections)) return; + const newSelections = toggleToolSelection(tool.id, allTools, agentToolSelections); + setMutatingToolId(tool.id); + try { + await updateToolsMutation.mutateAsync(newSelections); + addSuccessToast({ title: labels.agentTools.addToolSuccessToast(tool.id) }); + } finally { + setMutatingToolId(null); + } + }, + [agentToolSelections, allTools, updateToolsMutation, addSuccessToast] + ); + + const handleRemoveTool = useCallback( + (tool: ToolDefinition) => { + const newSelections = toggleToolSelection(tool.id, allTools, agentToolSelections); + setMutatingToolId(tool.id); + updateToolsMutation.mutate(newSelections, { + onSuccess: () => { + setSelectedToolId(null); + addSuccessToast({ title: labels.agentTools.removeToolSuccessToast(tool.id) }); + }, + onSettled: () => setMutatingToolId(null), + }); + }, + [agentToolSelections, allTools, updateToolsMutation, addSuccessToast] + ); + + const handleToggleTool = useCallback( + (tool: ToolDefinition, isActive: boolean) => { + if (enableElasticCapabilities && defaultToolIdSet.has(tool.id)) return; + if (isActive) { + handleAddTool(tool); + } else { + handleRemoveTool(tool); + } + }, + [handleAddTool, handleRemoveTool, enableElasticCapabilities, defaultToolIdSet] + ); + + /** Guarded removal: only prevents removing auto-included tools from the agent. */ + const handleRemoveSelectedTool = useCallback(() => { + if (!selectedToolId) return; + if (enableElasticCapabilities && defaultToolIdSet.has(selectedToolId)) return; + const tool = activeTools.find((t) => t.id === selectedToolId); + if (tool) { + handleRemoveTool(tool); + } + }, [selectedToolId, activeTools, handleRemoveTool, enableElasticCapabilities, defaultToolIdSet]); + + const isLoading = agentLoading || toolsLoading; + + if (isLoading) { + return ( + + + + ); + } + + return ( + +
+ + + + + + + + +

{labels.tools.title}

+
+
+
+
+ + + + + {labels.agentTools.manageAllTools} + + + + + {labels.agentTools.addToolButton} + + + + +
+ + + + {labels.agentTools.pageDescription} + +
+ + + +
+ setSearchQuery(e.target.value)} + incremental + fullWidth + /> +
+ +
+ +
+
+ + + {selectedToolId ? ( + + ) : ( + + + {labels.agentTools.noToolSelectedMessage} + + + )} + +
+ + {isLibraryOpen && ( + + )} +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/tools/tool_detail_panel.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/tools/tool_detail_panel.tsx new file mode 100644 index 0000000000000..dfec8267760a2 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/tools/tool_detail_panel.tsx @@ -0,0 +1,132 @@ +/* + * 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 React from 'react'; +import { + EuiBadge, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiText, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { labels } from '../../../utils/i18n'; +import { useToolService } from '../../../hooks/tools/use_tools'; +import { appPaths } from '../../../utils/app_paths'; +import { useNavigation } from '../../../hooks/use_navigation'; +import { DetailPanelLayout } from '../common/detail_panel_layout'; + +interface ToolDetailPanelProps { + toolId: string; + onRemove: () => void; + isAutoIncluded?: boolean; +} + +export const ToolDetailPanel: React.FC = ({ + toolId, + onRemove, + isAutoIncluded = false, +}) => { + const { euiTheme } = useEuiTheme(); + const { tool, isLoading } = useToolService(toolId); + const { createAgentBuilderUrl } = useNavigation(); + const isReadOnly = tool?.readonly; + const editInLibraryUrl = createAgentBuilderUrl(appPaths.manage.toolDetails({ toolId })); + + return ( + + {tool?.id} + + } + headerActions={(openConfirmRemove) => ( + + {isAutoIncluded ? ( + + {labels.agentTools.autoIncludedBadgeLabel} + + ) : isReadOnly ? ( + <> + + + {labels.agentTools.readOnlyBadge} + + + + + {labels.agentTools.removeToolButtonLabel} + + + + ) : ( + <> + + + {labels.agentTools.editInLibraryLink} + + + + + {labels.agentTools.removeToolButtonLabel} + + + + )} + + )} + confirmRemove={{ + title: labels.agentTools.removeToolConfirmTitle(tool?.id ?? toolId), + body: labels.agentTools.removeToolConfirmBody, + confirmButtonText: labels.agentTools.removeToolConfirmButton, + cancelButtonText: labels.agentTools.removeToolCancelButton, + onConfirm: onRemove, + }} + > +
+ +

{labels.agentTools.toolDetailDescriptionLabel}

+
+ + {tool?.description || '\u2014'} + +
+
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/tools/tool_library_panel.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/tools/tool_library_panel.tsx new file mode 100644 index 0000000000000..6dc731c0bec7c --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/tools/tool_library_panel.tsx @@ -0,0 +1,64 @@ +/* + * 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 React, { useMemo } from 'react'; +import type { ToolDefinition } from '@kbn/agent-builder-common'; +import { labels } from '../../../utils/i18n'; +import { appPaths } from '../../../utils/app_paths'; +import { LibraryPanel } from '../common/library_panel'; +import type { LibraryPanelLabels } from '../common/library_panel'; + +const libraryLabels: LibraryPanelLabels = { + title: labels.agentTools.addToolFromLibraryTitle, + manageLibraryLink: labels.agentTools.manageToolLibraryLink, + searchPlaceholder: labels.agentTools.searchAvailableToolsPlaceholder, + availableSummary: labels.agentTools.availableToolsSummary, + noMatchMessage: labels.agentTools.noAvailableToolsMatchMessage, + noItemsMessage: labels.agentTools.noAvailableToolsMessage, + disabledBadgeLabel: labels.agentTools.autoIncludedBadgeLabel, + disabledTooltipTitle: labels.agentTools.autoIncludedTooltipTitle, + disabledTooltipBody: labels.agentTools.autoIncludedTooltipBody, +}; + +interface ToolLibraryPanelProps { + onClose: () => void; + allTools: ToolDefinition[]; + activeToolIdSet: Set; + onToggleTool: (tool: ToolDefinition, isActive: boolean) => void; + mutatingToolId: string | null; + enableElasticCapabilities?: boolean; + builtinToolIdSet?: Set; +} + +export const ToolLibraryPanel: React.FC = ({ + onClose, + allTools, + activeToolIdSet, + onToggleTool, + mutatingToolId, + enableElasticCapabilities = false, + builtinToolIdSet, +}) => { + const disabledItemIdSet = useMemo(() => { + if (!enableElasticCapabilities || !builtinToolIdSet) return undefined; + return builtinToolIdSet; + }, [enableElasticCapabilities, builtinToolIdSet]); + + return ( + + onClose={onClose} + allItems={allTools} + activeItemIdSet={activeToolIdSet} + onToggleItem={onToggleTool} + mutatingItemId={mutatingToolId} + flyoutTitleId="toolLibraryFlyoutTitle" + libraryLabels={libraryLabels} + manageLibraryPath={appPaths.tools.list} + disabledItemIdSet={disabledItemIdSet} + /> + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/tools/tool_readonly_flyout.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/tools/tool_readonly_flyout.tsx new file mode 100644 index 0000000000000..f104cb7020291 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/agents/tools/tool_readonly_flyout.tsx @@ -0,0 +1,73 @@ +/* + * 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 React from 'react'; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiHorizontalRule, + EuiLoadingSpinner, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { labels } from '../../../utils/i18n'; +import { useToolService } from '../../../hooks/tools/use_tools'; + +interface ToolReadOnlyFlyoutProps { + toolId: string; + onClose: () => void; +} + +export const ToolReadOnlyFlyout: React.FC = ({ toolId, onClose }) => { + const { tool, isLoading } = useToolService(toolId); + + return ( + + + + + +

{tool?.id ?? toolId}

+
+
+ + + {labels.agentTools.readOnlyBadge} + + +
+
+ + {isLoading ? ( + + + + ) : tool ? ( + + + +

{labels.agentTools.toolDetailIdLabel}

+
+ {tool.id} +
+ + + +

{labels.agentTools.toolDetailDescriptionLabel}

+
+ {tool.description || '\u2014'} +
+
+ ) : null} +
+
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/common/agent_avatar.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/common/agent_avatar.tsx index 84ea6ae42c9f3..fd594a0a453c7 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/common/agent_avatar.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/common/agent_avatar.tsx @@ -86,21 +86,23 @@ export const AgentAvatar: React.FC = (props) => { const isDefaultAgent = agentId === agentBuilderDefaultAgentId; const shouldUseIcon = !symbol && (isBuiltIn || isDefaultAgent || Boolean(icon)); + const borderAndShapeStyles = css` + border: 1px solid ${euiTheme.colors.borderBaseSubdued}; + ${shape === 'circle' ? 'border-radius: 50%;' : roundedBorderRadiusStyles} + `; + if (shouldUseIcon) { const iconType = icon ?? 'logoElastic'; const iconSize = getIconSize({ size }); - if (hasBackground) { - const panelStyles = css` - background-color: ${color}; - ${roundedBorderRadiusStyles} - `; - return ( - - - - ); - } - return ; + const panelStyles = css` + ${hasBackground ? `background-color: ${color};` : ''} + ${borderAndShapeStyles} + `; + return ( + + + + ); } let type: 'user' | 'space' | undefined; @@ -109,7 +111,6 @@ export const AgentAvatar: React.FC = (props) => { } else if (shape === 'square') { type = 'space'; } - const avatarStyles = shape === 'square' && roundedBorderRadiusStyles; return ( = (props) => { initials={symbol} type={type} color={color} - css={avatarStyles} + css={borderAndShapeStyles} /> ); }; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/common/agent_selector/agent_selector_dropdown.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/common/agent_selector/agent_selector_dropdown.tsx new file mode 100644 index 0000000000000..727ffdacebdb9 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/common/agent_selector/agent_selector_dropdown.tsx @@ -0,0 +1,237 @@ +/* + * 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 React, { useState } from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiPopover, + EuiPopoverFooter, + EuiSelectable, + EuiText, + useEuiTheme, +} from '@elastic/eui'; +import type { EuiPopoverProps } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import type { AgentDefinition } from '@kbn/agent-builder-common'; + +import { useUiPrivileges } from '../../../hooks/use_ui_privileges'; +import { useNavigation } from '../../../hooks/use_navigation'; +import { appPaths } from '../../../utils/app_paths'; +import { + selectorPopoverPanelStyles, + useSelectorListStyles, +} from '../../conversations/conversation_input/input_actions/input_actions.styles'; +import { useAgentOptions } from './use_agent_options'; + +const SELECTOR_POPOVER_HEIGHT = 400; +const AGENT_OPTION_ROW_HEIGHT = 52; // 48px content + 4px bottom gap +const HORIZONTAL_RULE_HEIGHT = 1; +const AGENT_SELECTOR_HEADER_HEIGHT = 56; +const AGENT_SELECTOR_FOOTER_HEIGHT = 64; + +const labels = { + selectAgent: i18n.translate('xpack.agentBuilder.agentSelectorDropdown.selectAgent.ariaLabel', { + defaultMessage: 'Select an agent', + }), + availableAgents: i18n.translate('xpack.agentBuilder.agentSelectorDropdown.availableAgents', { + defaultMessage: 'Available agents', + }), + newAgent: i18n.translate('xpack.agentBuilder.agentSelectorDropdown.newAgent', { + defaultMessage: 'New agent', + }), + manageAgents: i18n.translate('xpack.agentBuilder.agentSelectorDropdown.manageAgents', { + defaultMessage: 'Manage agents', + }), +}; + +const agentSelectId = 'agentBuilderAgentSelectorDropdown'; +const agentListId = `${agentSelectId}_listbox`; + +const AgentListHeader: React.FC = () => { + const { euiTheme } = useEuiTheme(); + const { manageAgents } = useUiPrivileges(); + const { createAgentBuilderUrl } = useNavigation(); + const createAgentHref = createAgentBuilderUrl(appPaths.agents.new); + return ( + + + + {labels.availableAgents} + + + + + {labels.newAgent} + + + + ); +}; + +const AgentListFooter: React.FC = () => { + const { euiTheme } = useEuiTheme(); + const { manageAgents } = useUiPrivileges(); + const { createAgentBuilderUrl } = useNavigation(); + const manageAgentsHref = createAgentBuilderUrl(appPaths.agents.list); + return ( + + + {labels.manageAgents} + + + ); +}; + +export interface AgentSelectorDropdownProps { + agents: AgentDefinition[]; + selectedAgent?: AgentDefinition; + onAgentChange: (agentId: string) => void; + anchorPosition?: EuiPopoverProps['anchorPosition']; + /** Shown in the trigger button when selectedAgent is undefined (e.g. deleted agent) */ + fallbackLabel?: string; +} + +export const AgentSelectorDropdown: React.FC = ({ + agents, + selectedAgent, + onAgentChange, + anchorPosition = 'downLeft', + fallbackLabel, +}) => { + const { euiTheme } = useEuiTheme(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const { agentOptions, renderAgentOption } = useAgentOptions({ + agents, + selectedAgentId: selectedAgent?.id, + }); + + const maxListHeight = + SELECTOR_POPOVER_HEIGHT - + AGENT_SELECTOR_HEADER_HEIGHT - + HORIZONTAL_RULE_HEIGHT - + AGENT_SELECTOR_FOOTER_HEIGHT - + HORIZONTAL_RULE_HEIGHT; + const listHeight = Math.min(agentOptions.length * AGENT_OPTION_ROW_HEIGHT, maxListHeight); + + const selectorListStyles = css` + ${useSelectorListStyles({ listId: agentListId })} + &#${agentListId} .euiSelectableListItem { + align-items: flex-start; + padding-bottom: ${euiTheme.size.xs}; + } + `; + + const triggerButton = ( + setIsPopoverOpen((v) => !v)} + data-test-subj="agentBuilderAgentSelectorButton" + > + {selectedAgent?.name ?? fallbackLabel} + + ); + + return ( + setIsPopoverOpen(false)} + > + + + + + + + + + { + const { checked, key: agentId } = changedOption; + if (checked === 'on' && agentId) { + onAgentChange(agentId); + setIsPopoverOpen(false); + } + }} + singleSelection + renderOption={(option) => renderAgentOption({ agent: option.agent })} + height={listHeight} + listProps={{ + id: agentListId, + isVirtualized: true, + rowHeight: AGENT_OPTION_ROW_HEIGHT, + onFocusBadge: false, + css: selectorListStyles, + }} + > + {(list) => ( + <> + {list} + + + )} + + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/agent_selector/use_agent_options.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/common/agent_selector/use_agent_options.tsx similarity index 50% rename from x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/agent_selector/use_agent_options.tsx rename to x-pack/platform/plugins/shared/agent_builder/public/application/components/common/agent_selector/use_agent_options.tsx index 25320154b52e2..b91aa3a5ee47d 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/agent_selector/use_agent_options.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/common/agent_selector/use_agent_options.tsx @@ -10,9 +10,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui'; import type { AgentDefinition } from '@kbn/agent-builder-common'; import React, { useMemo } from 'react'; import { css } from '@emotion/react'; -import { i18n } from '@kbn/i18n'; -import { AgentAvatar } from '../../../../common/agent_avatar'; -import { OptionText } from '../option_text'; +import { AgentAvatar } from '../agent_avatar'; +import { OptionText } from '../../conversations/conversation_input/input_actions/option_text'; type AgentOptionData = EuiSelectableOption<{ agent?: AgentDefinition }>; @@ -20,18 +19,11 @@ interface AgentOptionProps { agent?: AgentDefinition; } -const readonlyAgentTooltip = i18n.translate( - 'xpack.agentBuilder.agentSelector.readonlyAgentTooltip', - { - defaultMessage: 'This agent is read-only.', - } -); - const AgentOptionPrepend: React.FC<{ agent: AgentDefinition }> = ({ agent }) => { return ( - + ); @@ -44,25 +36,31 @@ const AgentOption: React.FC = ({ agent }) => { return ( - + {agent.name} - {agent.readonly && ( - - - - )} + + + ); @@ -75,25 +73,28 @@ export const useAgentOptions = ({ agents: AgentDefinition[]; selectedAgentId?: string; }) => { - const agentOptions = useMemo( - () => - agents.map((agent) => { - let checked: 'on' | undefined; - if (agent.id === selectedAgentId) { - checked = 'on'; - } - const option: AgentOptionData = { - key: agent.id, - label: agent.name, - checked, - prepend: , - textWrap: 'wrap', - data: { agent }, - }; - return option; - }), - [agents, selectedAgentId] - ); + const agentOptions = useMemo(() => { + const sorted = [...agents].sort((a, b) => { + if (a.id === selectedAgentId) return -1; + if (b.id === selectedAgentId) return 1; + return 0; + }); + return sorted.map((agent) => { + let checked: 'on' | undefined; + if (agent.id === selectedAgentId) { + checked = 'on'; + } + const option: AgentOptionData = { + key: agent.id, + label: agent.name, + checked, + prepend: , + textWrap: 'wrap', + data: { agent }, + }; + return option; + }); + }, [agents, selectedAgentId]); return { agentOptions, renderAgentOption: (props: AgentOptionProps) => , diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/common/route_display.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/common/route_display.tsx new file mode 100644 index 0000000000000..ef40fa9e878a3 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/common/route_display.tsx @@ -0,0 +1,54 @@ +/* + * 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 React from 'react'; +import { useLocation, useParams } from 'react-router-dom'; + +import { EuiCode, EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; + +export const RouteDisplay: React.FC = () => { + const location = useLocation(); + const params = useParams(); + const { euiTheme } = useEuiTheme(); + + const containerStyles = css` + padding: ${euiTheme.size.xl}; + height: 100%; + `; + + const codeStyles = css` + font-size: ${euiTheme.size.l}; + padding: ${euiTheme.size.m}; + `; + + return ( + + + +

Current Route

+
+
+ + {location.pathname} + + {Object.keys(params).length > 0 && ( + + + Route params: {JSON.stringify(params)} + + + )} +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/actions/start_new_conversation_button.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/actions/start_new_conversation_button.tsx index e71c16d521917..b956c458b83cd 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/actions/start_new_conversation_button.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/actions/start_new_conversation_button.tsx @@ -10,6 +10,7 @@ import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useConversationContext } from '../../../context/conversation/conversation_context'; import { useNavigation } from '../../../hooks/use_navigation'; +import { useLastAgentId } from '../../../hooks/use_last_agent_id'; import { appPaths } from '../../../utils/app_paths'; const NEW_CONVERSATION_BUTTON_LABEL = i18n.translate( @@ -19,19 +20,18 @@ const NEW_CONVERSATION_BUTTON_LABEL = i18n.translate( } ); -const NEW_CONVERSATION_PATH = appPaths.chat.new; - export const StartNewConversationButton: React.FC = () => { const { navigateToAgentBuilderUrl } = useNavigation(); const { isEmbeddedContext, setConversationId } = useConversationContext(); + const lastAgentId = useLastAgentId(); const handleClick = useCallback(() => { if (isEmbeddedContext) { setConversationId?.(undefined); } else { - navigateToAgentBuilderUrl(NEW_CONVERSATION_PATH); + navigateToAgentBuilderUrl(appPaths.agent.conversations.new({ agentId: lastAgentId })); } - }, [isEmbeddedContext, setConversationId, navigateToAgentBuilderUrl]); + }, [isEmbeddedContext, setConversationId, navigateToAgentBuilderUrl, lastAgentId]); return ( void; -} - -export const CloseDockedViewButton: React.FC = ({ onClose }) => { - return ( - - ); -}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversation_actions_left.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversation_actions_left.tsx deleted file mode 100644 index 3eaf439b1e24f..0000000000000 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversation_actions_left.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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 React from 'react'; -import { EuiFlexGroup, EuiTourStep } from '@elastic/eui'; -import { ConversationsHistoryButton } from './conversations_history_button'; -import { useHasActiveConversation } from '../../../hooks/use_conversation'; -import { NewConversationButton } from './new_conversation_button'; -import { TourStep, useAgentBuilderTour } from '../../../context/agent_builder_tour_context'; - -export const ConversationLeftActions: React.FC<{}> = () => { - const hasActiveConversation = useHasActiveConversation(); - const { getStepProps } = useAgentBuilderTour(); - - return ( - - - - - {hasActiveConversation && } - - ); -}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversation_actions_right.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversation_actions_right.tsx index ede854986dce9..66f71e2fbb514 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversation_actions_right.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversation_actions_right.tsx @@ -6,47 +6,45 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiTourStep } from '@elastic/eui'; +import { EuiButtonIcon, EuiFlexGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useConversationContext } from '../../../context/conversation/conversation_context'; import { MoreActionsButton } from './more_actions_button'; -import { CloseDockedViewButton } from './close_docked_view_button'; -import { TourStep, useAgentBuilderTour } from '../../../context/agent_builder_tour_context'; const labels = { container: i18n.translate('xpack.agentBuilder.conversationActions.container', { defaultMessage: 'Conversation actions', }), + close: i18n.translate('xpack.agentBuilder.conversationActions.close', { + defaultMessage: 'Close', + }), }; export interface ConversationRightActionsProps { onClose?: () => void; - onRenameConversation: () => void; } -export const ConversationRightActions: React.FC = ({ - onClose, - onRenameConversation, -}) => { +export const ConversationRightActions: React.FC = ({ onClose }) => { const { isEmbeddedContext } = useConversationContext(); - const { getStepProps } = useAgentBuilderTour(); - return ( - - + {isEmbeddedContext && ( + - - {isEmbeddedContext ? : null} + )} ); }; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversation_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversation_header.tsx index 1a5d160b4667c..90d9bfb163d16 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversation_header.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversation_header.tsx @@ -5,15 +5,20 @@ * 2.0. */ -import React, { useState } from 'react'; +import React from 'react'; import { css } from '@emotion/react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { ConversationRightActions } from './conversation_actions_right'; -import { ConversationLeftActions } from './conversation_actions_left'; import { ConversationTitle } from './conversation_title'; -const centerSectionStyles = css` +const headerGridStyles = css` + display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; + width: 100%; +`; + +const rightActionsStyles = css` + justify-self: end; `; interface ConversationHeaderProps { @@ -24,26 +29,18 @@ export const ConversationHeader: React.FC = ({ onClose, ariaLabelledBy, }) => { - const [isEditing, setIsEditing] = useState(false); - return ( - - - - - - - - - setIsEditing(true)} - /> - - +
+ {/* Left column — intentionally empty, reserved for future actions */} +
+ + {/* Center column — always exactly centered */} + + + {/* Right column — right-aligned within its 1fr column */} +
+ +
+
); }; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversation_title.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversation_title.tsx index ffdd97c61cf6c..daa5850d6caab 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversation_title.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversation_title.tsx @@ -5,129 +5,142 @@ * 2.0. */ -import React, { useState, useEffect } from 'react'; -import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; +import React, { useState } from 'react'; +import { + EuiButtonEmpty, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiIcon, + EuiPopover, + useEuiTheme, +} from '@elastic/eui'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; -import { - useConversationTitle, - useHasActiveConversation, - useHasPersistedConversation, -} from '../../../hooks/use_conversation'; -import { RenameConversationInput } from './rename_conversation_input'; +import { useConversationTitle, useHasPersistedConversation } from '../../../hooks/use_conversation'; +import { DeleteConversationModal } from '../delete_conversation_modal'; +import { RenameConversationModal } from '../rename_conversation_modal'; const labels = { - ariaLabel: i18n.translate('xpack.agentBuilder.conversationTitle.ariaLabel', { - defaultMessage: 'Conversation title', - }), rename: i18n.translate('xpack.agentBuilder.conversationTitle.rename', { - defaultMessage: 'Rename conversation', + defaultMessage: 'Rename', + }), + delete: i18n.translate('xpack.agentBuilder.conversationTitle.delete', { + defaultMessage: 'Delete', }), newConversation: i18n.translate('xpack.agentBuilder.conversationTitle.newConversation', { defaultMessage: 'New conversation', }), + openTitleMenu: i18n.translate('xpack.agentBuilder.conversationTitle.openTitleMenu', { + defaultMessage: 'Open conversation menu', + }), }; interface ConversationTitleProps { ariaLabelledBy?: string; - isEditing: boolean; - setIsEditing: (isEditing: boolean) => void; } -export const ConversationTitle: React.FC = ({ - ariaLabelledBy, - isEditing, - setIsEditing, -}) => { - const { title, isLoading } = useConversationTitle(); - const hasActiveConversation = useHasActiveConversation(); +export const ConversationTitle: React.FC = ({ ariaLabelledBy }) => { + const { title, isLoading: isLoadingTitle } = useConversationTitle(); const hasPersistedConversation = useHasPersistedConversation(); const { euiTheme } = useEuiTheme(); - const [isHovering, setIsHovering] = useState(false); - const [previousTitle, setPreviousTitle] = useState(''); - const [currentText, setCurrentText] = useState(''); - - useEffect(() => { - if (isLoading || !hasActiveConversation) return; - - const fullText = title || labels.newConversation; - - // Typewriter: ONLY when transitioning from "New conversation" to actual title - if (previousTitle === labels.newConversation && title) { - if (currentText.length < fullText.length) { - const timeout = setTimeout(() => { - setCurrentText(fullText.substring(0, currentText.length + 1)); - }, 50); - return () => clearTimeout(timeout); - } - } else if (title && title !== previousTitle) { - // Normal title change: immediate - setCurrentText(fullText); - } else if (!title) { - // Reset when switching to new conversation (no title) - setCurrentText(''); - } - - // Always track the previous title - setPreviousTitle(fullText); - }, [title, currentText, isLoading, previousTitle, hasActiveConversation]); - const displayedTitle = currentText || previousTitle; - - const handlePencilClick = () => { - setIsEditing(true); - setIsHovering(false); - }; - - const handleCancel = () => { - setIsEditing(false); - }; - - const shouldShowTitle = hasActiveConversation; - if (!shouldShowTitle) { - return null; + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isRenameModalOpen, setIsRenameModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const displayedTitle = isLoadingTitle ? '' : title || labels.newConversation; + + // No popover for unsaved conversations — just show the title + if (!hasPersistedConversation) { + return ( +

+ {displayedTitle} +

+ ); } - if (isEditing) { - return ; - } + const menuItems = [ + { + setIsPopoverOpen(false); + setIsRenameModalOpen(true); + }} + data-test-subj="agentBuilderConversationRenameButton" + > + {labels.rename} + , + } + onClick={() => { + setIsPopoverOpen(false); + setIsDeleteModalOpen(true); + }} + css={css` + color: ${euiTheme.colors.danger}; + `} + data-test-subj="agentBuilderConversationDeleteButton" + > + {labels.delete} + , + ]; - const titleStyles = css` + const titleButtonStyles = css` + max-width: 100%; + block-size: auto; font-weight: ${euiTheme.font.weight.semiBold}; + .euiButtonEmpty__text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } `; - // Only show rename icon when there is a conversation ID !== 'new' - const canRename = hasPersistedConversation; + const titleButton = ( + setIsPopoverOpen((open) => !open)} + aria-expanded={isPopoverOpen} + css={titleButtonStyles} + data-test-subj="agentBuilderConversationTitleButton" + > + {displayedTitle} + + ); return ( - setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - data-test-subj="agentBuilderConversationTitle" - > - -

- {displayedTitle} -

-
- {canRename && ( - - - - )} -
+ <> + setIsPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="downCenter" + aria-label={labels.openTitleMenu} + > + + + + setIsRenameModalOpen(false)} + /> + + setIsDeleteModalOpen(false)} + /> + ); }; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversations_history_button.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversations_history_button.tsx deleted file mode 100644 index 7774094e4ee55..0000000000000 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/conversations_history_button.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* - * 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 { - EuiButtonEmpty, - EuiButtonIcon, - EuiPageHeaderSection, - EuiToolTip, - useEuiTheme, -} from '@elastic/eui'; -import React, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { css } from '@emotion/react'; -import { useHasActiveConversation } from '../../../hooks/use_conversation'; -import { ConversationsHistoryPopover } from '../conversations_history/conversations_history_popover'; -import { useConversationContext } from '../../../context/conversation/conversation_context'; -import { useConversationList } from '../../../hooks/use_conversation_list'; - -const labels = { - open: i18n.translate('xpack.agentBuilder.conversationsHistory.open', { - defaultMessage: 'Open conversations', - }), - close: i18n.translate('xpack.agentBuilder.conversationsHistory.close', { - defaultMessage: 'Close conversations', - }), - conversations: i18n.translate('xpack.agentBuilder.conversationsHistory.conversations', { - defaultMessage: 'Conversations', - }), -}; - -export const ConversationsHistoryButton: React.FC = () => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const hasActiveConversation = useHasActiveConversation(); - const { euiTheme } = useEuiTheme(); - const { isEmbeddedContext } = useConversationContext(); - const { conversations, isLoading } = useConversationList(); - - const hasNoConversations = !isLoading && conversations?.length === 0; - - const togglePopover = () => { - setIsPopoverOpen(!isPopoverOpen); - }; - - const paddingLeft = css` - padding-left: ${euiTheme.size.s}; - `; - - const showButtonIcon = hasActiveConversation; - - const button = showButtonIcon ? ( - {labels.conversations}

}> - -
- ) : ( - - {labels.conversations} - - ); - - const shouldAddPaddingLeft = hasActiveConversation && !isEmbeddedContext; - return ( - - setIsPopoverOpen(false)} - /> - - ); -}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/more_actions_button.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/more_actions_button.tsx index 94448c672c3fb..836fea064304e 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/more_actions_button.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/more_actions_button.tsx @@ -8,38 +8,22 @@ import { EuiContextMenuItem, EuiButtonIcon, - EuiButtonEmpty, EuiPopover, EuiContextMenuPanel, - EuiTitle, EuiSpacer, - EuiIcon, - useEuiTheme, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useState } from 'react'; -import { - DATA_SOURCES_ENABLED_SETTING_ID, - AGENT_BUILDER_CONNECTORS_ENABLED_SETTING_ID, -} from '@kbn/management-settings-ids'; -import { DATA_SOURCES_APP_ID } from '@kbn/deeplinks-data-sources'; -import { css } from '@emotion/react'; -import { useIsAgentReadOnly } from '../../../hooks/agents/use_is_agent_read_only'; -import { useExperimentalFeatures } from '../../../hooks/use_experimental_features'; import { useNavigation } from '../../../hooks/use_navigation'; -import { - useHasActiveConversation, - useAgentId, - useHasPersistedConversation, -} from '../../../hooks/use_conversation'; +import { useAgentId } from '../../../hooks/use_conversation'; import { useConversationContext } from '../../../context/conversation/conversation_context'; import { useConversationId } from '../../../context/conversation/use_conversation_id'; import { useKibana } from '../../../hooks/use_kibana'; -import { searchParamNames } from '../../../search_param_names'; import { appPaths } from '../../../utils/app_paths'; import { DeleteConversationModal } from '../delete_conversation_modal'; import { useHasConnectorsAllPrivileges } from '../../../hooks/use_has_connectors_all_privileges'; import { useUiPrivileges } from '../../../hooks/use_ui_privileges'; + const fullscreenLabels = { actions: i18n.translate('xpack.agentBuilder.conversationActions.actions', { defaultMessage: 'More', @@ -47,53 +31,8 @@ const fullscreenLabels = { actionsAriaLabel: i18n.translate('xpack.agentBuilder.conversationActions.actionsAriaLabel', { defaultMessage: 'More', }), - conversationTitleLabel: i18n.translate( - 'xpack.agentBuilder.conversationActions.conversationTitleLabel', - { - defaultMessage: 'Conversation', - } - ), - editCurrentAgent: i18n.translate('xpack.agentBuilder.conversationActions.editCurrentAgent', { - defaultMessage: 'Edit agent', - }), - cloneAgentAsNew: i18n.translate('xpack.agentBuilder.conversationActions.duplicateAgentAsNew', { - defaultMessage: 'Duplicate as new', - }), - conversationAgentLabel: i18n.translate( - 'xpack.agentBuilder.conversationActions.conversationAgentLabel', - { - defaultMessage: 'Agent', - } - ), - conversationManagementLabel: i18n.translate( - 'xpack.agentBuilder.conversationActions.conversationManagementLabel', - { - defaultMessage: 'Management', - } - ), - agents: i18n.translate('xpack.agentBuilder.conversationActions.agents', { - defaultMessage: 'View all agents', - }), - tools: i18n.translate('xpack.agentBuilder.conversationActions.tools', { - defaultMessage: 'View all tools', - }), - skills: i18n.translate('xpack.agentBuilder.conversationActions.skills', { - defaultMessage: 'View all skills', - }), - plugins: i18n.translate('xpack.agentBuilder.conversationActions.plugins', { - defaultMessage: 'View all plugins', - }), - connectors: i18n.translate('xpack.agentBuilder.conversationActions.connectors', { - defaultMessage: 'View all connectors', - }), - sources: i18n.translate('xpack.agentBuilder.conversationActions.sources', { - defaultMessage: 'View all sources', - }), - rename: i18n.translate('xpack.agentBuilder.conversationActions.rename', { - defaultMessage: 'Rename', - }), - delete: i18n.translate('xpack.agentBuilder.conversationActions.delete', { - defaultMessage: 'Delete', + agentDetails: i18n.translate('xpack.agentBuilder.conversationActions.agentDetails', { + defaultMessage: 'Agent details', }), genAiSettings: i18n.translate('xpack.agentBuilder.conversationActions.genAiSettings', { defaultMessage: 'GenAI Settings', @@ -108,63 +47,28 @@ const fullscreenLabels = { defaultMessage: 'View', }), fullScreen: i18n.translate('xpack.agentBuilder.conversationActions.fullScreen', { - defaultMessage: 'Full screen', + defaultMessage: 'Open in full screen', }), }; -const popoverMinWidthStyles = css` - min-width: 240px; -`; - -const MenuSectionTitle = ({ title }: { title: string }) => { - const { euiTheme } = useEuiTheme(); - return ( - <> - - -

{title}

-
- - - ); -}; - interface MoreActionsButtonProps { - onRenameConversation: () => void; onCloseSidebar?: () => void; } -export const MoreActionsButton: React.FC = ({ - onRenameConversation, - onCloseSidebar, -}) => { +export const MoreActionsButton: React.FC = ({ onCloseSidebar }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const hasActiveConversation = useHasActiveConversation(); - const hasPersistedConversation = useHasPersistedConversation(); + const agentId = useAgentId(); - const isAgentReadOnly = useIsAgentReadOnly(agentId); const { createAgentBuilderUrl, navigateToAgentBuilderUrl } = useNavigation(); const { isEmbeddedContext } = useConversationContext(); const conversationId = useConversationId(); - const { euiTheme } = useEuiTheme(); const { manageAgents } = useUiPrivileges(); const { - services: { application, uiSettings }, + services: { application }, } = useKibana(); const hasAccessToGenAiSettings = useHasConnectorsAllPrivileges(); - const isExperimentalFeaturesEnabled = useExperimentalFeatures(); - const isDataSourcesEnabled = uiSettings.get(DATA_SOURCES_ENABLED_SETTING_ID, false); - const isABConnectorsEnabled = uiSettings.get( - AGENT_BUILDER_CONNECTORS_ENABLED_SETTING_ID, - false - ); const closePopover = () => { setIsPopoverOpen(false); @@ -175,22 +79,31 @@ export const MoreActionsButton: React.FC = ({ }; const handleOpenFullScreen = useCallback(() => { - if (!application) { - return; - } + if (!application) return; + setIsPopoverOpen(false); onCloseSidebar?.(); + const path = conversationId - ? appPaths.chat.conversation({ conversationId }) - : appPaths.chat.new; - const params = !conversationId && agentId ? { [searchParamNames.agentId]: agentId } : undefined; - navigateToAgentBuilderUrl(path, params); + ? appPaths.agent.conversations.byId({ agentId: agentId!, conversationId: conversationId! }) + : appPaths.agent.conversations.new({ agentId: agentId! }); + + navigateToAgentBuilderUrl(path); }, [application, agentId, conversationId, navigateToAgentBuilderUrl, onCloseSidebar]); - const menuItems = [ + const embeddedContextMenuItems = [ + + {fullscreenLabels.agentDetails} + , ...(isEmbeddedContext && application ? [ - , = ({ , ] : []), - ...(hasPersistedConversation + ...(hasAccessToGenAiSettings ? [ - , { - closePopover(); - onRenameConversation(); - }} - > - {fullscreenLabels.rename} - , - { - closePopover(); - setIsDeleteModalOpen(true); - }} + key="agentBuilderSettings" + icon="gear" + onClick={closePopover} + href={application.getUrlForApp('management', { path: '/ai/genAiSettings' })} + data-test-subj="agentBuilderGenAiSettingsButton" > - {fullscreenLabels.delete} + {fullscreenLabels.genAiSettings} , ] : []), - , - - {fullscreenLabels.editCurrentAgent} - , + ]; + + const fullscreenMenuItems = [ - {fullscreenLabels.cloneAgentAsNew} + {fullscreenLabels.agentDetails} , - , - } - onClick={closePopover} - href={createAgentBuilderUrl(appPaths.agents.list)} - data-test-subj="agentBuilderActionsAgents" - > - {fullscreenLabels.agents} - , - - {fullscreenLabels.tools} - , - ...(isExperimentalFeaturesEnabled - ? [ - - {fullscreenLabels.skills} - , - - {fullscreenLabels.plugins} - , - ] - : []), - ...(isABConnectorsEnabled - ? [ - - {fullscreenLabels.connectors} - , - ] - : []), - ...(isDataSourcesEnabled - ? [ - - {fullscreenLabels.sources} - , - ] - : []), ...(hasAccessToGenAiSettings ? [ = ({ : []), ]; + const menuItems = isEmbeddedContext ? embeddedContextMenuItems : fullscreenMenuItems; + const buttonProps = { iconType: 'boxesVertical' as const, color: 'text' as const, + size: 'm' as const, 'aria-label': fullscreenLabels.actionsAriaLabel, onClick: togglePopover, 'data-test-subj': 'agentBuilderMoreActionsButton', }; - const showButtonIcon = hasActiveConversation; - const button = showButtonIcon ? ( - - ) : ( - {fullscreenLabels.actions} - ); return ( <> } isOpen={isPopoverOpen} closePopover={closePopover} panelPaddingSize="xs" anchorPosition="downCenter" - panelProps={{ - css: popoverMinWidthStyles, - }} + aria-label={fullscreenLabels.actionsAriaLabel} > diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/new_conversation_button.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/new_conversation_button.tsx deleted file mode 100644 index 1818c9e9f7ed5..0000000000000 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/new_conversation_button.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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 { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { useNavigation } from '../../../hooks/use_navigation'; -import { appPaths } from '../../../utils/app_paths'; -import { useConversationId } from '../../../context/conversation/use_conversation_id'; -import { useSendMessage } from '../../../context/send_message/send_message_context'; -import { useConversationContext } from '../../../context/conversation/conversation_context'; - -interface NewConversationButtonProps { - onClose?: () => void; -} - -export const NewConversationButton: React.FC = ({ onClose }) => { - const { createAgentBuilderUrl } = useNavigation(); - const { isEmbeddedContext, setConversationId } = useConversationContext(); - const conversationId = useConversationId(); - const isNewConversation = !conversationId; - const { cleanConversation } = useSendMessage(); - - const handleClick = () => { - // For new conversations, there isn't anywhere to navigate to, so instead we clean the conversation state - if (isNewConversation) { - cleanConversation(); - } - if (isEmbeddedContext) { - setConversationId?.(undefined); - } - onClose?.(); - }; - - const buttonProps = isEmbeddedContext - ? {} - : { - href: createAgentBuilderUrl(appPaths.chat.new), - }; - - const label = i18n.translate('xpack.agentBuilder.newConversationButton.ariaLabel', { - defaultMessage: 'Create new conversation', - }); - - return ( - {label}

}> - -
- ); -}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/agent_selector/agent_select_dropdown.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/agent_selector/agent_select_dropdown.tsx deleted file mode 100644 index f2bbd7e2cd829..0000000000000 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/agent_selector/agent_select_dropdown.tsx +++ /dev/null @@ -1,202 +0,0 @@ -/* - * 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 { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiPopover, - EuiPopoverFooter, - EuiSelectable, -} from '@elastic/eui'; -import { css } from '@emotion/react'; -import { i18n } from '@kbn/i18n'; -import type { AgentDefinition } from '@kbn/agent-builder-common'; -import React, { useState } from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { useUiPrivileges } from '../../../../../hooks/use_ui_privileges'; -import { useHasActiveConversation } from '../../../../../hooks/use_conversation'; -import { useNavigation } from '../../../../../hooks/use_navigation'; -import { appPaths } from '../../../../../utils/app_paths'; -import { - getMaxListHeight, - selectorPopoverPanelStyles, - useSelectorListStyles, -} from '../input_actions.styles'; -import { useAgentOptions } from './use_agent_options'; -import { InputPopoverButton } from '../input_popover_button'; -import { AgentAvatar } from '../../../../common/agent_avatar'; - -const AGENT_OPTION_ROW_HEIGHT = 44; - -const selectAgentAriaLabel = i18n.translate( - 'xpack.agentBuilder.conversationInput.agentSelector.selectAgent.ariaLabel', - { - defaultMessage: 'Select an agent', - } -); -const selectAgentFallbackButtonLabel = i18n.translate( - 'xpack.agentBuilder.conversationInput.agentSelector.fallbackButtonLabel', - { defaultMessage: 'Agents' } -); -const createAgentAriaLabel = i18n.translate( - 'xpack.agentBuilder.conversationInput.agentSelector.createAgent.ariaLabel', - { - defaultMessage: 'Create an agent', - } -); -const manageAgentsAriaLabel = i18n.translate( - 'xpack.agentBuilder.conversationInput.agentSelector.manageAgents.ariaLabel', - { - defaultMessage: 'Manage agents', - } -); - -const agentSelectId = 'agentBuilderAgentSelect'; -const agentListId = `${agentSelectId}_listbox`; - -const AgentSelectPopoverButton: React.FC<{ - isPopoverOpen: boolean; - selectedAgent?: AgentDefinition; - onClick: () => void; -}> = ({ isPopoverOpen, selectedAgent, onClick }) => { - const hasActiveConversation = useHasActiveConversation(); - const iconType = selectedAgent - ? () => - : 'productAgent'; - return ( - - {selectedAgent?.name ?? selectAgentFallbackButtonLabel} - - ); -}; - -const AgentListFooter: React.FC = () => { - const { manageAgents } = useUiPrivileges(); - const { createAgentBuilderUrl } = useNavigation(); - const createAgentHref = createAgentBuilderUrl(appPaths.agents.new); - const manageAgentsHref = createAgentBuilderUrl(appPaths.agents.list); - return ( - - - - - - - - - - - - - - - ); -}; - -interface AgentSelectDropdownProps { - selectedAgent?: AgentDefinition; - onAgentChange: (agentId: string) => void; - agents?: AgentDefinition[]; -} - -export const AgentSelectDropdown: React.FC = ({ - selectedAgent, - onAgentChange, - agents = [], -}) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const { agentOptions, renderAgentOption } = useAgentOptions({ - agents, - selectedAgentId: selectedAgent?.id, - }); - const selectorListStyles = css` - ${useSelectorListStyles({ listId: agentListId })} - &#${agentListId} .euiSelectableListItem { - align-items: flex-start; - } - `; - - const listItemsHeight = agentOptions.length * AGENT_OPTION_ROW_HEIGHT; - // Calculate height based on item count, capped at max rows - const listHeight = Math.min(listItemsHeight, getMaxListHeight({ withFooter: true })); - - return ( - setIsPopoverOpen(!isPopoverOpen)} - /> - } - isOpen={isPopoverOpen} - anchorPosition="upCenter" - closePopover={() => setIsPopoverOpen(false)} - > - { - const { checked, key: agentId } = changedOption; - const isChecked = checked === 'on'; - if (isChecked && agentId) { - onAgentChange(agentId); - setIsPopoverOpen(false); - } - }} - singleSelection - renderOption={(option) => renderAgentOption({ agent: option.agent })} - height={listHeight} - listProps={{ - id: agentListId, - isVirtualized: true, - rowHeight: AGENT_OPTION_ROW_HEIGHT, - onFocusBadge: false, - css: selectorListStyles, - }} - > - {(list) => ( - <> - {list} - - - )} - - - ); -}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/agent_selector/agent_selector.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/agent_selector/agent_selector.tsx deleted file mode 100644 index ca80b14a522db..0000000000000 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/agent_selector/agent_selector.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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 { EuiLoadingSpinner } from '@elastic/eui'; -import React from 'react'; -import { useConversationContext } from '../../../../../context/conversation/conversation_context'; -import { useAgentBuilderAgents } from '../../../../../hooks/agents/use_agents'; -import { AgentSelectDropdown } from './agent_select_dropdown'; - -interface AgentSelectorProps { - agentId?: string; -} - -export const AgentSelector: React.FC = ({ agentId }) => { - const { agents, isLoading: isLoadingAgents } = useAgentBuilderAgents(); - const { conversationActions } = useConversationContext(); - - const handleAgentChange = (newAgentId: string) => { - conversationActions.setAgentId(newAgentId); - }; - - if (isLoadingAgents || !agentId) { - return ; - } - - const currentAgent = agents.find((agent) => agent.id === agentId); - - return ( - - ); -}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/input_actions.styles.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/input_actions.styles.ts index 74e0ca1e8a72e..1e0e3ef68aa8d 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/input_actions.styles.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/input_actions.styles.ts @@ -28,7 +28,7 @@ export const getMaxListHeight = ({ return height; }; -const SELECTOR_POPOVER_WIDTH = 275; +const SELECTOR_POPOVER_WIDTH = 400; export const selectorPopoverPanelStyles = css` inline-size: ${SELECTOR_POPOVER_WIDTH}px; `; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/input_actions.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/input_actions.tsx index 6a5ff01a88bb6..6860861659e56 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/input_actions.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/input_actions.tsx @@ -5,10 +5,8 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiTourStep } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React from 'react'; -import { TourStep, useAgentBuilderTour } from '../../../../context/agent_builder_tour_context'; -import { AgentSelector } from './agent_selector'; import { ConversationActionButton } from './conversation_action_button'; import { ConnectorSelector } from './connector_selector'; @@ -24,39 +22,28 @@ export const InputActions: React.FC = ({ isSubmitDisabled, resetToPendingMessage, agentId, -}) => { - const { getStepProps } = useAgentBuilderTour(); - - return ( - - - - - - - - - - - - - - - - - - - - - - ); -}; +}) => ( + + + + + + + + + + + + + + +); diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_list_item_styles.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_list_item_styles.ts new file mode 100644 index 0000000000000..954e3ac632079 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_list_item_styles.ts @@ -0,0 +1,33 @@ +/* + * 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 { css } from '@emotion/react'; +import type { EuiThemeComputed } from '@elastic/eui'; + +export const createConversationListItemStyles = (euiTheme: EuiThemeComputed) => css` + text-decoration: none; + padding: 6px ${euiTheme.size.s}; + border-radius: ${euiTheme.border.radius.small}; + color: ${euiTheme.colors.textParagraph}; + font-size: ${euiTheme.font.scale.s}${euiTheme.font.defaultUnits}; + cursor: pointer; + background: none; + border: none; + width: 100%; + text-align: left; + &:hover { + background-color: ${euiTheme.colors.backgroundLightPrimary}; + color: ${euiTheme.colors.textPrimary}; + text-decoration: none; + } +`; + +export const createActiveConversationListItemStyles = (euiTheme: EuiThemeComputed) => css` + ${createConversationListItemStyles(euiTheme)} + background-color: ${euiTheme.colors.backgroundLightPrimary}; + color: ${euiTheme.colors.textPrimary}; +`; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_search_modal.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_search_modal.tsx new file mode 100644 index 0000000000000..99c34bb8dacf1 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_search_modal.tsx @@ -0,0 +1,165 @@ +/* + * 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 React, { useMemo, useState } from 'react'; +import { + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, + EuiText, + EuiTextTruncate, + useEuiTheme, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { useConversationList } from '../../hooks/use_conversation_list'; +import { + createActiveConversationListItemStyles, + createConversationListItemStyles, +} from './conversation_list_item_styles'; +import { NoConversationsPrompt } from './embeddable_conversation_header/no_conversations_prompt'; + +const labels = { + title: i18n.translate('xpack.agentBuilder.conversationSearchModal.title', { + defaultMessage: 'Search chats', + }), + searchPlaceholder: i18n.translate( + 'xpack.agentBuilder.conversationSearchModal.searchPlaceholder', + { defaultMessage: 'Search chats' } + ), + latestChats: i18n.translate('xpack.agentBuilder.conversationSearchModal.latestChats', { + defaultMessage: 'Latest chats', + }), +}; + +const MODAL_WIDTH = 480; +const LIST_MAX_HEIGHT = 290; + +interface ConversationSearchModalProps { + agentId: string; + currentConversationId?: string; + onClose: () => void; + onSelectConversation: (conversationId: string) => void; +} + +export const ConversationSearchModal: React.FC = ({ + agentId, + currentConversationId, + onClose, + onSelectConversation, +}) => { + const [searchValue, setSearchValue] = useState(''); + + const { euiTheme } = useEuiTheme(); + const modalTitleId = useGeneratedHtmlId(); + + const { conversations = [], isLoading } = useConversationList({ agentId }); + + const sortedConversations = useMemo( + () => + [...conversations].sort( + (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() + ), + [conversations] + ); + + const filteredConversations = useMemo(() => { + if (!searchValue) return sortedConversations; + const lower = searchValue.toLowerCase(); + return sortedConversations.filter((c) => c.title.toLowerCase().includes(lower)); + }, [sortedConversations, searchValue]); + + const itemStyles = createConversationListItemStyles(euiTheme); + const activeItemStyles = createActiveConversationListItemStyles(euiTheme); + + const listStyles = css` + overflow-y: auto; + max-height: ${LIST_MAX_HEIGHT}px; + margin-top: ${euiTheme.size.s}; + `; + + const renderList = () => { + if (isLoading) { + return ( + + + + + + ); + } + + if (filteredConversations.length === 0) { + return 0} />; + } + + return ( + + {filteredConversations.map((conversation) => { + const isActive = currentConversationId === conversation.id; + return ( + + + + ); + })} + + ); + }; + + return ( + + + {labels.title} + + + + setSearchValue(e.target.value)} + data-test-subj="agentBuilderConversationSearchInput" + /> + + +

{labels.latestChats}

+
+ +
{renderList()}
+
+
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversations_history/conversations_history_list.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversations_history/conversations_history_list.tsx deleted file mode 100644 index f44f96c384318..0000000000000 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversations_history/conversations_history_list.tsx +++ /dev/null @@ -1,209 +0,0 @@ -/* - * 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 type { EuiSelectableOption } from '@elastic/eui'; -import { - EuiButtonIcon, - EuiFlexItem, - EuiLoadingSpinner, - EuiPopoverTitle, - EuiSelectable, - useEuiTheme, -} from '@elastic/eui'; -import { css } from '@emotion/react'; -import { i18n } from '@kbn/i18n'; -import type { ConversationWithoutRounds } from '@kbn/agent-builder-common'; -import React, { useCallback, useMemo, useState } from 'react'; -import { useConversationContext } from '../../../context/conversation/conversation_context'; -import { useConversationId } from '../../../context/conversation/use_conversation_id'; -import { useConversationList } from '../../../hooks/use_conversation_list'; -import { useNavigation } from '../../../hooks/use_navigation'; -import { appPaths } from '../../../utils/app_paths'; -import { groupConversationsByTime } from '../../../utils/group_conversations'; -import { NoConversationsPrompt } from './no_conversations_prompt'; -import { DeleteConversationModal } from '../delete_conversation_modal'; - -const EMPTY_CONTAINER_HEIGHT = 300; - -const ROW_HEIGHT = 32; -const MAX_ROWS = 18; -const MAX_LIST_HEIGHT = ROW_HEIGHT * MAX_ROWS; - -const emptyContainerStyles = css` - height: ${EMPTY_CONTAINER_HEIGHT}px; - justify-content: center; - align-items: center; -`; - -const deleteConversationLabel = (title: string) => - i18n.translate('xpack.agentBuilder.conversationsHistory.deleteConversation', { - defaultMessage: 'Delete conversation {title}', - values: { title }, - }); - -interface ConversationHistoryListProps { - onClose?: () => void; -} - -export const ConversationHistoryList: React.FC = ({ onClose }) => { - const { conversations = [], isLoading } = useConversationList(); - const currentConversationId = useConversationId(); - const { navigateToAgentBuilderUrl } = useNavigation(); - const { isEmbeddedContext, setConversationId } = useConversationContext(); - const { euiTheme } = useEuiTheme(); - const [conversationToDelete, setConversationToDelete] = - useState(null); - - const timeSections = useMemo(() => { - if (!conversations || conversations.length === 0) { - return []; - } - return groupConversationsByTime(conversations); - }, [conversations]); - - const selectableOptions = useMemo(() => { - const options: EuiSelectableOption[] = []; - - timeSections.forEach(({ label, conversations: sectionConversations }) => { - // Add group label - options.push({ - label, - isGroupLabel: true, - }); - - // Add conversation options - sectionConversations.forEach((conversation) => { - options.push({ - key: conversation.id, - label: conversation.title, - checked: currentConversationId === conversation.id ? 'on' : undefined, - 'data-test-subj': `conversationItem-${conversation.id}`, - append: ( - { - // Must stop click event from propagating to list item which would trigger navigation - event.stopPropagation(); - setConversationToDelete(conversation); - }} - data-test-subj={`deleteConversationButton-${conversation.id}`} - /> - ), - data: { - conversation, - }, - }); - }); - }); - - return options; - }, [timeSections, currentConversationId]); - - const handleChange = useCallback( - (_options: EuiSelectableOption[], _event: unknown, changedOption: EuiSelectableOption) => { - if (!changedOption?.data?.conversation) return; - - const conversation = changedOption.data.conversation as ConversationWithoutRounds; - - if (isEmbeddedContext) { - setConversationId?.(conversation.id); - } else { - navigateToAgentBuilderUrl(appPaths.chat.conversation({ conversationId: conversation.id })); - } - onClose?.(); - }, - [isEmbeddedContext, onClose, setConversationId, navigateToAgentBuilderUrl] - ); - - if (isLoading) { - return ( - - - - ); - } - - if (timeSections.length === 0) { - return ( - - - - ); - } - - // remove borders from list items and group labels - const listStylesOverride = css` - .euiSelectableListItem:not(:last-of-type) { - border-block-end: 0; - } - .euiSelectableList__groupLabel { - border-block-end: 0; - :not(:first-of-type) { - padding-block-start: ${euiTheme.size.m}; - } - } - /* Only show append icon on hover or focus */ - .euiSelectableListItem__append { - opacity: 0; - } - .euiSelectableListItem:hover .euiSelectableListItem__append, - .euiSelectableListItem-isFocused .euiSelectableListItem__append { - opacity: 1; - } - `; - - const listItemsHeight = selectableOptions.length * ROW_HEIGHT; - // Calculate height based on item count, capped at max rows - const listHeight = Math.min(listItemsHeight, MAX_LIST_HEIGHT); - - return ( - <> - { - node?.focus(); - }, - }} - options={selectableOptions} - onChange={handleChange} - singleSelection={true} - aria-label={i18n.translate('xpack.agentBuilder.conversationsHistory.conversations', { - defaultMessage: 'Conversations', - })} - data-test-subj="agentBuilderConversationList" - listProps={{ - bordered: false, - showIcons: false, - onFocusBadge: false, - }} - css={listStylesOverride} - > - {(list, search) => ( - <> - {search} - {list} - - )} - - { - setConversationToDelete(null); - }} - conversation={conversationToDelete ?? undefined} - /> - - ); -}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversations_history/conversations_history_popover.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversations_history/conversations_history_popover.tsx deleted file mode 100644 index 3557e32836092..0000000000000 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversations_history/conversations_history_popover.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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 { css } from '@emotion/react'; -import { EuiPopover } from '@elastic/eui'; -import React from 'react'; -import { ConversationHistoryList } from './conversations_history_list'; - -interface ConversationsHistoryPopoverProps { - button: React.ReactElement; - isOpen: boolean; - closePopover: () => void; -} - -const popoverPanelStyles = css` - width: 400px; -`; - -export const ConversationsHistoryPopover: React.FC = ({ - button, - isOpen, - closePopover, -}) => { - return ( - - - - ); -}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversations_history/no_conversations_prompt.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversations_history/no_conversations_prompt.tsx deleted file mode 100644 index a9389eddc11dc..0000000000000 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversations_history/no_conversations_prompt.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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 { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; - -export const NoConversationsPrompt: React.FC = () => { - return ( - - - - - - -

- {i18n.translate('xpack.agentBuilder.conversationsHistory.noConversations', { - defaultMessage: "You haven't started any conversations yet.", - })} -

-
-
-
- ); -}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversations_view.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversations_view.tsx index 787bb582bfb19..b3c39d5c05cd2 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversations_view.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversations_view.tsx @@ -7,12 +7,9 @@ import { useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; -import { i18n } from '@kbn/i18n'; -import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; import React from 'react'; import { Conversation } from './conversation'; import { ConversationHeader } from './conversation_header/conversation_header'; -import { AgentBuilderTourProvider } from '../../context/agent_builder_tour_context'; import { RoutedConversationsProvider } from '../../context/conversation/routed_conversations_provider'; import { SendMessageProvider } from '../../context/send_message/send_message_context'; import { conversationBackgroundStyles, headerHeight } from './conversation.styles'; @@ -20,17 +17,24 @@ import { conversationBackgroundStyles, headerHeight } from './conversation.style export const AgentBuilderConversationsView: React.FC<{}> = () => { const { euiTheme } = useEuiTheme(); - const mainStyles = css` - border: none; + const containerStyles = css` + display: flex; + flex-direction: column; + height: var(--kbn-application--content-height); ${conversationBackgroundStyles(euiTheme)} `; + const headerStyles = css` - justify-content: center; + flex-shrink: 0; height: ${headerHeight}px; + display: flex; + align-items: center; + padding: ${euiTheme.size.m}; `; + const contentStyles = css` width: 100%; - height: 100%; + flex: 1; max-block-size: calc(var(--kbn-application--content-height) - ${headerHeight}px); display: flex; justify-content: center; @@ -38,51 +42,17 @@ export const AgentBuilderConversationsView: React.FC<{}> = () => { padding: 0 ${euiTheme.size.base} ${euiTheme.size.base} ${euiTheme.size.base}; `; - const labels = { - header: i18n.translate('xpack.agentBuilder.conversationsView.header', { - defaultMessage: 'Conversation header', - }), - content: i18n.translate('xpack.agentBuilder.conversationsView.content', { - defaultMessage: 'Conversation content', - }), - }; - return ( - - - - - - - - - - +
+
+ +
+
+ +
+
); diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/delete_conversation_modal.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/delete_conversation_modal.tsx index 119387e5f4bb8..f0195162142fa 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/delete_conversation_modal.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/delete_conversation_modal.tsx @@ -53,7 +53,7 @@ export const DeleteConversationModal: React.FC = ( title={ } titleProps={{ id: confirmModalTitleId }} diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/agents_popover_view.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/agents_popover_view.tsx new file mode 100644 index 0000000000000..d75f24f7ef66e --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/agents_popover_view.tsx @@ -0,0 +1,164 @@ +/* + * 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 React from 'react'; +import type { EuiSelectableOption } from '@elastic/eui'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiSelectable, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import type { AgentDefinition } from '@kbn/agent-builder-common'; +import { useConversationContext } from '../../../context/conversation/conversation_context'; +import { useAgentBuilderAgents } from '../../../hooks/agents/use_agents'; +import { useNavigation } from '../../../hooks/use_navigation'; +import { appPaths } from '../../../utils/app_paths'; +import { useAgentOptions } from '../../common/agent_selector/use_agent_options'; +import { useSelectorListStyles } from '../conversation_input/input_actions/input_actions.styles'; + +const labels = { + availableAgents: i18n.translate('xpack.agentBuilder.embeddableAgentsView.availableAgents', { + defaultMessage: 'Available agents', + }), + agentDetails: i18n.translate('xpack.agentBuilder.embeddableAgentsView.agentDetails', { + defaultMessage: 'Agent details', + }), + selectAgent: i18n.translate('xpack.agentBuilder.embeddableAgentsView.selectAgent', { + defaultMessage: 'Select an agent', + }), +}; + +type AgentOption = EuiSelectableOption<{ agent?: AgentDefinition }>; + +interface AgentsPopoverViewProps { + panelHeight: number; + panelWidth: number; + onBack: () => void; + onClose: () => void; +} + +export const AgentsPopoverView: React.FC = ({ + panelHeight, + panelWidth, + onBack, + onClose, +}) => { + const { euiTheme } = useEuiTheme(); + const { agentId, setAgentId } = useConversationContext(); + const { agents } = useAgentBuilderAgents(); + const { agentOptions, renderAgentOption } = useAgentOptions({ agents, selectedAgentId: agentId }); + + const agentListStyles = css` + ${useSelectorListStyles({ listId: 'agentBuilderEmbeddableAgentsList' })} + &#agentBuilderEmbeddableAgentsList .euiSelectableListItem { + align-items: flex-start; + } + `; + + const { createAgentBuilderUrl } = useNavigation(); + const agentDetailsHref = agentId + ? createAgentBuilderUrl(appPaths.agent.overview({ agentId })) + : undefined; + + const handleAgentChange = ( + _options: AgentOption[], + _event: unknown, + changedOption: AgentOption + ) => { + const { checked, key: newAgentId } = changedOption; + if (checked === 'on' && newAgentId) { + setAgentId?.(newAgentId); + onClose(); + } + }; + + const rowPaddingStyles = css` + padding: ${euiTheme.size.base}; + `; + + return ( + + {/* Header row — back button + agent details link */} + + + + + {labels.availableAgents} + + + + + {labels.agentDetails} + + + + + + + + {/* Agents list — scrollable */} + + renderAgentOption({ agent: option.agent })} + listProps={{ + id: 'agentBuilderEmbeddableAgentsList', + isVirtualized: false, + onFocusBadge: false, + bordered: false, + css: agentListStyles, + }} + > + {(list) => list} + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/conversations_popover_view.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/conversations_popover_view.tsx new file mode 100644 index 0000000000000..b3eb21a8705a9 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/conversations_popover_view.tsx @@ -0,0 +1,157 @@ +/* + * 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 React, { useState } from 'react'; +import { + EuiButton, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiText, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { useConversationContext } from '../../../context/conversation/conversation_context'; +import { useAgentBuilderAgents } from '../../../hooks/agents/use_agents'; +import { useAgentId } from '../../../hooks/use_conversation'; +import { AgentAvatar } from '../../common/agent_avatar'; +import { EmbeddableConversationList } from './embeddable_conversation_list'; + +const labels = { + newChat: i18n.translate('xpack.agentBuilder.embeddableConversationsView.newChat', { + defaultMessage: 'New chat', + }), + searchPlaceholder: i18n.translate( + 'xpack.agentBuilder.embeddableConversationsView.searchPlaceholder', + { defaultMessage: 'Search chats' } + ), + availableAgents: i18n.translate( + 'xpack.agentBuilder.embeddableConversationsView.availableAgents', + { defaultMessage: 'Available agents' } + ), +}; + +interface ConversationsPopoverViewProps { + panelHeight: number; + panelWidth: number; + onSwitchToAgents: () => void; + onClose: () => void; +} + +export const ConversationsPopoverView: React.FC = ({ + panelHeight, + panelWidth, + onSwitchToAgents, + onClose, +}) => { + const [searchValue, setSearchValue] = useState(''); + + const { euiTheme } = useEuiTheme(); + const { setConversationId } = useConversationContext(); + const { agents } = useAgentBuilderAgents(); + const agentId = useAgentId(); + + const currentAgent = agents.find((a) => a.id === agentId); + + const handleNewChat = () => { + setConversationId?.(undefined); + onClose(); + }; + + const agentRowStyles = css` + padding: ${euiTheme.size.base}; + cursor: pointer; + &:hover { + background: ${euiTheme.colors.backgroundBaseSubdued}; + } + `; + + const searchRowStyles = css` + padding: ${euiTheme.size.s} ${euiTheme.size.m}; + `; + + const listStyles = css` + padding: ${euiTheme.size.xs} ${euiTheme.size.s}; + overflow-y: auto; + min-height: 0; + `; + + return ( + + {/* Agent row — click to switch to agents view */} + + + {currentAgent && ( + + + + )} + + + {currentAgent?.name} + + + + + + + + + + + {/* Search + New chat */} + + + + setSearchValue(e.target.value)} + data-test-subj="agentBuilderEmbeddableConversationSearch" + /> + + + + {labels.newChat} + + + + + + + + {/* Conversation list — scrollable */} + + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/embeddable_conversation_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/embeddable_conversation_header.tsx new file mode 100644 index 0000000000000..e387fc6773fbe --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/embeddable_conversation_header.tsx @@ -0,0 +1,92 @@ +/* + * 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 React from 'react'; +import { css } from '@emotion/react'; +import { EuiText, useEuiTheme } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ConversationRightActions } from '../conversation_header/conversation_actions_right'; +import { ConversationTitle } from '../conversation_header/conversation_title'; +import { EmbeddableMenuButton } from './embeddable_menu_button'; +import { useAgentBuilderAgents } from '../../../hooks/agents/use_agents'; +import { useAgentId, useHasActiveConversation } from '../../../hooks/use_conversation'; + +const newConversationTitleLabel = i18n.translate( + 'xpack.agentBuilder.embeddableHeader.newConversation', + { + defaultMessage: 'New Conversation', + } +); + +interface EmbeddableConversationHeaderProps { + onClose?: () => void; + ariaLabelledBy?: string; +} + +export const EmbeddableConversationHeader: React.FC = ({ + onClose, + ariaLabelledBy, +}) => { + const { euiTheme } = useEuiTheme(); + const agentId = useAgentId(); + const { agents } = useAgentBuilderAgents(); + const hasActiveConversation = useHasActiveConversation(); + const currentAgent = agents.find((a) => a.id === agentId); + + return ( +
+ + + {/* Center: title + agent subtitle — overflow hidden ensures nothing escapes the column */} +
+ {hasActiveConversation ? ( + + ) : ( +

+ {newConversationTitleLabel} +

+ )} + {currentAgent && ( + + {currentAgent.name} + + )} +
+ + {/* Right: kebab menu + close */} + +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/embeddable_conversation_list.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/embeddable_conversation_list.tsx new file mode 100644 index 0000000000000..fd8b347647aa2 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/embeddable_conversation_list.tsx @@ -0,0 +1,96 @@ +/* + * 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 React, { useMemo } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiTextTruncate, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { useConversationContext } from '../../../context/conversation/conversation_context'; +import { useConversationList } from '../../../hooks/use_conversation_list'; +import { + createConversationListItemStyles, + createActiveConversationListItemStyles, +} from '../conversation_list_item_styles'; +import { NoConversationsPrompt } from './no_conversations_prompt'; + +interface EmbeddableConversationListProps { + searchValue: string; + onClose: () => void; +} + +export const EmbeddableConversationList: React.FC = ({ + searchValue, + onClose, +}) => { + const { euiTheme } = useEuiTheme(); + const { agentId, conversationId, setConversationId } = useConversationContext(); + const { conversations = [], isLoading } = useConversationList({ agentId }); + + const sortedConversations = useMemo( + () => + [...conversations].sort( + (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() + ), + [conversations] + ); + + const filteredConversations = useMemo(() => { + if (!searchValue) return sortedConversations; + const lower = searchValue.toLowerCase(); + return sortedConversations.filter((c) => c.title.toLowerCase().includes(lower)); + }, [sortedConversations, searchValue]); + + const itemStyles = createConversationListItemStyles(euiTheme); + const activeItemStyles = createActiveConversationListItemStyles(euiTheme); + + if (isLoading) { + return ( + + + + + + ); + } + + if (filteredConversations.length === 0) { + return 0} />; + } + + return ( + + {filteredConversations.map((conversation) => { + const isActive = conversationId === conversation.id; + return ( + + + + ); + })} + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/embeddable_menu_button.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/embeddable_menu_button.tsx new file mode 100644 index 0000000000000..c7740bf39ce6a --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/embeddable_menu_button.tsx @@ -0,0 +1,67 @@ +/* + * 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 React, { useState } from 'react'; +import { EuiButtonIcon, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ConversationsPopoverView } from './conversations_popover_view'; +import { AgentsPopoverView } from './agents_popover_view'; + +const POPOVER_HEIGHT = 500; +const POPOVER_WIDTH = 400; + +const openMenuLabel = i18n.translate('xpack.agentBuilder.embeddableMenuButton.openMenu', { + defaultMessage: 'Open navigation menu', +}); + +export const EmbeddableMenuButton: React.FC = () => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [view, setView] = useState<'conversations' | 'agents'>('conversations'); + + const closePopover = () => { + setIsPopoverOpen(false); + setView('conversations'); + }; + + const button = ( + setIsPopoverOpen((v) => !v)} + data-test-subj="agentBuilderEmbeddableMenuButton" + /> + ); + + return ( + + {view === 'conversations' ? ( + setView('agents')} + onClose={closePopover} + /> + ) : ( + setView('conversations')} + onClose={closePopover} + /> + )} + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/no_conversations_prompt.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/no_conversations_prompt.tsx new file mode 100644 index 0000000000000..42ac453d5482d --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/embeddable_conversation_header/no_conversations_prompt.tsx @@ -0,0 +1,37 @@ +/* + * 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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +interface NoConversationsPromptProps { + isFiltered: boolean; +} + +export const NoConversationsPrompt: React.FC = ({ isFiltered }) => { + const message = isFiltered + ? i18n.translate('xpack.agentBuilder.embeddableConversationList.noResults', { + defaultMessage: 'No conversations match your search.', + }) + : i18n.translate('xpack.agentBuilder.embeddableConversationList.noConversations', { + defaultMessage: "You haven't started any conversations yet.", + }); + + return ( + + + + + + +

{message}

+
+
+
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/rename_conversation_modal.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/rename_conversation_modal.tsx new file mode 100644 index 0000000000000..f87e38739f757 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/rename_conversation_modal.tsx @@ -0,0 +1,135 @@ +/* + * 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 React, { useCallback, useEffect, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { formatAgentBuilderErrorMessage } from '@kbn/agent-builder-browser'; +import { useConversationContext } from '../../context/conversation/conversation_context'; +import { useConversationId } from '../../context/conversation/use_conversation_id'; +import { useConversationTitle } from '../../hooks/use_conversation'; +import { useToasts } from '../../hooks/use_toasts'; + +const labels = { + inputPlaceholder: i18n.translate('xpack.agentBuilder.renameConversationModal.inputPlaceholder', { + defaultMessage: 'Enter conversation name', + }), + renameErrorToast: i18n.translate('xpack.agentBuilder.renameConversationModal.renameErrorToast', { + defaultMessage: 'Failed to rename conversation', + }), +}; + +interface RenameConversationModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const RenameConversationModal: React.FC = ({ + isOpen, + onClose, +}) => { + const conversationId = useConversationId(); + const { title } = useConversationTitle(); + const { conversationActions } = useConversationContext(); + const { addErrorToast } = useToasts(); + const [newTitle, setNewTitle] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + // Sync input to current title each time the modal opens + useEffect(() => { + if (isOpen) { + setNewTitle(title || ''); + } + }, [isOpen, title]); + + const isDirty = newTitle.trim() !== (title || '').trim(); + + const handleSave = useCallback(async () => { + if (!conversationId || !newTitle.trim() || !isDirty) return; + setIsLoading(true); + try { + await conversationActions.renameConversation(conversationId, newTitle.trim()); + onClose(); + } catch (error) { + addErrorToast({ + title: labels.renameErrorToast, + text: formatAgentBuilderErrorMessage(error), + }); + } finally { + setIsLoading(false); + } + }, [conversationId, newTitle, isDirty, conversationActions, onClose, addErrorToast]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') onClose(); + }, + [handleSave, onClose] + ); + + const modalTitleId = useGeneratedHtmlId(); + + if (!isOpen || !conversationId) return null; + + return ( + + + + + + + + + setNewTitle(e.target.value)} + onKeyDown={handleKeyDown} + data-test-subj="renameConversationModalInput" + /> + + + + + + + + + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/app_layout.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/app_layout.tsx new file mode 100644 index 0000000000000..15c1fa6713e25 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/app_layout.tsx @@ -0,0 +1,71 @@ +/* + * 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 React, { useCallback, useState } from 'react'; + +import { EuiWindowEvent, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import { isMac } from '@kbn/shared-ux-utility'; + +import { + CONDENSED_SIDEBAR_WIDTH, + SIDEBAR_WIDTH, + UnifiedSidebar, +} from './unified_sidebar/unified_sidebar'; + +interface AppLayoutProps { + children: React.ReactNode; +} + +export const AppLayout: React.FC = ({ children }) => { + const { euiTheme } = useEuiTheme(); + const [isCondensed, setIsCondensed] = useState(false); + + const onKeyDown = useCallback((event: KeyboardEvent) => { + if ((event.code === 'Period' || event.key === '.') && (isMac ? event.metaKey : event.ctrlKey)) { + event.preventDefault(); + setIsCondensed((v) => !v); + } + }, []); + + const sidebarStyles = css` + @media (max-width: ${euiTheme.breakpoint.m - 1}px) { + display: none; + } + `; + + const contentStyles = css` + overflow: auto; + background-color: ${euiTheme.colors.backgroundBasePlain}; + `; + + return ( + <> + + setIsCondensed((v) => !v)} + /> + } + pageSideBarProps={{ + minWidth: isCondensed ? CONDENSED_SIDEBAR_WIDTH : SIDEBAR_WIDTH, + css: sidebarStyles, + }} + > + + {children} + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/shared/agent_selector.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/shared/agent_selector.tsx new file mode 100644 index 0000000000000..d1a37919f1d26 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/shared/agent_selector.tsx @@ -0,0 +1,51 @@ +/* + * 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 React, { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom-v5-compat'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; + +import { i18n } from '@kbn/i18n'; + +import { useAgentBuilderAgents } from '../../../../hooks/agents/use_agents'; +import { storageKeys } from '../../../../storage_keys'; +import { AgentSelectorDropdown } from '../../../common/agent_selector/agent_selector_dropdown'; + +const deletedAgentLabel = i18n.translate('xpack.agentBuilder.sidebar.agentSelector.deletedAgent', { + defaultMessage: '(Deleted agent)', +}); + +interface AgentSelectorProps { + agentId: string; + getNavigationPath: (newAgentId: string) => string; +} + +export const AgentSelector: React.FC = ({ agentId, getNavigationPath }) => { + const { agents, isLoading } = useAgentBuilderAgents(); + const navigate = useNavigate(); + const [, setStoredAgentId] = useLocalStorage(storageKeys.agentId); + + const currentAgent = agents.find((a) => a.id === agentId); + + const handleAgentChange = useCallback( + (newAgentId: string) => { + setStoredAgentId(newAgentId); + navigate(getNavigationPath(newAgentId)); + }, + [navigate, setStoredAgentId, getNavigationPath] + ); + + return ( + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/shared/sidebar_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/shared/sidebar_header.tsx new file mode 100644 index 0000000000000..9e5f5d9086fdc --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/shared/sidebar_header.tsx @@ -0,0 +1,159 @@ +/* + * 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 React from 'react'; +import { useNavigate } from 'react-router-dom-v5-compat'; + +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + useEuiTheme, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; + +import { useAgentBuilderAgents } from '../../../../hooks/agents/use_agents'; +import { appPaths } from '../../../../utils/app_paths'; +import { AgentAvatar } from '../../../common/agent_avatar'; +import { AgentSelector } from './agent_selector'; + +const labels = { + manageComponents: i18n.translate('xpack.agentBuilder.sidebar.header.manageComponents', { + defaultMessage: 'Manage components', + }), + back: i18n.translate('xpack.agentBuilder.sidebar.header.back', { + defaultMessage: 'Back', + }), + toggleSidebar: i18n.translate('xpack.agentBuilder.sidebar.header.toggleSidebar', { + defaultMessage: 'Toggle sidebar', + }), +}; + +interface SidebarHeaderProps { + sidebarView: 'conversation' | 'manage'; + agentId: string; + getNavigationPath: (newAgentId: string) => string; + isCondensed: boolean; + onToggleCondensed: () => void; +} + +export const SidebarHeader: React.FC = ({ + sidebarView, + agentId, + getNavigationPath, + isCondensed, + onToggleCondensed, +}) => { + const { euiTheme } = useEuiTheme(); + const navigate = useNavigate(); + const { agents } = useAgentBuilderAgents(); + const currentAgent = agents.find((a) => a.id === agentId); + + const headerStyles = css` + gap: ${euiTheme.size.s}; + padding: ${euiTheme.size.base} ${euiTheme.size.l}; + flex-grow: 0; + `; + + const condensedHeaderStyles = css` + padding: ${euiTheme.size.base} 0; + flex-grow: 0; + align-items: center; + `; + + if (isCondensed) { + return ( + + + + + {currentAgent && sidebarView === 'conversation' && ( + + + + )} + + ); + } + + const sidebarToggle = ( + + + + ); + + const renderTitle = () => { + if (sidebarView === 'conversation') { + return ( + + + {currentAgent && ( + + )} + + {sidebarToggle} + + ); + } + if (sidebarView === 'manage') { + return ( + + + navigate(appPaths.root)} + > + {labels.manageComponents} + + + {sidebarToggle} + + ); + } + return null; + }; + + return ( + + {renderTitle()} + {sidebarView === 'conversation' && ( + + + + )} + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/shared/sidebar_nav_list.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/shared/sidebar_nav_list.tsx new file mode 100644 index 0000000000000..ec0b2419158de --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/shared/sidebar_nav_list.tsx @@ -0,0 +1,66 @@ +/* + * 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 React from 'react'; +import { Link } from 'react-router-dom-v5-compat'; + +import { EuiFlexGroup, EuiFlexItem, EuiIcon, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; + +import type { SidebarNavItem } from '../../../../route_config'; + +interface SidebarNavListProps { + items: SidebarNavItem[]; + isActive: (path: string) => boolean; + onItemClick?: () => void; +} + +export const SidebarNavList: React.FC = ({ items, isActive, onItemClick }) => { + const { euiTheme } = useEuiTheme(); + + const baseLinkStyles = css` + display: flex; + align-items: center; + gap: ${euiTheme.size.s}; + width: 100%; + padding: 6px ${euiTheme.size.s}; + border-radius: ${euiTheme.border.radius.small}; + text-decoration: none; + color: ${euiTheme.colors.textParagraph}; + + &:hover { + background-color: ${euiTheme.colors.backgroundLightPrimary}; + color: ${euiTheme.colors.textPrimary}; + text-decoration: none; + } + `; + + const activeLinkStyles = css` + ${baseLinkStyles} + background-color: ${euiTheme.colors.backgroundLightPrimary}; + color: ${euiTheme.colors.textPrimary}; + `; + + return ( + + + {items.map((item) => ( + + + {item.icon && } + {item.label} + + + ))} + + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/unified_sidebar.test.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/unified_sidebar.test.tsx new file mode 100644 index 0000000000000..033913dbb75cc --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/unified_sidebar.test.tsx @@ -0,0 +1,129 @@ +/* + * 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 '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from '@kbn/shared-ux-router'; + +jest.mock('../../../hooks/use_kibana', () => ({ + useKibana: () => ({ services: {} }), +})); + +jest.mock('../../../hooks/use_navigation', () => ({ + useNavigation: () => ({ navigateToAgentBuilderUrl: jest.fn() }), +})); + +jest.mock('../../../hooks/agents/use_agents', () => ({ + useAgentBuilderAgents: () => ({ isFetched: true, agents: [] }), +})); + +jest.mock('../../../hooks/agents/use_validate_agent_id', () => ({ + useValidateAgentId: () => () => true, +})); + +jest.mock('../../../hooks/use_last_agent_id', () => ({ + useLastAgentId: () => 'test-agent', +})); + +jest.mock('../../../hooks/use_conversation_list', () => ({ + useConversationList: () => ({ conversations: [], isLoading: false, refresh: jest.fn() }), +})); + +jest.mock('../../../hooks/use_feature_flags', () => ({ + useFeatureFlags: () => ({ experimental: false, connectors: false }), +})); + +jest.mock('./shared/sidebar_header', () => ({ + SidebarHeader: () => null, +})); + +jest.mock('react-use/lib/useLocalStorage', () => ({ + __esModule: true, + default: () => [undefined, jest.fn()], +})); + +import { UnifiedSidebar } from './unified_sidebar'; + +const renderSidebar = (path: string) => + render( + + + + ); + +describe('UnifiedSidebar', () => { + describe('conversation sidebar', () => { + it('renders for agent root route', () => { + renderSidebar('/agents/my-agent'); + expect(screen.getByTestId('agentBuilderSidebar-conversation')).toBeInTheDocument(); + expect(screen.queryByTestId('agentBuilderSidebar-manage')).not.toBeInTheDocument(); + }); + + it('renders for conversation route', () => { + renderSidebar('/agents/my-agent/conversations/abc-123'); + expect(screen.getByTestId('agentBuilderSidebar-conversation')).toBeInTheDocument(); + expect(screen.queryByTestId('agentBuilderSidebar-manage')).not.toBeInTheDocument(); + }); + + it('renders for overview route', () => { + renderSidebar('/agents/my-agent/overview'); + expect(screen.getByTestId('agentBuilderSidebar-conversation')).toBeInTheDocument(); + expect(screen.queryByTestId('agentBuilderSidebar-manage')).not.toBeInTheDocument(); + }); + + it('renders for skills route', () => { + renderSidebar('/agents/my-agent/skills'); + expect(screen.getByTestId('agentBuilderSidebar-conversation')).toBeInTheDocument(); + expect(screen.queryByTestId('agentBuilderSidebar-manage')).not.toBeInTheDocument(); + }); + + it('renders for plugins route', () => { + renderSidebar('/agents/my-agent/plugins'); + expect(screen.getByTestId('agentBuilderSidebar-conversation')).toBeInTheDocument(); + expect(screen.queryByTestId('agentBuilderSidebar-manage')).not.toBeInTheDocument(); + }); + + it('renders for connectors route', () => { + renderSidebar('/agents/my-agent/connectors'); + expect(screen.getByTestId('agentBuilderSidebar-conversation')).toBeInTheDocument(); + expect(screen.queryByTestId('agentBuilderSidebar-manage')).not.toBeInTheDocument(); + }); + }); + + describe('manage sidebar', () => { + it('renders for manage agents route', () => { + renderSidebar('/manage/agents'); + expect(screen.getByTestId('agentBuilderSidebar-manage')).toBeInTheDocument(); + expect(screen.queryByTestId('agentBuilderSidebar-conversation')).not.toBeInTheDocument(); + }); + + it('renders for manage tools route', () => { + renderSidebar('/manage/tools'); + expect(screen.getByTestId('agentBuilderSidebar-manage')).toBeInTheDocument(); + expect(screen.queryByTestId('agentBuilderSidebar-conversation')).not.toBeInTheDocument(); + }); + + it('renders for manage skills route', () => { + renderSidebar('/manage/skills'); + expect(screen.getByTestId('agentBuilderSidebar-manage')).toBeInTheDocument(); + expect(screen.queryByTestId('agentBuilderSidebar-conversation')).not.toBeInTheDocument(); + }); + + it('renders for manage plugins route', () => { + renderSidebar('/manage/plugins'); + expect(screen.getByTestId('agentBuilderSidebar-manage')).toBeInTheDocument(); + expect(screen.queryByTestId('agentBuilderSidebar-conversation')).not.toBeInTheDocument(); + }); + + it('renders for manage connectors route', () => { + renderSidebar('/manage/connectors'); + expect(screen.getByTestId('agentBuilderSidebar-manage')).toBeInTheDocument(); + expect(screen.queryByTestId('agentBuilderSidebar-conversation')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/unified_sidebar.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/unified_sidebar.tsx new file mode 100644 index 0000000000000..0e4e373c87db4 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/unified_sidebar.tsx @@ -0,0 +1,96 @@ +/* + * 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 React, { useCallback, useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; + +import { EuiFlexGroup, EuiPanel, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; + +import { agentBuilderDefaultAgentId } from '@kbn/agent-builder-common'; +import { storageKeys } from '../../../storage_keys'; +import { getSidebarViewForRoute, getAgentIdFromPath } from '../../../route_config'; +import { useAgentBuilderAgents } from '../../../hooks/agents/use_agents'; +import { useValidateAgentId } from '../../../hooks/agents/use_validate_agent_id'; +import { ConversationSidebarView } from './views/conversation_view'; +import { ManageSidebarView } from './views/manage_view'; +import { SidebarHeader } from './shared/sidebar_header'; +import { appPaths } from '../../../utils/app_paths'; + +export const SIDEBAR_WIDTH = 300; +export const CONDENSED_SIDEBAR_WIDTH = 64; + +interface UnifiedSidebarProps { + isCondensed: boolean; + onToggleCondensed: () => void; +} + +export const UnifiedSidebar: React.FC = ({ + isCondensed, + onToggleCondensed, +}) => { + const location = useLocation(); + const sidebarView = getSidebarViewForRoute(location.pathname); + const agentIdFromPath = getAgentIdFromPath(location.pathname) ?? agentBuilderDefaultAgentId; + const [, setStoredAgentId] = useLocalStorage(storageKeys.agentId); + const { isFetched: isAgentsFetched } = useAgentBuilderAgents(); + const validateAgentId = useValidateAgentId(); + const { euiTheme } = useEuiTheme(); + + useEffect(() => { + // Wait for agents to load before validating — prevents falsely skipping valid IDs during initial load + if (isAgentsFetched && agentIdFromPath && validateAgentId(agentIdFromPath)) { + setStoredAgentId(agentIdFromPath); + } + }, [isAgentsFetched, agentIdFromPath, validateAgentId, setStoredAgentId]); + + const getNavigationPath = useCallback( + (newAgentId: string) => appPaths.agent.root({ agentId: newAgentId }), + [] + ); + + const sidebarStyles = css` + width: ${isCondensed ? CONDENSED_SIDEBAR_WIDTH : SIDEBAR_WIDTH}px; + height: 100%; + border-radius: 0; + border-right: 1px solid ${euiTheme.colors.borderBaseSubdued}; + display: flex; + flex-direction: column; + `; + + const sidebarContentStyles = css` + flex: 1; + position: relative; + overflow: hidden; + `; + + return ( + + + {!isCondensed && ( + + {sidebarView === 'conversation' && } + {sidebarView === 'manage' && } + + )} + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/conversation_view/conversation_footer.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/conversation_view/conversation_footer.tsx new file mode 100644 index 0000000000000..f1d127320df0b --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/conversation_view/conversation_footer.tsx @@ -0,0 +1,46 @@ +/* + * 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 React from 'react'; + +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; + +import { appPaths } from '../../../../../utils/app_paths'; +import { useNavigation } from '../../../../../hooks/use_navigation'; +import { SidebarLink } from './sidebar_link'; + +export const ConversationFooter: React.FC = () => { + const { navigateToAgentBuilderUrl } = useNavigation(); + + return ( + + + + + + { + e.preventDefault(); + navigateToAgentBuilderUrl(appPaths.manage.agents); + }} + /> + + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/conversation_view/conversation_list.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/conversation_view/conversation_list.tsx new file mode 100644 index 0000000000000..cdc25b9b6bc64 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/conversation_view/conversation_list.tsx @@ -0,0 +1,84 @@ +/* + * 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 React, { useMemo } from 'react'; +import { Link } from 'react-router-dom-v5-compat'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiTextTruncate, + useEuiTheme, +} from '@elastic/eui'; +import { appPaths } from '../../../../../utils/app_paths'; +import { useConversationList } from '../../../../../hooks/use_conversation_list'; +import { + createConversationListItemStyles, + createActiveConversationListItemStyles, +} from '../../../../conversations/conversation_list_item_styles'; +import { NoConversationsPrompt } from '../../../../conversations/embeddable_conversation_header/no_conversations_prompt'; + +interface ConversationListProps { + agentId: string; + currentConversationId: string | undefined; + onItemClick?: () => void; +} + +export const ConversationList: React.FC = ({ + agentId, + currentConversationId, + onItemClick, +}) => { + const { euiTheme } = useEuiTheme(); + const { conversations = [], isLoading } = useConversationList({ agentId }); + + const sortedConversations = useMemo( + () => + [...conversations].sort( + (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() + ), + [conversations] + ); + + const linkStyles = createConversationListItemStyles(euiTheme); + const activeLinkStyles = createActiveConversationListItemStyles(euiTheme); + + if (isLoading) { + return ( + + + + + + ); + } + + if (sortedConversations.length === 0) { + return ; + } + + return ( + + {sortedConversations.map((conversation) => { + const isActive = currentConversationId === conversation.id; + return ( + + + + + + ); + })} + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/conversation_view/index.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/conversation_view/index.tsx new file mode 100644 index 0000000000000..dc5bf9594b975 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/conversation_view/index.tsx @@ -0,0 +1,279 @@ +/* + * 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 React, { useEffect, useMemo, useState, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { + EuiAccordion, + EuiButton, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiSpacer, + EuiText, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; + +import { i18n } from '@kbn/i18n'; +import { agentBuilderDefaultAgentId } from '@kbn/agent-builder-common'; +import { appPaths } from '../../../../../utils/app_paths'; +import { + getAgentIdFromPath, + getAgentSettingsNavItems, + getConversationIdFromPath, +} from '../../../../../route_config'; +import { useFeatureFlags } from '../../../../../hooks/use_feature_flags'; +import { useNavigation } from '../../../../../hooks/use_navigation'; +import { useValidateAgentId } from '../../../../../hooks/agents/use_validate_agent_id'; +import { useAgentBuilderAgents } from '../../../../../hooks/agents/use_agents'; +import { useLastAgentId } from '../../../../../hooks/use_last_agent_id'; +import { useConversationList } from '../../../../../hooks/use_conversation_list'; +import { SidebarNavList } from '../../shared/sidebar_nav_list'; + +import { ConversationFooter } from './conversation_footer'; +import { ConversationList } from './conversation_list'; +import { ConversationSearchModal } from '../../../../conversations/conversation_search_modal'; + +const customizeLabel = i18n.translate('xpack.agentBuilder.sidebar.conversation.customize', { + defaultMessage: 'Customize', +}); + +const newLabel = i18n.translate('xpack.agentBuilder.sidebar.conversation.new', { + defaultMessage: 'New', +}); + +const searchChatsAriaLabel = i18n.translate('xpack.agentBuilder.sidebar.conversation.searchChats', { + defaultMessage: 'Search chats', +}); + +const chatsLabel = i18n.translate('xpack.agentBuilder.sidebar.conversation.chats', { + defaultMessage: 'Chats', +}); + +const containerStyles = css` + display: flex; + gap: 0; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; +`; + +const chatsAccordionStyles = css` + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + + .euiAccordion__childWrapper { + flex: 1; + min-height: 0; + overflow: hidden; + } + + .euiAccordion__children { + height: 100%; + display: flex; + flex-direction: column; + } +`; + +export const ConversationSidebarView: React.FC = () => { + const { pathname } = useLocation(); + const agentId = getAgentIdFromPath(pathname) ?? agentBuilderDefaultAgentId; + const conversationId = getConversationIdFromPath(pathname); + const { euiTheme } = useEuiTheme(); + const { navigateToAgentBuilderUrl } = useNavigation(); + const validateAgentId = useValidateAgentId(); + const { isFetched: isAgentsFetched } = useAgentBuilderAgents(); + const lastAgentId = useLastAgentId(); + const featureFlags = useFeatureFlags(); + + const hasSetCustomiseAccordionFirstTime = useRef(false); + + const { conversations = [] } = useConversationList({ agentId }); + const hasConversations = conversations.length > 0; + + const navItems = useMemo( + () => getAgentSettingsNavItems(agentId, featureFlags), + [agentId, featureFlags] + ); + + const isActive = (path: string) => pathname === path; + + const isAnyNavItemActive = navItems.some((item) => isActive(item.path)); + + const [isCustomizeOpen, setIsCustomizeOpen] = useState(false); + const [isChatsOpen, setIsChatsOpen] = useState(true); + const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); + + // When the user refreshes on an agent settings route, ensure the Customize accordion is open + useEffect(() => { + if (isAnyNavItemActive && !isCustomizeOpen && !hasSetCustomiseAccordionFirstTime.current) { + setIsCustomizeOpen(true); + setIsChatsOpen(false); + hasSetCustomiseAccordionFirstTime.current = true; + } + }, [isAnyNavItemActive, isCustomizeOpen]); + + useEffect(() => { + // Once agents have loaded, redirect to the last valid agent if the current agent ID + // is not recognised — but only when there is no conversation ID in the URL (new + // conversation route). Existing conversations for a deleted agent are intentionally + // shown read-only with the input disabled. + + // We also check that lastAgentId itself is valid before redirecting: if local storage + // holds a stale/invalid ID too, navigating to it would trigger this effect again and + // cause an infinite redirect loop. + if ( + isAgentsFetched && + !conversationId && + !validateAgentId(agentId) && + validateAgentId(lastAgentId) + ) { + navigateToAgentBuilderUrl(appPaths.agent.root({ agentId: lastAgentId })); + } + }, [ + isAgentsFetched, + conversationId, + agentId, + lastAgentId, + validateAgentId, + navigateToAgentBuilderUrl, + ]); + + const accordionButtonStyles = css` + padding: ${euiTheme.size.s} ${euiTheme.size.base}; + + .euiAccordion__triggerWrapper { + padding: 0; + } + + .euiAccordion__iconButton { + color: ${euiTheme.colors.textParagraph}; + } + `; + + const buttonStyles = css` + color: ${euiTheme.colors.textSubdued}; + font-weight: ${euiTheme.font.weight.semiBold}; + `; + + const conversationListStyles = css` + flex: 1; + min-height: 0; + overflow-y: auto; + `; + + return ( +
+ + + + {/* Customize accordion - with agent settings nav items */} + + {customizeLabel} + + } + arrowDisplay="left" + forceState={isCustomizeOpen ? 'open' : 'closed'} + onToggle={() => setIsCustomizeOpen((prev) => !prev)} + paddingSize="none" + css={accordionButtonStyles} + > +
+ setIsChatsOpen(false)} + /> +
+
+ + {/* Chats accordion - with conversation list */} + + {chatsLabel} + + } + extraAction={ + + + + navigateToAgentBuilderUrl(appPaths.agent.conversations.new({ agentId })) + } + data-test-subj="agentBuilderSidebarNewConversationButton" + > + {newLabel} + + + + setIsSearchModalOpen(true)} + disabled={!hasConversations} + data-test-subj="agentBuilderSidebarSearchChatsButton" + /> + + + } + arrowDisplay="left" + forceState={isChatsOpen ? 'open' : 'closed'} + onToggle={() => setIsChatsOpen((prev) => !prev)} + buttonProps={{ 'data-test-subj': 'agentBuilderSidebarChatsToggle' }} + paddingSize="none" + css={[accordionButtonStyles, chatsAccordionStyles]} + > +
+ + setIsCustomizeOpen(false)} + /> +
+
+ + + + {isSearchModalOpen && ( + setIsSearchModalOpen(false)} + onSelectConversation={(id) => { + navigateToAgentBuilderUrl( + appPaths.agent.conversations.byId({ agentId, conversationId: id }) + ); + setIsSearchModalOpen(false); + }} + /> + )} +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/conversation_view/sidebar_link.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/conversation_view/sidebar_link.tsx new file mode 100644 index 0000000000000..d49a0edb9f4d4 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/conversation_view/sidebar_link.tsx @@ -0,0 +1,64 @@ +/* + * 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 React from 'react'; +import { EuiIcon, EuiText, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; + +interface SidebarLinkProps { + label: string; + href: string; + onClick: (e: React.MouseEvent) => void; +} + +export const SidebarLink: React.FC = ({ label, href, onClick }) => { + const { euiTheme } = useEuiTheme(); + + const wrapperStyles = css` + display: flex; + align-items: center; + height: 100%; + padding: ${euiTheme.size.base}; + `; + + const linkStyles = css` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + text-decoration: none; + color: inherit; + padding: 6px ${euiTheme.size.s}; + border-radius: ${euiTheme.border.radius.medium}; + + &:hover { + background-color: ${euiTheme.colors.backgroundBaseSubdued}; + text-decoration: none; + } + + &:focus-visible { + outline: ${euiTheme.focus.width} solid ${euiTheme.focus.color}; + outline-offset: -${euiTheme.focus.width}; + } + `; + + return ( + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/manage_view.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/manage_view.tsx new file mode 100644 index 0000000000000..a6f0053801132 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/manage_view.tsx @@ -0,0 +1,41 @@ +/* + * 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 React, { useMemo } from 'react'; + +import { EuiFlexGroup, EuiHorizontalRule, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { useFeatureFlags } from '../../../../hooks/use_feature_flags'; +import { getManageNavItems } from '../../../../route_config'; +import { SidebarNavList } from '../shared/sidebar_nav_list'; + +interface ManageSidebarViewProps { + pathname: string; +} + +export const ManageSidebarView: React.FC = ({ pathname }) => { + const featureFlags = useFeatureFlags(); + const { euiTheme } = useEuiTheme(); + + const navItems = useMemo(() => getManageNavItems(featureFlags), [featureFlags]); + + const isActive = (path: string) => pathname.startsWith(path); + + return ( + + + + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/redirects/legacy_conversation_redirect.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/redirects/legacy_conversation_redirect.tsx new file mode 100644 index 0000000000000..9b573c171f00a --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/redirects/legacy_conversation_redirect.tsx @@ -0,0 +1,70 @@ +/* + * 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 React, { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { Navigate, useNavigate } from 'react-router-dom-v5-compat'; + +import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useQuery } from '@kbn/react-query'; + +import { useLastAgentId } from '../../hooks/use_last_agent_id'; +import { useAgentBuilderServices } from '../../hooks/use_agent_builder_service'; +import { appPaths } from '../../utils/app_paths'; +import { newConversationId } from '../../utils/new_conversation'; + +export const LegacyConversationRedirect: React.FC = () => { + const { conversationId } = useParams<{ conversationId?: string }>(); + const navigate = useNavigate(); + const lastAgentId = useLastAgentId(); + const { conversationsService } = useAgentBuilderServices(); + + const isNewConversation = !conversationId || conversationId === newConversationId; + + const { + data: conversation, + isLoading, + isError, + } = useQuery({ + queryKey: ['conversation-redirect', conversationId], + queryFn: () => conversationsService.get({ conversationId: conversationId! }), + enabled: !isNewConversation, + retry: false, + }); + + useEffect(() => { + if (conversation?.agent_id && conversationId) { + navigate( + appPaths.agent.conversations.byId({ + agentId: conversation.agent_id, + conversationId, + }), + { replace: true } + ); + } else if (isError && conversationId) { + navigate(appPaths.agent.conversations.byId({ agentId: lastAgentId, conversationId }), { + replace: true, + }); + } + }, [conversation, conversationId, isError, lastAgentId, navigate]); + + if (isNewConversation) { + return ; + } + + if (isLoading) { + return ( + + + + + + ); + } + + return null; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/redirects/root_redirect.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/redirects/root_redirect.tsx new file mode 100644 index 0000000000000..87d900232c17e --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/redirects/root_redirect.tsx @@ -0,0 +1,17 @@ +/* + * 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 React from 'react'; +import { Navigate } from 'react-router-dom-v5-compat'; + +import { useLastAgentId } from '../../hooks/use_last_agent_id'; +import { appPaths } from '../../utils/app_paths'; + +export const RootRedirect: React.FC = () => { + const lastAgentId = useLastAgentId(); + return ; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/context/agent_builder_tour_context.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/context/agent_builder_tour_context.tsx deleted file mode 100644 index df3dbcea069f6..0000000000000 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/context/agent_builder_tour_context.tsx +++ /dev/null @@ -1,250 +0,0 @@ -/* - * 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 React, { - createContext, - useContext, - useState, - type PropsWithChildren, - useEffect, -} from 'react'; - -import { EuiButton, EuiButtonEmpty, EuiText, type EuiTourStepProps } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import type { PopoverAnchorPosition } from '@elastic/eui/src/components/popover/popover'; -import { storageKeys } from '../storage_keys'; -import { useConversationContext } from './conversation/conversation_context'; -import { useKibana } from '../hooks/use_kibana'; - -export enum TourStep { - AgentSelector = 'agent-selector', - LlmSelector = 'llm-selector', - ConversationsHistory = 'conversations-history', - ConversationActions = 'conversation-actions', -} - -export type TourStepProps = Pick< - EuiTourStepProps, - | 'maxWidth' - | 'isStepOpen' - | 'title' - | 'content' - | 'onFinish' - | 'step' - | 'stepsTotal' - | 'anchorPosition' - | 'footerAction' -> & { anchor?: undefined }; - -interface AgentBuilderTourContextValue { - isTourActive: boolean; - getStepProps: (step: TourStep) => TourStepProps; -} - -const AgentBuilderTourContext = createContext(undefined); - -const DEFAULT_STEP = 1; -const TOUR_DELAY = 250; // 250ms - -const tourConfig = { - tourPopoverWidth: 370, - anchorPosition: 'downCenter' as PopoverAnchorPosition, - maxSteps: 4, -}; - -const labels = { - agentSelector: { - title: i18n.translate('xpack.agentBuilder.agentBuilderTour.agentSelector.title', { - defaultMessage: 'Meet your active agent 🕵️‍♂️', - }), - content: i18n.translate('xpack.agentBuilder.agentBuilderTour.agentSelector.content', { - defaultMessage: - "An agent's behavior is defined by its custom instructions and available tools. Switch agents when you need different capabilities for your tasks.", - }), - }, - llmSelector: { - title: i18n.translate('xpack.agentBuilder.agentBuilderTour.llmSelector.title', { - defaultMessage: 'Select your LLM 🧠', - }), - content: i18n.translate('xpack.agentBuilder.agentBuilderTour.llmSelector.content', { - defaultMessage: - 'Your agent uses this model to generate responses. Switch LLMs to prioritize faster responses, lower costs, or more complex reasoning.', - }), - }, - // TODO: Add prompts step once we have prompts. - // prompts: { - // title: i18n.translate('xpack.agentBuilder.agentBuilderTour.prompts.title', { - // defaultMessage: 'Reuse your prompts ✍️', - // }), - // content: i18n.translate('xpack.agentBuilder.agentBuilderTour.prompts.content', { - // defaultMessage: 'Store your favorite queries here. Pick one to drop it into the chat.', - // }), - // }, - conversationsHistory: { - title: i18n.translate('xpack.agentBuilder.agentBuilderTour.conversationsHistory.title', { - defaultMessage: 'Browse your conversations 💬', - }), - content: i18n.translate('xpack.agentBuilder.agentBuilderTour.conversationsHistory.content', { - defaultMessage: 'Find all your previous conversations here.', - }), - }, - conversationActions: { - title: i18n.translate('xpack.agentBuilder.agentBuilderTour.conversationActions.title', { - defaultMessage: 'Jump to key actions ⚙️', - }), - content: i18n.translate('xpack.agentBuilder.agentBuilderTour.conversationActions.content', { - defaultMessage: - 'This menu is your hub for key management actions. You can quickly access important pages from here.', - }), - }, - closeTour: i18n.translate('xpack.agentBuilder.agentBuilderTour.closeTour', { - defaultMessage: 'Close tour', - }), - next: i18n.translate('xpack.agentBuilder.agentBuilderTour.next', { - defaultMessage: 'Next', - }), - finishTour: i18n.translate('xpack.agentBuilder.agentBuilderTour.finishTour', { - defaultMessage: 'Finish tour', - }), -}; - -interface TourStepConfig { - step: number; - title: string; - content: React.ReactNode; - footerActions: React.ReactNode[]; -} - -export const AgentBuilderTourProvider: React.FC> = ({ children }) => { - const { isEmbeddedContext } = useConversationContext(); - const { notifications } = useKibana().services; - const isTourEnabled = notifications.tours.isEnabled(); - - const [currentStep, setCurrentStep] = useState(DEFAULT_STEP); - - const [isTourActive, setIsTourActive] = useState(false); - - useEffect(() => { - const hasSeenTour = localStorage.getItem(storageKeys.hasSeenAgentBuilderTour); - let timer: NodeJS.Timeout | undefined; - - if (!isEmbeddedContext && !hasSeenTour && isTourEnabled) { - // We use a delay to ensure the tour is not triggered immediately when the page loads to ensure correct anchor positioning. - timer = setTimeout(() => { - setIsTourActive(true); - }, TOUR_DELAY); - } - - return () => { - if (timer) { - clearTimeout(timer); - } - }; - }, [isEmbeddedContext, isTourEnabled]); - - const handleMoveToNextStep = () => { - setCurrentStep((prev) => prev + 1); - }; - - const handleFinishTour = () => { - setIsTourActive(false); - localStorage.setItem(storageKeys.hasSeenAgentBuilderTour, 'true'); - }; - - const footerActions = [ - - {labels.closeTour} - , - - {labels.next} - , - ]; - - const footerActionsFinish = [ - - {labels.finishTour} - , - ]; - - const tourSteps: Record = { - [TourStep.AgentSelector]: { - title: labels.agentSelector.title, - content: ( - -

{labels.agentSelector.content}

-
- ), - footerActions, - step: 1, - }, - [TourStep.LlmSelector]: { - title: labels.llmSelector.title, - content: ( - -

{labels.llmSelector.content}

-
- ), - footerActions, - step: 2, - }, - [TourStep.ConversationsHistory]: { - title: labels.conversationsHistory.title, - content: ( - -

{labels.conversationsHistory.content}

-
- ), - footerActions, - step: 3, - }, - [TourStep.ConversationActions]: { - title: labels.conversationActions.title, - content: ( - -

{labels.conversationActions.content}

-
- ), - footerActions: footerActionsFinish, - step: 4, - }, - }; - - const getStepProps = (step: TourStep): TourStepProps => { - const stepConfig = tourSteps[step]; - - return { - maxWidth: tourConfig.tourPopoverWidth, - isStepOpen: currentStep === stepConfig.step && isTourActive, - title: stepConfig.title, - content: stepConfig.content, - onFinish: handleFinishTour, - step: stepConfig.step, - stepsTotal: tourConfig.maxSteps, - anchorPosition: tourConfig.anchorPosition, - footerAction: stepConfig.footerActions, - }; - }; - - const contextValue: AgentBuilderTourContextValue = { - isTourActive, - getStepProps, - }; - - return ( - - {children} - - ); -}; - -export const useAgentBuilderTour = (): AgentBuilderTourContextValue => { - const context = useContext(AgentBuilderTourContext); - if (!context) { - throw new Error('useAgentBuilderTour must be used within an AgentBuilderTourProvider'); - } - return context; -}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/context/conversation/conversation_context.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/context/conversation/conversation_context.tsx index cd16638411c59..ca351e0063d49 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/context/conversation/conversation_context.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/context/conversation/conversation_context.tsx @@ -25,6 +25,7 @@ interface ConversationContextValue { removeAttachment?: (attachmentIndex: number) => void; browserApiTools?: Array>; setConversationId?: (conversationId?: string) => void; + setAgentId?: (agentId: string) => void; conversationActions: ConversationActions; } diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/context/conversation/embeddable_conversations_provider.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/context/conversation/embeddable_conversations_provider.tsx index d369a0dcd0db3..3436b4d3e2905 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/context/conversation/embeddable_conversations_provider.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/context/conversation/embeddable_conversations_provider.tsx @@ -9,6 +9,7 @@ import React, { useMemo, useEffect, useCallback, useState, useRef } from 'react' import { I18nProvider } from '@kbn/i18n-react'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { QueryClient, QueryClientProvider } from '@kbn/react-query'; +import { agentBuilderDefaultAgentId } from '@kbn/agent-builder-common'; import type { AttachmentInput } from '@kbn/agent-builder-common/attachments'; import type { EmbeddableConversationInternalProps, @@ -21,7 +22,6 @@ import { SendMessageProvider } from '../send_message/send_message_context'; import { useConversationActions } from './use_conversation_actions'; import { usePersistedConversationId } from '../../hooks/use_persisted_conversation_id'; import { AppLeaveContext } from '../app_leave_context'; -import { AgentBuilderTourProvider } from '../agent_builder_tour_context'; const noopOnAppLeave = () => {}; interface EmbeddableConversationsProviderProps extends EmbeddableConversationInternalProps { @@ -181,18 +181,23 @@ export const EmbeddableConversationsProvider: React.FC { + setCurrentProps((prev) => ({ ...prev, agentId: id, newConversation: true })); + }, []); + const conversationContextValue = useMemo( () => ({ conversationId, shouldStickToBottom: true, isEmbeddedContext: true, sessionTag: currentProps.sessionTag, - agentId: currentProps.agentId, + agentId: currentProps.agentId ?? agentBuilderDefaultAgentId, initialMessage: currentProps.initialMessage, autoSendInitialMessage: currentProps.autoSendInitialMessage ?? false, resetInitialMessage, browserApiTools: currentProps.browserApiTools, setConversationId, + setAgentId, attachments: currentProps.attachments, upsertAttachments, resetAttachments, @@ -210,6 +215,7 @@ export const EmbeddableConversationsProvider: React.FC - - {children} - + {children} diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/context/conversation/routed_conversations_provider.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/context/conversation/routed_conversations_provider.tsx index 33452a8cba2e3..a77fd71a0adb3 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/context/conversation/routed_conversations_provider.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/context/conversation/routed_conversations_provider.tsx @@ -7,7 +7,6 @@ import React, { useMemo, useEffect, useRef, useCallback, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; -import { useSearchParams } from 'react-router-dom-v5-compat'; import { useQueryClient } from '@kbn/react-query'; import type { AttachmentInput } from '@kbn/agent-builder-common/attachments'; import { ConversationContext } from './conversation_context'; @@ -16,9 +15,8 @@ import { newConversationId } from '../../utils/new_conversation'; import { appPaths } from '../../utils/app_paths'; import { useNavigation } from '../../hooks/use_navigation'; import { useAgentBuilderServices } from '../../hooks/use_agent_builder_service'; -import { useAgentBuilderAgents } from '../../hooks/agents/use_agents'; -import { searchParamNames } from '../../search_param_names'; import { useConversationActions } from './use_conversation_actions'; +import { queryKeys } from '../../query_keys'; import { upsertAttachmentsIntoList } from './upsert_attachments_into_list'; interface RoutedConversationsProviderProps { @@ -30,23 +28,23 @@ export const RoutedConversationsProvider: React.FC { const queryClient = useQueryClient(); const { conversationsService } = useAgentBuilderServices(); - const { conversationId: conversationIdParam } = useParams<{ conversationId?: string }>(); + const { conversationId: conversationIdParam, agentId: agentIdParam } = useParams<{ + conversationId?: string; + agentId?: string; + }>(); const conversationId = useMemo(() => { return conversationIdParam === newConversationId ? undefined : conversationIdParam; }, [conversationIdParam]); + const agentIdFromPath = agentIdParam; + const location = useLocation(); const shouldStickToBottom = location.state?.shouldStickToBottom ?? true; const initialMessage = location.state?.initialMessage; - // Get search params for agent ID syncing - const [searchParams] = useSearchParams(); - const { agents } = useAgentBuilderAgents(); - const { navigateToAgentBuilderUrl } = useNavigation(); const shouldAllowConversationRedirectRef = useRef(true); - const agentIdSyncedRef = useRef(false); useEffect(() => { return () => { @@ -55,17 +53,26 @@ export const RoutedConversationsProvider: React.FC { + if (!conversationId) { + queryClient.removeQueries({ queryKey: queryKeys.conversations.byId(newConversationId) }); + } + }, [agentIdFromPath, conversationId, queryClient]); + const navigateToConversation = useCallback( ({ nextConversationId }: { nextConversationId: string }) => { // Navigate to the conversation if redirect is allowed - if (shouldAllowConversationRedirectRef.current) { - const path = appPaths.chat.conversation({ conversationId: nextConversationId }); - const params = undefined; + if (shouldAllowConversationRedirectRef.current && agentIdFromPath) { + const path = appPaths.agent.conversations.byId({ + agentId: agentIdFromPath, + conversationId: nextConversationId, + }); const state = { shouldStickToBottom: false }; - navigateToAgentBuilderUrl(path, params, state); + navigateToAgentBuilderUrl(path, undefined, state); } }, - [shouldAllowConversationRedirectRef, navigateToAgentBuilderUrl] + [shouldAllowConversationRedirectRef, navigateToAgentBuilderUrl, agentIdFromPath] ); const onConversationCreated = useCallback( @@ -78,9 +85,8 @@ export const RoutedConversationsProvider: React.FC { if (isCurrentConversation) { - // If deleting current conversation, navigate to new conversation - const path = appPaths.chat.new; - navigateToAgentBuilderUrl(path, undefined, { shouldStickToBottom: true }); + // If deleting current conversation, navigate to root (redirects to last used agent) + navigateToAgentBuilderUrl(appPaths.root, undefined, { shouldStickToBottom: true }); } }, [navigateToAgentBuilderUrl] @@ -113,23 +119,6 @@ export const RoutedConversationsProvider: React.FC { - if (agentIdSyncedRef.current || conversationId) { - return; - } - - // If we don't have a selected agent id, check for a valid agent id in the search params - // This is used for the "chat with agent" action on the Agent pages - const agentIdParam = searchParams.get(searchParamNames.agentId); - - if (agentIdParam && agents.some((agent) => agent.id === agentIdParam)) { - // Agent id passed to sync is valid, set it and mark as synced - conversationActions.setAgentId(agentIdParam); - agentIdSyncedRef.current = true; - } - }, [searchParams, agents, conversationId, conversationActions]); - const contextValue = useMemo( () => ({ conversationId, @@ -138,6 +127,7 @@ export const RoutedConversationsProvider: React.FC { setError(null); - setErrorSteps([]); + setErrorSteps((prev) => (prev.length === 0 ? prev : [])); }, []); + const { key: locationKey } = useLocation(); + useEffect(() => { - // Clear errors any time conversation id changes - we do not persist it. - if (conversationId) { - removeError(); - } - }, [conversationId, removeError]); + // Clear error state on every navigation. location.key is updated on every history push, + // including re-navigation to the same URL (e.g. clicking "New" while already on /new + // with an errored conversation). A conversationId-based effect was used previously but + // missed both that case and navigating from an errored conversation back to /new, since + // conversationId stays undefined for the /new route. + // + // Known gap: the old NewConversationButton (which lived inside SendMessageProvider) also + // cancelled any in-flight stream via cleanConversation() when clicking "New" on /new. + // That button was removed during the sidebar refactor and the sidebar replacement lives + // outside SendMessageProvider, so it cannot call cleanConversation() directly. Fixing the + // streaming-cancellation case properly requires a larger provider refactor — + // and is left as a known limitation for now. + removeError(); + }, [locationKey, removeError]); const browserApiToolsMetadata = useMemo(() => { if (!browserApiTools) return undefined; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/skills/use_skills.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/skills/use_skills.ts index 56ee2890b0dbf..ae8e892fe8ec5 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/skills/use_skills.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/skills/use_skills.ts @@ -20,6 +20,7 @@ export const useSkillsService = () => { const { data, isLoading, error, isError } = useQuery({ queryKey: queryKeys.skills.all, queryFn: () => skillsService.list(), + keepPreviousData: true, }); return { skills: data ?? [], isLoading, error, isError }; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/use_conversation.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/use_conversation.ts index 3df2ccebc4af4..663346cfc8b09 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/use_conversation.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/use_conversation.ts @@ -101,25 +101,22 @@ const useGetNewConversationAgentId = () => { export const useAgentId = () => { const { conversation } = useConversation(); const context = useConversationContext(); - const agentId = conversation?.agent_id; const conversationId = useConversationId(); const isNewConversation = !conversationId; const getNewConversationAgentId = useGetNewConversationAgentId(); - if (agentId) { - return agentId; - } - - if (context.agentId) { - return context.agentId; + // For new conversations, URL (context.agentId) is the source of truth + if (isNewConversation) { + return context.agentId ?? getNewConversationAgentId(); } - // For new conversations, agent id must be defined - if (isNewConversation) { - return getNewConversationAgentId(); + // For existing conversations, use the conversation's stored agent_id + if (conversation?.agent_id) { + return conversation.agent_id; } - return undefined; + // Fallback to context (URL) for edge cases + return context.agentId; }; export const useConversationTitle = () => { diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/use_feature_flags.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/use_feature_flags.ts new file mode 100644 index 0000000000000..10db14ac706a3 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/use_feature_flags.ts @@ -0,0 +1,18 @@ +/* + * 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 { useMemo } from 'react'; +import { useUiSetting } from '@kbn/kibana-react-plugin/public'; +import { AGENT_BUILDER_CONNECTORS_ENABLED_SETTING_ID } from '@kbn/management-settings-ids'; +import type { FeatureFlags } from '../route_config'; +import { useExperimentalFeatures } from './use_experimental_features'; + +export const useFeatureFlags = (): FeatureFlags => { + const experimental = useExperimentalFeatures(); + const connectors = useUiSetting(AGENT_BUILDER_CONNECTORS_ENABLED_SETTING_ID); + return useMemo(() => ({ experimental, connectors }), [experimental, connectors]); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/use_last_agent_id.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/use_last_agent_id.ts new file mode 100644 index 0000000000000..03c5c768a3802 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/use_last_agent_id.ts @@ -0,0 +1,16 @@ +/* + * 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 useLocalStorage from 'react-use/lib/useLocalStorage'; +import { agentBuilderDefaultAgentId } from '@kbn/agent-builder-common'; + +import { storageKeys } from '../storage_keys'; + +export const useLastAgentId = (): string => { + const [agentIdStorage] = useLocalStorage(storageKeys.agentId); + return agentIdStorage ?? agentBuilderDefaultAgentId; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/pages/agent_overview.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/pages/agent_overview.tsx new file mode 100644 index 0000000000000..cbc9256eabb10 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/pages/agent_overview.tsx @@ -0,0 +1,23 @@ +/* + * 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 React, { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import { AgentOverview } from '../components/agents/overview/agent_overview'; +import { useAgentBuilderAgentById } from '../hooks/agents/use_agent_by_id'; +import { useBreadcrumb } from '../hooks/use_breadcrumbs'; + +export const AgentBuilderAgentOverviewPage: React.FC = () => { + const { agentId } = useParams<{ agentId: string }>(); + const { agent } = useAgentBuilderAgentById(agentId); + + const breadcrumbs = useMemo(() => [{ text: agent?.name ?? agentId }], [agent?.name, agentId]); + + useBreadcrumb(breadcrumbs); + + return ; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/pages/agent_plugins.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/pages/agent_plugins.tsx new file mode 100644 index 0000000000000..b9c3e64157d6e --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/pages/agent_plugins.tsx @@ -0,0 +1,31 @@ +/* + * 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 React, { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import { AgentPlugins } from '../components/agents/plugins/agent_plugins'; +import { useAgentBuilderAgentById } from '../hooks/agents/use_agent_by_id'; +import { useBreadcrumb } from '../hooks/use_breadcrumbs'; +import { appPaths } from '../utils/app_paths'; +import { labels } from '../utils/i18n'; + +export const AgentBuilderAgentPluginsPage: React.FC = () => { + const { agentId } = useParams<{ agentId: string }>(); + const { agent } = useAgentBuilderAgentById(agentId); + + const breadcrumbs = useMemo( + () => [ + { text: agent?.name ?? agentId, path: appPaths.agent.overview({ agentId }) }, + { text: labels.plugins.title }, + ], + [agent?.name, agentId] + ); + + useBreadcrumb(breadcrumbs); + + return ; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/pages/agent_skills.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/pages/agent_skills.tsx new file mode 100644 index 0000000000000..337453f976be5 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/pages/agent_skills.tsx @@ -0,0 +1,31 @@ +/* + * 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 React, { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import { AgentSkills } from '../components/agents/skills/agent_skills'; +import { useAgentBuilderAgentById } from '../hooks/agents/use_agent_by_id'; +import { useBreadcrumb } from '../hooks/use_breadcrumbs'; +import { appPaths } from '../utils/app_paths'; +import { labels } from '../utils/i18n'; + +export const AgentBuilderAgentSkillsPage: React.FC = () => { + const { agentId } = useParams<{ agentId: string }>(); + const { agent } = useAgentBuilderAgentById(agentId); + + const breadcrumbs = useMemo( + () => [ + { text: agent?.name ?? agentId, path: appPaths.agent.overview({ agentId }) }, + { text: labels.skills.title }, + ], + [agent?.name, agentId] + ); + + useBreadcrumb(breadcrumbs); + + return ; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/pages/agent_tools.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/pages/agent_tools.tsx new file mode 100644 index 0000000000000..e959188625059 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/pages/agent_tools.tsx @@ -0,0 +1,31 @@ +/* + * 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 React, { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import { AgentTools } from '../components/agents/tools/agent_tools'; +import { useAgentBuilderAgentById } from '../hooks/agents/use_agent_by_id'; +import { useBreadcrumb } from '../hooks/use_breadcrumbs'; +import { appPaths } from '../utils/app_paths'; +import { labels } from '../utils/i18n'; + +export const AgentBuilderAgentToolsPage: React.FC = () => { + const { agentId } = useParams<{ agentId: string }>(); + const { agent } = useAgentBuilderAgentById(agentId); + + const breadcrumbs = useMemo( + () => [ + { text: agent?.name ?? agentId, path: appPaths.agent.overview({ agentId }) }, + { text: labels.tools.title }, + ], + [agent?.name, agentId] + ); + + useBreadcrumb(breadcrumbs); + + return ; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/pages/conversations.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/pages/conversations.tsx index 1dd8ee47b6470..c1eb0de7d97b6 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/pages/conversations.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/pages/conversations.tsx @@ -15,7 +15,7 @@ export const AgentBuilderConversationsPage: React.FC = () => { useBreadcrumb([ { text: labels.conversations.title, - path: appPaths.chat.new, + path: appPaths.root, }, ]); return ; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/route_config.test.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/route_config.test.ts new file mode 100644 index 0000000000000..6aeed8cc5247c --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/route_config.test.ts @@ -0,0 +1,103 @@ +/* + * 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 { getSidebarViewForRoute, getAgentIdFromPath } from './route_config'; + +describe('route_config', () => { + describe('getSidebarViewForRoute', () => { + describe('agent routes', () => { + it('returns "conversation" for agent root', () => { + expect(getSidebarViewForRoute('/agents/elastic-ai-agent')).toBe('conversation'); + expect(getSidebarViewForRoute('/agents/my-custom-agent')).toBe('conversation'); + }); + + it('returns "conversation" for conversation routes', () => { + expect(getSidebarViewForRoute('/agents/elastic-ai-agent/conversations/123')).toBe( + 'conversation' + ); + expect(getSidebarViewForRoute('/agents/my-agent/conversations/abc-def')).toBe( + 'conversation' + ); + }); + + it('returns "conversation" for overview route', () => { + expect(getSidebarViewForRoute('/agents/elastic-ai-agent/overview')).toBe('conversation'); + }); + + it('returns "conversation" for skills route', () => { + expect(getSidebarViewForRoute('/agents/elastic-ai-agent/skills')).toBe('conversation'); + }); + + it('returns "conversation" for tools route', () => { + expect(getSidebarViewForRoute('/agents/elastic-ai-agent/tools')).toBe('conversation'); + }); + + it('returns "conversation" for plugins route', () => { + expect(getSidebarViewForRoute('/agents/elastic-ai-agent/plugins')).toBe('conversation'); + }); + + it('returns "conversation" for connectors route', () => { + expect(getSidebarViewForRoute('/agents/elastic-ai-agent/connectors')).toBe('conversation'); + }); + }); + + describe('manage routes', () => { + it('returns "manage" for manage agents routes', () => { + expect(getSidebarViewForRoute('/manage/agents')).toBe('manage'); + expect(getSidebarViewForRoute('/manage/agents/new')).toBe('manage'); + }); + + it('returns "manage" for manage tools routes', () => { + expect(getSidebarViewForRoute('/manage/tools')).toBe('manage'); + expect(getSidebarViewForRoute('/manage/tools/new')).toBe('manage'); + expect(getSidebarViewForRoute('/manage/tools/tool-123')).toBe('manage'); + expect(getSidebarViewForRoute('/manage/tools/bulk_import_mcp')).toBe('manage'); + }); + + it('returns "manage" for manage skills routes', () => { + expect(getSidebarViewForRoute('/manage/skills')).toBe('manage'); + expect(getSidebarViewForRoute('/manage/skills/new')).toBe('manage'); + expect(getSidebarViewForRoute('/manage/skills/skill-123')).toBe('manage'); + }); + + it('returns "manage" for manage plugins routes', () => { + expect(getSidebarViewForRoute('/manage/plugins')).toBe('manage'); + expect(getSidebarViewForRoute('/manage/plugins/plugin-123')).toBe('manage'); + }); + + it('returns "manage" for manage connectors route', () => { + expect(getSidebarViewForRoute('/manage/connectors')).toBe('manage'); + }); + }); + + describe('fallback behavior', () => { + it('returns "conversation" for unknown routes', () => { + expect(getSidebarViewForRoute('/unknown')).toBe('conversation'); + expect(getSidebarViewForRoute('/')).toBe('conversation'); + expect(getSidebarViewForRoute('/some/random/path')).toBe('conversation'); + }); + }); + }); + + describe('getAgentIdFromPath', () => { + it('extracts agent ID from agent routes', () => { + expect(getAgentIdFromPath('/agents/elastic-ai-agent')).toBe('elastic-ai-agent'); + expect(getAgentIdFromPath('/agents/my-custom-agent')).toBe('my-custom-agent'); + expect(getAgentIdFromPath('/agents/elastic-ai-agent/skills')).toBe('elastic-ai-agent'); + expect(getAgentIdFromPath('/agents/elastic-ai-agent/conversations/123')).toBe( + 'elastic-ai-agent' + ); + }); + + it('returns undefined for non-agent routes', () => { + expect(getAgentIdFromPath('/manage/agents')).toBeUndefined(); + expect(getAgentIdFromPath('/manage/tools')).toBeUndefined(); + expect(getAgentIdFromPath('/')).toBeUndefined(); + expect(getAgentIdFromPath('/unknown')).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/route_config.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/route_config.tsx new file mode 100644 index 0000000000000..8e72b676e6d2f --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/route_config.tsx @@ -0,0 +1,255 @@ +/* + * 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 React from 'react'; +import { matchPath } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; + +import { RouteDisplay } from './components/common/route_display'; +import { AgentBuilderConversationsPage } from './pages/conversations'; +import { AgentBuilderAgentsPage } from './pages/agents'; +import { AgentBuilderAgentsCreate } from './pages/agent_create'; +import { AgentBuilderAgentsEdit } from './pages/agent_edit'; +import { AgentBuilderAgentOverviewPage } from './pages/agent_overview'; +import { AgentBuilderAgentSkillsPage } from './pages/agent_skills'; +import { AgentBuilderAgentPluginsPage } from './pages/agent_plugins'; +import { AgentBuilderAgentToolsPage } from './pages/agent_tools'; +import { AgentBuilderToolsPage } from './pages/tools'; +import { AgentBuilderToolCreatePage } from './pages/tool_create'; +import { AgentBuilderToolDetailsPage } from './pages/tool_details'; +import { AgentBuilderBulkImportMcpToolsPage } from './pages/bulk_import_mcp_tools'; +import { AgentBuilderSkillsPage } from './pages/skills'; +import { AgentBuilderSkillCreatePage } from './pages/skill_create'; +import { AgentBuilderSkillDetailsPage } from './pages/skill_details'; +import { AgentBuilderPluginsPage } from './pages/plugins'; +import { AgentBuilderPluginDetailsPage } from './pages/plugin_details'; +import { AgentBuilderConnectorsPage } from './pages/connectors'; + +export type SidebarView = 'conversation' | 'manage'; + +export interface FeatureFlags { + experimental: boolean; + connectors: boolean; +} + +export interface RouteDefinition { + path: string; + element: React.ReactNode; + sidebarView: SidebarView; + isExperimental?: boolean; + isConnectors?: boolean; + navLabel?: string; + navIcon?: string; +} + +const navLabels = { + overview: i18n.translate('xpack.agentBuilder.routeConfig.overview', { + defaultMessage: 'Overview', + }), + skills: i18n.translate('xpack.agentBuilder.routeConfig.skills', { + defaultMessage: 'Skills', + }), + tools: i18n.translate('xpack.agentBuilder.routeConfig.tools', { + defaultMessage: 'Tools', + }), + plugins: i18n.translate('xpack.agentBuilder.routeConfig.plugins', { + defaultMessage: 'Plugins', + }), + connectors: i18n.translate('xpack.agentBuilder.routeConfig.connectors', { + defaultMessage: 'Connectors', + }), + agents: i18n.translate('xpack.agentBuilder.routeConfig.agents', { + defaultMessage: 'Agents', + }), +}; + +// Routes ordered from most specific to least specific for correct matching +export const agentRoutes: RouteDefinition[] = [ + { + path: '/agents/:agentId/conversations/:conversationId', + sidebarView: 'conversation', + element: , + }, + { + path: '/agents/:agentId/overview', + sidebarView: 'conversation', + navLabel: navLabels.overview, + element: , + }, + { + path: '/agents/:agentId/skills', + sidebarView: 'conversation', + isExperimental: true, + navLabel: navLabels.skills, + element: , + }, + { + path: '/agents/:agentId/plugins', + sidebarView: 'conversation', + isExperimental: true, + navLabel: navLabels.plugins, + element: , + }, + { + path: '/agents/:agentId/connectors', + sidebarView: 'conversation', + navLabel: navLabels.connectors, + isConnectors: true, + element: , + }, + { + path: '/agents/:agentId/tools', + sidebarView: 'conversation', + navLabel: navLabels.tools, + element: , + }, + // Catch-all for agent root - must be last + { + path: '/agents/:agentId', + sidebarView: 'conversation', + element: , + }, +]; + +export const manageRoutes: RouteDefinition[] = [ + { + path: '/manage/agents', + sidebarView: 'manage', + navLabel: navLabels.agents, + element: , + }, + { + path: '/manage/agents/new', + sidebarView: 'manage', + element: , + }, + { + path: '/manage/agents/:agentId', + sidebarView: 'manage', + element: , + }, + { + path: '/manage/skills', + sidebarView: 'manage', + navLabel: navLabels.skills, + isExperimental: true, + element: , + }, + { + path: '/manage/skills/new', + sidebarView: 'manage', + isExperimental: true, + element: , + }, + { + path: '/manage/skills/:skillId', + sidebarView: 'manage', + isExperimental: true, + element: , + }, + { + path: '/manage/plugins', + sidebarView: 'manage', + isExperimental: true, + navLabel: navLabels.plugins, + element: , + }, + { + path: '/manage/plugins/:pluginId', + sidebarView: 'manage', + isExperimental: true, + element: , + }, + { + path: '/manage/connectors', + sidebarView: 'manage', + navLabel: navLabels.connectors, + isConnectors: true, + element: , + }, + { + path: '/manage/tools', + sidebarView: 'manage', + navLabel: navLabels.tools, + element: , + }, + { + path: '/manage/tools/new', + sidebarView: 'manage', + element: , + }, + { + path: '/manage/tools/bulk_import_mcp', + sidebarView: 'manage', + element: , + }, + { + path: '/manage/tools/:toolId', + sidebarView: 'manage', + element: , + }, +]; + +export const allRoutes: RouteDefinition[] = [...agentRoutes, ...manageRoutes]; + +export const getSidebarViewForRoute = (pathname: string): SidebarView => { + for (const route of allRoutes) { + if (matchPath(pathname, { path: route.path, exact: false })) { + return route.sidebarView; + } + } + return 'conversation'; +}; + +export const getAgentIdFromPath = (pathname: string): string | undefined => { + const match = pathname.match(/^\/agents\/([^/]+)/); + return match ? match[1] : undefined; +}; + +export const getConversationIdFromPath = (pathname: string): string | undefined => { + const match = pathname.match(/^\/agents\/[^/]+\/conversations\/([^/]+)/); + return match ? match[1] : undefined; +}; + +export interface SidebarNavItem { + label: string; + path: string; + icon?: string; +} + +const isRouteEnabled = (route: RouteDefinition, flags: FeatureFlags): boolean => { + if (route.isExperimental && !flags.experimental) return false; + if (route.isConnectors && !flags.connectors) return false; + return true; +}; + +export const getEnabledRoutes = (flags: FeatureFlags): RouteDefinition[] => { + return allRoutes.filter((route) => isRouteEnabled(route, flags)); +}; + +export const getAgentSettingsNavItems = ( + agentId: string, + flags: FeatureFlags +): SidebarNavItem[] => { + return agentRoutes + .filter((route) => route.navLabel && isRouteEnabled(route, flags)) + .map((route) => ({ + label: route.navLabel ?? '', + path: route.path.replace(':agentId', agentId), + icon: route.navIcon, + })); +}; + +export const getManageNavItems = (flags: FeatureFlags): SidebarNavItem[] => { + return manageRoutes + .filter((route) => route.navLabel && isRouteEnabled(route, flags)) + .map((route) => ({ + label: route.navLabel!, + path: route.path, + icon: route.navIcon, + })); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/routes.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/routes.tsx index 514878aeb35af..13f78402431c2 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/routes.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/routes.tsx @@ -6,104 +6,43 @@ */ import { Route, Routes } from '@kbn/shared-ux-router'; -import React from 'react'; -import { AGENT_BUILDER_CONNECTORS_ENABLED_SETTING_ID } from '@kbn/management-settings-ids'; -import { AgentBuilderAgentsCreate } from './pages/agent_create'; -import { AgentBuilderAgentsEdit } from './pages/agent_edit'; -import { AgentBuilderAgentsPage } from './pages/agents'; -import { AgentBuilderConversationsPage } from './pages/conversations'; -import { AgentBuilderToolCreatePage } from './pages/tool_create'; -import { AgentBuilderToolDetailsPage } from './pages/tool_details'; -import { AgentBuilderToolsPage } from './pages/tools'; -import { AgentBuilderBulkImportMcpToolsPage } from './pages/bulk_import_mcp_tools'; -import { AgentBuilderSkillsPage } from './pages/skills'; -import { AgentBuilderSkillCreatePage } from './pages/skill_create'; -import { AgentBuilderSkillDetailsPage } from './pages/skill_details'; -import { AgentBuilderPluginsPage } from './pages/plugins'; -import { AgentBuilderPluginDetailsPage } from './pages/plugin_details'; -import { useExperimentalFeatures } from './hooks/use_experimental_features'; -import { useKibana } from './hooks/use_kibana'; -import { AgentBuilderConnectorsPage } from './pages/connectors'; +import React, { useMemo } from 'react'; -export const AgentBuilderRoutes: React.FC<{}> = () => { - const isExperimentalFeaturesEnabled = useExperimentalFeatures(); - const { - services: { uiSettings }, - } = useKibana(); - const isConnectorsEnabled = uiSettings.get( - AGENT_BUILDER_CONNECTORS_ENABLED_SETTING_ID, - false - ); - - return ( - - - - - - - - - - - - - - - - +import { AppLayout } from './components/layout/app_layout'; +import { RootRedirect } from './components/redirects/root_redirect'; +import { LegacyConversationRedirect } from './components/redirects/legacy_conversation_redirect'; +import { getEnabledRoutes } from './route_config'; +import { useFeatureFlags } from './hooks/use_feature_flags'; - - - - - - - - - - - +export const AgentBuilderRoutes: React.FC<{}> = () => { + const featureFlags = useFeatureFlags(); - - - + const enabledRoutes = useMemo(() => getEnabledRoutes(featureFlags), [featureFlags]); - {isConnectorsEnabled ? ( - - + return ( + + + {enabledRoutes.map((route) => ( + + {route.element} + + ))} + + {/* Legacy routes - redirect to new structure */} + + - ) : null} - {isExperimentalFeaturesEnabled - ? [ - - - , - - - , - - - , - ] - : null} - - {isExperimentalFeaturesEnabled - ? [ - - - , - - - , - ] - : null} + {/* Redirect /agents to /agents/:lastAgentId */} + + + - {/* Default to conversations page */} - - - - + {/* Root route - redirect to last used agent */} + + + + + ); }; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/storage_keys.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/storage_keys.ts index b42641281dcec..fd1ac87f3c041 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/storage_keys.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/storage_keys.ts @@ -5,13 +5,11 @@ * 2.0. */ -export const AGENT_BUILDER_TOUR_STORAGE_KEY = 'agentBuilder.hasSeenTour'; - export const storageKeys = { agentId: 'agentBuilder.agentId', lastUsedConnector: 'agentBuilder.lastUsedConnector', welcomeMessageDismissed: 'agentBuilder.welcomeMessageDismissed', - hasSeenAgentBuilderTour: AGENT_BUILDER_TOUR_STORAGE_KEY, + autoIncludeWarningDismissed: 'agentBuilder.autoIncludeWarningDismissed', getLastConversationKey: (sessionTag?: string, agentId?: string): string => { const tag = sessionTag || 'default'; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/utils/app_paths.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/utils/app_paths.ts index 422016b33451e..38c0593561b83 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/utils/app_paths.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/utils/app_paths.ts @@ -9,38 +9,69 @@ import { newConversationId } from './new_conversation'; export const appPaths = { root: '/', - agents: { - list: '/agents', - new: '/agents/new', - edit: ({ agentId }: { agentId: string }) => { - return `/agents/${agentId}`; + + // Agent-scoped routes (all under /agents/:agentId) + agent: { + root: ({ agentId }: { agentId: string }) => `/agents/${agentId}`, + conversations: { + new: ({ agentId }: { agentId: string }) => + `/agents/${agentId}/conversations/${newConversationId}`, + byId: ({ agentId, conversationId }: { agentId: string; conversationId: string }) => + `/agents/${agentId}/conversations/${conversationId}`, }, + skills: ({ agentId }: { agentId: string }) => `/agents/${agentId}/skills`, + plugins: ({ agentId }: { agentId: string }) => `/agents/${agentId}/plugins`, + tools: ({ agentId }: { agentId: string }) => `/agents/${agentId}/tools`, + connectors: ({ agentId }: { agentId: string }) => `/agents/${agentId}/connectors`, + overview: ({ agentId }: { agentId: string }) => `/agents/${agentId}/overview`, }, - chat: { - new: `/conversations/${newConversationId}`, - newWithAgent: ({ agentId }: { agentId: string }) => { - return `/conversations/${newConversationId}?agent_id=${agentId}`; - }, - conversation: ({ conversationId }: { conversationId: string }) => { - return `/conversations/${conversationId}`; - }, + + // Manage routes (global CRUD, no agent context) + manage: { + agents: '/manage/agents', + agentsNew: '/manage/agents/new', + agentDetails: ({ agentId }: { agentId: string }) => `/manage/agents/${agentId}`, + tools: '/manage/tools', + toolsNew: '/manage/tools/new', + toolDetails: ({ toolId }: { toolId: string }) => `/manage/tools/${toolId}`, + toolsBulkImport: '/manage/tools/bulk_import_mcp', + skills: '/manage/skills', + skillsNew: '/manage/skills/new', + skillDetails: ({ skillId }: { skillId: string }) => `/manage/skills/${skillId}`, + plugins: '/manage/plugins', + pluginDetails: ({ pluginId }: { pluginId: string }) => `/manage/plugins/${pluginId}`, + connectors: '/manage/connectors', + }, + + // Legacy paths - redirect to new structure via LegacyConversationRedirect + legacy: { + conversation: ({ conversationId }: { conversationId: string }) => + `/conversations/${conversationId}`, + }, + + // Backward compatibility aliases pointing to new routes + // TODO: Migrate consumers to use appPaths.agent.* or appPaths.manage.* directly and remove these aliases + agents: { + list: '/manage/agents', + new: '/manage/agents/new', + edit: ({ agentId }: { agentId: string }) => `/manage/agents/${agentId}`, }, connectors: { list: '/connectors', }, tools: { - list: '/tools', - new: '/tools/new', - details: ({ toolId }: { toolId: string }) => `/tools/${toolId}`, - bulkImportMcp: '/tools/bulk_import_mcp', + list: '/manage/tools', + new: '/manage/tools/new', + details: ({ toolId }: { toolId: string }) => `/manage/tools/${toolId}`, + bulkImportMcp: '/manage/tools/bulk_import_mcp', }, skills: { - list: '/skills', - new: '/skills/new', - details: ({ skillId }: { skillId: string }) => `/skills/${skillId}`, + list: '/manage/skills', + new: '/manage/skills/new', + details: ({ skillId }: { skillId: string }) => `/manage/skills/${skillId}`, }, plugins: { - list: '/plugins', - details: ({ pluginId }: { pluginId: string }) => `/plugins/${pluginId}`, + list: '/manage/plugins', + details: ({ pluginId }: { pluginId: string }) => `/manage/plugins/${pluginId}`, }, }; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/utils/i18n.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/utils/i18n.ts index 25cc883dcac79..bd00339c795a7 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/utils/i18n.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/utils/i18n.ts @@ -633,6 +633,610 @@ export const labels = { defaultMessage: 'Experimental', }), }, + agentSkills: { + pageDescription: i18n.translate('xpack.agentBuilder.agentSkills.pageDescription', { + defaultMessage: + 'Capabilities that help the agent analyze data, generate queries, and perform tasks.', + }), + addSkillButton: i18n.translate('xpack.agentBuilder.agentSkills.addSkillButton', { + defaultMessage: 'Add skill', + }), + importFromLibraryMenuItem: i18n.translate( + 'xpack.agentBuilder.agentSkills.importFromLibraryMenuItem', + { + defaultMessage: 'Import from skill library', + } + ), + createSkillMenuItem: i18n.translate('xpack.agentBuilder.agentSkills.createSkillMenuItem', { + defaultMessage: 'Create a skill', + }), + createFromChatButton: i18n.translate('xpack.agentBuilder.agentSkills.createFromChatButton', { + defaultMessage: 'Create from chat', + }), + searchActiveSkillsPlaceholder: i18n.translate( + 'xpack.agentBuilder.agentSkills.searchActiveSkillsPlaceholder', + { + defaultMessage: 'Search active skills', + } + ), + addSkillFromLibraryTitle: i18n.translate( + 'xpack.agentBuilder.agentSkills.addSkillFromLibraryTitle', + { + defaultMessage: 'Add skill from library', + } + ), + manageSkillLibraryLink: i18n.translate( + 'xpack.agentBuilder.agentSkills.manageSkillLibraryLink', + { + defaultMessage: 'Manage skill library', + } + ), + searchAvailableSkillsPlaceholder: i18n.translate( + 'xpack.agentBuilder.agentSkills.searchAvailableSkillsPlaceholder', + { + defaultMessage: 'Search available skills', + } + ), + availableSkillsSummary: (showing: number, total: number) => + i18n.translate('xpack.agentBuilder.agentSkills.availableSkillsSummary', { + defaultMessage: + 'Showing {showing} of {total} {total, plural, one {Available skill} other {Available skills}}', + values: { showing, total }, + }), + addButtonLabel: i18n.translate('xpack.agentBuilder.agentSkills.addButtonLabel', { + defaultMessage: 'Add', + }), + editSkillAriaLabel: i18n.translate('xpack.agentBuilder.agentSkills.editSkillAriaLabel', { + defaultMessage: 'Edit skill', + }), + removeSkillAriaLabel: i18n.translate('xpack.agentBuilder.agentSkills.removeSkillAriaLabel', { + defaultMessage: 'Remove skill from agent', + }), + noActiveSkillsMessage: i18n.translate('xpack.agentBuilder.agentSkills.noActiveSkillsMessage', { + defaultMessage: 'No skills assigned to this agent yet. Add skills from the library.', + }), + noActiveSkillsMatchMessage: i18n.translate( + 'xpack.agentBuilder.agentSkills.noActiveSkillsMatchMessage', + { + defaultMessage: 'No active skills match your search.', + } + ), + noAvailableSkillsMessage: i18n.translate( + 'xpack.agentBuilder.agentSkills.noAvailableSkillsMessage', + { + defaultMessage: 'All skills have been added to this agent.', + } + ), + noAvailableSkillsMatchMessage: i18n.translate( + 'xpack.agentBuilder.agentSkills.noAvailableSkillsMatchMessage', + { + defaultMessage: 'No available skills match your search.', + } + ), + removeSkillSuccessToast: (skillName: string) => + i18n.translate('xpack.agentBuilder.agentSkills.removeSkillSuccessToast', { + defaultMessage: 'Skill "{skillName}" removed from agent', + values: { skillName }, + }), + addSkillSuccessToast: (skillName: string) => + i18n.translate('xpack.agentBuilder.agentSkills.addSkillSuccessToast', { + defaultMessage: 'Skill "{skillName}" added to agent', + values: { skillName }, + }), + updateSkillsErrorToast: i18n.translate( + 'xpack.agentBuilder.agentSkills.updateSkillsErrorToast', + { + defaultMessage: 'Unable to update agent skills', + } + ), + editSkillFlyoutTitle: i18n.translate('xpack.agentBuilder.agentSkills.editSkillFlyoutTitle', { + defaultMessage: 'Edit skill', + }), + createSkillFlyoutTitle: i18n.translate( + 'xpack.agentBuilder.agentSkills.createSkillFlyoutTitle', + { + defaultMessage: 'Create skill', + } + ), + viewSkillLibraryLink: i18n.translate('xpack.agentBuilder.agentSkills.viewSkillLibraryLink', { + defaultMessage: 'View skill library', + }), + sharedSkillWarning: i18n.translate('xpack.agentBuilder.agentSkills.sharedSkillWarning', { + defaultMessage: + 'You are editing a shared skill. Changes will affect all users using this skill.', + }), + newSkillLibraryInfo: i18n.translate('xpack.agentBuilder.agentSkills.newSkillLibraryInfo', { + defaultMessage: + 'This skill will be added to your library and be available for other agents to use.', + }), + advancedOptionsLabel: i18n.translate('xpack.agentBuilder.agentSkills.advancedOptionsLabel', { + defaultMessage: 'Advanced options', + }), + allSkillsSummary: (showing: number, total: number) => + i18n.translate('xpack.agentBuilder.agentSkills.allSkillsSummary', { + defaultMessage: 'Showing {showing} of {total} {total, plural, one {Skill} other {Skills}}', + values: { showing, total }, + }), + removeSkillButtonLabel: i18n.translate( + 'xpack.agentBuilder.agentSkills.removeSkillButtonLabel', + { + defaultMessage: 'Remove', + } + ), + skillDetailInstructionsLabel: i18n.translate( + 'xpack.agentBuilder.agentSkills.skillDetailInstructionsLabel', + { + defaultMessage: 'Instructions', + } + ), + instructionsViewModeLegend: i18n.translate( + 'xpack.agentBuilder.agentSkills.instructionsViewModeLegend', + { + defaultMessage: 'Instructions view mode', + } + ), + instructionsViewRenderedLabel: i18n.translate( + 'xpack.agentBuilder.agentSkills.instructionsViewRenderedLabel', + { + defaultMessage: 'Rendered', + } + ), + instructionsViewRawLabel: i18n.translate( + 'xpack.agentBuilder.agentSkills.instructionsViewRawLabel', + { + defaultMessage: 'Raw', + } + ), + noSkillSelectedMessage: i18n.translate( + 'xpack.agentBuilder.agentSkills.noSkillSelectedMessage', + { + defaultMessage: 'Select a skill to view details.', + } + ), + removeSkillConfirmTitle: (skillName: string) => + i18n.translate('xpack.agentBuilder.agentSkills.removeSkillConfirmTitle', { + defaultMessage: 'Remove "{skillName}" from agent?', + values: { skillName }, + }), + removeSkillConfirmBody: i18n.translate( + 'xpack.agentBuilder.agentSkills.removeSkillConfirmBody', + { + defaultMessage: 'The skill will no longer be available to this agent.', + } + ), + removeSkillConfirmButton: i18n.translate( + 'xpack.agentBuilder.agentSkills.removeSkillConfirmButton', + { + defaultMessage: 'Remove', + } + ), + removeSkillCancelButton: i18n.translate( + 'xpack.agentBuilder.agentSkills.removeSkillCancelButton', + { + defaultMessage: 'Cancel', + } + ), + elasticCapabilitiesManagedTooltip: i18n.translate( + 'xpack.agentBuilder.agentSkills.elasticCapabilitiesManagedTooltip', + { + defaultMessage: + 'This built-in skill is automatically included because Elastic Capabilities is enabled for this agent.', + } + ), + elasticCapabilitiesReadOnlyBadge: i18n.translate( + 'xpack.agentBuilder.agentSkills.elasticCapabilitiesReadOnlyBadge', + { + defaultMessage: 'Auto', + } + ), + readOnlyBadge: i18n.translate('xpack.agentBuilder.agentSkills.readOnlyBadge', { + defaultMessage: 'Read only', + }), + autoIncludedBadgeLabel: i18n.translate( + 'xpack.agentBuilder.agentSkills.autoIncludedBadgeLabel', + { + defaultMessage: 'Auto-included', + } + ), + autoIncludedTooltipTitle: i18n.translate( + 'xpack.agentBuilder.agentSkills.autoIncludedTooltipTitle', + { + defaultMessage: 'Added automatically from agent settings', + } + ), + autoIncludedTooltipBody: i18n.translate( + 'xpack.agentBuilder.agentSkills.autoIncludedTooltipBody', + { + defaultMessage: 'Turn off auto-include built-in skills to manage it yourself', + } + ), + elasticCapabilitiesCallout: i18n.translate( + 'xpack.agentBuilder.agentSkills.elasticCapabilitiesCallout', + { + defaultMessage: + 'Built-in skills are automatically included while Elastic Capabilities is enabled.', + } + ), + manageAllSkills: i18n.translate('xpack.agentBuilder.agentSkills.manageAllSkillsLink', { + defaultMessage: 'Manage all skills', + }), + }, + agentPlugins: { + pageDescription: i18n.translate('xpack.agentBuilder.agentPlugins.pageDescription', { + defaultMessage: 'Pre-built packages that bundle multiple capabilities into a single install.', + }), + installPluginButton: i18n.translate('xpack.agentBuilder.agentPlugins.installPluginButton', { + defaultMessage: 'Install plugin', + }), + fromUrlOrZipMenuItem: i18n.translate('xpack.agentBuilder.agentPlugins.fromUrlOrZipMenuItem', { + defaultMessage: 'From URL or ZIP', + }), + fromLibraryMenuItem: i18n.translate('xpack.agentBuilder.agentPlugins.fromLibraryMenuItem', { + defaultMessage: 'From library', + }), + searchActivePluginsPlaceholder: i18n.translate( + 'xpack.agentBuilder.agentPlugins.searchActivePluginsPlaceholder', + { + defaultMessage: 'Search active plugins', + } + ), + noActivePluginsMessage: i18n.translate( + 'xpack.agentBuilder.agentPlugins.noActivePluginsMessage', + { + defaultMessage: + 'No plugins assigned to this agent yet. Install or add plugins from the library.', + } + ), + noActivePluginsMatchMessage: i18n.translate( + 'xpack.agentBuilder.agentPlugins.noActivePluginsMatchMessage', + { + defaultMessage: 'No active plugins match your search.', + } + ), + removePluginButtonLabel: i18n.translate( + 'xpack.agentBuilder.agentPlugins.removePluginButtonLabel', + { + defaultMessage: 'Remove', + } + ), + removePluginAriaLabel: i18n.translate('xpack.agentBuilder.agentPlugins.removePluginAriaLabel', { + defaultMessage: 'Remove plugin from agent', + }), + removePluginConfirmTitle: (pluginName: string) => + i18n.translate('xpack.agentBuilder.agentPlugins.removePluginConfirmTitle', { + defaultMessage: 'Remove "{pluginName}" from agent?', + values: { pluginName }, + }), + removePluginConfirmBody: i18n.translate( + 'xpack.agentBuilder.agentPlugins.removePluginConfirmBody', + { + defaultMessage: 'The plugin and its skills will no longer be available to this agent.', + } + ), + removePluginConfirmButton: i18n.translate( + 'xpack.agentBuilder.agentPlugins.removePluginConfirmButton', + { + defaultMessage: 'Remove', + } + ), + removePluginCancelButton: i18n.translate( + 'xpack.agentBuilder.agentPlugins.removePluginCancelButton', + { + defaultMessage: 'Cancel', + } + ), + addPluginSuccessToast: (pluginName: string) => + i18n.translate('xpack.agentBuilder.agentPlugins.addPluginSuccessToast', { + defaultMessage: 'Plugin "{pluginName}" added to agent', + values: { pluginName }, + }), + removePluginSuccessToast: (pluginName: string) => + i18n.translate('xpack.agentBuilder.agentPlugins.removePluginSuccessToast', { + defaultMessage: 'Plugin "{pluginName}" removed from agent', + values: { pluginName }, + }), + updatePluginsErrorToast: i18n.translate( + 'xpack.agentBuilder.agentPlugins.updatePluginsErrorToast', + { + defaultMessage: 'Unable to update agent plugins', + } + ), + noPluginSelectedMessage: i18n.translate( + 'xpack.agentBuilder.agentPlugins.noPluginSelectedMessage', + { + defaultMessage: 'Select a plugin to view details.', + } + ), + skillsCountBadge: (count: number) => + i18n.translate('xpack.agentBuilder.agentPlugins.skillsCountBadge', { + defaultMessage: '{count} {count, plural, one {Skill} other {Skills}}', + values: { count }, + }), + addPluginFromLibraryTitle: i18n.translate( + 'xpack.agentBuilder.agentPlugins.addPluginFromLibraryTitle', + { + defaultMessage: 'Add plugin from library', + } + ), + managePluginLibraryLink: i18n.translate( + 'xpack.agentBuilder.agentPlugins.managePluginLibraryLink', + { + defaultMessage: 'Manage plugin library', + } + ), + searchAvailablePluginsPlaceholder: i18n.translate( + 'xpack.agentBuilder.agentPlugins.searchAvailablePluginsPlaceholder', + { + defaultMessage: 'Search available plugins', + } + ), + availablePluginsSummary: (showing: number, total: number) => + i18n.translate('xpack.agentBuilder.agentPlugins.availablePluginsSummary', { + defaultMessage: + 'Showing {showing} of {total} {total, plural, one {Available plugin} other {Available plugins}}', + values: { showing, total }, + }), + noAvailablePluginsMatchMessage: i18n.translate( + 'xpack.agentBuilder.agentPlugins.noAvailablePluginsMatchMessage', + { + defaultMessage: 'No available plugins match your search.', + } + ), + noAvailablePluginsMessage: i18n.translate( + 'xpack.agentBuilder.agentPlugins.noAvailablePluginsMessage', + { + defaultMessage: 'All plugins have been added to this agent.', + } + ), + skillDetailInstalledVia: (source: string) => + i18n.translate('xpack.agentBuilder.agentPlugins.skillDetailInstalledVia', { + defaultMessage: 'Installed via {source}', + values: { source }, + }), + pluginDetailIdLabel: i18n.translate('xpack.agentBuilder.agentPlugins.pluginDetailIdLabel', { + defaultMessage: 'ID', + }), + pluginDetailNameLabel: i18n.translate('xpack.agentBuilder.agentPlugins.pluginDetailNameLabel', { + defaultMessage: 'Name', + }), + pluginDetailDescriptionLabel: i18n.translate( + 'xpack.agentBuilder.agentPlugins.pluginDetailDescriptionLabel', + { + defaultMessage: 'Description', + } + ), + pluginDetailSkillsLabel: i18n.translate( + 'xpack.agentBuilder.agentPlugins.pluginDetailSkillsLabel', + { + defaultMessage: 'Skills', + } + ), + pluginDetailAuthorLabel: i18n.translate( + 'xpack.agentBuilder.agentPlugins.pluginDetailAuthorLabel', + { + defaultMessage: 'Author', + } + ), + pluginDetailSourceLabel: i18n.translate( + 'xpack.agentBuilder.agentPlugins.pluginDetailSourceLabel', + { + defaultMessage: 'Source', + } + ), + installPluginFlyoutTitle: i18n.translate( + 'xpack.agentBuilder.agentPlugins.installPluginFlyoutTitle', + { + defaultMessage: 'Install plugin...', + } + ), + installPluginUrlTab: i18n.translate('xpack.agentBuilder.agentPlugins.installPluginUrlTab', { + defaultMessage: 'URL', + }), + installPluginUploadTab: i18n.translate( + 'xpack.agentBuilder.agentPlugins.installPluginUploadTab', + { + defaultMessage: 'Upload ZIP', + } + ), + autoBadge: i18n.translate('xpack.agentBuilder.agentPlugins.autoBadge', { + defaultMessage: 'Auto', + }), + autoPluginManagedTooltip: i18n.translate( + 'xpack.agentBuilder.agentPlugins.autoPluginManagedTooltip', + { + defaultMessage: + 'This plugin is automatically included while Elastic Capabilities is enabled.', + } + ), + autoIncludedBadgeLabel: i18n.translate( + 'xpack.agentBuilder.agentPlugins.autoIncludedBadgeLabel', + { + defaultMessage: 'Auto-included', + } + ), + autoIncludedTooltipTitle: i18n.translate( + 'xpack.agentBuilder.agentPlugins.autoIncludedTooltipTitle', + { + defaultMessage: 'Added automatically from agent settings', + } + ), + autoIncludedTooltipBody: i18n.translate( + 'xpack.agentBuilder.agentPlugins.autoIncludedTooltipBody', + { + defaultMessage: 'Turn off auto-include built-in plugins to manage it yourself', + } + ), + manageAllPlugins: i18n.translate('xpack.agentBuilder.agentPlugins.manageAllSkillsLink', { + defaultMessage: 'Manage all plugins', + }), + }, + agentTools: { + pageDescription: i18n.translate('xpack.agentBuilder.agentTools.pageDescription', { + defaultMessage: + 'Modular, reusable Elasticsearch operations. Agents use them to search, retrieve, and analyze your data.', + }), + addToolButton: i18n.translate('xpack.agentBuilder.agentTools.addToolButton', { + defaultMessage: 'Add tool', + }), + fromLibraryMenuItem: i18n.translate('xpack.agentBuilder.agentTools.fromLibraryMenuItem', { + defaultMessage: 'From library', + }), + createNewToolMenuItem: i18n.translate('xpack.agentBuilder.agentTools.createNewToolMenuItem', { + defaultMessage: 'Create new tool', + }), + searchActiveToolsPlaceholder: i18n.translate( + 'xpack.agentBuilder.agentTools.searchActiveToolsPlaceholder', + { + defaultMessage: 'Search active tools', + } + ), + noActiveToolsMessage: i18n.translate('xpack.agentBuilder.agentTools.noActiveToolsMessage', { + defaultMessage: + 'No tools assigned to this agent yet. Add tools from the library or create a new one.', + }), + noActiveToolsMatchMessage: i18n.translate( + 'xpack.agentBuilder.agentTools.noActiveToolsMatchMessage', + { + defaultMessage: 'No active tools match your search.', + } + ), + removeToolButtonLabel: i18n.translate('xpack.agentBuilder.agentTools.removeToolButtonLabel', { + defaultMessage: 'Remove', + }), + removeToolAriaLabel: i18n.translate('xpack.agentBuilder.agentTools.removeToolAriaLabel', { + defaultMessage: 'Remove tool from agent', + }), + removeToolConfirmTitle: (toolId: string) => + i18n.translate('xpack.agentBuilder.agentTools.removeToolConfirmTitle', { + defaultMessage: 'Remove "{toolId}" from agent?', + values: { toolId }, + }), + removeToolConfirmBody: i18n.translate('xpack.agentBuilder.agentTools.removeToolConfirmBody', { + defaultMessage: 'The tool will no longer be available to this agent.', + }), + removeToolConfirmButton: i18n.translate( + 'xpack.agentBuilder.agentTools.removeToolConfirmButton', + { + defaultMessage: 'Remove', + } + ), + removeToolCancelButton: i18n.translate('xpack.agentBuilder.agentTools.removeToolCancelButton', { + defaultMessage: 'Cancel', + }), + addToolSuccessToast: (toolId: string) => + i18n.translate('xpack.agentBuilder.agentTools.addToolSuccessToast', { + defaultMessage: 'Tool "{toolId}" added to agent', + values: { toolId }, + }), + removeToolSuccessToast: (toolId: string) => + i18n.translate('xpack.agentBuilder.agentTools.removeToolSuccessToast', { + defaultMessage: 'Tool "{toolId}" removed from agent', + values: { toolId }, + }), + updateToolsErrorToast: i18n.translate('xpack.agentBuilder.agentTools.updateToolsErrorToast', { + defaultMessage: 'Unable to update agent tools', + }), + noToolSelectedMessage: i18n.translate('xpack.agentBuilder.agentTools.noToolSelectedMessage', { + defaultMessage: 'Select a tool to view details.', + }), + readOnlyBadge: i18n.translate('xpack.agentBuilder.agentTools.readOnlyBadge', { + defaultMessage: 'Read only', + }), + addToolFromLibraryTitle: i18n.translate( + 'xpack.agentBuilder.agentTools.addToolFromLibraryTitle', + { + defaultMessage: 'Add tool from library', + } + ), + manageToolLibraryLink: i18n.translate('xpack.agentBuilder.agentTools.manageToolLibraryLink', { + defaultMessage: 'Manage tool library', + }), + editInLibraryLink: i18n.translate('xpack.agentBuilder.agentTools.editInLibraryLink', { + defaultMessage: 'Edit in library', + }), + searchAvailableToolsPlaceholder: i18n.translate( + 'xpack.agentBuilder.agentTools.searchAvailableToolsPlaceholder', + { + defaultMessage: 'Search available tools', + } + ), + availableToolsSummary: (showing: number, total: number) => + i18n.translate('xpack.agentBuilder.agentTools.availableToolsSummary', { + defaultMessage: 'Showing {showing} of {total} {total, plural, one {Tool} other {Tools}}', + values: { showing, total }, + }), + noAvailableToolsMatchMessage: i18n.translate( + 'xpack.agentBuilder.agentTools.noAvailableToolsMatchMessage', + { + defaultMessage: 'No available tools match your search.', + } + ), + noAvailableToolsMessage: i18n.translate( + 'xpack.agentBuilder.agentTools.noAvailableToolsMessage', + { + defaultMessage: 'All tools have been added to this agent.', + } + ), + toolDetailIdLabel: i18n.translate('xpack.agentBuilder.agentTools.toolDetailIdLabel', { + defaultMessage: 'ID', + }), + toolDetailTypeLabel: i18n.translate('xpack.agentBuilder.agentTools.toolDetailTypeLabel', { + defaultMessage: 'Type', + }), + toolDetailDescriptionLabel: i18n.translate( + 'xpack.agentBuilder.agentTools.toolDetailDescriptionLabel', + { + defaultMessage: 'Description', + } + ), + toolDetailTagsLabel: i18n.translate('xpack.agentBuilder.agentTools.toolDetailTagsLabel', { + defaultMessage: 'Tags', + }), + noTagsLabel: i18n.translate('xpack.agentBuilder.agentTools.noTagsLabel', { + defaultMessage: 'No tags', + }), + autoIncludedTooltip: i18n.translate('xpack.agentBuilder.agentTools.autoIncludedTooltip', { + defaultMessage: 'This tool is automatically included and cannot be removed.', + }), + autoIncludedBadgeLabel: i18n.translate('xpack.agentBuilder.agentTools.autoIncludedBadgeLabel', { + defaultMessage: 'Auto-included', + }), + autoIncludedTooltipTitle: i18n.translate( + 'xpack.agentBuilder.agentTools.autoIncludedTooltipTitle', + { + defaultMessage: 'Added automatically from agent settings', + } + ), + autoIncludedTooltipBody: i18n.translate( + 'xpack.agentBuilder.agentTools.autoIncludedTooltipBody', + { + defaultMessage: 'Turn off auto-include built-in tools to manage it yourself', + } + ), + elasticCapabilitiesManagedTooltip: i18n.translate( + 'xpack.agentBuilder.agentTools.elasticCapabilitiesManagedTooltip', + { + defaultMessage: + 'This built-in tool is automatically included because Elastic Capabilities is enabled for this agent.', + } + ), + elasticCapabilitiesReadOnlyBadge: i18n.translate( + 'xpack.agentBuilder.agentTools.elasticCapabilitiesReadOnlyBadge', + { + defaultMessage: 'Auto', + } + ), + elasticCapabilitiesCallout: i18n.translate( + 'xpack.agentBuilder.agentTools.elasticCapabilitiesCallout', + { + defaultMessage: + 'Built-in tools are automatically included while Elastic Capabilities is enabled.', + } + ), + manageAllTools: i18n.translate('xpack.agentBuilder.agentTools.manageAllToolsLink', { + defaultMessage: 'Manage all tools', + }), + }, plugins: { title: i18n.translate('xpack.agentBuilder.plugins.title', { defaultMessage: 'Plugins' }), pluginsTableCaption: (pluginsCount: number) => @@ -845,6 +1449,144 @@ export const labels = { ), }, }, + agentOverview: { + autoIncludeEnabledToast: i18n.translate( + 'xpack.agentBuilder.overview.autoInclude.enabledToast', + { + defaultMessage: 'Built-in capabilities enabled', + } + ), + autoIncludeDisabledToast: i18n.translate( + 'xpack.agentBuilder.overview.autoInclude.disabledToast', + { + defaultMessage: 'Built-in capabilities disabled', + } + ), + autoIncludeErrorToast: i18n.translate('xpack.agentBuilder.overview.autoInclude.errorToast', { + defaultMessage: 'Unable to update capabilities setting', + }), + instructionsSavedToast: i18n.translate('xpack.agentBuilder.overview.instructions.savedToast', { + defaultMessage: 'Instructions saved', + }), + instructionsErrorToast: i18n.translate('xpack.agentBuilder.overview.instructions.errorToast', { + defaultMessage: 'Unable to save instructions', + }), + docsLink: i18n.translate('xpack.agentBuilder.overview.docsLink', { + defaultMessage: 'Docs', + }), + editDetailsButton: i18n.translate('xpack.agentBuilder.overview.editDetailsButton', { + defaultMessage: 'Edit details', + }), + capabilitiesTitle: i18n.translate('xpack.agentBuilder.overview.capabilities.title', { + defaultMessage: 'Capabilities', + }), + capabilitiesDescription: i18n.translate( + 'xpack.agentBuilder.overview.capabilities.description', + { + defaultMessage: 'Manage the capabilities this agent uses to perform tasks and activities.', + } + ), + skillsDescription: i18n.translate( + 'xpack.agentBuilder.overview.capabilities.skillsDescription', + { + defaultMessage: 'Combine prompts and tools into reusable logic your agent can invoke.', + } + ), + addSkill: i18n.translate('xpack.agentBuilder.overview.capabilities.addSkill', { + defaultMessage: 'Add a skill', + }), + customizeSkills: i18n.translate('xpack.agentBuilder.overview.capabilities.customizeSkills', { + defaultMessage: 'Customize', + }), + pluginsDescription: i18n.translate( + 'xpack.agentBuilder.overview.capabilities.pluginsDescription', + { + defaultMessage: + 'Add packaged sets of skills from external sources to quickly extend your agent.', + } + ), + addPlugin: i18n.translate('xpack.agentBuilder.overview.capabilities.addPlugin', { + defaultMessage: 'Add a plugin', + }), + customizePlugins: i18n.translate('xpack.agentBuilder.overview.capabilities.customizePlugins', { + defaultMessage: 'Customize', + }), + connectorsDescription: i18n.translate( + 'xpack.agentBuilder.overview.capabilities.connectorsDescription', + { + defaultMessage: 'Connect external services to give your agent access to data and actions.', + } + ), + addConnector: i18n.translate('xpack.agentBuilder.overview.capabilities.addConnector', { + defaultMessage: 'Add a connector', + }), + settingsTitle: i18n.translate('xpack.agentBuilder.overview.settings.title', { + defaultMessage: 'Settings', + }), + settingsDescription: i18n.translate('xpack.agentBuilder.overview.settings.description', { + defaultMessage: 'Configure how this agent behaves and how its capabilities are managed.', + }), + autoIncludeTitle: i18n.translate('xpack.agentBuilder.overview.settings.autoIncludeTitle', { + defaultMessage: 'Include built-in capabilities automatically', + }), + autoIncludeDescription: i18n.translate( + 'xpack.agentBuilder.overview.settings.autoIncludeDescription', + { + defaultMessage: + 'Automatically include all current and future Elastic-built skills, plugins, and tools. Turn off to manage them manually.', + } + ), + autoIncludeLabel: i18n.translate('xpack.agentBuilder.overview.settings.autoIncludeLabel', { + defaultMessage: 'Include built-in capabilities automatically', + }), + instructionsTitle: i18n.translate('xpack.agentBuilder.overview.settings.instructionsTitle', { + defaultMessage: 'Use custom instructions', + }), + instructionsDescription: i18n.translate( + 'xpack.agentBuilder.overview.settings.instructionsDescription', + { + defaultMessage: + 'Define how the agent should behave, what it should prioritize, and any rules it should follow when responding.', + } + ), + instructionsPlaceholder: i18n.translate( + 'xpack.agentBuilder.overview.settings.instructionsPlaceholder', + { + defaultMessage: 'No custom instructions.', + } + ), + saveInstructionsButton: i18n.translate( + 'xpack.agentBuilder.overview.settings.saveInstructionsButton', + { + defaultMessage: 'Save instructions', + } + ), + byAuthor: (author: string) => + i18n.translate('xpack.agentBuilder.overview.byAuthor', { + defaultMessage: 'By {author}', + values: { author }, + }), + agentId: (id: string) => + i18n.translate('xpack.agentBuilder.overview.agentId', { + defaultMessage: 'ID {id}', + values: { id }, + }), + skillsLabel: (count: number) => + i18n.translate('xpack.agentBuilder.overview.capabilities.skills', { + defaultMessage: '{count, plural, one {Skill} other {Skills}}', + values: { count }, + }), + pluginsLabel: (count: number) => + i18n.translate('xpack.agentBuilder.overview.capabilities.plugins', { + defaultMessage: '{count, plural, one {Plugin} other {Plugins}}', + values: { count }, + }), + connectorsLabel: (count: number) => + i18n.translate('xpack.agentBuilder.overview.capabilities.connectors', { + defaultMessage: '{count, plural, one {Connector} other {Connectors}}', + values: { count }, + }), + }, navigationAbort: { title: i18n.translate('xpack.agentBuilder.navigationAbort.title', { defaultMessage: 'Abort chat request?', diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/utils/tool_selection_utils.test.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/utils/tool_selection_utils.test.ts index 456141c1c9c24..c4e6907fe7551 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/utils/tool_selection_utils.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/utils/tool_selection_utils.test.ts @@ -14,6 +14,7 @@ import { ToolType, allToolsSelectionWildcard } from '@kbn/agent-builder-common'; import { toggleToolSelection, isToolSelected, + getActiveTools, cleanInvalidToolReferences, } from './tool_selection_utils'; import type { AgentEditState } from '../hooks/agents/use_agent_edit'; @@ -79,6 +80,53 @@ describe('tool_selection_utils', () => { }); }); + describe('getActiveTools', () => { + const defaultToolIds = new Set(['tool1', 'tool3']); + + it('should return only explicitly selected tools when elastic capabilities are disabled', () => { + const selections: ToolSelection[] = [{ tool_ids: ['tool2'] }]; + const result = getActiveTools(mockTools, selections, false, defaultToolIds); + + expect(result.map((t) => t.id)).toEqual(['tool2']); + }); + + it('should include default tools when elastic capabilities are enabled', () => { + const selections: ToolSelection[] = [{ tool_ids: ['tool2'] }]; + const result = getActiveTools(mockTools, selections, true, defaultToolIds); + + expect(result.map((t) => t.id)).toEqual(['tool2', 'tool1', 'tool3']); + }); + + it('should not duplicate tools that are both explicit and default', () => { + const selections: ToolSelection[] = [{ tool_ids: ['tool1', 'tool2'] }]; + const result = getActiveTools(mockTools, selections, true, defaultToolIds); + + expect(result.map((t) => t.id)).toEqual(['tool1', 'tool2', 'tool3']); + }); + + it('should return empty array when no tools match selections', () => { + const selections: ToolSelection[] = [{ tool_ids: [] }]; + const result = getActiveTools(mockTools, selections, false, defaultToolIds); + + expect(result).toEqual([]); + }); + + it('should return only default tools when no explicit selections and elastic capabilities are enabled', () => { + const selections: ToolSelection[] = [{ tool_ids: [] }]; + const result = getActiveTools(mockTools, selections, true, defaultToolIds); + + expect(result.map((t) => t.id)).toEqual(['tool1', 'tool3']); + }); + + it('should handle wildcard selections with elastic capabilities', () => { + const selections: ToolSelection[] = [{ tool_ids: [allToolsSelectionWildcard] }]; + const result = getActiveTools(mockTools, selections, true, defaultToolIds); + + // All tools already selected via wildcard, no defaults to append + expect(result.map((t) => t.id)).toEqual(['tool1', 'tool2', 'tool3']); + }); + }); + describe('cleanInvalidToolReferences', () => { const mockToolDefinitions: ToolDefinition[] = [ { diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/utils/tool_selection_utils.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/utils/tool_selection_utils.ts index 2081bbc0f7258..168a6b64fe2db 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/utils/tool_selection_utils.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/utils/tool_selection_utils.ts @@ -93,6 +93,28 @@ export const toggleToolSelection = ( } }; +/** + * Returns the list of active tools for an agent, combining explicitly selected tools + * with default tools when elastic capabilities are enabled. Default tools that are + * already explicitly selected are not duplicated. + */ +export const getActiveTools = ( + allTools: T[], + agentToolSelections: ToolSelection[], + enableElasticCapabilities: boolean, + defaultToolIds: Set +): T[] => { + const explicitTools = allTools.filter((t) => isToolSelected(t, agentToolSelections)); + if (enableElasticCapabilities) { + const explicitIdSet = new Set(explicitTools.map((t) => t.id)); + const defaultToolsNotExplicit = allTools.filter( + (t) => defaultToolIds.has(t.id) && !explicitIdSet.has(t.id) + ); + return [...explicitTools, ...defaultToolsNotExplicit]; + } + return explicitTools; +}; + /** * Removes invalid tool references from the agent configuration. * Filters out tool IDs that don't exist in the available tools list, diff --git a/x-pack/platform/plugins/shared/agent_builder/public/embeddable/embeddable_conversation.tsx b/x-pack/platform/plugins/shared/agent_builder/public/embeddable/embeddable_conversation.tsx index 952c8713340ce..8867f4467853b 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/embeddable/embeddable_conversation.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/embeddable/embeddable_conversation.tsx @@ -11,7 +11,7 @@ import { css } from '@emotion/react'; import type { EmbeddableConversationInternalProps } from './types'; import { EmbeddableConversationsProvider } from '../application/context/conversation/embeddable_conversations_provider'; import { Conversation } from '../application/components/conversations/conversation'; -import { ConversationHeader } from '../application/components/conversations/conversation_header/conversation_header'; +import { EmbeddableConversationHeader } from '../application/components/conversations/embeddable_conversation_header/embeddable_conversation_header'; import { conversationBackgroundStyles, headerHeight, @@ -66,7 +66,7 @@ export const EmbeddableConversationInternal: React.FC - + diff --git a/x-pack/platform/plugins/shared/agent_builder/tsconfig.json b/x-pack/platform/plugins/shared/agent_builder/tsconfig.json index 01dfe1dbda314..659c02eae9d2b 100644 --- a/x-pack/platform/plugins/shared/agent_builder/tsconfig.json +++ b/x-pack/platform/plugins/shared/agent_builder/tsconfig.json @@ -107,7 +107,6 @@ "@kbn/core-elasticsearch-server-mocks", "@kbn/core-data-streams-server", "@kbn/task-manager-plugin", - "@kbn/deeplinks-data-sources", "@kbn/evals-plugin", "@kbn/usage-api-plugin", "@kbn/es-query", diff --git a/x-pack/platform/test/agent_builder_functional/tests/agents/agents_list.ts b/x-pack/platform/test/agent_builder_functional/tests/agents/agents_list.ts index ff4c9bb4295ff..cfb2637cfb09f 100644 --- a/x-pack/platform/test/agent_builder_functional/tests/agents/agents_list.ts +++ b/x-pack/platform/test/agent_builder_functional/tests/agents/agents_list.ts @@ -24,7 +24,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it('renders', async function () { - await agentBuilder.navigateToApp('agents'); + await agentBuilder.navigateToApp('manage/agents'); const titleSelector = 'agentBuilderAgentsListPageTitle'; const newAgentButtonSelector = 'agentBuilderNewAgentButton'; @@ -62,11 +62,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('chats with agent', async function () { const agent = agents[0]; await agentBuilder.clickAgentChat(agent.id); - await browser.waitForUrlToBe(`/app/agent_builder/conversations/new?agent_id=${agent.id}`); + await browser.waitForUrlToBe(`/app/agent_builder/agents/${agent.id}/conversations/new`); const agentText = await testSubjects.getVisibleText('agentBuilderAgentSelectorButton'); expect(agentText).to.contain(agent.name); // go back to agents list - await agentBuilder.navigateToApp('agents'); + await agentBuilder.navigateToApp('manage/agents'); }); it('has edit link with correct href', async function () { diff --git a/x-pack/platform/test/agent_builder_functional/tests/agents/edit_agent.ts b/x-pack/platform/test/agent_builder_functional/tests/agents/edit_agent.ts index 2665a2a2276b7..bbb3ba96053d2 100644 --- a/x-pack/platform/test/agent_builder_functional/tests/agents/edit_agent.ts +++ b/x-pack/platform/test/agent_builder_functional/tests/agents/edit_agent.ts @@ -26,7 +26,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('should navigate to agent edit form', async function () { await agentBuilder.clickAgentEdit(agent.id); - await browser.waitForUrlToBe(`/app/agent_builder/agents/${agent.id}`); + await browser.waitForUrlToBe(`/app/agent_builder/manage/agents/${agent.id}`); }); it('should show agent name as page title', async function () { @@ -56,7 +56,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('should clone agent', async function () { agent = agents[1]; await agentBuilder.clickAgentClone(agent.id); - await browser.waitForUrlToBe(`/app/agent_builder/agents/new?source_id=${agent.id}`); + await browser.waitForUrlToBe(`/app/agent_builder/manage/agents/new?source_id=${agent.id}`); expect(await agentBuilder.getAgentFormDisplayName()).to.be(agent.name); const idInput = agentBuilder.getAgentIdInput(); expect(await idInput.getValue()).to.be('test_agent_3'); diff --git a/x-pack/platform/test/agent_builder_functional/tests/converse/conversation_error_handling.ts b/x-pack/platform/test/agent_builder_functional/tests/converse/conversation_error_handling.ts index 36995a9abb75f..6c1358f260be8 100644 --- a/x-pack/platform/test/agent_builder_functional/tests/converse/conversation_error_handling.ts +++ b/x-pack/platform/test/agent_builder_functional/tests/converse/conversation_error_handling.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { AGENT_BUILDER_TOUR_STORAGE_KEY } from '@kbn/agent-builder-plugin/public/application/storage_keys'; +import { agentBuilderDefaultAgentId } from '@kbn/agent-builder-common'; import type { LlmProxy } from '../../../agent_builder_api_integration/utils/llm_proxy'; import { createLlmProxy } from '../../../agent_builder_api_integration/utils/llm_proxy'; import { @@ -31,8 +31,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { before(async () => { llmProxy = await createLlmProxy(log); await createConnector(llmProxy, supertest); - await agentBuilder.navigateToApp('conversations/new'); - await browser.setLocalStorageItem(AGENT_BUILDER_TOUR_STORAGE_KEY, 'true'); + await agentBuilder.navigateToApp(); }); after(async () => { @@ -52,7 +51,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const MOCKED_RESPONSE = 'This is a successful response after retry'; const MOCKED_TITLE = 'Error Handling Test'; - await agentBuilder.navigateToApp('conversations/new'); + await agentBuilder.navigateToApp(); // setup interceptors to return 400 error await setupAgentDirectError({ @@ -103,7 +102,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const INVALID_ID = 'this-id-does-not-exist-12345'; const initialUrl = await browser.getCurrentUrl(); - await agentBuilder.navigateToApp(`conversations/${INVALID_ID}`); + await agentBuilder.navigateToApp( + `agents/${agentBuilderDefaultAgentId}/conversations/${INVALID_ID}` + ); await testSubjects.existOrFail('errorPrompt'); @@ -126,7 +127,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const MOCKED_RESPONSE = 'This is a successful response in new conversation'; const MOCKED_TITLE = 'New Conversation After Error'; - await agentBuilder.navigateToApp('conversations/new'); + await agentBuilder.navigateToApp(); // setup interceptors to return 400 error await setupAgentDirectError({ @@ -232,7 +233,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const NEW_RESPONSE = 'This is a successful response after error'; const MOCKED_TITLE = 'Error Cleared Test'; - await agentBuilder.navigateToApp('conversations/new'); + await agentBuilder.navigateToApp(); // setup interceptors to return 400 error await setupAgentDirectError({ diff --git a/x-pack/platform/test/agent_builder_functional/tests/converse/conversation_flow.ts b/x-pack/platform/test/agent_builder_functional/tests/converse/conversation_flow.ts index 7985d9850ff8a..3fa1590bbfa67 100644 --- a/x-pack/platform/test/agent_builder_functional/tests/converse/conversation_flow.ts +++ b/x-pack/platform/test/agent_builder_functional/tests/converse/conversation_flow.ts @@ -41,7 +41,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it('navigates to new conversation page and shows initial state', async () => { - await agentBuilder.navigateToApp('conversations/new'); + await agentBuilder.navigateToApp(); // Assert the welcome page is displayed await testSubjects.existOrFail('agentBuilderWelcomePage'); @@ -76,15 +76,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const responseText = await responseElement.getVisibleText(); expect(responseText).to.contain(MOCKED_RESPONSE); - const titleElement = await testSubjects.find('agentBuilderConversationTitle'); + const titleElement = await testSubjects.find('agentBuilderConversationTitleButton'); const titleText = await titleElement.getVisibleText(); expect(titleText).to.contain(MOCKED_TITLE); - await agentBuilder.openConversationsHistory(); - // Wait for the conversation to appear in the list + // Wait for the conversation to appear in the sidebar await retry.try(async () => { - const conversationList = await testSubjects.find('agentBuilderConversationList'); - const conversationText = await conversationList.getVisibleText(); + const conversationItem = await testSubjects.find( + `agentBuilderSidebarConversation-${conversationId}` + ); + const conversationText = await conversationItem.getVisibleText(); expect(conversationText).to.contain(MOCKED_TITLE); }); }); diff --git a/x-pack/platform/test/agent_builder_functional/tests/tools/landing_page.ts b/x-pack/platform/test/agent_builder_functional/tests/tools/landing_page.ts index 33e67a9839d46..641fb2c57dce9 100644 --- a/x-pack/platform/test/agent_builder_functional/tests/tools/landing_page.ts +++ b/x-pack/platform/test/agent_builder_functional/tests/tools/landing_page.ts @@ -11,12 +11,15 @@ export default function ({ getPageObjects, getService }: AgentBuilderUiFtrProvid const { agentBuilder } = getPageObjects(['agentBuilder']); const testSubjects = getService('testSubjects'); const supertest = getService('supertest'); + const retry = getService('retry'); describe('tools landing page', function () { it('should render', async () => { await agentBuilder.navigateToToolsLanding(); - await testSubjects.existOrFail('agentBuilderToolsPage'); - await testSubjects.existOrFail('agentBuilderToolsTable'); + await retry.try(async () => { + await testSubjects.existOrFail('agentBuilderToolsPage'); + await testSubjects.existOrFail('agentBuilderToolsTable'); + }); }); it('should bulk delete tools from the table', async () => { @@ -37,14 +40,18 @@ export default function ({ getPageObjects, getService }: AgentBuilderUiFtrProvid } await agentBuilder.navigateToToolsLanding(); - await testSubjects.existOrFail('agentBuilderToolsTable'); + await retry.try(async () => { + await testSubjects.existOrFail('agentBuilderToolsTable'); + }); // Search for our specific tools to filter the table (avoids pagination issues) const search = agentBuilder.toolsSearch(); await search.type(`ftr.esql.${timestamp}`); - // Wait for the first tool to appear (ensures search has filtered the results) - await testSubjects.existOrFail(`agentBuilderToolsTableRow-${ids[0]}`); + // Wait for the tools data to load and the first row to appear before proceeding + await retry.try(async () => { + await testSubjects.existOrFail(`agentBuilderToolsTableRow-${ids[0]}`); + }); await agentBuilder.bulkDeleteTools(ids); diff --git a/x-pack/platform/test/functional/page_objects/agent_builder_page.ts b/x-pack/platform/test/functional/page_objects/agent_builder_page.ts index 18209a97fd857..3d9530adf9d2a 100644 --- a/x-pack/platform/test/functional/page_objects/agent_builder_page.ts +++ b/x-pack/platform/test/functional/page_objects/agent_builder_page.ts @@ -6,6 +6,7 @@ */ import type { ToolType } from '@kbn/agent-builder-common'; +import { agentBuilderDefaultAgentId } from '@kbn/agent-builder-common'; import { subj } from '@kbn/test-subj-selector'; import { AGENT_BUILDER_APP_ID } from '../../agent_builder/common/constants'; import type { LlmProxy } from '../../agent_builder_api_integration/utils/llm_proxy'; @@ -31,7 +32,7 @@ export class AgentBuilderPageObject extends FtrService { /** * Navigate to the AgentBuilder app */ - async navigateToApp(path: string = 'conversations/new') { + async navigateToApp(path: string = `agents/${agentBuilderDefaultAgentId}/conversations/new`) { await this.common.navigateToApp(AGENT_BUILDER_APP_ID, { path }); } @@ -58,7 +59,7 @@ export class AgentBuilderPageObject extends FtrService { async getCurrentConversationIdFromUrl(): Promise { return await this.retry.try(async () => { const url = await this.browser.getCurrentUrl(); - // URL should be something like: /app/agent_builder/conversations/{conversationId} + // URL should be something like: /app/agent_builder/agents/{agentId}/conversations/{conversationId} const match = url.match(/\/conversations\/([^\/\?]+)/); if (!match) { throw new Error('Could not extract conversation ID from URL'); @@ -78,7 +79,7 @@ export class AgentBuilderPageObject extends FtrService { withToolCall: boolean = false ): Promise { // Navigate to new conversation - await this.navigateToApp('conversations/new'); + await this.navigateToApp(); await (withToolCall ? setupAgentCallSearchToolWithNoIndexSelectedThenAnswer({ @@ -112,37 +113,15 @@ export class AgentBuilderPageObject extends FtrService { return await this.getCurrentConversationIdFromUrl(); } - async openConversationsHistory() { - // Only open if not already open - if (await this.isConversationsHistoryOpen()) { - return; - } - - const conversationsHistoryToggleBtn = await this.testSubjects.find( - 'agentBuilderConversationsHistoryToggleBtn' - ); - await conversationsHistoryToggleBtn.click(); - - // Wait for the conversations history popover to be visible and populated - await this.retry.try(async () => { - const conversationList = await this.testSubjects.find('agentBuilderConversationList'); - // Verify the list is actually visible and has content - const isDisplayed = await conversationList.isDisplayed(); - if (!isDisplayed) { - throw new Error('Conversation list is not displayed'); - } - }); - } - /** - * Check if the conversations history popover is currently open + * Ensure the Chats accordion in the sidebar is expanded. + * It defaults to open but can be collapsed by the user or when agent settings routes are active. */ - async isConversationsHistoryOpen(): Promise { - try { - const conversationList = await this.testSubjects.find('agentBuilderConversationList'); - return await conversationList.isDisplayed(); - } catch { - return false; + private async ensureChatsAccordionOpen() { + const toggle = await this.testSubjects.find('agentBuilderSidebarChatsToggle'); + const isExpanded = await toggle.getAttribute('aria-expanded'); + if (isExpanded !== 'true') { + await toggle.click(); } } @@ -150,9 +129,10 @@ export class AgentBuilderPageObject extends FtrService { * Navigate to an existing conversation by clicking on it in the history sidebar */ async navigateToConversationViaHistory(conversationId: string) { - await this.openConversationsHistory(); - - const conversationItem = await this.testSubjects.find(`conversationItem-${conversationId}`); + await this.ensureChatsAccordionOpen(); + const conversationItem = await this.testSubjects.find( + `agentBuilderSidebarConversation-${conversationId}` + ); await conversationItem.click(); } @@ -160,7 +140,9 @@ export class AgentBuilderPageObject extends FtrService { * Navigate to an existing conversation using the conversation ID in the URL */ async navigateToConversationById(conversationId: string) { - await this.navigateToApp(`conversations/${conversationId}`); + await this.navigateToApp( + `agents/${agentBuilderDefaultAgentId}/conversations/${conversationId}` + ); } /** @@ -187,18 +169,20 @@ export class AgentBuilderPageObject extends FtrService { } /** - * Delete a conversation by clicking the more actions button and then the delete button + * Delete a conversation by clicking the title button popover and then the delete button */ async deleteConversation(conversationId: string) { - await this.openConversationsHistory(); + await this.ensureChatsAccordionOpen(); // Click on conversation to open it - const conversationItem = await this.testSubjects.find(`conversationItem-${conversationId}`); + const conversationItem = await this.testSubjects.find( + `agentBuilderSidebarConversation-${conversationId}` + ); await conversationItem.click(); - // Click on the more actions button - const moreActionsButton = await this.testSubjects.find('agentBuilderMoreActionsButton'); - await moreActionsButton.click(); + // Click the title button to open the popover with rename/delete actions + const titleButton = await this.testSubjects.find('agentBuilderConversationTitleButton'); + await titleButton.click(); // Click on the delete button from the popover const deleteButton = await this.testSubjects.find('agentBuilderConversationDeleteButton'); @@ -207,9 +191,9 @@ export class AgentBuilderPageObject extends FtrService { const confirmButton = await this.testSubjects.find('confirmModalConfirmButton'); await confirmButton.click(); - // Wait for the conversation to be removed + // Wait for the conversation to be removed from the sidebar await this.retry.try(async () => { - await this.testSubjects.missingOrFail(`conversationItem-${conversationId}`); + await this.testSubjects.missingOrFail(`agentBuilderSidebarConversation-${conversationId}`); }); } @@ -217,10 +201,10 @@ export class AgentBuilderPageObject extends FtrService { * Check if a conversation exists in the history by conversation ID */ async isConversationInHistory(conversationId: string): Promise { - await this.openConversationsHistory(); + await this.ensureChatsAccordionOpen(); try { - await this.testSubjects.find(`conversationItem-${conversationId}`); + await this.testSubjects.find(`agentBuilderSidebarConversation-${conversationId}`); return true; } catch (error) { return false; @@ -239,28 +223,34 @@ export class AgentBuilderPageObject extends FtrService { * Click the new conversation button */ async clickNewConversationButton() { - const newButton = await this.testSubjects.find('agentBuilderNewConversationButton'); + const newButton = await this.testSubjects.find('agentBuilderSidebarNewConversationButton'); await newButton.click(); } /** - * Get the current conversation title text + * Get the current conversation title text. + * For persisted conversations the title is inside a button (popover trigger); + * for unsaved ones it is a plain h4. */ async getConversationTitle(): Promise { - const titleElement = await this.testSubjects.find('agentBuilderConversationTitle'); + const isPersisted = await this.testSubjects.exists('agentBuilderConversationTitleButton'); + const selector = isPersisted + ? 'agentBuilderConversationTitleButton' + : 'agentBuilderConversationTitle'; + const titleElement = await this.testSubjects.find(selector); return await titleElement.getVisibleText(); } /** - * Rename a conversation by hovering over the title, clicking the pencil icon, - * entering the new name, and submitting + * Rename a conversation by clicking the title button to open the popover, + * then selecting rename, entering the new name, and submitting. */ async renameConversation(newTitle: string): Promise { - // Hover over the conversation title to reveal the pencil icon - const titleElement = await this.testSubjects.find('agentBuilderConversationTitle'); - await titleElement.moveMouseTo(); + // Click the title button to open the popover + const titleButton = await this.testSubjects.find('agentBuilderConversationTitleButton'); + await titleButton.click(); - // Click the pencil icon to enter edit mode + // Click the rename button from the popover const renameButton = await this.testSubjects.find('agentBuilderConversationRenameButton'); await renameButton.click(); @@ -313,15 +303,15 @@ export class AgentBuilderPageObject extends FtrService { * ========================== */ async navigateToToolsLanding() { - await this.navigateToApp('tools'); + await this.navigateToApp('manage/tools'); } async navigateToNewTool() { - await this.navigateToApp('tools/new'); + await this.navigateToApp('manage/tools/new'); } async navigateToTool(toolId: string) { - await this.navigateToApp(`tools/${toolId}`); + await this.navigateToApp(`manage/tools/${toolId}`); } /* @@ -390,7 +380,7 @@ export class AgentBuilderPageObject extends FtrService { * ========================== */ async navigateToBulkImportMcp() { - await this.navigateToApp('tools/bulk_import_mcp'); + await this.navigateToApp('manage/tools/bulk_import_mcp'); } async openManageMcpMenu() { @@ -540,7 +530,7 @@ export class AgentBuilderPageObject extends FtrService { * ========================== */ async createAgentViaUI({ id, name, labels }: { id: string; name: string; labels: string[] }) { - await this.navigateToApp('agents/new'); + await this.navigateToApp('manage/agents/new'); const selectors = { inputs: { id: 'agentSettingsIdInput',