+ {/* Contain route render crashes so navigation stays usable;
keyed by path so the boundary resets when the user leaves. */}
-
-
-
+
+
+
+
+
+
+
-
-
-
-
+
);
}
export default function App() {
diff --git a/frontend/src/Navigation.tsx b/frontend/src/Navigation.tsx
index 89184f39..59b7ea11 100644
--- a/frontend/src/Navigation.tsx
+++ b/frontend/src/Navigation.tsx
@@ -47,6 +47,13 @@ import { ActiveState } from './models/misc';
import { getConversations } from './preferences/preferenceApi';
import SectionNav from './navigation/SectionNav';
import SectionRail from './navigation/SectionRail';
+import SidebarLevel from './navigation/SidebarLevel';
+import {
+ getActiveItem,
+ getSectionForPath,
+ type Section,
+} from './navigation/sections';
+import { useSidebarLevel } from './navigation/SidebarLevelProvider';
import { useSectionContext } from './navigation/useSectionContext';
import { useLastAppPath } from './navigation/useLastAppPath';
import {
@@ -94,22 +101,57 @@ 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 } =
- useSectionContext();
+ const { section: routeSection, item: routeSectionItem } = useSectionContext();
+ const { pending, goToLevel } = useSidebarLevel();
+
+ // While a level change is in flight the sidebar runs ahead of the route,
+ // so it can start moving on the click rather than on the commit.
+ const activeSection = pending ? pending.section : routeSection;
+ const activeSectionItem =
+ pending && pending.section
+ ? getActiveItem(pending.section, pending.pathname)
+ : pending
+ ? null
+ : routeSectionItem;
const lastAppPath = useLastAppPath();
const inSection = Boolean(activeSection);
- // 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');
+ // The sidebar is a stack: chats, a section, and a record inside it. A
+ // section that declares a parent sits on the third level, above its
+ // parent's nav.
+ const nestedSection = activeSection?.parentPath ? activeSection : null;
+ const topSection = nestedSection
+ ? getSectionForPath(nestedSection.parentPath ?? '')
+ : activeSection;
+ const sidebarDepth = nestedSection ? 2 : activeSection ? 1 : 0;
- const exitSection = () => {
+ // Panels stay mounted after being left so they have something to animate
+ // out, and so the one behind is already there to be revealed on the way
+ // back. They park off screen, so the cost is a subtree nobody can see.
+ const lastTopSection = useRef(null);
+ const lastNestedSection = useRef(null);
+ if (topSection) lastTopSection.current = topSection;
+ if (nestedSection) lastNestedSection.current = nestedSection;
+ const topPanel = topSection ?? lastTopSection.current;
+ const nestedPanel = nestedSection ?? lastNestedSection.current;
+
+ // Back means "up one level": out of an agent lands on the agent list, out
+ // of a top-level section lands back in the app.
+ const backLabelFor = (section: Section) =>
+ section.parentLabelKey
+ ? t(section.parentLabelKey)
+ : t('navigation.backToApp');
+
+ const exitSectionFrom = (section: Section | null) => () => {
if (isMobile || isTablet) setNavOpen(false);
- navigate(activeSection?.parentPath ?? lastAppPath.current ?? '/');
+ goToLevel(section?.parentPath ?? lastAppPath.current ?? '/');
};
+ const exitSection = exitSectionFrom(activeSection);
+ const backLabel = activeSection
+ ? backLabelFor(activeSection)
+ : t('navigation.backToApp');
+
const closeNavOnMobile = () => {
if (isMobile || isTablet) setNavOpen(false);
};
@@ -394,7 +436,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
size="icon"
onClick={() => {
dispatch(setSelectedAgent(null));
- navigate(AGENTS_MANAGE_ROOT);
+ goToLevel(AGENTS_MANAGE_ROOT);
}}
aria-label={t('manageAgents')}
className="text-muted-foreground hover:text-foreground"
@@ -419,7 +461,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
type="button"
variant="ghost"
size="icon"
- onClick={() => navigate('/settings')}
+ onClick={() => goToLevel('/settings')}
aria-label={t('settings.label')}
className="text-muted-foreground hover:text-foreground"
>
@@ -476,14 +518,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
mounted so the conversation list keeps its scroll position while
the user is away in a section. */}
{/* Inside a section its own nav is the way around, so this entry
@@ -712,8 +764,13 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
)}
>
{
+ if (event.metaKey || event.ctrlKey || event.shiftKey) return;
+ event.preventDefault();
+ closeNavOnMobile();
+ goToLevel('/settings');
+ }}
className="hover:bg-sidebar-accent mx-4 my-auto flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3"
>
{
e.stopPropagation();
- navigate(agentLogsPath(agent.id));
+ goToLevel(agentLogsPath(agent.id));
},
variant: 'default',
iconWidth: 14,
@@ -109,9 +114,9 @@ export default function AgentCard({
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
if (agent.agent_type === 'workflow') {
- navigate(agentEditPath(agent.id, true));
+ goToLevel(agentEditPath(agent.id, true));
} else {
- navigate(agentEditPath(agent.id));
+ goToLevel(agentEditPath(agent.id));
}
},
variant: 'default',
@@ -195,9 +200,9 @@ export default function AgentCard({
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
if (agent.agent_type === 'workflow') {
- navigate(agentEditPath(agent.id, true));
+ goToLevel(agentEditPath(agent.id, true));
} else {
- navigate(agentEditPath(agent.id));
+ goToLevel(agentEditPath(agent.id));
}
},
variant: 'default',
diff --git a/frontend/src/navigation/SectionNav.tsx b/frontend/src/navigation/SectionNav.tsx
index 33a3c1b6..ec1ff63b 100644
--- a/frontend/src/navigation/SectionNav.tsx
+++ b/frontend/src/navigation/SectionNav.tsx
@@ -5,6 +5,7 @@ import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { getVisibleGroups, type Section, type SectionItem } from './sections';
+import { useSidebarLevel } from './SidebarLevelProvider';
type SectionNavProps = {
section: Section;
@@ -33,6 +34,7 @@ export default function SectionNav({
onNavigate,
}: SectionNavProps) {
const { t } = useTranslation();
+ const { goToLevel } = useSidebarLevel();
const groups = getVisibleGroups(section, { isAdmin });
const sectionTitle = section.title ?? t(section.titleKey);
@@ -43,7 +45,15 @@ export default function SectionNav({
{
+ // Same level, so nothing slides — but routing through the level
+ // provider still renders the page at low priority, which keeps the
+ // highlight moving under the cursor instead of after the mount.
+ if (event.metaKey || event.ctrlKey || event.shiftKey) return;
+ event.preventDefault();
+ goToLevel(item.path);
+ onNavigate?.();
+ }}
aria-current={isActive ? 'page' : undefined}
className={cn(
'hover:bg-sidebar-accent mx-4 my-1 flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3',
diff --git a/frontend/src/navigation/SectionRail.tsx b/frontend/src/navigation/SectionRail.tsx
index e9be3372..5ece5227 100644
--- a/frontend/src/navigation/SectionRail.tsx
+++ b/frontend/src/navigation/SectionRail.tsx
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { getSectionItems, type Section } from './sections';
+import { useSidebarLevel } from './SidebarLevelProvider';
type SectionRailProps = {
section: Section;
@@ -28,10 +29,17 @@ export default function SectionRail({
backLabel,
}: SectionRailProps) {
const { t } = useTranslation();
+ const { goToLevel } = useSidebarLevel();
const items = getSectionItems(section, { isAdmin });
return (
- <>
+ // Keyed on the section so switching level replays the fade: the rail is
+ // too narrow to slide panels through, but it should not swap in place
+ // with no acknowledgement either.
+
);
}
diff --git a/frontend/src/navigation/SidebarLevel.tsx b/frontend/src/navigation/SidebarLevel.tsx
new file mode 100644
index 00000000..20d15f10
--- /dev/null
+++ b/frontend/src/navigation/SidebarLevel.tsx
@@ -0,0 +1,73 @@
+import type { ReactNode } from 'react';
+
+import { cn } from '@/lib/utils';
+
+type SidebarLevelProps = {
+ /** This panel's place in the hierarchy: 0 chats, 1 a section, 2 a record. */
+ depth: number;
+ /** The level on screen right now. */
+ current: number;
+ children: ReactNode;
+ className?: string;
+};
+
+/**
+ * One panel in the sidebar's navigation stack.
+ *
+ * Every panel is positioned from a single number — its depth relative to the
+ * level on screen — so push and pop fall out of the same rule instead of
+ * needing a direction to be tracked. A panel above the current level waits
+ * off to the right; the current one sits at rest; ones below are parked just
+ * off to the left. Changing level therefore animates both panels the right
+ * way round, whichever way the user is going.
+ *
+ * The panel behind only travels a quarter of the width, so it trails the
+ * incoming panel rather than marching with it — the cue that one sits on top
+ * of the other rather than beside it. Each panel paints its own background
+ * so it occludes the one behind while it slides.
+ *
+ * `visibility` is in the transition on purpose: CSS keeps an element visible
+ * for the whole duration when either end of the transition is `visible`, so a
+ * panel stays on screen while it leaves and only drops out of the tab order
+ * once it has gone. Panels are never unmounted, which is what lets the chat
+ * list keep its scroll position across a trip into settings.
+ */
+export default function SidebarLevel({
+ depth,
+ current,
+ children,
+ className,
+}: SidebarLevelProps) {
+ const offset = depth - current;
+
+ return (
+
+ );
+}
diff --git a/frontend/src/navigation/SidebarLevelProvider.tsx b/frontend/src/navigation/SidebarLevelProvider.tsx
new file mode 100644
index 00000000..37d766d6
--- /dev/null
+++ b/frontend/src/navigation/SidebarLevelProvider.tsx
@@ -0,0 +1,82 @@
+import {
+ createContext,
+ startTransition,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+ type ReactNode,
+} from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+
+import type { Section } from './sections';
+import { useSectionResolver } from './useSectionResolver';
+
+type PendingLevel = { pathname: string; section: Section | null };
+
+type SidebarLevelValue = {
+ /** The level the sidebar should show, ahead of the route when moving. */
+ pending: PendingLevel | null;
+ /** Navigate in a way the sidebar can animate immediately. */
+ goToLevel: (to: string) => void;
+};
+
+const SidebarLevelContext = createContext({
+ pending: null,
+ goToLevel: () => {},
+});
+
+/**
+ * Lets the sidebar change level on the click rather than on the commit.
+ *
+ * Mounting a section's page is expensive — measured at a single ~170ms
+ * blocking frame in a production build — and the sidebar's own class change
+ * used to ride along in that same commit. The panels therefore only began
+ * moving once the new page had rendered: a pause, and then a slide the user
+ * had stopped expecting.
+ *
+ * So the two updates are split by priority. The level lands as an urgent
+ * update that touches nothing but the sidebar, so React can commit and paint
+ * it straight away and the transition starts on time; the route change goes
+ * through `startTransition`, which renders the page at low priority and
+ * yields between slices instead of blocking that paint. The target's section
+ * is resolved up front so the incoming panel slides in with its content
+ * already in place rather than arriving empty.
+ */
+export function SidebarLevelProvider({ children }: { children: ReactNode }) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const resolve = useSectionResolver();
+ const [pending, setPending] = useState(null);
+
+ const goToLevel = useCallback(
+ (to: string) => {
+ const pathname = to.split('?')[0];
+ setPending({ pathname, section: resolve(pathname) });
+ startTransition(() => navigate(to));
+ },
+ [navigate, resolve],
+ );
+
+ // Hand back to the route once it catches up, and never hold the sidebar
+ // ahead of it for long: a navigation can be refused (an unsaved-changes
+ // guard) or land somewhere else entirely, and a level that never resolved
+ // would leave the sidebar showing a section the user is not in.
+ useEffect(() => {
+ if (!pending) return;
+ if (pending.pathname === location.pathname) {
+ setPending(null);
+ return;
+ }
+ const timer = setTimeout(() => setPending(null), 600);
+ return () => clearTimeout(timer);
+ }, [pending, location.pathname]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export const useSidebarLevel = () => useContext(SidebarLevelContext);
diff --git a/frontend/src/navigation/sections.test.ts b/frontend/src/navigation/sections.test.ts
index 5ed518b2..31c6ff1d 100644
--- a/frontend/src/navigation/sections.test.ts
+++ b/frontend/src/navigation/sections.test.ts
@@ -4,6 +4,7 @@ import {
ADMIN_SECTION,
AGENTS_SECTION,
buildAgentSection,
+ depthOf,
getActiveItem,
getSectionForPath,
getSectionItems,
@@ -140,3 +141,17 @@ describe('buildAgentSection', () => {
);
});
});
+
+describe('depthOf', () => {
+ it('puts chats, sections and records on their own level', () => {
+ expect(depthOf(null)).toBe(0);
+ expect(depthOf(getSectionForPath('/settings'))).toBe(1);
+ expect(depthOf(getSectionForPath('/agents/manage'))).toBe(1);
+ expect(depthOf(getSectionForPath('/admin/users'))).toBe(1);
+ expect(depthOf(buildAgentSection('a1', 'Support bot', false))).toBe(2);
+ });
+
+ it('keeps a chat with an agent at the chat level', () => {
+ expect(depthOf(getSectionForPath('/agents/a1/c/c1'))).toBe(0);
+ });
+});
diff --git a/frontend/src/navigation/sections.ts b/frontend/src/navigation/sections.ts
index aff680c3..6d6b81c6 100644
--- a/frontend/src/navigation/sections.ts
+++ b/frontend/src/navigation/sections.ts
@@ -378,3 +378,10 @@ export function getActiveItem(
}
return best;
}
+
+/**
+ * Which level of the sidebar stack a section occupies: the chat list, a
+ * section, or a record inside one.
+ */
+export const depthOf = (section: Section | null): number =>
+ section ? (section.parentPath ? 2 : 1) : 0;
diff --git a/frontend/src/navigation/useSectionResolver.ts b/frontend/src/navigation/useSectionResolver.ts
new file mode 100644
index 00000000..5e509639
--- /dev/null
+++ b/frontend/src/navigation/useSectionResolver.ts
@@ -0,0 +1,38 @@
+import { useCallback } from 'react';
+import { useSelector } from 'react-redux';
+
+import { matchAgentScopedRoute } from '../agents/paths';
+import {
+ selectAgents,
+ selectSelectedAgent,
+ selectSharedAgents,
+} from '../preferences/preferenceSlice';
+import { buildAgentSection, getSectionForPath, type Section } from './sections';
+
+/**
+ * Resolves any pathname to its section, including the ones built per route
+ * from a record in the store. Taking a pathname rather than reading the
+ * current one lets the sidebar resolve a route it is *about* to go to, which
+ * is what allows it to start moving on the click.
+ */
+export function useSectionResolver(): (pathname: string) => Section | null {
+ const agents = useSelector(selectAgents);
+ const sharedAgents = useSelector(selectSharedAgents);
+ const selectedAgent = useSelector(selectSelectedAgent);
+
+ return useCallback(
+ (pathname: string) => {
+ const scoped = matchAgentScopedRoute(pathname);
+ if (!scoped) return getSectionForPath(pathname);
+
+ const name = [
+ ...(agents ?? []),
+ ...(sharedAgents ?? []),
+ ...(selectedAgent ? [selectedAgent] : []),
+ ].find((agent) => agent.id === scoped.agentId)?.name;
+
+ return buildAgentSection(scoped.agentId, name, scoped.workflow);
+ },
+ [agents, sharedAgents, selectedAgent],
+ );
+}