Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
5c31794
feat(desktop): add cloud terminal button with yolocode e2b api integr…
caffeinum Nov 29, 2025
40dde09
fix(desktop): use staging.yolocode.ai api endpoint for cloud sandboxes
caffeinum Nov 29, 2025
93e9074
refactor(desktop): move cloud terminal button to separate section bel…
caffeinum Nov 29, 2025
051c7d2
feat(desktop): add webview tabs for cloud agent and cloud terminal
caffeinum Nov 29, 2025
93ee19f
feat(desktop): add cloud workspace button in header
caffeinum Nov 29, 2025
25f4f81
fix(desktop): use electron webview tag instead of iframe for external…
caffeinum Nov 29, 2025
910f825
fix(desktop): pre-fill webssh with localhost/user credentials
caffeinum Nov 29, 2025
0f8ebc4
fix(desktop): pass github token to cloud sandbox API for private repo…
caffeinum Nov 29, 2025
e4cbbfc
feat: manage tab lifecycle
caffeinum Nov 29, 2025
9852807
merge: resolve conflicts with main, add cloud sandbox features
caffeinum Nov 29, 2025
5cb59d3
Merge remote-tracking branch 'origin/main' into feat/yolocode
caffeinum Nov 30, 2025
5332f99
refactor(desktop): migrate cloud sandbox handlers to tRPC router
caffeinum Nov 30, 2025
ecc7f44
update comment
caffeinum Nov 30, 2025
fc496e6
refactor(desktop): complete cloud IPC to tRPC migration
caffeinum Nov 30, 2025
3549b3e
refactor(desktop): add createCloudTab factory with sandbox id extraction
caffeinum Nov 30, 2025
3f2933d
refactor(desktop): WorkspaceItem fetches its own cloud status
caffeinum Nov 30, 2025
d5e994a
Merge remote-tracking branch 'origin/main' into feat/yolocode
caffeinum Nov 30, 2025
9cea7e0
feat(desktop): add split tab support for Cloud tabs
caffeinum Nov 30, 2025
faabaa8
Merge remote-tracking branch 'origin/main' into feat/yolocode
caffeinum Dec 1, 2025
7fd0cfe
Merge remote-tracking branch 'origin/main' into feat/yolocode
caffeinum Dec 1, 2025
53d37ff
Merge remote-tracking branch 'origin/main' into feat/yolocode
caffeinum Dec 3, 2025
adbc170
feat(desktop): reimplement cloud webview support for windows/panes ar…
caffeinum Dec 3, 2025
95857fe
fix(desktop): align cloud workspace button with other toolbar buttons
caffeinum Dec 3, 2025
9f34acf
feat(desktop): add split view for cloud workspaces with agent + ssh p…
caffeinum Dec 3, 2025
00e9e76
fix(desktop): adjust cloud split view to 60/40 ratio
caffeinum Dec 3, 2025
4f30076
Merge remote-tracking branch 'origin/main' into feat/yolocode
caffeinum Dec 3, 2025
3172833
Merge remote-tracking branch 'origin/main' into feat/yolocode
caffeinum Dec 5, 2025
5a45acb
format
caffeinum Dec 5, 2025
d803440
Merge remote-tracking branch 'origin/main' into feat/yolocode
caffeinum Dec 7, 2025
b0caf09
merge main into feat/yolocode, resolve conflicts adapting cloud/webvi…
caffeinum Dec 9, 2025
d6a43df
pass worktree branch to yolocode cloud api when creating sandbox
caffeinum Dec 9, 2025
322af4f
add handoff to cloud: validates clean git status, pushes branch, crea…
caffeinum Dec 9, 2025
b58a40b
check branch is synced with remote before handoff (no unpushed commits)
caffeinum Dec 9, 2025
bcd01da
add delete cloud sandbox option in workspace context menu
caffeinum Dec 9, 2025
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
350 changes: 350 additions & 0 deletions apps/desktop/src/lib/trpc/routers/cloud/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,350 @@
import { execSync } from "node:child_process";
import { cloudApiClient } from "main/lib/cloud-api-client";
import { db } from "main/lib/db";
import type { CloudSandbox } from "main/lib/db/schemas";
import { z } from "zod";
import { publicProcedure, router } from "../..";

/**
* Extract GitHub repo URL from a local git repository path
* Returns null for non-GitHub remotes
*/
function getGithubRepoUrl(repoPath: string): string | null {
try {
const remoteUrl = execSync("git remote get-url origin", {
cwd: repoPath,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();

// Convert SSH URL to HTTPS
// [email protected]:user/repo.git -> https://github.com/user/repo
if (remoteUrl.startsWith("[email protected]:")) {
const path = remoteUrl
.replace("[email protected]:", "")
.replace(/\.git$/, "");
return `https://github.com/${path}`;
}

// HTTPS GitHub URL, clean up trailing .git
if (remoteUrl.includes("github.com")) {
return remoteUrl.replace(/\.git$/, "");
}

// Non-GitHub remote, treat as absent
return null;
} catch (error) {
console.error("Failed to get GitHub repo URL:", error);
return null;
}
}

/**
* Check if git working directory is clean (no uncommitted changes)
*/
function isGitClean(repoPath: string): boolean {
try {
const status = execSync("git status --porcelain", {
cwd: repoPath,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
return status === "";
} catch {
return false;
}
}

/**
* Check if branch exists on remote
*/
function branchExistsOnRemote(repoPath: string, branch: string): boolean {
try {
const result = execSync(`git ls-remote --heads origin ${branch}`, {
cwd: repoPath,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
return result.length > 0;
} catch {
return false;
}
}

/**
* Check if local branch has unpushed commits compared to remote
* Returns true if synced (no unpushed commits), false if there are unpushed commits
*/
function isBranchSynced(repoPath: string, branch: string): boolean {
try {
// Fetch latest from remote first
execSync("git fetch origin", {
cwd: repoPath,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});

// Check if there are commits ahead of remote
const ahead = execSync(`git rev-list --count origin/${branch}..${branch}`, {
cwd: repoPath,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();

return ahead === "0";
} catch {
// If remote branch doesn't exist, consider it not synced
return false;
}
}

/**
* Push current branch to remote
*/
function pushBranch(
repoPath: string,
branch: string,
): { success: boolean; error?: string } {
try {
execSync(`git push -u origin ${branch}`, {
cwd: repoPath,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}

const cloudSandboxSchema = z.object({
id: z.string(),
name: z.string(),
status: z.enum(["creating", "running", "stopped", "error"]),
websshHost: z.string().optional(),
claudeHost: z.string().optional(),
createdAt: z.string(),
error: z.string().optional(),
});

export const createCloudRouter = () => {
return router({
/**
* Create a new cloud sandbox on the default branch
* Creates a fresh workspace - doesn't use any existing worktree's branch
*/
createSandbox: publicProcedure
.input(
z.object({
name: z.string(),
projectId: z.string(),
taskDescription: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
// Look up project to get mainRepoPath
const project = db.data.projects.find((p) => p.id === input.projectId);
if (!project) {
return {
success: false as const,
error: `Project ${input.projectId} not found`,
};
}

// Extract GitHub URL from local repo path
const githubRepo = getGithubRepoUrl(project.mainRepoPath);
if (!githubRepo) {
return {
success: false as const,
error:
"Could not determine GitHub repository URL. Make sure the repo has a GitHub origin.",
};
}

// Always use default branch (don't pass githubBranch)
return cloudApiClient.createSandbox({
name: input.name,
githubRepo,
taskDescription: input.taskDescription,
});
}),

/**
* Handoff an existing worktree to cloud
* Requires clean git status and pushes branch if needed
*/
handoffToCloud: publicProcedure
.input(
z.object({
name: z.string(),
worktreeId: z.string(),
taskDescription: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
// Look up worktree
const worktree = db.data.worktrees.find(
(wt) => wt.id === input.worktreeId,
);
if (!worktree) {
return {
success: false as const,
error: `Worktree ${input.worktreeId} not found`,
};
}

// Look up project to get mainRepoPath
const project = db.data.projects.find(
(p) => p.id === worktree.projectId,
);
if (!project) {
return {
success: false as const,
error: `Project for worktree not found`,
};
}

// Extract GitHub URL from local repo path
const githubRepo = getGithubRepoUrl(project.mainRepoPath);
if (!githubRepo) {
return {
success: false as const,
error:
"Could not determine GitHub repository URL. Make sure the repo has a GitHub origin.",
};
}

// Check git status is clean
if (!isGitClean(worktree.path)) {
return {
success: false as const,
error:
"Working directory has uncommitted changes. Please commit or stash your changes before handing off to cloud.",
code: "DIRTY_WORKTREE" as const,
};
}

const branch = worktree.branch;
if (!branch) {
return {
success: false as const,
error: "Could not determine branch for worktree",
};
}

// Check if branch exists on remote
const existsOnRemote = branchExistsOnRemote(worktree.path, branch);

if (existsOnRemote) {
// Branch exists on remote - check if local is synced
if (!isBranchSynced(worktree.path, branch)) {
return {
success: false as const,
error:
"Local branch has unpushed commits. Please push your changes before handing off to cloud.",
code: "UNPUSHED_COMMITS" as const,
};
}
} else {
// Branch doesn't exist on remote - push it
const pushResult = pushBranch(worktree.path, branch);
if (!pushResult.success) {
return {
success: false as const,
error: `Failed to push branch to remote: ${pushResult.error}`,
code: "PUSH_FAILED" as const,
};
}
}

// Create sandbox with the branch
return cloudApiClient.createSandbox({
name: input.name,
githubRepo,
githubBranch: branch,
taskDescription: input.taskDescription,
});
}),

deleteSandbox: publicProcedure
.input(
z.object({
sandboxId: z.string(),
}),
)
.mutation(async ({ input }) => {
return cloudApiClient.deleteSandbox(input.sandboxId);
}),

listSandboxes: publicProcedure.query(async () => {
return cloudApiClient.listSandboxes();
}),

getSandboxStatus: publicProcedure
.input(
z.object({
sandboxId: z.string(),
}),
)
.query(async ({ input }) => {
return cloudApiClient.getSandboxStatus(input.sandboxId);
}),

getWorktreeCloudStatus: publicProcedure
.input(
z.object({
worktreeId: z.string(),
}),
)
.query(async ({ input }) => {
const worktree = db.data.worktrees.find(
(wt) => wt.id === input.worktreeId,
);
if (!worktree?.cloudSandbox?.id) {
return { hasCloud: false as const };
}

const result = await cloudApiClient.getSandboxStatus(
worktree.cloudSandbox.id,
);
return {
hasCloud: true as const,
sandboxId: worktree.cloudSandbox.id,
status: result.success ? result.status : "stopped",
};
}),

setWorktreeSandbox: publicProcedure
.input(
z.object({
worktreeId: z.string(),
cloudSandbox: cloudSandboxSchema.nullable(),
}),
)
.mutation(async ({ input }) => {
try {
await db.update((data) => {
const worktree = data.worktrees.find(
(wt) => wt.id === input.worktreeId,
);
if (worktree) {
worktree.cloudSandbox =
(input.cloudSandbox as CloudSandbox) ?? undefined;
}
});
return { success: true as const };
} catch (error) {
console.error("Failed to update worktree sandbox:", error);
return {
success: false as const,
error: error instanceof Error ? error.message : String(error),
};
}
}),
});
};

export type CloudRouter = ReturnType<typeof createCloudRouter>;
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { BrowserWindow } from "electron";
import { router } from "..";
import { createCloudRouter } from "./cloud";
import { createConfigRouter } from "./config";
import { createExternalRouter } from "./external";
import { createMenuRouter } from "./menu";
Expand Down Expand Up @@ -27,6 +28,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
menu: createMenuRouter(),
external: createExternalRouter(),
settings: createSettingsRouter(),
cloud: createCloudRouter(),
config: createConfigRouter(),
});
};
Expand Down
Loading