From 8ec162bb26c49679b059bb977ca66e93801c5775 Mon Sep 17 00:00:00 2001 From: Md Mushfiqur Rahim <20mahin2020@gmail.com> Date: Fri, 29 May 2026 15:05:51 +0600 Subject: [PATCH] fix: pet hover card gets cut off at screen edges (#2860) * fix: pet hover card gets cut off at screen edges * fix: address review feedback - viewport clamping + unadopted pet wake - Add window.innerHeight check to prevent bottom-edge clipping - Increase menuH estimate for safer positioning - Open pet settings instead of no-op Wake for unadopted pets * fix: address review feedback on pet menu positioning and wake action - Add viewport height check (viewH) to prevent bottom-edge clipping - Increase menuH estimate for safer positioning - Open pet settings instead of no-op Wake for unadopted pets --- apps/web/src/components/ChatComposer.tsx | 114 +++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/apps/web/src/components/ChatComposer.tsx b/apps/web/src/components/ChatComposer.tsx index 0e21d4e01..4401ea0a9 100644 --- a/apps/web/src/components/ChatComposer.tsx +++ b/apps/web/src/components/ChatComposer.tsx @@ -283,6 +283,9 @@ export const ChatComposer = forwardRef( const toolsMenuRef = useRef(null); const toolsTriggerRef = useRef(null); const petEnabled = Boolean(onAdoptPet && onTogglePet); + const [petMenuOpen, setPetMenuOpen] = useState(false); + const petWrapRef = useRef(null); + const [petMenuStyle, setPetMenuStyle] = useState({}); const linkedDirs = projectMetadata?.linkedDirs ?? []; // initialDraft is only honored on the first non-empty value the parent // hands us. After we seed once, the composer is fully under user control @@ -326,6 +329,53 @@ export const ChatComposer = forwardRef( }; }, [toolsOpen]); + useEffect(() => { + if (!petMenuOpen) return; + function onPointer(e: MouseEvent) { + const target = e.target as Node; + if (petWrapRef.current?.contains(target)) return; + setPetMenuOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') setPetMenuOpen(false); + } + document.addEventListener('mousedown', onPointer); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onPointer); + document.removeEventListener('keydown', onKey); + }; + }, [petMenuOpen]); + + // Viewport-aware pet menu positioning — flips the popover to stay + // within screen bounds instead of clipping at the edge. + useEffect(() => { + if (!petMenuOpen) return; + const wrap = petWrapRef.current; + if (!wrap) return; + const rect = wrap.getBoundingClientRect(); + const menuW = 260; + const menuH = 200; + const gap = 6; + const viewW = window.innerWidth; + const viewH = window.innerHeight; + // Prefer opening upward (bottom of menu above the button). + // Flip downward when there isn't enough room above. + // When neither direction fits, clamp to viewport bounds. + let top: number; + if (rect.top >= menuH + gap) { + top = rect.top - menuH - gap; + } else if (rect.bottom + menuH + gap <= viewH) { + top = rect.bottom + gap; + } else { + top = Math.max(gap, viewH - menuH - gap); + } + // Right-align by default (menu right edge ≈ button right edge). + // Shift left when the menu would spill past the viewport left edge. + const left = Math.max(8, Math.min(viewW - menuW - 8, rect.right - menuW)); + setPetMenuStyle({ position: 'fixed', top, left }); + }, [petMenuOpen]); + // Lazy-fetch the user's external MCP servers list once on mount so the // `/mcp …` slash palette and the composer's MCP button popover have // something to render. We deliberately do not reactively re-fetch when @@ -1706,6 +1756,70 @@ export const ChatComposer = forwardRef( ) : null} + {petEnabled ? ( +
+ + {petMenuOpen ? ( +
+
+ {t('pet.composerMenuTitle')} + {t('pet.composerMenuHint')} +
+ + +
+ ) : null} +
+ ) : null}