From 033a58742be36d0235839f62c86e7075ba5436d6 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 7 May 2026 12:46:54 +0800 Subject: [PATCH] add agent companion desktop pet --- Cargo.toml | 2 +- src/apps/desktop/capabilities/default.json | 2 +- src/apps/desktop/src/lib.rs | 2 + src/apps/desktop/src/theme.rs | 122 +++++++- src/apps/desktop/tauri.conf.json | 1 + src/crates/core/src/service/config/types.rs | 63 +++- src/web-ui/src/app/App.tsx | 26 ++ .../AgentCompanionDesktopPet.scss | 110 +++++++ .../AgentCompanionDesktopPet.tsx | 108 +++++++ .../src/flow_chat/components/ChatInput.tsx | 18 +- .../components/ChatInputPixelPet.scss | 9 +- .../hooks/useAgentCompanionActivity.ts | 16 + .../services/AgentCompanionActivityBridge.ts | 18 ++ .../flow_chat/utils/agentCompanionActivity.ts | 281 ++++++++++++++++++ .../config/components/SessionConfig.tsx | 32 ++ .../services/AIExperienceConfigService.ts | 5 + .../services/AgentCompanionWindowService.ts | 27 ++ .../src/infrastructure/config/types/index.ts | 3 + src/web-ui/src/locales/en-US/flow-chat.json | 17 ++ .../en-US/settings/session-config.json | 8 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 17 ++ .../zh-CN/settings/session-config.json | 8 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 17 ++ .../zh-TW/settings/session-config.json | 8 +- src/web-ui/src/main.tsx | 20 ++ 25 files changed, 924 insertions(+), 16 deletions(-) create mode 100644 src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss create mode 100644 src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx create mode 100644 src/web-ui/src/flow_chat/hooks/useAgentCompanionActivity.ts create mode 100644 src/web-ui/src/flow_chat/services/AgentCompanionActivityBridge.ts create mode 100644 src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts create mode 100644 src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts diff --git a/Cargo.toml b/Cargo.toml index fde4e9289..da7f482a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,7 +102,7 @@ similar = "2.5" urlencoding = "2.1" # Tauri (desktop only) - tauri = { version = "2", features = ["unstable"] } + tauri = { version = "2", features = ["unstable", "macos-private-api"] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2.6" tauri-plugin-fs = "2" diff --git a/src/apps/desktop/capabilities/default.json b/src/apps/desktop/capabilities/default.json index 945580903..84176317e 100644 --- a/src/apps/desktop/capabilities/default.json +++ b/src/apps/desktop/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "BitFun default capabilities", - "windows": ["main"], + "windows": ["main", "agent-companion-pet"], "permissions": [ "log:default", "autostart:default", diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 3fd87d558..c19155032 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -386,6 +386,8 @@ pub async fn run() { fix_mermaid_code, get_app_state, update_app_status, + theme::show_agent_companion_desktop_pet, + theme::hide_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 91a690199..e801e251f 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -4,7 +4,12 @@ use bitfun_core::infrastructure::try_get_path_manager_arc; use bitfun_core::service::config::types::GlobalConfig; use dark_light::Mode; use log::{debug, error, warn}; -use tauri::WebviewUrl; +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_MARGIN: i32 = 64; #[derive(Debug, Clone)] pub struct ThemeConfig { @@ -297,10 +302,121 @@ pub fn create_main_window(app_handle: &tauri::AppHandle) { } } +fn app_url(path: &str) -> WebviewUrl { + if cfg!(debug_assertions) { + match format!("http://localhost:1422/{}", path).parse() { + Ok(url) => WebviewUrl::External(url), + Err(e) => { + error!("Invalid dev URL, fallback to app URL: {}", e); + WebviewUrl::App(path.into()) + } + } + } else { + let app_path = if path.starts_with('?') { + format!("index.html{}", path) + } else { + path.to_string() + }; + WebviewUrl::App(app_path.into()) + } +} + +fn position_agent_companion_window(app: &tauri::AppHandle, window: &tauri::WebviewWindow) { + let monitor: Option = window + .current_monitor() + .ok() + .flatten() + .or_else(|| app.primary_monitor().ok().flatten()); + + let Some(monitor) = monitor else { + return; + }; + + 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 x = area_position.x + area_size.width + - AGENT_COMPANION_WINDOW_WIDTH + - f64::from(AGENT_COMPANION_WINDOW_MARGIN); + let y = area_position.y + area_size.height + - AGENT_COMPANION_WINDOW_HEIGHT + - f64::from(AGENT_COMPANION_WINDOW_MARGIN); + + if let Err(e) = window.set_position(tauri::LogicalPosition::new( + x.max(area_position.x), + y.max(area_position.y), + )) { + warn!("Failed to position Agent companion window: {}", e); + } +} + #[tauri::command] -pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { - use tauri::Manager; +pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) { + position_agent_companion_window(&app, &window); + window.show().map_err(|e| { + error!("Failed to show Agent companion window: {}", e); + format!("Failed to show Agent companion window: {}", e) + })?; + return Ok(()); + } + let url = app_url("?bitfunWindow=agent-companion"); + 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, + ) + .max_inner_size( + AGENT_COMPANION_WINDOW_WIDTH, + AGENT_COMPANION_WINDOW_HEIGHT, + ) + .min_inner_size( + AGENT_COMPANION_WINDOW_WIDTH, + AGENT_COMPANION_WINDOW_HEIGHT, + ) + .resizable(false) + .decorations(false) + .transparent(true) + .always_on_top(true) + .skip_taskbar(true) + .shadow(false) + .visible(false) + .accept_first_mouse(true) + .background_color(tauri::window::Color(0, 0, 0, 0)); + + builder = builder.disable_drag_drop_handler(); + + let window = builder.build().map_err(|e| { + error!("Failed to create Agent companion window: {}", e); + format!("Failed to create Agent companion window: {}", e) + })?; + + position_agent_companion_window(&app, &window); + + window.show().map_err(|e| { + error!("Failed to show Agent companion window: {}", e); + format!("Failed to show Agent companion window: {}", e) + })?; + + 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) { + window.close().map_err(|e| { + error!("Failed to close Agent companion window: {}", e); + format!("Failed to close Agent companion window: {}", e) + })?; + } + Ok(()) +} + +#[tauri::command] +pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { if let Some(main_window) = app.get_webview_window("main") { #[cfg(target_os = "windows")] { diff --git a/src/apps/desktop/tauri.conf.json b/src/apps/desktop/tauri.conf.json index 4c013b9b7..2fe0345f3 100644 --- a/src/apps/desktop/tauri.conf.json +++ b/src/apps/desktop/tauri.conf.json @@ -49,6 +49,7 @@ "security": { "csp": null }, + "macOSPrivateApi": true, "withGlobalTauri": true } } diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index 59dcad292..34068f668 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -114,6 +114,25 @@ pub struct AIExperienceConfig { pub enable_visual_mode: bool, /// Whether to show the pixel Agent companion in the collapsed chat input. pub enable_agent_companion: bool, + /// Where to show the Agent companion: "input" or "desktop". + pub agent_companion_display_mode: String, + /// Optional Petdex-compatible companion package selected by the user. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_companion_pet: Option, +} + +/// User-selected Agent companion pet package. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AgentCompanionPetSelection { + pub id: String, + pub display_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub source: String, + pub package_path: String, + pub spritesheet_path: String, + pub spritesheet_mime_type: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1254,6 +1273,8 @@ impl Default for AIExperienceConfig { enable_welcome_panel_ai_analysis: false, enable_visual_mode: false, enable_agent_companion: true, + agent_companion_display_mode: "desktop".to_string(), + agent_companion_pet: None, } } } @@ -1663,7 +1684,7 @@ impl AIModelConfig { #[cfg(test)] mod tests { - use super::{AIConfig, AIModelConfig, ReasoningMode}; + use super::{AIConfig, AIExperienceConfig, AIModelConfig, ReasoningMode}; #[test] fn deserializes_compatibility_thinking_flag_into_reasoning_mode() { @@ -1683,6 +1704,46 @@ mod tests { assert!(config.enable_thinking_process); } + #[test] + fn preserves_selected_agent_companion_pet() { + let config: AIExperienceConfig = serde_json::from_value(serde_json::json!({ + "enable_session_title_generation": true, + "enable_welcome_panel_ai_analysis": false, + "enable_visual_mode": false, + "enable_agent_companion": true, + "agent_companion_display_mode": "desktop", + "agent_companion_pet": { + "id": "pixel-panda", + "displayName": "Pixel Panda", + "description": "A gentle panda holding a tiny pixel brush.", + "source": "preset", + "packagePath": "/agent-companion-pets/pixel-panda", + "spritesheetPath": "/agent-companion-pets/pixel-panda/spritesheet.webp", + "spritesheetMimeType": "image/webp" + } + })) + .expect("AI experience config with selected companion pet should deserialize"); + + let pet = config + .agent_companion_pet + .as_ref() + .expect("selected companion pet should be retained"); + assert_eq!(pet.id, "pixel-panda"); + assert_eq!(pet.display_name, "Pixel Panda"); + assert_eq!(pet.package_path, "/agent-companion-pets/pixel-panda"); + assert_eq!(config.agent_companion_display_mode, "desktop"); + + let serialized = serde_json::to_value(&config).expect("config should serialize"); + assert_eq!( + serialized["agent_companion_pet"]["displayName"], + "Pixel Panda" + ); + assert_eq!( + serialized["agent_companion_pet"]["spritesheetPath"], + "/agent-companion-pets/pixel-panda/spritesheet.webp" + ); + } + #[test] fn deserializes_compatibility_false_thinking_flag_into_default_reasoning_mode() { let config: AIModelConfig = serde_json::from_value(serde_json::json!({ diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 42c9c5820..f0fb3a8e0 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -10,6 +10,10 @@ import { NotificationContainer, NotificationCenter } from '../shared/notificatio import { AnnouncementProvider } from '../shared/announcement-system'; import { ConfirmDialogRenderer } from '../component-library'; import { createLogger } from '@/shared/utils/logger'; +import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService'; +import { syncAgentCompanionDesktopWindow } from '@/infrastructure/config/services/AgentCompanionWindowService'; +import { buildAgentCompanionActivity, subscribeAgentCompanionActivity } from '@/flow_chat/utils/agentCompanionActivity'; +import { emitAgentCompanionActivity } from '@/flow_chat/services/AgentCompanionActivityBridge'; import { useWorkspaceContext } from '../infrastructure/contexts/WorkspaceContext'; import SplashScreen from './components/SplashScreen/SplashScreen'; import { useGlobalSceneShortcuts } from './hooks/useGlobalSceneShortcuts'; @@ -159,6 +163,28 @@ function App() { }, []); + useEffect(() => { + const emitCurrentAgentCompanionActivity = () => { + void emitAgentCompanionActivity(buildAgentCompanionActivity()); + }; + + void aiExperienceConfigService.getSettingsAsync().then(async settings => { + await syncAgentCompanionDesktopWindow(settings); + emitCurrentAgentCompanionActivity(); + window.setTimeout(emitCurrentAgentCompanionActivity, 250); + }); + return aiExperienceConfigService.addChangeListener(settings => { + void syncAgentCompanionDesktopWindow(settings).then(() => { + emitCurrentAgentCompanionActivity(); + window.setTimeout(emitCurrentAgentCompanionActivity, 250); + }); + }); + }, []); + + useEffect(() => subscribeAgentCompanionActivity(activity => { + void emitAgentCompanionActivity(activity); + }), []); + // 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 new file mode 100644 index 000000000..d91a17046 --- /dev/null +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss @@ -0,0 +1,110 @@ +.bitfun-agent-companion-window-root, +.bitfun-agent-companion-window-body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: transparent !important; +} + +.bitfun-agent-companion-window-body #root { + width: 100vw; + height: 100vh; + background: transparent; +} + +.bitfun-agent-companion-window { + width: 100vw; + height: 100vh; + position: relative; + background: transparent; + cursor: grab; + user-select: none; + + &:active { + cursor: grabbing; + } + + &__pet { + position: absolute; + right: 0; + bottom: 0; + width: min(96px, 100vw); + height: min(96px, 100vh); + max-width: 96px; + max-height: 96px; + transform-origin: center bottom; + filter: drop-shadow(0 12px 18px rgba(15, 23, 42, 0.18)); + } + + &__bubbles { + position: absolute; + right: 88px; + bottom: 24px; + width: min(252px, calc(100vw - 104px)); + max-height: calc(100vh - 16px); + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + pointer-events: none; + } + + &__bubble { + max-width: 100%; + min-width: 132px; + padding: 7px 10px; + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: 10px 10px 2px 10px; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.14); + color: #1f2937; + backdrop-filter: blur(10px); + } + + &__bubble-title, + &__bubble-status { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__bubble-title { + font-size: 11px; + line-height: 1.25; + font-weight: 650; + } + + &__bubble-status { + margin-top: 2px; + font-size: 10px; + line-height: 1.25; + color: rgba(31, 41, 55, 0.7); + } + + &__bubble--attention, + &__bubble--waiting { + border-color: rgba(217, 119, 6, 0.22); + background: rgba(255, 251, 235, 0.94); + } + + &__bubble--completed { + border-color: rgba(22, 163, 74, 0.18); + background: rgba(240, 253, 244, 0.94); + } + + &__bubble--error, + &__bubble--interrupted { + border-color: rgba(220, 38, 38, 0.18); + background: rgba(254, 242, 242, 0.94); + } + + .bitfun-chat-input-pixel-pet { + --bitfun-petdex-width: min(88px, 91.4vw); + --bitfun-petdex-height: min(96px, 100vh); + --bitfun-petdex-margin-top: 0; + + pointer-events: none; + } +} diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx new file mode 100644 index 000000000..ee1f45bd0 --- /dev/null +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { listen } from '@tauri-apps/api/event'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { aiExperienceConfigService, type AgentCompanionPetSelection, type AIExperienceSettings } from '@/infrastructure/config/services/AIExperienceConfigService'; +import { ChatInputPixelPet } from '@/flow_chat/components/ChatInputPixelPet'; +import type { ChatInputPetMood } from '@/flow_chat/utils/chatInputPetMood'; +import type { AgentCompanionActivityPayload, AgentCompanionTaskStatus } from '@/flow_chat/utils/agentCompanionActivity'; +import { createLogger } from '@/shared/utils/logger'; +import './AgentCompanionDesktopPet.scss'; + +const log = createLogger('AgentCompanionDesktopPet'); + +export const AgentCompanionDesktopPet: React.FC = () => { + const { t } = useTranslation('flow-chat'); + const [pet, setPet] = useState( + () => aiExperienceConfigService.getSettings().agent_companion_pet ?? null, + ); + const [mood, setMood] = useState('rest'); + const [tasks, setTasks] = useState([]); + + useEffect(() => { + document.documentElement.classList.add('bitfun-agent-companion-window-root'); + document.body.classList.add('bitfun-agent-companion-window-body'); + + const applySettings = (settings: AIExperienceSettings) => { + setPet(settings.agent_companion_pet ?? null); + if (!settings.enable_agent_companion || settings.agent_companion_display_mode !== 'desktop') { + void getCurrentWindow().close(); + } + }; + + void aiExperienceConfigService.getSettingsAsync().then(settings => { + applySettings(settings); + }); + + let removeTauriListener: (() => void) | null = null; + void listen('agent-companion://settings-updated', event => { + applySettings(event.payload); + }).then(unlisten => { + removeTauriListener = unlisten; + }).catch(error => { + log.warn('Failed to listen for Agent companion settings updates', error); + }); + + let removeActivityListener: (() => void) | null = null; + void listen('agent-companion://activity-updated', event => { + setMood(event.payload.mood); + setTasks(event.payload.tasks); + }).then(unlisten => { + removeActivityListener = unlisten; + }).catch(error => { + log.warn('Failed to listen for Agent companion activity updates', error); + }); + + const removeListener = aiExperienceConfigService.addChangeListener(settings => { + applySettings(settings); + }); + + return () => { + removeListener(); + removeTauriListener?.(); + removeActivityListener?.(); + document.documentElement.classList.remove('bitfun-agent-companion-window-root'); + document.body.classList.remove('bitfun-agent-companion-window-body'); + }; + }, []); + + const startDrag = () => { + void getCurrentWindow().startDragging().catch(error => { + log.warn('Failed to start Agent companion window drag', error); + }); + }; + + return ( +
void getCurrentWindow().close()} + title="Double-click to close" + > + {tasks.length > 0 && ( +
+ {tasks.map(task => ( +
+ + {task.title} + + + {t(task.labelKey, { defaultValue: task.defaultLabel })} + +
+ ))} +
+ )} + +
+ ); +}; + +export default AgentCompanionDesktopPet; diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 804125089..9b3da659b 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -56,6 +56,7 @@ import { deriveChatInputPetMood } from '../utils/chatInputPetMood'; import { ChatInputPixelPet } from './ChatInputPixelPet'; import { expandWidgetPromptReferenceTokens } from '@/tools/generative-widget/widgetPromptReference'; import { useDeepReviewConsent } from './DeepReviewConsentDialog'; +import { useAgentCompanionActivity } from '../hooks/useAgentCompanionActivity'; import { useSessionReviewActivity } from '../hooks/useSessionReviewActivity'; import { shouldBlockDeepReviewCommand } from '../utils/deepReviewCommandGuard'; import './ChatInput.scss'; @@ -273,29 +274,38 @@ export const ChatInput: React.FC = ({ ); const currentReviewActivity = useSessionReviewActivity(currentSessionId); const sessionMachineSnapshot = useSessionStateMachine(effectiveTargetSessionId); + const companionActivity = useAgentCompanionActivity(); const { confirmDeepReviewLaunch, deepReviewConsentDialog } = useDeepReviewConsent(); - const petMood = useMemo( + const targetPetMood = useMemo( () => deriveChatInputPetMood(sessionMachineSnapshot), [sessionMachineSnapshot], ); + const petMood = targetPetMood === 'rest' ? companionActivity.mood : targetPetMood; const [agentCompanionEnabled, setAgentCompanionEnabled] = useState( () => aiExperienceConfigService.getSettings().enable_agent_companion, ); + const [agentCompanionDisplayMode, setAgentCompanionDisplayMode] = useState( + () => aiExperienceConfigService.getSettings().agent_companion_display_mode, + ); const [agentCompanionPet, setAgentCompanionPet] = useState( () => aiExperienceConfigService.getSettings().agent_companion_pet ?? null, ); useEffect(() => { void aiExperienceConfigService.getSettingsAsync().then(initialSettings => { setAgentCompanionEnabled(initialSettings.enable_agent_companion); + setAgentCompanionDisplayMode(initialSettings.agent_companion_display_mode); setAgentCompanionPet(initialSettings.agent_companion_pet ?? null); }); return aiExperienceConfigService.addChangeListener(settings => { setAgentCompanionEnabled(settings.enable_agent_companion); + setAgentCompanionDisplayMode(settings.agent_companion_display_mode); setAgentCompanionPet(settings.agent_companion_pet ?? null); }); }, []); + const agentCompanionInInput = + agentCompanionEnabled && agentCompanionDisplayMode === 'input'; const showCollapsedPet = - agentCompanionEnabled && !inputState.isActive && !inputState.value.trim(); + agentCompanionInInput && !inputState.isActive && !inputState.value.trim(); const { transition, setQueuedInput } = useSessionStateMachineActions(effectiveTargetSessionId); const { workspace, workspacePath } = useCurrentWorkspace(); @@ -2266,7 +2276,7 @@ export const ChatInput: React.FC = ({ }, []); const isCollapsedProcessing = !inputState.isActive && !!derivedState?.isProcessing; - const petReplacesStopChrome = agentCompanionEnabled && isCollapsedProcessing; + const petReplacesStopChrome = agentCompanionInInput && isCollapsedProcessing; const petStopClickable = petReplacesStopChrome && derivedState?.canCancel; const collapsedPetSplitSend = petReplacesStopChrome && derivedState?.sendButtonMode === 'split'; @@ -2530,7 +2540,7 @@ export const ChatInput: React.FC = ({ {!inputState.isActive && !inputState.value.trim() && - !agentCompanionEnabled && ( + !agentCompanionInInput && ( ( + () => buildAgentCompanionActivity(), + ); + + useEffect(() => subscribeAgentCompanionActivity(setActivity), []); + + return activity; +} diff --git a/src/web-ui/src/flow_chat/services/AgentCompanionActivityBridge.ts b/src/web-ui/src/flow_chat/services/AgentCompanionActivityBridge.ts new file mode 100644 index 000000000..7f240f183 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/AgentCompanionActivityBridge.ts @@ -0,0 +1,18 @@ +import { isTauriRuntime } from '@/infrastructure/runtime'; +import { createLogger } from '@/shared/utils/logger'; +import type { AgentCompanionActivityPayload } from '../utils/agentCompanionActivity'; + +const log = createLogger('AgentCompanionActivityBridge'); + +export async function emitAgentCompanionActivity( + activity: AgentCompanionActivityPayload, +): Promise { + if (!isTauriRuntime()) return; + + try { + const { emit } = await import('@tauri-apps/api/event'); + await emit('agent-companion://activity-updated', activity); + } catch (error) { + log.warn('Failed to emit Agent companion activity update', error); + } +} diff --git a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts new file mode 100644 index 000000000..4c9032d79 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts @@ -0,0 +1,281 @@ +import { FlowChatStore } from '../store/FlowChatStore'; +import { stateMachineManager } from '../state-machine/SessionStateMachineManager'; +import { ProcessingPhase, type SessionStateMachine } from '../state-machine/types'; +import { deriveChatInputPetMood, type ChatInputPetMood } from './chatInputPetMood'; +import type { Session } from '../types/flow-chat'; + +export type AgentCompanionTaskState = + | 'running' + | 'waiting' + | 'attention' + | 'completed' + | 'error' + | 'interrupted'; + +export interface AgentCompanionTaskStatus { + sessionId: string; + title: string; + mood: ChatInputPetMood; + state: AgentCompanionTaskState; + labelKey: string; + defaultLabel: string; + startedAt: number; + updatedAt: number; +} + +export interface AgentCompanionActivityPayload { + mood: ChatInputPetMood; + tasks: AgentCompanionTaskStatus[]; +} + +const EMPTY_ACTIVITY: AgentCompanionActivityPayload = { + mood: 'rest', + tasks: [], +}; + +const taskOrderBySessionId = new Map(); +let nextTaskOrder = 0; + +function ensureTaskOrder(sessionId: string): number { + const existingOrder = taskOrderBySessionId.get(sessionId); + if (existingOrder !== undefined) { + return existingOrder; + } + + const order = nextTaskOrder; + nextTaskOrder += 1; + taskOrderBySessionId.set(sessionId, order); + return order; +} + +function pruneTaskOrder(activeTasks: AgentCompanionTaskStatus[]): void { + const activeSessionIds = new Set(activeTasks.map(task => task.sessionId)); + Array.from(taskOrderBySessionId.keys()).forEach(sessionId => { + if (!activeSessionIds.has(sessionId)) { + taskOrderBySessionId.delete(sessionId); + } + }); +} + +function sessionTitle(session: Session): string { + return session.title?.trim() || 'Session'; +} + +function runningLabel(snapshot: SessionStateMachine | null): { + state: AgentCompanionTaskState; + labelKey: string; + defaultLabel: string; +} { + switch (snapshot?.context.processingPhase) { + case ProcessingPhase.THINKING: + return { + state: 'running', + labelKey: 'agentCompanion.activity.thinking', + defaultLabel: 'Thinking', + }; + case ProcessingPhase.TOOL_CALLING: + return { + state: 'waiting', + labelKey: 'agentCompanion.activity.usingTools', + defaultLabel: 'Using tools', + }; + case ProcessingPhase.TOOL_CONFIRMING: + return { + state: 'attention', + labelKey: 'agentCompanion.activity.waitingApproval', + defaultLabel: 'Waiting for approval', + }; + case ProcessingPhase.STREAMING: + return { + state: 'running', + labelKey: 'agentCompanion.activity.writing', + defaultLabel: 'Writing', + }; + case ProcessingPhase.COMPACTING: + return { + state: 'running', + labelKey: 'agentCompanion.activity.compacting', + defaultLabel: 'Compacting context', + }; + case ProcessingPhase.FINALIZING: + return { + state: 'running', + labelKey: 'agentCompanion.activity.finishing', + defaultLabel: 'Finishing', + }; + case ProcessingPhase.STARTING: + return { + state: 'running', + labelKey: 'agentCompanion.activity.starting', + defaultLabel: 'Starting', + }; + default: + return { + state: 'running', + labelKey: 'agentCompanion.activity.working', + defaultLabel: 'Working', + }; + } +} + +function attentionTask(session: Session): AgentCompanionTaskStatus | null { + if (session.needsUserAttention === 'ask_user') { + return { + sessionId: session.sessionId, + title: sessionTitle(session), + mood: 'waiting', + state: 'attention', + labelKey: 'agentCompanion.activity.needsInput', + defaultLabel: 'Needs input', + startedAt: session.lastActiveAt || session.updatedAt || session.createdAt, + updatedAt: session.updatedAt || session.lastActiveAt || session.createdAt, + }; + } + + if (session.needsUserAttention === 'tool_confirm') { + return { + sessionId: session.sessionId, + title: sessionTitle(session), + mood: 'waiting', + state: 'attention', + labelKey: 'agentCompanion.activity.needsApproval', + defaultLabel: 'Needs approval', + startedAt: session.lastActiveAt || session.updatedAt || session.createdAt, + updatedAt: session.updatedAt || session.lastActiveAt || session.createdAt, + }; + } + + return null; +} + +function completionTask(session: Session): AgentCompanionTaskStatus | null { + if (!session.hasUnreadCompletion) { + return null; + } + + const base = { + sessionId: session.sessionId, + title: sessionTitle(session), + mood: 'rest' as ChatInputPetMood, + startedAt: session.lastFinishedAt || session.updatedAt || session.lastActiveAt || session.createdAt, + updatedAt: session.lastFinishedAt || session.updatedAt || session.lastActiveAt || session.createdAt, + }; + + if (session.hasUnreadCompletion === 'completed') { + return { + ...base, + state: 'completed', + labelKey: 'agentCompanion.activity.completed', + defaultLabel: 'Completed', + }; + } + + if (session.hasUnreadCompletion === 'interrupted') { + return { + ...base, + state: 'interrupted', + labelKey: 'agentCompanion.activity.interrupted', + defaultLabel: 'Interrupted', + }; + } + + return { + ...base, + state: 'error', + labelKey: 'agentCompanion.activity.failed', + defaultLabel: 'Failed', + }; +} + +function taskStableOrder(task: AgentCompanionTaskStatus): number { + return ensureTaskOrder(task.sessionId); +} + +function aggregateMood(tasks: AgentCompanionTaskStatus[]): ChatInputPetMood { + if (tasks.some(task => task.mood === 'waiting')) { + return 'waiting'; + } + if (tasks.some(task => task.mood === 'analyzing')) { + return 'analyzing'; + } + if (tasks.some(task => task.mood === 'working')) { + return 'working'; + } + return 'rest'; +} + +export function buildAgentCompanionActivity(): AgentCompanionActivityPayload { + const sessions = Array.from(FlowChatStore.getInstance().getState().sessions.values()) + .filter(session => !session.isTransient); + const tasks: AgentCompanionTaskStatus[] = []; + + sessions.forEach(session => { + const snapshot = stateMachineManager.getSnapshot(session.sessionId); + const mood = deriveChatInputPetMood(snapshot); + + if (mood !== 'rest') { + const label = runningLabel(snapshot); + tasks.push({ + sessionId: session.sessionId, + title: sessionTitle(session), + mood, + state: label.state, + labelKey: label.labelKey, + defaultLabel: label.defaultLabel, + startedAt: snapshot?.context.stats.startTime || session.lastActiveAt || session.updatedAt || session.createdAt, + updatedAt: snapshot?.context.lastUpdateTime || session.updatedAt || session.lastActiveAt || session.createdAt, + }); + return; + } + + const attention = attentionTask(session); + if (attention) { + tasks.push(attention); + return; + } + + const completion = completionTask(session); + if (completion) { + tasks.push(completion); + } + }); + + if (!tasks.length) { + pruneTaskOrder(tasks); + return EMPTY_ACTIVITY; + } + + [...tasks] + .sort((a, b) => a.startedAt - b.startedAt) + .forEach(task => { + ensureTaskOrder(task.sessionId); + }); + pruneTaskOrder(tasks); + + const sortedTasks = tasks + .sort((a, b) => taskStableOrder(a) - taskStableOrder(b)) + .slice(0, 4); + + return { + mood: aggregateMood(sortedTasks), + tasks: sortedTasks, + }; +} + +export function subscribeAgentCompanionActivity( + listener: (payload: AgentCompanionActivityPayload) => void, +): () => void { + const emitCurrent = () => { + listener(buildAgentCompanionActivity()); + }; + + const unsubscribeStore = FlowChatStore.getInstance().subscribe(emitCurrent); + const unsubscribeMachines = stateMachineManager.subscribeGlobal(emitCurrent); + + emitCurrent(); + + return () => { + unsubscribeStore(); + unsubscribeMachines(); + }; +} diff --git a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx index 3b14c51c9..8896f8fbb 100644 --- a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx @@ -319,6 +319,19 @@ const SessionConfig: React.FC = () => { })), ]; + const companionDisplayModeOptions: SelectOption[] = [ + { + value: 'desktop', + label: t('features.agentCompanion.displayDesktop'), + description: t('features.agentCompanion.displayDesktopDesc'), + }, + { + value: 'input', + label: t('features.agentCompanion.displayInput'), + description: t('features.agentCompanion.displayInputDesc'), + }, + ]; + const selectedCompanionPet = settings?.agent_companion_pet ? companionPets.find(pet => pet.packagePath === settings.agent_companion_pet?.packagePath) ?? settings.agent_companion_pet @@ -733,6 +746,25 @@ const SessionConfig: React.FC = () => { /> + +