From 145e6838cf12bba24881df174f22057c15a28120 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 28 Jul 2026 01:26:08 -0700 Subject: [PATCH 1/2] fix(worktree): defer isolation until first prompt --- src/apps/cli/src/agent/runtime_client.rs | 4 +- src/apps/cli/src/chat_state.rs | 49 ++++++- src/apps/cli/src/modes/chat/sessions.rs | 9 +- src/apps/cli/src/modes/chat/worktree.rs | 129 ++++++++++-------- src/apps/cli/src/peer_host/commands/dialog.rs | 3 +- src/apps/desktop/src/api/agentic_api.rs | 12 +- .../src/agentic/coordination/coordinator.rs | 111 ++++++++++++++- .../src/agentic/coordination/scheduler.rs | 12 +- .../src/flow_chat/components/ChatInput.tsx | 55 ++------ .../ChatInputWorkspaceStrip.test.tsx | 37 +++-- .../components/ChatInputWorkspaceStrip.tsx | 57 ++++---- .../flow-chat-manager/MessageModule.ts | 44 +++++- .../src/flow_chat/store/FlowChatStore.test.ts | 35 +++++ .../src/flow_chat/store/FlowChatStore.ts | 29 ++++ src/web-ui/src/flow_chat/types/flow-chat.ts | 6 + .../flow_chat/utils/sessionWorktree.test.ts | 46 +++++++ .../src/flow_chat/utils/sessionWorktree.ts | 44 ++++++ .../api/service-api/AgentAPI.ts | 3 + .../api/service-api/WorktreeAPI.ts | 4 +- src/web-ui/src/locales/en-US/worktrees.json | 2 + src/web-ui/src/locales/zh-CN/worktrees.json | 2 + src/web-ui/src/locales/zh-TW/worktrees.json | 2 + 22 files changed, 528 insertions(+), 167 deletions(-) diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 00d82a1fed..3d65f6e6a4 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -822,7 +822,9 @@ impl CliAgentRuntimeClient { original_message: None, turn_id: Some(turn_id.clone()), agent_type: agent_type.to_string(), - workspace_path: Some(self.workspace_path_string()), + // Dialog submission uses this path to locate persisted session + // state. Execution still comes from the session's resolved binding. + workspace_path: Some(self.project_workspace_path_string()), remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index d261c41f57..fbcde75038 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -315,6 +315,9 @@ pub(crate) struct ChatState { is_git_repository: bool, /// Whether this runtime exposes session worktree lifecycle controls. worktree_control_available: bool, + /// Empty-session preference. The actual worktree is created only after the + /// user submits the first prompt. + worktree_isolation_requested: Option, /// Current model display name (shown in shortcuts bar) pub current_model_name: String, /// Effective Auto mode for permission results that evaluate to Ask. @@ -375,6 +378,7 @@ impl ChatState { git_branch: None, is_git_repository: false, worktree_control_available: true, + worktree_isolation_requested: None, current_model_name: String::new(), auto_approve_ask: false, messages: Vec::new(), @@ -411,7 +415,7 @@ impl ChatState { self.git_branch = branch.filter(|value| !value.trim().is_empty()); } - pub(crate) fn is_worktree_enabled(&self) -> bool { + pub(crate) fn is_worktree_materialized(&self) -> bool { self.workspace_binding .as_ref() .and_then(|binding| binding.execution_target.as_ref()) @@ -419,6 +423,19 @@ impl ChatState { .is_some() } + pub(crate) fn is_worktree_enabled(&self) -> bool { + self.worktree_isolation_requested + .unwrap_or_else(|| self.is_worktree_materialized()) + } + + pub(crate) fn requested_worktree_enabled(&self) -> Option { + self.worktree_isolation_requested + } + + pub(crate) fn set_worktree_isolation_requested(&mut self, requested: Option) { + self.worktree_isolation_requested = requested; + } + pub(crate) fn has_conversation_history(&self) -> bool { self.metadata.message_count > 0 } @@ -445,7 +462,7 @@ impl ChatState { return branch.to_string(); } - if self.is_worktree_enabled() { + if self.is_worktree_materialized() { if let Some(commit) = execution_target .and_then(|target| target.base_commit.as_deref()) .map(str::trim) @@ -465,6 +482,14 @@ impl ChatState { pub(crate) fn worktree_status_label(&self) -> &'static str { if !self.worktree_control_available { "unavailable" + } else if self.worktree_isolation_requested == Some(true) + && !self.is_worktree_materialized() + { + "pending-on" + } else if self.worktree_isolation_requested == Some(false) + && self.is_worktree_materialized() + { + "pending-off" } else if self.is_worktree_enabled() { "on" } else if self.is_git_repository { @@ -1421,6 +1446,26 @@ mod tests { assert!(state.has_conversation_history()); } + #[test] + fn worktree_preference_is_visible_before_materialization() { + let mut state = ChatState::new( + "session-1".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("/tmp/project".to_string()), + ); + state.set_git_repository_status(true, Some("main".to_string())); + state.set_worktree_isolation_requested(Some(true)); + + assert!(state.is_worktree_enabled()); + assert!(!state.is_worktree_materialized()); + assert_eq!(state.worktree_status_label(), "pending-on"); + assert_eq!( + state.workspace_context_label(), + "Branch: main | Worktree: pending-on" + ); + } + #[test] fn workspace_context_prefers_managed_worktree_branch_or_detached_commit() { let mut state = ChatState::new( diff --git a/src/apps/cli/src/modes/chat/sessions.rs b/src/apps/cli/src/modes/chat/sessions.rs index f2d88dfa19..fa8f32b4f0 100644 --- a/src/apps/cli/src/modes/chat/sessions.rs +++ b/src/apps/cli/src/modes/chat/sessions.rs @@ -109,7 +109,7 @@ impl ChatMode { /// Show skill list/configuration menu. /// Send a message to the agent programmatically (used by slash commands like /init) fn send_message_to_agent( - &self, + &mut self, message: String, chat_view: &mut ChatView, chat_state: &mut ChatState, @@ -130,6 +130,13 @@ impl ChatMode { return; } + if let Err(error) = self.materialize_requested_worktree(chat_view, chat_state, rt_handle) { + tracing::error!("Failed to prepare worktree for submitted prompt: {error}"); + chat_view.set_status(Some(format!("Error: {error}"))); + chat_state.add_system_message(error); + return; + } + let display_name = agent_display_name(&self.agent_type); chat_view.set_status(Some(format!("{} is thinking...", display_name))); diff --git a/src/apps/cli/src/modes/chat/worktree.rs b/src/apps/cli/src/modes/chat/worktree.rs index 73e4dd3667..30d453d1b9 100644 --- a/src/apps/cli/src/modes/chat/worktree.rs +++ b/src/apps/cli/src/modes/chat/worktree.rs @@ -63,6 +63,73 @@ impl ChatMode { ) } + /// Materialize the checkbox/slash-command preference only after the user + /// has submitted a prompt. Keeping this next to the shared binding adapter + /// gives interactive input, prompt commands, and future send paths one + /// transition implementation. + fn materialize_requested_worktree( + &mut self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> std::result::Result<(), String> { + let Some(enabled) = chat_state.requested_worktree_enabled() else { + return Ok(()); + }; + if enabled == chat_state.is_worktree_materialized() { + chat_state.set_worktree_isolation_requested(None); + return Ok(()); + } + + chat_view.set_status(Some(if enabled { + "Creating worktree after prompt submission...".to_string() + } else { + "Releasing worktree after prompt submission...".to_string() + })); + let project_workspace_path = chat_state.project_workspace_path().map(str::to_string); + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(WorktreeService::bind_session( + WorktreeSessionBindingRequest { + request_id: uuid::Uuid::new_v4().to_string(), + session_id: chat_state.core_session_id.clone(), + project_workspace_path, + enabled, + }, + )) + }) + .map_err(|error| { + format!( + "Worktree isolation could not be prepared ({}): {}", + error.code.as_str(), + error.message + ) + })?; + + let previous_binding = chat_state.workspace_binding.as_ref(); + let binding = AgentSessionWorkspaceBinding { + workspace_id: result.workspace_id, + workspace_path: result.workspace_path, + project_workspace_path: Some(result.project_workspace_path), + execution_target: Some(result.execution_target), + remote_connection_id: previous_binding + .and_then(|binding| binding.remote_connection_id.clone()), + remote_ssh_host: previous_binding.and_then(|binding| binding.remote_ssh_host.clone()), + }; + self.agent.set_workspace_binding(&binding); + chat_state.apply_workspace_binding(binding); + chat_state.set_worktree_isolation_requested(None); + self.workspace = chat_state.workspace.clone(); + self.refresh_workspace_git_status(chat_state, rt_handle); + + if let Some(path) = result.retained_worktree_path { + chat_state.add_system_message(format!( + "The released worktree was kept because it may contain local or unpublished work: {}", + path + )); + } + Ok(()) + } + fn handle_worktree_command( &mut self, arguments: &str, @@ -79,8 +146,8 @@ impl ChatMode { } }; - self.refresh_workspace_git_status(chat_state, rt_handle); if command == WorktreeCommand::Status { + self.refresh_workspace_git_status(chat_state, rt_handle); let message = Self::worktree_status_message(chat_state); chat_view.set_status(Some(chat_state.workspace_context_label())); chat_state.add_system_message(message); @@ -116,58 +183,12 @@ impl ChatMode { WorktreeCommand::Set(enabled) => enabled, WorktreeCommand::Status => unreachable!("status returned above"), }; - chat_view.set_status(Some(if enabled { - "Enabling worktree isolation...".to_string() - } else { - "Disabling worktree isolation...".to_string() - })); - - let project_workspace_path = chat_state.project_workspace_path().map(str::to_string); - let result = tokio::task::block_in_place(|| { - rt_handle.block_on(WorktreeService::bind_session( - WorktreeSessionBindingRequest { - request_id: uuid::Uuid::new_v4().to_string(), - session_id: chat_state.core_session_id.clone(), - project_workspace_path, - enabled, - }, - )) - }); - - let result = match result { - Ok(result) => result, - Err(error) => { - let message = format!( - "Worktree isolation could not be changed ({}): {}", - error.code.as_str(), - error.message - ); - tracing::warn!("{}", message); - chat_view.set_status(Some(message.clone())); - chat_state.add_system_message(message); - return Ok(None); - } - }; - - let previous_binding = chat_state.workspace_binding.as_ref(); - let binding = AgentSessionWorkspaceBinding { - workspace_id: result.workspace_id, - workspace_path: result.workspace_path, - project_workspace_path: Some(result.project_workspace_path), - execution_target: Some(result.execution_target), - remote_connection_id: previous_binding - .and_then(|binding| binding.remote_connection_id.clone()), - remote_ssh_host: previous_binding.and_then(|binding| binding.remote_ssh_host.clone()), - }; - self.agent.set_workspace_binding(&binding); - chat_state.apply_workspace_binding(binding); - self.workspace = chat_state.workspace.clone(); - self.refresh_workspace_git_status(chat_state, rt_handle); - + chat_state.set_worktree_isolation_requested(Some(enabled)); let status = if enabled { - "Worktree isolation enabled".to_string() + "Worktree isolation armed; it will be created after the first prompt is submitted" + .to_string() } else { - "Worktree isolation disabled".to_string() + "Worktree isolation disarmed; no Git work runs until a prompt is submitted".to_string() }; chat_view.set_status(Some(format!( "{} ({})", @@ -179,12 +200,6 @@ impl ChatMode { status, Self::worktree_status_message(chat_state) )); - if let Some(path) = result.retained_worktree_path { - chat_state.add_system_message(format!( - "The released worktree was kept because it may contain local or unpublished work: {}", - path - )); - } Ok(None) } diff --git a/src/apps/cli/src/peer_host/commands/dialog.rs b/src/apps/cli/src/peer_host/commands/dialog.rs index 9ec6374997..49a956a239 100644 --- a/src/apps/cli/src/peer_host/commands/dialog.rs +++ b/src/apps/cli/src/peer_host/commands/dialog.rs @@ -40,7 +40,8 @@ pub(crate) async fn start_dialog_turn( let user_input = get_string(request, "userInput")?; let original_user_input = optional_string(request, "originalUserInput"); let agent_type = get_string(request, "agentType")?; - let workspace_path = optional_string(request, "workspacePath"); + let workspace_path = optional_string(request, "projectWorkspacePath") + .or_else(|| optional_string(request, "workspacePath")); let remote_connection_id = optional_string(request, "remoteConnectionId"); let remote_ssh_host = optional_string(request, "remoteSshHost"); let controller_lease = attached_controller_lease()?; diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 56755bedd0..6e1ee50c14 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -254,7 +254,13 @@ pub struct StartDialogTurnRequest { pub user_input: String, pub original_user_input: Option, pub agent_type: String, + /// Concrete execution root retained for backward compatibility and + /// non-native transports. pub workspace_path: Option, + /// Stable project root used to locate the session transcript when + /// execution happens in a managed worktree. + #[serde(default)] + pub project_workspace_path: Option, pub remote_connection_id: Option, pub remote_ssh_host: Option, pub turn_id: Option, @@ -1739,6 +1745,7 @@ fn desktop_dialog_turn_request( original_user_input, agent_type, workspace_path, + project_workspace_path, remote_connection_id, remote_ssh_host, turn_id, @@ -1762,7 +1769,7 @@ fn desktop_dialog_turn_request( original_message: original_user_input, turn_id, agent_type, - workspace_path, + workspace_path: project_workspace_path.or(workspace_path), remote_connection_id, remote_ssh_host, policy, @@ -3167,7 +3174,8 @@ mod tests { "userInput": "resolved input", "originalUserInput": "original input", "agentType": "agentic", - "workspacePath": "/workspace/project", + "workspacePath": "/worktrees/session-1", + "projectWorkspacePath": "/workspace/project", "remoteConnectionId": "connection-1", "remoteSshHost": "host-1", "turnId": "turn-1", diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 9ade7a5638..e640e84417 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -104,6 +104,57 @@ const SESSION_REFERENCE_ARTIFACT_STEM_LENGTH: usize = 8; const SESSION_REFERENCE_ARTIFACT_STEM_EXTENSION_LENGTH: usize = 4; const SESSION_REFERENCE_NAME_CHAR_LIMIT: usize = 96; +fn comparable_workspace_path(path: &str) -> String { + let path = path.trim(); + let mut normalized = dunce::canonicalize(Path::new(path)) + .unwrap_or_else(|_| PathBuf::from(path)) + .to_string_lossy() + .replace('\\', "/"); + while normalized.len() > 1 && normalized.ends_with('/') { + normalized.pop(); + } + #[cfg(windows)] + { + normalized.make_ascii_lowercase(); + } + normalized +} + +/// Turn submission APIs historically carried a single `workspacePath`, even +/// though a worktree session has two roots. Accept the persisted execution +/// root as a legacy alias, but normalize it to the owning project root before +/// any session-storage lookup. An unrelated requested workspace is preserved +/// so the existing cross-workspace identity check still rejects it. +pub(super) fn session_storage_workspace_locator( + requested_workspace_path: Option<&str>, + execution_workspace_path: Option<&str>, + project_workspace_path: Option<&str>, +) -> Option { + let requested_workspace_path = requested_workspace_path + .map(str::trim) + .filter(|path| !path.is_empty()); + let execution_workspace_path = execution_workspace_path + .map(str::trim) + .filter(|path| !path.is_empty()); + let project_workspace_path = project_workspace_path + .map(str::trim) + .filter(|path| !path.is_empty()); + + match requested_workspace_path { + Some(requested) + if execution_workspace_path.is_some_and(|execution| { + comparable_workspace_path(requested) == comparable_workspace_path(execution) + }) => + { + Some(project_workspace_path.unwrap_or(requested).to_string()) + } + Some(requested) => Some(requested.to_string()), + None => project_workspace_path + .or(execution_workspace_path) + .map(str::to_string), + } +} + fn trimmed_model_id(value: Option<&str>) -> Option { value .map(str::trim) @@ -3557,7 +3608,17 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet mut additional_prepended_messages: Vec, suppress_session_title_generation: bool, ) -> BitFunResult<()> { - let requested_restore_path = match workspace_path.as_deref() { + let loaded_session = self.session_manager.get_session(&session_id); + let storage_workspace_path = session_storage_workspace_locator( + workspace_path.as_deref(), + loaded_session + .as_ref() + .and_then(|session| session.config.workspace_path.as_deref()), + loaded_session + .as_ref() + .and_then(|session| session.config.project_workspace_path.as_deref()), + ); + let requested_restore_path = match storage_workspace_path.as_deref() { Some(workspace_path) => Some( Self::resolve_session_restore_path( workspace_path, @@ -3572,7 +3633,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // Get latest session, restoring from persistence on demand so every entry // point can use the same start_dialog_turn flow. A loaded session must keep // the same storage identity as this invocation. - let session = match self.session_manager.get_session(&session_id) { + let session = match loaded_session { Some(session) => { if let Some(restore_path) = requested_restore_path.as_deref() { self.session_manager @@ -3766,9 +3827,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ); let restore_workspace_path = session .config - .workspace_path + .project_workspace_path .as_deref() - .or(workspace_path.as_deref()) + .or(storage_workspace_path.as_deref()) .ok_or_else(|| { BitFunError::Validation(format!( "workspace_path is required when restoring session: {}", @@ -9229,9 +9290,10 @@ mod tests { normalize_subagent_max_concurrency, resolve_agent_session_create_created_by, resolve_agent_submission_turn_id, resolve_subagent_model_selection, runtime_port_error_preserving_message, runtime_tool_restrictions_for_session_lifetime, - turn_review_manifest_for_agent, BackgroundSubagentWaitMode, ConversationCoordinator, - SessionMemoryMode, SessionReferenceLocator, SessionRelationshipKind, - SubagentExecutionRequest, TEST_AGENT_MODEL_DEFAULTS, + session_storage_workspace_locator, turn_review_manifest_for_agent, + BackgroundSubagentWaitMode, ConversationCoordinator, SessionMemoryMode, + SessionReferenceLocator, SessionRelationshipKind, SubagentExecutionRequest, + TEST_AGENT_MODEL_DEFAULTS, }; use crate::agentic::coordination::coordination_store::{ BackgroundTaskRegistration, RegisteredBackgroundTask, @@ -9272,6 +9334,41 @@ mod tests { use std::sync::Arc; use std::time::Duration; + #[test] + fn worktree_execution_root_is_a_legacy_alias_for_project_storage() { + assert_eq!( + session_storage_workspace_locator( + Some(r"D:\worktrees\session-1"), + Some("D:/worktrees/session-1"), + Some("D:/projects/BitFun"), + ) + .as_deref(), + Some("D:/projects/BitFun") + ); + assert_eq!( + session_storage_workspace_locator( + None, + Some("/worktrees/session-1"), + Some("/projects/BitFun"), + ) + .as_deref(), + Some("/projects/BitFun") + ); + } + + #[test] + fn unrelated_workspace_is_not_rewritten_to_the_project_storage_root() { + assert_eq!( + session_storage_workspace_locator( + Some("/projects/other"), + Some("/worktrees/session-1"), + Some("/projects/BitFun"), + ) + .as_deref(), + Some("/projects/other") + ); + } + #[test] fn btw_session_memory_mode_requires_both_generation_switches() { assert_eq!( diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index 3a53511aab..f5c4f90632 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -11,7 +11,8 @@ //! - Queue cleared on unrecoverable failure use super::coordinator::{ - ConversationCoordinator, DialogTriggerSource, HiddenSubagentExecutionRequest, SubagentResult, + session_storage_workspace_locator, ConversationCoordinator, DialogTriggerSource, + HiddenSubagentExecutionRequest, SubagentResult, }; use super::turn_outcome::TurnOutcome; use super::turn_settlement::TurnSettlementRegistration; @@ -988,9 +989,16 @@ impl DialogScheduler { &self, session_id: String, resolved_turn_id: String, - queued_turn: QueuedTurn, + mut queued_turn: QueuedTurn, reject_if_busy: bool, ) -> Result { + if let Some(session) = self.session_manager.get_session(&session_id) { + queued_turn.workspace_path = session_storage_workspace_locator( + queued_turn.workspace_path.as_deref(), + session.config.workspace_path.as_deref(), + session.config.project_workspace_path.as_deref(), + ); + } if let Some(workspace_path) = queued_turn.workspace_path.as_deref() { let requested_storage_path = Self::resolve_session_restore_path( workspace_path, diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index cdc09b55b2..7a2b602d04 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -47,7 +47,6 @@ import { type SlashActionId, } from '../utils/slashActionSelection'; import { notificationService } from '@/shared/notification-system'; -import { worktreeAPI } from '@/infrastructure/api/service-api/WorktreeAPI'; import { useI18n } from '@/infrastructure/i18n'; import { inputReducer, initialInputState, type InputAction } from '../reducers/inputReducer'; import { modeReducer, initialModeState } from '../reducers/modeReducer'; @@ -82,6 +81,7 @@ import { isReviewSlashCommand } from '../deep-review/launch/commandParser'; import { createLogger } from '@/shared/utils/logger'; import { isSamePath } from '@/shared/utils/pathUtils'; import { + isSessionWorktreeIsolationEnabled, isSessionWorktreeBindingLocked, sessionWorktreeBindingSubscriptionKey, } from '../utils/sessionWorktree'; @@ -837,15 +837,6 @@ export const ChatInput: React.FC = ({ || workspacePath || '' ).trim(); - const sessionProjectWorkspacePath = ( - (!hasRegisteredWorkspace && ( - effectiveTargetSession?.config.projectWorkspacePath - || effectiveTargetSession?.projectWorkspacePath - || effectiveTargetSession?.workspacePath - )) - || workspacePath - || '' - ).trim(); const workspacePathRef = useRef(sessionBoundWorkspacePath); workspacePathRef.current = sessionBoundWorkspacePath; const { openedWorkspaces } = useWorkspaceContext(); @@ -1905,8 +1896,8 @@ export const ChatInput: React.FC = ({ }, [isAcpTargetSession, permissionModeSaving, t, toolPermissionConfig]); /** - * Worktree isolation is a property of where the session runs, so it can only - * move while the session is still empty. Remote sessions have no worktrees. + * Checking worktree isolation only arms the empty session. The first prompt + * materializes the worktree after it has visibly been submitted. */ const worktreeControl = useMemo(() => { if (!effectiveTargetSessionId || !effectiveTargetSession) return undefined; @@ -1919,8 +1910,9 @@ export const ChatInput: React.FC = ({ ); return { + enabled: isSessionWorktreeIsolationEnabled(effectiveTargetSession), locked, - onChange: async (enabled: boolean) => { + onChange: (enabled: boolean) => { const latestSession = FlowChatStore.getInstance() .getState() .sessions @@ -1932,38 +1924,10 @@ export const ChatInput: React.FC = ({ notificationService.error(tWorktrees('strip.toggleLocked')); return; } - const latestProjectWorkspacePath = ( - latestSession.config.projectWorkspacePath - || latestSession.projectWorkspacePath - || latestSession.workspacePath - || sessionProjectWorkspacePath - ).trim(); - try { - const result = await worktreeAPI.bindSession( - effectiveTargetSessionId, - enabled, - globalThis.crypto?.randomUUID?.() ?? `worktree-${Date.now()}`, - latestProjectWorkspacePath, - ); - FlowChatStore.getInstance().updateSessionExecutionTarget(effectiveTargetSessionId, { - workspacePath: result.workspacePath, - projectWorkspacePath: result.projectWorkspacePath, - workspaceId: result.workspaceId, - executionTarget: result.executionTarget, - }); - if (result.retainedWorktreePath) { - notificationService.info( - tWorktrees('strip.retained', { path: result.retainedWorktreePath }), - { duration: 6000 }, - ); - } - } catch (error) { - log.error('Failed to toggle session worktree isolation', error); - notificationService.error( - error instanceof Error ? error.message : String(error), - { duration: 5000 }, - ); - } + FlowChatStore.getInstance().setSessionWorktreeIsolationRequested( + effectiveTargetSessionId, + enabled, + ); }, }; }, [ @@ -1972,7 +1936,6 @@ export const ChatInput: React.FC = ({ derivedState?.isProcessing, isAcpTargetSession, isSubagentInputTarget, - sessionProjectWorkspacePath, tWorktrees, ]); diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx index b8deeed0e2..bd37689c73 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx @@ -167,7 +167,7 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { ); }); @@ -183,17 +183,14 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { expect(onChange).toHaveBeenCalledWith(true); }); - it('coalesces repeated clicks while a worktree transition is pending', async () => { - let finishTransition!: () => void; - const onChange = vi.fn(() => new Promise(resolve => { - finishTransition = resolve; - })); + it('updates repeated clicks optimistically without waiting for Git work', async () => { + const onChange = vi.fn(); await act(async () => { root.render( ); }); @@ -204,10 +201,24 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { toggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); - expect(onChange).toHaveBeenCalledOnce(); + expect(onChange).toHaveBeenNthCalledWith(1, true); + expect(onChange).toHaveBeenNthCalledWith(2, false); + }); + + it('shows an armed worktree as checked before it is materialized', async () => { await act(async () => { - finishTransition(); + root.render( + + ); }); + + const toggle = container.querySelector('[data-testid="chat-input-worktree-toggle"]'); + expect(toggle?.dataset.worktreeEnabled).toBe('true'); + expect(toggle?.dataset.worktreeMaterialized).toBe('false'); }); it('shows the toggle as on inside a worktree and asks to turn it off', async () => { @@ -225,7 +236,7 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { branch: 'bitfun/isolated', lifecycle: 'managed', }} - worktreeControl={{ locked: false, onChange }} + worktreeControl={{ enabled: true, locked: false, onChange }} /> ); }); @@ -247,7 +258,7 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { ); }); @@ -268,7 +279,7 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { ); }); @@ -285,7 +296,7 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { rootPath: '/worktrees/wt-1', lifecycle: 'managed', }} - worktreeControl={{ locked: false, onChange }} + worktreeControl={{ enabled: true, locked: false, onChange }} /> ); }); diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index 2144809f24..74d2959297 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -8,7 +8,6 @@ import { Activity, Check, EyeOff, - Loader2, GitBranch, Shield, ShieldAlert, @@ -56,9 +55,11 @@ export interface ChatInputWorkspaceStripProps { * Omitted when the session cannot host a worktree at all (remote, no session). */ worktreeControl?: { + /** Desired state, including an armed worktree not created until first send. */ + enabled: boolean; /** Locked once the session has a transcript — its history describes one directory. */ locked: boolean; - onChange: (enabled: boolean) => void | Promise; + onChange: (enabled: boolean) => void; }; } @@ -84,8 +85,6 @@ export const ChatInputWorkspaceStrip: React.FC = ( const { t: tWorktrees } = useI18n('worktrees'); const permissionRootRef = useRef(null); const [permissionMenuOpen, setPermissionMenuOpen] = useState(false); - const [worktreePending, setWorktreePending] = useState(false); - const worktreePendingRef = useRef(false); const trimmedPath = repositoryPath.trim(); const label = workspaceLabel.trim(); @@ -115,7 +114,11 @@ export const ChatInputWorkspaceStrip: React.FC = ( const showPermission = !!permissionControl; const showRightActions = showPermission || showUsage || showGoal; const isWorktree = !!executionTarget?.worktreeId; - const showWorktreeToggle = !!worktreeControl && (isRepository || isWorktree); + const worktreeEnabled = worktreeControl?.enabled ?? isWorktree; + const worktreeEnabledRef = useRef(worktreeEnabled); + worktreeEnabledRef.current = worktreeEnabled; + const showWorktreeToggle = + !!worktreeControl && (isRepository || isWorktree || worktreeEnabled); const permissionCopy = { ask: { label: t('chatInput.permissionMode.ask.label'), @@ -178,12 +181,17 @@ export const ChatInputWorkspaceStrip: React.FC = ( : '—'); const workspaceTooltipContent = trimmedPath || label; - const worktreeToggleDisabled = !!worktreeControl?.locked || worktreePending; - const worktreeTooltip = worktreeControl?.locked - ? tWorktrees('strip.toggleLocked') - : isWorktree - ? tWorktrees('strip.toggleOnDescription', { path: trimmedPath }) - : tWorktrees('strip.toggleOffDescription'); + const worktreeToggleDisabled = !!worktreeControl?.locked; + let worktreeTooltip = tWorktrees('strip.toggleOffDescription'); + if (worktreeControl?.locked) { + worktreeTooltip = tWorktrees('strip.toggleLocked'); + } else if (worktreeEnabled && !isWorktree) { + worktreeTooltip = tWorktrees('strip.togglePendingOnDescription'); + } else if (!worktreeEnabled && isWorktree) { + worktreeTooltip = tWorktrees('strip.togglePendingOffDescription'); + } else if (isWorktree) { + worktreeTooltip = tWorktrees('strip.toggleOnDescription', { path: trimmedPath }); + } const permissionMode = permissionControl?.mode ?? 'ask'; const permissionModeLabel = permissionCopy[permissionMode].label; const permissionTooltip = permissionMode === 'acp' @@ -197,15 +205,12 @@ export const ChatInputWorkspaceStrip: React.FC = ( const showPermissionLabel = permissionMode !== 'acp'; const handleWorktreeToggle = () => { - if (!worktreeControl || worktreeToggleDisabled || worktreePendingRef.current) { + if (!worktreeControl || worktreeToggleDisabled) { return; } - worktreePendingRef.current = true; - setWorktreePending(true); - void Promise.resolve(worktreeControl.onChange(!isWorktree)).finally(() => { - worktreePendingRef.current = false; - setWorktreePending(false); - }); + const nextEnabled = !worktreeEnabledRef.current; + worktreeEnabledRef.current = nextEnabled; + worktreeControl.onChange(nextEnabled); }; const split = !!label && showRightActions; @@ -248,28 +253,22 @@ export const ChatInputWorkspaceStrip: React.FC = (