diff --git a/web/src/lib/dashboard-modal-shell.test.ts b/web/src/lib/dashboard-modal-shell.test.ts new file mode 100644 index 0000000000..2f358ebd21 --- /dev/null +++ b/web/src/lib/dashboard-modal-shell.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + DASHBOARD_MODAL_BACKDROP, + DASHBOARD_MODAL_PANEL, + shouldCloseOuterModalOnEscape, +} from "./dashboard-modal-shell"; + +describe("dashboard modal shell", () => { + it("uses an opaque panel (bg-card), not the glass Card default", () => { + expect(DASHBOARD_MODAL_PANEL).toMatch(/\bbg-card\b/); + expect(DASHBOARD_MODAL_PANEL).not.toMatch(/bg-background-base\/\d+/); + }); + + it("keeps the backdrop above page chrome (z-[100])", () => { + expect(DASHBOARD_MODAL_BACKDROP).toMatch(/z-\[100\]/); + expect(DASHBOARD_MODAL_BACKDROP).toMatch(/\bbg-background\/85\b/); + }); + + it("does not close the outer modal on Escape while a nested picker is open", () => { + expect(shouldCloseOuterModalOnEscape(true)).toBe(false); + expect(shouldCloseOuterModalOnEscape(false)).toBe(true); + }); +}); diff --git a/web/src/lib/dashboard-modal-shell.ts b/web/src/lib/dashboard-modal-shell.ts new file mode 100644 index 0000000000..64cabbd84c --- /dev/null +++ b/web/src/lib/dashboard-modal-shell.ts @@ -0,0 +1,29 @@ +/** + * Shared dashboard dialog shell classes. + * + * Page `` defaults to `bg-background-base/80` (glass). That looks fine + * on the page canvas, but as a modal panel it lets the Models page bleed + * through and kills readability — especially on Cyberpunk / mobile. + * + * Modal panels must use opaque `bg-card`; backdrops use the same z-index + * band as Auxiliary / Confirm dialogs so they sit above page chrome. + * + * Callers must `createPortal(..., document.body)` — `z-[100]` alone cannot + * escape the dashboard column's `relative z-2` stacking context (see + * ModelPickerDialog / ToolsetConfigDrawer). + */ +export const DASHBOARD_MODAL_BACKDROP = + "fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"; + +export const DASHBOARD_MODAL_PANEL = + "relative w-full border border-border bg-card shadow-2xl"; + +/** + * Outer modals that host a nested picker (e.g. MoA → ModelPickerDialog) + * must ignore Escape while the picker is open; the picker owns that key. + */ +export function shouldCloseOuterModalOnEscape( + nestedPickerOpen: boolean, +): boolean { + return !nestedPickerOpen; +} diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index 504c8ddb54..e4fb338bc9 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { createPortal } from "react-dom"; import { Brain, ChevronDown, @@ -22,6 +23,11 @@ import type { ModelsAnalyticsResponse, } from "@/lib/api"; import { timeAgo, cn, themedBody } from "@/lib/utils"; +import { + DASHBOARD_MODAL_BACKDROP, + DASHBOARD_MODAL_PANEL, + shouldCloseOuterModalOnEscape, +} from "@/lib/dashboard-modal-shell"; import { formatTokenCount } from "@/lib/format"; import { Button } from "@nous-research/ui/ui/components/button"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; @@ -711,6 +717,17 @@ function MoaModelsModal({ const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + // Nested ModelPickerDialog owns Escape while open — don't dismiss MoA too. + const closeMoaUnlessPickerOpen = useCallback(() => { + if (!shouldCloseOuterModalOnEscape(picker !== null)) return; + onClose(); + }, [picker, onClose]); + + const modalRef = useModalBehavior({ + open: true, + onClose: closeMoaUnlessPickerOpen, + }); + const presetNames = Object.keys(draft.presets || {}); const preset = draft.presets[selected] || draft.presets[presetNames[0]]; const slotLabel = (slot: MoaModelSlot) => `${slot.provider || "(provider)"} · ${slot.model || "(model)"}`; @@ -778,13 +795,36 @@ function MoaModelsModal({ if (!preset) return null; - return ( -
- - - Configure Mixture of Agents presets - - + // Portal to document.body: the main dashboard column is `relative z-2`, + // which traps fixed descendants below the sidebar (same as ModelPickerDialog). + return createPortal( +
{ + if (e.target === e.currentTarget) closeMoaUnlessPickerOpen(); + }} + role="dialog" + aria-modal="true" + aria-labelledby="moa-modal-title" + > + {/* Opaque panel — do not use here; Card defaults to bg-background-base/80. */} +
+
+

+ Configure Mixture of Agents presets +

+
+

Presets appear as models under the Mixture of Agents provider. References produce perspectives; the aggregator is the acting model that answers and calls tools.

@@ -835,10 +875,10 @@ function MoaModelsModal({ {error &&
{error}
}
- +
- - +
+
{picker && ( setPicker(null)} /> )} -
+
, + document.body, ); }