mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-03 16:57:06 +08:00
Compare commits
5 Commits
bb/desktop
...
austin/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17b5acbdc9 | ||
|
|
2fbd2c48ae | ||
|
|
30f7816cd1 | ||
|
|
e996673bec | ||
|
|
36ddca4b50 |
145
web/src/App.tsx
145
web/src/App.tsx
@@ -58,8 +58,8 @@ import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Backdrop } from "@/components/Backdrop";
|
||||
import { SidebarFooter } from "@/components/SidebarFooter";
|
||||
import { SidebarStatusStrip, gatewayLine } from "@/components/SidebarStatusStrip";
|
||||
import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint";
|
||||
@@ -100,7 +100,7 @@ import type { PluginManifest } from "@/plugins";
|
||||
import { useTheme } from "@/themes";
|
||||
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
|
||||
import { api } from "@/lib/api";
|
||||
import type { StatusResponse } from "@/lib/api";
|
||||
import type { StatusResponse, UpdateCheckResponse } from "@/lib/api";
|
||||
|
||||
function RootRedirect() {
|
||||
return <Navigate to="/sessions" replace />;
|
||||
@@ -483,18 +483,23 @@ export default function App() {
|
||||
<ProfileProvider>
|
||||
<div
|
||||
data-layout-variant={layoutVariant}
|
||||
className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black text-text-primary antialiased"
|
||||
className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-background-base text-text-primary antialiased"
|
||||
>
|
||||
<SelectionSwitcher />
|
||||
<Backdrop />
|
||||
<PluginSlot name="backdrop" />
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-0"
|
||||
>
|
||||
<PluginSlot name="backdrop" />
|
||||
</div>
|
||||
|
||||
<header
|
||||
className={cn(
|
||||
"lg:hidden fixed top-0 left-0 right-0 z-40 min-h-14",
|
||||
"flex items-center gap-2 px-4 py-2",
|
||||
"border-b border-current/20",
|
||||
"bg-background-base/90 backdrop-blur-sm",
|
||||
"bg-background-base",
|
||||
)}
|
||||
style={{
|
||||
background: "var(--component-header-background)",
|
||||
@@ -514,10 +519,7 @@ export default function App() {
|
||||
<Menu />
|
||||
</Button>
|
||||
|
||||
<Typography
|
||||
className="font-bold text-[0.95rem] leading-[0.95] tracking-[0.05em] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
<Typography className="font-bold text-[0.95rem] leading-[0.95] tracking-[0.05em] text-midground">
|
||||
{t.app.brand}
|
||||
</Typography>
|
||||
</header>
|
||||
@@ -529,7 +531,7 @@ export default function App() {
|
||||
onClick={closeMobile}
|
||||
className={cn(
|
||||
"lg:hidden fixed inset-0 z-40 p-0 block",
|
||||
"bg-black/60 backdrop-blur-sm",
|
||||
"bg-black/70",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -543,13 +545,13 @@ export default function App() {
|
||||
id="app-sidebar"
|
||||
aria-label={t.app.navigation}
|
||||
className={cn(
|
||||
"fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col",
|
||||
"fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col font-sans",
|
||||
"border-r border-current/20",
|
||||
"bg-background-base/95 backdrop-blur-sm",
|
||||
"transition-[transform] duration-200 ease-out",
|
||||
"bg-background-base",
|
||||
"transition-[transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
||||
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0 lg:overflow-hidden",
|
||||
"lg:transition-[width] lg:duration-[600ms] lg:ease-[cubic-bezier(0.33,1.35,0.62,1)]",
|
||||
"lg:transition-[width] lg:duration-300 lg:ease-[cubic-bezier(0.23,1,0.32,1)]",
|
||||
collapsed && "lg:w-14",
|
||||
)}
|
||||
style={{
|
||||
@@ -573,10 +575,7 @@ export default function App() {
|
||||
>
|
||||
<PluginSlot name="header-left" />
|
||||
|
||||
<Typography
|
||||
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground uppercase"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
<Typography className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground uppercase">
|
||||
Hermes
|
||||
<br />
|
||||
Agent
|
||||
@@ -638,7 +637,7 @@ export default function App() {
|
||||
<span
|
||||
className={cn(
|
||||
"px-5 pt-2.5 pb-1",
|
||||
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
|
||||
"font-sans text-display text-xs tracking-[0.12em] text-text-tertiary",
|
||||
isDesktopCollapsed && "lg:hidden",
|
||||
)}
|
||||
id="hermes-sidebar-plugin-nav-heading"
|
||||
@@ -845,7 +844,7 @@ function SidebarNavLink({
|
||||
cn(
|
||||
"group/nav relative flex items-center gap-3",
|
||||
"px-5 py-2.5",
|
||||
"font-mondwest text-display uppercase text-sm tracking-[0.12em]",
|
||||
"font-sans text-display uppercase text-sm tracking-[0.12em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
isActive
|
||||
@@ -879,7 +878,6 @@ function SidebarNavLink({
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -904,6 +902,47 @@ function SidebarSystemActions({
|
||||
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
|
||||
useSystemActions();
|
||||
const canUpdateHermes = status?.can_update_hermes === true;
|
||||
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
|
||||
const [updateConfirmOpen, setUpdateConfirmOpen] = useState(false);
|
||||
const [updateConfirmInfo, setUpdateConfirmInfo] =
|
||||
useState<UpdateCheckResponse | null>(null);
|
||||
const [updateConfirmChecking, setUpdateConfirmChecking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!updateConfirmOpen) {
|
||||
setUpdateConfirmInfo(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setUpdateConfirmChecking(true);
|
||||
api
|
||||
.checkHermesUpdate(false)
|
||||
.then((info) => {
|
||||
if (!cancelled) setUpdateConfirmInfo(info);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setUpdateConfirmInfo(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setUpdateConfirmChecking(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [updateConfirmOpen]);
|
||||
|
||||
const updateConfirmDescription = useMemo(() => {
|
||||
if (updateConfirmInfo?.behind && updateConfirmInfo.behind > 0) {
|
||||
const cmd = updateConfirmInfo.update_command;
|
||||
const n = updateConfirmInfo.behind;
|
||||
return `This will run 'hermes update' (${cmd}) and pull ${n} new commit${n === 1 ? "" : "s"}. The gateway restarts when the update finishes; the current session keeps its prompt cache until then.`;
|
||||
}
|
||||
const cmd = updateConfirmInfo?.update_command ?? "hermes update";
|
||||
return (
|
||||
t.status.updateHermesConfirmMessage ??
|
||||
`This will run 'hermes update' (${cmd}) and restart the gateway when it finishes.`
|
||||
);
|
||||
}, [t.status.updateHermesConfirmMessage, updateConfirmInfo]);
|
||||
|
||||
const items: SystemActionItem[] = [
|
||||
{
|
||||
@@ -926,12 +965,35 @@ function SidebarSystemActions({
|
||||
|
||||
const handleClick = (action: SystemAction) => {
|
||||
if (isBusy) return;
|
||||
if (action === "restart") {
|
||||
setRestartConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
if (action === "update") {
|
||||
setUpdateConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
void runAction(action);
|
||||
navigate("/sessions");
|
||||
onNavigate();
|
||||
};
|
||||
|
||||
const confirmRestart = () => {
|
||||
setRestartConfirmOpen(false);
|
||||
void runAction("restart");
|
||||
navigate("/sessions");
|
||||
onNavigate();
|
||||
};
|
||||
|
||||
const confirmUpdate = () => {
|
||||
setUpdateConfirmOpen(false);
|
||||
void runAction("update");
|
||||
navigate("/sessions");
|
||||
onNavigate();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col",
|
||||
@@ -942,7 +1004,7 @@ function SidebarSystemActions({
|
||||
<span
|
||||
className={cn(
|
||||
"px-5 pt-0.5 pb-0.5",
|
||||
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
|
||||
"font-sans text-display text-xs tracking-[0.12em] text-text-tertiary",
|
||||
collapsed && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
@@ -970,6 +1032,36 @@ function SidebarSystemActions({
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
cancelLabel={t.common.cancel}
|
||||
confirmLabel={t.status.restartGateway}
|
||||
description={
|
||||
t.status.restartGatewayConfirmMessage ??
|
||||
"This restarts the Hermes gateway process. Connected channels and active sessions will reconnect afterward."
|
||||
}
|
||||
loading={pendingAction === "restart"}
|
||||
onCancel={() => setRestartConfirmOpen(false)}
|
||||
onConfirm={confirmRestart}
|
||||
open={restartConfirmOpen}
|
||||
title={
|
||||
t.status.restartGatewayConfirmTitle ?? `${t.status.restartGateway}?`
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
cancelLabel={t.common.cancel}
|
||||
confirmLabel={t.status.updateHermesConfirmNow ?? "Update now"}
|
||||
description={
|
||||
updateConfirmChecking ? t.common.loading : updateConfirmDescription
|
||||
}
|
||||
loading={pendingAction === "update" || updateConfirmChecking}
|
||||
onCancel={() => setUpdateConfirmOpen(false)}
|
||||
onConfirm={confirmUpdate}
|
||||
open={updateConfirmOpen}
|
||||
title={t.status.updateHermesConfirmTitle ?? `${t.status.updateHermes}?`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1012,7 +1104,7 @@ function SystemActionButton({
|
||||
className={cn(
|
||||
"group/action relative flex w-full items-center gap-3",
|
||||
"px-5 py-2.5",
|
||||
"font-mondwest text-display text-xs tracking-[0.1em]",
|
||||
"font-sans text-display text-xs tracking-[0.1em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
busy
|
||||
@@ -1050,7 +1142,6 @@ function SystemActionButton({
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
@@ -1186,8 +1277,8 @@ function SidebarTooltip({ anchor, label, warmRef }: SidebarTooltipProps) {
|
||||
className={cn(
|
||||
"fixed z-[100] pointer-events-none",
|
||||
"px-2 py-1",
|
||||
"bg-background-base/95 border border-current/20 backdrop-blur-sm shadow-lg",
|
||||
"font-mondwest text-display text-xs tracking-[0.1em] text-midground uppercase",
|
||||
"bg-background-base border border-current/20 shadow-lg",
|
||||
"font-sans text-display text-xs tracking-[0.1em] text-midground uppercase",
|
||||
)}
|
||||
style={{
|
||||
top: rect.top + rect.height / 2,
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useGpuTier } from "@nous-research/ui/hooks/use-gpu-tier";
|
||||
|
||||
import fillerBgUrl from "@nous-research/ui/assets/filler-bg0.webp";
|
||||
|
||||
/**
|
||||
* Replicates the visual layer stack of `<Overlays dark />` from
|
||||
* `@nous-research/ui` without pulling in its leva / gsap / three peer deps.
|
||||
*
|
||||
* See `design-language/src/ui/components/overlays/index.tsx` for the source of
|
||||
* truth. Defaults match LENS_0 (the Hermes teal dark preset); the deep canvas
|
||||
* and the warm vignette both read theme-switchable CSS custom properties so
|
||||
* `ThemeProvider` can repaint the stack without remounting.
|
||||
*
|
||||
* z-1 bg = `var(--background-base)`, mix-blend-mode driven by
|
||||
* `--component-backdrop-bg-blend-mode` (default `difference`).
|
||||
* Both LENS_0-style dark themes and the LENS_5I-style Nous Blue
|
||||
* light theme keep `difference` here — the canvas is flipped by
|
||||
* the z-200 FG inversion layer, not by changing this blend mode.
|
||||
* The CSS var is exposed as a hook so future presets can override
|
||||
* it (e.g. `multiply` to paint the bg as-is before inversion)
|
||||
* without touching this component.
|
||||
* z-2 bundled filler-bg WebP, inverted, opacity 0.033, difference
|
||||
* z-99 warm top-left vignette (`var(--warm-glow)`), opacity 0.22, lighten
|
||||
* z-200 FG inversion = `var(--foreground)` (opaque white in LENS_5I,
|
||||
* alpha-0 in LENS_0), mix-blend-mode: difference. This is the
|
||||
* layer that flips the dashboard into "light mode" for inverted
|
||||
* themes; for normal dark themes its alpha is 0 so it's a no-op.
|
||||
* Deliberately placed above every UI overlay z-index (modals,
|
||||
* tooltips, and dropUp dropdowns all sit at z-[100]) so portaled
|
||||
* elements get inverted along with the rest of the page instead
|
||||
* of painting with pre-inversion colors on top of the lens.
|
||||
* z-201 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
|
||||
* color-dodge) — gated on GPU tier. Sits above the inversion
|
||||
* layer by design so the grain is not flipped.
|
||||
*
|
||||
* `useGpuTier` returns 0 when WebGL is unavailable, the renderer is a
|
||||
* software rasterizer (SwiftShader/llvmpipe), or the user has
|
||||
* `prefers-reduced-motion: reduce` set. We skip the animated noise layer
|
||||
* in that case so low-power / accessibility-conscious sessions stay crisp,
|
||||
* mirroring the DS `<Noise />` component's own opt-out.
|
||||
*/
|
||||
export function Backdrop() {
|
||||
const gpuTier = useGpuTier();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-[1]"
|
||||
style={
|
||||
{
|
||||
backgroundColor: "var(--background-base)",
|
||||
mixBlendMode:
|
||||
"var(--component-backdrop-bg-blend-mode, difference)",
|
||||
} as unknown as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-[2]"
|
||||
style={
|
||||
{
|
||||
// Themes can override the filler background by setting
|
||||
// `assets.bg` — the <img> hides itself when a CSS bg is set
|
||||
// so the two don't double-darken. CSS var fallbacks keep the
|
||||
// default behaviour unchanged when no theme customises these.
|
||||
mixBlendMode:
|
||||
"var(--component-backdrop-filler-blend-mode, difference)",
|
||||
opacity: "var(--component-backdrop-filler-opacity, 0.033)",
|
||||
backgroundImage: "var(--theme-asset-bg)",
|
||||
backgroundSize: "var(--component-backdrop-background-size, cover)",
|
||||
backgroundPosition:
|
||||
"var(--component-backdrop-background-position, center)",
|
||||
} as unknown as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
className="h-[150dvh] w-auto min-w-[100dvw] object-cover object-top-left invert theme-default-filler"
|
||||
fetchPriority="low"
|
||||
src={fillerBgUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-[99]"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse at 0% 0%, transparent 60%, var(--warm-glow) 100%)",
|
||||
mixBlendMode: "lighten",
|
||||
opacity: 0.22,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Foreground inversion layer. Source-of-truth: LENS_5I.Lens.fgOpacity
|
||||
+ fgBlend: 'difference' in `design-language/src/ui/components/
|
||||
overlays/lens.ts`. With `--foreground-alpha: 0` (LENS_0 dark default)
|
||||
the layer is fully transparent and contributes nothing; with
|
||||
alpha 1 + opaque white it inverts the entire stack below it,
|
||||
producing the LENS_5I "light mode" look without altering any
|
||||
downstream component code.
|
||||
|
||||
z-200 (not 100) so it sits above every portaled UI overlay —
|
||||
sidebar tooltips, dropUp dropdowns, and modal dialogs all use
|
||||
z-[100], which is what the DS Lens picks too; portals append
|
||||
at the end of <body>, so equal z-index + later DOM order means
|
||||
they'd paint on top of the inversion and skip the flip. Inlined
|
||||
z-index for the same reason the DS does it — Tailwind's JIT
|
||||
scan sometimes drops non-default z utilities. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0"
|
||||
style={{
|
||||
backgroundColor: "var(--foreground)",
|
||||
mixBlendMode: "difference",
|
||||
zIndex: 200,
|
||||
}}
|
||||
/>
|
||||
|
||||
{gpuTier > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-[201]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%23eaeaea' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E\")",
|
||||
backgroundSize: "512px 512px",
|
||||
mixBlendMode: "color-dodge",
|
||||
opacity: "calc(0.55 * var(--noise-opacity-mul, 1))",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export function ConfirmDialog({
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
}}
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-background/85 p-4"
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
|
||||
@@ -78,7 +78,6 @@ export function LanguageSwitcher({ collapsed = false, dropUp = false }: Language
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline text-display tracking-wide text-xs"
|
||||
>
|
||||
{locale === "en" ? "EN" : current.name}
|
||||
@@ -151,7 +150,7 @@ function LanguageSwitcherOptions({
|
||||
aria-selected={selected}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-1.5 flex items-center gap-2 cursor-pointer",
|
||||
"font-mondwest text-display text-xs tracking-[0.08em]",
|
||||
"font-sans text-display text-xs tracking-[0.08em]",
|
||||
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
||||
selected ? "font-semibold text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
|
||||
@@ -288,7 +288,7 @@ export function ModelPickerDialog(props: Props) {
|
||||
// Toast.tsx for the same pattern.
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -164,7 +164,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={handleBackdrop}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { Users } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectOption,
|
||||
} from "@nous-research/ui/ui/components/select";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -10,14 +15,24 @@ import { cn } from "@/lib/utils";
|
||||
* Keys, Skills, MCP, Models) reads/writes the selected profile via the
|
||||
* fetchJSON ?profile= injection. Hidden when only one profile exists.
|
||||
*/
|
||||
export function ProfileSwitcher({ collapsed }: { collapsed?: boolean }) {
|
||||
export function ProfileSwitcher({ collapsed }: ProfileSwitcherProps) {
|
||||
const { profile, currentProfile, profiles, setProfile } = useProfileScope();
|
||||
const { t } = useI18n();
|
||||
|
||||
const currentDashboardLabel = useMemo(
|
||||
() =>
|
||||
(t.app.currentProfileOption ?? "this dashboard ({name})").replace(
|
||||
"{name}",
|
||||
currentProfile || "default",
|
||||
),
|
||||
[currentProfile, t.app.currentProfileOption],
|
||||
);
|
||||
|
||||
if (profiles.length < 2) return null;
|
||||
|
||||
const managed = profile || currentProfile || "default";
|
||||
const isOther = !!profile && profile !== currentProfile;
|
||||
const managingLabel = t.app.managingProfile ?? "Managing profile";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -25,7 +40,7 @@ export function ProfileSwitcher({ collapsed }: { collapsed?: boolean }) {
|
||||
"flex items-center gap-2 border-b border-current/10 px-3 py-2",
|
||||
collapsed && "lg:justify-center lg:px-0",
|
||||
)}
|
||||
title={t.app.managingProfile ?? "Managing profile"}
|
||||
title={managingLabel}
|
||||
>
|
||||
<Users
|
||||
className={cn(
|
||||
@@ -33,35 +48,38 @@ export function ProfileSwitcher({ collapsed }: { collapsed?: boolean }) {
|
||||
isOther ? "text-amber-300" : "text-text-tertiary",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
aria-label={t.app.managingProfile ?? "Managing profile"}
|
||||
|
||||
<Select
|
||||
className={cn(
|
||||
"h-7 w-full min-w-0 rounded-none border bg-background px-1 text-xs",
|
||||
isOther
|
||||
? "border-amber-500/50 text-amber-300"
|
||||
: "border-border text-text-secondary",
|
||||
"min-w-0 flex-1",
|
||||
collapsed && "lg:hidden",
|
||||
"[&_button]:h-7 [&_button]:border-border [&_button]:bg-background [&_button]:px-2 [&_button]:text-xs",
|
||||
"[&_button]:font-sans [&_button]:normal-case [&_button]:tracking-normal",
|
||||
"[&_[role=listbox]>div]:font-sans [&_[role=listbox]>div]:text-xs",
|
||||
"[&_[role=listbox]>div]:normal-case [&_[role=listbox]>div]:tracking-normal",
|
||||
isOther &&
|
||||
"[&_button]:border-amber-500/50 [&_button]:text-amber-300",
|
||||
)}
|
||||
id="hermes-profile-switcher"
|
||||
onValueChange={setProfile}
|
||||
value={profile}
|
||||
onChange={(e) => setProfile(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{(t.app.currentProfileOption ?? "this dashboard ({name})").replace(
|
||||
"{name}",
|
||||
currentProfile || "default",
|
||||
)}
|
||||
</option>
|
||||
<SelectOption value="">{currentDashboardLabel}</SelectOption>
|
||||
|
||||
{profiles
|
||||
.filter((name) => name !== currentProfile)
|
||||
.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
<SelectOption key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
</SelectOption>
|
||||
))}
|
||||
</select>
|
||||
{collapsed && (
|
||||
<span className="sr-only">{managed}</span>
|
||||
)}
|
||||
</Select>
|
||||
|
||||
{collapsed && <span className="sr-only">{managed}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProfileSwitcherProps {
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
@@ -25,11 +25,10 @@ export function SidebarFooter({ status }: SidebarFooterProps) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"font-mondwest text-display text-xs tracking-[0.12em] text-midground",
|
||||
"font-sans text-display text-xs tracking-[0.12em] text-midground",
|
||||
"transition-opacity hover:opacity-90",
|
||||
"focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
|
||||
)}
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.footer.org}
|
||||
</a>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SidebarStatusStrip({ status }: SidebarStatusStripProps) {
|
||||
"focus-visible:ring-inset",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1 font-mondwest text-xs leading-snug tracking-[0.08em]">
|
||||
<div className="flex flex-col gap-1 font-sans text-xs leading-snug tracking-[0.08em]">
|
||||
<p className="break-words">
|
||||
<span className="text-text-tertiary">{gatewayStatusLabel}</span>{" "}
|
||||
<span className={cn("font-medium", gw.tone)}>{gw.label}</span>
|
||||
|
||||
@@ -81,7 +81,6 @@ export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitch
|
||||
|
||||
{!collapsed && (
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline text-display tracking-wide text-xs"
|
||||
>
|
||||
{label}
|
||||
@@ -121,7 +120,7 @@ export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitch
|
||||
aria-label={sheetTitle}
|
||||
className={cn(
|
||||
"min-w-[240px] max-h-[70dvh] overflow-y-auto",
|
||||
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
|
||||
"border border-current/20 bg-background-base/95",
|
||||
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
|
||||
dropUp ? "fixed z-[100]" : "absolute z-50 right-0 top-full mt-1",
|
||||
)}
|
||||
@@ -134,7 +133,6 @@ export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitch
|
||||
>
|
||||
<div className="border-b border-current/20 px-3 py-2">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
|
||||
>
|
||||
{sheetTitle}
|
||||
@@ -192,7 +190,6 @@ function ThemeSwitcherOptions({
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<Typography
|
||||
mondwest
|
||||
className="truncate text-display text-xs tracking-wide"
|
||||
>
|
||||
{th.label}
|
||||
@@ -235,7 +232,6 @@ function FontSection({ fontChoices, fontId, setFont }: FontSectionProps) {
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Type className="h-3 w-3 text-text-tertiary" />
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
|
||||
>
|
||||
{t.theme?.fontTitle ?? "Font"}
|
||||
@@ -317,12 +313,6 @@ function FontSection({ fontChoices, fontId, setFont }: FontSectionProps) {
|
||||
}
|
||||
|
||||
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
|
||||
// Inverted themes (Nous Blue / future lens themes) author their palette
|
||||
// pre-inversion — `#FFAC02` reads as `#0053FD` blue once the foreground-
|
||||
// difference layer flips the page. The picker can't replay that math
|
||||
// cheaply, so themes opt-in to an explicit `swatchColors` triplet that
|
||||
// mirrors the on-screen result. Falls back to the raw palette hexes for
|
||||
// every other theme so existing dark-theme swatches are untouched.
|
||||
const [c1, c2, c3] = theme.swatchColors ?? [
|
||||
theme.palette.background.hex,
|
||||
theme.palette.midground.hex,
|
||||
|
||||
@@ -214,7 +214,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function PageHeaderProvider({
|
||||
className={cn(
|
||||
"z-1 w-full shrink-0",
|
||||
"box-border border-b border-current/20",
|
||||
"bg-background-base/40 backdrop-blur-sm",
|
||||
"bg-background-base",
|
||||
// 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",
|
||||
)}
|
||||
@@ -88,7 +88,6 @@ export function PageHeaderProvider({
|
||||
? "shrink truncate"
|
||||
: "truncate",
|
||||
)}
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{displayTitle}
|
||||
</h1>
|
||||
|
||||
@@ -121,6 +121,9 @@ export const en: Translations = {
|
||||
platformError: "error",
|
||||
recentSessions: "Recent Sessions",
|
||||
restartGateway: "Restart Gateway",
|
||||
restartGatewayConfirmMessage:
|
||||
"This restarts the Hermes gateway process. Connected channels and active sessions will reconnect afterward.",
|
||||
restartGatewayConfirmTitle: "Restart gateway?",
|
||||
restartingGateway: "Restarting gateway…",
|
||||
running: "Running",
|
||||
runningRemote: "Running (remote)",
|
||||
@@ -129,6 +132,10 @@ export const en: Translations = {
|
||||
startedInBackground: "Started in background — check logs for progress",
|
||||
stopped: "Stopped",
|
||||
updateHermes: "Update Hermes",
|
||||
updateHermesConfirmMessage:
|
||||
"This runs hermes update and restarts the gateway when it finishes. Active sessions keep their prompt cache until then.",
|
||||
updateHermesConfirmNow: "Update now",
|
||||
updateHermesConfirmTitle: "Update Hermes?",
|
||||
updatingHermes: "Updating Hermes…",
|
||||
waitingForOutput: "Waiting for output…",
|
||||
},
|
||||
|
||||
@@ -139,6 +139,8 @@ export interface Translations {
|
||||
activeSessions: string;
|
||||
recentSessions: string;
|
||||
restartGateway: string;
|
||||
restartGatewayConfirmMessage?: string;
|
||||
restartGatewayConfirmTitle?: string;
|
||||
restartingGateway: string;
|
||||
running: string;
|
||||
runningRemote: string;
|
||||
@@ -147,6 +149,9 @@ export interface Translations {
|
||||
startedInBackground: string;
|
||||
stopped: string;
|
||||
updateHermes: string;
|
||||
updateHermesConfirmMessage?: string;
|
||||
updateHermesConfirmNow?: string;
|
||||
updateHermesConfirmTitle?: string;
|
||||
updatingHermes: string;
|
||||
waitingForOutput: string;
|
||||
};
|
||||
|
||||
@@ -43,10 +43,8 @@
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Hermes Agent — Nous DS with the LENS_0 (Hermes teal) lens applied */
|
||||
/* statically. Mirrors nousnet-web/(hermes-agent)/layout.tsx so the */
|
||||
/* canonical Hermes palette is the default — teal canvas + cream */
|
||||
/* accent — without relying on leva/gsap at runtime. */
|
||||
/* Hermes Agent — Nous DS with the LENS_0 (Hermes teal) palette applied
|
||||
statically as the default dashboard theme. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
:root {
|
||||
@@ -63,10 +61,6 @@
|
||||
--background-base: #041c1c;
|
||||
--background-alpha: 1;
|
||||
|
||||
/* Consumed by <Backdrop />; also theme-switchable. */
|
||||
--warm-glow: rgba(255, 189, 56, 0.35);
|
||||
--noise-opacity-mul: 1;
|
||||
|
||||
/* Typography tokens — rewritten by ThemeProvider. Defaults match the
|
||||
system stack so themes that don't override look native. */
|
||||
--theme-font-sans: system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
@@ -228,11 +222,6 @@ code { font-size: 0.875rem; }
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Plus-lighter blend used by logos/titles for a subtle glow. */
|
||||
.blend-lighter {
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
/* System UI-monospace stack — distinct from `font-courier` (Courier
|
||||
Prime), used for dense data readouts where the display font would
|
||||
break the grid. Routes through the theme's mono stack so themes
|
||||
@@ -256,14 +245,3 @@ code { font-size: 0.875rem; }
|
||||
2px 2px;
|
||||
}
|
||||
|
||||
/* When a theme provides `assets.bg`, the backdrop's <div> renders it as
|
||||
a CSS background; the default filler <img> is hidden to prevent
|
||||
double-compositing. Unset → initial → empty, so the :not() selector
|
||||
matches and the default image stays visible. */
|
||||
:root:not([style*="--theme-asset-bg:"]) .theme-default-filler {
|
||||
display: block;
|
||||
}
|
||||
:root[style*="--theme-asset-bg:"] .theme-default-filler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -326,7 +326,7 @@ export default function ChannelsPage() {
|
||||
{editing && (
|
||||
<div
|
||||
ref={editModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && setEditing(null)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -80,14 +80,19 @@ function generateChannelId(scope?: string): string {
|
||||
// with cream foreground — we intentionally don't pick monokai or a loud
|
||||
// theme, because the TUI's skin engine already paints the content; the
|
||||
// terminal chrome just needs to sit quietly inside the dashboard.
|
||||
// `background` is omitted here — it's supplied dynamically from the active
|
||||
// theme's `terminalBackground` field so users can control it via YAML themes.
|
||||
const TERMINAL_THEME_STATIC = {
|
||||
foreground: "#f0e6d2",
|
||||
cursor: "#f0e6d2",
|
||||
cursorAccent: "#0d2626",
|
||||
selectionBackground: "#f0e6d244",
|
||||
};
|
||||
const DEFAULT_TERMINAL_BACKGROUND = "#000000";
|
||||
const DEFAULT_TERMINAL_FOREGROUND = "#f0e6d2";
|
||||
|
||||
function buildTerminalTheme(background: string, foreground: string) {
|
||||
return {
|
||||
background,
|
||||
foreground,
|
||||
cursor: foreground,
|
||||
cursorAccent: background,
|
||||
selectionBackground:
|
||||
foreground.length === 7 ? `${foreground}44` : foreground,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS width for xterm font tiers.
|
||||
@@ -215,10 +220,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
);
|
||||
|
||||
const { theme } = useTheme();
|
||||
const terminalBg = theme.terminalBackground ?? "#000000";
|
||||
const terminalBg = theme.terminalBackground ?? DEFAULT_TERMINAL_BACKGROUND;
|
||||
const terminalFg = theme.terminalForeground ?? DEFAULT_TERMINAL_FOREGROUND;
|
||||
const terminalTheme = useMemo(
|
||||
() => ({ ...TERMINAL_THEME_STATIC, background: terminalBg }),
|
||||
[terminalBg],
|
||||
() => buildTerminalTheme(terminalBg, terminalFg),
|
||||
[terminalBg, terminalFg],
|
||||
);
|
||||
|
||||
// The dashboard keeps ChatPage mounted persistently so the PTY survives tab
|
||||
@@ -911,12 +917,12 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
}, [isActive]);
|
||||
|
||||
// Keep the live xterm theme in sync when the active theme's terminal
|
||||
// background changes (e.g. user switches to a custom YAML theme mid-session).
|
||||
// colors change (e.g. user switches to a custom YAML theme mid-session).
|
||||
useEffect(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
term.options.theme = { ...TERMINAL_THEME_STATIC, background: terminalBg };
|
||||
}, [terminalBg]);
|
||||
term.options.theme = terminalTheme;
|
||||
}, [terminalTheme]);
|
||||
|
||||
// Layout:
|
||||
// outer flex column — sits inside the dashboard's content area
|
||||
@@ -946,7 +952,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
onClick={closeMobilePanel}
|
||||
className={cn(
|
||||
"fixed inset-0 z-[55] p-0 block",
|
||||
"bg-black/60 backdrop-blur-sm",
|
||||
"bg-black/60",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -958,7 +964,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
className={cn(
|
||||
"font-mondwest fixed top-0 right-0 z-[60] flex h-dvh max-h-dvh w-64 min-w-0 flex-col antialiased",
|
||||
"border-l border-current/20 text-midground",
|
||||
"bg-background-base/95 backdrop-blur-sm",
|
||||
"bg-background-base/95",
|
||||
"transition-transform duration-200 ease-out",
|
||||
"[background:var(--component-sidebar-background)]",
|
||||
"[clip-path:var(--component-sidebar-clip-path)]",
|
||||
@@ -976,7 +982,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-display font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.modelToolsSheetTitle}
|
||||
<br />
|
||||
@@ -1051,7 +1056,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
Offer an in-place restart so the user never has to refresh the
|
||||
whole page to get a working chat back. */}
|
||||
{sessionEnded && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/60 backdrop-blur-sm">
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/60">
|
||||
<div className="text-sm tracking-wide text-white/80">
|
||||
Session ended.
|
||||
</div>
|
||||
@@ -1074,13 +1079,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
"absolute z-10",
|
||||
"normal-case tracking-normal font-normal",
|
||||
"rounded border border-current/30",
|
||||
"bg-black/20 backdrop-blur-sm",
|
||||
"bg-black/20",
|
||||
"opacity-70 hover:opacity-100 hover:border-current/60",
|
||||
"transition-opacity duration-150",
|
||||
"bottom-2 right-2 px-2 py-1 text-xs sm:bottom-3 sm:right-3 sm:px-2.5 sm:py-1.5",
|
||||
"lg:bottom-4 lg:right-4",
|
||||
)}
|
||||
style={{ color: TERMINAL_THEME_STATIC.foreground }}
|
||||
style={{ color: terminalFg }}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Copy className="h-3 w-3 shrink-0" />
|
||||
|
||||
@@ -546,7 +546,7 @@ export default function CronPage() {
|
||||
{createModalOpen && (
|
||||
<div
|
||||
ref={createModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && setCreateModalOpen(false)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -667,7 +667,7 @@ export default function CronPage() {
|
||||
{editJob && (
|
||||
<div
|
||||
ref={editModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && setEditJob(null)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -334,7 +334,7 @@ export default function McpPage() {
|
||||
{createModalOpen && (
|
||||
<div
|
||||
ref={createModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={(e) =>
|
||||
e.target === e.currentTarget && setCreateModalOpen(false)
|
||||
}
|
||||
@@ -455,7 +455,7 @@ export default function McpPage() {
|
||||
{installEntry && (
|
||||
<div
|
||||
ref={installModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={(e) =>
|
||||
e.target === e.currentTarget && setInstallEntry(null)
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ function UseAsMenu({
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative" data-use-as-menu>
|
||||
<div className={cn("relative", open && "z-20")} data-use-as-menu>
|
||||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
@@ -392,7 +392,7 @@ function ModelCard({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`min-w-0 max-w-full overflow-hidden${isMain ? " ring-1 ring-primary/40" : ""}`}
|
||||
className={cn("min-w-0 max-w-full", isMain && "ring-1 ring-primary/40")}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -575,7 +575,7 @@ function AuxiliaryTasksModal({
|
||||
return (
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -779,7 +779,7 @@ function MoaModelsModal({
|
||||
if (!preset) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4 backdrop-blur-sm">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4">
|
||||
<Card className="max-h-[85vh] w-full max-w-2xl overflow-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Configure Mixture of Agents presets</CardTitle>
|
||||
|
||||
@@ -804,7 +804,7 @@ export default function ProfilesPage() {
|
||||
{createModalOpen && (
|
||||
<div
|
||||
ref={createModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={(e) =>
|
||||
e.target === e.currentTarget && setCreateModalOpen(false)
|
||||
}
|
||||
@@ -1231,7 +1231,7 @@ export default function ProfilesPage() {
|
||||
{editorName && (
|
||||
<div
|
||||
ref={editorModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && closeEditor()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -31,6 +31,7 @@ import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
@@ -570,7 +571,7 @@ export default function SystemPage() {
|
||||
{hookModalOpen && (
|
||||
<div
|
||||
ref={hookModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && setHookModalOpen(false)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -635,15 +636,21 @@ export default function SystemPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Checkbox
|
||||
checked={hookApprove}
|
||||
onChange={(e) => setHookApprove(e.target.checked)}
|
||||
id="hook-approve"
|
||||
onCheckedChange={(checked) => setHookApprove(checked === true)}
|
||||
/>
|
||||
Approve now (grant consent so it fires; otherwise it stays
|
||||
configured but inactive)
|
||||
</label>
|
||||
|
||||
<Label
|
||||
className="cursor-pointer text-sm font-normal normal-case tracking-normal text-muted-foreground"
|
||||
htmlFor="hook-approve"
|
||||
>
|
||||
Approve now (grant consent so it fires; otherwise it stays
|
||||
configured but inactive)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-warning">
|
||||
Shell hooks run arbitrary commands on this host. Only add scripts
|
||||
you trust. Takes effect on the next gateway/session restart.
|
||||
@@ -1097,16 +1104,21 @@ export default function SystemPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-current"
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Checkbox
|
||||
checked={shareRedact}
|
||||
disabled={sharing}
|
||||
onChange={(e) => setShareRedact(e.target.checked)}
|
||||
id="share-redact"
|
||||
onCheckedChange={(checked) => setShareRedact(checked === true)}
|
||||
/>
|
||||
Redact credential-shaped tokens before upload (recommended)
|
||||
</label>
|
||||
|
||||
<Label
|
||||
className="cursor-pointer select-none text-xs font-normal normal-case tracking-normal text-muted-foreground"
|
||||
htmlFor="share-redact"
|
||||
>
|
||||
Redact credential-shaped tokens before upload (recommended)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{shareResult && (
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
|
||||
@@ -303,7 +303,7 @@ export default function WebhooksPage() {
|
||||
{createModalOpen && (
|
||||
<div
|
||||
ref={createModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && closeCreateModal()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -19,7 +19,8 @@ import React, { Fragment, useEffect, useState } from "react";
|
||||
* these in their manifest's `slots` field get wired in automatically.
|
||||
*
|
||||
* Shell-wide slots:
|
||||
* - `backdrop` — rendered inside `<Backdrop />`, above the noise layer
|
||||
* - `backdrop` — optional full-viewport background decoration;
|
||||
* mounted behind shell chrome at z-0
|
||||
* - `header-left` — injected before the Hermes brand in the top bar
|
||||
* - `header-right` — injected before the theme/language switchers
|
||||
* - `header-banner` — injected below the top nav bar, full-width
|
||||
|
||||
@@ -82,8 +82,6 @@ function paletteVars(palette: ThemePalette): Record<string, string> {
|
||||
...layerVars("background", palette.background),
|
||||
...layerVars("midground", palette.midground),
|
||||
...layerVars("foreground", palette.foreground),
|
||||
"--warm-glow": palette.warmGlow,
|
||||
"--noise-opacity-mul": String(palette.noiseOpacity),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -391,11 +389,15 @@ function applyTheme(theme: DashboardTheme) {
|
||||
applyCustomCSS(theme.customCSS);
|
||||
applyLayoutVariant(theme.layoutVariant);
|
||||
|
||||
// Terminal background — read by ChatPage via useTheme(); also available as CSS var.
|
||||
// Terminal colors — read by ChatPage via useTheme(); also available as CSS vars.
|
||||
root.style.setProperty(
|
||||
"--theme-terminal-background",
|
||||
theme.terminalBackground ?? "#000000",
|
||||
);
|
||||
root.style.setProperty(
|
||||
"--theme-terminal-foreground",
|
||||
theme.terminalForeground ?? "#f0e6d2",
|
||||
);
|
||||
|
||||
// Re-assert the font override last: theme application just rewrote
|
||||
// --theme-font-sans/-display, so an active override has to win again.
|
||||
|
||||
@@ -184,98 +184,27 @@ export const roseTheme: DashboardTheme = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Nous Blue — the inverted "light mode" Hermes look, ported from the
|
||||
* LENS_5I overlay preset in `@nous-research/ui`.
|
||||
*
|
||||
* Unlike the other built-ins (which paint dark color directly on the
|
||||
* canvas), this theme relies on `<Backdrop />`'s foreground inversion
|
||||
* layer: an opaque white sheet at z-200 with `mix-blend-mode: difference`
|
||||
* that flips the entire stack below it. Authoring colors stay dark
|
||||
* (`#170d02` brown background, `#FFAC02` orange midground), and the
|
||||
* inversion converts them to their visual complements at paint time —
|
||||
* the orange midground reads as #0053FD Nous-blue on screen, against a
|
||||
* cream `#E8F2FD` canvas.
|
||||
*
|
||||
* Note on bg blend mode: the DS Lens uses `multiply` for LENS_5I because
|
||||
* nousnet-web's <body> is white; hermes-agent's App root is `bg-black`,
|
||||
* so we leave the bg layer's blend mode at the `difference` default —
|
||||
* `difference(#170d02, #000)` passes the bg through unchanged, and the
|
||||
* subsequent FG-difference layer then inverts it to cream. Using
|
||||
* `multiply` here would collapse the bg to pure black against the
|
||||
* `bg-black` root and produce a plain-white canvas instead of the
|
||||
* intended cream-blue.
|
||||
*
|
||||
* Source of truth for the palette: `design-language/src/ui/components/
|
||||
* overlays/lens.ts` (LENS_5I export).
|
||||
*/
|
||||
/** Light mode — vivid Nous-blue accents on a cream canvas. */
|
||||
export const nousBlueTheme: DashboardTheme = {
|
||||
name: "nous-blue",
|
||||
label: "Nous Blue",
|
||||
description: "Light mode — vivid Nous-blue accents on cream canvas",
|
||||
palette: {
|
||||
background: { hex: "#170d02", alpha: 1 },
|
||||
midground: { hex: "#FFAC02", alpha: 1 },
|
||||
foreground: { hex: "#FFFFFF", alpha: 1 },
|
||||
// Same warm-amber as nousnet-web's overlay glow; after the FG
|
||||
// inversion it reads as a cool ultraviolet vignette in the top-left.
|
||||
warmGlow: "rgba(255, 172, 2, 0.18)",
|
||||
// Noise sits above the FG inversion and is NOT flipped, so a softer
|
||||
// multiplier keeps it from speckling over the bright post-inversion
|
||||
// canvas.
|
||||
noiseOpacity: 0.4,
|
||||
background: { hex: "#E8F2FD", alpha: 1 },
|
||||
midground: { hex: "#0053FD", alpha: 1 },
|
||||
foreground: { hex: "#170d02", alpha: 0 },
|
||||
warmGlow: "rgba(0, 83, 253, 0.12)",
|
||||
noiseOpacity: 0,
|
||||
},
|
||||
typography: DEFAULT_TYPOGRAPHY,
|
||||
layout: DEFAULT_LAYOUT,
|
||||
// Inverted page: the embedded terminal is below the FG layer too, so
|
||||
// a `#000000` source paints as visual white — i.e. a proper light-mode
|
||||
// terminal pane. xterm picks lighter palette colors against the "black"
|
||||
// canvas, which then read as dark text on screen post-inversion.
|
||||
terminalBackground: "#000000",
|
||||
componentStyles: {
|
||||
backdrop: {
|
||||
// Lower than LENS_5I.Lens.fillerOpacity (0.06). The filler texture
|
||||
// gets amplified post-inversion: small variations against the deep
|
||||
// `#170d02` source bg are barely visible, but those same variations
|
||||
// against the bright `#E8F2FD` post-inversion canvas read as a
|
||||
// heavy cloud/marble pattern — especially on near-empty pages
|
||||
// (loading spinners, blank states). 0.02 keeps subtle grain
|
||||
// without overwhelming the canvas.
|
||||
fillerOpacity: "0.02",
|
||||
},
|
||||
},
|
||||
// Pre-invert absolute-hex tokens so they read as their familiar colors
|
||||
// through the FG difference layer. e.g. source #04D3C9 (cyan) is what
|
||||
// gets painted, and `255 - channel` flips it to #FB2C36 (red) on screen.
|
||||
// Without these, the default destructive/success/warning tokens would
|
||||
// appear as their unintuitive complements.
|
||||
colorOverrides: {
|
||||
destructive: "#04d3c9",
|
||||
destructiveForeground: "#000000",
|
||||
success: "#b5217f",
|
||||
warning: "#0042c7",
|
||||
},
|
||||
// Pre-inverted data-series accents for the Analytics/Models token
|
||||
// charts. The defaults (#ffe6cb cream + #34d399 emerald) would render
|
||||
// through the FG difference layer as dark navy + hot-coral on the
|
||||
// bright Nous-blue canvas — the coral is the "red" users see for
|
||||
// Output values without these overrides. Source → on-screen:
|
||||
// Input: #ffe6cb → #001934 (dark navy) ← unchanged
|
||||
// Output: #ffac02 → #0053fd (vivid Nous-blue) ← brand accent
|
||||
// Input keeps the cream source so it stays a neutral, low-contrast
|
||||
// dark-blue against the cream canvas; output paints as the brand
|
||||
// Nous-blue so the "primary" series in token-flow charts reads as
|
||||
// the highlight color, matching the rest of the inverted UI chrome.
|
||||
terminalBackground: "#f5f8fc",
|
||||
terminalForeground: "#170d02",
|
||||
seriesColors: {
|
||||
inputTokenAccent: "#ffe6cb",
|
||||
outputTokenAccent: "#ffac02",
|
||||
inputTokenAccent: "#001934",
|
||||
outputTokenAccent: "#0053fd",
|
||||
},
|
||||
// Explicit picker swatch — the raw palette hex (`#170d02`, `#FFAC02`,
|
||||
// amber rgba) doesn't reflect what users see after the FG inversion,
|
||||
// so we paint the post-inversion visual triplet directly:
|
||||
// white → vivid Nous-blue → cream/light-blue
|
||||
// matching the actual on-screen rendering of the theme.
|
||||
swatchColors: ["#FFFFFF", "#0053FD", "#E8F2FD"],
|
||||
swatchColors: ["#170d02", "#0053FD", "#E8F2FD"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
* Themes customise three orthogonal layers:
|
||||
*
|
||||
* 1. `palette` — the 3-layer color triplet (background/midground/
|
||||
* foreground) + warm-glow + noise opacity. The
|
||||
* design-system cascade in `src/index.css` derives
|
||||
* every shadcn-compat token (card, muted, border,
|
||||
* primary, etc.) from this triplet via `color-mix()`.
|
||||
* foreground). Legacy `warmGlow` / `noiseOpacity`
|
||||
* fields remain for theme YAML compat but are unused
|
||||
* by the lightweight shell.
|
||||
* 2. `typography` — font families, base font size, line height,
|
||||
* letter spacing. An optional `fontUrl` is injected
|
||||
* as `<link rel="stylesheet">` so self-hosted and
|
||||
@@ -33,10 +32,9 @@ export interface ThemePalette {
|
||||
/** Top-layer highlight. In LENS_0 this is white @ alpha 0 — invisible by
|
||||
* default but still drives `--color-ring`-style accents. */
|
||||
foreground: ThemeLayer;
|
||||
/** Warm vignette color for <Backdrop />, as an rgba() string. */
|
||||
/** Legacy palette field — kept for theme YAML compat. */
|
||||
warmGlow: string;
|
||||
/** Scalar multiplier (0–1.2) on the noise overlay. Lower for softer themes
|
||||
* like Mono and Rosé, higher for grittier themes like Cyberpunk. */
|
||||
/** Legacy palette field — kept for theme YAML compat. */
|
||||
noiseOpacity: number;
|
||||
}
|
||||
|
||||
@@ -79,12 +77,11 @@ export interface ThemeLayout {
|
||||
export type ThemeLayoutVariant = "standard" | "cockpit" | "tiled";
|
||||
|
||||
/** Named hero/background assets a theme can populate. Each value is
|
||||
* emitted as a CSS var (`--theme-asset-<name>`). The default shell
|
||||
* consumes `bg` in `<Backdrop />` when present; other slots are
|
||||
* plugin-facing — a cockpit sidebar plugin reads `--theme-asset-hero`
|
||||
* to render its hero render without coupling to the theme name. */
|
||||
* emitted as a CSS var (`--theme-asset-<name>`). Plugin slots and
|
||||
* shell chrome may consume these via CSS. */
|
||||
export interface ThemeAssets {
|
||||
/** Full-viewport background image URL, injected under the noise layer. */
|
||||
/** Full-viewport background image URL. Exposed as `--theme-asset-bg` for
|
||||
* the `backdrop` plugin slot or theme `customCSS`. */
|
||||
bg?: string;
|
||||
/** Hero render (Gundam, mascot, wallpaper) — for plugin sidebars/overlays. */
|
||||
hero?: string;
|
||||
@@ -103,7 +100,7 @@ export interface ThemeAssets {
|
||||
|
||||
/** Component-style override buckets. Each bucket's entries become CSS
|
||||
* vars (`--component-<bucket>-<kebab-property>`) that shell components
|
||||
* (Card, Backdrop, App header/footer, etc.) read. Values are plain CSS
|
||||
* (Card, App header/footer, etc.) read. Values are plain CSS
|
||||
* strings — we don't parse them, so themes can use `clip-path`,
|
||||
* `border-image`, `background`, `box-shadow`, and anything else CSS
|
||||
* accepts. */
|
||||
@@ -124,13 +121,7 @@ export interface ThemeComponentStyles {
|
||||
* `--series-input-token` / `--series-output-token` CSS vars consumed
|
||||
* inline by pages that render input-vs-output token flows. Themes can
|
||||
* omit either field to inherit the default token defined in
|
||||
* `index.css` (Hermes-teal `#ffe6cb` for input, `#34d399` for output).
|
||||
*
|
||||
* Inverted-lens themes (e.g. Nous Blue) must pre-invert these hex
|
||||
* values so they read as their intended visual color after the FG
|
||||
* difference layer flips them (`out = 255 − channel`). E.g. to make
|
||||
* output paint as Nous-blue `#0053FD` on screen, set
|
||||
* `outputTokenAccent: "#FFAC02"` — the difference math reverses it. */
|
||||
* `index.css` (Hermes-teal `#ffe6cb` for input, `#34d399` for output). */
|
||||
export interface ThemeSeriesColors {
|
||||
/** Input-tokens series accent (Analytics chart bars + table values). */
|
||||
inputTokenAccent?: string;
|
||||
@@ -181,18 +172,17 @@ export interface DashboardTheme {
|
||||
/** Per-component CSS-var overrides. See `ThemeComponentStyles`. */
|
||||
componentStyles?: ThemeComponentStyles;
|
||||
colorOverrides?: ThemeColorOverrides;
|
||||
/** Data-series accent colors for Analytics/Models token charts.
|
||||
* See `ThemeSeriesColors` for inversion-aware values. */
|
||||
/** Data-series accent colors for Analytics/Models token charts. */
|
||||
seriesColors?: ThemeSeriesColors;
|
||||
/** Explicit 3-color swatch override for the theme picker. Use when the
|
||||
* palette's raw hex values don't reflect what users see on screen —
|
||||
* e.g. inverted "lens" themes whose foreground-difference layer flips
|
||||
* the authored colors to their visual complements. Order matches the
|
||||
/** Explicit 3-color swatch override for the theme picker. Order matches the
|
||||
* default swatch cells: [background, midground, warmGlow]. */
|
||||
swatchColors?: [string, string, string];
|
||||
/** Background color for the embedded terminal pane (xterm.js).
|
||||
* Hex string. Defaults to `"#000000"` when absent. */
|
||||
terminalBackground?: string;
|
||||
/** Default text/cursor color for the embedded terminal pane (xterm.js).
|
||||
* Hex string. Defaults to `"#f0e6d2"` when absent. */
|
||||
terminalForeground?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user