From a6c22806308e8a3c7b21145f80fb471756bb443d Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:19:46 -0600 Subject: [PATCH] fix(ui): dock the announcement card in the sidebar so it never covers the task thread (#19511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A new user lands on their first task after onboarding. The task thread and the composer are the core of that screen. > - The app shows an optional in-app announcement (now "Introducing Connectors"). On desktop the card floats at the bottom-left, 22rem wide, but the sidebar is only 240px wide. > - In the Nov 5 cloud onboarding test, the tester said: "onboarding popup blocks core real estate". > - At 1280x800 the card covers the left part of the composer. At 1440x900 and 1280x800 it covers the task thread area and the sidebar account menu. > - This pull request docks the card inside the desktop sidebar column, in normal layout flow, above the account menu. > - The benefit is that the card stays visible and dismissible, but it can never cover the thread or the composer. ## Linked Issues or Issue Description **What happened?** On a new company's first task, the "Introducing Connectors" announcement card floats over the task thread. At 1280x800 it covers the left part of the composer. **Steps to reproduce** 1. Create a new company and open its first task at 1280x800. 2. Wait 3 seconds for the announcement card to show. 3. See the card on top of the composer and the account menu. **Expected behavior** Optional announcements do not cover the task thread or the composer. **Evidence** The staging test company (Claude subscription path) has a local dismissal for `2026-09-14-connectors`. The live feed at `pages.paperclip.ing/announcements/v1/current.json` serves that announcement. No other first-use overlay showed on the first task or the dashboard. Toasts and the task-detail error notice are transient and are not first-use popups. ## What Changed - `AnnouncementWell` has a `placement` prop: `sidebar` (in-flow, no `fixed`) or `floating` (the earlier fixed placement). - `Layout` renders the `sidebar` placement inside the desktop `SidebarShell`, above the account menu. It renders the `floating` placement only on mobile, where it stays above the bottom nav. - While the desktop sidebar is collapsed to a rail or closed, the card waits. It shows again when the sidebar is expanded. - In the sidebar, the card is capped at half the window height (`--announcement-sidebar-max-height: 50dvh`) and scrolls. It uses the existing compact image height token, so the sidebar navigation keeps most of its height. - New e2e spec `tests/e2e/announcement-placement.spec.ts`. It makes a new company with a team lead and a first task. It serves the live connectors announcement through the app's announcement API. At 1440x900 and 1280x800 it checks that the card does not overlap the composer or `#main-content`, that the card is fully in the viewport and dismissible, and that it does not overlap the account menu trigger and the Account dialog opens. - Unit test for the two placements in `AnnouncementWell.test.tsx`. **Product decision for Dotta:** this PR does not hide announcements on first use. Another option is to hold all announcements until the user has used the product for some time (for example, after the first task is done or after the first day). That is a one-line gate in `AnnouncementWell`'s `enabled` condition, if we want it. ## Verification Red on `master` (819420a60a), same spec, before the fix: ``` [1/1] [chromium] › tests/e2e/announcement-placement.spec.ts › Announcement placement on a new company's first task › the announcement does not cover the task thread or composer Error: 1440x900: announcement overlaps task thread Expected: 0 Received: 42904 Error: 1280x800: announcement overlaps composer Expected: 0 Received: 8568 Error: 1280x800: announcement overlaps task thread Expected: 0 Received: 42904 1 failed ``` The spec also checks the account menu (requested by the N-13 connectors-upsell fix). Red on `master` with the same spec: ``` Error: 1440x900: announcement overlaps task thread Received: 42904 Error: 1440x900: announcement overlaps account menu Received: 5312 Error: locator.click: Test ended. (the card intercepts the "Open account menu" click) ``` Green after the fix: ``` [1/1] [chromium] › tests/e2e/announcement-placement.spec.ts › Announcement placement on a new company's first task › the announcement does not cover the task thread or composer 1 passed (45.0s) ``` Review follow-up (Greptile/Ponytail: a long announcement in a 208px sidebar had no height cap). A second spec uses a 400-character description, a 208px sidebar, and a 1280x720 window. Red without the cap: ``` Error: docked card keeps at most half the window height Received: 732 Error: expect(locator).toBeInViewport() failed (sidebar "Tasks" link) Received: viewport ratio 0 Error: expect(locator).toBeInViewport() failed (account menu) Received: viewport ratio 0 ``` The spec sets the sidebar width key for both the classic and the Coordinator workspace, and asserts the rendered width is 208px. Green with the cap (both specs): ``` 2 passed (57.8s) ``` Neighbouring suites: ``` cd ui && npx vitest run src/components/AnnouncementWell.test.tsx src/components/AnnouncementCard src/hooks/useAnnouncement.test.tsx src/components/Layout.test.tsx Test Files 4 passed (4) Tests 57 passed (57) ``` - `pnpm check:token-gates`: All gates clean. - `pnpm --filter @paperclipai/ui typecheck`: pass. - Screenshots: the spec writes `announcement-1440x900.png` and `announcement-1280x800.png` to its test output. Before: the card (352px wide) sits over the composer at x 296–364 and over the account menu. After: the card is 216px wide inside the sidebar, above the account menu. The thread and composer are clear. Run it: ``` npx playwright test --config tests/e2e/playwright.config.ts tests/e2e/announcement-placement.spec.ts ``` ## Risks - Low risk. The card now takes vertical space in the sidebar while it shows. The sidebar navigation scrolls as before. The card goes away when the user dismisses it. - Users who keep the desktop sidebar collapsed do not see the announcement until they expand the sidebar. - Mobile behavior does not change. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with tool use (shell, browser, Playwright). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- tests/e2e/announcement-placement.spec.ts | 166 ++++++++++++++++++++ ui/src/components/AnnouncementWell.test.tsx | 10 ++ ui/src/components/AnnouncementWell.tsx | 21 ++- ui/src/components/Layout.tsx | 8 +- ui/src/index.css | 7 + 5 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/announcement-placement.spec.ts diff --git a/tests/e2e/announcement-placement.spec.ts b/tests/e2e/announcement-placement.spec.ts new file mode 100644 index 00000000000..3cb0a41ca85 --- /dev/null +++ b/tests/e2e/announcement-placement.spec.ts @@ -0,0 +1,166 @@ +import { test, expect, type APIRequestContext, type APIResponse, type Locator, type Page } from "@playwright/test"; + +/** + * N-17: a new user's first task view must stay usable while the in-app + * announcement card is showing. The card is optional product news; it must not + * sit on top of the task thread or the composer at ordinary laptop sizes. + * + * The announcement feed is remote, so this spec serves the live + * `2026-09-14-connectors` announcement through the app's own announcement API. + */ + +const CONNECTORS_ANNOUNCEMENT = { + id: "2026-09-14-connectors", + 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/59dd6169b4f7a0e3af527801853abac554e80c59519f556c9d234385682a65ee.png", + alt: "Paperclip agents with their own sets of connected apps.", + }, + primaryAction: { kind: "route", label: "Explore connectors", path: "/apps" }, +}; + +// 1x1 PNG so the card renders its image band at full height. +const PIXEL_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", + "base64", +); + +const VIEWPORTS = [ + { width: 1440, height: 900 }, + { width: 1280, height: 800 }, +] as const; + +async function json(response: APIResponse) { + const text = await response.text(); + expect(response.ok(), `${response.url()}: ${response.status()} ${text}`).toBe(true); + return JSON.parse(text); +} + +type Box = { x: number; y: number; width: number; height: number }; + +function overlapArea(a: Box, b: Box) { + const width = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x); + const height = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y); + return width > 0 && height > 0 ? Math.round(width * height) : 0; +} + +async function box(locator: Locator, name: string): Promise { + const value = await locator.boundingBox(); + expect(value, `${name} should have a layout box`).not.toBeNull(); + return value!; +} + +async function serveConnectorsAnnouncement(page: Page, announcement: object = CONNECTORS_ANNOUNCEMENT) { + await page.route("**/api/announcements/current", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(announcement) }), + ); + await page.route(`**/api/announcements/${CONNECTORS_ANNOUNCEMENT.id}/image`, (route) => + route.fulfill({ status: 200, contentType: "image/png", body: PIXEL_PNG }), + ); + await page.route(`**/api/announcements/${CONNECTORS_ANNOUNCEMENT.id}/dismiss`, (route) => + route.fulfill({ status: 204 }), + ); +} + +async function firstTask(request: APIRequestContext, name: string) { + const company = await json(await request.post("/api/companies", { data: { name: `${name} ${Date.now()}` } })); + const lead = await json( + await request.post(`/api/companies/${company.id}/agents`, { + data: { + name: "Team Lead", + role: "ceo", + adapterType: "process", + adapterConfig: { command: process.execPath, args: ["-e", "console.log('ok')"], env: { NODE_OPTIONS: "" } }, + runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } }, + }, + }), + ); + const issue = await json( + await request.post(`/api/companies/${company.id}/issues`, { + data: { title: "Paperclip onboarding", status: "backlog", assigneeAgentId: lead.id }, + }), + ); + return { company, issue }; +} + +test.describe("Announcement placement on a new company's first task", () => { + test("the announcement does not cover the task thread or composer", async ({ page, request }, testInfo) => { + const { company, issue } = await firstTask(request, "Announcement Placement"); + await serveConnectorsAnnouncement(page); + + for (const viewport of VIEWPORTS) { + await page.setViewportSize(viewport); + await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`); + + const composer = page.getByTestId("task-chat-composer-dock"); + await expect(composer).toBeVisible({ timeout: 30_000 }); + const announcement = page.getByRole("complementary", { name: "Paperclip announcements" }); + await expect(announcement.getByRole("heading", { name: CONNECTORS_ANNOUNCEMENT.title })).toBeVisible({ + timeout: 15_000, + }); + // Let the entrance animation settle before measuring. + await page.waitForTimeout(500); + + const label = `${viewport.width}x${viewport.height}`; + await page.screenshot({ path: testInfo.outputPath(`announcement-${label}.png`) }); + + const card = await box(announcement, "announcement"); + const thread = await box(page.locator("#main-content"), "task thread"); + const composerBox = await box(composer, "composer"); + expect.soft(overlapArea(card, composerBox), `${label}: announcement overlaps composer`).toBe(0); + expect.soft(overlapArea(card, thread), `${label}: announcement overlaps task thread`).toBe(0); + // The card stays fully readable and dismissible. + expect.soft(card.y + card.height, `${label}: announcement is clipped`).toBeLessThanOrEqual(viewport.height); + await expect.soft(announcement.getByRole("button", { name: "Dismiss announcement" })).toBeInViewport(); + await expect.soft(announcement.getByRole("link", { name: "Explore connectors" })).toBeInViewport(); + + // The account menu stays reachable while the card shows. + const accountTrigger = page.getByRole("button", { name: "Open account menu" }); + const accountBox = await box(accountTrigger, "account menu trigger"); + expect.soft(overlapArea(card, accountBox), `${label}: announcement overlaps account menu`).toBe(0); + await accountTrigger.click(); + await expect.soft(page.getByRole("dialog", { name: "Account" })).toBeVisible(); + await page.keyboard.press("Escape"); + } + }); + + test("a long announcement docked in a narrow sidebar keeps navigation and the account menu usable", async ({ page, request }) => { + const { company, issue } = await firstTask(request, "Announcement Long"); + const long = { + ...CONNECTORS_ANNOUNCEMENT, + title: "Give your agents the access they need to finish real work across every tool your team uses daily", + description: "Connect Gmail, GitHub, Notion, and more. ".repeat(10).slice(0, 400).trim(), + }; + await serveConnectorsAnnouncement(page, long); + // The narrowest supported sidebar width on a short laptop window. + // Classic and Coordinator workspaces persist the width under different keys. + await page.addInitScript(() => { + localStorage.setItem("paperclip.sidebar.width", "208"); + localStorage.setItem("paperclip.coordinator.sidebar-width", "208"); + }); + const viewport = { width: 1280, height: 720 }; + await page.setViewportSize(viewport); + await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`); + + const announcement = page.getByRole("complementary", { name: "Paperclip announcements" }); + await expect(announcement.getByRole("heading", { name: long.title })).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(500); + + const card = await box(announcement, "announcement"); + // The docked well spans the sidebar column, so its width is the sidebar width. + expect(Math.round(card.width), "sidebar renders at its 208px minimum").toBe(208); + expect.soft(card.height, "docked card keeps at most half the window height").toBeLessThanOrEqual(viewport.height / 2 + 1); + await expect.soft(page.getByLabel("Workspace navigation").getByRole("link", { name: "Tasks" })).toBeInViewport(); + const accountTrigger = page.getByRole("button", { name: "Open account menu" }); + await expect.soft(accountTrigger).toBeInViewport(); + expect.soft(overlapArea(card, await box(accountTrigger, "account menu trigger"))).toBe(0); + // The card scrolls, so its actions stay reachable. + const action = announcement.getByRole("link", { name: "Explore connectors" }); + await action.scrollIntoViewIfNeeded(); + await expect.soft(action).toBeInViewport(); + expect.soft(overlapArea(await box(action, "announcement action"), card)).toBeGreaterThan(0); + }); +}); diff --git a/ui/src/components/AnnouncementWell.test.tsx b/ui/src/components/AnnouncementWell.test.tsx index 0a4930cfebf..f8643880f43 100644 --- a/ui/src/components/AnnouncementWell.test.tsx +++ b/ui/src/components/AnnouncementWell.test.tsx @@ -49,6 +49,16 @@ describe("announcement placement gates", () => { health = { deploymentMode: "local_trusted" } as HealthStatus; await render(); expect(visible()).toBe(true); expect(state.hook).toHaveBeenLastCalledWith(expect.objectContaining({ userId: "local-board", enabled: true })); }); + it("docks in normal flow for the sidebar and floats only when asked", async () => { + await act(async () => root.render()); + const docked = container.querySelector('[aria-label="Paperclip announcements"]')!; + expect(docked.getAttribute("data-placement")).toBe("sidebar"); + expect(docked.className).not.toMatch(/\bfixed\b/); + await render(); + const floating = container.querySelector('[aria-label="Paperclip announcements"]')!; + expect(floating.getAttribute("data-placement")).toBe("floating"); + expect(floating.className).toMatch(/\bfixed\b/); + }); it("yields to toasts and modal/command dialogs without dismissing", async () => { await render(); expect(visible()).toBe(true); state.toasts = [{}]; await render(); expect(visible()).toBe(false); diff --git a/ui/src/components/AnnouncementWell.tsx b/ui/src/components/AnnouncementWell.tsx index d5d263539a0..ef5aae502e7 100644 --- a/ui/src/components/AnnouncementWell.tsx +++ b/ui/src/components/AnnouncementWell.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared"; +import { cn } from "@/lib/utils"; import { useAccountIdentity } from "@/api/companies-query"; import type { HealthStatus } from "@/api/health"; import { issuesApi } from "@/api/issues"; @@ -45,7 +46,14 @@ function useOnboardingInProgress(companyId: string | null) { return query.data.some((issue) => issue.originKind === ONBOARDING_FIRST_TASK_ORIGIN_KIND && !FINISHED_ONBOARDING_STATUSES.has(issue.status)); } -export function AnnouncementWell({ health }: { health?: HealthStatus }) { +/** + * Where the card lives. `sidebar` docks it in the desktop sidebar column, in + * normal layout flow, so it never covers the task thread or composer. + * `floating` keeps the mobile placement above the bottom navigation. + */ +export type AnnouncementPlacement = "sidebar" | "floating"; + +export function AnnouncementWell({ health, placement = "floating" }: { health?: HealthStatus; placement?: AnnouncementPlacement }) { const { userId: accountId, settled } = useAccountIdentity(); const { selectedCompanyId, loading } = useCompany(); const { onboardingOpen } = useDialogState(); @@ -65,7 +73,16 @@ export function AnnouncementWell({ health }: { health?: HealthStatus }) { const modalOpen = useModalOpen(Boolean(announcement)); if (!announcement || modalOpen || (toasts?.length ?? 0) > 0) return null; return ( -