diff --git a/components/ChatInput.test.mjs b/components/ChatInput.test.mjs index 8eeabee8a..28b46a0b1 100644 --- a/components/ChatInput.test.mjs +++ b/components/ChatInput.test.mjs @@ -301,6 +301,22 @@ test("renders the shared field model selector as a disabled gray control", () => test("caps an upward menu to the visible space above its anchor", () => { assert.equal(getUpwardMenuMaxHeight(343, 36), 299); assert.equal(getUpwardMenuMaxHeight(40, 36), 0); + // Composer sitting under a 36px top bar with only ~160px of air: a 400px + // file list would paint through the bar and hide the leading matches. + assert.equal(getUpwardMenuMaxHeight(200, 36), 156); + assert.ok(getUpwardMenuMaxHeight(200, 36) < 400); +}); + +test("file mention menu applies the measured upward height cap", () => { + const source = readFileSync(new URL("./ChatInput.tsx", import.meta.url), "utf8"); + const start = source.indexOf("{atMenuOpen && atQuery !== null && (() => {"); + assert.notEqual(start, -1); + const block = source.slice(start, start + 4000); + assert.match(block, /ref=\{atMenuRef\}/); + assert.match(block, /min\(48vh, 400px, \$\{atMenuMaxHeight\}px\)/); + assert.match(block, /flexDirection: "column"/); + assert.match(block, /minHeight: 0/); + assert.equal(block.includes("maxHeight: \"min(48vh, 400px)\""), false); }); test("compresses large images while preserving small images and GIFs", async () => { diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 7871b02a5..bf65c63e4 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -147,6 +147,45 @@ function getVisibleTopBoundary(element: HTMLElement): number { return visibleTop; } +function subscribeUpwardMenuMaxHeight( + menu: HTMLElement, + onChange: (height: number) => void, +): () => void { + let frameId: number | null = null; + const update = () => { + frameId = null; + onChange(getUpwardMenuMaxHeight( + menu.getBoundingClientRect().bottom, + getVisibleTopBoundary(menu), + )); + }; + const scheduleUpdate = () => { + if (frameId !== null) cancelAnimationFrame(frameId); + frameId = requestAnimationFrame(update); + }; + + update(); + const parent = menu.parentElement; + const anchorObserver = typeof ResizeObserver === "undefined" || !parent + ? null + : new ResizeObserver(scheduleUpdate); + if (parent) anchorObserver?.observe(parent); + const viewport = window.visualViewport; + viewport?.addEventListener("resize", scheduleUpdate); + viewport?.addEventListener("scroll", scheduleUpdate); + window.addEventListener("resize", scheduleUpdate); + window.addEventListener("scroll", scheduleUpdate, true); + + return () => { + anchorObserver?.disconnect(); + viewport?.removeEventListener("resize", scheduleUpdate); + viewport?.removeEventListener("scroll", scheduleUpdate); + window.removeEventListener("resize", scheduleUpdate); + window.removeEventListener("scroll", scheduleUpdate, true); + if (frameId !== null) cancelAnimationFrame(frameId); + }; +} + const THINKING_LEVELS = ["auto", "off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; const THINKING_LEVEL_DESC_KEYS: Record = { auto: "chat.thinkingUseDefault", off: "chat.thinkingOff", minimal: "chat.thinkingMinimal", low: "chat.thinkingLow", @@ -527,6 +566,7 @@ export const ChatInput = forwardRef(function ChatInput({ const [slashMenuMaxHeight, setSlashMenuMaxHeight] = useState(null); const [atQuery, setAtQuery] = useState(null); const [atMenuOpen, setAtMenuOpen] = useState(false); + const [atMenuMaxHeight, setAtMenuMaxHeight] = useState(null); const [atActiveIndex, setAtActiveIndex] = useState(0); const [imageWarningDismissed, setImageWarningDismissed] = useState(false); const [historyMenuOpen, setHistoryMenuOpen] = useState(false); @@ -553,6 +593,7 @@ export const ChatInput = forwardRef(function ChatInput({ const slashCommandsRequestedRef = useRef(false); const slashMenuRef = useRef(null); const slashItemRefs = useRef>([]); + const atMenuRef = useRef(null); const atItemRefs = useRef>([]); const historyItemRefs = useRef>([]); const fileIndexMetaRef = useRef<{ cwd: string; fetchedAt: number } | null>(null); @@ -1430,45 +1471,25 @@ export const ChatInput = forwardRef(function ChatInput({ setSlashMenuMaxHeight(null); return; } - const menu = slashMenuRef.current; if (!menu) return; - - let frameId: number | null = null; - const update = () => { - frameId = null; - const nextHeight = getUpwardMenuMaxHeight( - menu.getBoundingClientRect().bottom, - getVisibleTopBoundary(menu), - ); + return subscribeUpwardMenuMaxHeight(menu, (nextHeight) => { setSlashMenuMaxHeight((current) => current === nextHeight ? current : nextHeight); - }; - const scheduleUpdate = () => { - if (frameId !== null) cancelAnimationFrame(frameId); - frameId = requestAnimationFrame(update); - }; - - update(); - const anchorObserver = typeof ResizeObserver === "undefined" || !menu.parentElement - ? null - : new ResizeObserver(scheduleUpdate); - if (menu.parentElement) anchorObserver?.observe(menu.parentElement); - const viewport = window.visualViewport; - viewport?.addEventListener("resize", scheduleUpdate); - viewport?.addEventListener("scroll", scheduleUpdate); - window.addEventListener("resize", scheduleUpdate); - window.addEventListener("scroll", scheduleUpdate, true); - - return () => { - anchorObserver?.disconnect(); - viewport?.removeEventListener("resize", scheduleUpdate); - viewport?.removeEventListener("scroll", scheduleUpdate); - window.removeEventListener("resize", scheduleUpdate); - window.removeEventListener("scroll", scheduleUpdate, true); - if (frameId !== null) cancelAnimationFrame(frameId); - }; + }); }, [slashMenuOpen, slashQuery]); + useLayoutEffect(() => { + if (!atMenuOpen || atQuery === null) { + setAtMenuMaxHeight(null); + return; + } + const menu = atMenuRef.current; + if (!menu) return; + return subscribeUpwardMenuMaxHeight(menu, (nextHeight) => { + setAtMenuMaxHeight((current) => current === nextHeight ? current : nextHeight); + }); + }, [atMenuOpen, atQuery]); + // Build model options: prefer modelList (has provider info), fallback to modelNames const modelOptions: ModelSelectorOption[] = (() => { if (modelList && modelList.length > 0) { @@ -1953,6 +1974,7 @@ export const ChatInput = forwardRef(function ChatInput({ : ""; return (
(function ChatInput({ borderRadius: 8, boxShadow: "0 -6px 20px rgba(0,0,0,0.12)", overflow: "hidden", - maxHeight: "min(48vh, 400px)", + boxSizing: "border-box", + display: "flex", + flexDirection: "column", + maxHeight: atMenuMaxHeight === null + ? "min(48vh, 400px)" + : `min(48vh, 400px, ${atMenuMaxHeight}px)`, }} >
(function ChatInput({ gap: 8, fontSize: 11, color: "var(--text-dim)", + flexShrink: 0, }} > @@ -1986,7 +2014,7 @@ export const ChatInput = forwardRef(function ChatInput({ {t("chat.tabEnter")}
-
+
{!indexLoading && atMatches.length === 0 ? (
{needsServerSearch && !serverResultInUse ? t("chat.searching") : t("chat.noMatchingFiles")}