From ac874307158d63b377e8bd8fb9dd5f6099f9e471 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 21 Sep 2026 23:45:50 +0100 Subject: [PATCH] Animate the sidebar as a stack, and let it move on the click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar cross-faded between two states, which stopped describing what was happening once sections could nest: entering a section slid, but opening an agent from the agent list swapped in place with no motion at all, so going deeper and going sideways looked identical. Panels are now positioned from a single number — their depth relative to the level on screen. A panel above the current level waits off to the right, the current one sits at rest, and ones below park just off to the left, so push and pop fall out of the same rule and no direction has to be tracked. The panel behind travels a quarter of the width and dims rather than sliding out with the one in front, and the arriving panel carries a shadow off its leading edge that the container clips once it lands, so the two read as stacked rather than adjacent. The motion was also starting far too late. Mounting a section's page costs a single ~170ms blocking frame in a production build, and the sidebar's own class change rode along in that same commit: measured from the click, the panels did not begin moving for ~290ms, so the animation played to an audience that had stopped expecting it. The two updates are now split by priority. The level lands as an urgent update touching nothing but the sidebar, so React can commit and paint it straight away; the route change goes through startTransition, which renders the page at low priority and yields instead of blocking that paint. The target's section is resolved from the path up front, so the incoming panel arrives with its content already in place. The style change now lands ~53ms after the click. Only translate and opacity are animated, so the compositor keeps the motion smooth across the frames the page render still costs. Timing is tuned against where the travel actually lands rather than by feel: half the distance by ~65ms so the panel tracks the click, 90% by ~180ms so the movement reads as movement, settled by ~300ms. --- frontend/src/App.tsx | 39 ++--- frontend/src/Navigation.tsx | 143 ++++++++++++------ frontend/src/agents/AgentCard.tsx | 15 +- frontend/src/navigation/SectionNav.tsx | 12 +- frontend/src/navigation/SectionRail.tsx | 17 ++- frontend/src/navigation/SidebarLevel.tsx | 73 +++++++++ .../src/navigation/SidebarLevelProvider.tsx | 82 ++++++++++ frontend/src/navigation/sections.test.ts | 15 ++ frontend/src/navigation/sections.ts | 7 + frontend/src/navigation/useSectionResolver.ts | 38 +++++ 10 files changed, 372 insertions(+), 69 deletions(-) create mode 100644 frontend/src/navigation/SidebarLevel.tsx create mode 100644 frontend/src/navigation/SidebarLevelProvider.tsx create mode 100644 frontend/src/navigation/useSectionResolver.ts 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], + ); +}