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 <thomasp@squareup.com>
This commit is contained in:
thomaspblock
2026-08-28 13:52:03 -04:00
committed by GitHub
parent 58cc4b7e9b
commit a89f5df4c5
10 changed files with 139 additions and 25 deletions

View File

@@ -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",

View File

@@ -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 (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
<Folders className="h-10 w-10 text-muted-foreground/40" />
@@ -314,6 +319,10 @@ export function EmptyState() {
Projects published to this relay will appear here.
</p>
</div>
<Button onClick={onCreateProject} size="sm" type="button">
<Plus className="h-4 w-4" />
Create project
</Button>
</div>
);
}

View File

@@ -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 (
<CreateProjectDialog
isCreating={createProjectMutation.isPending}
onCreate={async (input) => {
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}
/>
);
}

View File

@@ -258,7 +258,7 @@ export function ProjectHomeContextPanel({
return (
<div
className="space-y-4 px-2 pb-8 pt-1"
className="space-y-4 px-2 pb-8 pt-3"
data-testid="project-home-context-panel"
>
<ContextSection testId="project-home-context-workspace">

View File

@@ -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<ProjectsViewMode | null>(() => readStoredViewMode());
const [sort, setSort] = React.useState<ProjectsSort>(() => readStoredSort());
@@ -566,7 +563,15 @@ export function ProjectsView() {
}
if (projectReadModels.length === 0) {
return <EmptyState />;
return (
<>
<ProjectCreationDialog
onOpenChange={setCreateProjectOpen}
open={createProjectOpen}
/>
<EmptyState onCreateProject={() => setCreateProjectOpen(true)} />
</>
);
}
const projectItems = (
@@ -740,19 +745,7 @@ export function ProjectsView() {
overviewDetached ? "projects-overview-content-pod" : undefined
}
>
<CreateProjectDialog
isCreating={createProjectMutation.isPending}
onCreate={async (input) => {
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);
}}
<ProjectCreationDialog
onOpenChange={setCreateProjectOpen}
open={createProjectOpen}
/>

View File

@@ -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(

View File

@@ -405,7 +405,7 @@ export function projectsOverviewContext(
section: "prs",
},
],
title: "Projects",
title: "Activity",
};
}

View File

@@ -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);

View File

@@ -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,
);
});

View File

@@ -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);