Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/code/src/main/services/handoff/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ export const handoffPreflightResult = z.object({
reason: z.string().optional(),
localTreeDirty: z.boolean(),
localGitState: handoffLocalGitStateSchema.optional(),
changedFiles: z
.array(
z.object({
path: z.string(),
status: z.enum([
"modified",
"added",
"deleted",
"renamed",
"untracked",
]),
linesAdded: z.number().optional(),
linesRemoved: z.number().optional(),
}),
)
.optional(),
});

export type HandoffPreflightResult = z.infer<typeof handoffPreflightResult>;
Expand Down
67 changes: 66 additions & 1 deletion apps/code/src/main/services/handoff/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
type GitHandoffBranchDivergence,
readHandoffLocalGitState,
} from "@posthog/git/handoff";
import { ResetToDefaultBranchSaga } from "@posthog/git/sagas/branch";
import { StashPushSaga } from "@posthog/git/sagas/stash";
import { app, dialog, net } from "electron";
import { inject, injectable } from "inversify";
import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository";
Expand Down Expand Up @@ -63,9 +65,16 @@ export class HandoffService extends TypedEventEmitter<HandoffServiceEvents> {

let localTreeDirty = false;
let localGitState: AgentTypes.HandoffLocalGitState | undefined;
let changedFileDetails: HandoffPreflightResult["changedFiles"];
try {
const changedFiles = await this.gitService.getChangedFilesHead(repoPath);
localTreeDirty = changedFiles.length > 0;
changedFileDetails = changedFiles.map((f) => ({
path: f.path,
status: f.status,
linesAdded: f.linesAdded,
linesRemoved: f.linesRemoved,
}));
localGitState = await this.getLocalGitState(repoPath);
} catch (err) {
log.warn("Failed to check local working tree", { repoPath, err });
Expand All @@ -76,7 +85,13 @@ export class HandoffService extends TypedEventEmitter<HandoffServiceEvents> {
? "Local working tree has uncommitted changes. Commit or stash them first."
: undefined;

return { canHandoff, reason, localTreeDirty, localGitState };
return {
canHandoff,
reason,
localTreeDirty,
localGitState,
changedFiles: changedFileDetails,
};
}

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

await this.cleanupLocalAfterCloudHandoff(
repoPath,
input.localGitState?.branch ?? null,
);

return {
success: true,
logEntryCount: result.data.flushedLogEntryCount,
};
}

private async cleanupLocalAfterCloudHandoff(
repoPath: string,
branchName: string | null,
): Promise<void> {
try {
const hasChanges =
(await this.gitService.getChangedFilesHead(repoPath)).length > 0;

if (hasChanges) {
const label = branchName ?? "unknown";
const stashSaga = new StashPushSaga();
const stashResult = await stashSaga.run({
baseDir: repoPath,
message: `posthog-code: handoff backup (${label})`,
});
if (!stashResult.success) {
log.warn("Failed to stash changes during cloud handoff cleanup", {
error: stashResult.error,
});
return;
}
}

const resetSaga = new ResetToDefaultBranchSaga();
const resetResult = await resetSaga.run({ baseDir: repoPath });
if (!resetResult.success) {
log.warn(
"Failed to reset to default branch during cloud handoff cleanup",
{
error: resetResult.error,
},
);
return;
}

log.info("Local cleanup after cloud handoff complete", {
repoPath,
switched: resetResult.data.switched,
defaultBranch: resetResult.data.defaultBranch,
});
} catch (err) {
log.warn("Post-handoff local cleanup failed", { repoPath, err });
}
}

private createApiClient(apiHost: string, teamId: number): PostHogAPIClient {
const config = this.agentAuthAdapter.createPosthogConfig({
apiHost,
Expand Down
67 changes: 55 additions & 12 deletions apps/code/src/renderer/components/HeaderRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries";
import { DiffStatsBadge } from "@features/code-review/components/DiffStatsBadge";
import { CloudGitInteractionHeader } from "@features/git-interaction/components/CloudGitInteractionHeader";
import { GitInteractionHeader } from "@features/git-interaction/components/GitInteractionHeader";
import { HandoffConfirmDialog } from "@features/sessions/components/HandoffConfirmDialog";
import { useSessionForTask } from "@features/sessions/hooks/useSession";
import { useSessionCallbacks } from "@features/sessions/hooks/useSessionCallbacks";
import { useHandoffDialogStore } from "@features/sessions/stores/handoffDialogStore";
import { SidebarTrigger } from "@features/sidebar/components/SidebarTrigger";
import { useSidebarStore } from "@features/sidebar/stores/sidebarStore";
import { useWorkspace } from "@features/workspace/hooks/useWorkspace";
Expand All @@ -12,38 +14,79 @@ import type { Task } from "@shared/types";
import { useHeaderStore } from "@stores/headerStore";
import { useNavigationStore } from "@stores/navigationStore";
import { isWindows } from "@utils/platform";
import { useState } from "react";

function LocalHandoffButton({ taskId, task }: { taskId: string; task: Task }) {
const session = useSessionForTask(taskId);
const workspace = useWorkspace(taskId);
const repoPath = workspace?.folderPath ?? null;
const authStatus = useAuthStateValue((s) => s.status);
const { handleContinueInCloud } = useSessionCallbacks({
const { initiateHandoffToCloud } = useSessionCallbacks({
taskId,
task,
session: session ?? undefined,
repoPath,
});

const confirmOpen = useHandoffDialogStore((s) => s.confirmOpen);
const direction = useHandoffDialogStore((s) => s.direction);
const branchName = useHandoffDialogStore((s) => s.branchName);
const openConfirm = useHandoffDialogStore((s) => s.openConfirm);
const closeConfirm = useHandoffDialogStore((s) => s.closeConfirm);

const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

if (authStatus !== "authenticated") return null;

const handleConfirm = async () => {
setError(null);
setIsSubmitting(true);
try {
await initiateHandoffToCloud();
} catch (err) {
setError(err instanceof Error ? err.message : "Handoff failed");
} finally {
setIsSubmitting(false);
}
};

return (
<Button
size="1"
variant="soft"
disabled={session?.handoffInProgress}
onClick={handleContinueInCloud}
>
<Text size="1">
{session?.handoffInProgress ? "Transferring..." : "Continue in cloud"}
</Text>
</Button>
<>
<Button
size="1"
variant="soft"
disabled={session?.handoffInProgress}
onClick={() =>
openConfirm(taskId, "to-cloud", workspace?.branchName ?? null)
}
>
<Text size="1">
{session?.handoffInProgress ? "Transferring..." : "Continue in cloud"}
</Text>
</Button>
{confirmOpen && direction === "to-cloud" && (
<HandoffConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
if (!open) {
closeConfirm();
setError(null);
}
}}
direction="to-cloud"
branchName={branchName}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
error={error}
/>
)}
</>
);
}

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

export function HeaderRow() {
Expand Down
Loading
Loading