diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8308c9449bc..e2c590a9708 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,6 +288,9 @@ jobs: if: ${{ !cancelled() }} run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop Smoke E2E (${{ matrix.shard }})" working-directory: desktop + - name: Bestie experiment e2e + if: matrix.shard == 1 + run: pnpm -C desktop test:e2e:bestie - name: Upload desktop smoke e2e artifacts if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/desktop/package.json b/desktop/package.json index e810d2bc284..3e9e176d88a 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -20,6 +20,7 @@ "test:e2e": "pnpm build:e2e && playwright test", "test:e2e:smoke": "pnpm build:e2e && playwright test --project=smoke", "test:e2e:integration": "pnpm build:e2e && playwright test --project=integration", + "test:e2e:bestie": "VITE_BUZZ_BESTIE=1 pnpm build:e2e && playwright test --config=playwright.bestie.config.ts", "test:e2e:release-smoke": "pnpm build:e2e && playwright test --config=playwright.release-smoke.config.ts", "test:e2e:report": "playwright show-report", "tauri:build": "tauri build" diff --git a/desktop/playwright.bestie.config.ts b/desktop/playwright.bestie.config.ts new file mode 100644 index 00000000000..e543e3e1cc1 --- /dev/null +++ b/desktop/playwright.bestie.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from "@playwright/test"; + +const webPort = process.env.BUZZ_BESTIE_E2E_WEB_PORT ?? "4174"; +const webUrl = `http://127.0.0.1:${webPort}`; + +export default defineConfig({ + testDir: "./tests/e2e", + testMatch: "**/bestie-sidebar.spec.ts", + timeout: 30_000, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: [["list"]], + use: { + ...devices["Desktop Chrome"], + baseURL: webUrl, + screenshot: "only-on-failure", + trace: "on-first-retry", + }, + webServer: { + command: `python3 -m http.server ${webPort} -d dist`, + cwd: ".", + reuseExistingServer: false, + url: webUrl, + }, +}); diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index eb5ab5a95d8..f72e9578e77 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -22,6 +22,7 @@ import { useSettingsShortcuts } from "@/app/useSettingsShortcuts"; import { useAppShellKeyboardShortcuts } from "@/app/useAppShellKeyboardShortcuts"; import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotifications"; import { useAppShellLifecycleEffects } from "@/app/useAppShellLifecycleEffects"; +import { useScopedOpenDmNavigation } from "@/app/useScopedOpenDmNavigation"; import { useChannelActivityProjection } from "@/app/useChannelActivityProjection"; import { useTauriWindowDrag } from "@/app/useTauriWindowDrag"; import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; @@ -505,6 +506,11 @@ export function AppShell() { createForumMutation = useCreateChannelMutation(); const { applyCanvas, applyAgents } = useApplyTemplate(); const openDmMutation = useOpenDmMutation(); + const openDm = useScopedOpenDmNavigation({ + goChannel, + relayUrl: communitiesHook.activeCommunity?.relayUrl, + signerPubkey: identityQuery.data?.pubkey, + }); const hideDmMutation = useHideDmMutation(); useDmResurfaceFromMessages({ pubkey: identityQuery.data?.pubkey, @@ -870,13 +876,7 @@ export function AppShell() { onMarkChannelRead={markChannelRead} onMarkChannelUnread={markChannelUnread} onBrowseChannels={handleOpenBrowseChannels} - onOpenDm={async ({ pubkeys }) => { - const directMessage = - await openDmMutation.mutateAsync({ - pubkeys, - }); - await goChannel(directMessage.id); - }} + onOpenDm={openDm} onSelectAgents={() => void goAgents()} onSelectChannel={handleSidebarChannelSelect} onOpenSearchResult={handleOpenSearchResult} diff --git a/desktop/src/app/useScopedOpenDmNavigation.ts b/desktop/src/app/useScopedOpenDmNavigation.ts new file mode 100644 index 00000000000..8cb00398a48 --- /dev/null +++ b/desktop/src/app/useScopedOpenDmNavigation.ts @@ -0,0 +1,45 @@ +import * as React from "react"; + +import { canonicalRelayUrl } from "@/features/agents/managedAgentRuntimeStatus"; +import { useOpenDmMutation } from "@/features/channels/hooks"; +import type { OpenDmInput } from "@/shared/api/tauriChannels"; + +type OpenDmScope = { + relayUrl?: string; + signerPubkey?: string; +}; + +export function useScopedOpenDmNavigation({ + goChannel, + relayUrl, + signerPubkey, +}: OpenDmScope & { + goChannel: (channelId: string) => Promise; +}) { + const openDmMutation = useOpenDmMutation(); + const scopeRef = React.useRef({}); + scopeRef.current = { relayUrl, signerPubkey }; + + return React.useCallback( + async (input: OpenDmInput) => { + const directMessage = await openDmMutation.mutateAsync(input); + const currentScope = scopeRef.current; + if ( + input.expectedRelayUrl && + canonicalRelayUrl(input.expectedRelayUrl) !== + canonicalRelayUrl(currentScope.relayUrl ?? "") + ) { + return; + } + if ( + input.expectedSignerPubkey && + input.expectedSignerPubkey.toLowerCase() !== + currentScope.signerPubkey?.toLowerCase() + ) { + return; + } + await goChannel(directMessage.id); + }, + [goChannel, openDmMutation], + ); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 50c9a80f6b1..d0ffad0096a 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -552,7 +552,10 @@ export function AppSidebar({ data-testid="sidebar-scroll-content" > void; onMarkAllChannelsRead: () => void; onBrowseChannels?: (onCreated?: (channelId: string) => void) => void; - onOpenDm: (input: { pubkeys: string[] }) => Promise; + onOpenDm: (input: OpenDmInput) => Promise; onUpdateCommunity: ( id: string, updates: Partial>, diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index a9bf7058eb6..8f108dbdd80 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -1,8 +1,13 @@ import { Activity, Bot, Folders, Inbox, Zap } from "lucide-react"; +import * as React from "react"; +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { pickBestieAgent } from "@/features/agents/lib/bestie"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { TopbarSearch } from "@/features/search/ui/TopbarSearch"; import { SidebarProjectsSection } from "@/features/sidebar/ui/SidebarProjectsSection"; import { FeatureGate } from "@/shared/features"; +import type { OpenDmInput } from "@/shared/api/tauriChannels"; import type { Channel, SearchHit } from "@/shared/api/types"; import { SidebarHeader, @@ -29,7 +34,7 @@ type AppSidebarPinnedHeaderProps = { onBrowseChannels?: () => void; onCreateAgent: () => void; onCreateChannel: () => void; - onOpenDm: (input: { pubkeys: string[] }) => Promise; + onOpenDm: (input: OpenDmInput) => Promise; onOpenSearchResult: (hit: SearchHit, query: string) => void; onSelectChannel: (channelId: string) => void; searchChannels: Channel[]; @@ -39,7 +44,10 @@ type AppSidebarPinnedHeaderProps = { }; type AppSidebarPrimaryMenuProps = { + bestieRelayUrl?: string | null; + currentPubkey?: string; homeBadgeCount: number; + onOpenDm: (input: OpenDmInput) => Promise; onSelectAgents: () => void; onSelectHome: () => void; onSelectProjects: () => void; @@ -89,7 +97,10 @@ export function AppSidebarPinnedHeader({ } export function AppSidebarPrimaryMenu({ + bestieRelayUrl, + currentPubkey, homeBadgeCount, + onOpenDm, onSelectAgents, onSelectHome, onSelectProjects, @@ -167,6 +178,13 @@ export function AppSidebarPrimaryMenu({ Agents + + + ); } + +function BestieSidebarMenuItem({ + currentPubkey, + onOpenDm, + relayUrl, +}: { + currentPubkey?: string; + onOpenDm: (input: OpenDmInput) => Promise; + relayUrl?: string | null; +}) { + const managedAgentsQuery = useManagedAgentsQuery(); + const bestieAgent = React.useMemo( + () => pickBestieAgent(managedAgentsQuery.data ?? [], relayUrl), + [managedAgentsQuery.data, relayUrl], + ); + + if (!bestieAgent) return null; + + return ( + + { + const expectedRelayUrl = relayUrl?.trim(); + const expectedSignerPubkey = currentPubkey?.trim(); + if (!expectedRelayUrl || !expectedSignerPubkey) return; + void onOpenDm({ + expectedRelayUrl, + expectedSignerPubkey, + pubkeys: [bestieAgent.pubkey], + }); + }} + tooltip={`Message ${bestieAgent.name}`} + type="button" + > + + Bestie + + + ); +} diff --git a/desktop/tests/e2e/bestie-sidebar.spec.ts b/desktop/tests/e2e/bestie-sidebar.spec.ts new file mode 100644 index 00000000000..4a449954248 --- /dev/null +++ b/desktop/tests/e2e/bestie-sidebar.spec.ts @@ -0,0 +1,133 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; +import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features"; + +const BESTIE_PUBKEY = + "be571e0000000000000000000000000000000000000000000000000000000000"; +const OWNER_PUBKEY = "deadbeef".repeat(8); +const RELAY_A = "ws://localhost:3000"; +const COMMUNITY_A = { + addedAt: "2026-01-01T00:00:00.000Z", + id: "bestie-community-a", + name: "Alpha", + relayUrl: RELAY_A, +}; +const COMMUNITY_B = { + addedAt: "2026-01-02T00:00:00.000Z", + id: "bestie-community-b", + name: "Bravo", + relayUrl: "ws://localhost:3001", +}; + +const bestie = { + avatarUrl: null, + name: "Bestie", + personaId: "builtin:bestie", + pubkey: BESTIE_PUBKEY, + relayUrl: RELAY_A, + status: "running" as const, +}; + +async function seedCommunities( + page: import("@playwright/test").Page, + activeId = COMMUNITY_A.id, +) { + await page.addInitScript( + ({ active, communities }) => { + window.localStorage.setItem( + "buzz-communities", + JSON.stringify(communities), + ); + window.localStorage.setItem("buzz-active-community-id", active); + }, + { active: activeId, communities: [COMMUNITY_A, COMMUNITY_B] }, + ); +} + +test("the enabled Bestie experiment adds a direct-message entry below Agents", async ({ + page, +}) => { + await installMockBridge(page, { managedAgents: [bestie] }); + await page.goto("/"); + + const agentsEntry = page.getByTestId("open-agents-view"); + const bestieEntry = page.getByTestId("open-bestie-dm"); + await expect(bestieEntry).toBeVisible(); + await expect(bestieEntry).toContainText("Bestie"); + + const [agentsBox, bestieBox] = await Promise.all([ + agentsEntry.boundingBox(), + bestieEntry.boundingBox(), + ]); + expect(agentsBox).not.toBeNull(); + expect(bestieBox).not.toBeNull(); + expect(bestieBox?.y).toBeGreaterThan(agentsBox?.y ?? 0); + + await bestieEntry.click(); + await expect(page.getByTestId("chat-title")).toHaveText("Bestie"); +}); + +test("the disabled Bestie experiment does not mount the sidebar entry", async ({ + page, +}) => { + await installMockBridge(page, { managedAgents: [bestie] }); + await page.addInitScript((key) => { + const overrides = JSON.parse( + window.localStorage.getItem(key) ?? "{}", + ) as Record; + overrides.bestie = false; + window.localStorage.setItem(key, JSON.stringify(overrides)); + }, FEATURE_OVERRIDES_STORAGE_KEY); + await page.goto("/"); + + await expect(page.getByTestId("open-bestie-dm")).toHaveCount(0); +}); + +test("a delayed Bestie open is scoped to its rendered community and signer", async ({ + page, +}) => { + await installMockBridge( + page, + { managedAgents: [bestie], openDmDelayMs: 1_000 }, + { skipCommunitySeed: true }, + ); + await seedCommunities(page); + await page.goto("/"); + + await page.getByTestId("open-bestie-dm").click(); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMAND_LOG__?.findLast( + (entry) => entry.command === "open_dm", + )?.payload, + ), + ) + .toBeTruthy(); + const openDmPayload = await page.evaluate( + () => + window.__BUZZ_E2E_COMMAND_LOG__?.findLast( + (entry) => entry.command === "open_dm", + )?.payload, + ); + expect(openDmPayload).toMatchObject({ + expectedRelayUrl: RELAY_A, + expectedSignerPubkey: OWNER_PUBKEY, + pubkeys: [BESTIE_PUBKEY], + }); + + await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click(); + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-active-community-id"), + ), + ) + .toBe(COMMUNITY_B.id); + + await page.waitForTimeout(1_150); + await expect(page.getByTestId("chat-title")).toHaveCount(0); + await expect(page.getByTestId("open-bestie-dm")).toHaveCount(0); +});