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
3 changes: 1 addition & 2 deletions docs/development/ui-testids-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,7 @@
| 工作区创建 ACP 会话 | `nav-workspace-menu-create-acp-session` | 重复项。配合 `data-acp-client-id` 使用。 |
| 工作区创建 Init 会话 | `nav-workspace-menu-create-init-session` | 启动 AGENTS.md/init 会话。 |
| 工作区相关路径 | `nav-workspace-menu-related-paths` | 打开相关路径对话框。 |
| 工作区新建 worktree | `nav-workspace-menu-new-worktree` | 打开 worktree 创建对话框。 |
| 工作区删除 worktree | `nav-workspace-menu-delete-worktree` | 删除关联 worktree 工作区。 |
| 会话 worktree 开关 | `chat-input-worktree-toggle` | 会话输入框状态条,切换当前会话的 worktree 隔离。配合 `data-worktree-enabled` 使用。 |
| 工作区复制路径 | `nav-workspace-menu-copy-path` | 复制工作区路径。 |
| 工作区 reveal | `nav-workspace-menu-reveal` | 在文件管理器中显示工作区。 |
| 工作区关闭 | `nav-workspace-menu-close` | 关闭工作区。 |
Expand Down
3 changes: 1 addition & 2 deletions docs/development/ui-testids.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,7 @@ Avoid adding IDs to these surfaces unless there is a clear automated workflow.
| Workspace create ACP session | `nav-workspace-menu-create-acp-session` | Repeated item. Pair with `data-acp-client-id`. |
| Workspace create init session | `nav-workspace-menu-create-init-session` | Starts AGENTS.md/init session. |
| Workspace related paths | `nav-workspace-menu-related-paths` | Opens related paths dialog. |
| Workspace new worktree | `nav-workspace-menu-new-worktree` | Opens worktree creation dialog. |
| Workspace delete worktree | `nav-workspace-menu-delete-worktree` | Deletes linked worktree workspace. |
| Session worktree toggle | `chat-input-worktree-toggle` | Chat input strip. Toggles worktree isolation for the current session. Pair with `data-worktree-enabled`. |
| Workspace copy path | `nav-workspace-menu-copy-path` | Copies workspace path. |
| Workspace reveal | `nav-workspace-menu-reveal` | Reveals workspace in file explorer. |
| Workspace close | `nav-workspace-menu-close` | Closes workspace. |
Expand Down
4 changes: 4 additions & 0 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1775,6 +1775,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
"webdriver_bridge_result",
RemoteWorkspacePolicy::LegacyUnaudited,
),
(
"worktree_bind_session",
RemoteWorkspacePolicy::RemoteUnsupported,
),
("worktree_create", RemoteWorkspacePolicy::RemoteUnsupported),
(
"worktree_create_branch",
Expand Down
12 changes: 11 additions & 1 deletion src/apps/desktop/src/api/worktree_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ use bitfun_core::service::remote_ssh::lookup_remote_connection;
use bitfun_core::service::worktree::{
WorktreeCreateBranchRequest, WorktreeCreateRequest, WorktreeCreateResult, WorktreeListRequest,
WorktreeMutationResult, WorktreePromoteRequest, WorktreeRecreateRequest, WorktreeRemoveRequest,
WorktreeRemoveResult, WorktreeService,
WorktreeRemoveResult, WorktreeService, WorktreeSessionBindingRequest,
WorktreeSessionBindingResult,
};
use bitfun_core_types::{WorktreeError, WorktreeErrorCode, WorktreeSummary};

Expand Down Expand Up @@ -67,6 +68,15 @@ pub async fn worktree_remove(
WorktreeService::remove(request).await
}

/// Toggle worktree isolation for a single session. The project path is derived
/// from the session itself, so remote checks live in the product layer.
#[tauri::command]
pub async fn worktree_bind_session(
request: WorktreeSessionBindingRequest,
) -> Result<WorktreeSessionBindingResult, WorktreeError> {
WorktreeService::bind_session(request).await
}

#[tauri::command]
pub async fn worktree_recreate(
request: WorktreeRecreateRequest,
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1354,6 +1354,7 @@ pub async fn run() {
api::worktree_api::worktree_promote,
api::worktree_api::worktree_remove,
api::worktree_api::worktree_recreate,
api::worktree_api::worktree_bind_session,
generate_commit_message,
quick_commit_message,
save_git_repo_history,
Expand Down
68 changes: 68 additions & 0 deletions src/crates/assembly/core/src/agentic/session/session_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ use bitfun_services_core::session::{
set_deep_review_run_manifest, set_review_target_evidence, set_session_relationship,
SessionStorageLayout,
};
use bitfun_core_types::SessionExecutionTarget;
use dashmap::{mapref::entry::Entry, DashMap};
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -158,6 +159,15 @@ fn current_unix_secs() -> i64 {
.unwrap_or_default()
}

/// Where a session executes, as a single atomic rebind.
#[derive(Debug, Clone)]
pub struct SessionExecutionBindingUpdate {
pub workspace_path: String,
pub project_workspace_path: String,
pub workspace_id: Option<String>,
pub execution_target: SessionExecutionTarget,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SessionResourceCleanupPolicy {
BestEffort,
Expand Down Expand Up @@ -3478,6 +3488,64 @@ impl SessionManager {
Ok(())
}

/// Rebind where a session executes (in-memory + persistence).
///
/// Only the workspace roots and the resolved execution target move; session
/// storage stays keyed on `project_workspace_path`, so the transcript keeps
/// its identity when a session is moved into or out of a managed worktree.
pub async fn update_session_execution_binding(
&self,
session_id: &str,
binding: SessionExecutionBindingUpdate,
) -> BitFunResult<()> {
// Mirrors update_session_model_id: an evicted session must be restored
// from its recorded storage path before the mutation permit is taken.
if !self.sessions.contains_key(session_id) && self.config.enable_persistence {
let session_storage_path = self
.session_storage_path_index
.get(session_id)
.map(|entry| entry.value().path.clone());
if let Some(session_storage_path) = session_storage_path {
let _ = self
.restore_session_from_storage_path(&session_storage_path, session_id)
.await;
}
}

let _mutation_guard = self.acquire_session_mutation(session_id).await?;

if let Some(mut session) = self.sessions.get_mut(session_id) {
session.config.workspace_path = Some(binding.workspace_path.clone());
session.config.project_workspace_path = Some(binding.project_workspace_path.clone());
session.config.execution_target = Some(binding.execution_target.clone());
session.config.workspace_id = binding.workspace_id.clone();
session.updated_at = SystemTime::now();
session.last_activity_at = SystemTime::now();
} else {
return Err(BitFunError::NotFound(format!(
"Session not found: {}",
session_id
)));
}

if self.should_persist_session_id(session_id) {
let effective_path = self.effective_session_storage_path(session_id).await;
let session_snapshot = self.sessions.get(session_id).map(|s| s.clone());
if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) {
self.persistence_manager
.save_session(&workspace_path, &session)
.await?;
}
}

debug!(
"Session execution binding updated: session_id={}, workspace_path={}",
session_id, binding.workspace_path
);

Ok(())
}

/// Sync session context window from AI config without requiring an explicit model_id.
///
/// Subagent sessions created via `build_session_config_for_workspace` use
Expand Down
9 changes: 9 additions & 0 deletions src/crates/assembly/core/src/service/worktree/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ use uuid::Uuid;
const WORKTREE_REGISTRY_VERSION: u32 = 1;
const REGISTRY_FILE_NAME: &str = "worktrees.json";

mod session_binding;

pub use session_binding::{WorktreeSessionBindingRequest, WorktreeSessionBindingResult};

static REPOSITORY_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>> = OnceLock::new();

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -259,6 +263,11 @@ impl WorktreeService {
}
}

/// User-level worktree defaults (root directory, branch prefix, copy policy).
pub async fn settings() -> WorktreeSettings {
load_settings().await
}

pub async fn list(request: WorktreeListRequest) -> Result<Vec<WorktreeSummary>, WorktreeError> {
let context = Self::repository_context(Path::new(&request.project_workspace_path)).await?;
let lock = repository_lock(&context.common_git_dir);
Expand Down
Loading