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:
parent
07ba9e9266
commit
1cf2c763ef
3 changed files with 104 additions and 11 deletions
23
web/src/lib/dashboard-modal-shell.test.ts
Normal file
23
web/src/lib/dashboard-modal-shell.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
29
web/src/lib/dashboard-modal-shell.ts
Normal file
29
web/src/lib/dashboard-modal-shell.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue