diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49f508ca..2772d157 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ import useDataInitializer from './hooks/useDataInitializer'; import useTokenAuth from './hooks/useTokenAuth'; import Navigation from './Navigation'; import { getSectionForPath } from './navigation/sections'; +import { SidebarLevelProvider } from './navigation/SidebarLevelProvider'; import PageNotFound from './PageNotFound'; import Setting from './settings'; import Teams from './settings/Teams'; @@ -91,26 +92,28 @@ function MainLayout() { const inSection = Boolean(getSectionForPath(location.pathname)); return ( -
- - -
- {/* Contain route render crashes so navigation stays usable; + +
+ + +
+ {/* 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. */}
-
+ { @@ -579,11 +614,13 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { { + onClick={(event) => { dispatch(setSelectedAgent(null)); - if (isMobile || isTablet) { - setNavOpen(false); - } + closeNavOnMobile(); + if (event.metaKey || event.ctrlKey || event.shiftKey) + return; + event.preventDefault(); + goToLevel(AGENTS_MANAGE_ROOT); }} 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 ${ @@ -608,11 +645,13 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { { - if (isMobile || isTablet) { - setNavOpen(false); - } + onClick={(event) => { + closeNavOnMobile(); dispatch(setSelectedAgent(null)); + if (event.metaKey || event.ctrlKey || event.shiftKey) + return; + event.preventDefault(); + goToLevel(AGENTS_MANAGE_ROOT); }} 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 ${ @@ -679,26 +718,39 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { <> )}
-
-
- {activeSection && ( + + + {topPanel && ( )} -
+ + + {nestedPanel && ( + + )} +
{/* 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 ( +
0 && 'invisible translate-x-full opacity-100', + className, + )} + > + {children} +
+ ); +} 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], + ); +}