fix(dashboard): opaque MoA presets modal (stop page bleed-through) (#67410)

* fix(dashboard): make MoA presets modal opaque and readable

Card defaults to bg-background-base/80 glass, so the Mixture of Agents
dialog let the Models page bleed through — especially on Cyberpunk/mobile.
Portal an opaque dialog shell above the z-2 dashboard column, and ignore
Escape while the nested model picker is open.

* test(web): lock dashboard modal shell to opaque panel classes

Guard the MoA/dialog shell contract so glass Card defaults cannot
quietly return to modal panels, and Escape stays picker-aware.
This commit is contained in:
HexLab 2026-07-20 06:09:12 +07:00 committed by GitHub
parent 07ba9e9266
commit 1cf2c763ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 104 additions and 11 deletions

View file

@ -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);
});
});

View file

@ -0,0 +1,29 @@
/**
* Shared dashboard dialog shell classes.
*
* Page `<Card>` 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;
}

View file

@ -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<string | null>(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 (
<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>
</CardHeader>
<CardContent className="space-y-4">
// Portal to document.body: the main dashboard column is `relative z-2`,
// which traps fixed descendants below the sidebar (same as ModelPickerDialog).
return createPortal(
<div
ref={modalRef}
className={DASHBOARD_MODAL_BACKDROP}
onMouseDown={(e) => {
if (e.target === e.currentTarget) closeMoaUnlessPickerOpen();
}}
role="dialog"
aria-modal="true"
aria-labelledby="moa-modal-title"
>
{/* Opaque panel — do not use <Card> here; Card defaults to bg-background-base/80. */}
<div
className={cn(
themedBody,
DASHBOARD_MODAL_PANEL,
"max-h-[85vh] max-w-2xl overflow-auto flex flex-col",
)}
>
<header className="p-5 pb-3 border-b border-border">
<h2
id="moa-modal-title"
className="font-mondwest text-display text-base tracking-wider"
>
Configure Mixture of Agents presets
</h2>
</header>
<div className="space-y-4 p-5">
<p className="text-xs text-text-secondary">
Presets appear as models under the Mixture of Agents provider. References produce perspectives; the aggregator is the acting model that answers and calls tools.
</p>
@ -835,10 +875,10 @@ function MoaModelsModal({
{error && <div className="text-xs text-destructive">{error}</div>}
<div className="flex justify-end gap-2 pt-2">
<Button ghost onClick={onClose} disabled={busy}>Cancel</Button>
<Button onClick={save} disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
<Button onClick={() => void save()} disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
</div>
</CardContent>
</Card>
</div>
</div>
{picker && (
<ModelPickerDialog
key={`moa-picker-${refreshKey}-${selected}-${picker.kind}-${picker.kind === "reference" ? picker.index : "agg"}`}
@ -862,7 +902,8 @@ function MoaModelsModal({
onClose={() => setPicker(null)}
/>
)}
</div>
</div>,
document.body,
);
}