diff --git a/apps/desktop/src/lib/trpc/routers/cloud/index.ts b/apps/desktop/src/lib/trpc/routers/cloud/index.ts new file mode 100644 index 000000000..007e99f40 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/cloud/index.ts @@ -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 + // git@github.com:user/repo.git -> https://github.com/user/repo + if (remoteUrl.startsWith("git@github.com:")) { + const path = remoteUrl + .replace("git@github.com:", "") + .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; diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 342d11f1b..33220ab4a 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -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"; @@ -27,6 +28,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { menu: createMenuRouter(), external: createExternalRouter(), settings: createSettingsRouter(), + cloud: createCloudRouter(), config: createConfigRouter(), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index a7c4b801c..91dfc0484 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -1,5 +1,6 @@ import { homedir } from "node:os"; import { join } from "node:path"; +import { cloudApiClient } from "main/lib/cloud-api-client"; import { db } from "main/lib/db"; import { terminalManager } from "main/lib/terminal-manager"; import { nanoid } from "nanoid"; @@ -414,6 +415,19 @@ export const createWorkspacesRouter = () => { (p) => p.id === workspace.projectId, ); + // Kill cloud sandbox if present + if (worktree?.cloudSandbox?.id) { + try { + console.log( + `Deleting cloud sandbox ${worktree.cloudSandbox.id} for worktree ${worktree.id}`, + ); + await cloudApiClient.deleteSandbox(worktree.cloudSandbox.id); + } catch (error) { + console.error("Failed to delete cloud sandbox:", error); + // Continue with deletion even if sandbox deletion fails + } + } + let teardownError: string | undefined; if (worktree && project) { @@ -547,6 +561,26 @@ export const createWorkspacesRouter = () => { return { success: true }; }), + getDanglingSandboxes: publicProcedure.query(async () => { + // Get all sandboxes from the cloud API + const result = await cloudApiClient.listSandboxes(); + if (!result.success || !result.sandboxes) { + return []; + } + + // Get all sandbox IDs that are linked to worktrees + const linkedSandboxIds = new Set( + db.data.worktrees + .filter((wt) => wt.cloudSandbox?.id) + .map((wt) => wt.cloudSandbox?.id), + ); + + // Return only running sandboxes that are not linked to any worktree + return result.sandboxes.filter( + (s) => !linkedSandboxIds.has(s.id) && s.status === "running", + ); + }), + refreshGitStatus: publicProcedure .input(z.object({ workspaceId: z.string() })) .mutation(async ({ input }) => { diff --git a/apps/desktop/src/main/lib/cloud-api-client.ts b/apps/desktop/src/main/lib/cloud-api-client.ts new file mode 100644 index 000000000..bcfd6d047 --- /dev/null +++ b/apps/desktop/src/main/lib/cloud-api-client.ts @@ -0,0 +1,277 @@ +import { execSync } from "node:child_process"; +import type { CloudSandbox } from "shared/types"; + +interface CreateSandboxParams { + name: string; + githubRepo?: string; + githubBranch?: string; + taskDescription?: string; + envVars?: Record; +} + +interface CreateSandboxResponse { + id: string; + name: string; + template: string; + status: string; + createdAt: string; + metadata: { + userId: string; + userLogin: string; + displayName: string; + name: string; + actualSandboxName: string; + githubRepo?: string; + autoPause: string; + }; + githubRepo?: string; + host: string; + websshHost: string; + claudeHost: string; +} + +interface ListSandboxResponse { + id: string; + name: string; + status: string; + websshHost?: string; + claudeHost?: string; + createdAt: string; +} + +/** + * Client for interacting with yolocode cloud API + * Uses GitHub token for authentication + */ +class CloudApiClient { + private baseUrl = "https://staging.yolocode.ai/api/e2b-sandboxes"; + + /** + * Get GitHub token from gh CLI + */ + private getGithubToken(): string | null { + try { + const token = execSync("gh auth token", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + return token; + } catch (error) { + console.error("Failed to get GitHub token:", error); + return null; + } + } + + /** + * Create a new cloud sandbox + */ + async createSandbox( + params: CreateSandboxParams, + ): Promise<{ success: boolean; sandbox?: CloudSandbox; error?: string }> { + const token = this.getGithubToken(); + if (!token) { + return { + success: false, + error: "GitHub authentication required. Please run 'gh auth login'", + }; + } + + try { + // Get Claude Code auth token from .env.local + const claudeAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN; + + const requestBody = { + name: params.name, + template: "yolocode", + githubRepo: params.githubRepo, + githubBranch: params.githubBranch, + githubToken: token, // Pass gh token for repo cloning + taskDescription: params.taskDescription, + envVars: { + ...params.envVars, + ...(claudeAuthToken && { + CLAUDE_CODE_OAUTH_TOKEN: claudeAuthToken, + }), + }, + }; + + // Log request but mask sensitive data + console.log("Creating sandbox with params:", { + name: requestBody.name, + template: requestBody.template, + githubRepo: requestBody.githubRepo, + githubBranch: requestBody.githubBranch, + taskDescription: requestBody.taskDescription, + envVars: claudeAuthToken + ? { CLAUDE_CODE_OAUTH_TOKEN: "***" } + : undefined, + }); + + const response = await fetch(this.baseUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error:", errorText); + console.error("Response status:", response.status); + console.error("Response statusText:", response.statusText); + return { + success: false, + error: `Failed to create sandbox: ${response.statusText}. Details: ${errorText}`, + }; + } + + const data: CreateSandboxResponse = await response.json(); + + // Override claudeHost to use port 7030 for web UI + const claudeHost = + data.claudeHost?.replace(/:\d+/, ":7030") || data.claudeHost; + + const sandbox: CloudSandbox = { + id: data.id, + name: data.name, + status: "running", + websshHost: data.websshHost, + claudeHost: claudeHost, + createdAt: data.createdAt, + }; + + console.log("Created sandbox:", sandbox); + + return { success: true, sandbox }; + } catch (error) { + console.error("Failed to create sandbox:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Delete a cloud sandbox + */ + async deleteSandbox( + sandboxId: string, + ): Promise<{ success: boolean; error?: string }> { + const token = this.getGithubToken(); + if (!token) { + return { + success: false, + error: "GitHub authentication required", + }; + } + + try { + const response = await fetch(`${this.baseUrl}/${sandboxId}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + return { + success: false, + error: `Failed to delete sandbox: ${response.statusText}`, + }; + } + + return { success: true }; + } catch (error) { + console.error("Failed to delete sandbox:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * List all sandboxes for the current user + */ + async listSandboxes(): Promise<{ + success: boolean; + sandboxes?: CloudSandbox[]; + error?: string; + }> { + const token = this.getGithubToken(); + if (!token) { + return { + success: false, + error: "GitHub authentication required", + }; + } + + try { + const response = await fetch(this.baseUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + return { + success: false, + error: `Failed to list sandboxes: ${response.statusText}`, + }; + } + + const data: ListSandboxResponse[] = await response.json(); + + const sandboxes: CloudSandbox[] = data.map((s) => ({ + id: s.id, + name: s.name, + status: s.status === "running" ? "running" : "stopped", + websshHost: s.websshHost, + claudeHost: s.claudeHost?.replace(/https:\/\/\d+-/, (match) => + match.replace(/\d+/, "7030"), + ), + createdAt: s.createdAt, + })); + + return { success: true, sandboxes }; + } catch (error) { + console.error("Failed to list sandboxes:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Get status of a specific sandbox + */ + async getSandboxStatus(sandboxId: string): Promise<{ + success: boolean; + status?: "running" | "stopped" | "error"; + error?: string; + }> { + const result = await this.listSandboxes(); + if (!result.success || !result.sandboxes) { + return { success: false, error: result.error }; + } + + const sandbox = result.sandboxes.find((s) => s.id === sandboxId); + if (!sandbox) { + // Sandbox not found - might have been deleted + return { success: true, status: "stopped" }; + } + + return { + success: true, + status: sandbox.status as "running" | "stopped", + }; + } +} + +export const cloudApiClient = new CloudApiClient(); +export default cloudApiClient; diff --git a/apps/desktop/src/main/lib/db/schemas.ts b/apps/desktop/src/main/lib/db/schemas.ts index 91cb77db1..15ce63537 100644 --- a/apps/desktop/src/main/lib/db/schemas.ts +++ b/apps/desktop/src/main/lib/db/schemas.ts @@ -10,6 +10,16 @@ export interface Project { defaultBranch?: string; // Detected default branch (e.g., 'main', 'master') } +export interface CloudSandbox { + id: string; + name: string; + status: "creating" | "running" | "stopped" | "error"; + websshHost?: string; + claudeHost?: string; + createdAt: string; + error?: string; +} + export interface GitStatus { branch: string; needsRebase: boolean; @@ -46,6 +56,7 @@ export interface Worktree { path: string; branch: string; createdAt: number; + cloudSandbox?: CloudSandbox; gitStatus?: GitStatus; githubStatus?: GitHubStatus; } diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CloudWorkspaceButton.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CloudWorkspaceButton.tsx new file mode 100644 index 000000000..c5eb25f7e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CloudWorkspaceButton.tsx @@ -0,0 +1,195 @@ +import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { useState } from "react"; +import { HiMiniCloud, HiMiniFolderOpen } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; +import { trpcClient } from "renderer/lib/trpc-client"; +import { useOpenNew } from "renderer/react-query/projects"; +import { useCreateWorkspace } from "renderer/react-query/workspaces"; +import { useAddCloudTab } from "renderer/stores/tabs"; + +export interface CloudWorkspaceButtonProps { + className?: string; +} + +export function CloudWorkspaceButton({ className }: CloudWorkspaceButtonProps) { + const [isOpen, setIsOpen] = useState(false); + const [isCreating, setIsCreating] = useState(false); + + const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); + const createWorkspace = useCreateWorkspace(); + const openNew = useOpenNew(); + const addCloudTab = useAddCloudTab(); + + const generateSandboxName = () => { + const adjectives = [ + "happy", + "sleepy", + "brave", + "clever", + "gentle", + "bright", + "calm", + "bold", + "swift", + "quiet", + ]; + const nouns = [ + "cat", + "fox", + "owl", + "bear", + "wolf", + "deer", + "hawk", + "lynx", + "seal", + "dove", + ]; + const randomAdj = adjectives[Math.floor(Math.random() * adjectives.length)]; + const randomNoun = nouns[Math.floor(Math.random() * nouns.length)]; + const timestamp = Date.now().toString(36); + return `${randomAdj}-${randomNoun}-${timestamp}`; + }; + + const handleCreateCloudWorkspace = async (projectId: string) => { + setIsCreating(true); + setIsOpen(false); + + const toastId = toast.loading("Creating cloud workspace..."); + + try { + // 1. Create local workspace first + const workspaceResult = await createWorkspace.mutateAsync({ projectId }); + const workspaceId = workspaceResult.workspace.id; + const worktreeId = workspaceResult.workspace.worktreeId; + + // 2. Create cloud sandbox (uses default branch) + const sandboxName = generateSandboxName(); + const result = await trpcClient.cloud.createSandbox.mutate({ + name: sandboxName, + projectId, + taskDescription: `Cloud development workspace`, + }); + + if (!result.success) { + throw new Error(result.error || "Failed to create cloud sandbox"); + } + + const sandbox = result.sandbox; + + // 3. Save sandbox to worktree + if (sandbox) { + await trpcClient.cloud.setWorktreeSandbox.mutate({ + worktreeId, + cloudSandbox: sandbox, + }); + } + + // 4. Add cloud split tab with Agent (left) + SSH (right) + if (sandbox?.claudeHost && sandbox?.websshHost) { + const agentUrl = sandbox.claudeHost.startsWith("http") + ? sandbox.claudeHost + : `https://${sandbox.claudeHost}`; + + const sshBaseUrl = sandbox.websshHost.startsWith("http") + ? sandbox.websshHost + : `https://${sandbox.websshHost}`; + const sshUrl = `${sshBaseUrl}/?hostname=localhost&username=user`; + + addCloudTab(workspaceId, agentUrl, sshUrl); + } + + toast.success("Cloud workspace created", { id: toastId }); + } catch (error) { + console.error("Error creating cloud workspace:", error); + toast.error("Failed to create cloud workspace", { + id: toastId, + description: + error instanceof Error ? error.message : "An unknown error occurred", + }); + } finally { + setIsCreating(false); + } + }; + + const handleOpenNewProject = async () => { + try { + const result = await openNew.mutateAsync(undefined); + if (!result.canceled && "project" in result && result.project) { + handleCreateCloudWorkspace(result.project.id); + } + } catch (error) { + toast.error("Failed to open project", { + description: + error instanceof Error ? error.message : "An unknown error occurred", + }); + } + }; + + return ( + + + + + +
+ {recentProjects.length > 0 && ( +
+

+ Create Cloud Workspace +

+ {recentProjects.map( + (project: { + id: string; + name: string; + mainRepoPath: string; + }) => ( + + ), + )} +
+ )} +
+ +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DanglingSandboxItem.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DanglingSandboxItem.tsx new file mode 100644 index 000000000..ee9dbf1df --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DanglingSandboxItem.tsx @@ -0,0 +1,156 @@ +import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; +import { useState } from "react"; +import { HiMiniCloud, HiMiniXMark } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; +import { trpcClient } from "renderer/lib/trpc-client"; +import { useAddCloudTab } from "renderer/stores/tabs"; + +interface DanglingSandboxItemProps { + id: string; + name: string; + status: string; + claudeHost?: string; + websshHost?: string; +} + +export function DanglingSandboxItem({ + id, + name, + status, + claudeHost, + websshHost, +}: DanglingSandboxItemProps) { + const [isDeleting, setIsDeleting] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const utils = trpc.useUtils(); + const addCloudTab = useAddCloudTab(); + + // Get first project to create workspace in + const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); + const createWorkspace = trpc.workspaces.create.useMutation({ + onSuccess: () => { + utils.workspaces.invalidate(); + }, + }); + + const handleClick = async () => { + if (isCreating || isDeleting) return; + + // Need a project to create workspace + const project = recentProjects[0]; + if (!project) { + toast.error("No project available", { + description: "Open a project first to attach this sandbox", + }); + return; + } + + setIsCreating(true); + const toastId = toast.loading("Creating workspace for sandbox..."); + + try { + // Create a workspace + const result = await createWorkspace.mutateAsync({ + projectId: project.id, + name: name, + }); + + const worktreeId = result.workspace.worktreeId; + + // Link sandbox to worktree + await trpcClient.cloud.setWorktreeSandbox.mutate({ + worktreeId, + cloudSandbox: { + id, + name, + status: status as "creating" | "running" | "stopped" | "error", + claudeHost, + websshHost, + createdAt: new Date().toISOString(), + }, + }); + + // Open cloud split tab with Agent + SSH + if (claudeHost && websshHost) { + const agentUrl = claudeHost.startsWith("http") + ? claudeHost + : `https://${claudeHost}`; + const sshUrl = websshHost.startsWith("http") + ? `${websshHost}/?hostname=localhost&username=user` + : `https://${websshHost}/?hostname=localhost&username=user`; + addCloudTab(result.workspace.id, agentUrl, sshUrl); + } + + // Invalidate dangling sandboxes query since this one is now linked + await utils.workspaces.getDanglingSandboxes.invalidate(); + + toast.success("Workspace created", { id: toastId }); + } catch (error) { + toast.error("Failed to create workspace", { + id: toastId, + description: + error instanceof Error ? error.message : "An unknown error occurred", + }); + } finally { + setIsCreating(false); + } + }; + + const handleKill = async (e: React.MouseEvent) => { + e.stopPropagation(); + setIsDeleting(true); + try { + const result = await trpcClient.cloud.deleteSandbox.mutate({ + sandboxId: id, + }); + + if (result.success) { + toast.success("Sandbox deleted"); + await utils.workspaces.getDanglingSandboxes.invalidate(); + } else { + toast.error("Failed to delete sandbox", { + description: result.error, + }); + } + } catch (error) { + toast.error("Failed to delete sandbox", { + description: + error instanceof Error ? error.message : "An unknown error occurred", + }); + } finally { + setIsDeleting(false); + } + }; + + const isStopped = status === "stopped"; + + return ( +
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx index 469a26dd3..35ba191e9 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx @@ -6,6 +6,7 @@ import { WorkspaceItem } from "./WorkspaceItem"; interface Workspace { id: string; projectId: string; + worktreeId: string; worktreePath: string; name: string; tabOrder: number; @@ -72,6 +73,7 @@ export function WorkspaceGroup({ s.panes); const rename = useWorkspaceRename(id, title); + // Fetch cloud status for this worktree (polls every 30s if it has a sandbox) + const { data: cloudStatus } = trpc.cloud.getWorktreeCloudStatus.useQuery( + { worktreeId }, + { refetchInterval: 30000 }, + ); + + const isCloudWorkspace = cloudStatus?.hasCloud === true; + const isSandboxStopped = isCloudWorkspace && cloudStatus.status === "stopped"; + // Query to check if workspace is empty - only enabled when needed const canDeleteQuery = trpc.workspaces.canDelete.useQuery( { id }, @@ -151,6 +166,7 @@ export function WorkspaceItem({ <> ) : ( <> + {/* Cloud workspace icon */} + {isCloudWorkspace && ( + + {isSandboxStopped ? ( + + ) : ( + + )} + + )} void; @@ -25,11 +30,22 @@ interface WorkspaceItemContextMenuProps { export function WorkspaceItemContextMenu({ children, workspaceId, + worktreeId, worktreePath, workspaceAlias, onRename, }: WorkspaceItemContextMenuProps) { const openInFinder = trpc.external.openInFinder.useMutation(); + const addCloudTab = useAddCloudTab(); + const [isHandingOff, setIsHandingOff] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // Query cloud status for this worktree + const { data: cloudStatus } = trpc.cloud.getWorktreeCloudStatus.useQuery( + { worktreeId }, + { refetchInterval: 30000 }, + ); + const utils = trpc.useUtils(); const handleOpenInFinder = () => { if (worktreePath) { @@ -37,6 +53,157 @@ export function WorkspaceItemContextMenu({ } }; + const generateSandboxName = () => { + const adjectives = [ + "happy", + "sleepy", + "brave", + "clever", + "gentle", + "bright", + "calm", + "bold", + "swift", + "quiet", + ]; + const nouns = [ + "cat", + "fox", + "owl", + "bear", + "wolf", + "deer", + "hawk", + "lynx", + "seal", + "dove", + ]; + const randomAdj = adjectives[Math.floor(Math.random() * adjectives.length)]; + const randomNoun = nouns[Math.floor(Math.random() * nouns.length)]; + const timestamp = Date.now().toString(36); + return `${randomAdj}-${randomNoun}-${timestamp}`; + }; + + const handleHandoffToCloud = async () => { + if (isHandingOff) return; + setIsHandingOff(true); + + const toastId = toast.loading("Handing off to cloud..."); + + try { + const sandboxName = generateSandboxName(); + const result = await trpcClient.cloud.handoffToCloud.mutate({ + name: sandboxName, + worktreeId, + taskDescription: "Handoff from local workspace", + }); + + if (!result.success) { + // Handle specific error codes + if ("code" in result && result.code === "DIRTY_WORKTREE") { + toast.error("Uncommitted changes", { + id: toastId, + description: + "Please commit or stash your changes before handing off to cloud.", + }); + } else if ("code" in result && result.code === "UNPUSHED_COMMITS") { + toast.error("Unpushed commits", { + id: toastId, + description: + "Please push your commits before handing off to cloud.", + }); + } else if ("code" in result && result.code === "PUSH_FAILED") { + toast.error("Failed to push branch", { + id: toastId, + description: result.error, + }); + } else { + toast.error("Handoff failed", { + id: toastId, + description: result.error, + }); + } + return; + } + + const sandbox = result.sandbox; + + // Save sandbox to worktree + if (sandbox) { + await trpcClient.cloud.setWorktreeSandbox.mutate({ + worktreeId, + cloudSandbox: sandbox, + }); + } + + // Add cloud split tab with Agent (left) + SSH (right) + if (sandbox?.claudeHost && sandbox?.websshHost) { + const agentUrl = sandbox.claudeHost.startsWith("http") + ? sandbox.claudeHost + : `https://${sandbox.claudeHost}`; + + const sshBaseUrl = sandbox.websshHost.startsWith("http") + ? sandbox.websshHost + : `https://${sandbox.websshHost}`; + const sshUrl = `${sshBaseUrl}/?hostname=localhost&username=user`; + + addCloudTab(workspaceId, agentUrl, sshUrl); + } + + toast.success("Handed off to cloud", { id: toastId }); + } catch (error) { + console.error("Error handing off to cloud:", error); + toast.error("Handoff failed", { + id: toastId, + description: + error instanceof Error ? error.message : "An unknown error occurred", + }); + } finally { + setIsHandingOff(false); + } + }; + + const handleDeleteCloudSandbox = async () => { + if (isDeleting || !cloudStatus?.hasCloud || !cloudStatus.sandboxId) return; + setIsDeleting(true); + + const toastId = toast.loading("Deleting cloud sandbox..."); + + try { + const result = await trpcClient.cloud.deleteSandbox.mutate({ + sandboxId: cloudStatus.sandboxId, + }); + + if (!result.success) { + toast.error("Failed to delete sandbox", { + id: toastId, + description: result.error, + }); + return; + } + + // Clear sandbox from worktree + await trpcClient.cloud.setWorktreeSandbox.mutate({ + worktreeId, + cloudSandbox: null, + }); + + // Invalidate cloud status query + await utils.cloud.getWorktreeCloudStatus.invalidate({ worktreeId }); + + toast.success("Cloud sandbox deleted", { id: toastId }); + } catch (error) { + console.error("Error deleting cloud sandbox:", error); + toast.error("Failed to delete sandbox", { + id: toastId, + description: + error instanceof Error ? error.message : "An unknown error occurred", + }); + } finally { + setIsDeleting(false); + } + }; + return ( @@ -46,6 +213,25 @@ export function WorkspaceItemContextMenu({ Rename + {cloudStatus?.hasCloud ? ( + + + Delete Cloud Sandbox + + ) : ( + + + Handoff to Cloud + + )} + Open in Finder diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx index 61f72a195..9ef4af373 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx @@ -7,6 +7,8 @@ import { useIsSettingsTabOpen, } from "renderer/stores/app-state"; import { HOTKEYS } from "shared/hotkeys"; +import { CloudWorkspaceButton } from "./CloudWorkspaceButton"; +import { DanglingSandboxItem } from "./DanglingSandboxItem"; import { SettingsTab } from "./SettingsTab"; import { WorkspaceDropdown } from "./WorkspaceDropdown"; import { WorkspaceGroup } from "./WorkspaceGroup"; @@ -18,6 +20,10 @@ const ADD_BUTTON_WIDTH = 48; export function WorkspacesTabs() { const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const { data: danglingSandboxes = [] } = + trpc.workspaces.getDanglingSandboxes.useQuery(undefined, { + refetchInterval: 30000, // Refetch every 30 seconds + }); const activeWorkspaceId = activeWorkspace?.id || null; const setActiveWorkspace = useSetActiveWorkspace(); const currentView = useCurrentView(); @@ -147,13 +153,37 @@ export function WorkspacesTabs() { )} ))} - {isSettingsTabOpen && ( + + {/* Dangling sandboxes - orphaned cloud instances */} + {danglingSandboxes.length > 0 && ( <> {groups.length > 0 && (
)} +
+ {danglingSandboxes.map((sandbox) => ( + + ))} +
+ + )} + + {isSettingsTabOpen && ( + <> + {(groups.length > 0 || danglingSandboxes.length > 0) && ( +
+
+
+ )}
+
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx index be0865419..e8f880366 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx @@ -16,7 +16,7 @@ interface TabContentContextMenuProps { children: ReactNode; onSplitHorizontal: () => void; onSplitVertical: () => void; - onClosePane: () => void; + onClosePane?: () => void; currentTabId: string; availableTabs: Tab[]; onMoveToTab: (tabId: string) => void; @@ -70,11 +70,15 @@ export function TabContentContextMenu({ - - - - Close Pane - + {onClosePane && ( + <> + + + + Close Pane + + + )}
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx index 4b9d15705..c700e8feb 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx @@ -10,6 +10,7 @@ import { import type { Pane, Tab } from "renderer/stores/tabs/types"; import { TabContentContextMenu } from "../TabContentContextMenu"; import { Terminal } from "../Terminal"; +import { WebView } from "../WebView"; type SplitOrientation = "vertical" | "horizontal"; @@ -158,7 +159,10 @@ export function TabPane({ className="w-full h-full overflow-hidden" onClick={handleFocus} > - + {pane.type === "terminal" && ( + + )} + {pane.type === "webview" && } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WebView/WebView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WebView/WebView.tsx new file mode 100644 index 000000000..e68256101 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WebView/WebView.tsx @@ -0,0 +1,42 @@ +import { useEffect, useRef } from "react"; + +interface WebViewProps { + url: string; +} + +export function WebView({ url }: WebViewProps) { + const webviewRef = useRef(null); + + useEffect(() => { + const webview = webviewRef.current; + if (!webview) return; + + const handleDomReady = () => { + console.log("WebView loaded:", url); + }; + + const handleDidFailLoad = (event: Electron.DidFailLoadEvent) => { + console.error("WebView failed to load:", event.errorDescription); + }; + + webview.addEventListener("dom-ready", handleDomReady); + webview.addEventListener("did-fail-load", handleDidFailLoad); + + return () => { + webview.removeEventListener("dom-ready", handleDomReady); + webview.removeEventListener("did-fail-load", handleDidFailLoad); + }; + }, [url]); + + return ( +
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WebView/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WebView/index.ts new file mode 100644 index 000000000..655d25f6c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WebView/index.ts @@ -0,0 +1 @@ +export { WebView } from "./WebView"; diff --git a/apps/desktop/src/renderer/stores/tabs/index.ts b/apps/desktop/src/renderer/stores/tabs/index.ts index 4684d2539..307e566a4 100644 --- a/apps/desktop/src/renderer/stores/tabs/index.ts +++ b/apps/desktop/src/renderer/stores/tabs/index.ts @@ -2,3 +2,11 @@ export * from "./store"; export * from "./types"; export * from "./useAgentHookListener"; export * from "./utils"; + +// Convenience hooks for cloud/webview operations +import { useTabsStore } from "./store"; + +export const useAddWebviewTab = () => + useTabsStore((state) => state.addWebviewTab); + +export const useAddCloudTab = () => useTabsStore((state) => state.addCloudTab); diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index a1122bd63..9c6ebaf9e 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -4,12 +4,15 @@ import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; import { electronStorage } from "../../lib/electron-storage"; import { movePaneToNewTab, movePaneToTab } from "./actions/move-pane"; -import type { TabsState, TabsStore } from "./types"; +import type { Tab, TabsState, TabsStore } from "./types"; import { type CreatePaneOptions, + createCloudTabWithPanes, createPane, createTabWithPane, + createWebviewPane, extractPaneIdsFromLayout, + generateId, getFirstPaneId, getPaneIdsForTab, isLastPaneInTab, @@ -453,6 +456,91 @@ export const useTabsStore = create()( })); }, + // Cloud/Webview operations + addWebviewTab: (workspaceId, url, name) => { + const state = get(); + const tabId = generateId("tab"); + const pane = createWebviewPane(tabId, url, name); + + // Use the pane name as the tab name for cloud tabs + const tab: Tab = { + id: tabId, + name: pane.name, + workspaceId, + layout: pane.id, // Single pane = leaf node + createdAt: Date.now(), + }; + + const currentActiveId = state.activeTabIds[workspaceId]; + const historyStack = state.tabHistoryStacks[workspaceId] || []; + const newHistoryStack = currentActiveId + ? [ + currentActiveId, + ...historyStack.filter((id) => id !== currentActiveId), + ] + : historyStack; + + set({ + tabs: [...state.tabs, tab], + panes: { ...state.panes, [pane.id]: pane }, + activeTabIds: { + ...state.activeTabIds, + [workspaceId]: tab.id, + }, + focusedPaneIds: { + ...state.focusedPaneIds, + [tab.id]: pane.id, + }, + tabHistoryStacks: { + ...state.tabHistoryStacks, + [workspaceId]: newHistoryStack, + }, + }); + + return tab.id; + }, + + addCloudTab: (workspaceId, agentUrl, sshUrl) => { + const state = get(); + const { tab, agentPane, sshPane } = createCloudTabWithPanes( + workspaceId, + agentUrl, + sshUrl, + ); + + const currentActiveId = state.activeTabIds[workspaceId]; + const historyStack = state.tabHistoryStacks[workspaceId] || []; + const newHistoryStack = currentActiveId + ? [ + currentActiveId, + ...historyStack.filter((id) => id !== currentActiveId), + ] + : historyStack; + + set({ + tabs: [...state.tabs, tab], + panes: { + ...state.panes, + [agentPane.id]: agentPane, + [sshPane.id]: sshPane, + }, + activeTabIds: { + ...state.activeTabIds, + [workspaceId]: tab.id, + }, + focusedPaneIds: { + ...state.focusedPaneIds, + [tab.id]: agentPane.id, // Focus agent pane by default + }, + tabHistoryStacks: { + ...state.tabHistoryStacks, + [workspaceId]: newHistoryStack, + }, + }); + + return tab.id; + }, + // Split operations splitPaneVertical: (tabId, sourcePaneId, path) => { const state = get(); diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index f0d34034f..75e5555cf 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -3,16 +3,14 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; /** * Pane types that can be displayed within a tab */ -export type PaneType = "terminal"; +export type PaneType = "terminal" | "webview"; /** - * A Pane represents a single terminal or content area within a Tab. - * Panes always belong to a Tab and are referenced by ID in the Tab's layout. + * Base pane properties shared by all pane types */ -export interface Pane { +interface BasePaneProps { id: string; tabId: string; - type: PaneType; name: string; isNew?: boolean; needsAttention?: boolean; @@ -20,6 +18,27 @@ export interface Pane { initialCwd?: string; } +/** + * Terminal pane - displays a terminal emulator + */ +export interface TerminalPane extends BasePaneProps { + type: "terminal"; +} + +/** + * Webview pane - displays a web page (used for cloud workspaces) + */ +export interface WebviewPane extends BasePaneProps { + type: "webview"; + url: string; +} + +/** + * A Pane represents a single terminal or content area within a Tab. + * Panes always belong to a Tab and are referenced by ID in the Tab's layout. + */ +export type Pane = TerminalPane | WebviewPane; + /** * A Tab is a container that holds one or more Panes in a Mosaic layout. * Tabs are displayed in the sidebar and always have at least one Pane. @@ -81,6 +100,14 @@ export interface TabsStore extends TabsState { setNeedsAttention: (paneId: string, needsAttention: boolean) => void; clearPaneInitialData: (paneId: string) => void; + // Cloud/Webview operations + addWebviewTab: (workspaceId: string, url: string, name?: string) => string; + addCloudTab: ( + workspaceId: string, + agentUrl: string, + sshUrl: string, + ) => string; + // Split operations splitPaneVertical: ( tabId: string, diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index dcea03ce1..0fc21de79 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -1,5 +1,5 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; -import type { Pane, PaneType, Tab } from "./types"; +import type { Pane, Tab, TerminalPane, WebviewPane } from "./types"; /** * Generates a unique ID with the given prefix @@ -41,19 +41,18 @@ export interface CreatePaneOptions { } /** - * Creates a new pane with the given properties + * Creates a new terminal pane */ export const createPane = ( tabId: string, - type: PaneType = "terminal", options?: CreatePaneOptions, -): Pane => { +): TerminalPane => { const id = generateId("pane"); return { id, tabId, - type, + type: "terminal", name: "Terminal", isNew: true, initialCommands: options?.initialCommands, @@ -61,6 +60,45 @@ export const createPane = ( }; }; +/** + * Creates a new webview pane for cloud workspaces + */ +export const createWebviewPane = ( + tabId: string, + url: string, + name?: string, +): WebviewPane => { + const id = generateId("pane"); + + // Derive name from URL if not provided + const derivedName = name || getWebviewNameFromUrl(url); + + return { + id, + tabId, + type: "webview", + name: derivedName, + url, + isNew: true, + }; +}; + +/** + * Extract a friendly name from a cloud URL + * URLs look like: https://7030-sandboxid.e2b.app or https://8888-sandboxid.e2b.app + */ +const getWebviewNameFromUrl = (url: string): string => { + const portMatch = url.match(/(\d+)-[a-z0-9-]+\.e2b\.app/); + if (portMatch) { + const port = portMatch[1]; + // 7030 = claude agent, 8888 = webssh terminal + if (port === "7030") return "Cloud Agent"; + if (port === "8888") return "Cloud SSH"; + return `Cloud (${port})`; + } + return "Cloud View"; +}; + /** * Generates a static tab name based on existing tabs * (e.g., "Terminal 1", "Terminal 2", finding the next available number) @@ -92,7 +130,7 @@ export const createTabWithPane = ( options?: CreatePaneOptions, ): { tab: Tab; pane: Pane } => { const tabId = generateId("tab"); - const pane = createPane(tabId, "terminal", options); + const pane = createPane(tabId, options); // Filter to same workspace for tab naming const workspaceTabs = existingTabs.filter( @@ -274,3 +312,34 @@ export const updateHistoryStack = ( return newStack; }; + +/** + * Creates a cloud tab with split view (Agent on left, SSH on right) + */ +export const createCloudTabWithPanes = ( + workspaceId: string, + agentUrl: string, + sshUrl: string, +): { tab: Tab; agentPane: WebviewPane; sshPane: WebviewPane } => { + const tabId = generateId("tab"); + const agentPane = createWebviewPane(tabId, agentUrl, "Cloud Agent"); + const sshPane = createWebviewPane(tabId, sshUrl, "Cloud SSH"); + + // Split layout: agent on left (60%), ssh on right (40%) + const layout: MosaicNode = { + direction: "row", + first: agentPane.id, + second: sshPane.id, + splitPercentage: 60, + }; + + const tab: Tab = { + id: tabId, + name: "Cloud", + workspaceId, + layout, + createdAt: Date.now(), + }; + + return { tab, agentPane, sshPane }; +}; diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index ebac5467e..25a32e710 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -55,6 +55,17 @@ export interface Tab { createdAt: string; } +// Cloud sandbox types +export interface CloudSandbox { + id: string; // E2B sandbox ID + name: string; // Sandbox name + status: "creating" | "running" | "stopped" | "error"; + websshHost?: string; // WebSSH terminal URL (port 8888) + claudeHost?: string; // Claude SSE server URL (web UI port 7030) + createdAt: string; + error?: string; // Error message if status is "error" +} + export interface Worktree { id: string; branch: string; @@ -65,6 +76,7 @@ export interface Worktree { merged?: boolean; // Indicates if this worktree has been merged into another worktree description?: string; // Optional description of the worktree's goal or purpose prUrl?: string; // Pull request URL if a PR has been created for this worktree + cloudSandbox?: CloudSandbox; // Cloud sandbox instance for this worktree } export interface Workspace {