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 (
+ {/* 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. */}
+
+ );
+}
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 (