Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 25 additions & 0 deletions desktop/playwright.bestie.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
14 changes: 7 additions & 7 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand Down
45 changes: 45 additions & 0 deletions desktop/src/app/useScopedOpenDmNavigation.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
}) {
const openDmMutation = useOpenDmMutation();
const scopeRef = React.useRef<OpenDmScope>({});
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],
);
}
3 changes: 3 additions & 0 deletions desktop/src/features/sidebar/ui/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,10 @@ export function AppSidebar({
data-testid="sidebar-scroll-content"
>
<AppSidebarPrimaryMenu
bestieRelayUrl={activeCommunity?.relayUrl}
currentPubkey={currentPubkey}
homeBadgeCount={homeBadgeCount}
onOpenDm={onOpenDm}
onSelectAgents={onSelectAgents}
onSelectHome={onSelectHome}
onSelectProjects={onSelectProjects}
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/features/sidebar/ui/AppSidebar.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { LeaveCommunityResult } from "@/features/communities/leaveCommunity
import type { Community } from "@/features/communities/types";
import type { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import type { SettingsSection } from "@/features/settings/ui/SettingsPanels";
import type { OpenDmInput } from "@/shared/api/tauriChannels";
import type {
Channel,
ChannelVisibility,
Expand Down Expand Up @@ -75,7 +76,7 @@ export type AppSidebarProps = {
) => void;
onMarkAllChannelsRead: () => void;
onBrowseChannels?: (onCreated?: (channelId: string) => void) => void;
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
onOpenDm: (input: OpenDmInput) => Promise<void>;
onUpdateCommunity: (
id: string,
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
Expand Down
67 changes: 66 additions & 1 deletion desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -29,7 +34,7 @@ type AppSidebarPinnedHeaderProps = {
onBrowseChannels?: () => void;
onCreateAgent: () => void;
onCreateChannel: () => void;
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
onOpenDm: (input: OpenDmInput) => Promise<void>;
onOpenSearchResult: (hit: SearchHit, query: string) => void;
onSelectChannel: (channelId: string) => void;
searchChannels: Channel[];
Expand All @@ -39,7 +44,10 @@ type AppSidebarPinnedHeaderProps = {
};

type AppSidebarPrimaryMenuProps = {
bestieRelayUrl?: string | null;
currentPubkey?: string;
homeBadgeCount: number;
onOpenDm: (input: OpenDmInput) => Promise<void>;
onSelectAgents: () => void;
onSelectHome: () => void;
onSelectProjects: () => void;
Expand Down Expand Up @@ -89,7 +97,10 @@ export function AppSidebarPinnedHeader({
}

export function AppSidebarPrimaryMenu({
bestieRelayUrl,
currentPubkey,
homeBadgeCount,
onOpenDm,
onSelectAgents,
onSelectHome,
onSelectProjects,
Expand Down Expand Up @@ -167,6 +178,13 @@ export function AppSidebarPrimaryMenu({
<SidebarMenuLabel>Agents</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
<FeatureGate feature="bestie">
<BestieSidebarMenuItem
currentPubkey={currentPubkey}
onOpenDm={onOpenDm}
relayUrl={bestieRelayUrl}
/>
</FeatureGate>
<FeatureGate feature="workflows">
<SidebarMenuItem>
<SidebarMenuButton
Expand All @@ -187,3 +205,50 @@ export function AppSidebarPrimaryMenu({
</>
);
}

function BestieSidebarMenuItem({
currentPubkey,
onOpenDm,
relayUrl,
}: {
currentPubkey?: string;
onOpenDm: (input: OpenDmInput) => Promise<void>;
relayUrl?: string | null;
}) {
const managedAgentsQuery = useManagedAgentsQuery();
const bestieAgent = React.useMemo(
() => pickBestieAgent(managedAgentsQuery.data ?? [], relayUrl),
[managedAgentsQuery.data, relayUrl],
);

if (!bestieAgent) return null;

return (
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-bestie-dm"
onClick={() => {
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"
>
<ProfileAvatar
avatarUrl={bestieAgent.avatarUrl}
className="size-4 text-3xs shadow-none"
label={bestieAgent.name}
plain
testId="bestie-sidebar-avatar"
/>
<SidebarMenuLabel>Bestie</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
133 changes: 133 additions & 0 deletions desktop/tests/e2e/bestie-sidebar.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean>;
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);
});
Loading