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
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { createPortal } from "react-dom";
/**
* THE shared menu shell (menu standardization, fix wave chunk B): one
* placement body for the app's dropdown menus — portaled to document.body
* (never clipped by an ancestor's overflow), fixed-anchored to the trigger's
* rect, viewport-capped. PLACEMENT-ONLY by design: every site keeps its own
* open/close/Esc contract (the layered-dismissal machinery differs
* legitimately by surface — settings sections, the keys editor, the setup
* dialog each own their Escape).
*
* - Side: each site declares its preferred side — the composer opens UP
* (its control sits at screen bottom), the settings rows open DOWN;
* the shell flips when the preferred side cannot show the menu's
* content and the other side is roomier (the graph popovers'
* honest-fitting behavior, generalized).
* - Cap: max-height = min(60vh, the chosen side's available room); the
* menu scrolls internally instead of stretching the page or escaping
* the viewport (graph-view's max-h-[60vh] pattern).
* - Re-placement: capture-phase scroll + window resize — the settings
* menus' discipline (the settings dialog's inner content scroll
* included).
*
* Sites (model menus first, owner 2026-09-22; the graph popovers and the
* stats-bar summary popover stay bespoke — seams stay open for later
* adoption): the settings Agent section's slot + search-engine menus, the
* Keys section's Add-provider menu, the composer's model/thinking menus
* (model-picker.tsx), and the SetupDialog's slot pickers — portaling THOSE
* out of the dialog's overflow-hidden frame so they extend beyond the
* dialog exactly like the settings menus (owner, chunk B item 7).
*
* SSR: menus only mount client-side while open; the hidden measuring pass
* below is the only pre-placement render, so a server render never emits
* a positioned menu.
*/
const GAP = 4; // anchor edge → menu (the bespoke shells' settled 4px)
const EDGE = 8; // viewport floor the menu never enters
const CAP_VH = 0.6; // 60vh — the graph pattern's cap
const MIN_USABLE = 96; // floor: a shorter cap is an unusable sliver; the
// cap then overflows the edge instead of crushing the menu
export type MenuSide = "up" | "down";
export type MenuAlign = "left" | "right";
/** One placement decision. `style` is the fixed-position payload; the side
* rides back for the flip assertion in tests. */
export interface MenuPlacement {
side: MenuSide;
style: Record<string, string | number>;
maxHeight: number;
}
/** Pure: the placement decision — anchored rect + viewport + the menu's
* UNCAPPED content height (scrollHeight, so re-placements after a search
* filter re-measure honestly) → side, fixed style, cap. Flip rule: stay
* on the preferred side unless it cannot show the full content AND the
* other side is roomier. The cap always keeps the menu inside its side's
* room when there is room; the floor keeps it usable when there isn't. */
export function computeMenuPlacement(
rect: { top: number; bottom: number; left: number; right: number },
viewport: { width: number; height: number },
contentHeight: number,
preferredSide: MenuSide,
align: MenuAlign,
): MenuPlacement {
const availDown = viewport.height - EDGE - (rect.bottom + GAP);
const availUp = rect.top - GAP - EDGE;
// contentHeight ≤ 0 (nothing measured yet) keeps the preferred side —
// the hidden first pass re-measures and decides with real numbers.
let side = preferredSide;
if (contentHeight > 0) {
const preferredAvail = preferredSide === "down" ? availDown : availUp;
const otherAvail = preferredSide === "down" ? availUp : availDown;
if (contentHeight > preferredAvail && otherAvail > preferredAvail) {
side = preferredSide === "down" ? "up" : "down";
}
}
const avail = side === "down" ? availDown : availUp;
const maxHeight = Math.max(MIN_USABLE, Math.min(CAP_VH * viewport.height, avail));
const horizontal: Record<string, number> =
align === "right" ? { right: viewport.width - rect.right } : { left: rect.left };
const vertical: Record<string, number> =
side === "down" ? { top: rect.bottom + GAP } : { bottom: viewport.height - rect.top + GAP };
return { side, style: { ...horizontal, ...vertical }, maxHeight };
}
/** The shared shell. Exactly one anchor seam: pass `anchorRef` (a live
* element ref) or `anchorTestid` (the settings section's pattern — the
* trigger is queried by testid at place time). */
export function MenuShell({
anchorRef,
anchorTestid,
testid,
preferredSide,
align = "right",
menuClass,
children,
}: {
anchorRef?: RefObject<HTMLElement | null>;
anchorTestid?: string;
/** The menu body's own testid (sites assert it). */
testid: string;
/** Which way the menu opens from the anchor when both sides fit. */
preferredSide: MenuSide;
/** Which anchor edge the menu's horizontal position keys off. */
align?: MenuAlign;
/** The menu body classes: width, border, bg, padding, text — the
* shell adds position, z-index, the height cap, and scrolling. */
menuClass: string;
children: ReactNode;
}) {
const menuRef = useRef<HTMLDivElement>(null);
const [placed, setPlaced] = useState<MenuPlacement | null>(null);
const anchor = () => {
if (anchorRef) return anchorRef.current;
if (anchorTestid !== undefined) return document.querySelector(`[data-testid="${anchorTestid}"]`);
return null;
};
// Measure + (re)place: on mount, then on every capture-phase scroll and
// resize (rAF-throttled). scrollHeight keeps the flip decision honest
// after the content changes shape (a search filtering the list).
useLayoutEffect(() => {
let raf = 0;
const place = () => {
const el = anchor();
if (!el || !menuRef.current) return;
const p = computeMenuPlacement(
el.getBoundingClientRect(),
{ width: window.innerWidth, height: window.innerHeight },
menuRef.current.scrollHeight,
preferredSide,
align,
);
setPlaced(p);
};
place();
const rePlace = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(place);
};
window.addEventListener("scroll", rePlace, true);
window.addEventListener("resize", rePlace);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", rePlace, true);
window.removeEventListener("resize", rePlace);
};
}, [anchorRef, anchorTestid, preferredSide, align]);
// First pass: mounted hidden at the viewport's top-left so the layout
// effect can measure the real content height before anything is seen.
// The menu is positioned ONCE and then only re-anchored — no flash.
if (placed === null) {
return createPortal(
<div
ref={menuRef}
data-testid={testid}
data-lh-menu=""
style={{ position: "fixed", left: 0, top: 0, visibility: "hidden" }}
className={menuClass}
>
{children}
</div>,
document.body,
);
}
return createPortal(
<div
ref={menuRef}
data-testid={testid}
data-lh-menu=""
style={{ position: "fixed", zIndex: 60, ...placed.style, maxHeight: placed.maxHeight, overflowY: "auto" }}
className={menuClass}
>
{children}
</div>,
document.body,
);
}
+13 -8
View File
@@ -6,6 +6,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { prettyModel } from "@/components/usage-format";
import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-menu";
import { composeSlotModel } from "@/components/settings/agent-section";
import { MenuShell } from "@/components/menu-shell";
/** GET /api/models is cached per page load for instant menu opens, but
* the federation is LIVE per call — keys saved mid-session widen the
@@ -252,9 +253,11 @@ export function ModelControl() {
settings' slot selects and the setup dialog use the exact same
one. */}
{open === "model" && (
<div
data-testid="model-menu"
className="absolute bottom-full right-0 z-40 mb-1.5 flex w-[22rem] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl"
<MenuShell
anchorRef={containerRef}
testid="model-menu"
preferredSide="up"
menuClass="flex w-[22rem] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl"
>
{error && <div className="mb-1.5 text-destructive">{error}</div>}
<ModelMenuList
@@ -268,13 +271,15 @@ export function ModelControl() {
: "Strong tier — runs prober + teacher, app-wide"
}
/>
</div>
</MenuShell>
)}
{open === "thinking" && (
<div
data-testid="thinking-menu"
className="absolute bottom-full right-0 z-40 mb-1.5 flex w-44 flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl"
<MenuShell
anchorRef={containerRef}
testid="thinking-menu"
preferredSide="up"
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl"
>
<ThinkingMenuList
levels={catalog ? levels : []}
@@ -286,7 +291,7 @@ export function ModelControl() {
footer="Levels this model actually supports"
/>
{error && <div className="mt-1 text-destructive">{error}</div>}
</div>
</MenuShell>
)}
</div>
);
+21 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Composer } from "@/components/composer";
@@ -69,6 +69,7 @@ export function DraftShell({
onSend,
settings,
onSettingsSaved,
setupCta,
}: {
busy: boolean;
text: string;
@@ -76,6 +77,10 @@ export function DraftShell({
onSend: () => void;
settings: ChatSettings | undefined;
onSettingsSaved: (s: ChatSettings) => void;
/** The first-run CTA (fix wave chunk B): the same "Set up your models"
* affordance home carries, rendered above the draft shell — passed by
* NewSession, which owns the configured state and the SetupDialog. */
setupCta?: ReactNode;
}) {
const canSend = text.trim().length > 0;
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
@@ -110,6 +115,7 @@ export function DraftShell({
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
<div className="bg-background pb-4">
<div className={DOCK}>
{setupCta}
<div className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
<Composer
busy={busy}
@@ -197,6 +203,20 @@ export function NewSession({
onSend={send}
settings={settings}
onSettingsSaved={saveDraft}
setupCta={
!configured && (
<div className="pointer-events-auto mb-2 flex justify-center">
<button
type="button"
data-testid="setup-cta"
onClick={() => setSetupOpen(true)}
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
>
Set up your models — chats start after the three picks
</button>
</div>
)
}
/>
{/* First-run setup: the send interception's landing surface. */}
<SetupDialog
+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>
);
+15 -7
View File
@@ -29,12 +29,13 @@
* CMU title, z-50.
*/
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type React from "react";
import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings } from "@/src/core";
import { prettyModel } from "@/components/usage-format";
import { ModelMenuList } from "@/components/settings/model-menu";
import { MenuShell } from "@/components/menu-shell";
/** The pickers' slot identity — same roster language as the Agent
* section's slot rows. */
@@ -80,6 +81,11 @@ export function SetupDialog({
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openSlot, setOpenSlot] = useState<ModelSlotName | null>(null);
/** The open slot's trigger wrapper — the shared shell's anchor (fix
* wave chunk B): one menu open at a time, so one ref suffices, and
* the menu PORTALS out of this dialog's overflow-hidden frame — it
* extends beyond the dialog exactly like the settings menus do. */
const triggerRef = useRef<HTMLDivElement>(null);
// Fresh /api/models per open — a key saved a moment ago (stage 1 →
// Keys → back) widens the pickers immediately, no restart.
@@ -204,7 +210,7 @@ export function SetupDialog({
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
<span className="font-ui text-sm text-foreground">{SLOT_META[slot].label}</span>
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</span>
<div className="relative">
<div ref={openSlot === slot ? triggerRef : undefined} className="relative">
<button
type="button"
aria-label={`${SLOT_META[slot].label} model`}
@@ -219,10 +225,12 @@ export function SetupDialog({
</span>
</button>
{openSlot === slot && (
<div
data-testid={`setup-menu-${slot}`}
data-lh-menu=""
className="absolute top-full left-0 z-40 flex max-h-96 w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
<MenuShell
anchorRef={triggerRef}
testid={`setup-menu-${slot}`}
preferredSide="down"
align="left"
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
<ModelMenuList
models={catalog}
@@ -234,7 +242,7 @@ export function SetupDialog({
}}
footer={catalog && catalog.length === 0 ? "No image-capable models — widen the provider keys" : undefined}
/>
</div>
</MenuShell>
)}
</div>
</div>