diff --git a/src/apps/desktop/src/api/snapshot_service.rs b/src/apps/desktop/src/api/snapshot_service.rs index 1dbaa4563..e8b0593d4 100644 --- a/src/apps/desktop/src/api/snapshot_service.rs +++ b/src/apps/desktop/src/api/snapshot_service.rs @@ -132,6 +132,14 @@ pub struct GetOperationDiffRequest { pub workspace_path: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSessionFileDiffStatsRequest { + pub sessionId: String, + pub filePath: String, + #[serde(alias = "workspacePath")] + pub workspace_path: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetOperationSummaryRequest { pub sessionId: String, @@ -668,6 +676,20 @@ pub async fn get_operation_diff( })) } +#[tauri::command] +pub async fn get_session_file_diff_stats( + request: GetSessionFileDiffStatsRequest, +) -> Result { + let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + + let stats = manager + .get_session_file_diff_stats(&request.sessionId, &request.filePath) + .await + .map_err(|e| format!("Failed to get session file diff stats: {}", e))?; + + serde_json::to_value(&stats).map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn get_operation_summary( request: GetOperationSummaryRequest, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index beb6da287..0b090305e 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -512,6 +512,7 @@ pub async fn run() { get_turn_files, get_file_diff, get_operation_diff, + get_session_file_diff_stats, get_operation_summary, get_session_operations, accept_operation, diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index 2b95cdb7e..390d65107 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -1,5 +1,7 @@ //! Theme System +use std::sync::OnceLock; + use bitfun_core::infrastructure::try_get_path_manager_arc; use bitfun_core::service::config::types::GlobalConfig; use dark_light::Mode; @@ -12,6 +14,12 @@ 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; +static AGENT_COMPANION_WINDOW_OPS: OnceLock> = OnceLock::new(); + +fn agent_companion_window_ops() -> &'static tokio::sync::Mutex<()> { + AGENT_COMPANION_WINDOW_OPS.get_or_init(|| tokio::sync::Mutex::new(())) +} + #[derive(Debug, Clone)] pub struct ThemeConfig { pub id: String, @@ -403,32 +411,30 @@ fn resize_agent_companion_window( 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); + // Keep the bottom-right corner fixed when bubbles change height. If we cannot + // read the previous geometry (e.g. transient platform errors), avoid snapping + // back to the default corner — that would feel like the pet "jumped". + if let Some((position, size)) = old_position.zip(old_size) { + let next_position = tauri::LogicalPosition::new( + position.x + size.width - width, + position.y + size.height - height, + ); + if let Err(e) = window.set_position(next_position) { + warn!("Failed to position Agent companion window: {}", e); + } } } #[tauri::command] pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(), String> { + let _guard = agent_companion_window_ops().lock().await; + + // Reuse any existing window: never destroy here. A previous implementation destroyed + // whenever `is_visible` was false, which raced with another `show` that had built the + // window but not called `show()` yet (or with `hide`), producing duplicate pets or + // stuck windows. if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) { - position_agent_companion_window(&app, &window); + let _ = window.unminimize(); window.show().map_err(|e| { error!("Failed to show Agent companion window: {}", e); format!("Failed to show Agent companion window: {}", e) @@ -481,6 +487,7 @@ pub async fn resize_agent_companion_desktop_pet( width: f64, height: f64, ) -> Result<(), String> { + let _guard = agent_companion_window_ops().lock().await; if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) { resize_agent_companion_window(&app, &window, width, height); } @@ -489,10 +496,11 @@ pub async fn resize_agent_companion_desktop_pet( #[tauri::command] pub async fn hide_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(), String> { + let _guard = agent_companion_window_ops().lock().await; 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) + window.destroy().map_err(|e| { + error!("Failed to destroy Agent companion window: {}", e); + format!("Failed to destroy Agent companion window: {}", e) })?; } Ok(()) diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index 34068f668..32d7e528d 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -1713,12 +1713,12 @@ mod tests { "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.", + "id": "boxcat", + "displayName": "Boxcat", + "description": "A tiny cat tucked inside a cardboard box for cozy coding sessions.", "source": "preset", - "packagePath": "/agent-companion-pets/pixel-panda", - "spritesheetPath": "/agent-companion-pets/pixel-panda/spritesheet.webp", + "packagePath": "/agent-companion-pets/boxcat", + "spritesheetPath": "/agent-companion-pets/boxcat/spritesheet.webp", "spritesheetMimeType": "image/webp" } })) @@ -1728,19 +1728,19 @@ mod tests { .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!(pet.id, "boxcat"); + assert_eq!(pet.display_name, "Boxcat"); + assert_eq!(pet.package_path, "/agent-companion-pets/boxcat"); 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" + "Boxcat" ); assert_eq!( serialized["agent_companion_pet"]["spritesheetPath"], - "/agent-companion-pets/pixel-panda/spritesheet.webp" + "/agent-companion-pets/boxcat/spritesheet.webp" ); } diff --git a/src/crates/core/src/service/snapshot/manager.rs b/src/crates/core/src/service/snapshot/manager.rs index 8e2910c90..dccd2e6d7 100644 --- a/src/crates/core/src/service/snapshot/manager.rs +++ b/src/crates/core/src/service/snapshot/manager.rs @@ -147,6 +147,18 @@ impl SnapshotManager { })) } + pub async fn get_session_file_diff_stats( + &self, + session_id: &str, + file_path: &str, + ) -> SnapshotResult { + let snapshot_service = self.snapshot_service.read().await; + let file_path = std::path::Path::new(file_path); + snapshot_service + .get_session_file_diff_stats(session_id, file_path) + .await + } + pub async fn get_operation_summary( &self, session_id: &str, diff --git a/src/crates/core/src/service/snapshot/service.rs b/src/crates/core/src/service/snapshot/service.rs index 4a9ecf834..722c74e85 100644 --- a/src/crates/core/src/service/snapshot/service.rs +++ b/src/crates/core/src/service/snapshot/service.rs @@ -168,6 +168,18 @@ impl SnapshotService { .await } + pub async fn get_session_file_diff_stats( + &self, + session_id: &str, + file_path: &Path, + ) -> SnapshotResult { + self.ensure_initialized().await?; + let snapshot_core = self.snapshot_core.read().await; + snapshot_core + .get_session_file_diff_stats(session_id, file_path) + .await + } + pub async fn get_operation_summary( &self, session_id: &str, diff --git a/src/crates/core/src/service/snapshot/snapshot_core.rs b/src/crates/core/src/service/snapshot/snapshot_core.rs index c324d938a..ab8f662e3 100644 --- a/src/crates/core/src/service/snapshot/snapshot_core.rs +++ b/src/crates/core/src/service/snapshot/snapshot_core.rs @@ -1,6 +1,7 @@ use crate::service::snapshot::snapshot_system::FileSnapshotSystem; use crate::service::snapshot::types::{ - DiffSummary, FileOperation, OperationType, SnapshotError, SnapshotResult, ToolContext, + DiffSummary, FileOperation, OperationType, SessionFileDiffStats, SnapshotError, SnapshotResult, + ToolContext, }; use crate::service::workspace_runtime::WorkspaceRuntimeContext; use log::{debug, info, warn}; @@ -48,6 +49,9 @@ struct SessionHistory { last_updated: SystemTime, } +/// Per-side size budget: above this we avoid loading baseline/disk texts for UI badge stats. +const SESSION_FILE_DIFF_STATS_MAX_SOURCE_BYTES: u64 = 512 * 1024; + impl SessionHistory { fn new(session_id: String) -> Self { let now = SystemTime::now(); @@ -570,6 +574,99 @@ impl SnapshotCore { Ok((before, after, mapped_anchor)) } + /// Line insert/delete counts versus session baseline vs workspace, without returning file bodies. + /// Large files skip full reads and aggregate per-operation diff summaries (`approximate: true`). + pub async fn get_session_file_diff_stats( + &self, + session_id: &str, + file_path: &Path, + ) -> SnapshotResult { + let Some(session) = self.sessions.get(session_id) else { + return Err(SnapshotError::SessionNotFound(session_id.to_string())); + }; + + let file_created = session_file_created_in_session(session, file_path); + + let workspace_bytes = if file_path.exists() { + tokio::fs::metadata(file_path) + .await + .map(|m| m.len()) + .unwrap_or(SESSION_FILE_DIFF_STATS_MAX_SOURCE_BYTES.saturating_add(1)) + } else { + 0 + }; + + let before_bytes = if file_created { + 0 + } else { + let before_snapshot_id = if let Some(baseline_id) = self + .snapshot_system + .get_baseline_snapshot_id(file_path) + .await + { + Some(baseline_id) + } else { + session + .all_operations_iter() + .find(|op| op.file_path == file_path) + .and_then(|op| op.before_snapshot_id.clone()) + }; + + match before_snapshot_id { + None => 0, + Some(id) => self + .snapshot_system + .get_snapshot_recorded_size_bytes(&id) + .await + .unwrap_or(SESSION_FILE_DIFF_STATS_MAX_SOURCE_BYTES.saturating_add(1)), + } + }; + + let too_large = workspace_bytes > SESSION_FILE_DIFF_STATS_MAX_SOURCE_BYTES + || before_bytes > SESSION_FILE_DIFF_STATS_MAX_SOURCE_BYTES; + + let path_exists = file_path.exists(); + + if too_large { + let agg = aggregate_operations_diff_summary_for_file(session, file_path); + let change_kind = change_kind_for_aggregate_path(file_created, path_exists); + debug!( + "get_session_file_diff_stats: approximate session_id={} file_path={:?} workspace_bytes={} before_bytes={} lines_added={} lines_removed={}", + session_id, + file_path, + workspace_bytes, + before_bytes, + agg.lines_added, + agg.lines_removed + ); + return Ok(SessionFileDiffStats { + file_path: file_path.to_string_lossy().to_string(), + lines_added: agg.lines_added, + lines_removed: agg.lines_removed, + approximate: true, + change_kind: change_kind.to_string(), + }); + } + + let (before, after) = self.get_file_diff(file_path, session_id).await?; + let summary = compute_diff_summary(&before, &after); + let change_kind = change_kind_from_diff_content(file_created, &before, &after); + debug!( + "get_session_file_diff_stats: exact session_id={} file_path={:?} lines_added={} lines_removed={}", + session_id, + file_path, + summary.lines_added, + summary.lines_removed + ); + Ok(SessionFileDiffStats { + file_path: file_path.to_string_lossy().to_string(), + lines_added: summary.lines_added, + lines_removed: summary.lines_removed, + approximate: false, + change_kind: change_kind.to_string(), + }) + } + pub fn get_file_change_history(&self, file_path: &Path) -> Vec { let mut entries = Vec::new(); for session in self.sessions.values() { @@ -903,6 +1000,53 @@ impl SnapshotCore { } } +fn session_file_created_in_session(session: &SessionHistory, file_path: &Path) -> bool { + session + .all_operations_iter() + .find(|op| op.file_path == file_path) + .map(|op| op.before_snapshot_id.is_none()) + .unwrap_or(false) +} + +fn aggregate_operations_diff_summary_for_file( + session: &SessionHistory, + file_path: &Path, +) -> DiffSummary { + let mut out = DiffSummary::default(); + for op in session.all_operations_iter() { + if op.file_path.as_path() == file_path { + out.lines_added += op.diff_summary.lines_added; + out.lines_removed += op.diff_summary.lines_removed; + out.lines_modified += op.diff_summary.lines_modified; + } + } + out +} + +fn change_kind_for_aggregate_path(file_created_in_session: bool, path_exists: bool) -> &'static str { + if file_created_in_session { + "create" + } else if !path_exists { + "delete" + } else { + "modify" + } +} + +fn change_kind_from_diff_content( + file_created_in_session: bool, + before: &str, + after: &str, +) -> &'static str { + if file_created_in_session { + return "create"; + } + if !before.is_empty() && after.is_empty() { + return "delete"; + } + "modify" +} + fn sanitize_id(id: &str) -> String { id.chars() .map(|c| { diff --git a/src/crates/core/src/service/snapshot/snapshot_system.rs b/src/crates/core/src/service/snapshot/snapshot_system.rs index 1bf2a8b8c..c23eb1812 100644 --- a/src/crates/core/src/service/snapshot/snapshot_system.rs +++ b/src/crates/core/src/service/snapshot/snapshot_system.rs @@ -526,6 +526,12 @@ impl FileSnapshotSystem { None } + /// Recorded logical size (bytes) from snapshot metadata, without loading file contents. + pub async fn get_snapshot_recorded_size_bytes(&self, snapshot_id: &str) -> SnapshotResult { + let snapshot = self.load_snapshot_from_disk(snapshot_id).await?; + Ok(snapshot.metadata.size) + } + /// Loads snapshot metadata from disk (without using in-memory cache). async fn load_snapshot_from_disk(&self, snapshot_id: &str) -> SnapshotResult { debug!( diff --git a/src/crates/core/src/service/snapshot/types.rs b/src/crates/core/src/service/snapshot/types.rs index 833a00a28..f24d48c86 100644 --- a/src/crates/core/src/service/snapshot/types.rs +++ b/src/crates/core/src/service/snapshot/types.rs @@ -62,6 +62,19 @@ pub struct DiffSummary { pub lines_modified: usize, } +/// Line-level diff stats for a session file (badge / toolbars), without full file contents. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionFileDiffStats { + pub file_path: String, + pub lines_added: usize, + pub lines_removed: usize, + /// True when stats were derived from per-operation summaries instead of a full baseline vs disk diff. + pub approximate: bool, + /// `create`, `modify`, or `delete` for UI mapping. + pub change_kind: String, +} + /// File modification status #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum FileModificationStatus { diff --git a/src/web-ui/public/agent-companion-pets/capy/pet.json b/src/web-ui/public/agent-companion-pets/capy/pet.json new file mode 100644 index 000000000..a7f87fc46 --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/capy/pet.json @@ -0,0 +1,6 @@ +{ + "id": "capy", + "displayName": "Capy", + "description": "An original emotionally stable capybara with a tiny orange on its head.", + "spritesheetPath": "spritesheet.webp" +} diff --git a/src/web-ui/public/agent-companion-pets/capy/spritesheet.webp b/src/web-ui/public/agent-companion-pets/capy/spritesheet.webp new file mode 100644 index 000000000..e8885c4b4 Binary files /dev/null and b/src/web-ui/public/agent-companion-pets/capy/spritesheet.webp differ diff --git a/src/web-ui/public/agent-companion-pets/cream-cat/pet.json b/src/web-ui/public/agent-companion-pets/cream-cat/pet.json deleted file mode 100644 index 4f28b83bb..000000000 --- a/src/web-ui/public/agent-companion-pets/cream-cat/pet.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "id": "cream-cat", - "displayName": "Cream Cat", - "description": "A tiny cream pixel-art cat with a heart bubble for happy moments.", - "spritesheetPath": "spritesheet.webp" -} diff --git a/src/web-ui/public/agent-companion-pets/cream-cat/spritesheet.webp b/src/web-ui/public/agent-companion-pets/cream-cat/spritesheet.webp deleted file mode 100644 index 2f629cc50..000000000 Binary files a/src/web-ui/public/agent-companion-pets/cream-cat/spritesheet.webp and /dev/null differ diff --git a/src/web-ui/public/agent-companion-pets/hachiware/pet.json b/src/web-ui/public/agent-companion-pets/hachiware/pet.json new file mode 100644 index 000000000..f7ee97506 --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/hachiware/pet.json @@ -0,0 +1,6 @@ +{ + "id": "hachiware", + "displayName": "Hachiware", + "description": "A tiny Hachiware-inspired desktop pet with white and blue cat markings, bright eyes, and cheerful expressions.", + "spritesheetPath": "spritesheet.webp" +} diff --git a/src/web-ui/public/agent-companion-pets/hachiware/spritesheet.webp b/src/web-ui/public/agent-companion-pets/hachiware/spritesheet.webp new file mode 100644 index 000000000..9e29a5ca4 Binary files /dev/null and b/src/web-ui/public/agent-companion-pets/hachiware/spritesheet.webp differ diff --git a/src/web-ui/public/agent-companion-pets/jiyi/pet.json b/src/web-ui/public/agent-companion-pets/jiyi/pet.json new file mode 100644 index 000000000..d620a1b98 --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/jiyi/pet.json @@ -0,0 +1,6 @@ +{ + "id": "jiyi", + "displayName": "\u5409\u4f0a", + "description": "A round white chibi bear with dark chocolate outlines, pink cheeks, tiny limbs, curled ears, and a small pink bear pouch.", + "spritesheetPath": "spritesheet.webp" +} diff --git a/src/web-ui/public/agent-companion-pets/jiyi/spritesheet.webp b/src/web-ui/public/agent-companion-pets/jiyi/spritesheet.webp new file mode 100644 index 000000000..b8ff29308 Binary files /dev/null and b/src/web-ui/public/agent-companion-pets/jiyi/spritesheet.webp differ diff --git a/src/web-ui/public/agent-companion-pets/panda-pix/pet.json b/src/web-ui/public/agent-companion-pets/panda-pix/pet.json new file mode 100644 index 000000000..63dff68b8 --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/panda-pix/pet.json @@ -0,0 +1,6 @@ +{ + "id": "panda-pix", + "displayName": "Panda", + "description": "Codux bundled pet atlas.", + "spritesheetPath": "spritesheet.png" +} diff --git a/src/web-ui/public/agent-companion-pets/panda-pix/spritesheet.png b/src/web-ui/public/agent-companion-pets/panda-pix/spritesheet.png new file mode 100644 index 000000000..03b1004c0 Binary files /dev/null and b/src/web-ui/public/agent-companion-pets/panda-pix/spritesheet.png differ diff --git a/src/web-ui/public/agent-companion-pets/pixel-panda/pet.json b/src/web-ui/public/agent-companion-pets/pixel-panda/pet.json deleted file mode 100644 index 68b164e8c..000000000 --- a/src/web-ui/public/agent-companion-pets/pixel-panda/pet.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "id": "pixel-panda", - "displayName": "Pixel Panda", - "description": "A gentle panda holding a tiny pixel brush.", - "spritesheetPath": "spritesheet.webp" -} diff --git a/src/web-ui/public/agent-companion-pets/pixel-panda/spritesheet.webp b/src/web-ui/public/agent-companion-pets/pixel-panda/spritesheet.webp deleted file mode 100644 index eae836ad8..000000000 Binary files a/src/web-ui/public/agent-companion-pets/pixel-panda/spritesheet.webp and /dev/null differ diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index 88d1abb25..849f2be59 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -35,9 +35,6 @@ export const AgentCompanionDesktopPet: React.FC = () => { 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 => { @@ -63,12 +60,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { 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'); @@ -144,8 +136,6 @@ export const AgentCompanionDesktopPet: React.FC = () => { return (
void getCurrentWindow().close()} - title="Double-click to close" > {tasks.length > 0 && (
= ({ label={t('ssh.remote.host')} value={formData.host} onChange={(e) => handleInputChange('host', e.target.value)} - placeholder="example.com" + placeholder="" prefix={} size="medium" /> @@ -575,7 +575,7 @@ export const SSHConnectionDialog: React.FC = ({ label={t('ssh.remote.username')} value={formData.username} onChange={(e) => handleInputChange('username', e.target.value)} - placeholder="root" + placeholder="" prefix={} size="medium" /> @@ -613,7 +613,7 @@ export const SSHConnectionDialog: React.FC = ({ type="password" value={formData.password} onChange={(e) => handleInputChange('password', e.target.value)} - placeholder={t('ssh.remote.password')} + placeholder="" prefix={} size="medium" /> diff --git a/src/web-ui/src/flow_chat/components/ChatInputPixelPet.scss b/src/web-ui/src/flow_chat/components/ChatInputPixelPet.scss index 7614f45ef..9bee0b7b0 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputPixelPet.scss +++ b/src/web-ui/src/flow_chat/components/ChatInputPixelPet.scss @@ -69,32 +69,32 @@ image-rendering: pixelated; transform-origin: 50% 80%; animation: - bitfun-petdex-walk 0.9s steps(6) infinite, - bitfun-petdex-breathe 3s ease-in-out infinite; + bitfun-petdex-walk 1.8s steps(6) infinite, + bitfun-petdex-breathe 6s ease-in-out infinite; } &__petdex--rest { animation: - bitfun-petdex-walk 1.2s steps(6) infinite, - bitfun-petdex-breathe 3.2s ease-in-out infinite; + bitfun-petdex-walk 2.4s steps(6) infinite, + bitfun-petdex-breathe 6.4s ease-in-out infinite; } &__petdex--working { animation: - bitfun-petdex-walk 0.58s steps(6) infinite, - bitfun-petdex-work 0.28s linear infinite; + bitfun-petdex-walk 1.16s steps(6) infinite, + bitfun-petdex-work 0.56s linear infinite; } &__petdex--hover { animation: - bitfun-petdex-walk 0.72s steps(6) infinite, - bitfun-petdex-hover 0.95s ease-in-out infinite; + bitfun-petdex-walk 1.44s steps(6) infinite, + bitfun-petdex-hover 1.9s ease-in-out infinite; } &__petdex--dragging { animation: - bitfun-petdex-walk 0.48s steps(6) infinite, - bitfun-petdex-dragging 0.24s linear infinite; + bitfun-petdex-walk 0.96s steps(6) infinite, + bitfun-petdex-dragging 0.48s linear infinite; } } @@ -127,36 +127,36 @@ .bitfun-panda-head { transform-origin: 50% 88%; transform-box: fill-box; - animation: bitfun-head-breathe 3.4s ease-in-out infinite; + animation: bitfun-head-breathe 6.8s ease-in-out infinite; &--analyzing { animation: - bitfun-head-breathe 2.6s ease-in-out infinite, - bitfun-head-tilt 2.4s ease-in-out infinite; + bitfun-head-breathe 5.2s ease-in-out infinite, + bitfun-head-tilt 4.8s ease-in-out infinite; } &--waiting { animation: - bitfun-head-breathe 2.6s ease-in-out infinite, - bitfun-head-look 3s ease-in-out infinite; + bitfun-head-breathe 5.2s ease-in-out infinite, + bitfun-head-look 6s ease-in-out infinite; } &--working { animation: - bitfun-head-breathe 2.6s ease-in-out infinite, - bitfun-head-shake 0.18s linear infinite; + bitfun-head-breathe 5.2s ease-in-out infinite, + bitfun-head-shake 0.36s linear infinite; } &--hover { animation: - bitfun-head-breathe 2.1s ease-in-out infinite, - bitfun-head-perk 0.95s ease-in-out infinite; + bitfun-head-breathe 4.2s ease-in-out infinite, + bitfun-head-perk 1.9s ease-in-out infinite; } &--dragging { animation: - bitfun-head-breathe 2.4s ease-in-out infinite, - bitfun-head-carried 0.24s linear infinite; + bitfun-head-breathe 4.8s ease-in-out infinite, + bitfun-head-carried 0.48s linear infinite; } } @@ -165,7 +165,7 @@ .bitfun-panda-head { &__face-layer { opacity: 0; - transition: opacity 0.32s ease; + transition: opacity 0.64s ease; &[data-active='true'] { opacity: 1; @@ -183,30 +183,30 @@ &--a { font-size: 30px; - animation: bitfun-zzz-rise 3.4s ease-in-out infinite; + animation: bitfun-zzz-rise 6.8s ease-in-out infinite; } &--b { font-size: 40px; - animation: bitfun-zzz-rise 3.4s ease-in-out -1.1s infinite; + animation: bitfun-zzz-rise 6.8s ease-in-out -2.2s infinite; } &--c { font-size: 54px; - animation: bitfun-zzz-rise 3.4s ease-in-out -2.2s infinite; + animation: bitfun-zzz-rise 6.8s ease-in-out -4.4s infinite; } } /* Analyzing — ears twitch + think pips climb upward in sequence. */ &--analyzing .bitfun-panda-head__ear--left { - animation: bitfun-ear-twitch-l 1.4s ease-in-out infinite; + animation: bitfun-ear-twitch-l 2.8s ease-in-out infinite; transform-origin: 50% 100%; transform-box: fill-box; } &--analyzing .bitfun-panda-head__ear--right { - animation: bitfun-ear-twitch-r 1.4s ease-in-out 0.18s infinite; + animation: bitfun-ear-twitch-r 2.8s ease-in-out 0.72s infinite; transform-origin: 50% 100%; transform-box: fill-box; } @@ -214,25 +214,25 @@ &__think-pip { fill: var(--bitfun-pet-decor); opacity: 0; - animation: bitfun-think-pulse 1.6s ease-in-out infinite; + animation: bitfun-think-pulse 3.2s ease-in-out infinite; &:nth-child(1) { animation-delay: 0s; } &:nth-child(2) { - animation-delay: 0.2s; + animation-delay: 0.4s; } &:nth-child(3) { - animation-delay: 0.4s; + animation-delay: 0.8s; } } /* Waiting — front paw taps + dots bob. */ &--waiting .bitfun-panda-head__paw--front { - animation: bitfun-paw-tap 0.9s ease-in-out infinite; + animation: bitfun-paw-tap 1.8s ease-in-out infinite; transform-origin: 70% 60%; transform-box: fill-box; } @@ -240,18 +240,18 @@ &__wait-pip { fill: var(--bitfun-pet-decor); opacity: 0.5; - animation: bitfun-wait-bounce 1.1s ease-in-out infinite; + animation: bitfun-wait-bounce 2.2s ease-in-out infinite; &:nth-child(1) { animation-delay: 0s; } &:nth-child(2) { - animation-delay: 0.18s; + animation-delay: 0.72s; } &:nth-child(3) { - animation-delay: 0.36s; + animation-delay: 1.44s; } } @@ -260,18 +260,18 @@ &__sweat-drop { fill: var(--bitfun-pet-decor-soft); opacity: 0; - animation: bitfun-sweat-fall 1.6s ease-in infinite; + animation: bitfun-sweat-fall 3.2s ease-in infinite; } &--working .bitfun-panda-head__ear--right { - animation: bitfun-ear-tense 1.6s ease-in-out infinite; + animation: bitfun-ear-tense 3.2s ease-in-out infinite; transform-origin: 50% 100%; transform-box: fill-box; } &--hover .bitfun-panda-head__ear--left, &--hover .bitfun-panda-head__ear--right { - animation: bitfun-ear-perk 0.95s ease-in-out infinite; + animation: bitfun-ear-perk 1.9s ease-in-out infinite; transform-origin: 50% 100%; transform-box: fill-box; } @@ -281,16 +281,16 @@ opacity: 0; transform-origin: center; transform-box: fill-box; - animation: bitfun-sparkle-pop 1.2s ease-in-out infinite; + animation: bitfun-sparkle-pop 2.4s ease-in-out infinite; &--b { - animation-delay: 0.22s; + animation-delay: 0.44s; } } &--dragging .bitfun-panda-head__paw--front, &--dragging .bitfun-panda-head__paw--rear { - animation: bitfun-paw-hold 0.48s ease-in-out infinite; + animation: bitfun-paw-hold 0.96s ease-in-out infinite; transform-origin: 50% 60%; transform-box: fill-box; } @@ -301,10 +301,10 @@ stroke-width: 8; stroke-linecap: round; opacity: 0; - animation: bitfun-drag-line-swoop 0.72s ease-in-out infinite; + animation: bitfun-drag-line-swoop 1.44s ease-in-out infinite; &--b { - animation-delay: 0.16s; + animation-delay: 0.32s; } } } @@ -312,25 +312,25 @@ /* ---------- Mood transition micro-bump ---------- */ .bitfun-chat-input-pixel-pet__stage--transition { - animation: bitfun-stage-bump 0.32s cubic-bezier(0.2, 1.4, 0.5, 1); + animation: bitfun-stage-bump 0.64s cubic-bezier(0.2, 1.4, 0.5, 1); } /* ---------- Idle micro-actions (rest only, all in-place) ---------- */ .bitfun-chat-input-pixel-pet__stage--idle-ear-twitch-left .bitfun-panda-head__ear--left { - animation: bitfun-ear-twitch-once 0.7s ease-in-out; + animation: bitfun-ear-twitch-once 1.4s ease-in-out; transform-origin: 50% 100%; transform-box: fill-box; } .bitfun-chat-input-pixel-pet__stage--idle-ear-twitch-right .bitfun-panda-head__ear--right { - animation: bitfun-ear-twitch-once 0.7s ease-in-out; + animation: bitfun-ear-twitch-once 1.4s ease-in-out; transform-origin: 50% 100%; transform-box: fill-box; } .bitfun-chat-input-pixel-pet__stage--idle-yawn .bitfun-panda-head__muzzle { - animation: bitfun-yawn 1.3s ease-in-out; + animation: bitfun-yawn 2.6s ease-in-out; transform-origin: 50% 30%; transform-box: fill-box; } @@ -343,12 +343,12 @@ .bitfun-chat-input-pixel-pet__stage--idle-body-roll .bitfun-panda-head { animation: - bitfun-head-breathe 3.4s ease-in-out infinite, - bitfun-body-roll 1.5s ease-in-out; + bitfun-head-breathe 6.8s ease-in-out infinite, + bitfun-body-roll 3.0s ease-in-out; } .bitfun-chat-input-pixel-pet__stage--idle-paw-wiggle .bitfun-panda-head__paw--front { - animation: bitfun-paw-wiggle 0.9s ease-in-out; + animation: bitfun-paw-wiggle 1.8s ease-in-out; transform-origin: 70% 60%; transform-box: fill-box; } diff --git a/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.tsx b/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.tsx index b2090fb42..1aab1dece 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.tsx @@ -11,7 +11,6 @@ import { useSnapshotState } from '../../../tools/snapshot_system/hooks/useSnapsh import { createDiffEditorTab } from '../../../shared/utils/tabUtils'; import { snapshotAPI } from '../../../infrastructure/api'; import { useCurrentWorkspace } from '../../../infrastructure/contexts/WorkspaceContext'; -import { diffService } from '../../../tools/editor/services'; import { createLogger } from '@/shared/utils/logger'; import { flowChatStore } from '../../store/FlowChatStore'; import type { FlowChatState } from '../../types/flow-chat'; @@ -241,30 +240,21 @@ export const SessionFileModificationsBar: React.FC { const timeoutId = setTimeout(() => { diff --git a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx index d6cb91667..8583eb7d5 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx @@ -20,7 +20,6 @@ import { useSnapshotState } from '../../../tools/snapshot_system/hooks/useSnapsh import { createDiffEditorTab } from '../../../shared/utils/tabUtils'; import { snapshotAPI } from '../../../infrastructure/api'; import { useWorkspaceContext } from '../../../infrastructure/contexts/WorkspaceContext'; -import { diffService } from '../../../tools/editor/services'; import { notificationService } from '../../../shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; import { createBtwChildSession } from '../../services/BtwThreadService'; @@ -314,30 +313,21 @@ export const SessionFilesBadge: React.FC = ({ let stats: FileStats | null = null; try { - const diffData = await snapshotAPI.getOperationDiff(sessionId, file.filePath); + const statsResp = await snapshotAPI.getSessionFileDiffStats( + sessionId, + file.filePath, + currentWorkspace?.rootPath, + ); const fileName = file.filePath.split(/[/\\]/).pop() || file.filePath; - let additions = 0; - let deletions = 0; - let operationType: 'write' | 'edit' | 'delete' = 'edit'; - - if (!diffData.originalContent && diffData.modifiedContent) { - operationType = 'write'; - additions = diffData.modifiedContent.split('\n').length; - deletions = 0; - } else if (diffData.originalContent && !diffData.modifiedContent) { - operationType = 'delete'; - additions = 0; - deletions = diffData.originalContent.split('\n').length; - } else if (diffData.originalContent && diffData.modifiedContent) { - const result = await diffService.computeDiff( - diffData.originalContent, - diffData.modifiedContent, - { timeout: 3000 } - ); - additions = result.stats.additions; - deletions = result.stats.deletions; - } + const additions = statsResp.linesAdded; + const deletions = statsResp.linesRemoved; + const operationType: 'write' | 'edit' | 'delete' = + statsResp.changeKind === 'create' + ? 'write' + : statsResp.changeKind === 'delete' + ? 'delete' + : 'edit'; stats = { filePath: file.filePath, @@ -381,7 +371,7 @@ export const SessionFilesBadge: React.FC = ({ } finally { setLoadingStats(false); } - }, [sessionId, t]); + }, [sessionId, t, currentWorkspace?.rootPath]); // Reload stats when the file list changes. useEffect(() => { diff --git a/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts index 8d0ea909c..d5903a575 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts @@ -49,6 +49,14 @@ export interface SandboxOperationDiff { anchorLine?: number | null; } +export interface SessionFileDiffStats { + filePath: string; + linesAdded: number; + linesRemoved: number; + approximate: boolean; + changeKind: 'create' | 'modify' | 'delete'; +} + export interface GetSessionModificationsRequest { sessionId: string; } @@ -180,6 +188,25 @@ export class SnapshotAPI { } } + async getSessionFileDiffStats( + sessionId: string, + filePath: string, + workspacePath?: string, + ): Promise { + try { + const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); + return await api.invoke('get_session_file_diff_stats', { + request: { sessionId, filePath, workspacePath: resolvedWorkspacePath }, + }); + } catch (error) { + throw createTauriCommandError('get_session_file_diff_stats', error, { + sessionId, + filePath, + workspacePath, + }); + } + } + async getOperationSummary( sessionId: string, operationId: string, diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts index 6eacfd1aa..a68c6bdd0 100644 --- a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts @@ -59,14 +59,15 @@ const BUILTIN_PETS: AgentCompanionPetSelection[] = [ spritesheetMimeType: 'image/webp', }, { - id: 'cream-cat', - displayName: 'Cream Cat', - description: 'A tiny cream pixel-art cat with a heart bubble for happy moments.', + id: 'capy', + displayName: 'Capy', + description: 'An original emotionally stable capybara with a tiny orange on its head.', source: 'preset', - packagePath: `${BUILTIN_PET_BASE}/cream-cat`, - spritesheetPath: `${BUILTIN_PET_BASE}/cream-cat/spritesheet.webp`, + packagePath: `${BUILTIN_PET_BASE}/capy`, + spritesheetPath: `${BUILTIN_PET_BASE}/capy/spritesheet.webp`, spritesheetMimeType: 'image/webp', }, + { id: 'elaina', displayName: 'Elaina', @@ -85,6 +86,16 @@ const BUILTIN_PETS: AgentCompanionPetSelection[] = [ spritesheetPath: `${BUILTIN_PET_BASE}/gugugaga/spritesheet.webp`, spritesheetMimeType: 'image/webp', }, + { + id: 'hachiware', + displayName: 'Hachiware', + description: + 'A tiny Hachiware-inspired desktop pet with white and blue cat markings, bright eyes, and cheerful expressions.', + source: 'preset', + packagePath: `${BUILTIN_PET_BASE}/hachiware`, + spritesheetPath: `${BUILTIN_PET_BASE}/hachiware/spritesheet.webp`, + spritesheetMimeType: 'image/webp', + }, { id: 'ikun', displayName: 'IKUN', @@ -95,14 +106,24 @@ const BUILTIN_PETS: AgentCompanionPetSelection[] = [ spritesheetMimeType: 'image/webp', }, { - id: 'pixel-panda', - displayName: 'Pixel Panda', - description: 'A gentle panda holding a tiny pixel brush.', + id: 'jiyi', + displayName: '吉伊', + description: + 'A round white chibi bear with dark chocolate outlines, pink cheeks, tiny limbs, curled ears, and a small pink bear pouch.', source: 'preset', - packagePath: `${BUILTIN_PET_BASE}/pixel-panda`, - spritesheetPath: `${BUILTIN_PET_BASE}/pixel-panda/spritesheet.webp`, + packagePath: `${BUILTIN_PET_BASE}/jiyi`, + spritesheetPath: `${BUILTIN_PET_BASE}/jiyi/spritesheet.webp`, spritesheetMimeType: 'image/webp', }, + { + id: 'panda-pix', + displayName: 'Panda', + description: 'Codux bundled pet atlas.', + source: 'preset', + packagePath: `${BUILTIN_PET_BASE}/panda-pix`, + spritesheetPath: `${BUILTIN_PET_BASE}/panda-pix/spritesheet.png`, + spritesheetMimeType: 'image/png', + }, { id: 'usagi', displayName: 'Usagi', diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts index cc9e4bd2e..e2cef02bc 100644 --- a/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts @@ -4,24 +4,34 @@ import type { AIExperienceSettings } from './AIExperienceConfigService'; const log = createLogger('AgentCompanionWindowService'); +/** + * Serialized `invoke`/`emit` so rapid settings toggles cannot interleave show/hide on the backend. + */ +let companionDesktopWindowSyncChain: Promise = Promise.resolve(); + export async function syncAgentCompanionDesktopWindow( settings: AIExperienceSettings, ): Promise { if (!isTauriRuntime()) return; - const command = settings.enable_agent_companion - && settings.agent_companion_display_mode === 'desktop' - ? 'show_agent_companion_desktop_pet' - : 'hide_agent_companion_desktop_pet'; + const run = async (): Promise => { + const command = settings.enable_agent_companion + && settings.agent_companion_display_mode === 'desktop' + ? 'show_agent_companion_desktop_pet' + : 'hide_agent_companion_desktop_pet'; - try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke(command); - if (command === 'show_agent_companion_desktop_pet') { - const { emit } = await import('@tauri-apps/api/event'); - await emit('agent-companion://settings-updated', settings); + try { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke(command); + if (command === 'show_agent_companion_desktop_pet') { + const { emit } = await import('@tauri-apps/api/event'); + await emit('agent-companion://settings-updated', settings); + } + } catch (error) { + log.error('Failed to sync Agent companion desktop window', { command, error }); } - } catch (error) { - log.error('Failed to sync Agent companion desktop window', { command, error }); - } + }; + + companionDesktopWindowSyncChain = companionDesktopWindowSyncChain.then(run, run); + await companionDesktopWindowSyncChain; } diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 08bb94556..f06f39b19 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -959,7 +959,7 @@ "authMethod": "Authentication Method", "password": "Password", "privateKey": "Private Key", - "privateKeyPath": "Private Key Path", + "privateKeyPath": "Private Key File Path", "browsePrivateKey": "Choose private key file", "pickPrivateKeyDialogTitle": "Select SSH private key", "passphrase": "Passphrase", diff --git a/src/web-ui/src/locales/en-US/settings/session-config.json b/src/web-ui/src/locales/en-US/settings/session-config.json index 222e8405c..5f1bcf1ae 100644 --- a/src/web-ui/src/locales/en-US/settings/session-config.json +++ b/src/web-ui/src/locales/en-US/settings/session-config.json @@ -14,7 +14,7 @@ "displayModeLabel": "Display location", "displayModeDescription": "Show the companion inside the input, or as a floating desktop pet outside the app.", "displayInput": "Inside input", - "displayInputDesc": "Keep the current behavior and show it when the chat input is collapsed.", + "displayInputDesc": "Show it when the chat input is collapsed.", "displayDesktop": "Floating desktop pet", "displayDesktopDesc": "Show a borderless always-on-top mini window outside the app. Drag to move it.", "petLabel": "Companion appearance", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 55d95f14c..c61824a40 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -959,7 +959,7 @@ "authMethod": "认证方式", "password": "密码", "privateKey": "私钥", - "privateKeyPath": "私钥路径", + "privateKeyPath": "私钥文件路径", "browsePrivateKey": "选择私钥文件", "pickPrivateKeyDialogTitle": "选择 SSH 私钥", "passphrase": "密码短语", diff --git a/src/web-ui/src/locales/zh-CN/settings/session-config.json b/src/web-ui/src/locales/zh-CN/settings/session-config.json index 00437f048..5f3d69c4b 100644 --- a/src/web-ui/src/locales/zh-CN/settings/session-config.json +++ b/src/web-ui/src/locales/zh-CN/settings/session-config.json @@ -14,7 +14,7 @@ "displayModeLabel": "显示位置", "displayModeDescription": "选择伙伴显示在输入框内,还是作为软件外的悬浮桌面宠物。", "displayInput": "输入框内", - "displayInputDesc": "保持当前效果,在会话输入框收起时显示。", + "displayInputDesc": "在会话输入框收起时显示。", "displayDesktop": "悬浮桌面宠物", "displayDesktopDesc": "在软件外显示为置顶的无边框小窗口,可拖动。", "petLabel": "伙伴形象", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 456294b98..44c4444fb 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -959,7 +959,7 @@ "authMethod": "認證方式", "password": "密碼", "privateKey": "私鑰", - "privateKeyPath": "私鑰路徑", + "privateKeyPath": "私鑰文件路徑", "browsePrivateKey": "選擇私鑰文件", "pickPrivateKeyDialogTitle": "選擇 SSH 私鑰", "passphrase": "密碼短語", diff --git a/src/web-ui/src/locales/zh-TW/settings/session-config.json b/src/web-ui/src/locales/zh-TW/settings/session-config.json index 5a1d93243..315152ea4 100644 --- a/src/web-ui/src/locales/zh-TW/settings/session-config.json +++ b/src/web-ui/src/locales/zh-TW/settings/session-config.json @@ -14,7 +14,7 @@ "displayModeLabel": "顯示位置", "displayModeDescription": "選擇夥伴顯示在輸入框內,還是作為軟體外的懸浮桌面寵物。", "displayInput": "輸入框內", - "displayInputDesc": "保持目前效果,在會話輸入框收起時顯示。", + "displayInputDesc": "在會話輸入框收起時顯示。", "displayDesktop": "懸浮桌面寵物", "displayDesktopDesc": "在軟體外顯示為置頂的無邊框小視窗,可拖動。", "petLabel": "夥伴形象",