From 61e6ff2ab28b724de673c151519f4665104eb363 Mon Sep 17 00:00:00 2001 From: viettranx Date: Wed, 11 Mar 2026 16:54:33 +0700 Subject: [PATCH] fix(ui): portal all custom dropdowns to document.body Custom dropdowns using absolute positioning were clipped by parent overflow containers (dialogs, scrollable sections). Fixed by rendering via createPortal with fixed positioning: - Combobox: default to body portal when no portalContainer prop - ToolNameSelect: add createPortal + fixed positioning - AgentSelector: replace overlay+absolute with portal - ContactInsertSearch: add createPortal + fixed positioning - ContactSearchBox (instances tab): add createPortal + fixed positioning --- ui/web/src/components/chat/agent-selector.tsx | 104 ++++++++++++------ .../components/shared/tool-name-select.tsx | 35 +++++- ui/web/src/components/ui/combobox.tsx | 50 +++++---- .../agent-detail/agent-instances-tab.tsx | 38 +++++-- .../file-sections/contact-insert-search.tsx | 38 +++++-- 5 files changed, 189 insertions(+), 76 deletions(-) diff --git a/ui/web/src/components/chat/agent-selector.tsx b/ui/web/src/components/chat/agent-selector.tsx index f2d33db4..00ff2286 100644 --- a/ui/web/src/components/chat/agent-selector.tsx +++ b/ui/web/src/components/chat/agent-selector.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useLayoutEffect } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Bot, ChevronDown } from "lucide-react"; import { useHttp } from "@/hooks/use-ws"; @@ -16,6 +17,9 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) { const connected = useAuthStore((s) => s.connected); const [agents, setAgents] = useState([]); const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const dropdownRef = useRef(null); + const [dropdownStyle, setDropdownStyle] = useState({}); useEffect(() => { if (!connected) return; @@ -28,10 +32,39 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) { .catch(() => {}); }, [http, connected]); + // Compute dropdown position for portal rendering + useLayoutEffect(() => { + if (!open || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + setDropdownStyle({ + position: "fixed", + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + zIndex: 9999, + }); + }, [open]); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + const target = e.target as Node; + if ( + containerRef.current && !containerRef.current.contains(target) && + (!dropdownRef.current || !dropdownRef.current.contains(target)) + ) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + const selected = agents.find((a) => a.agent_key === value); return ( -
+
- {open && ( - <> -
setOpen(false)} /> -
- {agents.length === 0 && ( -
- {t("noAgentsAvailable")} -
- )} - {agents.map((agent) => ( - - ))} -
- + {open && createPortal( +
+ {agents.length === 0 && ( +
+ {t("noAgentsAvailable")} +
+ )} + {agents.map((agent) => ( + + ))} +
, + document.body, )}
); diff --git a/ui/web/src/components/shared/tool-name-select.tsx b/ui/web/src/components/shared/tool-name-select.tsx index ca558b16..eb44dcf4 100644 --- a/ui/web/src/components/shared/tool-name-select.tsx +++ b/ui/web/src/components/shared/tool-name-select.tsx @@ -1,4 +1,5 @@ -import { useMemo, useState, useRef, useEffect } from "react"; +import { useMemo, useState, useRef, useEffect, useLayoutEffect } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { X, ChevronDownIcon } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -31,6 +32,8 @@ export function ToolNameSelect({ const [search, setSearch] = useState(""); const containerRef = useRef(null); const inputRef = useRef(null); + const dropdownRef = useRef(null); + const [dropdownStyle, setDropdownStyle] = useState({}); const allTools = useMemo(() => { const builtin: ToolOption[] = builtinTools.map((t) => ({ @@ -59,11 +62,28 @@ export function ToolNameSelect({ return { builtin: builtinGroup, custom: customGroup }; }, [filtered]); + // Compute dropdown position for portal rendering + useLayoutEffect(() => { + if (!open || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + setDropdownStyle({ + position: "fixed", + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + zIndex: 9999, + }); + }, [open, search]); + // Close on outside click useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + const target = e.target as Node; + if ( + containerRef.current && !containerRef.current.contains(target) && + (!dropdownRef.current || !dropdownRef.current.contains(target)) + ) { setOpen(false); } }; @@ -137,8 +157,12 @@ export function ToolNameSelect({ onClick={() => setOpen(!open)} />
- {open && (grouped.builtin.length > 0 || grouped.custom.length > 0) && ( -
+ {open && (grouped.builtin.length > 0 || grouped.custom.length > 0) && createPortal( +
{grouped.builtin.length > 0 && ( <>
@@ -176,7 +200,8 @@ export function ToolNameSelect({ ))} )} -
+
, + document.body, )}
); diff --git a/ui/web/src/components/ui/combobox.tsx b/ui/web/src/components/ui/combobox.tsx index 50806805..18723f1e 100644 --- a/ui/web/src/components/ui/combobox.tsx +++ b/ui/web/src/components/ui/combobox.tsx @@ -54,21 +54,31 @@ export function Combobox({ return () => document.removeEventListener("mousedown", handler); }, [open]); - // Compute dropdown position for portal mode + // Compute dropdown position — always use fixed positioning for portal rendering React.useLayoutEffect(() => { - if (!open || !portalContainer?.current || !containerRef.current) return; + if (!open || !containerRef.current) return; const inputRect = containerRef.current.getBoundingClientRect(); - const portalRect = portalContainer.current.getBoundingClientRect(); - const left = inputRect.left - portalRect.left; - const maxWidth = portalRect.width - left; - setDropdownStyle({ - position: "absolute", - top: inputRect.bottom - portalRect.top + 4, - left, - width: inputRect.width, - maxWidth, - zIndex: 50, - }); + if (portalContainer?.current) { + const portalRect = portalContainer.current.getBoundingClientRect(); + const left = inputRect.left - portalRect.left; + const maxWidth = portalRect.width - left; + setDropdownStyle({ + position: "absolute", + top: inputRect.bottom - portalRect.top + 4, + left, + width: inputRect.width, + maxWidth, + zIndex: 50, + }); + } else { + setDropdownStyle({ + position: "fixed", + top: inputRect.bottom + 4, + left: inputRect.left, + width: inputRect.width, + zIndex: 9999, + }); + } }, [open, search, portalContainer]); const filtered = React.useMemo(() => { @@ -98,11 +108,8 @@ export function Combobox({ const dropdownContent = open && filtered.length > 0 && (
{filtered.map((o) => (
); } diff --git a/ui/web/src/pages/agents/agent-detail/agent-instances-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-instances-tab.tsx index 0253cd52..682e7fa0 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-instances-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-instances-tab.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect, useMemo, useRef } from "react"; +import { useState, useEffect, useMemo, useRef, useLayoutEffect } from "react"; +import { createPortal } from "react-dom"; import { Save, Check, AlertCircle, Users, FileText, Search, UserPlus } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -157,15 +158,34 @@ function ContactSearchBox({ existingIDs, onSelect }: { existingIDs: Set; const [open, setOpen] = useState(false); const { contacts } = useContactSearch(search); const containerRef = useRef(null); + const dropdownRef = useRef(null); + const [dropdownStyle, setDropdownStyle] = useState({}); // Filter out contacts already in instances const filtered = contacts.filter((c) => !existingIDs.has(c.sender_id)); + // Compute dropdown position for portal rendering + useLayoutEffect(() => { + if (!open || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + setDropdownStyle({ + position: "fixed", + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + zIndex: 9999, + }); + }, [open, search]); + // Close on outside click useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + const target = e.target as Node; + if ( + containerRef.current && !containerRef.current.contains(target) && + (!dropdownRef.current || !dropdownRef.current.contains(target)) + ) { setOpen(false); } }; @@ -185,8 +205,8 @@ function ContactSearchBox({ existingIDs, onSelect }: { existingIDs: Set; className="h-8 w-full rounded-md border bg-transparent pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" />
- {open && search.length >= 2 && filtered.length > 0 && ( -
+ {open && search.length >= 2 && filtered.length > 0 && createPortal( +
{filtered.map((c) => (
))} -
+ , + document.body, )} - {open && search.length >= 2 && filtered.length === 0 && contacts.length === 0 && ( -
+ {open && search.length >= 2 && filtered.length === 0 && contacts.length === 0 && createPortal( +
{t("instances.noContactsFound")} -
+
, + document.body, )} ); diff --git a/ui/web/src/pages/agents/agent-detail/file-sections/contact-insert-search.tsx b/ui/web/src/pages/agents/agent-detail/file-sections/contact-insert-search.tsx index f01963cd..0da352f0 100644 --- a/ui/web/src/pages/agents/agent-detail/file-sections/contact-insert-search.tsx +++ b/ui/web/src/pages/agents/agent-detail/file-sections/contact-insert-search.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useLayoutEffect } from "react"; +import { createPortal } from "react-dom"; import { Search, UserPlus } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; @@ -24,12 +25,31 @@ export function ContactInsertSearch({ onInsert }: ContactInsertSearchProps) { const [open, setOpen] = useState(false); const { contacts } = useContactSearch(search); const containerRef = useRef(null); + const dropdownRef = useRef(null); + const [dropdownStyle, setDropdownStyle] = useState({}); + + // Compute dropdown position for portal rendering + useLayoutEffect(() => { + if (!open || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + setDropdownStyle({ + position: "fixed", + top: rect.bottom + 4, + left: rect.left, + width: Math.min(rect.width, 384), // max-w-sm = 24rem = 384px + zIndex: 9999, + }); + }, [open, search]); // Close on outside click useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + const target = e.target as Node; + if ( + containerRef.current && !containerRef.current.contains(target) && + (!dropdownRef.current || !dropdownRef.current.contains(target)) + ) { setOpen(false); } }; @@ -55,8 +75,8 @@ export function ContactInsertSearch({ onInsert }: ContactInsertSearchProps) { className="h-8 w-full max-w-sm rounded-md border bg-transparent pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" /> - {open && search.length >= 2 && contacts.length > 0 && ( -
+ {open && search.length >= 2 && contacts.length > 0 && createPortal( +
{contacts.map((c) => ( ))} -
+
, + document.body, )} - {open && search.length >= 2 && contacts.length === 0 && ( -
+ {open && search.length >= 2 && contacts.length === 0 && createPortal( +
{t("instances.noContactsFound")} -
+
, + document.body, )} );