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/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 9545e5eea3..7ae84e9e51 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -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", 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 = () => { - -