Skip to content

Commit 3172561

Browse files
authored
Merge pull request Stack-Cairn#227 from Mieluoxxx/fix/workspace-clone-default-branch
feat(workspace): clone repositories in the background
2 parents fa72652 + 2556edc commit 3172561

17 files changed

Lines changed: 1141 additions & 41 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", "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: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ import {
148148
import { findWorkspaceProject, mergeWorkspaceProjectsWithHistory } from "@/lib/workspaceProjects";
149149
import { FloorNavRail } from "@/pages/chat/transcript/FloorNavRail";
150150
import { WorkspaceCloneModal } from "@/pages/chat/WorkspaceCloneModal";
151+
import {
152+
type WorkspaceCloneTask,
153+
WorkspaceCloneTaskOverlay,
154+
} from "@/pages/chat/WorkspaceCloneTaskOverlay";
151155
import { LoginPage } from "@/pages/LoginPage";
152156
import { SettingsSyncLoading } from "@/pages/SettingsSyncLoading";
153157
import { SharedHistoryPage } from "@/pages/SharedHistoryPage";
@@ -235,6 +239,8 @@ export default function GatewayApp() {
235239
clearSession,
236240
} = useGatewaySession(historyShareToken);
237241
const { api, terminalClient, sftpClient, gitClient } = useGatewayClients(token);
242+
const [workspaceCloneTasks, setWorkspaceCloneTasks] = useState<WorkspaceCloneTask[]>([]);
243+
const dismissedWorkspaceCloneTaskIds = useRef(new Set<string>());
238244
const [status, setStatus] = useState<AgentStatus | null>(null);
239245
const [statusError, setStatusError] = useState<string | null>(null);
240246
// True only after an authenticated gateway connection has been established
@@ -1167,19 +1173,89 @@ export default function GatewayApp() {
11671173
if (!api) {
11681174
throw new Error("网关未连接。");
11691175
}
1170-
const response = await api.gitRequest<{ state?: { workdir?: string } }>("clone", parent, {
1176+
const task = await api.gitRequest<WorkspaceCloneTask>("clone_start", parent, {
11711177
name,
11721178
remoteUrl,
11731179
branch: branch || undefined,
11741180
});
1175-
const path = response.state?.workdir?.trim();
1176-
if (!path) {
1177-
throw new Error("克隆完成后未返回工作空间路径。");
1178-
}
1181+
dismissedWorkspaceCloneTaskIds.current.delete(task.id);
1182+
setWorkspaceCloneTasks((tasks) => [task, ...tasks.filter((item) => item.id !== task.id)]);
1183+
},
1184+
[api],
1185+
);
1186+
1187+
const refreshWorkspaceCloneTasks = useCallback(async () => {
1188+
if (!api) return;
1189+
const tasks = await api.gitRequest<WorkspaceCloneTask[]>("clone_tasks", "");
1190+
setWorkspaceCloneTasks(
1191+
tasks.filter((task) => !dismissedWorkspaceCloneTaskIds.current.has(task.id)),
1192+
);
1193+
}, [api]);
1194+
1195+
useEffect(() => {
1196+
void refreshWorkspaceCloneTasks().catch(() => {
1197+
// The next clone operation or connection change retries.
1198+
});
1199+
}, [refreshWorkspaceCloneTasks]);
1200+
1201+
const hasActiveWorkspaceCloneTask = workspaceCloneTasks.some(
1202+
(task) => task.status === "running" || task.status === "cancelling",
1203+
);
1204+
1205+
useEffect(() => {
1206+
if (!hasActiveWorkspaceCloneTask) return;
1207+
const timer = window.setInterval(() => {
1208+
void refreshWorkspaceCloneTasks().catch(() => {
1209+
// Keep the last task snapshot visible while transport reconnects.
1210+
});
1211+
}, 750);
1212+
return () => window.clearInterval(timer);
1213+
}, [hasActiveWorkspaceCloneTask, refreshWorkspaceCloneTasks]);
1214+
1215+
const handleCancelWorkspaceCloneTask = useCallback(
1216+
(taskId: string) => {
1217+
if (!api) return;
1218+
void api
1219+
.gitRequest<WorkspaceCloneTask>("clone_cancel", "", { taskId })
1220+
.then((task) =>
1221+
setWorkspaceCloneTasks((tasks) =>
1222+
tasks.map((item) => (item.id === task.id ? task : item)),
1223+
),
1224+
)
1225+
.catch(() => {
1226+
// Keep the running task visible so the next poll can reconcile it.
1227+
});
1228+
},
1229+
[api],
1230+
);
1231+
1232+
const handleDismissWorkspaceCloneTask = useCallback(
1233+
(taskId: string) => {
1234+
dismissedWorkspaceCloneTaskIds.current.add(taskId);
1235+
setWorkspaceCloneTasks((tasks) => tasks.filter((task) => task.id !== taskId));
1236+
if (!api) return;
1237+
// 服务端同步移除终态任务,否则刷新页面后快照会让卡片重现。
1238+
void api
1239+
.gitRequest<WorkspaceCloneTask[]>("clone_dismiss", "", { taskId })
1240+
.then((tasks) => {
1241+
dismissedWorkspaceCloneTaskIds.current.delete(taskId);
1242+
setWorkspaceCloneTasks(
1243+
tasks.filter((task) => !dismissedWorkspaceCloneTaskIds.current.has(task.id)),
1244+
);
1245+
})
1246+
.catch(() => {
1247+
// 本地 dismissed 集合已隐藏该卡片;服务端移除失败留待下次快照。
1248+
});
1249+
},
1250+
[api],
1251+
);
1252+
1253+
const handleOpenClonedWorkspace = useCallback(
1254+
(path: string) => {
11791255
activateWorkspaceProject(createWorkspaceProjectFromPath(path, "managed"));
11801256
void sidebarStore.refreshWorkdirs("new-workdir");
11811257
},
1182-
[activateWorkspaceProject, api, sidebarStore],
1258+
[activateWorkspaceProject, sidebarStore],
11831259
);
11841260

11851261
const handleLoadWorkspaceRemoteBranches = useCallback(
@@ -4282,6 +4358,12 @@ export default function GatewayApp() {
42824358
onClose={() => setWorkspaceCreateModalOpen(false)}
42834359
/>
42844360
) : null}
4361+
<WorkspaceCloneTaskOverlay
4362+
tasks={workspaceCloneTasks}
4363+
onCancel={handleCancelWorkspaceCloneTask}
4364+
onDismiss={handleDismissWorkspaceCloneTask}
4365+
onOpenWorkspace={handleOpenClonedWorkspace}
4366+
/>
42854367

42864368
{confirmDialog}
42874369

crates/agent-gateway/web/src/i18n/config.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,17 @@ export const translations: Record<Locale, Record<string, string>> = {
5151
"chat.workspaceCloneBranch": "分支",
5252
"chat.workspaceCloneBranchPlaceholder": "请选择分支",
5353
"chat.workspaceCloneBranchesLoading": "正在读取分支…",
54-
"chat.workspaceCloneSubmit": "克隆并打开",
55-
"chat.workspaceCloning": "正在克隆…",
54+
"chat.workspaceCloneTaskCancel": "取消",
55+
"chat.workspaceCloneTaskOpen": "打开工作空间",
56+
"chat.workspaceCloneTaskPhase.preparing": "正在准备克隆",
57+
"chat.workspaceCloneTaskPhase.receiving": "正在下载对象",
58+
"chat.workspaceCloneTaskPhase.resolving": "正在解析差异",
59+
"chat.workspaceCloneTaskPhase.finalizing": "正在完成克隆",
60+
"chat.workspaceCloneTaskPhase.completed": "克隆完成",
61+
"chat.workspaceCloneTaskPhase.failed": "克隆失败",
62+
"chat.workspaceCloneTaskPhase.cancelled": "克隆已取消",
63+
"chat.workspaceCloneSubmit": "开始克隆",
64+
"chat.workspaceCloning": "正在启动克隆…",
5665
"chat.workspaceCloneWebDisabled": "请先在远程设置中启用网页 Git 操作。",
5766
"chat.workspaceNewConversation": "新对话",
5867
"chat.workspaceMore": "更多",
@@ -1963,8 +1972,17 @@ export const translations: Record<Locale, Record<string, string>> = {
19631972
"chat.workspaceCloneBranch": "Branch",
19641973
"chat.workspaceCloneBranchPlaceholder": "Select a branch",
19651974
"chat.workspaceCloneBranchesLoading": "Loading branches…",
1966-
"chat.workspaceCloneSubmit": "Clone and open",
1967-
"chat.workspaceCloning": "Cloning…",
1975+
"chat.workspaceCloneTaskCancel": "Cancel",
1976+
"chat.workspaceCloneTaskOpen": "Open workspace",
1977+
"chat.workspaceCloneTaskPhase.preparing": "Preparing clone",
1978+
"chat.workspaceCloneTaskPhase.receiving": "Receiving objects",
1979+
"chat.workspaceCloneTaskPhase.resolving": "Resolving deltas",
1980+
"chat.workspaceCloneTaskPhase.finalizing": "Finishing clone",
1981+
"chat.workspaceCloneTaskPhase.completed": "Clone complete",
1982+
"chat.workspaceCloneTaskPhase.failed": "Clone failed",
1983+
"chat.workspaceCloneTaskPhase.cancelled": "Clone cancelled",
1984+
"chat.workspaceCloneSubmit": "Start clone",
1985+
"chat.workspaceCloning": "Starting clone…",
19681986
"chat.workspaceCloneWebDisabled": "Enable Web Git in Remote settings before cloning.",
19691987
"chat.workspaceNewConversation": "New conversation",
19701988
"chat.workspaceMore": "More",
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { Ban, CheckCircle2, FolderOpen, GitBranch, Loader2, X, XCircle } from "@/components/icons";
2+
import { Button } from "@/components/ui/button";
3+
import { useLocale } from "@/i18n";
4+
5+
export type WorkspaceCloneTask = {
6+
id: string;
7+
repositoryName: string;
8+
targetPath: string;
9+
status: "running" | "cancelling" | "completed" | "failed" | "cancelled";
10+
phase:
11+
| "preparing"
12+
| "receiving"
13+
| "resolving"
14+
| "finalizing"
15+
| "completed"
16+
| "failed"
17+
| "cancelled";
18+
progress: number | null;
19+
detail: string;
20+
error: string;
21+
};
22+
23+
type WorkspaceCloneTaskOverlayProps = {
24+
tasks: WorkspaceCloneTask[];
25+
onCancel: (taskId: string) => void;
26+
onDismiss: (taskId: string) => void;
27+
onOpenWorkspace: (path: string) => void;
28+
};
29+
30+
function TaskIcon({ task }: { task: WorkspaceCloneTask }) {
31+
if (task.status === "completed")
32+
return <CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />;
33+
if (task.status === "failed") return <XCircle className="h-4 w-4 text-destructive" />;
34+
if (task.status === "cancelled") return <Ban className="h-4 w-4 text-muted-foreground" />;
35+
return <Loader2 className="h-4 w-4 animate-spin text-sky-600 dark:text-sky-300" />;
36+
}
37+
38+
function CloneTaskCard({
39+
task,
40+
onCancel,
41+
onDismiss,
42+
onOpenWorkspace,
43+
}: WorkspaceCloneTaskOverlayProps & { task: WorkspaceCloneTask }) {
44+
const { t } = useLocale();
45+
const active = task.status === "running" || task.status === "cancelling";
46+
const progress = task.progress === null ? null : Math.min(100, Math.max(0, task.progress));
47+
const message = task.status === "failed" ? task.error || task.detail : task.detail;
48+
49+
return (
50+
<section
51+
className="pointer-events-auto w-80 overflow-hidden rounded-xl border border-border/70 bg-background/95 shadow-2xl backdrop-blur-xl"
52+
aria-live="polite"
53+
>
54+
<div className="flex items-start gap-2.5 px-3.5 pb-2 pt-3">
55+
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-muted/70">
56+
<TaskIcon task={task} />
57+
</div>
58+
<div className="min-w-0 flex-1">
59+
<div className="flex items-center gap-2">
60+
<GitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
61+
<span className="min-w-0 flex-1 truncate text-sm font-medium">
62+
{task.repositoryName}
63+
</span>
64+
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
65+
{progress === null || !active ? "" : `${progress}%`}
66+
</span>
67+
</div>
68+
<p
69+
className={`mt-1 truncate text-xs ${task.status === "failed" ? "text-destructive" : "text-muted-foreground"}`}
70+
>
71+
{active ? t(`chat.workspaceCloneTaskPhase.${task.phase}`) : message}
72+
</p>
73+
</div>
74+
{!active ? (
75+
<Button
76+
type="button"
77+
variant="ghost"
78+
size="icon"
79+
className="-mr-1 -mt-1 h-7 w-7 shrink-0 text-muted-foreground"
80+
onClick={() => onDismiss(task.id)}
81+
aria-label={t("settings.cancel")}
82+
>
83+
<X className="h-3.5 w-3.5" />
84+
</Button>
85+
) : null}
86+
</div>
87+
{active ? (
88+
<div className="px-3.5 pb-3">
89+
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
90+
{progress === null ? (
91+
<div className="h-full w-2/5 animate-[hubLoadingProgress_1.45s_cubic-bezier(0.4,0,0.2,1)_infinite] rounded-full bg-sky-500" />
92+
) : (
93+
<div
94+
className="h-full rounded-full bg-sky-500 transition-[width] duration-200"
95+
style={{ width: `${progress}%` }}
96+
/>
97+
)}
98+
</div>
99+
<div className="mt-2 flex items-center justify-between gap-2">
100+
<span className="min-w-0 truncate text-[11px] text-muted-foreground">
101+
{task.detail}
102+
</span>
103+
{task.status === "running" ? (
104+
<Button
105+
type="button"
106+
variant="ghost"
107+
size="sm"
108+
className="h-7 shrink-0 px-2 text-xs text-muted-foreground hover:text-destructive"
109+
onClick={() => onCancel(task.id)}
110+
>
111+
<Ban className="h-3.5 w-3.5" />
112+
{t("chat.workspaceCloneTaskCancel")}
113+
</Button>
114+
) : null}
115+
</div>
116+
</div>
117+
) : task.status === "completed" ? (
118+
<div className="flex justify-end border-t border-border/60 bg-muted/20 px-3.5 py-2.5">
119+
<Button
120+
type="button"
121+
size="sm"
122+
className="h-7 px-2.5 text-xs"
123+
onClick={() => onOpenWorkspace(task.targetPath)}
124+
>
125+
<FolderOpen className="h-3.5 w-3.5" />
126+
{t("chat.workspaceCloneTaskOpen")}
127+
</Button>
128+
</div>
129+
) : null}
130+
</section>
131+
);
132+
}
133+
134+
export function WorkspaceCloneTaskOverlay(props: WorkspaceCloneTaskOverlayProps) {
135+
if (props.tasks.length === 0) return null;
136+
return (
137+
<div className="pointer-events-none fixed bottom-4 right-4 z-[70] flex max-h-[calc(100vh-2rem)] flex-col-reverse gap-2 overflow-y-auto">
138+
{props.tasks.map((task) => (
139+
<CloneTaskCard key={task.id} task={task} {...props} />
140+
))}
141+
</div>
142+
);
143+
}

0 commit comments

Comments
 (0)