From 9235c83eb7f16c321ad02aa4cc502fbe0be252c1 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:19:58 +0200 Subject: [PATCH] fix(web): keep the composer command menu anchored to the composer (#5336) --- 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 c26f52cbc0c31ae462aab84f5d994f8ac92326a0..201fb79c566967469048abf556d57cfb413476d4 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(); -- 2.54.0