Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/apps/cli/src/agent/runtime_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
49 changes: 47 additions & 2 deletions src/apps/cli/src/chat_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>,
/// Current model display name (shown in shortcuts bar)
pub current_model_name: String,
/// Effective Auto mode for permission results that evaluate to Ask.
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -411,14 +415,27 @@ 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())
.and_then(|target| target.worktree_id.as_ref())
.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<bool> {
self.worktree_isolation_requested
}

pub(crate) fn set_worktree_isolation_requested(&mut self, requested: Option<bool>) {
self.worktree_isolation_requested = requested;
}

pub(crate) fn has_conversation_history(&self) -> bool {
self.metadata.message_count > 0
}
Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 8 additions & 1 deletion src/apps/cli/src/modes/chat/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)));

Expand Down
129 changes: 72 additions & 57 deletions src/apps/cli/src/modes/chat/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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!(
"{} ({})",
Expand All @@ -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)
}
Expand Down
3 changes: 2 additions & 1 deletion src/apps/cli/src/peer_host/commands/dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?;
Expand Down
12 changes: 10 additions & 2 deletions src/apps/desktop/src/api/agentic_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,13 @@ pub struct StartDialogTurnRequest {
pub user_input: String,
pub original_user_input: Option<String>,
pub agent_type: String,
/// Concrete execution root retained for backward compatibility and
/// non-native transports.
pub workspace_path: Option<String>,
/// Stable project root used to locate the session transcript when
/// execution happens in a managed worktree.
#[serde(default)]
pub project_workspace_path: Option<String>,
pub remote_connection_id: Option<String>,
pub remote_ssh_host: Option<String>,
pub turn_id: Option<String>,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Loading