From 4f68017e7fe0d95b3c6aeaad737b4d42fa6b021f Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:56:31 +0200 Subject: [PATCH] fix(web): keep the composer command menu anchored to the composer The `/`, `@`, and `$` menus are portaled to the body and positioned from the anchor's rect, refreshed on window resize, scroll, and a ResizeObserver on the anchor itself. The composer is centered and capped at `max-w-3xl`, so opening the right panel or the terminal drawer slides it without ever resizing it: the observer stays silent and the menu is left behind, floating next to or on top of the composer it belongs to. Observe the anchor's ancestors as well. They are what actually shrink, and they resize on every frame of the panel animation, so the menu tracks the composer through the transition instead of snapping late. Positions are compared before committing to state to keep the extra observations from re-rendering the menu on no-op resizes. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/chat/ChatComposer.tsx | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c26f52cbc0c..201fb79c566 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -108,13 +108,24 @@ import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; +type ComposerCommandMenuPosition = { + bottom: number; + left: number; + maxHeight: number; + width: number; +}; + +function composerCommandMenuPositionsEqual( + a: ComposerCommandMenuPosition, + b: ComposerCommandMenuPosition, +): boolean { + return ( + a.bottom === b.bottom && a.left === b.left && a.maxHeight === b.maxHeight && a.width === b.width + ); +} + function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: ReactNode }) { - const [position, setPosition] = useState<{ - bottom: number; - left: number; - maxHeight: number; - width: number; - } | null>(null); + const [position, setPosition] = useState(null); useLayoutEffect(() => { const anchor = props.anchor; @@ -125,12 +136,15 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: const updatePosition = () => { const rect = anchor.getBoundingClientRect(); - setPosition({ + const next = { bottom: window.innerHeight - rect.top + 8, left: rect.left, maxHeight: Math.max(96, rect.top - 24), width: rect.width, - }); + }; + setPosition((current) => + current && composerCommandMenuPositionsEqual(current, next) ? current : next, + ); }; updatePosition(); @@ -139,7 +153,16 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updatePosition); - observer?.observe(anchor); + if (observer) { + // The composer is centered and capped at a max width, so opening a side + // panel slides it sideways without ever resizing it. Watching the anchor + // alone would leave the menu behind; the ancestors are what shrink, and + // they resize on every frame of the panel animation. + observer.observe(anchor); + for (let element = anchor.parentElement; element; element = element.parentElement) { + observer.observe(element); + } + } return () => { observer?.disconnect();