mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
* feat(web): mobile dashboard UX polish Bottom sheets for sidebar theme/language pickers on narrow viewports with enter/exit animation and drag-to-close; inline header badges beside titles; bottom padding on the route outlet for scroll clearance; profiles loading uses a unicode braille spinner; align profile/cron card actions to the top; viewport-fit cover and supporting layout tweaks across dashboard pages. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix Nix web npm hash and mobile sheet accessibility. Align fetchNpmDeps in nix/web.nix with web/package-lock.json for CI. Improve BottomPickSheet backdrop labeling, avoid aria-hidden on the dialog during exit animation, and wire theme/language sheets with listbox semantics and localized dismiss labels. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
139 lines
4.6 KiB
TypeScript
139 lines
4.6 KiB
TypeScript
import { useLayoutEffect, useMemo, useState, type ReactNode } from "react";
|
|
import { useLocation } from "react-router-dom";
|
|
import { PageHeaderContext } from "./page-header-context";
|
|
import { resolvePageTitle } from "@/lib/resolve-page-title";
|
|
import { cn } from "@/lib/utils";
|
|
import { useI18n } from "@/i18n";
|
|
|
|
export function PageHeaderProvider({
|
|
children,
|
|
pluginTabs,
|
|
}: {
|
|
children: ReactNode;
|
|
pluginTabs: { path: string; label: string }[];
|
|
}) {
|
|
const { pathname } = useLocation();
|
|
const { t } = useI18n();
|
|
const [titleOverride, setTitleOverride] = useState<string | null>(null);
|
|
const [afterTitle, setAfterTitle] = useState<ReactNode>(null);
|
|
const [end, setEnd] = useState<ReactNode>(null);
|
|
|
|
// Clear any per-page title / toolbar slots when the path changes. Child routes
|
|
// re-fill these on mount via usePageHeader.
|
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
useLayoutEffect(() => {
|
|
setTitleOverride(null);
|
|
setAfterTitle(null);
|
|
setEnd(null);
|
|
}, [pathname]);
|
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
|
|
const defaultTitle = useMemo(
|
|
() => resolvePageTitle(pathname, t, pluginTabs),
|
|
[pathname, t, pluginTabs],
|
|
);
|
|
const displayTitle = titleOverride ?? defaultTitle;
|
|
|
|
const isChatRoute = pathname === "/chat" || pathname === "/chat/";
|
|
/** Env jump-nav is wide — stack below title on small screens so KEYS stays readable. */
|
|
const isEnvRoute =
|
|
pathname === "/env" || pathname.startsWith("/env/");
|
|
|
|
const value = useMemo(
|
|
() => ({
|
|
setAfterTitle,
|
|
setEnd,
|
|
setTitle: setTitleOverride,
|
|
}),
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<PageHeaderContext.Provider value={value}>
|
|
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden">
|
|
<header
|
|
className={cn(
|
|
"z-1 w-full shrink-0",
|
|
"box-border border-b border-current/20",
|
|
"bg-background-base/40 backdrop-blur-sm",
|
|
// Mobile stacks title + toolbar — fixed h-14 clips content; desktop stays one row.
|
|
"min-h-0 overflow-x-hidden overflow-y-visible py-3 sm:h-14 sm:min-h-[3.5rem] sm:overflow-hidden sm:py-0",
|
|
)}
|
|
role="banner"
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex w-full min-w-0 flex-1 gap-3 px-3 sm:h-full sm:gap-3 sm:px-6",
|
|
isChatRoute
|
|
? "flex-row items-center"
|
|
: "flex-col justify-center sm:flex-row sm:items-center",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex min-w-0 flex-1 gap-2 sm:gap-3",
|
|
afterTitle && isEnvRoute
|
|
? "flex-col items-start sm:flex-row sm:items-center"
|
|
: afterTitle
|
|
? "flex-row flex-wrap items-center"
|
|
: "flex-row items-center",
|
|
)}
|
|
>
|
|
<h1
|
|
className={cn(
|
|
"font-expanded min-w-0 text-sm font-bold tracking-[0.08em] text-midground",
|
|
afterTitle && isEnvRoute
|
|
? "max-w-full sm:min-w-0 sm:shrink sm:truncate"
|
|
: afterTitle
|
|
? "shrink truncate"
|
|
: "truncate",
|
|
)}
|
|
style={{ mixBlendMode: "plus-lighter" }}
|
|
>
|
|
{displayTitle}
|
|
</h1>
|
|
{afterTitle ? (
|
|
<div
|
|
className={cn(
|
|
"min-w-0 scrollbar-none",
|
|
isEnvRoute
|
|
? "w-full overflow-x-auto sm:flex-1 sm:overflow-x-auto"
|
|
: "shrink-0 overflow-visible",
|
|
)}
|
|
>
|
|
{afterTitle}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{end ? (
|
|
<div
|
|
className={cn(
|
|
"flex min-w-0 sm:max-w-md sm:flex-1",
|
|
isChatRoute
|
|
? "w-auto shrink-0 justify-end"
|
|
: "w-full justify-start sm:justify-end",
|
|
)}
|
|
>
|
|
{end}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</header>
|
|
|
|
<main
|
|
className={cn(
|
|
"min-h-0 w-full min-w-0 flex-1 flex flex-col",
|
|
// Bottom inset for scrolled pages lives on the route outlet wrapper in
|
|
// `App.tsx` (`w-full min-w-0`) so it pads scrollable content, not flex chrome.
|
|
isChatRoute
|
|
? "overflow-hidden"
|
|
: "overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",
|
|
)}
|
|
>
|
|
{children}
|
|
</main>
|
|
</div>
|
|
</PageHeaderContext.Provider>
|
|
);
|
|
}
|