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 ? ( + + ) : ( + <> + + + } /> + } /> + + } + /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + + + )} +
); }