SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
9b906ea verified
Raw History Blame Contribute Delete
10.5 kB
import React from "react";
import { useTranslation } from "react-i18next";
import { SidebarRailBody } from "./sidebar-rail-body";
import { getErrorStatus, useSettings } from "#/hooks/query/use-settings";
import { useConfig } from "#/hooks/query/use-config";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
import { useNavigation } from "#/context/navigation-context";
import { useActiveBackendContext } from "#/contexts/active-backend-context";
import { cn } from "#/utils/utils";
import { useSidebarMobileNav } from "./sidebar-mobile-nav-context";
import { useSidebarStore } from "#/stores/sidebar-store";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { useBackendsHealth } from "#/hooks/query/use-backends-health";
// The LLM settings modal is only mounted when the settings query 404s and
// LLM settings aren't hidden — keep it out of the sidebar's eager graph.
const SettingsModal = React.lazy(() =>
import("#/components/shared/modals/settings/settings-modal").then((m) => ({
default: m.SettingsModal,
})),
);
// Add/Manage backend modals are lifted into the sidebar (instead of living
// inside BackendSelector) so they survive the collapsed popover unmounting
// when the user moves the cursor out of the popover toward the modal.
const AddBackendModal = React.lazy(() =>
import("#/components/features/backends/add-backend-modal").then((m) => ({
default: m.AddBackendModal,
})),
);
const ManageBackendsModal = React.lazy(() =>
import("#/components/features/backends/manage-backends-modal").then((m) => ({
default: m.ManageBackendsModal,
})),
);
const MOBILE_DRAWER_TRANSITION_MS = 250;
export function Sidebar() {
const { t } = useTranslation("openhands");
const { currentPath } = useNavigation();
const { data: config } = useConfig();
const {
data: settings,
error: settingsError,
isError: settingsIsError,
isFetching: isFetchingSettings,
} = useSettings();
const { backends, active } = useActiveBackendContext();
const healthByBackendId = useBackendsHealth(backends);
const activeBackendHealth = healthByBackendId[active.backend.id];
const collapsed = useSidebarStore((state) => state.collapsed);
const setCollapsed = useSidebarStore((state) => state.setCollapsed);
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
const [collapsedBackendPopoverOpen, setCollapsedBackendPopoverOpen] =
React.useState(false);
const collapsedBackendCloseTimer = React.useRef<ReturnType<
typeof setTimeout
> | null>(null);
// Lifted out of BackendSelector so opening these modals from the
// collapsed-sidebar popover doesn't lose state when the popover unmounts
// (cursor moving toward the modal triggers onMouseLeave -> close).
const [addBackendModalOpen, setAddBackendModalOpen] = React.useState(false);
const [manageBackendsModalOpen, setManageBackendsModalOpen] =
React.useState(false);
const [collapsedRailHovered, setCollapsedRailHovered] = React.useState(false);
const suppressCollapsedExpandRef = React.useRef(false);
const [, refreshCollapsedExpandGate] = React.useReducer((n) => n + 1, 0);
const { isOpen: isMobileNavOpen, close: closeMobileNav } =
useSidebarMobileNav();
const [mobileDrawerMounted, setMobileDrawerMounted] = React.useState(false);
const [mobileDrawerVisible, setMobileDrawerVisible] = React.useState(false);
const collapsedBackendPopoverRef = useClickOutsideElement<HTMLDivElement>(
() => setCollapsedBackendPopoverOpen(false),
);
const settingsErrorStatus = getErrorStatus(settingsError);
React.useEffect(() => {
closeMobileNav();
}, [currentPath, closeMobileNav]);
React.useEffect(() => {
if (isMobileNavOpen) {
setMobileDrawerMounted(true);
const frame = requestAnimationFrame(() => {
setMobileDrawerVisible(true);
});
return () => cancelAnimationFrame(frame);
}
setMobileDrawerVisible(false);
const timer = window.setTimeout(() => {
setMobileDrawerMounted(false);
}, MOBILE_DRAWER_TRANSITION_MS);
return () => window.clearTimeout(timer);
}, [isMobileNavOpen]);
React.useEffect(() => {
if (!isMobileNavOpen) {
return undefined;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeMobileNav();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [isMobileNavOpen, closeMobileNav]);
React.useEffect(() => {
if (currentPath === "/settings") {
setSettingsModalIsOpen(false);
} else if (
!isFetchingSettings &&
settingsIsError &&
settingsErrorStatus !== 404
) {
// We don't show toast errors for settings in the global error handler
// because we have a special case for 404 errors
displayErrorToast(t(I18nKey.SETTINGS$FETCH_ERROR));
} else if (
settingsErrorStatus === 404 &&
!config?.feature_flags?.hide_llm_settings
) {
setSettingsModalIsOpen(true);
}
}, [
currentPath,
isFetchingSettings,
settingsIsError,
settingsErrorStatus,
config?.feature_flags?.hide_llm_settings,
t,
]);
const collapseToggleLabel = t(
collapsed ? I18nKey.SIDEBAR$EXPAND : I18nKey.SIDEBAR$COLLAPSE,
);
const handleCollapsedRailClick = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (!collapsed) {
return;
}
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
// Keep existing behavior for explicit controls/links and only use
// this as a convenience hit-area for empty collapsed-rail space.
if (
target.closest(
"a,button,input,textarea,select,[role='button'],[role='link']",
)
) {
return;
}
setCollapsed(false);
},
[collapsed, setCollapsed],
);
const handleCollapse = React.useCallback(() => {
setCollapsedRailHovered(false);
suppressCollapsedExpandRef.current = true;
refreshCollapsedExpandGate();
setCollapsed(true);
window.setTimeout(() => {
suppressCollapsedExpandRef.current = false;
refreshCollapsedExpandGate();
}, 250);
}, [setCollapsed]);
const showCollapsedExpandButton =
collapsed && collapsedRailHovered && !suppressCollapsedExpandRef.current;
const isExtensionsActive =
currentPath === "/customize" ||
currentPath.startsWith("/skills") ||
currentPath === "/plugins" ||
currentPath === "/apps" ||
currentPath === "/mcp";
const railBodyProps = {
collapseToggleLabel,
onCollapse: handleCollapse,
onExpand: () => setCollapsed(false),
showCollapsedExpandButton,
isExtensionsActive,
currentPath,
activeBackend: active.backend,
activeOrgId: active.orgId,
activeBackendHealth,
collapsedBackendPopoverOpen,
setCollapsedBackendPopoverOpen,
collapsedBackendPopoverRef,
collapsedBackendCloseTimer,
onOpenAddBackend: () => setAddBackendModalOpen(true),
onOpenManageBackends: () => setManageBackendsModalOpen(true),
};
return (
<>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the aside acts as a hit-area for the collapsed rail; nested controls handle their own keyboard interactions. */}
<aside
aria-label={t(I18nKey.SIDEBAR$NAVIGATION_LABEL)}
data-collapsed={collapsed ? "true" : "false"}
onClick={handleCollapsedRailClick}
onMouseEnter={() => {
if (collapsed) {
setCollapsedRailHovered(true);
}
}}
onMouseLeave={() => {
setCollapsedRailHovered(false);
}}
className={cn(
"max-md:hidden flex bg-base flex-col min-h-0 transition-[width,min-width] duration-200",
"md:border-r md:border-[var(--oh-border)] md:h-full",
collapsed
? "md:w-[60px] md:min-w-[60px] md:px-2.5"
: "md:w-[300px] md:min-w-[300px] pb-2 md:pl-2.5 md:pr-0",
currentPath === "/" && "md:pb-3",
)}
>
<SidebarRailBody
collapsed={collapsed}
showCollapseToggle
{...railBodyProps}
/>
</aside>
{mobileDrawerMounted ? (
<>
<div
className={cn(
"fixed inset-0 z-40 bg-black/50 md:hidden",
"transition-opacity ease-in-out motion-reduce:transition-none",
mobileDrawerVisible
? "opacity-100"
: "pointer-events-none opacity-0",
)}
style={{ transitionDuration: `${MOBILE_DRAWER_TRANSITION_MS}ms` }}
onClick={closeMobileNav}
aria-hidden={!mobileDrawerVisible}
/>
<aside
aria-label={t(I18nKey.SIDEBAR$NAVIGATION_LABEL)}
data-testid="sidebar-mobile-drawer"
aria-hidden={!mobileDrawerVisible}
className={cn(
"fixed inset-y-0 left-0 z-50 flex min-h-0 w-[min(300px,85vw)] flex-col bg-base",
"border-r border-[var(--oh-border)] pb-2 pl-2.5 pr-0 md:hidden",
"transition-transform ease-in-out motion-reduce:transition-none",
mobileDrawerVisible ? "translate-x-0" : "-translate-x-full",
)}
style={{ transitionDuration: `${MOBILE_DRAWER_TRANSITION_MS}ms` }}
>
<SidebarRailBody
collapsed={false}
showCollapseToggle={false}
showMobileCloseButton
onCloseMobile={closeMobileNav}
{...railBodyProps}
/>
</aside>
</>
) : null}
{settingsModalIsOpen && (
<React.Suspense fallback={null}>
<SettingsModal
settings={settings}
onClose={() => setSettingsModalIsOpen(false)}
/>
</React.Suspense>
)}
{addBackendModalOpen && (
<React.Suspense fallback={null}>
<AddBackendModal onClose={() => setAddBackendModalOpen(false)} />
</React.Suspense>
)}
{manageBackendsModalOpen && (
<React.Suspense fallback={null}>
<ManageBackendsModal
onClose={() => setManageBackendsModalOpen(false)}
/>
</React.Suspense>
)}
</>
);
}