Skip to content

Commit 64de17a

Browse files
committed
feat: add confirmation ui for handoff
1 parent 0420f7e commit 64de17a

File tree

19 files changed

+901
-199
lines changed

19 files changed

+901
-199
lines changed

apps/code/src/main/services/handoff/schemas.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,22 @@ export const handoffPreflightResult = z.object({
2828
reason: z.string().optional(),
2929
localTreeDirty: z.boolean(),
3030
localGitState: handoffLocalGitStateSchema.optional(),
31+
changedFiles: z
32+
.array(
33+
z.object({
34+
path: z.string(),
35+
status: z.enum([
36+
"modified",
37+
"added",
38+
"deleted",
39+
"renamed",
40+
"untracked",
41+
]),
42+
linesAdded: z.number().optional(),
43+
linesRemoved: z.number().optional(),
44+
}),
45+
)
46+
.optional(),
3147
});
3248

3349
export type HandoffPreflightResult = z.infer<typeof handoffPreflightResult>;

apps/code/src/main/services/handoff/service.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
type GitHandoffBranchDivergence,
1313
readHandoffLocalGitState,
1414
} from "@posthog/git/handoff";
15+
import { ResetToDefaultBranchSaga } from "@posthog/git/sagas/branch";
16+
import { StashPushSaga } from "@posthog/git/sagas/stash";
1517
import { app, dialog, net } from "electron";
1618
import { inject, injectable } from "inversify";
1719
import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository";
@@ -63,9 +65,16 @@ export class HandoffService extends TypedEventEmitter<HandoffServiceEvents> {
6365

6466
let localTreeDirty = false;
6567
let localGitState: AgentTypes.HandoffLocalGitState | undefined;
68+
let changedFileDetails: HandoffPreflightResult["changedFiles"];
6669
try {
6770
const changedFiles = await this.gitService.getChangedFilesHead(repoPath);
6871
localTreeDirty = changedFiles.length > 0;
72+
changedFileDetails = changedFiles.map((f) => ({
73+
path: f.path,
74+
status: f.status,
75+
linesAdded: f.linesAdded,
76+
linesRemoved: f.linesRemoved,
77+
}));
6978
localGitState = await this.getLocalGitState(repoPath);
7079
} catch (err) {
7180
log.warn("Failed to check local working tree", { repoPath, err });
@@ -76,7 +85,13 @@ export class HandoffService extends TypedEventEmitter<HandoffServiceEvents> {
7685
? "Local working tree has uncommitted changes. Commit or stash them first."
7786
: undefined;
7887

79-
return { canHandoff, reason, localTreeDirty, localGitState };
88+
return {
89+
canHandoff,
90+
reason,
91+
localTreeDirty,
92+
localGitState,
93+
changedFiles: changedFileDetails,
94+
};
8095
}
8196

8297
async execute(input: HandoffExecuteInput): Promise<HandoffExecuteResult> {
@@ -368,12 +383,62 @@ export class HandoffService extends TypedEventEmitter<HandoffServiceEvents> {
368383
};
369384
}
370385

386+
await this.cleanupLocalAfterCloudHandoff(
387+
repoPath,
388+
input.localGitState?.branch ?? null,
389+
);
390+
371391
return {
372392
success: true,
373393
logEntryCount: result.data.flushedLogEntryCount,
374394
};
375395
}
376396

397+
private async cleanupLocalAfterCloudHandoff(
398+
repoPath: string,
399+
branchName: string | null,
400+
): Promise<void> {
401+
try {
402+
const hasChanges =
403+
(await this.gitService.getChangedFilesHead(repoPath)).length > 0;
404+
405+
if (hasChanges) {
406+
const label = branchName ?? "unknown";
407+
const stashSaga = new StashPushSaga();
408+
const stashResult = await stashSaga.run({
409+
baseDir: repoPath,
410+
message: `posthog-code: handoff backup (${label})`,
411+
});
412+
if (!stashResult.success) {
413+
log.warn("Failed to stash changes during cloud handoff cleanup", {
414+
error: stashResult.error,
415+
});
416+
return;
417+
}
418+
}
419+
420+
const resetSaga = new ResetToDefaultBranchSaga();
421+
const resetResult = await resetSaga.run({ baseDir: repoPath });
422+
if (!resetResult.success) {
423+
log.warn(
424+
"Failed to reset to default branch during cloud handoff cleanup",
425+
{
426+
error: resetResult.error,
427+
},
428+
);
429+
return;
430+
}
431+
432+
log.info("Local cleanup after cloud handoff complete", {
433+
repoPath,
434+
switched: resetResult.data.switched,
435+
defaultBranch: resetResult.data.defaultBranch,
436+
});
437+
} catch (err) {
438+
log.warn("Post-handoff local cleanup failed", { repoPath, err });
439+
}
440+
}
441+
377442
private createApiClient(apiHost: string, teamId: number): PostHogAPIClient {
378443
const config = this.agentAuthAdapter.createPosthogConfig({
379444
apiHost,

apps/code/src/renderer/components/HeaderRow.tsx

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries";
22
import { DiffStatsBadge } from "@features/code-review/components/DiffStatsBadge";
33
import { CloudGitInteractionHeader } from "@features/git-interaction/components/CloudGitInteractionHeader";
44
import { GitInteractionHeader } from "@features/git-interaction/components/GitInteractionHeader";
5+
import { HandoffConfirmDialog } from "@features/sessions/components/HandoffConfirmDialog";
56
import { useSessionForTask } from "@features/sessions/hooks/useSession";
67
import { useSessionCallbacks } from "@features/sessions/hooks/useSessionCallbacks";
8+
import { useHandoffDialogStore } from "@features/sessions/stores/handoffDialogStore";
79
import { SidebarTrigger } from "@features/sidebar/components/SidebarTrigger";
810
import { useSidebarStore } from "@features/sidebar/stores/sidebarStore";
911
import { useWorkspace } from "@features/workspace/hooks/useWorkspace";
@@ -12,38 +14,79 @@ import type { Task } from "@shared/types";
1214
import { useHeaderStore } from "@stores/headerStore";
1315
import { useNavigationStore } from "@stores/navigationStore";
1416
import { isWindows } from "@utils/platform";
17+
import { useState } from "react";
1518

1619
function LocalHandoffButton({ taskId, task }: { taskId: string; task: Task }) {
1720
const session = useSessionForTask(taskId);
1821
const workspace = useWorkspace(taskId);
1922
const repoPath = workspace?.folderPath ?? null;
2023
const authStatus = useAuthStateValue((s) => s.status);
21-
const { handleContinueInCloud } = useSessionCallbacks({
24+
const { initiateHandoffToCloud } = useSessionCallbacks({
2225
taskId,
2326
task,
2427
session: session ?? undefined,
2528
repoPath,
2629
});
2730

31+
const confirmOpen = useHandoffDialogStore((s) => s.confirmOpen);
32+
const direction = useHandoffDialogStore((s) => s.direction);
33+
const branchName = useHandoffDialogStore((s) => s.branchName);
34+
const openConfirm = useHandoffDialogStore((s) => s.openConfirm);
35+
const closeConfirm = useHandoffDialogStore((s) => s.closeConfirm);
36+
37+
const [isSubmitting, setIsSubmitting] = useState(false);
38+
const [error, setError] = useState<string | null>(null);
39+
2840
if (authStatus !== "authenticated") return null;
2941

42+
const handleConfirm = async () => {
43+
setError(null);
44+
setIsSubmitting(true);
45+
try {
46+
await initiateHandoffToCloud();
47+
} catch (err) {
48+
setError(err instanceof Error ? err.message : "Handoff failed");
49+
} finally {
50+
setIsSubmitting(false);
51+
}
52+
};
53+
3054
return (
31-
<Button
32-
size="1"
33-
variant="soft"
34-
disabled={session?.handoffInProgress}
35-
onClick={handleContinueInCloud}
36-
>
37-
<Text size="1">
38-
{session?.handoffInProgress ? "Transferring..." : "Continue in cloud"}
39-
</Text>
40-
</Button>
55+
<>
56+
<Button
57+
size="1"
58+
variant="soft"
59+
disabled={session?.handoffInProgress}
60+
onClick={() =>
61+
openConfirm(taskId, "to-cloud", workspace?.branchName ?? null)
62+
}
63+
>
64+
<Text size="1">
65+
{session?.handoffInProgress ? "Transferring..." : "Continue in cloud"}
66+
</Text>
67+
</Button>
68+
{confirmOpen && direction === "to-cloud" && (
69+
<HandoffConfirmDialog
70+
open={confirmOpen}
71+
onOpenChange={(open) => {
72+
if (!open) {
73+
closeConfirm();
74+
setError(null);
75+
}
76+
}}
77+
direction="to-cloud"
78+
branchName={branchName}
79+
onConfirm={handleConfirm}
80+
isSubmitting={isSubmitting}
81+
error={error}
82+
/>
83+
)}
84+
</>
4185
);
4286
}
4387

4488
export const HEADER_HEIGHT = 36;
4589
const COLLAPSED_WIDTH = 110;
46-
/** Width reserved for Windows title bar buttons (Close/Minimize/Maximize) */
4790
const WINDOWS_TITLEBAR_INSET = 140;
4891

4992
export function HeaderRow() {

0 commit comments

Comments
 (0)