From a89f5df4c52c3aa5875d9fccb0eaf2222aee4084 Mon Sep 17 00:00:00 2001 From: thomaspblock Date: Fri, 28 Aug 2026 13:52:03 -0400 Subject: [PATCH] fix(desktop): complete project empty and context states (#6980) ## Summary First-time project users can now create a project directly from the empty state instead of reaching a dead end. The right-side project context also uses the intended Activity label and aligns its first row with the channel header. The populated and empty project views share one creation flow, so navigation, success feedback, and compatibility warnings remain consistent. ### Related issue Related: #6939 ### Testing - Desktop check, TypeScript typecheck, and repository file-size gate - 10 focused project overview context unit tests - E2E-mode build and 3 focused Playwright scenarios covering empty-state creation, right-sidebar alignment, and Activity context Signed-off-by: Thomas Petersen --- desktop/playwright.config.ts | 1 + .../src/features/projects/ui/ProjectCards.tsx | 11 ++- .../projects/ui/ProjectCreationDialog.tsx | 36 ++++++++++ .../projects/ui/ProjectHomeContextPanel.tsx | 2 +- .../src/features/projects/ui/ProjectsView.tsx | 29 +++----- .../ui/projectsOverviewContext.test.mjs | 2 +- .../projects/ui/projectsOverviewContext.ts | 2 +- desktop/src/testing/e2eBridge.ts | 6 ++ .../e2e/project-empty-state-alignment.spec.ts | 69 +++++++++++++++++++ desktop/tests/e2e/project-pr-review.spec.ts | 6 +- 10 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 desktop/src/features/projects/ui/ProjectCreationDialog.tsx create mode 100644 desktop/tests/e2e/project-empty-state-alignment.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index ee7d4ec9bf..9984046f3b 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -121,6 +121,7 @@ export default defineConfig({ "**/send-channel-binding.spec.ts", "**/project-cold-start.spec.ts", "**/project-commit-detail.spec.ts", + "**/project-empty-state-alignment.spec.ts", "**/project-inbox.spec.ts", "**/projects-v3-screenshots.spec.ts", "**/project-issue-comments.spec.ts", diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index e9672d643b..a1a48e89e4 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -5,6 +5,7 @@ import { Folders, GitCommit, GitPullRequest, + Plus, TerminalSquare, Trash2, } from "lucide-react"; @@ -304,7 +305,11 @@ function RepositoryUnavailableIndicator({ ); } -export function EmptyState() { +export function EmptyState({ + onCreateProject, +}: { + onCreateProject: () => void; +}) { return (
@@ -314,6 +319,10 @@ export function EmptyState() { Projects published to this relay will appear here.

+ ); } diff --git a/desktop/src/features/projects/ui/ProjectCreationDialog.tsx b/desktop/src/features/projects/ui/ProjectCreationDialog.tsx new file mode 100644 index 0000000000..1039c08783 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectCreationDialog.tsx @@ -0,0 +1,36 @@ +import { toast } from "sonner"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useCreateProjectMutation } from "@/features/projects/useCreateProject"; +import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog"; + +/** Shared project-creation flow for populated and first-run project views. */ +export function ProjectCreationDialog({ + onOpenChange, + open, +}: { + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + const { goProject } = useAppNavigation(); + const createProjectMutation = useCreateProjectMutation(); + + return ( + { + const result = await createProjectMutation.mutateAsync(input); + if (result.compatibilityWarning) { + toast.warning("Created as a standalone project", { + description: result.compatibilityWarning, + }); + } else { + toast.success(`Project "${result.project.name}" created.`); + } + await goProject(result.project.id); + }} + onOpenChange={onOpenChange} + open={open} + /> + ); +} diff --git a/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx index 63245ab632..080ec5aa32 100644 --- a/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx @@ -258,7 +258,7 @@ export function ProjectHomeContextPanel({ return (
diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index f33659bcfc..f79cf910cb 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useManagedAgentsQuery } from "@/features/agents/hooks"; @@ -17,7 +16,6 @@ import { useProjectsWorkItemsQuery, } from "@/features/projects/hooks"; import { useRepositoryActivitySummariesQuery } from "@/features/projects/repositoryActivityHooks"; -import { useCreateProjectMutation } from "@/features/projects/useCreateProject"; import { isExplicitProject } from "@/features/projects/projectModels"; import { projectsWithWorkItemRepositories } from "@/features/projects/projectWorkItems"; import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots"; @@ -52,7 +50,7 @@ import { ProjectsOverviewProjectItems, ProjectsOverviewRepositoryItems, } from "@/features/projects/ui/ProjectsOverviewItems"; -import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog"; +import { ProjectCreationDialog } from "@/features/projects/ui/ProjectCreationDialog"; import { CreateProjectIssueDialog } from "@/features/projects/ui/CreateProjectIssueDialog"; import { CreatePullRequestDialog } from "@/features/projects/ui/CreatePullRequestDialog"; import { ProjectAgentChatPanel } from "@/features/projects/ui/ProjectAgentChatPanel"; @@ -183,7 +181,6 @@ export function ProjectsView() { const [createIssueOpen, setCreateIssueOpen] = React.useState(false); const [createPullRequestOpen, setCreatePullRequestOpen] = React.useState(false); - const createProjectMutation = useCreateProjectMutation(); const [storedViewMode, setStoredViewMode] = React.useState(() => readStoredViewMode()); const [sort, setSort] = React.useState(() => readStoredSort()); @@ -566,7 +563,15 @@ export function ProjectsView() { } if (projectReadModels.length === 0) { - return ; + return ( + <> + + setCreateProjectOpen(true)} /> + + ); } const projectItems = ( @@ -740,19 +745,7 @@ export function ProjectsView() { overviewDetached ? "projects-overview-content-pod" : undefined } > - { - const result = await createProjectMutation.mutateAsync(input); - if (result.compatibilityWarning) { - toast.warning("Created as a standalone project", { - description: result.compatibilityWarning, - }); - } else { - toast.success(`Project "${result.project.name}" created.`); - } - await goProject(result.project.id); - }} + diff --git a/desktop/src/features/projects/ui/projectsOverviewContext.test.mjs b/desktop/src/features/projects/ui/projectsOverviewContext.test.mjs index 60e3b650a2..cef99805f1 100644 --- a/desktop/src/features/projects/ui/projectsOverviewContext.test.mjs +++ b/desktop/src/features/projects/ui/projectsOverviewContext.test.mjs @@ -72,7 +72,7 @@ test("activity pod shows workspace details without a create action", () => { }, }); - assert.equal(context.title, "Projects"); + assert.equal(context.title, "Activity"); assert.equal(context.detailsTitle, "Details"); assert.equal(context.action, null); assert.deepEqual( diff --git a/desktop/src/features/projects/ui/projectsOverviewContext.ts b/desktop/src/features/projects/ui/projectsOverviewContext.ts index 0b4c5bfc69..8d717e90eb 100644 --- a/desktop/src/features/projects/ui/projectsOverviewContext.ts +++ b/desktop/src/features/projects/ui/projectsOverviewContext.ts @@ -405,7 +405,7 @@ export function projectsOverviewContext( section: "prs", }, ], - title: "Projects", + title: "Activity", }; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index c620c57764..b06b1fa627 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1259,6 +1259,8 @@ declare global { __BUZZ_E2E_REPOSITORY_ONLY_PROJECTS__?: boolean; /** Leaves broad project enumeration pending while scoped project queries remain available. */ __BUZZ_E2E_DEFER_FULL_PROJECT_QUERIES__?: boolean; + /** Omits all seeded project and repository events for empty-state tests. */ + __BUZZ_E2E_EMPTY_PROJECTS__?: boolean; /** Project-scoped events accepted by the mock relay. */ __BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?: Array<{ content: string; @@ -5912,6 +5914,10 @@ function writeMockProjectBranch( } function buildMockProjectEvents(): RelayEvent[] { + if (window.__BUZZ_E2E_EMPTY_PROJECTS__) { + return []; + } + const events: RelayEvent[] = []; const daySeconds = 86_400; const now = Math.floor(Date.now() / 1000); diff --git a/desktop/tests/e2e/project-empty-state-alignment.spec.ts b/desktop/tests/e2e/project-empty-state-alignment.spec.ts new file mode 100644 index 0000000000..2532e32453 --- /dev/null +++ b/desktop/tests/e2e/project-empty-state-alignment.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +const ALIGNMENT_TOLERANCE_PX = 2; + +async function enableProjectsFeature(page: import("@playwright/test").Page) { + await page.addInitScript(() => { + window.localStorage.setItem( + "buzz-feature-overrides-v1", + JSON.stringify({ projects: true }), + ); + }); +} + +async function addProjectToSidebar( + page: import("@playwright/test").Page, + dtag: string, +) { + await page.getByTestId("sidebar-projects-section-label").hover(); + await page.getByTestId("sidebar-projects-create").click(); + const browser = page.getByTestId("project-browser-dialog"); + await browser.getByRole("searchbox", { name: "Search projects" }).fill(dtag); + await browser.getByTestId(`project-browser-result-${dtag}`).click(); + await expect(browser).toBeHidden(); + await expect(page.getByTestId(`sidebar-project-${dtag}`)).toBeVisible(); +} + +test("first-time project empty state opens project creation", async ({ + page, +}) => { + await enableProjectsFeature(page); + await page.addInitScript(() => { + window.__BUZZ_E2E_EMPTY_PROJECTS__ = true; + }); + await installMockBridge(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-projects-view").click(); + + await expect( + page.getByRole("main").getByText("No projects yet"), + ).toBeVisible(); + await page.getByRole("button", { name: "Create project" }).click(); + await expect(page.getByTestId("create-project-dialog")).toBeVisible(); +}); + +test("project home context aligns with the channel header", async ({ + page, +}) => { + await enableProjectsFeature(page); + await installMockBridge(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await addProjectToSidebar(page, "buzz"); + await waitForAnimations(page); + + const [headerTitleBox, tasksBox] = await Promise.all([ + page.getByTestId("chat-title").boundingBox(), + page.getByTestId("project-home-context-tasks").boundingBox(), + ]); + expect(headerTitleBox).not.toBeNull(); + expect(tasksBox).not.toBeNull(); + const headerTitleCenter = + (headerTitleBox?.y ?? 0) + (headerTitleBox?.height ?? 0) / 2; + const tasksCenter = (tasksBox?.y ?? 0) + (tasksBox?.height ?? 0) / 2; + expect(Math.abs(headerTitleCenter - tasksCenter)).toBeLessThanOrEqual( + ALIGNMENT_TOLERANCE_PX, + ); +}); diff --git a/desktop/tests/e2e/project-pr-review.spec.ts b/desktop/tests/e2e/project-pr-review.spec.ts index ace54e9fa4..16d249a6db 100644 --- a/desktop/tests/e2e/project-pr-review.spec.ts +++ b/desktop/tests/e2e/project-pr-review.spec.ts @@ -1722,7 +1722,7 @@ test("project overview presents collapsible context beside grouped activity", as page.getByTestId("projects-overview-context-panel"), ).toBeVisible(); await expect(page.getByTestId("projects-overview-context-title")).toHaveText( - "Projects", + "Activity", ); expect( Math.round( @@ -1757,7 +1757,7 @@ test("project overview presents collapsible context beside grouped activity", as await expect( page .getByTestId("projects-overview-context-panel") - .getByRole("heading", { name: "Activity" }), + .getByRole("heading", { level: 3, name: "Activity" }), ).toHaveCount(0); await expect( page @@ -1932,7 +1932,7 @@ test("project overview presents collapsible context beside grouped activity", as page.getByTestId("projects-overview-context-panel"), ).toBeVisible(); await expect(page.getByTestId("projects-overview-context-title")).toHaveText( - "Projects", + "Activity", ); await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0);