diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
index a83456652..108ba4c35 100644
--- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx
+++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
@@ -2,6 +2,12 @@ import * as React from "react";
import { ArrowLeft, ChevronRight, Link2, Plus } from "lucide-react";
import type { AddCommunityPrefillRequest } from "@/features/communities/addCommunityPrefill";
+import {
+ LOCAL_COMMUNITY_NAME,
+ LOCAL_COMMUNITY_RELAY_URL,
+} from "@/features/communities/communityStorage";
+import { useCommunities } from "@/features/communities/useCommunities";
+import { CommunityCreationChoice } from "@/features/communities/ui/CommunityCreationChoice";
import { HostedCommunityCreateFlow } from "@/features/communities/ui/HostedCommunityCreateFlow";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm";
@@ -22,7 +28,7 @@ type AddCommunityDialogProps = {
onOpenChange: (open: boolean) => void;
};
-type AddCommunityMode = "choose" | "create" | "join";
+type AddCommunityMode = "choose" | "create-choose" | "create-hosted" | "join";
const OPTION_CLASS =
"flex w-full items-center gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-4 text-left transition-colors duration-150 ease-out hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring";
@@ -33,6 +39,8 @@ export function AddCommunityDialog({
onOpenChange,
}: AddCommunityDialogProps) {
const communityOnboarding = useCommunityOnboarding();
+ const { communities } = useCommunities();
+ const hasLocalCommunity = communities.some((community) => community.local);
const [mode, setMode] = React.useState("choose");
const [joinError, setJoinError] = React.useState(null);
const appliedPrefillId = React.useRef(null);
@@ -78,19 +86,38 @@ export function AddCommunityDialog({
[communityOnboarding, handleClose, prefill?.name],
);
+ const startLocalCommunity = React.useCallback(() => {
+ const started = communityOnboarding.start({
+ source: "add-community",
+ communityName: LOCAL_COMMUNITY_NAME,
+ relayUrl: LOCAL_COMMUNITY_RELAY_URL,
+ });
+ if (!started) {
+ setJoinError(
+ "Finish connecting the community already in progress, then try again.",
+ );
+ return;
+ }
+ handleClose();
+ }, [communityOnboarding, handleClose]);
+
const title =
- mode === "create"
+ mode === "create-choose"
? "Create a new community"
- : mode === "join"
- ? "Join an existing community"
- : "Add community";
+ : mode === "create-hosted"
+ ? "Host a community online"
+ : mode === "join"
+ ? "Join an existing community"
+ : "Add community";
const description =
- mode === "create"
- ? "Opens Builderlab in your browser."
- : mode === "join"
- ? "Use the community URL or invite link you received."
- : "Create a new community or join one you already have.";
+ mode === "create-choose"
+ ? "Choose where your community will live."
+ : mode === "create-hosted"
+ ? "Opens Builderlab in your browser."
+ : mode === "join"
+ ? "Use the community URL or invite link you received."
+ : "Create a new community or join one you already have.";
return (
-
-
- Use this device
-
-
setIsHostedSignInOpen(true)}
+ onClick={() => showPage("create")}
type="button"
>
Create a community
@@ -204,6 +198,38 @@ export function WelcomeSetup({
) : null}
+ ) : page === "create" ? (
+
+
+
Create a community
+
+ Choose where your community will live.
+
+
+
+ setIsHostedSignInOpen(true)}
+ onChooseLocal={startLocalCommunity}
+ variant="onboarding"
+ />
+
+
+ showPage("welcome")}
+ type="button"
+ variant="ghost"
+ >
+ Back
+
+
+
) : page === "existing" ? (
{
test("capture: create a new community", async ({ page }) => {
await page.getByTestId("add-community-create").click();
+ await page.getByTestId("community-create-hosted").click();
const dialog = page.getByTestId("add-community-dialog");
await page.getByLabel("Community address").waitFor();
await waitForAnimations(page);
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index 403edbcda..3f5733b2e 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -1087,6 +1087,15 @@ test("first-community choices route join, create, owner, and member intents", as
await expect(
page.getByRole("button", { name: /Create a community/ }),
).toBeVisible();
+ await expect(page.getByTestId("community-choice-local")).toHaveCount(0);
+ await page.getByTestId("community-choice-create").click();
+ await expect(page.getByTestId("community-create-hosted")).toContainText(
+ "Host it online",
+ );
+ await expect(page.getByTestId("community-choice-local")).toContainText(
+ "Keep it on this device",
+ );
+ await page.getByTestId("community-create-back").click();
const existing = page.getByRole("button", {
name: /I already have a community/,
});
@@ -1169,6 +1178,7 @@ test("first-community owner can connect an existing hosted community", async ({
await page.goto("/");
await page.getByTestId("community-choice-create").click();
+ await page.getByTestId("community-create-hosted").click();
await expect(page.getByText("North Star")).toBeVisible();
await page.getByRole("button", { name: "Connect", exact: true }).click();
await expect(
@@ -1227,6 +1237,7 @@ test("first-community owner can create and connect a hosted community", async ({
await page.goto("/");
await page.getByTestId("community-choice-create").click();
+ await page.getByTestId("community-create-hosted").click();
await page.getByRole("button", { name: "Sign in to continue" }).click();
await expect(
page.getByRole("heading", { name: "Finish connecting Buzz" }),
@@ -1303,6 +1314,7 @@ test("hosted community address line stays within the card for a long name", asyn
await page.goto("/");
await page.getByTestId("community-choice-create").click();
+ await page.getByTestId("community-create-hosted").click();
await page.getByRole("button", { name: "Sign in to continue" }).click();
await expect(
page.getByRole("heading", { name: "Finish connecting Buzz" }),
@@ -1372,6 +1384,7 @@ test("first-community reports a created community without a relay address", asyn
await page.goto("/");
await page.getByTestId("community-choice-create").click();
+ await page.getByTestId("community-create-hosted").click();
await page.getByRole("textbox", { name: "Community name" }).fill("bee-lab");
await expect(page.getByText("That address is available.")).toBeVisible();
await page.getByRole("button", { name: "Next" }).click();
@@ -1403,6 +1416,7 @@ test("first-community X cancels a pending sign-in", async ({ page }) => {
await page.goto("/");
await page.getByTestId("community-choice-create").click();
+ await page.getByTestId("community-create-hosted").click();
await page.getByRole("button", { name: "Sign in to continue" }).click();
await expect(page.getByText("Waiting for your browser…")).toBeVisible();
await expect(
@@ -1410,8 +1424,9 @@ test("first-community X cancels a pending sign-in", async ({ page }) => {
).toHaveCount(0);
await page.getByRole("button", { name: "Close" }).click();
await expect(
- page.getByRole("button", { name: /Create a community/ }),
+ page.getByRole("heading", { name: "Create a community" }),
).toBeVisible();
+ await expect(page.getByTestId("community-create-hosted")).toBeVisible();
await expect
.poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? []))
.toEqual(expect.arrayContaining(["cancel_builderlab_login"]));
@@ -1445,6 +1460,7 @@ test("first-community owner can replace a mismatched account identity", async ({
await page.goto("/");
await page.getByTestId("community-choice-create").click();
+ await page.getByTestId("community-create-hosted").click();
await expect(
page.getByRole("heading", {
name: "This account uses a different Buzz identity",
@@ -1495,6 +1511,7 @@ test("first-community explains when the local identity belongs to another accoun
await page.goto("/");
await page.getByTestId("community-choice-create").click();
+ await page.getByTestId("community-create-hosted").click();
await page
.getByRole("button", { name: "Use this device's identity" })
.click();
@@ -1536,8 +1553,10 @@ test("back clears Builderlab auth before returning to first-community choices",
await page.goto("/");
await page.getByTestId("community-choice-create").click();
+ await page.getByTestId("community-create-hosted").click();
await page.getByRole("button", { name: "Back" }).click();
await page.getByTestId("community-choice-create").click();
+ await page.getByTestId("community-create-hosted").click();
await expect(page.getByRole("button", { name: "Continue" })).toBeVisible();
});
diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts
index 9dce5e759..be97ba4e0 100644
--- a/desktop/tests/e2e/sidebar.spec.ts
+++ b/desktop/tests/e2e/sidebar.spec.ts
@@ -103,11 +103,105 @@ test("add community starts with create and join choices", async ({ page }) => {
await expect(
page.getByRole("heading", { name: "Create a new community" }),
).toBeVisible();
+ await expect(page.getByTestId("community-create-hosted")).toContainText(
+ "Host it online",
+ );
+ await expect(page.getByTestId("community-choice-local")).toContainText(
+ "Keep it on this device",
+ );
+ await page.getByTestId("community-create-hosted").click();
await page.getByRole("button", { name: "Continue to Builderlab" }).click();
await page.getByRole("button", { name: "Connect and continue" }).click();
await expect(page.getByLabel("Community address")).toBeVisible();
});
+test("add community can start an on-this-device community", async ({
+ page,
+}) => {
+ await installMockBridge(page, { applyCommunityDelayMs: 1_000 });
+ await page.goto("/");
+
+ await openAddCommunityDialog(page);
+ await page.getByTestId("add-community-create").click();
+ await page.getByTestId("community-choice-local").click();
+
+ await expect
+ .poll(() =>
+ page.evaluate((key) => {
+ const raw = window.localStorage.getItem(key);
+ if (!raw) return null;
+ const transaction = JSON.parse(raw) as {
+ source?: string;
+ communityName?: string;
+ relayUrl?: string;
+ };
+ return {
+ source: transaction.source,
+ communityName: transaction.communityName,
+ relayUrl: transaction.relayUrl,
+ };
+ }, COMMUNITY_ONBOARDING_STORAGE_KEY),
+ )
+ .toEqual({
+ source: "add-community",
+ communityName: "On this device",
+ relayUrl: "buzz-local://on-this-device",
+ });
+});
+
+test("create choice offers to open an existing on-this-device community", async ({
+ page,
+}) => {
+ await page.addInitScript(() => {
+ const communities = [
+ {
+ id: "hosted",
+ name: "Hosted",
+ relayUrl: "wss://hosted.example.com",
+ addedAt: "2026-01-01T00:00:00.000Z",
+ },
+ {
+ id: "local",
+ name: "On this device",
+ relayUrl: "buzz-local://on-this-device",
+ local: true,
+ addedAt: "2026-01-02T00:00:00.000Z",
+ },
+ ];
+ window.localStorage.setItem(
+ "buzz-communities",
+ JSON.stringify(communities),
+ );
+ window.localStorage.setItem("buzz-active-community-id", "hosted");
+ });
+ await installMockBridge(
+ page,
+ { applyCommunityDelayMs: 1_000 },
+ { skipCommunitySeed: true },
+ );
+ await page.goto("/");
+
+ await openAddCommunityDialog(page);
+ await page.getByTestId("add-community-create").click();
+ await expect(page.getByTestId("community-choice-local")).toContainText(
+ "Open your on-this-device community",
+ );
+ await page.getByTestId("community-choice-local").click();
+ await expect
+ .poll(() =>
+ page.evaluate(() => localStorage.getItem("buzz-active-community-id")),
+ )
+ .toBe("local");
+ await expect
+ .poll(() =>
+ page.evaluate(() => {
+ const raw = localStorage.getItem("buzz-communities");
+ return raw ? JSON.parse(raw).length : 0;
+ }),
+ )
+ .toBe(2);
+});
+
test("automatically shows community join requirements near the community URL", async ({
page,
}) => {