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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-06 11:02:57 -06:00
1 parent 819420a60a
commit 05ba1f8a28
6 files changed
+281 -5

No files matched your search

@@ -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<string, unknown>) {
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);
}
});
});
@@ -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: () => <p>Announcement fixture</p> }));
(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(<QueryClientProvider client={client}><AnnouncementWell health={health} /></QueryClientProvider>));
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);
});
});
+7 -1
View File
@@ -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 }) => <button onClick={onDismiss}>Dismiss fixture</button> }));
(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(<AnnouncementWell health={health} />)); };
let client: QueryClient;
const render = async () => { await act(async () => root.render(<QueryClientProvider client={client}><AnnouncementWell health={health} /></QueryClientProvider>)); };
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(); });
+73 -3
View File
@@ -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<Element>();
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 (
<aside aria-label="Paperclip announcements" className="announcement-well fixed left-3 bottom-(--announcement-mobile-bottom) z-40 w-(--announcement-available-width) max-w-(--announcement-width) max-h-(--announcement-mobile-max-height) overflow-y-auto md:bottom-3 md:max-h-(--announcement-max-height)">
<aside
aria-label="Paperclip announcements"
style={{ "--announcement-account-clearance": accountClearance } as CSSProperties}
className="announcement-well fixed left-3 bottom-(--announcement-mobile-bottom) z-40 w-(--announcement-available-width) max-w-(--announcement-width) max-h-(--announcement-mobile-max-height) overflow-y-auto md:bottom-(--announcement-desktop-bottom) md:max-h-(--announcement-desktop-max-height)"
>
<AnnouncementCard key={announcement.id} announcement={announcement} onDismiss={() => dismiss(announcement.id)} />
</aside>
);
+5 -1
View File
@@ -3560,11 +3560,15 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--announcement-image-height: 8.5rem;
--announcement-image-mobile-height: 6rem;
--announcement-mobile-bottom: calc(var(--spacing) * 19 + env(safe-area-inset-bottom, 0px));
--announcement-max-height: calc(100dvh - var(--spacing) * 6);
--announcement-mobile-max-height: calc(100dvh - var(--announcement-mobile-bottom) - var(--spacing) * 3);
}
.announcement-well {
/* On desktop the well sits above the sidebar account row (measured by
AnnouncementWell as a unitless pixel count) so it never covers the
account menu trigger or its flyout. */
--announcement-desktop-bottom: calc(var(--announcement-account-clearance, 0) * 1px + var(--spacing) * 3);
--announcement-desktop-max-height: calc(100dvh - var(--announcement-desktop-bottom) - var(--spacing) * 3);
animation: announcement-enter var(--motion-duration-fast) var(--motion-ease-standard) both;
}
@keyframes announcement-enter {
+2
View File
@@ -311,6 +311,8 @@ export const queryKeys = {
recoveryActions: (id: string, actionId: string | null, runId: string | null) =>
["issues", id, "recovery-actions", actionId, runId] as const,
list: (companyId: string) => ["issues", companyId] as const,
onboardingFirstTask: (companyId: string) =>
["issues", companyId, "onboarding-first-task"] as const,
mentionPool: (companyId: string) =>
["issues", companyId, "mention-pool"] as const,
search: (