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 { prettyModel } from "@/components/usage-format";
|
||||||
import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-menu";
|
import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-menu";
|
||||||
import { composeSlotModel } from "@/components/settings/agent-section";
|
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
|
/** 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
|
* 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
|
settings' slot selects and the setup dialog use the exact same
|
||||||
one. */}
|
one. */}
|
||||||
{open === "model" && (
|
{open === "model" && (
|
||||||
<div
|
<MenuShell
|
||||||
data-testid="model-menu"
|
anchorRef={containerRef}
|
||||||
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"
|
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>}
|
{error && <div className="mb-1.5 text-destructive">{error}</div>}
|
||||||
<ModelMenuList
|
<ModelMenuList
|
||||||
@@ -268,13 +271,15 @@ export function ModelControl() {
|
|||||||
: "Strong tier — runs prober + teacher, app-wide"
|
: "Strong tier — runs prober + teacher, app-wide"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</MenuShell>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{open === "thinking" && (
|
{open === "thinking" && (
|
||||||
<div
|
<MenuShell
|
||||||
data-testid="thinking-menu"
|
anchorRef={containerRef}
|
||||||
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"
|
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
|
<ThinkingMenuList
|
||||||
levels={catalog ? levels : []}
|
levels={catalog ? levels : []}
|
||||||
@@ -286,7 +291,7 @@ export function ModelControl() {
|
|||||||
footer="Levels this model actually supports"
|
footer="Levels this model actually supports"
|
||||||
/>
|
/>
|
||||||
{error && <div className="mt-1 text-destructive">{error}</div>}
|
{error && <div className="mt-1 text-destructive">{error}</div>}
|
||||||
</div>
|
</MenuShell>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState, type ReactNode } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation";
|
import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||||
import { Composer } from "@/components/composer";
|
import { Composer } from "@/components/composer";
|
||||||
@@ -69,6 +69,7 @@ export function DraftShell({
|
|||||||
onSend,
|
onSend,
|
||||||
settings,
|
settings,
|
||||||
onSettingsSaved,
|
onSettingsSaved,
|
||||||
|
setupCta,
|
||||||
}: {
|
}: {
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -76,6 +77,10 @@ export function DraftShell({
|
|||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
settings: ChatSettings | undefined;
|
settings: ChatSettings | undefined;
|
||||||
onSettingsSaved: (s: ChatSettings) => void;
|
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;
|
const canSend = text.trim().length > 0;
|
||||||
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
|
// 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="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
|
||||||
<div className="bg-background pb-4">
|
<div className="bg-background pb-4">
|
||||||
<div className={DOCK}>
|
<div className={DOCK}>
|
||||||
|
{setupCta}
|
||||||
<div className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
|
<div className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
|
||||||
<Composer
|
<Composer
|
||||||
busy={busy}
|
busy={busy}
|
||||||
@@ -197,6 +203,20 @@ export function NewSession({
|
|||||||
onSend={send}
|
onSend={send}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
onSettingsSaved={saveDraft}
|
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. */}
|
{/* First-run setup: the send interception's landing surface. */}
|
||||||
<SetupDialog
|
<SetupDialog
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
MODEL_SLOTS,
|
MODEL_SLOTS,
|
||||||
@@ -206,54 +206,11 @@ function MenuTrigger({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The opened dropdown, PORTALED to document.body with fixed positioning:
|
/* The section's dropdowns all mount the shared MenuShell (fix wave chunk
|
||||||
* the dialog's overflow frame must not clip it — the owner wants the
|
* B): portaled to document.body, fixed-anchored to the open trigger by
|
||||||
* menus flowing over the whole screen, outside the settings surface
|
* testid (the section's one-ref pattern), opening DOWN with the shell's
|
||||||
* (2026-09-20). Right-aligned to its trigger, repositioned while the
|
* viewport cap and auto-flip. data-lh-menu rides the shell — the
|
||||||
* dialog content scrolls under it. */
|
* outside-click contract's exemption mark. */
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentSection({
|
export function AgentSection({
|
||||||
agent,
|
agent,
|
||||||
@@ -566,8 +523,9 @@ export function AgentSection({
|
|||||||
widthClass="w-20"
|
widthClass="w-20"
|
||||||
/>
|
/>
|
||||||
{thisMenuOpen === "model" && (
|
{thisMenuOpen === "model" && (
|
||||||
<AnchoredMenu
|
<MenuShell
|
||||||
anchorTestid={`slot-model-${slot}`}
|
preferredSide="down"
|
||||||
|
anchorTestid={`slot-model-${slot}`}
|
||||||
testid={`slot-model-menu-${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"
|
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"
|
: "Runs every agent of this tier"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</AnchoredMenu>
|
</MenuShell>
|
||||||
)}
|
)}
|
||||||
{thisMenuOpen === "thinking" && (
|
{thisMenuOpen === "thinking" && (
|
||||||
<AnchoredMenu
|
<MenuShell
|
||||||
anchorTestid={`slot-thinking-${slot}`}
|
preferredSide="down"
|
||||||
|
anchorTestid={`slot-thinking-${slot}`}
|
||||||
testid={`slot-thinking-menu-${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"
|
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);
|
setOpenMenu(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</AnchoredMenu>
|
</MenuShell>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
@@ -662,8 +621,9 @@ export function AgentSection({
|
|||||||
disabled={!anyKey}
|
disabled={!anyKey}
|
||||||
/>
|
/>
|
||||||
{open && (
|
{open && (
|
||||||
<AnchoredMenu
|
<MenuShell
|
||||||
anchorTestid="search-engine-trigger"
|
preferredSide="down"
|
||||||
|
anchorTestid="search-engine-trigger"
|
||||||
testid="search-engine-menu"
|
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"
|
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);
|
setOpenMenu(null);
|
||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
</AnchoredMenu>
|
</MenuShell>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|||||||
@@ -24,10 +24,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
|
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
|
||||||
import { SettingsGroup } from "./controls.tsx";
|
import { SettingsGroup } from "./controls.tsx";
|
||||||
import { ProviderMenuList } from "./model-menu.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 —
|
/** One pi-ai provider with plain API-key auth (the Platform seam —
|
||||||
* derived from pi-ai's own env-key table; no drift possible). */
|
* derived from pi-ai's own env-key table; no drift possible). */
|
||||||
@@ -250,11 +250,6 @@ function AddProviderRow({
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [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 (
|
return (
|
||||||
<div ref={anchorRef} className="relative">
|
<div ref={anchorRef} className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -267,16 +262,15 @@ function AddProviderRow({
|
|||||||
<span>+ Add provider key</span>
|
<span>+ Add provider key</span>
|
||||||
{addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
|
{addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
|
||||||
</button>
|
</button>
|
||||||
{open && style !== null && createPortal(
|
{open && (
|
||||||
<div
|
<MenuShell
|
||||||
data-testid="add-provider-menu"
|
anchorRef={anchorRef}
|
||||||
data-lh-menu=""
|
testid="add-provider-menu"
|
||||||
style={style}
|
preferredSide="down"
|
||||||
className="flex w-72 flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
menuClass="flex w-72 flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
||||||
>
|
>
|
||||||
<ProviderMenuList providers={addable} onPick={onPick} />
|
<ProviderMenuList providers={addable} onPick={onPick} />
|
||||||
</div>,
|
</MenuShell>
|
||||||
document.body,
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,12 +29,13 @@
|
|||||||
* CMU title, z-50.
|
* CMU title, z-50.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings } from "@/src/core";
|
import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings } from "@/src/core";
|
||||||
import { prettyModel } from "@/components/usage-format";
|
import { prettyModel } from "@/components/usage-format";
|
||||||
import { ModelMenuList } from "@/components/settings/model-menu";
|
import { ModelMenuList } from "@/components/settings/model-menu";
|
||||||
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
|
|
||||||
/** The pickers' slot identity — same roster language as the Agent
|
/** The pickers' slot identity — same roster language as the Agent
|
||||||
* section's slot rows. */
|
* section's slot rows. */
|
||||||
@@ -80,6 +81,11 @@ export function SetupDialog({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [openSlot, setOpenSlot] = useState<ModelSlotName | 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 →
|
// Fresh /api/models per open — a key saved a moment ago (stage 1 →
|
||||||
// Keys → back) widens the pickers immediately, no restart.
|
// 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">
|
<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="font-ui text-sm text-foreground">{SLOT_META[slot].label}</span>
|
||||||
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`${SLOT_META[slot].label} model`}
|
aria-label={`${SLOT_META[slot].label} model`}
|
||||||
@@ -219,10 +225,12 @@ export function SetupDialog({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{openSlot === slot && (
|
{openSlot === slot && (
|
||||||
<div
|
<MenuShell
|
||||||
data-testid={`setup-menu-${slot}`}
|
anchorRef={triggerRef}
|
||||||
data-lh-menu=""
|
testid={`setup-menu-${slot}`}
|
||||||
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"
|
preferredSide="down"
|
||||||
|
align="left"
|
||||||
|
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
||||||
>
|
>
|
||||||
<ModelMenuList
|
<ModelMenuList
|
||||||
models={catalog}
|
models={catalog}
|
||||||
@@ -234,7 +242,7 @@ export function SetupDialog({
|
|||||||
}}
|
}}
|
||||||
footer={catalog && catalog.length === 0 ? "No image-capable models — widen the provider keys" : undefined}
|
footer={catalog && catalog.length === 0 ? "No image-capable models — widen the provider keys" : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</MenuShell>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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.
|
// The chat composer, ready for the first message.
|
||||||
assert.ok(html.includes('data-testid="composer"'));
|
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"'));
|
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.
|
// The same empty state a fresh chat shows.
|
||||||
assert.ok(html.includes("The teacher will open by probing what you already know."));
|
assert.ok(html.includes("The teacher will open by probing what you already know."));
|
||||||
// The pointer-events pattern (953b22b): the wrapper stays inert…
|
// 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));
|
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)", () => {
|
it("renders the knowledge-context slider in the stats bar's left slot, at the decided default (absent settings = on, 1024)", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<DraftShell
|
<DraftShell
|
||||||
|
|||||||
Reference in New Issue
Block a user