From 60532ec46cef7eecb6c44394ac6d4fcee85fe6f8 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 21 Sep 2026 22:31:47 +0100 Subject: [PATCH 1/8] Move settings and admin navigation into the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings and admin both drove their pages from a horizontal tab strip. Seven tabs no longer fit: settings had grown scroll arrows, gradient masks and a hiddenGradient state machine just to survive on mobile, and the active tab was resolved by comparing translated labels against the URL. Teams and admin had no home in the strip at all — admin was reachable only from the Help popover. Replace both strips with a vertical nav that takes over the sidebar while you are inside a section, plus a back button that returns to the app. A declarative registry in navigation/sections.ts holds each section's destinations; the active item is resolved from the route by longest path match, so a detail route like /settings/tools/slack keeps Tools active. Section state is derived from the route rather than stored, so deep links and browser back keep working. - Below lg the sidebar is an overlay, so /settings and /admin render their destination list as page content and each page carries a back link to it. - Entering settings no longer clears the conversation; the back button returns to the route you came from and the chat list stays mounted, keeping its scroll position. - Collapsing the sidebar inside a section shows the same destinations as icons instead of stranding you on one page. - Detail views nested in a section page (a tool's config, a team) now use a breadcrumb rather than a second back arrow, so only the section nav means "leave". - Teams and admin join the settings nav; admin-only entries are hidden from non-admins along with the group heading they leave empty. --- frontend/src/App.tsx | 7 +- frontend/src/Navigation.tsx | 548 ++++++++++-------- frontend/src/admin/index.tsx | 79 +-- frontend/src/locale/de.json | 18 +- frontend/src/locale/en.json | 29 +- frontend/src/locale/es.json | 18 +- frontend/src/locale/jp.json | 18 +- frontend/src/locale/ru.json | 18 +- frontend/src/locale/zh-TW.json | 18 +- frontend/src/locale/zh.json | 18 +- frontend/src/navigation/DetailBreadcrumb.tsx | 53 ++ frontend/src/navigation/SectionIndexPage.tsx | 65 +++ frontend/src/navigation/SectionNav.tsx | 100 ++++ frontend/src/navigation/SectionPageHeader.tsx | 63 ++ frontend/src/navigation/SectionRail.tsx | 69 +++ frontend/src/navigation/sections.test.ts | 85 +++ frontend/src/navigation/sections.ts | 264 +++++++++ frontend/src/navigation/useLastAppPath.ts | 20 + frontend/src/settings/RemoteDeviceConfig.tsx | 21 +- frontend/src/settings/Teams.tsx | 21 +- frontend/src/settings/ToolConfig.tsx | 21 +- frontend/src/settings/index.tsx | 205 ++----- 22 files changed, 1226 insertions(+), 532 deletions(-) create mode 100644 frontend/src/navigation/DetailBreadcrumb.tsx create mode 100644 frontend/src/navigation/SectionIndexPage.tsx create mode 100644 frontend/src/navigation/SectionNav.tsx create mode 100644 frontend/src/navigation/SectionPageHeader.tsx create mode 100644 frontend/src/navigation/SectionRail.tsx create mode 100644 frontend/src/navigation/sections.test.ts create mode 100644 frontend/src/navigation/sections.ts create mode 100644 frontend/src/navigation/useLastAppPath.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0cf65dc6..49f508ca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ 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 PageNotFound from './PageNotFound'; import Setting from './settings'; import Teams from './settings/Teams'; @@ -84,11 +85,15 @@ 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 (
- +
{ + if (isMobile || isTablet) setNavOpen(false); + navigate(lastAppPath.current || '/'); + }; + + const closeNavOnMobile = () => { + if (isMobile || isTablet) setNavOpen(false); + }; + const [isDeletingConversation, setIsDeletingConversation] = useState(false); const [uploadModalState, setUploadModalState] = useState('INACTIVE'); @@ -320,7 +344,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { {!navOpen && !isMobile && !isTablet && (
+ className="bg-sidebar border-border scrollbar-overlay fixed top-0 left-0 z-10 hidden h-full w-14 flex-col items-center gap-2 overflow-x-hidden overflow-y-auto border-r py-3 lg:flex" + > - {queries?.length > 0 && ( - + {activeSection ? ( + + ) : ( + <> + {queries?.length > 0 && ( + + )} + + {conversations?.data && conversations.data.length > 0 && ( + + )} +
+ +
+ )} - - {conversations?.data && conversations.data.length > 0 && ( - - )} -
- -
)}
- { - if (isMobile || isTablet) { - setNavOpen(!navOpen); - } - resetConversation(); - }} - className={({ isActive }) => - `${isActive ? 'bg-transparent' : '' - } group border-sidebar-border hover:border-sidebar-border sticky mx-4 mt-4 flex cursor-pointer items-center gap-2.5 rounded-3xl border p-3 hover:bg-transparent dark:text-white` - } - > - -

- {t('newChat')} -

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

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

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

- {agent.name} -

-
-
- -
-
- ))} + +

+ {t('newChat')} +

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

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

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

+ {agent.name} +

+
+
+ +
+
+ ))} +
+ { + dispatch(setSelectedAgent(null)); + if (isMobile || isTablet) { + setNavOpen(false); + } + }} + className={({ isActive }) => + `hover:bg-sidebar-accent mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 ${ + isActive ? 'bg-sidebar-accent' : '' + }` + } + > +
+ +
+

+ {t('manageAgents')} +

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

{t('manageAgents')}

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

+ {t('chats')} +

+ +
+
+ {(conversations.data ?? []).map((conversation) => ( + handleConversationClick(id)} + onConversationClick={() => { + if (isMobile) { + setNavOpen(false); + } + }} + onDeleteConversation={(id) => + handleDeleteConversation(id) + } + onSave={(conversation) => + updateConversationName(conversation) + } + /> + ))} +
+
+ ) : ( + <> + )}
- ) : ( - { - if (isMobile || isTablet) { - setNavOpen(false); - } - 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' : '' - }` - } - > - +
+ {activeSection && ( + -

- {t('manageAgents')} -

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

{t('chats')}

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

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

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

- { - const tab = TABS.find((tb) => tb.label === label); - if (tab) navigate(tab.path); - }} - className="relative mt-6 flex flex-row items-center space-x-1 overflow-auto md:space-x-0" - > - - {TABS.map((tab) => ( - - {t(`admin.tabs.${tab.key}`, tab.label)} - - ))} - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + {showIndex ? ( + + ) : ( + <> + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + )}
); diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index df6dabfa..c88354cf 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -436,7 +436,6 @@ "label": "Teams", "subtitle": "Verwalte deine Teams, Mitglieder und geteilten Ressourcen.", "newTeam": "Neues Team", - "backToTeams": "Zurück zu den Teams", "createTeam": "Team erstellen", "createTeamDescription": "Gib deinem Team einen Namen. Mitglieder und Ressourcen kannst du nach dem Erstellen hinzufügen.", "editTeam": "Bearbeiten", @@ -565,7 +564,6 @@ "queryParameters": "Abfrageparameter", "body": "Body", "deleteActionWarning": "Bist du sicher, dass du die Aktion \"{{name}}\" löschen möchtest?", - "backToAllTools": "Zurück zu allen Werkzeugen", "save": "Speichern", "saving": "Speichere...", "saveFailed": "Werkzeug-Konfiguration konnte nicht gespeichert werden", @@ -872,10 +870,7 @@ "subtitle": "Das neue Geheimnis für \"{{name}}\" ist bereit. Das alte funktioniert nicht mehr." } } - }, - "scrollTabsLeft": "Tabs nach links scrollen", - "tabsAriaLabel": "Einstellungs-Tabs", - "scrollTabsRight": "Tabs nach rechts scrollen" + } }, "modals": { "uploadDoc": { @@ -1647,7 +1642,16 @@ "increaseZoom": "Vergrößern" }, "navigation": { - "agents": "Agenten" + "agents": "Agenten", + "backToApp": "Zurück zur App", + "sections": { + "groups": { + "personal": "Persönlich", + "workspace": "Arbeitsbereich", + "insights": "Auswertungen", + "administration": "Administration" + } + } }, "teams": { "switcher": { diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index cdf03a26..9da25867 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -441,7 +441,6 @@ "label": "Teams", "subtitle": "Manage your teams, members, and shared resources.", "newTeam": "New team", - "backToTeams": "Back to teams", "createTeam": "Create a team", "createTeamDescription": "Give your team a name. You can add members and share resources once it's created.", "editTeam": "Edit", @@ -570,7 +569,6 @@ "queryParameters": "Query Parameters", "body": "Body", "deleteActionWarning": "Are you sure you want to delete the action \"{{name}}\"?", - "backToAllTools": "Back to all tools", "save": "Save", "saving": "Saving...", "saveFailed": "Failed to save tool configuration", @@ -877,10 +875,7 @@ "subtitle": "The new secret for \"{{name}}\" is ready. The old one no longer works." } } - }, - "scrollTabsLeft": "Scroll tabs left", - "tabsAriaLabel": "Settings tabs", - "scrollTabsRight": "Scroll tabs right" + } }, "modals": { "uploadDoc": { @@ -1658,7 +1653,16 @@ "increaseZoom": "Increase zoom" }, "navigation": { - "agents": "Agents" + "agents": "Agents", + "backToApp": "Back to app", + "sections": { + "groups": { + "personal": "Personal", + "workspace": "Workspace", + "insights": "Insights", + "administration": "Administration" + } + } }, "teams": { "switcher": { @@ -1677,5 +1681,16 @@ }, "prompts": { "textAriaLabel": "Prompt Text" + }, + "admin": { + "label": "Admin", + "tabs": { + "overview": "Overview", + "users": "Users", + "admins": "Admins", + "usage": "Usage", + "quotas": "Quotas", + "audit": "Audit" + } } } diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 76058628..9726d619 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -436,7 +436,6 @@ "label": "Equipos", "subtitle": "Gestiona tus equipos, miembros y recursos compartidos.", "newTeam": "Nuevo equipo", - "backToTeams": "Volver a equipos", "createTeam": "Crear un equipo", "createTeamDescription": "Dale un nombre a tu equipo. Podrás añadir miembros y compartir recursos una vez creado.", "editTeam": "Editar", @@ -565,7 +564,6 @@ "queryParameters": "Parámetros de Consulta", "body": "Cuerpo", "deleteActionWarning": "¿Estás seguro de que deseas eliminar la acción \"{{name}}\"?", - "backToAllTools": "Volver a todas las herramientas", "save": "Guardar", "saving": "Guardando...", "saveFailed": "No se pudo guardar la configuración de la herramienta", @@ -872,10 +870,7 @@ "subtitle": "El nuevo secreto de \"{{name}}\" está listo. El anterior ya no funciona." } } - }, - "scrollTabsLeft": "Desplazar pestañas a la izquierda", - "tabsAriaLabel": "Pestañas de configuración", - "scrollTabsRight": "Desplazar pestañas a la derecha" + } }, "modals": { "uploadDoc": { @@ -1647,7 +1642,16 @@ "increaseZoom": "Aumentar zoom" }, "navigation": { - "agents": "Agentes" + "agents": "Agentes", + "backToApp": "Volver a la app", + "sections": { + "groups": { + "personal": "Personal", + "workspace": "Espacio de trabajo", + "insights": "Estadísticas", + "administration": "Administración" + } + } }, "teams": { "switcher": { diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 18626d19..e11dcfda 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -436,7 +436,6 @@ "label": "チーム", "subtitle": "チーム、メンバー、共有リソースを管理します。", "newTeam": "新しいチーム", - "backToTeams": "チーム一覧に戻る", "createTeam": "チームを作成", "createTeamDescription": "チームに名前を付けてください。作成後にメンバーの追加やリソースの共有ができます。", "editTeam": "編集", @@ -565,7 +564,6 @@ "queryParameters": "クエリパラメータ", "body": "ボディ", "deleteActionWarning": "アクション \"{{name}}\" を削除してもよろしいですか?", - "backToAllTools": "すべてのツールに戻る", "save": "保存", "saving": "保存中...", "saveFailed": "ツール設定の保存に失敗しました", @@ -872,10 +870,7 @@ "subtitle": "「{{name}}」の新しいシークレットの準備ができました。以前のものは使用できません。" } } - }, - "scrollTabsLeft": "タブを左にスクロール", - "tabsAriaLabel": "設定タブ", - "scrollTabsRight": "タブを右にスクロール" + } }, "modals": { "uploadDoc": { @@ -1647,7 +1642,16 @@ "increaseZoom": "ズームイン" }, "navigation": { - "agents": "エージェント" + "agents": "エージェント", + "backToApp": "アプリに戻る", + "sections": { + "groups": { + "personal": "個人", + "workspace": "ワークスペース", + "insights": "インサイト", + "administration": "管理" + } + } }, "teams": { "switcher": { diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 081860c6..eac5b593 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -436,7 +436,6 @@ "label": "Команды", "subtitle": "Управляйте командами, участниками и общими ресурсами.", "newTeam": "Новая команда", - "backToTeams": "Назад к командам", "createTeam": "Создать команду", "createTeamDescription": "Дайте команде название. После создания вы сможете добавить участников и делиться ресурсами.", "editTeam": "Редактировать", @@ -565,7 +564,6 @@ "queryParameters": "Параметры запроса", "body": "Тело запроса", "deleteActionWarning": "Вы уверены, что хотите удалить действие \"{{name}}\"?", - "backToAllTools": "Вернуться ко всем инструментам", "save": "Сохранить", "saving": "Сохранение...", "saveFailed": "Не удалось сохранить настройки инструмента", @@ -892,10 +890,7 @@ "subtitle": "Новый секрет для «{{name}}» готов. Старый больше не работает." } } - }, - "scrollTabsLeft": "Прокрутить вкладки влево", - "tabsAriaLabel": "Вкладки настроек", - "scrollTabsRight": "Прокрутить вкладки вправо" + } }, "modals": { "uploadDoc": { @@ -1667,7 +1662,16 @@ "increaseZoom": "Увеличить масштаб" }, "navigation": { - "agents": "Агенты" + "agents": "Агенты", + "backToApp": "Вернуться в приложение", + "sections": { + "groups": { + "personal": "Личное", + "workspace": "Рабочее пространство", + "insights": "Аналитика", + "administration": "Администрирование" + } + } }, "teams": { "switcher": { diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 7902cf5a..e258dcb6 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -436,7 +436,6 @@ "label": "團隊", "subtitle": "管理您的團隊、成員與共享資源。", "newTeam": "新增團隊", - "backToTeams": "返回團隊列表", "createTeam": "建立團隊", "createTeamDescription": "為您的團隊命名。建立後即可新增成員並分享資源。", "editTeam": "編輯", @@ -565,7 +564,6 @@ "queryParameters": "查詢參數", "body": "主體", "deleteActionWarning": "您確定要刪除操作 \"{{name}}\" 嗎?", - "backToAllTools": "返回所有工具", "save": "儲存", "saving": "儲存中...", "saveFailed": "儲存工具設定失敗", @@ -872,10 +870,7 @@ "subtitle": "「{{name}}」的新密鑰已就緒,舊密鑰已失效。" } } - }, - "scrollTabsLeft": "向左捲動標籤", - "tabsAriaLabel": "設定標籤", - "scrollTabsRight": "向右捲動標籤" + } }, "modals": { "uploadDoc": { @@ -1647,7 +1642,16 @@ "increaseZoom": "放大" }, "navigation": { - "agents": "代理" + "agents": "代理", + "backToApp": "返回應用程式", + "sections": { + "groups": { + "personal": "個人", + "workspace": "工作區", + "insights": "洞察", + "administration": "管理" + } + } }, "teams": { "switcher": { diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 7e647c5e..0ee3ea31 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -436,7 +436,6 @@ "label": "团队", "subtitle": "管理您的团队、成员和共享资源。", "newTeam": "新建团队", - "backToTeams": "返回团队列表", "createTeam": "创建团队", "createTeamDescription": "为您的团队命名。创建后即可添加成员并共享资源。", "editTeam": "编辑", @@ -565,7 +564,6 @@ "queryParameters": "查询参数", "body": "请求体", "deleteActionWarning": "您确定要删除操作 \"{{name}}\" 吗?", - "backToAllTools": "返回所有工具", "save": "保存", "saving": "保存中...", "saveFailed": "保存工具配置失败", @@ -872,10 +870,7 @@ "subtitle": "“{{name}}”的新密钥已就绪,旧密钥已失效。" } } - }, - "scrollTabsLeft": "向左滚动标签", - "tabsAriaLabel": "设置标签", - "scrollTabsRight": "向右滚动标签" + } }, "modals": { "uploadDoc": { @@ -1647,7 +1642,16 @@ "increaseZoom": "放大" }, "navigation": { - "agents": "代理" + "agents": "代理", + "backToApp": "返回应用", + "sections": { + "groups": { + "personal": "个人", + "workspace": "工作区", + "insights": "洞察", + "administration": "管理" + } + } }, "teams": { "switcher": { diff --git a/frontend/src/navigation/DetailBreadcrumb.tsx b/frontend/src/navigation/DetailBreadcrumb.tsx new file mode 100644 index 00000000..6f007a02 --- /dev/null +++ b/frontend/src/navigation/DetailBreadcrumb.tsx @@ -0,0 +1,53 @@ +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { cn } from '@/lib/utils'; + +type DetailBreadcrumbProps = { + parentLabel: string; + currentLabel: string; + /** Returns to the list view; kept a callback because these detail views are + * component state rather than routes, and some guard unsaved changes. */ + onParentClick: () => void; + className?: string; +}; + +/** + * Trail for a detail view nested inside a section page (a tool's config, a + * team). The section nav's back button always means "leave the section", so + * going up one level is a breadcrumb here rather than a second back arrow. + */ +export default function DetailBreadcrumb({ + parentLabel, + currentLabel, + onParentClick, + className, +}: DetailBreadcrumbProps) { + return ( + + + + + + + + + + + {currentLabel} + + + + + ); +} diff --git a/frontend/src/navigation/SectionIndexPage.tsx b/frontend/src/navigation/SectionIndexPage.tsx new file mode 100644 index 00000000..c71d525f --- /dev/null +++ b/frontend/src/navigation/SectionIndexPage.tsx @@ -0,0 +1,65 @@ +import { ChevronRight } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; + +import { selectIsAdmin } from '@/preferences/preferenceSlice'; + +import { getVisibleGroups, type Section } from './sections'; + +/** + * Small-screen landing page for a section: the same destinations as the + * sidebar nav, rendered as page content so they stay reachable while the + * sidebar is an overlay. Tapping a row pushes its page, which carries a back + * link to here. + */ +export default function SectionIndexPage({ section }: { section: Section }) { + const { t } = useTranslation(); + const isAdmin = useSelector(selectIsAdmin); + const groups = getVisibleGroups(section, { isAdmin }); + + return ( +
+

+ {t(section.titleKey)} +

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

+ {t(group.labelKey)} +

+ )} +
+ {group.items.map((item) => { + const Icon = item.icon; + return ( + + + + {t(item.labelKey)} + + + + ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/navigation/SectionNav.tsx b/frontend/src/navigation/SectionNav.tsx new file mode 100644 index 00000000..606a1d1a --- /dev/null +++ b/frontend/src/navigation/SectionNav.tsx @@ -0,0 +1,100 @@ +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'; + +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 groups = getVisibleGroups(section, { isAdmin }); + + const renderItem = (item: SectionItem) => { + const isActive = item.key === activeItemKey; + const Icon = item.icon; + return ( + + +

+ {t(item.labelKey)} +

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

+ {t(section.titleKey)} +

+ +
+ ); +} diff --git a/frontend/src/navigation/SectionPageHeader.tsx b/frontend/src/navigation/SectionPageHeader.tsx new file mode 100644 index 00000000..f4729e41 --- /dev/null +++ b/frontend/src/navigation/SectionPageHeader.tsx @@ -0,0 +1,63 @@ +import { ArrowLeft } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; + +import { useMediaQuery } from '@/hooks'; +import { cn } from '@/lib/utils'; + +import type { Section, SectionItem } from './sections'; + +/** + * 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 { isMobile, isTablet } = useMediaQuery(); + + if (!(isMobile || isTablet)) return null; + return ( + + + {t(section.titleKey)} + + ); +} + +/** + * Title block for a section page: the active item's name, preceded on small + * screens by a link back to the section index. + */ +export default function SectionPageHeader({ + section, + item, + className, +}: { + section: Section; + item: SectionItem | null; + className?: string; +}) { + const { t } = useTranslation(); + + return ( +
+ +

+ {t(item?.labelKey ?? section.titleKey)} +

+
+ ); +} diff --git a/frontend/src/navigation/SectionRail.tsx b/frontend/src/navigation/SectionRail.tsx new file mode 100644 index 00000000..e9be3372 --- /dev/null +++ b/frontend/src/navigation/SectionRail.tsx @@ -0,0 +1,69 @@ +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'; + +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 items = getSectionItems(section, { isAdmin }); + + return ( + <> + +
+ {items.map((item) => { + const label = t(item.labelKey); + const isActive = item.key === activeItemKey; + const Icon = item.icon; + return ( + + + + ); + })} + + ); +} diff --git a/frontend/src/navigation/sections.test.ts b/frontend/src/navigation/sections.test.ts new file mode 100644 index 00000000..6f0a3e8f --- /dev/null +++ b/frontend/src/navigation/sections.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { + ADMIN_SECTION, + 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(); + expect(getSectionForPath('/agents/edit/1')).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'); + }); +}); diff --git a/frontend/src/navigation/sections.ts b/frontend/src/navigation/sections.ts new file mode 100644 index 00000000..4457eaeb --- /dev/null +++ b/frontend/src/navigation/sections.ts @@ -0,0 +1,264 @@ +import { + BarChart3, + Boxes, + ChartNoAxesColumn, + Database, + FileClock, + Gauge, + KeyRound, + LayoutDashboard, + ScrollText, + Settings2, + ShieldCheck, + UserCog, + Users, + Wrench, + type LucideIcon, +} from 'lucide-react'; +import { useLocation } from 'react-router-dom'; + +/** 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; + titleKey: string; + matches: string[]; + 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: Boxes, + }, + { + 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 SECTIONS: Section[] = [SETTINGS_SECTION, ADMIN_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; +} + +/** 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, + }; +} diff --git a/frontend/src/navigation/useLastAppPath.ts b/frontend/src/navigation/useLastAppPath.ts new file mode 100644 index 00000000..72ce5d71 --- /dev/null +++ b/frontend/src/navigation/useLastAppPath.ts @@ -0,0 +1,20 @@ +import { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { getSectionForPath } from './sections'; + +/** + * Remembers the last route outside any section, so leaving a section returns + * the user to the conversation they were in rather than to a blank chat. + */ +export function useLastAppPath(fallback = '/') { + const location = useLocation(); + const lastAppPath = useRef(fallback); + + useEffect(() => { + if (!getSectionForPath(location.pathname)) + lastAppPath.current = `${location.pathname}${location.search}`; + }, [location.pathname, location.search]); + + return lastAppPath; +} diff --git a/frontend/src/settings/RemoteDeviceConfig.tsx b/frontend/src/settings/RemoteDeviceConfig.tsx index f8426410..b5ac1238 100644 --- a/frontend/src/settings/RemoteDeviceConfig.tsx +++ b/frontend/src/settings/RemoteDeviceConfig.tsx @@ -7,10 +7,10 @@ import devicesService, { AuditEntry, Device, } from '../api/services/devicesService'; -import ArrowLeft from '../assets/arrow-left.svg'; import CopyButton from '../components/CopyButton'; import Spinner from '../components/Spinner'; import ToolIcon from '../components/ToolIcon'; +import DetailBreadcrumb from '../navigation/DetailBreadcrumb'; import { Accordion, AccordionContent, @@ -194,19 +194,12 @@ export default function RemoteDeviceConfig({ tool, handleGoBack }: Props) { return (
-
-
- -

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

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

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

-
+
+ -
- - {tabsList.map((tab) => ( - - {tab} - - ))} - -
- -
- - - } /> - - } - /> - } /> - } /> - } /> - } - /> - } /> - } /> - } /> - +
+ {showIndex ? ( + + ) : ( + <> + + + } /> + } /> + + } + /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + + + )} +
); } From cfbf61f4f3ae75015e7c204ae702fabe199375f3 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 21 Sep 2026 23:04:12 +0100 Subject: [PATCH 2/8] Split the agents URL space and move its navigation into the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/Navigation.tsx | 36 +- frontend/src/admin/index.tsx | 7 +- frontend/src/agents/AgentCard.tsx | 22 +- frontend/src/agents/AgentLogs.tsx | 80 ++-- frontend/src/agents/AgentPageHeader.tsx | 18 +- frontend/src/agents/AgentsList.tsx | 266 ++++++----- frontend/src/agents/NewAgent.tsx | 26 +- frontend/src/agents/SharedAgentGate.tsx | 4 +- .../src/agents/components/AgentTypeModal.tsx | 7 +- frontend/src/agents/hooks/useAgentSearch.ts | 18 +- frontend/src/agents/index.tsx | 51 ++- frontend/src/agents/paths.test.ts | 72 +++ frontend/src/agents/paths.ts | 115 +++++ .../src/agents/schedules/SchedulesView.tsx | 425 +++++++++--------- .../src/agents/workflow/WorkflowBuilder.tsx | 5 +- frontend/src/conversation/Conversation.tsx | 10 +- frontend/src/locale/de.json | 1 + frontend/src/locale/en.json | 1 + frontend/src/locale/es.json | 1 + frontend/src/locale/jp.json | 1 + frontend/src/locale/ru.json | 1 + frontend/src/locale/zh-TW.json | 1 + frontend/src/locale/zh.json | 1 + frontend/src/modals/ImportAgentModal.tsx | 5 +- frontend/src/navigation/SectionIndexPage.tsx | 2 +- frontend/src/navigation/SectionNav.tsx | 10 +- frontend/src/navigation/SectionPageHeader.tsx | 41 +- frontend/src/navigation/SectionPills.tsx | 59 +++ frontend/src/navigation/sections.test.ts | 59 ++- frontend/src/navigation/sections.ts | 146 +++++- frontend/src/navigation/useSectionContext.ts | 54 +++ frontend/src/settings/index.tsx | 7 +- .../tier-b/guardrail-events-panel.spec.ts | 10 +- tests/e2e/specs/tier-b/guardrails.spec.ts | 2 +- tests/e2e/specs/tier-c/ui-smoke.spec.ts | 6 +- 35 files changed, 1055 insertions(+), 515 deletions(-) create mode 100644 frontend/src/agents/paths.test.ts create mode 100644 frontend/src/agents/paths.ts create mode 100644 frontend/src/navigation/SectionPills.tsx create mode 100644 frontend/src/navigation/useSectionContext.ts diff --git a/frontend/src/Navigation.tsx b/frontend/src/Navigation.tsx index b1f120e0..89184f39 100644 --- a/frontend/src/Navigation.tsx +++ b/frontend/src/Navigation.tsx @@ -12,6 +12,11 @@ import { Settings as SettingsIcon, } from 'lucide-react'; +import { + AGENTS_MANAGE_ROOT, + agentChatPath, + sharedAgentPath, +} from './agents/paths'; import { Agent } from './agents/types'; import conversationService from './api/services/conversationService'; import userService from './api/services/userService'; @@ -42,7 +47,7 @@ import { ActiveState } from './models/misc'; import { getConversations } from './preferences/preferenceApi'; import SectionNav from './navigation/SectionNav'; import SectionRail from './navigation/SectionRail'; -import { useActiveSection } from './navigation/sections'; +import { useSectionContext } from './navigation/useSectionContext'; import { useLastAppPath } from './navigation/useLastAppPath'; import { selectAgents, @@ -90,14 +95,19 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { // Section state is derived from the route, so deep links and the browser // back button keep working without a second source of truth. const { section: activeSection, item: activeSectionItem } = - useActiveSection(); + useSectionContext(); const lastAppPath = useLastAppPath(); const inSection = Boolean(activeSection); - const backToAppLabel = t('navigation.backToApp'); + + // Sections nest, so back means "up one level": out of an agent lands on + // the agent list, and out of a top-level section lands back in the app. + const backLabel = activeSection?.parentLabelKey + ? t(activeSection.parentLabelKey) + : t('navigation.backToApp'); const exitSection = () => { if (isMobile || isTablet) setNavOpen(false); - navigate(lastAppPath.current || '/'); + navigate(activeSection?.parentPath ?? lastAppPath.current ?? '/'); }; const closeNavOnMobile = () => { @@ -224,7 +234,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { resetConversation(); dispatch(setSelectedAgent(agent)); if (isMobile || isTablet) setNavOpen(!navOpen); - navigate(agent.id ? `/agents/${agent.id}/c/new` : '/c/new'); + navigate(agent.id ? agentChatPath(agent.id) : '/c/new'); }; const handleTogglePin = (agent: Agent) => { @@ -271,7 +281,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { return; } agent = await sharedResponse.json(); - navigate(`/agents/shared/${agent.shared_token}`); + navigate(sharedAgentPath(agent.shared_token)); } else { const agentResponse = await userService.getAgent(data.agent_id, token); if (!agentResponse.ok) { @@ -280,10 +290,10 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { } agent = await agentResponse.json(); if (agent.shared_token) { - navigate(`/agents/shared/${agent.shared_token}`); + navigate(sharedAgentPath(agent.shared_token)); } else { await Promise.resolve(dispatch(setSelectedAgent(agent))); - navigate(`/agents/${data.agent_id}/c/${index}`); + navigate(agentChatPath(data.agent_id, index)); } } } catch (error) { @@ -362,7 +372,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { activeItemKey={activeSectionItem?.key} isAdmin={isAdmin} onBack={exitSection} - backLabel={backToAppLabel} + backLabel={backLabel} /> ) : ( <> @@ -384,7 +394,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { size="icon" onClick={() => { dispatch(setSelectedAgent(null)); - navigate('/agents'); + navigate(AGENTS_MANAGE_ROOT); }} aria-label={t('manageAgents')} className="text-muted-foreground hover:text-foreground" @@ -567,7 +577,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { ))}
{ dispatch(setSelectedAgent(null)); @@ -596,7 +606,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
) : ( { if (isMobile || isTablet) { @@ -684,7 +694,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { activeItemKey={activeSectionItem?.key} isAdmin={isAdmin} onBack={exitSection} - backLabel={backToAppLabel} + backLabel={backLabel} onNavigate={closeNavOnMobile} /> )} diff --git a/frontend/src/admin/index.tsx b/frontend/src/admin/index.tsx index 2354661b..f8ad6157 100644 --- a/frontend/src/admin/index.tsx +++ b/frontend/src/admin/index.tsx @@ -2,8 +2,8 @@ import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; import { useMediaQuery } from '../hooks'; import SectionIndexPage from '../navigation/SectionIndexPage'; -import SectionPageHeader from '../navigation/SectionPageHeader'; -import { ADMIN_SECTION, getActiveItem } from '../navigation/sections'; +import { CurrentSectionHeader } from '../navigation/SectionPageHeader'; +import { ADMIN_SECTION } from '../navigation/sections'; import Admins from './Admins'; import Audit from './Audit'; import Overview from './Overview'; @@ -20,7 +20,6 @@ export default function Admin() { const location = useLocation(); const { isMobile, isTablet } = useMediaQuery(); - const activeItem = getActiveItem(ADMIN_SECTION, location.pathname); const showIndex = (isMobile || isTablet) && location.pathname === ADMIN_SECTION.rootPath; @@ -31,7 +30,7 @@ export default function Admin() { ) : ( <> - + } /> } /> diff --git a/frontend/src/agents/AgentCard.tsx b/frontend/src/agents/AgentCard.tsx index 64ae3dc3..d2df7efd 100644 --- a/frontend/src/agents/AgentCard.tsx +++ b/frontend/src/agents/AgentCard.tsx @@ -44,6 +44,12 @@ import { setAgents, setSelectedAgent, } from '../preferences/preferenceSlice'; +import { + agentChatPath, + agentEditPath, + agentLogsPath, + sharedAgentPath, +} from './paths'; import { Agent } from './types'; type AgentCardProps = { @@ -91,7 +97,7 @@ export default function AgentCard({ label: 'Logs', onClick: (e: SyntheticEvent) => { e.stopPropagation(); - navigate(`/agents/logs/${agent.id}`); + navigate(agentLogsPath(agent.id)); }, variant: 'default', iconWidth: 14, @@ -103,9 +109,9 @@ export default function AgentCard({ onClick: (e: SyntheticEvent) => { e.stopPropagation(); if (agent.agent_type === 'workflow') { - navigate(`/agents/workflow/edit/${agent.id}`); + navigate(agentEditPath(agent.id, true)); } else { - navigate(`/agents/edit/${agent.id}`); + navigate(agentEditPath(agent.id)); } }, variant: 'default', @@ -189,9 +195,9 @@ export default function AgentCard({ onClick: (e: SyntheticEvent) => { e.stopPropagation(); if (agent.agent_type === 'workflow') { - navigate(`/agents/workflow/edit/${agent.id}`); + navigate(agentEditPath(agent.id, true)); } else { - navigate(`/agents/edit/${agent.id}`); + navigate(agentEditPath(agent.id)); } }, variant: 'default', @@ -220,7 +226,7 @@ export default function AgentCard({ label: 'Open', onClick: (e: SyntheticEvent) => { e.stopPropagation(); - navigate(`/agents/shared/${agent.shared_token}`); + navigate(sharedAgentPath(agent.shared_token)); }, variant: 'default', iconWidth: 12, @@ -258,11 +264,11 @@ export default function AgentCard({ if (section === 'user' || section === 'team') { if (agent.status === 'published') { dispatch(setSelectedAgent(agent)); - navigate(agent.id ? `/agents/${agent.id}/c/new` : '/c/new'); + navigate(agent.id ? agentChatPath(agent.id) : '/c/new'); } } if (section === 'shared') { - navigate(`/agents/shared/${agent.shared_token}`); + navigate(sharedAgentPath(agent.shared_token)); } }; diff --git a/frontend/src/agents/AgentLogs.tsx b/frontend/src/agents/AgentLogs.tsx index 9fbe165e..85ff3485 100644 --- a/frontend/src/agents/AgentLogs.tsx +++ b/frontend/src/agents/AgentLogs.tsx @@ -9,7 +9,8 @@ import { selectToken } from '../preferences/preferenceSlice'; import Analytics from '../settings/Analytics'; import Logs from '../settings/Logs'; import { formatDateTime } from '../utils/dateTimeUtils'; -import AgentPageHeader from './AgentPageHeader'; +import { CurrentSectionHeader } from '../navigation/SectionPageHeader'; +import SectionPills from '../navigation/SectionPills'; import GuardrailEvents from './components/GuardrailEvents'; import { Agent } from './types'; @@ -39,51 +40,46 @@ export default function AgentLogs() { if (agentId) fetchAgent(agentId); }, [agentId, token]); - const agentEditPath = - agent?.agent_type === 'workflow' - ? `/agents/workflow/edit/${agentId}` - : `/agents/edit/${agentId}`; - return ( -
- -
- {agent && ( -
-

{agent.name}

-

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

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

{agent.name}

+

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

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

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

-

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

+
+
+ +

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

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

{t('agents.noSearchResults')}

+

{t('agents.tryDifferentSearch')}

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

{t('agents.noSearchResults')}

-

{t('agents.tryDifferentSearch')}

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

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

+ + + ) : ( +

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

+ )}

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

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

{agent.name}

-

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

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

{agent.name}

+

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

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

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

- -
-
-

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

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

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

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

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

    - -
    -

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

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

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

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

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

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

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

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

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

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

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

      +
      +
      +

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

      + +
      +

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

      -
      -

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

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

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

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

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

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

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

    + +
    +

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

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

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

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

-

- {t(section.titleKey)} +

+ {sectionTitle}