diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index c1ea0e061b..59c22f5cf7 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -108,6 +108,7 @@ export default defineConfig({
"**/project-inbox.spec.ts",
"**/project-issue-comments.spec.ts",
"**/project-pr-review.spec.ts",
+ "**/projects-empty-create.spec.ts",
"**/persona-model-combobox-screenshots.spec.ts",
"**/drafts-screenshots.spec.ts",
"**/drafts-all-fix-screenshots.spec.ts",
diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx
index ed28ddd583..d3b4d66aa3 100644
--- a/desktop/src/features/projects/ui/ProjectCards.tsx
+++ b/desktop/src/features/projects/ui/ProjectCards.tsx
@@ -4,6 +4,7 @@ import {
Folders,
GitCommit,
GitPullRequest,
+ Plus,
TerminalSquare,
Trash2,
} from "lucide-react";
@@ -313,9 +314,16 @@ function RepositoryUnavailableIndicator({
);
}
-export function EmptyState() {
+export function EmptyState({
+ onCreateProject,
+}: {
+ onCreateProject?: () => void;
+} = {}) {
return (
-
+
No projects yet
@@ -323,6 +331,18 @@ export function EmptyState() {
Projects published to this relay will appear here.
+ {onCreateProject ? (
+
+ ) : null}
);
}
diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx
index 72a514cea5..42b0da7520 100644
--- a/desktop/src/features/projects/ui/ProjectsView.tsx
+++ b/desktop/src/features/projects/ui/ProjectsView.tsx
@@ -597,10 +597,6 @@ export function ProjectsView() {
);
}
- if (projects.length === 0) {
- return
;
- }
-
const projectItems =
visibleProjects.length === 0 ? (
@@ -859,7 +855,11 @@ export function ProjectsView() {
- {filter === "all" ? (
+ {projects.length === 0 ? (
+
setCreateProjectOpen(true)}
+ />
+ ) : filter === "all" ? (
{
+ window.localStorage.setItem(key, JSON.stringify({ projects: true }));
+ },
+ { key: FEATURE_OVERRIDES_STORAGE_KEY },
+ );
+}
+
+/**
+ * Hide every seeded mock project so Projects renders the true empty state.
+ *
+ * Addresses must match MOCK_PROJECT_SEEDS (and the kind:30621 "buzz" project
+ * announcement) in `desktop/src/testing/e2eBridge.ts`. If a seed is added or
+ * renamed there without updating this list, the list stays populated and the
+ * `projects-empty-state` assertion below fails.
+ */
+async function hideAllMockProjects(page: import("@playwright/test").Page) {
+ const hiddenCards = [
+ `30621:${DEFAULT_MOCK_PUBKEY}:buzz`,
+ `30617:${DEFAULT_MOCK_PUBKEY}:buzz`,
+ `30617:${TEST_IDENTITIES.alice.pubkey}:relay-tools`,
+ `30617:${TEST_IDENTITIES.bob.pubkey}:design-system`,
+ ];
+ await page.addInitScript((cards) => {
+ window.localStorage.setItem(
+ "buzz.projects.hidden-cards.v1",
+ JSON.stringify(cards),
+ );
+ }, hiddenCards);
+}
+
+test("empty Projects keeps Create controls and offers an empty-state CTA", async ({
+ page,
+}) => {
+ await enableProjectsFeature(page);
+ await hideAllMockProjects(page);
+ await installMockBridge(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByTestId("open-projects-view").click();
+
+ // The chrome (header + toolbar + create menu) must mount with zero projects.
+ await expect(page.getByTestId("projects-empty-state")).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.getByTestId("projects-create-menu")).toBeVisible();
+
+ // Entry point 1: the toolbar "+" create menu.
+ await page.getByTestId("projects-create-menu").hover();
+ await page.getByRole("menuitem", { name: "Project" }).click();
+ await expect(page.getByTestId("create-project-dialog")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("create-project-dialog")).toBeHidden();
+
+ // Entry point 2: the empty-state CTA.
+ await page.getByTestId("projects-empty-create-project").click();
+ await expect(page.getByTestId("create-project-dialog")).toBeVisible();
+ await expect(page.getByTestId("create-project-name")).toBeVisible();
+});