diff --git a/docsgpt/core/models/anthropic.yaml b/docsgpt/core/models/anthropic.yaml index 5ac74cc4..673394b1 100644 --- a/docsgpt/core/models/anthropic.yaml +++ b/docsgpt/core/models/anthropic.yaml @@ -39,3 +39,7 @@ models: description: Anthropic's frontier tier above Opus for the most demanding reasoning and long-horizon agentic work context_window: 1000000 supports_structured_output: true + input_cost_per_million: 10.0 + output_cost_per_million: 50.0 + cached_input_cost_per_million: 1.0 + cache_write_cost_per_million: 12.5 diff --git a/docsgpt/core/models/openai.yaml b/docsgpt/core/models/openai.yaml index bfbda9c9..de6ddd38 100644 --- a/docsgpt/core/models/openai.yaml +++ b/docsgpt/core/models/openai.yaml @@ -34,3 +34,6 @@ models: context_window: 1050000 api_flavor: responses reasoning_effort: medium + input_cost_per_million: 5.0 + output_cost_per_million: 30.0 + cached_input_cost_per_million: 0.5 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0cf65dc6..2772d157 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,8 @@ import { useDarkTheme, useMediaQuery } from './hooks'; import useDataInitializer from './hooks/useDataInitializer'; import useTokenAuth from './hooks/useTokenAuth'; import Navigation from './Navigation'; +import { getSectionForPath } from './navigation/sections'; +import { SidebarLevelProvider } from './navigation/SidebarLevelProvider'; import PageNotFound from './PageNotFound'; import Setting from './settings'; import Teams from './settings/Teams'; @@ -84,28 +86,34 @@ function MainLayout() { const { isMobile, isTablet } = useMediaQuery(); const [navOpen, setNavOpen] = useState(!(isMobile || isTablet)); const location = useLocation(); + // Settings and admin pages keep the profile menu but drop the chat actions: + // the conversation now survives the trip, so "share" would target a chat + // that isn't on screen. + const inSection = Boolean(getSectionForPath(location.pathname)); return ( -
- - -
- {/* Contain route render crashes so navigation stays usable; + +
+ + +
+ {/* Contain route render crashes so navigation stays usable; keyed by path so the boundary resets when the user leaves. */} - - - + + + +
+ + +
- - - -
+ ); } export default function App() { diff --git a/frontend/src/Navigation.tsx b/frontend/src/Navigation.tsx index efdc07c3..283026f3 100644 --- a/frontend/src/Navigation.tsx +++ b/frontend/src/Navigation.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; -import { NavLink, useNavigate } from 'react-router-dom'; +import { Link, NavLink, useNavigate } from 'react-router-dom'; import { LayoutGrid, Menu, @@ -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'; @@ -34,15 +39,28 @@ import { import ConversationTile from './conversation/ConversationTile'; import { useMediaQuery } from './hooks'; import useTokenAuth from './hooks/useTokenAuth'; +import { cn } from './lib/utils'; import ConfirmationModal from './modals/ConfirmationModal'; import JWTModal from './modals/JWTModal'; import SearchConversationsModal from './modals/SearchConversationsModal'; import { ActiveState } from './models/misc'; import { getConversations } from './preferences/preferenceApi'; +import SectionNav from './navigation/SectionNav'; +import SectionRail from './navigation/SectionRail'; +import SidebarLevel from './navigation/SidebarLevel'; +import { + getActiveItem, + getSectionForPath, + type Section, +} from './navigation/sections'; +import { useSidebarLevel } from './navigation/SidebarLevelProvider'; +import { useSectionContext } from './navigation/useSectionContext'; +import { useLastAppPath } from './navigation/useLastAppPath'; import { selectAgents, selectConversationId, selectConversations, + selectIsAdmin, selectModalStateDeleteConv, selectSelectedAgent, selectSharedAgents, @@ -76,10 +94,68 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { const agents = useSelector(selectAgents); const sharedAgents = useSelector(selectSharedAgents); const selectedAgent = useSelector(selectSelectedAgent); + const isAdmin = useSelector(selectIsAdmin); const { isMobile, isTablet } = useMediaQuery(); const { showTokenModal, handleTokenSubmit } = useTokenAuth(); + // 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: routeSection, item: routeSectionItem } = useSectionContext(); + const { pending, goToLevel } = useSidebarLevel(); + + // While a level change is in flight the sidebar runs ahead of the route, + // so it can start moving on the click rather than on the commit. + const activeSection = pending ? pending.section : routeSection; + const activeSectionItem = + pending && pending.section + ? getActiveItem(pending.section, pending.pathname) + : pending + ? null + : routeSectionItem; + const lastAppPath = useLastAppPath(); + const inSection = Boolean(activeSection); + + // The sidebar is a stack: chats, a section, and a record inside it. A + // section that declares a parent sits on the third level, above its + // parent's nav. + const nestedSection = activeSection?.parentPath ? activeSection : null; + const topSection = nestedSection + ? getSectionForPath(nestedSection.parentPath ?? '') + : activeSection; + const sidebarDepth = nestedSection ? 2 : activeSection ? 1 : 0; + + // Panels stay mounted after being left so they have something to animate + // out, and so the one behind is already there to be revealed on the way + // back. They park off screen, so the cost is a subtree nobody can see. + const lastTopSection = useRef
(null); + const lastNestedSection = useRef
(null); + if (topSection) lastTopSection.current = topSection; + if (nestedSection) lastNestedSection.current = nestedSection; + const topPanel = topSection ?? lastTopSection.current; + const nestedPanel = nestedSection ?? lastNestedSection.current; + + // Back means "up one level": out of an agent lands on the agent list, out + // of a top-level section lands back in the app. + const backLabelFor = (section: Section) => + section.parentLabelKey + ? t(section.parentLabelKey) + : t('navigation.backToApp'); + + const exitSectionFrom = (section: Section | null) => () => { + if (isMobile || isTablet) setNavOpen(false); + goToLevel(section?.parentPath ?? lastAppPath.current ?? '/'); + }; + + const exitSection = exitSectionFrom(activeSection); + const backLabel = activeSection + ? backLabelFor(activeSection) + : t('navigation.backToApp'); + + const closeNavOnMobile = () => { + if (isMobile || isTablet) setNavOpen(false); + }; + const [isDeletingConversation, setIsDeletingConversation] = useState(false); const [uploadModalState, setUploadModalState] = useState('INACTIVE'); @@ -200,7 +276,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) => { @@ -247,7 +323,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) { @@ -256,10 +332,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) { @@ -320,7 +396,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { {!navOpen && !isMobile && !isTablet && (
+ className="bg-sidebar border-border scrollbar-overlay fixed top-0 left-0 z-10 hidden h-full w-14 flex-col items-center gap-2 overflow-x-hidden overflow-y-auto border-r py-3 lg:flex" + > - {queries?.length > 0 && ( - + {activeSection ? ( + + ) : ( + <> + {queries?.length > 0 && ( + + )} + + {conversations?.data && conversations.data.length > 0 && ( + + )} +
+ +
+ )} - - {conversations?.data && conversations.data.length > 0 && ( - - )} -
- -
)}
- { - if (isMobile || isTablet) { - setNavOpen(!navOpen); - } - resetConversation(); - }} - className={({ isActive }) => - `${isActive ? 'bg-transparent' : '' - } group border-sidebar-border hover:border-sidebar-border sticky mx-4 mt-4 flex cursor-pointer items-center gap-2.5 rounded-3xl border p-3 hover:bg-transparent dark:text-white` - } - > - -

- {t('newChat')} -

-
-
- {conversations?.loading && !isDeletingConversation && ( -
+ + { + if (isMobile || isTablet) { + setNavOpen(!navOpen); + } + resetConversation(); + }} + className={({ isActive }) => + `${ + isActive ? 'bg-transparent' : '' + } group border-sidebar-border hover:border-sidebar-border sticky mx-4 mt-4 flex cursor-pointer items-center gap-2.5 rounded-3xl border p-3 hover:bg-transparent dark:text-white` + } > - -
- )} - {recentAgents?.length > 0 ? ( -
-
-

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

-
-
-
- {recentAgents.map((agent, idx) => ( -
handleAgentClick(agent)} - > -
-
- -
-

- {agent.name} -

-
-
- -
-
- ))} + +

+ {t('newChat')} +

+ +
+ {conversations?.loading && !isDeletingConversation && ( +
+
+ )} + {recentAgents?.length > 0 ? ( +
+
+

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

+
+
+
+ {recentAgents.map((agent, idx) => ( +
handleAgentClick(agent)} + > +
+
+ +
+

+ {agent.name} +

+
+
+ +
+
+ ))} +
+ { + if (event.metaKey || event.ctrlKey || event.shiftKey) + return; + event.preventDefault(); + dispatch(setSelectedAgent(null)); + closeNavOnMobile(); + goToLevel(AGENTS_MANAGE_ROOT); + }} + className={({ isActive }) => + `hover:bg-sidebar-accent mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 ${ + isActive ? 'bg-sidebar-accent' : '' + }` + } + > +
+ +
+

+ {t('manageAgents')} +

+
+
+
+ ) : ( { + onClick={(event) => { + if (event.metaKey || event.ctrlKey || event.shiftKey) + return; + event.preventDefault(); + closeNavOnMobile(); dispatch(setSelectedAgent(null)); - if (isMobile || isTablet) { - setNavOpen(false); - } + goToLevel(AGENTS_MANAGE_ROOT); }} className={({ isActive }) => - `hover:bg-sidebar-accent mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 ${isActive ? 'bg-sidebar-accent' : '' + `hover:bg-sidebar-accent mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3 ${ + isActive ? 'bg-sidebar-accent' : '' }` } > -
- -
+

{t('manageAgents')}

-
+ )} + {conversations?.data && conversations.data.length > 0 ? ( +
+
+

+ {t('chats')} +

+ +
+
+ {(conversations.data ?? []).map((conversation) => ( + handleConversationClick(id)} + onConversationClick={() => { + if (isMobile) { + setNavOpen(false); + } + }} + onDeleteConversation={(id) => + handleDeleteConversation(id) + } + onSave={(conversation) => + updateConversationName(conversation) + } + /> + ))} +
+
+ ) : ( + <> + )}
- ) : ( - { - if (isMobile || isTablet) { - setNavOpen(false); + + + {topPanel && ( + - `hover:bg-sidebar-accent mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3 ${isActive ? 'bg-sidebar-accent' : '' - }` - } - > - -

- {t('manageAgents')} -

-
- )} - {conversations?.data && conversations.data.length > 0 ? ( -
-
-

{t('chats')}

- -
-
- {(conversations.data ?? []).map((conversation) => ( - handleConversationClick(id)} - onConversationClick={() => { - if (isMobile) { - setNavOpen(false); - } - }} - onDeleteConversation={(id) => handleDeleteConversation(id)} - onSave={(conversation) => - updateConversationName(conversation) - } - /> - ))} -
-
- ) : ( - <> - )} -
-
-
- { - if (isMobile || isTablet) { - setNavOpen(false); + )} + + + {nestedPanel && ( + + )} + +
+
+ {/* Inside a section its own nav is the way around, so this entry + would only duplicate what is already on screen. Entering settings + no longer clears the conversation either, so the section's back + button can return to it. */} +
+ - `hover:bg-sidebar-accent mx-4 my-auto flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3 ${isActive ? 'bg-sidebar-accent' : '' - }` - } + onClick={(event) => { + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + event.preventDefault(); + closeNavOnMobile(); + goToLevel('/settings'); + }} + className="hover:bg-sidebar-accent mx-4 my-auto flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3" > {t('settings.label')}

- +
@@ -698,7 +844,6 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { > -
* (cosmetic guard); every endpoint it calls is independently @admin_required on - * the server. + * the server. Navigation lives in the sidebar, like the settings section. */ export default function Admin() { - const { t } = useTranslation(); - const navigate = useNavigate(); const location = useLocation(); + const { isMobile, isTablet } = useMediaQuery(); - const active = - TABS.slice(1).find((tab) => location.pathname.startsWith(tab.path)) - ?.label ?? 'Overview'; + const showIndex = + (isMobile || isTablet) && location.pathname === ADMIN_SECTION.rootPath; return (
-

- {t('admin.label', 'Admin')} -

- { - const tab = TABS.find((tb) => tb.label === label); - if (tab) navigate(tab.path); - }} - className="relative mt-6 flex flex-row items-center space-x-1 overflow-auto md:space-x-0" - > - - {TABS.map((tab) => ( - - {t(`admin.tabs.${tab.key}`, tab.label)} - - ))} - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + {showIndex ? ( + + ) : ( + <> + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + )}
); diff --git a/frontend/src/agents/AgentCard.tsx b/frontend/src/agents/AgentCard.tsx index 64ae3dc3..3fd19855 100644 --- a/frontend/src/agents/AgentCard.tsx +++ b/frontend/src/agents/AgentCard.tsx @@ -28,6 +28,7 @@ import { Modal } from '../components/ui/modal'; import ConfirmationModal from '../modals/ConfirmationModal'; import MoveToFolderModal from '../modals/MoveToFolderModal'; import { ActiveState } from '../models/misc'; +import { useSidebarLevel } from '../navigation/SidebarLevelProvider'; import ShareToTeamModal from '../teams/ShareToTeamModal'; type AgentMenuOption = { @@ -44,6 +45,12 @@ import { setAgents, setSelectedAgent, } from '../preferences/preferenceSlice'; +import { + agentChatPath, + agentEditPath, + agentLogsPath, + sharedAgentPath, +} from './paths'; import { Agent } from './types'; type AgentCardProps = { @@ -61,6 +68,10 @@ export default function AgentCard({ }: AgentCardProps) { const { t } = useTranslation(); const navigate = useNavigate(); + // Opening an agent is a level change, so it goes through the sidebar's + // navigator: the panel starts sliding on the click rather than waiting for + // the editor to mount. + const { goToLevel } = useSidebarLevel(); const dispatch = useDispatch(); const token = useSelector(selectToken); const userAgents = useSelector(selectAgents); @@ -91,7 +102,7 @@ export default function AgentCard({ label: 'Logs', onClick: (e: SyntheticEvent) => { e.stopPropagation(); - navigate(`/agents/logs/${agent.id}`); + goToLevel(agentLogsPath(agent.id)); }, variant: 'default', iconWidth: 14, @@ -103,9 +114,9 @@ export default function AgentCard({ onClick: (e: SyntheticEvent) => { e.stopPropagation(); if (agent.agent_type === 'workflow') { - navigate(`/agents/workflow/edit/${agent.id}`); + goToLevel(agentEditPath(agent.id, true)); } else { - navigate(`/agents/edit/${agent.id}`); + goToLevel(agentEditPath(agent.id)); } }, variant: 'default', @@ -189,9 +200,9 @@ export default function AgentCard({ onClick: (e: SyntheticEvent) => { e.stopPropagation(); if (agent.agent_type === 'workflow') { - navigate(`/agents/workflow/edit/${agent.id}`); + goToLevel(agentEditPath(agent.id, true)); } else { - navigate(`/agents/edit/${agent.id}`); + goToLevel(agentEditPath(agent.id)); } }, variant: 'default', @@ -220,7 +231,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 +269,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..ec4ad597 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(); @@ -51,6 +61,10 @@ export default function AgentsList() { const [showAgentTypeModal, setShowAgentTypeModal] = useState(false); const [modalFolderId, setModalFolderId] = useState(null); + // 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); + // Sync folder path with URL useEffect(() => { const currentFolderInUrl = searchParams.get('folder'); @@ -58,26 +72,23 @@ export default function AgentsList() { folderPath.length > 0 ? folderPath[folderPath.length - 1] : null; if (currentFolderId !== currentFolderInUrl) { - const newUrl = currentFolderId - ? `/agents?folder=${currentFolderId}` - : '/agents'; - navigate(newUrl, { replace: true }); + navigate(agentsListPath(currentFolderId, activeFilter), { + replace: true, + }); } - }, [folderPath, searchParams, navigate]); + }, [folderPath, searchParams, navigate, activeFilter]); const { isLoading, refetchFolders, refetchUserAgents } = useAgentsFetch(); const { searchQuery, setSearchQuery, - activeFilter, - setActiveFilter, filteredAgentsBySection, totalAgentsBySection, hasAnyAgents, hasFilteredResults, isDataLoaded, - } = useAgentSearch(); + } = useAgentSearch(activeFilter); useEffect(() => { dispatch(setConversation([])); @@ -162,87 +173,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} - /> +
); } @@ -402,6 +399,19 @@ function AgentSection({ onCreateFolder(name, currentFolderId || undefined); }; + // Must stay above the empty-state returns below: a hook after an early + // return is skipped on the render that takes it, which React rejects with + // "rendered fewer hooks than expected". Reachable now that each filter is + // its own route — landing straight on an empty one renders once while the + // data loads, then again once it arrives empty. + const breadcrumbItems = useMemo(() => { + if (!folders || folderPath.length === 0) return []; + return folderPath.map((folderId) => { + const folder = folders.find((f) => f.id === folderId); + return { id: folderId, name: folder?.name || '' }; + }); + }, [folders, folderPath]); + const hasNoAgentsAtAll = !isLoading && totalAgents === 0; const isSearchingWithNoResults = !isLoading && searchQuery && filteredAgents.length === 0 && totalAgents > 0; @@ -435,70 +445,51 @@ function AgentSection({ ); } - // Build breadcrumb items from folder path - const breadcrumbItems = useMemo(() => { - if (!folders || folderPath.length === 0) return []; - return folderPath.map((folderId) => { - const folder = folders.find((f) => f.id === folderId); - return { id: folderId, name: folder?.name || '' }; - }); - }, [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..146594c3 --- /dev/null +++ b/frontend/src/agents/paths.test.ts @@ -0,0 +1,80 @@ +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'); + }); + + it('keeps the active filter, so opening a folder does not widen the list', () => { + expect(agentsListPath('f1', 'user')).toBe('/agents/manage/mine?folder=f1'); + expect(agentsListPath(null, 'user')).toBe('/agents/manage/mine'); + expect(filterFromPath(agentsListPath('f1', 'team').split('?')[0])).toBe( + 'team', + ); + }); +}); + +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..31ad7023 --- /dev/null +++ b/frontend/src/agents/paths.ts @@ -0,0 +1,123 @@ +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 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]}`; + +/** + * The agent list, optionally scoped to a folder and to a filter. The filter + * has to be carried explicitly: it lives in the path now, so building a + * folder URL off the bare root would silently widen the list back to every + * section the moment someone opened a folder from a filtered view. + */ +export const agentsListPath = ( + folderId?: string | null, + filter: AgentFilterTab = 'all', +): string => { + const base = agentsFilterPath(filter); + return folderId ? `${base}?folder=${encodeURIComponent(folderId)}` : base; +}; + +/** 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/index.css b/frontend/src/index.css index d3bd0ffc..a9e6438d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -690,8 +690,11 @@ Avoid over-scrolling in mobile browsers font-family: var(--font-mono); } + /* Disclosure content, e.g. an expanded tool call. Short on purpose: the + panel's height appears at once, so a long fade reads as the content + lagging behind the layout rather than as a reveal. */ .fade-in { - animation: fadeIn 0.5s ease-in-out; + animation: fadeIn 0.16s ease-out; } @keyframes fadeIn { @@ -703,10 +706,13 @@ Avoid over-scrolling in mobile browsers } } + /* The start state lives in the keyframes, not here: with `opacity: 0` on + the element itself the content was only ever visible *because* an + animation had run to completion, so anything that stopped it running — + a cancelled animation, a reduced-motion reset — left the answer blank. + The element now rests visible and the animation is purely additive. */ .fade-in-bubble { - opacity: 0; - transform: translateY(10px); - animation: fadeInUp 0.5s forwards; + animation: fadeInUp 0.26s ease-out; } .shimmer-text { @@ -754,9 +760,19 @@ Avoid over-scrolling in mobile browsers .dark .shimmer-text { background-image: none; } + + /* Safe to drop outright now that both rest in their final state. */ + .fade-in, + .fade-in-bubble { + animation: none; + } } @keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(6px); + } to { opacity: 1; transform: translateY(0); diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index df6dabfa..edcc0189 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -436,7 +436,6 @@ "label": "Teams", "subtitle": "Verwalte deine Teams, Mitglieder und geteilten Ressourcen.", "newTeam": "Neues Team", - "backToTeams": "Zurück zu den Teams", "createTeam": "Team erstellen", "createTeamDescription": "Gib deinem Team einen Namen. Mitglieder und Ressourcen kannst du nach dem Erstellen hinzufügen.", "editTeam": "Bearbeiten", @@ -565,7 +564,6 @@ "queryParameters": "Abfrageparameter", "body": "Body", "deleteActionWarning": "Bist du sicher, dass du die Aktion \"{{name}}\" löschen möchtest?", - "backToAllTools": "Zurück zu allen Werkzeugen", "save": "Speichern", "saving": "Speichere...", "saveFailed": "Werkzeug-Konfiguration konnte nicht gespeichert werden", @@ -872,10 +870,7 @@ "subtitle": "Das neue Geheimnis für \"{{name}}\" ist bereit. Das alte funktioniert nicht mehr." } } - }, - "scrollTabsLeft": "Tabs nach links scrollen", - "tabsAriaLabel": "Einstellungs-Tabs", - "scrollTabsRight": "Tabs nach rechts scrollen" + } }, "modals": { "uploadDoc": { @@ -1647,7 +1642,17 @@ "increaseZoom": "Vergrößern" }, "navigation": { - "agents": "Agenten" + "agents": "Agenten", + "backToApp": "Zurück zur App", + "backToAgents": "Zurück zu den Agenten", + "sections": { + "groups": { + "personal": "Persönlich", + "workspace": "Arbeitsbereich", + "insights": "Auswertungen", + "administration": "Administration" + } + } }, "teams": { "switcher": { diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index cdf03a26..577d2213 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -441,7 +441,6 @@ "label": "Teams", "subtitle": "Manage your teams, members, and shared resources.", "newTeam": "New team", - "backToTeams": "Back to teams", "createTeam": "Create a team", "createTeamDescription": "Give your team a name. You can add members and share resources once it's created.", "editTeam": "Edit", @@ -570,7 +569,6 @@ "queryParameters": "Query Parameters", "body": "Body", "deleteActionWarning": "Are you sure you want to delete the action \"{{name}}\"?", - "backToAllTools": "Back to all tools", "save": "Save", "saving": "Saving...", "saveFailed": "Failed to save tool configuration", @@ -877,10 +875,7 @@ "subtitle": "The new secret for \"{{name}}\" is ready. The old one no longer works." } } - }, - "scrollTabsLeft": "Scroll tabs left", - "tabsAriaLabel": "Settings tabs", - "scrollTabsRight": "Scroll tabs right" + } }, "modals": { "uploadDoc": { @@ -1658,7 +1653,17 @@ "increaseZoom": "Increase zoom" }, "navigation": { - "agents": "Agents" + "agents": "Agents", + "backToApp": "Back to app", + "backToAgents": "Back to agents", + "sections": { + "groups": { + "personal": "Personal", + "workspace": "Workspace", + "insights": "Insights", + "administration": "Administration" + } + } }, "teams": { "switcher": { @@ -1677,5 +1682,16 @@ }, "prompts": { "textAriaLabel": "Prompt Text" + }, + "admin": { + "label": "Admin", + "tabs": { + "overview": "Overview", + "users": "Users", + "admins": "Admins", + "usage": "Usage", + "quotas": "Quotas", + "audit": "Audit" + } } } diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 76058628..622667bb 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -436,7 +436,6 @@ "label": "Equipos", "subtitle": "Gestiona tus equipos, miembros y recursos compartidos.", "newTeam": "Nuevo equipo", - "backToTeams": "Volver a equipos", "createTeam": "Crear un equipo", "createTeamDescription": "Dale un nombre a tu equipo. Podrás añadir miembros y compartir recursos una vez creado.", "editTeam": "Editar", @@ -565,7 +564,6 @@ "queryParameters": "Parámetros de Consulta", "body": "Cuerpo", "deleteActionWarning": "¿Estás seguro de que deseas eliminar la acción \"{{name}}\"?", - "backToAllTools": "Volver a todas las herramientas", "save": "Guardar", "saving": "Guardando...", "saveFailed": "No se pudo guardar la configuración de la herramienta", @@ -872,10 +870,7 @@ "subtitle": "El nuevo secreto de \"{{name}}\" está listo. El anterior ya no funciona." } } - }, - "scrollTabsLeft": "Desplazar pestañas a la izquierda", - "tabsAriaLabel": "Pestañas de configuración", - "scrollTabsRight": "Desplazar pestañas a la derecha" + } }, "modals": { "uploadDoc": { @@ -1647,7 +1642,17 @@ "increaseZoom": "Aumentar zoom" }, "navigation": { - "agents": "Agentes" + "agents": "Agentes", + "backToApp": "Volver a la app", + "backToAgents": "Volver a agentes", + "sections": { + "groups": { + "personal": "Personal", + "workspace": "Espacio de trabajo", + "insights": "Estadísticas", + "administration": "Administración" + } + } }, "teams": { "switcher": { diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 18626d19..533e49a8 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -436,7 +436,6 @@ "label": "チーム", "subtitle": "チーム、メンバー、共有リソースを管理します。", "newTeam": "新しいチーム", - "backToTeams": "チーム一覧に戻る", "createTeam": "チームを作成", "createTeamDescription": "チームに名前を付けてください。作成後にメンバーの追加やリソースの共有ができます。", "editTeam": "編集", @@ -565,7 +564,6 @@ "queryParameters": "クエリパラメータ", "body": "ボディ", "deleteActionWarning": "アクション \"{{name}}\" を削除してもよろしいですか?", - "backToAllTools": "すべてのツールに戻る", "save": "保存", "saving": "保存中...", "saveFailed": "ツール設定の保存に失敗しました", @@ -872,10 +870,7 @@ "subtitle": "「{{name}}」の新しいシークレットの準備ができました。以前のものは使用できません。" } } - }, - "scrollTabsLeft": "タブを左にスクロール", - "tabsAriaLabel": "設定タブ", - "scrollTabsRight": "タブを右にスクロール" + } }, "modals": { "uploadDoc": { @@ -1647,7 +1642,17 @@ "increaseZoom": "ズームイン" }, "navigation": { - "agents": "エージェント" + "agents": "エージェント", + "backToApp": "アプリに戻る", + "backToAgents": "エージェントに戻る", + "sections": { + "groups": { + "personal": "個人", + "workspace": "ワークスペース", + "insights": "インサイト", + "administration": "管理" + } + } }, "teams": { "switcher": { diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 081860c6..46c7f03a 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -436,7 +436,6 @@ "label": "Команды", "subtitle": "Управляйте командами, участниками и общими ресурсами.", "newTeam": "Новая команда", - "backToTeams": "Назад к командам", "createTeam": "Создать команду", "createTeamDescription": "Дайте команде название. После создания вы сможете добавить участников и делиться ресурсами.", "editTeam": "Редактировать", @@ -565,7 +564,6 @@ "queryParameters": "Параметры запроса", "body": "Тело запроса", "deleteActionWarning": "Вы уверены, что хотите удалить действие \"{{name}}\"?", - "backToAllTools": "Вернуться ко всем инструментам", "save": "Сохранить", "saving": "Сохранение...", "saveFailed": "Не удалось сохранить настройки инструмента", @@ -892,10 +890,7 @@ "subtitle": "Новый секрет для «{{name}}» готов. Старый больше не работает." } } - }, - "scrollTabsLeft": "Прокрутить вкладки влево", - "tabsAriaLabel": "Вкладки настроек", - "scrollTabsRight": "Прокрутить вкладки вправо" + } }, "modals": { "uploadDoc": { @@ -1667,7 +1662,17 @@ "increaseZoom": "Увеличить масштаб" }, "navigation": { - "agents": "Агенты" + "agents": "Агенты", + "backToApp": "Вернуться в приложение", + "backToAgents": "Назад к агентам", + "sections": { + "groups": { + "personal": "Личное", + "workspace": "Рабочее пространство", + "insights": "Аналитика", + "administration": "Администрирование" + } + } }, "teams": { "switcher": { diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 7902cf5a..ea5c5d5b 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -436,7 +436,6 @@ "label": "團隊", "subtitle": "管理您的團隊、成員與共享資源。", "newTeam": "新增團隊", - "backToTeams": "返回團隊列表", "createTeam": "建立團隊", "createTeamDescription": "為您的團隊命名。建立後即可新增成員並分享資源。", "editTeam": "編輯", @@ -565,7 +564,6 @@ "queryParameters": "查詢參數", "body": "主體", "deleteActionWarning": "您確定要刪除操作 \"{{name}}\" 嗎?", - "backToAllTools": "返回所有工具", "save": "儲存", "saving": "儲存中...", "saveFailed": "儲存工具設定失敗", @@ -872,10 +870,7 @@ "subtitle": "「{{name}}」的新密鑰已就緒,舊密鑰已失效。" } } - }, - "scrollTabsLeft": "向左捲動標籤", - "tabsAriaLabel": "設定標籤", - "scrollTabsRight": "向右捲動標籤" + } }, "modals": { "uploadDoc": { @@ -1647,7 +1642,17 @@ "increaseZoom": "放大" }, "navigation": { - "agents": "代理" + "agents": "代理", + "backToApp": "返回應用程式", + "backToAgents": "返回代理", + "sections": { + "groups": { + "personal": "個人", + "workspace": "工作區", + "insights": "洞察", + "administration": "管理" + } + } }, "teams": { "switcher": { diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 7e647c5e..85baa522 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -436,7 +436,6 @@ "label": "团队", "subtitle": "管理您的团队、成员和共享资源。", "newTeam": "新建团队", - "backToTeams": "返回团队列表", "createTeam": "创建团队", "createTeamDescription": "为您的团队命名。创建后即可添加成员并共享资源。", "editTeam": "编辑", @@ -565,7 +564,6 @@ "queryParameters": "查询参数", "body": "请求体", "deleteActionWarning": "您确定要删除操作 \"{{name}}\" 吗?", - "backToAllTools": "返回所有工具", "save": "保存", "saving": "保存中...", "saveFailed": "保存工具配置失败", @@ -872,10 +870,7 @@ "subtitle": "“{{name}}”的新密钥已就绪,旧密钥已失效。" } } - }, - "scrollTabsLeft": "向左滚动标签", - "tabsAriaLabel": "设置标签", - "scrollTabsRight": "向右滚动标签" + } }, "modals": { "uploadDoc": { @@ -1647,7 +1642,17 @@ "increaseZoom": "放大" }, "navigation": { - "agents": "代理" + "agents": "代理", + "backToApp": "返回应用", + "backToAgents": "返回代理", + "sections": { + "groups": { + "personal": "个人", + "workspace": "工作区", + "insights": "洞察", + "administration": "管理" + } + } }, "teams": { "switcher": { 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/DetailBreadcrumb.tsx b/frontend/src/navigation/DetailBreadcrumb.tsx new file mode 100644 index 00000000..6f007a02 --- /dev/null +++ b/frontend/src/navigation/DetailBreadcrumb.tsx @@ -0,0 +1,53 @@ +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { cn } from '@/lib/utils'; + +type DetailBreadcrumbProps = { + parentLabel: string; + currentLabel: string; + /** Returns to the list view; kept a callback because these detail views are + * component state rather than routes, and some guard unsaved changes. */ + onParentClick: () => void; + className?: string; +}; + +/** + * Trail for a detail view nested inside a section page (a tool's config, a + * team). The section nav's back button always means "leave the section", so + * going up one level is a breadcrumb here rather than a second back arrow. + */ +export default function DetailBreadcrumb({ + parentLabel, + currentLabel, + onParentClick, + className, +}: DetailBreadcrumbProps) { + return ( + + + + + + + + + + + {currentLabel} + + + + + ); +} diff --git a/frontend/src/navigation/SectionIndexPage.tsx b/frontend/src/navigation/SectionIndexPage.tsx new file mode 100644 index 00000000..ba75711a --- /dev/null +++ b/frontend/src/navigation/SectionIndexPage.tsx @@ -0,0 +1,65 @@ +import { ChevronRight } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; + +import { selectIsAdmin } from '@/preferences/preferenceSlice'; + +import { getVisibleGroups, type Section } from './sections'; + +/** + * Small-screen landing page for a section: the same destinations as the + * sidebar nav, rendered as page content so they stay reachable while the + * sidebar is an overlay. Tapping a row pushes its page, which carries a back + * link to here. + */ +export default function SectionIndexPage({ section }: { section: Section }) { + const { t } = useTranslation(); + const isAdmin = useSelector(selectIsAdmin); + const groups = getVisibleGroups(section, { isAdmin }); + + return ( +
+

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

+
+ {groups.map((group) => ( +
+ {group.labelKey && ( +

+ {t(group.labelKey)} +

+ )} +
+ {group.items.map((item) => { + const Icon = item.icon; + return ( + + + + {t(item.labelKey)} + + + + ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/navigation/SectionNav.tsx b/frontend/src/navigation/SectionNav.tsx new file mode 100644 index 00000000..ec1ff63b --- /dev/null +++ b/frontend/src/navigation/SectionNav.tsx @@ -0,0 +1,114 @@ +import { ArrowLeft } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; + +import { cn } from '@/lib/utils'; + +import { getVisibleGroups, type Section, type SectionItem } from './sections'; +import { useSidebarLevel } from './SidebarLevelProvider'; + +type SectionNavProps = { + section: Section; + /** Key of the item matching the current route, from ``getActiveItem``. */ + activeItemKey?: string; + isAdmin: boolean; + /** Leaves the section — always "exit", never "up one level". */ + onBack: () => void; + backLabel: string; + onNavigate?: () => void; +}; + +/** + * Vertical nav that replaces the chat list while the user is inside a section. + * + * The only back affordance at this level is ``onBack``, which exits the + * section; anything deeper (a tool's config, a team's detail) keeps its own + * breadcrumb in the content column so the two never compete. + */ +export default function SectionNav({ + section, + activeItemKey, + isAdmin, + onBack, + backLabel, + onNavigate, +}: SectionNavProps) { + const { t } = useTranslation(); + const { goToLevel } = useSidebarLevel(); + const groups = getVisibleGroups(section, { isAdmin }); + const sectionTitle = section.title ?? t(section.titleKey); + + const renderItem = (item: SectionItem) => { + const isActive = item.key === activeItemKey; + const Icon = item.icon; + return ( + { + // Same level, so nothing slides — but routing through the level + // provider still renders the page at low priority, which keeps the + // highlight moving under the cursor instead of after the mount. + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + event.preventDefault(); + goToLevel(item.path); + onNavigate?.(); + }} + aria-current={isActive ? 'page' : undefined} + className={cn( + 'hover:bg-sidebar-accent mx-4 my-1 flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3', + isActive && 'bg-sidebar-accent', + )} + > + +

+ {t(item.labelKey)} +

+ + ); + }; + + return ( +
+ +

+ {sectionTitle} +

+ +
+ ); +} diff --git a/frontend/src/navigation/SectionPageHeader.tsx b/frontend/src/navigation/SectionPageHeader.tsx new file mode 100644 index 00000000..8104dd94 --- /dev/null +++ b/frontend/src/navigation/SectionPageHeader.tsx @@ -0,0 +1,94 @@ +import { ArrowLeft } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Link, useLocation } from 'react-router-dom'; + +import { useMediaQuery } from '@/hooks'; +import { cn } from '@/lib/utils'; + +import { getSectionForPath, type Section, type SectionItem } from './sections'; +import { useSectionContext } from './useSectionContext'; + +/** + * Back to the section's index. Only rendered below ``lg``, where the sidebar + * is an overlay and the section nav would otherwise sit behind the hamburger. + * On desktop the sidebar itself is the way back, so this stays out of the way. + */ +export function SectionBackLink({ + section, + className, +}: { + section: Section; + className?: string; +}) { + const { t } = useTranslation(); + const { pathname } = useLocation(); + const { isMobile, isTablet } = useMediaQuery(); + + if (!(isMobile || isTablet)) return null; + + // Up one level, matching the sidebar's back button: out of an agent lands + // on the agent list, out of a settings page on the settings index. A + // section whose destinations are views of one page has no level above + // unless it declares a parent — its pill row does the moving around. + const to = + section.parentPath ?? + (section.pageTitle === 'section' ? null : section.rootPath); + if (!to || to === pathname) return null; + + const parent = section.parentPath + ? getSectionForPath(section.parentPath) + : null; + return ( + + + {parent ? t(parent.titleKey) : (section.title ?? t(section.titleKey))} + + ); +} + +/** + * Title block for a section page: the active item's name, preceded on small + * screens by a link back to the section index. + */ +export default function SectionPageHeader({ + section, + item, + className, +}: { + section: Section; + item: SectionItem | null; + className?: string; +}) { + const { t } = useTranslation(); + + return ( +
+ +

+ {item && section.pageTitle !== 'section' + ? t(item.labelKey) + : (section.title ?? t(section.titleKey))} +

+
+ ); +} + +/** + * The title block for whichever section page is on screen. Saves every page + * from resolving its own section, and keeps the heading identical across + * settings, admin and agents. + */ +export function CurrentSectionHeader({ className }: { className?: string }) { + const { section, item } = useSectionContext(); + + if (!section) return null; + return ( + + ); +} diff --git a/frontend/src/navigation/SectionPills.tsx b/frontend/src/navigation/SectionPills.tsx new file mode 100644 index 00000000..4778f8f4 --- /dev/null +++ b/frontend/src/navigation/SectionPills.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; + +import { cn } from '@/lib/utils'; +import { selectIsAdmin } from '@/preferences/preferenceSlice'; + +import { getVisibleGroups } from './sections'; +import { useSectionContext } from './useSectionContext'; + +/** + * The current section's destinations as a pill row, shown only below `lg` + * where the sidebar is an overlay. + * + * Sections whose destinations are separate pages (settings, admin) use + * `SectionIndexPage` instead. This is for sections whose destinations are + * views of the page you are already on — the agent list's filters, an + * agent's own pages — where bouncing out to a menu to switch would be worse + * than a row of pills. + */ +export default function SectionPills({ className }: { className?: string }) { + const { t } = useTranslation(); + const { section, item } = useSectionContext(); + const isAdmin = useSelector(selectIsAdmin); + + if (!section) return null; + const items = getVisibleGroups(section, { isAdmin }).flatMap( + (group) => group.items, + ); + if (items.length < 2) return null; + + return ( +
+ {items.map((entry) => { + const isActive = entry.key === item?.key; + return ( + + {t(entry.labelKey)} + + ); + })} +
+ ); +} diff --git a/frontend/src/navigation/SectionRail.tsx b/frontend/src/navigation/SectionRail.tsx new file mode 100644 index 00000000..5ece5227 --- /dev/null +++ b/frontend/src/navigation/SectionRail.tsx @@ -0,0 +1,82 @@ +import { ArrowLeft } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +import { getSectionItems, type Section } from './sections'; +import { useSidebarLevel } from './SidebarLevelProvider'; + +type SectionRailProps = { + section: Section; + activeItemKey?: string; + isAdmin: boolean; + onBack: () => void; + backLabel: string; +}; + +/** + * Collapsed-sidebar counterpart to ``SectionNav``: the same destinations as + * icons, so collapsing the sidebar inside a section still leaves the section + * navigable instead of stranding the user on one page. + */ +export default function SectionRail({ + section, + activeItemKey, + isAdmin, + onBack, + backLabel, +}: SectionRailProps) { + const { t } = useTranslation(); + const { goToLevel } = useSidebarLevel(); + const items = getSectionItems(section, { isAdmin }); + + return ( + // Keyed on the section so switching level replays the fade: the rail is + // too narrow to slide panels through, but it should not swap in place + // with no acknowledgement either. +
+ +
+ {items.map((item) => { + const label = t(item.labelKey); + const isActive = item.key === activeItemKey; + const Icon = item.icon; + return ( + { + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + event.preventDefault(); + goToLevel(item.path); + }} + aria-label={label} + aria-current={isActive ? 'page' : undefined} + title={label} + className={cn( + 'hover:bg-sidebar-accent text-muted-foreground hover:text-foreground flex size-9 items-center justify-center rounded-full', + isActive && 'bg-sidebar-accent text-foreground', + )} + > + + + ); + })} +
+ ); +} diff --git a/frontend/src/navigation/SidebarLevel.tsx b/frontend/src/navigation/SidebarLevel.tsx new file mode 100644 index 00000000..20d15f10 --- /dev/null +++ b/frontend/src/navigation/SidebarLevel.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from 'react'; + +import { cn } from '@/lib/utils'; + +type SidebarLevelProps = { + /** This panel's place in the hierarchy: 0 chats, 1 a section, 2 a record. */ + depth: number; + /** The level on screen right now. */ + current: number; + children: ReactNode; + className?: string; +}; + +/** + * One panel in the sidebar's navigation stack. + * + * Every panel is positioned from a single number — its depth relative to the + * level on screen — so push and pop fall out of the same rule instead of + * needing a direction to be tracked. A panel above the current level waits + * off to the right; the current one sits at rest; ones below are parked just + * off to the left. Changing level therefore animates both panels the right + * way round, whichever way the user is going. + * + * The panel behind only travels a quarter of the width, so it trails the + * incoming panel rather than marching with it — the cue that one sits on top + * of the other rather than beside it. Each panel paints its own background + * so it occludes the one behind while it slides. + * + * `visibility` is in the transition on purpose: CSS keeps an element visible + * for the whole duration when either end of the transition is `visible`, so a + * panel stays on screen while it leaves and only drops out of the tab order + * once it has gone. Panels are never unmounted, which is what lets the chat + * list keep its scroll position across a trip into settings. + */ +export default function SidebarLevel({ + depth, + current, + children, + className, +}: SidebarLevelProps) { + const offset = depth - current; + + return ( +
0 && 'invisible translate-x-full opacity-100', + className, + )} + > + {children} +
+ ); +} diff --git a/frontend/src/navigation/SidebarLevelProvider.tsx b/frontend/src/navigation/SidebarLevelProvider.tsx new file mode 100644 index 00000000..37d766d6 --- /dev/null +++ b/frontend/src/navigation/SidebarLevelProvider.tsx @@ -0,0 +1,82 @@ +import { + createContext, + startTransition, + useCallback, + useContext, + useEffect, + useState, + type ReactNode, +} from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import type { Section } from './sections'; +import { useSectionResolver } from './useSectionResolver'; + +type PendingLevel = { pathname: string; section: Section | null }; + +type SidebarLevelValue = { + /** The level the sidebar should show, ahead of the route when moving. */ + pending: PendingLevel | null; + /** Navigate in a way the sidebar can animate immediately. */ + goToLevel: (to: string) => void; +}; + +const SidebarLevelContext = createContext({ + pending: null, + goToLevel: () => {}, +}); + +/** + * Lets the sidebar change level on the click rather than on the commit. + * + * Mounting a section's page is expensive — measured at a single ~170ms + * blocking frame in a production build — and the sidebar's own class change + * used to ride along in that same commit. The panels therefore only began + * moving once the new page had rendered: a pause, and then a slide the user + * had stopped expecting. + * + * So the two updates are split by priority. The level lands as an urgent + * update that touches nothing but the sidebar, so React can commit and paint + * it straight away and the transition starts on time; the route change goes + * through `startTransition`, which renders the page at low priority and + * yields between slices instead of blocking that paint. The target's section + * is resolved up front so the incoming panel slides in with its content + * already in place rather than arriving empty. + */ +export function SidebarLevelProvider({ children }: { children: ReactNode }) { + const navigate = useNavigate(); + const location = useLocation(); + const resolve = useSectionResolver(); + const [pending, setPending] = useState(null); + + const goToLevel = useCallback( + (to: string) => { + const pathname = to.split('?')[0]; + setPending({ pathname, section: resolve(pathname) }); + startTransition(() => navigate(to)); + }, + [navigate, resolve], + ); + + // Hand back to the route once it catches up, and never hold the sidebar + // ahead of it for long: a navigation can be refused (an unsaved-changes + // guard) or land somewhere else entirely, and a level that never resolved + // would leave the sidebar showing a section the user is not in. + useEffect(() => { + if (!pending) return; + if (pending.pathname === location.pathname) { + setPending(null); + return; + } + const timer = setTimeout(() => setPending(null), 600); + return () => clearTimeout(timer); + }, [pending, location.pathname]); + + return ( + + {children} + + ); +} + +export const useSidebarLevel = () => useContext(SidebarLevelContext); diff --git a/frontend/src/navigation/sections.test.ts b/frontend/src/navigation/sections.test.ts new file mode 100644 index 00000000..31c6ff1d --- /dev/null +++ b/frontend/src/navigation/sections.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; + +import { + ADMIN_SECTION, + AGENTS_SECTION, + buildAgentSection, + depthOf, + getActiveItem, + getSectionForPath, + getSectionItems, + getVisibleGroups, + SETTINGS_SECTION, +} from './sections'; + +describe('getSectionForPath', () => { + it('claims the section root and everything under it', () => { + expect(getSectionForPath('/settings')?.key).toBe('settings'); + expect(getSectionForPath('/settings/tools')?.key).toBe('settings'); + expect(getSectionForPath('/admin/users')?.key).toBe('admin'); + }); + + it('claims routes the section owns outside its root', () => { + expect(getSectionForPath('/teams')?.key).toBe('settings'); + }); + + it('leaves ordinary app routes alone', () => { + expect(getSectionForPath('/')).toBeNull(); + expect(getSectionForPath('/c/abc123')).toBeNull(); + }); + + it('separates managing agents from chatting with one', () => { + expect(getSectionForPath('/agents/manage')?.key).toBe('agents'); + expect(getSectionForPath('/agents/manage/edit/a1')?.key).toBe('agents'); + + // A conversation with an agent must leave the chat list in place. + expect(getSectionForPath('/agents/a1/c/c1')).toBeNull(); + expect(getSectionForPath('/agents/shared/tok')).toBeNull(); + }); + + it('matches whole path segments, not string prefixes', () => { + expect(getSectionForPath('/settings-export')).toBeNull(); + expect(getSectionForPath('/administrators')).toBeNull(); + }); +}); + +describe('getActiveItem', () => { + it('resolves the section root through the alias', () => { + expect(getActiveItem(SETTINGS_SECTION, '/settings')?.key).toBe('general'); + expect(getActiveItem(ADMIN_SECTION, '/admin')?.key).toBe('overview'); + }); + + it('prefers the deepest match over a shorter alias', () => { + expect(getActiveItem(SETTINGS_SECTION, '/settings/tools')?.key).toBe( + 'tools', + ); + expect( + getActiveItem(SETTINGS_SECTION, '/settings/access-tokens')?.key, + ).toBe('accessTokens'); + }); + + it('keeps the parent item active inside a detail route', () => { + expect(getActiveItem(SETTINGS_SECTION, '/settings/tools/slack')?.key).toBe( + 'tools', + ); + }); + + it('round-trips every configured item', () => { + for (const section of [SETTINGS_SECTION, ADMIN_SECTION]) { + for (const item of getSectionItems(section)) { + // Items pointing at another section are drill-throughs, not + // destinations this section can be "on". + if (item.leavesSection) continue; + expect(getActiveItem(section, item.path)?.key).toBe(item.key); + } + } + }); +}); + +describe('getVisibleGroups', () => { + it('hides admin-only entries and the group left empty by them', () => { + const groups = getVisibleGroups(SETTINGS_SECTION, { isAdmin: false }); + const keys = groups.flatMap((group) => group.items.map((i) => i.key)); + + expect(keys).not.toContain('admin'); + expect(groups.map((group) => group.key)).not.toContain('administration'); + expect(keys).toContain('general'); + }); + + it('shows them to admins', () => { + const keys = getVisibleGroups(SETTINGS_SECTION, { isAdmin: true }).flatMap( + (group) => group.items.map((i) => i.key), + ); + + expect(keys).toContain('admin'); + }); +}); + +describe('AGENTS_SECTION', () => { + it('gives every list filter its own route', () => { + expect(getActiveItem(AGENTS_SECTION, '/agents/manage')?.key).toBe('all'); + expect(getActiveItem(AGENTS_SECTION, '/agents/manage/mine')?.key).toBe( + 'user', + ); + expect( + getActiveItem(AGENTS_SECTION, '/agents/manage/discovered')?.key, + ).toBe('shared'); + }); +}); + +describe('buildAgentSection', () => { + it('is named after the agent and goes back to the list', () => { + const section = buildAgentSection('a1', 'Support bot', false); + + expect(section.title).toBe('Support bot'); + expect(section.parentPath).toBe('/agents/manage'); + }); + + it('falls back to a generic title before the agent has loaded', () => { + expect(buildAgentSection('a1', undefined, false).title).toBeUndefined(); + expect(buildAgentSection('a1', ' ', false).title).toBeUndefined(); + }); + + it('resolves each of the agent pages', () => { + const section = buildAgentSection('a1', 'Support bot', false); + + expect(getActiveItem(section, '/agents/manage/edit/a1')?.key).toBe( + 'overview', + ); + expect(getActiveItem(section, '/agents/manage/logs/a1')?.key).toBe('logs'); + expect(getActiveItem(section, '/agents/manage/schedules/a1')?.key).toBe( + 'schedules', + ); + }); + + it('points overview at the workflow builder for a workflow agent', () => { + const section = buildAgentSection('a1', 'Flow', true); + + expect(section.rootPath).toBe('/agents/manage/workflow/edit/a1'); + expect(getActiveItem(section, '/agents/manage/workflow/edit/a1')?.key).toBe( + 'overview', + ); + }); +}); + +describe('depthOf', () => { + it('puts chats, sections and records on their own level', () => { + expect(depthOf(null)).toBe(0); + expect(depthOf(getSectionForPath('/settings'))).toBe(1); + expect(depthOf(getSectionForPath('/agents/manage'))).toBe(1); + expect(depthOf(getSectionForPath('/admin/users'))).toBe(1); + expect(depthOf(buildAgentSection('a1', 'Support bot', false))).toBe(2); + }); + + it('keeps a chat with an agent at the chat level', () => { + expect(depthOf(getSectionForPath('/agents/a1/c/c1'))).toBe(0); + }); +}); diff --git a/frontend/src/navigation/sections.ts b/frontend/src/navigation/sections.ts new file mode 100644 index 00000000..5eb73b39 --- /dev/null +++ b/frontend/src/navigation/sections.ts @@ -0,0 +1,387 @@ +import { + BarChart3, + Blocks, + CalendarClock, + ChartNoAxesColumn, + Database, + FileClock, + Gauge, + Globe, + KeyRound, + LayoutDashboard, + LayoutGrid, + LayoutTemplate, + ScrollText, + Settings2, + ShieldCheck, + SquarePen, + User, + UserCog, + Users, + Wrench, + type LucideIcon, +} from 'lucide-react'; +import { + AGENTS_MANAGE_ROOT, + agentEditPath, + agentLogsPath, + agentSchedulesPath, + agentsFilterPath, +} from '../agents/paths'; + +/** A single destination in a section's vertical nav. */ +export type SectionItem = { + key: string; + path: string; + labelKey: string; + icon: LucideIcon; + /** Extra pathnames that also mark this item active (e.g. the section root). */ + aliases?: string[]; + /** Hidden from users without the global admin role. */ + adminOnly?: boolean; + /** Jumps to a different section rather than navigating within this one. */ + leavesSection?: boolean; +}; + +/** Items sharing a heading in the nav. */ +export type SectionGroup = { + key: string; + labelKey?: string; + items: SectionItem[]; +}; + +/** + * A top-level area that takes over the sidebar while the user is inside it. + * `matches` lists the pathnames the section owns — entering any of them swaps + * the sidebar from the chat list to this section's nav. + */ +export type Section = { + key: string; + rootPath: string; + /** Fallback title; `title` overrides it when the name comes from data. */ + titleKey: string; + /** Literal title for a section named after a record, e.g. an agent. */ + title?: string; + matches: string[]; + /** + * Where the back button goes. Sections nest — leaving an agent lands on the + * agent list, not the chat — so back always means "up one level", and the + * top level is the app. + */ + parentPath?: string; + parentLabelKey?: string; + /** + * What the page heading says. `'item'` (the default) suits sections whose + * destinations are separate pages; `'section'` suits ones whose + * destinations are views of a single page, like the agent list's filters, + * where the heading would otherwise flip to "All" or "My agents". + */ + pageTitle?: 'item' | 'section'; + groups: SectionGroup[]; +}; + +export const SETTINGS_SECTION: Section = { + key: 'settings', + rootPath: '/settings', + titleKey: 'settings.label', + matches: ['/settings', '/teams'], + groups: [ + { + key: 'personal', + labelKey: 'navigation.sections.groups.personal', + items: [ + { + key: 'general', + path: '/settings/general', + labelKey: 'settings.general.label', + icon: Settings2, + aliases: ['/settings'], + }, + { + key: 'accessTokens', + path: '/settings/access-tokens', + labelKey: 'settings.accessTokens.label', + icon: KeyRound, + }, + ], + }, + { + key: 'workspace', + labelKey: 'navigation.sections.groups.workspace', + items: [ + { + key: 'sources', + path: '/settings/sources', + labelKey: 'settings.sources.label', + icon: Database, + }, + { + key: 'tools', + path: '/settings/tools', + labelKey: 'settings.tools.label', + icon: Wrench, + }, + { + key: 'customModels', + path: '/settings/custom-models', + labelKey: 'settings.customModels.label', + icon: Blocks, + }, + { + key: 'teams', + path: '/teams', + labelKey: 'settings.teams.label', + icon: Users, + }, + ], + }, + { + key: 'insights', + labelKey: 'navigation.sections.groups.insights', + items: [ + { + key: 'analytics', + path: '/settings/analytics', + labelKey: 'settings.analytics.label', + icon: ChartNoAxesColumn, + }, + { + key: 'logs', + path: '/settings/logs', + labelKey: 'settings.logs.label', + icon: ScrollText, + }, + ], + }, + { + key: 'administration', + labelKey: 'navigation.sections.groups.administration', + items: [ + { + key: 'admin', + path: '/admin', + labelKey: 'admin.label', + icon: ShieldCheck, + adminOnly: true, + leavesSection: true, + }, + ], + }, + ], +}; + +export const ADMIN_SECTION: Section = { + key: 'admin', + rootPath: '/admin', + titleKey: 'admin.label', + matches: ['/admin'], + groups: [ + { + key: 'admin', + items: [ + { + key: 'overview', + path: '/admin/overview', + labelKey: 'admin.tabs.overview', + icon: LayoutDashboard, + aliases: ['/admin'], + }, + { + key: 'users', + path: '/admin/users', + labelKey: 'admin.tabs.users', + icon: Users, + }, + { + key: 'admins', + path: '/admin/roles', + labelKey: 'admin.tabs.admins', + icon: UserCog, + }, + { + key: 'usage', + path: '/admin/usage', + labelKey: 'admin.tabs.usage', + icon: BarChart3, + }, + { + key: 'quotas', + path: '/admin/quotas', + labelKey: 'admin.tabs.quotas', + icon: Gauge, + }, + { + key: 'audit', + path: '/admin/audit', + labelKey: 'admin.tabs.audit', + icon: FileClock, + }, + ], + }, + ], +}; + +export const AGENTS_SECTION: Section = { + key: 'agents', + rootPath: AGENTS_MANAGE_ROOT, + titleKey: 'agents.title', + // Only the management prefix. `/agents/:id/c/:conversationId` is a chat and + // must leave the sidebar on the conversation list. + matches: [AGENTS_MANAGE_ROOT], + pageTitle: 'section', + groups: [ + { + key: 'agents', + items: [ + { + key: 'all', + path: agentsFilterPath('all'), + labelKey: 'agents.filters.all', + icon: LayoutGrid, + }, + { + key: 'template', + path: agentsFilterPath('template'), + labelKey: 'agents.filters.byDocsGPT', + icon: LayoutTemplate, + }, + { + key: 'user', + path: agentsFilterPath('user'), + labelKey: 'agents.filters.byMe', + icon: User, + }, + { + key: 'team', + path: agentsFilterPath('team'), + labelKey: 'agents.filters.team', + icon: Users, + }, + { + key: 'shared', + path: agentsFilterPath('shared'), + labelKey: 'agents.filters.shared', + icon: Globe, + }, + ], + }, + ], +}; + +/** + * The nav for a single agent. Built per route rather than declared, because + * its title is the agent's name and its paths carry the agent's id. + */ +export function buildAgentSection( + agentId: string, + agentName: string | undefined, + workflow: boolean, +): Section { + return { + key: `agent:${agentId}`, + rootPath: agentEditPath(agentId, workflow), + titleKey: 'agents.pageHeader.fallbackName', + title: agentName?.trim() || undefined, + matches: [ + agentEditPath(agentId, workflow), + agentLogsPath(agentId), + agentSchedulesPath(agentId), + ], + parentPath: AGENTS_MANAGE_ROOT, + parentLabelKey: 'navigation.backToAgents', + groups: [ + { + key: 'agent', + items: [ + { + key: 'overview', + path: agentEditPath(agentId, workflow), + labelKey: 'agents.pageHeader.tabs.overview', + icon: SquarePen, + }, + { + key: 'logs', + path: agentLogsPath(agentId), + labelKey: 'agents.pageHeader.tabs.logs', + icon: ScrollText, + }, + { + key: 'schedules', + path: agentSchedulesPath(agentId), + labelKey: 'agents.pageHeader.tabs.schedules', + icon: CalendarClock, + }, + ], + }, + ], + }; +} + +export const SECTIONS: Section[] = [ + SETTINGS_SECTION, + ADMIN_SECTION, + AGENTS_SECTION, +]; + +const pathMatches = (pathname: string, path: string): boolean => + pathname === path || pathname.startsWith(`${path}/`); + +/** The section owning ``pathname``, or null when it is an ordinary app route. */ +export function getSectionForPath(pathname: string): Section | null { + return ( + SECTIONS.find((section) => + section.matches.some((match) => pathMatches(pathname, match)), + ) ?? null + ); +} + +/** Flattened items of a section, optionally dropping admin-only entries. */ +export function getSectionItems( + section: Section, + { isAdmin = true }: { isAdmin?: boolean } = {}, +): SectionItem[] { + return section.groups + .flatMap((group) => group.items) + .filter((item) => !item.adminOnly || isAdmin); +} + +/** Groups with admin-only entries removed, dropping any group left empty. */ +export function getVisibleGroups( + section: Section, + { isAdmin = true }: { isAdmin?: boolean } = {}, +): SectionGroup[] { + return section.groups + .map((group) => ({ + ...group, + items: group.items.filter((item) => !item.adminOnly || isAdmin), + })) + .filter((group) => group.items.length > 0); +} + +/** + * The nav item ``pathname`` belongs to. Longest match wins, so a deeper route + * (``/settings/tools/slack``) beats a shorter alias (``/settings``). + */ +export function getActiveItem( + section: Section, + pathname: string, +): SectionItem | null { + let best: SectionItem | null = null; + let bestLength = -1; + for (const item of getSectionItems(section)) { + for (const candidate of [item.path, ...(item.aliases ?? [])]) { + if (pathMatches(pathname, candidate) && candidate.length > bestLength) { + best = item; + bestLength = candidate.length; + } + } + } + return best; +} + +/** + * Which level of the sidebar stack a section occupies: the chat list, a + * section, or a record inside one. + */ +export const depthOf = (section: Section | null): number => + section ? (section.parentPath ? 2 : 1) : 0; diff --git a/frontend/src/navigation/useLastAppPath.ts b/frontend/src/navigation/useLastAppPath.ts new file mode 100644 index 00000000..72ce5d71 --- /dev/null +++ b/frontend/src/navigation/useLastAppPath.ts @@ -0,0 +1,20 @@ +import { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { getSectionForPath } from './sections'; + +/** + * Remembers the last route outside any section, so leaving a section returns + * the user to the conversation they were in rather than to a blank chat. + */ +export function useLastAppPath(fallback = '/') { + const location = useLocation(); + const lastAppPath = useRef(fallback); + + useEffect(() => { + if (!getSectionForPath(location.pathname)) + lastAppPath.current = `${location.pathname}${location.search}`; + }, [location.pathname, location.search]); + + return lastAppPath; +} diff --git a/frontend/src/navigation/useSectionContext.ts b/frontend/src/navigation/useSectionContext.ts new file mode 100644 index 00000000..c8296398 --- /dev/null +++ b/frontend/src/navigation/useSectionContext.ts @@ -0,0 +1,54 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { useLocation } from 'react-router-dom'; + +import { matchAgentScopedRoute } from '../agents/paths'; +import { + selectAgents, + selectSelectedAgent, + selectSharedAgents, +} from '../preferences/preferenceSlice'; +import { + buildAgentSection, + getActiveItem, + getSectionForPath, + type Section, + type SectionItem, +} from './sections'; + +/** + * The section the current route belongs to, and the nav item within it. + * + * Most sections are static and come straight from the registry. A route + * scoped to one agent gets a section built on the spot, since its title is + * the agent's name — which lives in the store, not in the path. + */ +export function useSectionContext(): { + section: Section | null; + item: SectionItem | null; +} { + const { pathname } = useLocation(); + const agents = useSelector(selectAgents); + const sharedAgents = useSelector(selectSharedAgents); + const selectedAgent = useSelector(selectSelectedAgent); + + const section = useMemo(() => { + const scoped = matchAgentScopedRoute(pathname); + if (!scoped) return getSectionForPath(pathname); + + const name = [ + ...(agents ?? []), + ...(sharedAgents ?? []), + ...(selectedAgent ? [selectedAgent] : []), + ].find((agent) => agent.id === scoped.agentId)?.name; + + // An agent saved moments ago may not be in the store yet; the section + // falls back to a generic title until it arrives. + return buildAgentSection(scoped.agentId, name, scoped.workflow); + }, [pathname, agents, sharedAgents, selectedAgent]); + + return { + section, + item: section ? getActiveItem(section, pathname) : null, + }; +} diff --git a/frontend/src/navigation/useSectionResolver.ts b/frontend/src/navigation/useSectionResolver.ts new file mode 100644 index 00000000..5e509639 --- /dev/null +++ b/frontend/src/navigation/useSectionResolver.ts @@ -0,0 +1,38 @@ +import { useCallback } from 'react'; +import { useSelector } from 'react-redux'; + +import { matchAgentScopedRoute } from '../agents/paths'; +import { + selectAgents, + selectSelectedAgent, + selectSharedAgents, +} from '../preferences/preferenceSlice'; +import { buildAgentSection, getSectionForPath, type Section } from './sections'; + +/** + * Resolves any pathname to its section, including the ones built per route + * from a record in the store. Taking a pathname rather than reading the + * current one lets the sidebar resolve a route it is *about* to go to, which + * is what allows it to start moving on the click. + */ +export function useSectionResolver(): (pathname: string) => Section | null { + const agents = useSelector(selectAgents); + const sharedAgents = useSelector(selectSharedAgents); + const selectedAgent = useSelector(selectSelectedAgent); + + return useCallback( + (pathname: string) => { + const scoped = matchAgentScopedRoute(pathname); + if (!scoped) return getSectionForPath(pathname); + + const name = [ + ...(agents ?? []), + ...(sharedAgents ?? []), + ...(selectedAgent ? [selectedAgent] : []), + ].find((agent) => agent.id === scoped.agentId)?.name; + + return buildAgentSection(scoped.agentId, name, scoped.workflow); + }, + [agents, sharedAgents, selectedAgent], + ); +} diff --git a/frontend/src/settings/RemoteDeviceConfig.tsx b/frontend/src/settings/RemoteDeviceConfig.tsx index f8426410..afc04833 100644 --- a/frontend/src/settings/RemoteDeviceConfig.tsx +++ b/frontend/src/settings/RemoteDeviceConfig.tsx @@ -7,10 +7,10 @@ import devicesService, { AuditEntry, Device, } from '../api/services/devicesService'; -import ArrowLeft from '../assets/arrow-left.svg'; import CopyButton from '../components/CopyButton'; import Spinner from '../components/Spinner'; import ToolIcon from '../components/ToolIcon'; +import DetailBreadcrumb from '../navigation/DetailBreadcrumb'; import { Accordion, AccordionContent, @@ -194,19 +194,14 @@ export default function RemoteDeviceConfig({ tool, handleGoBack }: Props) { return (
-
-
- -

{t('settings.tools.backToAllTools')}

-
+
+ + />
diff --git a/frontend/src/settings/ToolConfig.tsx b/frontend/src/settings/ToolConfig.tsx index 788b1373..6c845131 100644 --- a/frontend/src/settings/ToolConfig.tsx +++ b/frontend/src/settings/ToolConfig.tsx @@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import userService from '../api/services/userService'; -import ArrowLeft from '../assets/arrow-left.svg'; import ChevronRight from '../assets/chevron-right.svg'; import CircleCheck from '../assets/circle-check.svg'; import CircleX from '../assets/circle-x.svg'; @@ -24,6 +23,7 @@ import { Switch } from '../components/ui/switch'; import { useDarkTheme } from '../hooks'; import AddActionModal from '../modals/AddActionModal'; import ConfirmationModal from '../modals/ConfirmationModal'; +import DetailBreadcrumb from '../navigation/DetailBreadcrumb'; import ImportSpecModal from '../modals/ImportSpecModal'; import { ActiveState } from '../models/misc'; import { selectToken } from '../preferences/preferenceSlice'; @@ -330,19 +330,12 @@ export default function ToolConfig({ }; return (
-
-
- -

{t('settings.tools.backToAllTools')}

-
+
+ -
- - {tabsList.map((tab) => ( - - {tab} - - ))} - -
- -
- - - } /> - - } - /> - } /> - } /> - } /> - } - /> - } /> - } /> - } /> - +
+ {showIndex ? ( + + ) : ( + <> + + + } /> + } /> + + } + /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + + + )} +
); } diff --git a/tests/core/test_model_registry_yaml.py b/tests/core/test_model_registry_yaml.py index 1015f964..982455bf 100644 --- a/tests/core/test_model_registry_yaml.py +++ b/tests/core/test_model_registry_yaml.py @@ -30,11 +30,12 @@ from docsgpt.core.model_yaml import ( # in an upstream model id) that would silently break every agent that # references the old id. EXPECTED_IDS = { - "openai": {"gpt-5.5", "gpt-5.4-mini", "gpt-5.4-nano"}, + "openai": {"gpt-5.5", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.6-sol"}, "anthropic": { "claude-opus-4-7", "claude-sonnet-4-6", "claude-haiku-4-5", + "claude-fable-5", }, "google": { "gemini-3.1-pro-preview", @@ -59,6 +60,8 @@ EXPECTED_IDS = { "openai_compatible": { "deepseek-v4-flash", "deepseek-v4-pro", + "qwen3.8-max", + "glm-5.3", }, "docsgpt": {"docsgpt-local"}, "huggingface": {"huggingface-local"}, @@ -250,7 +253,11 @@ class TestRegistryPermutations: assert ids == EXPECTED_IDS["docsgpt"] def test_everything_set(self, monkeypatch): + # Every openai_compatible catalog reads its own key from the + # environment, so each needs one here for "everything" to mean it. monkeypatch.setenv("DEEPSEEK_API_KEY", "x") + monkeypatch.setenv("DASHSCOPE_API_KEY", "x") + monkeypatch.setenv("ZAI_API_KEY", "x") s = _make_settings( OPENAI_API_KEY="x", ANTHROPIC_API_KEY="x", diff --git a/tests/e2e/specs/tier-b/guardrail-events-panel.spec.ts b/tests/e2e/specs/tier-b/guardrail-events-panel.spec.ts index 1d7768db..8fc533d6 100644 --- a/tests/e2e/specs/tier-b/guardrail-events-panel.spec.ts +++ b/tests/e2e/specs/tier-b/guardrail-events-panel.spec.ts @@ -1,5 +1,5 @@ /** - * tier-b · the guardrail activity panel on /agents/logs/:agentId. + * tier-b · the guardrail activity panel on /agents/manage/logs/:agentId. * * Silent break covered: the audit journal existed for a while with no UI and * no caller — `userService.getGuardrailEvents` had zero references — so a @@ -121,7 +121,7 @@ test.describe('tier-b · guardrail activity panel', () => { const { context } = await newUserContext(browser, { sub }); const page = await context.newPage(); - await page.goto(`/agents/logs/${agentId}`); + await page.goto(`/agents/manage/logs/${agentId}`); const panel = page.getByTestId('guardrail-events'); await expect(panel).toBeVisible(); @@ -174,7 +174,7 @@ test.describe('tier-b · guardrail activity panel', () => { const { context } = await newUserContext(browser, { sub }); const page = await context.newPage(); - await page.goto(`/agents/logs/${agentId}`); + await page.goto(`/agents/manage/logs/${agentId}`); const table = page.getByTestId('guardrail-events-rows'); await expect(table).toContainText('denylist'); @@ -212,7 +212,7 @@ test.describe('tier-b · guardrail activity panel', () => { const { context } = await newUserContext(browser, { sub }); const page = await context.newPage(); - await page.goto(`/agents/logs/${agentId}`); + await page.goto(`/agents/manage/logs/${agentId}`); await expect(page.getByTestId('guardrail-events-empty')).toBeVisible(); await expect(page.getByTestId('guardrail-stat-blocked')).toContainText( @@ -253,7 +253,7 @@ test.describe('tier-b · guardrail activity panel', () => { const { context } = await newUserContext(browser, { sub }); const page = await context.newPage(); - await page.goto(`/agents/logs/${quiet.id}`); + await page.goto(`/agents/manage/logs/${quiet.id}`); await expect(page.getByTestId('guardrail-stat-blocked')).toContainText( '0', diff --git a/tests/e2e/specs/tier-b/guardrails.spec.ts b/tests/e2e/specs/tier-b/guardrails.spec.ts index 93e122cb..5a6114d1 100644 --- a/tests/e2e/specs/tier-b/guardrails.spec.ts +++ b/tests/e2e/specs/tier-b/guardrails.spec.ts @@ -731,7 +731,7 @@ test.describe('tier-b · guardrails builder UI', () => { expect(await dbAgentConfig(agentId)).toEqual({}); const page = await context.newPage(); - await page.goto(`/agents/edit/${agentId}`); + await page.goto(`/agents/manage/edit/${agentId}`); const section = page.getByTestId('guardrails-section'); await expect(section).toBeVisible(); diff --git a/tests/e2e/specs/tier-c/ui-smoke.spec.ts b/tests/e2e/specs/tier-c/ui-smoke.spec.ts index 6f15d3d0..5da532bb 100644 --- a/tests/e2e/specs/tier-c/ui-smoke.spec.ts +++ b/tests/e2e/specs/tier-c/ui-smoke.spec.ts @@ -17,7 +17,7 @@ import * as playwright from '@playwright/test'; * C10 upload drag-drop -> fixme: no global drop handler in current UI * C11 markdown rendering -> seeded /share conv renders

from ```md``` * C12 mermaid rendering -> seeded /share conv renders mermaid (code or svg) - * C13 agent logs route -> `/agents/logs/:agentId` renders title + * C13 agent logs route -> `/agents/manage/logs/:agentId` renders title * * Setup: one shared authenticated context per test (no beforeAll reuse because * some tests mutate localStorage / navigate to routes that break other tests). @@ -453,7 +453,7 @@ test.describe('tier-c · UI smoke', () => { } }); - test('C13 · agents/logs/:agentId renders for a seeded agent', async ({ + test('C13 · agents/manage/logs/:agentId renders for a seeded agent', async ({ browser, }) => { const { context, sub, token } = await newUserContext(browser); @@ -465,7 +465,7 @@ test.describe('tier-c · UI smoke', () => { const agentId = await insertStubAgent(sub, 'ui-smoke-agent'); const page = await context.newPage(); - await page.goto(`/agents/logs/${agentId}`); + await page.goto(`/agents/manage/logs/${agentId}`); // AgentPageHeader renders a breadcrumb plus a labelled sub-nav; it has // no heading element (it used to, before #2495 moved it to shadcn