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:
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<DraftShell
|
||||
busy={false}
|
||||
text=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
settings={undefined}
|
||||
onSettingsSaved={() => {}}
|
||||
setupCta={
|
||||
<button type="button" data-testid="setup-cta">
|
||||
Set up your models — chats start after the three picks
|
||||
</button>
|
||||
}
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<DraftShell
|
||||
|
||||
Reference in New Issue
Block a user