diff --git a/admin/inertia/components/chat/CollectionCombobox.tsx b/admin/inertia/components/chat/CollectionCombobox.tsx index a1b0c48..5e603f6 100644 --- a/admin/inertia/components/chat/CollectionCombobox.tsx +++ b/admin/inertia/components/chat/CollectionCombobox.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' interface CollectionComboboxProps { /** Current value. Empty string means "Uncategorized". */ @@ -32,6 +33,8 @@ export default function CollectionCombobox({ const [query, setQuery] = useState(value) const [isOpen, setIsOpen] = useState(false) const containerRef = useRef(null) + const dropdownRef = useRef(null) + const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null) useEffect(() => { setQuery(value) @@ -39,7 +42,12 @@ export default function CollectionCombobox({ useEffect(() => { function handleClickOutside(e: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + const target = e.target as Node + // The list is portaled to , so it is NOT inside containerRef — check both, + // otherwise mousedown on an option closes the list before its click can land. + const insideInput = containerRef.current?.contains(target) + const insideList = dropdownRef.current?.contains(target) + if (!insideInput && !insideList) { setIsOpen(false) setQuery(value) } @@ -48,6 +56,44 @@ export default function CollectionCombobox({ return () => document.removeEventListener('mousedown', handleClickOutside) }, [value]) + /** + * The list renders in a portal with fixed positioning rather than absolutely inside + * the input's own box. Every ancestor that sets `overflow` other than visible would + * otherwise clip it: in the Knowledge Base table each cell carries `truncate` + * (overflow:hidden), which cropped the list to a single row's height, and the modal + * body is an `overflow-y-auto` scroller the list could not escape either. Fixed + * positioning against the viewport sidesteps both. + */ + const reposition = useCallback(() => { + const input = containerRef.current + if (!input) return + const rect = input.getBoundingClientRect() + const MAX_HEIGHT = 224 // matches max-h-56 + const GAP = 4 + const spaceBelow = window.innerHeight - rect.bottom + // Flip above the input when the list would run off the bottom of the viewport + // and there is more headroom up top. + const flipUp = spaceBelow < MAX_HEIGHT + GAP && rect.top > spaceBelow + setPosition({ + left: rect.left, + top: flipUp ? Math.max(GAP, rect.top - GAP - MAX_HEIGHT) : rect.bottom + GAP, + width: rect.width, + }) + }, []) + + useLayoutEffect(() => { + if (!isOpen) return + reposition() + // `true` for capture so scrolling in any ancestor scroller keeps the list attached + // to its input rather than leaving it stranded mid-air. + window.addEventListener('scroll', reposition, true) + window.addEventListener('resize', reposition) + return () => { + window.removeEventListener('scroll', reposition, true) + window.removeEventListener('resize', reposition) + } + }, [isOpen, reposition]) + const normalizedQuery = query.trim().toLowerCase() const filtered = normalizedQuery ? options.filter((o) => o.toLowerCase().includes(normalizedQuery)) @@ -89,8 +135,12 @@ export default function CollectionCombobox({ placeholder={placeholder} className="w-full rounded border border-border-subtle bg-surface-primary px-2 py-1 text-sm text-text-primary disabled:opacity-50" /> - {isOpen && !disabled && ( -
+ {isOpen && !disabled && position && createPortal( +
{allowUncategorized && (
)} -
+ , + document.body )} ) diff --git a/admin/inertia/components/chat/KnowledgeBaseModal.tsx b/admin/inertia/components/chat/KnowledgeBaseModal.tsx index 942fb0a..910ea0a 100644 --- a/admin/inertia/components/chat/KnowledgeBaseModal.tsx +++ b/admin/inertia/components/chat/KnowledgeBaseModal.tsx @@ -453,7 +453,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o return (
-
+

Knowledge Base