Skip to content

Commit b8a41ea

Browse files
feat: Display real-time clone progress in UI (#128)
## Summary Adds visual feedback for repository cloning operations to improve onboarding UX. Users now see real-time clone progress when clicking "Run (Local)" on a task that requires cloning a repository. ![CleanShot 2025-11-12 at 12 30 53](https://github.com/user-attachments/assets/3a880675-1bb8-4a37-ac51-99c1ae96eb82) ### UI Changes - **Button text** shows current clone stage (e.g., "Receiving objects", "Resolving deltas", "Updating files") - **Progress bar** underneath button displays percentage completion (0-100%) - **Zero delay** - instant UI feedback when initiating clone (no waiting for IPC round-trip) ### Technical Implementation - Frontend generates `cloneId` before IPC call to eliminate UI delay - Added `--progress` flag to git clone for progress output in non-interactive environments - Parse progress from git stderr using regex and update UI in real-time - Clean architecture: frontend controls clone state creation, backend executes clone ### Test Plan - [ ] Delete posthog repository folder - [ ] Click "Run (Local)" on a task - [ ] Verify button immediately shows "Cloning PostHog/posthog..." - [ ] Verify progress bar appears and fills from 0% to 100% - [ ] Verify button text updates through stages: "Receiving objects", "Resolving deltas", "Updating files" - [ ] Verify task starts automatically after clone completes
1 parent 2602982 commit b8a41ea

File tree

9 files changed

+268
-72
lines changed

9 files changed

+268
-72
lines changed

src/main/preload.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
9494
cloneRepository: (
9595
repoUrl: string,
9696
targetPath: string,
97+
cloneId: string,
9798
): Promise<{ cloneId: string }> =>
98-
ipcRenderer.invoke("clone-repository", repoUrl, targetPath),
99+
ipcRenderer.invoke("clone-repository", repoUrl, targetPath, cloneId),
99100
onCloneProgress: (
100101
cloneId: string,
101102
listener: (event: {

src/main/services/git.ts

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,6 @@ interface ValidationResult {
2626
error?: string;
2727
}
2828

29-
const generateCloneId = () =>
30-
`clone-${Date.now()}-${Math.random().toString(36).substring(7)}`;
31-
3229
const sendCloneProgress = (
3330
win: BrowserWindow,
3431
cloneId: string,
@@ -54,9 +51,16 @@ export const isGitRepository = async (
5451
directoryPath: string,
5552
): Promise<boolean> => {
5653
try {
54+
// Check if it's a git work tree
5755
await execAsync("git rev-parse --is-inside-work-tree", {
5856
cwd: directoryPath,
5957
});
58+
59+
// Also check if there's at least one commit (not an empty/cloning repo)
60+
await execAsync("git rev-parse HEAD", {
61+
cwd: directoryPath,
62+
});
63+
6064
return true;
6165
} catch {
6266
return false;
@@ -274,20 +278,61 @@ export function registerGitIpc(
274278
targetPath: string,
275279
win: BrowserWindow,
276280
): ChildProcess => {
277-
const cloneProcess = exec(`git clone "${repoUrl}" "${targetPath}"`, {
278-
maxBuffer: CLONE_MAX_BUFFER,
279-
});
281+
// Expand home directory for SSH config path
282+
const homeDir = os.homedir();
283+
const sshConfigPath = path.join(homeDir, ".ssh", "config");
284+
285+
// Use GIT_SSH_COMMAND to ensure SSH uses the config file
286+
const env = {
287+
...process.env,
288+
GIT_SSH_COMMAND: `ssh -F ${sshConfigPath}`,
289+
};
290+
291+
const cloneProcess = exec(
292+
`git clone --progress "${repoUrl}" "${targetPath}"`,
293+
{
294+
maxBuffer: CLONE_MAX_BUFFER,
295+
env,
296+
},
297+
);
280298

281299
sendCloneProgress(win, cloneId, {
282300
status: "cloning",
283301
message: `Cloning ${repoUrl}...`,
284302
});
285303

304+
// Collect all output for debugging
305+
let _stdoutData = "";
306+
let stderrData = "";
307+
308+
cloneProcess.stdout?.on("data", (data: Buffer) => {
309+
const text = data.toString();
310+
_stdoutData += text;
311+
if (activeClones.get(cloneId)) {
312+
sendCloneProgress(win, cloneId, {
313+
status: "cloning",
314+
message: text.trim(),
315+
});
316+
}
317+
});
318+
286319
cloneProcess.stderr?.on("data", (data: Buffer) => {
320+
const text = data.toString();
321+
stderrData += text;
322+
287323
if (activeClones.get(cloneId)) {
324+
// Parse progress from git output (e.g., "Receiving objects: 45% (6234/13948)")
325+
const progressMatch = text.match(/(\w+\s+\w+):\s+(\d+)%/);
326+
let progressMessage = text.trim();
327+
328+
if (progressMatch) {
329+
const [, stage, percent] = progressMatch;
330+
progressMessage = `${stage}: ${percent}%`;
331+
}
332+
288333
sendCloneProgress(win, cloneId, {
289334
status: "cloning",
290-
message: data.toString().trim(),
335+
message: progressMessage,
291336
});
292337
}
293338
});
@@ -299,13 +344,14 @@ export function registerGitIpc(
299344
const message =
300345
code === 0
301346
? "Repository cloned successfully"
302-
: `Clone failed with exit code ${code}`;
347+
: `Clone failed with exit code ${code}. stderr: ${stderrData}`;
303348

304349
sendCloneProgress(win, cloneId, { status, message });
305350
activeClones.delete(cloneId);
306351
});
307352

308353
cloneProcess.on("error", (error: Error) => {
354+
console.error(`[git clone] Process error:`, error);
309355
if (activeClones.get(cloneId)) {
310356
sendCloneProgress(win, cloneId, {
311357
status: "error",
@@ -324,8 +370,8 @@ export function registerGitIpc(
324370
_event: IpcMainInvokeEvent,
325371
repoUrl: string,
326372
targetPath: string,
373+
cloneId: string,
327374
): Promise<{ cloneId: string }> => {
328-
const cloneId = generateCloneId();
329375
const win = getMainWindow();
330376

331377
if (!win) throw new Error("Main window not available");

src/renderer/features/task-detail/components/TaskActions.tsx

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { GearIcon, GlobeIcon } from "@radix-ui/react-icons";
2-
import { Button, Flex, IconButton, Tooltip } from "@radix-ui/themes";
2+
import { Button, Flex, IconButton, Progress, Tooltip } from "@radix-ui/themes";
33
import type React from "react";
44

55
interface TaskActionsProps {
66
isRunning: boolean;
77
isCloningRepo: boolean;
8+
cloneProgress: { message: string; percent: number } | null;
89
runMode: "local" | "cloud";
910
onRunTask: () => void;
1011
onCancel: () => void;
@@ -14,13 +15,26 @@ interface TaskActionsProps {
1415
export const TaskActions: React.FC<TaskActionsProps> = ({
1516
isRunning,
1617
isCloningRepo,
18+
cloneProgress,
1719
runMode,
1820
onRunTask,
1921
onCancel,
2022
onRunModeChange,
2123
}) => {
2224
const getRunButtonLabel = () => {
2325
if (isRunning) return "Running...";
26+
if (isCloningRepo && cloneProgress) {
27+
// Extract just the action part (e.g., "Receiving objects" from "Receiving objects: 45% (1234/5678)")
28+
// Handles various git progress formats
29+
const actionMatch = cloneProgress.message.match(
30+
/^(remote:\s*)?(.+?):\s*\d+%/,
31+
);
32+
if (actionMatch) {
33+
return actionMatch[2].trim();
34+
}
35+
// Fallback: if no percentage, return message as-is (e.g., "Cloning into...")
36+
return cloneProgress.message;
37+
}
2438
if (isCloningRepo) return "Cloning...";
2539
if (runMode === "cloud") return "Run (Cloud)";
2640
return "Run (Local)";
@@ -32,29 +46,40 @@ export const TaskActions: React.FC<TaskActionsProps> = ({
3246

3347
return (
3448
<Flex direction="column" gap="3">
35-
<Flex gap="2">
36-
<Button
37-
variant="classic"
38-
onClick={handleRunClick}
39-
disabled={isRunning || isCloningRepo}
40-
size="2"
41-
style={{ flex: 1 }}
42-
>
43-
{getRunButtonLabel()}
44-
</Button>
45-
<Tooltip content="Toggle between Local or Cloud Agent">
46-
<IconButton
47-
size="2"
49+
<Flex direction="column" gap="1" style={{ flex: 1 }}>
50+
<Flex gap="2">
51+
<Button
4852
variant="classic"
49-
color={runMode === "cloud" ? "blue" : "gray"}
53+
onClick={handleRunClick}
5054
disabled={isRunning || isCloningRepo}
51-
onClick={() =>
52-
onRunModeChange(runMode === "local" ? "cloud" : "local")
53-
}
55+
size="2"
56+
style={{ flex: 1 }}
57+
className="truncate"
5458
>
55-
{runMode === "cloud" ? <GlobeIcon /> : <GearIcon />}
56-
</IconButton>
57-
</Tooltip>
59+
<span className="truncate">{getRunButtonLabel()}</span>
60+
</Button>
61+
<Tooltip content="Toggle between Local or Cloud Agent">
62+
<IconButton
63+
size="2"
64+
variant="classic"
65+
color={runMode === "cloud" ? "blue" : "gray"}
66+
disabled={isRunning || isCloningRepo}
67+
onClick={() =>
68+
onRunModeChange(runMode === "local" ? "cloud" : "local")
69+
}
70+
>
71+
{runMode === "cloud" ? <GlobeIcon /> : <GearIcon />}
72+
</IconButton>
73+
</Tooltip>
74+
</Flex>
75+
{/* Progress bar underneath the button */}
76+
{isCloningRepo && cloneProgress && (
77+
<Progress
78+
value={cloneProgress.percent}
79+
size="1"
80+
aria-label={`Clone progress: ${cloneProgress.percent}%`}
81+
/>
82+
)}
5883
</Flex>
5984

6085
{isRunning && (

src/renderer/features/task-detail/components/TaskDetailPanel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export function TaskDetailPanel({ taskId, task }: TaskDetailPanelProps) {
9999
<TaskActions
100100
isRunning={execution.state.isRunning}
101101
isCloningRepo={repository.isCloning}
102+
cloneProgress={taskData.cloneProgress}
102103
runMode={execution.state.runMode}
103104
onRunTask={execution.actions.run}
104105
onCancel={execution.actions.cancel}

src/renderer/features/task-detail/hooks/useTaskData.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ export function useTaskData({ taskId, initialTask }: UseTaskDataParams) {
1515
const { data: tasks = [] } = useTasks();
1616
const { defaultWorkspace } = useAuthStore();
1717
const getTaskState = useTaskExecutionStore((state) => state.getTaskState);
18-
const initializeRepoPath = useTaskExecutionStore((state) => state.initializeRepoPath);
18+
const initializeRepoPath = useTaskExecutionStore(
19+
(state) => state.initializeRepoPath,
20+
);
1921

2022
const task = useMemo(
2123
() => tasks.find((t) => t.id === taskId) || initialTask,
@@ -50,12 +52,31 @@ export function useTaskData({ taskId, initialTask }: UseTaskDataParams) {
5052
: false,
5153
);
5254

55+
const cloneProgress = cloneStore(
56+
(state) => {
57+
if (!task.repository_config) return null;
58+
const repoKey = `${task.repository_config.organization}/${task.repository_config.repository}`;
59+
const cloneOp = state.getCloneForRepo(repoKey);
60+
if (!cloneOp?.latestMessage) return null;
61+
62+
const percentMatch = cloneOp.latestMessage.match(/(\d+)%/);
63+
const percent = percentMatch ? Number.parseInt(percentMatch[1], 10) : 0;
64+
65+
return {
66+
message: cloneOp.latestMessage,
67+
percent,
68+
};
69+
},
70+
(a, b) => a?.message === b?.message && a?.percent === b?.percent,
71+
);
72+
5373
return {
5474
task,
5575
repoPath: taskState.repoPath,
5676
repoExists: taskState.repoExists,
5777
derivedPath,
5878
isCloning,
79+
cloneProgress,
5980
defaultWorkspace,
6081
};
6182
}

0 commit comments

Comments
 (0)