diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index 9802dafb35..43dc76981c 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; -import { Button, Badge, Input, Modal, Toggle } from "@/shared/components"; +import { Button, Badge, Input, Modal, Toggle, TALL_MODAL_PROPS } from "@/shared/components"; import { providerAllowsOptionalApiKey, supportsBulkApiKey, @@ -445,7 +445,7 @@ export default function AddApiKeyModal({ title={getAddCredentialModalTitle(t, providerDisplayName, webSessionCredential)} onClose={onClose} size="lg" - bodyClassName="p-6 max-h-[85vh] overflow-y-auto" + {...TALL_MODAL_PROPS} >
{webProviderHostLink && ( diff --git a/src/shared/components/Modal.tsx b/src/shared/components/Modal.tsx index dfff5de2f1..5e385ebf8d 100644 --- a/src/shared/components/Modal.tsx +++ b/src/shared/components/Modal.tsx @@ -4,6 +4,14 @@ import { useEffect, useRef, useId } from "react"; import { cn } from "@/shared/utils/cn"; import Button from "./Button"; +// #6265 — preset for content-heavy modals: caps height on the OUTERMOST dialog +// wrapper only (single scroll owner) and keeps the inner body plain (no +// independent max-h/overflow), avoiding a double height cap that clips content. +export const TALL_MODAL_PROPS = { + className: "max-h-[90vh] overflow-y-auto", + bodyClassName: "p-6", +}; + interface ModalProps { isOpen: boolean; onClose: () => void; diff --git a/src/shared/components/index.tsx b/src/shared/components/index.tsx index a7ffdf4f36..774165b1ce 100644 --- a/src/shared/components/index.tsx +++ b/src/shared/components/index.tsx @@ -6,7 +6,7 @@ export { default as Checkbox } from "./Checkbox"; export { default as Textarea } from "./Textarea"; export { default as Card } from "./Card"; export { default as Collapsible } from "./Collapsible"; -export { default as Modal, ConfirmModal } from "./Modal"; +export { default as Modal, ConfirmModal, TALL_MODAL_PROPS } from "./Modal"; export { default as Loading, Spinner, PageLoading, Skeleton, CardSkeleton } from "./Loading"; export { default as Avatar } from "./Avatar"; export { default as Badge } from "./Badge"; diff --git a/tests/unit/ui/web-session-cookie-modal-size-6265.test.tsx b/tests/unit/ui/web-session-cookie-modal-size-6265.test.tsx new file mode 100644 index 0000000000..6d569f9047 --- /dev/null +++ b/tests/unit/ui/web-session-cookie-modal-size-6265.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +// Issue #6265 — the "Add session cookie" modal (AddApiKeyModal, shared by every +// `-web` cookie provider) was undersized on a 1920x1080 viewport: users had to +// scroll *inside* the modal to reach Save, and the top of the cookie helper text +// was clipped. Root cause: the height cap (`max-h-*` + `overflow-y-auto`) lived on +// the INNER body div only, while the OUTERMOST dialog wrapper had no height bound +// at all — so the whole box (header + body) could grow taller than the viewport +// and get clipped by the centering flexbox, independent of the inner scrollbar. +// +// Fix: move the single height cap to the outermost dialog wrapper (`role="dialog"`) +// and stop giving the inner body container its own independent `max-h-`/`overflow` +// cap, so there is exactly one scroll owner for the whole modal. +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +const { default: AddApiKeyModal } = + await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal"); + +const containers: Array<{ root: ReturnType; el: HTMLDivElement }> = []; + +function render(props: Record) { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + act(() => { + root.render( + undefined} + onClose={() => {}} + {...(props as any)} + /> + ); + }); + containers.push({ root, el }); + return el; +} + +afterEach(() => { + for (const { root, el } of containers.splice(0)) { + act(() => root.unmount()); + el.remove(); + } +}); + +describe("AddApiKeyModal — cookie modal sizing (#6265)", () => { + it("caps height on the OUTERMOST dialog wrapper, not on an inner body div", () => { + // chatgpt-web is a `kind: "cookie"` web-session provider — same shared modal + // path lmarena/claude-web/gemini-web/kimi-web/z-ai all go through. + const el = render({ provider: "chatgpt-web", providerName: "ChatGPT (Web)" }); + + const dialog = el.querySelector('[role="dialog"]'); + expect(dialog).toBeTruthy(); + + // The outermost wrapper must be the single owner of the height cap + scroll. + expect(dialog!.className).toMatch(/max-h-\[90vh\]/); + expect(dialog!.className).toMatch(/overflow-y-auto/); + + // The body container (last child of the dialog — header is first, no footer + // prop is used by AddApiKeyModal) must NOT carry its own independent max-h/ + // overflow cap — otherwise the outer cap and the inner cap fight (double cap), + // clipping content before the outer 90vh bound ever kicks in. + const bodyDiv = dialog!.children[dialog!.children.length - 1] as HTMLElement; + expect(bodyDiv).toBeTruthy(); + expect(bodyDiv.className).not.toMatch(/max-h-/); + expect(bodyDiv.className).not.toMatch(/overflow-y-auto/); + + // Sanity: the cookie helper text and Save button are both present in the DOM + // (this modal renders the full guide + form Save/Cancel inline in the body). + expect(el.textContent).toContain("How to get the session credential"); + const saveBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "save" + ); + expect(saveBtn).toBeTruthy(); + }); +});