Merge pull request #2819 from arc53/sidebar-section-nav

Move settings and admin navigation into the sidebar
This commit is contained in:
Alex authored and GitHub committed 2026-09-22 00:58:56 +01:00
commit f5b02ed374
49 files changed
+2630 -1042

No files matched your search

+4
View File
@@ -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
+3
View File
@@ -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
+26 -18
View File
@@ -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 (
<div className="bg-background relative h-screen overflow-hidden">
<Navigation navOpen={navOpen} setNavOpen={setNavOpen} />
<ActionButtons showNewChat={true} showShare={true} />
<div
className={`h-[calc(100dvh-64px)] overflow-auto transition-all duration-300 ease-in-out lg:h-screen ${
!(isMobile || isTablet)
? `${navOpen ? 'lg:ml-72' : 'lg:ml-14'}`
: 'ml-0 lg:ml-16'
}`}
>
{/* Contain route render crashes so navigation stays usable;
<SidebarLevelProvider>
<div className="bg-background relative h-screen overflow-hidden">
<Navigation navOpen={navOpen} setNavOpen={setNavOpen} />
<ActionButtons showNewChat={!inSection} showShare={!inSection} />
<div
className={`h-[calc(100dvh-64px)] overflow-auto transition-all duration-300 ease-in-out lg:h-screen ${
!(isMobile || isTablet)
? `${navOpen ? 'lg:ml-72' : 'lg:ml-14'}`
: 'ml-0 lg:ml-16'
}`}
>
{/* Contain route render crashes so navigation stays usable;
keyed by path so the boundary resets when the user leaves. */}
<ErrorBoundary key={location.pathname}>
<Outlet />
</ErrorBoundary>
<ErrorBoundary key={location.pathname}>
<Outlet />
</ErrorBoundary>
</div>
<UploadToast />
<ToolApprovalToast />
<TeamNotificationToast />
</div>
<UploadToast />
<ToolApprovalToast />
<TeamNotificationToast />
</div>
</SidebarLevelProvider>
);
}
export default function App() {
+387 -242
View File
@@ -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<Section | null>(null);
const lastNestedSection = useRef<Section | null>(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<ActiveState>('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 && (
<div
ref={navRef}
className="bg-sidebar border-border fixed top-0 left-0 z-10 hidden h-full w-14 flex-col items-center gap-2 border-r py-3 lg:flex">
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"
>
<Button
type="button"
variant="ghost"
@@ -331,64 +408,74 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
>
<PanelLeftOpen className="size-5" strokeWidth={1.75} />
</Button>
{queries?.length > 0 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => newChat()}
aria-label="Start new chat"
className="text-muted-foreground hover:text-foreground"
>
<Plus className="size-5" strokeWidth={1.75} />
</Button>
{activeSection ? (
<SectionRail
section={activeSection}
activeItemKey={activeSectionItem?.key}
isAdmin={isAdmin}
onBack={exitSection}
backLabel={backLabel}
/>
) : (
<>
{queries?.length > 0 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => newChat()}
aria-label="Start new chat"
className="text-muted-foreground hover:text-foreground"
>
<Plus className="size-5" strokeWidth={1.75} />
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
dispatch(setSelectedAgent(null));
goToLevel(AGENTS_MANAGE_ROOT);
}}
aria-label={t('manageAgents')}
className="text-muted-foreground hover:text-foreground"
>
<LayoutGrid className="size-5" strokeWidth={1.75} />
</Button>
{conversations?.data && conversations.data.length > 0 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setSearchOpen(true)}
aria-label={t('modals.searchConversations.searchPlaceholder')}
title={t('modals.searchConversations.searchPlaceholder')}
className="text-muted-foreground hover:text-foreground"
>
<SearchIcon className="size-5" strokeWidth={1.75} />
</Button>
)}
<div className="mt-auto flex flex-col items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => goToLevel('/settings')}
aria-label={t('settings.label')}
className="text-muted-foreground hover:text-foreground"
>
<SettingsIcon className="size-5" strokeWidth={1.75} />
</Button>
</div>
</>
)}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
dispatch(setSelectedAgent(null));
navigate('/agents');
}}
aria-label={t('manageAgents')}
className="text-muted-foreground hover:text-foreground"
>
<LayoutGrid className="size-5" strokeWidth={1.75} />
</Button>
{conversations?.data && conversations.data.length > 0 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setSearchOpen(true)}
aria-label={t('modals.searchConversations.searchPlaceholder')}
title={t('modals.searchConversations.searchPlaceholder')}
className="text-muted-foreground hover:text-foreground"
>
<SearchIcon className="size-5" strokeWidth={1.75} />
</Button>
)}
<div className="mt-auto flex flex-col items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
resetConversation();
navigate('/settings');
}}
aria-label={t('settings.label')}
className="text-muted-foreground hover:text-foreground"
>
<SettingsIcon className="size-5" strokeWidth={1.75} />
</Button>
</div>
</div>
)}
<div
className={`${!navOpen && '-ml-96 md:-ml-72'
} bg-sidebar dark:border-r-sidebar-border fixed top-0 z-20 flex h-full w-72 flex-col border-r border-b-0 transition-all duration-300 ease-in-out dark:text-white`}
className={`${
!navOpen && '-ml-96 md:-ml-72'
} bg-sidebar dark:border-r-sidebar-border fixed top-0 z-20 flex h-full w-72 flex-col border-r border-b-0 transition-all duration-300 ease-in-out dark:text-white`}
>
<div
className={
@@ -427,205 +514,264 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
)}
</Button>
</div>
<NavLink
to={'/c/new'}
onClick={() => {
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`
}
>
<Plus
className="text-muted-foreground group-hover:text-foreground size-5 shrink-0"
strokeWidth={1.75}
aria-label="Create new chat"
/>
<p className="text-muted-foreground group-hover:text-foreground text-sm">
{t('newChat')}
</p>
</NavLink>
<div
id="conversationsMainDiv"
className="scrollbar-overlay mb-auto h-[78vh] overflow-x-hidden overflow-y-auto dark:text-white"
>
{conversations?.loading && !isDeletingConversation && (
<div
className="text-foreground absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform dark:text-white"
role="status"
aria-label="Loading conversations"
{/* The chat list and the section nav swap places here. Both stay
mounted so the conversation list keeps its scroll position while
the user is away in a section. */}
<div className="relative flex min-h-0 flex-1 overflow-hidden">
<SidebarLevel depth={0} current={sidebarDepth}>
<NavLink
to={'/c/new'}
onClick={() => {
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`
}
>
<Spinner size="small" />
</div>
)}
{recentAgents?.length > 0 ? (
<div>
<div className="mx-4 my-auto mt-2 flex h-6 items-center">
<p className="mt-1 ml-4 text-sm font-semibold">
{t('navigation.agents')}
</p>
</div>
<div className="agents-container">
<div>
{recentAgents.map((agent, idx) => (
<div
key={idx}
className={`group hover:bg-sidebar-accent mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center justify-between rounded-3xl pl-4 ${agent.id === selectedAgent?.id && !conversationId
? 'bg-sidebar-accent'
: ''
}`}
onClick={() => handleAgentClick(agent)}
>
<div className="flex items-center gap-2">
<div className="flex w-6 justify-center">
<Avatar
src={agent.image}
alt="agent-logo"
imgClassName="h-6 w-6 rounded-full object-contain"
/>
</div>
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{agent.name}
</p>
</div>
<div
className={`${isMobile || isTablet ? 'flex' : 'invisible flex group-hover:visible'} items-center px-3`}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="h-auto w-auto rounded-full p-0 hover:bg-transparent hover:opacity-75"
onClick={(e) => {
e.stopPropagation();
handleTogglePin(agent);
}}
aria-label={
agent.pinned ? 'Unpin agent' : 'Pin agent'
}
>
<img
src={agent.pinned ? UnPin : Pin}
className="h-4 w-4"
></img>
</Button>
</div>
</div>
))}
<Plus
className="text-muted-foreground group-hover:text-foreground size-5 shrink-0"
strokeWidth={1.75}
aria-label="Create new chat"
/>
<p className="text-muted-foreground group-hover:text-foreground text-sm">
{t('newChat')}
</p>
</NavLink>
<div
id="conversationsMainDiv"
className="scrollbar-overlay min-h-0 flex-1 overflow-x-hidden overflow-y-auto dark:text-white"
>
{conversations?.loading && !isDeletingConversation && (
<div
className="text-foreground absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform dark:text-white"
role="status"
aria-label="Loading conversations"
>
<Spinner size="small" />
</div>
)}
{recentAgents?.length > 0 ? (
<div>
<div className="mx-4 my-auto mt-2 flex h-6 items-center">
<p className="mt-1 ml-4 text-sm font-semibold">
{t('navigation.agents')}
</p>
</div>
<div className="agents-container">
<div>
{recentAgents.map((agent, idx) => (
<div
key={idx}
className={`group hover:bg-sidebar-accent mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center justify-between rounded-3xl pl-4 ${
agent.id === selectedAgent?.id && !conversationId
? 'bg-sidebar-accent'
: ''
}`}
onClick={() => handleAgentClick(agent)}
>
<div className="flex items-center gap-2">
<div className="flex w-6 justify-center">
<Avatar
src={agent.image}
alt="agent-logo"
imgClassName="h-6 w-6 rounded-full object-contain"
/>
</div>
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{agent.name}
</p>
</div>
<div
className={`${isMobile || isTablet ? 'flex' : 'invisible flex group-hover:visible'} items-center px-3`}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="h-auto w-auto rounded-full p-0 hover:bg-transparent hover:opacity-75"
onClick={(e) => {
e.stopPropagation();
handleTogglePin(agent);
}}
aria-label={
agent.pinned ? 'Unpin agent' : 'Pin agent'
}
>
<img
src={agent.pinned ? UnPin : Pin}
className="h-4 w-4"
></img>
</Button>
</div>
</div>
))}
</div>
<NavLink
to={AGENTS_MANAGE_ROOT}
end
onClick={(event) => {
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' : ''
}`
}
>
<div className="flex w-6 justify-center">
<LayoutGrid
className="text-muted-foreground size-5"
strokeWidth={1.75}
aria-label="manage-agents"
/>
</div>
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{t('manageAgents')}
</p>
</NavLink>
</div>
</div>
) : (
<NavLink
to="/agents"
to={AGENTS_MANAGE_ROOT}
end
onClick={() => {
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' : ''
}`
}
>
<div className="flex w-6 justify-center">
<LayoutGrid
className="text-muted-foreground size-5"
strokeWidth={1.75}
aria-label="manage-agents"
/>
</div>
<LayoutGrid
className="text-muted-foreground size-5 shrink-0"
strokeWidth={1.75}
aria-label="manage-agents"
/>
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{t('manageAgents')}
</p>
</NavLink>
</div>
)}
{conversations?.data && conversations.data.length > 0 ? (
<div className="mt-7">
<div className="my-auto mt-2 ml-2.75 flex h-9 items-center justify-between gap-4 rounded-3xl p-1">
<p className="mt-1 ml-4 text-sm font-semibold">
{t('chats')}
</p>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setSearchOpen(true)}
className="text-muted-foreground hover:text-foreground hover:bg-sidebar-accent mr-1 rounded-full"
aria-label={t(
'modals.searchConversations.searchPlaceholder',
)}
title={t('modals.searchConversations.searchPlaceholder')}
>
<SearchIcon
className="size-5"
strokeWidth={1.75}
aria-label="search"
/>
</Button>
</div>
<div className="conversations-container">
{(conversations.data ?? []).map((conversation) => (
<ConversationTile
key={conversation.id}
conversation={conversation}
selectConversation={(id) => handleConversationClick(id)}
onConversationClick={() => {
if (isMobile) {
setNavOpen(false);
}
}}
onDeleteConversation={(id) =>
handleDeleteConversation(id)
}
onSave={(conversation) =>
updateConversationName(conversation)
}
/>
))}
</div>
</div>
) : (
<></>
)}
</div>
) : (
<NavLink
to="/agents"
end
onClick={() => {
if (isMobile || isTablet) {
setNavOpen(false);
</SidebarLevel>
<SidebarLevel depth={1} current={sidebarDepth}>
{topPanel && (
<SectionNav
section={topPanel}
activeItemKey={
topPanel === activeSection
? activeSectionItem?.key
: undefined
}
dispatch(setSelectedAgent(null));
}}
className={({ isActive }) =>
`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' : ''
}`
}
>
<LayoutGrid
className="text-muted-foreground size-5 shrink-0"
strokeWidth={1.75}
aria-label="manage-agents"
isAdmin={isAdmin}
onBack={exitSectionFrom(topPanel)}
backLabel={backLabelFor(topPanel)}
onNavigate={closeNavOnMobile}
/>
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{t('manageAgents')}
</p>
</NavLink>
)}
{conversations?.data && conversations.data.length > 0 ? (
<div className="mt-7">
<div className="my-auto mt-2 ml-2.75 p-1 flex h-9 items-center justify-between gap-4 rounded-3xl">
<p className="mt-1 ml-4 text-sm font-semibold">{t('chats')}</p>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setSearchOpen(true)}
className="mr-1 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent rounded-full"
aria-label={t('modals.searchConversations.searchPlaceholder')}
title={t('modals.searchConversations.searchPlaceholder')}
>
<SearchIcon
className="size-5"
strokeWidth={1.75}
aria-label="search"
/>
</Button>
</div>
<div className="conversations-container">
{(conversations.data ?? []).map((conversation) => (
<ConversationTile
key={conversation.id}
conversation={conversation}
selectConversation={(id) => handleConversationClick(id)}
onConversationClick={() => {
if (isMobile) {
setNavOpen(false);
}
}}
onDeleteConversation={(id) => handleDeleteConversation(id)}
onSave={(conversation) =>
updateConversationName(conversation)
}
/>
))}
</div>
</div>
) : (
<></>
)}
</div>
<div className="text-foreground flex h-auto flex-col justify-end dark:text-white">
<div className="dark:border-b-sidebar-border flex flex-col gap-2 border-b py-2">
<NavLink
onClick={() => {
if (isMobile || isTablet) {
setNavOpen(false);
)}
</SidebarLevel>
<SidebarLevel depth={2} current={sidebarDepth}>
{nestedPanel && (
<SectionNav
section={nestedPanel}
activeItemKey={
nestedPanel === activeSection
? activeSectionItem?.key
: undefined
}
resetConversation();
}}
isAdmin={isAdmin}
onBack={exitSectionFrom(nestedPanel)}
backLabel={backLabelFor(nestedPanel)}
onNavigate={closeNavOnMobile}
/>
)}
</SidebarLevel>
</div>
<div className="text-foreground flex h-auto shrink-0 flex-col justify-end dark:text-white">
{/* 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. */}
<div
className={cn(
'dark:border-b-sidebar-border flex flex-col gap-2 border-b py-2',
inSection && 'hidden',
)}
>
<Link
to="/settings"
className={({ isActive }) =>
`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"
>
<SettingsIcon
className="text-muted-foreground size-5 shrink-0"
@@ -635,7 +781,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<p className="text-foreground text-sm dark:text-white">
{t('settings.label')}
</p>
</NavLink>
</Link>
</div>
<div className="text-foreground flex flex-col justify-end dark:text-white">
<div className="flex items-center justify-between py-1">
@@ -698,7 +844,6 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
>
<Menu className="size-5" strokeWidth={1.75} />
</Button>
</div>
</div>
<ConfirmationModal
+26 -52
View File
@@ -1,13 +1,9 @@
import { useTranslation } from 'react-i18next';
import {
Navigate,
Route,
Routes,
useLocation,
useNavigate,
} from 'react-router-dom';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { Tabs, TabsList, TabsTrigger } from '../components/ui/tabs';
import { useMediaQuery } from '../hooks';
import SectionIndexPage from '../navigation/SectionIndexPage';
import { CurrentSectionHeader } from '../navigation/SectionPageHeader';
import { ADMIN_SECTION } from '../navigation/sections';
import Admins from './Admins';
import Audit from './Audit';
import Overview from './Overview';
@@ -15,60 +11,38 @@ import Quotas from './Quotas';
import Usage from './Usage';
import Users from './Users';
const TABS = [
{ key: 'overview', label: 'Overview', path: '/admin' },
{ key: 'users', label: 'Users', path: '/admin/users' },
{ key: 'admins', label: 'Admins', path: '/admin/roles' },
{ key: 'usage', label: 'Usage', path: '/admin/usage' },
{ key: 'quotas', label: 'Quotas', path: '/admin/quotas' },
{ key: 'audit', label: 'Audit', path: '/admin/audit' },
];
/**
* Admin dashboard (operator-level, global admin). Reached only via <AdminRoute>
* (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 (
<div className="h-full overflow-auto p-4 md:p-12">
<div className="mx-auto w-full max-w-7xl">
<p className="text-foreground text-2xl font-bold">
{t('admin.label', 'Admin')}
</p>
<Tabs
value={active}
onValueChange={(label) => {
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"
>
<TabsList aria-label={t('admin.tabsAriaLabel', 'Admin sections')}>
{TABS.map((tab) => (
<TabsTrigger key={tab.key} value={tab.label}>
{t(`admin.tabs.${tab.key}`, tab.label)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Routes>
<Route index element={<Overview />} />
<Route path="users" element={<Users />} />
<Route path="roles" element={<Admins />} />
<Route path="usage" element={<Usage />} />
<Route path="quotas" element={<Quotas />} />
<Route path="audit" element={<Audit />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
{showIndex ? (
<SectionIndexPage section={ADMIN_SECTION} />
) : (
<>
<CurrentSectionHeader />
<Routes>
<Route index element={<Overview />} />
<Route path="overview" element={<Overview />} />
<Route path="users" element={<Users />} />
<Route path="roles" element={<Admins />} />
<Route path="usage" element={<Usage />} />
<Route path="quotas" element={<Quotas />} />
<Route path="audit" element={<Audit />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
</>
)}
</div>
</div>
);
+19 -8
View File
@@ -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));
}
};
+38 -42
View File
@@ -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 (
<div className="p-4 pt-4 md:p-12 md:pt-4">
<AgentPageHeader
agentId={agentId}
agentName={agent?.name}
agentEditPath={agentEditPath}
currentPage="logs"
className="px-4"
/>
<div className="mt-6 flex flex-col gap-3 px-4">
{agent && (
<div className="flex flex-col gap-1">
<p className="text-foreground">{agent.name}</p>
<p className="text-muted-foreground text-xs">
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
</p>
<div className="h-full overflow-auto p-4 md:p-12">
<div className="mx-auto w-full max-w-6xl">
<CurrentSectionHeader />
<SectionPills className="mt-4" />
<div className="mt-6 flex flex-col gap-3">
{agent && (
<div className="flex flex-col gap-1">
<p className="text-foreground">{agent.name}</p>
<p className="text-muted-foreground text-xs">
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
</p>
</div>
)}
</div>
{loadingAgent ? (
<div className="flex h-[345px] w-full items-center justify-center">
<Spinner />
</div>
) : (
agent && <Analytics agentId={agent.id} />
)}
{!loadingAgent && agent && <GuardrailEvents agentId={agent.id} />}
{loadingAgent ? (
<div className="flex h-[55vh] w-full items-center justify-center">
<Spinner />
</div>
) : (
agent && (
<Logs
agentId={agent.id}
tableHeader={t('agents.logs.tableHeader')}
/>
)
)}
</div>
{loadingAgent ? (
<div className="flex h-[345px] w-full items-center justify-center">
<Spinner />
</div>
) : (
agent && <Analytics agentId={agent.id} />
)}
{!loadingAgent && agent && <GuardrailEvents agentId={agent.id} />}
{loadingAgent ? (
<div className="flex h-[55vh] w-full items-center justify-center">
<Spinner />
</div>
) : (
agent && (
<Logs agentId={agent.id} tableHeader={t('agents.logs.tableHeader')} />
)
)}
</div>
);
}
+14 -4
View File
@@ -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({
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/agents">{t('agents.pageHeader.crumbs.agents')}</Link>
<Link to={AGENTS_MANAGE_ROOT}>
{t('agents.pageHeader.crumbs.agents')}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
+142 -151
View File
@@ -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<string | null>(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 (
<div className="p-4 md:p-12">
<h1 className="text-foreground mb-0 text-3xl font-bold lg:text-4xl">
{t('agents.title')}
</h1>
<p className="text-muted-foreground mt-5 text-sm leading-6">
{t('agents.description')}
</p>
<div className="h-full overflow-auto p-4 md:p-12">
<div className="mx-auto w-full max-w-6xl">
<CurrentSectionHeader />
<p className="text-muted-foreground mt-5 text-sm leading-6">
{t('agents.description')}
</p>
<div className="mt-6 flex flex-col gap-4 pb-4">
<div className="w-full max-w-md">
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
label={t('agents.searchPlaceholder')}
labelBgClassName="bg-background"
className="rounded-full"
leftIcon={
<Search
className="text-muted-foreground size-4"
strokeWidth={1.75}
/>
<SectionPills className="mt-6" />
<div className="mt-6 flex flex-col gap-4 pb-4">
<div className="w-full max-w-md">
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
label={t('agents.searchPlaceholder')}
labelBgClassName="bg-background"
className="rounded-full"
leftIcon={
<Search
className="text-muted-foreground size-4"
strokeWidth={1.75}
/>
}
/>
</div>
</div>
{visibleSections.map((sectionConfig) => (
<AgentSection
key={sectionConfig.id}
config={sectionConfig}
filteredAgents={
filteredAgentsBySection[sectionConfig.id as AgentSectionId]
}
totalAgents={
totalAgentsBySection[sectionConfig.id as AgentSectionId]
}
searchQuery={searchQuery}
isFilteredView={activeFilter !== 'all'}
isLoading={isLoading[sectionConfig.id as AgentSectionId]}
folders={sectionConfig.id === 'user' ? folders : null}
folderPath={sectionConfig.id === 'user' ? folderPath : []}
onFolderPathChange={
sectionConfig.id === 'user' ? setFolderPath : undefined
}
onCreateFolder={handleSubmitNewFolder}
onDeleteFolder={handleDeleteFolder}
onRenameFolder={handleRenameFolder}
setModalFolderId={setModalFolderId}
setShowAgentTypeModal={setShowAgentTypeModal}
/>
</div>
))}
<div className="flex flex-wrap gap-2">
{FILTER_TABS.map((tab) => (
<Button
key={tab.id}
type="button"
variant="ghost"
onClick={() => setActiveFilter(tab.id)}
className={`rounded-full ${
activeFilter === tab.id
? 'bg-border text-foreground dark:bg-accent dark:text-white'
: 'dark:text-gray text-muted-foreground hover:bg-accent/50'
}`}
>
{t(tab.labelKey)}
</Button>
))}
</div>
</div>
{showSearchEmptyState && (
<div className="text-muted-foreground mt-12 flex flex-col items-center justify-center gap-2">
<p className="text-lg">{t('agents.noSearchResults')}</p>
<p className="text-sm">{t('agents.tryDifferentSearch')}</p>
</div>
)}
{visibleSections.map((sectionConfig) => (
<AgentSection
key={sectionConfig.id}
config={sectionConfig}
filteredAgents={
filteredAgentsBySection[sectionConfig.id as AgentSectionId]
}
totalAgents={totalAgentsBySection[sectionConfig.id as AgentSectionId]}
searchQuery={searchQuery}
isFilteredView={activeFilter !== 'all'}
isLoading={isLoading[sectionConfig.id as AgentSectionId]}
folders={sectionConfig.id === 'user' ? folders : null}
folderPath={sectionConfig.id === 'user' ? folderPath : []}
onFolderPathChange={
sectionConfig.id === 'user' ? setFolderPath : undefined
}
onCreateFolder={handleSubmitNewFolder}
onDeleteFolder={handleDeleteFolder}
onRenameFolder={handleRenameFolder}
setModalFolderId={setModalFolderId}
setShowAgentTypeModal={setShowAgentTypeModal}
<AgentTypeModal
isOpen={showAgentTypeModal}
onClose={() => setShowAgentTypeModal(false)}
folderId={modalFolderId}
/>
))}
{showSearchEmptyState && (
<div className="text-muted-foreground mt-12 flex flex-col items-center justify-center gap-2">
<p className="text-lg">{t('agents.noSearchResults')}</p>
<p className="text-sm">{t('agents.tryDifferentSearch')}</p>
</div>
)}
<AgentTypeModal
isOpen={showAgentTypeModal}
onClose={() => setShowAgentTypeModal(false)}
folderId={modalFolderId}
/>
</div>
</div>
);
}
@@ -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 = () => (
<svg
width="6"
height="10"
viewBox="0 0 6 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.54027 4.45973C5.68108 4.60058 5.76018 4.79159 5.76018 4.99075C5.76018 5.18992 5.68108 5.38092 5.54027 5.52177L1.29134 9.7707C1.22206 9.84244 1.13918 9.89966 1.04754 9.93902C0.955906 9.97839 0.857348 9.9991 0.757618 9.99997C0.657889 10.0008 0.558986 9.98183 0.466679 9.94407C0.374373 9.9063 0.290512 9.85053 0.21999 9.78001C0.149467 9.70949 0.0936966 9.62563 0.055931 9.53332C0.0181655 9.44101 -0.000838292 9.34211 2.83259e-05 9.24238C0.000894943 9.14265 0.0216148 9.04409 0.0609787 8.95246C0.100343 8.86082 0.157562 8.77794 0.229299 8.70866L3.9472 4.99075L0.229299 1.27285C0.0924814 1.13119 0.0167756 0.941464 0.0184869 0.744531C0.0201982 0.547597 0.0991896 0.359213 0.238448 0.219954C0.377707 0.0806961 0.56609 0.00170419 0.763024 -7.66275e-06C0.959958 -0.00171856 1.14969 0.073987 1.29134 0.210805L5.54027 4.45973Z"
fill="currentColor"
fillOpacity="0.5"
/>
</svg>
);
return (
<div className="mt-8 flex flex-col gap-4">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-2">
<h2 className="text-foreground flex flex-wrap items-center gap-2 text-lg font-semibold">
{config.id === 'user' && folderPath.length > 0 ? (
<>
<Button
type="button"
variant="link"
onClick={() => handleNavigateToPath(-1)}
className="text-muted-foreground hover:text-foreground h-auto p-0 text-lg font-semibold no-underline hover:no-underline dark:hover:text-white"
>
{t(`agents.sections.${config.id}.title`)}
</Button>
{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".
<Breadcrumb>
<BreadcrumbList className="text-foreground gap-2 text-lg font-semibold sm:gap-2">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<button
type="button"
onClick={() => handleNavigateToPath(-1)}
>
{t(`agents.sections.${config.id}.title`)}
</button>
</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbItems.map((item, index) => (
<span key={item.id} className="flex items-center gap-2">
<ChevronIcon />
{index === breadcrumbItems.length - 1 ? (
<span>{item.name}</span>
) : (
<Button
type="button"
variant="link"
onClick={() => handleNavigateToPath(index)}
className="text-muted-foreground hover:text-foreground h-auto p-0 text-lg font-semibold no-underline hover:no-underline dark:hover:text-white"
>
{item.name}
</Button>
)}
</span>
<Fragment key={item.id}>
<BreadcrumbSeparator />
<BreadcrumbItem>
{index === breadcrumbItems.length - 1 ? (
<BreadcrumbPage>{item.name}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<button
type="button"
onClick={() => handleNavigateToPath(index)}
>
{item.name}
</button>
</BreadcrumbLink>
)}
</BreadcrumbItem>
</Fragment>
))}
</>
) : (
t(`agents.sections.${config.id}.title`)
)}
</h2>
</BreadcrumbList>
</Breadcrumb>
) : (
<h2 className="text-foreground text-lg font-semibold">
{t(`agents.sections.${config.id}.title`)}
</h2>
)}
<p className="text-muted-foreground text-sm">
{t(`agents.sections.${config.id}.description`)}
</p>
+10 -16
View File
@@ -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 (
<div className="flex flex-col px-4 pt-4 pb-2 max-[1179px]:min-h-dvh min-[1180px]:h-dvh md:px-12 md:pt-4 md:pb-3">
<div className="flex flex-col p-4 pb-2 max-[1179px]:min-h-dvh min-[1180px]:h-dvh md:p-12 md:pt-4 md:pb-3">
{agent.agent_type === 'workflow' && (
<div className="mt-4 w-full">
<WorkflowBuilder />
</div>
)}
<div className="flex w-full flex-wrap items-center justify-between gap-2 px-4">
{showAgentNav ? (
<AgentPageHeader
agentId={agent.id}
agentName={agent.name}
agentEditPath={`/agents/edit/${agent.id}`}
currentPage="overview"
/>
) : (
<span aria-hidden />
)}
<div className="flex w-full flex-wrap items-center justify-between gap-2">
{showAgentNav ? <CurrentSectionHeader /> : <span aria-hidden />}
<div className="flex flex-wrap items-center gap-2">
{submitError && (
<div
@@ -895,6 +888,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
)}
</div>
</div>
{showAgentNav && <SectionPills className="mt-4" />}
<div className="bg-muted dark:bg-background mt-3 flex w-full flex-1 grid-cols-5 flex-col gap-10 rounded-2xl p-5 max-[1179px]:overflow-visible min-[1180px]:grid min-[1180px]:gap-5 min-[1180px]:overflow-hidden">
<div className="scrollbar-overlay col-span-2 flex flex-col gap-5 max-[1179px]:overflow-visible min-[1180px]:max-h-full min-[1180px]:overflow-y-auto min-[1180px]:pr-3">
<div className="bg-card rounded-2xl px-6 py-3">
+3 -1
View File
@@ -1,7 +1,9 @@
import { Navigate, useParams } from 'react-router-dom';
import { sharedAgentPath } from './paths';
export default function SharedAgentGate() {
const { agentId } = useParams();
return <Navigate to={`/agents/shared/${agentId}`} replace />;
return <Navigate to={sharedAgentPath(agentId ?? '')} replace />;
}
@@ -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();
};
+8 -10
View File
@@ -16,8 +16,6 @@ export type AgentsBySection = Record<AgentSectionId, Agent[]>;
interface UseAgentSearchResult {
searchQuery: string;
setSearchQuery: (query: string) => void;
activeFilter: AgentFilterTab;
setActiveFilter: (filter: AgentFilterTab) => void;
filteredAgentsBySection: AgentsBySection;
totalAgentsBySection: Record<AgentSectionId, number>;
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<AgentFilterTab>('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<AgentSectionId, boolean> => ({
template: templateAgents !== null,
@@ -143,8 +143,6 @@ export function useAgentSearch(): UseAgentSearchResult {
return {
searchQuery,
setSearchQuery: handleSearchChange,
activeFilter,
setActiveFilter: handleFilterChange,
filteredAgentsBySection,
totalAgentsBySection,
hasAnyAgents,
+42 -9
View File
@@ -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 (
<Navigate to={`${AGENTS_MANAGE_ROOT}${suffix}${location.search}`} replace />
);
}
export default function Agents() {
return (
<Routes>
<Route path="/" element={<AgentsList />} />
<Route path="/new" element={<NewAgent mode="new" />} />
<Route path="/edit/:agentId" element={<NewAgent mode="edit" />} />
<Route path="/logs/:agentId" element={<AgentLogs />} />
<Route path="/schedules/:agentId" element={<SchedulesView />} />
<Route path="/shared/:agentId" element={<SharedAgent />} />
<Route path="/workflow/new" element={<WorkflowBuilder />} />
<Route path="/workflow/edit/:agentId" element={<WorkflowBuilder />} />
{/* Managing agents. */}
<Route path="manage" element={<AgentsList />} />
<Route path="manage/templates" element={<AgentsList />} />
<Route path="manage/mine" element={<AgentsList />} />
<Route path="manage/team" element={<AgentsList />} />
<Route path="manage/discovered" element={<AgentsList />} />
<Route path="manage/new" element={<NewAgent mode="new" />} />
<Route path="manage/edit/:agentId" element={<NewAgent mode="edit" />} />
<Route path="manage/logs/:agentId" element={<AgentLogs />} />
<Route path="manage/schedules/:agentId" element={<SchedulesView />} />
<Route path="manage/workflow/new" element={<WorkflowBuilder />} />
<Route
path="manage/workflow/edit/:agentId"
element={<WorkflowBuilder />}
/>
{/* Using an agent someone shared. */}
<Route path="shared/:agentId" element={<SharedAgent />} />
{/* Pre-split URLs. `/agents` keeps its `?folder=` on the way through. */}
<Route index element={<LegacyManageRedirect />} />
<Route path="new" element={<LegacyManageRedirect />} />
<Route path="edit/:agentId" element={<LegacyManageRedirect />} />
<Route path="logs/:agentId" element={<LegacyManageRedirect />} />
<Route path="schedules/:agentId" element={<LegacyManageRedirect />} />
<Route path="workflow/*" element={<LegacyManageRedirect />} />
<Route path="*" element={<Navigate to={AGENTS_MANAGE_ROOT} replace />} />
</Routes>
);
}
+80
View File
@@ -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();
});
});
+123
View File
@@ -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<Exclude<AgentFilterTab, 'all'>, 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<string, AgentFilterTab>;
export const isWorkflowAgent = (agent: Pick<Agent, 'agent_type'>): 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<Agent, 'id' | 'agent_type'>,
): 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/`),
};
}
+209 -216
View File
@@ -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 (
<div className="p-4 pt-4 md:p-12 md:pt-4">
<AgentPageHeader
agentId={agentId}
agentName={agent?.name}
agentEditPath={agentEditPath}
currentPage="schedules"
className="px-4"
/>
<div className="mt-6 flex flex-col gap-3 px-4">
{agent && (
<div className="flex flex-col gap-1">
<p className="text-foreground">{agent.name}</p>
<p className="text-muted-foreground text-xs">
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
</p>
</div>
)}
</div>
{loadingAgent ? (
<div className="flex h-[55vh] w-full items-center justify-center">
<Spinner />
<div className="h-full overflow-auto p-4 md:p-12">
<div className="mx-auto w-full max-w-6xl">
<CurrentSectionHeader />
<SectionPills className="mt-4" />
<div className="mt-6 flex flex-col gap-3">
{agent && (
<div className="flex flex-col gap-1">
<p className="text-foreground">{agent.name}</p>
<p className="text-muted-foreground text-xs">
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
</p>
</div>
)}
</div>
) : (
agent && (
<div className="flex flex-col gap-4 p-4">
<header className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
{t('agents.schedules.heading')}
</h2>
<Button type="button" size="sm" onClick={openCreate}>
{t('agents.schedules.newRecurring')}
</Button>
</header>
<section>
<h3 className="text-muted-foreground mb-2 text-sm font-semibold uppercase">
{t('agents.schedules.recurring')} ({recurring.length})
</h3>
{recurring.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('agents.schedules.noRecurring')}
</p>
) : (
<ul className="flex flex-col gap-3">
{recurring.map((schedule) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
{formatCron(schedule.cron)} · tz:{' '}
{schedule.timezone} · next:{' '}
{formatTimestamp(schedule.next_run_at)}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
onClick={() => openEdit(schedule)}
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
>
{t('agents.schedules.edit')}
</Button>
<Button
type="button"
size="sm"
onClick={() =>
dispatch(
setSchedulePaused({
id: schedule.id,
action:
schedule.status === 'active'
? 'pause'
: 'resume',
token,
}),
)
}
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
>
{schedule.status === 'active'
? t('agents.schedules.pause')
: t('agents.schedules.resume')}
</Button>
<Button
type="button"
size="sm"
onClick={() =>
dispatch(
runScheduleNow({ id: schedule.id, token }),
)
}
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
>
{t('agents.schedules.runNow')}
</Button>
<Button
type="button"
variant="destructive-outline"
size="sm"
onClick={() => requestDelete(schedule)}
className="rounded-full px-5"
>
{t('agents.schedules.delete')}
</Button>
</div>
</div>
<Button
type="button"
variant="link"
size="sm"
onClick={() =>
setExpanded(
expanded === schedule.id ? null : schedule.id,
)
}
className="mt-2 h-auto p-0 text-xs underline"
{loadingAgent ? (
<div className="flex h-[55vh] w-full items-center justify-center">
<Spinner />
</div>
) : (
agent && (
<div className="flex flex-col gap-4 p-4">
<header className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
{t('agents.schedules.heading')}
</h2>
<Button type="button" size="sm" onClick={openCreate}>
{t('agents.schedules.newRecurring')}
</Button>
</header>
<section>
<h3 className="text-muted-foreground mb-2 text-sm font-semibold uppercase">
{t('agents.schedules.recurring')} ({recurring.length})
</h3>
{recurring.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('agents.schedules.noRecurring')}
</p>
) : (
<ul className="flex flex-col gap-3">
{recurring.map((schedule) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3"
>
{expanded === schedule.id
? t('agents.schedules.hideRuns')
: t('agents.schedules.showRuns')}
</Button>
{expanded === schedule.id && (
<div className="mt-2">
<RunLog
scheduleId={schedule.id}
onSelect={(run) => setActiveRun(run)}
/>
</div>
)}
</li>
))}
</ul>
)}
</section>
<section>
<h3 className="text-muted-foreground mb-2 text-sm font-semibold uppercase">
{t('agents.schedules.oneTime')} ({oneTime.length})
</h3>
{oneTime.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('agents.schedules.noOneTime')}
</p>
) : (
<ul className="flex flex-col gap-2">
{oneTime.map((schedule) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3 text-sm"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
{formatCron(schedule.cron)} · tz:{' '}
{schedule.timezone} · next:{' '}
{formatTimestamp(schedule.next_run_at)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
runs at {formatTimestamp(schedule.run_at)}
</p>
</div>
<div className="flex gap-2">
{schedule.status === 'active' && (
<div className="flex gap-2">
<Button
type="button"
size="sm"
@@ -338,8 +220,39 @@ export default function SchedulesView() {
>
{t('agents.schedules.edit')}
</Button>
)}
{schedule.status === 'active' && (
<Button
type="button"
size="sm"
onClick={() =>
dispatch(
setSchedulePaused({
id: schedule.id,
action:
schedule.status === 'active'
? 'pause'
: 'resume',
token,
}),
)
}
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
>
{schedule.status === 'active'
? t('agents.schedules.pause')
: t('agents.schedules.resume')}
</Button>
<Button
type="button"
size="sm"
onClick={() =>
dispatch(
runScheduleNow({ id: schedule.id, token }),
)
}
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
>
{t('agents.schedules.runNow')}
</Button>
<Button
type="button"
variant="destructive-outline"
@@ -347,43 +260,123 @@ export default function SchedulesView() {
onClick={() => requestDelete(schedule)}
className="rounded-full px-5"
>
{t('agents.schedules.cancel')}
{t('agents.schedules.delete')}
</Button>
)}
</div>
</div>
</div>
</li>
))}
</ul>
)}
</section>
<RunDetailDrawer
run={activeRun}
onClose={() => setActiveRun(null)}
/>
{modalOpen && (
<ScheduleFormModal
key={editing?.id ?? 'create'}
open={modalOpen}
initial={editing}
agentToolIds={agentToolIds}
onClose={closeModal}
onSubmit={handleSubmit}
submitting={submitting}
<Button
type="button"
variant="link"
size="sm"
onClick={() =>
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')}
</Button>
{expanded === schedule.id && (
<div className="mt-2">
<RunLog
scheduleId={schedule.id}
onSelect={(run) => setActiveRun(run)}
/>
</div>
)}
</li>
))}
</ul>
)}
</section>
<section>
<h3 className="text-muted-foreground mb-2 text-sm font-semibold uppercase">
{t('agents.schedules.oneTime')} ({oneTime.length})
</h3>
{oneTime.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('agents.schedules.noOneTime')}
</p>
) : (
<ul className="flex flex-col gap-2">
{oneTime.map((schedule) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3 text-sm"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
runs at {formatTimestamp(schedule.run_at)}
</p>
</div>
<div className="flex gap-2">
{schedule.status === 'active' && (
<Button
type="button"
size="sm"
onClick={() => openEdit(schedule)}
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
>
{t('agents.schedules.edit')}
</Button>
)}
{schedule.status === 'active' && (
<Button
type="button"
variant="destructive-outline"
size="sm"
onClick={() => requestDelete(schedule)}
className="rounded-full px-5"
>
{t('agents.schedules.cancel')}
</Button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</section>
<RunDetailDrawer
run={activeRun}
onClose={() => setActiveRun(null)}
/>
)}
<ConfirmationModal
message={t('agents.schedules.deleteConfirm')}
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel={t('agents.schedules.delete')}
handleSubmit={confirmDelete}
handleCancel={() => setScheduleToDelete(null)}
variant="danger"
/>
</div>
)
)}
{modalOpen && (
<ScheduleFormModal
key={editing?.id ?? 'create'}
open={modalOpen}
initial={editing}
agentToolIds={agentToolIds}
onClose={closeModal}
onSubmit={handleSubmit}
submitting={submitting}
/>
)}
<ConfirmationModal
message={t('agents.schedules.deleteConfirm')}
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel={t('agents.schedules.delete')}
handleSubmit={confirmDelete}
handleCancel={() => setScheduleToDelete(null)}
variant="danger"
/>
</div>
)
)}
</div>
</div>
);
}
@@ -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() {
<AgentPageHeader
agentId={effectiveAgentId}
agentName={workflowName}
agentEditPath={`/agents/workflow/edit/${effectiveAgentId}`}
agentEditPath={agentEditPath(effectiveAgentId, true)}
currentPage="overview"
inline
/>
+3 -7
View File
@@ -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
}
/>
+20 -4
View File
@@ -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);
+12 -7
View File
@@ -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": {
+23 -7
View File
@@ -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"
}
}
}
+12 -7
View File
@@ -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": {
+12 -7
View File
@@ -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": {
+12 -7
View File
@@ -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": {
+12 -7
View File
@@ -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": {
+12 -7
View File
@@ -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": {
+3 -2
View File
@@ -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);
@@ -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 (
<Breadcrumb className={cn('min-w-0', className)}>
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<button type="button" onClick={onParentClick}>
{parentLabel}
</button>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage
title={currentLabel}
className="max-w-[32ch] truncate"
>
{currentLabel}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}
@@ -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 (
<div className="flex flex-col">
<h1 className="text-foreground dark:text-foreground text-2xl font-bold">
{section.title ?? t(section.titleKey)}
</h1>
<div className="mt-6 flex flex-col gap-6">
{groups.map((group) => (
<div key={group.key} className="flex flex-col gap-1">
{group.labelKey && (
<p className="text-muted-foreground mb-1 text-xs font-medium">
{t(group.labelKey)}
</p>
)}
<div className="border-border divide-border divide-y overflow-hidden rounded-2xl border">
{group.items.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.key}
to={item.path}
className="hover:bg-muted dark:hover:bg-accent flex items-center gap-3 px-4 py-3.5"
>
<Icon
className="text-muted-foreground size-5 shrink-0"
strokeWidth={1.75}
aria-hidden
/>
<span className="text-foreground dark:text-foreground flex-1 text-sm">
{t(item.labelKey)}
</span>
<ChevronRight
className="text-muted-foreground size-4 shrink-0"
strokeWidth={1.75}
aria-hidden
/>
</Link>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
+114
View File
@@ -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 (
<Link
key={item.key}
to={item.path}
onClick={(event) => {
// 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',
)}
>
<Icon
className="text-muted-foreground size-5 shrink-0"
strokeWidth={1.75}
aria-hidden
/>
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{t(item.labelKey)}
</p>
</Link>
);
};
return (
<div className="flex h-full flex-col">
<button
type="button"
onClick={onBack}
className="group border-sidebar-border hover:border-sidebar-border mx-4 mt-4 flex shrink-0 cursor-pointer items-center gap-2.5 rounded-3xl border p-3 text-left"
>
<ArrowLeft
className="text-muted-foreground group-hover:text-foreground size-5 shrink-0"
strokeWidth={1.75}
aria-hidden
/>
<p className="text-muted-foreground group-hover:text-foreground text-sm">
{backLabel}
</p>
</button>
<p
className="text-foreground mt-6 ml-8 shrink-0 truncate pr-4 text-sm font-semibold dark:text-white"
title={sectionTitle}
>
{sectionTitle}
</p>
<nav
aria-label={sectionTitle}
className="scrollbar-overlay mt-3 flex-1 overflow-x-hidden overflow-y-auto pb-4"
>
{groups.map((group) => (
<div key={group.key} className="mt-5 first:mt-0">
{group.labelKey && (
<p className="text-muted-foreground mb-1 ml-8 text-xs font-medium">
{t(group.labelKey)}
</p>
)}
{group.items.map(renderItem)}
</div>
))}
</nav>
</div>
);
}
@@ -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 (
<Link
to={to}
className={cn(
'text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-2 text-sm',
className,
)}
>
<ArrowLeft className="size-4 shrink-0" strokeWidth={1.75} aria-hidden />
{parent ? t(parent.titleKey) : (section.title ?? t(section.titleKey))}
</Link>
);
}
/**
* 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 (
<div className={cn('flex flex-col', className)}>
<SectionBackLink section={section} />
<h1 className="text-foreground dark:text-foreground text-2xl font-bold">
{item && section.pageTitle !== 'section'
? t(item.labelKey)
: (section.title ?? t(section.titleKey))}
</h1>
</div>
);
}
/**
* 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 (
<SectionPageHeader section={section} item={item} className={className} />
);
}
+59
View File
@@ -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 (
<div
className={cn(
'no-scrollbar flex gap-2 overflow-x-auto lg:hidden',
className,
)}
>
{items.map((entry) => {
const isActive = entry.key === item?.key;
return (
<Link
key={entry.key}
to={entry.path}
aria-current={isActive ? 'page' : undefined}
className={cn(
'rounded-full px-4 py-2 text-sm whitespace-nowrap transition-colors',
isActive
? 'bg-border text-foreground dark:bg-accent dark:text-white'
: 'text-muted-foreground hover:bg-accent/50 dark:text-gray',
)}
>
{t(entry.labelKey)}
</Link>
);
})}
</div>
);
}
+82
View File
@@ -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.
<div
key={section.key}
className="animate-in fade-in flex flex-col items-center gap-2 duration-200 motion-reduce:animate-none"
>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onBack}
aria-label={backLabel}
title={backLabel}
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-5" strokeWidth={1.75} />
</Button>
<div className="bg-border my-1 h-px w-6 shrink-0" aria-hidden />
{items.map((item) => {
const label = t(item.labelKey);
const isActive = item.key === activeItemKey;
const Icon = item.icon;
return (
<Link
key={item.key}
to={item.path}
onClick={(event) => {
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',
)}
>
<Icon className="size-5" strokeWidth={1.75} aria-hidden />
</Link>
);
})}
</div>
);
}
+73
View File
@@ -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 (
<div
aria-hidden={offset !== 0}
className={cn(
'bg-sidebar absolute inset-0 flex flex-col',
'transition-[translate,opacity,visibility] duration-400',
// Decelerating, and tuned against where the travel actually lands:
// half the distance by ~65ms so the panel tracks the click, 90% by
// ~180ms so the movement reads as movement, settled by ~300ms. A
// sharper curve (the usual 0.32,0.72,0,1) covers 90% in 110ms at this
// duration, which registers as a cut rather than a slide; an even
// one (0.4,0,0.2,1) takes 105ms just to reach halfway and feels like
// it is lagging behind the pointer.
'ease-[cubic-bezier(0.25,0.8,0.25,1)] motion-reduce:transition-none',
// The arriving panel carries a shadow off its leading edge, which
// the container clips once it comes to rest — so the layering only
// shows while there is layering to show.
offset === 0 &&
'visible translate-x-0 opacity-100 shadow-[-12px_0_24px_-6px_rgba(0,0,0,0.45)]',
// Dimmed rather than faded out: at rest the panel in front covers it
// completely, so there is nothing to hide, and keeping it legible
// while it trails is the whole point of the shorter travel.
offset < 0 && 'invisible -translate-x-1/4 opacity-50',
offset > 0 && 'invisible translate-x-full opacity-100',
className,
)}
>
{children}
</div>
);
}
@@ -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<SidebarLevelValue>({
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<PendingLevel | null>(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 (
<SidebarLevelContext.Provider value={{ pending, goToLevel }}>
{children}
</SidebarLevelContext.Provider>
);
}
export const useSidebarLevel = () => useContext(SidebarLevelContext);
+157
View File
@@ -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);
});
});
+387
View File
@@ -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;
+20
View File
@@ -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;
}
@@ -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,
};
}
@@ -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],
);
}
+9 -14
View File
@@ -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 (
<div className="scrollbar-overlay mt-8 flex flex-col gap-6">
<div className="mb-4 flex items-center justify-between">
<div className="text-foreground dark:text-foreground flex items-center gap-3 text-sm">
<Button
type="button"
variant="outline"
size="icon"
className="text-muted-foreground rounded-full p-3"
onClick={handleGoBack}
>
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</Button>
<p className="mt-px">{t('settings.tools.backToAllTools')}</p>
</div>
<div className="mb-4 flex items-center justify-between gap-3">
<DetailBreadcrumb
parentLabel={t('settings.tools.label')}
currentLabel={
device?.name || tool.customName || tool.displayName || tool.name
}
onParentClick={handleGoBack}
/>
<Button
type="button"
className="rounded-full px-3 py-2 text-xs text-nowrap text-white sm:px-4 sm:py-2"
+11 -10
View File
@@ -1,5 +1,4 @@
import {
ArrowLeft,
Bot,
ChevronRight,
FileText,
@@ -24,6 +23,9 @@ import userService from '../api/services/userService';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import NoFilesIcon from '../assets/no-files.svg';
import SkeletonLoader from '../components/SkeletonLoader';
import DetailBreadcrumb from '../navigation/DetailBreadcrumb';
import { SectionBackLink } from '../navigation/SectionPageHeader';
import { SETTINGS_SECTION } from '../navigation/sections';
import { Avatar } from '../components/ui/avatar';
import { Button } from '../components/ui/button';
import {
@@ -511,6 +513,9 @@ export default function Teams() {
return (
<div className="h-full overflow-auto p-4 md:p-12">
<div className="mx-auto w-full max-w-5xl">
<SectionBackLink section={SETTINGS_SECTION} />
</div>
<div className="mx-auto flex w-full max-w-5xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-foreground text-2xl font-bold">
@@ -607,20 +612,16 @@ export default function Teams() {
</div>
) : (
<div className="mx-auto mt-8 max-w-5xl space-y-6">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground -ml-2"
onClick={() => {
<DetailBreadcrumb
parentLabel={t('settings.teams.label')}
currentLabel={selected.name}
onParentClick={() => {
setSelected(null);
setMembers([]);
setGrants([]);
setError(null);
}}
>
<ArrowLeft size={16} strokeWidth={1.75} aria-hidden />
{t('settings.teams.backToTeams')}
</Button>
/>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
+7 -14
View File
@@ -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 (
<div className="scrollbar-overlay mt-8 flex flex-col gap-4">
<div className="mb-4 flex items-center justify-between">
<div className="text-foreground dark:text-foreground flex items-center gap-3 text-sm">
<Button
type="button"
variant="outline"
size="icon"
className="text-muted-foreground rounded-full p-3"
onClick={handleBackClick}
>
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</Button>
<p className="mt-px">{t('settings.tools.backToAllTools')}</p>
</div>
<div className="mb-4 flex items-center justify-between gap-3">
<DetailBreadcrumb
parentLabel={t('settings.tools.label')}
currentLabel={tool.customName || tool.displayName || tool.name}
onParentClick={handleBackClick}
/>
<Button
type="button"
className="rounded-full px-3 py-2 text-xs text-nowrap text-white sm:px-4 sm:py-2"
+47 -157
View File
@@ -1,21 +1,12 @@
import React, { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import {
Navigate,
Route,
Routes,
useLocation,
useNavigate,
} from 'react-router-dom';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import userService from '../api/services/userService';
import ArrowLeft from '../assets/arrow-left.svg';
import ArrowRight from '../assets/arrow-right.svg';
import { Button } from '../components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '../components/ui/tabs';
import i18n from '../locale/i18n';
import { useMediaQuery } from '../hooks';
import { Doc } from '../models/misc';
import SectionIndexPage from '../navigation/SectionIndexPage';
import { CurrentSectionHeader } from '../navigation/SectionPageHeader';
import { SETTINGS_SECTION } from '../navigation/sections';
import {
selectPaginatedDocuments,
selectSourceDocs,
@@ -25,90 +16,25 @@ import {
} from '../preferences/preferenceSlice';
import Analytics from './Analytics';
import CustomModels from './CustomModels';
import Sources from './Sources';
import General from './General';
import Logs from './Logs';
import PersonalAccessTokens from './PersonalAccessTokens';
import Sources from './Sources';
import Tools from './Tools';
type HiddenGradientType = 'left' | 'right' | undefined;
/**
* Settings shell. The section's destinations live in the sidebar
* (see `navigation/SectionNav`), so this only renders the active page and
* its title — except below `lg`, where the sidebar is an overlay and
* `/settings` shows the destination list as page content instead.
*/
export default function Settings() {
const dispatch = useDispatch();
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { isMobile, isTablet } = useMediaQuery();
const getActiveTabFromPath = () => {
const path = location.pathname;
if (path.includes('/settings/sources')) return t('settings.sources.label');
if (path.includes('/settings/analytics'))
return t('settings.analytics.label');
if (path.includes('/settings/logs')) return t('settings.logs.label');
if (path.includes('/settings/tools')) return t('settings.tools.label');
if (path.includes('/settings/custom-models'))
return t('settings.customModels.label');
if (path.includes('/settings/access-tokens'))
return t('settings.accessTokens.label');
return t('settings.general.label');
};
const [activeTab, setActiveTab] = React.useState(getActiveTabFromPath());
const tabsList = [
t('settings.general.label'),
t('settings.sources.label'),
t('settings.analytics.label'),
t('settings.logs.label'),
t('settings.tools.label'),
t('settings.customModels.label'),
t('settings.accessTokens.label'),
];
const [hiddenGradient, setHiddenGradient] =
useState<HiddenGradientType>('left');
const containerRef = useRef<null | HTMLDivElement>(null);
const scrollTabs = useCallback(
(direction: number) => {
if (containerRef.current) {
const container = containerRef.current;
container.scrollLeft += direction * 100;
if (container.scrollLeft === 0) {
setHiddenGradient('left');
} else if (
container.scrollLeft + container.offsetWidth ===
container.scrollWidth
) {
setHiddenGradient('right');
} else {
setHiddenGradient(undefined);
}
}
},
[containerRef],
);
const handleTabChange = (tab: string) => {
setActiveTab(tab);
if (tab === t('settings.general.label')) navigate('/settings');
else if (tab === t('settings.sources.label')) navigate('/settings/sources');
else if (tab === t('settings.analytics.label'))
navigate('/settings/analytics');
else if (tab === t('settings.logs.label')) navigate('/settings/logs');
else if (tab === t('settings.tools.label')) navigate('/settings/tools');
else if (tab === t('settings.customModels.label'))
navigate('/settings/custom-models');
else if (tab === t('settings.accessTokens.label'))
navigate('/settings/access-tokens');
};
React.useEffect(() => {
setActiveTab(getActiveTabFromPath());
}, [location.pathname]);
React.useEffect(() => {
const newActiveTab = getActiveTabFromPath();
setActiveTab(newActiveTab);
}, [i18n.language]);
const showIndex =
(isMobile || isTablet) && location.pathname === SETTINGS_SECTION.rootPath;
const token = useSelector(selectToken);
const documents = useSelector(selectSourceDocs);
@@ -139,74 +65,38 @@ export default function Settings() {
return (
<div className="h-full overflow-auto p-4 md:p-12">
<p className="text-foreground dark:text-foreground text-2xl font-bold">
{t('settings.label')}
</p>
<Tabs
value={activeTab}
onValueChange={(tab) => handleTabChange(tab)}
className="relative mt-6 flex flex-row items-center space-x-1 overflow-auto md:space-x-0"
>
<div
className={`${hiddenGradient === 'left' ? 'hidden' : ''} dark:from-background pointer-events-none absolute inset-y-0 left-6 w-14 bg-linear-to-r from-white md:hidden`}
></div>
<div
className={`${hiddenGradient === 'right' ? 'hidden' : ''} dark:from-background pointer-events-none absolute inset-y-0 right-6 w-14 bg-linear-to-l from-white md:hidden`}
></div>
<div className="z-10 md:hidden">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => scrollTabs(-1)}
className="hover:bg-muted dark:hover:bg-accent h-6 w-6 rounded-full"
aria-label={t('settings.scrollTabsLeft')}
>
<img src={ArrowLeft} alt="left-arrow" className="h-3" />
</Button>
</div>
<TabsList ref={containerRef} aria-label={t('settings.tabsAriaLabel')}>
{tabsList.map((tab) => (
<TabsTrigger key={tab} value={tab}>
{tab}
</TabsTrigger>
))}
</TabsList>
<div className="z-10 md:hidden">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => scrollTabs(1)}
className="hover:bg-muted dark:hover:bg-accent h-6 w-6 rounded-full"
aria-label={t('settings.scrollTabsRight')}
>
<img src={ArrowRight} alt="right-arrow" className="h-3" />
</Button>
</div>
</Tabs>
<Routes>
<Route index element={<General />} />
<Route
path="sources"
element={
<Sources
paginatedDocuments={paginatedDocuments}
handleDeleteDocument={handleDeleteClick}
/>
}
/>
<Route path="analytics" element={<Analytics />} />
<Route path="logs" element={<Logs />} />
<Route path="tools" element={<Tools />} />
<Route
path="devices"
element={<Navigate to="/settings/tools" replace />}
/>
<Route path="custom-models" element={<CustomModels />} />
<Route path="access-tokens" element={<PersonalAccessTokens />} />
<Route path="*" element={<Navigate to="/settings" replace />} />
</Routes>
<div className="mx-auto w-full max-w-6xl">
{showIndex ? (
<SectionIndexPage section={SETTINGS_SECTION} />
) : (
<>
<CurrentSectionHeader />
<Routes>
<Route index element={<General />} />
<Route path="general" element={<General />} />
<Route
path="sources"
element={
<Sources
paginatedDocuments={paginatedDocuments}
handleDeleteDocument={handleDeleteClick}
/>
}
/>
<Route path="analytics" element={<Analytics />} />
<Route path="logs" element={<Logs />} />
<Route path="tools" element={<Tools />} />
<Route
path="devices"
element={<Navigate to="/settings/tools" replace />}
/>
<Route path="custom-models" element={<CustomModels />} />
<Route path="access-tokens" element={<PersonalAccessTokens />} />
<Route path="*" element={<Navigate to="/settings" replace />} />
</Routes>
</>
)}
</div>
</div>
);
}
+8 -1
View File
@@ -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",
@@ -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',
+1 -1
View File
@@ -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();
+3 -3
View File
@@ -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 <h1> 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