Skip to content

Commit e4cbbfc

Browse files
committed
feat: manage tab lifecycle
1 parent 0f8ebc4 commit e4cbbfc

10 files changed

Lines changed: 492 additions & 4 deletions

File tree

apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { homedir } from "node:os";
22
import { join } from "node:path";
3+
import { cloudApiClient } from "main/lib/cloud-api-client";
34
import { db } from "main/lib/db";
45
import { nanoid } from "nanoid";
56
import { SUPERSET_DIR_NAME, WORKTREES_DIR_NAME } from "shared/constants";
@@ -114,11 +115,17 @@ export const createWorkspacesRouter = () => {
114115
return db.data.workspaces.slice().sort((a, b) => a.tabOrder - b.tabOrder);
115116
}),
116117

117-
getAllGrouped: publicProcedure.query(() => {
118+
getAllGrouped: publicProcedure.query(async () => {
118119
const activeProjects = db.data.projects.filter(
119120
(p) => p.tabOrder !== null,
120121
);
121122

123+
// Fetch live sandbox statuses
124+
const sandboxResult = await cloudApiClient.listSandboxes();
125+
const liveSandboxes = new Map(
126+
(sandboxResult.sandboxes ?? []).map((s) => [s.id, s.status]),
127+
);
128+
122129
const groupsMap = new Map<
123130
string,
124131
{
@@ -138,6 +145,8 @@ export const createWorkspacesRouter = () => {
138145
createdAt: number;
139146
updatedAt: number;
140147
lastOpenedAt: number;
148+
cloudSandboxId?: string;
149+
cloudSandboxStatus?: string;
141150
}>;
142151
}
143152
>();
@@ -161,9 +170,24 @@ export const createWorkspacesRouter = () => {
161170

162171
for (const workspace of workspaces) {
163172
if (groupsMap.has(workspace.projectId)) {
173+
const worktree = db.data.worktrees.find(
174+
(wt) => wt.id === workspace.worktreeId,
175+
);
176+
const sandboxId = worktree?.cloudSandbox?.id;
177+
// Use live status if available, fallback to stored status
178+
const liveStatus = sandboxId
179+
? liveSandboxes.get(sandboxId)
180+
: undefined;
181+
// If sandbox exists in db but not in live list, it's stopped/deleted
182+
const status = sandboxId
183+
? (liveStatus ?? "stopped")
184+
: worktree?.cloudSandbox?.status;
185+
164186
groupsMap.get(workspace.projectId)?.workspaces.push({
165187
...workspace,
166188
worktreePath: getWorktreePath(workspace.worktreeId) ?? "",
189+
cloudSandboxId: sandboxId,
190+
cloudSandboxStatus: status,
167191
});
168192
}
169193
}
@@ -295,6 +319,19 @@ export const createWorkspacesRouter = () => {
295319
(p) => p.id === workspace.projectId,
296320
);
297321

322+
// Kill cloud sandbox if present
323+
if (worktree?.cloudSandbox?.id) {
324+
try {
325+
console.log(
326+
`Deleting cloud sandbox ${worktree.cloudSandbox.id} for worktree ${worktree.id}`,
327+
);
328+
await cloudApiClient.deleteSandbox(worktree.cloudSandbox.id);
329+
} catch (error) {
330+
console.error("Failed to delete cloud sandbox:", error);
331+
// Continue with deletion even if sandbox deletion fails
332+
}
333+
}
334+
298335
if (worktree && project) {
299336
try {
300337
const exists = await worktreeExists(
@@ -408,6 +445,26 @@ export const createWorkspacesRouter = () => {
408445

409446
return { success: true };
410447
}),
448+
449+
getDanglingSandboxes: publicProcedure.query(async () => {
450+
// Get all sandboxes from the cloud API
451+
const result = await cloudApiClient.listSandboxes();
452+
if (!result.success || !result.sandboxes) {
453+
return [];
454+
}
455+
456+
// Get all sandbox IDs that are linked to worktrees
457+
const linkedSandboxIds = new Set(
458+
db.data.worktrees
459+
.filter((wt) => wt.cloudSandbox?.id)
460+
.map((wt) => wt.cloudSandbox?.id),
461+
);
462+
463+
// Return only running sandboxes that are not linked to any worktree
464+
return result.sandboxes.filter(
465+
(s) => !linkedSandboxIds.has(s.id) && s.status === "running",
466+
);
467+
}),
411468
});
412469
};
413470

apps/desktop/src/main/lib/cloud-api-client.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ interface CreateSandboxResponse {
2929
claudeHost: string;
3030
}
3131

32+
interface ListSandboxResponse {
33+
id: string;
34+
name: string;
35+
status: string;
36+
websshHost?: string;
37+
claudeHost?: string;
38+
createdAt: string;
39+
}
40+
3241
/**
3342
* Client for interacting with yolocode cloud API
3443
* Uses GitHub token for authentication
@@ -180,6 +189,85 @@ class CloudApiClient {
180189
};
181190
}
182191
}
192+
193+
/**
194+
* List all sandboxes for the current user
195+
*/
196+
async listSandboxes(): Promise<{
197+
success: boolean;
198+
sandboxes?: CloudSandbox[];
199+
error?: string;
200+
}> {
201+
const token = this.getGithubToken();
202+
if (!token) {
203+
return {
204+
success: false,
205+
error: "GitHub authentication required",
206+
};
207+
}
208+
209+
try {
210+
const response = await fetch(this.baseUrl, {
211+
method: "GET",
212+
headers: {
213+
Authorization: `Bearer ${token}`,
214+
},
215+
});
216+
217+
if (!response.ok) {
218+
return {
219+
success: false,
220+
error: `Failed to list sandboxes: ${response.statusText}`,
221+
};
222+
}
223+
224+
const data: ListSandboxResponse[] = await response.json();
225+
226+
const sandboxes: CloudSandbox[] = data.map((s) => ({
227+
id: s.id,
228+
name: s.name,
229+
status: s.status === "running" ? "running" : "stopped",
230+
websshHost: s.websshHost,
231+
claudeHost: s.claudeHost?.replace(/https:\/\/\d+-/, (match) =>
232+
match.replace(/\d+/, "7030"),
233+
),
234+
createdAt: s.createdAt,
235+
}));
236+
237+
return { success: true, sandboxes };
238+
} catch (error) {
239+
console.error("Failed to list sandboxes:", error);
240+
return {
241+
success: false,
242+
error: error instanceof Error ? error.message : String(error),
243+
};
244+
}
245+
}
246+
247+
/**
248+
* Get status of a specific sandbox
249+
*/
250+
async getSandboxStatus(sandboxId: string): Promise<{
251+
success: boolean;
252+
status?: "running" | "stopped" | "error";
253+
error?: string;
254+
}> {
255+
const result = await this.listSandboxes();
256+
if (!result.success || !result.sandboxes) {
257+
return { success: false, error: result.error };
258+
}
259+
260+
const sandbox = result.sandboxes.find((s) => s.id === sandboxId);
261+
if (!sandbox) {
262+
// Sandbox not found - might have been deleted
263+
return { success: true, status: "stopped" };
264+
}
265+
266+
return {
267+
success: true,
268+
status: sandbox.status as "running" | "stopped",
269+
};
270+
}
183271
}
184272

185273
export const cloudApiClient = new CloudApiClient();

apps/desktop/src/main/lib/cloud-ipcs.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
22
import { ipcMain } from "electron";
33
import { cloudApiClient } from "./cloud-api-client";
44
import { db } from "./db";
5+
import type { CloudSandbox } from "./db/schemas";
56

67
/**
78
* Extract GitHub repo URL from a local git repository path
@@ -78,4 +79,41 @@ export function registerCloudHandlers() {
7879
return cloudApiClient.deleteSandbox(input.sandboxId);
7980
},
8081
);
82+
83+
ipcMain.handle("cloud-sandbox-list", async () => {
84+
return cloudApiClient.listSandboxes();
85+
});
86+
87+
ipcMain.handle(
88+
"cloud-sandbox-status",
89+
async (_event, input: { sandboxId: string }) => {
90+
return cloudApiClient.getSandboxStatus(input.sandboxId);
91+
},
92+
);
93+
94+
ipcMain.handle(
95+
"worktree-set-cloud-sandbox",
96+
async (
97+
_event,
98+
input: { worktreeId: string; cloudSandbox: CloudSandbox | null },
99+
) => {
100+
try {
101+
await db.update((data) => {
102+
const worktree = data.worktrees.find(
103+
(wt) => wt.id === input.worktreeId,
104+
);
105+
if (worktree) {
106+
worktree.cloudSandbox = input.cloudSandbox ?? undefined;
107+
}
108+
});
109+
return { success: true };
110+
} catch (error) {
111+
console.error("Failed to update worktree sandbox:", error);
112+
return {
113+
success: false,
114+
error: error instanceof Error ? error.message : String(error),
115+
};
116+
}
117+
},
118+
);
81119
}

apps/desktop/src/main/lib/db/schemas.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,23 @@ export interface Project {
88
createdAt: number;
99
}
1010

11+
export interface CloudSandbox {
12+
id: string;
13+
name: string;
14+
status: "creating" | "running" | "stopped" | "error";
15+
websshHost?: string;
16+
claudeHost?: string;
17+
createdAt: string;
18+
error?: string;
19+
}
20+
1121
export interface Worktree {
1222
id: string;
1323
projectId: string;
1424
path: string;
1525
branch: string;
1626
createdAt: number;
27+
cloudSandbox?: CloudSandbox;
1728
}
1829

1930
export interface Workspace {

apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CloudWorkspaceButton.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export function CloudWorkspaceButton({ className }: CloudWorkspaceButtonProps) {
6666
// 1. Create local workspace first
6767
const workspaceResult = await createWorkspace.mutateAsync({ projectId });
6868
const workspaceId = workspaceResult.workspace.id;
69+
const worktreeId = workspaceResult.workspace.worktreeId;
6970

7071
// 2. Create cloud sandbox
7172
const sandboxName = generateSandboxName();
@@ -81,7 +82,15 @@ export function CloudWorkspaceButton({ className }: CloudWorkspaceButtonProps) {
8182

8283
const sandbox = result.sandbox;
8384

84-
// 3. Add two webview tabs: Claude chat (7030) and WebSSH (8888)
85+
// 3. Save sandbox to worktree
86+
if (sandbox) {
87+
await window.ipcRenderer.invoke("worktree-set-cloud-sandbox", {
88+
worktreeId,
89+
cloudSandbox: sandbox,
90+
});
91+
}
92+
93+
// 4. Add two webview tabs: Claude chat (7030) and WebSSH (8888)
8594
if (sandbox?.claudeHost) {
8695
const claudeUrl = sandbox.claudeHost.startsWith("http")
8796
? sandbox.claudeHost

0 commit comments

Comments
 (0)