From 584aabeb14e532687e2dd05b78e3109adb36fe61 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 9 May 2026 17:49:31 +0800 Subject: [PATCH] fix(web-ui): poll cursor position for companion pet hover Use Tauri cursorPosition with window outer position and scale factor to keep pet hitbox hover state accurate when the window is moved or DPI changes. --- .../AgentCompanionDesktopPet.tsx | 95 ++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index 6fa227b77..364fda304 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { listen } from '@tauri-apps/api/event'; -import { getCurrentWindow } from '@tauri-apps/api/window'; +import { cursorPosition, getCurrentWindow } from '@tauri-apps/api/window'; import { aiExperienceConfigService, type AgentCompanionPetSelection, type AIExperienceSettings } from '@/infrastructure/config/services/AIExperienceConfigService'; import { ChatInputPixelPet, type ChatInputPixelPetMood } from '@/flow_chat/components/ChatInputPixelPet'; import type { ChatInputPetMood } from '@/flow_chat/utils/chatInputPetMood'; @@ -21,6 +21,7 @@ const BUBBLE_GAP = 6; const BUBBLE_MIN_WIDTH = 132; const BUBBLE_MAX_WIDTH = 252; const WINDOW_EDGE_BUFFER = 4; +const POINTER_HOVER_POLL_INTERVAL_MS = 120; export const AgentCompanionDesktopPet: React.FC = () => { const { t } = useTranslation('flow-chat'); @@ -144,6 +145,98 @@ export const AgentCompanionDesktopPet: React.FC = () => { }); }, [activePetSize.height, activePetSize.width, tasks]); + useEffect(() => { + const tauriWindow = getCurrentWindow(); + let disposed = false; + let windowPosition: { x: number; y: number } | null = null; + let scaleFactor = 1; + let removeWindowMovedListener: (() => void) | null = null; + let removeScaleChangedListener: (() => void) | null = null; + + void tauriWindow.outerPosition() + .then(position => { + windowPosition = position; + }) + .catch(error => { + log.warn('Failed to read Agent companion window position', error); + }); + + void tauriWindow.scaleFactor() + .then(nextScaleFactor => { + scaleFactor = nextScaleFactor; + }) + .catch(error => { + log.warn('Failed to read Agent companion window scale factor', error); + }); + + void tauriWindow.onMoved(event => { + windowPosition = event.payload; + }).then(unlisten => { + if (disposed) { + unlisten(); + } else { + removeWindowMovedListener = unlisten; + } + }).catch(error => { + log.warn('Failed to listen for Agent companion window moves', error); + }); + + void tauriWindow.onScaleChanged(event => { + scaleFactor = event.payload.scaleFactor; + }).then(unlisten => { + if (disposed) { + unlisten(); + } else { + removeScaleChangedListener = unlisten; + } + }).catch(error => { + log.warn('Failed to listen for Agent companion scale changes', error); + }); + + const pollPointerHover = async () => { + try { + if (!windowPosition) { + windowPosition = await tauriWindow.outerPosition(); + } + + const pointer = await cursorPosition(); + if (disposed) { + return; + } + + const hitbox = dockRef.current?.querySelector('.bitfun-agent-companion-window__pet-hitbox'); + if (!hitbox) { + setIsHoveringPet(false); + return; + } + + const hitboxRect = hitbox.getBoundingClientRect(); + const pointerX = (pointer.x - windowPosition.x) / scaleFactor; + const pointerY = (pointer.y - windowPosition.y) / scaleFactor; + const isPointerInsideHitbox = pointerX >= hitboxRect.left + && pointerX <= hitboxRect.right + && pointerY >= hitboxRect.top + && pointerY <= hitboxRect.bottom; + + setIsHoveringPet(isPointerInsideHitbox); + } catch (error) { + log.warn('Failed to poll Agent companion pointer hover state', error); + } + }; + + const intervalId = window.setInterval(() => { + void pollPointerHover(); + }, POINTER_HOVER_POLL_INTERVAL_MS); + void pollPointerHover(); + + return () => { + disposed = true; + window.clearInterval(intervalId); + removeWindowMovedListener?.(); + removeScaleChangedListener?.(); + }; + }, []); + const startDrag = (event: React.PointerEvent) => { if (event.button !== 0) { return;