Skip to content

Commit 2556edc

Browse files
committed
fix(workspace): correct clone progress math and persist task dismissal
- parse_git_clone_progress computed phase progress in u8, so percent.saturating_mul(80) clamped to 255 for any percent >= 4 and the receiving phase reported 5-7% for the whole download; widen to u16 (with a 100 cap) before scaling, and cover the parser with unit tests - split clone stderr chunks on both \r and \n before applying them: newline-terminated lines (Cloning into/remote:) used to merge with the next progress line into one multi-line detail string - dismissing a finished task card now also removes it from the desktop registry via clone_dismiss (new Tauri command + gateway git action, write-gated in guard.go like clone_start/clone_cancel); previously the dismissed-set lived only in page state, so a WebUI refresh resurrected every historical card and terminal tasks leaked until app exit; in-flight tasks refuse dismissal - relax the WebUI clone poll from 250ms to 750ms: each tick crosses the WS gateway into a desktop spawn_blocking round-trip, too chatty for remote/mobile sessions (desktop stays at 250ms over local IPC)
1 parent 7771b59 commit 2556edc

5 files changed

Lines changed: 138 additions & 23 deletions

File tree

crates/agent-gateway/internal/protocol/pbws/guard.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ func vetAgentRequest(sm *session.Manager, env *gatewayv1.GatewayEnvelope) error
108108
// enable_web_git 门控,读操作(status/log/diff 等)始终放行。
109109
func gitActionIsWrite(action string) bool {
110110
switch action {
111-
case "clone", "clone_start", "clone_cancel", "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":
111+
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":
112112
return true
113113
default:
114114
return false

crates/agent-gateway/web/src/app/GatewayApp.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,7 +1205,7 @@ export default function GatewayApp() {
12051205
void refreshWorkspaceCloneTasks().catch(() => {
12061206
// Keep the last task snapshot visible while transport reconnects.
12071207
});
1208-
}, 250);
1208+
}, 750);
12091209
return () => window.clearInterval(timer);
12101210
}, [hasActiveWorkspaceCloneTask, refreshWorkspaceCloneTasks]);
12111211

@@ -1226,10 +1226,26 @@ export default function GatewayApp() {
12261226
[api],
12271227
);
12281228

1229-
const handleDismissWorkspaceCloneTask = useCallback((taskId: string) => {
1230-
dismissedWorkspaceCloneTaskIds.current.add(taskId);
1231-
setWorkspaceCloneTasks((tasks) => tasks.filter((task) => task.id !== taskId));
1232-
}, []);
1229+
const handleDismissWorkspaceCloneTask = useCallback(
1230+
(taskId: string) => {
1231+
dismissedWorkspaceCloneTaskIds.current.add(taskId);
1232+
setWorkspaceCloneTasks((tasks) => tasks.filter((task) => task.id !== taskId));
1233+
if (!api) return;
1234+
// 服务端同步移除终态任务,否则刷新页面后快照会让卡片重现。
1235+
void api
1236+
.gitRequest<WorkspaceCloneTask[]>("clone_dismiss", "", { taskId })
1237+
.then((tasks) => {
1238+
dismissedWorkspaceCloneTaskIds.current.delete(taskId);
1239+
setWorkspaceCloneTasks(
1240+
tasks.filter((task) => !dismissedWorkspaceCloneTaskIds.current.has(task.id)),
1241+
);
1242+
})
1243+
.catch(() => {
1244+
// 本地 dismissed 集合已隐藏该卡片;服务端移除失败留待下次快照。
1245+
});
1246+
},
1247+
[api],
1248+
);
12331249

12341250
const handleOpenClonedWorkspace = useCallback(
12351251
(path: string) => {

crates/agent-gui/src-tauri/src/commands/workspace/git.rs

Lines changed: 106 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,24 @@ impl GitCloneTaskRegistry {
376376
.ok_or_else(|| "找不到克隆任务。".to_string())
377377
}
378378

379+
/// 终态任务的唯一清理路径:用户关闭任务卡时从注册表移除,
380+
/// 否则刷新/重连后快照会让已关闭的卡片重现。运行中的任务拒绝移除。
381+
pub fn dismiss(&self, id: String) -> Result<(), String> {
382+
let mut tasks = self
383+
.tasks
384+
.lock()
385+
.map_err(|_| "克隆任务注册表不可用。".to_string())?;
386+
let id = id.trim();
387+
let Some(entry) = tasks.get(id) else {
388+
return Ok(());
389+
};
390+
if entry.task.status == "running" || entry.task.status == "cancelling" {
391+
return Err("克隆任务仍在进行,无法移除。".to_string());
392+
}
393+
tasks.remove(id);
394+
Ok(())
395+
}
396+
379397
fn run(
380398
self: Arc<Self>,
381399
id: String,
@@ -457,20 +475,25 @@ impl GitCloneTaskRegistry {
457475
}
458476

459477
fn apply_output(&self, id: &str, chunk: &str) {
460-
let detail = chunk.trim().to_string();
461-
if detail.is_empty() {
462-
return;
463-
}
464-
self.update(id, |task| {
465-
if task.status != "running" {
466-
return;
467-
}
468-
task.detail = detail.clone();
469-
if let Some((phase, progress)) = parse_git_clone_progress(&detail) {
470-
task.phase = phase.to_string();
471-
task.progress = Some(progress);
478+
// 读取线程按 \r 切块,但 git 的非进度输出(Cloning into/remote: 等)
479+
// 以 \n 结尾,会与下一条进度行合并进同一 chunk——逐行处理防止
480+
// detail 混入多行文本。
481+
for line in chunk.split(['\r', '\n']) {
482+
let detail = line.trim();
483+
if detail.is_empty() {
484+
continue;
472485
}
473-
});
486+
self.update(id, |task| {
487+
if task.status != "running" {
488+
return;
489+
}
490+
task.detail = detail.to_string();
491+
if let Some((phase, progress)) = parse_git_clone_progress(detail) {
492+
task.phase = phase.to_string();
493+
task.progress = Some(progress);
494+
}
495+
});
496+
}
474497
}
475498

476499
fn fail(&self, id: &str, error: String, target: &Path) {
@@ -525,15 +548,16 @@ fn parse_git_clone_progress(line: &str) -> Option<(&'static str, u8)> {
525548
.trim()
526549
.parse::<u8>()
527550
.ok()
551+
.map(|percent| percent.min(100) as u16)
528552
};
529553
if let Some(percent) = parse_percent("Receiving objects:") {
530-
return Some(("receiving", 5 + percent.saturating_mul(80) / 100));
554+
return Some(("receiving", (5 + percent * 80 / 100) as u8));
531555
}
532556
if let Some(percent) = parse_percent("Resolving deltas:") {
533-
return Some(("resolving", 85 + percent.saturating_mul(15) / 100));
557+
return Some(("resolving", (85 + percent * 15 / 100) as u8));
534558
}
535559
if let Some(percent) = parse_percent("Checking out files:") {
536-
return Some(("finalizing", 95 + percent.saturating_mul(5) / 100));
560+
return Some(("finalizing", (95 + percent * 5 / 100) as u8));
537561
}
538562
if line.starts_with("remote:") || line.starts_with("Cloning into") {
539563
return Some(("preparing", 5));
@@ -3088,6 +3112,11 @@ pub(crate) fn git_gateway_clone_task_action_sync(
30883112
.map_err(|error| format!("序列化 Git 响应失败:{error}")),
30893113
"clone_cancel" => serde_json::to_value(registry.cancel(args.task_id.unwrap_or_default())?)
30903114
.map_err(|error| format!("序列化 Git 响应失败:{error}")),
3115+
"clone_dismiss" => {
3116+
registry.dismiss(args.task_id.unwrap_or_default())?;
3117+
serde_json::to_value(registry.snapshot()?)
3118+
.map_err(|error| format!("序列化 Git 响应失败:{error}"))
3119+
}
30913120
_ => git_gateway_action_sync(action, workdir, args_json),
30923121
}
30933122
}
@@ -3196,6 +3225,15 @@ pub fn git_clone_repository_cancel(
31963225
registry.cancel(task_id)
31973226
}
31983227

3228+
#[tauri::command(rename_all = "snake_case")]
3229+
pub fn git_clone_repository_dismiss(
3230+
registry: tauri::State<'_, Arc<GitCloneTaskRegistry>>,
3231+
task_id: String,
3232+
) -> Result<Vec<GitCloneTask>, String> {
3233+
registry.dismiss(task_id)?;
3234+
registry.snapshot()
3235+
}
3236+
31993237
#[tauri::command(rename_all = "snake_case")]
32003238
pub async fn git_list_remote_branches(
32013239
remote_url: String,
@@ -3689,8 +3727,12 @@ mod tests {
36893727
},
36903728
);
36913729

3692-
let cancelled = registry.cancel(task.id).expect("cancel clone task");
3730+
let cancelled = registry.cancel(task.id.clone()).expect("cancel clone task");
36933731
assert_eq!(cancelled.status, "cancelling");
3732+
assert!(
3733+
registry.dismiss(task.id).is_err(),
3734+
"in-flight clone task must refuse dismissal"
3735+
);
36943736
let deadline = Instant::now() + Duration::from_secs(1);
36953737
loop {
36963738
if child.try_wait().expect("read clone process").is_some() {
@@ -3796,6 +3838,53 @@ mod tests {
37963838
.expect("task snapshot")
37973839
.iter()
37983840
.any(|task| task["id"] == task_id));
3841+
let dismissed = git_gateway_clone_task_action_sync(
3842+
"clone_dismiss".to_string(),
3843+
String::new(),
3844+
json!({ "taskId": task_id }).to_string(),
3845+
&registry,
3846+
)
3847+
.expect("dismiss gateway clone task");
3848+
assert!(
3849+
dismissed.as_array().expect("dismissed snapshot").is_empty(),
3850+
"dismissed task must leave the registry"
3851+
);
3852+
}
3853+
3854+
#[test]
3855+
fn parses_git_clone_progress_lines() {
3856+
assert_eq!(
3857+
parse_git_clone_progress("Cloning into '.'..."),
3858+
Some(("preparing", 5))
3859+
);
3860+
assert_eq!(
3861+
parse_git_clone_progress("remote: Enumerating objects: 1553, done."),
3862+
Some(("preparing", 5))
3863+
);
3864+
assert_eq!(
3865+
parse_git_clone_progress("Receiving objects: 0% (1/1553)"),
3866+
Some(("receiving", 5))
3867+
);
3868+
assert_eq!(
3869+
parse_git_clone_progress("Receiving objects: 50% (777/1553), 10.20 MiB | 5.00 MiB/s"),
3870+
Some(("receiving", 45))
3871+
);
3872+
assert_eq!(
3873+
parse_git_clone_progress("Receiving objects: 100% (1553/1553), done."),
3874+
Some(("receiving", 85))
3875+
);
3876+
assert_eq!(
3877+
parse_git_clone_progress("Resolving deltas: 100% (900/900), done."),
3878+
Some(("resolving", 100))
3879+
);
3880+
assert_eq!(
3881+
parse_git_clone_progress("Checking out files: 40% (200/500)"),
3882+
Some(("finalizing", 97))
3883+
);
3884+
assert_eq!(
3885+
parse_git_clone_progress("fatal: repository not found"),
3886+
None
3887+
);
37993888
}
38003889

38013890
#[test]

crates/agent-gui/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ macro_rules! app_invoke_handler {
196196
commands::git::git_clone_repository_start,
197197
commands::git::git_clone_repository_tasks,
198198
commands::git::git_clone_repository_cancel,
199+
commands::git::git_clone_repository_dismiss,
199200
commands::git::git_list_remote_branches,
200201
commands::git::git_switch_branch,
201202
commands::git::git_create_branch,

crates/agent-gui/src/pages/chat/workspace/cloneTasks.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@ export async function cancelWorkspaceCloneTask(taskId: string) {
8888
export function dismissWorkspaceCloneTask(taskId: string) {
8989
dismissedTaskIds.add(taskId);
9090
replaceTasks(tasks.filter((task) => task.id !== taskId));
91+
// 服务端同步移除终态任务,否则重新挂载后快照会让卡片重现。
92+
void invoke<WorkspaceCloneTask[]>("git_clone_repository_dismiss", { task_id: taskId })
93+
.then((nextTasks) => {
94+
dismissedTaskIds.delete(taskId);
95+
replaceTasks(nextTasks);
96+
})
97+
.catch(() => {
98+
// 本地 dismissedTaskIds 已隐藏该卡片;服务端移除失败留待下次快照。
99+
});
91100
}
92101

93102
function subscribe(listener: () => void) {

0 commit comments

Comments
 (0)