From 56517dda6c5b32bc901b7dbf1605eaaa041c5fba Mon Sep 17 00:00:00 2001 From: Tonio Date: Thu, 5 Nov 2026 01:23:53 -0800 Subject: [PATCH] fix(ui): bundle connector, toolbar, empty-state and agent roster fixes (#18716) 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. > - Operators use connector setup, collection headers, empty states, and agent lists to manage that work. > - Provider redirects replace the setup page, and collection controls use different sizes. > - Agent selection inserts a bar above the list. The row link highlights only part of the row. > - This pull request keeps sign-in in a separate window and applies the Tasks control sizes to collection and audit headers. > - It also reuses artifact artwork for empty collections and gives agent selection a fixed action card. > - These changes preserve the operator's place and keep controls and rows stable. ## Linked Issues or Issue Description **What happened?** Connector sign-in replaced the current page. Inline collection controls had different heights. Projects and Artifacts used small placeholder empty states. Agent selection moved the list, hover covered only the identity cell, and chat/status shared that cell without matching headings. **Expected behavior** Provider sign-in opens in a new tab or window. Collection controls match Tasks. Empty collections use the large artifact graphics. Agent actions float above the page while rows remain aligned. **Steps to reproduce** Start connector sign-in from a page or dialog. Compare collection and Audit headers. Open an empty Projects or Artifacts collection. Select an agent, then hover across its row. Related public work: #13584 covers an earlier connector new-tab implementation, including same-page fallbacks that this bundle does not use. #8528 covers Tasks toggle sizing. #17742 covers scoped agent Audit sections. This bundle does not change server callback or audit authorization contracts. ## What Changed - Reserve a separate authorization window during the click. Use validated sign-in URLs, retain a new-tab fallback, and close blank windows after failed requests. Store Cloud reauthentication state in the destination window. - Use the Tasks 44px control foundation for shared collection headers and Audit, Costs, Timeline, Secrets, Teams, Gateways, and agent skill controls. - Use a large artifact stack with an off-white Paperclip image with a dark icon for Projects and artifact Tasks empty states. Use one matching artifact for other types. Set 20px gaps and use the label “Create project”. Action hover and keyboard focus animate the artwork. - Portal agent bulk actions to a fixed card 48px above the bottom safe area. Use quintic entry/exit motion and respect reduced motion. Retain card contents during exit. - Give chat and status separate cells, add a Status heading, use the MessagesSquare chat action with a hover tooltip, and highlight the entire row. - Add linked agent chips and a manager picker to the agent profile. Block self and descendant manager choices. Keep the name crown and custom title inline during edits. - Update design guidance, the connection maps, regression tests, and empty-state Storybook fixtures. ## Verification - Final commit `a41a6c6494a839deaaec1ef7cc50c90078ab6381`: all 55 checks passed, three skipped. [Canonical CI run](https://github.com/paperclipai/paperclip/actions/runs/41999792769) includes all eight browser shards and all server/runner/workspace groups. Greptile: 5/5, zero unresolved review findings. - Workspace typecheck/build passed. Local UI suite passed 11,006 tests in 880 files, plus 542 CLI tests. Subsequent connector callback changes passed all 245 AppsConnect tests, UI typecheck, token gates, and query-error policy checks. Manager cache/error regressions passed four tests. Map checks passed. - Local connector browser suite: 20 passed. All six chat integration shards: 1,931 passed. Workspace B groups passed; database tests passed 313 cases across 72 files with one worker. - Browser preview verified agent manager changes, overview links, inline name/title geometry, empty-state colors, row actions and the chat tooltip. Live provider consent and post-deployment feature verification remain separate from these fixtures. - Preserved failures: ten bulk-roster tests initially missed the portaled action card; their selectors were fixed and all twelve roster tests passed. A nested fixture selected pnpm 11 and removed local dependencies; the local shim now pins pnpm 9. Embedded PostgreSQL native links were restored. A Python 3.9 fixture failure passed all 19 tests with the current runtime. Concurrent local database startup failures passed on the one-worker rerun. - The broad local serialized server run passed 3,791 tests with one scheduling assertion failure, which passed in isolation. The local general-server run reproduced a native recovery provider/model fixture failure and has not completed. These server sources are unchanged by this bundle; all final canonical CI server shards passed. This is not a claim that every broad local run passed. - [Earlier CI run](https://github.com/paperclipai/paperclip/actions/runs/41995119880) failed a browser test during cleanup when heartbeat-run cancellation hit a PostgreSQL lock timeout. All final browser shards passed without weakening that test. - Review follow-ups cover Slack popup/fallback recovery, a single artwork hover transform, shared manager error rendering, detached standalone OAuth windows, and verified task-bound callbacks in both hosts. ## Risks - Browser popup policy and provider callback behavior can affect sign-in completion. The original setup page stays open; blocked windows have an explicit recovery path. - Shared toolbar styles affect several surfaces. Menu content remains in its existing portals; canonical label tabs and search clear buttons retain their own geometry. - No schema, server, API authorization, or migration change. - Required CI and review are complete. Deployment provenance and hosted feature verification are checked separately after merge. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository tools, test execution, and browser inspection. The exact model ID and context-window size were not exposed in this session. ## 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 relevant tests locally and they pass (broader local exceptions are recorded above) - [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 --------- Co-authored-by: Paperclip --- DESIGN.md | 27 +++- maps/feature-map/connection-setup.md | 4 +- tests/e2e/chat-adapters-ui-providers.spec.ts | 22 ++- tests/e2e/project-repositories.spec.ts | 4 +- .../project-repository-skills.spec.ts | 7 +- .../storybook-visual/toolbars-design.spec.ts | 4 +- ui/src/components/CollectionToolbar.tsx | 1 + ui/src/components/EmptyState.tsx | 10 +- ui/src/components/InlineEditor.tsx | 13 +- ui/src/components/ProjectsToolbar.tsx | 2 +- ui/src/components/ReportsToPicker.tsx | 16 ++- .../agents/AgentBulkRoster.test.tsx | 8 +- ui/src/components/agents/AgentBulkRoster.tsx | 37 +++-- .../agents/AgentBulkSettingsSheet.tsx | 4 +- .../components/agents/AgentIdentityChip.tsx | 8 ++ .../artifacts/v1/ArtifactEmptyGraphic.tsx | 12 ++ .../components/artifacts/v1/ArtifactFront.tsx | 2 +- .../artifacts/v1/ArtifactTile.test.tsx | 2 + .../artifacts/v1/assets/empty-brand.svg | 1 + .../coordinator/CoordinatorNeedsYou.tsx | 2 +- .../connections/ConnectionSetupFlow.tsx | 133 ++++++++++-------- .../remote-mcp/RemoteMcpProductionSetup.tsx | 75 ++++++++-- ui/src/hooks/useAuthorizationWindow.test.tsx | 78 ++++++++++ ui/src/hooks/useAuthorizationWindow.tsx | 46 ++++++ ui/src/index.css | 106 +++++++++++--- ui/src/lib/oauthWindowOutcome.ts | 26 ++++ ui/src/pages/AgentDetail.tsx | 8 +- ui/src/pages/Agents.production.tsx | 34 ++--- ui/src/pages/Agents.test.tsx | 10 +- ui/src/pages/Agents.tsx | 48 ++++--- ui/src/pages/Artifacts.tsx | 6 +- ui/src/pages/CompanySkills.production.tsx | 4 +- ui/src/pages/Costs.tsx | 4 +- ui/src/pages/Projects.tsx | 5 +- ui/src/pages/Secrets.tsx | 6 +- ui/src/pages/TeamCatalog.tsx | 2 +- ui/src/pages/Timeline.tsx | 4 +- .../pages/agent-detail/AgentDirectReports.tsx | 15 +- .../AgentReportingManager.test.tsx | 65 +++++++++ .../agent-detail/AgentReportingManager.tsx | 64 +++++++++ ui/src/pages/agent-skills/AgentSkillsTab.tsx | 2 +- ui/src/pages/apps/AppDetail.test.tsx | 12 +- ui/src/pages/apps/AppDetail.tsx | 35 +++-- ui/src/pages/apps/AppsConnect.test.tsx | 127 +++++++++++++---- ui/src/pages/apps/Connections.tsx | 20 +-- .../pages/apps/app-detail/AdvancedPanel.tsx | 19 ++- ui/src/pages/apps/chat/GitHubChatSetup.tsx | 12 +- ui/src/pages/apps/chat/SlackToolSettings.tsx | 14 +- .../chat/slack/ManagedSlackSetup.test.tsx | 36 ++++- .../apps/chat/slack/ManagedSlackSetup.tsx | 63 ++++++++- .../apps/chat/slack/OwnSlackAppSetup.tsx | 9 +- ui/src/pages/apps/gateways/GatewaysList.tsx | 4 +- ui/src/pages/audit/AgentCommentary.tsx | 2 +- ui/src/pages/audit/AuditFeed.tsx | 6 +- ui/src/pages/audit/AuditRuns.tsx | 2 +- ui/src/pages/tools/AuditTab.tsx | 2 +- ui/src/pages/tools/PasteConfigTab.test.tsx | 61 +++++++- ui/src/pages/tools/PasteConfigTab.tsx | 61 ++++++-- .../artifact-v1-primitives.stories.tsx | 32 ++++- .../design-proposals/ToolbarsPreview.tsx | 4 +- 60 files changed, 1123 insertions(+), 325 deletions(-) create mode 100644 ui/src/components/agents/AgentIdentityChip.tsx create mode 100644 ui/src/components/artifacts/v1/ArtifactEmptyGraphic.tsx create mode 100644 ui/src/components/artifacts/v1/assets/empty-brand.svg create mode 100644 ui/src/hooks/useAuthorizationWindow.test.tsx create mode 100644 ui/src/hooks/useAuthorizationWindow.tsx create mode 100644 ui/src/lib/oauthWindowOutcome.ts create mode 100644 ui/src/pages/agent-detail/AgentReportingManager.test.tsx create mode 100644 ui/src/pages/agent-detail/AgentReportingManager.tsx diff --git a/DESIGN.md b/DESIGN.md index 3099f7bfa28..026e1f6b633 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -951,7 +951,7 @@ Standard. Artifact type filters also support Compact for dense standalone uses. patterns. Tabs contains only Playground, Standard and Compact. Playground compares the two sizes with local fixture state; narrow width remains a control. -Accepted toolbar foundation for Design Proposals and the opt-in sandbox: +Accepted toolbar foundation for all production inline collection and audit toolbars: - Use the shared `ToolbarSearch` and 44px control height, with one token-driven border, radius and horizontal padding across Tasks, Decisions, Skills and @@ -977,6 +977,31 @@ Further consolidation proposals, pending visual review: Keep panel-switching tab semantics separate from exclusive collection filters. Closable document tabs retain their additional behavior. +### Collection empty states and roster selection + +Projects uses the large artifact stack with a dark grey Paperclip mark on an off-white +image front. The empty-state action is “Create project”. Artwork, message and +primary action have 20px gaps (`gap-5`). Hovering or keyboard-focusing the action +fans the same artwork as hovering the graphic. Artifacts uses that stack for +Tasks and the corresponding single full-size artifact for each other type filter. + +Agent lists give chat and status their own vertically centered cells. Chat has a +screen-reader-only column label and a MessageCircleCheck icon action. Hover and +focus highlight the entire row; the identity link has no separate background. +Bulk actions portal to a fixed card centered in the viewport, 48px above the +bottom safe area, with the shared border and a soft shadow. The card retains its +contents during the quintic rise/drop transition; reduced motion removes travel. +Selection never inserts space above the roster. + +Agent Overview's Profile uses the bulk-edit avatar/name chips for reporting +relationships. Within raised modules, their background uses the Skills chip +surface so they remain distinct from the module. Agent chips have hover/focus +states and link to the agent's overview. Reports to precedes Direct reports: +No manager opens the manager picker; an assigned manager has a linked chip and +an adjacent edit control. The picker disables self/descendant choices to prevent +reporting loops. The editable heading sizes to its live text so the primary-agent +crown stays beside the name throughout editing. + ### Segmented controller comparison `Design proposals / Components / Segmented controllers` owns the segmented diff --git a/maps/feature-map/connection-setup.md b/maps/feature-map/connection-setup.md index f5b980383fa..532cf1a1b59 100644 --- a/maps/feature-map/connection-setup.md +++ b/maps/feature-map/connection-setup.md @@ -35,7 +35,7 @@ policies use the ordinary Permissions screen. See [connector notes](../../doc/co an empty explicit selection and company-wide access must not be confused. - `credentials`: `connection-setup/credentials` — provider sign-in, key, or generic MCP URL follows its supported setup method; credentials are not retained in browser draft storage. -- `handoff`: `connection-setup/handoff` — provider popup/full-page callback returns to the correct setup +- `handoff`: `connection-setup/handoff` — provider sign-in opens in a separate tab/window; its callback returns to the correct setup host with the original task intent and identity choice intact. - `draft-recovery`: `connection-setup/draft-recovery` — cancel, Back, Save & exit, reload, Finish setup, and reconnect preserve the exact draft rather than creating duplicate connections. @@ -63,7 +63,7 @@ catalog setup and a generic endpoint may use different flows. ### `task-request` Open the task's pending connection request and its setup action as the named -user. Setup can open in a task dialog and may use an OAuth popup. Follow the +user. Setup can open in a task dialog; provider sign-in always uses a separate window/tab. Follow the request's original link if setup falls back to a standalone page. ### `resume-connection` diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts index 5b4679b6ec4..6ebe799976c 100644 --- a/tests/e2e/chat-adapters-ui-providers.spec.ts +++ b/tests/e2e/chat-adapters-ui-providers.spec.ts @@ -206,7 +206,7 @@ test.describe.serial("native chat adapter UI", () => { await expectCompletedSlackSetupCloses(page, seed); expect(mock.slackCreations).toBe(1); expect(mock.slackInstallations).toBe(1); }); - test("Slack: a failed manager refresh reloads workspace authorization before retry", async ({ page }) => { + test("Slack: a failed manager refresh opens workspace authorization in a separate tab", async ({ page }) => { const slack = PROVIDERS.find(provider => provider.provider === "slack")!; await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); let refreshFailed = false; @@ -223,12 +223,16 @@ test.describe.serial("native chat adapter UI", () => { authorizationRequests++; await route.fulfill({ json: { authorizationUrl: "https://slack.test/manager-consent", expiresAt: new Date(Date.now() + 600_000).toISOString() } }); }); - await page.route("https://slack.test/manager-consent", route => route.fulfill({ contentType: "text/html", body: "Slack authorization fixture" })); + await page.context().route("https://slack.test/manager-consent", route => route.fulfill({ contentType: "text/html", body: "Slack authorization fixture" })); await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); await expect(page.getByRole("alert")).toBeVisible(); + const authorizationOpened = page.context().waitForEvent("page"); await page.getByRole("button", { name: "Reconnect Slack workspace", exact: true }).click(); + const authorizationPage = await authorizationOpened; await expect.poll(() => authorizationRequests).toBe(1); - await expect(page).toHaveURL("https://slack.test/manager-consent"); + await expect(authorizationPage).toHaveURL("https://slack.test/manager-consent"); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/chat/connect\\?provider=slack`)); + await authorizationPage.close(); }); test("Slack: every uncertain creation retry needs a fresh confirmation", async ({ page }) => { const slack = PROVIDERS.find(provider => provider.provider === "slack")!; @@ -321,7 +325,7 @@ test.describe.serial("native chat adapter UI", () => { const rootUrl = new URL(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`, test.info().project.use.baseURL).href; const callbackUrl = new URL("/api/chat-slack/oauth/callback?state=fixture-state&code=fixture-code", rootUrl).href; let callbackRequests = 0; - await page.route("**/api/chat-slack/oauth/callback?**", async route => { + await page.context().route("**/api/chat-slack/oauth/callback?**", async route => { callbackRequests += 1; if (callbackRequests === 1) { const body = oauthCallbackInterstitialHtml(); @@ -371,13 +375,19 @@ test.describe.serial("native chat adapter UI", () => { await page.goto(rootUrl); expect(mock.slackInstallations).toBe(1); const installation = await holdSetupRequest("install"); + const retryWindowOpened = page.context().waitForEvent("page"); try { await page.getByRole("button", { name: "Install in Slack", exact: true }).click(); await installation.requested; await expectNavigationLocked(); } finally { installation.release(); } - await expect(page.getByRole("heading", { name: "Fixture Slack consent" })).toBeVisible(); - await page.getByRole("link", { name: "Approve installation" }).click(); + const retryConsentWindow = await retryWindowOpened; + await expect(retryConsentWindow.getByRole("heading", { name: "Fixture Slack consent" })).toBeVisible(); + await expect(page).toHaveURL(rootUrl); + await retryConsentWindow.getByRole("link", { name: "Approve installation" }).click(); + await expect.poll(() => callbackRequests).toBe(2); + await retryConsentWindow.close(); + await page.bringToFront(); await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible(); await expect(page.getByRole("link", { name: "Open Slack app Settings" })).toBeHidden(); await expect(page.getByRole("link", { name: "Open Slack", exact: true })).toHaveAttribute("href", "https://app.slack.com/client/TE2E/DE2E"); diff --git a/tests/e2e/project-repositories.spec.ts b/tests/e2e/project-repositories.spec.ts index e13640dc834..9631db41fdc 100644 --- a/tests/e2e/project-repositories.spec.ts +++ b/tests/e2e/project-repositories.spec.ts @@ -6,7 +6,7 @@ test("project creation and repository configuration persist on a short mobile vi expect(createdCompany.ok()).toBe(true); const company = await createdCompany.json(); await page.goto(`/${company.issuePrefix}/projects`); - await page.getByRole("button", { name: "Add Project", exact: true }).first().click(); + await page.getByRole("button", { name: "Create project", exact: true }).first().click(); const dialog = page.getByRole("dialog"); await expect(dialog.getByRole("textbox", { name: "Project name" })).toBeFocused(); await dialog.getByRole("textbox", { name: "Project name" }).fill("Repository acceptance"); @@ -68,7 +68,7 @@ test("a short new-project dialog keeps actions visible and rejects unavailable s await page.route(`**/api/companies/${company.id}/project-repositories`, (route) => route.fulfill({ json: { repositories: repos, connectionCount: 1, failedConnectionCount: 0 } })); await page.setViewportSize({ width: 390, height: 420 }); await page.goto(`/${company.issuePrefix}/projects`); - await page.getByRole("button", { name: "Add Project", exact: true }).first().click(); + await page.getByRole("button", { name: "Create project", exact: true }).first().click(); const dialog = page.getByRole("dialog"); await dialog.getByRole("textbox", { name: "Project name" }).fill("Rejected draft"); await dialog.getByRole("button", { name: "Add GitHub repo", exact: true }).click(); diff --git a/tests/storybook-visual/project-repository-skills.spec.ts b/tests/storybook-visual/project-repository-skills.spec.ts index 8377efc9120..38c6e49e9a9 100644 --- a/tests/storybook-visual/project-repository-skills.spec.ts +++ b/tests/storybook-visual/project-repository-skills.spec.ts @@ -49,7 +49,7 @@ test('repairs a missing branch without prompting for another connection', async test('creating a project imports repository skills without another setup flow', async ({ page }) => { await page.goto(story('create-project')); - await page.getByRole('button', { name: 'Add Project', exact: true }).first().click(); + await page.getByRole('button', { name: 'Create project', exact: true }).first().click(); const dialog = page.getByRole('dialog', { name: 'Create project', exact: true }); await dialog.getByRole('textbox', { name: 'Project name', exact: true }).fill('Repository project'); await dialog.getByRole('button', { name: 'Add GitHub repo', exact: true }).click(); @@ -57,9 +57,8 @@ test('creating a project imports repository skills without another setup flow', await dialog.getByRole('button', { name: 'Create project', exact: true }).click(); await expect(dialog).toHaveCount(0); await page.getByRole('link', { name: 'Skills', exact: true }).click(); - await page.getByLabel('Skills', { exact: true }).getByRole('link', { name: 'Sources', exact: true }).click(); - const source = page.getByRole('region', { name: 'acme/team-skills', exact: true }); - await expect(source.getByText('Code review', { exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Installed skills', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: /^Code review by GitHub/ })).toBeVisible(); await expect(page.getByRole('button', { name: 'Import skills', exact: true })).toHaveCount(0); }); diff --git a/tests/storybook-visual/toolbars-design.spec.ts b/tests/storybook-visual/toolbars-design.spec.ts index a59bf4278f6..e71c49a0750 100644 --- a/tests/storybook-visual/toolbars-design.spec.ts +++ b/tests/storybook-visual/toolbars-design.spec.ts @@ -150,8 +150,8 @@ test("Projects toolbar searches, sorts and reports creation locally", async ({ p await page.getByRole("button", { name: "Updated", exact: true }).click(); await page.keyboard.press("Escape"); await expect(preview.getByRole("status")).toContainText("Search: design · Sort: updated · Direction: desc"); - await preview.getByRole("button", { name: "Add Project", exact: true }).click(); - await expect(preview.getByRole("status")).toContainText("Add Project requested · local preview only"); + await preview.getByRole("button", { name: "Create project", exact: true }).click(); + await expect(preview.getByRole("status")).toContainText("Create project requested · local preview only"); }); diff --git a/ui/src/components/CollectionToolbar.tsx b/ui/src/components/CollectionToolbar.tsx index e1cf96ce7f0..5d1bcdddfef 100644 --- a/ui/src/components/CollectionToolbar.tsx +++ b/ui/src/components/CollectionToolbar.tsx @@ -36,6 +36,7 @@ export function CollectionToolbar({ return (
-
+
+ {graphic ? :
-
+
} {title ? ( <>

{title}

diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index 69ae83adf70..c57da0cdfcb 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -17,6 +17,8 @@ interface InlineEditorProps { className?: string; placeholder?: string; multiline?: boolean; + /** Keep a short heading's editor as wide as its live text, preserving adjacent controls. */ + fitContent?: boolean; imageUploadHandler?: (file: File) => Promise; /** Called when a non-image file is dropped onto the editor. */ onDropFile?: (file: File) => Promise; @@ -82,6 +84,7 @@ function OwnedInlineEditor({ className, placeholder = "Click to edit...", multiline = false, + fitContent = false, nullable = false, imageUploadHandler, onDropFile, @@ -504,14 +507,14 @@ function OwnedInlineEditor({ } if (editing) { - - return ( + const editor = (