diff --git a/ANNOUNCEMENT_DEMO.md b/ANNOUNCEMENT_DEMO.md new file mode 100644 index 000000000..b06d7f7ab --- /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 3fe33e040..7d0e3e15c 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 --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 000000000..10f903dd6 --- /dev/null +++ b/desktop/announcementDemoAgentPlugin.ts @@ -0,0 +1,258 @@ +import type { Plugin } from "vite"; + +const AGENT_RESPONSE_PATH = "/__announcement-demo/agent-response"; +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; +}; + +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 + ); +} + +function parseAgentRequest(value: unknown): AnnouncementDemoAgentRequest { + if (typeof value !== "object" || value === null) { + throw new Error("The agent request was not valid JSON."); + } + + const candidate = value as Record; + if (candidate.provider !== "anthropic" && candidate.provider !== "openai") { + throw new Error("Choose Anthropic or OpenAI as the agent provider."); + } + if (typeof candidate.apiKey !== "string" || !candidate.apiKey.trim()) { + throw new Error("Add an API key in the agent settings first."); + } + if (typeof candidate.model !== "string" || !candidate.model.trim()) { + 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: candidate.provider, + apiKey: candidate.apiKey.trim(), + model: candidate.model.trim(), + 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; +} + +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 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: 350, + 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) { + 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 { + return { + name: "buzz-announcement-demo-agent", + 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 = parseAgentRequest(await readRequestBody(request)); + const text = + input.provider === "openai" + ? await requestOpenAiResponse(input) + : await requestAnthropicResponse(input); + response.statusCode = 200; + response.end(JSON.stringify({ text })); + } catch (error) { + response.statusCode = 502; + response.end(JSON.stringify({ error: friendlyError(error) })); + } + }); + }, + }; +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index b6296f0f3..1619960d2 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/attachments/flight-path-capture.png b/desktop/public/demo/attachments/flight-path-capture.png new file mode 100644 index 000000000..b651ba398 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 000000000..045e057c8 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 000000000..4c16face5 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 000000000..f8196da13 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 000000000..99319a8d4 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 000000000..df40f71b0 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 000000000..e97ae6ea3 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 000000000..bc5d16d71 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 000000000..ca84431b7 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 000000000..2d88d2e2d 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 000000000..f0dd6ad67 Binary files /dev/null and b/desktop/public/demo/avatars/theo-martin-full.png differ diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index a73105a67..09497ae2c 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -8,6 +8,12 @@ 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_AGENT, + ANNOUNCEMENT_DEMO_COMMUNITY_NAME, + ANNOUNCEMENT_DEMO_PEOPLE, + ANNOUNCEMENT_DEMO_SECTION_STORE, +} from "@/testing/announcementDemoFixtures"; import { ThemeProvider } from "@/shared/theme/ThemeProvider"; import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider"; import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider"; @@ -22,6 +28,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,23 +51,44 @@ 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"; + + if (!isDevE2eMock && !isAnnouncementDemo) { return; } const e2eWindow = window as E2eWindow; - e2eWindow.__BUZZ_E2E__ ??= { mode: "mock" }; + if (isAnnouncementDemo) { + e2eWindow.__BUZZ_E2E__ = { + mode: "mock", + mock: { + announcementDemo: true, + managedAgents: [ + { + pubkey: ANNOUNCEMENT_DEMO_AGENT.pubkey, + name: ANNOUNCEMENT_DEMO_AGENT.name, + systemPrompt: ANNOUNCEMENT_DEMO_AGENT.systemPrompt, + status: "running", + channelNames: [...ANNOUNCEMENT_DEMO_AGENT.channelNames], + respondTo: "owner-only", + }, + ], + }, + }; + } else { + e2eWindow.__BUZZ_E2E__ ??= { mode: "mock" }; + } const community = { addedAt: new Date().toISOString(), id: E2E_COMMUNITY_ID, - name: "E2E Test", + name: isAnnouncementDemo ? ANNOUNCEMENT_DEMO_COMMUNITY_NAME : "E2E Test", relayUrl: "ws://localhost:3000", }; window.localStorage.setItem("buzz-communities", JSON.stringify([community])); @@ -67,6 +97,25 @@ function configureDevE2eBridgeFromUrl() { `${ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX}${E2E_DEFAULT_PUBKEY}`, "true", ); + + if (isAnnouncementDemo) { + const relayStorageScope = encodeURIComponent("ws://localhost:3000"); + window.localStorage.setItem( + `${CHANNEL_SECTIONS_STORAGE_KEY_PREFIX}:${E2E_DEFAULT_PUBKEY}:${relayStorageScope}`, + JSON.stringify(ANNOUNCEMENT_DEMO_SECTION_STORE), + ); + window.localStorage.setItem( + `${SELF_PROFILE_STORAGE_KEY_PREFIX}:ws://localhost:3000:${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 +155,7 @@ async function installE2eBridgeIfConfigured() { async function bootstrap() { resetDevWebviewStateFromUrl(); - configureDevE2eBridgeFromUrl(); + configureMockBridgeFromUrl(); await installE2eBridgeIfConfigured(); await migrateLegacyCommunityStorageBeforeRender(); renderApp(); diff --git a/desktop/src/testing/announcementDemoFixtures.ts b/desktop/src/testing/announcementDemoFixtures.ts new file mode 100644 index 000000000..8f88c265c --- /dev/null +++ b/desktop/src/testing/announcementDemoFixtures.ts @@ -0,0 +1,1394 @@ +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_AGENT = { + pubkey: "70".repeat(32), + name: "Scout", + systemPrompt: + "You are Scout, a concise and thoughtful AI teammate at Honeycomb Studios. Help the team prepare the Buzz announcement, turn loose ideas into clear next steps, and keep replies conversational. In channels, answer in one to four sentences unless someone asks for more detail.", + channelNames: ["flight-path", "design", "marketing", "queen-bee-launch"], +} 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: "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; + +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", + "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"] }], + }, + ], + "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 000000000..809338781 --- /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 f9ae71999..8a9ad5813 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -49,6 +49,15 @@ import type { RawInstallRuntimeResult, } from "@/shared/api/tauri"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + ANNOUNCEMENT_DEMO_CHANNELS, + ANNOUNCEMENT_DEMO_DMS, + ANNOUNCEMENT_DEMO_MESSAGES, + ANNOUNCEMENT_DEMO_PEOPLE, + ANNOUNCEMENT_DEMO_PROJECT_SUBJECTS, + ANNOUNCEMENT_DEMO_PROJECTS, + type AnnouncementDemoPersonKey, +} from "@/testing/announcementDemoFixtures"; type TestIdentity = { privateKey: string; @@ -67,6 +76,7 @@ type MockManagedAgentSeed = { name: string; avatarUrl?: string | null; personaId?: string | null; + systemPrompt?: string | null; status?: RawManagedAgent["status"]; channelNames?: string[]; channelIds?: string[]; @@ -121,6 +131,7 @@ type MockSearchProfileSeed = { type E2eConfig = { mode?: "mock" | "relay"; mock?: { + announcementDemo?: boolean; acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[]; acpAuthMethods?: Record; connectAcpRuntimeResult?: RawConnectAcpRuntimeResult; @@ -323,6 +334,10 @@ type E2eConfig = { identity?: TestIdentity; }; +type MockGlobalAgentConfig = NonNullable< + NonNullable["globalAgentConfig"] +>; + type RawBlobDescriptor = { url: string; sha256: string; @@ -1016,6 +1031,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 +1052,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 +1099,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 +1351,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 +1903,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 +1933,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 +1970,54 @@ 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 entries = savedState + ? savedState.agents.map((saved) => ({ + agent: cloneSavedManagedAgent(saved.agent), + channelIds: saved.channelIds, + })) + : (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), + })); + + 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 +2025,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 = [ @@ -2543,6 +2683,120 @@ const mockSockets = new Map(); let mockWebsocketSendMutexWedged = false; const realSockets = new Map(); let mockManagedAgents: MockManagedAgent[] = []; +const pendingAnnouncementDemoAgentResponses = 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 +3154,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 +3602,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" ? [ @@ -3585,6 +4012,7 @@ function recordMockMessage(channelId: string, event: RelayEvent) { channel.last_message_at = new Date(event.created_at * 1_000).toISOString(); touchMockChannel(channel); + queueAnnouncementDemoAgentResponses(channelId, event); } function resetMockUserStatuses() { @@ -3725,6 +4153,253 @@ function emitMockTypingIndicator(channelId: string, pubkey: string) { return event; } +type AnnouncementDemoProviderConfig = { + provider: "anthropic" | "openai"; + apiKey: string; + model: string; +}; + +type AnnouncementDemoAgentReply = { + text?: string; + error?: string; +}; + +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; + if (!provider) { + throw new Error( + "Choose Anthropic or OpenAI and add its API key in agent settings.", + ); + } + + const model = ( + agent.model ?? + env.BUZZ_AGENT_MODEL ?? + env.GOOSE_MODEL ?? + env.OPENAI_COMPAT_MODEL ?? + mockGlobalAgentConfig.model ?? + "" + ).trim(); + if (!model) { + throw new Error("Choose a model in agent settings."); + } + + const apiKey = + provider === "openai" + ? env.OPENAI_COMPAT_API_KEY?.trim() || env.OPENAI_API_KEY?.trim() + : env.ANTHROPIC_API_KEY?.trim(); + if (!apiKey) { + throw new Error( + `Add an ${provider === "openai" ? "OpenAI" : "Anthropic"} API key in agent settings.`, + ); + } + + return { provider, apiKey, model }; +} + +function getAnnouncementDemoAgentTargets(channelId: string, event: RelayEvent) { + if (event.kind !== 9 || mockAgentPubkeys.has(normalizePubkey(event.pubkey))) { + 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(); + + return mockManagedAgents.filter((agent) => { + const pubkey = normalizePubkey(agent.pubkey); + const isActive = agent.status === "running" || agent.status === "deployed"; + return ( + isActive && (mentionedPubkeys.has(pubkey) || dmMemberPubkeys.has(pubkey)) + ); + }); +} + +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, +) { + 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"}`; + 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.`; +} + +async function requestAnnouncementDemoAgentReply( + channelId: string, + agent: MockManagedAgent, +) { + const provider = getAnnouncementDemoProviderConfig(agent); + const response = await fetch("/__announcement-demo/agent-response", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...provider, + systemPrompt: getAnnouncementDemoSystemPrompt(channelId, agent), + 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, +) { + const sourceThread = getThreadReferenceFromTags(sourceEvent.tags); + const tags = sourceThread.rootEventId + ? buildReplyMessageTags( + channelId, + agent.pubkey, + sourceEvent.id, + sourceThread.rootEventId, + [sourceEvent.pubkey], + ) + : buildTopLevelMessageTags(channelId, [sourceEvent.pubkey], agent.pubkey); + const event = createMockEvent( + 9, + content, + tags, + agent.pubkey, + Math.max(Math.floor(Date.now() / 1_000), sourceEvent.created_at + 1), + ); + recordMockMessage(channelId, event); + emitMockLiveEvent(channelId, event); +} + +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); + emitMockTypingIndicator(channelId, agent.pubkey); + const typingInterval = window.setInterval( + () => emitMockTypingIndicator(channelId, agent.pubkey), + 2_500, + ); + + void requestAnnouncementDemoAgentReply(channelId, agent) + .then(async (reply) => { + await new Promise((resolve) => window.setTimeout(resolve, 650)); + 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((error: unknown) => { + 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), + ); + }) + .finally(() => { + window.clearInterval(typingInterval); + pendingAnnouncementDemoAgentResponses.delete(responseKey); + persistAnnouncementDemoAgentState(); + }); + } +} + function toRawForumPost( event: RelayEvent, channelId: string, @@ -4595,8 +5270,18 @@ function buildMockProjectEvents(): RelayEvent[] { const daySeconds = 86_400; const now = Math.floor(Date.now() / 1000); const historyDays = 26 * 7; + 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 MOCK_PROJECT_SEEDS.entries()) { + 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 +5313,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 +6734,7 @@ async function handleAddChannelMembers( syncMockChannel(targetChannel); touchMockChannel(targetChannel); syncMockRelayAgentsFromManagedAgents(); + persistAnnouncementDemoAgentState(); return { added, errors, @@ -6093,6 +6777,7 @@ async function handleRemoveChannelMember( syncMockChannel(channel); touchMockChannel(channel); syncMockRelayAgentsFromManagedAgents(); + persistAnnouncementDemoAgentState(); return; } @@ -6191,8 +6876,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 +7997,7 @@ async function handleCreateManagedAgent( has_profile_event: true, }); syncMockRelayAgentsFromManagedAgents(); + persistAnnouncementDemoAgentState(); return { agent: cloneManagedAgent(managedAgent), @@ -7279,6 +8070,7 @@ async function handleStartManagedAgent( : `started mock harness at ${now}`, ); syncMockRelayAgentsFromManagedAgents(); + persistAnnouncementDemoAgentState(); return cloneManagedAgent(agent); } @@ -7294,6 +8086,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 +8111,7 @@ async function handleDeleteManagedAgent(args: { (candidate) => candidate.pubkey !== args.pubkey, ); syncMockRelayAgentsFromManagedAgents(); + persistAnnouncementDemoAgentState(); } async function handleSetManagedAgentStartOnAppLaunch(args: { @@ -7327,6 +8121,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 +8132,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 +8179,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 }; } @@ -8374,8 +9171,10 @@ export function maybeInstallE2eTauriMocks() { return; } + applyAnnouncementDemoFixtures(config); resetMockRelayMembers(config); resetMockRelayAgents(config); + resetMockGlobalAgentConfig(config); resetMockManagedAgents(config); resetMockPersonas(config); resetMockTeams(config); @@ -8385,7 +9184,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 +9430,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 +9465,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 +10220,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 +10246,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 +10709,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 000000000..ef9e96c05 --- /dev/null +++ b/desktop/tests/e2e/announcement-demo.spec.ts @@ -0,0 +1,143 @@ +import { expect, test } from "@playwright/test"; + +test("announcement demo loads its workspace, people, and projects", async ({ + page, +}) => { + 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."; + await page.route("**/__announcement-demo/agent-response", async (route) => { + await route.fulfill({ json: { text: agentReply } }); + }); + await page.goto("/?demo=announcement"); + await page.waitForFunction( + () => + typeof ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function", + ); + + 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", + }, + }); + }); + + 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); + + 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(); + + 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("@Sco"); + const agentMention = page + .getByTestId("message-composer") + .getByTestId("mention-autocomplete") + .locator("button", { hasText: "Scout" }); + 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 }); + + 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 bea570482..a0840ab59 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", + ), + } + : {}), }, },