diff --git a/frontend/src/Navigation.tsx b/frontend/src/Navigation.tsx index b1f120e0..89184f39 100644 --- a/frontend/src/Navigation.tsx +++ b/frontend/src/Navigation.tsx @@ -12,6 +12,11 @@ import { Settings as SettingsIcon, } from 'lucide-react'; +import { + AGENTS_MANAGE_ROOT, + agentChatPath, + sharedAgentPath, +} from './agents/paths'; import { Agent } from './agents/types'; import conversationService from './api/services/conversationService'; import userService from './api/services/userService'; @@ -42,7 +47,7 @@ import { ActiveState } from './models/misc'; import { getConversations } from './preferences/preferenceApi'; import SectionNav from './navigation/SectionNav'; import SectionRail from './navigation/SectionRail'; -import { useActiveSection } from './navigation/sections'; +import { useSectionContext } from './navigation/useSectionContext'; import { useLastAppPath } from './navigation/useLastAppPath'; import { selectAgents, @@ -90,14 +95,19 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { // Section state is derived from the route, so deep links and the browser // back button keep working without a second source of truth. const { section: activeSection, item: activeSectionItem } = - useActiveSection(); + useSectionContext(); const lastAppPath = useLastAppPath(); const inSection = Boolean(activeSection); - const backToAppLabel = t('navigation.backToApp'); + + // Sections nest, so back means "up one level": out of an agent lands on + // the agent list, and out of a top-level section lands back in the app. + const backLabel = activeSection?.parentLabelKey + ? t(activeSection.parentLabelKey) + : t('navigation.backToApp'); const exitSection = () => { if (isMobile || isTablet) setNavOpen(false); - navigate(lastAppPath.current || '/'); + navigate(activeSection?.parentPath ?? lastAppPath.current ?? '/'); }; const closeNavOnMobile = () => { @@ -224,7 +234,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { resetConversation(); dispatch(setSelectedAgent(agent)); if (isMobile || isTablet) setNavOpen(!navOpen); - navigate(agent.id ? `/agents/${agent.id}/c/new` : '/c/new'); + navigate(agent.id ? agentChatPath(agent.id) : '/c/new'); }; const handleTogglePin = (agent: Agent) => { @@ -271,7 +281,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { return; } agent = await sharedResponse.json(); - navigate(`/agents/shared/${agent.shared_token}`); + navigate(sharedAgentPath(agent.shared_token)); } else { const agentResponse = await userService.getAgent(data.agent_id, token); if (!agentResponse.ok) { @@ -280,10 +290,10 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { } agent = await agentResponse.json(); if (agent.shared_token) { - navigate(`/agents/shared/${agent.shared_token}`); + navigate(sharedAgentPath(agent.shared_token)); } else { await Promise.resolve(dispatch(setSelectedAgent(agent))); - navigate(`/agents/${data.agent_id}/c/${index}`); + navigate(agentChatPath(data.agent_id, index)); } } } catch (error) { @@ -362,7 +372,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { activeItemKey={activeSectionItem?.key} isAdmin={isAdmin} onBack={exitSection} - backLabel={backToAppLabel} + backLabel={backLabel} /> ) : ( <> @@ -384,7 +394,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { size="icon" onClick={() => { dispatch(setSelectedAgent(null)); - navigate('/agents'); + navigate(AGENTS_MANAGE_ROOT); }} aria-label={t('manageAgents')} className="text-muted-foreground hover:text-foreground" @@ -567,7 +577,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { ))} { dispatch(setSelectedAgent(null)); @@ -596,7 +606,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { ) : ( { if (isMobile || isTablet) { @@ -684,7 +694,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { activeItemKey={activeSectionItem?.key} isAdmin={isAdmin} onBack={exitSection} - backLabel={backToAppLabel} + backLabel={backLabel} onNavigate={closeNavOnMobile} /> )} diff --git a/frontend/src/admin/index.tsx b/frontend/src/admin/index.tsx index 2354661b..f8ad6157 100644 --- a/frontend/src/admin/index.tsx +++ b/frontend/src/admin/index.tsx @@ -2,8 +2,8 @@ import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; import { useMediaQuery } from '../hooks'; import SectionIndexPage from '../navigation/SectionIndexPage'; -import SectionPageHeader from '../navigation/SectionPageHeader'; -import { ADMIN_SECTION, getActiveItem } from '../navigation/sections'; +import { CurrentSectionHeader } from '../navigation/SectionPageHeader'; +import { ADMIN_SECTION } from '../navigation/sections'; import Admins from './Admins'; import Audit from './Audit'; import Overview from './Overview'; @@ -20,7 +20,6 @@ export default function Admin() { const location = useLocation(); const { isMobile, isTablet } = useMediaQuery(); - const activeItem = getActiveItem(ADMIN_SECTION, location.pathname); const showIndex = (isMobile || isTablet) && location.pathname === ADMIN_SECTION.rootPath; @@ -31,7 +30,7 @@ export default function Admin() { ) : ( <> - + } /> } /> diff --git a/frontend/src/agents/AgentCard.tsx b/frontend/src/agents/AgentCard.tsx index 64ae3dc3..d2df7efd 100644 --- a/frontend/src/agents/AgentCard.tsx +++ b/frontend/src/agents/AgentCard.tsx @@ -44,6 +44,12 @@ import { setAgents, setSelectedAgent, } from '../preferences/preferenceSlice'; +import { + agentChatPath, + agentEditPath, + agentLogsPath, + sharedAgentPath, +} from './paths'; import { Agent } from './types'; type AgentCardProps = { @@ -91,7 +97,7 @@ export default function AgentCard({ label: 'Logs', onClick: (e: SyntheticEvent) => { e.stopPropagation(); - navigate(`/agents/logs/${agent.id}`); + navigate(agentLogsPath(agent.id)); }, variant: 'default', iconWidth: 14, @@ -103,9 +109,9 @@ export default function AgentCard({ onClick: (e: SyntheticEvent) => { e.stopPropagation(); if (agent.agent_type === 'workflow') { - navigate(`/agents/workflow/edit/${agent.id}`); + navigate(agentEditPath(agent.id, true)); } else { - navigate(`/agents/edit/${agent.id}`); + navigate(agentEditPath(agent.id)); } }, variant: 'default', @@ -189,9 +195,9 @@ export default function AgentCard({ onClick: (e: SyntheticEvent) => { e.stopPropagation(); if (agent.agent_type === 'workflow') { - navigate(`/agents/workflow/edit/${agent.id}`); + navigate(agentEditPath(agent.id, true)); } else { - navigate(`/agents/edit/${agent.id}`); + navigate(agentEditPath(agent.id)); } }, variant: 'default', @@ -220,7 +226,7 @@ export default function AgentCard({ label: 'Open', onClick: (e: SyntheticEvent) => { e.stopPropagation(); - navigate(`/agents/shared/${agent.shared_token}`); + navigate(sharedAgentPath(agent.shared_token)); }, variant: 'default', iconWidth: 12, @@ -258,11 +264,11 @@ export default function AgentCard({ if (section === 'user' || section === 'team') { if (agent.status === 'published') { dispatch(setSelectedAgent(agent)); - navigate(agent.id ? `/agents/${agent.id}/c/new` : '/c/new'); + navigate(agent.id ? agentChatPath(agent.id) : '/c/new'); } } if (section === 'shared') { - navigate(`/agents/shared/${agent.shared_token}`); + navigate(sharedAgentPath(agent.shared_token)); } }; diff --git a/frontend/src/agents/AgentLogs.tsx b/frontend/src/agents/AgentLogs.tsx index 9fbe165e..85ff3485 100644 --- a/frontend/src/agents/AgentLogs.tsx +++ b/frontend/src/agents/AgentLogs.tsx @@ -9,7 +9,8 @@ import { selectToken } from '../preferences/preferenceSlice'; import Analytics from '../settings/Analytics'; import Logs from '../settings/Logs'; import { formatDateTime } from '../utils/dateTimeUtils'; -import AgentPageHeader from './AgentPageHeader'; +import { CurrentSectionHeader } from '../navigation/SectionPageHeader'; +import SectionPills from '../navigation/SectionPills'; import GuardrailEvents from './components/GuardrailEvents'; import { Agent } from './types'; @@ -39,51 +40,46 @@ export default function AgentLogs() { if (agentId) fetchAgent(agentId); }, [agentId, token]); - const agentEditPath = - agent?.agent_type === 'workflow' - ? `/agents/workflow/edit/${agentId}` - : `/agents/edit/${agentId}`; - return ( -
- -
- {agent && ( -
-

{agent.name}

-

- {agent.last_used_at - ? t('agents.logs.lastUsedAt') + - ' ' + - formatDateTime(agent.last_used_at) - : t('agents.logs.noUsageHistory')} -

+
+
+ + +
+ {agent && ( +
+

{agent.name}

+

+ {agent.last_used_at + ? t('agents.logs.lastUsedAt') + + ' ' + + formatDateTime(agent.last_used_at) + : t('agents.logs.noUsageHistory')} +

+
+ )} +
+ {loadingAgent ? ( +
+
+ ) : ( + agent && + )} + {!loadingAgent && agent && } + {loadingAgent ? ( +
+ +
+ ) : ( + agent && ( + + ) )}
- {loadingAgent ? ( -
- -
- ) : ( - agent && - )} - {!loadingAgent && agent && } - {loadingAgent ? ( -
- -
- ) : ( - agent && ( - - ) - )}
); } diff --git a/frontend/src/agents/AgentPageHeader.tsx b/frontend/src/agents/AgentPageHeader.tsx index 6d693f58..77005df1 100644 --- a/frontend/src/agents/AgentPageHeader.tsx +++ b/frontend/src/agents/AgentPageHeader.tsx @@ -12,6 +12,13 @@ import { } from '@/components/ui/breadcrumb'; import { cn } from '@/lib/utils'; +import { + AGENTS_MANAGE_ROOT, + agentEditPath as agentEditPathProp, + agentLogsPath, + agentSchedulesPath, +} from './paths'; + export type AgentPageTab = 'overview' | 'logs' | 'schedules'; type AgentPageHeaderProps = { @@ -47,7 +54,8 @@ export default function AgentPageHeader({ const { t } = useTranslation(); const editPath = - agentEditPath ?? (agentId ? `/agents/edit/${agentId}` : '/agents'); + agentEditPath ?? + (agentId ? agentEditPathProp(agentId) : AGENTS_MANAGE_ROOT); const tabs = useMemo( () => [ { @@ -58,12 +66,12 @@ export default function AgentPageHeader({ { id: 'logs' as const, label: t('agents.pageHeader.tabs.logs'), - href: agentId ? `/agents/logs/${agentId}` : '#', + href: agentId ? agentLogsPath(agentId) : '#', }, { id: 'schedules' as const, label: t('agents.pageHeader.tabs.schedules'), - href: agentId ? `/agents/schedules/${agentId}` : '#', + href: agentId ? agentSchedulesPath(agentId) : '#', }, ], [agentId, editPath, t], @@ -84,7 +92,9 @@ export default function AgentPageHeader({ - {t('agents.pageHeader.crumbs.agents')} + + {t('agents.pageHeader.crumbs.agents')} + diff --git a/frontend/src/agents/AgentsList.tsx b/frontend/src/agents/AgentsList.tsx index 446e8295..4288ec21 100644 --- a/frontend/src/agents/AgentsList.tsx +++ b/frontend/src/agents/AgentsList.tsx @@ -1,5 +1,12 @@ import { Search } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useSearchParams } from 'react-router-dom'; @@ -24,18 +31,21 @@ import AgentCard from './AgentCard'; import { AgentSectionId, agentSectionsConfig } from './agents.config'; import AgentTypeModal from './components/AgentTypeModal'; import FolderCard from './FolderCard'; -import { AgentFilterTab, useAgentSearch } from './hooks/useAgentSearch'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '../components/ui/breadcrumb'; +import { CurrentSectionHeader } from '../navigation/SectionPageHeader'; +import SectionPills from '../navigation/SectionPills'; +import { useAgentSearch } from './hooks/useAgentSearch'; +import { agentsListPath, filterFromPath } from './paths'; import { useAgentsFetch } from './hooks/useAgentsFetch'; import { Agent, AgentFolder } from './types'; -const FILTER_TABS: { id: AgentFilterTab; labelKey: string }[] = [ - { id: 'all', labelKey: 'agents.filters.all' }, - { id: 'template', labelKey: 'agents.filters.byDocsGPT' }, - { id: 'user', labelKey: 'agents.filters.byMe' }, - { id: 'team', labelKey: 'agents.filters.team' }, - { id: 'shared', labelKey: 'agents.filters.shared' }, -]; - export default function AgentsList() { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -59,25 +69,27 @@ export default function AgentsList() { if (currentFolderId !== currentFolderInUrl) { const newUrl = currentFolderId - ? `/agents?folder=${currentFolderId}` - : '/agents'; + ? agentsListPath(currentFolderId) + : agentsListPath(); navigate(newUrl, { replace: true }); } }, [folderPath, searchParams, navigate]); const { isLoading, refetchFolders, refetchUserAgents } = useAgentsFetch(); + // The list's filter is a route, so it is linkable and survives a reload; + // the sidebar nav (and the pill row below `lg`) does the navigating. + const activeFilter = filterFromPath(location.pathname); + const { searchQuery, setSearchQuery, - activeFilter, - setActiveFilter, filteredAgentsBySection, totalAgentsBySection, hasAnyAgents, hasFilteredResults, isDataLoaded, - } = useAgentSearch(); + } = useAgentSearch(activeFilter); useEffect(() => { dispatch(setConversation([])); @@ -162,87 +174,73 @@ export default function AgentsList() { activeFilter === 'all'; return ( -
-

- {t('agents.title')} -

-

- {t('agents.description')} -

+
+
+ +

+ {t('agents.description')} +

-
-
- setSearchQuery(e.target.value)} - label={t('agents.searchPlaceholder')} - labelBgClassName="bg-background" - className="rounded-full" - leftIcon={ - + + +
+
+ setSearchQuery(e.target.value)} + label={t('agents.searchPlaceholder')} + labelBgClassName="bg-background" + className="rounded-full" + leftIcon={ + + } + /> +
+
+ + {visibleSections.map((sectionConfig) => ( + -
+ ))} -
- {FILTER_TABS.map((tab) => ( - - ))} -
-
+ {showSearchEmptyState && ( +
+

{t('agents.noSearchResults')}

+

{t('agents.tryDifferentSearch')}

+
+ )} - {visibleSections.map((sectionConfig) => ( - setShowAgentTypeModal(false)} + folderId={modalFolderId} /> - ))} - - {showSearchEmptyState && ( -
-

{t('agents.noSearchResults')}

-

{t('agents.tryDifferentSearch')}

-
- )} - - setShowAgentTypeModal(false)} - folderId={modalFolderId} - /> +
); } @@ -444,61 +442,51 @@ function AgentSection({ }); }, [folders, folderPath]); - const ChevronIcon = () => ( - - - - ); - return (
-

- {config.id === 'user' && folderPath.length > 0 ? ( - <> - + {config.id === 'user' && breadcrumbItems.length > 0 ? ( + // Drilling into a folder is a trail, not a back button — the + // sidebar's back is the only thing that means "leave". + + + + + + + {breadcrumbItems.map((item, index) => ( - - - {index === breadcrumbItems.length - 1 ? ( - {item.name} - ) : ( - - )} - + + + + {index === breadcrumbItems.length - 1 ? ( + {item.name} + ) : ( + + + + )} + + ))} - - ) : ( - t(`agents.sections.${config.id}.title`) - )} -

+ + + ) : ( +

+ {t(`agents.sections.${config.id}.title`)} +

+ )}

{t(`agents.sections.${config.id}.description`)}

diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index 72238d7e..c071bf8f 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -69,7 +69,9 @@ import { getToolDisplayName, isClassicAgentToolVisible, } from '../utils/toolUtils'; -import AgentPageHeader from './AgentPageHeader'; +import { CurrentSectionHeader } from '../navigation/SectionPageHeader'; +import { agentsListPath } from './paths'; +import SectionPills from '../navigation/SectionPills'; import GuardrailsSection, { guardrailsIncomplete, } from './components/GuardrailsSection'; @@ -316,8 +318,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { const navigateBackToAgents = useCallback(() => { const targetPath = validatedFolderId - ? `/agents?folder=${validatedFolderId}` - : '/agents'; + ? agentsListPath(validatedFolderId) + : agentsListPath(); navigate(targetPath); }, [navigate, validatedFolderId]); @@ -675,7 +677,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { const getAgent = async () => { const response = await userService.getAgent(agentId, token); if (!response.ok) { - navigate('/agents'); + navigate(agentsListPath()); throw new Error('Failed to fetch agent'); } const data = await response.json(); @@ -797,23 +799,14 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { const showAgentNav = effectiveMode === 'edit' && Boolean(agent.id); return ( -
+
{agent.agent_type === 'workflow' && (
)} -
- {showAgentNav ? ( - - ) : ( - - )} +
+ {showAgentNav ? : }
{submitError && (
+ {showAgentNav && }
diff --git a/frontend/src/agents/SharedAgentGate.tsx b/frontend/src/agents/SharedAgentGate.tsx index 877b6b1f..0bfbc2e4 100644 --- a/frontend/src/agents/SharedAgentGate.tsx +++ b/frontend/src/agents/SharedAgentGate.tsx @@ -1,7 +1,9 @@ import { Navigate, useParams } from 'react-router-dom'; +import { sharedAgentPath } from './paths'; + export default function SharedAgentGate() { const { agentId } = useParams(); - return ; + return ; } diff --git a/frontend/src/agents/components/AgentTypeModal.tsx b/frontend/src/agents/components/AgentTypeModal.tsx index 1dc21699..ed219b00 100644 --- a/frontend/src/agents/components/AgentTypeModal.tsx +++ b/frontend/src/agents/components/AgentTypeModal.tsx @@ -1,4 +1,5 @@ import { Bot, Workflow } from 'lucide-react'; +import { agentNewPath } from '../paths'; import { useNavigate } from 'react-router-dom'; import { Modal } from '../../components/ui/modal'; @@ -18,11 +19,9 @@ export default function AgentTypeModal({ const handleSelect = (type: 'normal' | 'workflow') => { if (type === 'workflow') { - navigate( - `/agents/workflow/new${folderId ? `?folder_id=${folderId}` : ''}`, - ); + navigate(agentNewPath({ workflow: true, folderId })); } else { - navigate(`/agents/new${folderId ? `?folder_id=${folderId}` : ''}`); + navigate(agentNewPath({ folderId })); } onClose(); }; diff --git a/frontend/src/agents/hooks/useAgentSearch.ts b/frontend/src/agents/hooks/useAgentSearch.ts index 39819fd0..2b63d6c4 100644 --- a/frontend/src/agents/hooks/useAgentSearch.ts +++ b/frontend/src/agents/hooks/useAgentSearch.ts @@ -16,8 +16,6 @@ export type AgentsBySection = Record; interface UseAgentSearchResult { searchQuery: string; setSearchQuery: (query: string) => void; - activeFilter: AgentFilterTab; - setActiveFilter: (filter: AgentFilterTab) => void; filteredAgentsBySection: AgentsBySection; totalAgentsBySection: Record; hasAnyAgents: boolean; @@ -40,9 +38,15 @@ const filterAgentsByQuery = ( ); }; -export function useAgentSearch(): UseAgentSearchResult { +/** + * Search and filtering for the agent list. The filter is passed in rather + * than held here: it lives in the route, so it stays linkable and survives a + * reload. + */ +export function useAgentSearch( + activeFilter: AgentFilterTab = 'all', +): UseAgentSearchResult { const [searchQuery, setSearchQuery] = useState(''); - const [activeFilter, setActiveFilter] = useState('all'); const templateAgents = useSelector(selectTemplateAgents); const allUserAgents = useSelector(selectAgents); @@ -71,10 +75,6 @@ export function useAgentSearch(): UseAgentSearchResult { setSearchQuery(query); }, []); - const handleFilterChange = useCallback((filter: AgentFilterTab) => { - setActiveFilter(filter); - }, []); - const isDataLoaded = useMemo( (): Record => ({ template: templateAgents !== null, @@ -143,8 +143,6 @@ export function useAgentSearch(): UseAgentSearchResult { return { searchQuery, setSearchQuery: handleSearchChange, - activeFilter, - setActiveFilter: handleFilterChange, filteredAgentsBySection, totalAgentsBySection, hasAnyAgents, diff --git a/frontend/src/agents/index.tsx b/frontend/src/agents/index.tsx index 1ed0072a..380fd188 100644 --- a/frontend/src/agents/index.tsx +++ b/frontend/src/agents/index.tsx @@ -1,23 +1,56 @@ -import { Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; import AgentLogs from './AgentLogs'; import AgentsList from './AgentsList'; import NewAgent from './NewAgent'; +import { AGENTS_MANAGE_ROOT } from './paths'; import SchedulesView from './schedules/SchedulesView'; import SharedAgent from './SharedAgent'; import WorkflowBuilder from './workflow/WorkflowBuilder'; +/** + * Sends a pre-split management URL to its `/agents/manage` equivalent, + * keeping the rest of the path and any query. Bookmarks and older links + * (including the e2e specs' direct `goto`s) keep working. + */ +function LegacyManageRedirect() { + const location = useLocation(); + const suffix = location.pathname.replace(/^\/agents/, ''); + return ( + + ); +} + export default function Agents() { return ( - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* Managing agents. */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + + {/* Using an agent someone shared. */} + } /> + + {/* Pre-split URLs. `/agents` keeps its `?folder=` on the way through. */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> ); } diff --git a/frontend/src/agents/paths.test.ts b/frontend/src/agents/paths.test.ts new file mode 100644 index 00000000..b45bcb1d --- /dev/null +++ b/frontend/src/agents/paths.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { + agentChatPath, + agentEditPathFor, + agentsFilterPath, + agentsListPath, + filterFromPath, + matchAgentScopedRoute, +} from './paths'; + +describe('agentsFilterPath / filterFromPath', () => { + it('round-trips every filter', () => { + for (const filter of ['all', 'template', 'user', 'team', 'shared'] as const) + expect(filterFromPath(agentsFilterPath(filter))).toBe(filter); + }); + + it('treats the list root and unknown slugs as unfiltered', () => { + expect(filterFromPath('/agents/manage')).toBe('all'); + expect(filterFromPath('/agents/manage/nonsense')).toBe('all'); + }); + + it('does not read a filter out of an agent page', () => { + expect(filterFromPath('/agents/manage/edit/a1')).toBe('all'); + }); +}); + +describe('agentsListPath', () => { + it('encodes a folder id into the query', () => { + expect(agentsListPath()).toBe('/agents/manage'); + expect(agentsListPath('a b')).toBe('/agents/manage?folder=a%20b'); + expect(agentsListPath(null)).toBe('/agents/manage'); + }); +}); + +describe('agentEditPathFor', () => { + it('sends workflow agents to the builder', () => { + expect(agentEditPathFor({ id: 'a1', agent_type: 'workflow' })).toBe( + '/agents/manage/workflow/edit/a1', + ); + expect(agentEditPathFor({ id: 'a1', agent_type: 'classic' })).toBe( + '/agents/manage/edit/a1', + ); + }); +}); + +describe('matchAgentScopedRoute', () => { + it('recognises each of an agent’s pages', () => { + expect(matchAgentScopedRoute('/agents/manage/edit/a1')).toEqual({ + agentId: 'a1', + page: 'overview', + workflow: false, + }); + expect(matchAgentScopedRoute('/agents/manage/logs/a1')?.page).toBe('logs'); + expect(matchAgentScopedRoute('/agents/manage/schedules/a1')?.page).toBe( + 'schedules', + ); + expect(matchAgentScopedRoute('/agents/manage/workflow/edit/a1')).toEqual({ + agentId: 'a1', + page: 'overview', + workflow: true, + }); + }); + + it('ignores the list, and a chat with an agent', () => { + expect(matchAgentScopedRoute('/agents/manage')).toBeNull(); + expect(matchAgentScopedRoute('/agents/manage/mine')).toBeNull(); + expect(matchAgentScopedRoute('/agents/manage/new')).toBeNull(); + // The whole point of the split: this is a conversation, not an editor. + expect(matchAgentScopedRoute(agentChatPath('a1', 'c1'))).toBeNull(); + }); +}); diff --git a/frontend/src/agents/paths.ts b/frontend/src/agents/paths.ts new file mode 100644 index 00000000..1194206b --- /dev/null +++ b/frontend/src/agents/paths.ts @@ -0,0 +1,115 @@ +import type { AgentFilterTab } from './hooks/useAgentSearch'; +import type { Agent } from './types'; + +/** + * Routes under `/agents` cover two different things: *using* an agent (a + * conversation) and *managing* them (the list, the editor, logs, schedules). + * They used to share the `/agents/…` prefix, which left no way to tell them + * apart from the pathname — so the sidebar could not react to one without + * also reacting to the other. Management now lives under `/agents/manage`. + * + * Every caller builds its links from here rather than from a literal, so the + * next move costs one edit instead of thirty. + */ + +/** Root of the management mode; also the agents section's root path. */ +export const AGENTS_MANAGE_ROOT = '/agents/manage'; + +/** URL segment for each list filter. `all` is the root, so it has none. */ +const FILTER_SLUGS: Record, string> = { + template: 'templates', + user: 'mine', + team: 'team', + // Not `shared`: `/agents/shared/:token` already means "open this public + // agent", and two different meanings under one word invites mistakes. + shared: 'discovered', +}; + +const SLUG_FILTERS = Object.fromEntries( + Object.entries(FILTER_SLUGS).map(([filter, slug]) => [slug, filter]), +) as Record; + +export const isWorkflowAgent = (agent: Pick): boolean => + agent.agent_type === 'workflow'; + +/** The agent list, optionally scoped to a folder. */ +export const agentsListPath = (folderId?: string | null): string => + folderId + ? `${AGENTS_MANAGE_ROOT}?folder=${encodeURIComponent(folderId)}` + : AGENTS_MANAGE_ROOT; + +/** The list narrowed to one filter; each filter is its own linkable route. */ +export const agentsFilterPath = (filter: AgentFilterTab): string => + filter === 'all' + ? AGENTS_MANAGE_ROOT + : `${AGENTS_MANAGE_ROOT}/${FILTER_SLUGS[filter]}`; + +/** Which filter a list route selects; `all` for anything unrecognised. */ +export const filterFromPath = (pathname: string): AgentFilterTab => { + const slug = pathname.startsWith(`${AGENTS_MANAGE_ROOT}/`) + ? pathname.slice(AGENTS_MANAGE_ROOT.length + 1).split('/')[0] + : ''; + return SLUG_FILTERS[slug] ?? 'all'; +}; + +export const agentNewPath = ( + options: { workflow?: boolean; folderId?: string | null } = {}, +): string => { + const base = options.workflow + ? `${AGENTS_MANAGE_ROOT}/workflow/new` + : `${AGENTS_MANAGE_ROOT}/new`; + return options.folderId + ? `${base}?folder_id=${encodeURIComponent(options.folderId)}` + : base; +}; + +export const agentEditPath = ( + agentId: string | undefined, + workflow = false, +): string => + workflow + ? `${AGENTS_MANAGE_ROOT}/workflow/edit/${agentId}` + : `${AGENTS_MANAGE_ROOT}/edit/${agentId}`; + +/** Edit path for an agent whose type decides which editor opens. */ +export const agentEditPathFor = ( + agent: Pick, +): string => agentEditPath(agent.id, isWorkflowAgent(agent)); + +export const agentLogsPath = (agentId: string | undefined): string => + `${AGENTS_MANAGE_ROOT}/logs/${agentId}`; + +export const agentSchedulesPath = (agentId: string | undefined): string => + `${AGENTS_MANAGE_ROOT}/schedules/${agentId}`; + +/** Using an agent, not managing it — deliberately outside `/agents/manage`. */ +export const agentChatPath = ( + agentId: string | undefined, + conversationId = 'new', +) => `/agents/${agentId}/c/${conversationId}`; + +export const sharedAgentPath = (sharedToken: string | undefined): string => + `/agents/shared/${sharedToken}`; + +export type AgentScopedPage = 'overview' | 'logs' | 'schedules'; + +const AGENT_SCOPED_ROUTE = new RegExp( + `^${AGENTS_MANAGE_ROOT}/(?:workflow/)?(edit|logs|schedules)/([^/]+)`, +); + +/** + * Whether a route is scoped to one agent, and which of its pages it is. + * Drives the per-agent sidebar nav. + */ +export function matchAgentScopedRoute( + pathname: string, +): { agentId: string; page: AgentScopedPage; workflow: boolean } | null { + const match = AGENT_SCOPED_ROUTE.exec(pathname); + if (!match) return null; + const [, segment, agentId] = match; + return { + agentId, + page: segment === 'edit' ? 'overview' : (segment as AgentScopedPage), + workflow: pathname.startsWith(`${AGENTS_MANAGE_ROOT}/workflow/`), + }; +} diff --git a/frontend/src/agents/schedules/SchedulesView.tsx b/frontend/src/agents/schedules/SchedulesView.tsx index bc755f22..e10cea5d 100644 --- a/frontend/src/agents/schedules/SchedulesView.tsx +++ b/frontend/src/agents/schedules/SchedulesView.tsx @@ -11,7 +11,8 @@ import { ActiveState } from '../../models/misc'; import { selectToken } from '../../preferences/preferenceSlice'; import type { AppDispatch, RootState } from '../../store'; import { formatDateTime } from '../../utils/dateTimeUtils'; -import AgentPageHeader from '../AgentPageHeader'; +import { CurrentSectionHeader } from '../../navigation/SectionPageHeader'; +import SectionPills from '../../navigation/SectionPills'; import type { Agent } from '../types'; import type { Schedule, @@ -146,190 +147,71 @@ export default function SchedulesView() { } }; - const agentEditPath = - agent?.agent_type === 'workflow' - ? `/agents/workflow/edit/${agentId}` - : `/agents/edit/${agentId}`; - return ( -
- -
- {agent && ( -
-

{agent.name}

-

- {agent.last_used_at - ? t('agents.logs.lastUsedAt') + - ' ' + - formatDateTime(agent.last_used_at) - : t('agents.logs.noUsageHistory')} -

-
- )} -
- {loadingAgent ? ( -
- +
+
+ + +
+ {agent && ( +
+

{agent.name}

+

+ {agent.last_used_at + ? t('agents.logs.lastUsedAt') + + ' ' + + formatDateTime(agent.last_used_at) + : t('agents.logs.noUsageHistory')} +

+
+ )}
- ) : ( - agent && ( -
-
-

- {t('agents.schedules.heading')} -

- -
-
-

- {t('agents.schedules.recurring')} ({recurring.length}) -

- {recurring.length === 0 ? ( -

- {t('agents.schedules.noRecurring')} -

- ) : ( -
    - {recurring.map((schedule) => ( -
  • -
    -
    -
    -

    - {schedule.name || - schedule.instruction.slice(0, 80)} -

    - -
    -

    - {formatCron(schedule.cron)} · tz:{' '} - {schedule.timezone} · next:{' '} - {formatTimestamp(schedule.next_run_at)} -

    -
    -
    - - - - -
    -
    - + +
    +

    + {t('agents.schedules.recurring')} ({recurring.length}) +

    + {recurring.length === 0 ? ( +

    + {t('agents.schedules.noRecurring')} +

    + ) : ( +
      + {recurring.map((schedule) => ( +
    • - {expanded === schedule.id - ? t('agents.schedules.hideRuns') - : t('agents.schedules.showRuns')} - - {expanded === schedule.id && ( -
      - setActiveRun(run)} - /> -
      - )} -
    • - ))} -
    - )} -
    -
    -

    - {t('agents.schedules.oneTime')} ({oneTime.length}) -

    - {oneTime.length === 0 ? ( -

    - {t('agents.schedules.noOneTime')} -

    - ) : ( -
      - {oneTime.map((schedule) => ( -
    • -
      -
      -
      -

      - {schedule.name || - schedule.instruction.slice(0, 80)} +

      +
      +
      +

      + {schedule.name || + schedule.instruction.slice(0, 80)} +

      + +
      +

      + {formatCron(schedule.cron)} · tz:{' '} + {schedule.timezone} · next:{' '} + {formatTimestamp(schedule.next_run_at)}

      -
      -

      - runs at {formatTimestamp(schedule.run_at)} -

      -
      -
      - {schedule.status === 'active' && ( +
      - )} - {schedule.status === 'active' && ( + + - )} +
      -
      -
    • - ))} -
    - )} -
    - setActiveRun(null)} - /> - {modalOpen && ( - + setExpanded( + expanded === schedule.id ? null : schedule.id, + ) + } + className="mt-2 h-auto p-0 text-xs underline" + > + {expanded === schedule.id + ? t('agents.schedules.hideRuns') + : t('agents.schedules.showRuns')} + + {expanded === schedule.id && ( +
    + setActiveRun(run)} + /> +
    + )} +
  • + ))} +
+ )} +
+
+

+ {t('agents.schedules.oneTime')} ({oneTime.length}) +

+ {oneTime.length === 0 ? ( +

+ {t('agents.schedules.noOneTime')} +

+ ) : ( +
    + {oneTime.map((schedule) => ( +
  • +
    +
    +
    +

    + {schedule.name || + schedule.instruction.slice(0, 80)} +

    + +
    +

    + runs at {formatTimestamp(schedule.run_at)} +

    +
    +
    + {schedule.status === 'active' && ( + + )} + {schedule.status === 'active' && ( + + )} +
    +
    +
  • + ))} +
+ )} +
+ setActiveRun(null)} /> - )} - setScheduleToDelete(null)} - variant="danger" - /> -
- ) - )} + {modalOpen && ( + + )} + setScheduleToDelete(null)} + variant="danger" + /> +
+ ) + )} +
); } diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx index d71867f6..a31bbf50 100644 --- a/frontend/src/agents/workflow/WorkflowBuilder.tsx +++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx @@ -65,6 +65,7 @@ import { selectToken, } from '../../preferences/preferenceSlice'; import { getToolDisplayName } from '../../utils/toolUtils'; +import { agentEditPath, agentsListPath } from '../paths'; import AgentPageHeader from '../AgentPageHeader'; import { Agent } from '../types'; import { ConditionCase, WorkflowNode } from '../types/workflow'; @@ -669,7 +670,7 @@ function WorkflowBuilderInner() { }, []); const navigateBackToAgents = useCallback(() => { - navigate(folderId ? `/agents?folder=${folderId}` : '/agents'); + navigate(agentsListPath(folderId)); }, [navigate, folderId]); const handleDeleteAgent = useCallback(async () => { @@ -1548,7 +1549,7 @@ function WorkflowBuilderInner() { diff --git a/frontend/src/conversation/Conversation.tsx b/frontend/src/conversation/Conversation.tsx index 1cb2487e..592509f8 100644 --- a/frontend/src/conversation/Conversation.tsx +++ b/frontend/src/conversation/Conversation.tsx @@ -9,6 +9,7 @@ import { Agent } from '../agents/types'; import ArtifactSidebar from '../components/ArtifactSidebar'; import ErrorBoundary from '../components/ErrorBoundary'; import MessageInput from '../components/MessageInput'; +import { agentChatPath, agentEditPathFor } from '../agents/paths'; import { useMediaQuery } from '../hooks'; import { selectConversationId, @@ -122,7 +123,7 @@ export default function Conversation() { useEffect(() => { if (!isNewChatRoute || !conversationId) return; const target = urlAgentId - ? `/agents/${urlAgentId}/c/${conversationId}` + ? agentChatPath(urlAgentId, conversationId) : `/c/${conversationId}`; navigate(target, { replace: true }); }, [conversationId, isNewChatRoute, urlAgentId]); @@ -397,12 +398,7 @@ export default function Conversation() { agent={selectedAgent} onEdit={ selectedAgent.id - ? () => - navigate( - selectedAgent.agent_type === 'workflow' - ? `/agents/workflow/edit/${selectedAgent.id}` - : `/agents/edit/${selectedAgent.id}`, - ) + ? () => navigate(agentEditPathFor(selectedAgent)) : undefined } /> diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index c88354cf..edcc0189 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -1644,6 +1644,7 @@ "navigation": { "agents": "Agenten", "backToApp": "Zurück zur App", + "backToAgents": "Zurück zu den Agenten", "sections": { "groups": { "personal": "Persönlich", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 9da25867..577d2213 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -1655,6 +1655,7 @@ "navigation": { "agents": "Agents", "backToApp": "Back to app", + "backToAgents": "Back to agents", "sections": { "groups": { "personal": "Personal", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 9726d619..622667bb 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -1644,6 +1644,7 @@ "navigation": { "agents": "Agentes", "backToApp": "Volver a la app", + "backToAgents": "Volver a agentes", "sections": { "groups": { "personal": "Personal", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index e11dcfda..533e49a8 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -1644,6 +1644,7 @@ "navigation": { "agents": "エージェント", "backToApp": "アプリに戻る", + "backToAgents": "エージェントに戻る", "sections": { "groups": { "personal": "個人", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index eac5b593..46c7f03a 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -1664,6 +1664,7 @@ "navigation": { "agents": "Агенты", "backToApp": "Вернуться в приложение", + "backToAgents": "Назад к агентам", "sections": { "groups": { "personal": "Личное", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index e258dcb6..ea5c5d5b 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -1644,6 +1644,7 @@ "navigation": { "agents": "代理", "backToApp": "返回應用程式", + "backToAgents": "返回代理", "sections": { "groups": { "personal": "個人", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 0ee3ea31..85baa522 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -1644,6 +1644,7 @@ "navigation": { "agents": "代理", "backToApp": "返回应用", + "backToAgents": "返回代理", "sections": { "groups": { "personal": "个人", diff --git a/frontend/src/modals/ImportAgentModal.tsx b/frontend/src/modals/ImportAgentModal.tsx index a0fa7753..47cc573d 100644 --- a/frontend/src/modals/ImportAgentModal.tsx +++ b/frontend/src/modals/ImportAgentModal.tsx @@ -18,6 +18,7 @@ import { SelectTrigger, SelectValue, } from '../components/ui/select'; +import { agentEditPath } from '../agents/paths'; import { ActiveState } from '../models/misc'; import { selectSourceDocs, selectToken } from '../preferences/preferenceSlice'; @@ -215,8 +216,8 @@ export default function ImportAgentModal({ const agentId = data.agent_id as string; const editPath = data.agent_type === 'workflow' - ? `/agents/workflow/edit/${agentId}` - : `/agents/edit/${agentId}`; + ? agentEditPath(agentId, true) + : agentEditPath(agentId); if (data.warnings && data.warnings.length > 0) { // Keep the modal open so the user sees what was skipped. setGoToEditPath(editPath); diff --git a/frontend/src/navigation/SectionIndexPage.tsx b/frontend/src/navigation/SectionIndexPage.tsx index c71d525f..ba75711a 100644 --- a/frontend/src/navigation/SectionIndexPage.tsx +++ b/frontend/src/navigation/SectionIndexPage.tsx @@ -21,7 +21,7 @@ export default function SectionIndexPage({ section }: { section: Section }) { return (

- {t(section.titleKey)} + {section.title ?? t(section.titleKey)}

{groups.map((group) => ( diff --git a/frontend/src/navigation/SectionNav.tsx b/frontend/src/navigation/SectionNav.tsx index 606a1d1a..33a3c1b6 100644 --- a/frontend/src/navigation/SectionNav.tsx +++ b/frontend/src/navigation/SectionNav.tsx @@ -34,6 +34,7 @@ export default function SectionNav({ }: SectionNavProps) { const { t } = useTranslation(); const groups = getVisibleGroups(section, { isAdmin }); + const sectionTitle = section.title ?? t(section.titleKey); const renderItem = (item: SectionItem) => { const isActive = item.key === activeItemKey; @@ -77,11 +78,14 @@ export default function SectionNav({ {backLabel}

-

- {t(section.titleKey)} +

+ {sectionTitle}