diff --git a/ANNOUNCEMENT_DEMO.md b/ANNOUNCEMENT_DEMO.md new file mode 100644 index 0000000000..b06d7f7ab2 --- /dev/null +++ b/ANNOUNCEMENT_DEMO.md @@ -0,0 +1,44 @@ +# Buzz announcement demo + +This branch includes a deterministic, relay-free workspace for recording the +Buzz announcement film. + +## Launch + +From this worktree, run: + +```bash +just announcement-staging +``` + +The command launches the native Tauri staging app on this worktree's isolated +port with announcement mode enabled. The app uses the deterministic mock +workspace rather than reading or writing real staging-relay data. Reloading the +app restores the same clean demo workspace. + +The local demo relay also includes a running agent named **Scout**. Configure +OpenAI or Anthropic in the Agents screen, then `@mention` Scout in +`#flight-path`, `#design`, `#marketing`, or +`#queen-bee-launch`. Scout shows a typing indicator and replies using the +selected provider with the recent conversation as context. Direct messages to +Scout work too. Agent settings are retained for reloads during the current app +session; provider keys are never written into the repository or logged by the +local provider bridge. + +For a browser-only preview, use `just announcement-demo`. + +## Demo workspace + +- Workspace: **Honeycomb Studios** +- People: Alex Rivera (Product Lead) and nine fictional teammates across + engineering, design, marketing, research, QA, data, support, video, and + community +- Sections: **The Hive**, **Product**, and **Launch Swarm** +- Channels include `#announcements`, `#general`, `#flight-path`, `#design`, + `#mobile`, `#product-ideas`, `#marketing`, and + `#queen-bee-launch` +- Projects: `flight-path`, `nectar`, `comb-kit`, and `swarm-launch` +- Direct messages: Maya Chen, Jordan Brooks, and Priya Shah + +All portraits are generated fictional people. Scout uses the default generated +identity until the announcement's final agent avatar is ready. diff --git a/Justfile b/Justfile index 3fe33e040a..c72501ddc9 100644 --- a/Justfile +++ b/Justfile @@ -488,6 +488,31 @@ desktop-dev: echo "Starting frontend dev server on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}" pnpm exec vite --port "${BUZZ_VITE_PORT}" --strictPort +# Open the deterministic, relay-free announcement workspace in a browser +announcement-demo: + #!/usr/bin/env bash + set -euo pipefail + cd {{desktop_dir}} + [[ -d node_modules ]] || pnpm install + source ../scripts/instance-env.sh + demo_url="http://127.0.0.1:${BUZZ_VITE_PORT}/?demo=announcement" + pnpm exec vite --host 127.0.0.1 --port "${BUZZ_VITE_PORT}" --strictPort & + vite_pid=$! + trap 'kill "$vite_pid" 2>/dev/null || true' EXIT + for _ in $(seq 1 40); do + if curl -sf "http://127.0.0.1:${BUZZ_VITE_PORT}/" >/dev/null; then + break + fi + sleep 0.25 + done + echo "Opening the Buzz announcement demo at ${demo_url}" + open "${demo_url}" + wait "$vite_pid" + +# Open the deterministic announcement workspace in the native staging shell +announcement-staging *ARGS: + VITE_ANNOUNCEMENT_DEMO=1 just staging {{ARGS}} + # ─── Web ───────────────────────────────────────────────────────────────────── # Run the web frontend dev server (port derived from worktree to avoid collisions) diff --git a/desktop/announcementDemoAgentPlugin.ts b/desktop/announcementDemoAgentPlugin.ts new file mode 100644 index 0000000000..14af71ffa3 --- /dev/null +++ b/desktop/announcementDemoAgentPlugin.ts @@ -0,0 +1,391 @@ +import { loadEnv, type Plugin } from "vite"; + +const AGENT_RESPONSE_PATH = "/__announcement-demo/agent-response"; +const MOCK_RELAY_QUERY_PATH = "/query"; +const MAX_REQUEST_BYTES = 64 * 1024; +const REQUEST_TIMEOUT_MS = 45_000; + +type AnnouncementDemoAgentMessage = { + role: "assistant" | "user"; + content: string; +}; + +type AnnouncementDemoAgentRequest = { + provider: "anthropic" | "openai"; + apiKey: string; + model: string; + systemPrompt: string; + messages: AnnouncementDemoAgentMessage[]; +}; + +type ProviderErrorBody = { + error?: string | { message?: string }; + message?: string; +}; + +type OpenAiReasoningEffort = "low" | "minimal" | "none"; + +type AgentEnvironment = Record; + +function nonEmptyString(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function supportedProvider(value: unknown) { + const provider = nonEmptyString(value)?.toLowerCase(); + return provider === "anthropic" || provider === "openai" ? provider : null; +} + +function firstEnvironmentValue( + environment: AgentEnvironment, + keys: readonly string[], +) { + for (const key of keys) { + const value = nonEmptyString(environment[key]); + if (value) { + return value; + } + } + return null; +} + +function isAgentMessage(value: unknown): value is AnnouncementDemoAgentMessage { + if (typeof value !== "object" || value === null) { + return false; + } + + const candidate = value as Record; + return ( + (candidate.role === "assistant" || candidate.role === "user") && + typeof candidate.content === "string" && + candidate.content.trim().length > 0 + ); +} + +export function resolveAnnouncementDemoAgentRequest( + value: unknown, + environment: AgentEnvironment = process.env, +): AnnouncementDemoAgentRequest { + if (typeof value !== "object" || value === null) { + throw new Error("The agent request was not valid JSON."); + } + + const candidate = value as Record; + const submittedKey = nonEmptyString(candidate.apiKey); + const openAiEnvironmentKey = firstEnvironmentValue(environment, [ + "OPENAI_COMPAT_API_KEY", + "OPENAI_API_KEY", + ]); + const anthropicEnvironmentKey = firstEnvironmentValue(environment, [ + "ANTHROPIC_API_KEY", + ]); + const provider = + supportedProvider(candidate.provider) ?? + supportedProvider( + firstEnvironmentValue(environment, [ + "BUZZ_AGENT_PROVIDER", + "GOOSE_PROVIDER", + ]), + ) ?? + (openAiEnvironmentKey + ? "openai" + : anthropicEnvironmentKey + ? "anthropic" + : null); + if (!provider) { + throw new Error("Choose Anthropic or OpenAI as the agent provider."); + } + + const apiKey = + submittedKey ?? + (provider === "openai" ? openAiEnvironmentKey : anthropicEnvironmentKey); + if (!apiKey) { + throw new Error("Add an API key in the agent settings first."); + } + + const model = + nonEmptyString(candidate.model) ?? + firstEnvironmentValue(environment, [ + "BUZZ_AGENT_MODEL", + "GOOSE_MODEL", + ...(provider === "openai" + ? ["OPENAI_COMPAT_MODEL", "OPENAI_MODEL"] + : ["ANTHROPIC_MODEL"]), + ]); + if (!model) { + throw new Error("Choose a model in the agent settings first."); + } + if ( + typeof candidate.systemPrompt !== "string" || + !Array.isArray(candidate.messages) || + !candidate.messages.every(isAgentMessage) + ) { + throw new Error("The agent conversation context was incomplete."); + } + + return { + provider, + apiKey, + model, + systemPrompt: candidate.systemPrompt, + messages: candidate.messages, + }; +} + +function readRequestBody(request: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let bytesRead = 0; + + request.on("data", (chunk: Buffer) => { + bytesRead += chunk.length; + if (bytesRead > MAX_REQUEST_BYTES) { + reject(new Error("The agent conversation was too large to send.")); + return; + } + chunks.push(chunk); + }); + request.on("end", () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + } catch { + reject(new Error("The agent request was not valid JSON.")); + } + }); + request.on("error", reject); + }); +} + +function normalizeAnthropicModel(model: string) { + if (model === "goose-claude-4-6-sonnet") { + return "claude-sonnet-4-6"; + } + if (model === "goose-claude-4-6-opus") { + return "claude-opus-4-6"; + } + return model.replace(/^anthropic\//, ""); +} + +function extractProviderError(body: ProviderErrorBody, status: number) { + const nestedMessage = + typeof body.error === "object" ? body.error.message : undefined; + const message = nestedMessage ?? body.message ?? body.error; + if (typeof message === "string" && message.trim()) { + return `Provider request failed (${status}): ${message.trim()}`; + } + return `Provider request failed with status ${status}.`; +} + +function extractOpenAiText(body: unknown) { + if (typeof body !== "object" || body === null) { + return null; + } + + const response = body as { + output_text?: unknown; + output?: Array<{ + content?: Array<{ type?: unknown; text?: unknown }>; + }>; + }; + if (typeof response.output_text === "string" && response.output_text.trim()) { + return response.output_text.trim(); + } + + const text = (response.output ?? []) + .flatMap((item) => item.content ?? []) + .filter((part) => part.type === "output_text") + .map((part) => (typeof part.text === "string" ? part.text : "")) + .join("") + .trim(); + return text || null; +} + +export function announcementDemoOpenAiReasoningEffort( + model: string, +): OpenAiReasoningEffort | null { + const normalizedModel = model.trim().toLowerCase(); + if (/^gpt-5\.(?:4|5|6)(?:[.-]|$)/.test(normalizedModel)) { + return "none"; + } + if (/^gpt-5(?:[.-]|$)/.test(normalizedModel)) { + return "minimal"; + } + if (/^o[1-9](?:[.-]|$)/.test(normalizedModel)) { + return "low"; + } + return null; +} + +function extractAnthropicText(body: unknown) { + if (typeof body !== "object" || body === null) { + return null; + } + + const response = body as { + content?: Array<{ type?: unknown; text?: unknown }>; + }; + const text = (response.content ?? []) + .filter((part) => part.type === "text") + .map((part) => (typeof part.text === "string" ? part.text : "")) + .join("") + .trim(); + return text || null; +} + +async function requestOpenAiResponse(input: AnnouncementDemoAgentRequest) { + const reasoningEffort = announcementDemoOpenAiReasoningEffort(input.model); + const response = await fetch("https://api.openai.com/v1/responses", { + method: "POST", + headers: { + Authorization: `Bearer ${input.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: input.model, + instructions: input.systemPrompt, + input: input.messages, + max_output_tokens: 2_000, + ...(reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {}), + store: false, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const body = (await response.json()) as unknown; + if (!response.ok) { + throw new Error( + extractProviderError(body as ProviderErrorBody, response.status), + ); + } + + const text = extractOpenAiText(body); + if (!text) { + const responseDetails = body as { + status?: unknown; + incomplete_details?: { reason?: unknown }; + }; + if ( + responseDetails.status === "incomplete" && + responseDetails.incomplete_details?.reason === "max_output_tokens" + ) { + throw new Error( + "OpenAI ran out of output tokens before producing visible text.", + ); + } + throw new Error("OpenAI returned a response without any text."); + } + return text; +} + +async function requestAnthropicResponse(input: AnnouncementDemoAgentRequest) { + const response = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": input.apiKey, + }, + body: JSON.stringify({ + model: normalizeAnthropicModel(input.model), + max_tokens: 350, + system: input.systemPrompt, + messages: input.messages, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const body = (await response.json()) as unknown; + if (!response.ok) { + throw new Error( + extractProviderError(body as ProviderErrorBody, response.status), + ); + } + + const text = extractAnthropicText(body); + if (!text) { + throw new Error("Anthropic returned a response without any text."); + } + return text; +} + +function friendlyError(error: unknown) { + if (error instanceof Error && error.name === "TimeoutError") { + return "The model took too long to respond. Please try again."; + } + if (error instanceof Error) { + return error.message; + } + return "The model could not respond just now."; +} + +/** + * Local-only provider proxy for the announcement demo. Keeping the API request + * in Vite means provider credentials are never built into the frontend bundle + * or written to source, and no Docker-backed relay is required. + */ +export function announcementDemoAgentPlugin(): Plugin { + let agentEnvironment: AgentEnvironment = process.env; + + return { + name: "buzz-announcement-demo-agent", + configResolved(config) { + agentEnvironment = { + ...loadEnv(config.mode, config.envDir, ""), + ...process.env, + }; + }, + configureServer(server) { + server.middlewares.use(AGENT_RESPONSE_PATH, async (request, response) => { + response.setHeader("Content-Type", "application/json"); + response.setHeader("Cache-Control", "no-store"); + + if (request.method !== "POST") { + response.statusCode = 405; + response.end(JSON.stringify({ error: "Method not allowed." })); + return; + } + + try { + const input = resolveAnnouncementDemoAgentRequest( + await readRequestBody(request), + agentEnvironment, + ); + const text = + input.provider === "openai" + ? await requestOpenAiResponse(input) + : await requestAnthropicResponse(input); + response.statusCode = 200; + response.end(JSON.stringify({ text })); + } catch (error) { + const message = friendlyError(error); + server.config.logger.error(`[announcement-demo-agent] ${message}`); + response.statusCode = 502; + response.end(JSON.stringify({ error: message })); + } + }); + server.middlewares.use( + MOCK_RELAY_QUERY_PATH, + async (request, response) => { + response.setHeader("Content-Type", "application/json"); + response.setHeader("Cache-Control", "no-store"); + + if (request.method !== "POST") { + response.statusCode = 405; + response.end(JSON.stringify({ error: "Method not allowed." })); + return; + } + + try { + await readRequestBody(request); + // Announcement state is served by the in-browser bridge. This + // endpoint is only a safety net for a native or direct-fetch code + // path that bypasses the bridge; it must never reach a real relay. + response.statusCode = 200; + response.end(JSON.stringify([])); + } catch (error) { + response.statusCode = 400; + response.end(JSON.stringify({ error: friendlyError(error) })); + } + }, + ); + }, + }; +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index b6296f0f3a..1619960d28 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -1,5 +1,8 @@ import { defineConfig, devices } from "@playwright/test"; +const previewPort = process.env.BUZZ_E2E_PREVIEW_PORT ?? "4173"; +const previewUrl = `http://127.0.0.1:${previewPort}`; + export default defineConfig({ testDir: "./tests/e2e", timeout: 30_000, @@ -10,7 +13,7 @@ export default defineConfig({ ["html", { open: "never", outputFolder: "playwright-report" }], ], use: { - baseURL: "http://127.0.0.1:4173", + baseURL: previewUrl, screenshot: "only-on-failure", trace: "on-first-retry", video: "retain-on-failure", @@ -21,6 +24,7 @@ export default defineConfig({ testMatch: [ "**/smoke.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", + "**/announcement-demo.spec.ts", "**/navigation.spec.ts", "**/channels.spec.ts", "**/channel-shared-header-backdrop.spec.ts", @@ -140,9 +144,9 @@ export default defineConfig({ }, ], webServer: { - command: "python3 -m http.server 4173 -d dist", + command: `python3 -m http.server ${previewPort} -d dist`, cwd: ".", reuseExistingServer: !process.env.CI, - url: "http://127.0.0.1:4173", + url: previewUrl, }, }); diff --git a/desktop/public/demo/agents/bumble.png b/desktop/public/demo/agents/bumble.png new file mode 100644 index 0000000000..aa8d72aace Binary files /dev/null and b/desktop/public/demo/agents/bumble.png differ diff --git a/desktop/public/demo/agents/fizz.png b/desktop/public/demo/agents/fizz.png new file mode 100644 index 0000000000..e4b8b96cf1 Binary files /dev/null and b/desktop/public/demo/agents/fizz.png differ diff --git a/desktop/public/demo/agents/honey.png b/desktop/public/demo/agents/honey.png new file mode 100644 index 0000000000..222f67f359 Binary files /dev/null and b/desktop/public/demo/agents/honey.png differ diff --git a/desktop/public/demo/attachments/flight-path-capture.png b/desktop/public/demo/attachments/flight-path-capture.png new file mode 100644 index 0000000000..b651ba398d Binary files /dev/null and b/desktop/public/demo/attachments/flight-path-capture.png differ diff --git a/desktop/public/demo/avatars/alex-rivera-candid.png b/desktop/public/demo/avatars/alex-rivera-candid.png new file mode 100644 index 0000000000..045e057c80 Binary files /dev/null and b/desktop/public/demo/avatars/alex-rivera-candid.png differ diff --git a/desktop/public/demo/avatars/camille-dubois-candid.png b/desktop/public/demo/avatars/camille-dubois-candid.png new file mode 100644 index 0000000000..4c16face55 Binary files /dev/null and b/desktop/public/demo/avatars/camille-dubois-candid.png differ diff --git a/desktop/public/demo/avatars/elena-torres.png b/desktop/public/demo/avatars/elena-torres.png new file mode 100644 index 0000000000..f8196da137 Binary files /dev/null and b/desktop/public/demo/avatars/elena-torres.png differ diff --git a/desktop/public/demo/avatars/jordan-brooks-candid.png b/desktop/public/demo/avatars/jordan-brooks-candid.png new file mode 100644 index 0000000000..99319a8d4b Binary files /dev/null and b/desktop/public/demo/avatars/jordan-brooks-candid.png differ diff --git a/desktop/public/demo/avatars/marcus-reed-candid.png b/desktop/public/demo/avatars/marcus-reed-candid.png new file mode 100644 index 0000000000..df40f71b07 Binary files /dev/null and b/desktop/public/demo/avatars/marcus-reed-candid.png differ diff --git a/desktop/public/demo/avatars/maya-chen-candid.png b/desktop/public/demo/avatars/maya-chen-candid.png new file mode 100644 index 0000000000..e97ae6ea36 Binary files /dev/null and b/desktop/public/demo/avatars/maya-chen-candid.png differ diff --git a/desktop/public/demo/avatars/noah-kim-candid.png b/desktop/public/demo/avatars/noah-kim-candid.png new file mode 100644 index 0000000000..bc5d16d71c Binary files /dev/null and b/desktop/public/demo/avatars/noah-kim-candid.png differ diff --git a/desktop/public/demo/avatars/priya-shah-candid.png b/desktop/public/demo/avatars/priya-shah-candid.png new file mode 100644 index 0000000000..ca84431b78 Binary files /dev/null and b/desktop/public/demo/avatars/priya-shah-candid.png differ diff --git a/desktop/public/demo/avatars/sofia-patel-full.png b/desktop/public/demo/avatars/sofia-patel-full.png new file mode 100644 index 0000000000..2d88d2e2dc Binary files /dev/null and b/desktop/public/demo/avatars/sofia-patel-full.png differ diff --git a/desktop/public/demo/avatars/theo-martin-full.png b/desktop/public/demo/avatars/theo-martin-full.png new file mode 100644 index 0000000000..f0dd6ad67e Binary files /dev/null and b/desktop/public/demo/avatars/theo-martin-full.png differ diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 77bd557c97..21c9e61e05 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -182,6 +182,20 @@ export const ChannelPane = React.memo(function ChannelPane({ channelPaneMountedRef.current = false; }; }, []); + React.useEffect(() => { + const followChannelTail = (event: Event) => { + const channelId = (event as CustomEvent<{ channelId?: string }>).detail + ?.channelId; + if (!channelId || channelId !== activeChannelIdRef.current) { + return; + } + messageTimelineRef.current?.scrollToBottomOnNextUpdate(); + }; + + window.addEventListener("buzz:follow-channel-tail", followChannelTail); + return () => + window.removeEventListener("buzz:follow-channel-tail", followChannelTail); + }, []); // Clear the ?autoSend search param once the auto-submit fires so // back-navigation cannot re-trigger the send. // When `onAutoSendComplete` is provided it does a surgical single-key clear diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index a73105a671..35df3daa3f 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -8,6 +8,14 @@ import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider"; import { migrateLegacyCommunityStorageBeforeRender } from "@/features/communities/legacyCommunityStorage"; import { CommunitiesProvider } from "@/features/communities/useCommunities"; import { CommunityOnboardingProvider } from "@/features/onboarding/communityOnboarding"; +import { + ANNOUNCEMENT_DEMO_AGENTS, + ANNOUNCEMENT_DEMO_COMMUNITY_NAME, + ANNOUNCEMENT_DEMO_INITIAL_READ_CHANNEL_IDS, + ANNOUNCEMENT_DEMO_PEOPLE, + ANNOUNCEMENT_DEMO_SECTION_STORE, +} from "@/testing/announcementDemoFixtures"; +import { localReadStateKey } from "@/features/channels/readState/readStateFormat"; import { ThemeProvider } from "@/shared/theme/ThemeProvider"; import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider"; import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider"; @@ -22,6 +30,9 @@ const E2E_DEFAULT_PUBKEY = "deadbeef".repeat(8); const E2E_COMMUNITY_ID = "e2e-default-community"; const ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX = "buzz-onboarding-complete.v1:"; const DEV_STATE_RESET_PARAM = "resetDevState"; +const ANNOUNCEMENT_DEMO_QUERY_VALUE = "announcement"; +const CHANNEL_SECTIONS_STORAGE_KEY_PREFIX = "buzz-channel-sections.v1"; +const SELF_PROFILE_STORAGE_KEY_PREFIX = "buzz-self-profile.v1"; function resetDevWebviewStateFromUrl() { if (!import.meta.env.DEV) { @@ -42,24 +53,51 @@ function resetDevWebviewStateFromUrl() { window.history.replaceState(window.history.state, "", url); } -function configureDevE2eBridgeFromUrl() { - if (!import.meta.env.DEV) { - return; - } - +function configureMockBridgeFromUrl() { const url = new URL(window.location.href); - if (url.searchParams.get("e2e") !== "mock") { + const isDevE2eMock = + import.meta.env.DEV && url.searchParams.get("e2e") === "mock"; + const isAnnouncementDemo = + url.searchParams.get("demo") === ANNOUNCEMENT_DEMO_QUERY_VALUE || + import.meta.env.VITE_ANNOUNCEMENT_DEMO === "1"; + const mockRelayWsUrl = isAnnouncementDemo + ? `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}` + : "ws://localhost:3000"; + + if (!isDevE2eMock && !isAnnouncementDemo) { return; } + // The native recording build intentionally reuses the browser mock bridge; + // only live model calls leave the webview through the local provider proxy. const e2eWindow = window as E2eWindow; - e2eWindow.__BUZZ_E2E__ ??= { mode: "mock" }; + if (isAnnouncementDemo) { + e2eWindow.__BUZZ_E2E__ = { + mode: "mock", + relayHttpUrl: url.origin, + relayWsUrl: mockRelayWsUrl, + mock: { + announcementDemo: true, + managedAgents: ANNOUNCEMENT_DEMO_AGENTS.map((agent) => ({ + pubkey: agent.pubkey, + name: agent.name, + avatarUrl: agent.avatarUrl, + systemPrompt: agent.systemPrompt, + status: "running" as const, + channelNames: [...agent.channelNames], + respondTo: "owner-only" as const, + })), + }, + }; + } else { + e2eWindow.__BUZZ_E2E__ ??= { mode: "mock" }; + } const community = { addedAt: new Date().toISOString(), id: E2E_COMMUNITY_ID, - name: "E2E Test", - relayUrl: "ws://localhost:3000", + name: isAnnouncementDemo ? ANNOUNCEMENT_DEMO_COMMUNITY_NAME : "E2E Test", + relayUrl: mockRelayWsUrl, }; window.localStorage.setItem("buzz-communities", JSON.stringify([community])); window.localStorage.setItem("buzz-active-community-id", E2E_COMMUNITY_ID); @@ -67,6 +105,37 @@ function configureDevE2eBridgeFromUrl() { `${ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX}${E2E_DEFAULT_PUBKEY}`, "true", ); + + if (isAnnouncementDemo) { + const relayStorageScope = encodeURIComponent(mockRelayWsUrl); + const initialReadAt = new Date().toISOString(); + window.localStorage.setItem( + `${CHANNEL_SECTIONS_STORAGE_KEY_PREFIX}:${E2E_DEFAULT_PUBKEY}:${relayStorageScope}`, + JSON.stringify(ANNOUNCEMENT_DEMO_SECTION_STORE), + ); + window.localStorage.setItem( + localReadStateKey(E2E_DEFAULT_PUBKEY), + JSON.stringify( + Object.fromEntries( + ANNOUNCEMENT_DEMO_INITIAL_READ_CHANNEL_IDS.map((channelId) => [ + channelId, + initialReadAt, + ]), + ), + ), + ); + window.localStorage.setItem( + `${SELF_PROFILE_STORAGE_KEY_PREFIX}:${mockRelayWsUrl}:${E2E_DEFAULT_PUBKEY}`, + JSON.stringify({ + version: 1, + displayName: ANNOUNCEMENT_DEMO_PEOPLE.viewer.displayName, + avatarUrl: ANNOUNCEMENT_DEMO_PEOPLE.viewer.avatarUrl, + avatarDataUrl: null, + updatedAt: Date.now(), + hasProfileEvent: true, + }), + ); + } } function renderApp() { @@ -106,7 +175,7 @@ async function installE2eBridgeIfConfigured() { async function bootstrap() { resetDevWebviewStateFromUrl(); - configureDevE2eBridgeFromUrl(); + configureMockBridgeFromUrl(); await installE2eBridgeIfConfigured(); await migrateLegacyCommunityStorageBeforeRender(); renderApp(); diff --git a/desktop/src/testing/announcementDemoAgentPlugin.test.mjs b/desktop/src/testing/announcementDemoAgentPlugin.test.mjs new file mode 100644 index 0000000000..7eb53f6b4d --- /dev/null +++ b/desktop/src/testing/announcementDemoAgentPlugin.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + announcementDemoOpenAiReasoningEffort, + resolveAnnouncementDemoAgentRequest, +} from "../../announcementDemoAgentPlugin.ts"; + +const baseRequest = { + provider: null, + apiKey: null, + model: null, + systemPrompt: "You are Fizz.", + messages: [{ role: "user", content: "Can you help?" }], +}; + +test("uses OpenAI environment variables when the demo request has no local credentials", () => { + const request = resolveAnnouncementDemoAgentRequest(baseRequest, { + OPENAI_API_KEY: "server-openai-key", + OPENAI_MODEL: "gpt-demo", + }); + + assert.equal(request.provider, "openai"); + assert.equal(request.apiKey, "server-openai-key"); + assert.equal(request.model, "gpt-demo"); +}); + +test("uses Anthropic environment variables with explicit native provider settings", () => { + const request = resolveAnnouncementDemoAgentRequest( + { ...baseRequest, provider: "anthropic" }, + { + ANTHROPIC_API_KEY: "server-anthropic-key", + ANTHROPIC_MODEL: "claude-demo", + }, + ); + + assert.equal(request.provider, "anthropic"); + assert.equal(request.apiKey, "server-anthropic-key"); + assert.equal(request.model, "claude-demo"); +}); + +test("prefers credentials entered in the app over environment fallbacks", () => { + const request = resolveAnnouncementDemoAgentRequest( + { + ...baseRequest, + provider: "openai", + apiKey: "in-app-key", + model: "in-app-model", + }, + { + OPENAI_API_KEY: "server-openai-key", + OPENAI_MODEL: "server-model", + }, + ); + + assert.equal(request.apiKey, "in-app-key"); + assert.equal(request.model, "in-app-model"); +}); + +test("uses the smallest supported reasoning effort for short demo replies", () => { + assert.equal(announcementDemoOpenAiReasoningEffort("gpt-5.4-mini"), "none"); + assert.equal(announcementDemoOpenAiReasoningEffort("gpt-5"), "minimal"); + assert.equal(announcementDemoOpenAiReasoningEffort("o3-mini"), "low"); + assert.equal(announcementDemoOpenAiReasoningEffort("gpt-4.1-mini"), null); +}); diff --git a/desktop/src/testing/announcementDemoFixtures.ts b/desktop/src/testing/announcementDemoFixtures.ts new file mode 100644 index 0000000000..3ba6b552f9 --- /dev/null +++ b/desktop/src/testing/announcementDemoFixtures.ts @@ -0,0 +1,1688 @@ +export type AnnouncementDemoPersonKey = + | "viewer" + | "engineer" + | "designer" + | "marketing" + | "researcher" + | "qa" + | "data" + | "support" + | "producer" + | "community"; + +export type AnnouncementDemoChannelRole = + | "owner" + | "admin" + | "member" + | "guest"; + +export const ANNOUNCEMENT_DEMO_COMMUNITY_NAME = "Honeycomb Studios"; + +export const ANNOUNCEMENT_DEMO_AGENTS = [ + { + pubkey: "70".repeat(32), + name: "Fizz", + avatarUrl: "/demo/agents/fizz.png", + systemPrompt: + "You are Fizz, an energetic maker who turns ideas into action. Be upbeat, practical, and decisive. Help users plan, create, solve problems, and finish work. Add occasional bee wordplay or 🐝✨—keep it charming, never distracting.", + channelNames: [ + "flight-path", + "engineering", + "design", + "marketing", + "queen-bee-launch", + ], + }, + { + pubkey: "71".repeat(32), + name: "Honey", + avatarUrl: "/demo/agents/honey.png", + systemPrompt: + "You are Honey, a warm and thoughtful communicator. Help users write clearly, organize ideas, brainstorm, summarize, and prepare for conversations. Be kind, creative, and concise. Add occasional bee wordplay or 🍯🐝—keep it sweet, never excessive.", + channelNames: [ + "flight-path", + "engineering", + "design", + "marketing", + "queen-bee-launch", + ], + }, + { + pubkey: "72".repeat(32), + name: "Bumble", + avatarUrl: "/demo/agents/bumble.png", + systemPrompt: + "You are Bumble, a curious and adventurous researcher. Explore questions, compare options, check assumptions, and explain what you find clearly. Be candid when uncertain and favor useful evidence. Add occasional bee wordplay or 🐝🔎—keep it playful, never chaotic.", + channelNames: [ + "flight-path", + "engineering", + "design", + "marketing", + "queen-bee-launch", + ], + }, +] as const; + +export type AnnouncementDemoAgentName = + (typeof ANNOUNCEMENT_DEMO_AGENTS)[number]["name"]; + +export const ANNOUNCEMENT_DEMO_LIVE_CONVERSATIONS = [ + { + channelName: "flight-path", + agentOrder: ["Fizz", "Honey", "Bumble"], + humanClose: { + delayMs: 1_000, + author: "producer", + content: "That’s the version. I can cut to that — nice swarm work 🐝", + }, + steps: [ + { + delayMs: 2_400, + author: "engineer", + content: + "Small thing: the desktop-to-mobile handoff still feels a little fast.", + agentMentions: [], + startsAgentChain: false, + }, + { + delayMs: 2_800, + author: "designer", + content: "Yeah — I want one extra beat on the sent message.", + agentMentions: [], + startsAgentChain: false, + }, + { + delayMs: 2_600, + author: "producer", + content: "That would give the camera somewhere to land too.", + agentMentions: [], + startsAgentChain: false, + }, + { + delayMs: 2_800, + author: "engineer", + content: + "@Fizz can you turn that into a clean three-beat capture plan?", + agentMentions: ["Fizz"], + startsAgentChain: true, + }, + ], + }, + { + channelName: "engineering", + agentOrder: ["Bumble", "Fizz", "Honey"], + humanClose: { + delayMs: 1_100, + author: "engineer", + content: + "Perfect. That gives me the patch and the verification path — I’m on it.", + }, + steps: [ + { + delayMs: 2_600, + author: "qa", + content: + "I can still reproduce one duplicate message after waking the laptop.", + agentMentions: [], + startsAgentChain: false, + }, + { + delayMs: 3_000, + author: "engineer", + content: "Only on the first reconnect?", + agentMentions: [], + startsAgentChain: false, + }, + { + delayMs: 2_800, + author: "qa", + content: "Yep. One duplicate, then the subscription settles.", + agentMentions: [], + startsAgentChain: false, + }, + { + delayMs: 3_000, + author: "engineer", + content: + "@Bumble can you trace the likely path and pull Fizz and Honey into a fix plan?", + agentMentions: ["Bumble"], + startsAgentChain: true, + }, + ], + }, +] as const; + +export const ANNOUNCEMENT_DEMO_PEOPLE = { + viewer: { + displayName: "Alex Rivera", + role: "Product Lead", + avatarUrl: "/demo/avatars/alex-rivera-candid.png", + nip05Handle: "alex@honeycomb.studio", + presence: "online", + }, + engineer: { + displayName: "Maya Chen", + role: "Software Engineer", + avatarUrl: "/demo/avatars/maya-chen-candid.png", + nip05Handle: "maya@honeycomb.studio", + presence: "online", + }, + designer: { + displayName: "Jordan Brooks", + role: "Product Designer", + avatarUrl: "/demo/avatars/jordan-brooks-candid.png", + nip05Handle: "jordan@honeycomb.studio", + presence: "online", + }, + marketing: { + displayName: "Priya Shah", + role: "Marketing Lead", + avatarUrl: "/demo/avatars/priya-shah-candid.png", + nip05Handle: "priya@honeycomb.studio", + presence: "away", + }, + researcher: { + displayName: "Sofia Patel", + role: "User Researcher", + avatarUrl: "/demo/avatars/sofia-patel-full.png", + nip05Handle: "sofia@honeycomb.studio", + presence: "online", + }, + qa: { + displayName: "Marcus Reed", + role: "QA Engineer", + avatarUrl: "/demo/avatars/marcus-reed-candid.png", + nip05Handle: "marcus@honeycomb.studio", + presence: "online", + }, + data: { + displayName: "Elena Torres", + role: "Data Analyst", + avatarUrl: "/demo/avatars/elena-torres.png", + nip05Handle: "elena@honeycomb.studio", + presence: "online", + }, + support: { + displayName: "Theo Martin", + role: "Customer Experience", + avatarUrl: "/demo/avatars/theo-martin-full.png", + nip05Handle: "theo@honeycomb.studio", + presence: "away", + }, + producer: { + displayName: "Camille Dubois", + role: "Video Producer", + avatarUrl: "/demo/avatars/camille-dubois-candid.png", + nip05Handle: "camille@honeycomb.studio", + presence: "online", + }, + community: { + displayName: "Noah Kim", + role: "Community Manager", + avatarUrl: "/demo/avatars/noah-kim-candid.png", + nip05Handle: "noah@honeycomb.studio", + presence: "online", + }, +} as const; + +export const ANNOUNCEMENT_DEMO_CHANNELS = [ + { + id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + name: "announcements", + channelType: "stream", + visibility: "open", + description: "Company news, milestones, and important updates", + topic: "The latest from across Honeycomb Studios", + purpose: "Keep the whole team aligned on what matters now.", + createdBy: "viewer", + lastMessageMinutesAgo: 4, + memberRoles: [ + ["viewer", "owner"], + ["engineer", "admin"], + ["designer", "member"], + ["marketing", "member"], + ["researcher", "member"], + ["qa", "member"], + ["producer", "member"], + ["community", "member"], + ], + }, + { + id: "9dae0116-799b-5071-a0a8-fdd30a91a35d", + name: "general", + channelType: "stream", + visibility: "open", + description: "Quick questions, celebrations, and team chatter", + topic: "Where everyone checks in", + purpose: "Make everyday collaboration feel easy and human.", + createdBy: "viewer", + lastMessageMinutesAgo: 18, + memberRoles: [ + ["viewer", "owner"], + ["engineer", "member"], + ["designer", "member"], + ["marketing", "member"], + ["researcher", "member"], + ["qa", "member"], + ["data", "member"], + ["support", "member"], + ["producer", "member"], + ["community", "member"], + ], + }, + { + id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9", + name: "flight-path", + channelType: "stream", + visibility: "open", + description: "Engineering plans for the next Buzz release", + topic: "Release candidate polish", + purpose: "Track implementation, quality, and release readiness.", + createdBy: "engineer", + lastMessageMinutesAgo: 7, + memberRoles: [ + ["engineer", "owner"], + ["viewer", "admin"], + ["designer", "member"], + ["qa", "member"], + ["data", "member"], + ["producer", "member"], + ], + }, + { + id: "4e91b4d2-8207-4d63-9b1a-3c72a8f01142", + name: "engineering", + channelType: "stream", + visibility: "open", + description: "Desktop, relay, and infrastructure engineering", + topic: "Reconnect reliability and the next release candidate", + purpose: "Debug together, review changes, and keep the build healthy.", + createdBy: "engineer", + lastMessageMinutesAgo: 5, + memberRoles: [ + ["engineer", "owner"], + ["viewer", "admin"], + ["qa", "member"], + ["data", "member"], + ["support", "member"], + ["designer", "member"], + ], + }, + { + id: "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e", + name: "design", + channelType: "stream", + visibility: "open", + description: "Product design, motion, and the Comb Kit system", + topic: "Announcement film UI pass", + purpose: "Shape a coherent product experience across every surface.", + createdBy: "designer", + lastMessageMinutesAgo: 8, + memberRoles: [ + ["designer", "owner"], + ["viewer", "admin"], + ["engineer", "member"], + ["researcher", "member"], + ["producer", "member"], + ], + }, + { + id: "94a444a4-c0a3-5966-ab05-530c6ddc2301", + name: "mobile", + channelType: "stream", + visibility: "open", + description: "The mobile experience and cross-device continuity", + topic: "Conversation handoff and mobile polish", + purpose: "Make Buzz feel continuous wherever the team works.", + createdBy: "engineer", + lastMessageMinutesAgo: 26, + memberRoles: [ + ["engineer", "owner"], + ["viewer", "member"], + ["designer", "member"], + ["qa", "member"], + ["data", "member"], + ["support", "member"], + ], + }, + { + id: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11", + name: "product-ideas", + channelType: "forum", + visibility: "open", + description: "Research notes and ideas worth growing", + topic: "Signals from customers and the community", + purpose: "Turn observations into thoughtful product bets.", + createdBy: "viewer", + lastMessageMinutesAgo: 34, + memberRoles: [ + ["viewer", "owner"], + ["engineer", "member"], + ["designer", "member"], + ["marketing", "member"], + ["researcher", "member"], + ["data", "member"], + ["support", "member"], + ["community", "member"], + ], + }, + { + id: "c6f3a9b2-4d55-5a23-bf78-5b9e2g3c5d6f", + name: "marketing", + channelType: "stream", + visibility: "open", + description: "Launch story, social, press, and community rollout", + topic: "Announcement week", + purpose: "Carry the Buzz story to every audience with clarity.", + createdBy: "marketing", + lastMessageMinutesAgo: 9, + memberRoles: [ + ["marketing", "owner"], + ["viewer", "admin"], + ["designer", "member"], + ["researcher", "member"], + ["data", "member"], + ["producer", "member"], + ["community", "member"], + ], + }, + { + id: "1be1dcdb-4c31-5a8c-81de-ac102552ca10", + name: "launch-notes", + channelType: "forum", + visibility: "open", + description: "Final launch decisions, scripts, and review threads", + topic: "The announcement film", + purpose: "Keep final feedback focused and easy to find.", + createdBy: "marketing", + lastMessageMinutesAgo: 22, + memberRoles: [ + ["marketing", "owner"], + ["viewer", "admin"], + ["engineer", "member"], + ["designer", "member"], + ["qa", "member"], + ["producer", "member"], + ], + }, + { + id: "3c2d9f0a-1b44-5e77-9a21-6f8b0c4d2e91", + name: "queen-bee-launch", + channelType: "stream", + visibility: "private", + description: "Private launch room for the announcement team", + topic: "Final cut and release timing", + purpose: "Coordinate the last mile before Buzz takes flight.", + createdBy: "viewer", + lastMessageMinutesAgo: 3, + memberRoles: [ + ["viewer", "owner"], + ["engineer", "member"], + ["designer", "member"], + ["marketing", "member"], + ["qa", "member"], + ["producer", "member"], + ["community", "member"], + ], + }, +] as const; + +export const ANNOUNCEMENT_DEMO_DMS = [ + { + id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8", + person: "engineer", + lastMessageMinutesAgo: 11, + }, + { + id: "7eb9f239-9393-50b0-bd76-d85eef0511c7", + person: "designer", + lastMessageMinutesAgo: 16, + }, + { + id: "d1ec7000-d000-4000-8000-000000000001", + person: "marketing", + lastMessageMinutesAgo: 6, + }, +] as const; + +// Start the recording workspace with a believable mix of read and unread +// conversations. Channels omitted from this list retain their unread state. +export const ANNOUNCEMENT_DEMO_INITIAL_READ_CHANNEL_IDS = [ + "9dae0116-799b-5071-a0a8-fdd30a91a35d", // general + "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9", // flight-path + "94a444a4-c0a3-5966-ab05-530c6ddc2301", // mobile + "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11", // product-ideas + "1be1dcdb-4c31-5a8c-81de-ac102552ca10", // launch-notes + "7eb9f239-9393-50b0-bd76-d85eef0511c7", // Jordan Brooks DM + "d1ec7000-d000-4000-8000-000000000001", // Priya Shah DM +] as const; + +export const ANNOUNCEMENT_DEMO_SECTION_STORE = { + version: 1, + sections: [ + { id: "the-hive", name: "The Hive", icon: "🐝", order: 0 }, + { + id: "product-garden", + name: "Product", + icon: "🛠️", + order: 1, + }, + { id: "launch-swarm", name: "Launch Swarm", icon: "🚀", order: 2 }, + ], + assignments: { + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50": "the-hive", + "9dae0116-799b-5071-a0a8-fdd30a91a35d": "the-hive", + "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9": "product-garden", + "4e91b4d2-8207-4d63-9b1a-3c72a8f01142": "product-garden", + "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e": "product-garden", + "94a444a4-c0a3-5966-ab05-530c6ddc2301": "product-garden", + "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11": "product-garden", + "c6f3a9b2-4d55-5a23-bf78-5b9e2g3c5d6f": "launch-swarm", + "1be1dcdb-4c31-5a8c-81de-ac102552ca10": "launch-swarm", + "3c2d9f0a-1b44-5e77-9a21-6f8b0c4d2e91": "launch-swarm", + }, +} as const; + +export const ANNOUNCEMENT_DEMO_PROJECTS = [ + { + dtag: "flight-path", + name: "flight-path", + description: + "A faster, calmer navigation foundation for Buzz on desktop and mobile.", + owner: "engineer", + contributors: ["viewer", "designer", "qa", "data"], + activityLevel: 4, + }, + { + dtag: "nectar", + name: "nectar", + description: + "Customer signals, product research, and insights that shape the roadmap.", + owner: "viewer", + contributors: ["designer", "marketing", "researcher", "support"], + activityLevel: 2, + }, + { + dtag: "comb-kit", + name: "comb-kit", + description: + "Buzz's shared design system for coherent, expressive product surfaces.", + owner: "designer", + contributors: ["engineer", "producer", "researcher"], + activityLevel: 3, + }, + { + dtag: "swarm-launch", + name: "swarm-launch", + description: + "The announcement campaign, launch assets, and coordinated rollout plan.", + owner: "marketing", + contributors: ["viewer", "designer", "producer", "community", "data"], + activityLevel: 3, + }, +] as const; + +export const ANNOUNCEMENT_DEMO_PROJECT_SUBJECTS = [ + "Polish the flight-path transition", + "Refine the launch story", + "Add mobile handoff analytics", + "Review the final motion pass", + "Tighten contribution summaries", + "Prepare the announcement build", + "Update Comb Kit components", + "Capture the release walkthrough", +] as const; + +export const ANNOUNCEMENT_DEMO_MESSAGES: Record< + string, + ReadonlyArray<{ + id: string; + author: AnnouncementDemoPersonKey; + minutesAgo: number; + content: string; + kind?: 9 | 45001; + extraTags?: ReadonlyArray>; + reactions?: ReadonlyArray<{ + emoji: string; + authors: ReadonlyArray; + minutesAgo?: number; + }>; + }> +> = { + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50": [ + { + id: "announcement-demo-hive-old-1", + author: "viewer", + minutesAgo: 4_620, + content: + "Quick heads-up: we’re moving the announcement recording to Thursday.", + reactions: [ + { emoji: "👍", authors: ["marketing", "producer", "engineer"] }, + ], + }, + { + id: "announcement-demo-hive-old-2", + author: "marketing", + minutesAgo: 4_604, + content: "Works for the launch calendar.", + }, + { + id: "announcement-demo-hive-old-3", + author: "producer", + minutesAgo: 4_590, + content: "I’ll shift the edit review and send a new capture plan.", + }, + { + id: "announcement-demo-hive-old-4", + author: "viewer", + minutesAgo: 4_574, + content: + "Thank you. Same publishing window, just a calmer recording day.", + }, + { + id: "announcement-demo-hive-1", + author: "viewer", + minutesAgo: 96, + content: "Morning! The announcement build is ready for a full-team pass.", + }, + { + id: "announcement-demo-hive-2", + author: "viewer", + minutesAgo: 93, + content: + "Please drop launch-only notes in #launch-notes so we keep one source of truth.", + }, + { + id: "announcement-demo-hive-3", + author: "marketing", + minutesAgo: 71, + content: + "Copy is locked on my side. Press kit is still in review, but there are no blockers.", + }, + { + id: "announcement-demo-hive-4", + author: "producer", + minutesAgo: 68, + content: "Great. I have the capture plan and a backup shot list ready.", + }, + { + id: "announcement-demo-hive-5", + author: "qa", + minutesAgo: 46, + content: "Final smoke pass is clean ✅", + reactions: [ + { emoji: "🎉", authors: ["viewer", "engineer", "designer"] }, + { emoji: "🙌", authors: ["marketing", "producer"] }, + ], + }, + { + id: "announcement-demo-hive-6", + author: "qa", + minutesAgo: 45, + content: "I’m checking notifications one more time, just to be safe.", + }, + { + id: "announcement-demo-hive-7", + author: "viewer", + minutesAgo: 18, + content: + "Thank you, Marcus. And thank you all for making this final stretch feel so calm.", + }, + { + id: "announcement-demo-hive-8", + author: "community", + minutesAgo: 4, + content: "Preview invites are queued for Thursday morning.", + reactions: [{ emoji: "🐝", authors: ["marketing", "viewer"] }], + }, + ], + "9dae0116-799b-5071-a0a8-fdd30a91a35d": [ + { + id: "announcement-demo-waggle-old-1", + author: "community", + minutesAgo: 5_920, + content: "I tried the new channel sections with zero instructions.", + }, + { + id: "announcement-demo-waggle-old-2", + author: "designer", + minutesAgo: 5_914, + content: "And?", + }, + { + id: "announcement-demo-waggle-old-3", + author: "community", + minutesAgo: 5_908, + content: "Found everything. No notes. Slightly suspicious.", + reactions: [ + { emoji: "😂", authors: ["designer", "engineer", "researcher"] }, + ], + }, + { + id: "announcement-demo-waggle-old-4", + author: "researcher", + minutesAgo: 3_130, + content: "Customer session quote of the day: “Oh, that’s where it went.”", + }, + { + id: "announcement-demo-waggle-old-5", + author: "support", + minutesAgo: 3_121, + content: "Honestly, that might be our whole product strategy.", + reactions: [ + { emoji: "💯", authors: ["viewer", "data"] }, + { emoji: "🐝", authors: ["community"] }, + ], + }, + { + id: "announcement-demo-waggle-old-6", + author: "engineer", + minutesAgo: 1_755, + content: "New search animation is on staging.", + }, + { + id: "announcement-demo-waggle-old-7", + author: "qa", + minutesAgo: 1_748, + content: "I will pretend I didn’t see that until after lunch.", + }, + { + id: "announcement-demo-waggle-1", + author: "support", + minutesAgo: 74, + content: "A preview team called Buzz “surprisingly calm” this morning.", + }, + { + id: "announcement-demo-waggle-2", + author: "designer", + minutesAgo: 66, + content: "Oh, I love that.", + }, + { + id: "announcement-demo-waggle-3", + author: "designer", + minutesAgo: 65, + content: + "I softened the selected states yesterday, so hopefully it feels even calmer now.", + }, + { + id: "announcement-demo-waggle-4", + author: "data", + minutesAgo: 56, + content: + "The numbers agree. People are finding active work faster and bouncing around less.", + }, + { + id: "announcement-demo-waggle-5", + author: "community", + minutesAgo: 48, + content: "Can we steal “surprisingly calm” for the launch recap?", + }, + { + id: "announcement-demo-waggle-6", + author: "marketing", + minutesAgo: 46, + content: "Already wrote it down 😂", + }, + { + id: "announcement-demo-waggle-7", + author: "engineer", + minutesAgo: 31, + content: "Release candidate is green on desktop and mobile 🎉", + reactions: [ + { emoji: "🎉", authors: ["viewer", "designer", "marketing", "qa"] }, + ], + }, + { + id: "announcement-demo-waggle-8", + author: "engineer", + minutesAgo: 29, + content: "Please nobody breathe on main for the next hour.", + }, + { + id: "announcement-demo-waggle-9", + author: "qa", + minutesAgo: 23, + content: "Too late. I looked at it.", + }, + { + id: "announcement-demo-waggle-10", + author: "qa", + minutesAgo: 18, + content: "Still green.", + reactions: [{ emoji: "😅", authors: ["engineer", "designer"] }], + }, + ], + "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9": [ + { + id: "announcement-demo-flight-old-1", + author: "engineer", + minutesAgo: 7_230, + content: "First transition pass is ready.", + }, + { + id: "announcement-demo-flight-old-2", + author: "engineer", + minutesAgo: 7_226, + content: "https://github.com/block/buzz/pull/1768", + reactions: [{ emoji: "👀", authors: ["qa", "designer"] }], + }, + { + id: "announcement-demo-flight-old-3", + author: "qa", + minutesAgo: 7_205, + content: "Pulling it now.", + }, + { + id: "announcement-demo-flight-old-4", + author: "qa", + minutesAgo: 7_172, + content: "Found one focus jump when you reverse direction quickly.", + }, + { + id: "announcement-demo-flight-old-5", + author: "engineer", + minutesAgo: 7_151, + content: "Good catch. Fixed and pushed.", + reactions: [{ emoji: "⚡", authors: ["qa"] }], + }, + { + id: "announcement-demo-flight-old-6", + author: "designer", + minutesAgo: 4_390, + content: "Motion feels close. Can we shave about 40ms off the settle?", + }, + { + id: "announcement-demo-flight-old-7", + author: "engineer", + minutesAgo: 4_378, + content: "Yep. Trying 180ms now.", + }, + { + id: "announcement-demo-flight-old-8", + author: "designer", + minutesAgo: 4_360, + content: "That’s it.", + reactions: [ + { emoji: "✨", authors: ["engineer", "viewer"] }, + { emoji: "👍", authors: ["qa"] }, + ], + }, + { + id: "announcement-demo-flight-old-9", + author: "data", + minutesAgo: 2_910, + content: "Navigation sample jumped overnight. This path is getting used.", + }, + { + id: "announcement-demo-flight-old-10", + author: "viewer", + minutesAgo: 2_895, + content: "Great. Let’s make it the center of the film sequence.", + }, + { + id: "announcement-demo-flight-1", + author: "data", + minutesAgo: 69, + content: + "Fresh navigation sample is in. Channel → project is now the most common multi-surface flow.", + }, + { + id: "announcement-demo-flight-2", + author: "engineer", + minutesAgo: 58, + content: "Nice. The transitions are in.", + }, + { + id: "announcement-demo-flight-3", + author: "engineer", + minutesAgo: 56, + content: + "Kept them quick, but there’s enough movement to understand the context change.", + }, + { + id: "announcement-demo-flight-4", + author: "qa", + minutesAgo: 47, + content: "Testing reduced motion now.", + }, + { + id: "announcement-demo-flight-5", + author: "qa", + minutesAgo: 42, + content: "Keyboard path is clean too. Focus lands where it should.", + }, + { + id: "announcement-demo-flight-6", + author: "designer", + minutesAgo: 36, + content: + "The speed feels right. I’d pause for half a beat on the project header in the recording.", + }, + { + id: "announcement-demo-flight-linear", + author: "engineer", + minutesAgo: 33, + content: + "I tracked the last timing polish in [BUZ-482 · Capture transition](https://linear.app/honeycomb/issue/BUZ-482/capture-transition-polish).", + reactions: [{ emoji: "👀", authors: ["qa"] }], + }, + { + id: "announcement-demo-flight-7", + author: "viewer", + minutesAgo: 28, + content: "Agreed. That’s where the idea becomes legible.", + }, + { + id: "announcement-demo-flight-8", + author: "producer", + minutesAgo: 19, + content: + "I’ll use one clean cursor move, let the project settle, then type a short update.", + }, + { + id: "announcement-demo-flight-9", + author: "engineer", + minutesAgo: 13, + content: + "Demo build is running. Final cleanup is here: https://github.com/block/buzz/pull/1768", + reactions: [ + { emoji: "✅", authors: ["qa", "viewer"] }, + { emoji: "🚀", authors: ["producer"] }, + ], + }, + { + id: "announcement-demo-flight-10", + author: "viewer", + minutesAgo: 7, + content: "Perfect. That’s the move.", + reactions: [{ emoji: "🎬", authors: ["producer", "marketing"] }], + }, + ], + "4e91b4d2-8207-4d63-9b1a-3c72a8f01142": [ + { + id: "announcement-demo-engineering-old-1", + author: "engineer", + minutesAgo: 6_240, + content: + "I moved reconnect ownership into the subscription manager. Less state crossing the bridge now.", + }, + { + id: "announcement-demo-engineering-old-2", + author: "qa", + minutesAgo: 6_231, + content: "Nice. I’ll run the sleep / wake matrix against it.", + }, + { + id: "announcement-demo-engineering-old-3", + author: "qa", + minutesAgo: 6_198, + content: "First ten loops are clean.", + reactions: [{ emoji: "✅", authors: ["engineer", "viewer"] }], + }, + { + id: "announcement-demo-engineering-old-4", + author: "engineer", + minutesAgo: 6_194, + content: "love to hear it", + }, + { + id: "announcement-demo-engineering-old-5", + author: "data", + minutesAgo: 4_330, + content: "Reconnect p95 dropped from 2.8s to 1.1s on the canary cohort.", + reactions: [{ emoji: "⚡", authors: ["engineer", "qa"] }], + }, + { + id: "announcement-demo-engineering-old-6", + author: "engineer", + minutesAgo: 4_318, + content: "That lines up with removing the second backoff window.", + }, + { + id: "announcement-demo-engineering-old-7", + author: "support", + minutesAgo: 2_915, + content: "Two preview teams reported a duplicated message after sleep.", + }, + { + id: "announcement-demo-engineering-old-8", + author: "engineer", + minutesAgo: 2_910, + content: "Same OS version?", + }, + { + id: "announcement-demo-engineering-old-9", + author: "support", + minutesAgo: 2_904, + content: "Both on macOS 15. One Intel, one Apple silicon.", + }, + { + id: "announcement-demo-engineering-old-10", + author: "engineer", + minutesAgo: 1_860, + content: + "Reproduced. The old generation can deliver once after the new one starts.", + }, + { + id: "announcement-demo-engineering-pr", + author: "engineer", + minutesAgo: 1_842, + content: + "Guard is up for review: https://github.com/block/buzz/pull/2037", + reactions: [{ emoji: "👀", authors: ["qa", "viewer"] }], + }, + { + id: "announcement-demo-engineering-old-11", + author: "qa", + minutesAgo: 1_831, + content: "Pulling it now.", + }, + { + id: "announcement-demo-engineering-old-12", + author: "qa", + minutesAgo: 1_804, + content: "Twenty reconnects, no duplicates.", + reactions: [{ emoji: "🙌", authors: ["engineer", "support"] }], + }, + { + id: "announcement-demo-engineering-1", + author: "engineer", + minutesAgo: 94, + content: "Tightened the generation guard this morning.", + }, + { + id: "announcement-demo-engineering-2", + author: "engineer", + minutesAgo: 92, + content: "```ts\nif (generation !== activeGeneration) return;\n```", + }, + { + id: "announcement-demo-engineering-3", + author: "qa", + minutesAgo: 76, + content: "Manual reconnect and offline recovery both look good.", + }, + { + id: "announcement-demo-engineering-4", + author: "data", + minutesAgo: 61, + content: + "Telemetry is clean too — no doubled fan-out in the latest sample.", + }, + { + id: "announcement-demo-engineering-linear", + author: "engineer", + minutesAgo: 43, + content: + "I put the remaining soak checklist in [BUZ-519 · Reconnect reliability](https://linear.app/honeycomb/issue/BUZ-519/reconnect-reliability).", + reactions: [{ emoji: "✅", authors: ["qa"] }], + }, + { + id: "announcement-demo-engineering-5", + author: "support", + minutesAgo: 22, + content: "Customer replay passed on both affected machines.", + }, + { + id: "announcement-demo-engineering-6", + author: "engineer", + minutesAgo: 5, + content: "Release build is green. I’m doing one last sleep / wake pass.", + reactions: [ + { emoji: "🚀", authors: ["qa", "viewer"] }, + { emoji: "🙏", authors: ["support"] }, + ], + }, + ], + "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e": [ + { + id: "announcement-demo-design-old-1", + author: "designer", + minutesAgo: 6_080, + content: "Trying a quieter selected state for the sidebar.", + }, + { + id: "announcement-demo-design-old-2", + author: "designer", + minutesAgo: 6_074, + content: + "Before / after is in the PR: https://github.com/block/buzz/pull/1712", + reactions: [{ emoji: "👀", authors: ["researcher", "producer"] }], + }, + { + id: "announcement-demo-design-old-3", + author: "researcher", + minutesAgo: 6_050, + content: "The quieter version wins for me.", + }, + { + id: "announcement-demo-design-old-4", + author: "producer", + minutesAgo: 6_041, + content: "Same. It reads better in motion.", + reactions: [{ emoji: "👍", authors: ["designer", "viewer"] }], + }, + { + id: "announcement-demo-design-old-5", + author: "designer", + minutesAgo: 3_270, + content: "New avatar crops are in.", + }, + { + id: "announcement-demo-design-old-6", + author: "producer", + minutesAgo: 3_260, + content: "These feel much more like a real team.", + }, + { + id: "announcement-demo-design-old-7", + author: "designer", + minutesAgo: 3_253, + content: "Exactly what I was hoping for 🙌", + reactions: [{ emoji: "❤️", authors: ["producer", "researcher"] }], + }, + { + id: "announcement-demo-design-1", + author: "researcher", + minutesAgo: 78, + content: + "People understood the sections immediately in the latest sessions.", + }, + { + id: "announcement-demo-design-2", + author: "researcher", + minutesAgo: 76, + content: + "The “Product” section was the favorite. Organized, but not too formal.", + }, + { + id: "announcement-demo-design-3", + author: "designer", + minutesAgo: 64, + content: + "That’s helpful. I’ve made the hierarchy a little clearer and quieted the channel icons.", + }, + { + id: "announcement-demo-design-4", + author: "designer", + minutesAgo: 62, + content: "The active state holds up in both themes now.", + }, + { + id: "announcement-demo-design-5", + author: "producer", + minutesAgo: 51, + content: "Looks great on camera.", + reactions: [{ emoji: "✨", authors: ["designer", "viewer"] }], + }, + { + id: "announcement-demo-design-6", + author: "producer", + minutesAgo: 49, + content: + "Can we keep the cursor away from the avatars in the opening shot?", + }, + { + id: "announcement-demo-design-7", + author: "designer", + minutesAgo: 43, + content: "Yep. I’ll frame the movement around the channel names.", + }, + { + id: "announcement-demo-design-8", + author: "engineer", + minutesAgo: 34, + content: "Motion pass is smooth at the recording size.", + }, + { + id: "announcement-demo-design-9", + author: "viewer", + minutesAgo: 22, + content: "Let’s keep the opening wide. The workspace should land first.", + }, + { + id: "announcement-demo-design-10", + author: "producer", + minutesAgo: 12, + content: + "Works for me. I’m calling this sequence locked.\n\n![image](/demo/attachments/flight-path-capture.png)", + extraTags: [ + [ + "imeta", + "url /demo/attachments/flight-path-capture.png", + "m image/png", + "x f0b8ade18d5dfc5a7780736d8ce495d9b946d4bef831a21da55806f44a6dafd1", + "size 266615", + "dim 1200x717", + "filename flight-path-capture.png", + ], + ], + reactions: [ + { emoji: "🔥", authors: ["designer", "marketing"] }, + { emoji: "🎬", authors: ["viewer"] }, + ], + }, + { + id: "announcement-demo-design-doc", + author: "designer", + minutesAgo: 10, + content: + "I added the final frames to the [Announcement storyboard](https://docs.google.com/document/d/1BUZZANNOUNCEMENTSTORYBOARD/edit).", + }, + { + id: "announcement-demo-design-doc-reply", + author: "producer", + minutesAgo: 8, + content: "Perfect, I see them.", + reactions: [{ emoji: "🙏", authors: ["designer"] }], + }, + ], + "94a444a4-c0a3-5966-ab05-530c6ddc2301": [ + { + id: "announcement-demo-mobile-old-1", + author: "engineer", + minutesAgo: 5_030, + content: "Draft handoff prototype is on staging.", + }, + { + id: "announcement-demo-mobile-old-2", + author: "support", + minutesAgo: 5_011, + content: "Trying it with the support workspace now.", + }, + { + id: "announcement-demo-mobile-old-3", + author: "support", + minutesAgo: 4_998, + content: "This is good. I forgot which device I started on.", + reactions: [ + { emoji: "🎯", authors: ["engineer", "designer"] }, + { emoji: "🙌", authors: ["viewer"] }, + ], + }, + { + id: "announcement-demo-mobile-old-4", + author: "qa", + minutesAgo: 3_615, + content: "Found an edge case with an old notification replacing a draft.", + }, + { + id: "announcement-demo-mobile-old-5", + author: "engineer", + minutesAgo: 3_590, + content: "Reproduced. Fixing now.", + }, + { + id: "announcement-demo-mobile-old-6", + author: "engineer", + minutesAgo: 3_552, + content: "Fix is up: https://github.com/block/buzz/pull/1674", + reactions: [{ emoji: "✅", authors: ["qa", "support"] }], + }, + { + id: "announcement-demo-mobile-old-7", + author: "qa", + minutesAgo: 3_531, + content: "Confirmed. Draft wins now.", + }, + { + id: "announcement-demo-mobile-1", + author: "support", + minutesAgo: 91, + content: + "Most common mobile question: will opening a thread mess with where I left off on desktop?", + }, + { + id: "announcement-demo-mobile-2", + author: "engineer", + minutesAgo: 78, + content: + "The draft follows you now. https://github.com/block/buzz/pull/1674", + reactions: [{ emoji: "🙌", authors: ["support", "designer"] }], + }, + { + id: "announcement-demo-mobile-3", + author: "engineer", + minutesAgo: 76, + content: + "Active channel and thread context too. It should feel like the same session.", + }, + { + id: "announcement-demo-mobile-4", + author: "designer", + minutesAgo: 67, + content: + "Nice. I’ll make sure the recording shows the same draft on both screens.", + }, + { + id: "announcement-demo-mobile-5", + author: "data", + minutesAgo: 56, + content: + "Handoff completion is up in the preview group, especially from notifications.", + }, + { + id: "announcement-demo-mobile-6", + author: "qa", + minutesAgo: 45, + content: "Tested cold launch, background resume, and expired sessions.", + }, + { + id: "announcement-demo-mobile-7", + author: "qa", + minutesAgo: 43, + content: "All clean.", + reactions: [{ emoji: "✅", authors: ["engineer", "support"] }], + }, + { + id: "announcement-demo-mobile-8", + author: "support", + minutesAgo: 35, + content: + "The recovery copy feels much better too. Clear, but not alarming.", + }, + { + id: "announcement-demo-mobile-9", + author: "designer", + minutesAgo: 26, + content: "Clean handoff recording is in the folder.", + reactions: [{ emoji: "🎥", authors: ["producer"] }], + }, + ], + "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11": [ + { + id: "announcement-demo-garden-old-1", + author: "researcher", + minutesAgo: 7_410, + kind: 45001, + content: + "Interview notes: where new teammates look first when they enter an established workspace.", + }, + { + id: "announcement-demo-garden-old-2", + author: "support", + minutesAgo: 5_870, + kind: 45001, + content: + "Idea: a gentle “what changed while you were away?” summary for busy channels.", + }, + { + id: "announcement-demo-garden-old-3", + author: "researcher", + minutesAgo: 3_260, + kind: 45001, + content: + "Research plan: test real workspace context against a generic onboarding checklist.", + }, + { + id: "announcement-demo-garden-1", + author: "researcher", + minutesAgo: 128, + kind: 45001, + content: + "Research synthesis: what should a calm first five minutes in Buzz feel like?", + }, + { + id: "announcement-demo-garden-2", + author: "researcher", + minutesAgo: 111, + kind: 45001, + content: + "Onboarding idea: use the team’s real channels and current work instead of a generic checklist.", + }, + { + id: "announcement-demo-garden-3", + author: "support", + minutesAgo: 93, + kind: 45001, + content: + "Could repeated support answers become lightweight guidance inside the relevant channel?", + }, + { + id: "announcement-demo-garden-4", + author: "data", + minutesAgo: 67, + kind: 45001, + content: + "Signal: teams with a few thoughtfully named sections return to active work faster.", + }, + { + id: "announcement-demo-garden-5", + author: "community", + minutesAgo: 34, + kind: 45001, + content: + "Community idea: a small gallery of real workspace patterns after launch.", + }, + ], + "c6f3a9b2-4d55-5a23-bf78-5b9e2g3c5d6f": [ + { + id: "announcement-demo-pollen-old-1", + author: "marketing", + minutesAgo: 6_350, + content: "First launch storyboard is ready for comments.", + }, + { + id: "announcement-demo-pollen-old-2", + author: "community", + minutesAgo: 6_322, + content: + "The community beat is strong. I’d bring it ten seconds earlier.", + }, + { + id: "announcement-demo-pollen-old-3", + author: "marketing", + minutesAgo: 6_309, + content: "Good call. Moving it before the mobile handoff.", + reactions: [{ emoji: "👍", authors: ["community", "producer"] }], + }, + { + id: "announcement-demo-pollen-old-4", + author: "producer", + minutesAgo: 4_880, + content: "Rough cut is exporting. No color pass yet.", + }, + { + id: "announcement-demo-pollen-old-5", + author: "marketing", + minutesAgo: 4_842, + content: "The pacing works. The middle title can be shorter.", + }, + { + id: "announcement-demo-pollen-old-6", + author: "producer", + minutesAgo: 4_831, + content: "Agreed. Cutting it to two words.", + reactions: [{ emoji: "✂️", authors: ["marketing"] }], + }, + { + id: "announcement-demo-pollen-old-7", + author: "community", + minutesAgo: 2_220, + content: + "Preview group asked if they can share behind-the-scenes stills.", + }, + { + id: "announcement-demo-pollen-old-8", + author: "marketing", + minutesAgo: 2_204, + content: "Yes, after the main post is live.", + }, + { + id: "announcement-demo-pollen-1", + author: "community", + minutesAgo: 87, + content: + "Announcement-day community flow is mapped, including the live Q&A.", + }, + { + id: "announcement-demo-pollen-2", + author: "marketing", + minutesAgo: 73, + content: + "Perfect. The campaign is still three beats: see the team, follow the work, keep moving.", + }, + { + id: "announcement-demo-pollen-3", + author: "marketing", + minutesAgo: 71, + content: "Let’s keep every caption anchored to one of those.", + }, + { + id: "announcement-demo-pollen-4", + author: "producer", + minutesAgo: 57, + content: "The edit already maps cleanly to that arc.", + }, + { + id: "announcement-demo-pollen-5", + author: "producer", + minutesAgo: 55, + content: "Team in channels, work in projects, then the mobile handoff.", + }, + { + id: "announcement-demo-pollen-6", + author: "community", + minutesAgo: 42, + content: + "I like it. Preview group is ready to share their favorite moment.", + }, + { + id: "announcement-demo-pollen-7", + author: "marketing", + minutesAgo: 34, + content: "Their own words, please. No copy-paste script.", + }, + { + id: "announcement-demo-pollen-8", + author: "community", + minutesAgo: 31, + content: "Definitely. That was the plan.", + }, + { + id: "announcement-demo-pollen-9", + author: "designer", + minutesAgo: 18, + content: + "Square, vertical, and wide exports are ready.\n\n[launch-social-crops.zip](https://mock.relay/media/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.zip)", + extraTags: [ + [ + "imeta", + "url https://mock.relay/media/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.zip", + "m application/zip", + "x bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size 18432000", + "filename launch-social-crops.zip", + ], + ], + reactions: [{ emoji: "🙌", authors: ["marketing", "community"] }], + }, + { + id: "announcement-demo-pollen-10", + author: "marketing", + minutesAgo: 9, + content: + "Found them. These look great. I updated the [Launch content calendar](https://docs.google.com/spreadsheets/d/1BUZZLAUNCHCALENDAR/edit#gid=0).", + reactions: [{ emoji: "✨", authors: ["designer"] }], + }, + ], + "1be1dcdb-4c31-5a8c-81de-ac102552ca10": [ + { + id: "announcement-demo-launch-notes-old-1", + author: "marketing", + minutesAgo: 7_120, + kind: 45001, + content: "Launch narrative v1: the three ideas the film needs to prove.", + }, + { + id: "announcement-demo-launch-notes-old-2", + author: "producer", + minutesAgo: 5_540, + kind: 45001, + content: "Edit review notes: pacing, supers, and the mobile transition.", + }, + { + id: "announcement-demo-launch-notes-old-3", + author: "qa", + minutesAgo: 3_980, + kind: 45001, + content: + "Capture readiness: paths that must remain interactive on camera.", + }, + { + id: "announcement-demo-launch-notes-1", + author: "producer", + minutesAgo: 137, + kind: 45001, + content: + "Final film review: opening hold, live message send, and mobile handoff.", + }, + { + id: "announcement-demo-launch-notes-2", + author: "marketing", + minutesAgo: 112, + kind: 45001, + content: "Copy lock: headline, product description, and social language.", + }, + { + id: "announcement-demo-launch-notes-3", + author: "qa", + minutesAgo: 86, + kind: 45001, + content: "Recording build checklist and final smoke results.", + }, + { + id: "announcement-demo-launch-notes-4", + author: "producer", + minutesAgo: 54, + kind: 45001, + content: + "Shot list: clean workspace overview plus backup angles.\n\n[announcement-shot-list.pdf](https://mock.relay/media/cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.pdf)", + extraTags: [ + [ + "imeta", + "url https://mock.relay/media/cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.pdf", + "m application/pdf", + "x cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "size 842731", + "filename announcement-shot-list.pdf", + ], + ], + }, + { + id: "announcement-demo-launch-notes-5", + author: "viewer", + minutesAgo: 22, + kind: 45001, + content: "Go/no-go: final capture and publishing checklist.", + }, + ], + "3c2d9f0a-1b44-5e77-9a21-6f8b0c4d2e91": [ + { + id: "announcement-demo-queen-old-1", + author: "producer", + minutesAgo: 3_480, + content: "Private room is open. I’ll keep final-cut notes here.", + }, + { + id: "announcement-demo-queen-old-2", + author: "marketing", + minutesAgo: 3_462, + content: "Perfect. I’ll keep publishing changes here too.", + }, + { + id: "announcement-demo-queen-old-3", + author: "viewer", + minutesAgo: 3_440, + content: "Small group, fast decisions. Exactly what we need.", + reactions: [{ emoji: "🐝", authors: ["producer", "marketing"] }], + }, + { + id: "announcement-demo-queen-old-4", + author: "engineer", + minutesAgo: 1_910, + content: "Recording build is pinned. I won’t touch the data after today.", + }, + { + id: "announcement-demo-queen-old-5", + author: "qa", + minutesAgo: 1_892, + content: "I’ll do the final sweep against that exact build.", + reactions: [{ emoji: "✅", authors: ["engineer", "viewer"] }], + }, + { + id: "announcement-demo-queen-1", + author: "producer", + minutesAgo: 66, + content: "Cut twelve is up. New opening, cleaner mobile transition.", + }, + { + id: "announcement-demo-queen-2", + author: "producer", + minutesAgo: 64, + content: "Runtime is 1:12 now.", + }, + { + id: "announcement-demo-queen-3", + author: "viewer", + minutesAgo: 55, + content: "Watching now.", + }, + { + id: "announcement-demo-queen-4", + author: "viewer", + minutesAgo: 51, + content: "The opening breathes much better.", + reactions: [{ emoji: "❤️", authors: ["producer", "marketing"] }], + }, + { + id: "announcement-demo-queen-5", + author: "qa", + minutesAgo: 43, + content: + "Every visible interaction is reproducible in the recording build.", + }, + { + id: "announcement-demo-queen-6", + author: "engineer", + minutesAgo: 32, + content: "I froze the demo data and added a quick reset.", + }, + { + id: "announcement-demo-queen-7", + author: "engineer", + minutesAgo: 30, + content: + "So we can rehearse without worrying about breaking the starting state.", + }, + { + id: "announcement-demo-queen-8", + author: "producer", + minutesAgo: 20, + content: "Sound mix is approved.", + reactions: [ + { emoji: "🎧", authors: ["marketing"] }, + { emoji: "✅", authors: ["viewer", "qa"] }, + ], + }, + { + id: "announcement-demo-queen-9", + author: "marketing", + minutesAgo: 8, + content: "Press, social, and the product page are staged.", + }, + { + id: "announcement-demo-queen-10", + author: "marketing", + minutesAgo: 3, + content: "Nothing publishes until we say go.", + reactions: [{ emoji: "👀", authors: ["viewer", "producer"] }], + }, + ], + "f48efb06-0c93-5025-aac9-2e646bb6bfa8": [ + { + id: "announcement-demo-dm-maya", + author: "engineer", + minutesAgo: 11, + content: "I left the clean build running for your capture session.", + }, + ], + "7eb9f239-9393-50b0-bd76-d85eef0511c7": [ + { + id: "announcement-demo-dm-jordan", + author: "designer", + minutesAgo: 16, + content: "Sending the updated storyboard now — the pacing feels right.", + }, + ], + "d1ec7000-d000-4000-8000-000000000001": [ + { + id: "announcement-demo-dm-priya", + author: "marketing", + minutesAgo: 6, + content: + "The launch calendar is clear. We can publish whenever the cut lands.", + }, + ], +}; diff --git a/desktop/src/testing/announcementDemoTauriCore.ts b/desktop/src/testing/announcementDemoTauriCore.ts new file mode 100644 index 0000000000..8093387812 --- /dev/null +++ b/desktop/src/testing/announcementDemoTauriCore.ts @@ -0,0 +1,43 @@ +import { + invoke as nativeInvoke, + type InvokeArgs, + type InvokeOptions, +} from "../../node_modules/@tauri-apps/api/core.js"; + +export type { + InvokeArgs, + InvokeOptions, +} from "../../node_modules/@tauri-apps/api/core.js"; +export { + addPluginListener, + Channel, + checkPermissions, + convertFileSrc, + isTauri, + PluginListener, + requestPermissions, + Resource, + SERIALIZE_TO_IPC_FN, + transformCallback, +} from "../../node_modules/@tauri-apps/api/core.js"; + +type AnnouncementDemoWindow = Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: unknown, + ) => unknown; +}; + +/** Route native-shell commands through the deterministic announcement bridge. */ +export function invoke( + command: string, + payload?: InvokeArgs, + options?: InvokeOptions, +): Promise { + const mockInvoke = (window as AnnouncementDemoWindow) + .__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (mockInvoke) { + return Promise.resolve(mockInvoke(command, payload)) as Promise; + } + return nativeInvoke(command, payload, options); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f9ae719999..60a197274f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -49,6 +49,18 @@ import type { RawInstallRuntimeResult, } from "@/shared/api/tauri"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + ANNOUNCEMENT_DEMO_AGENTS, + ANNOUNCEMENT_DEMO_CHANNELS, + ANNOUNCEMENT_DEMO_DMS, + ANNOUNCEMENT_DEMO_LIVE_CONVERSATIONS, + ANNOUNCEMENT_DEMO_MESSAGES, + ANNOUNCEMENT_DEMO_PEOPLE, + ANNOUNCEMENT_DEMO_PROJECT_SUBJECTS, + ANNOUNCEMENT_DEMO_PROJECTS, + type AnnouncementDemoAgentName, + type AnnouncementDemoPersonKey, +} from "@/testing/announcementDemoFixtures"; type TestIdentity = { privateKey: string; @@ -67,6 +79,7 @@ type MockManagedAgentSeed = { name: string; avatarUrl?: string | null; personaId?: string | null; + systemPrompt?: string | null; status?: RawManagedAgent["status"]; channelNames?: string[]; channelIds?: string[]; @@ -121,6 +134,7 @@ type MockSearchProfileSeed = { type E2eConfig = { mode?: "mock" | "relay"; mock?: { + announcementDemo?: boolean; acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[]; acpAuthMethods?: Record; connectAcpRuntimeResult?: RawConnectAcpRuntimeResult; @@ -323,6 +337,10 @@ type E2eConfig = { identity?: TestIdentity; }; +type MockGlobalAgentConfig = NonNullable< + NonNullable["globalAgentConfig"] +>; + type RawBlobDescriptor = { url: string; sha256: string; @@ -1016,6 +1034,12 @@ const BOB_PUBKEY = "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260"; const CHARLIE_PUBKEY = "554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea"; +const SOFIA_PUBKEY = "10".repeat(32); +const MARCUS_PUBKEY = "20".repeat(32); +const ELENA_PUBKEY = "30".repeat(32); +const THEO_PUBKEY = "40".repeat(32); +const CAMILLE_PUBKEY = "50".repeat(32); +const NOAH_PUBKEY = "60".repeat(32); const OUTSIDER_PUBKEY = "df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e"; const PROFILE_ONLY_AGENT_PUBKEY = @@ -1031,6 +1055,25 @@ const STARTER_GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const STARTER_WELCOME_CHANNEL_ID = "5f0b1b3c-2a37-5366-9b8c-31a4b21d8e77"; const STARTER_GENERAL_CHANNEL_NAME = "general"; const STARTER_WELCOME_CHANNEL_NAME = "welcome-everyone"; +const ANNOUNCEMENT_DEMO_PERSON_PUBKEYS: Record< + AnnouncementDemoPersonKey, + string +> = { + viewer: MOCK_IDENTITY_PUBKEY, + engineer: ALICE_PUBKEY, + designer: BOB_PUBKEY, + marketing: CHARLIE_PUBKEY, + researcher: SOFIA_PUBKEY, + qa: MARCUS_PUBKEY, + data: ELENA_PUBKEY, + support: THEO_PUBKEY, + producer: CAMILLE_PUBKEY, + community: NOAH_PUBKEY, +}; + +function getAnnouncementDemoPersonPubkey(key: AnnouncementDemoPersonKey) { + return ANNOUNCEMENT_DEMO_PERSON_PUBKEYS[key]; +} // Tracks whether `persist_current_identity` or `import_identity` has cleared // the lost flag set by `mock.identityLost`. Reset to false on each fresh page @@ -1059,6 +1102,12 @@ const mockDisplayNames = new Map([ [ALICE_PUBKEY, "alice"], [BOB_PUBKEY, "bob"], [CHARLIE_PUBKEY, "charlie"], + [SOFIA_PUBKEY, "sofia"], + [MARCUS_PUBKEY, "marcus"], + [ELENA_PUBKEY, "elena"], + [THEO_PUBKEY, "theo"], + [CAMILLE_PUBKEY, "camille"], + [NOAH_PUBKEY, "noah"], [PROFILE_ONLY_AGENT_PUBKEY, "mira"], [OWNED_RELAY_AGENT_PUBKEY, "nadia"], [OUTSIDER_PUBKEY, "outsider"], @@ -1305,6 +1354,72 @@ function cloneAgentMemoryListing( function resetMockRelayMembers(config: E2eConfig | undefined) { const pubkey = getMockMemberPubkey(config); + if (config?.mock?.announcementDemo) { + mockRelayMembers = [ + { + pubkey, + role: "owner", + added_by: null, + created_at: isoMinutesAgo(1_440), + }, + { + pubkey: ALICE_PUBKEY, + role: "admin", + added_by: pubkey, + created_at: isoMinutesAgo(1_200), + }, + { + pubkey: BOB_PUBKEY, + role: "member", + added_by: pubkey, + created_at: isoMinutesAgo(1_100), + }, + { + pubkey: CHARLIE_PUBKEY, + role: "member", + added_by: pubkey, + created_at: isoMinutesAgo(1_000), + }, + { + pubkey: SOFIA_PUBKEY, + role: "member", + added_by: pubkey, + created_at: isoMinutesAgo(940), + }, + { + pubkey: MARCUS_PUBKEY, + role: "member", + added_by: pubkey, + created_at: isoMinutesAgo(880), + }, + { + pubkey: ELENA_PUBKEY, + role: "member", + added_by: pubkey, + created_at: isoMinutesAgo(820), + }, + { + pubkey: THEO_PUBKEY, + role: "member", + added_by: pubkey, + created_at: isoMinutesAgo(760), + }, + { + pubkey: CAMILLE_PUBKEY, + role: "member", + added_by: pubkey, + created_at: isoMinutesAgo(700), + }, + { + pubkey: NOAH_PUBKEY, + role: "member", + added_by: pubkey, + created_at: isoMinutesAgo(640), + }, + ]; + return; + } + // Drive the active identity's role from `mock.relayRole` so the e2e harness // can exercise the NIP-IA admin gate (owner/admin → true, member/null → // false). Default stays `owner` to preserve existing test behavior. @@ -1791,7 +1906,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { idle_timeout_seconds: null, max_turn_duration_seconds: null, parallelism: 1, - system_prompt: null, + system_prompt: seed.systemPrompt?.trim() || null, avatar_url: seed.avatarUrl ?? null, model: null, env_vars: {}, @@ -1821,6 +1936,11 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { } function resetMockRelayAgents(config?: E2eConfig) { + if (config?.mock?.announcementDemo) { + mockRelayAgents = []; + return; + } + mockRelayAgents = defaultMockRelayAgents.map((agent) => ({ ...agent, channels: [...agent.channels], @@ -1853,37 +1973,88 @@ function resetMockRelayAgents(config?: E2eConfig) { function resetMockManagedAgents(config?: E2eConfig) { mockManagedAgents = []; - for (const seed of config?.mock?.managedAgents ?? []) { - mockManagedAgents.push(buildSeededManagedAgent(seed)); - applyMockDisplayName(seed.pubkey, seed.name); - mockAgentPubkeys.add(seed.pubkey); - mockProfiles.set(seed.pubkey, { - pubkey: seed.pubkey, - display_name: seed.name, - avatar_url: null, - about: null, + const savedState = config?.mock?.announcementDemo + ? readAnnouncementDemoAgentState() + : null; + const configuredEntries = (config?.mock?.managedAgents ?? []).map((seed) => ({ + agent: buildSeededManagedAgent(seed), + channelIds: mockChannels + .filter( + (channel) => + seed.channelIds?.includes(channel.id) || + seed.channelNames?.includes(channel.name), + ) + .map((channel) => channel.id), + })); + const configuredPubkeys = new Set( + configuredEntries.map(({ agent }) => normalizePubkey(agent.pubkey)), + ); + const entries = savedState + ? [ + ...configuredEntries.map((configured) => { + const saved = savedState.agents.find( + ({ agent }) => + normalizePubkey(agent.pubkey) === + normalizePubkey(configured.agent.pubkey), + ); + if (!saved) { + return configured; + } + return { + agent: { + ...cloneSavedManagedAgent(saved.agent), + name: configured.agent.name, + system_prompt: configured.agent.system_prompt, + avatar_url: configured.agent.avatar_url, + status: configured.agent.status, + pid: configured.agent.pid, + }, + channelIds: [ + ...new Set([...saved.channelIds, ...configured.channelIds]), + ], + }; + }), + ...savedState.agents + .filter( + ({ agent }) => + !configuredPubkeys.has(normalizePubkey(agent.pubkey)), + ) + .map((saved) => ({ + agent: cloneSavedManagedAgent(saved.agent), + channelIds: saved.channelIds, + })), + ] + : configuredEntries; + + for (const { agent, channelIds } of entries) { + mockManagedAgents.push(agent); + applyMockDisplayName(agent.pubkey, agent.name); + mockAgentPubkeys.add(agent.pubkey); + mockProfiles.set(agent.pubkey, { + pubkey: agent.pubkey, + display_name: agent.name, + avatar_url: agent.avatar_url, + about: agent.system_prompt, nip05_handle: null, owner_pubkey: MOCK_IDENTITY_PUBKEY, is_agent: true, has_profile_event: true, }); for (const channel of mockChannels) { - const isSeedChannel = - seed.channelIds?.includes(channel.id) || - seed.channelNames?.includes(channel.name); + const isSeedChannel = channelIds.includes(channel.id); if ( !isSeedChannel || - channel.members.some((member) => member.pubkey === seed.pubkey) + channel.members.some((member) => member.pubkey === agent.pubkey) ) { continue; } channel.members.push({ - pubkey: seed.pubkey, + pubkey: agent.pubkey, role: "bot", is_agent: true, joined_at: new Date().toISOString(), - display_name: seed.name, + display_name: agent.name, }); syncMockChannel(channel); touchMockChannel(channel); @@ -1891,9 +2062,15 @@ function resetMockManagedAgents(config?: E2eConfig) { } syncMockRelayAgentsFromManagedAgents(); + persistAnnouncementDemoAgentState(); } function resetMockPersonas(config?: E2eConfig) { + if (config?.mock?.announcementDemo) { + mockPersonas = []; + return; + } + const now = new Date().toISOString(); const activePersonaIds = new Set(config?.mock?.activePersonaIds ?? []); const builtInPersonas = [ @@ -2283,6 +2460,32 @@ const mockChannels: MockChannel[] = [ createMockMember(BOB_PUBKEY, "member", 900), ], }), + createMockChannel({ + id: "4e91b4d2-8207-4d63-9b1a-3c72a8f01142", + name: "engineering-demo", + channel_type: "stream", + visibility: "open", + description: "Desktop, relay, and infrastructure engineering", + topic: "Reconnect reliability and the next release candidate", + purpose: "Debug together, review changes, and keep the build healthy.", + last_message_at: isoMinutesAgo(5), + archived_at: null, + created_by: ALICE_PUBKEY, + topic_set_by: ALICE_PUBKEY, + topic_set_at: isoMinutesAgo(100), + purpose_set_by: ALICE_PUBKEY, + purpose_set_at: isoMinutesAgo(110), + topic_required: false, + max_members: null, + nip29_group_id: null, + created_minutes_ago: 1310, + updated_minutes_ago: 5, + members: [ + createMockMember(ALICE_PUBKEY, "owner", 1310), + createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1170), + createMockMember(BOB_PUBKEY, "member", 890), + ], + }), createMockChannel({ id: "94a444a4-c0a3-5966-ab05-530c6ddc2301", name: "agents", @@ -2543,6 +2746,121 @@ const mockSockets = new Map(); let mockWebsocketSendMutexWedged = false; const realSockets = new Map(); let mockManagedAgents: MockManagedAgent[] = []; +const pendingAnnouncementDemoAgentResponses = new Set(); +const startedAnnouncementDemoLiveConversations = new Set(); +let mockGlobalAgentConfig: MockGlobalAgentConfig = { + env_vars: {}, + provider: null, + model: null, +}; + +const ANNOUNCEMENT_DEMO_AGENT_STATE_KEY = + "buzz-announcement-demo-agent-state.v1"; + +type AnnouncementDemoAgentState = { + globalConfig: MockGlobalAgentConfig; + agents: Array<{ + agent: MockManagedAgent; + channelIds: string[]; + }>; +}; + +function cloneMockGlobalAgentConfig( + config: MockGlobalAgentConfig, +): MockGlobalAgentConfig { + return { + env_vars: { ...config.env_vars }, + provider: config.provider, + model: config.model, + }; +} + +function cloneSavedManagedAgent(agent: MockManagedAgent): MockManagedAgent { + return { + ...agent, + agent_args: [...agent.agent_args], + env_vars: { ...(agent.env_vars ?? {}) }, + backend: + agent.backend.type === "provider" + ? { + type: "provider", + id: agent.backend.id, + config: { ...agent.backend.config }, + } + : { type: "local" }, + respond_to_allowlist: [...agent.respond_to_allowlist], + log_lines: [...agent.log_lines], + }; +} + +function readAnnouncementDemoAgentState(): AnnouncementDemoAgentState | null { + try { + const stored = window.sessionStorage.getItem( + ANNOUNCEMENT_DEMO_AGENT_STATE_KEY, + ); + if (!stored) { + return null; + } + + const parsed = JSON.parse(stored) as Partial; + if (!parsed.globalConfig || !Array.isArray(parsed.agents)) { + return null; + } + return { + globalConfig: cloneMockGlobalAgentConfig(parsed.globalConfig), + agents: parsed.agents.map((entry) => ({ + agent: cloneSavedManagedAgent(entry.agent), + channelIds: [...entry.channelIds], + })), + }; + } catch { + return null; + } +} + +function persistAnnouncementDemoAgentState() { + if (!getConfig()?.mock?.announcementDemo) { + return; + } + + const state: AnnouncementDemoAgentState = { + globalConfig: cloneMockGlobalAgentConfig(mockGlobalAgentConfig), + agents: mockManagedAgents.map((agent) => ({ + agent: cloneSavedManagedAgent(agent), + channelIds: mockChannels + .filter((channel) => + channel.members.some( + (member) => + normalizePubkey(member.pubkey) === normalizePubkey(agent.pubkey), + ), + ) + .map((channel) => channel.id), + })), + }; + + try { + window.sessionStorage.setItem( + ANNOUNCEMENT_DEMO_AGENT_STATE_KEY, + JSON.stringify(state), + ); + } catch { + // Keep the live demo working even when web storage is unavailable. + } +} + +function resetMockGlobalAgentConfig(config?: E2eConfig) { + const savedState = config?.mock?.announcementDemo + ? readAnnouncementDemoAgentState() + : null; + mockGlobalAgentConfig = cloneMockGlobalAgentConfig( + savedState?.globalConfig ?? + config?.mock?.globalAgentConfig ?? { + env_vars: {}, + provider: null, + model: null, + }, + ); +} // Mesh-compute mock state — TEST-ONLY. // @@ -2900,6 +3218,114 @@ const mockPresence = new Map([ [OWNED_RELAY_AGENT_PUBKEY, "online"], [OUTSIDER_PUBKEY, "offline"], ]); + +function applyAnnouncementDemoFixtures(config: E2eConfig | undefined) { + if (!config?.mock?.announcementDemo) { + return; + } + + DEFAULT_MOCK_IDENTITY.display_name = + ANNOUNCEMENT_DEMO_PEOPLE.viewer.displayName; + + mockAgentPubkeys.delete(ALICE_PUBKEY); + mockAgentPubkeys.delete(CHARLIE_PUBKEY); + mockAgentPubkeys.delete(PROFILE_ONLY_AGENT_PUBKEY); + mockAgentPubkeys.delete(OWNED_RELAY_AGENT_PUBKEY); + mockProfiles.delete(PROFILE_ONLY_AGENT_PUBKEY); + mockProfiles.delete(OWNED_RELAY_AGENT_PUBKEY); + + for (const key of Object.keys( + ANNOUNCEMENT_DEMO_PEOPLE, + ) as AnnouncementDemoPersonKey[]) { + const person = ANNOUNCEMENT_DEMO_PEOPLE[key]; + const pubkey = getAnnouncementDemoPersonPubkey(key); + mockDisplayNames.set(pubkey, person.displayName); + mockKind0Names.set(pubkey, person.displayName.split(" ")[0].toLowerCase()); + mockProfiles.set(pubkey, { + pubkey, + display_name: person.displayName, + avatar_url: person.avatarUrl, + about: person.role, + nip05_handle: person.nip05Handle, + owner_pubkey: null, + is_agent: false, + has_profile_event: true, + }); + mockPresence.set(pubkey, person.presence); + } + + const channelsById = new Map( + mockChannels.map((channel) => [channel.id, channel]), + ); + const demoChannels = ANNOUNCEMENT_DEMO_CHANNELS.map((seed, seedIndex) => { + const channel = channelsById.get(seed.id); + if (!channel) { + throw new Error(`Missing announcement demo channel seed ${seed.id}`); + } + + channel.name = seed.name; + channel.channel_type = seed.channelType; + channel.visibility = seed.visibility; + channel.description = seed.description; + channel.topic = seed.topic; + channel.purpose = seed.purpose; + channel.last_message_at = isoMinutesAgo(seed.lastMessageMinutesAgo); + channel.archived_at = null; + channel.created_by = getAnnouncementDemoPersonPubkey(seed.createdBy); + channel.topic_set_by = channel.created_by; + channel.topic_set_at = isoMinutesAgo(60 + seedIndex * 5); + channel.purpose_set_by = channel.created_by; + channel.purpose_set_at = isoMinutesAgo(70 + seedIndex * 5); + channel.topic_required = false; + channel.max_members = null; + channel.members = seed.memberRoles.map(([personKey, role], index) => + createMockMember( + getAnnouncementDemoPersonPubkey(personKey), + role, + 1_300 - seedIndex * 20 - index * 10, + ), + ); + channel.participants = []; + channel.participant_pubkeys = []; + syncMockChannel(channel); + return channel; + }); + + const demoDms = ANNOUNCEMENT_DEMO_DMS.map((seed, seedIndex) => { + const channel = channelsById.get(seed.id); + if (!channel) { + throw new Error(`Missing announcement demo DM seed ${seed.id}`); + } + + const otherPubkey = getAnnouncementDemoPersonPubkey(seed.person); + channel.name = "DM"; + channel.channel_type = "dm"; + channel.visibility = "private"; + channel.description = `Direct message with ${ANNOUNCEMENT_DEMO_PEOPLE[seed.person].displayName}`; + channel.topic = null; + channel.purpose = null; + channel.last_message_at = isoMinutesAgo(seed.lastMessageMinutesAgo); + channel.archived_at = null; + channel.created_by = otherPubkey; + channel.topic_set_by = null; + channel.topic_set_at = null; + channel.purpose_set_by = null; + channel.purpose_set_at = null; + channel.topic_required = false; + channel.max_members = 2; + channel.members = [ + createMockMember(otherPubkey, "member", 700 - seedIndex * 20), + createMockMember(MOCK_IDENTITY_PUBKEY, "member", 700 - seedIndex * 20), + ]; + syncMockChannel(channel); + return channel; + }); + + mockChannels.splice(0, mockChannels.length, ...demoChannels, ...demoDms); + mockMessages.clear(); + mockProjectEventStore = null; +} + const mockFeedOverrides: RawHomeFeedResponse["feed"] = { mentions: [], needs_action: [], @@ -3240,12 +3666,77 @@ function buildReplyMessageTags( return tags; } +function announcementDemoEventId(channelId: string, slot: number): string { + const channelPrefix = channelId + .toLowerCase() + .replace(/[^0-9a-f]/g, "") + .padEnd(56, "0") + .slice(0, 56); + return `${channelPrefix}${slot.toString(16).padStart(8, "0")}`; +} + +function buildAnnouncementDemoMessages(channelId: string): RelayEvent[] | null { + if (!getConfig()?.mock?.announcementDemo) { + return null; + } + + const now = Math.floor(Date.now() / 1_000); + return (ANNOUNCEMENT_DEMO_MESSAGES[channelId] ?? []).flatMap( + (seed, messageIndex) => { + const messageId = announcementDemoEventId(channelId, messageIndex + 1); + const message: RelayEvent = { + id: messageId, + pubkey: getAnnouncementDemoPersonPubkey(seed.author), + created_at: now - seed.minutesAgo * 60, + kind: seed.kind ?? 9, + tags: [ + ["h", channelId], + ...(seed.extraTags?.map((tag) => [...tag]) ?? []), + ], + content: seed.content, + sig: "mocksig".repeat(20).slice(0, 128), + }; + const reactions = (seed.reactions ?? []).flatMap( + (reaction, reactionIndex) => + reaction.authors.map( + (author, authorIndex): RelayEvent => ({ + id: announcementDemoEventId( + channelId, + 10_000 + messageIndex * 100 + reactionIndex * 10 + authorIndex, + ), + pubkey: getAnnouncementDemoPersonPubkey(author), + created_at: + now - + (reaction.minutesAgo ?? Math.max(0, seed.minutesAgo - 1)) * 60 - + authorIndex, + kind: KIND_REACTION, + tags: [ + ["h", channelId], + ["e", messageId], + ], + content: reaction.emoji, + sig: "mocksig".repeat(20).slice(0, 128), + }), + ), + ); + + return [message, ...reactions]; + }, + ); +} + function getMockMessageStore(channelId: string): RelayEvent[] { const existing = mockMessages.get(channelId); if (existing) { return existing; } + const announcementDemoMessages = buildAnnouncementDemoMessages(channelId); + if (announcementDemoMessages) { + mockMessages.set(channelId, announcementDemoMessages); + return announcementDemoMessages; + } + const seeded: RelayEvent[] = channelId === "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" ? [ @@ -3532,6 +4023,14 @@ function emitMockHistory( } function emitMockLiveEvent(channelId: string, event: RelayEvent) { + if (getConfig()?.mock?.announcementDemo && event.kind === 9) { + window.dispatchEvent( + new CustomEvent("buzz:follow-channel-tail", { + detail: { channelId }, + }), + ); + } + for (const socket of mockSockets.values()) { for (const [subId, subscription] of socket.subscriptions) { if ( @@ -3574,7 +4073,11 @@ function hasMockLiveSubscription(channelId: string, kind?: number) { return false; } -function recordMockMessage(channelId: string, event: RelayEvent) { +function recordMockMessage( + channelId: string, + event: RelayEvent, + queueAgentResponses = true, +) { const history = getMockMessageStore(channelId); history.push(event); @@ -3585,6 +4088,9 @@ function recordMockMessage(channelId: string, event: RelayEvent) { channel.last_message_at = new Date(event.created_at * 1_000).toISOString(); touchMockChannel(channel); + if (queueAgentResponses) { + queueAnnouncementDemoAgentResponses(channelId, event); + } } function resetMockUserStatuses() { @@ -3725,6 +4231,522 @@ function emitMockTypingIndicator(channelId: string, pubkey: string) { return event; } +type AnnouncementDemoProviderConfig = { + provider: "anthropic" | "openai" | null; + apiKey: string | null; + model: string | null; +}; + +type AnnouncementDemoAgentReply = { + text?: string; + error?: string; +}; + +function getAnnouncementDemoLiveConversation(channelId: string) { + const channel = mockChannels.find((candidate) => candidate.id === channelId); + return ANNOUNCEMENT_DEMO_LIVE_CONVERSATIONS.find( + (conversation) => conversation.channelName === channel?.name, + ); +} + +function getAnnouncementDemoNextAgentName( + channelId: string, + currentAgentPubkey: string, +): AnnouncementDemoAgentName | null { + const currentAgent = ANNOUNCEMENT_DEMO_AGENTS.find( + (candidate) => + normalizePubkey(candidate.pubkey) === normalizePubkey(currentAgentPubkey), + ); + if (!currentAgent) { + return null; + } + + const agentOrder = + getAnnouncementDemoLiveConversation(channelId)?.agentOrder ?? + ANNOUNCEMENT_DEMO_AGENTS.map((agent) => agent.name); + const currentIndex = agentOrder.indexOf(currentAgent.name); + return agentOrder[currentIndex + 1] ?? null; +} + +function getAnnouncementDemoProviderConfig( + agent: MockManagedAgent, +): AnnouncementDemoProviderConfig { + const persona = agent.persona_id + ? mockPersonas.find((candidate) => candidate.id === agent.persona_id) + : null; + const env = { + ...mockGlobalAgentConfig.env_vars, + ...(persona?.env_vars ?? {}), + ...(agent.env_vars ?? {}), + }; + const configuredProvider = ( + env.BUZZ_AGENT_PROVIDER ?? + env.GOOSE_PROVIDER ?? + mockGlobalAgentConfig.provider ?? + "" + ) + .trim() + .toLowerCase(); + const provider = + configuredProvider === "openai" || configuredProvider === "anthropic" + ? configuredProvider + : env.OPENAI_COMPAT_API_KEY?.trim() || env.OPENAI_API_KEY?.trim() + ? "openai" + : env.ANTHROPIC_API_KEY?.trim() + ? "anthropic" + : null; + const model = + ( + agent.model ?? + env.BUZZ_AGENT_MODEL ?? + env.GOOSE_MODEL ?? + env.OPENAI_COMPAT_MODEL ?? + mockGlobalAgentConfig.model ?? + "" + ).trim() || null; + + const apiKey = + provider === "openai" + ? env.OPENAI_COMPAT_API_KEY?.trim() || env.OPENAI_API_KEY?.trim() + : provider === "anthropic" + ? env.ANTHROPIC_API_KEY?.trim() + : null; + + return { provider, apiKey: apiKey || null, model }; +} + +function getAnnouncementDemoAgentTargets(channelId: string, event: RelayEvent) { + const isAgentAuthored = mockAgentPubkeys.has(normalizePubkey(event.pubkey)); + const isAgentChainEvent = event.tags.some( + (tag) => tag[0] === ANNOUNCEMENT_DEMO_AGENT_CHAIN_TAG, + ); + if (event.kind !== 9 || (isAgentAuthored && !isAgentChainEvent)) { + return []; + } + + const channel = mockChannels.find((candidate) => candidate.id === channelId); + if (!channel) { + return []; + } + const mentionedPubkeys = new Set( + event.tags + .filter((tag) => tag[0] === "p" && tag[1]) + .map((tag) => normalizePubkey(tag[1])), + ); + const dmMemberPubkeys = + channel.channel_type === "dm" + ? new Set(channel.members.map((member) => normalizePubkey(member.pubkey))) + : new Set(); + const nextAgentName = isAgentAuthored + ? getAnnouncementDemoNextAgentName(channelId, event.pubkey) + : null; + + return mockManagedAgents.filter((agent) => { + const pubkey = normalizePubkey(agent.pubkey); + const isActive = agent.status === "running" || agent.status === "deployed"; + if (isAgentAuthored) { + const targetDefinition = ANNOUNCEMENT_DEMO_AGENTS.find( + (candidate) => normalizePubkey(candidate.pubkey) === pubkey, + ); + if (targetDefinition?.name !== nextAgentName) { + return false; + } + } + return ( + isActive && (mentionedPubkeys.has(pubkey) || dmMemberPubkeys.has(pubkey)) + ); + }); +} + +function getAnnouncementDemoNextAgent( + channelId: string, + sourceEvent: RelayEvent, + agent: MockManagedAgent, +) { + const isAgentChainEvent = sourceEvent.tags.some( + (tag) => tag[0] === ANNOUNCEMENT_DEMO_AGENT_CHAIN_TAG, + ); + if (!isAgentChainEvent) { + return null; + } + + const nextAgentName = getAnnouncementDemoNextAgentName( + channelId, + agent.pubkey, + ); + const nextDefinition = ANNOUNCEMENT_DEMO_AGENTS.find( + (candidate) => candidate.name === nextAgentName, + ); + if (!nextDefinition) { + return null; + } + return ( + mockManagedAgents.find( + (candidate) => + normalizePubkey(candidate.pubkey) === + normalizePubkey(nextDefinition.pubkey), + ) ?? null + ); +} + +function getAnnouncementDemoConversation( + channelId: string, + agent: MockManagedAgent, +) { + return getMockMessageStore(channelId) + .filter((event) => event.kind === 9 && event.content.trim()) + .sort((left, right) => left.created_at - right.created_at) + .slice(-14) + .map((event) => { + const isAgent = + normalizePubkey(event.pubkey) === normalizePubkey(agent.pubkey); + const displayName = + mockDisplayNames.get(event.pubkey) ?? + mockProfiles.get(event.pubkey)?.display_name ?? + "Teammate"; + return { + role: isAgent ? ("assistant" as const) : ("user" as const), + content: isAgent + ? event.content.trim() + : `${displayName}: ${event.content.trim()}`, + }; + }); +} + +function getAnnouncementDemoSystemPrompt( + channelId: string, + agent: MockManagedAgent, + sourceEvent: RelayEvent, +) { + const channel = mockChannels.find((candidate) => candidate.id === channelId); + const basePrompt = + agent.system_prompt?.trim() || + "You are a helpful AI teammate at Honeycomb Studios."; + const location = + channel?.channel_type === "dm" + ? "a direct message" + : `#${channel?.name ?? "a team channel"}`; + const nextAgent = getAnnouncementDemoNextAgent(channelId, sourceEvent, agent); + const isAgentChainEvent = sourceEvent.tags.some( + (tag) => tag[0] === ANNOUNCEMENT_DEMO_AGENT_CHAIN_TAG, + ); + const collaborationInstruction = nextAgent + ? ` This is a collaborative team turn: answer the previous teammate directly, then finish with a short, natural handoff to @${nextAgent.name}. Include exactly @${nextAgent.name} in the reply.` + : isAgentChainEvent + ? " This is the final turn in a short agent collaboration: respond directly to the previous agent, share one useful check or refinement, and close the loop without handing off again." + : ""; + return `${basePrompt}\n\nYou are replying in ${location}. Use the conversation context, sound like a real colleague, and do not prefix the reply with your name. Keep the reply to one to three short sentences with no lists or code blocks.${collaborationInstruction}`; +} + +async function requestAnnouncementDemoAgentReply( + channelId: string, + agent: MockManagedAgent, + sourceEvent: RelayEvent, +) { + const provider = getAnnouncementDemoProviderConfig(agent); + // The local Vite proxy keeps provider credentials out of the webview while + // still allowing the native demo to use the settings entered in the app. + const response = await fetch("/__announcement-demo/agent-response", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...provider, + systemPrompt: getAnnouncementDemoSystemPrompt( + channelId, + agent, + sourceEvent, + ), + messages: getAnnouncementDemoConversation(channelId, agent), + }), + }); + const body = (await response.json()) as AnnouncementDemoAgentReply; + if (!response.ok || !body.text?.trim()) { + throw new Error(body.error?.trim() || "The model returned no reply."); + } + return body.text.trim(); +} + +function announcementDemoAgentFailureMessage(error: unknown) { + const message = error instanceof Error ? error.message : ""; + if (/api key|authentication|401|unauthorized/i.test(message)) { + return "I couldn’t connect with that API key. Please check it in my settings and try again."; + } + if (/choose .*model|model .*not found|model.*does not exist/i.test(message)) { + return "I need a valid model selected in my settings before I can answer."; + } + if (/choose anthropic|choose openai|provider/i.test(message)) { + return "I need an Anthropic or OpenAI provider selected in my settings before I can answer."; + } + return "I hit a connection hiccup. Please try that again in a moment."; +} + +function publishAnnouncementDemoAgentReply( + channelId: string, + sourceEvent: RelayEvent, + agent: MockManagedAgent, + content: string, + continueAgentChain = true, +) { + const isAgentChainEvent = sourceEvent.tags.some( + (tag) => tag[0] === ANNOUNCEMENT_DEMO_AGENT_CHAIN_TAG, + ); + const nextAgent = continueAgentChain + ? getAnnouncementDemoNextAgent(channelId, sourceEvent, agent) + : null; + const mentionPubkeys = [ + sourceEvent.pubkey, + ...(nextAgent ? [nextAgent.pubkey] : []), + ]; + const sourceThread = getThreadReferenceFromTags(sourceEvent.tags); + const tags = sourceThread.rootEventId + ? buildReplyMessageTags( + channelId, + agent.pubkey, + sourceEvent.id, + sourceThread.rootEventId, + mentionPubkeys, + ) + : buildTopLevelMessageTags(channelId, mentionPubkeys, agent.pubkey); + if (isAgentChainEvent && continueAgentChain) { + tags.push([ANNOUNCEMENT_DEMO_AGENT_CHAIN_TAG, "1"]); + } + const event = createMockEvent( + 9, + content, + tags, + agent.pubkey, + Math.max(Math.floor(Date.now() / 1_000), sourceEvent.created_at + 1), + mockEventId(), + ); + recordMockMessage(channelId, event, !nextAgent); + emitMockLiveEvent(channelId, event); + if (nextAgent) { + window.setTimeout( + () => queueAnnouncementDemoAgentResponses(channelId, event), + ANNOUNCEMENT_DEMO_AGENT_HANDOFF_DELAY_MS, + ); + } else if (isAgentChainEvent && continueAgentChain) { + const humanClose = getAnnouncementDemoLiveConversation(channelId) + ?.humanClose ?? { + delayMs: 1_000, + author: "producer" as const, + content: "That’s the version. I can cut to that — nice swarm work 🐝", + }; + const closingAuthorPubkey = getAnnouncementDemoPersonPubkey( + humanClose.author, + ); + window.setTimeout(() => { + emitMockTypingIndicator(channelId, closingAuthorPubkey); + window.setTimeout( + () => + emitMockChannelMessage( + channelId, + humanClose.content, + null, + closingAuthorPubkey, + 9, + undefined, + [["announcement-demo-live", "1"]], + undefined, + mockEventId(), + ), + 650, + ); + }, humanClose.delayMs); + } +} + +const ANNOUNCEMENT_DEMO_AGENT_CHAIN_TAG = "announcement-demo-agent-chain"; +const ANNOUNCEMENT_DEMO_AGENT_SEEN_REACTION = "👀"; +const ANNOUNCEMENT_DEMO_AGENT_WORKING_REACTION = "💬"; +const ANNOUNCEMENT_DEMO_AGENT_WORKING_DELAY_MS = 350; +const ANNOUNCEMENT_DEMO_AGENT_MIN_TURN_MS = 1_800; +const ANNOUNCEMENT_DEMO_AGENT_REACTION_CLEANUP_DELAY_MS = 2_500; +const ANNOUNCEMENT_DEMO_AGENT_HANDOFF_DELAY_MS = 700; + +function publishAnnouncementDemoAgentReaction( + channelId: string, + sourceEvent: RelayEvent, + agent: MockManagedAgent, + emoji: string, +) { + const reaction = createMockEvent( + KIND_REACTION, + emoji, + [["e", sourceEvent.id]], + agent.pubkey, + Math.floor(Date.now() / 1_000), + mockEventId(), + ); + recordMockMessage(channelId, reaction); + emitMockLiveEvent(channelId, reaction); +} + +function clearAnnouncementDemoAgentReactions( + channelId: string, + sourceEvent: RelayEvent, + agent: MockManagedAgent, +) { + const store = getMockMessageStore(channelId); + const reactions = store.filter( + (event) => + event.kind === KIND_REACTION && + normalizePubkey(event.pubkey) === normalizePubkey(agent.pubkey) && + (event.content === ANNOUNCEMENT_DEMO_AGENT_SEEN_REACTION || + event.content === ANNOUNCEMENT_DEMO_AGENT_WORKING_REACTION) && + event.tags.some((tag) => tag[0] === "e" && tag[1] === sourceEvent.id), + ); + + for (const reaction of reactions) { + const index = store.indexOf(reaction); + if (index >= 0) { + store.splice(index, 1); + } + const deletion = createMockEvent( + KIND_DELETION, + "", + [["e", reaction.id]], + agent.pubkey, + ); + recordMockMessage(channelId, deletion); + emitMockLiveEvent(channelId, deletion); + } +} + +function queueAnnouncementDemoAgentResponses( + channelId: string, + sourceEvent: RelayEvent, +) { + if (!getConfig()?.mock?.announcementDemo) { + return; + } + + for (const agent of getAnnouncementDemoAgentTargets(channelId, sourceEvent)) { + const responseKey = `${sourceEvent.id}:${agent.pubkey}`; + if (pendingAnnouncementDemoAgentResponses.has(responseKey)) { + continue; + } + pendingAnnouncementDemoAgentResponses.add(responseKey); + const turnStartedAt = Date.now(); + publishAnnouncementDemoAgentReaction( + channelId, + sourceEvent, + agent, + ANNOUNCEMENT_DEMO_AGENT_SEEN_REACTION, + ); + const workingReactionTimer = window.setTimeout( + () => + publishAnnouncementDemoAgentReaction( + channelId, + sourceEvent, + agent, + ANNOUNCEMENT_DEMO_AGENT_WORKING_REACTION, + ), + ANNOUNCEMENT_DEMO_AGENT_WORKING_DELAY_MS, + ); + emitMockTypingIndicator(channelId, agent.pubkey); + const typingInterval = window.setInterval( + () => emitMockTypingIndicator(channelId, agent.pubkey), + 2_500, + ); + const waitForVisibleTurn = async () => { + const remainingMs = + ANNOUNCEMENT_DEMO_AGENT_MIN_TURN_MS - (Date.now() - turnStartedAt); + if (remainingMs > 0) { + await new Promise((resolve) => window.setTimeout(resolve, remainingMs)); + } + }; + + void requestAnnouncementDemoAgentReply(channelId, agent, sourceEvent) + .then(async (reply) => { + await waitForVisibleTurn(); + agent.last_error = null; + agent.last_error_code = null; + agent.log_lines.push( + `completed announcement demo turn at ${new Date().toISOString()}`, + ); + publishAnnouncementDemoAgentReply(channelId, sourceEvent, agent, reply); + }) + .catch(async (error: unknown) => { + await waitForVisibleTurn(); + const detail = error instanceof Error ? error.message : String(error); + agent.last_error = detail; + agent.log_lines.push(`announcement demo turn failed: ${detail}`); + publishAnnouncementDemoAgentReply( + channelId, + sourceEvent, + agent, + announcementDemoAgentFailureMessage(error), + false, + ); + }) + .finally(() => { + window.clearTimeout(workingReactionTimer); + window.clearInterval(typingInterval); + pendingAnnouncementDemoAgentResponses.delete(responseKey); + persistAnnouncementDemoAgentState(); + window.setTimeout( + () => + clearAnnouncementDemoAgentReactions(channelId, sourceEvent, agent), + ANNOUNCEMENT_DEMO_AGENT_REACTION_CLEANUP_DELAY_MS, + ); + }); + } +} + +function maybeStartAnnouncementDemoLiveConversation(channelId: string) { + if ( + startedAnnouncementDemoLiveConversations.has(channelId) || + !getConfig()?.mock?.announcementDemo + ) { + return; + } + + const conversation = getAnnouncementDemoLiveConversation(channelId); + if (!conversation) { + return; + } + + startedAnnouncementDemoLiveConversations.add(channelId); + let elapsedMs = 0; + for (const step of conversation.steps) { + elapsedMs += step.delayMs; + const authorPubkey = getAnnouncementDemoPersonPubkey(step.author); + window.setTimeout( + () => emitMockTypingIndicator(channelId, authorPubkey), + Math.max(0, elapsedMs - 1_000), + ); + window.setTimeout(() => { + const agentMentionPubkeys = ( + step.agentMentions as readonly AnnouncementDemoAgentName[] + ).map((name) => { + const agent = ANNOUNCEMENT_DEMO_AGENTS.find( + (candidate) => candidate.name === name, + ); + if (!agent) { + throw new Error(`Unknown announcement demo agent: ${name}`); + } + return agent.pubkey; + }); + const extraTags = [["announcement-demo-live", "1"]]; + if (step.startsAgentChain) { + extraTags.push([ANNOUNCEMENT_DEMO_AGENT_CHAIN_TAG, "1"]); + } + emitMockChannelMessage( + channelId, + step.content, + null, + authorPubkey, + 9, + agentMentionPubkeys.length > 0 ? agentMentionPubkeys : undefined, + extraTags, + undefined, + mockEventId(), + ); + }, elapsedMs); + } +} + function toRawForumPost( event: RelayEvent, channelId: string, @@ -4595,8 +5617,18 @@ function buildMockProjectEvents(): RelayEvent[] { const daySeconds = 86_400; const now = Math.floor(Date.now() / 1000); const historyDays = 26 * 7; - - for (const [projectIndex, seed] of MOCK_PROJECT_SEEDS.entries()) { + const projectSeeds = getConfig()?.mock?.announcementDemo + ? ANNOUNCEMENT_DEMO_PROJECTS.map((seed) => ({ + ...seed, + owner: getAnnouncementDemoPersonPubkey(seed.owner), + contributors: seed.contributors.map(getAnnouncementDemoPersonPubkey), + })) + : MOCK_PROJECT_SEEDS; + const projectSubjects: readonly string[] = getConfig()?.mock?.announcementDemo + ? ANNOUNCEMENT_DEMO_PROJECT_SUBJECTS + : MOCK_PROJECT_SUBJECTS; + + for (const [projectIndex, seed] of projectSeeds.entries()) { const repoAddress = `${KIND_REPO_ANNOUNCEMENT}:${seed.owner}:${seed.dtag}`; const authors = [seed.owner, ...seed.contributors]; const random = mulberry32(projectIndex + 1); @@ -4628,9 +5660,7 @@ function buildMockProjectEvents(): RelayEvent[] { now - dayOffset * daySeconds - Math.floor(random() * 10) * 3_600; const author = authors[Math.floor(random() * authors.length)]; const subject = - MOCK_PROJECT_SUBJECTS[ - Math.floor(random() * MOCK_PROJECT_SUBJECTS.length) - ]; + projectSubjects[Math.floor(random() * projectSubjects.length)]; const commitHash = `${seed.dtag}${dayOffset}x${index}` .padEnd(40, "0") .slice(0, 40); @@ -6051,6 +7081,7 @@ async function handleAddChannelMembers( syncMockChannel(targetChannel); touchMockChannel(targetChannel); syncMockRelayAgentsFromManagedAgents(); + persistAnnouncementDemoAgentState(); return { added, errors, @@ -6093,6 +7124,7 @@ async function handleRemoveChannelMember( syncMockChannel(channel); touchMockChannel(channel); syncMockRelayAgentsFromManagedAgents(); + persistAnnouncementDemoAgentState(); return; } @@ -6191,8 +7223,113 @@ async function handleGetFeed( wantedTypes.length === 0 || wantedTypes.includes(type); const currentPubkey = getMockMemberPubkey(config).toLowerCase(); - const defaultFeed: RawHomeFeedResponse["feed"] = - currentPubkey === ALICE_PUBKEY + const defaultFeed: RawHomeFeedResponse["feed"] = config?.mock + ?.announcementDemo + ? { + mentions: [ + { + id: "announcement-demo-feed-mention", + kind: 9, + pubkey: ALICE_PUBKEY, + content: + "Alex, the final desktop build is ready for the capture session.", + created_at: now - 7 * 60, + channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9", + channel_name: "flight-path", + tags: [ + ["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"], + ["p", currentPubkey], + ], + category: "mention" as const, + }, + ], + needs_action: [], + activity: [ + { + id: "announcement-demo-feed-priya", + kind: 9, + pubkey: CHARLIE_PUBKEY, + content: "Launch copy and the press kit are in final review.", + created_at: now - 9 * 60, + channel_id: "c6f3a9b2-4d55-5a23-bf78-5b9e2g3c5d6f", + channel_name: "marketing", + tags: [["e", "c6f3a9b2-4d55-5a23-bf78-5b9e2g3c5d6f"]], + category: "activity" as const, + }, + { + id: "announcement-demo-feed-camille", + kind: 9, + pubkey: CAMILLE_PUBKEY, + content: + "The final edit is at seventy-two seconds and ready for the new product capture.", + created_at: now - 10 * 60, + channel_id: "3c2d9f0a-1b44-5e77-9a21-6f8b0c4d2e91", + channel_name: "queen-bee-launch", + tags: [["e", "3c2d9f0a-1b44-5e77-9a21-6f8b0c4d2e91"]], + category: "activity" as const, + }, + { + id: "announcement-demo-feed-marcus", + kind: 9, + pubkey: MARCUS_PUBKEY, + content: + "The recording paths passed the final desktop and mobile smoke sweep.", + created_at: now - 11 * 60, + channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9", + channel_name: "flight-path", + tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]], + category: "activity" as const, + }, + { + id: "announcement-demo-feed-jordan", + kind: 9, + pubkey: BOB_PUBKEY, + content: "The final motion pass is ready in Comb Kit.", + created_at: now - 12 * 60, + channel_id: "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e", + channel_name: "design", + tags: [["e", "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e"]], + category: "activity" as const, + }, + { + id: "announcement-demo-feed-sofia", + kind: 45001, + pubkey: SOFIA_PUBKEY, + content: + "Research synthesis: what should a calm first five minutes in Buzz feel like?", + created_at: now - 13 * 60, + channel_id: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11", + channel_name: "product-ideas", + tags: [["e", "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11"]], + category: "activity" as const, + }, + { + id: "announcement-demo-feed-alex", + kind: 9, + pubkey: MOCK_IDENTITY_PUBKEY, + content: "The announcement cut is ready for one last watch.", + created_at: now - 14 * 60, + channel_id: "3c2d9f0a-1b44-5e77-9a21-6f8b0c4d2e91", + channel_name: "queen-bee-launch", + tags: [["e", "3c2d9f0a-1b44-5e77-9a21-6f8b0c4d2e91"]], + category: "activity" as const, + }, + { + id: "announcement-demo-feed-noah", + kind: 9, + pubkey: NOAH_PUBKEY, + content: + "The community preview group is ready for announcement day.", + created_at: now - 16 * 60, + channel_id: "c6f3a9b2-4d55-5a23-bf78-5b9e2g3c5d6f", + channel_name: "marketing", + tags: [["e", "c6f3a9b2-4d55-5a23-bf78-5b9e2g3c5d6f"]], + category: "activity" as const, + }, + ], + agent_activity: [], + } + : currentPubkey === ALICE_PUBKEY ? { mentions: [ { @@ -7207,6 +8344,7 @@ async function handleCreateManagedAgent( has_profile_event: true, }); syncMockRelayAgentsFromManagedAgents(); + persistAnnouncementDemoAgentState(); return { agent: cloneManagedAgent(managedAgent), @@ -7279,6 +8417,7 @@ async function handleStartManagedAgent( : `started mock harness at ${now}`, ); syncMockRelayAgentsFromManagedAgents(); + persistAnnouncementDemoAgentState(); return cloneManagedAgent(agent); } @@ -7294,6 +8433,7 @@ async function handleStopManagedAgent(args: { setMockPresenceStatus(agent.pubkey, "offline"); agent.log_lines.push(`stopped mock harness at ${now}`); syncMockRelayAgentsFromManagedAgents(); + persistAnnouncementDemoAgentState(); return cloneManagedAgent(agent); } @@ -7318,6 +8458,7 @@ async function handleDeleteManagedAgent(args: { (candidate) => candidate.pubkey !== args.pubkey, ); syncMockRelayAgentsFromManagedAgents(); + persistAnnouncementDemoAgentState(); } async function handleSetManagedAgentStartOnAppLaunch(args: { @@ -7327,6 +8468,7 @@ async function handleSetManagedAgentStartOnAppLaunch(args: { const agent = getMockManagedAgent(args.pubkey); agent.start_on_app_launch = args.startOnAppLaunch; agent.updated_at = new Date().toISOString(); + persistAnnouncementDemoAgentState(); return cloneManagedAgent(agent); } @@ -7337,6 +8479,7 @@ async function handleSetManagedAgentAutoRestart(args: { const agent = getMockManagedAgent(args.pubkey); agent.auto_restart_on_config_change = args.autoRestartOnConfigChange; agent.updated_at = new Date().toISOString(); + persistAnnouncementDemoAgentState(); return cloneManagedAgent(agent); } @@ -7383,6 +8526,7 @@ async function handleUpdateManagedAgent(args: { agent.respond_to_allowlist = args.input.respondToAllowlist; } agent.updated_at = new Date().toISOString(); + persistAnnouncementDemoAgentState(); return { agent: cloneManagedAgent(agent), profile_sync_error: null }; } @@ -7579,14 +8723,21 @@ async function handleSendChannelMessage( const mockPubkey = getMockMemberPubkey(config); if (!args.parentEventId) { - const event = createMockEvent(kind, args.content, [ - ...buildTopLevelMessageTags( - args.channelId, - args.mentionPubkeys, - mockPubkey, - ), - ...extraTags, - ]); + const event = createMockEvent( + kind, + args.content, + [ + ...buildTopLevelMessageTags( + args.channelId, + args.mentionPubkeys, + mockPubkey, + ), + ...extraTags, + ], + mockPubkey, + createdAt, + config?.mock?.announcementDemo ? mockEventId() : undefined, + ); recordMockMessage(args.channelId, event); emitMockLiveEvent(args.channelId, event); @@ -8162,6 +9313,9 @@ function sendToMockSocket(args: { channelId: onlyChannelId ?? GLOBAL_MOCK_SUBSCRIPTION, kinds: kinds.size > 0 ? [...kinds] : null, }); + for (const channelId of channelIds) { + maybeStartAnnouncementDemoLiveConversation(channelId); + } sendWsText(socket.handler, ["EOSE", subId]); return; } @@ -8374,8 +9528,10 @@ export function maybeInstallE2eTauriMocks() { return; } + applyAnnouncementDemoFixtures(config); resetMockRelayMembers(config); resetMockRelayAgents(config); + resetMockGlobalAgentConfig(config); resetMockManagedAgents(config); resetMockPersonas(config); resetMockTeams(config); @@ -8385,7 +9541,22 @@ export function maybeInstallE2eTauriMocks() { resetMockUserStatuses(); resetMockPendingCommunityDeepLinks(config); mockWebsocketSendMutexWedged = false; - mockWindows("main"); + // The browser test harness has no Tauri globals, so install the complete + // window mock there. The native announcement demo already has Tauri's + // read-only `window.__TAURI_INTERNALS__` binding; attempting to replace it + // throws before React can render. Its existing metadata is sufficient, and + // we swap only the nested invoke handler below. + const nativeTauriInternals = ( + window as unknown as { + __TAURI_INTERNALS__?: { + invoke: (command: string, payload?: unknown) => Promise; + listen?: (event: string, cb: () => void) => Promise<() => void>; + }; + } + ).__TAURI_INTERNALS__; + if (!nativeTauriInternals) { + mockWindows("main"); + } window.__BUZZ_E2E_COMMANDS__ = []; window.__BUZZ_E2E_COMMAND_PAYLOADS__ = []; window.__BUZZ_E2E_COMMAND_LOG__ = []; @@ -8616,13 +9787,33 @@ export function maybeInstallE2eTauriMocks() { deviceId: state === "running" ? "mock-endpoint-id" : null, deviceName: state === "running" ? "Mock desktop" : null, }); + const redactAnnouncementDemoLogValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(redactAnnouncementDemoLogValue); + } + if (typeof value !== "object" || value === null) { + return value; + } + + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + /api[_-]?key|token|secret|password/i.test(key) + ? "[redacted]" + : redactAnnouncementDemoLogValue(entry), + ]), + ); + }; const handleMockCommand = async (command: string, payload: unknown) => { const activeConfig = getConfig(); const identity = getActiveIdentity(activeConfig); window.__BUZZ_E2E_COMMANDS__?.push(command); + const logPayload = activeConfig?.mock?.announcementDemo + ? redactAnnouncementDemoLogValue(payload) + : payload; const loggedPayload = (() => { try { - return JSON.parse(JSON.stringify(payload ?? null)); + return JSON.parse(JSON.stringify(logPayload ?? null)); } catch { return null; } @@ -8631,9 +9822,14 @@ export function maybeInstallE2eTauriMocks() { command, payload: loggedPayload, }); - window.__BUZZ_E2E_COMMAND_LOG__?.push({ command, payload }); + window.__BUZZ_E2E_COMMAND_LOG__?.push({ + command, + payload: logPayload, + }); switch (command) { + case "set_prevent_sleep_active": + return null; case "mesh_installed_models": return mockMeshState.models; case "mesh_node_status": @@ -9381,15 +10577,7 @@ export function maybeInstallE2eTauriMocks() { return null; } case "get_global_agent_config": { - // Return the mock global agent config if provided; otherwise return - // an empty config (no global provider, model, or env vars). - return ( - config?.mock?.globalAgentConfig ?? { - env_vars: {}, - provider: null, - model: null, - } - ); + return cloneMockGlobalAgentConfig(mockGlobalAgentConfig); } case "set_global_agent_config": { // Echo back the submitted config as the saved value (mirrors the @@ -9415,6 +10603,8 @@ export function maybeInstallE2eTauriMocks() { if (saveDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, saveDelayMs)); } + mockGlobalAgentConfig = cloneMockGlobalAgentConfig(savedConfig); + persistAnnouncementDemoAgentState(); // In the E2E environment there are no running agents to restart, so // the counts default to 0 unless a spec drives them explicitly. return { @@ -9876,28 +11066,33 @@ export function maybeInstallE2eTauriMocks() { }; window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ = (command, payload) => handleMockCommand(command, payload ?? null); - mockIPC(handleMockCommand, { shouldMockEvents: true }); + if (!nativeTauriInternals) { + mockIPC(handleMockCommand, { shouldMockEvents: true }); + } // Wire up __TAURI_INTERNALS__.listen so tests can subscribe to backend-emitted // events (e.g. "agents-data-changed"). mockIPC already ensures __TAURI_INTERNALS__ // exists; we just add the listen property without clobbering invoke. - ( + const tauriInternals = ( window as unknown as { __TAURI_INTERNALS__: { listen?: (event: string, cb: () => void) => Promise<() => void>; }; } - ).__TAURI_INTERNALS__.listen = async (event: string, cb: () => void) => { - let listeners = tauriEventListeners.get(event); - if (!listeners) { - listeners = new Set(); - tauriEventListeners.set(event, listeners); - } - listeners.add(cb); - return () => { - tauriEventListeners.get(event)?.delete(cb); + ).__TAURI_INTERNALS__; + if (!nativeTauriInternals) { + tauriInternals.listen = async (event: string, cb: () => void) => { + let listeners = tauriEventListeners.get(event); + if (!listeners) { + listeners = new Set(); + tauriEventListeners.set(event, listeners); + } + listeners.add(cb); + return () => { + tauriEventListeners.get(event)?.delete(cb); + }; }; - }; + } installed = true; } diff --git a/desktop/tests/e2e/announcement-demo.spec.ts b/desktop/tests/e2e/announcement-demo.spec.ts new file mode 100644 index 0000000000..55b9f1372d --- /dev/null +++ b/desktop/tests/e2e/announcement-demo.spec.ts @@ -0,0 +1,360 @@ +import { expect, test } from "@playwright/test"; + +test("announcement demo loads its workspace, people, and projects", async ({ + page, +}) => { + test.setTimeout(75_000); + const agentReply = + "I’d lead with the handoff moment, then land on the shared launch room. That gives the story a clear before-and-after."; + const fizzReply = + "Three beats: hold on the send, cut on the mobile notification, then land in the shared launch room. @Honey, can you make that camera-ready? ✨"; + const honeyReply = + "Frame it as one thought moving with you: send, surface, arrive together. @Bumble, can you sanity-check the sequence? 🍯"; + const bumbleReply = + "The sequence tracks. Keep the notification visible for a full beat before the cut, and the handoff will read without narration. 🐝🔎"; + const engineeringBumbleReply = + "The duplicate points to the previous subscription generation delivering once after reconnect. @Fizz, can you turn that into the smallest safe guard?"; + const engineeringFizzReply = + "I’d reject callbacks whose generation no longer matches the active subscription, then add a sleep-wake regression case. @Honey, can you package the rollout check?"; + const engineeringHoneyReply = + "Ship the guard behind the existing reconnect path, run the twenty-cycle soak, and call out duplicate delivery in the release checklist. That closes the loop cleanly."; + const engineeringHumanClose = + "Perfect. That gives me the patch and the verification path — I’m on it."; + const humanClose = + "That’s the version. I can cut to that — nice swarm work 🐝"; + const fizzReplyVisible = fizzReply.replace("@Honey", "Honey"); + const honeyReplyVisible = honeyReply.replace("@Bumble", "Bumble"); + await page.route("**/__announcement-demo/agent-response", async (route) => { + const body = route.request().postDataJSON() as { + apiKey?: string; + messages?: Array<{ content?: string }>; + model?: string; + provider?: string; + systemPrompt?: string; + }; + expect(body.provider).toBe("openai"); + expect(body.apiKey).toBe("e2e-demo-key"); + expect(body.model).toBe("gpt-5.4-mini"); + expect(body.systemPrompt).toMatch(/You are (Fizz|Honey|Bumble)/); + const isCollaborativeTurn = body.systemPrompt?.includes( + "collaborative team turn", + ); + const isFinalTurn = body.systemPrompt?.includes( + "final turn in a short agent collaboration", + ); + const isEngineering = body.systemPrompt?.includes("in #engineering"); + const text = isEngineering + ? body.systemPrompt?.includes("You are Bumble") && isCollaborativeTurn + ? engineeringBumbleReply + : body.systemPrompt?.includes("You are Fizz") && isCollaborativeTurn + ? engineeringFizzReply + : engineeringHoneyReply + : body.systemPrompt?.includes("You are Fizz") && isCollaborativeTurn + ? fizzReply + : body.systemPrompt?.includes("You are Honey") && isCollaborativeTurn + ? honeyReply + : body.systemPrompt?.includes("You are Bumble") && isFinalTurn + ? bumbleReply + : agentReply; + await route.fulfill({ + json: { text }, + }); + }); + await page.goto("/?demo=announcement"); + await page.waitForFunction( + () => + typeof ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function", + ); + + const relayHttpUrl = await page.evaluate(async () => { + const invoke = ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: unknown, + ) => Promise; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) { + throw new Error("Announcement demo command bridge is unavailable."); + } + await invoke("set_global_agent_config", { + config: { + env_vars: { OPENAI_COMPAT_API_KEY: "e2e-demo-key" }, + provider: "openai", + model: "gpt-5.4-mini", + }, + }); + return invoke("get_relay_http_url"); + }); + expect(relayHttpUrl).toBe(new URL(page.url()).origin); + + await expect(page.getByText("The Hive", { exact: true })).toBeVisible(); + await expect(page.getByText("Product", { exact: true })).toBeVisible(); + await expect(page.getByText("Launch Swarm", { exact: true })).toBeVisible(); + await expect( + page.getByText("Honeycomb Studios", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Alex Rivera", { exact: true }).last(), + ).toBeVisible(); + await expect( + page.getByTestId("channel-DM").filter({ hasText: "Maya Chen" }), + ).toBeVisible(); + await expect( + page.getByTestId("channel-DM").filter({ hasText: "Jordan Brooks" }), + ).toBeVisible(); + await expect( + page.getByTestId("channel-DM").filter({ hasText: "Priya Shah" }), + ).toBeVisible(); + await expect(page.getByTestId("channel-DM")).toHaveCount(3); + + const unreadChannels = [ + "announcements", + "engineering", + "design", + "marketing", + "queen-bee-launch", + ] as const; + const readChannels = [ + "general", + "flight-path", + "mobile", + "product-ideas", + "launch-notes", + ] as const; + for (const channel of unreadChannels) { + await expect( + page + .getByTestId(`channel-${channel}`) + .locator("[data-testid^='channel-unread-']"), + ).toBeVisible(); + } + for (const channel of readChannels) { + await expect( + page + .getByTestId(`channel-${channel}`) + .locator("[data-testid^='channel-unread-']"), + ).toHaveCount(0); + } + + const mayaDm = page + .getByTestId("channel-DM") + .filter({ hasText: "Maya Chen" }); + const jordanDm = page + .getByTestId("channel-DM") + .filter({ hasText: "Jordan Brooks" }); + const priyaDm = page + .getByTestId("channel-DM") + .filter({ hasText: "Priya Shah" }); + await expect( + mayaDm.locator("xpath=..").getByTestId("channel-unread-DM"), + ).toBeVisible(); + await expect( + jordanDm.locator("xpath=..").getByTestId("channel-unread-DM"), + ).toHaveCount(0); + await expect( + priyaDm.locator("xpath=..").getByTestId("channel-unread-DM"), + ).toHaveCount(0); + + await page.getByTestId("channel-flight-path").click(); + const channelTimeline = page.getByTestId("message-timeline"); + await expect(channelTimeline).toContainText("Marcus Reed"); + await expect(channelTimeline).toContainText("Elena Torres"); + await expect(channelTimeline).toContainText("Perfect. That’s the move."); + const demoBuildRow = page + .getByTestId("message-row") + .filter({ hasText: "Demo build is running" }) + .last(); + await expect( + demoBuildRow.locator('[data-link-preview="github-pull-request"]'), + ).toBeVisible(); + await expect(demoBuildRow.getByTestId("message-reactions")).toContainText( + "✅", + ); + await expect( + channelTimeline.locator('[data-link-preview="linear-issue"]').last(), + ).toBeVisible(); + await expect(channelTimeline).toContainText( + "the desktop-to-mobile handoff still feels a little fast", + { timeout: 12_000 }, + ); + await expect(channelTimeline).toContainText( + "one extra beat on the sent message", + ); + await expect(channelTimeline).toContainText( + "give the camera somewhere to land", + ); + await expect(channelTimeline).toContainText( + "Fizz can you turn that into a clean three-beat capture plan?", + ); + const liveAgentRequestRow = page + .getByTestId("message-row") + .filter({ hasText: "three-beat capture plan" }) + .last(); + await expect( + liveAgentRequestRow.getByTestId("message-reactions"), + ).toContainText("👀"); + await expect( + liveAgentRequestRow.getByTestId("message-reactions"), + ).toContainText("💬"); + await expect(channelTimeline).toContainText(fizzReplyVisible, { + timeout: 10_000, + }); + const fizzReplyRow = page + .getByTestId("message-row") + .filter({ hasText: fizzReplyVisible }) + .last(); + await expect( + fizzReplyRow.locator('img[src$="/demo/agents/fizz.png"]'), + ).toBeVisible(); + await expect(fizzReplyRow.getByTestId("message-reactions")).toContainText( + "👀", + ); + await expect(fizzReplyRow.getByTestId("message-reactions")).toContainText( + "💬", + ); + await expect(channelTimeline).toContainText(honeyReplyVisible, { + timeout: 10_000, + }); + const honeyReplyRow = page + .getByTestId("message-row") + .filter({ hasText: honeyReplyVisible }) + .last(); + await expect( + honeyReplyRow.locator('img[src$="/demo/agents/honey.png"]'), + ).toBeVisible(); + await expect(honeyReplyRow.getByTestId("message-reactions")).toContainText( + "👀", + ); + await expect(honeyReplyRow.getByTestId("message-reactions")).toContainText( + "💬", + ); + await expect(channelTimeline).toContainText(bumbleReply, { + timeout: 10_000, + }); + const bumbleReplyRow = page + .getByTestId("message-row") + .filter({ hasText: bumbleReply }) + .last(); + await expect( + bumbleReplyRow.locator('img[src$="/demo/agents/bumble.png"]'), + ).toBeVisible(); + await expect(channelTimeline).toContainText(humanClose, { timeout: 5_000 }); + const humanCloseRow = page + .getByTestId("message-row") + .filter({ hasText: humanClose }) + .last(); + await expect(humanCloseRow).toBeInViewport({ ratio: 0.1 }); + await expect( + liveAgentRequestRow.getByTestId("message-reactions"), + ).toHaveCount(0, { timeout: 5_000 }); + + const channelMessage = `The recording pass is ready ${Date.now()}`; + await page.getByTestId("message-input").fill(channelMessage); + await page.getByTestId("send-message").click(); + await expect(channelTimeline).toContainText(channelMessage); + + const messageInput = page.getByTestId("message-input"); + await messageInput.fill("Could "); + await messageInput.pressSequentially("@Fiz"); + const agentMention = page + .getByTestId("message-composer") + .getByTestId("mention-autocomplete") + .locator("button", { hasText: "Fizz" }); + await expect(agentMention).toBeVisible(); + await agentMention.click(); + await messageInput.pressSequentially(" suggest the strongest story beat?"); + await page.getByTestId("send-message").click(); + await expect(channelTimeline).toContainText(agentReply, { timeout: 10_000 }); + + await page.getByTestId("channel-engineering").click(); + await expect(channelTimeline).toContainText( + "Release build is green. I’m doing one last sleep / wake pass.", + ); + await expect( + channelTimeline.locator('[data-link-preview="github-pull-request"]'), + ).toBeVisible(); + await expect( + channelTimeline.locator('[data-link-preview="linear-issue"]'), + ).toBeVisible(); + await expect(channelTimeline).toContainText( + "I can still reproduce one duplicate message after waking the laptop.", + { timeout: 5_000 }, + ); + await expect(channelTimeline).toContainText("Only on the first reconnect?"); + await expect(channelTimeline).toContainText( + "One duplicate, then the subscription settles.", + ); + await expect(channelTimeline).toContainText( + "Bumble can you trace the likely path and pull Fizz and Honey into a fix plan?", + { timeout: 10_000 }, + ); + await expect(channelTimeline).toContainText( + engineeringBumbleReply.replace("@Fizz", "Fizz"), + { timeout: 5_000 }, + ); + await expect(channelTimeline).toContainText( + engineeringFizzReply.replace("@Honey", "Honey"), + { timeout: 5_000 }, + ); + await expect(channelTimeline).toContainText(engineeringHoneyReply, { + timeout: 5_000, + }); + await expect(channelTimeline).toContainText(engineeringHumanClose, { + timeout: 5_000, + }); + const engineeringAgentRows = page + .getByTestId("message-row") + .filter({ has: page.locator('img[src^="/demo/agents/"]') }); + await expect(engineeringAgentRows).toHaveCount(3); + + const populatedChannels = [ + ["announcements", "Final smoke pass is clean"], + ["general", "Please nobody breathe on main"], + ["design", "Looks great on camera"], + ["mobile", "The draft follows you now"], + ["marketing", "No copy-paste script"], + ["queen-bee-launch", "Sound mix is approved"], + ] as const; + for (const [channel, excerpt] of populatedChannels) { + await page.getByTestId(`channel-${channel}`).click(); + await expect(channelTimeline).toContainText(excerpt); + } + + await page.getByTestId("channel-design").click(); + await expect(channelTimeline.getByAltText("image").last()).toBeVisible(); + await expect( + channelTimeline.locator('[data-link-preview="google-docs-document"]'), + ).toBeVisible(); + + await page.getByTestId("channel-marketing").click(); + await expect( + channelTimeline + .getByTestId("file-card") + .filter({ hasText: "launch-social-crops.zip" }), + ).toBeVisible(); + await expect( + channelTimeline.locator('[data-link-preview="google-sheets-spreadsheet"]'), + ).toBeVisible(); + + await page.getByTestId("channel-DM").filter({ hasText: "Maya Chen" }).click(); + const dmMessage = `Can you join the capture review? ${Date.now()}`; + await page.getByTestId("message-input").fill(dmMessage); + await page.getByTestId("send-message").click(); + await expect(page.getByTestId("message-timeline")).toContainText(dmMessage); + + await page.goto("/?demo=announcement#/projects"); + await page.locator('button[aria-label="Repositories"]').click(); + for (const project of ["flight-path", "nectar", "comb-kit", "swarm-launch"]) { + await expect( + page.locator( + `[data-testid="project-card-${project}"], [data-testid="project-row-${project}"]`, + ), + ).toBeVisible(); + } +}); diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts index bea5704826..a0840ab599 100644 --- a/desktop/vite.config.ts +++ b/desktop/vite.config.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import { announcementDemoAgentPlugin } from "./announcementDemoAgentPlugin"; // @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST; @@ -21,11 +22,20 @@ export default defineConfig(async () => ({ ], }), react(), + announcementDemoAgentPlugin(), ], resolve: { alias: { "@": "/src", "@features-manifest": path.resolve(__dirname, "../preview-features.json"), + ...(process.env.VITE_ANNOUNCEMENT_DEMO === "1" + ? { + "@tauri-apps/api/core": path.resolve( + __dirname, + "src/testing/announcementDemoTauriCore.ts", + ), + } + : {}), }, },