mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 03:12:55 +00:00
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:
1 parent
cfbf61f4f3
commit
ac87430715
10 files changed
+372
-69
No files matched your search
+21
-18
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user