Files
DocsGPT/frontend/src/navigation/SidebarLevelProvider.tsx
T
Alex ac87430715 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.
2026-09-21 23:45:50 +01:00

83 lines
2.8 KiB
TypeScript

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);