mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 03:12:55 +00:00
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.
83 lines
2.8 KiB
TypeScript
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);
|