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/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..71e5e54e96160 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/app_layout.tsx @@ -0,0 +1,35 @@ +/* + * 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, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; + +import { UnifiedSidebar } from './unified_sidebar/unified_sidebar'; + +interface AppLayoutProps { + children: React.ReactNode; +} + +export const AppLayout: React.FC = ({ children }) => { + const { euiTheme } = useEuiTheme(); + + const contentStyles = css` + overflow: auto; + background-color: ${euiTheme.colors.backgroundBasePlain}; + `; + + return ( + + + + + {children} + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/agent_selector.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/agent_selector.tsx new file mode 100644 index 0000000000000..5b39e0e910557 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/agent_selector.tsx @@ -0,0 +1,69 @@ +/* + * 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 { EuiFlexItem, EuiText, EuiSelect, EuiLoadingSpinner } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; + +import { useAgentBuilderAgents } from '../../../hooks/agents/use_agents'; +import { storageKeys } from '../../../storage_keys'; + +const labels = { + agentLabel: i18n.translate('xpack.agentBuilder.sidebar.agentSelector.agentLabel', { + defaultMessage: 'Agent', + }), + selectAgent: i18n.translate('xpack.agentBuilder.sidebar.agentSelector.selectAgent', { + defaultMessage: 'Select 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 handleAgentChange = useCallback( + (e: React.ChangeEvent) => { + const newAgentId = e.target.value; + setStoredAgentId(newAgentId); + navigate(getNavigationPath(newAgentId)); + }, + [navigate, setStoredAgentId, getNavigationPath] + ); + + const agentOptions = agents.map((agent) => ({ + value: agent.id, + text: agent.name, + })); + + return ( + + + {labels.agentLabel} + + {isLoading ? ( + + ) : ( + + )} + + ); +}; 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..4cf5a40dcf4cb --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/unified_sidebar.tsx @@ -0,0 +1,56 @@ +/* + * 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 { useLocation } from 'react-router-dom'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; + +import { EuiPanel } from '@elastic/eui'; +import { css } from '@emotion/react'; + +import { storageKeys } from '../../../storage_keys'; +import { getSidebarViewForRoute, getAgentIdFromPath } from '../../../route_config'; +import { ConversationSidebarView } from './views/conversation_view'; +import { AgentSettingsSidebarView } from './views/agent_settings_view'; +import { ManageSidebarView } from './views/manage_view'; + +const SIDEBAR_WIDTH = 200; + +export const UnifiedSidebar: React.FC = () => { + const location = useLocation(); + const sidebarView = getSidebarViewForRoute(location.pathname); + const agentIdFromPath = getAgentIdFromPath(location.pathname); + const [, setStoredAgentId] = useLocalStorage(storageKeys.agentId); + + useEffect(() => { + if (agentIdFromPath) { + setStoredAgentId(agentIdFromPath); + } + }, [agentIdFromPath, setStoredAgentId]); + + const sidebarStyles = css` + width: ${SIDEBAR_WIDTH}px; + min-width: ${SIDEBAR_WIDTH}px; + height: 100%; + border-radius: 0; + `; + + return ( + + {sidebarView === 'conversation' && } + {sidebarView === 'agentSettings' && } + {sidebarView === 'manage' && } + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/agent_settings_view.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/agent_settings_view.tsx new file mode 100644 index 0000000000000..443e1ac7651e1 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/agent_settings_view.tsx @@ -0,0 +1,90 @@ +/* + * 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 { Link } from 'react-router-dom-v5-compat'; + +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiHorizontalRule, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; + +import { appPaths } from '../../../../utils/app_paths'; +import { getAgentIdFromPath, getAgentSettingsNavItems } from '../../../../route_config'; +import { AgentSelector } from '../agent_selector'; + +const labels = { + back: i18n.translate('xpack.agentBuilder.sidebar.agentSettings.back', { + defaultMessage: '← Back', + }), + title: i18n.translate('xpack.agentBuilder.sidebar.agentSettings.title', { + defaultMessage: 'Agent Settings', + }), +}; + +interface AgentSettingsSidebarViewProps { + pathname: string; +} + +export const AgentSettingsSidebarView: React.FC = ({ pathname }) => { + const agentId = getAgentIdFromPath(pathname) ?? 'elastic-ai-agent'; + const { euiTheme } = useEuiTheme(); + + const linkStyles = css` + text-decoration: none; + color: inherit; + &:hover { + text-decoration: underline; + } + `; + + const activeLinkStyles = css` + ${linkStyles} + font-weight: ${euiTheme.font.weight.bold}; + color: ${euiTheme.colors.primaryText}; + `; + + const getNavigationPath = useCallback( + (newAgentId: string) => pathname.replace(`/agents/${agentId}`, `/agents/${newAgentId}`), + [pathname, agentId] + ); + + const navItems = useMemo(() => { + return getAgentSettingsNavItems(agentId); + }, [agentId]); + + const isActive = (path: string) => pathname === path; + + return ( + + + + {labels.back} + + + + + + + + + + + + {labels.title} + + + + {navItems.map((item) => ( + + + {item.label} + + + ))} + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/conversation_view.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/conversation_view.tsx new file mode 100644 index 0000000000000..f9e8ce86d5b0d --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/conversation_view.tsx @@ -0,0 +1,87 @@ +/* + * 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 { Link } from 'react-router-dom-v5-compat'; + +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiHorizontalRule } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; + +import { appPaths } from '../../../../utils/app_paths'; +import { getAgentIdFromPath } from '../../../../route_config'; +import { AgentSelector } from '../agent_selector'; + +const labels = { + customize: i18n.translate('xpack.agentBuilder.sidebar.conversation.customize', { + defaultMessage: 'Customize', + }), + conversationsTitle: i18n.translate('xpack.agentBuilder.sidebar.conversation.conversationsTitle', { + defaultMessage: 'Conversations', + }), + newConversation: i18n.translate('xpack.agentBuilder.sidebar.conversation.newConversation', { + defaultMessage: '+ New conversation', + }), + manageComponents: i18n.translate('xpack.agentBuilder.sidebar.conversation.manageComponents', { + defaultMessage: 'Manage components', + }), +}; + +interface ConversationSidebarViewProps { + pathname: string; +} + +export const ConversationSidebarView: React.FC = ({ pathname }) => { + const agentId = getAgentIdFromPath(pathname) ?? 'elastic-ai-agent'; + + const linkStyles = css` + text-decoration: none; + color: inherit; + &:hover { + text-decoration: underline; + } + `; + + const getNavigationPath = useCallback( + (newAgentId: string) => appPaths.agent.root({ agentId: newAgentId }), + [] + ); + + return ( + + + + + + {labels.customize} + + + + + + + + {labels.conversationsTitle} + + + + + + {labels.newConversation} + + + + + + + + {labels.manageComponents} + + + + ); +}; 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..7b913a38d6d35 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/layout/unified_sidebar/views/manage_view.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, { useMemo } from 'react'; +import { Link } from 'react-router-dom-v5-compat'; + +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiHorizontalRule, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; + +import { appPaths } from '../../../../utils/app_paths'; +import { useLastAgentId } from '../../../../hooks/use_last_agent_id'; +import { useExperimentalFeatures } from '../../../../hooks/use_experimental_features'; +import { getManageNavItems } from '../../../../route_config'; + +const labels = { + back: i18n.translate('xpack.agentBuilder.sidebar.manage.back', { + defaultMessage: '← Back', + }), + title: i18n.translate('xpack.agentBuilder.sidebar.manage.title', { + defaultMessage: 'Manage Components', + }), +}; + +interface ManageSidebarViewProps { + pathname: string; +} + +export const ManageSidebarView: React.FC = ({ pathname }) => { + const lastAgentId = useLastAgentId(); + const { euiTheme } = useEuiTheme(); + const isExperimentalFeaturesEnabled = useExperimentalFeatures(); + + const linkStyles = css` + text-decoration: none; + color: inherit; + &:hover { + text-decoration: underline; + } + `; + + const activeLinkStyles = css` + ${linkStyles} + font-weight: ${euiTheme.font.weight.bold}; + color: ${euiTheme.colors.primaryText}; + `; + + const isActive = (path: string) => pathname.startsWith(path); + + const navItems = useMemo(() => { + return getManageNavItems().filter((item) => { + if (item.isExperimental && !isExperimentalFeaturesEnabled) { + return false; + } + return true; + }); + }, [isExperimentalFeaturesEnabled]); + + return ( + + + + {labels.back} + + + + + + + + {labels.title} + + + + {navItems.map((item) => ( + + + {item.label} + + + ))} + + ); +}; 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..ff39458cf86b2 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/redirects/legacy_conversation_redirect.tsx @@ -0,0 +1,68 @@ +/* + * 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.root({ agentId: lastAgentId }), { 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/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/route_config.test.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/route_config.test.ts new file mode 100644 index 0000000000000..2612a536d16d7 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/route_config.test.ts @@ -0,0 +1,105 @@ +/* + * 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 "agentSettings" for instructions route', () => { + expect(getSidebarViewForRoute('/agents/elastic-ai-agent/instructions')).toBe( + 'agentSettings' + ); + }); + + it('returns "agentSettings" for skills route', () => { + expect(getSidebarViewForRoute('/agents/elastic-ai-agent/skills')).toBe('agentSettings'); + }); + + it('returns "agentSettings" for tools route', () => { + expect(getSidebarViewForRoute('/agents/elastic-ai-agent/tools')).toBe('agentSettings'); + }); + + it('returns "agentSettings" for plugins route', () => { + expect(getSidebarViewForRoute('/agents/elastic-ai-agent/plugins')).toBe('agentSettings'); + }); + + it('returns "agentSettings" for connectors route', () => { + expect(getSidebarViewForRoute('/agents/elastic-ai-agent/connectors')).toBe('agentSettings'); + }); + }); + + 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..8fe7002b1fe96 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/route_config.tsx @@ -0,0 +1,194 @@ +/* + * 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'; + +export type SidebarView = 'conversation' | 'agentSettings' | 'manage'; + +export interface RouteDefinition { + path: string; + element: React.ReactNode; + sidebarView: SidebarView; + isExperimental?: boolean; + navLabel?: string; +} + +const navLabels = { + instructions: i18n.translate('xpack.agentBuilder.routeConfig.instructions', { + defaultMessage: 'Instructions', + }), + 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/instructions', + sidebarView: 'agentSettings', + navLabel: navLabels.instructions, + element: , + }, + { + path: '/agents/:agentId/skills', + sidebarView: 'agentSettings', + navLabel: navLabels.skills, + element: , + }, + { + path: '/agents/:agentId/plugins', + sidebarView: 'agentSettings', + navLabel: navLabels.plugins, + element: , + }, + { + path: '/agents/:agentId/connectors', + sidebarView: 'agentSettings', + navLabel: navLabels.connectors, + 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/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: , + }, + { + 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', + navLabel: navLabels.plugins, + element: , + }, + { + path: '/manage/plugins/:pluginId', + sidebarView: 'manage', + element: , + }, + { + path: '/manage/connectors', + sidebarView: 'manage', + navLabel: navLabels.connectors, + 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 getAgentSettingsNavItems = ( + agentId: string +): Array<{ label: string; path: string }> => { + return agentRoutes + .filter((route) => route.navLabel && route.sidebarView === 'agentSettings') + .map((route) => ({ + label: route.navLabel!, + path: route.path.replace(':agentId', agentId), + })); +}; + +export const getManageNavItems = (): Array<{ + label: string; + path: string; + isExperimental?: boolean; +}> => { + return manageRoutes + .filter((route) => route.navLabel) + .map((route) => ({ + label: route.navLabel!, + path: route.path, + isExperimental: route.isExperimental, + })); +}; 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 7d2388a6e8054..e2a2425c12bf7 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,88 +6,45 @@ */ import { Route, Routes } from '@kbn/shared-ux-router'; -import React from 'react'; -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 React, { useMemo } from 'react'; + +import { AppLayout } from './components/layout/app_layout'; +import { RootRedirect } from './components/redirects/root_redirect'; +import { LegacyConversationRedirect } from './components/redirects/legacy_conversation_redirect'; +import { allRoutes } from './route_config'; import { useExperimentalFeatures } from './hooks/use_experimental_features'; export const AgentBuilderRoutes: React.FC<{}> = () => { const isExperimentalFeaturesEnabled = useExperimentalFeatures(); - return ( - - - - - - - - - - - - - - - - - - - - + const enabledRoutes = useMemo(() => { + return allRoutes.filter((route) => { + if (route.isExperimental && !isExperimentalFeaturesEnabled) { + return false; + } + return true; + }); + }, [isExperimentalFeaturesEnabled]); - - - - - - - - - - - - - {isExperimentalFeaturesEnabled - ? [ - - - , - - - , - - - , - ] - : null} - - {isExperimentalFeaturesEnabled - ? [ - - - , - - - , - ] - : null} - - {/* Default to conversations page */} - - - - + return ( + + + {enabledRoutes.map((route) => ( + + {route.element} + + ))} + + {/* Legacy routes - redirect to new structure */} + + + + + {/* Root route - redirect to last used agent */} + + + + + ); }; 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 d7f910bbed71e..7d7888e1fafa1 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,35 +9,35 @@ import { newConversationId } from './new_conversation'; export const appPaths = { root: '/', - agents: { - list: '/agents', - new: '/agents/new', - edit: ({ agentId }: { agentId: string }) => { - return `/agents/${agentId}`; - }, - }, - chat: { - new: `/conversations/${newConversationId}`, - newWithAgent: ({ agentId }: { agentId: string }) => { - return `/conversations/${newConversationId}?agent_id=${agentId}`; - }, - conversation: ({ conversationId }: { conversationId: string }) => { - return `/conversations/${conversationId}`; + + // 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`, + connectors: ({ agentId }: { agentId: string }) => `/agents/${agentId}/connectors`, + instructions: ({ agentId }: { agentId: string }) => `/agents/${agentId}/instructions`, }, - tools: { - list: '/tools', - new: '/tools/new', - details: ({ toolId }: { toolId: string }) => `/tools/${toolId}`, - bulkImportMcp: '/tools/bulk_import_mcp', - }, - skills: { - list: '/skills', - new: '/skills/new', - details: ({ skillId }: { skillId: string }) => `/skills/${skillId}`, - }, - plugins: { - list: '/plugins', - details: ({ pluginId }: { pluginId: string }) => `/plugins/${pluginId}`, + + // Manage routes (global CRUD, no agent context) + manage: { + agents: '/manage/agents', + agentsNew: '/manage/agents/new', + 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', }, };