From 732545724281ecf233a2f85ffadba5b9de78765f Mon Sep 17 00:00:00 2001 From: chriscrosstalk <49691103+chriscrosstalk@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:52:10 -0700 Subject: [PATCH] fix(kb): stop the collection dropdown being clipped, widen the modal (#1198) Two layout defects in the Knowledge Base modal, both found during v1.34.0-rc.4 QA. The collection combobox list was clipped to a single row's height on every row of the table. The cause is not stacking order - StyledTable gives each cell `truncate` (overflow:hidden) plus `relative`, so an absolutely-positioned child cannot escape the cell box. No z-index can win against a clip. The modal body is also an `overflow-y-auto` scroller, so simply opting the cell out of `truncate` still left rows lower down clipped by the scroller instead. Render the list in a portal with fixed positioning instead, measured from the input's rect and flipped above when it would run off the bottom of the viewport. Reposition on scroll and resize (capture listener, so any ancestor scroller counts). The click-outside handler now also checks the portaled list, otherwise mousedown on an option would close it before the click landed. Separately, the modal was capped at max-w-4xl (896px) while its table needs 906px, so the Delete button lost its right edge for everyone regardless of monitor size. max-w-5xl gives the table room with none to spare wasted. Verified against a real build on a test appliance. --- .../components/chat/CollectionCombobox.tsx | 61 +++++++++++++++++-- .../components/chat/KnowledgeBaseModal.tsx | 2 +- 2 files changed, 57 insertions(+), 6 deletions(-) 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