Fix wave chunk B: shared MenuShell for model menus, setup CTA on /chat/new

- components/menu-shell.tsx: one portaled, fixed-anchored, viewport-capped
  (60vh pattern) menu shell with site-preferred side + honest-fit auto-flip;
  computeMenuPlacement exported pure, placement-only (sites keep their own
  open/close/Esc contracts); re-places on capture-phase scroll/resize
- migrated: settings slot menus (agent-section), keys Add-provider,
  composer model+thinking (open up, right-aligned), SetupDialog pickers
  (portaled out of the overflow-hidden dialog frame — no clipping by
  construction); graph popovers + stats-bar summary stay bespoke (owner)
- /chat/new: the setup CTA above the draft blob, mirroring home's
- tests: menu-shell-pure (geometry/cap/flip/alignment), DraftShell CTA
This commit is contained in:
amosblomqvist
2026-09-22 16:56:44 +03:00
parent f6626f1703
commit 8675b19bef
8 changed files with 370 additions and 89 deletions
+18 -58
View File
@@ -42,7 +42,7 @@
*/
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { MenuShell } from "@/components/menu-shell";
import type React from "react";
import {
MODEL_SLOTS,
@@ -206,54 +206,11 @@ function MenuTrigger({
);
}
/** The opened dropdown, PORTALED to document.body with fixed positioning:
* the dialog's overflow frame must not clip it — the owner wants the
* menus flowing over the whole screen, outside the settings surface
* (2026-09-20). Right-aligned to its trigger, repositioned while the
* dialog content scrolls under it. */
function AnchoredMenu({
anchorTestid,
menuClass,
testid,
children,
}: {
/** The trigger to hang from — its data-testid. */
anchorTestid: string;
/** The positioned menu's classes (width, padding, frame). */
menuClass: string;
testid: string;
children: React.ReactNode;
}) {
const [style, setStyle] = useState<{ right: number; top: number } | null>(null);
useEffect(() => {
const place = () => {
const anchor = document.querySelector(`[data-testid="${anchorTestid}"]`);
if (!anchor) return;
const r = anchor.getBoundingClientRect();
setStyle({ right: window.innerWidth - r.right, top: r.bottom + 4 });
};
place();
// Capture-phase: catches the dialog's inner content scroll.
window.addEventListener("scroll", place, true);
window.addEventListener("resize", place);
return () => {
window.removeEventListener("scroll", place, true);
window.removeEventListener("resize", place);
};
}, [anchorTestid]);
if (style === null) return null;
return createPortal(
<div
data-testid={testid}
data-lh-menu=""
style={{ position: "fixed", right: style.right, top: style.top, zIndex: 60 }}
className={menuClass}
>
{children}
</div>,
document.body,
);
}
/* The section's dropdowns all mount the shared MenuShell (fix wave chunk
* B): portaled to document.body, fixed-anchored to the open trigger by
* testid (the section's one-ref pattern), opening DOWN with the shell's
* viewport cap and auto-flip. data-lh-menu rides the shell — the
* outside-click contract's exemption mark. */
export function AgentSection({
agent,
@@ -566,8 +523,9 @@ export function AgentSection({
widthClass="w-20"
/>
{thisMenuOpen === "model" && (
<AnchoredMenu
anchorTestid={`slot-model-${slot}`}
<MenuShell
preferredSide="down"
anchorTestid={`slot-model-${slot}`}
testid={`slot-model-menu-${slot}`}
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
@@ -585,11 +543,12 @@ export function AgentSection({
: "Runs every agent of this tier"
}
/>
</AnchoredMenu>
</MenuShell>
)}
{thisMenuOpen === "thinking" && (
<AnchoredMenu
anchorTestid={`slot-thinking-${slot}`}
<MenuShell
preferredSide="down"
anchorTestid={`slot-thinking-${slot}`}
testid={`slot-thinking-menu-${slot}`}
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
@@ -608,7 +567,7 @@ export function AgentSection({
setOpenMenu(null);
}}
/>
</AnchoredMenu>
</MenuShell>
)}
</span>
</SettingsRow>
@@ -662,8 +621,9 @@ export function AgentSection({
disabled={!anyKey}
/>
{open && (
<AnchoredMenu
anchorTestid="search-engine-trigger"
<MenuShell
preferredSide="down"
anchorTestid="search-engine-trigger"
testid="search-engine-menu"
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
@@ -677,7 +637,7 @@ export function AgentSection({
setOpenMenu(null);
}),
)}
</AnchoredMenu>
</MenuShell>
)}
</span>
</SettingsRow>
+8 -14
View File
@@ -24,10 +24,10 @@
*/
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
import { SettingsGroup } from "./controls.tsx";
import { ProviderMenuList } from "./model-menu.tsx";
import { MenuShell } from "@/components/menu-shell";
/** One pi-ai provider with plain API-key auth (the Platform seam —
* derived from pi-ai's own env-key table; no drift possible). */
@@ -250,11 +250,6 @@ function AddProviderRow({
};
}, [open]);
const style = open && anchorRef.current ? (() => {
const r = anchorRef.current.getBoundingClientRect();
return { position: "fixed" as const, right: window.innerWidth - r.right, top: r.bottom + 4, zIndex: 60 };
})() : null;
return (
<div ref={anchorRef} className="relative">
<button
@@ -267,16 +262,15 @@ function AddProviderRow({
<span>+ Add provider key</span>
{addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
</button>
{open && style !== null && createPortal(
<div
data-testid="add-provider-menu"
data-lh-menu=""
style={style}
className="flex w-72 flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
{open && (
<MenuShell
anchorRef={anchorRef}
testid="add-provider-menu"
preferredSide="down"
menuClass="flex w-72 flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
<ProviderMenuList providers={addable} onPick={onPick} />
</div>,
document.body,
</MenuShell>
)}
</div>
);