From a5c619b22240c76722449e6172f4bf6227caa003 Mon Sep 17 00:00:00 2001 From: GCWing Date: Mon, 11 May 2026 23:47:36 +0800 Subject: [PATCH] fix(web-ui): pet tap opens main window; persist in-progress turns only on quit - Resolve short clicks vs native drag on agent companion pet (threshold + pointer capture). - Defer saveAllInProgressTurns until user actually quits, not when hiding to tray/dock, so desktop pet bubbles are not cleared on window hide. - Document PersistenceModule contract for saveAllInProgressTurns. --- .../AgentCompanionDesktopPet.tsx | 80 ++++++++++++++++++- src/web-ui/src/app/layout/AppLayout.tsx | 29 ++++--- .../flow-chat-manager/PersistenceModule.ts | 5 +- 3 files changed, 100 insertions(+), 14 deletions(-) diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index 364fda304..c269e1cbc 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -22,6 +22,8 @@ const BUBBLE_MIN_WIDTH = 132; const BUBBLE_MAX_WIDTH = 252; const WINDOW_EDGE_BUFFER = 4; const POINTER_HOVER_POLL_INTERVAL_MS = 120; +/** Clicks shorter/smaller than this use `show_main_window`; beyond it we start a native drag. */ +const PET_DRAG_THRESHOLD_PX = 8; export const AgentCompanionDesktopPet: React.FC = () => { const { t } = useTranslation('flow-chat'); @@ -37,6 +39,12 @@ export const AgentCompanionDesktopPet: React.FC = () => { const bubblesRef = useRef(null); const lastActivitySequenceRef = useRef(0); const lastActivityEmittedAtRef = useRef(0); + const petPointerSessionRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + dragStarted: boolean; + } | null>(null); const displayTasks = [...tasks].reverse(); const activePetSize = pet && petFrameSize ? petFrameSize @@ -237,11 +245,56 @@ export const AgentCompanionDesktopPet: React.FC = () => { }; }, []); - const startDrag = (event: React.PointerEvent) => { + const showMainWindowFromPet = useCallback(async () => { + try { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('show_main_window'); + } catch (error) { + log.warn('Failed to show main window from Agent companion pet', error); + } + }, []); + + const clearPetPointerSession = (target: HTMLDivElement, pointerId: number) => { + const session = petPointerSessionRef.current; + if (!session || session.pointerId !== pointerId) { + return; + } + petPointerSessionRef.current = null; + try { + target.releasePointerCapture(pointerId); + } catch { + /* already released */ + } + }; + + const onPetPointerDown = (event: React.PointerEvent) => { if (event.button !== 0) { return; } + petPointerSessionRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + dragStarted: false, + }; + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + /* ignore */ + } + }; + const onPetPointerMove = (event: React.PointerEvent) => { + const session = petPointerSessionRef.current; + if (!session || event.pointerId !== session.pointerId || session.dragStarted) { + return; + } + const dx = event.clientX - session.startX; + const dy = event.clientY - session.startY; + if (dx * dx + dy * dy < PET_DRAG_THRESHOLD_PX * PET_DRAG_THRESHOLD_PX) { + return; + } + session.dragStarted = true; event.preventDefault(); setIsDraggingPet(true); void getCurrentWindow().startDragging() @@ -253,6 +306,26 @@ export const AgentCompanionDesktopPet: React.FC = () => { }); }; + const onPetPointerUp = (event: React.PointerEvent) => { + const session = petPointerSessionRef.current; + if (!session || event.pointerId !== session.pointerId) { + return; + } + const shouldShowMain = !session.dragStarted; + clearPetPointerSession(event.currentTarget, event.pointerId); + if (shouldShowMain) { + void showMainWindowFromPet(); + } + }; + + const onPetPointerCancel = (event: React.PointerEvent) => { + const session = petPointerSessionRef.current; + if (!session || event.pointerId !== session.pointerId) { + return; + } + clearPetPointerSession(event.currentTarget, event.pointerId); + }; + const displayMood: ChatInputPixelPetMood = isDraggingPet ? 'dragging' : isHoveringPet @@ -322,7 +395,10 @@ export const AgentCompanionDesktopPet: React.FC = () => { className="bitfun-agent-companion-window__pet-hitbox" onPointerEnter={() => setIsHoveringPet(true)} onPointerLeave={() => setIsHoveringPet(false)} - onPointerDown={startDrag} + onPointerDown={onPetPointerDown} + onPointerMove={onPetPointerMove} + onPointerUp={onPetPointerUp} + onPointerCancel={onPetPointerCancel} > = ({ className = '' }) => { t, ]); - // Save in-progress conversations before the native window is closed/hidden. + // When the user hides the main window (tray / macOS dock), the app keeps running. + // `saveAllInProgressTurns` settles in-flight dialog turns for disk persistence, which + // clears Agent companion desktop bubbles until the next chat update—so only run it + // immediately before we actually exit the process. React.useEffect(() => { let unlistenFn: (() => void) | null = null; let handlingClose = false; @@ -312,22 +315,23 @@ const AppLayout: React.FC = ({ className = '' }) => { try { // Both macOS and Windows/Linux: Rust intercepts the native close request - // and emits this event. We save turns then decide what to do. + // and emits this event. We decide hide vs quit; persist interrupted turns only on quit. const [{ listen }, { invoke }] = await Promise.all([ import('@tauri-apps/api/event'), import('@tauri-apps/api/core'), ]); - unlistenFn = await listen('bitfun_main_window_close_requested', async () => { - if (handlingClose) return; - handlingClose = true; - + const persistInterruptedTurnsForExit = async () => { try { - const flowChatManager = FlowChatManager.getInstance(); - await flowChatManager.saveAllInProgressTurns(); + await FlowChatManager.getInstance().saveAllInProgressTurns(); } catch (error) { - log.error('Failed to save conversations before close', error); + log.error('Failed to save conversations before quit', error); } + }; + + unlistenFn = await listen('bitfun_main_window_close_requested', async () => { + if (handlingClose) return; + handlingClose = true; if (isMacOS) { // macOS always hides to keep the app alive in the dock. @@ -360,17 +364,22 @@ const AppLayout: React.FC = ({ className = '' }) => { showCancel: true, }); if (shouldQuit) { + await persistInterruptedTurnsForExit(); await systemAPI.quitApp(); } else { await systemAPI.minimizeToTray(); } } else { // quit + await persistInterruptedTurnsForExit(); await systemAPI.quitApp(); } } catch (error) { log.error('Failed to handle close request', { behavior, error }); - try { await systemAPI.quitApp(); } catch { /* ignore */ } + try { + await persistInterruptedTurnsForExit(); + await systemAPI.quitApp(); + } catch { /* ignore */ } } finally { handlingClose = false; } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index 39f3afa76..26331d8dd 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -297,8 +297,9 @@ async function performSaveDialogTurnToDisk( } /** - * Save all in-progress dialog turns - * Used when closing the window to persist unfinished session turns + * Save all in-progress dialog turns by settling them for persistence. + * Call only when the app process is about to exit; hiding to tray/dock keeps the + * app alive and settling here would clear in-memory "active" state (e.g. desktop pet bubbles). */ export async function saveAllInProgressTurns(context: FlowChatContext): Promise { const state = context.flowChatStore.getState();