authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-05 11:19:58+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-05 11:19:58+02:00
log9235c83eb7f16c321ad02aa4cc502fbe0be252c1
tree96662d092b5d6eca3b1704eff23a31bde3655fe8
parente0c85a20ef0ec9b49ade5989a13850493036a49b
signature Signed by PGP key B5690EEEBB952194

fix(web): keep the composer command menu anchored to the composer (#5336)


1 files changed, 32 insertions(+), 9 deletions(-)

apps/web/src/components/chat/ChatComposer.tsx+32-9
...@@ -108,13 +108,24 @@ import { basenameOfPath } from "../../pierre-icons";...@@ -108,13 +108,24 @@ import { basenameOfPath } from "../../pierre-icons";
108import { cn, randomUUID } from "~/lib/utils";108import { cn, randomUUID } from "~/lib/utils";
109import { Separator } from "../ui/separator";109import { Separator } from "../ui/separator";
110110
111type ComposerCommandMenuPosition = {
112 bottom: number;
113 left: number;
114 maxHeight: number;
115 width: number;
116};
117
118function composerCommandMenuPositionsEqual(
119 a: ComposerCommandMenuPosition,
120 b: ComposerCommandMenuPosition,
121): boolean {
122 return (
123 a.bottom === b.bottom && a.left === b.left && a.maxHeight === b.maxHeight && a.width === b.width
124 );
125}
126
111function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: ReactNode }) {127function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: ReactNode }) {
112 const [position, setPosition] = useState<{128 const [position, setPosition] = useState<ComposerCommandMenuPosition | null>(null);
113 bottom: number;
114 left: number;
115 maxHeight: number;
116 width: number;
117 } | null>(null);
118129
119 useLayoutEffect(() => {130 useLayoutEffect(() => {
120 const anchor = props.anchor;131 const anchor = props.anchor;
...@@ -125,12 +136,15 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children:...@@ -125,12 +136,15 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children:
125136
126 const updatePosition = () => {137 const updatePosition = () => {
127 const rect = anchor.getBoundingClientRect();138 const rect = anchor.getBoundingClientRect();
128 setPosition({139 const next = {
129 bottom: window.innerHeight - rect.top + 8,140 bottom: window.innerHeight - rect.top + 8,
130 left: rect.left,141 left: rect.left,
131 maxHeight: Math.max(96, rect.top - 24),142 maxHeight: Math.max(96, rect.top - 24),
132 width: rect.width,143 width: rect.width,
133 });144 };
145 setPosition((current) =>
146 current && composerCommandMenuPositionsEqual(current, next) ? current : next,
147 );
134 };148 };
135149
136 updatePosition();150 updatePosition();
...@@ -139,7 +153,16 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children:...@@ -139,7 +153,16 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children:
139153
140 const observer =154 const observer =
141 typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updatePosition);155 typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updatePosition);
142 observer?.observe(anchor);156 if (observer) {
157 // The composer is centered and capped at a max width, so opening a side
158 // panel slides it sideways without ever resizing it. Watching the anchor
159 // alone would leave the menu behind; the ancestors are what shrink, and
160 // they resize on every frame of the panel animation.
161 observer.observe(anchor);
162 for (let element = anchor.parentElement; element; element = element.parentElement) {
163 observer.observe(element);
164 }
165 }
143166
144 return () => {167 return () => {
145 observer?.disconnect();168 observer?.disconnect();