From 86539eab00352de4631e5bd9ec151c0a677d74d3 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 27 Jul 2026 00:59:51 -0700 Subject: [PATCH 1/2] refactor(worktrees): replace the sidebar surface with a session toggle Managed worktrees were exposed as a WORKTREES group in the workspace list, plus a launcher dialog, a manager modal, and four workspace menu entries. That put an execution detail at the same level as projects and assistants, which is not where the concept belongs. Worktree isolation is now a property of a session: a checkbox chip next to the branch in the chat input strip, shown for Git workspaces. Turning it on creates a managed worktree and rebinds the session; turning it off returns the session to the project checkout and removes the worktree when it is clean, keeping it (with the path reported) when it still holds local work. The toggle is locked once a session has a transcript, since the history describes work done in one specific directory. Worktree sessions now appear directly in the project's session list instead of a separate group, so the removed `localSessionsOnly` filter no longer hides them. Also fixed while here: - the strip labelled worktree sessions with the worktree directory basename, which is a generated id; it now keeps the owning project's name - useGitState only auto-refreshes on mount, so the branch went stale when the execution root moved; the strip now refetches on a path change - dropped the `defaultTarget` setting, whose only consumer was the launcher --- docs/development/ui-testids-CN.md | 3 +- docs/development/ui-testids.md | 3 +- src/apps/desktop/src/api/worktree_api.rs | 12 +- src/apps/desktop/src/lib.rs | 1 + .../src/agentic/session/session_manager.rs | 68 ++++ .../assembly/core/src/service/worktree/mod.rs | 9 + .../src/service/worktree/session_binding.rs | 310 ++++++++++++++ src/crates/contracts/core-types/src/lib.rs | 2 +- .../contracts/core-types/src/worktree.rs | 12 +- src/crates/contracts/runtime-ports/src/lib.rs | 2 +- .../src/app/components/NavPanel/MainNav.tsx | 34 +- .../sections/sessions/SessionsSection.tsx | 37 +- .../sections/workspaces/ProjectWorktrees.scss | 123 ------ .../sections/workspaces/ProjectWorktrees.tsx | 319 --------------- .../sections/workspaces/WorkspaceItem.tsx | 171 +------- .../workspaces/WorktreeLauncherModal.scss | 83 ---- .../workspaces/WorktreeLauncherModal.test.tsx | 208 ---------- .../workspaces/WorktreeLauncherModal.tsx | 320 --------------- .../workspaces/WorktreeManagerModal.scss | 120 ------ .../workspaces/WorktreeManagerModal.test.tsx | 217 ---------- .../workspaces/WorktreeManagerModal.tsx | 385 ------------------ .../settings/settingsTabSearchContent.ts | 4 +- .../src/flow_chat/components/ChatInput.tsx | 80 +++- .../components/ChatInputWorkspaceStrip.scss | 97 ++--- .../ChatInputWorkspaceStrip.test.tsx | 142 ++++--- .../components/ChatInputWorkspaceStrip.tsx | 290 +++++-------- .../src/flow_chat/store/FlowChatStore.ts | 37 ++ .../api/service-api/WorktreeAPI.ts | 24 +- .../config/components/WorktreesConfig.tsx | 26 +- .../business/worktreeWorkspaceService.test.ts | 61 --- .../business/worktreeWorkspaceService.ts | 77 ---- src/web-ui/src/locales/en-US/common.json | 13 - src/web-ui/src/locales/en-US/worktrees.json | 106 +---- src/web-ui/src/locales/zh-CN/common.json | 13 - src/web-ui/src/locales/zh-CN/worktrees.json | 108 +---- src/web-ui/src/locales/zh-TW/common.json | 13 - src/web-ui/src/locales/zh-TW/worktrees.json | 110 +---- .../src/shared/services/worktreeUIEvents.ts | 19 - src/web-ui/src/shared/types/global-state.ts | 4 - tests/e2e/specs/l1-worktree-restart.spec.ts | 28 +- tests/e2e/specs/l1-worktree.spec.ts | 62 ++- 41 files changed, 881 insertions(+), 2872 deletions(-) create mode 100644 src/crates/assembly/core/src/service/worktree/session_binding.rs delete mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/ProjectWorktrees.scss delete mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/ProjectWorktrees.tsx delete mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.scss delete mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.test.tsx delete mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.tsx delete mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.scss delete mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.test.tsx delete mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.tsx delete mode 100644 src/web-ui/src/infrastructure/services/business/worktreeWorkspaceService.test.ts delete mode 100644 src/web-ui/src/infrastructure/services/business/worktreeWorkspaceService.ts delete mode 100644 src/web-ui/src/shared/services/worktreeUIEvents.ts diff --git a/docs/development/ui-testids-CN.md b/docs/development/ui-testids-CN.md index 50280ab106..cc1aa01566 100644 --- a/docs/development/ui-testids-CN.md +++ b/docs/development/ui-testids-CN.md @@ -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` | 关闭工作区。 | diff --git a/docs/development/ui-testids.md b/docs/development/ui-testids.md index d2baec4b20..e4f7ef3ec8 100644 --- a/docs/development/ui-testids.md +++ b/docs/development/ui-testids.md @@ -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. | diff --git a/src/apps/desktop/src/api/worktree_api.rs b/src/apps/desktop/src/api/worktree_api.rs index 04d6296c61..b4c0bdcfc2 100644 --- a/src/apps/desktop/src/api/worktree_api.rs +++ b/src/apps/desktop/src/api/worktree_api.rs @@ -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}; @@ -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 { + WorktreeService::bind_session(request).await +} + #[tauri::command] pub async fn worktree_recreate( request: WorktreeRecreateRequest, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 894f06d4b4..c37ba0913b 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -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, diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index b33e0016b3..415887c166 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -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}; @@ -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, + pub execution_target: SessionExecutionTarget, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SessionResourceCleanupPolicy { BestEffort, @@ -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 diff --git a/src/crates/assembly/core/src/service/worktree/mod.rs b/src/crates/assembly/core/src/service/worktree/mod.rs index 7bbf19ab34..ce5ba337d7 100644 --- a/src/crates/assembly/core/src/service/worktree/mod.rs +++ b/src/crates/assembly/core/src/service/worktree/mod.rs @@ -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>>>> = OnceLock::new(); #[derive(Debug, Clone, Serialize, Deserialize)] @@ -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, WorktreeError> { let context = Self::repository_context(Path::new(&request.project_workspace_path)).await?; let lock = repository_lock(&context.common_git_dir); diff --git a/src/crates/assembly/core/src/service/worktree/session_binding.rs b/src/crates/assembly/core/src/service/worktree/session_binding.rs new file mode 100644 index 0000000000..1a98eee8b2 --- /dev/null +++ b/src/crates/assembly/core/src/service/worktree/session_binding.rs @@ -0,0 +1,310 @@ +//! Per-session worktree isolation. +//! +//! A session either executes in the project checkout or in a managed worktree +//! of the same repository. This module owns the transition between the two: +//! it creates or releases the worktree and rebinds the session in one step, so +//! callers never have to keep the two halves consistent themselves. +//! +//! Rebinding is only offered while a session is still empty. Once a transcript +//! exists it describes work done in a specific directory, and moving that +//! directory underneath it would silently invalidate the history. + +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::session::SessionExecutionBindingUpdate; +use crate::service::remote_ssh::lookup_remote_connection; +use crate::service::workspace::get_global_workspace_service; +use crate::service::worktree::{ + WorktreeCreateRequest, WorktreeListRequest, WorktreeRemoveRequest, WorktreeService, +}; +use bitfun_core_types::{ + SessionExecutionTarget, WorktreeError, WorktreeErrorCode, WorktreeLifecycle, +}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeSessionBindingRequest { + pub request_id: String, + pub session_id: String, + /// `true` moves the session into a managed worktree, `false` back to the project. + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeSessionBindingResult { + pub session_id: String, + pub workspace_path: String, + pub project_workspace_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + pub execution_target: SessionExecutionTarget, + /// Set when a released worktree was kept because it still held local work. + #[serde(skip_serializing_if = "Option::is_none")] + pub retained_worktree_path: Option, +} + +/// Session facts the binding decision depends on. +struct SessionBindingContext { + project_workspace_path: String, + execution_target: SessionExecutionTarget, +} + +fn error(code: WorktreeErrorCode, message: impl Into) -> WorktreeError { + WorktreeError { + code, + message: message.into(), + recovery_path: None, + } +} + +async fn load_binding_context(session_id: &str) -> Result { + let coordinator = get_global_coordinator().ok_or_else(|| { + error( + WorktreeErrorCode::IoFailed, + "Session coordinator is not initialized", + ) + })?; + let session = coordinator + .get_session_manager() + .get_session(session_id) + .ok_or_else(|| { + error( + WorktreeErrorCode::WorktreeNotFound, + format!("Session not found: {session_id}"), + ) + })?; + + if !session.dialog_turn_ids.is_empty() { + return Err(error( + WorktreeErrorCode::WorktreeBusy, + "Worktree isolation can only be changed before the session's first message", + )); + } + if session.config.remote_connection_id.is_some() { + return Err(error( + WorktreeErrorCode::RemoteUnsupported, + "Managed worktrees are not supported for remote SSH workspaces yet", + )); + } + + let workspace_path = session.config.workspace_path.clone().ok_or_else(|| { + error( + WorktreeErrorCode::InvalidPath, + "Session is not bound to a workspace", + ) + })?; + let project_workspace_path = session + .config + .project_workspace_path + .clone() + .unwrap_or_else(|| workspace_path.clone()); + if lookup_remote_connection(&project_workspace_path) + .await + .is_some() + { + return Err(error( + WorktreeErrorCode::RemoteUnsupported, + "Managed worktrees are not supported for remote SSH workspaces yet", + )); + } + + let execution_target = session + .config + .execution_target + .clone() + .unwrap_or_else(|| SessionExecutionTarget::local(workspace_path)); + + Ok(SessionBindingContext { + project_workspace_path, + execution_target, + }) +} + +async fn current_workspace_id(root_path: &str) -> Option { + get_global_workspace_service()? + .get_workspace_by_path(Path::new(root_path)) + .await + .map(|workspace| workspace.id) +} + +async fn rebind( + session_id: &str, + project_workspace_path: &str, + execution_target: SessionExecutionTarget, +) -> Result { + let coordinator = get_global_coordinator().ok_or_else(|| { + error( + WorktreeErrorCode::IoFailed, + "Session coordinator is not initialized", + ) + })?; + let workspace_id = current_workspace_id(&execution_target.root_path).await; + + coordinator + .get_session_manager() + .update_session_execution_binding( + session_id, + SessionExecutionBindingUpdate { + workspace_path: execution_target.root_path.clone(), + project_workspace_path: project_workspace_path.to_string(), + workspace_id: workspace_id.clone(), + execution_target: execution_target.clone(), + }, + ) + .await + .map_err(|session_error| { + error( + WorktreeErrorCode::IoFailed, + format!("Failed to rebind session workspace: {session_error}"), + ) + })?; + + Ok(WorktreeSessionBindingResult { + session_id: session_id.to_string(), + workspace_path: execution_target.root_path.clone(), + project_workspace_path: project_workspace_path.to_string(), + workspace_id, + execution_target, + retained_worktree_path: None, + }) +} + +impl WorktreeService { + /// Move a session into a fresh managed worktree, or back to the project checkout. + /// + /// Enabling is idempotent through `request_id`: a retried request replays the + /// worktree that request already created instead of allocating another one. + pub async fn bind_session( + request: WorktreeSessionBindingRequest, + ) -> Result { + let context = load_binding_context(&request.session_id).await?; + let is_worktree = context.execution_target.worktree_id.is_some(); + + if request.enabled == is_worktree { + // Already in the requested state; report it rather than churn Git. + return Ok(WorktreeSessionBindingResult { + session_id: request.session_id, + workspace_path: context.execution_target.root_path.clone(), + project_workspace_path: context.project_workspace_path, + workspace_id: current_workspace_id(&context.execution_target.root_path).await, + execution_target: context.execution_target, + retained_worktree_path: None, + }); + } + + if request.enabled { + Self::enable_session_worktree(&request, &context).await + } else { + Self::disable_session_worktree(&request, &context).await + } + } + + async fn enable_session_worktree( + request: &WorktreeSessionBindingRequest, + context: &SessionBindingContext, + ) -> Result { + let settings = Self::settings().await; + let created = Self::create(WorktreeCreateRequest { + request_id: request.request_id.clone(), + project_workspace_path: context.project_workspace_path.clone(), + source_workspace_path: Some(context.execution_target.root_path.clone()), + base_ref: None, + copy_local_changes: settings.copy_local_changes, + }) + .await?; + + let worktree_id = created.execution_target.worktree_id.clone(); + match rebind( + &request.session_id, + &created.worktree.project_workspace_path, + created.execution_target, + ) + .await + { + Ok(result) => Ok(result), + Err(bind_error) => { + // The worktree only exists to host this session; drop it again so a + // failed toggle does not leave an orphan directory behind. + if created.created { + if let Some(worktree_id) = worktree_id.as_deref() { + if let Err(rollback_error) = + Self::rollback_created(&context.project_workspace_path, worktree_id) + .await + { + log::warn!( + "Failed to roll back worktree {worktree_id} after a failed session rebind: {rollback_error}" + ); + } + } + } + Err(bind_error) + } + } + } + + async fn disable_session_worktree( + request: &WorktreeSessionBindingRequest, + context: &SessionBindingContext, + ) -> Result { + let worktree_id = context + .execution_target + .worktree_id + .clone() + .ok_or_else(|| { + error( + WorktreeErrorCode::WorktreeNotFound, + "Session is not bound to a worktree", + ) + })?; + let worktree_path = context.execution_target.root_path.clone(); + + // Detach first: removal safety checks count sessions still pointing here. + let mut result = rebind( + &request.session_id, + &context.project_workspace_path, + SessionExecutionTarget::local(context.project_workspace_path.clone()), + ) + .await?; + + let removable = Self::list(WorktreeListRequest { + project_workspace_path: context.project_workspace_path.clone(), + }) + .await + .ok() + .and_then(|worktrees| { + worktrees + .into_iter() + .find(|worktree| worktree.worktree_id == worktree_id) + }) + .map(|worktree| { + worktree.lifecycle == WorktreeLifecycle::Managed + && !worktree.dirty + && !worktree.has_unpublished_commits + && !worktree.locked + && !worktree.missing + && worktree.associated_session_count == 0 + }) + .unwrap_or(false); + + if removable { + match Self::remove(WorktreeRemoveRequest { + request_id: request.request_id.clone(), + project_workspace_path: context.project_workspace_path.clone(), + worktree_id, + force: false, + }) + .await + { + Ok(_) => return Ok(result), + Err(remove_error) => { + log::warn!("Released worktree could not be removed: {remove_error}"); + } + } + } + + result.retained_worktree_path = Some(worktree_path); + Ok(result) + } +} diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index af3088b90c..cb235f45c1 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -31,6 +31,6 @@ pub use surface::{ pub use tool_image_attachment::ToolImageAttachment; pub use worktree::{ SessionExecutionTarget, SessionExecutionTargetKind, SessionExecutionTargetRequest, - WorktreeDefaultTarget, WorktreeError, WorktreeErrorCode, WorktreeLifecycle, + WorktreeError, WorktreeErrorCode, WorktreeLifecycle, WorktreeSessionSummary, WorktreeSettings, WorktreeSummary, }; diff --git a/src/crates/contracts/core-types/src/worktree.rs b/src/crates/contracts/core-types/src/worktree.rs index 48c5c394ad..882d1e3127 100644 --- a/src/crates/contracts/core-types/src/worktree.rs +++ b/src/crates/contracts/core-types/src/worktree.rs @@ -74,19 +74,10 @@ impl SessionExecutionTarget { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum WorktreeDefaultTarget { - #[default] - Local, - ManagedWorktree, -} - -/// User-level defaults for new worktrees. +/// User-level defaults for worktrees created by session isolation. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorktreeSettings { - pub default_target: WorktreeDefaultTarget, pub root_path: String, pub branch_prefix: String, pub copy_local_changes: bool, @@ -95,7 +86,6 @@ pub struct WorktreeSettings { impl Default for WorktreeSettings { fn default() -> Self { Self { - default_target: WorktreeDefaultTarget::Local, root_path: "~/.bitfun/worktrees".to_string(), branch_prefix: "bitfun/".to_string(), copy_local_changes: false, diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 5b3a9c14e9..3583a362a3 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -13,7 +13,7 @@ use tokio_util::sync::CancellationToken; pub use bitfun_core_types::{ SessionExecutionTarget, SessionExecutionTargetKind, SessionExecutionTargetRequest, - WorktreeDefaultTarget, WorktreeError, WorktreeErrorCode, WorktreeLifecycle, WorktreeSettings, + WorktreeError, WorktreeErrorCode, WorktreeLifecycle, WorktreeSettings, WorktreeSummary, }; diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index 4e73b4dd50..41e2d1af26 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -13,7 +13,7 @@ import React, { useCallback, useState, useMemo, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { Plus, FolderOpen, FolderPlus, History, Check, User, Users, Puzzle, Blocks, ChevronDown, Search, GitBranch } from 'lucide-react'; +import { Plus, FolderOpen, FolderPlus, History, Check, User, Users, Puzzle, Blocks, ChevronDown, Search } from 'lucide-react'; // import { PanelsTopLeft } from 'lucide-react'; // temporarily hidden: Pages nav entry import { Tooltip } from '@/component-library'; import { useApp } from '../../hooks/useApp'; @@ -23,7 +23,6 @@ import type { SceneTabId } from '../SceneBar/types'; import SectionHeader from './components/SectionHeader'; import MiniAppEntry from './components/MiniAppEntry'; import WorkspaceListSection from './sections/workspaces/WorkspaceListSection'; -import { openWorktreeLauncher } from '@/shared/services/worktreeUIEvents'; import SessionsSection from './sections/sessions/SessionsSection'; import { useSceneStore } from '../../stores/sceneStore'; import { useMyAgentStore } from '../../scenes/my-agent/myAgentStore'; @@ -77,7 +76,6 @@ const MainNav: React.FC = ({ const activeTabId = useSceneStore(s => s.activeTabId); const setSelectedAssistantWorkspaceId = useMyAgentStore((s) => s.setSelectedAssistantWorkspaceId); const { t } = useI18n('common'); - const { t: tWorktrees } = useI18n('worktrees'); // const { t: tPages } = useI18n('scenes/pages'); // temporarily hidden: Pages nav entry const { currentWorkspace, @@ -253,21 +251,6 @@ const MainNav: React.FC = ({ void handleCreateProjectSession('Cowork'); }, [handleCreateProjectSession, setSessionMode]); - const handleCreateWorktreeSession = useCallback(() => { - const target = pickWorkspaceForProjectChatSession(currentWorkspace, normalWorkspacesList); - if (!target) { - notificationService.warning(t('nav.sessions.needProjectWorkspaceForSession'), { - duration: 4500, - }); - return; - } - if (isRemoteWorkspace(target)) { - notificationService.info(tWorktrees('launcher.remoteUnsupported'), { duration: 3500 }); - return; - } - openWorktreeLauncher(target.worktree?.mainRepoPath || target.rootPath, 'agentic'); - }, [currentWorkspace, normalWorkspacesList, t, tWorktrees]); - const handleOpenProject = useCallback(async () => { try { const { pickWorkspaceDirectory } = await import( @@ -537,21 +520,6 @@ const MainNav: React.FC = ({ - - - - - - {loadError ? ( - - ) : null} - {managedWorktrees.map(worktree => { - const isCollapsed = collapsed.has(worktree.worktreeId); - const revision = worktree.branch || worktree.head.slice(0, 9); - return ( -
-
- -
- {lifecycleLabel(worktree)} - {worktree.dirty ? {t('labels.dirty')} : null} - {worktree.missing ? {t('labels.missing')} : null} - {worktree.runningSessionCount > 0 - ? {t('labels.running', { count: worktree.runningSessionCount })} - : null} -
- - - -
- {!isCollapsed ? ( - - ) : null} -
- ); - })} - - ) : null} - - setLauncherOpen(false)} - onSubmit={createManagedSession} - /> - setManagerOpen(false)} - onRefresh={refresh} - onCreateWorktree={() => { - setLauncherMode('agentic'); - setLauncherOpen(true); - }} - onCreateSession={worktree => createSession(worktree)} - /> - - ); -}; - -export default ProjectWorktrees; 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 7f22997ede..9d3045a8d8 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 @@ -1,15 +1,12 @@ import React, { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; -import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot, Link2, ListChecks, Loader2, Clock3, ShieldCheck, Pencil } from 'lucide-react'; +import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, Bot, Link2, ListChecks, Loader2, Clock3, ShieldCheck, Pencil } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { DotMatrixArrowRightIcon } from './DotMatrixArrowRightIcon'; import { Button, ConfirmDialog, InputDialog, Modal, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; -import { - deleteWorktreeWorkspace, -} from '@/infrastructure/services/business/worktreeWorkspaceService'; import { useNavSceneStore } from '@/app/stores/navSceneStore'; import { useApp } from '@/app/hooks/useApp'; import { useGitBasicInfo } from '@/tools/git/hooks/useGitState'; @@ -27,14 +24,8 @@ import { findReusableEmptySessionId } from '@/app/utils/projectSessionWorkspace' import type { AcpClientInfo } from '@/infrastructure/api/service-api/ACPClientAPI'; import { loadWorkspaceAcpMenuClients } from './workspaceAcpMenuClients'; import SessionsSection from '../sessions/SessionsSection'; -import ProjectWorktrees from './ProjectWorktrees'; -import { - openWorktreeLauncher, - openWorktreeManager, -} from '@/shared/services/worktreeUIEvents'; import { WorkspaceKind, - isLinkedWorktreeWorkspace, isRemoteWorkspace, type WorkspaceInfo, } from '@/shared/types'; @@ -90,7 +81,6 @@ const WorkspaceItem: React.FC = ({ onDragEnd, }) => { const { t } = useI18n('common'); - const { t: tWorktrees } = useI18n('worktrees'); const { t: tFiles } = useTranslation('panels/files'); const { setActiveWorkspace, @@ -110,23 +100,13 @@ const WorkspaceItem: React.FC = ({ getWorkspaceGitBasicInfoOptions(workspace, isActive), historySessionOpenTransition !== null ); - const { - isRepository, - isLoading: isGitBasicInfoLoading, - state: gitBasicInfoState, - refreshBasic: refreshGitBasicInfo, - } = useGitBasicInfo( - workspace.rootPath, - gitBasicInfoOptions - ); + useGitBasicInfo(workspace.rootPath, gitBasicInfoOptions); const [menuOpen, setMenuOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deleteWorktreeDialogOpen, setDeleteWorktreeDialogOpen] = useState(false); const [resetDialogOpen, setResetDialogOpen] = useState(false); const [relatedPathsDialogOpen, setRelatedPathsDialogOpen] = useState(false); const [projectPermissionsDialogOpen, setProjectPermissionsDialogOpen] = useState(false); const [isDeletingAssistant, setIsDeletingAssistant] = useState(false); - const [isDeletingWorktree, setIsDeletingWorktree] = useState(false); const [isResettingWorkspace, setIsResettingWorkspace] = useState(false); const [sessionsCollapsed, setSessionsCollapsed] = useState(false); const [searchIndexModalOpen, setSearchIndexModalOpen] = useState(false); @@ -153,7 +133,6 @@ const WorkspaceItem: React.FC = ({ workspace.workspaceKind === WorkspaceKind.Assistant ? workspace.identity?.name?.trim() || workspace.name : workspace.name; - const isLinkedWorktree = isLinkedWorktreeWorkspace(workspace); const relatedPathCount = workspace.relatedPaths?.length ?? 0; const workspaceIsRemote = isRemoteWorkspace(workspace); const canShowSearchIndex = @@ -163,12 +142,6 @@ const WorkspaceItem: React.FC = ({ workspace.workspaceKind === WorkspaceKind.Normal || workspace.workspaceKind === WorkspaceKind.Remote ); - const shouldRefreshGitBasicInfoOnMenuOpen = - !isActive && - !workspaceIsRemote && - !gitBasicInfoState && - !isGitBasicInfoLoading; - const isWorktreeActionDisabled = isGitBasicInfoLoading || !isRepository; const workspaceSearchIndex = useWorkspaceSearchIndex({ workspacePath: canShowSearchIndex ? workspace.rootPath : undefined, enabled: canShowSearchIndex, @@ -383,12 +356,8 @@ const WorkspaceItem: React.FC = ({ }, []); const handleMenuTriggerClick = useCallback(() => { - const nextOpen = !menuOpen; - setMenuOpen(nextOpen); - if (nextOpen && shouldRefreshGitBasicInfoOnMenuOpen) { - void refreshGitBasicInfo(); - } - }, [menuOpen, refreshGitBasicInfo, shouldRefreshGitBasicInfoOnMenuOpen]); + setMenuOpen(open => !open); + }, []); useEffect(() => { if (!menuOpen) return; @@ -738,33 +707,6 @@ const WorkspaceItem: React.FC = ({ } }, [setActiveWorkspace, t, workspace]); - const handleRequestDeleteWorktree = useCallback(() => { - setMenuOpen(false); - setDeleteWorktreeDialogOpen(true); - }, []); - - const handleConfirmDeleteWorktree = useCallback(async () => { - if (!isLinkedWorktree || isDeletingWorktree) { - return; - } - - setIsDeletingWorktree(true); - try { - await deleteWorktreeWorkspace({ - workspace, - closeWorkspaceById, - }); - notificationService.success(t('nav.workspaces.worktreeDeleted'), { duration: 2500 }); - } catch (error) { - notificationService.error( - error instanceof Error ? error.message : t('nav.workspaces.deleteWorktreeFailed'), - { duration: 4000 }, - ); - } finally { - setIsDeletingWorktree(false); - } - }, [closeWorkspaceById, isDeletingWorktree, isLinkedWorktree, t, workspace]); - const handleOpenFiles = useCallback(async () => { try { await handleActivate(); @@ -891,35 +833,6 @@ const WorkspaceItem: React.FC = ({ {t('nav.workspaces.actions.newSession')} - - - {isLinkedWorktree ? ( - - ) : ( - - )} - {!isLinkedWorktree ? ( - - ) : null} - ), - Checkbox: ({ - checked, - disabled, - label, - description, - onChange, - }: { - checked: boolean; - disabled?: boolean; - label: React.ReactNode; - description?: React.ReactNode; - onChange: React.ChangeEventHandler; - }) => ( - - ), - Input: (props: React.InputHTMLAttributes) => , - Modal: ({ - children, - isOpen, - title, - }: { - children: React.ReactNode; - isOpen: boolean; - title: React.ReactNode; - }) => isOpen ?

{title}

{children}
: null, - Select: ({ - id, - value, - disabled, - options, - onChange, - }: { - id?: string; - value: string; - disabled?: boolean; - options: Array<{ value: string; label: string }>; - onChange: (value: string) => void; - }) => ( - - ), -})); - -async function flushLauncherProbe(): Promise { - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - vi.advanceTimersByTime(200); - await Promise.resolve(); - await Promise.resolve(); - }); -} - -describe('WorktreeLauncherModal', () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - vi.useFakeTimers(); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - mocks.getRepositoryBasic.mockResolvedValue({ current_branch: 'main' }); - mocks.getStatus.mockResolvedValue({ - staged: ['src/staged.ts'], - unstaged: ['src/unstaged.ts'], - untracked: ['notes.txt'], - conflicts: [], - }); - mocks.getConfig.mockResolvedValue({ - rootPath: '/managed', - branchPrefix: 'bitfun/', - defaultTarget: 'local', - copyLocalChanges: true, - }); - mocks.resolveRevision.mockResolvedValue('0123456789abcdef'); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - vi.useRealTimers(); - vi.clearAllMocks(); - }); - - it('resolves the base and preserves the opt-in copy default only at source HEAD', async () => { - const onSubmit = vi.fn(async () => undefined); - await act(async () => { - root.render( - - ); - }); - await flushLauncherProbe(); - - const copy = container.querySelector('[data-testid="copy-local-changes"]'); - expect(copy?.disabled).toBe(false); - expect(copy?.checked).toBe(true); - expect(container.textContent).toContain('resolvedCommit'); - expect(container.textContent).toContain('/managed/Repo/…'); - - const createButton = Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('launcher.create')); - await act(async () => { - createButton?.click(); - await Promise.resolve(); - }); - expect(onSubmit).toHaveBeenCalledWith({ - mode: 'agentic', - baseRef: 'main', - copyLocalChanges: true, - }); - }); - - it('shows a clear unsupported state without probing a remote repository', async () => { - await act(async () => { - root.render( - undefined)} - /> - ); - }); - - expect(container.textContent).toContain('launcher.remoteUnsupported'); - expect(mocks.getRepositoryBasic).not.toHaveBeenCalled(); - const createButton = Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('launcher.create')); - expect(createButton?.disabled).toBe(true); - }); -}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.tsx deleted file mode 100644 index 4bc180acd6..0000000000 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { GitBranch, Loader2 } from 'lucide-react'; -import { Button, Checkbox, Input, Modal, Select } from '@/component-library'; -import { configAPI, gitAPI } from '@/infrastructure/api'; -import { useI18n } from '@/infrastructure/i18n'; -import type { GitStatus } from '@/infrastructure/api/service-api/GitAPI'; -import './WorktreeLauncherModal.scss'; - -export type WorktreeSessionMode = 'agentic' | 'Cowork'; - -export interface WorktreeLauncherSubmit { - mode: WorktreeSessionMode; - baseRef: string; - copyLocalChanges: boolean; -} - -interface WorktreeSettings { - defaultTarget: 'local' | 'managedWorktree'; - rootPath: string; - branchPrefix: string; - copyLocalChanges: boolean; -} - -interface WorktreeLauncherModalProps { - isOpen: boolean; - projectWorkspacePath: string; - projectName: string; - remote?: boolean; - initialMode?: WorktreeSessionMode; - onClose: () => void; - onSubmit: (request: WorktreeLauncherSubmit) => Promise; -} - -const DEFAULT_SETTINGS: WorktreeSettings = { - defaultTarget: 'local', - rootPath: '~/.bitfun/worktrees', - branchPrefix: 'bitfun/', - copyLocalChanges: false, -}; - -function changeCount(status: GitStatus | null): number { - if (!status) return 0; - return ( - status.staged.length - + status.unstaged.length - + status.untracked.length - + status.conflicts.length - ); -} - -export const WorktreeLauncherModal: React.FC = ({ - isOpen, - projectWorkspacePath, - projectName, - remote = false, - initialMode = 'agentic', - onClose, - onSubmit, -}) => { - const { t } = useI18n('worktrees'); - const [mode, setMode] = useState(initialMode); - const [baseRef, setBaseRef] = useState('HEAD'); - const [baseCommit, setBaseCommit] = useState(''); - const [sourceHead, setSourceHead] = useState(''); - const [status, setStatus] = useState(null); - const [settings, setSettings] = useState(DEFAULT_SETTINGS); - const [copyLocalChanges, setCopyLocalChanges] = useState(false); - const [loading, setLoading] = useState(false); - const [probing, setProbing] = useState(false); - const [error, setError] = useState(null); - const [availabilityError, setAvailabilityError] = useState(null); - - useEffect(() => { - if (!isOpen) return; - setMode(initialMode); - setError(null); - setSourceHead(''); - setAvailabilityError(remote ? t('launcher.remoteUnsupported') : null); - setProbing(!remote); - let cancelled = false; - - if (remote) { - setStatus(null); - setBaseCommit(''); - return; - } - - void Promise.all([ - gitAPI.getRepositoryBasic(projectWorkspacePath), - gitAPI.getStatus(projectWorkspacePath, 'worktree_launcher'), - configAPI.getConfig('app.worktrees', { skipRetryOnNotFound: true }), - gitAPI.resolveRevision(projectWorkspacePath, 'HEAD').catch(() => ''), - ]) - .then(([repository, nextStatus, configured, headCommit]) => { - if (cancelled) return; - const nextSettings = { - ...DEFAULT_SETTINGS, - ...(configured && typeof configured === 'object' ? configured : {}), - } as WorktreeSettings; - const suggestedRef = repository.current_branch?.trim() || 'HEAD'; - setSettings(nextSettings); - setBaseRef(suggestedRef); - setSourceHead(headCommit); - setStatus(nextStatus); - setCopyLocalChanges( - nextSettings.copyLocalChanges - && changeCount(nextStatus) > 0 - && !!headCommit, - ); - if (!headCommit) { - setAvailabilityError(t('launcher.unbornRepository')); - } - }) - .catch(() => { - if (!cancelled) { - setAvailabilityError(t('launcher.notGitRepository')); - setStatus(null); - } - }) - .finally(() => { - if (!cancelled) setProbing(false); - }); - - return () => { - cancelled = true; - }; - }, [initialMode, isOpen, projectWorkspacePath, remote, t]); - - useEffect(() => { - if (!isOpen || remote || availabilityError || !baseRef.trim()) { - setBaseCommit(''); - return; - } - let cancelled = false; - const timer = window.setTimeout(() => { - void gitAPI - .resolveRevision(projectWorkspacePath, baseRef.trim()) - .then(commit => { - if (!cancelled) { - setBaseCommit(commit); - setError(null); - } - }) - .catch(resolveError => { - if (!cancelled) { - setBaseCommit(''); - const message = resolveError instanceof Error - ? resolveError.message.toLowerCase() - : String(resolveError).toLowerCase(); - if ( - baseRef.trim() === 'HEAD' - && ( - message.includes('unborn') - || message.includes('initial commit') - || message.includes('unknown revision') - || message.includes('needed a single revision') - ) - ) { - setAvailabilityError(t('launcher.unbornRepository')); - setError(null); - } else { - setError(t('launcher.invalidBaseRef')); - } - } - }); - }, 180); - return () => { - cancelled = true; - window.clearTimeout(timer); - }; - }, [availabilityError, baseRef, isOpen, projectWorkspacePath, remote, t]); - - const dirtyCount = changeCount(status); - const canCopyLocalChanges = - dirtyCount > 0 && !!sourceHead && baseCommit === sourceHead; - useEffect(() => { - if (!canCopyLocalChanges) { - setCopyLocalChanges(false); - } else if (settings.copyLocalChanges) { - setCopyLocalChanges(true); - } - }, [canCopyLocalChanges, settings.copyLocalChanges]); - const targetPreview = useMemo( - () => `${settings.rootPath.replace(/\/$/, '')}/${projectName}/…`, - [projectName, settings.rootPath], - ); - const canSubmit = !probing && !availabilityError && !!baseCommit && !loading; - - const submit = async () => { - if (!canSubmit) return; - setLoading(true); - setError(null); - try { - await onSubmit({ - mode, - baseRef: baseRef.trim(), - copyLocalChanges: copyLocalChanges && dirtyCount > 0, - }); - onClose(); - } catch (submitError) { - setError(submitError instanceof Error ? submitError.message : String(submitError)); - } finally { - setLoading(false); - } - }; - - return ( - -
{ - if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - void submit(); - } - }} - > -

{t('launcher.description')}

- -
- - setBaseRef(event.target.value)} - placeholder="HEAD" - disabled={loading || probing || !!availabilityError} - autoFocus - /> - - {baseCommit - ? t('launcher.resolvedCommit', { commit: baseCommit.slice(0, 12) }) - : t('launcher.baseRefHint')} - -
- -
- {t('launcher.targetPath')} - {targetPreview} -
- - {dirtyCount > 0 ? ( -
- setCopyLocalChanges(event.target.checked)} - disabled={loading || !canCopyLocalChanges} - label={t('launcher.copyChanges')} - description={ - t('launcher.copyChangesSummary', { - count: dirtyCount, - staged: status?.staged.length ?? 0, - unstaged: status?.unstaged.length ?? 0, - untracked: status?.untracked.length ?? 0, - }) - + ( - canCopyLocalChanges - ? '' - : ` ${t('launcher.copyChangesRequiresHead')}` - ) - } - /> -
- ) : null} - - {probing ? ( -
- - {t('launcher.checking')} -
- ) : null} - {availabilityError || error ? ( -
- {availabilityError || error} -
- ) : null} - -
- - -
-
-
- ); -}; - -export default WorktreeLauncherModal; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.scss deleted file mode 100644 index 25a2c2f4d1..0000000000 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.scss +++ /dev/null @@ -1,120 +0,0 @@ -.bitfun-worktree-manager { - display: flex; - min-height: 320px; - max-height: min(70vh, 720px); - flex-direction: column; - gap: 14px; - padding: 18px; -} - -.bitfun-worktree-manager__toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.bitfun-worktree-manager__toolbar > p { - margin: 0; - color: var(--color-text-secondary); - font-size: 12px; -} - -.bitfun-worktree-manager__toolbar > div, -.bitfun-worktree-manager__actions { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.bitfun-worktree-manager__state, -.bitfun-worktree-manager__error, -.bitfun-worktree-manager__empty { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 24px; - border: 1px dashed var(--border-base); - border-radius: 8px; - color: var(--color-text-secondary); - font-size: 12px; -} - -.bitfun-worktree-manager__error { - color: var(--color-error); -} - -.bitfun-worktree-manager__empty { - flex-direction: column; - min-height: 180px; -} - -.bitfun-worktree-manager__list { - display: flex; - overflow: auto; - flex-direction: column; - gap: 8px; -} - -.bitfun-worktree-manager__item { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - padding: 12px; - border: 1px solid var(--border-base); - border-radius: 8px; - background: var(--color-bg-secondary); -} - -.bitfun-worktree-manager__item-copy { - display: flex; - min-width: 0; - flex: 1; - flex-direction: column; - gap: 5px; -} - -.bitfun-worktree-manager__item-heading { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px; -} - -.bitfun-worktree-manager__item-heading > span { - padding: 1px 5px; - border-radius: 9px; - background: var(--color-bg-tertiary); - color: var(--color-text-secondary); - font-size: 10px; -} - -.bitfun-worktree-manager__item-copy code, -.bitfun-worktree-manager__item-copy > span { - overflow: hidden; - color: var(--color-text-secondary); - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.bitfun-worktree-manager__risk-list p { - margin: 0 0 8px; -} - -.bitfun-worktree-manager__risk-list ul { - margin: 0; - padding-left: 18px; -} - -.bitfun-worktree-manager .is-spinning { - animation: bitfun-worktree-manager-spin 0.8s linear infinite; -} - -@keyframes bitfun-worktree-manager-spin { - to { - transform: rotate(360deg); - } -} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.test.tsx deleted file mode 100644 index 693b1ae83b..0000000000 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.test.tsx +++ /dev/null @@ -1,217 +0,0 @@ -/** - * @vitest-environment jsdom - */ - -import React, { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { WorktreeSummary } from '@/infrastructure/api/service-api/WorktreeAPI'; -import { WorktreeManagerModal } from './WorktreeManagerModal'; - -globalThis.IS_REACT_ACT_ENVIRONMENT = true; - -const mocks = vi.hoisted(() => ({ - getConfig: vi.fn(), - remove: vi.fn(), - refresh: vi.fn(async () => undefined), - revealInExplorer: vi.fn(), - success: vi.fn(), - error: vi.fn(), -})); - -vi.mock('@/infrastructure/api', () => ({ - configAPI: { getConfig: mocks.getConfig }, - workspaceAPI: { revealInExplorer: mocks.revealInExplorer }, - worktreeAPI: { - createBranch: vi.fn(), - promote: vi.fn(), - recreate: vi.fn(), - remove: mocks.remove, - }, -})); - -vi.mock('@/infrastructure/api/service-api/WorktreeAPI', () => ({ - WorktreeCommandError: class WorktreeCommandError extends Error { - constructor( - public readonly code: string, - message: string, - public readonly recoveryPath?: string, - ) { - super(message); - } - }, -})); - -vi.mock('@/infrastructure/i18n', () => ({ - useI18n: () => ({ - t: (key: string, values?: Record) => - values ? `${key}:${JSON.stringify(values)}` : key, - }), -})); - -vi.mock('@/shared/notification-system', () => ({ - notificationService: { - success: mocks.success, - error: mocks.error, - }, -})); - -vi.mock('@/component-library', () => ({ - Button: ({ - children, - disabled, - onClick, - }: { - children: React.ReactNode; - disabled?: boolean; - onClick?: () => void; - }) => ( - - ), - ConfirmDialog: ({ - isOpen, - title, - message, - preview, - confirmText, - onConfirm, - }: { - isOpen: boolean; - title: React.ReactNode; - message: React.ReactNode; - preview?: React.ReactNode; - confirmText: React.ReactNode; - onConfirm: () => void; - }) => isOpen ? ( -
-

{title}

- {message} - {preview} - -
- ) : null, - InputDialog: () => null, - Modal: ({ - children, - isOpen, - title, - }: { - children: React.ReactNode; - isOpen: boolean; - title: React.ReactNode; - }) => isOpen ?

{title}

{children}
: null, -})); - -function summary(overrides: Partial = {}): WorktreeSummary { - return { - worktreeId: 'wt-1', - projectWorkspacePath: '/repo', - path: '/managed/wt-1', - head: '0123456789abcdef', - lifecycle: 'managed', - isMain: false, - dirty: false, - locked: false, - missing: false, - hasUnpublishedCommits: false, - associatedSessionCount: 0, - runningSessionCount: 0, - sessions: [], - ...overrides, - }; -} - -describe('WorktreeManagerModal removal safety', () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - mocks.getConfig.mockResolvedValue({ branchPrefix: 'bitfun/' }); - mocks.remove.mockResolvedValue({ worktreeId: 'wt-1', removed: true }); - mocks.refresh.mockClear(); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - vi.clearAllMocks(); - }); - - async function openRemoveDialog(worktree: WorktreeSummary): Promise { - await act(async () => { - root.render( - undefined)} - /> - ); - await Promise.resolve(); - }); - const item = container.querySelector('[data-worktree-id="wt-1"]'); - const removeButton = Array.from(item?.querySelectorAll('button') ?? []) - .find(button => button.textContent?.includes('manager.remove')); - await act(async () => { - removeButton?.click(); - }); - } - - it('lists each loss risk and requires a second confirmation before force removal', async () => { - await openRemoveDialog(summary({ - dirty: true, - hasUnpublishedCommits: true, - associatedSessionCount: 2, - })); - - expect(container.textContent).toContain('manager.risks.dirty'); - expect(container.textContent).toContain('manager.risks.unpublished'); - expect(container.textContent).toContain('manager.risks.sessions'); - - await act(async () => { - container.querySelector('[data-testid="confirm-remove"]')?.click(); - }); - expect(mocks.remove).not.toHaveBeenCalled(); - expect(container.textContent).toContain('manager.removeDialog.forceTitle'); - - await act(async () => { - container.querySelector('[data-testid="confirm-remove"]')?.click(); - await Promise.resolve(); - await Promise.resolve(); - }); - expect(mocks.remove).toHaveBeenCalledWith( - '/repo', - 'wt-1', - expect.any(String), - true, - ); - expect(mocks.refresh).toHaveBeenCalledOnce(); - }); - - it('never offers force removal while a session remains unarchived', async () => { - await openRemoveDialog(summary({ - associatedSessionCount: 1, - runningSessionCount: 1, - })); - - expect(container.textContent).toContain('manager.removeDialog.blocked'); - expect(container.textContent).toContain('manager.risks.running'); - await act(async () => { - container.querySelector('[data-testid="confirm-remove"]')?.click(); - }); - expect(mocks.remove).not.toHaveBeenCalled(); - expect(container.querySelector('[data-testid="confirm-dialog"]')).toBeNull(); - }); -}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.tsx deleted file mode 100644 index 541f1e7aed..0000000000 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.tsx +++ /dev/null @@ -1,385 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { - Archive, - ExternalLink, - GitBranch, - Loader2, - Plus, - RefreshCw, - Trash2, -} from 'lucide-react'; -import { Button, ConfirmDialog, InputDialog, Modal } from '@/component-library'; -import { configAPI, workspaceAPI, worktreeAPI } from '@/infrastructure/api'; -import type { WorktreeSummary } from '@/infrastructure/api/service-api/WorktreeAPI'; -import { WorktreeCommandError } from '@/infrastructure/api/service-api/WorktreeAPI'; -import { useI18n } from '@/infrastructure/i18n'; -import { notificationService } from '@/shared/notification-system'; -import './WorktreeManagerModal.scss'; - -interface WorktreeManagerModalProps { - isOpen: boolean; - projectWorkspacePath: string; - worktrees: WorktreeSummary[]; - loading: boolean; - error?: string | null; - onClose: () => void; - onRefresh: () => Promise; - onCreateWorktree: () => void; - onCreateSession: (worktree: WorktreeSummary) => Promise; -} - -function requestId(): string { - return globalThis.crypto?.randomUUID?.() ?? `worktree-${Date.now()}-${Math.random()}`; -} - -export const WorktreeManagerModal: React.FC = ({ - isOpen, - projectWorkspacePath, - worktrees, - loading, - error, - onClose, - onRefresh, - onCreateWorktree, - onCreateSession, -}) => { - const { t } = useI18n('worktrees'); - const [branchTarget, setBranchTarget] = useState(null); - const [removeTarget, setRemoveTarget] = useState(null); - const [forceStage, setForceStage] = useState(false); - const [pendingId, setPendingId] = useState(null); - const [branchPrefix, setBranchPrefix] = useState('bitfun/'); - - useEffect(() => { - if (!isOpen) return; - void configAPI - .getConfig('app.worktrees', { skipRetryOnNotFound: true }) - .then(value => { - if (value && typeof value.branchPrefix === 'string') { - setBranchPrefix(value.branchPrefix); - } - }) - .catch(() => undefined); - }, [isOpen]); - - const visibleWorktrees = useMemo( - () => worktrees.filter(worktree => !worktree.isMain), - [worktrees], - ); - const lifecycleLabel = (worktree: WorktreeSummary): string => { - if (worktree.lifecycle === 'permanent') return t('labels.lifecycle.permanent'); - if (worktree.lifecycle === 'external') return t('labels.lifecycle.external'); - return t('labels.lifecycle.managed'); - }; - - const runMutation = async ( - worktree: WorktreeSummary, - operation: () => Promise, - successMessage: string, - ): Promise => { - setPendingId(worktree.worktreeId); - try { - await operation(); - notificationService.success(successMessage, { duration: 2500 }); - await onRefresh(); - return null; - } catch (operationError) { - notificationService.error( - operationError instanceof Error ? operationError.message : String(operationError), - { duration: 4500 }, - ); - return operationError; - } finally { - setPendingId(null); - } - }; - - const confirmRemove = async () => { - if (!removeTarget) return; - const hasBlockingSessions = removeTarget.runningSessionCount > 0; - if (hasBlockingSessions) { - setRemoveTarget(null); - setForceStage(false); - return; - } - const needsForce = removeTarget.dirty || removeTarget.hasUnpublishedCommits; - if (needsForce && !forceStage) { - setForceStage(true); - return; - } - const target = removeTarget; - const operationError = await runMutation( - target, - () => worktreeAPI.remove( - projectWorkspacePath, - target.worktreeId, - requestId(), - needsForce, - ), - t('manager.removed'), - ); - if (!operationError) { - setRemoveTarget(null); - setForceStage(false); - } else if ( - operationError instanceof WorktreeCommandError - && (operationError.code === 'dirty_worktree' - || operationError.code === 'unpublished_commits') - ) { - setForceStage(true); - } - }; - - const removeRisks = removeTarget - ? [ - removeTarget.dirty ? t('manager.risks.dirty') : null, - removeTarget.hasUnpublishedCommits ? t('manager.risks.unpublished') : null, - removeTarget.associatedSessionCount > 0 - ? t('manager.risks.sessions', { count: removeTarget.associatedSessionCount }) - : null, - removeTarget.runningSessionCount > 0 - ? t('manager.risks.running', { count: removeTarget.runningSessionCount }) - : null, - ].filter((value): value is string => !!value) - : []; - - return ( - <> - -
-
-

{t('manager.description')}

-
- - -
-
- - {loading ? ( -
- - {t('manager.loading')} -
- ) : null} - {error ? ( -
{error}
- ) : null} - {!loading && !error && visibleWorktrees.length === 0 ? ( -
- - {t('manager.emptyTitle')} - {t('manager.emptyDescription')} -
- ) : null} - -
- {visibleWorktrees.map(worktree => { - const pending = pendingId === worktree.worktreeId; - const revision = worktree.branch || t('labels.detached', { - commit: worktree.head.slice(0, 10), - }); - return ( -
-
-
- - {revision} - {lifecycleLabel(worktree)} - {worktree.dirty ? {t('labels.dirty')} : null} - {worktree.missing ? {t('labels.missing')} : null} -
- {worktree.path} - - {t('manager.sessionCount', { count: worktree.associatedSessionCount })} - {worktree.hasUnpublishedCommits - ? ` · ${t('labels.unpublished')}` - : ''} - -
-
- {!worktree.missing ? ( - <> - - - {!worktree.branch ? ( - - ) : null} - - ) : ( - - )} - {worktree.lifecycle === 'managed' ? ( - - ) : null} - -
-
- ); - })} -
-
-
- - setBranchTarget(null)} - onConfirm={branch => { - const target = branchTarget; - if (!target) return; - void runMutation( - target, - () => worktreeAPI.createBranch( - projectWorkspacePath, - target.worktreeId, - branch, - requestId(), - ), - t('manager.branchCreated'), - ); - }} - title={t('manager.branchDialog.title')} - description={t('manager.branchDialog.description')} - defaultValue={`${branchPrefix}${branchTarget?.worktreeId.slice(0, 8) ?? ''}`} - confirmText={t('manager.createBranch')} - validator={value => value.trim() ? null : t('manager.branchDialog.required')} - /> - - { - setRemoveTarget(null); - setForceStage(false); - }} - onConfirm={() => void confirmRemove()} - title={ - forceStage - ? t('manager.removeDialog.forceTitle') - : t('manager.removeDialog.title') - } - type={forceStage ? 'error' : 'warning'} - message={ -
-

- {removeTarget?.runningSessionCount - ? t('manager.removeDialog.blocked') - : forceStage - ? t('manager.removeDialog.forceMessage') - : t('manager.removeDialog.message')} -

- {removeRisks.length > 0 ? ( -
    - {removeRisks.map(risk =>
  • {risk}
  • )} -
- ) : ( - {t('manager.risks.clean')} - )} -
- } - preview={removeTarget?.path} - confirmText={ - removeTarget?.runningSessionCount - ? t('actions.close') - : forceStage - ? t('manager.removeDialog.forceConfirm') - : t('manager.remove') - } - cancelText={t('actions.cancel')} - confirmDanger={!removeTarget?.runningSessionCount} - showCancel={!removeTarget?.runningSessionCount} - /> - - ); -}; - -export default WorktreeManagerModal; diff --git a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts index 0577c3b79b..9d29bfd3a2 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts @@ -52,8 +52,8 @@ export const SETTINGS_TAB_SEARCH_CONTENT: Record = ({ registration, }) => { const { t } = useTranslation('flow-chat'); + const { t: tWorktrees } = useI18n('worktrees'); const canLaunchReview = isTauriRuntime(); const [inputState, dispatchLocalInput] = useReducer(inputReducer, initialInputState); @@ -830,13 +833,33 @@ export const ChatInput: React.FC = ({ ? '' : (effectiveTargetSession?.workspacePath || '').trim(); const contextPath = (workspacePath || '').trim(); + // A managed worktree is where the session executes, not a different project. + // Its directory is a generated id, so keep labelling by the owning project. + const sessionProjectPath = hasRegisteredWorkspace + ? '' + : ( + effectiveTargetSession?.config.projectWorkspacePath + || effectiveTargetSession?.projectWorkspacePath + || '' + ).trim(); + const isWorktreeSession = !!effectiveTargetSession?.config.executionTarget?.worktreeId; const sessionUsesDifferentRoot = !!sessionPath - && (!contextPath || !isSamePath(sessionPath, contextPath)); + && (!contextPath || !isSamePath(sessionPath, contextPath)) + && !( + isWorktreeSession + && !!contextPath + && !!sessionProjectPath + && isSamePath(sessionProjectPath, contextPath) + ); if (name && !sessionUsesDifferentRoot) return name; + if (isWorktreeSession && sessionProjectPath) return path.basename(sessionProjectPath); if (chatStripRepositoryPath) return path.basename(chatStripRepositoryPath); return ''; }, [ chatStripRepositoryPath, + effectiveTargetSession?.config.executionTarget?.worktreeId, + effectiveTargetSession?.config.projectWorkspacePath, + effectiveTargetSession?.projectWorkspacePath, effectiveTargetSession?.workspacePath, hasRegisteredWorkspace, workspaceName, @@ -1850,6 +1873,56 @@ 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. + */ + const worktreeControl = useMemo(() => { + if (!effectiveTargetSessionId || !effectiveTargetSession) return undefined; + if (effectiveTargetSession.remoteConnectionId) return undefined; + if (isSubagentInputTarget || isAcpTargetSession) return undefined; + + const locked = effectiveTargetSession.dialogTurns.length > 0 + || effectiveTargetSession.status === 'active'; + + return { + locked, + onChange: async (enabled: boolean) => { + try { + const result = await worktreeAPI.bindSession( + effectiveTargetSessionId, + enabled, + globalThis.crypto?.randomUUID?.() ?? `worktree-${Date.now()}`, + ); + 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 }, + ); + } + }, + }; + }, [ + effectiveTargetSession, + effectiveTargetSessionId, + isAcpTargetSession, + isSubagentInputTarget, + tWorktrees, + ]); + const handleHidePermissionModeControl = useCallback(async () => { try { await configManager.setConfig('app.flow_chat.show_permission_mode_control', false); @@ -5288,10 +5361,7 @@ export const ChatInput: React.FC = ({ repositoryPath={chatStripRepositoryPath} workspaceLabel={chatStripWorkspaceLabel} executionTarget={effectiveTargetSession?.config.executionTarget} - projectWorkspacePath={ - effectiveTargetSession?.config.projectWorkspacePath - || effectiveTargetSession?.projectWorkspacePath - } + worktreeControl={worktreeControl} deferPassiveGitRefresh={deferChatStripPassiveGitRefresh} permissionControl={showPermissionModeControl ? { mode: permissionMode, diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss index 3126efc388..87ef2a8f97 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss @@ -401,73 +401,58 @@ flex: 0 1 auto; max-width: 35%; gap: 3px; - border: 0; - background: transparent; - color: inherit; - font: inherit; - - &:not(:disabled) { - cursor: pointer; - } - - &:disabled { - opacity: 1; - } } - } - - &__worktree { - position: relative; - display: flex; - min-width: 0; - flex: 0 1 auto; - max-width: 35%; - } - - &__worktree &__chip--branch { - max-width: 100%; - } - - &__worktree-menu { - position: absolute; - bottom: calc(100% + 7px); - left: 0; - z-index: 11; - display: flex; - width: max-content; - min-width: 180px; - flex-direction: column; - gap: 2px; - padding: 5px; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-base; - background: var(--color-bg-elevated); - box-shadow: var(--shadow-lg); - > button { - display: flex; + /* Checkbox-style chip: reads as a control without competing with the branch. */ + &--worktree { align-items: center; - gap: 7px; - padding: 7px 8px; - border: 0; - border-radius: $size-radius-sm; + flex: none; + gap: 4px; + margin-left: 4px; + border: 1px solid var(--border-subtle); background: transparent; - color: var(--color-text-secondary); + color: var(--color-text-muted); font: inherit; - text-align: left; + font-size: var(--flowchat-font-size-xxs); + line-height: 1; + white-space: nowrap; cursor: pointer; - &:hover:not(:disabled), - &:focus-visible { + &:hover:not(:disabled) { background: var(--element-bg-medium); - color: var(--color-text-primary); + color: var(--color-text-secondary); + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: 1px; } &:disabled { - cursor: wait; + cursor: default; opacity: 0.55; } } + + &--worktree-on { + border-color: var(--color-accent-500); + color: var(--color-accent-500); + + &:hover:not(:disabled) { + color: var(--color-accent-500); + } + } + } + + &__worktree-icon { + flex-shrink: 0; + width: 11px; + height: 11px; + color: inherit; + + &.is-spinning { + animation: bitfun-chat-input-workspace-strip-spin 0.9s linear infinite; + } } &__workspace { @@ -523,3 +508,9 @@ } } } + +@keyframes bitfun-chat-input-workspace-strip-spin { + to { + transform: rotate(360deg); + } +} 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 8d14bf4867..aa65fac3e5 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx @@ -15,8 +15,6 @@ const mocks = vi.hoisted(() => ({ isRepository: true, refreshBasic: vi.fn(async () => undefined), })), - listWorktrees: vi.fn(), - onWorktreeChanged: vi.fn(), })); vi.mock('react-i18next', () => ({ @@ -33,9 +31,6 @@ vi.mock('@/component-library', () => ({ IconButton: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( ), - InputDialog: ({ isOpen }: { isOpen: boolean }) => ( - isOpen ?
: null - ), Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, })); @@ -43,17 +38,6 @@ vi.mock('@/tools/git/hooks/useGitState', () => ({ useGitState: mocks.useGitState, })); -vi.mock('@/infrastructure/api', () => ({ - configAPI: { getConfig: vi.fn() }, - workspaceAPI: { revealInExplorer: vi.fn() }, - worktreeAPI: { - list: mocks.listWorktrees, - onChanged: mocks.onWorktreeChanged, - createBranch: vi.fn(), - promote: vi.fn(), - }, -})); - describe('ChatInputWorkspaceStrip git refresh behavior', () => { let container: HTMLDivElement; let root: Root; @@ -68,9 +52,6 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { isRepository: true, refreshBasic: mocks.refreshBasic, }); - mocks.listWorktrees.mockReset(); - mocks.onWorktreeChanged.mockReset(); - mocks.onWorktreeChanged.mockReturnValue(vi.fn()); }); afterEach(() => { @@ -179,55 +160,122 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { expect(container.querySelector('[data-testid="chat-input-permission-menu"]')).toBeNull(); }); - it('refreshes the session-bound worktree chip from worktree events', async () => { - let onChanged: ((event: { projectWorkspacePath: string }) => void) | undefined; - mocks.onWorktreeChanged.mockImplementation(callback => { - onChanged = callback; - return vi.fn(); - }); - mocks.listWorktrees.mockResolvedValue([{ - worktreeId: 'wt-1', - projectWorkspacePath: '/repo', - path: '/worktrees/wt-1', - head: '0123456789abcdef', - branch: 'bitfun/isolated', - lifecycle: 'permanent', - isMain: false, - dirty: false, - locked: false, - missing: false, - hasUnpublishedCommits: false, - associatedSessionCount: 1, - runningSessionCount: 1, - sessions: [], - }]); + it('offers the worktree toggle for a Git workspace and reports the new state', async () => { + const onChange = vi.fn(async () => undefined); + await act(async () => { + root.render( + + ); + }); + + const toggle = container.querySelector('[data-testid="chat-input-worktree-toggle"]'); + expect(toggle).not.toBeNull(); + expect(toggle?.dataset.worktreeEnabled).toBe('false'); + expect(toggle?.disabled).toBe(false); + + await act(async () => { + toggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onChange).toHaveBeenCalledWith(true); + }); + it('shows the toggle as on inside a worktree and asks to turn it off', async () => { + const onChange = vi.fn(async () => undefined); await act(async () => { root.render( ); - await Promise.resolve(); }); - expect(mocks.listWorktrees).toHaveBeenCalledWith('/repo'); + const toggle = container.querySelector('[data-testid="chat-input-worktree-toggle"]'); + expect(toggle?.dataset.worktreeEnabled).toBe('true'); expect(container.textContent).toContain('bitfun/isolated'); await act(async () => { - onChanged?.({ projectWorkspacePath: '/repo' }); - await Promise.resolve(); + toggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); - expect(mocks.listWorktrees).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenCalledWith(false); + }); + + it('locks the toggle once the session has a transcript', async () => { + const onChange = vi.fn(async () => undefined); + await act(async () => { + root.render( + + ); + }); + + const toggle = container.querySelector('[data-testid="chat-input-worktree-toggle"]'); + expect(toggle?.disabled).toBe(true); + + await act(async () => { + toggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('refetches Git state when the execution root moves into a worktree', async () => { + const onChange = vi.fn(async () => undefined); + await act(async () => { + root.render( + + ); + }); + expect(mocks.refreshBasic).not.toHaveBeenCalled(); + + await act(async () => { + root.render( + + ); + }); + expect(mocks.refreshBasic).toHaveBeenCalled(); }); + + it('omits the toggle when the session cannot host a worktree', async () => { + await act(async () => { + root.render( + + ); + }); + + expect(container.querySelector('[data-testid="chat-input-worktree-toggle"]')).toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index 693fb2d0de..f908fd7e3d 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -6,29 +6,22 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Activity, - Archive, Check, EyeOff, - FolderOpen, + Loader2, GitBranch, - Settings2, Shield, ShieldAlert, ShieldCheck, + Square, + SquareCheck, } from 'lucide-react'; import { ThreadGoalStripButton } from './thread-goal/ThreadGoalStripButton'; import type { ThreadGoalSnapshot } from '../services/goalService'; -import { Tooltip, IconButton, InputDialog } from '@/component-library'; +import { Tooltip, IconButton } from '@/component-library'; import { useGitState } from '@/tools/git/hooks/useGitState'; -import { configAPI, workspaceAPI, worktreeAPI } from '@/infrastructure/api'; -import type { - SessionExecutionTarget, - WorktreeSummary, -} from '@/infrastructure/api/service-api/WorktreeAPI'; +import type { SessionExecutionTarget } from '@/infrastructure/api/service-api/WorktreeAPI'; import { useI18n } from '@/infrastructure/i18n'; -import { notificationService } from '@/shared/notification-system'; -import { openWorktreeManager } from '@/shared/services/worktreeUIEvents'; -import { isSamePath } from '@/shared/utils/pathUtils'; import './ChatInputWorkspaceStrip.scss'; export interface ChatInputWorkspaceStripProps { @@ -58,8 +51,15 @@ export interface ChatInputWorkspaceStripProps { deferPassiveGitRefresh?: boolean; /** Resolved target bound to the active session. */ executionTarget?: SessionExecutionTarget; - /** Main project that owns the active worktree session. */ - projectWorkspacePath?: string; + /** + * Per-session worktree isolation, rendered next to the branch for Git workspaces. + * Omitted when the session cannot host a worktree at all (remote, no session). + */ + worktreeControl?: { + /** Locked once the session has a transcript — its history describes one directory. */ + locked: boolean; + onChange: (enabled: boolean) => void | Promise; + }; } export type ChatInputPermissionMode = 'ask' | 'auto' | 'full_access' | 'acp'; @@ -78,18 +78,13 @@ export const ChatInputWorkspaceStrip: React.FC = ( permissionControl, deferPassiveGitRefresh = false, executionTarget, - projectWorkspacePath, + worktreeControl, }) => { const { t } = useTranslation('flow-chat'); const { t: tWorktrees } = useI18n('worktrees'); const permissionRootRef = useRef(null); - const worktreeRootRef = useRef(null); const [permissionMenuOpen, setPermissionMenuOpen] = useState(false); - const [worktreeMenuOpen, setWorktreeMenuOpen] = useState(false); - const [branchDialogOpen, setBranchDialogOpen] = useState(false); - const [branchPrefix, setBranchPrefix] = useState('bitfun/'); - const [worktreeMutationPending, setWorktreeMutationPending] = useState(false); - const [liveWorktree, setLiveWorktree] = useState(null); + const [worktreePending, setWorktreePending] = useState(false); const trimmedPath = repositoryPath.trim(); const label = workspaceLabel.trim(); @@ -102,14 +97,24 @@ export const ChatInputWorkspaceStrip: React.FC = ( debugSource: 'chat_input_workspace_strip', }); + // Toggling worktree isolation moves the execution root under a live strip. + // The shared Git cache holds nothing for the new directory, and useGitState + // only auto-refreshes on mount, so ask for the new branch explicitly. + const previousRepositoryPathRef = useRef(trimmedPath); + useEffect(() => { + if (previousRepositoryPathRef.current === trimmedPath) return; + previousRepositoryPathRef.current = trimmedPath; + if (trimmedPath) { + void refreshBasic(); + } + }, [refreshBasic, trimmedPath]); + const showUsage = usageReport?.visible && !!usageReport.onOpen; const showGoal = threadGoal?.visible && !!threadGoal.onOpen; const showPermission = !!permissionControl; const showRightActions = showPermission || showUsage || showGoal; const isWorktree = !!executionTarget?.worktreeId; - const effectiveWorktreePath = liveWorktree?.path || executionTarget?.rootPath || trimmedPath; - const effectiveWorktreeLifecycle = - liveWorktree?.lifecycle || executionTarget?.lifecycle; + const showWorktreeToggle = !!worktreeControl && (isRepository || isWorktree); const permissionCopy = { ask: { label: t('chatInput.permissionMode.ask.label'), @@ -130,20 +135,16 @@ export const ChatInputWorkspaceStrip: React.FC = ( } satisfies Record; useEffect(() => { - if (!permissionMenuOpen && !worktreeMenuOpen) return; + if (!permissionMenuOpen) return; const handlePointerDown = (event: PointerEvent) => { if (!permissionRootRef.current?.contains(event.target as Node)) { setPermissionMenuOpen(false); } - if (!worktreeRootRef.current?.contains(event.target as Node)) { - setWorktreeMenuOpen(false); - } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setPermissionMenuOpen(false); - setWorktreeMenuOpen(false); } }; @@ -153,54 +154,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( document.removeEventListener('pointerdown', handlePointerDown); document.removeEventListener('keydown', handleKeyDown); }; - }, [permissionMenuOpen, worktreeMenuOpen]); - - useEffect(() => { - if (!worktreeMenuOpen) return; - void configAPI - .getConfig('app.worktrees', { skipRetryOnNotFound: true }) - .then(value => { - if (value && typeof value.branchPrefix === 'string') { - setBranchPrefix(value.branchPrefix); - } - }) - .catch(() => undefined); - }, [worktreeMenuOpen]); - - useEffect(() => { - const worktreeId = executionTarget?.worktreeId; - if (!worktreeId || !projectWorkspacePath) { - setLiveWorktree(null); - return; - } - let cancelled = false; - const refreshWorktree = async () => { - try { - const worktrees = await worktreeAPI.list(projectWorkspacePath); - if (!cancelled) { - setLiveWorktree( - worktrees.find(worktree => worktree.worktreeId === worktreeId) ?? null, - ); - await refreshBasic(); - } - } catch { - if (!cancelled) setLiveWorktree(null); - } - }; - void refreshWorktree(); - const unsubscribe = worktreeAPI.onChanged(event => { - if ( - !event.projectWorkspacePath - || isSamePath(event.projectWorkspacePath, projectWorkspacePath) - ) { - void refreshWorktree(); - } - }); - return () => { - cancelled = true; - unsubscribe(); - }; - }, [executionTarget?.worktreeId, projectWorkspacePath, refreshBasic]); + }, [permissionMenuOpen]); const branchTooltipContent = useMemo( () => @@ -214,8 +168,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( return null; } - const branchLabel = liveWorktree?.branch?.trim() - || executionTarget?.branch?.trim() + const branchLabel = executionTarget?.branch?.trim() || (isWorktree && currentBranch?.trim()) || (isWorktree && executionTarget?.baseCommit ? tWorktrees('labels.detached', { commit: executionTarget.baseCommit.slice(0, 9) }) @@ -224,6 +177,12 @@ 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 permissionMode = permissionControl?.mode ?? 'ask'; const permissionModeLabel = permissionCopy[permissionMode].label; const permissionTooltip = permissionMode === 'acp' @@ -236,6 +195,16 @@ export const ChatInputWorkspaceStrip: React.FC = ( : Shield; const showPermissionLabel = permissionMode !== 'acp'; + const handleWorktreeToggle = () => { + if (!worktreeControl || worktreeToggleDisabled) { + return; + } + setWorktreePending(true); + void Promise.resolve(worktreeControl.onChange(!isWorktree)).finally(() => { + setWorktreePending(false); + }); + }; + const split = !!label && showRightActions; const actionsOnly = !label && showRightActions; @@ -260,22 +229,8 @@ export const ChatInputWorkspaceStrip: React.FC = ( {' / '} - -
- - {isWorktree && worktreeMenuOpen ? ( -
- - {!liveWorktree?.branch && !executionTarget.branch ? ( - - ) : null} - {effectiveWorktreeLifecycle === 'managed' ? ( - - ) : null} - -
- ) : null} -
+
+ {showWorktreeToggle ? ( + + + + ) : null}
) : null} @@ -497,30 +423,6 @@ export const ChatInputWorkspaceStrip: React.FC = ( ) : null} ) : null} - setBranchDialogOpen(false)} - onConfirm={branch => { - if (!projectWorkspacePath || !executionTarget?.worktreeId) return; - setWorktreeMutationPending(true); - void worktreeAPI - .createBranch( - projectWorkspacePath, - executionTarget.worktreeId, - branch, - globalThis.crypto?.randomUUID?.() ?? `worktree-${Date.now()}`, - ) - .then(() => notificationService.success(tWorktrees('manager.branchCreated'))) - .catch(error => notificationService.error( - error instanceof Error ? error.message : String(error), - )) - .finally(() => setWorktreeMutationPending(false)); - }} - title={tWorktrees('manager.branchDialog.title')} - description={tWorktrees('manager.branchDialog.description')} - defaultValue={`${branchPrefix}${executionTarget?.worktreeId?.slice(0, 8) ?? ''}`} - confirmText={tWorktrees('manager.createBranch')} - /> ); }; diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 9453846036..b2781e88da 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -2115,6 +2115,43 @@ export class FlowChatStore { }); } + /** + * Apply a backend session rebind (worktree isolation toggled on or off). + * The project root stays put; only the execution directory moves. + */ + public updateSessionExecutionTarget( + sessionId: string, + binding: { + workspacePath: string; + projectWorkspacePath: string; + workspaceId?: string; + executionTarget: Session['config']['executionTarget']; + }, + ): void { + this.setState(prev => { + const session = prev.sessions.get(sessionId); + if (!session) return prev; + + const newSessions = new Map(prev.sessions); + newSessions.set(sessionId, { + ...session, + workspacePath: binding.workspacePath, + projectWorkspacePath: binding.projectWorkspacePath, + workspaceId: binding.workspaceId ?? session.workspaceId, + config: { + ...session.config, + workspacePath: binding.workspacePath, + projectWorkspacePath: binding.projectWorkspacePath, + workspaceId: binding.workspaceId ?? session.config.workspaceId, + executionTarget: binding.executionTarget, + }, + lastActiveAt: Date.now(), + }); + + return { ...prev, sessions: newSessions }; + }); + } + public updateSessionFocusedReviewDisplayLabel( sessionId: string, focusedReviewDisplayLabel: Session['focusedReviewDisplayLabel'], diff --git a/src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.ts b/src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.ts index 71ea63ee1e..32d917b704 100644 --- a/src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.ts @@ -6,10 +6,8 @@ export type SessionExecutionTargetRequest = | { kind: 'existingWorktree'; worktreeId: string }; export type WorktreeLifecycle = 'managed' | 'permanent' | 'external'; -export type WorktreeDefaultTarget = 'local' | 'managedWorktree'; export interface WorktreeSettings { - defaultTarget: WorktreeDefaultTarget; rootPath: string; branchPrefix: string; copyLocalChanges: boolean; @@ -106,6 +104,16 @@ export interface WorktreeChangedEvent { projectWorkspacePath: string; } +export interface WorktreeSessionBindingResult { + sessionId: string; + workspacePath: string; + projectWorkspacePath: string; + workspaceId?: string; + executionTarget: SessionExecutionTarget; + /** Set when a released worktree was kept because it still held local work. */ + retainedWorktreePath?: string; +} + export function toWorktreeCommandError(error: unknown): WorktreeCommandError { const candidates: unknown[] = [error]; if (error instanceof Error) { @@ -204,6 +212,18 @@ export class WorktreeAPI { }); } + /** + * Move a session into a managed worktree, or back to the project checkout. + * Only allowed while the session has no messages yet. + */ + bindSession( + sessionId: string, + enabled: boolean, + requestId: string, + ): Promise { + return invokeWorktree('worktree_bind_session', { sessionId, enabled, requestId }); + } + onChanged(callback: (event: WorktreeChangedEvent) => void): () => void { return api.listen('worktree://changed', callback); } diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx index 935bad0c7a..8bdf785b7b 100644 --- a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx @@ -5,7 +5,6 @@ import { ConfigPageLoading, ConfigPageMessage, Input, - Select, Switch, } from '@/component-library'; import { configAPI } from '@/infrastructure/api'; @@ -21,7 +20,6 @@ import { import './WorktreesConfig.scss'; const DEFAULT_SETTINGS: WorktreeSettings = { - defaultTarget: 'local', rootPath: '~/.bitfun/worktrees', branchPrefix: 'bitfun/', copyLocalChanges: false, @@ -94,29 +92,9 @@ const WorktreesConfig: React.FC = () => { - -