diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index ee7d4ec9bf0..9984046f3be 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 e9672d643bd..a1a48e89e47 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 00000000000..1039c087833 --- /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 63245ab632c..080ec5aa32a 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 f33659bcfc5..f79cf910cba 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 60e3b650a20..cef99805f15 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 0b4c5bfc693..8d717e90eb5 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 c620c577648..b06b1fa627c 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 00000000000..2532e32453d --- /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 ace54e9fa47..16d249a6dbb 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);