Skip to content
Merged
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
7 changes: 5 additions & 2 deletions apps/web/app/gen2/[workspaceId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";

import { Gen2WorkspaceRoom } from "@/components/gen2/workspace-room";
import { AppChrome } from "@/components/shell/app-chrome";
import { requireUser } from "@/lib/auth/session";
import { Gen2AccessError } from "@/lib/gen2/errors";
import { Gen2AccessError, Gen2LifecycleError } from "@/lib/gen2/errors";
import { getGen2WorkspaceDetail } from "@/lib/gen2/workspaces";

export const metadata: Metadata = { title: "Workspace" };
Expand All @@ -21,6 +21,9 @@ export default async function Gen2WorkspacePage({
workspace = await getGen2WorkspaceDetail(workspaceId, user.id);
} catch (error) {
if (error instanceof Gen2AccessError) notFound();
if (error instanceof Gen2LifecycleError && error.status === 409) {
redirect("/gen2");
}
throw error;
}

Expand Down
17 changes: 17 additions & 0 deletions apps/web/app/gen2/gen2.css
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@
border-color: var(--line-strong);
}

.gen2-card-pending {
cursor: default;
opacity: 0.72;
}

.gen2-card:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 3px;
Expand Down Expand Up @@ -168,6 +173,14 @@
color: var(--muted);
}

.gen2-create-limit {
flex-basis: 100%;
margin: 0;
color: var(--muted);
font-size: 0.875rem;
line-height: 1.5;
}

.gen2-room-header {
display: flex;
flex-wrap: wrap;
Expand Down Expand Up @@ -204,6 +217,10 @@
background: #ef4444;
}

.gen2-status-deleting .gen2-status-dot {
background: #ef4444;
}

.gen2-note {
margin: 0;
color: var(--muted);
Expand Down
3 changes: 3 additions & 0 deletions apps/web/app/gen2/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export default async function Gen2WorkspacesPage() {
</p>
<CreateGen2WorkspaceForm
githubConnected={github.connected}
ownedWorkspaceCount={
workspaces.filter((workspace) => workspace.role === "owner").length
}
appSlug={process.env.GITHUB_APP_SLUG}
connectGitHub={connectGitHubAccount.bind(null, "/gen2")}
/>
Expand Down
15 changes: 15 additions & 0 deletions apps/web/components/gen2/create-workspace-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ describe("CreateGen2WorkspaceForm", () => {
);
});

it("blocks workspace creation when the owner limit is reached", () => {
render(
<CreateGen2WorkspaceForm
githubConnected={false}
ownedWorkspaceCount={2}
/>,
);
expect(
screen.getByRole("button", { name: /Blank workspace/ }),
).toBeDisabled();
expect(screen.getByRole("status")).toHaveTextContent(
"You own 2 of 2 Gen 2 workspaces. Delete one to create another.",
);
});

it("offers GitHub when it is not connected yet", () => {
render(<CreateGen2WorkspaceForm githubConnected={false} />);
expect(
Expand Down
15 changes: 13 additions & 2 deletions apps/web/components/gen2/create-workspace-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { Plus, Search } from "lucide-react";

import { GithubMark } from "@/components/settings/github-mark";
import { GEN2_MAX_OWNED_WORKSPACES } from "@/lib/gen2/constants";

type Installation = {
id: number;
Expand All @@ -27,10 +28,12 @@ type Repository = {
*/
export function CreateGen2WorkspaceForm({
githubConnected,
ownedWorkspaceCount = 0,
appSlug,
connectGitHub,
}: {
githubConnected: boolean;
ownedWorkspaceCount?: number;
appSlug?: string | undefined;
/**
* The bound `connectGitHubAccount` server action, handed down rather than
Expand All @@ -40,6 +43,7 @@ export function CreateGen2WorkspaceForm({
connectGitHub?: (() => void) | undefined;
}) {
const router = useRouter();
const atWorkspaceLimit = ownedWorkspaceCount >= GEN2_MAX_OWNED_WORKSPACES;
const [installations, setInstallations] = useState<Installation[]>([]);
const [installationId, setInstallationId] = useState<number | null>(null);
const [repositories, setRepositories] = useState<Repository[]>([]);
Expand Down Expand Up @@ -118,7 +122,7 @@ export function CreateGen2WorkspaceForm({
<button
type="button"
className="primary-button"
disabled={busy}
disabled={busy || atWorkspaceLimit}
onClick={() => void create({})}
>
<Plus aria-hidden="true" size={14} />
Expand Down Expand Up @@ -163,13 +167,20 @@ export function CreateGen2WorkspaceForm({
)}
</div>

{atWorkspaceLimit ? (
<p className="gen2-create-limit" role="status">
You own {ownedWorkspaceCount} of {GEN2_MAX_OWNED_WORKSPACES} Gen 2
workspaces. Delete one to create another.
</p>
) : null}

{githubConnected && visible.length > 0 ? (
<ul className="gen2-repo-list">
{visible.map((repo) => (
<li key={repo.id}>
<button
type="button"
disabled={busy}
disabled={busy || atWorkspaceLimit}
onClick={() =>
void create({
installationId,
Expand Down
73 changes: 73 additions & 0 deletions apps/web/components/gen2/workspace-list.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({ refresh: vi.fn() }));

vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: mocks.refresh }),
}));

import type { Gen2Workspace } from "@codev/contracts";

import { Gen2WorkspaceList } from "./workspace-list";

const ownerWorkspace: Gen2Workspace = {
id: "11111111-1111-4111-8111-111111111111",
name: "Studio",
repository: null,
status: "ready",
sandboxId: "sandbox-1",
lastError: null,
role: "owner",
createdAt: "2026-09-20T20:00:00.000Z",
updatedAt: "2026-09-20T20:00:00.000Z",
};

describe("Gen2WorkspaceList", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal(
"confirm",
vi.fn(() => true),
);
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 204 })),
);
});

it("confirms permanent deletion and refreshes the owner count", async () => {
render(<Gen2WorkspaceList workspaces={[ownerWorkspace]} />);
fireEvent.click(screen.getByRole("button", { name: "Delete Studio" }));

await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(
`/api/gen2/workspaces/${ownerWorkspace.id}`,
{ method: "DELETE" },
),
);
expect(window.confirm).toHaveBeenCalledWith(
expect.stringContaining("permanently deletes the workspace"),
);
expect(mocks.refresh).toHaveBeenCalledOnce();
expect(screen.getByText("No workspaces yet.")).toBeInTheDocument();
});

it("shows interrupted deletion as retryable and not openable", () => {
const deletingWorkspace = {
...ownerWorkspace,
status: "deleting" as const,
lastError: "Deletion did not finish.",
};
render(<Gen2WorkspaceList workspaces={[deletingWorkspace]} />);

expect(screen.getByText("Deleting")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: /Studio/ })).toBeNull();
expect(
screen.getByRole("button", { name: "Retry deletion of Studio" }),
).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent(
"Deletion did not finish. Retry deletion to continue.",
);
});
});
56 changes: 42 additions & 14 deletions apps/web/components/gen2/workspace-list.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { LoaderCircle, Trash2 } from "lucide-react";
import type { Gen2Workspace } from "@codev/contracts";
Expand All @@ -11,13 +12,15 @@ const STATUS_LABEL: Record<Gen2Workspace["status"], string> = {
ready: "Ready",
failed: "Failed",
stopped: "Idle",
deleting: "Deleting",
};

export function Gen2WorkspaceList({
workspaces: initialWorkspaces,
}: {
workspaces: Gen2Workspace[];
}) {
const router = useRouter();
const [workspaces, setWorkspaces] = useState(initialWorkspaces);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [actionError, setActionError] = useState<{
Expand Down Expand Up @@ -52,6 +55,7 @@ export function Gen2WorkspaceList({
setWorkspaces((current) =>
current.filter((item) => item.id !== workspace.id),
);
router.refresh();
} catch {
setActionError({
workspaceId: workspace.id,
Expand All @@ -70,36 +74,50 @@ export function Gen2WorkspaceList({
<ul className="gen2-list">
{workspaces.map((workspace) => {
const isDeleting = deletingId === workspace.id;
const deletionPending = workspace.status === "deleting";
const error =
actionError?.workspaceId === workspace.id
? actionError.message
: null;
const cardContent = (
<>
<strong>{workspace.name}</strong>
{workspace.repository ? (
<span className="gen2-card-repo">
{workspace.repository.fullName}
</span>
) : null}
<span className={`gen2-status gen2-status-${workspace.status}`}>
<span className="gen2-status-dot" aria-hidden="true" />
{STATUS_LABEL[workspace.status]}
</span>
</>
);

return (
<li key={workspace.id}>
<div className="gen2-card-row">
<Link className="gen2-card" href={`/gen2/${workspace.id}`}>
<strong>{workspace.name}</strong>
{workspace.repository ? (
<span className="gen2-card-repo">
{workspace.repository.fullName}
</span>
) : null}
<span className={`gen2-status gen2-status-${workspace.status}`}>
<span className="gen2-status-dot" aria-hidden="true" />
{STATUS_LABEL[workspace.status]}
</span>
</Link>
{deletionPending ? (
<div className="gen2-card gen2-card-pending">{cardContent}</div>
) : (
<Link className="gen2-card" href={`/gen2/${workspace.id}`}>
{cardContent}
</Link>
)}
{workspace.role === "owner" ? (
<button
type="button"
className="gen2-delete-button"
aria-label={
isDeleting
? `Deleting ${workspace.name}`
: `Delete ${workspace.name}`
: deletionPending
? `Retry deletion of ${workspace.name}`
: `Delete ${workspace.name}`
}
title={
deletionPending ? "Retry deletion" : "Delete workspace"
}
title="Delete workspace"
disabled={deletingId !== null}
onClick={() => void deleteWorkspace(workspace)}
>
Expand All @@ -115,6 +133,16 @@ export function Gen2WorkspaceList({
</button>
) : null}
</div>
{deletionPending && !error ? (
<p
className="gen2-action-error"
role={workspace.lastError ? "alert" : "status"}
>
{workspace.lastError
? "Deletion did not finish. Retry deletion to continue."
: "Workspace deletion is in progress."}
</p>
) : null}
{error ? (
<p className="gen2-action-error" role="alert">
{error}
Expand Down
1 change: 1 addition & 0 deletions apps/web/components/gen2/workspace-room.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const STATUS_LABEL: Record<Gen2WorkspaceDetail["status"], string> = {
ready: "Ready",
failed: "Failed",
stopped: "Starting",
deleting: "Deleting",
};

export function Gen2WorkspaceRoom({
Expand Down
2 changes: 2 additions & 0 deletions apps/web/lib/gen2/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Maximum number of Gen 2 workspaces a user may own at once. */
export const GEN2_MAX_OWNED_WORKSPACES = 2;
12 changes: 12 additions & 0 deletions apps/web/lib/gen2/instance-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,18 @@ describe("gen2 instance lifecycle", () => {
expect(mocks.updates).toEqual([]);
});

it("does not restart a workspace once deletion has begun", async () => {
mocks.member.status = "deleting";
await expect(
ensureGen2Instance(mocks.member.id, "user-1", {
provision: mocks.provision,
destroy: mocks.destroy,
}),
).rejects.toMatchObject({ status: 409 });
expect(mocks.ensureHostReady).not.toHaveBeenCalled();
expect(mocks.provision).not.toHaveBeenCalled();
});

it("leaves a ready workspace untouched when runtime verification succeeds", async () => {
mocks.member.status = "ready";
mocks.member.sandboxId = "sandbox-1";
Expand Down
Loading
Loading