From 57fcd1ee4f70a65238f417219be454e55dd2c9c4 Mon Sep 17 00:00:00 2001 From: Morgan Woods Date: Sun, 9 Aug 2026 14:15:06 +0800 Subject: [PATCH 1/9] feat(git): add git worktree support with sidebar project grouping - Create git worktrees under ~/.liveagent/worktree// from the branch selector; the new worktree opens as a workspace in the sidebar - Detect branches checked out in a worktree and offer "Delete Worktree" (removes the worktree and its branch) instead of a failing branch delete, with a force-removal escalation for dirty worktrees - Add user-defined sidebar project groups with create/rename/delete/move and collapsed state; worktrees auto-group under their source repository project, reusing renamed groups via sourceProjectPath - Persist workspaceProjectGroups in system settings (the save whitelist previously dropped the field, losing groups on restart) - Move the "New Worktree" entry next to "Create New Branch" in the git menu; keep worktree names free of auto-capitalization --- .../src/commands/config/settings/mod.rs | 1 + .../src/commands/config/settings/system.rs | 1 + .../src/commands/config/settings/tests.rs | 49 ++ .../src-tauri/src/commands/workspace/git.rs | 617 +++++++++++++++++- crates/agent-gui/src-tauri/src/lib.rs | 2 + crates/agent-gui/src/i18n/config.ts | 56 ++ .../agent-gui/src/lib/git/tauriGitClient.ts | 18 + crates/agent-gui/src/lib/settings/index.ts | 68 ++ crates/agent-gui/src/pages/ChatPage.tsx | 14 + .../chat/sidebar/ChatSidebarContainer.tsx | 12 + .../chat/workspace/useWorkspaceProjects.ts | 163 +++++ .../workspace-project-parent.test.mjs | 53 ++ .../tools/workspace-project-groups.test.mjs | 151 +++++ .../components/chat/ChatHistorySidebar.tsx | 520 +++++++++++++-- .../src/components/git/GitBranchSelector.tsx | 431 ++++++++++-- crates/agent-ui/src/lib/git/types.ts | 46 ++ crates/agent-ui/src/lib/workspaceProjects.ts | 140 ++++ .../src/pages/chat/ChatComposerBar.tsx | 4 + 18 files changed, 2212 insertions(+), 134 deletions(-) create mode 100644 crates/agent-gui/test/settings/workspace-project-parent.test.mjs create mode 100644 crates/agent-gui/test/tools/workspace-project-groups.test.mjs diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs index c348c9a46..8fd871a4a 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs @@ -33,6 +33,7 @@ const SYSTEM_WORKDIR_KEY: &str = "workdir"; // 保存白名单,导致重启后设置丢失;补入本键持久化。 const SYSTEM_TOOL_POLICIES_KEY: &str = "toolPolicies"; const SYSTEM_WORKSPACE_PROJECTS_KEY: &str = "workspaceProjects"; +const SYSTEM_WORKSPACE_PROJECT_GROUPS_KEY: &str = "workspaceProjectGroups"; const SYSTEM_ACTIVE_WORKSPACE_PROJECT_ID_KEY: &str = "activeWorkspaceProjectId"; const SYSTEM_HIDDEN_WORKSPACE_PROJECT_PATHS_KEY: &str = "hiddenWorkspaceProjectPaths"; const SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY: &str = "missingWorkspaceProjectPaths"; diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs index 13e39c380..00954b9ca 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs @@ -501,6 +501,7 @@ fn save_system_with_default_workdir( SYSTEM_WORKDIR_KEY, SYSTEM_TOOL_POLICIES_KEY, SYSTEM_WORKSPACE_PROJECTS_KEY, + SYSTEM_WORKSPACE_PROJECT_GROUPS_KEY, SYSTEM_ACTIVE_WORKSPACE_PROJECT_ID_KEY, SYSTEM_HIDDEN_WORKSPACE_PROJECT_PATHS_KEY, SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY, diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs index 5f44fe32e..d2c4ac385 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs @@ -1133,6 +1133,7 @@ mod tests { SYSTEM_SYSTEM_PROXY_KEY.to_string(), SYSTEM_TOOL_POLICIES_KEY.to_string(), SYSTEM_WORKDIR_KEY.to_string(), + SYSTEM_WORKSPACE_PROJECT_GROUPS_KEY.to_string(), SYSTEM_WORKSPACE_PROJECTS_KEY.to_string(), SYSTEM_WORKSPACE_RESOURCE_SETTINGS_KEY.to_string(), ] @@ -1149,6 +1150,7 @@ mod tests { "systemProxy": default_system_proxy_json(), "workdir": default_workdir.clone(), "toolPolicies": { "Bash": "ask", "server:docs-mcp": "deny" }, + "workspaceProjectGroups": null, "workspaceProjects": [ { "id": DEFAULT_WORKSPACE_PROJECT_ID, @@ -1191,6 +1193,49 @@ mod tests { ); } + #[test] + fn save_system_round_trips_workspace_project_groups() { + let mut conn = open_memory_db(); + save_system_with_default_workdir( + &mut conn, + json!({ + "executionMode": "tools", + "workdir": "/tmp/liveagent-default-project", + "workspaceProjectGroups": [ + { + "id": "g1", + "name": "LiveAgent", + "projectPaths": ["/tmp/repo", "/tmp/wt"], + "sourceProjectPath": "/tmp/repo", + "collapsed": true, + "createdAt": 100, + "updatedAt": 100 + } + ] + }), + "/tmp/liveagent-default-project", + ) + .expect("save system"); + + let loaded = load_system(&conn) + .expect("load system") + .expect("system settings"); + assert_eq!( + loaded.get(SYSTEM_WORKSPACE_PROJECT_GROUPS_KEY), + Some(&json!([ + { + "id": "g1", + "name": "LiveAgent", + "projectPaths": ["/tmp/repo", "/tmp/wt"], + "sourceProjectPath": "/tmp/repo", + "collapsed": true, + "createdAt": 100, + "updatedAt": 100 + } + ])) + ); + } + #[test] fn save_system_normalizes_workspace_resource_settings() { let now = std::time::SystemTime::now() @@ -1251,6 +1296,8 @@ mod tests { })) ); } + ); + } #[test] fn workspace_resource_settings_are_not_truncated_after_one_hundred_paths() { @@ -1403,6 +1450,7 @@ mod tests { "systemProxy": default_system_proxy_json(), "workdir": "/tmp/liveagent-default-project", "toolPolicies": null, + "workspaceProjectGroups": null, "workspaceProjects": [ { "id": DEFAULT_WORKSPACE_PROJECT_ID, @@ -1455,6 +1503,7 @@ mod tests { "systemProxy": default_system_proxy_json(), "workdir": "/tmp/liveagent-default-project", "toolPolicies": null, + "workspaceProjectGroups": null, "workspaceProjects": [ { "id": DEFAULT_WORKSPACE_PROJECT_ID, diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs index 90c2f791a..13e1f2acd 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs @@ -105,6 +105,14 @@ pub struct GitBranch { pub struct GitBranchesResponse { pub state: GitRepositoryState, pub branches: Vec, + pub worktrees: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitWorktreeInfo { + pub path: String, + pub branch: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -201,6 +209,17 @@ pub struct GitOperationResponse { pub message: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitWorktreeResponse { + pub ok: bool, + pub state: GitRepositoryState, + pub worktree_path: String, + pub stdout: String, + pub stderr: String, + pub message: String, +} + #[derive(Debug, Clone, Deserialize, Default)] #[serde(rename_all = "camelCase")] struct GitGatewayArgs { @@ -1087,6 +1106,7 @@ pub(crate) fn git_branches_sync(workdir: String) -> Result Result Result, String> { + let output = git_success(repo_root, &["worktree", "list", "--porcelain"])?; + let mut worktrees = Vec::new(); + for block in output.stdout.split("\n\n") { + let mut path = String::new(); + let mut branch = String::new(); + for line in block.lines() { + if let Some(rest) = line.strip_prefix("worktree ") { + path = rest.trim().to_string(); + } else if let Some(rest) = line.strip_prefix("branch refs/heads/") { + branch = rest.trim().to_string(); + } + } + if !path.is_empty() { + worktrees.push(GitWorktreeInfo { path, branch }); + } + } + Ok(worktrees) } fn ensure_ready_state(workdir: &str) -> Result { @@ -1696,6 +1745,162 @@ pub(crate) fn git_create_branch_sync( ) } +/// Worktree 存储基目录(`~/.liveagent/worktree`)。Worktree 是仓库的检出 +/// 副本,落在应用存储域,避免污染工作区目录结构。 +fn worktree_storage_base() -> Result { + let home = dirs::home_dir().ok_or_else(|| "无法定位用户目录。".to_string())?; + let dir = home.join(".liveagent").join("worktree"); + fs::create_dir_all(&dir).map_err(|error| format!("创建 worktree 目录失败:{error}"))?; + Ok(dir) +} + +/// 稳定且唯一的 repo id:`-`。 +/// 同一仓库根路径永远映射到同一 id,目录可读;32 位哈希碰撞概率低,但非绝对。 +fn repo_worktree_id(repo_root: &str) -> String { + let basename = Path::new(repo_root) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| repo_root.to_string()); + let sanitized = sanitize_repo_id_component(&basename); + format!("{sanitized}-{:08x}", fnv1a64(repo_root.as_bytes()) as u32) +} + +fn sanitize_repo_id_component(input: &str) -> String { + let mut out = String::new(); + for ch in input.trim().chars() { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' { + out.push(ch); + } else { + out.push('-'); + } + } + let compact = out + .split('-') + .filter(|part| !part.is_empty()) + .collect::>() + .join("-"); + let trimmed = compact + .trim_matches(|ch| ch == '-' || ch == '.') + .to_string(); + if trimmed.is_empty() { + "repo".to_string() + } else { + trimmed.chars().take(80).collect() + } +} + +/// FNV-1a 64 位哈希,与前端展示无关、仅用于目录命名,无需引入额外依赖。 +fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// Worktree 名称同时作为目录名与分支名:必须是一个文件系统组件, +/// 且能通过 git 的分支名校验。 +fn validate_worktree_name(repo_root: &str, name: &str) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("Worktree 名称不能为空。".to_string()); + } + if trimmed == "." || trimmed == ".." { + return Err("Worktree 名称不能是 . 或 ..。".to_string()); + } + if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains(':') { + return Err("Worktree 名称不能包含路径分隔符。".to_string()); + } + if trimmed + .chars() + .any(|ch| ch == '\0' || ch.is_ascii_control()) + { + return Err("Worktree 名称包含非法字符。".to_string()); + } + git_success(repo_root, &["check-ref-format", "--branch", trimmed])?; + Ok(trimmed.to_string()) +} + +pub(crate) fn git_create_worktree_sync( + workdir: String, + name: String, + start_point: Option, +) -> Result { + let base = worktree_storage_base()?; + git_create_worktree_in_base(workdir, name, start_point, &base) +} + +/// 实际创建逻辑:基目录由调用方注入(生产为 `~/.liveagent/worktree`, +/// 测试传临时目录),便于单测隔离且不触碰真实 home。 +fn git_create_worktree_in_base( + workdir: String, + name: String, + start_point: Option, + base: &Path, +) -> Result { + let state = ensure_ready_state(&workdir)?; + let repo_root = + fs::canonicalize(&state.repo_root).map_err(|error| format!("无法解析仓库路径:{error}"))?; + let repo_root_str = repo_root.to_string_lossy().into_owned(); + let worktree_name = validate_worktree_name(&repo_root_str, &name)?; + let repo_dir = base.join(repo_worktree_id(&repo_root_str)); + let target = repo_dir.join(&worktree_name); + if target.exists() { + return Err(format!("Worktree 目标已存在:{}", target.display())); + } + fs::create_dir_all(&repo_dir).map_err(|error| format!("创建 worktree 目录失败:{error}"))?; + + let validated_start_point = start_point + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| validate_start_point(&repo_root_str, value)) + .transpose()?; + let start_point = validated_start_point.unwrap_or_else(|| "HEAD".to_string()); + + let result = git_success( + &repo_root_str, + &[ + "worktree", + "add", + "-b", + worktree_name.as_str(), + target.to_string_lossy().as_ref(), + start_point.as_str(), + ], + ); + + let response_state = git_status_sync(workdir)?; + match result { + Ok(output) => { + let worktree_path = fs::canonicalize(&target) + .map_err(|error| format!("无法解析 worktree 路径:{error}"))? + .to_string_lossy() + .into_owned(); + Ok(GitWorktreeResponse { + ok: true, + state: response_state, + worktree_path, + stdout: output.stdout, + stderr: output.stderr, + message: "Worktree 已创建。".to_string(), + }) + } + Err(error) => { + let _ = fs::remove_dir_all(&target); + Ok(GitWorktreeResponse { + ok: false, + state: response_state, + worktree_path: target.to_string_lossy().into_owned(), + stdout: String::new(), + stderr: error.clone(), + message: error, + }) + } + } +} + pub(crate) fn git_init_sync( workdir: String, branch: String, @@ -2944,11 +3149,61 @@ pub(crate) fn git_delete_branch_sync( return Err("不能删除当前检出的分支。".to_string()); } let delete_flag = if force == Some(true) { "-D" } else { "-d" }; - operation_response( - &workdir, - git_success(&state.repo_root, &["branch", delete_flag, branch.as_str()]), - "分支已删除。", - ) + let result = git_success(&state.repo_root, &["worktree", "prune", "--expire", "now"]) + .and_then(|_| git_success(&state.repo_root, &["branch", delete_flag, branch.as_str()])); + operation_response(&workdir, result, "分支已删除。") +} + +/// 移除 worktree,成功后可选删除其检出的分支。worktree 路径必须是 +/// `git worktree list` 中登记的路径(防御任意路径删除)。 +pub(crate) fn git_remove_worktree_sync( + workdir: String, + worktree_path: String, + force: Option, + delete_branch: Option, +) -> Result { + let state = ensure_ready_state(&workdir)?; + let trimmed = worktree_path.trim(); + if trimmed.is_empty() { + return Err("Worktree 路径不能为空。".to_string()); + } + let canonical = + fs::canonicalize(trimmed).map_err(|error| format!("无法解析 Worktree 路径:{error}"))?; + let registered = git_worktrees_sync(&state.repo_root)?.iter().any(|info| { + fs::canonicalize(&info.path) + .map(|path| path == canonical) + .unwrap_or(false) + }); + if !registered { + return Err("目标路径不是当前仓库已登记的 Worktree。".to_string()); + } + + let mut args = vec!["worktree", "remove"]; + if force == Some(true) { + args.push("--force"); + } + args.push(trimmed); + let remove_result = git_success(&state.repo_root, &args); + let result = match remove_result { + Ok(_) => { + // worktree 移除成功后,尝试删除其检出的分支;分支删除失败时 + // worktree 已不在(重试会提示未登记),错误信息必须说明这一点。 + let branch = delete_branch.as_deref().map(str::trim).unwrap_or(""); + if branch.is_empty() { + Ok(GitOutput { + stdout: String::new(), + stderr: String::new(), + }) + } else { + match git_success(&state.repo_root, &["branch", "-d", branch]) { + Ok(output) => Ok(output), + Err(error) => Err(format!("Worktree 已移除,但分支删除失败:{error}")), + } + } + } + Err(error) => Err(error), + }; + operation_response(&workdir, result, "Worktree 已移除。") } pub(crate) fn git_rename_branch_sync( @@ -3177,6 +3432,18 @@ pub async fn git_create_branch( .map_err(|error| format!("git_create_branch join 失败:{error}"))? } +#[tauri::command(rename_all = "snake_case")] +pub async fn git_create_worktree( + workdir: String, + name: String, + start_point: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + git_create_worktree_sync(workdir, name, start_point) + }) + .await + .map_err(|error| format!("git_create_worktree join 失败:{error}"))? +} #[tauri::command(rename_all = "snake_case")] pub async fn git_init( workdir: String, @@ -3424,6 +3691,20 @@ pub async fn git_delete_branch( .map_err(|error| format!("git_delete_branch join 失败:{error}"))? } +#[tauri::command(rename_all = "snake_case")] +pub async fn git_remove_worktree( + workdir: String, + worktree_path: String, + force: Option, + delete_branch: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + git_remove_worktree_sync(workdir, worktree_path, force, delete_branch) + }) + .await + .map_err(|error| format!("git_remove_worktree join 失败:{error}"))? +} + #[tauri::command(rename_all = "snake_case")] pub async fn git_rename_branch( workdir: String, @@ -4550,6 +4831,222 @@ mod tests { assert_eq!(branch_head, initial_sha); } + #[test] + fn git_create_worktree_uses_liveagent_layout_and_checks_out_branch() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let initial_branch = git_success(&workdir, &["branch", "--show-current"]) + .expect("read initial branch") + .stdout + .trim() + .to_string(); + + let created = git_create_worktree_in_base( + workdir.clone(), + "feature-alpha".to_string(), + None, + worktree_root.path(), + ) + .expect("create worktree"); + assert!(created.ok, "create worktree failed: {}", created.message); + + // 路径布局:// + let repo_root = + fs::canonicalize(git_status_sync(workdir.clone()).expect("status").repo_root) + .expect("canonicalize repo root"); + let expected = fs::canonicalize( + worktree_root + .path() + .join(repo_worktree_id(&repo_root.to_string_lossy())) + .join("feature-alpha"), + ) + .expect("resolve expected worktree path"); + assert_eq!(PathBuf::from(&created.worktree_path), expected); + assert!(expected.is_dir(), "worktree directory should exist"); + + // 新 worktree 检出到同名新分支 + let branch = git_success(&created.worktree_path, &["branch", "--show-current"]) + .expect("branch of worktree"); + assert_eq!(branch.stdout.trim(), "feature-alpha"); + // 原仓库留在原分支 + assert_eq!(created.state.head, initial_branch); + } + + #[test] + fn git_create_worktree_can_start_from_another_branch() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + + run_temp_git(repo.path(), &["checkout", "-b", "second"]); + fs::write(repo.path().join("second.txt"), "second\n").expect("write second file"); + run_temp_git(repo.path(), &["add", "second.txt"]); + run_temp_git(repo.path(), &["commit", "-m", "second"]); + run_temp_git(repo.path(), &["checkout", "-"]); + + let created = git_create_worktree_in_base( + workdir.clone(), + "from-second".to_string(), + Some("second".to_string()), + worktree_root.path(), + ) + .expect("create worktree from branch"); + assert!(created.ok, "create worktree failed: {}", created.message); + + let branch = git_success(&created.worktree_path, &["branch", "--show-current"]) + .expect("branch of worktree"); + assert_eq!(branch.stdout.trim(), "from-second"); + let log = git_success(&created.worktree_path, &["log", "--oneline", "-1"]) + .expect("worktree head"); + assert!( + log.stdout.contains("second"), + "worktree should start from second commit: {}", + log.stdout + ); + } + + #[test] + fn git_create_worktree_rejects_duplicate_name() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + + let first = git_create_worktree_in_base( + workdir.clone(), + "dup-alpha".to_string(), + None, + worktree_root.path(), + ) + .expect("first create"); + assert!(first.ok, "first create failed: {}", first.message); + + let second = git_create_worktree_in_base( + workdir.clone(), + "dup-alpha".to_string(), + None, + worktree_root.path(), + ); + assert!(second.is_err(), "duplicate worktree name must be rejected"); + } + + #[test] + fn git_create_worktree_rejects_invalid_names() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + + for invalid in ["", "a/b", "a\\b", "..", ".", "bad name", "HEAD"] { + let result = git_create_worktree_in_base( + workdir.clone(), + invalid.to_string(), + None, + worktree_root.path(), + ); + assert!(result.is_err(), "invalid name {invalid:?} must fail"); + } + } + + #[test] + fn git_worktrees_lists_created_worktree_branches() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let created = git_create_worktree_in_base( + workdir.clone(), + "wt-alpha".to_string(), + None, + worktree_root.path(), + ) + .expect("create worktree"); + assert!(created.ok, "create worktree failed: {}", created.message); + + let state = git_status_sync(workdir.clone()).expect("status"); + let worktrees = git_worktrees_sync(&state.repo_root).expect("worktree list"); + let wt = worktrees + .iter() + .find(|info| info.path == created.worktree_path) + .expect("created worktree listed"); + assert_eq!(wt.branch, "wt-alpha"); + } + + #[test] + fn git_remove_worktree_removes_worktree_and_branch() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let created = git_create_worktree_in_base( + workdir.clone(), + "wt-remove".to_string(), + None, + worktree_root.path(), + ) + .expect("create worktree"); + assert!(created.ok, "create worktree failed: {}", created.message); + + let removed = git_remove_worktree_sync( + workdir.clone(), + created.worktree_path.clone(), + None, + Some("wt-remove".to_string()), + ) + .expect("remove worktree"); + assert!(removed.ok, "remove worktree failed: {}", removed.message); + assert!( + !PathBuf::from(&created.worktree_path).exists(), + "worktree directory should be gone" + ); + + // 分支应随之删除 + let state = git_status_sync(workdir.clone()).expect("status"); + let worktrees = git_worktrees_sync(&state.repo_root).expect("worktree list"); + assert!( + !worktrees + .iter() + .any(|info| info.path == created.worktree_path), + "worktree should be unregistered" + ); + let branches = git_branches_sync(workdir.clone()).expect("branches"); + assert!( + !branches + .branches + .iter() + .any(|branch| branch.full_name == "wt-remove"), + "branch should be deleted with the worktree" + ); + } + + #[test] + fn git_remove_worktree_rejects_unregistered_path() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let outside = tempfile::tempdir().expect("outside dir"); + let result = git_remove_worktree_sync( + workdir.clone(), + outside.path().to_string_lossy().to_string(), + None, + None, + ); + assert!( + result.is_err(), + "unregistered worktree path must be rejected" + ); + } + #[test] fn git_compare_commit_with_remote_uses_origin_fallback() { let Some(repo) = init_temp_repo() else { @@ -5024,6 +5521,114 @@ mod tests { ); } + #[test] + fn git_delete_branch_prunes_manually_deleted_worktree() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let worktree_path = worktree_root.path().join("stale-worktree"); + let worktree_path_str = worktree_path.to_string_lossy().into_owned(); + + run_temp_git( + repo.path(), + &[ + "worktree", + "add", + "-b", + "stale-worktree", + worktree_path_str.as_str(), + ], + ); + fs::remove_dir_all(&worktree_path).expect("manually delete worktree directory"); + + let stale_list = git_success( + repo.path().to_string_lossy().as_ref(), + &["worktree", "list", "--porcelain"], + ) + .expect("list stale worktrees"); + assert!( + stale_list.stdout.contains("refs/heads/stale-worktree"), + "stale worktree registration should exist before delete: {}", + stale_list.stdout + ); + + let deleted = git_delete_branch_sync(workdir, "stale-worktree".to_string(), None) + .expect("delete stale worktree branch"); + assert!( + deleted.ok, + "delete should prune stale worktree: {}", + deleted.message + ); + assert!( + !ref_exists( + repo.path().to_string_lossy().as_ref(), + "refs/heads/stale-worktree" + ), + "stale worktree branch should be deleted" + ); + + let pruned_list = git_success( + repo.path().to_string_lossy().as_ref(), + &["worktree", "list", "--porcelain"], + ) + .expect("list pruned worktrees"); + assert!( + !pruned_list.stdout.contains("refs/heads/stale-worktree"), + "stale worktree registration should be pruned: {}", + pruned_list.stdout + ); + } + + #[test] + fn git_delete_branch_keeps_existing_worktree_protected_when_forced() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let worktree_path = worktree_root.path().join("active-worktree"); + let worktree_path_str = worktree_path.to_string_lossy().into_owned(); + + run_temp_git( + repo.path(), + &[ + "worktree", + "add", + "-b", + "active-worktree", + worktree_path_str.as_str(), + ], + ); + + let refused = git_delete_branch_sync(workdir, "active-worktree".to_string(), Some(true)) + .expect("force delete active worktree branch"); + assert!(!refused.ok, "active worktree branch must remain protected"); + assert!( + worktree_path.is_dir(), + "active worktree directory should remain" + ); + assert!( + ref_exists( + repo.path().to_string_lossy().as_ref(), + "refs/heads/active-worktree" + ), + "active worktree branch should remain" + ); + + let worktree_list = git_success( + repo.path().to_string_lossy().as_ref(), + &["worktree", "list", "--porcelain"], + ) + .expect("list active worktrees"); + assert!( + worktree_list.stdout.contains("refs/heads/active-worktree"), + "active worktree registration should remain: {}", + worktree_list.stdout + ); + } + #[test] fn git_rename_branch_renames_local_and_current_branch() { let Some(repo) = init_temp_repo() else { diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index bc80581c1..b19e1f6bb 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -217,6 +217,8 @@ macro_rules! app_invoke_handler { commands::git::git_list_remote_branches, commands::git::git_switch_branch, commands::git::git_create_branch, + commands::git::git_create_worktree, + commands::git::git_remove_worktree, commands::git::git_diff, commands::git::git_log, commands::git::git_commit_details, diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 93a830a30..49d1be54b 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -55,6 +55,18 @@ export const translations: Record> = { "chat.recentConversation": "最近会话", "chat.workspaceSection": "工作空间", "chat.workspaceCreate": "新建工作空间", + "chat.workspaceAdd": "添加…", + "chat.workspaceUngrouped": "未分组", + "chat.workspaceGroupCreate": "新建分组", + "chat.workspaceGroupNamePlaceholder": "分组名称", + "chat.workspaceGroupRename": "重命名分组", + "chat.workspaceGroupDelete": "删除分组", + "chat.workspaceGroupDeleteConfirmTitle": "删除分组「{name}」?", + "chat.workspaceGroupDeleteConfirmDescription": "组内项目将回到未分组,项目本身不会被删除。", + "chat.workspaceGroupToggle": "展开/折叠分组", + "chat.workspaceGroupActions": "分组操作", + "chat.workspaceGroupMove": "移动到分组", + "chat.workspaceGroupUngroup": "移出分组", "chat.workspaceCreateDescription": "打开已有文件夹,或从远程 Git 仓库创建新的工作空间。", "chat.workspaceOpenFolder": "打开本地文件夹", "chat.workspaceOpenFolderDescription": "将已有文件夹添加为工作空间。", @@ -776,9 +788,24 @@ export const translations: Record> = { "git.branchSelector.deleteForceTitle": "分支尚未完全合并", "git.branchSelector.deleteForceDescription": "强制删除(-D)会丢弃仅存在于该分支上的提交。", "git.branchSelector.forceDelete": "强制删除", + "git.branchSelector.deleteWorktree": "删除 Worktree", + "git.branchSelector.deleteWorktreeConfirmTitle": "删除 Worktree「{path}」?", + "git.branchSelector.deleteWorktreeConfirmDescription": "将移除 Worktree 目录,并尝试删除其分支「{branch}」。", + "git.branchSelector.deleteWorktreeForceTitle": "Worktree 包含未提交改动", + "git.branchSelector.deleteWorktreeForceDescription": "强制移除(--force)会丢弃 Worktree 中的未提交改动。", + "git.branchSelector.forceRemoveWorktree": "强制移除", "git.branchSelector.moreActions": "更多操作", "git.branchSelector.stashPush": "暂存当前改动 (stash)", "git.branchSelector.stashPop": "恢复最近的 stash", + "git.branchSelector.createWorktree": "新建 Worktree", + "git.branchSelector.createWorktreeTitle": "新建 Worktree", + "git.branchSelector.worktreeDescription": "在独立目录检出仓库副本,可并行开发多个分支。", + "git.branchSelector.worktreeName": "Worktree 名称", + "git.branchSelector.worktreeNamePlaceholder": "feature-my-worktree", + "git.branchSelector.worktreeStartPoint": "基于分支", + "git.branchSelector.worktreeLocationHint": + "将保存到 ~/.liveagent/worktree 下的独立目录,创建后自动在侧边栏打开。", + "git.branchSelector.worktreeFailed": "创建 Worktree 失败", "projectTools.reorderTab": "调整标签排序", "projectTools.reorderTabHint": "拖动排序,或聚焦后按左右方向键移动", "projectTools.shell": "Shell", @@ -2329,6 +2356,19 @@ export const translations: Record> = { "chat.recentConversation": "Conversations", "chat.workspaceSection": "Workspaces", "chat.workspaceCreate": "New workspace", + "chat.workspaceAdd": "Add…", + "chat.workspaceUngrouped": "Ungrouped", + "chat.workspaceGroupCreate": "New Group", + "chat.workspaceGroupNamePlaceholder": "Group name", + "chat.workspaceGroupRename": "Rename group", + "chat.workspaceGroupDelete": "Delete group", + "chat.workspaceGroupDeleteConfirmTitle": 'Delete group "{name}"?', + "chat.workspaceGroupDeleteConfirmDescription": + "Projects in the group move back to ungrouped; the projects themselves are not deleted.", + "chat.workspaceGroupToggle": "Toggle group", + "chat.workspaceGroupActions": "Group actions", + "chat.workspaceGroupMove": "Move to group", + "chat.workspaceGroupUngroup": "Ungroup", "chat.workspaceCreateDescription": "Open an existing folder or create a new workspace from a remote Git repository.", "chat.workspaceOpenFolder": "Open local folder", @@ -3086,9 +3126,25 @@ export const translations: Record> = { "git.branchSelector.deleteForceDescription": "Force delete (-D) discards commits that only exist on this branch.", "git.branchSelector.forceDelete": "Force delete", + "git.branchSelector.deleteWorktree": "Delete Worktree", + "git.branchSelector.deleteWorktreeConfirmTitle": "Delete worktree \"{path}\"?", + "git.branchSelector.deleteWorktreeConfirmDescription": "Removes the worktree directory and tries to delete its branch \"{branch}\".", + "git.branchSelector.deleteWorktreeForceTitle": "Worktree contains uncommitted changes", + "git.branchSelector.deleteWorktreeForceDescription": "Force removal (--force) discards uncommitted changes in the worktree.", + "git.branchSelector.forceRemoveWorktree": "Force remove", "git.branchSelector.moreActions": "More actions", "git.branchSelector.stashPush": "Stash changes", "git.branchSelector.stashPop": "Pop latest stash", + "git.branchSelector.createWorktree": "Create Worktree", + "git.branchSelector.createWorktreeTitle": "Create Worktree", + "git.branchSelector.worktreeDescription": + "Check out a separate copy of the repository to work on multiple branches in parallel.", + "git.branchSelector.worktreeName": "Worktree name", + "git.branchSelector.worktreeNamePlaceholder": "feature-my-worktree", + "git.branchSelector.worktreeStartPoint": "Start point", + "git.branchSelector.worktreeLocationHint": + "Saved under ~/.liveagent/worktree in a separate directory and opened in the sidebar automatically.", + "git.branchSelector.worktreeFailed": "Failed to create worktree", "projectTools.reorderTab": "Reorder tab", "projectTools.reorderTabHint": "Drag to reorder, or focus and use Left/Right", "projectTools.shell": "Shell", diff --git a/crates/agent-gui/src/lib/git/tauriGitClient.ts b/crates/agent-gui/src/lib/git/tauriGitClient.ts index 5cf43cec0..6de477151 100644 --- a/crates/agent-gui/src/lib/git/tauriGitClient.ts +++ b/crates/agent-gui/src/lib/git/tauriGitClient.ts @@ -7,6 +7,7 @@ import { normalizeGitOperationResponse, normalizeGitRepositoryDiscovery, normalizeGitRepositoryState, + normalizeGitWorktreeResponse, } from "@liveagent/ui/lib/git/types"; import { invoke } from "@tauri-apps/api/core"; @@ -132,6 +133,23 @@ export const tauriGitClient: GitClient = { workdir, ); }, + async createWorktree(workdir, name, startPoint) { + return normalizeGitWorktreeResponse( + await invoke("git_create_worktree", { workdir, name, start_point: startPoint }), + workdir, + ); + }, + async removeWorktree(workdir, worktreePath, force, deleteBranch) { + return normalizeGitOperationResponse( + await invoke("git_remove_worktree", { + workdir, + worktree_path: worktreePath, + force, + delete_branch: deleteBranch, + }), + workdir, + ); + }, async stashPush(workdir, message) { return normalizeGitOperationResponse( await invoke("git_stash_push", { workdir, message }), diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index 5df702d3f..14b5d94a8 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -265,6 +265,7 @@ export type SystemSettings = { */ toolPolicies?: Record; workspaceProjects: WorkspaceProject[]; + workspaceProjectGroups: WorkspaceProjectGroup[]; activeWorkspaceProjectId?: string; hiddenWorkspaceProjectPaths: string[]; missingWorkspaceProjectPaths: string[]; @@ -296,6 +297,23 @@ export type EffectiveWorkspaceResources = { export type WorkspaceProjectKind = "managed" | "folder" | "history"; +/** + * 侧边栏项目分组。成员用原始路径存储(匹配时经 + * `workspaceProjectPathKey` 归一化),与 hidden/missing/archived 一致。 + * + * `sourceProjectPath` 标记自动分组(git worktree 聚合):指向原始仓库 + * 项目的路径,重命名分组后仍可据此复用,避免重复建组。 + */ +export type WorkspaceProjectGroup = { + id: string; + name: string; + projectPaths: string[]; + sourceProjectPath?: string; + collapsed?: boolean; + createdAt: number; + updatedAt: number; +}; + export type WorkspaceProject = { id: string; name: string; @@ -871,6 +889,54 @@ function normalizeWorkspaceProject(input: unknown): WorkspaceProject | null { }; } +function normalizeWorkspaceProjectGroups(input: unknown): WorkspaceProjectGroup[] { + if (!Array.isArray(input)) return []; + const out: WorkspaceProjectGroup[] = []; + const seenIds = new Set(); + for (const raw of input) { + const group = normalizeWorkspaceProjectGroup(raw); + if (!group) continue; + if (seenIds.has(group.id)) continue; + seenIds.add(group.id); + out.push(group); + } + return out; +} + +function normalizeWorkspaceProjectGroup(input: unknown): WorkspaceProjectGroup | null { + const obj = (input && typeof input === "object" ? input : {}) as Record; + const id = typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : createUuid(); + const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : "未命名分组"; + const projectPaths: string[] = []; + const seenPaths = new Set(); + for (const raw of normalizeStringArray(obj.projectPaths)) { + const path = normalizeWorkspaceProjectPath(raw); + if (!path) continue; + const key = workspaceProjectPathKey(path); + if (seenPaths.has(key)) continue; + seenPaths.add(key); + projectPaths.push(path); + } + const sourceProjectPath = normalizeWorkspaceProjectPath(obj.sourceProjectPath); + const createdAt = + typeof obj.createdAt === "number" && Number.isFinite(obj.createdAt) && obj.createdAt > 0 + ? obj.createdAt + : Date.now(); + const updatedAt = + typeof obj.updatedAt === "number" && Number.isFinite(obj.updatedAt) && obj.updatedAt > 0 + ? obj.updatedAt + : createdAt; + return { + id, + name, + projectPaths, + ...(sourceProjectPath ? { sourceProjectPath } : {}), + ...(obj.collapsed === true ? { collapsed: true } : {}), + createdAt, + updatedAt, + }; +} + function normalizeWorkspaceProjects(input: unknown): WorkspaceProject[] { if (!Array.isArray(input)) return []; const out: WorkspaceProject[] = []; @@ -1807,6 +1873,7 @@ export function normalizeSystemSettings(input: unknown): SystemSettings { workdir: normalizeWorkdir(obj.workdir), toolPolicies: normalizeToolPolicies(obj.toolPolicies), workspaceProjects: normalizeWorkspaceProjects(obj.workspaceProjects), + workspaceProjectGroups: normalizeWorkspaceProjectGroups(obj.workspaceProjectGroups), activeWorkspaceProjectId: typeof obj.activeWorkspaceProjectId === "string" && obj.activeWorkspaceProjectId.trim() ? obj.activeWorkspaceProjectId.trim() @@ -2467,6 +2534,7 @@ export function getDefaultSettings(): AppSettings { executionMode: "tools", workdir: "", workspaceProjects: [], + workspaceProjectGroups: [], activeWorkspaceProjectId: undefined, hiddenWorkspaceProjectPaths: [], missingWorkspaceProjectPaths: [], diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 2bc69452a..365f373c8 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -305,6 +305,13 @@ export function ChatPage(props: ChatPageProps) { handleOpenWorkspaceFolder, handleCloneWorkspaceProject, handleOpenClonedWorkspace, + handleOpenWorktree, + workspaceProjectGroups, + handleCreateWorkspaceGroup, + handleRenameWorkspaceGroup, + handleDeleteWorkspaceGroup, + handleMoveWorkspaceProjectToGroup, + handleToggleWorkspaceGroupCollapsed, handleLoadWorkspaceRemoteBranches, handleStartRenamingWorkspaceProject, handleCommitWorkspaceProjectRename, @@ -1845,6 +1852,7 @@ export function ChatPage(props: ChatPageProps) { activeView={activeView} showProjects={isAgentMode} projects={workspaceProjects} + workspaceProjectGroups={workspaceProjectGroups} activeProjectId={activeWorkspaceProject?.id} missingProjectPathKeys={missingWorkspaceProjectPathKeys} projectRenamingId={projectRenamingId} @@ -1854,6 +1862,11 @@ export function ChatPage(props: ChatPageProps) { onProjectsCollapsedChange={handleSidebarProjectsCollapsedChange} onRecentCollapsedChange={handleSidebarRecentCollapsedChange} onCreateProject={handleOpenCreateWorkspaceProject} + onCreateWorkspaceGroup={handleCreateWorkspaceGroup} + onRenameWorkspaceGroup={handleRenameWorkspaceGroup} + onDeleteWorkspaceGroup={handleDeleteWorkspaceGroup} + onMoveProjectToGroup={handleMoveWorkspaceProjectToGroup} + onToggleWorkspaceGroupCollapsed={handleToggleWorkspaceGroupCollapsed} onSelectProject={handleSelectWorkspaceProject} onNewConversationForProject={handleNewConversationForProject} onBrowseProjectInFileTree={handleBrowseWorkspaceProjectInFileTree} @@ -2065,6 +2078,7 @@ export function ChatPage(props: ChatPageProps) { thinkingAlwaysOn={chatRuntimeThinkingAlwaysOn} gitClient={tauriGitClient} workspaceActivityClient={tauriWorkspaceActivityClient} + onOpenWorktree={handleOpenWorktree} onSend={handleSend} onStop={handleStopSending} onComposerBusyChange={handleComposerBusyChange} diff --git a/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx b/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx index 3ad8cf280..23d6679b2 100644 --- a/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx +++ b/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx @@ -43,6 +43,7 @@ type ChatSidebarContainerProps = { // Merged (settings ∪ history workdirs) but unsorted — the container sorts // with the store's activity/running inputs. projects: WorkspaceProject[]; + workspaceProjectGroups: WorkspaceProjectGroup[]; activeProjectId?: string; missingProjectPathKeys: ReadonlySet; projectRenamingId: string | null; @@ -52,6 +53,11 @@ type ChatSidebarContainerProps = { onProjectsCollapsedChange: (collapsed: boolean) => void; onRecentCollapsedChange: (collapsed: boolean) => void; onCreateProject: () => void; + onCreateWorkspaceGroup: (name: string) => void; + onRenameWorkspaceGroup: (groupId: string, name: string) => void; + onDeleteWorkspaceGroup: (groupId: string) => void; + onMoveProjectToGroup: (projectPath: string, groupId: string | null) => void; + onToggleWorkspaceGroupCollapsed: (groupId: string) => void; onSelectProject: (project: WorkspaceProject) => void; onNewConversationForProject: (project: WorkspaceProject) => void; onBrowseProjectInFileTree: (project: WorkspaceProject) => void; @@ -239,6 +245,7 @@ export function ChatSidebarContainer(props: ChatSidebarContainerProps) { activeView={props.activeView} showProjects={props.showProjects} projects={sortedProjects} + workspaceProjectGroups={props.workspaceProjectGroups} activeProjectId={props.activeProjectId} missingProjectPathKeys={props.missingProjectPathKeys} runningProjectPathKeys={projectActivityInputs.runningWorkdirPathKeys} @@ -249,6 +256,11 @@ export function ChatSidebarContainer(props: ChatSidebarContainerProps) { onProjectsCollapsedChange={props.onProjectsCollapsedChange} onRecentCollapsedChange={props.onRecentCollapsedChange} onCreateProject={props.onCreateProject} + onCreateWorkspaceGroup={props.onCreateWorkspaceGroup} + onRenameWorkspaceGroup={props.onRenameWorkspaceGroup} + onDeleteWorkspaceGroup={props.onDeleteWorkspaceGroup} + onMoveProjectToGroup={props.onMoveProjectToGroup} + onToggleWorkspaceGroupCollapsed={props.onToggleWorkspaceGroupCollapsed} onSelectProject={props.onSelectProject} onNewConversationForProject={props.onNewConversationForProject} onBrowseProjectInFileTree={props.onBrowseProjectInFileTree} diff --git a/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts b/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts index 9d739e7c1..7648743cc 100644 --- a/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts +++ b/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts @@ -25,8 +25,22 @@ import { resolveWorkspaceProjects, updateCustomSettings, type WorkspaceProject, + type WorkspaceProjectGroup, workspaceProjectPathKey, } from "../../../lib/settings"; +import { createUuid } from "@liveagent/ui/lib/shared/id"; +import { sidebarScopeKey } from "@liveagent/ui/lib/sidebar/scope"; +import type { SidebarStore } from "@liveagent/ui/lib/sidebar/store"; +import type { SidebarScope } from "@liveagent/ui/lib/sidebar/types"; +import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector"; +import { invokeFs } from "@liveagent/ui/lib/tools/fsBackend"; +import { + assignWorkspaceProjectToGroup, + ensureWorktreeProjectGroup, + fallbackWorkspaceProjectName, + findWorkspaceProject, + mergeWorkspaceProjectsWithHistory, +} from "@liveagent/ui/lib/workspaceProjects"; import { asErrorMessage } from "../chatPageUtils"; import { startWorkspaceCloneTask } from "./cloneTasks"; import { @@ -377,6 +391,59 @@ export function useWorkspaceProjects(params: UseWorkspaceProjectsParams) { [activateWorkspaceProject], ); + // Git worktree 派生工作区:添加并激活后,自动归入以原始仓库项目命名 + // 的自动分组(原项目与 worktree 同组)。从 worktree 再创建 worktree 时, + // 通过当前自动分组的 sourceProjectPath 追溯原始仓库。 + const activeProjectPathKey = activeWorkspaceProject + ? workspaceProjectPathKey(activeWorkspaceProject.path) + : ""; + const handleOpenWorktree = useCallback( + (path: string) => { + const trimmed = path.trim(); + const worktreeKey = workspaceProjectPathKey(trimmed); + if (!trimmed || !worktreeKey || !activeWorkspaceProject) return; + const nextProject = createWorkspaceProjectFromPath(trimmed, "managed"); + activateWorkspaceProject(nextProject); + setSettings((prev) => { + // 当前项目是否位于某个自动分组 → 溯源到原始仓库路径 + const autoGroup = prev.system.workspaceProjectGroups.find( + (group) => + group.sourceProjectPath && + group.projectPaths.some( + (memberPath) => workspaceProjectPathKey(memberPath) === activeProjectPathKey, + ), + ); + const sourcePath = autoGroup?.sourceProjectPath ?? activeWorkspaceProject.path; + const sourceProject = prev.system.workspaceProjects.find( + (item) => workspaceProjectPathKey(item.path) === workspaceProjectPathKey(sourcePath), + ); + const groupName = sourceProject?.name || fallbackWorkspaceProjectName(sourcePath); + const ensured = ensureWorktreeProjectGroup(prev.system.workspaceProjectGroups, { + name: groupName, + sourceProjectPath: sourcePath, + }); + let workspaceProjectGroups = assignWorkspaceProjectToGroup( + ensured.groups, + ensured.groupId, + sourcePath, + ); + workspaceProjectGroups = assignWorkspaceProjectToGroup( + workspaceProjectGroups, + ensured.groupId, + trimmed, + ); + return { + ...prev, + system: { + ...prev.system, + workspaceProjectGroups, + }, + }; + }); + }, + [activateWorkspaceProject, activeProjectPathKey, activeWorkspaceProject, setSettings], + ); + const handleLoadWorkspaceRemoteBranches = useCallback( (remoteUrl: string) => invoke<{ defaultBranch: string; branches: string[] }>("git_list_remote_branches", { @@ -384,6 +451,95 @@ export function useWorkspaceProjects(params: UseWorkspaceProjectsParams) { }), [], ); + + const updateWorkspaceProjectGroups = useCallback( + (updater: (groups: WorkspaceProjectGroup[]) => WorkspaceProjectGroup[]) => { + setSettings((prev) => { + const next = updater(prev.system.workspaceProjectGroups); + if (next === prev.system.workspaceProjectGroups) return prev; + return { ...prev, system: { ...prev.system, workspaceProjectGroups: next } }; + }); + }, + [setSettings], + ); + + const handleCreateWorkspaceGroup = useCallback( + (nameInput: string) => { + const name = nameInput.trim(); + if (!name) return; + const now = Date.now(); + updateWorkspaceProjectGroups((groups) => [ + ...groups, + { + id: createUuid(), + name, + projectPaths: [], + createdAt: now, + updatedAt: now, + }, + ]); + }, + [updateWorkspaceProjectGroups], + ); + + const handleRenameWorkspaceGroup = useCallback( + (groupId: string, nameInput: string) => { + const name = nameInput.trim(); + if (!name) return; + updateWorkspaceProjectGroups((groups) => + groups.map((group) => + group.id === groupId ? { ...group, name, updatedAt: Date.now() } : group, + ), + ); + }, + [updateWorkspaceProjectGroups], + ); + + const handleDeleteWorkspaceGroup = useCallback( + (groupId: string) => { + // 删除分组只解除成员归属,项目保留在列表中。 + updateWorkspaceProjectGroups((groups) => groups.filter((group) => group.id !== groupId)); + }, + [updateWorkspaceProjectGroups], + ); + + const handleMoveWorkspaceProjectToGroup = useCallback( + (projectPath: string, groupId: string | null) => { + const pathKey = workspaceProjectPathKey(projectPath); + if (!pathKey) return; + updateWorkspaceProjectGroups((groups) => { + if (groupId === null) { + // 移出所有分组 + return groups.map((group) => + group.projectPaths.some((path) => workspaceProjectPathKey(path) === pathKey) + ? { + ...group, + updatedAt: Date.now(), + projectPaths: group.projectPaths.filter( + (path) => workspaceProjectPathKey(path) !== pathKey, + ), + } + : group, + ); + } + return assignWorkspaceProjectToGroup(groups, groupId, projectPath); + }); + }, + [updateWorkspaceProjectGroups], + ); + + const handleToggleWorkspaceGroupCollapsed = useCallback( + (groupId: string) => { + updateWorkspaceProjectGroups((groups) => + groups.map((group) => + group.id === groupId + ? { ...group, collapsed: !group.collapsed, updatedAt: Date.now() } + : group, + ), + ); + }, + [updateWorkspaceProjectGroups], + ); const commitWorkspaceProjectRename = useCallback( (project: WorkspaceProject, nextNameInput: string) => { if (project.id === DEFAULT_WORKSPACE_PROJECT_ID) return; @@ -550,6 +706,13 @@ export function useWorkspaceProjects(params: UseWorkspaceProjectsParams) { handleOpenWorkspaceFolder, handleCloneWorkspaceProject, handleOpenClonedWorkspace, + handleOpenWorktree, + workspaceProjectGroups: settings.system.workspaceProjectGroups, + handleCreateWorkspaceGroup, + handleRenameWorkspaceGroup, + handleDeleteWorkspaceGroup, + handleMoveWorkspaceProjectToGroup, + handleToggleWorkspaceGroupCollapsed, handleLoadWorkspaceRemoteBranches, handleStartRenamingWorkspaceProject, handleCommitWorkspaceProjectRename, diff --git a/crates/agent-gui/test/settings/workspace-project-parent.test.mjs b/crates/agent-gui/test/settings/workspace-project-parent.test.mjs new file mode 100644 index 000000000..610b6b6f7 --- /dev/null +++ b/crates/agent-gui/test/settings/workspace-project-parent.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const settings = loader.loadModule("src/lib/settings/index.ts"); + +test("normalizeSystemSettings keeps workspace project groups", () => { + const normalized = settings.normalizeSystemSettings({ + workspaceProjectGroups: [ + { + id: "g1", + name: "Repo", + projectPaths: ["/work/repo", "/work/repo/worktrees/a"], + sourceProjectPath: "/work/repo", + collapsed: true, + createdAt: 100, + updatedAt: 100, + }, + ], + }); + assert.equal(normalized.workspaceProjectGroups.length, 1); + assert.equal(normalized.workspaceProjectGroups[0].name, "Repo"); + assert.deepEqual(normalized.workspaceProjectGroups[0].projectPaths, [ + "/work/repo", + "/work/repo/worktrees/a", + ]); + assert.equal(normalized.workspaceProjectGroups[0].sourceProjectPath, "/work/repo"); + assert.equal(normalized.workspaceProjectGroups[0].collapsed, true); +}); + +test("normalizeSystemSettings drops invalid group entries and dedupes paths", () => { + const normalized = settings.normalizeSystemSettings({ + workspaceProjectGroups: [ + { + id: "g1", + name: "Group", + projectPaths: ["/work/a", "/work/a/", " ", 42], + createdAt: 100, + updatedAt: 100, + }, + { id: "g1", name: "Duplicate id", projectPaths: [], createdAt: 100, updatedAt: 100 }, + { name: "Missing id", projectPaths: [], createdAt: 100, updatedAt: 100 }, + ], + }); + assert.equal(normalized.workspaceProjectGroups.length, 2); + assert.deepEqual(normalized.workspaceProjectGroups[0].projectPaths, ["/work/a"]); +}); + +test("legacy settings without groups normalize to an empty list", () => { + const normalized = settings.normalizeSystemSettings({}); + assert.deepEqual(normalized.workspaceProjectGroups, []); +}); diff --git a/crates/agent-gui/test/tools/workspace-project-groups.test.mjs b/crates/agent-gui/test/tools/workspace-project-groups.test.mjs new file mode 100644 index 000000000..24aa1af5a --- /dev/null +++ b/crates/agent-gui/test/tools/workspace-project-groups.test.mjs @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const { + assignWorkspaceProjectToGroup, + ensureWorktreeProjectGroup, + buildWorkspaceProjectSections, + sliceWorkspaceProjectSections, +} = loader.loadModule("src/lib/workspaceProjects.ts"); + +function project(id, path, extra = {}) { + return { + id, + name: id, + path, + kind: "managed", + createdAt: 1, + updatedAt: 1, + ...extra, + }; +} + +function group(id, name, projectPaths = [], extra = {}) { + return { + id, + name, + projectPaths, + createdAt: 1, + updatedAt: 1, + ...extra, + }; +} + +test("assignWorkspaceProjectToGroup moves a project between groups", () => { + const groups = [ + group("g1", "Alpha", ["/work/a"]), + group("g2", "Beta", ["/work/b"]), + ]; + const next = assignWorkspaceProjectToGroup(groups, "g2", "/work/a"); + assert.deepEqual( + next.map((g) => [g.id, g.projectPaths]), + [ + ["g1", []], + ["g2", ["/work/b", "/work/a"]], + ], + ); +}); + +test("assignWorkspaceProjectToGroup is idempotent for the target group", () => { + const groups = [group("g1", "Alpha", ["/work/a"])]; + const next = assignWorkspaceProjectToGroup(groups, "g1", "/work/a"); + assert.deepEqual(next.map((g) => [g.id, g.projectPaths]), [["g1", ["/work/a"]]]); +}); + +test("ensureWorktreeProjectGroup reuses the group by sourceProjectPath after rename", () => { + const renamed = group("g1", "用户改名后的组", ["/work/repo"], { + sourceProjectPath: "/work/repo", + }); + const ensured = ensureWorktreeProjectGroup([renamed], { + name: "repo", + sourceProjectPath: "/work/repo", + }); + assert.equal(ensured.groupId, "g1"); + assert.equal(ensured.groups.length, 1); +}); + +test("ensureWorktreeProjectGroup creates a new group for a new source", () => { + const ensured = ensureWorktreeProjectGroup([], { + name: "repo", + sourceProjectPath: "/work/repo", + }); + assert.ok(ensured.groupId); + assert.equal(ensured.groups.length, 1); + assert.equal(ensured.groups[0].name, "repo"); + assert.equal(ensured.groups[0].sourceProjectPath, "/work/repo"); +}); + +test("buildWorkspaceProjectSections groups members under their section", () => { + const repo = project("repo", "/work/repo"); + const worktree = project("wt", "/work/wt"); + const other = project("other", "/work/other"); + const sections = buildWorkspaceProjectSections( + [repo, worktree, other], + [group("g1", "repo", ["/work/repo", "/work/wt"])], + ); + assert.deepEqual( + sections.grouped.map((s) => [s.group.id, s.projects.map((p) => p.id)]), + [["g1", ["repo", "wt"]]], + ); + assert.deepEqual(sections.ungrouped.map((p) => p.id), ["other"]); +}); + +test("buildWorkspaceProjectSections orders sections by earliest member index", () => { + const repo = project("repo", "/work/repo"); + const activeWt = project("wt", "/work/wt"); + const middle = project("middle", "/work/middle"); + // 子项目更活跃 → 输入列表中下标更小 → 整组提前 + const sections = buildWorkspaceProjectSections( + [activeWt, repo, middle], + [ + group("g1", "repo", ["/work/repo", "/work/wt"]), + group("g2", "middle", ["/work/middle"]), + ], + ); + assert.deepEqual( + sections.grouped.map((s) => s.group.id), + ["g1", "g2"], + ); +}); + +test("buildWorkspaceProjectSections ignores members missing from the list", () => { + const repo = project("repo", "/work/repo"); + const sections = buildWorkspaceProjectSections( + [repo], + [group("g1", "repo", ["/work/repo", "/gone/worktree"])], + ); + assert.deepEqual(sections.grouped[0].projects.map((p) => p.id), ["repo"]); +}); + +test("sliceWorkspaceProjectSections never splits a group", () => { + const repo = project("repo", "/work/repo"); + const wt = project("wt", "/work/wt"); + const a = project("a", "/work/a"); + const b = project("b", "/work/b"); + const sections = buildWorkspaceProjectSections( + [repo, wt, a, b], + [ + group("g1", "repo", ["/work/repo", "/work/wt"]), + group("g2", "a", ["/work/a"]), + group("g3", "b", ["/work/b"]), + ], + ); + const sliced = sliceWorkspaceProjectSections(sections, 1); + assert.equal(sliced.sections.grouped.length, 1); + assert.equal(sliced.sections.grouped[0].projects.length, 2); + assert.equal(sliced.hiddenProjectCount, 2); +}); + +test("single project belongs to exactly one group at a time", () => { + const groups = [ + group("g1", "Alpha", ["/work/a"]), + group("g2", "Beta", []), + ]; + const moved = assignWorkspaceProjectToGroup(groups, "g2", "/work/a"); + const counts = moved.map( + (g) => g.projectPaths.filter((p) => p === "/work/a").length, + ); + assert.deepEqual(counts, [0, 1]); +}); diff --git a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx index 3793155ed..f478dd0b4 100644 --- a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx @@ -28,6 +28,7 @@ import { import { DEFAULT_WORKSPACE_PROJECT_ID, type WorkspaceProject, + type WorkspaceProjectGroup, workspaceProjectPathKey, } from "@liveagent/app/lib/settings"; import { Button } from "@liveagent/ui/components/ui/button"; @@ -68,6 +69,10 @@ import { useState, } from "react"; import type { SidebarConversation } from "../../lib/sidebar/types"; +import { + buildWorkspaceProjectSections, + sliceWorkspaceProjectSections, +} from "../../lib/workspaceProjects"; export type ChatHistorySidebarListStatus = "initial" | "loading" | "syncing" | "ready"; export type ChatHistorySidebarMutationKind = "rename" | "pin" | "move" | "delete"; @@ -103,6 +108,9 @@ type ChatHistorySidebarProps = { showProjects?: boolean; // Pre-sorted by the container (pinned/running/activity); rendered as-is. projects?: WorkspaceProject[]; + // Sidebar project groups; worktree projects are auto-grouped under their + // source repository project. + workspaceProjectGroups?: WorkspaceProjectGroup[]; activeProjectId?: string; missingProjectPathKeys: ReadonlySet; runningProjectPathKeys: ReadonlySet; @@ -113,6 +121,11 @@ type ChatHistorySidebarProps = { onProjectsCollapsedChange?: (collapsed: boolean) => void; onRecentCollapsedChange?: (collapsed: boolean) => void; onCreateProject?: () => void; + onCreateWorkspaceGroup?: (name: string) => void; + onRenameWorkspaceGroup?: (groupId: string, name: string) => void; + onDeleteWorkspaceGroup?: (groupId: string) => void; + onMoveProjectToGroup?: (projectPath: string, groupId: string | null) => void; + onToggleWorkspaceGroupCollapsed?: (groupId: string) => void; onSelectProject?: (project: WorkspaceProject) => void; onNewConversationForProject?: (project: WorkspaceProject) => void; onBrowseProjectInFileTree?: (project: WorkspaceProject) => void; @@ -181,9 +194,7 @@ const SIDEBAR_RECENT_MIN_BODY_HEIGHT = 160; // share so the recent section sits a little higher and gets a little more room. const SIDEBAR_PROJECTS_BODY_DEFAULT_RATIO = 0.5; const SIDEBAR_MOBILE_PROJECTS_BODY_DEFAULT_RATIO = 0.4; -// Projects are not virtualized; cap the rendered rows and offer an explicit -// "show all (N)" expansion instead. -const SIDEBAR_PROJECT_RENDER_CAP = 30; +const PROJECT_LIST_COLLAPSED_MAX = 30; const EMPTY_PROJECT_PATH_KEYS = new Set(); const HISTORY_LOADING_SKELETON_ROWS = [ { title: "w-36", meta: "w-20" }, @@ -964,6 +975,109 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { ); }, areHistoryRowPropsEqual); +// 项目分组标题行:折叠切换、成员计数、重命名与删除。 +function ProjectGroupHeader(props: { + group: WorkspaceProjectGroup; + memberCount: number; + isRenaming: boolean; + renameDraft: string; + onRenameDraftChange: (value: string) => void; + onCommitRename: () => void; + onCancelRename: () => void; + onToggleCollapsed: () => void; + onStartRename: () => void; + onDelete: () => void; +}) { + const { + group, + memberCount, + isRenaming, + renameDraft, + onRenameDraftChange, + onCommitRename, + onCancelRename, + onToggleCollapsed, + onStartRename, + onDelete, + } = props; + const { t } = useLocale(); + + if (isRenaming) { + return ( +
+ onRenameDraftChange(event.currentTarget.value)} + onBlur={onCommitRename} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + onCommitRename(); + } else if (event.key === "Escape") { + event.preventDefault(); + onCancelRename(); + } + }} + className="h-7 min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 text-[calc(13px*var(--zone-font-scale,1))] font-semibold shadow-none outline-none focus-visible:border-0 focus-visible:bg-transparent" + autoFocus + /> +
+ ); + } + + return ( +
+ + + + } + aria-label={t("chat.workspaceGroupActions")} + title={t("chat.workspaceGroupActions")} + > + + + + + + {t("chat.workspaceGroupRename")} + + + + {t("chat.workspaceGroupDelete")} + + + +
+ ); +} + const ProjectRow = memo(function ProjectRow(props: { project: WorkspaceProject; isActive: boolean; @@ -991,6 +1105,11 @@ const ProjectRow = memo(function ProjectRow(props: { onArchiveProject: (project: WorkspaceProject) => void; onUnarchiveProject: (project: WorkspaceProject) => void; onSetPendingRemove: (projectId: string | null) => void; + // 分组内的项目行:相对组头缩进,形成层级视觉。 + indented?: boolean; + // 分组归属:菜单中提供“移动到分组”子菜单。 + workspaceProjectGroups?: WorkspaceProjectGroup[]; + onMoveProjectToGroup?: (projectPath: string, groupId: string | null) => void; menuOpen: boolean; onMenuOpenChange: (projectId: string, open: boolean) => void; }) { @@ -1018,6 +1137,9 @@ const ProjectRow = memo(function ProjectRow(props: { onArchiveProject, onUnarchiveProject, onSetPendingRemove, + indented = false, + workspaceProjectGroups = [], + onMoveProjectToGroup, menuOpen, onMenuOpenChange, } = props; @@ -1030,6 +1152,11 @@ const ProjectRow = memo(function ProjectRow(props: { const suppressMenuReturnFocusRef = useRef(false); const isDefaultProject = project.id === DEFAULT_WORKSPACE_PROJECT_ID; const isPinned = project.isPinned === true; + const currentGroupId = workspaceProjectGroups.find((group) => + group.projectPaths.some( + (path) => workspaceProjectPathKey(path) === workspaceProjectPathKey(project.path), + ), + )?.id; const ProjectFolderIcon = isActive ? FolderOpen : FolderClosed; useEffect(() => { @@ -1150,6 +1277,7 @@ const ProjectRow = memo(function ProjectRow(props: { ref={rowRef} className={cn( "group/project grid h-[30px] grid-cols-[minmax(0,1fr)_auto] items-center rounded-lg pl-1 transition-colors", + indented && "pl-5", isMissing ? "text-destructive hover:bg-destructive/10" : isArchived @@ -1430,6 +1558,45 @@ const ProjectRow = memo(function ProjectRow(props: { {t("chat.workspaceArchive")} ) : null} + {onMoveProjectToGroup ? ( + + + + {t("chat.workspaceGroupMove")} + + + {workspaceProjectGroups.map((group) => ( + onMoveProjectToGroup(project.path, group.id)} + className="gap-2 text-xs" + > + {group.id === currentGroupId ? ( + + ) : ( + + )} + {group.name} + + ))} + {currentGroupId ? ( + onMoveProjectToGroup(project.path, null)} + className="gap-2 text-xs" + > + + {t("chat.workspaceGroupUngroup")} + + ) : null} + + + ) : null} {isArchived ? ( { - if (!sectionsDisabled) { - onCreateProject?.(); - } - }); const handleShowAllProjects = useStableEvent(() => { if (!sectionsDisabled) { setShowAllProjects((current) => !current); @@ -1937,24 +2105,84 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi ), [archivedProjectPathKeys, projects], ); - // Projects arrive pre-sorted from the container; only the render cap is - // applied here. - const renderedProjects = useMemo( - () => (showAllProjects ? activeProjects : activeProjects.slice(0, SIDEBAR_PROJECT_RENDER_CAP)), - [activeProjects, showAllProjects], + // Projects arrive pre-sorted from the container; the view organizes them + // into group sections (worktree projects auto-grouped under their source + // repository) plus the ungrouped remainder. The collapsed view slices by + // section so a group is never split. + const projectSections = useMemo( + () => buildWorkspaceProjectSections(activeProjects, workspaceProjectGroups ?? []), + [activeProjects, workspaceProjectGroups], ); + const slicedSections = useMemo( + () => + showAllProjects + ? { sections: projectSections, hiddenProjectCount: 0 } + : sliceWorkspaceProjectSections(projectSections, PROJECT_LIST_COLLAPSED_MAX), + [projectSections, showAllProjects], + ); + const renderedSections = slicedSections.sections; + const hiddenProjectCount = slicedSections.hiddenProjectCount; // Divider slot between the pinned block and the rest of the projects. - const firstUnpinnedProjectIndex = useMemo(() => { - if (renderedProjects[0]?.isPinned !== true) { + // The first section's first member determines pinned placement; a pinned or + // running member promotes its whole section via the earliest sorted index. + const firstUnpinnedSectionIndex = useMemo(() => { + const firstMember = renderedSections.grouped[0]?.projects[0] ?? renderedSections.ungrouped[0]; + if (firstMember?.isPinned !== true) { return -1; } - const index = renderedProjects.findIndex((project) => project.isPinned !== true); - return index > 0 ? index : -1; - }, [renderedProjects]); + const groupedIndex = renderedSections.grouped.findIndex( + (section) => section.projects[0]?.isPinned !== true, + ); + if (groupedIndex > 0) return groupedIndex; + if (renderedSections.ungrouped[0]?.isPinned === true) return -1; + return renderedSections.grouped.length; + }, [renderedSections]); // Archiving must always leave at least one active workspace behind. const canArchiveProjects = Boolean(onArchiveProject) && activeProjects.length > 1; const [archivedGroupOpen, setArchivedGroupOpen] = useState(false); - const hasCappedProjects = activeProjects.length > SIDEBAR_PROJECT_RENDER_CAP; + const [creatingGroup, setCreatingGroup] = useState(false); + const [groupDraft, setGroupDraft] = useState(""); + const [renamingGroupId, setRenamingGroupId] = useState(null); + const [groupRenameDraft, setGroupRenameDraft] = useState(""); + const { confirm: requestGroupDeleteConfirm, dialog: groupDeleteDialog } = useConfirmDialog(); + + const commitNewGroup = useCallback(() => { + const name = groupDraft.trim(); + if (name) onCreateWorkspaceGroup?.(name); + setCreatingGroup(false); + setGroupDraft(""); + }, [groupDraft, onCreateWorkspaceGroup]); + + const cancelNewGroup = useCallback(() => { + setCreatingGroup(false); + setGroupDraft(""); + }, []); + + const commitGroupRename = useCallback(() => { + const name = groupRenameDraft.trim(); + if (renamingGroupId && name) onRenameWorkspaceGroup?.(renamingGroupId, name); + setRenamingGroupId(null); + setGroupRenameDraft(""); + }, [groupRenameDraft, onRenameWorkspaceGroup, renamingGroupId]); + + const cancelGroupRename = useCallback(() => { + setRenamingGroupId(null); + setGroupRenameDraft(""); + }, []); + + const requestDeleteGroup = useCallback( + async (group: WorkspaceProjectGroup) => { + const confirmed = await requestGroupDeleteConfirm({ + title: t("chat.workspaceGroupDeleteConfirmTitle").replace("{name}", group.name), + description: t("chat.workspaceGroupDeleteConfirmDescription"), + confirmLabel: t("chat.workspaceGroupDelete"), + cancelLabel: t("chat.cancel"), + tone: "destructive", + }); + if (confirmed) onDeleteWorkspaceGroup?.(group.id); + }, + [onDeleteWorkspaceGroup, requestGroupDeleteConfirm, t], + ); const sidebarSectionLayout = useMemo(() => { const { containerHeight, @@ -2591,18 +2819,49 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi style={{ transform: `rotate(${projectsCollapsed ? 0 : 90}deg)` }} /> - + + + } + > + + + + onCreateProject?.()} + className="gap-2 text-xs" + > + + {t("chat.workspaceCreate")} + + { + setCreatingGroup(true); + setGroupDraft(""); + }} + className="gap-2 text-xs" + > + + {t("chat.workspaceGroupCreate")} + + +
- {renderedProjects.map((project, projectIndex) => { - const pathKey = workspaceProjectPathKey(project.path); + {creatingGroup ? ( +
+ + setGroupDraft(event.currentTarget.value)} + onBlur={commitNewGroup} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commitNewGroup(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelNewGroup(); + } + }} + placeholder={t("chat.workspaceGroupNamePlaceholder")} + className="h-7 min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 text-[calc(13px*var(--zone-font-scale,1))] shadow-none outline-none focus-visible:border-0 focus-visible:bg-transparent" + autoFocus + /> + + +
+ ) : null} + {renderedSections.grouped.map((section, sectionIndex) => { + const { group, projects: members } = section; + const collapsed = group.collapsed === true; return ( - - {projectIndex === firstUnpinnedProjectIndex ? ( + + {sectionIndex === firstUnpinnedSectionIndex ? ( From b9b9406ee14b14d4f53a849966807a7b11ede377 Mon Sep 17 00:00:00 2001 From: Morgan Woods Date: Sun, 9 Aug 2026 16:24:57 +0800 Subject: [PATCH 2/9] fix(ci): restore shared worktree contracts --- crates/agent-gui/src/lib/settings/index.ts | 19 ++----------- .../components/chat/ChatHistorySidebar.tsx | 2 +- .../src/components/git/GitBranchSelector.tsx | 28 ++++++++++++------- crates/agent-ui/src/lib/git/types.ts | 4 +-- .../agent-ui/src/lib/workspaceProjectTypes.ts | 16 +++++++++++ crates/agent-ui/src/lib/workspaceProjects.ts | 2 +- 6 files changed, 40 insertions(+), 31 deletions(-) create mode 100644 crates/agent-ui/src/lib/workspaceProjectTypes.ts diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index 14b5d94a8..b453fbf40 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -22,6 +22,7 @@ import { MAX_CHAT_TRANSCRIPT_WIDTH, MIN_CHAT_TRANSCRIPT_WIDTH, } from "@liveagent/ui/lib/transcript-width/transcriptWidthModel"; +import type { WorkspaceProjectGroup } from "@liveagent/ui/lib/workspaceProjectTypes"; import { DEFAULT_LOCALE, type Locale, normalizeLocale } from "../../i18n/config"; import { ANTHROPIC_LONG_CONTEXT_WINDOW, @@ -34,6 +35,7 @@ import { } from "../providers/anthropicModels"; import { normalizeFontFamily } from "../system/fontFamily"; +export type { WorkspaceProjectGroup } from "@liveagent/ui/lib/workspaceProjectTypes"; export { normalizeFontFamily } from "../system/fontFamily"; export function isThinkingAlwaysOnForModel( @@ -297,23 +299,6 @@ export type EffectiveWorkspaceResources = { export type WorkspaceProjectKind = "managed" | "folder" | "history"; -/** - * 侧边栏项目分组。成员用原始路径存储(匹配时经 - * `workspaceProjectPathKey` 归一化),与 hidden/missing/archived 一致。 - * - * `sourceProjectPath` 标记自动分组(git worktree 聚合):指向原始仓库 - * 项目的路径,重命名分组后仍可据此复用,避免重复建组。 - */ -export type WorkspaceProjectGroup = { - id: string; - name: string; - projectPaths: string[]; - sourceProjectPath?: string; - collapsed?: boolean; - createdAt: number; - updatedAt: number; -}; - export type WorkspaceProject = { id: string; name: string; diff --git a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx index f478dd0b4..b19f7a3c3 100644 --- a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx @@ -28,7 +28,6 @@ import { import { DEFAULT_WORKSPACE_PROJECT_ID, type WorkspaceProject, - type WorkspaceProjectGroup, workspaceProjectPathKey, } from "@liveagent/app/lib/settings"; import { Button } from "@liveagent/ui/components/ui/button"; @@ -73,6 +72,7 @@ import { buildWorkspaceProjectSections, sliceWorkspaceProjectSections, } from "../../lib/workspaceProjects"; +import type { WorkspaceProjectGroup } from "../../lib/workspaceProjectTypes"; export type ChatHistorySidebarListStatus = "initial" | "loading" | "syncing" | "ready"; export type ChatHistorySidebarMutationKind = "rename" | "pin" | "move" | "delete"; diff --git a/crates/agent-ui/src/components/git/GitBranchSelector.tsx b/crates/agent-ui/src/components/git/GitBranchSelector.tsx index 6e1858f00..fabfe25d3 100644 --- a/crates/agent-ui/src/components/git/GitBranchSelector.tsx +++ b/crates/agent-ui/src/components/git/GitBranchSelector.tsx @@ -1143,10 +1143,13 @@ export function GitBranchSelector(props: { // 分支被 worktree 检出时,删除入口切换为删除 worktree(连带分支)。 const checkedOutWorktreePath = branchAction - ? worktrees.find((worktree) => worktree.branch === branchAction.branch.fullName)?.path + ? gitClient?.removeWorktree + ? worktrees.find((worktree) => worktree.branch === branchAction.branch.fullName)?.path + : undefined : undefined; const deleteWorktreeFlow = useCallback(async () => { - if (!branchAction || !gitClient) return; + if (!branchAction || !gitClient?.removeWorktree) return; + const removeWorktree = gitClient.removeWorktree; const { branch } = branchAction; const worktreePath = worktrees.find((worktree) => worktree.branch === branch.fullName)?.path; if (!worktreePath) return; @@ -1165,7 +1168,7 @@ export function GitBranchSelector(props: { }); if (!confirmed) return; const ok = await runSheetMutation(() => - gitClient.removeWorktree(workdir, worktreePath, false, branch.fullName), + removeWorktree(workdir, worktreePath, false, branch.fullName), ); if (ok) { resetBranchAction(); @@ -1182,7 +1185,7 @@ export function GitBranchSelector(props: { }); if (!forced) return; const forcedOk = await runSheetMutation(() => - gitClient.removeWorktree(workdir, worktreePath, true, branch.fullName), + removeWorktree(workdir, worktreePath, true, branch.fullName), ); if (forcedOk) resetBranchAction(); }, [ @@ -1255,11 +1258,12 @@ export function GitBranchSelector(props: { ]); const openWorktreeModal = useCallback(() => { + if (!gitClient?.createWorktree) return; setWorktreeDraft(""); setWorktreeError(""); setWorktreeModalOpen(true); handleMenuOpenChange(false); - }, [handleMenuOpenChange]); + }, [gitClient, handleMenuOpenChange]); const closeWorktreeModal = useCallback(() => { if (worktreeBusy) return; @@ -1271,15 +1275,15 @@ export function GitBranchSelector(props: { const createWorktree = useCallback(() => { const name = worktreeDraft.trim(); - if (!name || !gitClient || !workdir.trim() || worktreeBusy) return; + if (!name || !gitClient?.createWorktree || !workdir.trim() || worktreeBusy) return; + const createWorktreeRequest = gitClient.createWorktree; if (!canWrite) { setWorktreeError(disabledMessage || t("git.branchSelector.writeDisabled")); return; } setWorktreeBusy(true); setWorktreeError(""); - void gitClient - .createWorktree(workdir, name, worktreeStartPoint) + void createWorktreeRequest(workdir, name, worktreeStartPoint) .then((response) => { if (!response.ok) { setWorktreeError( @@ -1711,7 +1715,8 @@ export function GitBranchSelector(props: {
- + ) : null}
)} @@ -1750,7 +1756,8 @@ export function GitBranchSelector(props: { onClose={resetBranchAction} /> {confirmDialog} - + ) : null} ; deleteBranch(workdir: string, branch: string, force?: boolean): Promise; renameBranch(workdir: string, branch: string, newBranch: string): Promise; - createWorktree(workdir: string, name: string, startPoint?: string): Promise; - removeWorktree( + createWorktree?(workdir: string, name: string, startPoint?: string): Promise; + removeWorktree?( workdir: string, worktreePath: string, force?: boolean, diff --git a/crates/agent-ui/src/lib/workspaceProjectTypes.ts b/crates/agent-ui/src/lib/workspaceProjectTypes.ts new file mode 100644 index 000000000..bad156895 --- /dev/null +++ b/crates/agent-ui/src/lib/workspaceProjectTypes.ts @@ -0,0 +1,16 @@ +/** + * 侧边栏项目分组。成员用原始路径存储(匹配时经 + * `workspaceProjectPathKey` 归一化),与 hidden/missing/archived 一致。 + * + * `sourceProjectPath` 标记自动分组(git worktree 聚合):指向原始仓库 + * 项目的路径,重命名分组后仍可据此复用,避免重复建组。 + */ +export type WorkspaceProjectGroup = { + id: string; + name: string; + projectPaths: string[]; + sourceProjectPath?: string; + collapsed?: boolean; + createdAt: number; + updatedAt: number; +}; diff --git a/crates/agent-ui/src/lib/workspaceProjects.ts b/crates/agent-ui/src/lib/workspaceProjects.ts index 99bff3b74..76693e176 100644 --- a/crates/agent-ui/src/lib/workspaceProjects.ts +++ b/crates/agent-ui/src/lib/workspaceProjects.ts @@ -3,11 +3,11 @@ import { DEFAULT_WORKSPACE_PROJECT_NAME, type SystemSettings, type WorkspaceProject, - type WorkspaceProjectGroup, workspaceProjectPathKey, } from "@liveagent/app/lib/settings"; import { createUuid } from "./shared/id"; import type { SidebarWorkdirSummary } from "./sidebar/types"; +import type { WorkspaceProjectGroup } from "./workspaceProjectTypes"; type WorkspaceProjectActivitySource = { path?: string; From 2c726b6ec02d1c7a8d58432187328308f73cdc2f Mon Sep 17 00:00:00 2001 From: Morgan Woods Date: Sun, 9 Aug 2026 16:41:03 +0800 Subject: [PATCH 3/9] fix(i18n): add shared worktree translations --- crates/agent-gateway/web/src/i18n/config.ts | 60 +++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index c25a807ff..287745c66 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -37,6 +37,18 @@ export const translations: Record> = { "chat.recentConversation": "最近会话", "chat.workspaceSection": "工作空间", "chat.workspaceCreate": "新建工作空间", + "chat.workspaceAdd": "添加…", + "chat.workspaceUngrouped": "未分组", + "chat.workspaceGroupCreate": "新建分组", + "chat.workspaceGroupNamePlaceholder": "分组名称", + "chat.workspaceGroupRename": "重命名分组", + "chat.workspaceGroupDelete": "删除分组", + "chat.workspaceGroupDeleteConfirmTitle": "删除分组「{name}」?", + "chat.workspaceGroupDeleteConfirmDescription": "组内项目将回到未分组,项目本身不会被删除。", + "chat.workspaceGroupToggle": "展开/折叠分组", + "chat.workspaceGroupActions": "分组操作", + "chat.workspaceGroupMove": "移动到分组", + "chat.workspaceGroupUngroup": "移出分组", "chat.workspaceCreateDescription": "打开已有文件夹,或从远程 Git 仓库创建新的工作空间。", "chat.workspaceOpenFolder": "打开本地文件夹", "chat.workspaceOpenFolderDescription": "将已有文件夹添加为工作空间。", @@ -755,9 +767,26 @@ export const translations: Record> = { "git.branchSelector.deleteForceTitle": "分支尚未完全合并", "git.branchSelector.deleteForceDescription": "强制删除(-D)会丢弃仅存在于该分支上的提交。", "git.branchSelector.forceDelete": "强制删除", + "git.branchSelector.deleteWorktree": "删除 Worktree", + "git.branchSelector.deleteWorktreeConfirmTitle": "删除 Worktree「{path}」?", + "git.branchSelector.deleteWorktreeConfirmDescription": + "将移除 Worktree 目录,并尝试删除其分支「{branch}」。", + "git.branchSelector.deleteWorktreeForceTitle": "Worktree 包含未提交改动", + "git.branchSelector.deleteWorktreeForceDescription": + "强制移除(--force)会丢弃 Worktree 中的未提交改动。", + "git.branchSelector.forceRemoveWorktree": "强制移除", "git.branchSelector.moreActions": "更多操作", "git.branchSelector.stashPush": "暂存当前改动 (stash)", "git.branchSelector.stashPop": "恢复最近的 stash", + "git.branchSelector.createWorktree": "新建 Worktree", + "git.branchSelector.createWorktreeTitle": "新建 Worktree", + "git.branchSelector.worktreeDescription": "在独立目录检出仓库副本,可并行开发多个分支。", + "git.branchSelector.worktreeName": "Worktree 名称", + "git.branchSelector.worktreeNamePlaceholder": "feature-my-worktree", + "git.branchSelector.worktreeStartPoint": "基于分支", + "git.branchSelector.worktreeLocationHint": + "将保存到 ~/.liveagent/worktree 下的独立目录,创建后自动在侧边栏打开。", + "git.branchSelector.worktreeFailed": "创建 Worktree 失败", "projectTools.reorderTab": "调整标签排序", "projectTools.reorderTabHint": "拖动排序,或聚焦后按左右方向键移动", "projectTools.shell": "Shell", @@ -2251,6 +2280,19 @@ export const translations: Record> = { "chat.recentConversation": "Conversations", "chat.workspaceSection": "Workspaces", "chat.workspaceCreate": "New workspace", + "chat.workspaceAdd": "Add…", + "chat.workspaceUngrouped": "Ungrouped", + "chat.workspaceGroupCreate": "New Group", + "chat.workspaceGroupNamePlaceholder": "Group name", + "chat.workspaceGroupRename": "Rename group", + "chat.workspaceGroupDelete": "Delete group", + "chat.workspaceGroupDeleteConfirmTitle": 'Delete group "{name}"?', + "chat.workspaceGroupDeleteConfirmDescription": + "Projects in the group move back to ungrouped; the projects themselves are not deleted.", + "chat.workspaceGroupToggle": "Toggle group", + "chat.workspaceGroupActions": "Group actions", + "chat.workspaceGroupMove": "Move to group", + "chat.workspaceGroupUngroup": "Ungroup", "chat.workspaceCreateDescription": "Open an existing folder or create a new workspace from a remote Git repository.", "chat.workspaceOpenFolder": "Open local folder", @@ -3003,9 +3045,27 @@ export const translations: Record> = { "git.branchSelector.deleteForceDescription": "Force delete (-D) discards commits that only exist on this branch.", "git.branchSelector.forceDelete": "Force delete", + "git.branchSelector.deleteWorktree": "Delete Worktree", + "git.branchSelector.deleteWorktreeConfirmTitle": 'Delete worktree "{path}"?', + "git.branchSelector.deleteWorktreeConfirmDescription": + 'Removes the worktree directory and tries to delete its branch "{branch}".', + "git.branchSelector.deleteWorktreeForceTitle": "Worktree contains uncommitted changes", + "git.branchSelector.deleteWorktreeForceDescription": + "Force removal (--force) discards uncommitted changes in the worktree.", + "git.branchSelector.forceRemoveWorktree": "Force remove", "git.branchSelector.moreActions": "More actions", "git.branchSelector.stashPush": "Stash changes", "git.branchSelector.stashPop": "Pop latest stash", + "git.branchSelector.createWorktree": "Create Worktree", + "git.branchSelector.createWorktreeTitle": "Create Worktree", + "git.branchSelector.worktreeDescription": + "Check out a separate copy of the repository to work on multiple branches in parallel.", + "git.branchSelector.worktreeName": "Worktree name", + "git.branchSelector.worktreeNamePlaceholder": "feature-my-worktree", + "git.branchSelector.worktreeStartPoint": "Start point", + "git.branchSelector.worktreeLocationHint": + "Saved under ~/.liveagent/worktree in a separate directory and opened in the sidebar automatically.", + "git.branchSelector.worktreeFailed": "Failed to create worktree", "projectTools.reorderTab": "Reorder tab", "projectTools.reorderTabHint": "Drag to reorder, or focus and use Left/Right", "projectTools.shell": "Shell", From d847e680c24b8ae47eedf244628b9855524cb653 Mon Sep 17 00:00:00 2001 From: Morgan Woods Date: Sun, 9 Aug 2026 19:13:30 +0800 Subject: [PATCH 4/9] feat(gateway): enable worktree groups in webui --- .../internal/protocol/pbws/guard.go | 2 +- .../test/websocket/v2_git_gating_test.go | 12 +- .../test/webui/gateway-git-client.test.mjs | 46 ++++++ .../test/webui/web-settings.test.mjs | 37 +++++ .../agent-gateway/web/src/app/GatewayApp.tsx | 131 ++++++++++++++++++ .../app/sidebar/GatewaySidebarContainer.tsx | 13 ++ .../web/src/lib/git/gatewayGitClient.ts | 17 +++ .../web/src/lib/settings/index.ts | 45 ++++++ .../src-tauri/src/commands/workspace/git.rs | 13 ++ 9 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 crates/agent-gateway/test/webui/gateway-git-client.test.mjs diff --git a/crates/agent-gateway/internal/protocol/pbws/guard.go b/crates/agent-gateway/internal/protocol/pbws/guard.go index 3b3ef86a6..27b5b52c3 100644 --- a/crates/agent-gateway/internal/protocol/pbws/guard.go +++ b/crates/agent-gateway/internal/protocol/pbws/guard.go @@ -141,7 +141,7 @@ func vetChatFileOpen(req *gatewayv2.ChatFileOpenRequest) error { // enable_web_git 门控,读操作(status/log/diff 等)始终放行。 func gitActionIsWrite(action string) bool { switch action { - case "clone", "clone_start", "clone_cancel", "clone_dismiss", "init", "switch_branch", "create_branch", "stage", "stage_all", "unstage", "unstage_all", "discard", "discard_all", "add_to_gitignore", "commit", "fetch", "pull", "set_remote", "push", "delete_branch", "rename_branch", "stash_push", "stash_pop": + case "clone", "clone_start", "clone_cancel", "clone_dismiss", "init", "switch_branch", "create_branch", "create_worktree", "stage", "stage_all", "unstage", "unstage_all", "discard", "discard_all", "add_to_gitignore", "commit", "fetch", "pull", "set_remote", "push", "delete_branch", "rename_branch", "remove_worktree", "stash_push", "stash_pop": return true default: return false diff --git a/crates/agent-gateway/test/websocket/v2_git_gating_test.go b/crates/agent-gateway/test/websocket/v2_git_gating_test.go index 87e548cc7..afe619577 100644 --- a/crates/agent-gateway/test/websocket/v2_git_gating_test.go +++ b/crates/agent-gateway/test/websocket/v2_git_gating_test.go @@ -62,7 +62,7 @@ func TestV2GitRejectsWriteRequestsWhenDisabled(t *testing.T) { _, _, conn, cleanup := newV2GitBrowserTest(t, false) defer cleanup() - for _, action := range []string{"clone", "stage", "init", "stage_all", "unstage_all", "discard_all", "push", "commit"} { + for _, action := range []string{"clone", "stage", "init", "create_worktree", "remove_worktree", "stage_all", "unstage_all", "discard_all", "push", "commit"} { id := "git-disabled-" + action sendGitAgentRequest(t, conn, id, action) @@ -99,10 +99,12 @@ func TestV2GitAllowsWriteRequestsWhenEnabled(t *testing.T) { _, agentSession, conn, cleanup := newV2GitBrowserTest(t, true) defer cleanup() - sendGitAgentRequest(t, conn, "git-stage-1", "stage") + for _, action := range []string{"stage", "create_worktree", "remove_worktree"} { + sendGitAgentRequest(t, conn, "git-write-"+action, action) - outbound := readOutboundEnvelope(t, agentSession) - if outbound.GetGitRequest().GetAction() != "stage" { - t.Fatalf("outbound = %#v, want forwarded git stage request", outbound) + outbound := readOutboundEnvelope(t, agentSession) + if outbound.GetGitRequest().GetAction() != action { + t.Fatalf("outbound = %#v, want forwarded git %s request", outbound, action) + } } } diff --git a/crates/agent-gateway/test/webui/gateway-git-client.test.mjs b/crates/agent-gateway/test/webui/gateway-git-client.test.mjs new file mode 100644 index 000000000..6f9a6288c --- /dev/null +++ b/crates/agent-gateway/test/webui/gateway-git-client.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader(); +const { createGatewayGitClient } = loader.loadModule("src/lib/git/gatewayGitClient.ts"); + +test("gateway git client forwards worktree create and remove operations", async () => { + const calls = []; + const api = { + async gitRequest(action, workdir, args) { + calls.push({ action, workdir, args }); + if (action === "create_worktree") { + return { ok: true, worktreePath: "/workspace/.worktrees/topic" }; + } + return { ok: true }; + }, + }; + const client = createGatewayGitClient(api); + + const created = await client.createWorktree("/workspace/project", "topic", "main"); + await client.removeWorktree( + "/workspace/project", + "/workspace/.worktrees/topic", + true, + "topic", + ); + + assert.equal(created.worktreePath, "/workspace/.worktrees/topic"); + assert.deepEqual(calls, [ + { + action: "create_worktree", + workdir: "/workspace/project", + args: { name: "topic", startPoint: "main" }, + }, + { + action: "remove_worktree", + workdir: "/workspace/project", + args: { + worktreePath: "/workspace/.worktrees/topic", + force: true, + deleteBranch: "topic", + }, + }, + ]); +}); diff --git a/crates/agent-gateway/test/webui/web-settings.test.mjs b/crates/agent-gateway/test/webui/web-settings.test.mjs index b4f03d411..817974544 100644 --- a/crates/agent-gateway/test/webui/web-settings.test.mjs +++ b/crates/agent-gateway/test/webui/web-settings.test.mjs @@ -10,6 +10,43 @@ const chatHelpers = loader.loadModule("@/lib/chat/chatPageHelpers.ts"); const adminApi = loader.loadModule("@/lib/adminApi.ts"); const RIGHT_DOCK_TAB_IDS = settings.RIGHT_DOCK_SINGLETON_TAB_IDS; +test("web settings normalize and preserve workspace project groups", () => { + const normalized = settings.normalizeSettings({ + system: { + workspaceProjectGroups: [ + { + id: " source-group ", + name: " Source ", + projectPaths: [" /workspace/project ", "/workspace/project", "/workspace/topic"], + sourceProjectPath: " /workspace/project ", + collapsed: true, + createdAt: 100, + updatedAt: 200, + }, + { id: "source-group", name: "duplicate", projectPaths: [] }, + ], + }, + }); + + assert.deepEqual(normalized.system.workspaceProjectGroups, [ + { + id: "source-group", + name: "Source", + projectPaths: ["/workspace/project", "/workspace/topic"], + sourceProjectPath: "/workspace/project", + collapsed: true, + createdAt: 100, + updatedAt: 200, + }, + ]); + + const update = settingsSync.buildGatewaySettingsSyncUpdatePayload( + settings.normalizeSettings({}), + normalized, + ); + assert.deepEqual(update.system.workspaceProjectGroups, normalized.system.workspaceProjectGroups); +}); + test("custom provider normalization defaults and filters ordered custom headers", () => { assert.deepEqual(settings.normalizeCustomProvider({}).customHeaders, []); diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 42a669e1d..523f0e55f 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -177,9 +177,13 @@ import { sortSidebarConversations } from "@liveagent/ui/lib/sidebar/reconcile"; import { createSidebarStore } from "@liveagent/ui/lib/sidebar/store"; import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector"; import { + assignWorkspaceProjectToGroup, + ensureWorktreeProjectGroup, + fallbackWorkspaceProjectName, findWorkspaceProject, mergeWorkspaceProjectsWithHistory, } from "@liveagent/ui/lib/workspaceProjects"; +import type { WorkspaceProjectGroup } from "@liveagent/ui/lib/workspaceProjectTypes"; import { FloorNavRail } from "@liveagent/ui/pages/chat/transcript/FloorNavRail"; import { CHAT_TRANSCRIPT_WIDTH_CSS_VAR, @@ -1430,6 +1434,126 @@ export default function GatewayApp() { [activateWorkspaceProject, sidebarStore], ); + const handleOpenWorktree = useCallback( + (path: string) => { + const trimmed = path.trim(); + const worktreeKey = workspaceProjectPathKey(trimmed); + const sourceProjectPath = displayedConversationWorkdirRef.current.trim(); + if (!trimmed || !worktreeKey || !sourceProjectPath) return; + activateWorkspaceProject(createWorkspaceProjectFromPath(trimmed, "managed")); + setSettings((prev) => { + const autoGroup = prev.system.workspaceProjectGroups.find( + (group) => + group.sourceProjectPath && + group.projectPaths.some( + (memberPath) => + workspaceProjectPathKey(memberPath) === workspaceProjectPathKey(sourceProjectPath), + ), + ); + const sourcePath = autoGroup?.sourceProjectPath ?? sourceProjectPath; + const sourceProject = prev.system.workspaceProjects.find( + (project) => + workspaceProjectPathKey(project.path) === workspaceProjectPathKey(sourcePath), + ); + const ensured = ensureWorktreeProjectGroup(prev.system.workspaceProjectGroups, { + name: sourceProject?.name || fallbackWorkspaceProjectName(sourcePath), + sourceProjectPath: sourcePath, + }); + let workspaceProjectGroups = assignWorkspaceProjectToGroup( + ensured.groups, + ensured.groupId, + sourcePath, + ); + workspaceProjectGroups = assignWorkspaceProjectToGroup( + workspaceProjectGroups, + ensured.groupId, + trimmed, + ); + return { ...prev, system: { ...prev.system, workspaceProjectGroups } }; + }); + void sidebarStore.refreshWorkdirs("new-workdir"); + }, + [activateWorkspaceProject, setSettings, sidebarStore], + ); + + const updateWorkspaceProjectGroups = useCallback( + (updater: (groups: WorkspaceProjectGroup[]) => WorkspaceProjectGroup[]) => { + setSettings((prev) => { + const next = updater(prev.system.workspaceProjectGroups); + if (next === prev.system.workspaceProjectGroups) return prev; + return { ...prev, system: { ...prev.system, workspaceProjectGroups: next } }; + }); + }, + [setSettings], + ); + + const handleCreateWorkspaceGroup = useCallback( + (nameInput: string) => { + const name = nameInput.trim(); + if (!name) return; + const now = Date.now(); + updateWorkspaceProjectGroups((groups) => [ + ...groups, + { id: createUuid(), name, projectPaths: [], createdAt: now, updatedAt: now }, + ]); + }, + [updateWorkspaceProjectGroups], + ); + + const handleRenameWorkspaceGroup = useCallback( + (groupId: string, nameInput: string) => { + const name = nameInput.trim(); + if (!name) return; + updateWorkspaceProjectGroups((groups) => + groups.map((group) => + group.id === groupId ? { ...group, name, updatedAt: Date.now() } : group, + ), + ); + }, + [updateWorkspaceProjectGroups], + ); + + const handleDeleteWorkspaceGroup = useCallback( + (groupId: string) => { + updateWorkspaceProjectGroups((groups) => groups.filter((group) => group.id !== groupId)); + }, + [updateWorkspaceProjectGroups], + ); + + const handleMoveWorkspaceProjectToGroup = useCallback( + (projectPath: string, groupId: string | null) => { + const pathKey = workspaceProjectPathKey(projectPath); + if (!pathKey) return; + updateWorkspaceProjectGroups((groups) => { + if (groupId === null) { + return groups.map((group) => { + const projectPaths = group.projectPaths.filter( + (path) => workspaceProjectPathKey(path) !== pathKey, + ); + return projectPaths.length === group.projectPaths.length + ? group + : { ...group, projectPaths, updatedAt: Date.now() }; + }); + } + return assignWorkspaceProjectToGroup(groups, groupId, projectPath); + }); + }, + [updateWorkspaceProjectGroups], + ); + + const handleToggleWorkspaceGroupCollapsed = useCallback( + (groupId: string) => { + updateWorkspaceProjectGroups((groups) => + groups.map((group) => + group.id === groupId + ? { ...group, collapsed: !group.collapsed, updatedAt: Date.now() } + : group, + ), + ); + }, + [updateWorkspaceProjectGroups], + ); + const commitWorkspaceProjectRename = useCallback( (project: WorkspaceProject, nextNameInput: string) => { if (project.id === DEFAULT_WORKSPACE_PROJECT_ID) return; @@ -4682,6 +4806,7 @@ export default function GatewayApp() { activeView={activeView} showProjects={isAgentMode && status?.online === true} projects={workspaceProjects} + workspaceProjectGroups={settings.system.workspaceProjectGroups} activeProjectId={activeWorkspaceProject?.id} missingProjectPathKeys={missingWorkspaceProjectPathKeys} projectRenamingId={projectRenamingId} @@ -4697,6 +4822,11 @@ export default function GatewayApp() { onProjectsCollapsedChange={handleSidebarProjectsCollapsedChange} onRecentCollapsedChange={handleSidebarRecentCollapsedChange} onCreateProject={handleOpenCreateWorkspaceProject} + onCreateWorkspaceGroup={handleCreateWorkspaceGroup} + onRenameWorkspaceGroup={handleRenameWorkspaceGroup} + onDeleteWorkspaceGroup={handleDeleteWorkspaceGroup} + onMoveProjectToGroup={handleMoveWorkspaceProjectToGroup} + onToggleWorkspaceGroupCollapsed={handleToggleWorkspaceGroupCollapsed} onSelectProject={handleSelectWorkspaceProject} onNewConversationForProject={handleNewConversationForProject} onBrowseProjectInFileTree={handleBrowseWorkspaceProjectInFileTree} @@ -5009,6 +5139,7 @@ export default function GatewayApp() { reasoningOptions={chatRuntimeReasoningOptions} thinkingAlwaysOn={chatRuntimeThinkingAlwaysOn} gitClient={gitClient} + onOpenWorktree={handleOpenWorktree} gitWriteEnabled={settings.remote.enableWebGit} gitDisabledMessage={gitDisabledMessage} workspaceActivityClient={workspaceActivityClient} diff --git a/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx b/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx index 51e6e6362..f0e8f86b6 100644 --- a/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx +++ b/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx @@ -18,6 +18,7 @@ import type { SidebarSnapshot, SidebarStore } from "@liveagent/ui/lib/sidebar/st import type { SidebarErrorCode } from "@liveagent/ui/lib/sidebar/types"; import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector"; import { sortWorkspaceProjectsByActivity } from "@liveagent/ui/lib/workspaceProjects"; +import type { WorkspaceProjectGroup } from "@liveagent/ui/lib/workspaceProjectTypes"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ChatHistorySummary } from "@/lib/chat/chatHistory"; import type { WorkspaceProject } from "@/lib/settings"; @@ -82,6 +83,7 @@ export type GatewaySidebarContainerProps = { // the store's activity snapshot so project reordering never re-renders // GatewayApp. projects: WorkspaceProject[]; + workspaceProjectGroups?: WorkspaceProjectGroup[]; activeProjectId?: string; missingProjectPathKeys: ReadonlySet; projectRenamingId: string | null; @@ -103,6 +105,11 @@ export type GatewaySidebarContainerProps = { onProjectsCollapsedChange: (collapsed: boolean) => void; onRecentCollapsedChange: (collapsed: boolean) => void; onCreateProject: () => void; + onCreateWorkspaceGroup?: (name: string) => void; + onRenameWorkspaceGroup?: (groupId: string, name: string) => void; + onDeleteWorkspaceGroup?: (groupId: string) => void; + onMoveProjectToGroup?: (projectPath: string, groupId: string | null) => void; + onToggleWorkspaceGroupCollapsed?: (groupId: string) => void; onSelectProject: (project: WorkspaceProject) => void; onNewConversationForProject: (project: WorkspaceProject) => void; onBrowseProjectInFileTree: (project: WorkspaceProject) => void; @@ -362,6 +369,7 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { activeView={props.activeView} showProjects={props.showProjects} projects={sortedProjects} + workspaceProjectGroups={props.workspaceProjectGroups} activeProjectId={props.activeProjectId} missingProjectPathKeys={props.missingProjectPathKeys} runningProjectPathKeys={projectActivityInputs.runningWorkdirPathKeys} @@ -372,6 +380,11 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { onProjectsCollapsedChange={props.onProjectsCollapsedChange} onRecentCollapsedChange={props.onRecentCollapsedChange} onCreateProject={props.onCreateProject} + onCreateWorkspaceGroup={props.onCreateWorkspaceGroup} + onRenameWorkspaceGroup={props.onRenameWorkspaceGroup} + onDeleteWorkspaceGroup={props.onDeleteWorkspaceGroup} + onMoveProjectToGroup={props.onMoveProjectToGroup} + onToggleWorkspaceGroupCollapsed={props.onToggleWorkspaceGroupCollapsed} onSelectProject={props.onSelectProject} onNewConversationForProject={props.onNewConversationForProject} onBrowseProjectInFileTree={props.onBrowseProjectInFileTree} diff --git a/crates/agent-gateway/web/src/lib/git/gatewayGitClient.ts b/crates/agent-gateway/web/src/lib/git/gatewayGitClient.ts index 73e481fce..0f742f2ec 100644 --- a/crates/agent-gateway/web/src/lib/git/gatewayGitClient.ts +++ b/crates/agent-gateway/web/src/lib/git/gatewayGitClient.ts @@ -7,6 +7,7 @@ import { normalizeGitOperationResponse, normalizeGitRepositoryDiscovery, normalizeGitRepositoryState, + normalizeGitWorktreeResponse, } from "@liveagent/ui/lib/git/types"; import type { GatewayWebSocketClientLike } from "@/lib/gatewaySocket"; @@ -46,6 +47,12 @@ export function createGatewayGitClient(api: GatewayWebSocketClientLike): GitClie workdir, ); }, + async createWorktree(workdir, name, startPoint) { + return normalizeGitWorktreeResponse( + await api.gitRequest("create_worktree", workdir, { name, startPoint }), + workdir, + ); + }, async diff(workdir, mode, path) { return normalizeGitDiffResponse(await api.gitRequest("diff", workdir, { mode, path })); }, @@ -137,6 +144,16 @@ export function createGatewayGitClient(api: GatewayWebSocketClientLike): GitClie workdir, ); }, + async removeWorktree(workdir, worktreePath, force, deleteBranch) { + return normalizeGitOperationResponse( + await api.gitRequest("remove_worktree", workdir, { + worktreePath, + force, + deleteBranch, + }), + workdir, + ); + }, async stashPush(workdir, message) { return normalizeGitOperationResponse( await api.gitRequest("stash_push", workdir, { message }), diff --git a/crates/agent-gateway/web/src/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index 408c4e7ae..f6e40e31a 100644 --- a/crates/agent-gateway/web/src/lib/settings/index.ts +++ b/crates/agent-gateway/web/src/lib/settings/index.ts @@ -24,6 +24,7 @@ import { MAX_CHAT_TRANSCRIPT_WIDTH, MIN_CHAT_TRANSCRIPT_WIDTH, } from "@liveagent/ui/lib/transcript-width/transcriptWidthModel"; +import type { WorkspaceProjectGroup } from "@liveagent/ui/lib/workspaceProjectTypes"; import { DEFAULT_LOCALE, type Locale, normalizeLocale } from "../../i18n/config"; import { normalizeFontFamily } from "../fontFamily"; @@ -246,6 +247,7 @@ export type SystemSettings = { */ toolPolicies?: Record; workspaceProjects: WorkspaceProject[]; + workspaceProjectGroups: WorkspaceProjectGroup[]; activeWorkspaceProjectId?: string; hiddenWorkspaceProjectPaths: string[]; missingWorkspaceProjectPaths: string[]; @@ -865,6 +867,47 @@ function normalizeWorkspaceProjects(input: unknown): WorkspaceProject[] { return out; } +function normalizeWorkspaceProjectGroups(input: unknown): WorkspaceProjectGroup[] { + if (!Array.isArray(input)) return []; + const out: WorkspaceProjectGroup[] = []; + const seenIds = new Set(); + for (const raw of input) { + const obj = (raw && typeof raw === "object" ? raw : {}) as Record; + const id = typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : createUuid(); + if (seenIds.has(id)) continue; + const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : "未命名分组"; + const projectPaths: string[] = []; + const seenPaths = new Set(); + for (const path of normalizeStringArray(obj.projectPaths)) { + const normalizedPath = normalizeWorkspaceProjectPath(path); + const pathKey = workspaceProjectPathKey(normalizedPath); + if (!pathKey || seenPaths.has(pathKey)) continue; + seenPaths.add(pathKey); + projectPaths.push(normalizedPath); + } + const sourceProjectPath = normalizeWorkspaceProjectPath(obj.sourceProjectPath); + const createdAt = + typeof obj.createdAt === "number" && Number.isFinite(obj.createdAt) && obj.createdAt > 0 + ? obj.createdAt + : Date.now(); + const updatedAt = + typeof obj.updatedAt === "number" && Number.isFinite(obj.updatedAt) && obj.updatedAt > 0 + ? obj.updatedAt + : createdAt; + seenIds.add(id); + out.push({ + id, + name, + projectPaths, + ...(sourceProjectPath ? { sourceProjectPath } : {}), + ...(obj.collapsed === true ? { collapsed: true } : {}), + createdAt, + updatedAt, + }); + } + return out; +} + export function normalizeHiddenWorkspaceProjectPaths(input: unknown): string[] { const out: string[] = []; const seen = new Set(); @@ -1843,6 +1886,7 @@ export function normalizeSystemSettings(input: unknown): SystemSettings { workdir: normalizeWorkdir(obj.workdir), toolPolicies: normalizeToolPolicies(obj.toolPolicies), workspaceProjects: normalizeWorkspaceProjects(obj.workspaceProjects), + workspaceProjectGroups: normalizeWorkspaceProjectGroups(obj.workspaceProjectGroups), activeWorkspaceProjectId: typeof obj.activeWorkspaceProjectId === "string" && obj.activeWorkspaceProjectId.trim() ? obj.activeWorkspaceProjectId.trim() @@ -2493,6 +2537,7 @@ export function getDefaultSettings(): AppSettings { executionMode: "tools", workdir: "", workspaceProjects: [], + workspaceProjectGroups: [], activeWorkspaceProjectId: undefined, hiddenWorkspaceProjectPaths: [], missingWorkspaceProjectPaths: [], diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs index 13e1f2acd..5f7eb48a4 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs @@ -239,6 +239,8 @@ struct GitGatewayArgs { user_email: Option, force: Option, new_branch: Option, + worktree_path: Option, + delete_branch: Option, task_id: Option, } @@ -3294,6 +3296,11 @@ pub(crate) fn git_gateway_action_sync( args.branch.unwrap_or_default(), args.start_point, )?), + "create_worktree" => serde_json::to_value(git_create_worktree_sync( + workdir, + args.name.unwrap_or_default(), + args.start_point, + )?), "log" => serde_json::to_value(git_log_sync(workdir, args.limit, args.skip)?), "commit_details" => serde_json::to_value(git_commit_details_sync( workdir, @@ -3349,6 +3356,12 @@ pub(crate) fn git_gateway_action_sync( args.branch.unwrap_or_default(), args.new_branch.unwrap_or_default(), )?), + "remove_worktree" => serde_json::to_value(git_remove_worktree_sync( + workdir, + args.worktree_path.unwrap_or_default(), + args.force, + args.delete_branch, + )?), "stash_push" => serde_json::to_value(git_stash_push_sync(workdir, args.message)?), "stash_pop" => serde_json::to_value(git_stash_pop_sync(workdir)?), "" => return Err("Git action 不能为空。".to_string()), From 136fc77cb32fc182fc2de5b438fefef609aead83 Mon Sep 17 00:00:00 2001 From: Morgan Woods Date: Mon, 10 Aug 2026 13:46:38 +0800 Subject: [PATCH 5/9] fix(git): address PR review on worktree list, render cap and group idempotency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M1: git_worktrees_sync 过滤主工作树(path == repo_root),主仓库检出的 分支不再被误判为被 linked worktree 检出;git_remove_worktree_sync 的 登记校验随之天然排除主仓库(防御深度前置)。 - L1: assignWorkspaceProjectToGroup 无变化时返回原引用,让两端 updateWorkspaceProjectGroups 的引用相等短路生效,幂等操作不再触发 多余的 settings 写入/同步。 - L3: repo_worktree_id 使用完整 64 位 fnv1a64 hex(16 字符),消除 32 位截断的碰撞顾虑。 - Bug: sliceWorkspaceProjectSections 改为按成员数裁剪(分组整组纳入、 绝不拆开),未分组项目也受渲染上限约束,hiddenProjectCount 统计 全部被隐藏成员,show-all 按钮恢复生效。 - 附带 rebase 至最新 upstream 后的对齐修正(settings 测试 row_count、 import 布局、i18n 格式、loader 路径)。 --- .../src/commands/config/settings/tests.rs | 4 +- .../src-tauri/src/commands/workspace/git.rs | 36 +++++++++++++++-- crates/agent-gui/src/i18n/config.ts | 14 ++++--- .../chat/sidebar/ChatSidebarContainer.tsx | 2 +- .../chat/workspace/useWorkspaceProjects.ts | 17 ++------ .../tools/workspace-project-groups.test.mjs | 39 +++++++++++++++++-- crates/agent-ui/src/lib/workspaceProjects.ts | 24 +++++++++--- 7 files changed, 101 insertions(+), 35 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs index d2c4ac385..e85296425 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs @@ -1121,7 +1121,7 @@ mod tests { }; let loaded = load_system(&conn).expect("load system"); - assert_eq!(row_count, 10); + assert_eq!(row_count, 11); assert_eq!( keys, vec![ @@ -1296,8 +1296,6 @@ mod tests { })) ); } - ); - } #[test] fn workspace_resource_settings_are_not_truncated_after_one_hundred_paths() { diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs index 5f7eb48a4..9c1ef491b 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs @@ -1185,8 +1185,12 @@ pub(crate) fn git_branches_sync(workdir: String) -> Result Result, String> { let output = git_success(repo_root, &["worktree", "list", "--porcelain"])?; + // 主仓库路径必然存在,canonicalize 安全;失败时退回原字符串兜底。 + let main = fs::canonicalize(repo_root).unwrap_or_else(|_| PathBuf::from(repo_root)); let mut worktrees = Vec::new(); for block in output.stdout.split("\n\n") { let mut path = String::new(); @@ -1198,7 +1202,12 @@ fn git_worktrees_sync(repo_root: &str) -> Result, String> { branch = rest.trim().to_string(); } } - if !path.is_empty() { + // stale 登记(目录已删)的 canonicalize 会失败:不属于主工作树,保留。 + if !path.is_empty() + && fs::canonicalize(&path) + .map(|path| path != main) + .unwrap_or(true) + { worktrees.push(GitWorktreeInfo { path, branch }); } } @@ -1756,15 +1765,15 @@ fn worktree_storage_base() -> Result { Ok(dir) } -/// 稳定且唯一的 repo id:`-`。 -/// 同一仓库根路径永远映射到同一 id,目录可读;32 位哈希碰撞概率低,但非绝对。 +/// 稳定且唯一的 repo id:`-`。 +/// 同一仓库根路径永远映射到同一 id,目录可读;64 位哈希碰撞概率可忽略。 fn repo_worktree_id(repo_root: &str) -> String { let basename = Path::new(repo_root) .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| repo_root.to_string()); let sanitized = sanitize_repo_id_component(&basename); - format!("{sanitized}-{:08x}", fnv1a64(repo_root.as_bytes()) as u32) + format!("{sanitized}-{:016x}", fnv1a64(repo_root.as_bytes())) } fn sanitize_repo_id_component(input: &str) -> String { @@ -4992,6 +5001,25 @@ mod tests { .expect("created worktree listed"); assert_eq!(wt.branch, "wt-alpha"); } + #[test] + fn git_worktrees_excludes_main_worktree() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let state = git_status_sync(workdir.clone()).expect("status"); + let worktrees = git_worktrees_sync(&state.repo_root).expect("worktree list"); + let main = fs::canonicalize(&state.repo_root).expect("canonicalize main"); + assert!( + !worktrees.iter().any(|info| { + fs::canonicalize(&info.path) + .map(|path| path == main) + .unwrap_or(false) + }), + "main worktree must not be listed as a linked worktree: {:#?}", + worktrees + ); + } #[test] fn git_remove_worktree_removes_worktree_and_branch() { diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 49d1be54b..2bde54580 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -790,9 +790,11 @@ export const translations: Record> = { "git.branchSelector.forceDelete": "强制删除", "git.branchSelector.deleteWorktree": "删除 Worktree", "git.branchSelector.deleteWorktreeConfirmTitle": "删除 Worktree「{path}」?", - "git.branchSelector.deleteWorktreeConfirmDescription": "将移除 Worktree 目录,并尝试删除其分支「{branch}」。", + "git.branchSelector.deleteWorktreeConfirmDescription": + "将移除 Worktree 目录,并尝试删除其分支「{branch}」。", "git.branchSelector.deleteWorktreeForceTitle": "Worktree 包含未提交改动", - "git.branchSelector.deleteWorktreeForceDescription": "强制移除(--force)会丢弃 Worktree 中的未提交改动。", + "git.branchSelector.deleteWorktreeForceDescription": + "强制移除(--force)会丢弃 Worktree 中的未提交改动。", "git.branchSelector.forceRemoveWorktree": "强制移除", "git.branchSelector.moreActions": "更多操作", "git.branchSelector.stashPush": "暂存当前改动 (stash)", @@ -3127,10 +3129,12 @@ export const translations: Record> = { "Force delete (-D) discards commits that only exist on this branch.", "git.branchSelector.forceDelete": "Force delete", "git.branchSelector.deleteWorktree": "Delete Worktree", - "git.branchSelector.deleteWorktreeConfirmTitle": "Delete worktree \"{path}\"?", - "git.branchSelector.deleteWorktreeConfirmDescription": "Removes the worktree directory and tries to delete its branch \"{branch}\".", + "git.branchSelector.deleteWorktreeConfirmTitle": 'Delete worktree "{path}"?', + "git.branchSelector.deleteWorktreeConfirmDescription": + 'Removes the worktree directory and tries to delete its branch "{branch}".', "git.branchSelector.deleteWorktreeForceTitle": "Worktree contains uncommitted changes", - "git.branchSelector.deleteWorktreeForceDescription": "Force removal (--force) discards uncommitted changes in the worktree.", + "git.branchSelector.deleteWorktreeForceDescription": + "Force removal (--force) discards uncommitted changes in the worktree.", "git.branchSelector.forceRemoveWorktree": "Force remove", "git.branchSelector.moreActions": "More actions", "git.branchSelector.stashPush": "Stash changes", diff --git a/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx b/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx index 23d6679b2..62d73cd08 100644 --- a/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx +++ b/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx @@ -27,7 +27,7 @@ import { } from "../../../agent-ui-adapters/sidebarChrome"; import type { AppUpdateController } from "../../../lib/appUpdates"; import { normalizeConversationTitle } from "../../../lib/chat/page/chatPageHelpers"; -import type { WorkspaceProject } from "../../../lib/settings"; +import type { WorkspaceProject, WorkspaceProjectGroup } from "../../../lib/settings"; import { moveConversationsToWorkspace, moveConversationToWorkspace, diff --git a/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts b/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts index 7648743cc..b6d6c093a 100644 --- a/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts +++ b/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts @@ -1,9 +1,13 @@ +import { createUuid } from "@liveagent/ui/lib/shared/id"; import { sidebarScopeKey } from "@liveagent/ui/lib/sidebar/scope"; import type { SidebarStore } from "@liveagent/ui/lib/sidebar/store"; import type { SidebarScope } from "@liveagent/ui/lib/sidebar/types"; import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector"; import { invokeFs } from "@liveagent/ui/lib/tools/fsBackend"; import { + assignWorkspaceProjectToGroup, + ensureWorktreeProjectGroup, + fallbackWorkspaceProjectName, findWorkspaceProject, mergeWorkspaceProjectsWithHistory, } from "@liveagent/ui/lib/workspaceProjects"; @@ -28,19 +32,6 @@ import { type WorkspaceProjectGroup, workspaceProjectPathKey, } from "../../../lib/settings"; -import { createUuid } from "@liveagent/ui/lib/shared/id"; -import { sidebarScopeKey } from "@liveagent/ui/lib/sidebar/scope"; -import type { SidebarStore } from "@liveagent/ui/lib/sidebar/store"; -import type { SidebarScope } from "@liveagent/ui/lib/sidebar/types"; -import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector"; -import { invokeFs } from "@liveagent/ui/lib/tools/fsBackend"; -import { - assignWorkspaceProjectToGroup, - ensureWorktreeProjectGroup, - fallbackWorkspaceProjectName, - findWorkspaceProject, - mergeWorkspaceProjectsWithHistory, -} from "@liveagent/ui/lib/workspaceProjects"; import { asErrorMessage } from "../chatPageUtils"; import { startWorkspaceCloneTask } from "./cloneTasks"; import { diff --git a/crates/agent-gui/test/tools/workspace-project-groups.test.mjs b/crates/agent-gui/test/tools/workspace-project-groups.test.mjs index 24aa1af5a..b99e524d6 100644 --- a/crates/agent-gui/test/tools/workspace-project-groups.test.mjs +++ b/crates/agent-gui/test/tools/workspace-project-groups.test.mjs @@ -8,8 +8,7 @@ const { ensureWorktreeProjectGroup, buildWorkspaceProjectSections, sliceWorkspaceProjectSections, -} = loader.loadModule("src/lib/workspaceProjects.ts"); - +} = loader.loadModule("@liveagent/ui/lib/workspaceProjects.ts"); function project(id, path, extra = {}) { return { id, @@ -132,10 +131,44 @@ test("sliceWorkspaceProjectSections never splits a group", () => { group("g3", "b", ["/work/b"]), ], ); + // g1 有 2 个成员,超出上限 1 时整组都放不下 → 整组隐藏,绝不拆开。 const sliced = sliceWorkspaceProjectSections(sections, 1); + assert.equal(sliced.sections.grouped.length, 0); + assert.equal(sliced.sections.ungrouped.length, 0); + assert.equal(sliced.hiddenProjectCount, 4); +}); + +test("sliceWorkspaceProjectSections caps ungrouped projects", () => { + const projects = [0, 1, 2, 3, 4].map((index) => + project(`p${index}`, `/work/p${index}`), + ); + const sections = buildWorkspaceProjectSections(projects, []); + const sliced = sliceWorkspaceProjectSections(sections, 2); + assert.deepEqual( + sliced.sections.ungrouped.map((p) => p.id), + ["p0", "p1"], + ); + assert.equal(sliced.hiddenProjectCount, 3); +}); + +test("sliceWorkspaceProjectSections fills remaining capacity with ungrouped", () => { + const repo = project("repo", "/work/repo"); + const wt = project("wt", "/work/wt"); + const a = project("a", "/work/a"); + const b = project("b", "/work/b"); + const sections = buildWorkspaceProjectSections( + [repo, wt, a, b], + [group("g1", "repo", ["/work/repo", "/work/wt"])], + ); + // g1 占 2 个名额,上限 4 的剩余 2 个分给未分组项目。 + const sliced = sliceWorkspaceProjectSections(sections, 4); assert.equal(sliced.sections.grouped.length, 1); assert.equal(sliced.sections.grouped[0].projects.length, 2); - assert.equal(sliced.hiddenProjectCount, 2); + assert.deepEqual( + sliced.sections.ungrouped.map((p) => p.id), + ["a", "b"], + ); + assert.equal(sliced.hiddenProjectCount, 0); }); test("single project belongs to exactly one group at a time", () => { diff --git a/crates/agent-ui/src/lib/workspaceProjects.ts b/crates/agent-ui/src/lib/workspaceProjects.ts index 76693e176..a5280ae13 100644 --- a/crates/agent-ui/src/lib/workspaceProjects.ts +++ b/crates/agent-ui/src/lib/workspaceProjects.ts @@ -378,7 +378,10 @@ export function assignWorkspaceProjectToGroup( : group.projectPaths.filter((path) => workspaceProjectPathKey(path) !== targetKey), }; }); - return touched ? next : next; + // 无变化时返回原引用,让调用方的 `next === prev` 短路生效, + // 避免幂等操作触发多余的 settings 写入/同步。参数虽声明 readonly, + // 实际调用方(settings 系统)传入的都是可变数组,原样返回满足契约。 + return touched ? next : (groups as WorkspaceProjectGroup[]); } /** @@ -470,19 +473,28 @@ export function buildWorkspaceProjectSections( return { grouped, ungrouped }; } -/** 折叠视图按区块切片(绝不拆开分组);hiddenProjectCount 为被隐藏的成员数。 */ +/** 折叠视图按区块切片(绝不拆开分组);hiddenProjectCount 为被隐藏的成员数。 + * 上限是项目总数:分组整组纳入直到容量耗尽,剩余容量分给未分组项目, + * 保证未分组项目(常见场景)也受渲染上限约束。 + */ export function sliceWorkspaceProjectSections( sections: WorkspaceProjectSections, maxGrouped: number, ): { sections: WorkspaceProjectSections; hiddenProjectCount: number } { - const grouped = sections.grouped.slice(0, maxGrouped); + const grouped: WorkspaceProjectSection[] = []; + let usedMembers = 0; + for (const section of sections.grouped) { + if (usedMembers + section.projects.length > maxGrouped) break; + grouped.push(section); + usedMembers += section.projects.length; + } + const ungrouped = sections.ungrouped.slice(0, Math.max(0, maxGrouped - usedMembers)); const totalMembers = sections.ungrouped.length + sections.grouped.reduce((sum, section) => sum + section.projects.length, 0); - const visibleMembers = - sections.ungrouped.length + grouped.reduce((sum, section) => sum + section.projects.length, 0); + const visibleMembers = usedMembers + ungrouped.length; return { - sections: { grouped, ungrouped: sections.ungrouped }, + sections: { grouped, ungrouped }, hiddenProjectCount: Math.max(0, totalMembers - visibleMembers), }; } From dbf8080e9c287c8c1e7ba306aeba900559e2238a Mon Sep 17 00:00:00 2001 From: Morgan Woods Date: Mon, 10 Aug 2026 15:23:13 +0800 Subject: [PATCH 6/9] fix(git): handle unmerged worktree branch removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - git_remove_worktree_sync: force 传导到分支删除,--force 时用 git branch -D 让未合并提交的分支也能被清理 - GitBranchSelector: worktree 已移除但分支未完全合并时,不再重试已注销的 worktree,直接对残留分支提供强制删除确认(复用 deleteForce 文案) - 新增 isGitWorktreeBranchNotFullyMergedError 错误分类纯函数及单测 - Rust 集成测试覆盖普通失败保留分支与 force 成功删除分支两条路径 --- .../src-tauri/src/commands/workspace/git.rs | 101 +++++++++++++++++- .../agent-gui/test/tools/git-types.test.mjs | 25 +++++ .../src/components/git/GitBranchSelector.tsx | 18 ++++ crates/agent-ui/src/lib/git/types.ts | 8 ++ 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 crates/agent-gui/test/tools/git-types.test.mjs diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs index 9c1ef491b..ba84ea707 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs @@ -3199,6 +3199,8 @@ pub(crate) fn git_remove_worktree_sync( Ok(_) => { // worktree 移除成功后,尝试删除其检出的分支;分支删除失败时 // worktree 已不在(重试会提示未登记),错误信息必须说明这一点。 + // force 同时传导到分支删除:`--force` 时用 `-D`, + // 让未合并提交的分支也能被清理。 let branch = delete_branch.as_deref().map(str::trim).unwrap_or(""); if branch.is_empty() { Ok(GitOutput { @@ -3206,7 +3208,8 @@ pub(crate) fn git_remove_worktree_sync( stderr: String::new(), }) } else { - match git_success(&state.repo_root, &["branch", "-d", branch]) { + let delete_flag = if force == Some(true) { "-D" } else { "-d" }; + match git_success(&state.repo_root, &["branch", delete_flag, branch]) { Ok(output) => Ok(output), Err(error) => Err(format!("Worktree 已移除,但分支删除失败:{error}")), } @@ -5069,6 +5072,102 @@ mod tests { ); } + #[test] + fn git_remove_worktree_reports_unmerged_branch_after_removing_worktree() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let created = git_create_worktree_in_base( + workdir.clone(), + "wt-unmerged".to_string(), + None, + worktree_root.path(), + ) + .expect("create worktree"); + let worktree_path = PathBuf::from(&created.worktree_path); + fs::write(worktree_path.join("unmerged.txt"), "unmerged\n").expect("write worktree file"); + run_temp_git(&worktree_path, &["add", "unmerged.txt"]); + run_temp_git( + &worktree_path, + &["commit", "-m", "unmerged worktree commit"], + ); + + let result = git_remove_worktree_sync( + workdir.clone(), + created.worktree_path.clone(), + None, + Some("wt-unmerged".to_string()), + ) + .expect("worktree removal should return an operation response"); + + assert!( + !result.ok, + "unmerged branch deletion should report an error" + ); + assert!( + result.message.contains("Worktree 已移除,但分支删除失败") + && result.message.contains("not fully merged"), + "unexpected removal error: {}", + result.message + ); + assert!( + !worktree_path.exists(), + "worktree should already be removed" + ); + let branches = git_branches_sync(workdir).expect("branches"); + assert!( + branches + .branches + .iter() + .any(|branch| branch.full_name == "wt-unmerged"), + "the unmerged branch should remain available for force deletion" + ); + } + + #[test] + fn git_remove_worktree_force_deletes_unmerged_branch() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let created = git_create_worktree_in_base( + workdir.clone(), + "wt-force-unmerged".to_string(), + None, + worktree_root.path(), + ) + .expect("create worktree"); + let worktree_path = PathBuf::from(&created.worktree_path); + fs::write(worktree_path.join("unmerged.txt"), "unmerged\n").expect("write worktree file"); + run_temp_git(&worktree_path, &["add", "unmerged.txt"]); + run_temp_git( + &worktree_path, + &["commit", "-m", "unmerged worktree commit"], + ); + + let result = git_remove_worktree_sync( + workdir.clone(), + created.worktree_path.clone(), + Some(true), + Some("wt-force-unmerged".to_string()), + ) + .expect("force remove worktree"); + + assert!(result.ok, "force removal failed: {}", result.message); + assert!(!worktree_path.exists(), "worktree should be removed"); + let branches = git_branches_sync(workdir).expect("branches"); + assert!( + !branches + .branches + .iter() + .any(|branch| branch.full_name == "wt-force-unmerged"), + "force removal should delete the unmerged branch" + ); + } + #[test] fn git_remove_worktree_rejects_unregistered_path() { let Some(repo) = init_temp_repo() else { diff --git a/crates/agent-gui/test/tools/git-types.test.mjs b/crates/agent-gui/test/tools/git-types.test.mjs new file mode 100644 index 000000000..0459eeeaa --- /dev/null +++ b/crates/agent-gui/test/tools/git-types.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const { isGitWorktreeBranchNotFullyMergedError } = createTsModuleLoader().loadModule( + "@liveagent/ui/lib/git/types.ts", +); + +test("recognizes an unmerged branch failure after worktree removal", () => { + assert.equal( + isGitWorktreeBranchNotFullyMergedError( + "Worktree 已移除,但分支删除失败:error: The branch 'feature' is not fully merged.", + ), + true, + ); +}); + +test("does not classify dirty worktree removal as an unmerged branch failure", () => { + assert.equal( + isGitWorktreeBranchNotFullyMergedError( + "fatal: contains modified or untracked files, use --force to delete it", + ), + false, + ); +}); diff --git a/crates/agent-ui/src/components/git/GitBranchSelector.tsx b/crates/agent-ui/src/components/git/GitBranchSelector.tsx index fabfe25d3..fd2e70de9 100644 --- a/crates/agent-ui/src/components/git/GitBranchSelector.tsx +++ b/crates/agent-ui/src/components/git/GitBranchSelector.tsx @@ -49,6 +49,7 @@ import type { import { emptyGitRepositoryState, gitDiscoveredRepositoryLabel, + isGitWorktreeBranchNotFullyMergedError, selectedGitRepositoryLabel, } from "../../lib/git/types"; @@ -1174,6 +1175,23 @@ export function GitBranchSelector(props: { resetBranchAction(); return; } + // worktree 已移除但分支未完全合并:不能重试 removeWorktree,直接对 + // 仍然存在的分支执行强制删除,并复用普通分支删除的确认文案。 + if (isGitWorktreeBranchNotFullyMergedError(actionErrorRef.current)) { + const forced = await confirm({ + title: t("git.branchSelector.deleteForceTitle"), + description: t("git.branchSelector.deleteForceDescription"), + confirmLabel: t("git.branchSelector.forceDelete"), + cancelLabel: t("chat.cancel"), + tone: "destructive", + }); + if (!forced) return; + const forcedOk = await runSheetMutation(() => + gitClient.deleteBranch(workdir, branch.fullName, true), + ); + if (forcedOk) resetBranchAction(); + return; + } // Worktree 含未提交改动时 git 拒绝移除 → 提供强制移除的二次确认。 if (!/contains modified or untracked files/i.test(actionErrorRef.current)) return; const forced = await confirm({ diff --git a/crates/agent-ui/src/lib/git/types.ts b/crates/agent-ui/src/lib/git/types.ts index 5ce54c837..6502bba38 100644 --- a/crates/agent-ui/src/lib/git/types.ts +++ b/crates/agent-ui/src/lib/git/types.ts @@ -442,6 +442,14 @@ export function normalizeGitOperationResponse(input: unknown, workdir = ""): Git }; } +/** + * Worktree 已成功移除,但其未完全合并的分支无法被普通删除;此时应 + * 直接引导用户确认强制删除分支,而不是重试已经注销的 Worktree。 + */ +export function isGitWorktreeBranchNotFullyMergedError(message: string): boolean { + return /worktree .*分支删除失败/i.test(message) && /not fully merged/i.test(message); +} + export function normalizeGitWorktreeResponse(input: unknown, workdir = ""): GitWorktreeResponse { const source = asObject(input); return { From 3a51586fc04a2b6b55e15f0d229139ac2cb4632c Mon Sep 17 00:00:00 2001 From: Morgan Woods Date: Tue, 11 Aug 2026 18:16:55 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix(git):=20=E6=9C=AA=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=88=86=E6=94=AF=E5=88=A0=E9=99=A4=E6=94=B9=E4=B8=BA=E5=8D=95?= =?UTF-8?q?=E7=8B=AC=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一提交把 worktree 的 --force 直接传导为分支 -D,导致「丢弃未提交 改动」这一次确认同时授权了「丢弃未合并提交」两个不同的破坏性动作。 - git_remove_worktree_sync: 分支删除恒用 -d,--force 只作用于 worktree 自身的未提交改动;未合并时返回错误交由前端确认 - GitBranchSelector: 抽出 confirmForceDeleteBranch 复用三处确认+强删逻辑 - 补齐 force 移除 worktree 成功但分支残留未合并的路径,避免卡在 「worktree 已删、分支仍在」的中间态 - Rust 测试同步反转断言并改名以描述新契约 --- .../src-tauri/src/commands/workspace/git.rs | 24 +++++--- .../src/components/git/GitBranchSelector.tsx | 61 +++++++++++-------- 2 files changed, 53 insertions(+), 32 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs index ba84ea707..74b387beb 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs @@ -3199,8 +3199,8 @@ pub(crate) fn git_remove_worktree_sync( Ok(_) => { // worktree 移除成功后,尝试删除其检出的分支;分支删除失败时 // worktree 已不在(重试会提示未登记),错误信息必须说明这一点。 - // force 同时传导到分支删除:`--force` 时用 `-D`, - // 让未合并提交的分支也能被清理。 + // force 只用于丢弃 worktree 的未提交改动,分支仍用 `-d`, + // 让前端对未合并提交显示单独的确认。 let branch = delete_branch.as_deref().map(str::trim).unwrap_or(""); if branch.is_empty() { Ok(GitOutput { @@ -3208,8 +3208,7 @@ pub(crate) fn git_remove_worktree_sync( stderr: String::new(), }) } else { - let delete_flag = if force == Some(true) { "-D" } else { "-d" }; - match git_success(&state.repo_root, &["branch", delete_flag, branch]) { + match git_success(&state.repo_root, &["branch", "-d", branch]) { Ok(output) => Ok(output), Err(error) => Err(format!("Worktree 已移除,但分支删除失败:{error}")), } @@ -5127,7 +5126,7 @@ mod tests { } #[test] - fn git_remove_worktree_force_deletes_unmerged_branch() { + fn git_remove_worktree_force_preserves_unmerged_branch_for_confirmation() { let Some(repo) = init_temp_repo() else { return; }; @@ -5156,15 +5155,24 @@ mod tests { ) .expect("force remove worktree"); - assert!(result.ok, "force removal failed: {}", result.message); + assert!( + !result.ok, + "force removal must not delete an unmerged branch" + ); + assert!( + result.message.contains("Worktree 已移除,但分支删除失败") + && result.message.contains("not fully merged"), + "unexpected force removal error: {}", + result.message + ); assert!(!worktree_path.exists(), "worktree should be removed"); let branches = git_branches_sync(workdir).expect("branches"); assert!( - !branches + branches .branches .iter() .any(|branch| branch.full_name == "wt-force-unmerged"), - "force removal should delete the unmerged branch" + "force removal should preserve the unmerged branch for confirmation" ); } diff --git a/crates/agent-ui/src/components/git/GitBranchSelector.tsx b/crates/agent-ui/src/components/git/GitBranchSelector.tsx index fd2e70de9..82a9c8afb 100644 --- a/crates/agent-ui/src/components/git/GitBranchSelector.tsx +++ b/crates/agent-ui/src/components/git/GitBranchSelector.tsx @@ -1109,6 +1109,22 @@ export function GitBranchSelector(props: { } }, [branchAction, t]); + const confirmForceDeleteBranch = useCallback( + async (branch: GitBranchInfo) => { + if (!gitClient) return false; + const forced = await confirm({ + title: t("git.branchSelector.deleteForceTitle"), + description: t("git.branchSelector.deleteForceDescription"), + confirmLabel: t("git.branchSelector.forceDelete"), + cancelLabel: t("chat.cancel"), + tone: "destructive", + }); + if (!forced) return false; + return runSheetMutation(() => gitClient.deleteBranch(workdir, branch.fullName, true)); + }, + [confirm, gitClient, runSheetMutation, t, workdir], + ); + const deleteBranchFlow = useCallback(async () => { if (!branchAction || !gitClient) return; const { branch } = branchAction; @@ -1128,19 +1144,18 @@ export function GitBranchSelector(props: { return; } if (!/not fully merged/i.test(actionErrorRef.current)) return; - const forced = await confirm({ - title: t("git.branchSelector.deleteForceTitle"), - description: t("git.branchSelector.deleteForceDescription"), - confirmLabel: t("git.branchSelector.forceDelete"), - cancelLabel: t("chat.cancel"), - tone: "destructive", - }); - if (!forced) return; - const forcedOk = await runSheetMutation(() => - gitClient.deleteBranch(workdir, branch.fullName, true), - ); + const forcedOk = await confirmForceDeleteBranch(branch); if (forcedOk) resetBranchAction(); - }, [branchAction, confirm, gitClient, resetBranchAction, runSheetMutation, t, workdir]); + }, [ + branchAction, + confirm, + confirmForceDeleteBranch, + gitClient, + resetBranchAction, + runSheetMutation, + t, + workdir, + ]); // 分支被 worktree 检出时,删除入口切换为删除 worktree(连带分支)。 const checkedOutWorktreePath = branchAction @@ -1178,17 +1193,7 @@ export function GitBranchSelector(props: { // worktree 已移除但分支未完全合并:不能重试 removeWorktree,直接对 // 仍然存在的分支执行强制删除,并复用普通分支删除的确认文案。 if (isGitWorktreeBranchNotFullyMergedError(actionErrorRef.current)) { - const forced = await confirm({ - title: t("git.branchSelector.deleteForceTitle"), - description: t("git.branchSelector.deleteForceDescription"), - confirmLabel: t("git.branchSelector.forceDelete"), - cancelLabel: t("chat.cancel"), - tone: "destructive", - }); - if (!forced) return; - const forcedOk = await runSheetMutation(() => - gitClient.deleteBranch(workdir, branch.fullName, true), - ); + const forcedOk = await confirmForceDeleteBranch(branch); if (forcedOk) resetBranchAction(); return; } @@ -1205,10 +1210,18 @@ export function GitBranchSelector(props: { const forcedOk = await runSheetMutation(() => removeWorktree(workdir, worktreePath, true, branch.fullName), ); - if (forcedOk) resetBranchAction(); + if (forcedOk) { + resetBranchAction(); + return; + } + if (isGitWorktreeBranchNotFullyMergedError(actionErrorRef.current)) { + const branchDeleted = await confirmForceDeleteBranch(branch); + if (branchDeleted) resetBranchAction(); + } }, [ branchAction, confirm, + confirmForceDeleteBranch, gitClient, resetBranchAction, runSheetMutation, From 8985dacc9e47683238c1f0da1afd7dc14d826299 Mon Sep 17 00:00:00 2001 From: Morgan Woods Date: Tue, 11 Aug 2026 18:17:04 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix(sidebar):=20=E8=A1=A5=E9=BD=90=E6=97=A0?= =?UTF-8?q?=E5=88=86=E7=BB=84=E6=97=B6=E7=BD=AE=E9=A1=B6=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=9A=84=E5=88=86=E9=9A=94=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit firstUnpinnedSectionIndex 只处理分组区块之间的分隔位置,当工作区完全 没有分组时(最常见场景)置顶项目与普通项目之间缺少分隔线。 - 新增 firstUnpinnedWorkspaceProjectIndex 纯函数及定向单测 - ChatHistorySidebar: 仅在无分组时按未分组项目下标渲染分隔线, 避免与 firstUnpinnedSectionIndex 重复 --- .../tools/workspace-project-groups.test.mjs | 9 ++ .../components/chat/ChatHistorySidebar.tsx | 88 +++++++++++-------- crates/agent-ui/src/lib/workspaceProjects.ts | 7 ++ 3 files changed, 67 insertions(+), 37 deletions(-) diff --git a/crates/agent-gui/test/tools/workspace-project-groups.test.mjs b/crates/agent-gui/test/tools/workspace-project-groups.test.mjs index b99e524d6..34c181c1c 100644 --- a/crates/agent-gui/test/tools/workspace-project-groups.test.mjs +++ b/crates/agent-gui/test/tools/workspace-project-groups.test.mjs @@ -7,6 +7,7 @@ const { assignWorkspaceProjectToGroup, ensureWorktreeProjectGroup, buildWorkspaceProjectSections, + firstUnpinnedWorkspaceProjectIndex, sliceWorkspaceProjectSections, } = loader.loadModule("@liveagent/ui/lib/workspaceProjects.ts"); function project(id, path, extra = {}) { @@ -118,6 +119,14 @@ test("buildWorkspaceProjectSections ignores members missing from the list", () = assert.deepEqual(sections.grouped[0].projects.map((p) => p.id), ["repo"]); }); +test("firstUnpinnedWorkspaceProjectIndex marks the divider inside ungrouped projects", () => { + const pinned = project("pinned", "/work/pinned", { isPinned: true, pinnedAt: 2 }); + const regular = project("regular", "/work/regular"); + assert.equal(firstUnpinnedWorkspaceProjectIndex([pinned, regular]), 1); + assert.equal(firstUnpinnedWorkspaceProjectIndex([regular, pinned]), -1); + assert.equal(firstUnpinnedWorkspaceProjectIndex([pinned]), -1); +}); + test("sliceWorkspaceProjectSections never splits a group", () => { const repo = project("repo", "/work/repo"); const wt = project("wt", "/work/wt"); diff --git a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx index b19f7a3c3..20d5d049e 100644 --- a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx @@ -70,6 +70,7 @@ import { import type { SidebarConversation } from "../../lib/sidebar/types"; import { buildWorkspaceProjectSections, + firstUnpinnedWorkspaceProjectIndex, sliceWorkspaceProjectSections, } from "../../lib/workspaceProjects"; import type { WorkspaceProjectGroup } from "../../lib/workspaceProjectTypes"; @@ -2137,6 +2138,10 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi if (renderedSections.ungrouped[0]?.isPinned === true) return -1; return renderedSections.grouped.length; }, [renderedSections]); + const firstUnpinnedUngroupedIndex = + renderedSections.grouped.length === 0 + ? firstUnpinnedWorkspaceProjectIndex(renderedSections.ungrouped) + : -1; // Archiving must always leave at least one active workspace behind. const canArchiveProjects = Boolean(onArchiveProject) && activeProjects.length > 1; const [archivedGroupOpen, setArchivedGroupOpen] = useState(false); @@ -2999,45 +3004,54 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi {t("chat.workspaceUngrouped")} ) : null} - {renderedSections.ungrouped.map((project) => { + {renderedSections.ungrouped.map((project, projectIndex) => { const pathKey = workspaceProjectPathKey(project.path); return ( - + + {projectIndex === firstUnpinnedUngroupedIndex ? ( +