diff --git a/components/menu-shell.tsx b/components/menu-shell.tsx new file mode 100644 index 0000000..5cd7207 --- /dev/null +++ b/components/menu-shell.tsx @@ -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; + 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 = + align === "right" ? { right: viewport.width - rect.right } : { left: rect.left }; + const vertical: Record = + 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; + 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(null); + const [placed, setPlaced] = useState(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( +
+ {children} +
, + document.body, + ); + } + return createPortal( +
+ {children} +
, + document.body, + ); +} \ No newline at end of file diff --git a/components/model-picker.tsx b/components/model-picker.tsx index a81bfc3..dfa182c 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -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" && ( -
{error &&
{error}
} -
+ )} {open === "thinking" && ( -
{error &&
{error}
} -
+ )} ); diff --git a/components/new-session.tsx b/components/new-session.tsx index b1c644f..4e1a10f 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -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({
+ {setupCta}
+ +
+ ) + } /> {/* First-run setup: the send interception's landing surface. */} (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( -
- {children} -
, - 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" && ( - @@ -585,11 +543,12 @@ export function AgentSection({ : "Runs every agent of this tier" } /> - + )} {thisMenuOpen === "thinking" && ( - @@ -608,7 +567,7 @@ export function AgentSection({ setOpenMenu(null); }} /> - + )} @@ -662,8 +621,9 @@ export function AgentSection({ disabled={!anyKey} /> {open && ( - @@ -677,7 +637,7 @@ export function AgentSection({ setOpenMenu(null); }), )} - + )} diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index e84d5e9..c036f22 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -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 (
- {open && style !== null && createPortal( -
-
, - document.body, + )}
); diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 2ce79ef..51fff94 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -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(null); const [openSlot, setOpenSlot] = useState(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(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({
{SLOT_META[slot].label} {SLOT_META[slot].roster} -
+
{openSlot === slot && ( -
-
+ )}
diff --git a/tests/menu-shell-pure.test.ts b/tests/menu-shell-pure.test.ts new file mode 100644 index 0000000..49343eb --- /dev/null +++ b/tests/menu-shell-pure.test.ts @@ -0,0 +1,82 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { computeMenuPlacement, type MenuSide } from "../components/menu-shell.tsx"; + +/** + * The shared menu shell's placement decision (fix wave chunk B): the + * pure math behind every dropdown's anchoring — side preference, the + * honest-fit flip, the 60vh cap, alignment. The DOM effects (portal, + * measure-then-show, re-placement on scroll/resize) are live-verified; + * this pins the geometry. + */ + +const rect = (top: number, bottom: number) => ({ top, bottom, left: 100, right: 300 }); +const viewport = (height: number) => ({ width: 1280, height }); + +describe("computeMenuPlacement — side", () => { + it("keeps the preferred side when it shows the whole menu", () => { + for (const preferred of ["up", "down"] as MenuSide[]) { + const p = computeMenuPlacement(rect(400, 440), viewport(800), 300, preferred, "right"); + assert.equal(p.side, preferred); + } + }); + + it("flips when the preferred side cannot hold the content and the other is roomier", () => { + // Anchor 30px above the viewport bottom: down has ~2px of room, + // up has ~380. A 300px menu must flip UP despite preferring down. + const p = computeMenuPlacement(rect(770, 800), viewport(800), 300, "down", "right"); + assert.equal(p.side, "up"); + // ...and mirrors: an anchor at the very top preferring up flips down. + const q = computeMenuPlacement(rect(0, 30), viewport(800), 300, "up", "right"); + assert.equal(q.side, "down"); + }); + + it("stays put when neither side fits better (the roomier side already preferred)", () => { + // Short viewport: down has 450, up has 200 — a 600px menu stays + // down (capped gracefully) because up is NOT roomier. + const p = computeMenuPlacement(rect(150, 190), viewport(650), 600, "down", "right"); + assert.equal(p.side, "down"); + }); + + it("nothing measured yet (contentHeight ≤ 0) keeps the preferred side", () => { + const p = computeMenuPlacement(rect(770, 800), viewport(800), 0, "down", "right"); + assert.equal(p.side, "down"); + }); +}); + +describe("computeMenuPlacement — cap + position", () => { + it("caps at 60vh when the side has more room than that", () => { + // Anchor high: 648px below it — more than 60vh(480) → the cap is 60vh. + const p = computeMenuPlacement(rect(100, 140), viewport(800), 300, "down", "right"); + assert.equal(p.maxHeight, 0.6 * 800); + assert.equal(p.style.top, 144); // anchor bottom + GAP(4) + assert.equal(p.style.right, 1280 - 300); // right-aligned to the anchor + }); + + it("caps at the side's available room when 60vh exceeds it", () => { + // Anchor at 500/800: down room = 800 − 8 − 544 = 248 < 480. The + // short content (100) fits down — no flip — so the cap binds at 248. + const p = computeMenuPlacement(rect(500, 540), viewport(800), 100, "down", "right"); + assert.equal(p.side, "down"); + assert.equal(p.maxHeight, 248); + }); + + it("never crushes below the usability floor, even against a tiny side", () => { + // Anchor 30px above the bottom, both sides thin: capped to the + // floor, not a 2px sliver — the cap overflows rather than crushes. + const p = computeMenuPlacement(rect(30, 60), viewport(90), 300, "down", "right"); + assert.equal(p.maxHeight, 96); + }); + + it("up-side menus anchor their BOTTOM above the anchor's top", () => { + const p = computeMenuPlacement(rect(400, 440), viewport(800), 300, "up", "right"); + assert.equal(p.style.bottom, 800 - 400 + 4); // viewport − anchor.top − GAP + assert.ok(!("top" in p.style)); + }); + + it("left alignment keys off the anchor's left edge", () => { + const p = computeMenuPlacement(rect(400, 440), viewport(800), 300, "down", "left"); + assert.equal(p.style.left, 100); + assert.ok(!("right" in p.style)); + }); +}); \ No newline at end of file diff --git a/tests/render-new-session.test.tsx b/tests/render-new-session.test.tsx index 20672b3..09887ef 100644 --- a/tests/render-new-session.test.tsx +++ b/tests/render-new-session.test.tsx @@ -74,8 +74,11 @@ describe("DraftShell — the sessionless surface", () => { ); // The chat composer, ready for the first message. assert.ok(html.includes('data-testid="composer"')); - // The pre-chat model control (its empty-chatId defer branch lifts here). + // The pre-chat model control (fix wave: the strong-slot control — + // app-wide, no per-chat write, no empty-chatId defer). assert.ok(html.includes('data-testid="stats-model"')); + // No CTA by default: a configured surface stays quiet. + assert.ok(!html.includes('data-testid="setup-cta"')); // The same empty state a fresh chat shows. assert.ok(html.includes("The teacher will open by probing what you already know.")); // The pointer-events pattern (953b22b): the wrapper stays inert… @@ -84,6 +87,30 @@ describe("DraftShell — the sessionless surface", () => { assert.ok(/rounded-2xl[^>]*pointer-events-auto|pointer-events-auto[^>]*rounded-2xl/.test(html)); }); + it("renders the setup CTA above the composer blob when passed (fix wave chunk B: /chat/new mirrors home)", () => { + const html = renderToStaticMarkup( + {}} + onSend={() => {}} + settings={undefined} + onSettingsSaved={() => {}} + setupCta={ + + } + />, + ); + // The same affordance home carries, above the draft shell's blob. + assert.ok(html.includes('data-testid="setup-cta"')); + // Above the composer: the CTA's markup precedes the blob's. + const cta = html.indexOf('data-testid="setup-cta"'); + const blob = html.search(/rounded-2xl[^>]*pointer-events-auto|pointer-events-auto[^>]*rounded-2xl/); + assert.ok(cta !== -1 && blob !== -1 && cta < blob, "CTA above the blob"); + }); + it("renders the knowledge-context slider in the stats bar's left slot, at the decided default (absent settings = on, 1024)", () => { const html = renderToStaticMarkup(