From 05ba1f8a28329a62afd47d988013fb3bd76d8f70 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Nov 2026 11:02:57 -0600 Subject: [PATCH] fix(ui): keep announcements out of onboarding and off the account menu The connectors announcement appeared 3 s into a new user's onboarding first task and, pinned bottom-left over the sidebar, covered the account menu trigger and the left edge of the task composer (N-13). - Hide the announcement well while the company's onboarding first task (origin_kind onboarding_first_task) is not done or cancelled; unknown state keeps the optional card hidden. - On desktop, measure the sidebar account row and place the well above it so it never covers the account menu trigger. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/announcement-first-session.spec.ts | 131 ++++++++++++++++++ .../AnnouncementWell.onboarding.test.tsx | 63 +++++++++ ui/src/components/AnnouncementWell.test.tsx | 8 +- ui/src/components/AnnouncementWell.tsx | 76 +++++++++- ui/src/index.css | 6 +- ui/src/lib/queryKeys.ts | 2 + 6 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/announcement-first-session.spec.ts create mode 100644 ui/src/components/AnnouncementWell.onboarding.test.tsx diff --git a/tests/e2e/announcement-first-session.spec.ts b/tests/e2e/announcement-first-session.spec.ts new file mode 100644 index 00000000000..ba048b46fc5 --- /dev/null +++ b/tests/e2e/announcement-first-session.spec.ts @@ -0,0 +1,131 @@ +import { test, expect, request as pwRequest, type APIRequestContext, type Page } from "@playwright/test"; + +/** + * E2E (N-13): the in-app announcement well ("Introducing Connectors" upsell) + * + * 1. stays hidden while a new company's onboarding first task is open, so it + * cannot cover the onboarding questions in the user's first session; and + * 2. where it does render, never covers the sidebar account menu trigger or + * its flyout at a normal desktop size. + * + * The public feed is mocked so the spec does not depend on what the live feed + * is publishing; the card mirrors the real 2026-09-14-connectors entry. + */ + +const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199); +const BASE_URL = `http://127.0.0.1:${PORT}`; +const ANNOUNCEMENT_ID = "e2e-n13-connectors"; +const SETTLE_WAIT_MS = 4_500; // ANNOUNCEMENT_SETTLE_MS (3 s) plus slack. +const ONE_PIXEL_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", +); + +type Box = { x: number; y: number; width: number; height: number }; +function intersects(a: Box, b: Box) { + return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; +} + +async function createCompany(board: APIRequestContext, name: string) { + const res = await board.post(`${BASE_URL}/api/companies`, { data: { name: `${name}-${Date.now()}` } }); + if (!res.ok()) throw new Error(`POST /api/companies → ${res.status()}: ${await res.text()}`); + const company = await res.json(); + // A company without agents lands on "Create your first agent"; add one so the normal shell renders. + const agentRes = await board.post(`${BASE_URL}/api/companies/${company.id}/agents`, { + data: { name: "Chief of staff", role: "ceo", adapterType: "process", adapterConfig: { command: "true" } }, + }); + if (!agentRes.ok()) throw new Error(`POST agents → ${agentRes.status()}: ${await agentRes.text()}`); + return { id: company.id as string, prefix: (company.issuePrefix ?? company.prefix) as string }; +} + +async function createIssue(board: APIRequestContext, companyId: string, data: Record) { + const res = await board.post(`${BASE_URL}/api/companies/${companyId}/issues`, { data }); + if (!res.ok()) throw new Error(`POST issues → ${res.status()}: ${await res.text()}`); + return res.json() as Promise<{ id: string; identifier: string; originKind: string | null }>; +} + +async function mockAnnouncementFeed(page: Page) { + await page.route("**/api/announcements/current", (route) => route.fulfill({ json: { + id: ANNOUNCEMENT_ID, + eyebrow: "Introducing Connectors", + title: "Give your agents the access they need", + description: "Connect Gmail, GitHub, Notion, and more. Choose which agents get access, which accounts they use, and which actions they can take.", + image: { path: `assets/${"a".repeat(64)}.png`, alt: "Paperclip agents with their own sets of connected apps." }, + primaryAction: { kind: "route", label: "Explore connectors", path: "/apps" }, + } })); + await page.route(`**/api/announcements/${ANNOUNCEMENT_ID}/image`, (route) => + route.fulfill({ contentType: "image/png", body: ONE_PIXEL_PNG })); + await page.route(`**/api/announcements/${ANNOUNCEMENT_ID}/dismiss`, (route) => route.fulfill({ status: 204 })); + await page.route("**/api/workspace-preferences/me", (route) => route.fulfill({ json: { + userId: "local-board", coordinatorWorkspaceEnabled: false, updatedAt: null, + } })); + await page.addInitScript(() => { + for (const key of Object.keys(window.localStorage)) { + if (key.includes("announcement")) window.localStorage.removeItem(key); + } + }); +} + +test.describe("Announcement well (N-13)", () => { + let board: APIRequestContext; + const companyIds: string[] = []; + + test.beforeAll(async () => { + board = await pwRequest.newContext({ baseURL: BASE_URL }); + }); + + test.afterAll(async () => { + for (const id of companyIds) await board.delete(`${BASE_URL}/api/companies/${id}`).catch(() => {}); + await board.dispose(); + }); + + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockAnnouncementFeed(page); + }); + + test("a brand-new company with its onboarding task open does not show the upsell", async ({ page }) => { + const company = await createCompany(board, "E2E-N13-Onboarding"); + companyIds.push(company.id); + const firstTask = await createIssue(board, company.id, { + title: "Plan our launch", status: "todo", onboardingFirstTask: true, + }); + expect(firstTask.originKind).toBe("onboarding_first_task"); + + await page.goto(`/${company.prefix}/issues/${firstTask.identifier}`); + await expect(page.getByRole("button", { name: "Open account menu" })).toBeVisible(); + await page.waitForTimeout(SETTLE_WAIT_MS); + await page.screenshot({ path: test.info().outputPath("onboarding-task.png") }); + await expect(page.getByRole("complementary", { name: "Paperclip announcements" })).toHaveCount(0); + }); + + test("where the upsell shows, it does not cover the account menu or its flyout", async ({ page }) => { + const company = await createCompany(board, "E2E-N13-Established"); + companyIds.push(company.id); + await createIssue(board, company.id, { title: "Ordinary task", status: "todo" }); + + await page.goto(`/${company.prefix}/dashboard`); + const well = page.getByRole("complementary", { name: "Paperclip announcements" }); + await expect(well).toBeVisible({ timeout: 15_000 }); + await expect(well.getByRole("link", { name: "Explore connectors" })).toBeVisible(); + await page.waitForTimeout(300); // Let the entry animation finish before measuring. + await page.screenshot({ path: test.info().outputPath("established-company.png") }); + + const trigger = page.getByRole("button", { name: "Open account menu" }); + const wellBox = await well.boundingBox(); + const triggerBox = await trigger.boundingBox(); + expect(wellBox).not.toBeNull(); + expect(triggerBox).not.toBeNull(); + expect(intersects(wellBox!, triggerBox!), `well ${JSON.stringify(wellBox)} covers account trigger ${JSON.stringify(triggerBox)}`).toBe(false); + + // A real click lands on the trigger (Playwright fails if another element would receive it). + await trigger.click({ timeout: 5_000 }); + const flyout = page.getByRole("dialog", { name: "Account" }); + await expect(flyout).toBeVisible(); + const flyoutBox = await flyout.boundingBox(); + if (await well.isVisible()) { + const openWellBox = await well.boundingBox(); + expect(intersects(openWellBox!, flyoutBox!), "well covers the open account flyout").toBe(false); + } + }); +}); diff --git a/ui/src/components/AnnouncementWell.onboarding.test.tsx b/ui/src/components/AnnouncementWell.onboarding.test.tsx new file mode 100644 index 00000000000..d680841728e --- /dev/null +++ b/ui/src/components/AnnouncementWell.onboarding.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { HealthStatus } from "@/api/health"; +import { AnnouncementWell } from "./AnnouncementWell"; +import { announcementPreview } from "@/lib/announcement-preview"; + +// N-13: the connectors announcement must not appear in a new user's first +// session, i.e. while the company's onboarding first task is still open. +const state = vi.hoisted(() => ({ list: vi.fn() })); +vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: "alice", settled: true }) })); +vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company", loading: false }) })); +vi.mock("@/context/DialogContext", () => ({ useDialogState: () => ({ onboardingOpen: false }) })); +vi.mock("@/context/ToastContext", () => ({ useOptionalToastActions: () => null, useOptionalToastState: () => [] })); +vi.mock("@/api/issues", () => ({ issuesApi: { list: state.list } })); +vi.mock("@/hooks/useAnnouncement", () => ({ useAnnouncement: (options: { enabled: boolean }) => ({ + announcement: options.enabled ? announcementPreview : null, dismiss: vi.fn(), +}) })); +vi.mock("./AnnouncementCard", () => ({ AnnouncementCard: () =>

Announcement fixture

})); +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const firstTask = (status: string) => ({ id: "issue-1", companyId: "company", originKind: "onboarding_first_task", status }); + +describe("announcement well during onboarding", () => { + let container: HTMLDivElement; + let root: Root; + const health = { deploymentMode: "authenticated" } as HealthStatus; + const visible = () => Boolean(container.querySelector('[aria-label="Paperclip announcements"]')); + const render = async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => root.render()); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); + }; + beforeEach(() => { + state.list.mockReset(); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); + }); + afterEach(async () => { await act(async () => root.unmount()); container.remove(); }); + + it("stays hidden while the onboarding first task is open", async () => { + state.list.mockResolvedValue([firstTask("todo")]); + await render(); + expect(visible()).toBe(false); + }); + + it("shows once the onboarding first task is finished, or when the company never had one", async () => { + state.list.mockResolvedValue([firstTask("done")]); + await render(); + expect(visible()).toBe(true); + await act(async () => root.unmount()); root = createRoot(container); + state.list.mockResolvedValue([]); + await render(); + expect(visible()).toBe(true); + }); + + it("stays hidden when onboarding state cannot be read", async () => { + state.list.mockRejectedValue(new Error("offline")); + await render(); + expect(visible()).toBe(false); + }); +}); diff --git a/ui/src/components/AnnouncementWell.test.tsx b/ui/src/components/AnnouncementWell.test.tsx index ebf0a0fabff..0a4930cfebf 100644 --- a/ui/src/components/AnnouncementWell.test.tsx +++ b/ui/src/components/AnnouncementWell.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { HealthStatus } from "@/api/health"; import { AnnouncementWell } from "./AnnouncementWell"; @@ -18,6 +19,7 @@ vi.mock("@/hooks/useAnnouncement", () => ({ useAnnouncement: (options: { enabled state.hook(options); return { announcement: options.enabled ? announcementPreview : null, dismiss: state.dismiss }; } })); +vi.mock("@/api/issues", () => ({ issuesApi: { list: vi.fn().mockResolvedValue([]) } })); vi.mock("./AnnouncementCard", () => ({ AnnouncementCard: ({ onDismiss }: { onDismiss: () => void }) => })); (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -25,12 +27,16 @@ describe("announcement placement gates", () => { let container: HTMLDivElement; let root: Root; let health: HealthStatus; - const render = async () => { await act(async () => root.render()); }; + let client: QueryClient; + const render = async () => { await act(async () => root.render()); }; const visible = () => Boolean(container.querySelector('[aria-label="Paperclip announcements"]')); beforeEach(() => { Object.assign(state, { userId: "alice", settled: true, companyId: "company", loading: false, onboardingOpen: false, toasts: [] }); vi.clearAllMocks(); health = { deploymentMode: "authenticated" } as HealthStatus; + // Onboarding is finished (no open first task); onboarding gating is covered in AnnouncementWell.onboarding.test.tsx. + client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + client.setQueryData(["issues", "company", "onboarding-first-task"], []); container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(async () => { await act(async () => root.unmount()); container.remove(); }); diff --git a/ui/src/components/AnnouncementWell.tsx b/ui/src/components/AnnouncementWell.tsx index b71ce16f095..60488e60592 100644 --- a/ui/src/components/AnnouncementWell.tsx +++ b/ui/src/components/AnnouncementWell.tsx @@ -1,10 +1,14 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, type CSSProperties } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared"; import { useAccountIdentity } from "@/api/companies-query"; import type { HealthStatus } from "@/api/health"; +import { issuesApi } from "@/api/issues"; import { useCompany } from "@/context/CompanyContext"; import { useDialogState } from "@/context/DialogContext"; import { useOptionalToastActions, useOptionalToastState } from "@/context/ToastContext"; import { useAnnouncement } from "@/hooks/useAnnouncement"; +import { queryKeys } from "@/lib/queryKeys"; import { AnnouncementCard } from "./AnnouncementCard"; // Includes the command palette, sheets and dialogs created outside DialogContext. @@ -22,16 +26,77 @@ function useModalOpen(enabled: boolean) { return open; } +const FINISHED_ONBOARDING_STATUSES = new Set(["done", "cancelled"]); + +/** + * A new user's first session is the onboarding first task. Announcements wait + * until that task is finished so they cannot cover its questions. Unknown + * state (loading or a failed read) keeps the optional card hidden. + */ +function useOnboardingInProgress(companyId: string | null) { + const query = useQuery({ + queryKey: queryKeys.issues.onboardingFirstTask(companyId ?? "__none__"), + queryFn: ({ signal }) => issuesApi.list(companyId!, { originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND, limit: 1 }, { signal }), + enabled: Boolean(companyId), + staleTime: 60_000, + }); + if (!companyId) return false; + if (!query.isSuccess) return true; + return query.data.some((issue) => issue.originKind === ONBOARDING_FIRST_TASK_ORIGIN_KIND && !FINISHED_ONBOARDING_STATUSES.has(issue.status)); +} + +const ACCOUNT_ROW_SELECTOR = "[data-sidebar-account]"; +/** + * Distance in CSS pixels from the viewport bottom to the top of the visible + * sidebar account row, so the desktop well can sit above the account menu + * trigger instead of covering it. Zero when no account row is on screen. + */ +function useAccountRowClearance(enabled: boolean) { + const [clearance, setClearance] = useState(0); + useEffect(() => { + if (!enabled) return; + let frame = 0; + const observed = new Set(); + const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => schedule()); + function measure() { + let next = 0; + for (const row of document.querySelectorAll(ACCOUNT_ROW_SELECTOR)) { + if (resizeObserver && !observed.has(row)) { observed.add(row); resizeObserver.observe(row); } + const rect = row.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0 || rect.right <= 0 || rect.left >= window.innerWidth) continue; + next = Math.max(next, Math.ceil(window.innerHeight - rect.top)); + } + setClearance(next); + } + function schedule() { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(measure); + } + measure(); + const mutationObserver = new MutationObserver(schedule); + mutationObserver.observe(document.body, { childList: true, subtree: true }); + window.addEventListener("resize", schedule); + return () => { + cancelAnimationFrame(frame); + mutationObserver.disconnect(); + resizeObserver?.disconnect(); + window.removeEventListener("resize", schedule); + }; + }, [enabled]); + return clearance; +} + export function AnnouncementWell({ health }: { health?: HealthStatus }) { const { userId: accountId, settled } = useAccountIdentity(); const { selectedCompanyId, loading } = useCompany(); const { onboardingOpen } = useDialogState(); const toastActions = useOptionalToastActions(); const toasts = useOptionalToastState(); + const onboardingInProgress = useOnboardingInProgress(selectedCompanyId); const userId = health?.deploymentMode === "local_trusted" ? "local-board" : (settled ? accountId : null); const { announcement, dismiss } = useAnnouncement({ userId, companyId: selectedCompanyId, - enabled: Boolean(userId && !loading && selectedCompanyId && !onboardingOpen), + enabled: Boolean(userId && !loading && selectedCompanyId && !onboardingOpen && !onboardingInProgress), onSaveFailure: (savedLocally) => toastActions?.pushToast({ title: savedLocally ? "Dismissed in this browser" : "Dismissed for this visit", body: "Couldn’t save across devices. We’ll retry when you reconnect or return.", @@ -39,9 +104,14 @@ export function AnnouncementWell({ health }: { health?: HealthStatus }) { }), }); const modalOpen = useModalOpen(Boolean(announcement)); + const accountClearance = useAccountRowClearance(Boolean(announcement)); if (!announcement || modalOpen || (toasts?.length ?? 0) > 0) return null; return ( -