diff --git a/frontend/features/chat/components/sections/chat-model-picker-layout.test.mjs b/frontend/features/chat/components/sections/chat-model-picker-layout.test.mjs new file mode 100644 index 00000000..1df56915 --- /dev/null +++ b/frontend/features/chat/components/sections/chat-model-picker-layout.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + resolveDesktopMenuListMaxHeight, + resolveDesktopModelMenuListMaxHeight, +} from "./chat-model-picker-layout.ts"; + +test("limits the list to the space remaining after panel chrome", () => { + assert.equal( + resolveDesktopModelMenuListMaxHeight({ + viewportTop: 24, + viewportBottom: 144, + sideOffset: 8, + verticalChrome: 40, + }), + 80, + ); +}); + +test("uses all normal viewport space remaining after panel chrome", () => { + assert.equal( + resolveDesktopModelMenuListMaxHeight({ + viewportTop: 24, + viewportBottom: 424, + sideOffset: 8, + verticalChrome: 40, + }), + 360, + ); +}); + +test("limits a positioned submenu to its remaining viewport space", () => { + assert.equal(resolveDesktopMenuListMaxHeight(72, 12), 60); +}); + +test("uses the larger space below the trigger and deducts the side offset", () => { + assert.equal( + resolveDesktopModelMenuListMaxHeight({ + viewportTop: 24, + viewportBottom: 424, + triggerTop: 96, + triggerBottom: 124, + sideOffset: 8, + verticalChrome: 40, + }), + 252, + ); +}); + +test("uses the larger space above the trigger", () => { + assert.equal( + resolveDesktopModelMenuListMaxHeight({ + viewportTop: 24, + viewportBottom: 424, + triggerTop: 324, + triggerBottom: 352, + sideOffset: 8, + verticalChrome: 40, + }), + 252, + ); +}); + +test("returns zero when panel chrome consumes all available space", () => { + assert.equal( + resolveDesktopModelMenuListMaxHeight({ + viewportTop: 24, + viewportBottom: 64, + sideOffset: 8, + verticalChrome: 40, + }), + 0, + ); +}); diff --git a/frontend/features/chat/components/sections/chat-model-picker-layout.ts b/frontend/features/chat/components/sections/chat-model-picker-layout.ts new file mode 100644 index 00000000..409ba388 --- /dev/null +++ b/frontend/features/chat/components/sections/chat-model-picker-layout.ts @@ -0,0 +1,40 @@ +export function resolveDesktopMenuListMaxHeight(maxPanelHeight: number, verticalChrome: number): number { + return Math.max(0, maxPanelHeight - verticalChrome); +} + +/** + * Vendor/model list max-height for the desktop picker. + * + * Must be derived from the trigger's free space (and a hard viewport cap), + * never from the floating panel's current top. Using the floating top creates + * a loop: collision-shift pushes the panel off-screen → maxHeight expands to + * "full remaining viewport" → the top edge stays clipped while the inner list + * still scrolls. + */ +export function resolveDesktopModelMenuListMaxHeight(input: { + viewportTop: number; + viewportBottom: number; + triggerTop?: number | null; + triggerBottom?: number | null; + sideOffset: number; + verticalChrome: number; +}): number { + const viewportHeight = Math.max(0, input.viewportBottom - input.viewportTop); + + const hasTrigger = + typeof input.triggerTop === "number" && + Number.isFinite(input.triggerTop) && + typeof input.triggerBottom === "number" && + Number.isFinite(input.triggerBottom); + + const spaceBelowTrigger = hasTrigger + ? input.viewportBottom - (input.triggerBottom as number) - input.sideOffset + : viewportHeight; + const spaceAboveTrigger = hasTrigger + ? (input.triggerTop as number) - input.viewportTop - input.sideOffset + : viewportHeight; + const preferredSideSpace = Math.max(spaceBelowTrigger, spaceAboveTrigger, 0); + const maxPanelHeight = Math.min(viewportHeight, preferredSideSpace || viewportHeight); + + return resolveDesktopMenuListMaxHeight(maxPanelHeight, input.verticalChrome); +} diff --git a/frontend/features/chat/components/sections/chat-model-picker.tsx b/frontend/features/chat/components/sections/chat-model-picker.tsx index b3f433ae..23683b12 100644 --- a/frontend/features/chat/components/sections/chat-model-picker.tsx +++ b/frontend/features/chat/components/sections/chat-model-picker.tsx @@ -9,6 +9,10 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { InputGroupButton } from "@/components/ui/input-group"; import type { ChatModelOption } from "@/features/chat/types/chat-runtime"; +import { + resolveDesktopMenuListMaxHeight, + resolveDesktopModelMenuListMaxHeight, +} from "./chat-model-picker-layout"; import { useIsMobile } from "@/shared/hooks/use-mobile"; import { LobeHubIcon } from "@/shared/components/lobehub-icon"; import { @@ -35,8 +39,11 @@ type ChatModelPickerProps = { const MODEL_MENU_COLLISION_PADDING = 24; const DESKTOP_MODEL_MENU_WIDTH = 224; const DESKTOP_MODEL_SUBMENU_GAP = 8; +const DESKTOP_MODEL_MENU_SIDE_OFFSET = 8; const DESKTOP_MODEL_MENU_MIN_SCROLL_HEIGHT = 96; +/** Vendor panel non-list chrome: p-1.5 × 2 + header h-7. */ const DESKTOP_VENDOR_MENU_VERTICAL_CHROME = 40; +/** Model submenu non-list chrome: p-1.5 × 2. */ const DESKTOP_SUBMENU_VERTICAL_CHROME = 12; function resolveVendorGroups(modelOptions: ChatModelOption[]) { @@ -539,7 +546,7 @@ export function ChatModelPicker({ }, [isMobile, open]); const updateDesktopSubmenuMetrics = React.useCallback(() => { - if (!open || isMobile || !hasDesktopModelSubmenu) { + if (!open || isMobile) { setDesktopSubmenuSide("right"); setDesktopSubmenuTop(0); setDesktopSubmenuWidth(DESKTOP_MODEL_MENU_WIDTH); @@ -550,23 +557,24 @@ export function ChatModelPicker({ const menuRoot = desktopMenuRootRef.current; const vendorMenu = desktopVendorMenuRef.current; - const submenu = desktopSubmenuRef.current; - const activeVendorButton = activeDesktopVendorGroup - ? desktopVendorItemRefs.current.get(activeDesktopVendorGroup.vendor) - : null; - if (!menuRoot || !vendorMenu || !activeVendorButton) { + if (!menuRoot || !vendorMenu) { return; } const menuRootRect = menuRoot.getBoundingClientRect(); const vendorMenuRect = vendorMenu.getBoundingClientRect(); + const submenu = desktopSubmenuRef.current; const submenuRect = submenu?.getBoundingClientRect(); - const activeVendorRect = activeVendorButton.getBoundingClientRect(); + const activeVendorButton = activeDesktopVendorGroup + ? desktopVendorItemRefs.current.get(activeDesktopVendorGroup.vendor) + : null; + const activeVendorRect = activeVendorButton?.getBoundingClientRect(); + const triggerRect = document.getElementById("chat-model-menu-trigger")?.getBoundingClientRect(); const viewportLeft = MODEL_MENU_COLLISION_PADDING; const viewportRight = window.innerWidth - MODEL_MENU_COLLISION_PADDING; const viewportTop = MODEL_MENU_COLLISION_PADDING; const viewportBottom = window.innerHeight - MODEL_MENU_COLLISION_PADDING; - const viewportHeight = Math.max(DESKTOP_MODEL_MENU_MIN_SCROLL_HEIGHT, viewportBottom - viewportTop); + const viewportHeight = Math.max(0, viewportBottom - viewportTop); const rightAvailableWidth = Math.max(0, viewportRight - vendorMenuRect.right - DESKTOP_MODEL_SUBMENU_GAP); const leftAvailableWidth = Math.max(0, vendorMenuRect.left - viewportLeft - DESKTOP_MODEL_SUBMENU_GAP); const nextSubmenuSide = @@ -580,20 +588,35 @@ export function ChatModelPicker({ nextSubmenuSide === "right" ? rightAvailableWidth : leftAvailableWidth, ), ); - const nextVendorListMaxHeight = Math.max( - DESKTOP_MODEL_MENU_MIN_SCROLL_HEIGHT, - viewportBottom - Math.max(vendorMenuRect.top, viewportTop) - DESKTOP_VENDOR_MENU_VERTICAL_CHROME, - ); - const submenuHeight = submenuRect?.height ?? 320; - const submenuOuterHeight = Math.min(submenuHeight, viewportHeight); - const maxViewportTop = Math.max(viewportTop, viewportBottom - submenuOuterHeight); - const viewportAlignedTop = Math.min(Math.max(activeVendorRect.top, viewportTop), maxViewportTop); - const nextSubmenuTop = Math.max(0, viewportAlignedTop - menuRootRect.top); - const actualSubmenuViewportTop = menuRootRect.top + nextSubmenuTop; - const nextSubmenuListMaxHeight = Math.max( - DESKTOP_MODEL_MENU_MIN_SCROLL_HEIGHT, - viewportBottom - actualSubmenuViewportTop - DESKTOP_SUBMENU_VERTICAL_CHROME, - ); + const nextVendorListMaxHeight = resolveDesktopModelMenuListMaxHeight({ + viewportTop, + viewportBottom, + triggerTop: triggerRect?.top, + triggerBottom: triggerRect?.bottom, + sideOffset: DESKTOP_MODEL_MENU_SIDE_OFFSET, + verticalChrome: DESKTOP_VENDOR_MENU_VERTICAL_CHROME, + }); + + let nextSubmenuTop = 0; + let nextSubmenuListMaxHeight = nextVendorListMaxHeight; + if (hasDesktopModelSubmenu && activeVendorRect) { + const submenuHeight = submenuRect?.height ?? nextVendorListMaxHeight + DESKTOP_SUBMENU_VERTICAL_CHROME; + const submenuOuterHeight = Math.min(submenuHeight, viewportHeight); + const maxViewportTop = Math.max(viewportTop, viewportBottom - submenuOuterHeight); + // Anchor in viewport coordinates, then convert to an offset within + // menuRoot (which may sit above viewportTop for a frame before re-shift). + const preferredSubmenuViewportTop = Math.min( + Math.max(activeVendorRect.top, viewportTop), + maxViewportTop, + ); + nextSubmenuTop = preferredSubmenuViewportTop - menuRootRect.top; + const actualSubmenuViewportTop = menuRootRect.top + nextSubmenuTop; + nextSubmenuListMaxHeight = resolveDesktopMenuListMaxHeight( + Math.min(viewportHeight, viewportBottom - actualSubmenuViewportTop), + DESKTOP_SUBMENU_VERTICAL_CHROME, + ); + } + setDesktopSubmenuSide(nextSubmenuSide); setDesktopSubmenuTop(nextSubmenuTop); setDesktopSubmenuWidth(nextSubmenuWidth); @@ -604,7 +627,7 @@ export function ChatModelPicker({ React.useLayoutEffect(() => { updateDesktopSubmenuMetrics(); - if (!open || isMobile || !hasDesktopModelSubmenu) { + if (!open || isMobile) { return; } @@ -697,7 +720,7 @@ export function ChatModelPicker({ { if (!isMobile && selectedModelButtonRef.current) { @@ -795,7 +818,7 @@ export function ChatModelPicker({ width: desktopSubmenuWidth, } as React.CSSProperties} className={cn( - "absolute rounded-xl border-[0.5px] border-border bg-popover p-1.5 shadow-xs", + "absolute flex max-h-[calc(100dvh-3rem)] flex-col overflow-hidden rounded-xl border-[0.5px] border-border bg-popover p-1.5 shadow-xs", desktopSubmenuSide === "right" ? "left-[calc(100%+0.5rem)]" : "right-[calc(100%+0.5rem)]", )} > @@ -822,8 +845,11 @@ export function ChatModelPicker({ ) : null} -
-
+
+
{t("vendor")} {selectedVendorLabel} @@ -834,43 +860,45 @@ export function ChatModelPicker({ {t("empty")}
) : ( - -
- {vendorGroups.map((group) => { - const selectedVendor = group.vendor === selectedVendorKey; - const activeVendor = group.vendor === activeDesktopVendorGroup?.vendor; - const vendorIconURL = resolveLobeHubIconURL(group.icon); - return ( - - ); - })} -
-
+
+ +
+ {vendorGroups.map((group) => { + const selectedVendor = group.vendor === selectedVendorKey; + const activeVendor = group.vendor === activeDesktopVendorGroup?.vendor; + const vendorIconURL = resolveLobeHubIconURL(group.icon); + return ( + + ); + })} +
+
+
)}
diff --git a/frontend/package.json b/frontend/package.json index d75894a6..f62f19de 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "analyze:output": "next experimental-analyze --output", "start": "next start", "typecheck": "tsc --noEmit --pretty false", + "test": "node --no-warnings --test features/chat/components/sections/chat-model-picker-layout.test.mjs", "check": "pnpm lint && pnpm typecheck", "lint": "biome lint .", "lint:fix": "biome lint --write .",