fix(ui): dock the announcement card in the sidebar so it never covers the task thread (#19511)

## 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-06 12:19:46 -06:00
1 parent 35280516c0
commit a6c2280630
5 files changed
+209 -3

No files matched your search

+166
View File
@@ -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<Box> {
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);
});
});
@@ -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(<QueryClientProvider client={client}><AnnouncementWell health={health} placement="sidebar" /></QueryClientProvider>));
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);
+19 -2
View File
@@ -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 (
<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"
data-placement={placement}
className={cn(
"announcement-well",
placement === "sidebar"
? "shrink-0 overflow-y-auto px-3 pb-2 max-h-(--announcement-sidebar-max-height)"
: "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)",
)}
>
<AnnouncementCard key={announcement.id} announcement={announcement} onDismiss={() => dismiss(announcement.id)} />
</aside>
);
+7 -1
View File
@@ -656,6 +656,12 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
<Sidebar>{sidebarSections}</Sidebar>
)}
</div>
{/* Dock optional announcements in the sidebar column so they never
cover the task thread or composer. While the sidebar is a rail
the card waits until the sidebar is expanded again. */}
{sidebarOpen && (replacesPrimarySidebar || !collapsed) ? (
<AnnouncementWell health={health} placement="sidebar" />
) : null}
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
forceExpanded={replacesPrimarySidebar}
@@ -764,7 +770,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
<BoardDialogs />
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
<ToastViewport />
<AnnouncementWell health={health} />
{isMobile ? <AnnouncementWell health={health} placement="floating" /> : null}
<PluginAppShellOverlays localTrusted={health?.deploymentMode === "local_trusted"} />
</div>
</ChatSetupSidebarProvider>
+7
View File
@@ -3559,6 +3559,8 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--announcement-available-width: calc(100vw - var(--spacing) * 6);
--announcement-image-height: 8.5rem;
--announcement-image-mobile-height: 6rem;
/* Docked in the sidebar: the navigation keeps at least half the height. */
--announcement-sidebar-max-height: 50dvh;
--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);
@@ -3567,6 +3569,11 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
.announcement-well {
animation: announcement-enter var(--motion-duration-fast) var(--motion-ease-standard) both;
}
/* Docked in the narrow sidebar column: keep the image band compact so the
sidebar navigation keeps most of its height. */
.announcement-well[data-placement="sidebar"] {
--announcement-image-height: var(--announcement-image-mobile-height);
}
@keyframes announcement-enter {
from { opacity: 0; }
to { opacity: 1; }