From 598822bf109c32c097be4fe1bc36280071297e33 Mon Sep 17 00:00:00 2001 From: J3y0r Date: Fri, 8 May 2026 02:33:43 +0000 Subject: [PATCH] fix(tui): sync runtime state on workspace switch --- crates/tui/src/task_manager.rs | 37 +++++++++++++++-- crates/tui/src/tools/shell.rs | 5 +++ crates/tui/src/tui/ui.rs | 51 ++++++++++++++--------- crates/tui/src/tui/ui/tests.rs | 74 +++++++++++++++++++++++++++++++++- 4 files changed, 142 insertions(+), 25 deletions(-) diff --git a/crates/tui/src/task_manager.rs b/crates/tui/src/task_manager.rs index e19d146e..bc6fde3a 100644 --- a/crates/tui/src/task_manager.rs +++ b/crates/tui/src/task_manager.rs @@ -701,6 +701,7 @@ pub type SharedTaskManager = Arc; pub struct TaskManager { cfg: TaskManagerConfig, + default_workspace: Mutex, executor: Arc, tasks_dir: PathBuf, artifacts_dir: PathBuf, @@ -766,8 +767,10 @@ impl TaskManager { let (tasks, queue) = load_state(&tasks_dir, &queue_path)?; let cancel_token = CancellationToken::new(); + let default_workspace = cfg.default_workspace.clone(); let manager = Arc::new(Self { cfg, + default_workspace: Mutex::new(default_workspace), executor, tasks_dir, artifacts_dir, @@ -810,6 +813,15 @@ impl TaskManager { self.cancel_token.is_cancelled() } + pub async fn set_default_workspace(&self, workspace: PathBuf) { + let mut default_workspace = self.default_workspace.lock().await; + *default_workspace = workspace; + } + + pub async fn default_workspace(&self) -> PathBuf { + self.default_workspace.lock().await.clone() + } + /// Enqueue a new task. pub async fn add_task(&self, req: NewTaskRequest) -> Result { let prompt = req.prompt.trim().to_string(); @@ -822,9 +834,10 @@ impl TaskManager { id: format!("task_{}", &Uuid::new_v4().to_string()[..8]), prompt, model: req.model.unwrap_or_else(|| self.cfg.default_model.clone()), - workspace: req - .workspace - .unwrap_or_else(|| self.cfg.default_workspace.clone()), + workspace: match req.workspace { + Some(workspace) => workspace, + None => self.default_workspace().await, + }, mode: req.mode.unwrap_or_else(|| self.cfg.default_mode.clone()), allow_shell: req.allow_shell.unwrap_or(self.cfg.allow_shell), trust_mode: req.trust_mode.unwrap_or(self.cfg.trust_mode), @@ -1763,6 +1776,24 @@ mod tests { Ok(()) } + #[tokio::test] + async fn default_workspace_updates_for_future_tasks() -> Result<()> { + let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4())); + let new_workspace = + std::env::temp_dir().join(format!("deepseek-workspace-{}", Uuid::new_v4())); + let manager = + TaskManager::start_with_executor(test_config(root), Arc::new(MockExecutor)).await?; + + manager.set_default_workspace(new_workspace.clone()).await; + let task = manager + .add_task(NewTaskRequest::from_prompt("test workspace default")) + .await?; + + assert_eq!(manager.default_workspace().await, new_workspace); + assert_eq!(task.workspace, new_workspace); + Ok(()) + } + #[tokio::test] async fn record_tool_metadata_updates_explicit_task() -> Result<()> { let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4())); diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index e9f0cc3f..cae5b8e1 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -584,6 +584,11 @@ impl ShellManager { self.sandbox_manager.is_available() } + #[allow(dead_code)] + pub fn default_workspace(&self) -> &Path { + &self.default_workspace + } + /// Execute a shell command with the configured sandbox policy. #[allow(dead_code)] pub fn execute( diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3a259c85..96e84298 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -38,7 +38,7 @@ use crate::core::coherence::CoherenceState; use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; use crate::core::events::Event as EngineEvent; use crate::core::ops::Op; -use crate::hooks::HookEvent; +use crate::hooks::{HookEvent, HookExecutor}; use crate::models::{ContentBlock, Message, SystemPrompt, context_window_for_model}; use crate::palette; use crate::prompts; @@ -270,7 +270,7 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { match load_result { Ok(Some(saved)) => { - let recovered = apply_loaded_session(&mut app, &saved); + let recovered = apply_loaded_session(&mut app, config, &saved); if !recovered { app.status_message = Some(format!( "Resumed session: {}", @@ -4557,7 +4557,7 @@ async fn apply_command_result( handle_mcp_ui_action(app, config, action).await; } AppAction::SwitchWorkspace { workspace } => { - switch_workspace(app, engine_handle, config, workspace).await; + switch_workspace(app, engine_handle, task_manager, config, workspace).await; } AppAction::SwitchProfile { profile } => { app.config_profile = Some(profile.clone()); @@ -4626,9 +4626,31 @@ async fn apply_command_result( Ok(false) } +fn apply_workspace_runtime_state(app: &mut App, config: &Config, workspace: PathBuf) { + app.workspace = workspace.clone(); + app.hooks = HookExecutor::new(config.hooks_config(), workspace.clone()); + app.skills_dir = crate::tui::app::resolve_skills_dir(&workspace, &config.skills_dir(), config); + app.refresh_skill_cache(); + app.workspace_context = None; + if let Ok(mut cell) = app.workspace_context_cell.lock() { + *cell = None; + } + app.workspace_context_refreshed_at = None; + app.file_tree = None; + + let shell_manager = crate::tools::shell::new_shared_shell_manager(workspace); + app.runtime_services.shell_manager = Some(shell_manager); + app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone())); +} + +async fn sync_runtime_workspace_state(task_manager: &SharedTaskManager, workspace: PathBuf) { + task_manager.set_default_workspace(workspace).await; +} + async fn switch_workspace( app: &mut App, engine_handle: &mut EngineHandle, + task_manager: &SharedTaskManager, config: &Config, workspace: PathBuf, ) { @@ -4646,20 +4668,8 @@ async fn switch_workspace( return; } - app.workspace = workspace.clone(); - app.hooks = HookExecutor::new(config.hooks_config(), workspace.clone()); - app.skills_dir = crate::tui::app::resolve_skills_dir(&workspace, &config.skills_dir(), config); - app.refresh_skill_cache(); - app.workspace_context = None; - if let Ok(mut cell) = app.workspace_context_cell.lock() { - *cell = None; - } - app.workspace_context_refreshed_at = None; - app.file_tree = None; - - let shell_manager = crate::tools::shell::new_shared_shell_manager(workspace.clone()); - app.runtime_services.shell_manager = Some(shell_manager); - app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone())); + apply_workspace_runtime_state(app, config, workspace.clone()); + sync_runtime_workspace_state(task_manager, workspace.clone()).await; let _ = engine_handle.send(Op::Shutdown).await; let engine_config = build_engine_config(app, config); @@ -5564,7 +5574,8 @@ async fn handle_view_events( match manager.load_session(&session_id) { Ok(session) => { - let recovered = apply_loaded_session(app, &session); + let recovered = apply_loaded_session(app, config, &session); + sync_runtime_workspace_state(task_manager, app.workspace.clone()).await; let _ = engine_handle .send(Op::SyncSession { messages: app.api_messages.clone(), @@ -5888,7 +5899,7 @@ async fn apply_provider_picker_api_key( switch_provider(app, engine_handle, config, provider, None).await; } -fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool { +fn apply_loaded_session(app: &mut App, config: &Config, session: &SavedSession) -> bool { let (messages, recovered_draft) = recover_interrupted_user_tail(&session.messages); app.api_messages = messages; app.clear_history(); @@ -5934,7 +5945,7 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool { app.viewport.transcript_selection.clear(); app.model.clone_from(&session.metadata.model); app.update_model_compaction_budget(); - app.workspace.clone_from(&session.metadata.workspace); + apply_workspace_runtime_state(app, config, session.metadata.workspace.clone()); app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX); app.session.total_conversation_tokens = app.session.total_tokens; app.session.session_cost = 0.0; diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 608f8878..0c42e57c 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -586,7 +586,7 @@ fn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() { "finish the Qthresh proof bundle", )]); - let recovered = apply_loaded_session(&mut app, &session); + let recovered = apply_loaded_session(&mut app, &Config::default(), &session); assert!(recovered); assert!(app.api_messages.is_empty()); @@ -637,7 +637,7 @@ fn apply_loaded_session_resets_unpersisted_telemetry() { let mut session = saved_session_with_messages(vec![text_message("assistant", "ready")]); session.metadata.total_tokens = 500; - let recovered = apply_loaded_session(&mut app, &session); + let recovered = apply_loaded_session(&mut app, &Config::default(), &session); assert!(!recovered); assert_eq!(app.session.total_tokens, 500); @@ -657,6 +657,76 @@ fn apply_loaded_session_resets_unpersisted_telemetry() { assert!(app.session.turn_cache_history.is_empty()); } +#[test] +fn apply_loaded_session_resets_workspace_runtime_state() { + let mut app = create_test_app(); + let config = Config::default(); + let old_shell_manager = app + .runtime_services + .shell_manager + .as_ref() + .expect("shell manager") + .clone(); + let old_context_cell = app.workspace_context_cell.clone(); + app.workspace_context = Some(Workspace::new(PathBuf::from("."))); + if let Ok(mut cell) = old_context_cell.lock() { + *cell = Some("old workspace context".to_string()); + } + app.workspace_context_refreshed_at = Some(Instant::now()); + app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(PathBuf::from( + ".", + ))); + + let mut session = saved_session_with_messages(vec![text_message("assistant", "ready")]); + session.metadata.workspace = TempDir::new().expect("temp dir").path().to_path_buf(); + + let recovered = apply_loaded_session(&mut app, &config, &session); + + assert!(!recovered); + assert_eq!(app.workspace, session.metadata.workspace); + assert!(app.workspace_context.is_none()); + assert!(app.workspace_context_refreshed_at.is_none()); + assert!(app.file_tree.is_none()); + assert!(old_context_cell.lock().expect("context cell").is_none()); + let new_shell_manager = app + .runtime_services + .shell_manager + .as_ref() + .expect("shell manager") + .clone(); + assert!(!std::sync::Arc::ptr_eq( + &old_shell_manager, + &new_shell_manager + )); + assert_eq!( + new_shell_manager + .lock() + .expect("shell manager") + .default_workspace(), + session.metadata.workspace.as_path() + ); + assert!(app.runtime_services.hook_executor.is_some()); +} + +#[test] +fn apply_loaded_session_updates_current_workspace_display() { + let mut app = create_test_app(); + let config = Config::default(); + let workspace = TempDir::new().expect("temp dir"); + let mut session = saved_session_with_messages(vec![text_message("assistant", "ready")]); + session.metadata.workspace = workspace.path().to_path_buf(); + + let recovered = apply_loaded_session(&mut app, &config, &session); + let result = commands::core::workspace_switch(&mut app, None); + + assert!(!recovered); + assert_eq!( + result.message, + Some(format!("Current workspace: {}", workspace.path().display())) + ); + assert!(result.action.is_none()); +} + #[tokio::test] async fn drain_web_config_events_applies_draft_without_closing_session() { let mut app = create_test_app();