Animate the sidebar as a stack, and let it move on the click

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.
This commit is contained in:
Alex committed 2026-09-21 23:45:50 +01:00
1 parent cfbf61f4f3
commit ac87430715
10 files changed
+372 -69

No files matched your search

+21 -18
View File
@@ -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 (
<div className="bg-background relative h-screen overflow-hidden">
<Navigation navOpen={navOpen} setNavOpen={setNavOpen} />
<ActionButtons showNewChat={!inSection} showShare={!inSection} />
<div
className={`h-[calc(100dvh-64px)] overflow-auto transition-all duration-300 ease-in-out lg:h-screen ${
!(isMobile || isTablet)
? `${navOpen ? 'lg:ml-72' : 'lg:ml-14'}`
: 'ml-0 lg:ml-16'
}`}
>
{/* Contain route render crashes so navigation stays usable;
<SidebarLevelProvider>
<div className="bg-background relative h-screen overflow-hidden">
<Navigation navOpen={navOpen} setNavOpen={setNavOpen} />
<ActionButtons showNewChat={!inSection} showShare={!inSection} />
<div
className={`h-[calc(100dvh-64px)] overflow-auto transition-all duration-300 ease-in-out lg:h-screen ${
!(isMobile || isTablet)
? `${navOpen ? 'lg:ml-72' : 'lg:ml-14'}`
: 'ml-0 lg:ml-16'
}`}
>
{/* Contain route render crashes so navigation stays usable;
keyed by path so the boundary resets when the user leaves. */}
<ErrorBoundary key={location.pathname}>
<Outlet />
</ErrorBoundary>
<ErrorBoundary key={location.pathname}>
<Outlet />
</ErrorBoundary>
</div>
<UploadToast />
<ToolApprovalToast />
<TeamNotificationToast />
</div>
<UploadToast />
<ToolApprovalToast />
<TeamNotificationToast />
</div>
</SidebarLevelProvider>
);
}
export default function App() {
+100 -43
View File
@@ -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<Section | null>(null);
const lastNestedSection = useRef<Section | null>(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. */}
<div className="relative flex min-h-0 flex-1 overflow-hidden">
<div
className={cn(
'absolute inset-0 flex flex-col transition-[translate,opacity,visibility] duration-300 ease-in-out',
inSection
? 'invisible -translate-x-full opacity-0'
: 'visible translate-x-0 opacity-100',
)}
>
<SidebarLevel depth={0} current={sidebarDepth}>
<NavLink
to={'/c/new'}
onClick={() => {
@@ -579,11 +614,13 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<NavLink
to={AGENTS_MANAGE_ROOT}
end
onClick={() => {
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) {
<NavLink
to={AGENTS_MANAGE_ROOT}
end
onClick={() => {
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) {
<></>
)}
</div>
</div>
<div
className={cn(
'absolute inset-0 flex flex-col transition-[translate,opacity,visibility] duration-300 ease-in-out',
inSection
? 'visible translate-x-0 opacity-100'
: 'invisible translate-x-full opacity-0',
)}
>
{activeSection && (
</SidebarLevel>
<SidebarLevel depth={1} current={sidebarDepth}>
{topPanel && (
<SectionNav
section={activeSection}
activeItemKey={activeSectionItem?.key}
section={topPanel}
activeItemKey={
topPanel === activeSection
? activeSectionItem?.key
: undefined
}
isAdmin={isAdmin}
onBack={exitSection}
backLabel={backLabel}
onBack={exitSectionFrom(topPanel)}
backLabel={backLabelFor(topPanel)}
onNavigate={closeNavOnMobile}
/>
)}
</div>
</SidebarLevel>
<SidebarLevel depth={2} current={sidebarDepth}>
{nestedPanel && (
<SectionNav
section={nestedPanel}
activeItemKey={
nestedPanel === activeSection
? activeSectionItem?.key
: undefined
}
isAdmin={isAdmin}
onBack={exitSectionFrom(nestedPanel)}
backLabel={backLabelFor(nestedPanel)}
onNavigate={closeNavOnMobile}
/>
)}
</SidebarLevel>
</div>
<div className="text-foreground flex h-auto shrink-0 flex-col justify-end dark:text-white">
{/* Inside a section its own nav is the way around, so this entry
@@ -712,8 +764,13 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
)}
>
<Link
onClick={closeNavOnMobile}
to="/settings"
onClick={(event) => {
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"
>
<SettingsIcon
+10 -5
View File
@@ -28,6 +28,7 @@ import { Modal } from '../components/ui/modal';
import ConfirmationModal from '../modals/ConfirmationModal';
import MoveToFolderModal from '../modals/MoveToFolderModal';
import { ActiveState } from '../models/misc';
import { useSidebarLevel } from '../navigation/SidebarLevelProvider';
import ShareToTeamModal from '../teams/ShareToTeamModal';
type AgentMenuOption = {
@@ -67,6 +68,10 @@ export default function AgentCard({
}: AgentCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
// Opening an agent is a level change, so it goes through the sidebar's
// navigator: the panel starts sliding on the click rather than waiting for
// the editor to mount.
const { goToLevel } = useSidebarLevel();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const userAgents = useSelector(selectAgents);
@@ -97,7 +102,7 @@ export default function AgentCard({
label: 'Logs',
onClick: (e: SyntheticEvent) => {
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',
+11 -1
View File
@@ -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({
<Link
key={item.key}
to={item.path}
onClick={onNavigate}
onClick={(event) => {
// 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',
+15 -2
View File
@@ -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.
<div
key={section.key}
className="animate-in fade-in flex flex-col items-center gap-2 duration-200 motion-reduce:animate-none"
>
<Button
type="button"
variant="ghost"
@@ -52,6 +60,11 @@ export default function SectionRail({
<Link
key={item.key}
to={item.path}
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault();
goToLevel(item.path);
}}
aria-label={label}
aria-current={isActive ? 'page' : undefined}
title={label}
@@ -64,6 +77,6 @@ export default function SectionRail({
</Link>
);
})}
</>
</div>
);
}
+73
View File
@@ -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 (
<div
aria-hidden={offset !== 0}
className={cn(
'bg-sidebar absolute inset-0 flex flex-col',
'transition-[translate,opacity,visibility] duration-400',
// Decelerating, and tuned against where the travel actually lands:
// half the distance by ~65ms so the panel tracks the click, 90% by
// ~180ms so the movement reads as movement, settled by ~300ms. A
// sharper curve (the usual 0.32,0.72,0,1) covers 90% in 110ms at this
// duration, which registers as a cut rather than a slide; an even
// one (0.4,0,0.2,1) takes 105ms just to reach halfway and feels like
// it is lagging behind the pointer.
'ease-[cubic-bezier(0.25,0.8,0.25,1)] motion-reduce:transition-none',
// The arriving panel carries a shadow off its leading edge, which
// the container clips once it comes to rest — so the layering only
// shows while there is layering to show.
offset === 0 &&
'visible translate-x-0 opacity-100 shadow-[-12px_0_24px_-6px_rgba(0,0,0,0.45)]',
// Dimmed rather than faded out: at rest the panel in front covers it
// completely, so there is nothing to hide, and keeping it legible
// while it trails is the whole point of the shorter travel.
offset < 0 && 'invisible -translate-x-1/4 opacity-50',
offset > 0 && 'invisible translate-x-full opacity-100',
className,
)}
>
{children}
</div>
);
}
@@ -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<SidebarLevelValue>({
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<PendingLevel | null>(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 (
<SidebarLevelContext.Provider value={{ pending, goToLevel }}>
{children}
</SidebarLevelContext.Provider>
);
}
export const useSidebarLevel = () => useContext(SidebarLevelContext);
+15
View File
@@ -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);
});
});
+7
View File
@@ -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;
@@ -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],
);
}