From e20f4b5a8b12415bc2042ef0fc6b526a8cadb788 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 7 May 2026 17:53:03 +0800 Subject: [PATCH] refine desktop pet task bubbles --- src/apps/desktop/src/lib.rs | 1 + src/apps/desktop/src/theme.rs | 117 +++++++++++++++--- src/web-ui/src/app/App.tsx | 38 ++++++ .../AgentCompanionDesktopPet.scss | 42 +++++-- .../AgentCompanionDesktopPet.tsx | 74 ++++++++++- 5 files changed, 239 insertions(+), 33 deletions(-) diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index c1915503..beb6da28 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -388,6 +388,7 @@ pub async fn run() { update_app_status, theme::show_agent_companion_desktop_pet, theme::hide_agent_companion_desktop_pet, + theme::resize_agent_companion_desktop_pet, list_agent_companion_pets, import_agent_companion_pet_package, delete_agent_companion_pet_package, diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index e801e251..2b95cdb7 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -7,8 +7,9 @@ use log::{debug, error, warn}; use tauri::{Manager, WebviewUrl}; const AGENT_COMPANION_WINDOW_LABEL: &str = "agent-companion-pet"; -const AGENT_COMPANION_WINDOW_WIDTH: f64 = 360.0; -const AGENT_COMPANION_WINDOW_HEIGHT: f64 = 180.0; +const AGENT_COMPANION_WINDOW_MIN_SIZE: f64 = 96.0; +const AGENT_COMPANION_WINDOW_MAX_WIDTH: f64 = 360.0; +const AGENT_COMPANION_WINDOW_MAX_HEIGHT: f64 = 240.0; const AGENT_COMPANION_WINDOW_MARGIN: i32 = 64; #[derive(Debug, Clone)] @@ -321,31 +322,104 @@ fn app_url(path: &str) -> WebviewUrl { } } -fn position_agent_companion_window(app: &tauri::AppHandle, window: &tauri::WebviewWindow) { +fn agent_companion_default_position( + app: &tauri::AppHandle, + window: &tauri::WebviewWindow, +) -> Option> { let monitor: Option = window .current_monitor() .ok() .flatten() .or_else(|| app.primary_monitor().ok().flatten()); - let Some(monitor) = monitor else { - return; - }; + let monitor = monitor?; let scale_factor = monitor.scale_factor(); let area = monitor.work_area(); let area_position = area.position.to_logical::(scale_factor); let area_size = area.size.to_logical::(scale_factor); + let window_size = window + .outer_size() + .ok() + .map(|size| size.to_logical::(scale_factor)); + let window_width = window_size + .as_ref() + .map(|size| size.width) + .unwrap_or(AGENT_COMPANION_WINDOW_MIN_SIZE); + let window_height = window_size + .as_ref() + .map(|size| size.height) + .unwrap_or(AGENT_COMPANION_WINDOW_MIN_SIZE); let x = area_position.x + area_size.width - - AGENT_COMPANION_WINDOW_WIDTH + - window_width - f64::from(AGENT_COMPANION_WINDOW_MARGIN); let y = area_position.y + area_size.height - - AGENT_COMPANION_WINDOW_HEIGHT + - window_height - f64::from(AGENT_COMPANION_WINDOW_MARGIN); - if let Err(e) = window.set_position(tauri::LogicalPosition::new( + Some(tauri::LogicalPosition::new( x.max(area_position.x), y.max(area_position.y), + )) +} + +fn position_agent_companion_window(app: &tauri::AppHandle, window: &tauri::WebviewWindow) { + let Some(position) = agent_companion_default_position(app, window) else { + return; + }; + + if let Err(e) = window.set_position(position) { + warn!("Failed to position Agent companion window: {}", e); + } +} + +fn resize_agent_companion_window( + app: &tauri::AppHandle, + window: &tauri::WebviewWindow, + width: f64, + height: f64, +) { + let monitor: Option = window + .current_monitor() + .ok() + .flatten() + .or_else(|| app.primary_monitor().ok().flatten()); + let scale_factor = monitor.as_ref().map(|monitor| monitor.scale_factor()); + let old_size = scale_factor.and_then(|scale_factor| { + window + .outer_size() + .ok() + .map(|size| size.to_logical::(scale_factor)) + }); + let old_position = scale_factor.and_then(|scale_factor| { + window + .outer_position() + .ok() + .map(|position| position.to_logical::(scale_factor)) + }); + + if let Err(e) = window.set_size(tauri::LogicalSize::new(width, height)) { + warn!("Failed to resize Agent companion window: {}", e); + return; + } + + let next_position = old_position + .zip(old_size) + .map(|(position, size)| { + tauri::LogicalPosition::new( + position.x + size.width - width, + position.y + size.height - height, + ) + }) + .or_else(|| agent_companion_default_position(app, window)); + + let Some(position) = next_position else { + return; + }; + + if let Err(e) = window.set_position(tauri::LogicalPosition::new( + position.x, + position.y, )) { warn!("Failed to position Agent companion window: {}", e); } @@ -366,17 +440,14 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( let mut builder = tauri::WebviewWindowBuilder::new(&app, AGENT_COMPANION_WINDOW_LABEL, url) .title("BitFun Agent Companion") .inner_size( - AGENT_COMPANION_WINDOW_WIDTH, - AGENT_COMPANION_WINDOW_HEIGHT, + AGENT_COMPANION_WINDOW_MIN_SIZE, + AGENT_COMPANION_WINDOW_MIN_SIZE, ) .max_inner_size( - AGENT_COMPANION_WINDOW_WIDTH, - AGENT_COMPANION_WINDOW_HEIGHT, - ) - .min_inner_size( - AGENT_COMPANION_WINDOW_WIDTH, - AGENT_COMPANION_WINDOW_HEIGHT, + AGENT_COMPANION_WINDOW_MAX_WIDTH, + AGENT_COMPANION_WINDOW_MAX_HEIGHT, ) + .min_inner_size(1.0, 1.0) .resizable(false) .decorations(false) .transparent(true) @@ -404,6 +475,18 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( Ok(()) } +#[tauri::command] +pub async fn resize_agent_companion_desktop_pet( + app: tauri::AppHandle, + width: f64, + height: f64, +) -> Result<(), String> { + if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) { + resize_agent_companion_window(&app, &window, width, height); + } + Ok(()) +} + #[tauri::command] pub async fn hide_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) { diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index f0fb3a8e..a3bddf86 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -14,6 +14,7 @@ import { aiExperienceConfigService } from '@/infrastructure/config/services/AIEx import { syncAgentCompanionDesktopWindow } from '@/infrastructure/config/services/AgentCompanionWindowService'; import { buildAgentCompanionActivity, subscribeAgentCompanionActivity } from '@/flow_chat/utils/agentCompanionActivity'; import { emitAgentCompanionActivity } from '@/flow_chat/services/AgentCompanionActivityBridge'; +import { FlowChatStore } from '@/flow_chat/store/FlowChatStore'; import { useWorkspaceContext } from '../infrastructure/contexts/WorkspaceContext'; import SplashScreen from './components/SplashScreen/SplashScreen'; import { useGlobalSceneShortcuts } from './hooks/useGlobalSceneShortcuts'; @@ -185,6 +186,43 @@ function App() { void emitAgentCompanionActivity(activity); }), []); + useEffect(() => { + let unlisten: (() => void) | null = null; + void import('@tauri-apps/api/event') + .then(({ listen }) => listen<{ sessionId?: string }>( + 'agent-companion://open-session', + async event => { + const sessionId = event.payload?.sessionId; + if (!sessionId) return; + + const flowChatStore = FlowChatStore.getInstance(); + if (flowChatStore.getState().sessions.has(sessionId)) { + flowChatStore.switchSession(sessionId); + } + + 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 bubble', { + sessionId, + error, + }); + } + }, + )) + .then(removeListener => { + unlisten = removeListener; + }) + .catch(error => { + log.warn('Failed to listen for Agent companion session open events', error); + }); + + return () => { + unlisten?.(); + }; + }, []); + // Observe AI initialization state useEffect(() => { if (aiError) { diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss index ada0aa3e..0fc0519a 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss @@ -18,13 +18,8 @@ height: 100vh; position: relative; background: transparent; - cursor: grab; user-select: none; - &:active { - cursor: grabbing; - } - &__pet { width: min(96px, 100vw); height: min(96px, 100vh); @@ -52,18 +47,34 @@ &__bubbles { position: absolute; - right: 88px; - bottom: 24px; - width: min(252px, calc(100vw - 104px)); - max-height: calc(100vh - 16px); + right: calc(var(--bitfun-agent-companion-pet-size, 96px) - var(--bitfun-agent-companion-gap, 8px)); + bottom: 0; + width: min(252px, calc(100vw - var(--bitfun-agent-companion-pet-size, 96px) - var(--bitfun-agent-companion-gap, 8px))); + max-height: 100vh; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; - pointer-events: none; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + pointer-events: auto; + scrollbar-width: thin; + + &::-webkit-scrollbar { + width: 5px; + } + + &::-webkit-scrollbar-thumb { + border-radius: 999px; + background: rgba(15, 23, 42, 0.18); + } } &__bubble { + appearance: none; + text-align: left; + font: inherit; max-width: 100%; min-width: 132px; padding: 7px 10px; @@ -73,6 +84,17 @@ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.14); color: #1f2937; backdrop-filter: blur(10px); + cursor: pointer; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 12px 26px rgba(15, 23, 42, 0.18); + } + + &:focus-visible { + outline: 2px solid rgba(59, 130, 246, 0.7); + outline-offset: 2px; + } } &__bubble-title, diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index c7739d51..88d1abb2 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { 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'; @@ -10,6 +10,12 @@ import { createLogger } from '@/shared/utils/logger'; import './AgentCompanionDesktopPet.scss'; const log = createLogger('AgentCompanionDesktopPet'); +const PET_SIZE = 96; +const WINDOW_WIDTH_WITH_BUBBLES = 360; +const WINDOW_MAX_HEIGHT = 240; +const WINDOW_HORIZONTAL_GAP = 8; +const MAX_VISIBLE_BUBBLES = 3; +const BUBBLE_GAP = 6; export const AgentCompanionDesktopPet: React.FC = () => { const { t } = useTranslation('flow-chat'); @@ -20,6 +26,8 @@ export const AgentCompanionDesktopPet: React.FC = () => { const [tasks, setTasks] = useState([]); const [isHoveringPet, setIsHoveringPet] = useState(false); const [isDraggingPet, setIsDraggingPet] = useState(false); + const bubblesRef = useRef(null); + const displayTasks = [...tasks].reverse(); useEffect(() => { document.documentElement.classList.add('bitfun-agent-companion-window-root'); @@ -68,6 +76,33 @@ export const AgentCompanionDesktopPet: React.FC = () => { }; }, []); + useLayoutEffect(() => { + const bubbleCount = tasks.length; + const nextWidth = bubbleCount > 0 ? WINDOW_WIDTH_WITH_BUBBLES : PET_SIZE; + const bubbleElements = Array.from(bubblesRef.current?.children ?? []) + .slice(0, MAX_VISIBLE_BUBBLES); + const visibleBubbleHeight = bubbleElements.reduce( + (sum, child) => sum + child.getBoundingClientRect().height, + 0, + ) + Math.max(0, bubbleElements.length - 1) * BUBBLE_GAP; + const measuredBubbleHeight = bubblesRef.current?.scrollHeight ?? 0; + const targetBubbleHeight = bubbleCount > MAX_VISIBLE_BUBBLES + ? visibleBubbleHeight + : measuredBubbleHeight; + const nextHeight = bubbleCount > 0 + ? Math.max(PET_SIZE, Math.min(WINDOW_MAX_HEIGHT, targetBubbleHeight)) + : PET_SIZE; + + void import('@tauri-apps/api/core') + .then(({ invoke }) => invoke('resize_agent_companion_desktop_pet', { + width: nextWidth, + height: nextHeight, + })) + .catch(error => { + log.warn('Failed to resize Agent companion window', error); + }); + }, [tasks]); + const startDrag = (event: React.PointerEvent) => { if (event.button !== 0) { return; @@ -90,6 +125,22 @@ export const AgentCompanionDesktopPet: React.FC = () => { ? 'hover' : mood; + const openTaskSession = async (task: AgentCompanionTaskStatus) => { + try { + const [{ invoke }, { emit }] = await Promise.all([ + import('@tauri-apps/api/core'), + import('@tauri-apps/api/event'), + ]); + await emit('agent-companion://open-session', { sessionId: task.sessionId }); + await invoke('show_main_window'); + } catch (error) { + log.warn('Failed to open Agent companion task session', { + sessionId: task.sessionId, + error, + }); + } + }; + return (
{ title="Double-click to close" > {tasks.length > 0 && ( -
- {tasks.map(task => ( -
event.stopPropagation()} + style={{ + '--bitfun-agent-companion-pet-size': `${PET_SIZE}px`, + '--bitfun-agent-companion-gap': `${WINDOW_HORIZONTAL_GAP}px`, + } as React.CSSProperties} + > + {displayTasks.map(task => ( +
+ ))}
)}