mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 03:12:55 +00:00
Split the agents URL space and move its navigation into the sidebar
Routes under /agents covered two different things: using an agent (a conversation) and managing them (the list, editor, logs, schedules). Sharing the prefix 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, and every caller builds its links from agents/paths.ts rather than from a literal. Pre-split URLs redirect, keeping their query. With the prefixes distinct, agent management becomes a section like settings and admin: - The list's five filters are routes rather than component state, so a filtered view is linkable and survives a reload. The hand-rolled pill row is gone from the content on desktop. - An agent's own pages (overview, logs, schedules) get a nav titled after the agent, replacing the breadcrumb-and-underline sub-nav. Sections nest to support it: `parentPath` makes back mean "up one level", so leaving an agent lands on the agent list rather than the chat. - Sections named after a record are built per route, since the title comes from the store rather than the path. - `pageTitle` distinguishes sections whose destinations are separate pages from ones whose destinations are views of a single page; the latter keep their own heading instead of flipping to "All". Below lg, where the sidebar is an overlay, those view-style destinations appear as a pill row on the page — bouncing out to an index page to change a filter would be worse than a row of pills. The workflow builder keeps its full-screen canvas and its own header, the one place a content-owned nav still earns its keep. Page shells converge on the shared padding, max width and header, and the agent list's folder trail uses the shared breadcrumb primitives.
This commit is contained in:
1 parent
60532ec46c
commit
cfbf61f4f3
35 files changed
+763
-223
No files matched your search
+23
-13
@@ -12,6 +12,11 @@ import {
|
||||
Settings as SettingsIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
AGENTS_MANAGE_ROOT,
|
||||
agentChatPath,
|
||||
sharedAgentPath,
|
||||
} from './agents/paths';
|
||||
import { Agent } from './agents/types';
|
||||
import conversationService from './api/services/conversationService';
|
||||
import userService from './api/services/userService';
|
||||
@@ -42,7 +47,7 @@ import { ActiveState } from './models/misc';
|
||||
import { getConversations } from './preferences/preferenceApi';
|
||||
import SectionNav from './navigation/SectionNav';
|
||||
import SectionRail from './navigation/SectionRail';
|
||||
import { useActiveSection } from './navigation/sections';
|
||||
import { useSectionContext } from './navigation/useSectionContext';
|
||||
import { useLastAppPath } from './navigation/useLastAppPath';
|
||||
import {
|
||||
selectAgents,
|
||||
@@ -90,14 +95,19 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
// Section state is derived from the route, so deep links and the browser
|
||||
// back button keep working without a second source of truth.
|
||||
const { section: activeSection, item: activeSectionItem } =
|
||||
useActiveSection();
|
||||
useSectionContext();
|
||||
const lastAppPath = useLastAppPath();
|
||||
const inSection = Boolean(activeSection);
|
||||
const backToAppLabel = t('navigation.backToApp');
|
||||
|
||||
// Sections nest, so back means "up one level": out of an agent lands on
|
||||
// the agent list, and out of a top-level section lands back in the app.
|
||||
const backLabel = activeSection?.parentLabelKey
|
||||
? t(activeSection.parentLabelKey)
|
||||
: t('navigation.backToApp');
|
||||
|
||||
const exitSection = () => {
|
||||
if (isMobile || isTablet) setNavOpen(false);
|
||||
navigate(lastAppPath.current || '/');
|
||||
navigate(activeSection?.parentPath ?? lastAppPath.current ?? '/');
|
||||
};
|
||||
|
||||
const closeNavOnMobile = () => {
|
||||
@@ -224,7 +234,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
resetConversation();
|
||||
dispatch(setSelectedAgent(agent));
|
||||
if (isMobile || isTablet) setNavOpen(!navOpen);
|
||||
navigate(agent.id ? `/agents/${agent.id}/c/new` : '/c/new');
|
||||
navigate(agent.id ? agentChatPath(agent.id) : '/c/new');
|
||||
};
|
||||
|
||||
const handleTogglePin = (agent: Agent) => {
|
||||
@@ -271,7 +281,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
return;
|
||||
}
|
||||
agent = await sharedResponse.json();
|
||||
navigate(`/agents/shared/${agent.shared_token}`);
|
||||
navigate(sharedAgentPath(agent.shared_token));
|
||||
} else {
|
||||
const agentResponse = await userService.getAgent(data.agent_id, token);
|
||||
if (!agentResponse.ok) {
|
||||
@@ -280,10 +290,10 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
}
|
||||
agent = await agentResponse.json();
|
||||
if (agent.shared_token) {
|
||||
navigate(`/agents/shared/${agent.shared_token}`);
|
||||
navigate(sharedAgentPath(agent.shared_token));
|
||||
} else {
|
||||
await Promise.resolve(dispatch(setSelectedAgent(agent)));
|
||||
navigate(`/agents/${data.agent_id}/c/${index}`);
|
||||
navigate(agentChatPath(data.agent_id, index));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -362,7 +372,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
activeItemKey={activeSectionItem?.key}
|
||||
isAdmin={isAdmin}
|
||||
onBack={exitSection}
|
||||
backLabel={backToAppLabel}
|
||||
backLabel={backLabel}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -384,7 +394,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
dispatch(setSelectedAgent(null));
|
||||
navigate('/agents');
|
||||
navigate(AGENTS_MANAGE_ROOT);
|
||||
}}
|
||||
aria-label={t('manageAgents')}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
@@ -567,7 +577,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
))}
|
||||
</div>
|
||||
<NavLink
|
||||
to="/agents"
|
||||
to={AGENTS_MANAGE_ROOT}
|
||||
end
|
||||
onClick={() => {
|
||||
dispatch(setSelectedAgent(null));
|
||||
@@ -596,7 +606,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
</div>
|
||||
) : (
|
||||
<NavLink
|
||||
to="/agents"
|
||||
to={AGENTS_MANAGE_ROOT}
|
||||
end
|
||||
onClick={() => {
|
||||
if (isMobile || isTablet) {
|
||||
@@ -684,7 +694,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
activeItemKey={activeSectionItem?.key}
|
||||
isAdmin={isAdmin}
|
||||
onBack={exitSection}
|
||||
backLabel={backToAppLabel}
|
||||
backLabel={backLabel}
|
||||
onNavigate={closeNavOnMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
import { useMediaQuery } from '../hooks';
|
||||
import SectionIndexPage from '../navigation/SectionIndexPage';
|
||||
import SectionPageHeader from '../navigation/SectionPageHeader';
|
||||
import { ADMIN_SECTION, getActiveItem } from '../navigation/sections';
|
||||
import { CurrentSectionHeader } from '../navigation/SectionPageHeader';
|
||||
import { ADMIN_SECTION } from '../navigation/sections';
|
||||
import Admins from './Admins';
|
||||
import Audit from './Audit';
|
||||
import Overview from './Overview';
|
||||
@@ -20,7 +20,6 @@ export default function Admin() {
|
||||
const location = useLocation();
|
||||
const { isMobile, isTablet } = useMediaQuery();
|
||||
|
||||
const activeItem = getActiveItem(ADMIN_SECTION, location.pathname);
|
||||
const showIndex =
|
||||
(isMobile || isTablet) && location.pathname === ADMIN_SECTION.rootPath;
|
||||
|
||||
@@ -31,7 +30,7 @@ export default function Admin() {
|
||||
<SectionIndexPage section={ADMIN_SECTION} />
|
||||
) : (
|
||||
<>
|
||||
<SectionPageHeader section={ADMIN_SECTION} item={activeItem} />
|
||||
<CurrentSectionHeader />
|
||||
<Routes>
|
||||
<Route index element={<Overview />} />
|
||||
<Route path="overview" element={<Overview />} />
|
||||
|
||||
@@ -44,6 +44,12 @@ import {
|
||||
setAgents,
|
||||
setSelectedAgent,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import {
|
||||
agentChatPath,
|
||||
agentEditPath,
|
||||
agentLogsPath,
|
||||
sharedAgentPath,
|
||||
} from './paths';
|
||||
import { Agent } from './types';
|
||||
|
||||
type AgentCardProps = {
|
||||
@@ -91,7 +97,7 @@ export default function AgentCard({
|
||||
label: 'Logs',
|
||||
onClick: (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/agents/logs/${agent.id}`);
|
||||
navigate(agentLogsPath(agent.id));
|
||||
},
|
||||
variant: 'default',
|
||||
iconWidth: 14,
|
||||
@@ -103,9 +109,9 @@ export default function AgentCard({
|
||||
onClick: (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
if (agent.agent_type === 'workflow') {
|
||||
navigate(`/agents/workflow/edit/${agent.id}`);
|
||||
navigate(agentEditPath(agent.id, true));
|
||||
} else {
|
||||
navigate(`/agents/edit/${agent.id}`);
|
||||
navigate(agentEditPath(agent.id));
|
||||
}
|
||||
},
|
||||
variant: 'default',
|
||||
@@ -189,9 +195,9 @@ export default function AgentCard({
|
||||
onClick: (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
if (agent.agent_type === 'workflow') {
|
||||
navigate(`/agents/workflow/edit/${agent.id}`);
|
||||
navigate(agentEditPath(agent.id, true));
|
||||
} else {
|
||||
navigate(`/agents/edit/${agent.id}`);
|
||||
navigate(agentEditPath(agent.id));
|
||||
}
|
||||
},
|
||||
variant: 'default',
|
||||
@@ -220,7 +226,7 @@ export default function AgentCard({
|
||||
label: 'Open',
|
||||
onClick: (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/agents/shared/${agent.shared_token}`);
|
||||
navigate(sharedAgentPath(agent.shared_token));
|
||||
},
|
||||
variant: 'default',
|
||||
iconWidth: 12,
|
||||
@@ -258,11 +264,11 @@ export default function AgentCard({
|
||||
if (section === 'user' || section === 'team') {
|
||||
if (agent.status === 'published') {
|
||||
dispatch(setSelectedAgent(agent));
|
||||
navigate(agent.id ? `/agents/${agent.id}/c/new` : '/c/new');
|
||||
navigate(agent.id ? agentChatPath(agent.id) : '/c/new');
|
||||
}
|
||||
}
|
||||
if (section === 'shared') {
|
||||
navigate(`/agents/shared/${agent.shared_token}`);
|
||||
navigate(sharedAgentPath(agent.shared_token));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,21 +40,12 @@ 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">
|
||||
<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>
|
||||
@@ -81,9 +73,13 @@ export default function AgentLogs() {
|
||||
</div>
|
||||
) : (
|
||||
agent && (
|
||||
<Logs agentId={agent.id} tableHeader={t('agents.logs.tableHeader')} />
|
||||
<Logs
|
||||
agentId={agent.id}
|
||||
tableHeader={t('agents.logs.tableHeader')}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
@@ -24,18 +31,21 @@ import AgentCard from './AgentCard';
|
||||
import { AgentSectionId, agentSectionsConfig } from './agents.config';
|
||||
import AgentTypeModal from './components/AgentTypeModal';
|
||||
import FolderCard from './FolderCard';
|
||||
import { AgentFilterTab, useAgentSearch } from './hooks/useAgentSearch';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '../components/ui/breadcrumb';
|
||||
import { CurrentSectionHeader } from '../navigation/SectionPageHeader';
|
||||
import SectionPills from '../navigation/SectionPills';
|
||||
import { useAgentSearch } from './hooks/useAgentSearch';
|
||||
import { agentsListPath, filterFromPath } from './paths';
|
||||
import { useAgentsFetch } from './hooks/useAgentsFetch';
|
||||
import { Agent, AgentFolder } from './types';
|
||||
|
||||
const FILTER_TABS: { id: AgentFilterTab; labelKey: string }[] = [
|
||||
{ id: 'all', labelKey: 'agents.filters.all' },
|
||||
{ id: 'template', labelKey: 'agents.filters.byDocsGPT' },
|
||||
{ id: 'user', labelKey: 'agents.filters.byMe' },
|
||||
{ id: 'team', labelKey: 'agents.filters.team' },
|
||||
{ id: 'shared', labelKey: 'agents.filters.shared' },
|
||||
];
|
||||
|
||||
export default function AgentsList() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
@@ -59,25 +69,27 @@ export default function AgentsList() {
|
||||
|
||||
if (currentFolderId !== currentFolderInUrl) {
|
||||
const newUrl = currentFolderId
|
||||
? `/agents?folder=${currentFolderId}`
|
||||
: '/agents';
|
||||
? agentsListPath(currentFolderId)
|
||||
: agentsListPath();
|
||||
navigate(newUrl, { replace: true });
|
||||
}
|
||||
}, [folderPath, searchParams, navigate]);
|
||||
|
||||
const { isLoading, refetchFolders, refetchUserAgents } = useAgentsFetch();
|
||||
|
||||
// The list's filter is a route, so it is linkable and survives a reload;
|
||||
// the sidebar nav (and the pill row below `lg`) does the navigating.
|
||||
const activeFilter = filterFromPath(location.pathname);
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
activeFilter,
|
||||
setActiveFilter,
|
||||
filteredAgentsBySection,
|
||||
totalAgentsBySection,
|
||||
hasAnyAgents,
|
||||
hasFilteredResults,
|
||||
isDataLoaded,
|
||||
} = useAgentSearch();
|
||||
} = useAgentSearch(activeFilter);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setConversation([]));
|
||||
@@ -162,14 +174,15 @@ 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>
|
||||
<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>
|
||||
|
||||
<SectionPills className="mt-6" />
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 pb-4">
|
||||
<div className="w-full max-w-md">
|
||||
<Input
|
||||
@@ -187,24 +200,6 @@ export default function AgentsList() {
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{visibleSections.map((sectionConfig) => (
|
||||
@@ -214,7 +209,9 @@ export default function AgentsList() {
|
||||
filteredAgents={
|
||||
filteredAgentsBySection[sectionConfig.id as AgentSectionId]
|
||||
}
|
||||
totalAgents={totalAgentsBySection[sectionConfig.id as AgentSectionId]}
|
||||
totalAgents={
|
||||
totalAgentsBySection[sectionConfig.id as AgentSectionId]
|
||||
}
|
||||
searchQuery={searchQuery}
|
||||
isFilteredView={activeFilter !== 'all'}
|
||||
isLoading={isLoading[sectionConfig.id as AgentSectionId]}
|
||||
@@ -244,6 +241,7 @@ export default function AgentsList() {
|
||||
folderId={modalFolderId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -444,61 +442,51 @@ function AgentSection({
|
||||
});
|
||||
}, [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
|
||||
{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"
|
||||
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>
|
||||
</button>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{breadcrumbItems.map((item, index) => (
|
||||
<span key={item.id} className="flex items-center gap-2">
|
||||
<ChevronIcon />
|
||||
<Fragment key={item.id}>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
{index === breadcrumbItems.length - 1 ? (
|
||||
<span>{item.name}</span>
|
||||
<BreadcrumbPage>{item.name}</BreadcrumbPage>
|
||||
) : (
|
||||
<Button
|
||||
<BreadcrumbLink asChild>
|
||||
<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>
|
||||
</button>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</span>
|
||||
</BreadcrumbItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
) : (
|
||||
t(`agents.sections.${config.id}.title`)
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
agentChatPath,
|
||||
agentEditPathFor,
|
||||
agentsFilterPath,
|
||||
agentsListPath,
|
||||
filterFromPath,
|
||||
matchAgentScopedRoute,
|
||||
} from './paths';
|
||||
|
||||
describe('agentsFilterPath / filterFromPath', () => {
|
||||
it('round-trips every filter', () => {
|
||||
for (const filter of ['all', 'template', 'user', 'team', 'shared'] as const)
|
||||
expect(filterFromPath(agentsFilterPath(filter))).toBe(filter);
|
||||
});
|
||||
|
||||
it('treats the list root and unknown slugs as unfiltered', () => {
|
||||
expect(filterFromPath('/agents/manage')).toBe('all');
|
||||
expect(filterFromPath('/agents/manage/nonsense')).toBe('all');
|
||||
});
|
||||
|
||||
it('does not read a filter out of an agent page', () => {
|
||||
expect(filterFromPath('/agents/manage/edit/a1')).toBe('all');
|
||||
});
|
||||
});
|
||||
|
||||
describe('agentsListPath', () => {
|
||||
it('encodes a folder id into the query', () => {
|
||||
expect(agentsListPath()).toBe('/agents/manage');
|
||||
expect(agentsListPath('a b')).toBe('/agents/manage?folder=a%20b');
|
||||
expect(agentsListPath(null)).toBe('/agents/manage');
|
||||
});
|
||||
});
|
||||
|
||||
describe('agentEditPathFor', () => {
|
||||
it('sends workflow agents to the builder', () => {
|
||||
expect(agentEditPathFor({ id: 'a1', agent_type: 'workflow' })).toBe(
|
||||
'/agents/manage/workflow/edit/a1',
|
||||
);
|
||||
expect(agentEditPathFor({ id: 'a1', agent_type: 'classic' })).toBe(
|
||||
'/agents/manage/edit/a1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchAgentScopedRoute', () => {
|
||||
it('recognises each of an agent’s pages', () => {
|
||||
expect(matchAgentScopedRoute('/agents/manage/edit/a1')).toEqual({
|
||||
agentId: 'a1',
|
||||
page: 'overview',
|
||||
workflow: false,
|
||||
});
|
||||
expect(matchAgentScopedRoute('/agents/manage/logs/a1')?.page).toBe('logs');
|
||||
expect(matchAgentScopedRoute('/agents/manage/schedules/a1')?.page).toBe(
|
||||
'schedules',
|
||||
);
|
||||
expect(matchAgentScopedRoute('/agents/manage/workflow/edit/a1')).toEqual({
|
||||
agentId: 'a1',
|
||||
page: 'overview',
|
||||
workflow: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores the list, and a chat with an agent', () => {
|
||||
expect(matchAgentScopedRoute('/agents/manage')).toBeNull();
|
||||
expect(matchAgentScopedRoute('/agents/manage/mine')).toBeNull();
|
||||
expect(matchAgentScopedRoute('/agents/manage/new')).toBeNull();
|
||||
// The whole point of the split: this is a conversation, not an editor.
|
||||
expect(matchAgentScopedRoute(agentChatPath('a1', 'c1'))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { AgentFilterTab } from './hooks/useAgentSearch';
|
||||
import type { Agent } from './types';
|
||||
|
||||
/**
|
||||
* Routes under `/agents` cover two different things: *using* an agent (a
|
||||
* conversation) and *managing* them (the list, the editor, logs, schedules).
|
||||
* They used to share the `/agents/…` prefix, which left no way to tell them
|
||||
* apart from the pathname — so the sidebar could not react to one without
|
||||
* also reacting to the other. Management now lives under `/agents/manage`.
|
||||
*
|
||||
* Every caller builds its links from here rather than from a literal, so the
|
||||
* next move costs one edit instead of thirty.
|
||||
*/
|
||||
|
||||
/** Root of the management mode; also the agents section's root path. */
|
||||
export const AGENTS_MANAGE_ROOT = '/agents/manage';
|
||||
|
||||
/** URL segment for each list filter. `all` is the root, so it has none. */
|
||||
const FILTER_SLUGS: Record<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 agent list, optionally scoped to a folder. */
|
||||
export const agentsListPath = (folderId?: string | null): string =>
|
||||
folderId
|
||||
? `${AGENTS_MANAGE_ROOT}?folder=${encodeURIComponent(folderId)}`
|
||||
: AGENTS_MANAGE_ROOT;
|
||||
|
||||
/** The list narrowed to one filter; each filter is its own linkable route. */
|
||||
export const agentsFilterPath = (filter: AgentFilterTab): string =>
|
||||
filter === 'all'
|
||||
? AGENTS_MANAGE_ROOT
|
||||
: `${AGENTS_MANAGE_ROOT}/${FILTER_SLUGS[filter]}`;
|
||||
|
||||
/** Which filter a list route selects; `all` for anything unrecognised. */
|
||||
export const filterFromPath = (pathname: string): AgentFilterTab => {
|
||||
const slug = pathname.startsWith(`${AGENTS_MANAGE_ROOT}/`)
|
||||
? pathname.slice(AGENTS_MANAGE_ROOT.length + 1).split('/')[0]
|
||||
: '';
|
||||
return SLUG_FILTERS[slug] ?? 'all';
|
||||
};
|
||||
|
||||
export const agentNewPath = (
|
||||
options: { workflow?: boolean; folderId?: string | null } = {},
|
||||
): string => {
|
||||
const base = options.workflow
|
||||
? `${AGENTS_MANAGE_ROOT}/workflow/new`
|
||||
: `${AGENTS_MANAGE_ROOT}/new`;
|
||||
return options.folderId
|
||||
? `${base}?folder_id=${encodeURIComponent(options.folderId)}`
|
||||
: base;
|
||||
};
|
||||
|
||||
export const agentEditPath = (
|
||||
agentId: string | undefined,
|
||||
workflow = false,
|
||||
): string =>
|
||||
workflow
|
||||
? `${AGENTS_MANAGE_ROOT}/workflow/edit/${agentId}`
|
||||
: `${AGENTS_MANAGE_ROOT}/edit/${agentId}`;
|
||||
|
||||
/** Edit path for an agent whose type decides which editor opens. */
|
||||
export const agentEditPathFor = (
|
||||
agent: Pick<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/`),
|
||||
};
|
||||
}
|
||||
@@ -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,21 +147,12 @@ 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">
|
||||
<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>
|
||||
@@ -385,5 +377,6 @@ export default function SchedulesView() {
|
||||
)
|
||||
)}
|
||||
</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
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1644,6 +1644,7 @@
|
||||
"navigation": {
|
||||
"agents": "Agenten",
|
||||
"backToApp": "Zurück zur App",
|
||||
"backToAgents": "Zurück zu den Agenten",
|
||||
"sections": {
|
||||
"groups": {
|
||||
"personal": "Persönlich",
|
||||
|
||||
@@ -1655,6 +1655,7 @@
|
||||
"navigation": {
|
||||
"agents": "Agents",
|
||||
"backToApp": "Back to app",
|
||||
"backToAgents": "Back to agents",
|
||||
"sections": {
|
||||
"groups": {
|
||||
"personal": "Personal",
|
||||
|
||||
@@ -1644,6 +1644,7 @@
|
||||
"navigation": {
|
||||
"agents": "Agentes",
|
||||
"backToApp": "Volver a la app",
|
||||
"backToAgents": "Volver a agentes",
|
||||
"sections": {
|
||||
"groups": {
|
||||
"personal": "Personal",
|
||||
|
||||
@@ -1644,6 +1644,7 @@
|
||||
"navigation": {
|
||||
"agents": "エージェント",
|
||||
"backToApp": "アプリに戻る",
|
||||
"backToAgents": "エージェントに戻る",
|
||||
"sections": {
|
||||
"groups": {
|
||||
"personal": "個人",
|
||||
|
||||
@@ -1664,6 +1664,7 @@
|
||||
"navigation": {
|
||||
"agents": "Агенты",
|
||||
"backToApp": "Вернуться в приложение",
|
||||
"backToAgents": "Назад к агентам",
|
||||
"sections": {
|
||||
"groups": {
|
||||
"personal": "Личное",
|
||||
|
||||
@@ -1644,6 +1644,7 @@
|
||||
"navigation": {
|
||||
"agents": "代理",
|
||||
"backToApp": "返回應用程式",
|
||||
"backToAgents": "返回代理",
|
||||
"sections": {
|
||||
"groups": {
|
||||
"personal": "個人",
|
||||
|
||||
@@ -1644,6 +1644,7 @@
|
||||
"navigation": {
|
||||
"agents": "代理",
|
||||
"backToApp": "返回应用",
|
||||
"backToAgents": "返回代理",
|
||||
"sections": {
|
||||
"groups": {
|
||||
"personal": "个人",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function SectionIndexPage({ section }: { section: Section }) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-foreground dark:text-foreground text-2xl font-bold">
|
||||
{t(section.titleKey)}
|
||||
{section.title ?? t(section.titleKey)}
|
||||
</h1>
|
||||
<div className="mt-6 flex flex-col gap-6">
|
||||
{groups.map((group) => (
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function SectionNav({
|
||||
}: SectionNavProps) {
|
||||
const { t } = useTranslation();
|
||||
const groups = getVisibleGroups(section, { isAdmin });
|
||||
const sectionTitle = section.title ?? t(section.titleKey);
|
||||
|
||||
const renderItem = (item: SectionItem) => {
|
||||
const isActive = item.key === activeItemKey;
|
||||
@@ -77,11 +78,14 @@ export default function SectionNav({
|
||||
{backLabel}
|
||||
</p>
|
||||
</button>
|
||||
<p className="text-foreground mt-6 ml-8 shrink-0 text-sm font-semibold dark:text-white">
|
||||
{t(section.titleKey)}
|
||||
<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={t(section.titleKey)}
|
||||
aria-label={sectionTitle}
|
||||
className="scrollbar-overlay mt-3 flex-1 overflow-x-hidden overflow-y-auto pb-4"
|
||||
>
|
||||
{groups.map((group) => (
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
import { useMediaQuery } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { Section, SectionItem } from './sections';
|
||||
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
|
||||
@@ -20,19 +21,33 @@ export function SectionBackLink({
|
||||
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={section.rootPath}
|
||||
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 />
|
||||
{t(section.titleKey)}
|
||||
{parent ? t(parent.titleKey) : (section.title ?? t(section.titleKey))}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -56,8 +71,24 @@ export default function SectionPageHeader({
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<SectionBackLink section={section} />
|
||||
<h1 className="text-foreground dark:text-foreground text-2xl font-bold">
|
||||
{t(item?.labelKey ?? section.titleKey)}
|
||||
{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} />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ADMIN_SECTION,
|
||||
AGENTS_SECTION,
|
||||
buildAgentSection,
|
||||
getActiveItem,
|
||||
getSectionForPath,
|
||||
getSectionItems,
|
||||
@@ -23,7 +25,15 @@ describe('getSectionForPath', () => {
|
||||
it('leaves ordinary app routes alone', () => {
|
||||
expect(getSectionForPath('/')).toBeNull();
|
||||
expect(getSectionForPath('/c/abc123')).toBeNull();
|
||||
expect(getSectionForPath('/agents/edit/1')).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', () => {
|
||||
@@ -83,3 +93,50 @@ describe('getVisibleGroups', () => {
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,33 @@
|
||||
import {
|
||||
BarChart3,
|
||||
CalendarClock,
|
||||
Boxes,
|
||||
ChartNoAxesColumn,
|
||||
Database,
|
||||
FileClock,
|
||||
Gauge,
|
||||
Globe,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
LayoutTemplate,
|
||||
ScrollText,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
SquarePen,
|
||||
User,
|
||||
UserCog,
|
||||
Users,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
AGENTS_MANAGE_ROOT,
|
||||
agentEditPath,
|
||||
agentLogsPath,
|
||||
agentSchedulesPath,
|
||||
agentsFilterPath,
|
||||
} from '../agents/paths';
|
||||
|
||||
/** A single destination in a section's vertical nav. */
|
||||
export type SectionItem = {
|
||||
@@ -46,8 +58,25 @@ export type SectionGroup = {
|
||||
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[];
|
||||
};
|
||||
|
||||
@@ -192,7 +221,107 @@ export const ADMIN_SECTION: Section = {
|
||||
],
|
||||
};
|
||||
|
||||
export const SECTIONS: Section[] = [SETTINGS_SECTION, ADMIN_SECTION];
|
||||
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}/`);
|
||||
@@ -249,16 +378,3 @@ export function getActiveItem(
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Route-derived section state — no extra store, so deep links keep working. */
|
||||
export function useActiveSection(): {
|
||||
section: Section | null;
|
||||
item: SectionItem | null;
|
||||
} {
|
||||
const { pathname } = useLocation();
|
||||
const section = getSectionForPath(pathname);
|
||||
return {
|
||||
section,
|
||||
item: section ? getActiveItem(section, pathname) : null,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import userService from '../api/services/userService';
|
||||
import { useMediaQuery } from '../hooks';
|
||||
import { Doc } from '../models/misc';
|
||||
import SectionIndexPage from '../navigation/SectionIndexPage';
|
||||
import SectionPageHeader from '../navigation/SectionPageHeader';
|
||||
import { getActiveItem, SETTINGS_SECTION } from '../navigation/sections';
|
||||
import { CurrentSectionHeader } from '../navigation/SectionPageHeader';
|
||||
import { SETTINGS_SECTION } from '../navigation/sections';
|
||||
import {
|
||||
selectPaginatedDocuments,
|
||||
selectSourceDocs,
|
||||
@@ -33,7 +33,6 @@ export default function Settings() {
|
||||
const location = useLocation();
|
||||
const { isMobile, isTablet } = useMediaQuery();
|
||||
|
||||
const activeItem = getActiveItem(SETTINGS_SECTION, location.pathname);
|
||||
const showIndex =
|
||||
(isMobile || isTablet) && location.pathname === SETTINGS_SECTION.rootPath;
|
||||
|
||||
@@ -71,7 +70,7 @@ export default function Settings() {
|
||||
<SectionIndexPage section={SETTINGS_SECTION} />
|
||||
) : (
|
||||
<>
|
||||
<SectionPageHeader section={SETTINGS_SECTION} item={activeItem} />
|
||||
<CurrentSectionHeader />
|
||||
<Routes>
|
||||
<Route index element={<General />} />
|
||||
<Route path="general" element={<General />} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user