diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 899f1c2a..2bc322f5 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -41,16 +41,22 @@ fn remote_workspace_from_info(info: &WorkspaceInfo) -> Option, path: &str, + request_preferred: Option<&str>, ) -> Option { - let hint = state + let manager = get_remote_workspace_manager()?; + let legacy = state .get_remote_workspace_async() .await .map(|w| w.connection_id); - let manager = get_remote_workspace_manager()?; - manager.lookup_connection(path, hint.as_deref()).await + let preferred: Option = request_preferred + .map(|s| s.to_string()) + .or(legacy); + manager.lookup_connection(path, preferred.as_deref()).await } #[derive(Debug, Deserialize)] @@ -142,6 +148,8 @@ pub struct ReadFileContentRequest { #[serde(rename = "filePath")] pub file_path: String, pub encoding: Option, + #[serde(default, rename = "remoteConnectionId")] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] @@ -151,6 +159,8 @@ pub struct WriteFileContentRequest { #[serde(rename = "filePath")] pub file_path: String, pub content: String, + #[serde(default, rename = "remoteConnectionId")] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] @@ -173,11 +183,15 @@ pub struct GetFileMetadataRequest { pub struct GetFileTreeRequest { pub path: String, pub max_depth: Option, + #[serde(default, rename = "remoteConnectionId")] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] pub struct GetDirectoryChildrenRequest { pub path: String, + #[serde(default, rename = "remoteConnectionId")] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] @@ -186,6 +200,8 @@ pub struct GetDirectoryChildrenPaginatedRequest { pub path: String, pub offset: Option, pub limit: Option, + #[serde(default)] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] @@ -1366,8 +1382,12 @@ pub async fn get_file_tree( } } + let preferred = request.remote_connection_id.as_deref(); let filesystem_service = &state.filesystem_service; - match filesystem_service.build_file_tree(&request.path).await { + match filesystem_service + .build_file_tree_with_remote_hint(&request.path, preferred) + .await + { Ok(nodes) => { fn convert_node_to_json( node: bitfun_core::infrastructure::FileTreeNode, @@ -1433,9 +1453,10 @@ pub async fn get_directory_children( } } + let preferred = request.remote_connection_id.as_deref(); let filesystem_service = &state.filesystem_service; match filesystem_service - .get_directory_contents(&request.path) + .get_directory_contents_with_remote_hint(&request.path, preferred) .await { Ok(nodes) => { @@ -1484,9 +1505,10 @@ pub async fn get_directory_children_paginated( } } + let preferred = request.remote_connection_id.as_deref(); let filesystem_service = &state.filesystem_service; match filesystem_service - .get_directory_contents(&request.path) + .get_directory_contents_with_remote_hint(&request.path, preferred) .await { Ok(nodes) => { @@ -1527,7 +1549,13 @@ pub async fn read_file_content( state: State<'_, AppState>, request: ReadFileContentRequest, ) -> Result { - if let Some(entry) = lookup_remote_entry_for_path(&state, &request.file_path).await { + if let Some(entry) = lookup_remote_entry_for_path( + &state, + &request.file_path, + request.remote_connection_id.as_deref(), + ) + .await + { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; let bytes = remote_fs.read_file(&entry.connection_id, &request.file_path).await @@ -1553,7 +1581,13 @@ pub async fn write_file_content( state: State<'_, AppState>, request: WriteFileContentRequest, ) -> Result<(), String> { - if let Some(entry) = lookup_remote_entry_for_path(&state, &request.file_path).await { + if let Some(entry) = lookup_remote_entry_for_path( + &state, + &request.file_path, + request.remote_connection_id.as_deref(), + ) + .await + { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; remote_fs.write_file(&entry.connection_id, &request.file_path, request.content.as_bytes()).await @@ -1618,7 +1652,7 @@ pub async fn check_path_exists( state: State<'_, AppState>, request: CheckPathExistsRequest, ) -> Result { - if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path, None).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; return remote_fs.exists(&entry.connection_id, &request.path).await @@ -1636,7 +1670,7 @@ pub async fn get_file_metadata( ) -> Result { use std::time::SystemTime; - if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path, None).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; @@ -1706,7 +1740,7 @@ pub async fn get_file_editor_sync_hash( state: State<'_, AppState>, request: GetFileMetadataRequest, ) -> Result { - if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path, None).await { let remote_fs = state .get_remote_file_service_async() .await @@ -1742,7 +1776,7 @@ pub async fn rename_file( state: State<'_, AppState>, request: RenameFileRequest, ) -> Result<(), String> { - if let Some(entry) = lookup_remote_entry_for_path(&state, &request.old_path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.old_path, None).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; remote_fs.rename(&entry.connection_id, &request.old_path, &request.new_path).await @@ -1783,7 +1817,7 @@ pub async fn delete_file( state: State<'_, AppState>, request: DeleteFileRequest, ) -> Result<(), String> { - if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path, None).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; remote_fs.remove_file(&entry.connection_id, &request.path).await @@ -1807,7 +1841,7 @@ pub async fn delete_directory( ) -> Result<(), String> { let recursive = request.recursive.unwrap_or(false); - if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path, None).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; if recursive { @@ -1834,7 +1868,7 @@ pub async fn create_file( state: State<'_, AppState>, request: CreateFileRequest, ) -> Result<(), String> { - if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path, None).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; remote_fs.write_file(&entry.connection_id, &request.path, b"").await @@ -1857,7 +1891,7 @@ pub async fn create_directory( state: State<'_, AppState>, request: CreateDirectoryRequest, ) -> Result<(), String> { - if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path, None).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; remote_fs.create_dir_all(&entry.connection_id, &request.path).await @@ -1887,7 +1921,7 @@ pub async fn list_directory_files( ) -> Result, String> { use std::path::Path; - if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path, None).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; let entries = remote_fs.read_dir(&entry.connection_id, &request.path).await diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index 9d27ea37..b609e6b9 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -161,7 +161,7 @@ pub async fn get_all_tools_info() -> Result, String> { tool_infos.push(ToolInfo { name: tool.name().to_string(), description, - input_schema: tool.input_schema(), + input_schema: tool.input_schema_for_model().await, is_readonly: tool.is_readonly(), is_concurrency_safe: tool.is_concurrency_safe(None), needs_permissions: tool.needs_permissions(None), @@ -188,7 +188,7 @@ pub async fn get_readonly_tools_info() -> Result, String> { tool_infos.push(ToolInfo { name: tool.name().to_string(), description, - input_schema: tool.input_schema(), + input_schema: tool.input_schema_for_model().await, is_readonly: tool.is_readonly(), is_concurrency_safe: tool.is_concurrency_safe(None), needs_permissions: tool.needs_permissions(None), @@ -212,7 +212,7 @@ pub async fn get_tool_info(tool_name: String) -> Result, String return Ok(Some(ToolInfo { name: tool.name().to_string(), description, - input_schema: tool.input_schema(), + input_schema: tool.input_schema_for_model().await, is_readonly: tool.is_readonly(), is_concurrency_safe: tool.is_concurrency_safe(None), needs_permissions: tool.needs_permissions(None), diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/core/src/agentic/agents/mod.rs index f007a6bc..1d506639 100644 --- a/src/crates/core/src/agentic/agents/mod.rs +++ b/src/crates/core/src/agentic/agents/mod.rs @@ -30,7 +30,7 @@ pub use explore_agent::ExploreAgent; pub use file_finder_agent::FileFinderAgent; pub use generate_doc_agent::GenerateDocAgent; pub use plan_mode::PlanMode; -pub use prompt_builder::{PromptBuilder, PromptBuilderContext}; +pub use prompt_builder::{PromptBuilder, PromptBuilderContext, RemoteExecutionHints}; pub use registry::{ get_agent_registry, AgentCategory, AgentInfo, AgentRegistry, CustomSubagentConfig, SubAgentSource, diff --git a/src/crates/core/src/agentic/agents/prompt_builder/mod.rs b/src/crates/core/src/agentic/agents/prompt_builder/mod.rs index 3441d908..affb34c8 100644 --- a/src/crates/core/src/agentic/agents/prompt_builder/mod.rs +++ b/src/crates/core/src/agentic/agents/prompt_builder/mod.rs @@ -1,3 +1,3 @@ mod prompt_builder; -pub use prompt_builder::{PromptBuilder, PromptBuilderContext}; +pub use prompt_builder::{PromptBuilder, PromptBuilderContext, RemoteExecutionHints}; diff --git a/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs b/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs index e62d46a7..0e344297 100644 --- a/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs +++ b/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs @@ -5,6 +5,7 @@ use crate::service::agent_memory::build_workspace_agent_memory_prompt; use crate::service::ai_memory::AIMemoryManager; use crate::service::ai_rules::get_global_ai_rules_service; use crate::service::bootstrap::build_workspace_persona_prompt; +use crate::service::config::get_app_language_code; use crate::service::config::global::GlobalConfigManager; use crate::service::project_context::ProjectContextService; use crate::util::errors::{BitFunError, BitFunResult}; @@ -24,11 +25,23 @@ const PLACEHOLDER_AGENT_MEMORY: &str = "{AGENT_MEMORY}"; const PLACEHOLDER_CLAW_WORKSPACE: &str = "{CLAW_WORKSPACE}"; const PLACEHOLDER_VISUAL_MODE: &str = "{VISUAL_MODE}"; +/// SSH remote host facts for system prompt (workspace tools run here, not on the local client). +#[derive(Debug, Clone)] +pub struct RemoteExecutionHints { + pub connection_display_name: String, + pub kernel_name: String, + pub hostname: String, +} + #[derive(Debug, Clone)] pub struct PromptBuilderContext { pub workspace_path: String, pub session_id: Option, pub model_name: Option, + /// When set, file/shell tools target this remote environment; OS and path instructions follow it. + pub remote_execution: Option, + /// Pre-built tree text for `{PROJECT_LAYOUT}` when the workspace is not on the local disk. + pub remote_project_layout: Option, } impl PromptBuilderContext { @@ -41,8 +54,20 @@ impl PromptBuilderContext { workspace_path: workspace_path.into().replace("\\", "/"), session_id, model_name, + remote_execution: None, + remote_project_layout: None, } } + + pub fn with_remote_prompt_overlay( + mut self, + execution: RemoteExecutionHints, + project_layout: Option, + ) -> Self { + self.remote_execution = Some(execution); + self.remote_project_layout = project_layout; + self + } } pub struct PromptBuilder { @@ -60,22 +85,48 @@ impl PromptBuilder { /// Provide complete environment information pub fn get_env_info(&self) -> String { - let os_name = std::env::consts::OS; - let os_family = std::env::consts::FAMILY; - let arch = std::env::consts::ARCH; + let host_os = std::env::consts::OS; + let host_family = std::env::consts::FAMILY; + let host_arch = std::env::consts::ARCH; let now = chrono::Local::now(); let current_date = now.format("%Y-%m-%d").to_string(); - let computer_use_keys = match os_name { - "macos" => "Computer use / `key_chord`: this host is **macOS** — use `command`, `option`, `control`, `shift` (not Win/Linux modifier names). **System clipboard (prefer over long type_text):** command+a (select all), command+c (copy), command+x (cut), command+v (paste). Spotlight: command+space; switch app: command+tab.", - "windows" => "Computer use / `key_chord`: this host is **Windows** — use `meta`/`super` for the Windows key, `alt`, `control`, `shift`. **System clipboard:** control+a/c/x/v. Start menu: meta; Alt+Tab for window switch.", - "linux" => "Computer use / `key_chord`: this host is **Linux** — typically `control`, `alt`, `shift`, and sometimes `meta`/`super` depending on the desktop; match the user's session. **System clipboard:** usually control+a/c/x/v (confirm in-app menus if unsure).", - _ => "Computer use / `key_chord`: match modifier names to this host's OS (see Operating System above). Prefer standard clipboard chords before retyping long text.", + let computer_use_keys = match host_os { + "macos" => "Computer use / `key_chord`: the **local BitFun desktop** is **macOS** — use `command`, `option`, `control`, `shift` (not Win/Linux modifier names). **System clipboard (prefer over long type_text):** command+a (select all), command+c (copy), command+x (cut), command+v (paste). Spotlight: command+space; switch app: command+tab.", + "windows" => "Computer use / `key_chord`: the **local BitFun desktop** is **Windows** — use `meta`/`super` for the Windows key, `alt`, `control`, `shift`. **System clipboard:** control+a/c/x/v. Start menu: meta; Alt+Tab for window switch.", + "linux" => "Computer use / `key_chord`: the **local BitFun desktop** is **Linux** — typically `control`, `alt`, `shift`, and sometimes `meta`/`super` depending on the desktop; match the user's session. **System clipboard:** usually control+a/c/x/v (confirm in-app menus if unsure).", + _ => "Computer use / `key_chord`: match modifier names to the **local BitFun desktop** OS below. Prefer standard clipboard chords before retyping long text.", }; - format!( - r#"# Environment Information + if let Some(remote) = &self.context.remote_execution { + format!( + r#"# Environment Information + +- Workspace root (file tools, Glob, LS, Bash on workspace): {} +- Execution environment: **Remote SSH** — connection "{}". +- Remote host: {} (uname/kernel: {}) +- **Paths and shell:** POSIX on the remote server — use forward slashes and Unix shell syntax (bash/sh). Do **not** use PowerShell, `cmd.exe`, or Windows-style paths for workspace operations. +- Local BitFun client OS: {} ({}) — applies to Computer use / UI automation on this machine only, not to workspace file or terminal tools. +- Local client architecture: {} +- Current Date: {} +- {} + + +"#, + self.context.workspace_path, + remote.connection_display_name.replace('"', "'"), + remote.hostname.replace('"', "'"), + remote.kernel_name.replace('"', "'"), + host_os, + host_family, + host_arch, + current_date, + computer_use_keys + ) + } else { + format!( + r#"# Environment Information - Current Working Directory: {} - Operating System: {} ({}) @@ -85,17 +136,28 @@ impl PromptBuilder { "#, - self.context.workspace_path, - os_name, - os_family, - arch, - current_date, - computer_use_keys - ) + self.context.workspace_path, + host_os, + host_family, + host_arch, + current_date, + computer_use_keys + ) + } } /// Get workspace file list pub fn get_project_layout(&self) -> String { + if let Some(remote_layout) = &self.context.remote_project_layout { + let mut project_layout = "# Workspace Layout\n\n".to_string(); + project_layout.push_str( + "Below is a snapshot of the current workspace's file structure on the **remote** host.\n\n", + ); + project_layout.push_str(remote_layout); + project_layout.push_str("\n\n\n"); + return project_layout; + } + let (hit_limit, formatted_files_list) = get_formatted_files_list( &self.context.workspace_path, self.file_tree_max_entries, @@ -120,6 +182,10 @@ impl PromptBuilder { /// Parameters: /// - filter: Optional filter, supports `include=category1,category2` or `exclude=category1` pub async fn get_project_context(&self, filter: Option<&str>) -> Option { + if self.context.remote_execution.is_some() { + return None; + } + let service = ProjectContextService::new(); let workspace = Path::new(&self.context.workspace_path); @@ -230,11 +296,7 @@ Prefer MermaidInteractive tool when available, otherwise output Mermaid code blo /// Returns empty string if config cannot be read /// Returns error if language code is unsupported async fn get_language_preference(&self) -> BitFunResult { - let language_code = GlobalConfigManager::get_service() - .await? - .get_config::(Some("app.language")) - .await?; - + let language_code = get_app_language_code().await; Self::format_language_instruction(&language_code) } @@ -285,16 +347,21 @@ Do not read from, modify, create, move, or delete files outside this workspace u // Replace {PERSONA} if result.contains(PLACEHOLDER_PERSONA) { - let workspace = Path::new(&self.context.workspace_path); - let persona = match build_workspace_persona_prompt(workspace).await { - Ok(prompt) => prompt.unwrap_or_default(), - Err(e) => { - warn!( - "Failed to build workspace persona prompt: path={} error={}", - workspace.display(), - e - ); - String::new() + let persona = if self.context.remote_execution.is_some() { + "# Workspace persona\nMarkdown persona files (e.g. BOOTSTRAP.md, SOUL.md) live on the **remote** workspace. Use Read or Glob under the workspace root above to load them.\n\n" + .to_string() + } else { + let workspace = Path::new(&self.context.workspace_path); + match build_workspace_persona_prompt(workspace).await { + Ok(prompt) => prompt.unwrap_or_default(), + Err(e) => { + warn!( + "Failed to build workspace persona prompt: path={} error={}", + workspace.display(), + e + ); + String::new() + } } }; result = result.replace(PLACEHOLDER_PERSONA, &persona); @@ -361,16 +428,21 @@ Do not read from, modify, create, move, or delete files outside this workspace u // Replace {AGENT_MEMORY} if result.contains(PLACEHOLDER_AGENT_MEMORY) { - let workspace = Path::new(&self.context.workspace_path); - let agent_memory = match build_workspace_agent_memory_prompt(workspace).await { - Ok(prompt) => prompt, - Err(e) => { - warn!( - "Failed to build workspace agent memory prompt: path={} error={}", - workspace.display(), - e - ); - String::new() + let agent_memory = if self.context.remote_execution.is_some() { + "# Agent memory\nSession memory under `.bitfun/` is stored on the **remote** host for this workspace. Use file tools with POSIX paths under the workspace root if you need to read it.\n\n" + .to_string() + } else { + let workspace = Path::new(&self.context.workspace_path); + match build_workspace_agent_memory_prompt(workspace).await { + Ok(prompt) => prompt, + Err(e) => { + warn!( + "Failed to build workspace agent memory prompt: path={} error={}", + workspace.display(), + e + ); + String::new() + } } }; result = result.replace(PLACEHOLDER_AGENT_MEMORY, &agent_memory); diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index b0dae0da..3847be10 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -644,7 +644,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "Failed to create fallback session metadata during turn finalization: session_id={}, error={}", session_id, e ); - return; + // Do not return: on read-only or transient IO errors we still try to persist the + // minimal dialog turn so local/remote UI history is not silently empty. } } diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index 10c8e245..a3796947 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -4,7 +4,7 @@ use super::round_executor::RoundExecutor; use super::types::{ExecutionContext, ExecutionResult, RoundContext}; -use crate::agentic::agents::{get_agent_registry, PromptBuilderContext}; +use crate::agentic::agents::{get_agent_registry, PromptBuilderContext, RemoteExecutionHints}; use crate::agentic::core::{Message, MessageContent, MessageHelper, MessageSemanticKind, Session}; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue}; use crate::agentic::image_analysis::{ @@ -13,7 +13,9 @@ use crate::agentic::image_analysis::{ }; use crate::agentic::session::{CompressionTailPolicy, ContextCompressor, SessionManager}; use crate::agentic::tools::{get_all_registered_tools, SubagentParentInfo}; -use crate::agentic::WorkspaceBinding; +use crate::agentic::util::build_remote_workspace_layout_preview; +use crate::agentic::{WorkspaceBackend, WorkspaceBinding}; +use crate::service::remote_ssh::workspace_state::get_remote_workspace_manager; use crate::infrastructure::ai::get_global_ai_client_factory; use crate::service::config::get_global_config_service; use crate::service::config::types::{ModelCapability, ModelCategory}; @@ -887,13 +889,90 @@ impl ExecutionEngine { .workspace .as_ref() .map(|workspace| workspace.root_path_string()); - let prompt_context = workspace_str.map(|workspace_path| { - PromptBuilderContext::new( - workspace_path, + let prompt_context = if let Some(workspace_path) = workspace_str { + let base = PromptBuilderContext::new( + workspace_path.clone(), Some(context.session_id.clone()), Some(ai_client.config.model.clone()), - ) - }); + ); + let overlayed = if let Some(ws) = context.workspace.as_ref() { + if ws.is_remote() { + if let Some(cid) = ws.connection_id() { + if let Some(mgr) = get_remote_workspace_manager() { + let ssh_opt = mgr.get_ssh_manager().await; + let fs_opt = mgr.get_file_service().await; + let (kernel_name, hostname) = + if let Some(ref ssh) = ssh_opt { + if let Some(info) = ssh.get_server_info(cid).await { + (info.os_type, info.hostname) + } else { + ( + "Linux".to_string(), + "remote".to_string(), + ) + } + } else { + ( + "Linux".to_string(), + "remote".to_string(), + ) + }; + let connection_display_name = + match &ws.backend { + WorkspaceBackend::Remote { + connection_name, + .. + } => connection_name.clone(), + _ => cid.to_string(), + }; + let remote_layout = if let Some(ref fs) = fs_opt { + match build_remote_workspace_layout_preview( + fs, + cid, + &workspace_path, + 200, + ) + .await + { + Ok((_, s)) => Some(s), + Err(e) => { + warn!( + "Remote workspace layout for prompt failed: {}", + e + ); + None + } + } + } else { + None + }; + base.with_remote_prompt_overlay( + RemoteExecutionHints { + connection_display_name, + kernel_name, + hostname, + }, + remote_layout, + ) + } else { + warn!( + "Remote workspace active but RemoteWorkspaceStateManager is missing; using client OS hints only" + ); + base + } + } else { + base + } + } else { + base + } + } else { + base + }; + Some(overlayed) + } else { + None + }; current_agent .get_system_prompt(prompt_context.as_ref()) .await? @@ -1437,7 +1516,7 @@ impl ExecutionEngine { tool_definitions.push(ToolDefinition { name: tool.name().to_string(), description, - parameters: tool.input_schema(), + parameters: tool.input_schema_for_model().await, }); } } diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 090a6640..36ff5718 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -10,6 +10,7 @@ use crate::agentic::image_analysis::ImageContextData; use crate::agentic::persistence::PersistenceManager; use crate::agentic::session::SessionContextStore; use crate::infrastructure::ai::get_global_ai_client_factory; +use crate::service::config::{get_app_language_code, short_model_user_language_instruction}; use crate::service::session::{ DialogTurnData, DialogTurnKind, ModelRoundData, TextItemData, TurnStatus, UserMessageData, }; @@ -1463,17 +1464,9 @@ impl SessionManager { let max_length = max_length.unwrap_or(20); - // Get current user locale for language setting - let user_language = if let Some(service) = crate::service::get_global_i18n_service().await { - service.get_current_locale().await - } else { - crate::service::LocaleId::ZhCN - }; - - let language_instruction = match user_language { - crate::service::LocaleId::ZhCN => "使用简体中文", - crate::service::LocaleId::EnUS => "Use English", - }; + // Match agent `LANGUAGE_PREFERENCE`: use `app.language`, not I18nService (see `app_language` module). + let lang_code = get_app_language_code().await; + let language_instruction = short_model_user_language_instruction(lang_code.as_str()); // Construct system prompt let system_prompt = format!( diff --git a/src/crates/core/src/agentic/tools/framework.rs b/src/crates/core/src/agentic/tools/framework.rs index 810be01c..67e14721 100644 --- a/src/crates/core/src/agentic/tools/framework.rs +++ b/src/crates/core/src/agentic/tools/framework.rs @@ -67,6 +67,30 @@ impl ToolUseContext { pub fn ws_shell(&self) -> Option<&dyn crate::agentic::workspace::WorkspaceShell> { self.workspace_services.as_ref().map(|s| s.shell.as_ref()) } + + /// Resolve a user or model-supplied path for file/shell tools. Uses POSIX semantics when the + /// workspace is remote SSH so Windows-hosted clients still resolve `/home/...` correctly. + pub fn resolve_workspace_tool_path(&self, path: &str) -> BitFunResult { + let workspace_root_owned = self + .workspace + .as_ref() + .map(|w| w.root_path_string()); + crate::agentic::tools::workspace_paths::resolve_workspace_tool_path( + path, + self.current_working_directory.as_deref(), + workspace_root_owned.as_deref(), + self.is_remote(), + ) + } + + /// Whether `path` is absolute for the active workspace (POSIX `/` for remote SSH). + pub fn workspace_path_is_effectively_absolute(&self, path: &str) -> bool { + if self.is_remote() { + crate::agentic::tools::workspace_paths::posix_style_path_is_absolute(path) + } else { + Path::new(path).is_absolute() + } + } } /// Tool options @@ -193,6 +217,12 @@ pub trait Tool: Send + Sync { /// Input mode definition - using JSON Schema fn input_schema(&self) -> Value; + /// JSON Schema sent to the model (may depend on app language or other runtime config). + /// Default: same as [`input_schema`]. + async fn input_schema_for_model(&self) -> Value { + self.input_schema() + } + /// Input JSON Schema - optional extra schema fn input_json_schema(&self) -> Option { None diff --git a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs index 06c2a560..d2243690 100644 --- a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs @@ -259,6 +259,14 @@ Usage notes: context: Option<&ToolUseContext>, ) -> BitFunResult { let mut base = self.description().await?; + if context.map(|c| c.is_remote()).unwrap_or(false) { + base = format!( + r#"**Remote workspace:** Commands run on the **SSH server** in a shell whose initial working directory is the **remote workspace root** (same as running a terminal on that machine). The shell name shown below may reflect your **local** BitFun settings; the actual interpreter on the server is typically `sh`/`bash`. Use **Unix** syntax and POSIX paths — not PowerShell or Windows paths. + +{base}"#, + base = base + ); + } if context.and_then(|c| c.agent_type.as_deref()) == Some("Claw") { base.push_str( "\n\n**Claw (desktop automation):** Prefer this tool for anything achievable from the **workspace shell** (build, test, git, scripts, CLIs). On **macOS**, `open -a \"AppName\"` launches or foregrounds an app with fewer steps than GUI workflows. Use **`ComputerUse`** **`action: locate`** for **named** on-screen controls before guessing coordinates from **`action: screenshot`** alone.", diff --git a/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs b/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs index 453d319c..f3f9ffbb 100644 --- a/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs @@ -3,6 +3,7 @@ //! Used to get structured code review results. use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::service::config::get_app_language_code; use crate::util::errors::BitFunResult; use async_trait::async_trait; use log::warn; @@ -20,11 +21,38 @@ impl CodeReviewTool { "submit_code_review" } - pub fn description_str() -> &'static str { - "Submit code review results. After completing the review analysis, you must call this tool to submit a structured review report. All text fields must use Chinese (Simplified Chinese)." + /// Sync schema fallback (e.g. tests); prefers zh-CN wording. For model calls use [`input_schema_for_model`]. + pub fn input_schema_value() -> Value { + Self::input_schema_value_for_language("zh-CN") } - pub fn input_schema_value() -> Value { + pub fn description_for_language(lang_code: &str) -> String { + match lang_code { + "en-US" => "Submit code review results. After completing the review analysis, you must call this tool to submit a structured review report. All user-visible text fields must be in English (per app language setting).".to_string(), + _ => "提交代码审查结果。完成审查分析后必须调用本工具提交结构化审查报告。所有用户可见的文本字段必须使用简体中文。".to_string(), + } + } + + pub fn input_schema_value_for_language(lang_code: &str) -> Value { + let (oa, conf, title, desc, sugg, strengths) = match lang_code { + "en-US" => ( + "Overall assessment (2-3 sentences, in English)", + "Context limitation note (optional, in English)", + "Issue title (in English)", + "Issue description (in English)", + "Fix suggestion (in English, optional)", + "Code strengths (1-2 items, in English)", + ), + _ => ( + "总体评价(2-3 句,使用简体中文)", + "上下文局限说明(可选,使用简体中文)", + "问题标题(简体中文)", + "问题描述(简体中文)", + "修复建议(可选,简体中文)", + "代码优点(1-2 条,简体中文)", + ), + }; + json!({ "type": "object", "properties": { @@ -34,7 +62,7 @@ impl CodeReviewTool { "properties": { "overall_assessment": { "type": "string", - "description": "Overall assessment (2-3 sentences, use Chinese)" + "description": oa }, "risk_level": { "type": "string", @@ -48,7 +76,7 @@ impl CodeReviewTool { }, "confidence_note": { "type": "string", - "description": "Context limitation note (optional, use Chinese)" + "description": conf } }, "required": ["overall_assessment", "risk_level", "recommended_action"] @@ -83,15 +111,15 @@ impl CodeReviewTool { }, "title": { "type": "string", - "description": "Issue title (Chinese)" + "description": title }, "description": { "type": "string", - "description": "Issue description (Chinese)" + "description": desc }, "suggestion": { "type": ["string", "null"], - "description": "Fix suggestion (Chinese, optional)" + "description": sugg } }, "required": ["severity", "certainty", "category", "file", "title", "description"] @@ -99,7 +127,7 @@ impl CodeReviewTool { }, "positive_points": { "type": "array", - "description": "Code strengths (1-2 items, Chinese)", + "description": strengths, "items": { "type": "string" } @@ -190,13 +218,19 @@ impl Tool for CodeReviewTool { } async fn description(&self) -> BitFunResult { - Ok(Self::description_str().to_string()) + let lang = get_app_language_code().await; + Ok(Self::description_for_language(lang.as_str())) } fn input_schema(&self) -> Value { Self::input_schema_value() } + async fn input_schema_for_model(&self) -> Value { + let lang = get_app_language_code().await; + Self::input_schema_value_for_language(lang.as_str()) + } + fn is_readonly(&self) -> bool { true } diff --git a/src/crates/core/src/agentic/tools/implementations/create_plan_tool.rs b/src/crates/core/src/agentic/tools/implementations/create_plan_tool.rs index ac41ff9a..f315e5c8 100644 --- a/src/crates/core/src/agentic/tools/implementations/create_plan_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/create_plan_tool.rs @@ -179,31 +179,46 @@ Additional guidelines: let plan_file_name = format!("{}_{}.plan.md", name_normalized, uuid_short); - // Get workspace path - let workspace_path = context - .workspace_root() - .ok_or(BitFunError::tool("Workspace path not set".to_string()))?; - - // Use global PathManager to get plans directory path - let path_manager = get_path_manager_arc(); - let plans_dir = path_manager.project_plans_dir(&workspace_path); - let plan_file_path = plans_dir.join(&plan_file_name); - - // Ensure plans directory exists - path_manager - .ensure_dir(&plans_dir) - .await - .map_err(|e| BitFunError::tool(format!("Failed to create plans directory: {}", e)))?; - - // Generate file content let file_content = generate_plan_file_content(name, overview, plan, todos); - // Write file - fs::write(&plan_file_path, &file_content) - .await - .map_err(|e| BitFunError::tool(format!("Failed to write plan file: {}", e)))?; - - let plan_file_path_str = plan_file_path.to_string_lossy().to_string(); + let plan_file_path_str = if context.is_remote() { + let ws_fs = context.ws_fs().ok_or_else(|| { + BitFunError::tool("Workspace file system not available for remote CreatePlan".to_string()) + })?; + let ws_shell = context.ws_shell().ok_or_else(|| { + BitFunError::tool("Workspace shell not available for remote CreatePlan".to_string()) + })?; + let root = context + .workspace + .as_ref() + .map(|w| w.root_path_string()) + .ok_or_else(|| BitFunError::tool("Workspace path not set".to_string()))?; + ws_shell + .exec("mkdir -p .bitfun/plans", Some(30_000)) + .await + .map_err(|e| BitFunError::tool(format!("Failed to create plans directory: {}", e)))?; + let plan_path = format!("{}/.bitfun/plans/{}", root.trim_end_matches('/'), plan_file_name); + ws_fs + .write_file(&plan_path, file_content.as_bytes()) + .await + .map_err(|e| BitFunError::tool(format!("Failed to write plan file: {}", e)))?; + plan_path + } else { + let workspace_path = context + .workspace_root() + .ok_or(BitFunError::tool("Workspace path not set".to_string()))?; + let path_manager = get_path_manager_arc(); + let plans_dir = path_manager.project_plans_dir(&workspace_path); + let plan_file_path = plans_dir.join(&plan_file_name); + path_manager + .ensure_dir(&plans_dir) + .await + .map_err(|e| BitFunError::tool(format!("Failed to create plans directory: {}", e)))?; + fs::write(&plan_file_path, &file_content) + .await + .map_err(|e| BitFunError::tool(format!("Failed to write plan file: {}", e)))?; + plan_file_path.to_string_lossy().to_string() + }; // Process todos for return result let processed_todos: Vec = if let Some(todos_arr) = todos { diff --git a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/core/src/agentic/tools/implementations/cron_tool.rs index c734fea8..98d588ae 100644 --- a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/cron_tool.rs @@ -3,6 +3,7 @@ use crate::agentic::coordination::get_global_coordinator; use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; +use crate::agentic::tools::workspace_paths::posix_style_path_is_absolute; use crate::service::{ cron::{ CreateCronJobRequest, CronJob, CronJobPayload, CronJobRunStatus, CronSchedule, @@ -55,18 +56,38 @@ impl CronTool { Ok(()) } - fn validate_workspace_format(workspace: &str) -> Result<(), String> { + fn validate_workspace_format( + workspace: &str, + context: Option<&ToolUseContext>, + ) -> Result<(), String> { if workspace.trim().is_empty() { return Err("workspace cannot be empty".to_string()); } + let is_remote = context.map(|c| c.is_remote()).unwrap_or(false); + if is_remote { + if !posix_style_path_is_absolute(workspace.trim()) { + return Err("workspace must be an absolute POSIX path on the remote host".to_string()); + } + return Ok(()); + } if !Path::new(workspace.trim()).is_absolute() { return Err("workspace must be an absolute path".to_string()); } Ok(()) } - fn resolve_workspace(&self, workspace: &str) -> BitFunResult { - Self::validate_workspace_format(workspace).map_err(BitFunError::tool)?; + fn resolve_workspace( + &self, + workspace: &str, + context: Option<&ToolUseContext>, + ) -> BitFunResult { + Self::validate_workspace_format(workspace, context).map_err(BitFunError::tool)?; + + if let Some(ctx) = context { + if ctx.is_remote() { + return ctx.resolve_workspace_tool_path(workspace.trim()); + } + } let resolved = normalize_path(workspace.trim()); let path = Path::new(&resolved); @@ -91,7 +112,10 @@ impl CronTool { "workspace is required when the current workspace is unavailable".to_string(), ) })?; - self.resolve_workspace(&workspace.to_string_lossy().to_string()) + self.resolve_workspace( + &workspace.to_string_lossy().to_string(), + Some(context), + ) } fn resolve_effective_workspace( @@ -100,7 +124,7 @@ impl CronTool { context: &ToolUseContext, ) -> BitFunResult { match workspace { - Some(workspace) => self.resolve_workspace(workspace), + Some(workspace) => self.resolve_workspace(workspace, Some(context)), None => self.resolve_workspace_from_context(context), } } @@ -669,7 +693,7 @@ Patch schema for "update": }; if let Some(workspace) = parsed.workspace.as_deref() { - if let Err(message) = Self::validate_workspace_format(workspace) { + if let Err(message) = Self::validate_workspace_format(workspace, context) { return ValidationResult { result: false, message: Some(message), diff --git a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs b/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs index 0170f470..2f5164e3 100644 --- a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs @@ -131,9 +131,10 @@ Important notes: }; } - let path = Path::new(path_str); - - if !path.is_absolute() { + let is_abs = context + .map(|c| c.workspace_path_is_effectively_absolute(path_str)) + .unwrap_or_else(|| Path::new(path_str).is_absolute()); + if !is_abs { return ValidationResult { result: false, message: Some("path must be an absolute path".to_string()), @@ -144,7 +145,8 @@ Important notes: let is_remote = context.map(|c| c.is_remote()).unwrap_or(false); if !is_remote { - if !path.exists() { + let local_path = Path::new(path_str); + if !local_path.exists() { return ValidationResult { result: false, message: Some(format!("Path does not exist: {}", path_str)), @@ -153,13 +155,13 @@ Important notes: }; } - if path.is_dir() { + if local_path.is_dir() { let recursive = input .get("recursive") .and_then(|v| v.as_bool()) .unwrap_or(false); - let is_empty = match fs::read_dir(path).await { + let is_empty = match fs::read_dir(local_path).await { Ok(mut entries) => entries.next_entry().await.ok().flatten().is_none(), Err(_) => false, }; @@ -234,6 +236,8 @@ Important notes: .and_then(|v| v.as_bool()) .unwrap_or(false); + let resolved_path = context.resolve_workspace_tool_path(path_str)?; + // Remote workspace: delete via shell command if context.is_remote() { let ws_shell = context.ws_shell().ok_or_else(|| { @@ -241,9 +245,9 @@ Important notes: })?; let rm_cmd = if recursive { - format!("rm -rf '{}'", path_str.replace('\'', "'\\''")) + format!("rm -rf '{}'", resolved_path.replace('\'', "'\\''")) } else { - format!("rm -f '{}'", path_str.replace('\'', "'\\''")) + format!("rm -f '{}'", resolved_path.replace('\'', "'\\''")) }; let (_stdout, stderr, exit_code) = ws_shell @@ -257,7 +261,7 @@ Important notes: let result_data = json!({ "success": true, - "path": path_str, + "path": resolved_path, "is_directory": recursive, "recursive": recursive, "is_remote": true @@ -270,13 +274,13 @@ Important notes: }]); } - let path = Path::new(path_str); + let path = Path::new(&resolved_path); let is_directory = path.is_dir(); debug!( "DeleteFile tool deleting {}: {}", if is_directory { "directory" } else { "file" }, - path_str + resolved_path ); if is_directory { @@ -297,7 +301,7 @@ Important notes: let result_data = json!({ "success": true, - "path": path_str, + "path": resolved_path, "is_directory": is_directory, "recursive": recursive }); diff --git a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs index 1dc01714..5933b785 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs @@ -1,4 +1,3 @@ -use super::util::resolve_path_with_workspace; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -97,11 +96,7 @@ Usage: .and_then(|v| v.as_bool()) .unwrap_or(false); - let resolved_path = resolve_path_with_workspace( - file_path, - context.current_working_directory(), - context.workspace_root(), - )?; + let resolved_path = context.resolve_workspace_tool_path(file_path)?; // When WorkspaceServices is available (both local and remote), // use the abstract FS to read → edit in memory → write back. diff --git a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs index c5ea84be..bcd7b459 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs @@ -1,7 +1,7 @@ -use super::util::resolve_path_with_workspace; use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; +use crate::agentic::tools::workspace_paths::resolve_workspace_tool_path; use crate::service::ai_rules::get_global_ai_rules_service; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -227,10 +227,17 @@ Usage: } }; - let resolved_path = match resolve_path_with_workspace( + let cwd_owned = context.and_then(|ctx| ctx.current_working_directory.clone()); + let root_owned = context.and_then(|ctx| { + ctx.workspace + .as_ref() + .map(|w| w.root_path_string()) + }); + let resolved_path = match resolve_workspace_tool_path( file_path, - context.and_then(|ctx| ctx.current_working_directory()), - context.and_then(|ctx| ctx.workspace_root()), + cwd_owned.as_deref(), + root_owned.as_deref(), + context.map(|c| c.is_remote()).unwrap_or(false), ) { Ok(path) => path, Err(err) => { @@ -301,11 +308,7 @@ Usage: .and_then(|v| v.as_u64()) .unwrap_or(self.default_max_lines_to_read as u64) as usize; - let resolved_path = resolve_path_with_workspace( - file_path, - context.current_working_directory(), - context.workspace_root(), - )?; + let resolved_path = context.resolve_workspace_tool_path(file_path)?; // Use the workspace file system from context — works for both local and remote. let read_file_result = if context.is_remote() { diff --git a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs index 61eca34c..42895ee6 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs @@ -1,7 +1,7 @@ -use super::util::resolve_path_with_workspace; use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; +use crate::agentic::tools::workspace_paths::resolve_workspace_tool_path; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use serde_json::{json, Value}; @@ -89,10 +89,17 @@ Usage: }; } - if let Err(err) = resolve_path_with_workspace( + let cwd_owned = context.and_then(|ctx| ctx.current_working_directory.clone()); + let root_owned = context.and_then(|ctx| { + ctx.workspace + .as_ref() + .map(|w| w.root_path_string()) + }); + if let Err(err) = resolve_workspace_tool_path( file_path, - context.and_then(|ctx| ctx.current_working_directory()), - context.and_then(|ctx| ctx.workspace_root()), + cwd_owned.as_deref(), + root_owned.as_deref(), + context.map(|c| c.is_remote()).unwrap_or(false), ) { return ValidationResult { result: false, @@ -132,11 +139,7 @@ Usage: .and_then(|v| v.as_str()) .ok_or_else(|| BitFunError::tool("file_path is required".to_string()))?; - let resolved_path = resolve_path_with_workspace( - file_path, - context.current_working_directory(), - context.workspace_root(), - )?; + let resolved_path = context.resolve_workspace_tool_path(file_path)?; let content = input .get("content") diff --git a/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs b/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs index fd37c1aa..f63c9dde 100644 --- a/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs @@ -1,7 +1,7 @@ -use super::util::resolve_path_with_workspace; use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; +use crate::agentic::tools::workspace_paths::resolve_workspace_tool_path; use crate::service::git::git_service::GitService; use crate::service::git::git_types::GitDiffParams; use crate::service::git::git_utils::get_repository_root; @@ -328,7 +328,7 @@ Usage: async fn validate_input( &self, input: &Value, - _context: Option<&ToolUseContext>, + context: Option<&ToolUseContext>, ) -> ValidationResult { if let Some(file_path) = input.get("file_path").and_then(|v| v.as_str()) { if file_path.is_empty() { @@ -340,23 +340,50 @@ Usage: }; } - let path = Path::new(file_path); - if !path.exists() { - return ValidationResult { - result: false, - message: Some(format!("File does not exist: {}", file_path)), - error_code: Some(404), - meta: None, - }; - } + let is_remote = context.map(|c| c.is_remote()).unwrap_or(false); + + let cwd_owned = context.and_then(|c| c.current_working_directory.clone()); + let root_owned = context.and_then(|c| { + c.workspace + .as_ref() + .map(|w| w.root_path_string()) + }); + let resolved_path = match resolve_workspace_tool_path( + file_path, + cwd_owned.as_deref(), + root_owned.as_deref(), + is_remote, + ) { + Ok(p) => p, + Err(e) => { + return ValidationResult { + result: false, + message: Some(e.to_string()), + error_code: Some(400), + meta: None, + }; + } + }; - if !path.is_file() { - return ValidationResult { - result: false, - message: Some(format!("Path is not a file: {}", file_path)), - error_code: Some(400), - meta: None, - }; + if !is_remote { + let path = Path::new(&resolved_path); + if !path.exists() { + return ValidationResult { + result: false, + message: Some(format!("File does not exist: {}", resolved_path)), + error_code: Some(404), + meta: None, + }; + } + + if !path.is_file() { + return ValidationResult { + result: false, + message: Some(format!("Path is not a file: {}", resolved_path)), + error_code: Some(400), + meta: None, + }; + } } } else { return ValidationResult { @@ -409,17 +436,44 @@ Usage: .and_then(|v| v.as_str()) .ok_or_else(|| BitFunError::tool("file_path is required".to_string()))?; - let resolved_path = resolve_path_with_workspace( - file_path, - context.current_working_directory(), - context.workspace_root(), - )?; + let resolved_path = context.resolve_workspace_tool_path(file_path)?; debug!( "GetFileDiff tool starting diff retrieval for file: {:?}", resolved_path ); + if context.is_remote() { + let ws_fs = context.ws_fs().ok_or_else(|| { + BitFunError::tool("Workspace file system not available for remote diff".to_string()) + })?; + let content = ws_fs + .read_file_text(&resolved_path) + .await + .map_err(|e| BitFunError::tool(format!("Failed to read file: {}", e)))?; + let total_lines = content.lines().count(); + let data = json!({ + "file_path": resolved_path, + "diff_type": "full", + "diff_format": "unified", + "diff_content": content.clone(), + "original_content": "", + "modified_content": content, + "stats": { + "additions": 0, + "deletions": 0, + "total_lines": total_lines + }, + "message": "File full content on remote workspace (baseline/git diff not available locally)" + }); + let result_for_assistant = self.render_tool_result_message(&data); + return Ok(vec![ToolResult::Result { + data, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]); + } + // Priority 1: Try baseline diff let path = Path::new(&resolved_path); if let Some(result) = self diff --git a/src/crates/core/src/agentic/tools/implementations/git_tool.rs b/src/crates/core/src/agentic/tools/implementations/git_tool.rs index 1dddf190..eb1e38d3 100644 --- a/src/crates/core/src/agentic/tools/implementations/git_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/git_tool.rs @@ -64,21 +64,77 @@ impl GitTool { .any(|&danger| full_cmd.contains(danger)) } - /// Get workspace path + fn sh_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) + } + + /// Resolve repository root: workspace root or a path resolved with the same rules as file tools + /// (POSIX on remote SSH). fn get_repo_path( working_directory: Option<&str>, context: &ToolUseContext, ) -> BitFunResult { if let Some(dir) = working_directory { - Ok(dir.to_string()) + let trimmed = dir.trim(); + if trimmed.is_empty() { + return context + .workspace + .as_ref() + .map(|w| w.root_path_string()) + .ok_or_else(|| BitFunError::tool("No workspace path available".to_string())); + } + context.resolve_workspace_tool_path(trimmed) } else { context - .workspace_root() - .map(|p| p.to_string_lossy().to_string()) + .workspace + .as_ref() + .map(|w| w.root_path_string()) .ok_or_else(|| BitFunError::tool("No workspace path available".to_string())) } } + /// Run `git` on the remote host over SSH (same environment as native CLI on the server). + async fn execute_remote_git_cli( + repo_path: &str, + operation: &str, + args: Option<&str>, + context: &ToolUseContext, + ) -> BitFunResult { + let shell = context + .ws_shell() + .ok_or_else(|| BitFunError::tool("Remote Git requires workspace shell (SSH)".to_string()))?; + + let args_str = args.unwrap_or("").trim(); + let cmd = if args_str.is_empty() { + format!( + "git --no-pager -C {} {}", + Self::sh_quote(repo_path), + operation + ) + } else { + format!( + "git --no-pager -C {} {} {}", + Self::sh_quote(repo_path), + operation, + args_str + ) + }; + + let (stdout, stderr, exit_code) = shell + .exec(&cmd, Some(180_000)) + .await + .map_err(|e| BitFunError::tool(format!("Remote git failed: {}", e)))?; + + Ok(json!({ + "success": exit_code == 0, + "exit_code": exit_code, + "stdout": stdout, + "stderr": stderr, + "command": cmd, + "remote_execution": true, + })) + } + /// Execute status operation using GitService async fn execute_status(repo_path: &str) -> BitFunResult { let status = GitService::get_status(repo_path) @@ -595,7 +651,11 @@ This tool provides a safe and convenient way to execute Git commands. It support - Dangerous operations (like `push --force`, `reset --hard`) will show warnings - Never run `git config` to modify user settings - Always verify changes before committing -- Use `--dry-run` for push/pull operations when unsure + - Use `--dry-run` for push/pull operations when unsure + +## Remote SSH + +When the workspace is opened over Remote SSH, Git runs on the **server** (see tool description context at runtime). ## Commit Message Guidelines @@ -610,6 +670,19 @@ When creating commits, use this format for the commit message: Co-Authored-By: BitFun"#.to_string()) } + async fn description_with_context( + &self, + context: Option<&ToolUseContext>, + ) -> BitFunResult { + let mut base = self.description().await?; + if context.map(|c| c.is_remote()).unwrap_or(false) { + base.push_str( + "\n\n**Remote workspace:** Commands execute on the **SSH host** via `git -C …`, using the same repository and Git install as a native terminal on that server (equivalent to Claude Code / CLI on the remote machine). Paths are POSIX paths on the server.", + ); + } + Ok(base) + } + fn input_schema(&self) -> Value { json!({ "type": "object", @@ -831,19 +904,22 @@ When creating commits, use this format for the commit message: let start_time = std::time::Instant::now(); - // Select execution method based on operation type - let result = match operation { - "status" => Self::execute_status(&repo_path).await?, - "diff" => Self::execute_diff(&repo_path, args).await?, - "log" => Self::execute_log(&repo_path, args).await?, - "add" => Self::execute_add(&repo_path, args).await?, - "commit" => Self::execute_commit(&repo_path, args).await?, - "push" => Self::execute_push(&repo_path, args).await?, - "pull" => Self::execute_pull(&repo_path, args).await?, - "checkout" | "switch" => Self::execute_checkout(&repo_path, args).await?, - "branch" => Self::execute_branch(&repo_path, args).await?, - // Other operations use generic command execution - _ => Self::execute_generic(&repo_path, operation, args).await?, + // Remote SSH workspace: run git on the server (not libgit2 on the PC). + let result = if context.is_remote() { + Self::execute_remote_git_cli(&repo_path, operation, args, context).await? + } else { + match operation { + "status" => Self::execute_status(&repo_path).await?, + "diff" => Self::execute_diff(&repo_path, args).await?, + "log" => Self::execute_log(&repo_path, args).await?, + "add" => Self::execute_add(&repo_path, args).await?, + "commit" => Self::execute_commit(&repo_path, args).await?, + "push" => Self::execute_push(&repo_path, args).await?, + "pull" => Self::execute_pull(&repo_path, args).await?, + "checkout" | "switch" => Self::execute_checkout(&repo_path, args).await?, + "branch" => Self::execute_branch(&repo_path, args).await?, + _ => Self::execute_generic(&repo_path, operation, args).await?, + } }; let duration = start_time.elapsed(); @@ -860,10 +936,12 @@ When creating commits, use this format for the commit message: "execution_time_ms".to_string(), json!(duration.as_millis() as u64), ); - obj.insert( - "command".to_string(), - json!(format!("git {} {}", operation, args.unwrap_or(""))), - ); + if !context.is_remote() { + obj.insert( + "command".to_string(), + json!(format!("git {} {}", operation, args.unwrap_or(""))), + ); + } obj.insert("operation".to_string(), json!(operation)); obj.insert("working_directory".to_string(), json!(repo_path)); } diff --git a/src/crates/core/src/agentic/tools/implementations/glob_tool.rs b/src/crates/core/src/agentic/tools/implementations/glob_tool.rs index 2c163564..0f4d6edc 100644 --- a/src/crates/core/src/agentic/tools/implementations/glob_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/glob_tool.rs @@ -5,7 +5,7 @@ use globset::GlobBuilder; use ignore::WalkBuilder; use log::warn; use serde_json::{json, Value}; -use std::path::{Path, PathBuf}; +use std::path::Path; pub fn glob_with_ignore( search_path: &str, @@ -176,22 +176,17 @@ impl Tool for GlobTool { .and_then(|v| v.as_str()) .ok_or_else(|| BitFunError::tool("pattern is required".to_string()))?; - let resolved_path = match input.get("path").and_then(|v| v.as_str()) { - Some(user_path) if Path::new(user_path).is_absolute() => PathBuf::from(user_path), - Some(user_path) => { - let workspace_root = context.workspace_root().ok_or_else(|| { - BitFunError::tool(format!( - "workspace_path is required to resolve relative search path: {}", - user_path - )) - })?; - workspace_root.join(user_path) - } - None => context.workspace_root().map(PathBuf::from).ok_or_else(|| { - BitFunError::tool( - "workspace_path is required when Glob path is omitted".to_string(), - ) - })?, + let resolved_str = match input.get("path").and_then(|v| v.as_str()) { + Some(user_path) => context.resolve_workspace_tool_path(user_path)?, + None => context + .workspace + .as_ref() + .map(|w| w.root_path_string()) + .ok_or_else(|| { + BitFunError::tool( + "workspace_path is required when Glob path is omitted".to_string(), + ) + })?, }; let limit = input @@ -206,7 +201,7 @@ impl Tool for GlobTool { BitFunError::tool("Workspace shell not available".to_string()) })?; - let search_dir = resolved_path.display().to_string(); + let search_dir = resolved_str.clone(); let find_cmd = build_remote_find_command(&search_dir, pattern, limit); let (stdout, _stderr, _exit_code) = ws_shell @@ -238,7 +233,7 @@ impl Tool for GlobTool { }]); } - let matches = call_glob(&resolved_path.display().to_string(), pattern, limit) + let matches = call_glob(&resolved_str, pattern, limit) .map_err(|e| BitFunError::tool(e))?; let result_text = if matches.is_empty() { @@ -250,7 +245,7 @@ impl Tool for GlobTool { let result = ToolResult::Result { data: json!({ "pattern": pattern, - "path": resolved_path.display().to_string(), + "path": resolved_str, "matches": matches, "match_count": matches.len() }), diff --git a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs b/src/crates/core/src/agentic/tools/implementations/grep_tool.rs index 8244b410..2f801ba1 100644 --- a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/grep_tool.rs @@ -1,4 +1,3 @@ -use super::util::resolve_path_with_workspace; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -28,11 +27,7 @@ impl GrepTool { .ok_or_else(|| BitFunError::tool("pattern is required".to_string()))?; let search_path = input.get("path").and_then(|v| v.as_str()).unwrap_or("."); - let resolved_path = resolve_path_with_workspace( - search_path, - context.current_working_directory(), - context.workspace_root(), - )?; + let resolved_path = context.resolve_workspace_tool_path(search_path)?; let case_insensitive = input.get("-i").and_then(|v| v.as_bool()).unwrap_or(false); let head_limit = input @@ -107,11 +102,7 @@ impl GrepTool { .ok_or_else(|| BitFunError::tool("pattern is required".to_string()))?; let search_path = input.get("path").and_then(|v| v.as_str()).unwrap_or("."); - let resolved_path = resolve_path_with_workspace( - search_path, - context.current_working_directory(), - context.workspace_root(), - )?; + let resolved_path = context.resolve_workspace_tool_path(search_path)?; let case_insensitive = input.get("-i").and_then(|v| v.as_bool()).unwrap_or(false); let multiline = input diff --git a/src/crates/core/src/agentic/tools/implementations/ls_tool.rs b/src/crates/core/src/agentic/tools/implementations/ls_tool.rs index e2a9de0e..f8a79477 100644 --- a/src/crates/core/src/agentic/tools/implementations/ls_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/ls_tool.rs @@ -104,9 +104,10 @@ Usage: }; } - let path_obj = Path::new(path); - - if !path_obj.is_absolute() { + let is_abs = context + .map(|c| c.workspace_path_is_effectively_absolute(path)) + .unwrap_or_else(|| Path::new(path).is_absolute()); + if !is_abs { return ValidationResult { result: false, message: Some(format!("path must be an absolute path, got: {}", path)), @@ -117,7 +118,8 @@ Usage: let is_remote = context.map(|c| c.is_remote()).unwrap_or(false); if !is_remote { - if !path_obj.exists() { + let local_path = Path::new(path); + if !local_path.exists() { return ValidationResult { result: false, message: Some(format!("Directory does not exist: {}", path)), @@ -126,7 +128,7 @@ Usage: }; } - if !path_obj.is_dir() { + if !local_path.is_dir() { return ValidationResult { result: false, message: Some(format!("Path is not a directory: {}", path)), diff --git a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs index a669e876..8e638719 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs @@ -1,4 +1,5 @@ use super::util::normalize_path; +use crate::agentic::tools::workspace_paths::posix_style_path_is_absolute; use crate::agentic::coordination::{ get_global_coordinator, get_global_scheduler, AgentSessionReplyRoute, DialogSubmissionPolicy, DialogTriggerSource, @@ -42,7 +43,7 @@ impl SessionMessageTool { Ok(()) } - fn resolve_workspace(&self, workspace: &str) -> BitFunResult { + fn resolve_workspace(&self, workspace: &str, context: &ToolUseContext) -> BitFunResult { let workspace = workspace.trim(); if workspace.is_empty() { return Err(BitFunError::tool( @@ -50,6 +51,15 @@ impl SessionMessageTool { )); } + if context.is_remote() { + if !posix_style_path_is_absolute(workspace) { + return Err(BitFunError::tool( + "workspace must be an absolute POSIX path on the remote host".to_string(), + )); + } + return context.resolve_workspace_tool_path(workspace); + } + let path = Path::new(workspace); if !path.is_absolute() { return Err(BitFunError::tool( @@ -243,7 +253,26 @@ When overriding an existing session's agent_type, only switching between "agenti }; } - if !Path::new(parsed.workspace.trim()).is_absolute() { + let Some(context) = context else { + if !Path::new(parsed.workspace.trim()).is_absolute() + && !posix_style_path_is_absolute(parsed.workspace.trim()) + { + return ValidationResult { + result: false, + message: Some("workspace must be an absolute path".to_string()), + error_code: Some(400), + meta: None, + }; + } + return ValidationResult::default(); + }; + + let ws_ok = if context.is_remote() { + posix_style_path_is_absolute(parsed.workspace.trim()) + } else { + Path::new(parsed.workspace.trim()).is_absolute() + }; + if !ws_ok { return ValidationResult { result: false, message: Some("workspace must be an absolute path".to_string()), @@ -252,10 +281,6 @@ When overriding an existing session's agent_type, only switching between "agenti }; } - let Some(context) = context else { - return ValidationResult::default(); - }; - let Some(source_session_id) = context.session_id.as_deref() else { return ValidationResult { result: false, @@ -301,7 +326,7 @@ When overriding an existing session's agent_type, only switching between "agenti ) -> BitFunResult> { let params: SessionMessageInput = serde_json::from_value(input.clone()) .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; - let workspace = self.resolve_workspace(¶ms.workspace)?; + let workspace = self.resolve_workspace(¶ms.workspace, context)?; let workspace_path = Path::new(&workspace); let source_session_id = self.sender_session_id(context)?.to_string(); let target_session_id = params.session_id.clone(); diff --git a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs index 1b054f24..8b1a2786 100644 --- a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs @@ -85,9 +85,15 @@ impl Tool for SkillTool { &self, context: Option<&ToolUseContext>, ) -> BitFunResult { - Ok(self + let mut s = self .build_description(context.and_then(|ctx| ctx.workspace_root())) - .await) + .await; + if context.map(|c| c.is_remote()).unwrap_or(false) { + s.push_str( + "\n\n**Remote workspace:** Project-level skills under `.bitfun/skills` on the **server** may not appear in the list above because this index is built from the local workspace view. Use **Read** / **Glob** on the remote tree if you need a skill file that is not listed.", + ); + } + Ok(s) } fn input_schema(&self) -> Value { diff --git a/src/crates/core/src/agentic/tools/implementations/util.rs b/src/crates/core/src/agentic/tools/implementations/util.rs index aaa50e07..a7cafafc 100644 --- a/src/crates/core/src/agentic/tools/implementations/util.rs +++ b/src/crates/core/src/agentic/tools/implementations/util.rs @@ -1,75 +1,3 @@ -use crate::util::errors::{BitFunError, BitFunResult}; -use std::path::Path; -use std::path::{Component, PathBuf}; - -pub fn normalize_path(path: &str) -> String { - let path = Path::new(path); - let mut components = Vec::new(); - for component in path.components() { - match component { - Component::CurDir => {} // Ignore "." - Component::ParentDir => { - // Handle ".." - if !components.is_empty() { - components.pop(); - } - } - c => components.push(c), - } - } - components - .iter() - .collect::() - .to_string_lossy() - .to_string() -} - -pub fn resolve_path_with_workspace( - path: &str, - current_working_directory: Option<&Path>, - workspace_root: Option<&Path>, -) -> BitFunResult { - if Path::new(path).is_absolute() { - Ok(normalize_path(path)) - } else { - let base_path = current_working_directory.or(workspace_root).ok_or_else(|| { - BitFunError::tool(format!( - "A current working directory or workspace path is required to resolve relative path: {}", - path - )) - })?; - - Ok(normalize_path(&base_path.join(path).to_string_lossy().to_string())) - } -} - -pub fn resolve_path(path: &str) -> BitFunResult { - resolve_path_with_workspace(path, None, None) -} - -#[cfg(test)] -mod tests { - use super::resolve_path_with_workspace; - use std::path::Path; - - #[test] - fn resolves_relative_paths_from_current_working_directory_first() { - let resolved = resolve_path_with_workspace( - "src/main.rs", - Some(Path::new("/repo/crates/core")), - Some(Path::new("/repo")), - ) - .expect("path should resolve"); - - assert_eq!(resolved, "/repo/crates/core/src/main.rs"); - } - - #[test] - fn falls_back_to_workspace_root_when_current_working_directory_missing() { - let resolved = - resolve_path_with_workspace("src/main.rs", None, Some(Path::new("/repo"))) - .expect("path should resolve"); - - assert_eq!(resolved, "/repo/src/main.rs"); - } -} +pub use crate::agentic::tools::workspace_paths::{ + normalize_path, resolve_path, resolve_path_with_workspace, +}; diff --git a/src/crates/core/src/agentic/tools/mod.rs b/src/crates/core/src/agentic/tools/mod.rs index 3bf92eeb..d1bfef9f 100644 --- a/src/crates/core/src/agentic/tools/mod.rs +++ b/src/crates/core/src/agentic/tools/mod.rs @@ -7,6 +7,7 @@ pub mod computer_use_verification; pub mod framework; pub mod image_context; pub mod implementations; +pub mod workspace_paths; pub mod input_validator; pub mod pipeline; pub mod registry; diff --git a/src/crates/core/src/agentic/tools/workspace_paths.rs b/src/crates/core/src/agentic/tools/workspace_paths.rs new file mode 100644 index 00000000..14065a0a --- /dev/null +++ b/src/crates/core/src/agentic/tools/workspace_paths.rs @@ -0,0 +1,176 @@ +//! Workspace path resolution for agent tools. +//! +//! When BitFun runs on Windows but the open workspace is a **remote SSH** (POSIX) tree, +//! `std::path::Path` treats paths like `/home/user/proj` as non-absolute and joins them +//! incorrectly. Remote sessions must use POSIX path semantics for tool arguments. + +use crate::util::errors::{BitFunError, BitFunResult}; +use std::path::{Component, Path, PathBuf}; + +pub fn normalize_path(path: &str) -> String { + let path = Path::new(path); + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if !components.is_empty() { + components.pop(); + } + } + c => components.push(c), + } + } + components + .iter() + .collect::() + .to_string_lossy() + .to_string() +} + +pub fn resolve_path_with_workspace( + path: &str, + current_working_directory: Option<&Path>, + workspace_root: Option<&Path>, +) -> BitFunResult { + if Path::new(path).is_absolute() { + Ok(normalize_path(path)) + } else { + let base_path = current_working_directory.or(workspace_root).ok_or_else(|| { + BitFunError::tool(format!( + "A current working directory or workspace path is required to resolve relative path: {}", + path + )) + })?; + + Ok(normalize_path( + base_path.join(path).to_string_lossy().as_ref(), + )) + } +} + +pub fn resolve_path(path: &str) -> BitFunResult { + resolve_path_with_workspace(path, None, None) +} + +/// POSIX absolute: after normalizing backslashes, path starts with `/`. +pub fn posix_style_path_is_absolute(path: &str) -> bool { + let p = path.trim().replace('\\', "/"); + p.starts_with('/') +} + +fn posix_normalize_components(path: &str) -> String { + let path = path.trim().replace('\\', "/"); + let is_abs = path.starts_with('/'); + let mut stack: Vec = Vec::new(); + for part in path.split('/') { + if part.is_empty() || part == "." { + continue; + } + if part == ".." { + stack.pop(); + } else { + stack.push(part.to_string()); + } + } + let body = stack.join("/"); + if is_abs { + format!("/{}", body) + } else { + body + } +} + +/// Resolve a path using POSIX rules (for remote SSH workspaces). +pub fn posix_resolve_path_with_workspace( + path: &str, + current_working_directory: Option<&str>, + workspace_root: Option<&str>, +) -> BitFunResult { + let path = path.trim(); + if path.is_empty() { + return Err(BitFunError::tool("path cannot be empty".to_string())); + } + + let normalized_input = path.replace('\\', "/"); + + let combined = if posix_style_path_is_absolute(&normalized_input) { + normalized_input + } else { + let base = current_working_directory + .or(workspace_root) + .ok_or_else(|| { + BitFunError::tool(format!( + "A current working directory or workspace path is required to resolve relative path: {}", + path + )) + })? + .trim() + .replace('\\', "/"); + let base = base.trim_end_matches('/'); + format!("{}/{}", base, normalized_input) + }; + + Ok(posix_normalize_components(&combined)) +} + +/// Unified resolver: POSIX semantics when the workspace is remote SSH; otherwise host `Path`. +pub fn resolve_workspace_tool_path( + path: &str, + current_working_directory: Option<&str>, + workspace_root: Option<&str>, + workspace_is_remote: bool, +) -> BitFunResult { + if workspace_is_remote { + posix_resolve_path_with_workspace(path, current_working_directory, workspace_root) + } else { + resolve_path_with_workspace( + path, + current_working_directory.map(Path::new), + workspace_root.map(Path::new), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_relative_paths_from_current_working_directory_first() { + let resolved = resolve_path_with_workspace( + "src/main.rs", + Some(Path::new("/repo/crates/core")), + Some(Path::new("/repo")), + ) + .expect("path should resolve"); + + assert_eq!(resolved, "/repo/crates/core/src/main.rs"); + } + + #[test] + fn falls_back_to_workspace_root_when_current_working_directory_missing() { + let resolved = + resolve_path_with_workspace("src/main.rs", None, Some(Path::new("/repo"))) + .expect("path should resolve"); + + assert_eq!(resolved, "/repo/src/main.rs"); + } + + #[test] + fn posix_absolute_starts_with_slash() { + let r = posix_resolve_path_with_workspace( + "/home/user/file.txt", + None, + Some("/should/not/matter"), + ) + .unwrap(); + assert_eq!(r, "/home/user/file.txt"); + } + + #[test] + fn posix_relative_joins_workspace() { + let r = posix_resolve_path_with_workspace("src/main.rs", None, Some("/home/proj")).unwrap(); + assert_eq!(r, "/home/proj/src/main.rs"); + } +} diff --git a/src/crates/core/src/agentic/util/mod.rs b/src/crates/core/src/agentic/util/mod.rs index ccd0765a..05b3267e 100644 --- a/src/crates/core/src/agentic/util/mod.rs +++ b/src/crates/core/src/agentic/util/mod.rs @@ -1,3 +1,5 @@ pub mod list_files; +pub mod remote_workspace_layout; pub use list_files::get_formatted_files_list; +pub use remote_workspace_layout::build_remote_workspace_layout_preview; diff --git a/src/crates/core/src/agentic/util/remote_workspace_layout.rs b/src/crates/core/src/agentic/util/remote_workspace_layout.rs new file mode 100644 index 00000000..bf55b61f --- /dev/null +++ b/src/crates/core/src/agentic/util/remote_workspace_layout.rs @@ -0,0 +1,66 @@ +//! Build a text layout snapshot for remote SSH workspaces (system prompt). + +use crate::service::remote_ssh::{RemoteFileService, RemoteTreeNode}; +use tokio::time::{timeout, Duration}; + +fn append_tree_lines( + node: &RemoteTreeNode, + prefix: &str, + count: &mut usize, + max_lines: usize, + out: &mut Vec, +) -> bool { + let Some(children) = &node.children else { + return false; + }; + let n = children.len(); + let mut hit = false; + for (i, child) in children.iter().enumerate() { + if *count >= max_lines { + return true; + } + *count += 1; + let is_last = i == n - 1; + let conn = if is_last { "└── " } else { "├── " }; + let name = if child.is_dir { + format!("{}/", child.name.trim_end_matches('/')) + } else { + child.name.clone() + }; + out.push(format!("{}{}{}", prefix, conn, name)); + let ext = if is_last { " " } else { "│ " }; + if append_tree_lines(child, &format!("{}{}", prefix, ext), count, max_lines, out) { + hit = true; + } + } + hit +} + +/// Single SFTP `read_dir` at workspace root, formatted as a shallow tree (no subtree walk). +pub async fn build_remote_workspace_layout_preview( + file_service: &RemoteFileService, + connection_id: &str, + root: &str, + max_lines: usize, +) -> Result<(bool, String), String> { + const LAYOUT_PREVIEW_TIMEOUT: Duration = Duration::from_secs(15); + let tree = timeout( + LAYOUT_PREVIEW_TIMEOUT, + file_service.build_shallow_tree_for_layout_preview(connection_id, root), + ) + .await + .map_err(|_| "remote layout preview timed out".to_string())? + .map_err(|e| e.to_string())?; + + let root_line = root.trim_end_matches('/').to_string(); + let mut lines = vec![root_line.clone()]; + let mut count = lines.len(); + let mut hit = count >= max_lines; + if !hit { + hit = append_tree_lines(&tree, "", &mut count, max_lines, &mut lines); + } + if hit && count >= max_lines { + lines.push("... (truncated)".to_string()); + } + Ok((hit, lines.join("\n"))) +} diff --git a/src/crates/core/src/infrastructure/filesystem/file_tree.rs b/src/crates/core/src/infrastructure/filesystem/file_tree.rs index 82f4df49..b28b54a6 100644 --- a/src/crates/core/src/infrastructure/filesystem/file_tree.rs +++ b/src/crates/core/src/infrastructure/filesystem/file_tree.rs @@ -170,9 +170,19 @@ impl FileTreeService { } pub async fn build_tree(&self, root_path: &str) -> Result, String> { + self.build_tree_with_remote_hint(root_path, None).await + } + + pub async fn build_tree_with_remote_hint( + &self, + root_path: &str, + preferred_remote_connection_id: Option<&str>, + ) -> Result, String> { // For remote workspaces, delegate to get_directory_contents which handles SSH if crate::service::remote_ssh::workspace_state::is_remote_path(root_path).await { - return self.get_directory_contents(root_path).await; + return self + .get_directory_contents_with_remote_hint(root_path, preferred_remote_connection_id) + .await; } let root_path_buf = PathBuf::from(root_path); @@ -196,7 +206,7 @@ impl FileTreeService { ) -> BitFunResult<(Vec, FileTreeStatistics)> { // For remote workspaces, return simple directory listing with empty stats if crate::service::remote_ssh::workspace_state::is_remote_path(root_path).await { - let nodes = self.get_directory_contents(root_path).await + let nodes = self.get_directory_contents_with_remote_hint(root_path, None).await .map_err(|e| BitFunError::service(e))?; let stats = FileTreeStatistics { total_files: nodes.iter().filter(|n| !n.is_directory).count(), @@ -629,8 +639,23 @@ impl FileTreeService { } pub async fn get_directory_contents(&self, path: &str) -> Result, String> { + self.get_directory_contents_with_remote_hint(path, None).await + } + + /// `preferred_remote_connection_id`: when set (e.g. from workspace/session), resolves SSH file ops + /// without relying on global `active_connection_hint` — required when multiple remotes share the same root path. + pub async fn get_directory_contents_with_remote_hint( + &self, + path: &str, + preferred_remote_connection_id: Option<&str>, + ) -> Result, String> { // Check if this path belongs to any registered remote workspace - if let Some(entry) = crate::service::remote_ssh::workspace_state::lookup_remote_connection(path).await { + if let Some(entry) = crate::service::remote_ssh::workspace_state::lookup_remote_connection_with_hint( + path, + preferred_remote_connection_id, + ) + .await + { if let Some(manager) = crate::service::remote_ssh::workspace_state::get_remote_workspace_manager() { if let Some(file_service) = manager.get_file_service().await { match file_service.read_dir(&entry.connection_id, path).await { diff --git a/src/crates/core/src/service/config/app_language.rs b/src/crates/core/src/service/config/app_language.rs new file mode 100644 index 00000000..b22b99db --- /dev/null +++ b/src/crates/core/src/service/config/app_language.rs @@ -0,0 +1,32 @@ +//! Canonical UI language for user-facing AI output. +//! +//! Desktop and server store the active locale in `app.language` (see `i18n_set_language` in the +//! desktop crate). Agent prompts read this via `PromptBuilder::get_language_preference`. Any +//! other AI calls that should match the UI (e.g. session titles) must use the same source — not +//! `I18nService::get_current_locale`, which historically synced from `i18n.currentLanguage` only. + +use super::GlobalConfigManager; +use log::debug; + +/// Returns `zh-CN` or `en-US` from global config when valid; otherwise `zh-CN` (matches [`crate::service::config::AppConfig::default`]). +pub async fn get_app_language_code() -> String { + let Ok(svc) = GlobalConfigManager::get_service().await else { + return "zh-CN".to_string(); + }; + match svc.get_config::(Some("app.language")).await { + Ok(code) if code == "zh-CN" || code == "en-US" => code, + Ok(other) => { + debug!("Unknown app.language {}, defaulting to zh-CN", other); + "zh-CN".to_string() + } + Err(_) => "zh-CN".to_string(), + } +} + +/// Short instruction for models to answer in the app UI language (session titles, etc.). +pub fn short_model_user_language_instruction(lang_code: &str) -> &'static str { + match lang_code { + "en-US" => "Use English", + _ => "使用简体中文", + } +} diff --git a/src/crates/core/src/service/config/mod.rs b/src/crates/core/src/service/config/mod.rs index 032e6540..728af260 100644 --- a/src/crates/core/src/service/config/mod.rs +++ b/src/crates/core/src/service/config/mod.rs @@ -2,6 +2,7 @@ //! //! A complete configuration management system based on the Provider mechanism. +pub mod app_language; pub mod factory; pub mod global; pub mod manager; @@ -10,6 +11,7 @@ pub mod service; pub mod tool_config_sync; pub mod types; +pub use app_language::{get_app_language_code, short_model_user_language_instruction}; pub use factory::ConfigFactory; pub use global::{ get_global_config_service, initialize_global_config, reload_global_config, diff --git a/src/crates/core/src/service/filesystem/service.rs b/src/crates/core/src/service/filesystem/service.rs index 4783ee17..d2ea1a0d 100644 --- a/src/crates/core/src/service/filesystem/service.rs +++ b/src/crates/core/src/service/filesystem/service.rs @@ -32,8 +32,17 @@ impl FileSystemService { /// Builds a file tree. pub async fn build_file_tree(&self, root_path: &str) -> BitFunResult> { + self.build_file_tree_with_remote_hint(root_path, None).await + } + + /// Same as [`Self::build_file_tree`], but disambiguates remote roots when `preferred_remote_connection_id` is set. + pub async fn build_file_tree_with_remote_hint( + &self, + root_path: &str, + preferred_remote_connection_id: Option<&str>, + ) -> BitFunResult> { self.file_tree_service - .build_tree(root_path) + .build_tree_with_remote_hint(root_path, preferred_remote_connection_id) .await .map_err(|e| BitFunError::service(e)) } @@ -58,8 +67,16 @@ impl FileSystemService { /// Gets directory contents (shallow). pub async fn get_directory_contents(&self, path: &str) -> BitFunResult> { + self.get_directory_contents_with_remote_hint(path, None).await + } + + pub async fn get_directory_contents_with_remote_hint( + &self, + path: &str, + preferred_remote_connection_id: Option<&str>, + ) -> BitFunResult> { self.file_tree_service - .get_directory_contents(path) + .get_directory_contents_with_remote_hint(path, preferred_remote_connection_id) .await .map_err(|e| BitFunError::service(e)) } diff --git a/src/crates/core/src/service/i18n/service.rs b/src/crates/core/src/service/i18n/service.rs index 05d0f169..dfd4d381 100644 --- a/src/crates/core/src/service/i18n/service.rs +++ b/src/crates/core/src/service/i18n/service.rs @@ -63,16 +63,29 @@ impl I18nService { self.load_all_bundles().await?; if let Some(ref config_service) = self.config_service { - match config_service - .get_config::(Some("i18n.currentLanguage")) + // Prefer `app.language` (desktop source of truth), then legacy `i18n.currentLanguage`. + let mut resolved: Option = None; + if let Ok(app_lang) = config_service + .get_config::(Some("app.language")) .await { - Ok(locale) => { + resolved = LocaleId::from_str(&app_lang); + } + if resolved.is_none() { + if let Ok(locale) = config_service + .get_config::(Some("i18n.currentLanguage")) + .await + { + resolved = Some(locale); + } + } + match resolved { + Some(locale) => { let mut current = self.current_locale.write().await; *current = locale; info!("Loaded locale from config: {}", current.as_str()); } - Err(_) => { + None => { debug!("Locale config not found, using default"); } } diff --git a/src/crates/core/src/service/remote_ssh/remote_fs.rs b/src/crates/core/src/service/remote_ssh/remote_fs.rs index b59ad90c..13c28b8f 100644 --- a/src/crates/core/src/service/remote_ssh/remote_fs.rs +++ b/src/crates/core/src/service/remote_ssh/remote_fs.rs @@ -6,6 +6,27 @@ use crate::service::remote_ssh::types::{RemoteDirEntry, RemoteFileEntry, RemoteT use anyhow::anyhow; use std::sync::Arc; +/// Names skipped when listing workspace root for system-prompt preview (still lazy: no descent). +fn should_skip_dir_in_prompt_preview(name: &str) -> bool { + matches!( + name, + "node_modules" + | ".git" + | "target" + | ".cargo" + | "__pycache__" + | "dist" + | "build" + | ".venv" + | "venv" + | "vendor" + | ".next" + | ".cache" + | ".nx" + | ".gradle" + ) +} + /// Remote file service using SFTP protocol #[derive(Clone)] pub struct RemoteFileService { @@ -113,7 +134,7 @@ impl RemoteFileService { Ok(result) } - /// Build a tree of remote directory structure + /// Build a tree of remote directory structure (full walk; used by file explorer). pub async fn build_tree( &self, connection_id: &str, @@ -124,6 +145,54 @@ impl RemoteFileService { Box::pin(self.build_tree_impl(connection_id, path, 0, max_depth)).await } + /// System prompt only: **one** SFTP `read_dir` at `path`, no recursion into subdirectories. + /// Deep structure is left to list/glob tools (lazy expansion). + pub async fn build_shallow_tree_for_layout_preview( + &self, + connection_id: &str, + path: &str, + ) -> anyhow::Result { + const MAX_ENTRIES: usize = 80; + let name = std::path::Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string()); + + let mut entries = self.read_dir(connection_id, path).await?; + entries.retain(|e| { + if e.is_dir { + !should_skip_dir_in_prompt_preview(&e.name) + } else { + true + } + }); + entries.sort_by(|a, b| { + match (a.is_dir, b.is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.cmp(&b.name), + } + }); + entries.truncate(MAX_ENTRIES); + + let children: Vec = entries + .into_iter() + .map(|e| RemoteTreeNode { + name: e.name, + path: e.path, + is_dir: e.is_dir, + children: None, + }) + .collect(); + + Ok(RemoteTreeNode { + name, + path: path.to_string(), + is_dir: true, + children: Some(children), + }) + } + async fn build_tree_impl( &self, connection_id: &str, diff --git a/src/crates/core/src/service/snapshot/manager.rs b/src/crates/core/src/service/snapshot/manager.rs index a0c6c153..5fe69396 100644 --- a/src/crates/core/src/service/snapshot/manager.rs +++ b/src/crates/core/src/service/snapshot/manager.rs @@ -348,6 +348,10 @@ impl Tool for WrappedTool { self.original_tool.input_schema() } + async fn input_schema_for_model(&self) -> Value { + self.original_tool.input_schema_for_model().await + } + fn input_json_schema(&self) -> Option { self.original_tool.input_json_schema() } diff --git a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx index 15838b2c..3daff42e 100644 --- a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx +++ b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx @@ -9,6 +9,7 @@ import { useApp } from '@/app/hooks/useApp'; import { useMyAgentStore } from '@/app/scenes/my-agent/myAgentStore'; import { useNurseryStore } from '@/app/scenes/profile/nurseryStore'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { findWorkspaceForSession } from '@/flow_chat/utils/workspaceScope'; import { openMainSession } from '@/flow_chat/services/openBtwSession'; import type { FlowChatState, Session } from '@/flow_chat/types/flow-chat'; import type { WorkspaceInfo } from '@/shared/types'; @@ -76,7 +77,7 @@ const NavSearchDialog: React.FC = ({ open, onClose }) => { const result: Array<{ session: Session; workspace: WorkspaceInfo | undefined }> = []; const allWorkspaces = [...openedWorkspacesList]; for (const session of flowChatState.sessions.values()) { - const workspace = allWorkspaces.find(w => w.rootPath === session.workspacePath); + const workspace = findWorkspaceForSession(session, allWorkspaces); result.push({ session, workspace }); } result.sort((a, b) => { diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 002ff4b3..27618f36 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -70,12 +70,12 @@ const SessionsSection: React.FC = ({ workspacePath, remoteConnectionId = null, remoteSshHost = null, - isActiveWorkspace = true, + isActiveWorkspace: _isActiveWorkspace = true, assistantLabel, showSessionModeIcon = true, }) => { const { t } = useI18n('common'); - const { setActiveWorkspace } = useWorkspaceContext(); + const { setActiveWorkspace, currentWorkspace } = useWorkspaceContext(); const activeTabId = useSceneStore(s => s.activeTabId); const activeBtwSessionTab = useAgentCanvasStore(state => selectActiveBtwSessionTab(state as any)); const activeBtwSessionData = activeBtwSessionTab?.content.data as @@ -212,10 +212,12 @@ const SessionsSection: React.FC = ({ const session = flowChatStore.getState().sessions.get(sessionId); const relationship = resolveSessionRelationship(session); const parentSessionId = relationship.parentSessionId; - const activateWorkspace = workspaceId && !isActiveWorkspace + const mustActivateWorkspace = + Boolean(workspaceId) && workspaceId !== currentWorkspace?.id; + const activateWorkspace = mustActivateWorkspace ? async (targetWorkspaceId: string) => { - await setActiveWorkspace(targetWorkspaceId); - } + await setActiveWorkspace(targetWorkspaceId); + } : undefined; if (relationship.canOpenInAuxPane && parentSessionId && session) { @@ -250,7 +252,13 @@ const SessionsSection: React.FC = ({ log.error('Failed to switch session', err); } }, - [activeSessionId, editingSessionId, isActiveWorkspace, setActiveWorkspace, workspaceId] + [ + activeSessionId, + editingSessionId, + setActiveWorkspace, + workspaceId, + currentWorkspace?.id, + ] ); const resolveSessionTitle = useCallback( diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 62e093b5..ff5a4526 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -198,7 +198,7 @@ const WorkspaceItem: React.FC = ({ setIsResettingWorkspace(true); try { await resetAssistantWorkspace(workspace.id); - await flowChatManager.resetWorkspaceSessions(workspace.rootPath, { + await flowChatManager.resetWorkspaceSessions(workspace, { reinitialize: isActive, preferredMode: 'Claw', ensureAssistantBootstrap: @@ -213,7 +213,7 @@ const WorkspaceItem: React.FC = ({ } finally { setIsResettingWorkspace(false); } - }, [isActive, isDefaultAssistantWorkspace, isResettingWorkspace, resetAssistantWorkspace, t, workspace.id, workspace.rootPath, workspace.workspaceKind]); + }, [isActive, isDefaultAssistantWorkspace, isResettingWorkspace, resetAssistantWorkspace, t, workspace]); const handleReveal = useCallback(async () => { setMenuOpen(false); diff --git a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts index cd53e017..ff698618 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts @@ -1083,6 +1083,119 @@ export const useProjectCanvasStore = createCanvasStoreHook(); export const useGitCanvasStore = createCanvasStoreHook(); export const usePanelViewCanvasStore = createCanvasStoreHook(); +// ==================== Agent canvas: per-workspace snapshots (AuxPane / Session scene) ==================== +// Switching active workspace saves the current agent canvas under the previous workspace id and restores +// the snapshot for the next id, so remote/local tabs coexist across workspace switches. + +const AGENT_CANVAS_SNAPSHOT_MAX = 12; +const agentWorkspaceSnapshots = new Map(); +const agentSnapshotLruOrder: string[] = []; +/** Dedupes React Strict Mode double-invoke when `prev` is null (ref reset on remount). */ +let lastAgentCanvasSwitchTargetKey: string | null = null; + +function normalizeAgentWorkspaceKey(id: string | null | undefined): string { + return id ?? '__none__'; +} + +function extractAgentPersistableState(state: CanvasStore): CanvasStoreState { + return { + primaryGroup: state.primaryGroup, + secondaryGroup: state.secondaryGroup, + tertiaryGroup: state.tertiaryGroup, + activeGroupId: state.activeGroupId, + layout: state.layout, + isMissionControlOpen: state.isMissionControlOpen, + draggingTabId: state.draggingTabId, + draggingFromGroupId: state.draggingFromGroupId, + closedTabs: state.closedTabs, + maxClosedTabsHistory: state.maxClosedTabsHistory, + }; +} + +function rememberAgentSnapshot(key: string, snapshot: CanvasStoreState): void { + const clone = structuredClone(snapshot); + clone.draggingTabId = null; + clone.draggingFromGroupId = null; + agentWorkspaceSnapshots.set(key, clone); + const idx = agentSnapshotLruOrder.indexOf(key); + if (idx >= 0) agentSnapshotLruOrder.splice(idx, 1); + agentSnapshotLruOrder.push(key); + while (agentWorkspaceSnapshots.size > AGENT_CANVAS_SNAPSHOT_MAX) { + const evict = agentSnapshotLruOrder.shift(); + if (!evict) break; + agentWorkspaceSnapshots.delete(evict); + } +} + +function applyEmptyAgentCanvas(): void { + useAgentCanvasStore.setState({ + primaryGroup: createEditorGroupState(), + secondaryGroup: createEditorGroupState(), + tertiaryGroup: createEditorGroupState(), + activeGroupId: 'primary', + layout: createLayoutState(), + isMissionControlOpen: false, + draggingTabId: null, + draggingFromGroupId: null, + closedTabs: [], + maxClosedTabsHistory: initialState.maxClosedTabsHistory, + }); +} + +/** + * Save the current agent canvas under `prevWorkspaceId` (unless first mount) and restore the snapshot + * for `nextWorkspaceId` (or empty canvas if none). Capture target snapshot before LRU eviction. + */ +export function switchAgentCanvasWorkspace( + prevWorkspaceId: string | null | undefined, + nextWorkspaceId: string | null | undefined +): void { + const from = + prevWorkspaceId === null || prevWorkspaceId === undefined + ? null + : normalizeAgentWorkspaceKey(prevWorkspaceId); + const to = normalizeAgentWorkspaceKey(nextWorkspaceId); + + if (from === null && lastAgentCanvasSwitchTargetKey === to) { + return; + } + + const rawNext = agentWorkspaceSnapshots.get(to); + const nextSnapshotClone = rawNext ? structuredClone(rawNext) : null; + + if (from !== null) { + const current = extractAgentPersistableState(useAgentCanvasStore.getState() as CanvasStore); + rememberAgentSnapshot(from, current); + } + + if (nextSnapshotClone) { + useAgentCanvasStore.setState({ + primaryGroup: nextSnapshotClone.primaryGroup, + secondaryGroup: nextSnapshotClone.secondaryGroup, + tertiaryGroup: nextSnapshotClone.tertiaryGroup, + activeGroupId: nextSnapshotClone.activeGroupId, + layout: nextSnapshotClone.layout, + isMissionControlOpen: false, + draggingTabId: null, + draggingFromGroupId: null, + closedTabs: nextSnapshotClone.closedTabs, + maxClosedTabsHistory: nextSnapshotClone.maxClosedTabsHistory, + }); + } else { + applyEmptyAgentCanvas(); + } + + lastAgentCanvasSwitchTargetKey = to; +} + +/** Drop cached canvas for a closed workspace (does not touch the live canvas unless user switches back). */ +export function removeAgentCanvasSnapshot(workspaceId: string): void { + const key = normalizeAgentWorkspaceKey(workspaceId); + agentWorkspaceSnapshots.delete(key); + const idx = agentSnapshotLruOrder.indexOf(key); + if (idx >= 0) agentSnapshotLruOrder.splice(idx, 1); +} + const selectWholeCanvasStore = (state: CanvasStore) => state; export function useCanvasStore(): CanvasStore; diff --git a/src/web-ui/src/app/components/panels/content-canvas/stores/index.ts b/src/web-ui/src/app/components/panels/content-canvas/stores/index.ts index 28e4d0f5..1b96cbc5 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/stores/index.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/stores/index.ts @@ -13,4 +13,6 @@ export { useActiveTabId, useLayout, useDragging, + switchAgentCanvasWorkspace, + removeAgentCanvasSnapshot, } from './canvasStore'; diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx index df729c65..71268334 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx @@ -117,6 +117,27 @@ export const Tab: React.FC = ({ e.preventDefault(); }, []); + const isPinned = tab.state === 'pinned'; + + /** Middle-click closes (same as SceneBar session tabs); skip pinned and pin/popout controls. */ + const handleMiddleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 1) return; + if (isPinned) return; + const target = e.target as HTMLElement; + if (target.closest('.canvas-tab__pin-icon') || target.closest('.canvas-tab__popout-btn')) return; + e.preventDefault(); + }, [isPinned]); + + const handleAuxClick = useCallback((e: React.MouseEvent) => { + if (e.button !== 1) return; + if (isPinned) return; + const target = e.target as HTMLElement; + if (target.closest('.canvas-tab__pin-icon') || target.closest('.canvas-tab__popout-btn')) return; + e.preventDefault(); + e.stopPropagation(); + void onClose(); + }, [isPinned, onClose]); + const isTaskDetail = tab.content.type === 'task-detail'; // Build class names @@ -140,6 +161,8 @@ export const Tab: React.FC = ({ onClick={handleClick} onDoubleClick={handleDoubleClick} onContextMenu={handleContextMenu} + onMouseDown={handleMiddleMouseDown} + onAuxClick={handleAuxClick} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} draggable diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.tsx b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.tsx index e695307f..73eba8bf 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.tsx @@ -124,6 +124,28 @@ export const TabOverflowMenu: React.FC = ({ await onTabClose(tabId); }, [onTabClose]); + const handleItemMiddleMouseDown = useCallback((e: React.MouseEvent, tab: CanvasTab) => { + if (e.button !== 1) return; + if (tab.state === 'pinned') return; + const target = e.target as HTMLElement; + if (target.closest('.canvas-tab-overflow-menu__item-close')) return; + e.preventDefault(); + }, []); + + const handleItemAuxClick = useCallback( + async (e: React.MouseEvent, tab: CanvasTab) => { + if (e.button !== 1) return; + if (tab.state === 'pinned') return; + const target = e.target as HTMLElement; + if (target.closest('.canvas-tab-overflow-menu__item-close')) return; + e.preventDefault(); + e.stopPropagation(); + await onTabClose(tab.id); + setIsOpen(false); + }, + [onTabClose] + ); + // Decide whether to show button: overflow tabs or mission control const shouldShowButton = hasOverflow || hasMissionControl; @@ -199,6 +221,8 @@ export const TabOverflowMenu: React.FC = ({ activeTabId === tab.id ? 'is-active' : '' } ${tab.isDirty ? 'is-dirty' : ''} ${tab.fileDeletedFromDisk ? 'is-file-deleted' : ''}`} onClick={() => handleTabClick(tab.id)} + onMouseDown={(e) => handleItemMiddleMouseDown(e, tab)} + onAuxClick={(e) => void handleItemAuxClick(e, tab)} > {tab.state === 'preview' && {titleWithDeleted}} diff --git a/src/web-ui/src/app/scenes/session/AuxPane.tsx b/src/web-ui/src/app/scenes/session/AuxPane.tsx index b8c14db2..b312c495 100644 --- a/src/web-ui/src/app/scenes/session/AuxPane.tsx +++ b/src/web-ui/src/app/scenes/session/AuxPane.tsx @@ -7,7 +7,12 @@ import { forwardRef, useEffect, useRef, useImperativeHandle, useCallback } from 'react'; import { ContentCanvas, useCanvasStore } from '../../components/panels/content-canvas'; +import { + switchAgentCanvasWorkspace, + removeAgentCanvasSnapshot, +} from '../../components/panels/content-canvas/stores'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import type { PanelContent as OldPanelContent } from '../../components/panels/base/types'; import type { PanelContent } from '../../components/panels/content-canvas/types'; import { createLogger } from '@/shared/utils/logger'; @@ -31,6 +36,9 @@ interface AuxPaneProps { const AuxPane = forwardRef( ({ workspacePath, isSceneActive = true }, ref) => { + const { workspace } = useCurrentWorkspace(); + const workspaceId = workspace?.id; + const { addTab, switchToTab, @@ -89,28 +97,29 @@ const AuxPane = forwardRef( convertContent, ]); - const prevWorkspacePathRef = useRef(workspacePath); + const prevWorkspaceIdRef = useRef(undefined); useEffect(() => { - if (prevWorkspacePathRef.current && prevWorkspacePathRef.current !== workspacePath) { - log.debug('Workspace path changed, resetting tabs', { - from: prevWorkspacePathRef.current, - to: workspacePath - }); - closeAllTabs(); - } - prevWorkspacePathRef.current = workspacePath; - }, [workspacePath, closeAllTabs]); + const next = workspaceId; + const prev = prevWorkspaceIdRef.current; + if (prev === next) return; + + log.debug('Active workspace changed, swapping agent canvas snapshot', { + from: prev ?? '(none)', + to: next ?? '(none)', + }); + switchAgentCanvasWorkspace(prev ?? null, next ?? null); + prevWorkspaceIdRef.current = next; + }, [workspaceId]); useEffect(() => { const removeListener = workspaceManager.addEventListener((event) => { if (event.type === 'workspace:closed') { - log.debug('Workspace closed, resetting tabs'); - closeAllTabs(); + removeAgentCanvasSnapshot(event.workspaceId); } }); return () => removeListener(); - }, [closeAllTabs]); + }, []); const handleInteraction = useCallback(async (itemId: string, userInput: string) => { log.debug('Panel interaction', { itemId, userInput }); diff --git a/src/web-ui/src/flow_chat/hooks/useFlowChat.ts b/src/web-ui/src/flow_chat/hooks/useFlowChat.ts index a3c9baa7..f6fb07c6 100644 --- a/src/web-ui/src/flow_chat/hooks/useFlowChat.ts +++ b/src/web-ui/src/flow_chat/hooks/useFlowChat.ts @@ -133,7 +133,8 @@ export const useFlowChat = () => { const sessionConfig: SessionConfig = { modelName: config?.modelName || 'default', - ...config + ...config, + workspaceId: workspace?.id ?? config?.workspaceId, }; flowChatStore.createSession( @@ -172,7 +173,8 @@ export const useFlowChat = () => { const sessionConfig: SessionConfig = { modelName: config?.modelName || 'default', - ...config + ...config, + workspaceId: workspace?.id ?? config?.workspaceId, }; const sessionCount = flowChatStore.getState().sessions.size + 1; diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 2503ff20..cddbb607 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -13,6 +13,7 @@ import { AgentService } from '../../shared/services/agent-service'; import { stateMachineManager } from '../state-machine'; import { EventBatcher } from './EventBatcher'; import { createLogger } from '@/shared/utils/logger'; +import type { WorkspaceInfo } from '@/shared/types'; import { compareSessionsForDisplay, sessionBelongsToWorkspaceNavRow, @@ -191,25 +192,18 @@ export class FlowChatManager { } async resetWorkspaceSessions( - workspacePath: string, + workspace: Pick, options?: { reinitialize?: boolean; preferredMode?: string; /** After reinit, ask core to run assistant bootstrap if BOOTSTRAP.md is present (e.g. workspace reset). */ ensureAssistantBootstrap?: boolean; - /** When set, only removes/reinits sessions for this SSH connection (same path, different hosts). */ - remoteConnectionId?: string | null; - /** Disambiguates remote workspaces that share the same `workspacePath` (e.g. `/` on different hosts). */ - remoteSshHost?: string | null; } ): Promise { - const remoteConnectionId = options?.remoteConnectionId; - const remoteSshHost = options?.remoteSshHost; - const removedSessionIds = this.context.flowChatStore.removeSessionsByWorkspace( - workspacePath, - remoteConnectionId, - remoteSshHost - ); + const workspacePath = workspace.rootPath; + const remoteConnectionId = workspace.connectionId ?? null; + const remoteSshHost = workspace.sshHost ?? null; + const removedSessionIds = this.context.flowChatStore.removeSessionsForWorkspace(workspace); removedSessionIds.forEach(sessionId => { stateMachineManager.delete(sessionId); @@ -249,6 +243,7 @@ export class FlowChatManager { await this.createChatSession( { workspacePath, + workspaceId: workspace.id, ...(remoteConnectionId ? { remoteConnectionId } : {}), ...(remoteSshHost ? { remoteSshHost } : {}), }, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 84fb7394..a2918e30 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -782,13 +782,20 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { } // User may have pre-added this turn from the composer while the previous turn was still running; - // START was skipped then. When the backend dispatches this turn, move the state machine to PROCESSING. + // START failed then (PROCESSING/FINISHING cannot take START). When the backend dispatches this + // turn, align currentDialogTurnId so streaming events are not dropped. const machine = stateMachineManager.get(sessionId); - if (machine && machine.getCurrentState() === SessionExecutionState.IDLE) { - void stateMachineManager.transition(sessionId, SessionExecutionEvent.START, { - taskId: sessionId, - dialogTurnId: turnId, - }); + if (machine) { + const ctx = machine.getContext(); + if (ctx.currentDialogTurnId !== turnId) { + ctx.currentDialogTurnId = turnId; + } + if (machine.getCurrentState() === SessionExecutionState.IDLE) { + void stateMachineManager.transition(sessionId, SessionExecutionEvent.START, { + taskId: sessionId, + dialogTurnId: turnId, + }); + } } } @@ -1240,12 +1247,27 @@ function handleDialogTurnComplete( event: any, _onTodoWriteResult: (sessionId: string, turnId: string, result: any) => void ): void { - const { sessionId, turnId, subagentParentInfo } = event; + const sessionId = event?.sessionId ?? event?.session_id; + const turnId = event?.turnId ?? event?.turn_id; + const subagentParentInfo = event?.subagentParentInfo ?? event?.subagent_parent_info; if (subagentParentInfo) { return; } + if (!sessionId || !turnId) { + log.warn('DialogTurnCompleted missing sessionId or turnId', { event }); + return; + } + + const machine = stateMachineManager.get(sessionId); + if (machine) { + const ctx = machine.getContext(); + if (ctx.currentDialogTurnId !== turnId) { + ctx.currentDialogTurnId = turnId; + } + } + const store = FlowChatStore.getInstance(); const session = store.getState().sessions.get(sessionId); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index 00e5048f..11e1f684 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -150,11 +150,20 @@ export async function sendMessage( pinMode: 'sticky-latest', }; globalEventBus.emit(FLOWCHAT_PIN_TURN_TO_TOP_EVENT, pinRequest, 'MessageModule'); - - await stateMachineManager.transition(sessionId, SessionExecutionEvent.START, { + + const startOk = await stateMachineManager.transition(sessionId, SessionExecutionEvent.START, { taskId: sessionId, dialogTurnId, }); + // START is only valid from IDLE/ERROR (see STATE_TRANSITIONS). If the previous turn left the + // machine in PROCESSING/FINISHING, transition fails — but the backend still runs this turnId. + // Sync context so TextChunk/ModelRound events are not dropped (turn_id_mismatch). + if (!startOk) { + const machine = stateMachineManager.get(sessionId); + if (machine) { + machine.getContext().currentDialogTurnId = dialogTurnId; + } + } if (isFirstMessage) { handleTitleGeneration(context, sessionId, message); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 6c39b1a5..6e2e9540 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -62,10 +62,15 @@ const resolveSessionWorkspace = ( context: FlowChatContext, config?: SessionConfig ): WorkspaceInfo | null => { + const state = workspaceManager.getState(); + const configWorkspaceId = config?.workspaceId?.trim(); + if (configWorkspaceId) { + const byId = state.openedWorkspaces.get(configWorkspaceId); + if (byId) return byId; + } + const workspacePath = resolveSessionWorkspacePath(context, config); if (!workspacePath) return null; - - const state = workspaceManager.getState(); const pathMatches = Array.from(state.openedWorkspaces.values()).filter(workspace => { if (workspace.rootPath !== workspacePath) return false; if (workspace.workspaceKind !== WorkspaceKind.Remote) return true; @@ -175,9 +180,11 @@ export async function createChatSession( const agentType = resolveAgentType(mode, workspace); const sessionMode = normalizeSessionDisplayMode(agentType, workspace); const creationKey = - remoteConnectionId != null && remoteConnectionId !== '' - ? `${remoteConnectionId}\n${workspacePath}` - : workspacePath; + workspace?.id?.trim() + ? workspace.id + : remoteConnectionId != null && remoteConnectionId !== '' + ? `${remoteConnectionId}\n${workspacePath}` + : workspacePath; const pendingCreation = pendingSessionCreations.get(creationKey); if (pendingCreation) { @@ -197,6 +204,11 @@ export async function createChatSession( const maxContextTokens = await getModelMaxTokens(config.modelName); + const mergedConfig: SessionConfig = { + ...config, + workspaceId: workspace?.id ?? config.workspaceId, + }; + const createPromise = (async () => { const response = await agentAPI.createSession({ sessionName, @@ -218,7 +230,7 @@ export async function createChatSession( context.flowChatStore.createSession( response.sessionId, - config, + mergedConfig, undefined, sessionName, maxContextTokens, diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 14e55ace..d88640d9 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -23,6 +23,9 @@ import { deriveSessionRelationshipFromMetadata, normalizeSessionRelationship, } from '../utils/sessionMetadata'; +import type { WorkspaceInfo } from '@/shared/types'; +import { sessionBelongsToWorkspaceNavRow } from '../utils/sessionOrdering'; +import { sessionMatchesWorkspace } from '../utils/workspaceScope'; const log = createLogger('FlowChatStore'); @@ -210,6 +213,7 @@ export class FlowChatStore { maxContextTokens: maxContextTokens || 128128, mode: mode || 'agentic', workspacePath, + workspaceId: config.workspaceId, remoteConnectionId, remoteSshHost, parentSessionId: relationship.parentSessionId, @@ -657,28 +661,36 @@ export class FlowChatStore { }); } + /** + * Remove sessions bound to a workspace using stable id + host/path scope (never path-only). + */ + public removeSessionsForWorkspace( + workspace: Pick + ): string[] { + const removedSessionIds = Array.from(this.state.sessions.values()) + .filter(session => sessionMatchesWorkspace(session, workspace)) + .map(session => session.sessionId); + + return this.removeSessionsByIds(removedSessionIds); + } + + /** @deprecated Prefer `removeSessionsForWorkspace` with full `WorkspaceInfo`. */ public removeSessionsByWorkspace( workspacePath: string, remoteConnectionId?: string | null, remoteSshHost?: string | null ): string[] { - const wsConn = remoteConnectionId?.trim() ?? ''; - const wsHost = remoteSshHost?.trim() ?? ''; const removedSessionIds = Array.from(this.state.sessions.values()) - .filter(session => { - if (session.workspacePath !== workspacePath) return false; - const sc = session.remoteConnectionId?.trim() ?? ''; - if (wsConn.length > 0 || sc.length > 0) { - if (sc !== wsConn) return false; - } - const sh = session.remoteSshHost?.trim() ?? ''; - if (wsHost.length > 0 || sh.length > 0) { - return sh === wsHost; - } - return true; - }) + .filter(session => + sessionBelongsToWorkspaceNavRow(session, workspacePath, remoteConnectionId, remoteSshHost) + ) .map(session => session.sessionId); + return this.removeSessionsByIds(removedSessionIds); + } + + private removeSessionsByIds(removedSessionIds: string[]): string[] { + if (removedSessionIds.length === 0) { return []; } diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 39f7cb8a..1566e696 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -190,6 +190,9 @@ export interface Session { // Sessions are always kept in store for event processing; only display is filtered. workspacePath?: string; + /** Stable backend id — always set for new sessions; do not infer workspace from path alone. */ + workspaceId?: string; + /** SSH remote: same `workspacePath` on different hosts must not share coordinator/persistence. */ remoteConnectionId?: string; @@ -238,6 +241,8 @@ export interface SessionConfig { agentType?: string; context?: Record; workspacePath?: string; + /** Binds session to `WorkspaceInfo.id` (path alone is insufficient for remotes). */ + workspaceId?: string; /** Disambiguates sessions when multiple remote workspaces share the same `workspacePath`. */ remoteConnectionId?: string; remoteSshHost?: string; diff --git a/src/web-ui/src/flow_chat/utils/workspaceScope.ts b/src/web-ui/src/flow_chat/utils/workspaceScope.ts new file mode 100644 index 00000000..b835474b --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/workspaceScope.ts @@ -0,0 +1,47 @@ +/** + * Workspace ↔ session binding. Never identify a remote workspace by path alone. + * + * - Prefer `workspaceId` (backend `WorkspaceInfo.id`) on the session when present. + * - Otherwise use host + path + connection (see `sessionBelongsToWorkspaceNavRow`). + */ + +import type { WorkspaceInfo } from '@/shared/types'; +import type { Session } from '../types/flow-chat'; +import { sessionBelongsToWorkspaceNavRow } from './sessionOrdering'; + +type SessionScope = Pick< + Session, + 'workspaceId' | 'workspacePath' | 'remoteConnectionId' | 'remoteSshHost' +>; + +type WorkspaceScope = Pick; + +export function sessionMatchesWorkspace(session: SessionScope, workspace: WorkspaceScope): boolean { + const sid = session.workspaceId?.trim(); + const wid = workspace.id?.trim(); + if (sid && wid) { + return sid === wid; + } + return sessionBelongsToWorkspaceNavRow( + session, + workspace.rootPath, + workspace.connectionId ?? null, + workspace.sshHost ?? null + ); +} + +export function findWorkspaceForSession( + session: SessionScope, + workspaces: Iterable +): WorkspaceInfo | undefined { + const sid = session.workspaceId?.trim(); + if (sid) { + for (const w of workspaces) { + if (w.id === sid) return w; + } + } + for (const w of workspaces) { + if (sessionMatchesWorkspace(session, w)) return w; + } + return undefined; +} diff --git a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts index 7da7b8ff..cd6c7557 100644 --- a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts +++ b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts @@ -584,14 +584,11 @@ class WorkspaceManager { log.info('Deleting assistant workspace', { workspaceId }); const removedWorkspace = this.state.openedWorkspaces.get(workspaceId); - const removedRootPath = removedWorkspace?.rootPath; - const removedConnectionId = removedWorkspace?.connectionId ?? null; - await globalStateAPI.deleteAssistantWorkspace(workspaceId); - if (removedRootPath) { + if (removedWorkspace) { const { flowChatStore } = await import('@/flow_chat/store/FlowChatStore'); - flowChatStore.removeSessionsByWorkspace(removedRootPath, removedConnectionId); + flowChatStore.removeSessionsForWorkspace(removedWorkspace); } const [currentWorkspace, recentWorkspaces, openedWorkspaces] = await Promise.all([